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::fs;
use std::path::{Path, PathBuf};

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

pub const MARKDOWN_POSTS_DIR: &str = "content/posts";
const FIXTURE_POSTS_FILE: &str = "src/fixtures/posts.yaml";
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";

#[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, deserialize_with = "deserialize_optional_string_list")]
    tags: Option<Vec<String>>,
    post_type: Option<String>,
    image: Option<String>,
    images: Option<Vec<String>>,
    pinned: Option<bool>,
    published: Option<bool>,
    draft: Option<bool>,
    status: Option<String>,
    visibility: Option<String>,
    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, Deserialize)]
struct LegacyFixturePost {
    title: String,
    slug: String,
    content: String,
    excerpt: Option<String>,
    category: Option<String>,
    tags: Option<Vec<String>>,
    pinned: Option<bool>,
    published: Option<bool>,
}

fn io_error(err: std::io::Error) -> Error {
    Error::string(&err.to_string())
}

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

fn posts_dir() -> PathBuf {
    PathBuf::from(MARKDOWN_POSTS_DIR)
}

pub fn markdown_post_path(slug: &str) -> PathBuf {
    posts_dir().join(format!("{slug}.md"))
}

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 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()
    }
}

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()
}

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))
}

fn parse_markdown_post(path: &Path) -> Result<MarkdownPost> {
    let raw = fs::read_to_string(path).map_err(io_error)?;
    let file_stem = path
        .file_stem()
        .and_then(|value| value.to_str())
        .unwrap_or("post")
        .to_string();

    parse_markdown_source(&file_stem, &raw, &path.to_string_lossy())
}

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: normalize_post_visibility(frontmatter.visibility.as_deref()),
        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 ensure_markdown_posts_bootstrapped() -> Result<()> {
    let dir = posts_dir();
    fs::create_dir_all(&dir).map_err(io_error)?;

    let has_markdown = fs::read_dir(&dir)
        .map_err(io_error)?
        .filter_map(|entry| entry.ok())
        .any(|entry| entry.path().extension().and_then(|value| value.to_str()) == Some("md"));

    if has_markdown {
        return Ok(());
    }

    let raw = fs::read_to_string(FIXTURE_POSTS_FILE).map_err(io_error)?;
    let fixtures = serde_yaml::from_str::<Vec<LegacyFixturePost>>(&raw).map_err(yaml_error)?;

    for fixture in fixtures {
        let post = MarkdownPost {
            title: fixture.title,
            slug: fixture.slug.clone(),
            description: trim_to_option(fixture.excerpt),
            content: fixture.content,
            category: trim_to_option(fixture.category),
            tags: fixture.tags.unwrap_or_default(),
            post_type: "article".to_string(),
            image: None,
            images: Vec::new(),
            pinned: fixture.pinned.unwrap_or(false),
            status: if fixture.published.unwrap_or(true) {
                POST_STATUS_PUBLISHED.to_string()
            } else {
                POST_STATUS_DRAFT.to_string()
            },
            visibility: POST_VISIBILITY_PUBLIC.to_string(),
            publish_at: None,
            unpublish_at: None,
            canonical_url: None,
            noindex: false,
            og_image: None,
            redirect_from: Vec::new(),
            redirect_to: None,
            file_path: markdown_post_path(&fixture.slug)
                .to_string_lossy()
                .to_string(),
        };

        fs::write(
            markdown_post_path(&fixture.slug),
            build_markdown_document(&post),
        )
        .map_err(io_error)?;
    }

    Ok(())
}

fn load_markdown_posts_from_disk() -> Result<Vec<MarkdownPost>> {
    ensure_markdown_posts_bootstrapped()?;

    let mut posts = fs::read_dir(posts_dir())
        .map_err(io_error)?
        .filter_map(|entry| entry.ok())
        .map(|entry| entry.path())
        .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("md"))
        .map(|path| parse_markdown_post(&path))
        .collect::<Result<Vec<_>>>()?;

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

