use chrono::{DateTime, FixedOffset, NaiveDate, TimeZone, Utc};
use loco_rs::prelude::*;
use sea_orm::{
    ActiveModelTrait, ColumnTrait, Condition, EntityTrait, IntoActiveModel, QueryFilter,
    QueryOrder, Set,
};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use std::path::Path;

use crate::models::_entities::{categories, comments, posts, tags};

pub const POST_STATUS_DRAFT: &str = "draft";
pub const POST_STATUS_PUBLISHED: &str = "published";
pub const POST_STATUS_OFFLINE: &str = "offline";
pub const POST_VISIBILITY_PUBLIC: &str = "public";
pub const POST_VISIBILITY_UNLISTED: &str = "unlisted";
pub const POST_VISIBILITY_PRIVATE: &str = "private";
const VIRTUAL_MARKDOWN_PATH_PREFIX: &str = "article://posts";

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
struct MarkdownFrontmatter {
    title: Option<String>,
    slug: Option<String>,
    description: Option<String>,
    #[serde(
        default,
        alias = "category",
        alias = "categories",
        deserialize_with = "deserialize_optional_string_list"
    )]
    categories: Option<Vec<String>>,
    #[serde(
        default,
        alias = "tag",
        deserialize_with = "deserialize_optional_string_list"
    )]
    tags: Option<Vec<String>>,
    post_type: Option<String>,
    image: Option<String>,
    images: Option<Vec<String>>,
    pinned: Option<bool>,
    #[serde(alias = "Hidden")]
    hidden: Option<bool>,
    published: Option<bool>,
    draft: Option<bool>,
    status: Option<String>,
    visibility: Option<String>,
    #[serde(alias = "date")]
    publish_at: Option<String>,
    unpublish_at: Option<String>,
    canonical_url: Option<String>,
    noindex: Option<bool>,
    og_image: Option<String>,
    #[serde(default, deserialize_with = "deserialize_optional_string_list")]
    redirect_from: Option<Vec<String>>,
    redirect_to: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct MarkdownPost {
    pub title: String,
    pub slug: String,
    pub description: Option<String>,
    pub content: String,
    pub category: Option<String>,
    pub tags: Vec<String>,
    pub post_type: String,
    pub image: Option<String>,
    pub images: Vec<String>,
    pub pinned: bool,
    pub status: String,
    pub visibility: String,
    pub publish_at: Option<String>,
    pub unpublish_at: Option<String>,
    pub canonical_url: Option<String>,
    pub noindex: bool,
    pub og_image: Option<String>,
    pub redirect_from: Vec<String>,
    pub redirect_to: Option<String>,
    pub file_path: String,
}

#[derive(Debug, Clone)]
pub struct MarkdownPostDraft {
    pub title: String,
    pub slug: Option<String>,
    pub description: Option<String>,
    pub content: String,
    pub category: Option<String>,
    pub tags: Vec<String>,
    pub post_type: String,
    pub image: Option<String>,
    pub images: Vec<String>,
    pub pinned: bool,
    pub status: String,
    pub visibility: String,
    pub publish_at: Option<String>,
    pub unpublish_at: Option<String>,
    pub canonical_url: Option<String>,
    pub noindex: bool,
    pub og_image: Option<String>,
    pub redirect_from: Vec<String>,
    pub redirect_to: Option<String>,
}

#[derive(Debug, Clone)]
pub struct MarkdownImportFile {
    pub file_name: String,
    pub content: String,
}

#[derive(Debug, Clone)]
struct MarkdownDocumentSource {
    post: MarkdownPost,
    raw_markdown: String,
}

fn yaml_error(err: serde_yaml::Error) -> Error {
    Error::string(&err.to_string())
}

pub fn virtual_markdown_document_path(slug: &str) -> String {
    format!("{VIRTUAL_MARKDOWN_PATH_PREFIX}/{slug}")
}

fn normalize_newlines(input: &str) -> String {
    input.replace("\r\n", "\n")
}

fn trim_to_option(input: Option<String>) -> Option<String> {
    input.and_then(|value| {
        let trimmed = value.trim().to_string();
        if trimmed.is_empty() {
            None
        } else {
            Some(trimmed)
        }
    })
}

fn normalize_string_list(values: Option<Vec<String>>) -> Vec<String> {
    values
        .unwrap_or_default()
        .into_iter()
        .map(|item| item.trim().to_string())
        .filter(|item| !item.is_empty())
        .collect()
}

fn normalize_post_tags(values: Vec<String>) -> Vec<String> {
    let mut seen = std::collections::HashSet::new();

    normalize_string_list(Some(values))
        .into_iter()
        .filter(|item| seen.insert(normalized_match_key(item)))
        .collect()
}

fn yaml_scalar(value: &str) -> String {
    serde_yaml::to_string(value)
        .unwrap_or_else(|_| format!("{value:?}"))
        .trim()
        .to_string()
}