async fn sync_tags_from_posts(ctx: &AppContext, posts: &[MarkdownPost]) -> Result<()> {
    for post in posts {
        for tag_name in &post.tags {
            let slug = slugify(tag_name);
            let trimmed = tag_name.trim();
            let existing = tags::Entity::find()
                .filter(
                    Condition::any()
                        .add(tags::Column::Slug.eq(&slug))
                        .add(tags::Column::Name.eq(trimmed)),
                )
                .one(&ctx.db)
                .await?;

            if existing.is_none() {
                let item = tags::ActiveModel {
                    name: Set(Some(trimmed.to_string())),
                    slug: Set(slug),
                    ..Default::default()
                };
                let _ = item.insert(&ctx.db).await;
            }
        }
    }

    Ok(())
}

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 write_markdown_post_to_disk(post: &MarkdownPost) -> Result<()> {
    fs::write(
        markdown_post_path(&post.slug),
        build_markdown_document(post),
    )
    .map_err(io_error)
}

pub fn rewrite_category_references(
    current_name: Option<&str>,
    current_slug: &str,
    next_name: Option<&str>,
) -> Result<usize> {
    ensure_markdown_posts_bootstrapped()?;

    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 mut posts = load_markdown_posts_from_disk()?;

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

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

        match &next_category {
            Some(updated_name) if same_text(category, updated_name) => {}
            Some(updated_name) => {
                post.category = Some(updated_name.clone());
                write_markdown_post_to_disk(post)?;
                changed += 1;
            }
            None => {
                post.category = None;
                write_markdown_post_to_disk(post)?;
                changed += 1;
            }
        }
    }

    Ok(changed)
}

pub fn rewrite_tag_references(
    current_name: Option<&str>,
    current_slug: &str,
    next_name: Option<&str>,
) -> Result<usize> {
    ensure_markdown_posts_bootstrapped()?;

    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 mut posts = load_markdown_posts_from_disk()?;

    for post in &mut 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 {
            post.tags = updated_tags;
            write_markdown_post_to_disk(post)?;
            changed += 1;
        }
    }

    Ok(changed)
}

async fn dedupe_tags(ctx: &AppContext) -> Result<()> {
    let existing_tags = tags::Entity::find()
        .order_by_asc(tags::Column::Id)
        .all(&ctx.db)
        .await?;

    let mut seen = std::collections::HashSet::new();

    for tag in existing_tags {
        let key = if tag.slug.trim().is_empty() {
            tag.name.as_deref().map(slugify).unwrap_or_default()
        } else {
            slugify(&tag.slug)
        };

        if key.is_empty() || seen.insert(key) {
            continue;
        }

        let _ = tag.delete(&ctx.db).await;
    }

    Ok(())
}

async fn dedupe_categories(ctx: &AppContext) -> Result<()> {
    let existing_categories = categories::Entity::find()
        .order_by_asc(categories::Column::Id)
        .all(&ctx.db)
        .await?;

    let mut seen = std::collections::HashSet::new();

    for category in existing_categories {
        let key = if category.slug.trim().is_empty() {
            category.name.as_deref().map(slugify).unwrap_or_default()
        } else {
            slugify(&category.slug)
        };

        if key.is_empty() || seen.insert(key) {
            continue;
        }

        let _ = category.delete(&ctx.db).await;
    }

    Ok(())
}