fn normalize_redirect_list(values: Option<Vec<String>>) -> Vec<String> {
    let mut seen = std::collections::HashSet::new();

    normalize_string_list(values)
        .into_iter()
        .map(|item| item.trim_matches('/').to_string())
        .filter(|item| !item.is_empty())
        .filter(|item| seen.insert(item.to_lowercase()))
        .collect()
}

fn normalize_url_like(value: Option<String>) -> Option<String> {
    trim_to_option(value).map(|item| item.trim_end_matches('/').to_string())
}

pub fn normalize_post_status(value: Option<&str>) -> String {
    match value
        .map(str::trim)
        .unwrap_or_default()
        .to_ascii_lowercase()
        .as_str()
    {
        POST_STATUS_DRAFT => POST_STATUS_DRAFT.to_string(),
        POST_STATUS_OFFLINE => POST_STATUS_OFFLINE.to_string(),
        _ => POST_STATUS_PUBLISHED.to_string(),
    }
}

pub fn normalize_post_visibility(value: Option<&str>) -> String {
    match value
        .map(str::trim)
        .unwrap_or_default()
        .to_ascii_lowercase()
        .as_str()
    {
        POST_VISIBILITY_UNLISTED => POST_VISIBILITY_UNLISTED.to_string(),
        POST_VISIBILITY_PRIVATE => POST_VISIBILITY_PRIVATE.to_string(),
        _ => POST_VISIBILITY_PUBLIC.to_string(),
    }
}

fn parse_frontmatter_datetime(value: Option<String>) -> Option<DateTime<FixedOffset>> {
    let raw = trim_to_option(value)?;

    if let Ok(parsed) = DateTime::parse_from_rfc3339(&raw) {
        return Some(parsed);
    }

    if let Ok(date_only) = NaiveDate::parse_from_str(&raw, "%Y-%m-%d") {
        let naive = date_only.and_hms_opt(0, 0, 0)?;
        return FixedOffset::east_opt(0)?
            .from_local_datetime(&naive)
            .single();
    }

    None
}

pub fn format_frontmatter_datetime(value: Option<DateTime<FixedOffset>>) -> Option<String> {
    value.map(|item| item.with_timezone(&Utc).to_rfc3339())
}

fn resolve_post_status(frontmatter: &MarkdownFrontmatter) -> String {
    if let Some(status) = trim_to_option(frontmatter.status.clone()) {
        return normalize_post_status(Some(&status));
    }

    if frontmatter.draft.unwrap_or(false) {
        POST_STATUS_DRAFT.to_string()
    } else if frontmatter.published.unwrap_or(true) {
        POST_STATUS_PUBLISHED.to_string()
    } else {
        POST_STATUS_DRAFT.to_string()
    }
}

fn resolve_post_visibility(frontmatter: &MarkdownFrontmatter) -> String {
    if let Some(visibility) = trim_to_option(frontmatter.visibility.clone()) {
        return normalize_post_visibility(Some(&visibility));
    }

    if frontmatter.hidden.unwrap_or(false) {
        POST_VISIBILITY_UNLISTED.to_string()
    } else {
        POST_VISIBILITY_PUBLIC.to_string()
    }
}

pub fn effective_post_state(
    status: &str,
    publish_at: Option<DateTime<FixedOffset>>,
    unpublish_at: Option<DateTime<FixedOffset>>,
    now: DateTime<FixedOffset>,
) -> String {
    let normalized_status = normalize_post_status(Some(status));

    if normalized_status == POST_STATUS_DRAFT {
        return POST_STATUS_DRAFT.to_string();
    }

    if normalized_status == POST_STATUS_OFFLINE {
        return POST_STATUS_OFFLINE.to_string();
    }

    if publish_at.map(|value| value > now).unwrap_or(false) {
        return "scheduled".to_string();
    }

    if unpublish_at.map(|value| value <= now).unwrap_or(false) {
        return "expired".to_string();
    }

    POST_STATUS_PUBLISHED.to_string()
}

pub fn post_redirects_from_json(value: &Option<Value>) -> Vec<String> {
    value
        .as_ref()
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default()
        .into_iter()
        .filter_map(|item| item.as_str().map(ToString::to_string))
        .map(|item| item.trim_matches('/').to_string())
        .filter(|item| !item.is_empty())
        .collect()
}

fn json_string_array(value: &Option<Value>) -> Vec<String> {
    value
        .as_ref()
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default()
        .into_iter()
        .filter_map(|item| item.as_str().map(ToString::to_string))
        .map(|item| item.trim().to_string())
        .filter(|item| !item.is_empty())
        .collect()
}

fn markdown_post_from_model(post: &posts::Model) -> MarkdownPost {
    MarkdownPost {
        title: trim_to_option(post.title.clone()).unwrap_or_else(|| post.slug.clone()),
        slug: post.slug.clone(),
        description: trim_to_option(post.description.clone())
            .or_else(|| post.content.as_deref().and_then(excerpt_from_content)),
        content: post.content.clone().unwrap_or_default(),
        category: trim_to_option(post.category.clone()),
        tags: json_string_array(&post.tags),
        post_type: trim_to_option(post.post_type.clone()).unwrap_or_else(|| "article".to_string()),
        image: trim_to_option(post.image.clone()),
        images: json_string_array(&post.images),
        pinned: post.pinned.unwrap_or(false),
        status: normalize_post_status(post.status.as_deref()),
        visibility: normalize_post_visibility(post.visibility.as_deref()),
        publish_at: format_frontmatter_datetime(post.publish_at.clone()),
        unpublish_at: format_frontmatter_datetime(post.unpublish_at.clone()),
        canonical_url: normalize_url_like(post.canonical_url.clone()),
        noindex: post.noindex.unwrap_or(false),
        og_image: normalize_url_like(post.og_image.clone()),
        redirect_from: post_redirects_from_json(&post.redirect_from),
        redirect_to: trim_to_option(post.redirect_to.clone())
            .map(|item| item.trim_matches('/').to_string()),
        file_path: virtual_markdown_document_path(&post.slug),
    }
}

pub fn is_post_listed_publicly(post: &posts::Model, now: DateTime<FixedOffset>) -> bool {
    effective_post_state(
        post.status.as_deref().unwrap_or(POST_STATUS_PUBLISHED),
        post.publish_at,
        post.unpublish_at,
        now,
    ) == POST_STATUS_PUBLISHED
        && normalize_post_visibility(post.visibility.as_deref()) == POST_VISIBILITY_PUBLIC
}

pub fn is_post_publicly_accessible(post: &posts::Model, now: DateTime<FixedOffset>) -> bool {
    effective_post_state(
        post.status.as_deref().unwrap_or(POST_STATUS_PUBLISHED),
        post.publish_at,
        post.unpublish_at,
        now,
    ) == POST_STATUS_PUBLISHED
        && normalize_post_visibility(post.visibility.as_deref()) != POST_VISIBILITY_PRIVATE
}

fn split_inline_list(value: &str) -> Vec<String> {
    value
        .split([',', '，'])
        .map(|item| item.trim().to_string())
        .filter(|item| !item.is_empty())
        .collect()
}

fn deserialize_optional_string_list<'de, D>(
    deserializer: D,
) -> std::result::Result<Option<Vec<String>>, D::Error>
where
    D: Deserializer<'de>,
{
    let raw = Option::<serde_yaml::Value>::deserialize(deserializer)?;

    match raw {
        None | Some(serde_yaml::Value::Null) => Ok(None),
        Some(serde_yaml::Value::String(value)) => {
            let items = split_inline_list(&value);
            if items.is_empty() && !value.trim().is_empty() {
                Ok(Some(vec![value.trim().to_string()]))
            } else if items.is_empty() {
                Ok(None)
            } else {
                Ok(Some(items))
            }
        }
        Some(serde_yaml::Value::Sequence(items)) => Ok(Some(
            items
                .into_iter()
                .filter_map(|item| match item {
                    serde_yaml::Value::String(value) => {
                        let trimmed = value.trim().to_string();
                        (!trimmed.is_empty()).then_some(trimmed)
                    }
                    serde_yaml::Value::Number(value) => Some(value.to_string()),
                    _ => None,
                })
                .collect(),
        )),
        Some(other) => Err(serde::de::Error::custom(format!(
            "unsupported frontmatter list value: {other:?}"
        ))),
    }
}

fn slugify(value: &str) -> String {
    let mut slug = String::new();
    let mut last_was_dash = false;

    for ch in value.trim().chars() {
        if ch.is_alphanumeric() {
            for lower in ch.to_lowercase() {
                slug.push(lower);
            }
            last_was_dash = false;
        } else if (ch.is_whitespace() || ch == '-' || ch == '_') && !last_was_dash {
            slug.push('-');
            last_was_dash = true;
        }
    }

    slug.trim_matches('-').to_string()
}

fn normalized_match_key(value: &str) -> String {
    value.trim().to_lowercase()
}

fn same_text(left: &str, right: &str) -> bool {
    normalized_match_key(left) == normalized_match_key(right)
}

fn text_matches_any(value: &str, keys: &[String]) -> bool {
    let current = normalized_match_key(value);
    !current.is_empty() && keys.iter().any(|key| current == *key)
}

fn excerpt_from_content(content: &str) -> Option<String> {
    let mut in_code_block = false;

    for line in content.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with("```") {
            in_code_block = !in_code_block;
            continue;
        }

        if in_code_block || trimmed.is_empty() || trimmed.starts_with('#') {
            continue;
        }

        let excerpt = trimmed.chars().take(180).collect::<String>();
        return if excerpt.is_empty() {
            None
        } else {
            Some(excerpt)
        };
    }

    None
}