pub async fn sync_markdown_posts(ctx: &AppContext) -> Result<Vec<MarkdownPost>> {
    let markdown_posts = load_markdown_posts_from_disk()?;
    let markdown_slugs = markdown_posts
        .iter()
        .map(|post| post.slug.clone())
        .collect::<std::collections::HashSet<_>>();
    let existing_posts = posts::Entity::find().all(&ctx.db).await?;

    for stale_post in existing_posts
        .into_iter()
        .filter(|post| !markdown_slugs.contains(&post.slug))
    {
        let stale_slug = stale_post.slug.clone();
        let related_comments = comments::Entity::find()
            .filter(comments::Column::PostSlug.eq(&stale_slug))
            .all(&ctx.db)
            .await?;

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

        let _ = stale_post.delete(&ctx.db).await;
    }

    for post in &markdown_posts {
        let canonical_category = match post.category.as_deref() {
            Some(category) => ensure_category(ctx, category).await?,
            None => None,
        };
        let canonical_tags = canonicalize_tags(ctx, &post.tags).await?;

        let existing = posts::Entity::find()
            .filter(posts::Column::Slug.eq(&post.slug))
            .one(&ctx.db)
            .await?;
        let has_existing = existing.is_some();

        let mut model = existing
            .map(|item| item.into_active_model())
            .unwrap_or_default();

        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.category = Set(canonical_category);
        model.tags = Set(if canonical_tags.is_empty() {
            None
        } else {
            Some(Value::Array(
                canonical_tags.into_iter().map(Value::String).collect(),
            ))
        });
        model.post_type = Set(Some(post.post_type.clone()));
        model.image = Set(post.image.clone());
        model.images = Set(if post.images.is_empty() {
            None
        } else {
            Some(Value::Array(
                post.images
                    .iter()
                    .cloned()
                    .map(Value::String)
                    .collect::<Vec<_>>(),
            ))
        });
        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(if post.redirect_from.is_empty() {
            None
        } else {
            Some(Value::Array(
                post.redirect_from
                    .iter()
                    .cloned()
                    .map(Value::String)
                    .collect::<Vec<_>>(),
            ))
        });
        model.redirect_to = Set(
            trim_to_option(post.redirect_to.clone()).map(|item| item.trim_matches('/').to_string()),
        );

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

    sync_tags_from_posts(ctx, &markdown_posts).await?;
    dedupe_tags(ctx).await?;
    dedupe_categories(ctx).await?;
    Ok(markdown_posts)
}

pub fn read_markdown_document(slug: &str) -> Result<(String, String)> {
    let path = markdown_post_path(slug);
    if !path.exists() {
        return Err(Error::NotFound);
    }

    let raw = fs::read_to_string(&path).map_err(io_error)?;
    Ok((path.to_string_lossy().to_string(), raw))
}

pub async fn write_markdown_document(
    ctx: &AppContext,
    slug: &str,
    markdown: &str,
) -> Result<MarkdownPost> {
    ensure_markdown_posts_bootstrapped()?;
    let path = markdown_post_path(slug);
    fs::write(&path, normalize_newlines(markdown)).map_err(io_error)?;

    let updated = parse_markdown_post(&path)?;
    sync_markdown_posts(ctx).await?;
    Ok(updated)
}

pub async fn delete_markdown_post(ctx: &AppContext, slug: &str) -> Result<()> {
    ensure_markdown_posts_bootstrapped()?;
    let path = markdown_post_path(slug);
    if !path.exists() {
        return Err(Error::NotFound);
    }

    fs::remove_file(&path).map_err(io_error)?;
    sync_markdown_posts(ctx).await?;
    Ok(())
}

pub async fn create_markdown_post(
    ctx: &AppContext,
    draft: MarkdownPostDraft,
) -> Result<MarkdownPost> {
    ensure_markdown_posts_bootstrapped()?;

    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()));
    }

    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: markdown_post_path(&slug).to_string_lossy().to_string(),
    };

    let path = markdown_post_path(&slug);
    if path.exists() {
        return Err(Error::BadRequest(format!(
            "markdown post already exists for slug: {slug}"
        )));
    }

    fs::write(&path, build_markdown_document(&post)).map_err(io_error)?;
    sync_markdown_posts(ctx).await?;
    parse_markdown_post(&path)
}

pub async fn import_markdown_documents(
    ctx: &AppContext,
    files: Vec<MarkdownImportFile>,
) -> Result<Vec<MarkdownPost>> {
    ensure_markdown_posts_bootstrapped()?;

    let mut imported_slugs = 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;
        }

        fs::write(markdown_post_path(&slug), normalize_newlines(&file.content))
            .map_err(io_error)?;
        imported_slugs.push(slug);
    }

    sync_markdown_posts(ctx).await?;

    imported_slugs
        .into_iter()
        .map(|slug| parse_markdown_post(&markdown_post_path(&slug)))
        .collect()
}