fn title_from_content(content: &str) -> Option<String> {
    content.lines().find_map(|line| {
        line.trim()
            .strip_prefix("# ")
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToString::to_string)
    })
}

fn split_frontmatter(raw: &str) -> Result<(MarkdownFrontmatter, String)> {
    let normalized = normalize_newlines(raw);

    if !normalized.starts_with("---\n") {
        return Ok((MarkdownFrontmatter::default(), normalized));
    }

    let rest = &normalized[4..];
    let Some(end_index) = rest.find("\n---\n") else {
        return Err(Error::string("Markdown frontmatter is not closed"));
    };

    let frontmatter = &rest[..end_index];
    let content = rest[end_index + 5..].to_string();
    let parsed = serde_yaml::from_str::<MarkdownFrontmatter>(frontmatter).map_err(yaml_error)?;

    Ok((parsed, content))
}

pub fn parse_markdown_source(file_stem: &str, raw: &str, file_path: &str) -> Result<MarkdownPost> {
    let (frontmatter, content) = split_frontmatter(raw)?;

    let slug = trim_to_option(frontmatter.slug.clone()).unwrap_or_else(|| file_stem.to_string());
    let title = trim_to_option(frontmatter.title.clone())
        .or_else(|| title_from_content(&content))
        .unwrap_or_else(|| slug.clone());
    let description =
        trim_to_option(frontmatter.description.clone()).or_else(|| excerpt_from_content(&content));
    let category = normalize_string_list(frontmatter.categories.clone())
        .into_iter()
        .next();
    let tags = frontmatter
        .tags
        .clone()
        .unwrap_or_default()
        .into_iter()
        .map(|item| item.trim().to_string())
        .filter(|item| !item.is_empty())
        .collect::<Vec<_>>();

    Ok(MarkdownPost {
        title,
        slug,
        description,
        content: content.trim_start_matches('\n').to_string(),
        category,
        tags,
        post_type: trim_to_option(frontmatter.post_type.clone())
            .unwrap_or_else(|| "article".to_string()),
        image: trim_to_option(frontmatter.image.clone()),
        images: normalize_string_list(frontmatter.images.clone()),
        pinned: frontmatter.pinned.unwrap_or(false),
        status: resolve_post_status(&frontmatter),
        visibility: resolve_post_visibility(&frontmatter),
        publish_at: format_frontmatter_datetime(parse_frontmatter_datetime(
            frontmatter.publish_at.clone(),
        )),
        unpublish_at: format_frontmatter_datetime(parse_frontmatter_datetime(
            frontmatter.unpublish_at.clone(),
        )),
        canonical_url: normalize_url_like(frontmatter.canonical_url.clone()),
        noindex: frontmatter.noindex.unwrap_or(false),
        og_image: normalize_url_like(frontmatter.og_image.clone()),
        redirect_from: normalize_redirect_list(frontmatter.redirect_from.clone()),
        redirect_to: trim_to_option(frontmatter.redirect_to.clone())
            .map(|item| item.trim_matches('/').to_string()),
        file_path: file_path.to_string(),
    })
}

pub fn build_markdown_document(post: &MarkdownPost) -> String {
    let mut lines = vec![
        "---".to_string(),
        format!("title: {}", yaml_scalar(&post.title)),
        format!("slug: {}", yaml_scalar(&post.slug)),
    ];

    if let Some(description) = &post.description {
        lines.push(format!("description: {}", yaml_scalar(description)));
    }

    if let Some(category) = &post.category {
        lines.push(format!("category: {}", yaml_scalar(category)));
    }

    lines.push(format!("post_type: {}", yaml_scalar(&post.post_type)));
    lines.push(format!("pinned: {}", post.pinned));
    lines.push(format!("status: {}", yaml_scalar(&post.status)));
    lines.push(format!("visibility: {}", yaml_scalar(&post.visibility)));
    lines.push(format!("noindex: {}", post.noindex));

    if let Some(publish_at) = &post.publish_at {
        lines.push(format!("publish_at: {}", yaml_scalar(publish_at)));
    }

    if let Some(unpublish_at) = &post.unpublish_at {
        lines.push(format!("unpublish_at: {}", yaml_scalar(unpublish_at)));
    }

    if let Some(image) = &post.image {
        lines.push(format!("image: {}", yaml_scalar(image)));
    }

    if !post.images.is_empty() {
        lines.push("images:".to_string());
        for image in &post.images {
            lines.push(format!("  - {}", yaml_scalar(image)));
        }
    }

    if !post.tags.is_empty() {
        lines.push("tags:".to_string());
        for tag in &post.tags {
            lines.push(format!("  - {}", yaml_scalar(tag)));
        }
    }

    if let Some(canonical_url) = &post.canonical_url {
        lines.push(format!("canonical_url: {}", yaml_scalar(canonical_url)));
    }

    if let Some(og_image) = &post.og_image {
        lines.push(format!("og_image: {}", yaml_scalar(og_image)));
    }

    if !post.redirect_from.is_empty() {
        lines.push("redirect_from:".to_string());
        for redirect in &post.redirect_from {
            lines.push(format!("  - {}", yaml_scalar(redirect)));
        }
    }

    if let Some(redirect_to) = &post.redirect_to {
        lines.push(format!("redirect_to: {}", yaml_scalar(redirect_to)));
    }

    lines.push("---".to_string());
    lines.push(String::new());
    lines.push(post.content.trim().to_string());
    lines.push(String::new());

    lines.join("\n")
}

fn markdown_document_from_model(model: &posts::Model) -> Result<MarkdownDocumentSource> {
    let raw_markdown = model
        .source_markdown
        .clone()
        .map(|value| normalize_newlines(&value))
        .filter(|value| !value.trim().is_empty())
        .unwrap_or_else(|| build_markdown_document(&markdown_post_from_model(model)));
    let virtual_path = virtual_markdown_document_path(&model.slug);
    let post = parse_markdown_source(&model.slug, &raw_markdown, &virtual_path)?;

    Ok(MarkdownDocumentSource { post, raw_markdown })
}

async fn load_markdown_documents_from_store(
    ctx: &AppContext,
) -> Result<Vec<MarkdownDocumentSource>> {
    let mut documents = posts::Entity::find()
        .order_by_asc(posts::Column::Slug)
        .all(&ctx.db)
        .await?
        .into_iter()
        .map(|item| markdown_document_from_model(&item))
        .collect::<Result<Vec<_>>>()?;

    documents.sort_by(|left, right| left.post.slug.cmp(&right.post.slug));
    Ok(documents)
}

pub async fn load_markdown_posts_from_store(ctx: &AppContext) -> Result<Vec<MarkdownPost>> {
    Ok(load_markdown_documents_from_store(ctx)
        .await?
        .into_iter()
        .map(|document| document.post)
        .collect())
}

async fn ensure_category(ctx: &AppContext, raw_name: &str) -> Result<Option<String>> {
    let name = raw_name.trim();
    if name.is_empty() {
        return Ok(None);
    }

    let slug = slugify(name);
    let existing = categories::Entity::find()
        .filter(
            Condition::any()
                .add(categories::Column::Slug.eq(&slug))
                .add(categories::Column::Name.eq(name)),
        )
        .one(&ctx.db)
        .await?;

    if let Some(category) = existing {
        if let Some(existing_name) = category
            .name
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
        {
            return Ok(Some(existing_name.to_string()));
        }

        let mut category_model = category.into_active_model();
        category_model.name = Set(Some(name.to_string()));
        let updated = category_model.update(&ctx.db).await?;
        return Ok(updated.name.or_else(|| Some(name.to_string())));
    }

    let created = categories::ActiveModel {
        name: Set(Some(name.to_string())),
        slug: Set(slug),
        ..Default::default()
    }
    .insert(&ctx.db)
    .await?;

    Ok(created.name.or_else(|| Some(name.to_string())))
}

async fn canonicalize_tags(ctx: &AppContext, raw_tags: &[String]) -> Result<Vec<String>> {
    let mut canonical_tags = Vec::new();
    let mut seen = std::collections::HashSet::new();

    for tag_name in raw_tags {
        let trimmed = tag_name.trim();
        if trimmed.is_empty() {
            continue;
        }

        let slug = slugify(trimmed);
        if slug.is_empty() || !seen.insert(slug.clone()) {
            continue;
        }

        let existing = tags::Entity::find()
            .filter(
                Condition::any()
                    .add(tags::Column::Slug.eq(&slug))
                    .add(tags::Column::Name.eq(trimmed)),
            )
            .one(&ctx.db)
            .await?;

        let canonical_name = if let Some(tag) = existing {
            if let Some(existing_name) = tag
                .name
                .as_deref()
                .map(str::trim)
                .filter(|value| !value.is_empty())
            {
                existing_name.to_string()
            } else {
                let mut tag_model = tag.into_active_model();
                tag_model.name = Set(Some(trimmed.to_string()));
                tag_model
                    .update(&ctx.db)
                    .await?
                    .name
                    .unwrap_or_else(|| trimmed.to_string())
            }
        } else {
            tags::ActiveModel {
                name: Set(Some(trimmed.to_string())),
                slug: Set(slug),
                ..Default::default()
            }
            .insert(&ctx.db)
            .await?
            .name
            .unwrap_or_else(|| trimmed.to_string())
        };

        canonical_tags.push(canonical_name);
    }

    Ok(canonical_tags)
}

fn string_array_json(values: &[String]) -> Option<Value> {
    (!values.is_empty()).then(|| Value::Array(values.iter().cloned().map(Value::String).collect()))
}

fn apply_markdown_post_to_active_model(
    model: &mut posts::ActiveModel,
    post: &MarkdownPost,
    raw_markdown: &str,
) {
    model.title = Set(Some(post.title.clone()));
    model.slug = Set(post.slug.clone());
    model.description = Set(post.description.clone());
    model.content = Set(Some(post.content.clone()));
    model.source_markdown = Set(Some(raw_markdown.to_string()));
    model.category = Set(post.category.clone());
    model.tags = Set(string_array_json(&post.tags));
    model.post_type = Set(Some(post.post_type.clone()));
    model.image = Set(post.image.clone());
    model.images = Set(string_array_json(&post.images));
    model.pinned = Set(Some(post.pinned));
    model.status = Set(Some(normalize_post_status(Some(&post.status))));
    model.visibility = Set(Some(normalize_post_visibility(Some(&post.visibility))));
    model.publish_at = Set(parse_frontmatter_datetime(post.publish_at.clone()));
    model.unpublish_at = Set(parse_frontmatter_datetime(post.unpublish_at.clone()));
    model.canonical_url = Set(normalize_url_like(post.canonical_url.clone()));
    model.noindex = Set(Some(post.noindex));
    model.og_image = Set(normalize_url_like(post.og_image.clone()));
    model.redirect_from = Set(string_array_json(&post.redirect_from));
    model.redirect_to = Set(
        trim_to_option(post.redirect_to.clone()).map(|item| item.trim_matches('/').to_string())
    );
}

async fn save_markdown_post_to_store(
    ctx: &AppContext,
    mut post: MarkdownPost,
    slug_hint: Option<&str>,
    canonicalize_taxonomy: bool,
) -> Result<MarkdownPost> {
    let normalized_slug_hint = slug_hint
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string);

    post.title = trim_to_option(Some(post.title.clone())).unwrap_or_else(|| post.slug.clone());
    post.slug = trim_to_option(Some(post.slug.clone()))
        .or_else(|| normalized_slug_hint.clone())
        .unwrap_or_else(|| slugify(&post.title));
    post.description =
        trim_to_option(post.description.clone()).or_else(|| excerpt_from_content(&post.content));
    post.content = normalize_newlines(post.content.trim());
    post.category = trim_to_option(post.category.clone());
    post.tags = normalize_post_tags(post.tags.clone());
    post.post_type =
        trim_to_option(Some(post.post_type.clone())).unwrap_or_else(|| "article".to_string());
    post.image = trim_to_option(post.image.clone());
    post.images = normalize_string_list(Some(post.images.clone()));
    post.status = normalize_post_status(Some(&post.status));
    post.visibility = normalize_post_visibility(Some(&post.visibility));
    post.publish_at =
        format_frontmatter_datetime(parse_frontmatter_datetime(post.publish_at.clone()));
    post.unpublish_at =
        format_frontmatter_datetime(parse_frontmatter_datetime(post.unpublish_at.clone()));
    post.canonical_url = normalize_url_like(post.canonical_url.clone());
    post.og_image = normalize_url_like(post.og_image.clone());
    post.redirect_from = normalize_redirect_list(Some(post.redirect_from.clone()));
    post.redirect_to =
        trim_to_option(post.redirect_to.clone()).map(|item| item.trim_matches('/').to_string());

    if post.slug.trim().is_empty() {
        return Err(Error::BadRequest("slug is required".to_string()));
    }

    if canonicalize_taxonomy {
        post.category = match post.category.as_deref() {
            Some(category) => ensure_category(ctx, category).await?,
            None => None,
        };
        post.tags = canonicalize_tags(ctx, &post.tags).await?;
    }

    let existing_by_hint = if let Some(hint) = normalized_slug_hint.as_deref() {
        posts::Entity::find()
            .filter(posts::Column::Slug.eq(hint))
            .one(&ctx.db)
            .await?
    } else {
        None
    };
    let existing_by_slug =
        if existing_by_hint.as_ref().map(|item| item.slug.as_str()) == Some(post.slug.as_str()) {
            None
        } else {
            posts::Entity::find()
                .filter(posts::Column::Slug.eq(&post.slug))
                .one(&ctx.db)
                .await?
        };

    if let (Some(by_hint), Some(by_slug)) = (&existing_by_hint, &existing_by_slug) {
        if by_hint.id != by_slug.id {
            return Err(Error::BadRequest(format!(
                "markdown post already exists for slug: {}",
                post.slug
            )));
        }
    }

    let has_existing = existing_by_hint.is_some() || existing_by_slug.is_some();
    let mut model = existing_by_hint
        .or(existing_by_slug)
        .map(|item| item.into_active_model())
        .unwrap_or_default();
    post.file_path = virtual_markdown_document_path(&post.slug);
    let raw_markdown = build_markdown_document(&post);
    apply_markdown_post_to_active_model(&mut model, &post, &raw_markdown);

    if has_existing {
        model.update(&ctx.db).await?;
    } else {
        model.insert(&ctx.db).await?;
    }

    Ok(post)
}

pub async fn rewrite_category_references(
    ctx: &AppContext,
    current_name: Option<&str>,
    current_slug: &str,
    next_name: Option<&str>,
) -> Result<usize> {
    let mut match_keys = Vec::new();
    if let Some(name) = current_name {
        let normalized = normalized_match_key(name);
        if !normalized.is_empty() {
            match_keys.push(normalized);
        }
    }

    let normalized_slug = normalized_match_key(current_slug);
    if !normalized_slug.is_empty() {
        match_keys.push(normalized_slug);
    }

    if match_keys.is_empty() {
        return Ok(0);
    }

    let next_category = next_name
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string);
    let mut changed = 0_usize;
    let posts = load_markdown_posts_from_store(ctx).await?;

    for mut post in posts {
        let Some(category) = post.category.as_deref() else {
            continue;
        };

        if !text_matches_any(category, &match_keys) {
            continue;
        }

        let existing_slug = post.slug.clone();
        match &next_category {
            Some(updated_name) if same_text(category, updated_name) => {}
            Some(updated_name) => {
                post.category = Some(updated_name.clone());
                save_markdown_post_to_store(ctx, post, Some(&existing_slug), false).await?;
                changed += 1;
            }
            None => {
                post.category = None;
                save_markdown_post_to_store(ctx, post, Some(&existing_slug), false).await?;
                changed += 1;
            }
        }
    }

    Ok(changed)
}

pub async fn rewrite_tag_references(
    ctx: &AppContext,
    current_name: Option<&str>,
    current_slug: &str,
    next_name: Option<&str>,
) -> Result<usize> {
    let mut match_keys = Vec::new();
    if let Some(name) = current_name {
        let normalized = normalized_match_key(name);
        if !normalized.is_empty() {
            match_keys.push(normalized);
        }
    }

    let normalized_slug = normalized_match_key(current_slug);
    if !normalized_slug.is_empty() {
        match_keys.push(normalized_slug);
    }

    if match_keys.is_empty() {
        return Ok(0);
    }

    let next_tag = next_name
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string);
    let mut changed = 0_usize;
    let posts = load_markdown_posts_from_store(ctx).await?;

    for mut post in posts {
        let mut updated_tags = Vec::new();
        let mut seen = std::collections::HashSet::new();
        let mut post_changed = false;

        for tag in &post.tags {
            if text_matches_any(tag, &match_keys) {
                post_changed = true;
                if let Some(next_tag_name) = &next_tag {
                    let normalized = normalized_match_key(next_tag_name);
                    if seen.insert(normalized) {
                        updated_tags.push(next_tag_name.clone());
                    }
                }
                continue;
            }

            let normalized = normalized_match_key(tag);
            if seen.insert(normalized) {
                updated_tags.push(tag.clone());
            }
        }

        if post_changed {
            let existing_slug = post.slug.clone();
            post.tags = updated_tags;
            save_markdown_post_to_store(ctx, post, Some(&existing_slug), false).await?;
            changed += 1;
        }
    }

    Ok(changed)
}

pub async fn read_markdown_document_from_store(
    ctx: &AppContext,
    slug: &str,
) -> Result<(String, String)> {
    let post = posts::Entity::find()
        .filter(posts::Column::Slug.eq(slug))
        .one(&ctx.db)
        .await?
        .ok_or(Error::NotFound)?;
    let document = markdown_document_from_model(&post)?;
    Ok((
        virtual_markdown_document_path(&document.post.slug),
        document.raw_markdown,
    ))
}

pub async fn upsert_markdown_document(
    ctx: &AppContext,
    slug_hint: Option<&str>,
    markdown: &str,
) -> Result<MarkdownPost> {
    let normalized_markdown = normalize_newlines(markdown);
    let normalized_slug_hint = slug_hint
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string);
    let file_stem = normalized_slug_hint
        .as_deref()
        .filter(|value| !value.is_empty())
        .unwrap_or("post");
    let virtual_path = normalized_slug_hint
        .as_deref()
        .map(virtual_markdown_document_path)
        .unwrap_or_else(|| format!("{VIRTUAL_MARKDOWN_PATH_PREFIX}/draft"));
    let post = parse_markdown_source(file_stem, &normalized_markdown, &virtual_path)?;

    save_markdown_post_to_store(ctx, post, normalized_slug_hint.as_deref(), true).await
}

pub async fn write_markdown_document(
    ctx: &AppContext,
    slug: &str,
    markdown: &str,
) -> Result<MarkdownPost> {
    upsert_markdown_document(ctx, Some(slug), markdown).await
}

pub async fn delete_markdown_post(ctx: &AppContext, slug: &str) -> Result<()> {
    let post = posts::Entity::find()
        .filter(posts::Column::Slug.eq(slug))
        .one(&ctx.db)
        .await?
        .ok_or(Error::NotFound)?;
    let related_comments = comments::Entity::find()
        .filter(comments::Column::PostSlug.eq(slug))
        .all(&ctx.db)
        .await?;

    for comment in related_comments {
        let _ = comment.delete(&ctx.db).await;
    }

    post.delete(&ctx.db).await?;
    Ok(())
}

pub async fn create_markdown_post(
    ctx: &AppContext,
    draft: MarkdownPostDraft,
) -> Result<MarkdownPost> {
    let title = draft.title.trim().to_string();
    if title.is_empty() {
        return Err(Error::BadRequest("title is required".to_string()));
    }

    let slug = draft
        .slug
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .unwrap_or_else(|| slugify(&title));

    if slug.is_empty() {
        return Err(Error::BadRequest("slug is required".to_string()));
    }

    if posts::Entity::find()
        .filter(posts::Column::Slug.eq(&slug))
        .one(&ctx.db)
        .await?
        .is_some()
    {
        return Err(Error::BadRequest(format!(
            "markdown post already exists for slug: {slug}"
        )));
    }

    let post = MarkdownPost {
        title,
        slug: slug.clone(),
        description: trim_to_option(draft.description),
        content: draft.content.trim().to_string(),
        category: trim_to_option(draft.category),
        tags: draft
            .tags
            .into_iter()
            .map(|tag| tag.trim().to_string())
            .filter(|tag| !tag.is_empty())
            .collect(),
        post_type: {
            let normalized = draft.post_type.trim();
            if normalized.is_empty() {
                "article".to_string()
            } else {
                normalized.to_string()
            }
        },
        image: trim_to_option(draft.image),
        images: normalize_string_list(Some(draft.images)),
        pinned: draft.pinned,
        status: normalize_post_status(Some(&draft.status)),
        visibility: normalize_post_visibility(Some(&draft.visibility)),
        publish_at: format_frontmatter_datetime(parse_frontmatter_datetime(draft.publish_at)),
        unpublish_at: format_frontmatter_datetime(parse_frontmatter_datetime(draft.unpublish_at)),
        canonical_url: normalize_url_like(draft.canonical_url),
        noindex: draft.noindex,
        og_image: normalize_url_like(draft.og_image),
        redirect_from: normalize_redirect_list(Some(draft.redirect_from)),
        redirect_to: trim_to_option(draft.redirect_to)
            .map(|item| item.trim_matches('/').to_string()),
        file_path: virtual_markdown_document_path(&slug),
    };
    save_markdown_post_to_store(ctx, post, Some(&slug), true).await
}

pub async fn import_markdown_documents(
    ctx: &AppContext,
    files: Vec<MarkdownImportFile>,
) -> Result<Vec<MarkdownPost>> {
    let mut imported = Vec::new();

    for file in files {
        let path = Path::new(&file.file_name);
        let extension = path
            .extension()
            .and_then(|value| value.to_str())
            .unwrap_or_default()
            .to_ascii_lowercase();

        if extension != "md" && extension != "markdown" {
            continue;
        }

        let file_stem = path
            .file_stem()
            .and_then(|value| value.to_str())
            .unwrap_or("imported-post")
            .to_string();
        let parsed = parse_markdown_source(&file_stem, &file.content, &file.file_name)?;
        let slug = if parsed.slug.trim().is_empty() {
            slugify(&file_stem)
        } else {
            parsed.slug.clone()
        };

        if slug.is_empty() {
            continue;
        }

        imported.push(upsert_markdown_document(ctx, Some(&slug), &file.content).await?);
    }

    Ok(imported)
}

#[cfg(test)]
mod tests {
    use super::{POST_VISIBILITY_UNLISTED, parse_markdown_source};

    #[test]
    fn parse_markdown_source_supports_hugo_aliases() {
        let markdown = r#"---
title: "Linux Shell"
date: 2022-05-21T10:02:09+08:00
draft: false
Hidden: true
slug: linux-shell
categories:
  - Linux
tag:
  - Linux
  - Shell
---

# Linux Shell
"#;

        let post = parse_markdown_source("linux-shell", markdown, "content/posts/linux-shell.md")
            .expect("markdown should parse");

        assert_eq!(post.slug, "linux-shell");
        assert_eq!(post.category.as_deref(), Some("Linux"));
        assert_eq!(post.tags, vec!["Linux", "Shell"]);
        assert_eq!(post.visibility, POST_VISIBILITY_UNLISTED);
        assert_eq!(
            post.publish_at.as_deref(),
            Some("2022-05-21T02:02:09+00:00")
        );
    }
}
