use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::path::Path;

use crate::db::DbConnection as Connection;
use walkdir::WalkDir;

use crate::db::queries::embedding as qe;
use crate::db::queries::media as qm;
use crate::db::queries::media_translation as qmt;
use crate::db::queries::post as qp;
use crate::db::queries::post_translation as qt;
use crate::db::queries::project as qproject;
use crate::db::queries::script as qs;
use crate::db::queries::template as qtpl;
use crate::engine::{EngineError, EngineResult};
use crate::model::{Media, MediaTranslation, Post, PostTranslation, Script, Template};
use crate::util::frontmatter::{
    ScriptFrontmatter, TemplateFrontmatter, read_post_file, read_script_file, read_template_file,
    read_translation_file, write_script_file, write_template_file, write_translation_file,
};
use crate::util::sidecar::{
    MediaSidecar, MediaTranslationSidecar, read_sidecar, read_translation_sidecar,
};
use crate::util::{atomic_write_str, media_translation_sidecar_path};

/// A single field difference.
#[derive(Debug, Clone)]
pub struct DiffField {
    pub field_name: String,
    pub db_value: String,
    pub file_value: String,
}

/// Diff for a single entity.
#[derive(Debug, Clone)]
pub struct EntityDiff {
    pub entity_type: String,
    pub entity_id: String,
    pub file_path: String,
    pub fields: Vec<DiffField>,
}

/// An orphan file (exists on disk but not in DB, or vice versa).
#[derive(Debug, Clone)]
pub struct OrphanFile {
    pub file_path: String,
    pub reason: String,
}

/// Complete diff report.
#[derive(Debug, Clone, Default)]
pub struct DiffReport {
    pub diffs: Vec<EntityDiff>,
    pub orphans: Vec<OrphanFile>,
    pub errors: Vec<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RepairDirection {
    FileToDatabase,
    DatabaseToFile,
}

/// Compare DB state vs filesystem files and report all differences.
///
/// This function does NOT modify anything -- it only reports differences.
pub fn compute_metadata_diff(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
) -> EngineResult<DiffReport> {
    compute_metadata_diff_with_progress(conn, data_dir, project_id, |_, _| true)
}

pub fn compute_metadata_diff_with_progress(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
    mut on_progress: impl FnMut(usize, usize) -> bool,
) -> EngineResult<DiffReport> {
    const PHASES: usize = 8;
    let mut phase = 0;
    let mut next_phase = || -> EngineResult<()> {
        if !on_progress(phase, PHASES) {
            return Err(crate::engine::EngineError::Cancelled);
        }
        phase += 1;
        Ok(())
    };
    let mut report = DiffReport::default();

    next_phase()?;
    if let Ok(project) = qproject::get_project_by_id(conn, project_id) {
        match diff_project(conn, data_dir, &project) {
            Ok(Some(diff)) => report.diffs.push(diff),
            Ok(None) => {}
            Err(error) => report.errors.push(format!("project {project_id}: {error}")),
        }
        for result in [
            diff_categories(conn, data_dir, project_id),
            diff_category_meta(conn, data_dir, project_id),
            diff_publishing(conn, data_dir, project_id),
        ] {
            match result {
                Ok(Some(diff)) => report.diffs.push(diff),
                Ok(None) => {}
                Err(error) => report
                    .errors
                    .push(format!("project metadata {project_id}: {error}")),
            }
        }
    }

    // 1. Diff posts
    next_phase()?;
    let posts = qp::list_posts_by_project(conn, project_id)?;
    for post in &posts {
        if post.file_path.is_empty() {
            continue;
        }
        match diff_post(data_dir, post) {
            Ok(Some(d)) => report.diffs.push(d),
            Ok(None) => {}
            Err(e) => report.errors.push(format!("post {}: {e}", post.id)),
        }
    }

    // 2. Diff translations
    next_phase()?;
    for post in &posts {
        let translations = qt::list_post_translations_by_post(conn, &post.id)?;
        for t in &translations {
            if t.file_path.is_empty() {
                continue;
            }
            match diff_translation(data_dir, t) {
                Ok(Some(d)) => report.diffs.push(d),
                Ok(None) => {}
                Err(e) => report.errors.push(format!("translation {}: {e}", t.id)),
            }
        }
    }

    // 3. Diff media
    next_phase()?;
    let media_items = qm::list_media_by_project(conn, project_id)?;
    for m in &media_items {
        if m.sidecar_path.is_empty() {
            continue;
        }

        let translations = qmt::list_media_translations_by_media(conn, &m.id)?;
        for translation in &translations {
            match diff_media_translation(data_dir, m, translation) {
                Ok(Some(diff)) => report.diffs.push(diff),
                Ok(None) => {}
                Err(error) => report
                    .errors
                    .push(format!("media translation {}: {error}", translation.id)),
            }
        }
        match diff_media(data_dir, m) {
            Ok(Some(d)) => report.diffs.push(d),
            Ok(None) => {}
            Err(e) => report.errors.push(format!("media {}: {e}", m.id)),
        }
    }

    // 4. Diff templates
    next_phase()?;
    let templates = qtpl::list_templates_by_project(conn, project_id)?;
    for t in &templates {
        if t.file_path.is_empty() {
            continue;
        }
        match diff_template(data_dir, t) {
            Ok(Some(d)) => report.diffs.push(d),
            Ok(None) => {}
            Err(e) => report.errors.push(format!("template {}: {e}", t.id)),
        }
    }

    // 5. Diff scripts
    next_phase()?;
    let scripts = qs::list_scripts_by_project(conn, project_id)?;
    for s in &scripts {
        if s.file_path.is_empty() {
            continue;
        }
        match diff_script(data_dir, s) {
            Ok(Some(d)) => report.diffs.push(d),
            Ok(None) => {}
            Err(e) => report.errors.push(format!("script {}: {e}", s.id)),
        }
    }

    // 6. Detect orphans
    next_phase()?;
    if crate::engine::meta::read_project_json(data_dir)
        .is_ok_and(|metadata| metadata.semantic_similarity_enabled)
    {
        let service = crate::engine::embedding::EmbeddingService::production(conn, data_dir);
        let keys = qe::list_keys(conn, project_id)?
            .into_iter()
            .map(|key| (key.post_id.clone(), key))
            .collect::<std::collections::HashMap<_, _>>();
        for post in &posts {
            let expected = service.content_hash_for_post(post)?;
            let key = keys.get(&post.id);
            let current_hash = key.map(|key| key.content_hash.as_str()).unwrap_or("");
            let vector_status = key
                .filter(|key| crate::engine::embedding::decode_vector(&key.vector).is_ok())
                .map(|_| "ready")
                .unwrap_or("missing");
            if current_hash != expected || vector_status != "ready" {
                let mut fields = Vec::new();
                if current_hash != expected {
                    fields.push(DiffField {
                        field_name: "content_hash".into(),
                        db_value: current_hash.into(),
                        file_value: expected,
                    });
                }
                if vector_status != "ready" {
                    fields.push(DiffField {
                        field_name: "embedding".into(),
                        db_value: vector_status.into(),
                        file_value: "ready".into(),
                    });
                }
                report.diffs.push(EntityDiff {
                    entity_type: "embedding".into(),
                    entity_id: post.id.clone(),
                    file_path: format!("projects/{project_id}/embeddings.usearch"),
                    fields,
                });
            }
        }
    }

    // 7. Detect orphans
    next_phase()?;
    let orphans = detect_orphan_files(conn, data_dir, project_id)?;
    report.orphans = orphans;

    if !on_progress(PHASES, PHASES) {
        return Err(crate::engine::EngineError::Cancelled);
    }

    Ok(report)
}

/// Resolve one reported difference in the selected direction.
pub fn repair_metadata_diff_item(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
    direction: RepairDirection,
    item: &EntityDiff,
) -> EngineResult<()> {
    match direction {
        RepairDirection::FileToDatabase => {
            let path = data_dir.join(&item.file_path);
            match item.entity_type.as_str() {
                "project" | "categories" | "category_meta" | "publishing" => {
                    crate::engine::meta::sync_metadata_from_filesystem(conn, data_dir, project_id)?
                }
                "post" => {
                    crate::engine::post::rebuild_canonical_post(conn, data_dir, project_id, &path)?;
                }
                "post_translation" => {
                    crate::engine::post::rebuild_translation(conn, data_dir, project_id, &path)?;
                }
                "media" => {
                    crate::engine::media::rebuild_canonical_media(
                        conn, data_dir, project_id, &path,
                    )?;
                }
                "media_translation" => {
                    crate::engine::media::rebuild_translation_sidecar(
                        conn, data_dir, project_id, &path,
                    )?;
                }
                "script" => {
                    crate::engine::script_rebuild::rebuild_single_script(
                        conn, data_dir, project_id, &path,
                    )?;
                }
                "template" => {
                    crate::engine::template_rebuild::rebuild_single_template(
                        conn, data_dir, project_id, &path,
                    )?;
                }
                "embedding" => {
                    let post = qp::get_post_by_id(conn, &item.entity_id)?;
                    crate::engine::embedding::EmbeddingService::production(conn, data_dir)
                        .sync_post(&post)?;
                }
                other => return unsupported_repair(other),
            }
        }
        RepairDirection::DatabaseToFile => match item.entity_type.as_str() {
            "project" | "categories" | "category_meta" | "publishing" => {
                crate::engine::meta::flush_metadata_to_filesystem(conn, data_dir, project_id)?
            }
            "post" => rewrite_post_from_database(conn, data_dir, &item.entity_id)?,
            "post_translation" => {
                rewrite_post_translation_from_database(conn, data_dir, &item.entity_id)?
            }
            "media" => rewrite_media_from_database(conn, data_dir, &item.entity_id)?,
            "media_translation" => rewrite_media_translation_from_database(
                conn,
                data_dir,
                project_id,
                &item.entity_id,
            )?,
            "script" => rewrite_script_from_database(conn, data_dir, &item.entity_id)?,
            "template" => rewrite_template_from_database(conn, data_dir, &item.entity_id)?,
            "embedding" => crate::engine::embedding::EmbeddingService::production(conn, data_dir)
                .flush_project(project_id)?,
            other => return unsupported_repair(other),
        },
    }
    Ok(())
}

/// Import one content file reported as a filesystem orphan by
/// [`compute_metadata_diff`]. The normal per-entity rebuild paths remain the
/// sole parsers and writers for these formats.
pub fn import_orphan_file(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
    orphan: &OrphanFile,
) -> EngineResult<()> {
    if orphan.reason != "file_without_db_entry" {
        return Err(EngineError::Validation(format!(
            "cannot import an orphan that is absent from the filesystem: {}",
            orphan.file_path
        )));
    }
    let path = data_dir.join(&orphan.file_path);
    let canonical_data_dir = data_dir.canonicalize()?;
    let canonical_path = path.canonicalize()?;
    if !canonical_path.starts_with(&canonical_data_dir) {
        return Err(EngineError::Validation(
            "orphan path is outside the active project".into(),
        ));
    }

    if orphan.file_path.starts_with("posts/") && orphan.file_path.ends_with(".md") {
        let stem = path
            .file_stem()
            .and_then(|value| value.to_str())
            .unwrap_or("");
        if crate::engine::post::is_translation_filename(stem) {
            crate::engine::post::rebuild_translation(conn, data_dir, project_id, &path)?;
        } else {
            crate::engine::post::rebuild_canonical_post(conn, data_dir, project_id, &path)?;
        }
    } else if orphan.file_path.starts_with("media/") && orphan.file_path.ends_with(".meta") {
        let raw = fs::read_to_string(&path)?;
        if read_translation_sidecar(&raw).is_ok() {
            crate::engine::media::rebuild_translation_sidecar(conn, data_dir, project_id, &path)?;
        } else {
            crate::engine::media::rebuild_canonical_media(conn, data_dir, project_id, &path)?;
        }
    } else if orphan.file_path.starts_with("scripts/") && orphan.file_path.ends_with(".lua") {
        crate::engine::script_rebuild::rebuild_single_script(conn, data_dir, project_id, &path)?;
    } else if orphan.file_path.starts_with("templates/") && orphan.file_path.ends_with(".liquid") {
        crate::engine::template_rebuild::rebuild_single_template(
            conn, data_dir, project_id, &path,
        )?;
    } else {
        return Err(EngineError::Validation(format!(
            "unsupported orphan file: {}",
            orphan.file_path
        )));
    }
    Ok(())
}

fn diff_project(
    conn: &Connection,
    data_dir: &Path,
    project: &crate::model::Project,
) -> EngineResult<Option<EntityDiff>> {
    let metadata = crate::engine::meta::read_project_json(data_dir)?;
    let mut fields = Vec::new();
    let snapshot = crate::engine::meta::read_project_metadata_snapshot(conn, &project.id)?;
    let database = snapshot.as_ref();
    compare_field(
        &mut fields,
        "name",
        database.map_or(project.name.as_str(), |value| value.name.as_str()),
        &metadata.name,
    );
    compare_field(
        &mut fields,
        "description",
        database.map_or_else(
            || project.description.as_deref().unwrap_or(""),
            |value| value.description.as_deref().unwrap_or(""),
        ),
        metadata.description.as_deref().unwrap_or(""),
    );
    if let Some(database) = database {
        compare_optional_field(
            &mut fields,
            "public_url",
            &database.public_url,
            &metadata.public_url,
        );
        compare_optional_field(
            &mut fields,
            "main_language",
            &database.main_language,
            &metadata.main_language,
        );
        compare_optional_field(
            &mut fields,
            "default_author",
            &database.default_author,
            &metadata.default_author,
        );
        compare_field(
            &mut fields,
            "max_posts_per_page",
            &database.max_posts_per_page.to_string(),
            &metadata.max_posts_per_page.to_string(),
        );
        compare_field(
            &mut fields,
            "image_import_concurrency",
            &database.image_import_concurrency.to_string(),
            &metadata.image_import_concurrency.to_string(),
        );
        compare_optional_field(
            &mut fields,
            "blogmark_category",
            &database.blogmark_category,
            &metadata.blogmark_category,
        );
        compare_optional_field(
            &mut fields,
            "pico_theme",
            &database.pico_theme,
            &metadata.pico_theme,
        );
        compare_field(
            &mut fields,
            "semantic_similarity_enabled",
            if database.semantic_similarity_enabled {
                "true"
            } else {
                "false"
            },
            if metadata.semantic_similarity_enabled {
                "true"
            } else {
                "false"
            },
        );
        compare_field(
            &mut fields,
            "blog_languages",
            &json_value(&database.blog_languages)?,
            &json_value(&metadata.blog_languages)?,
        );
    }
    Ok((!fields.is_empty()).then_some(EntityDiff {
        entity_type: "project".into(),
        entity_id: project.id.clone(),
        file_path: "meta/project.json".into(),
        fields,
    }))
}

fn diff_categories(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
) -> EngineResult<Option<EntityDiff>> {
    let Some(database) = crate::engine::meta::read_categories_snapshot(conn, project_id)? else {
        return Ok(None);
    };
    let filesystem = crate::engine::meta::read_categories_json(data_dir)?;
    let mut fields = Vec::new();
    compare_field(
        &mut fields,
        "categories",
        &json_value(&database)?,
        &json_value(&filesystem)?,
    );
    Ok((!fields.is_empty()).then_some(EntityDiff {
        entity_type: "categories".into(),
        entity_id: project_id.into(),
        file_path: "meta/categories.json".into(),
        fields,
    }))
}

fn diff_category_meta(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
) -> EngineResult<Option<EntityDiff>> {
    let Some(database) = crate::engine::meta::read_category_meta_snapshot(conn, project_id)? else {
        return Ok(None);
    };
    let filesystem = crate::engine::meta::read_category_meta_json(data_dir)?;
    let database = database.into_iter().collect::<BTreeMap<_, _>>();
    let filesystem = filesystem.into_iter().collect::<BTreeMap<_, _>>();
    let mut fields = Vec::new();
    compare_field(
        &mut fields,
        "category_settings",
        &json_value(&database)?,
        &json_value(&filesystem)?,
    );
    Ok((!fields.is_empty()).then_some(EntityDiff {
        entity_type: "category_meta".into(),
        entity_id: project_id.into(),
        file_path: "meta/category-meta.json".into(),
        fields,
    }))
}

fn diff_publishing(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
) -> EngineResult<Option<EntityDiff>> {
    let Some(database) = crate::engine::meta::read_publishing_snapshot(conn, project_id)? else {
        return Ok(None);
    };
    let filesystem = crate::engine::meta::read_publishing_json(data_dir)?;
    let mut fields = Vec::new();
    compare_optional_field(
        &mut fields,
        "ssh_host",
        &database.ssh_host,
        &filesystem.ssh_host,
    );
    compare_optional_field(
        &mut fields,
        "ssh_user",
        &database.ssh_user,
        &filesystem.ssh_user,
    );
    compare_optional_field(
        &mut fields,
        "ssh_remote_path",
        &database.ssh_remote_path,
        &filesystem.ssh_remote_path,
    );
    compare_field(
        &mut fields,
        "ssh_mode",
        ssh_mode_name(&database.ssh_mode),
        ssh_mode_name(&filesystem.ssh_mode),
    );
    Ok((!fields.is_empty()).then_some(EntityDiff {
        entity_type: "publishing".into(),
        entity_id: project_id.into(),
        file_path: "meta/publishing.json".into(),
        fields,
    }))
}

fn unsupported_repair<T>(entity_type: &str) -> EngineResult<T> {
    Err(EngineError::Validation(format!(
        "unsupported metadata diff entity type: {entity_type}"
    )))
}

fn rewrite_post_from_database(
    conn: &Connection,
    data_dir: &Path,
    entity_id: &str,
) -> EngineResult<()> {
    let post = qp::get_post_by_id(conn, entity_id)?;
    crate::engine::post::rewrite_post_file_from_database(data_dir, &post)
}

fn rewrite_post_translation_from_database(
    conn: &Connection,
    data_dir: &Path,
    entity_id: &str,
) -> EngineResult<()> {
    let translation = qt::get_post_translation_by_id(conn, entity_id)?;
    let path = data_dir.join(&translation.file_path);
    let body = fs::read_to_string(&path)
        .ok()
        .and_then(|content| read_translation_file(&content).ok().map(|(_, body)| body))
        .unwrap_or_default();
    atomic_write_str(&path, &write_translation_file(&translation, &body))?;
    Ok(())
}

fn rewrite_media_from_database(
    conn: &Connection,
    data_dir: &Path,
    entity_id: &str,
) -> EngineResult<()> {
    let media = qm::get_media_by_id(conn, entity_id)?;
    let linked = crate::db::queries::post_media::list_post_media_by_media(conn, entity_id)
        .unwrap_or_default();
    let post_ids = linked
        .into_iter()
        .map(|link| link.post_id)
        .collect::<Vec<_>>();
    atomic_write_str(
        &data_dir.join(&media.sidecar_path),
        &MediaSidecar::from_media(&media, &post_ids).to_string(),
    )?;
    Ok(())
}

fn rewrite_media_translation_from_database(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
    entity_id: &str,
) -> EngineResult<()> {
    let media_items = qm::list_media_by_project(conn, project_id)?;
    for media in media_items {
        if let Some(translation) = qmt::list_media_translations_by_media(conn, &media.id)?
            .into_iter()
            .find(|translation| translation.id == entity_id)
        {
            let sidecar = MediaTranslationSidecar {
                translation_for: translation.translation_for,
                language: translation.language.clone(),
                title: translation.title,
                alt: translation.alt,
                caption: translation.caption,
            };
            let path = media_translation_sidecar_path(&media.file_path, &translation.language);
            atomic_write_str(&data_dir.join(path), &sidecar.to_string())?;
            return Ok(());
        }
    }
    Err(EngineError::NotFound(format!(
        "media translation {entity_id}"
    )))
}

fn rewrite_script_from_database(
    conn: &Connection,
    data_dir: &Path,
    entity_id: &str,
) -> EngineResult<()> {
    let script = qs::get_script_by_id(conn, entity_id)?;
    let path = data_dir.join(&script.file_path);
    let body = fs::read_to_string(&path)
        .ok()
        .and_then(|content| read_script_file(&content).ok().map(|(_, body)| body))
        .or(script.content.clone())
        .unwrap_or_default();
    let frontmatter = ScriptFrontmatter {
        id: script.id,
        project_id: Some(script.project_id),
        slug: script.slug,
        title: script.title,
        kind: script.kind.as_str().to_owned(),
        entrypoint: script.entrypoint,
        enabled: script.enabled,
        version: script.version,
        created_at: script.created_at,
        updated_at: script.updated_at,
    };
    atomic_write_str(&path, &write_script_file(&frontmatter, &body))?;
    Ok(())
}

fn rewrite_template_from_database(
    conn: &Connection,
    data_dir: &Path,
    entity_id: &str,
) -> EngineResult<()> {
    let template = qtpl::get_template_by_id(conn, entity_id)?;
    let path = data_dir.join(&template.file_path);
    let body = fs::read_to_string(&path)
        .ok()
        .and_then(|content| read_template_file(&content).ok().map(|(_, body)| body))
        .or(template.content.clone())
        .unwrap_or_default();
    let frontmatter = TemplateFrontmatter {
        id: template.id,
        project_id: Some(template.project_id),
        slug: template.slug,
        title: template.title,
        kind: template.kind.as_str().to_owned(),
        enabled: template.enabled,
        version: template.version,
        created_at: template.created_at,
        updated_at: template.updated_at,
    };
    atomic_write_str(&path, &write_template_file(&frontmatter, &body))?;
    Ok(())
}

// --- Internal helpers ---

fn opt_to_str(opt: &Option<String>) -> String {
    opt.clone().unwrap_or_default()
}

fn bool_to_str(b: bool) -> String {
    if b {
        "true".to_string()
    } else {
        "false".to_string()
    }
}

fn tags_to_json(tags: &[String]) -> String {
    serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string())
}

fn compare_field(fields: &mut Vec<DiffField>, name: &str, db_val: &str, file_val: &str) {
    if db_val != file_val {
        fields.push(DiffField {
            field_name: name.to_string(),
            db_value: db_val.to_string(),
            file_value: file_val.to_string(),
        });
    }
}

fn compare_optional_field(
    fields: &mut Vec<DiffField>,
    name: &str,
    db_val: &Option<String>,
    file_val: &Option<String>,
) {
    compare_field(
        fields,
        name,
        db_val.as_deref().unwrap_or(""),
        file_val.as_deref().unwrap_or(""),
    );
}

fn json_value(value: &impl serde::Serialize) -> EngineResult<String> {
    Ok(serde_json::to_string(value)?)
}

fn ssh_mode_name(mode: &crate::model::SshMode) -> &'static str {
    match mode {
        crate::model::SshMode::Scp => "scp",
        crate::model::SshMode::Rsync => "rsync",
    }
}

fn diff_post(data_dir: &Path, post: &Post) -> EngineResult<Option<EntityDiff>> {
    let abs_path = data_dir.join(&post.file_path);
    if !abs_path.exists() {
        // Will be caught by orphan detection
        return Ok(None);
    }

    let content = fs::read_to_string(&abs_path)?;
    let (fm, _body) = read_post_file(&content).map_err(EngineError::Parse)?;

    let mut fields = Vec::new();

    compare_field(&mut fields, "title", &post.title, &fm.title);
    compare_field(&mut fields, "slug", &post.slug, &fm.slug);
    compare_field(&mut fields, "status", post.status.as_str(), &fm.status);
    compare_field(
        &mut fields,
        "tags",
        &tags_to_json(&post.tags),
        &tags_to_json(&fm.tags),
    );
    compare_field(
        &mut fields,
        "categories",
        &tags_to_json(&post.categories),
        &tags_to_json(&fm.categories),
    );
    compare_field(
        &mut fields,
        "excerpt",
        &opt_to_str(&post.excerpt),
        &opt_to_str(&fm.excerpt),
    );
    compare_field(
        &mut fields,
        "author",
        &opt_to_str(&post.author),
        &opt_to_str(&fm.author),
    );
    compare_field(
        &mut fields,
        "language",
        &opt_to_str(&post.language),
        &opt_to_str(&fm.language),
    );
    compare_field(
        &mut fields,
        "doNotTranslate",
        &bool_to_str(post.do_not_translate),
        &bool_to_str(fm.do_not_translate),
    );
    compare_field(
        &mut fields,
        "templateSlug",
        &opt_to_str(&post.template_slug),
        &opt_to_str(&fm.template_slug),
    );
    compare_field(
        &mut fields,
        "createdAt",
        &post.created_at.to_string(),
        &fm.created_at.to_string(),
    );
    compare_field(
        &mut fields,
        "updatedAt",
        &post.updated_at.to_string(),
        &fm.updated_at.to_string(),
    );
    compare_field(
        &mut fields,
        "publishedAt",
        &post.published_at.map(|v| v.to_string()).unwrap_or_default(),
        &fm.published_at.map(|v| v.to_string()).unwrap_or_default(),
    );

    if fields.is_empty() {
        Ok(None)
    } else {
        Ok(Some(EntityDiff {
            entity_type: "post".to_string(),
            entity_id: post.id.clone(),
            file_path: post.file_path.clone(),
            fields,
        }))
    }
}

fn diff_translation(data_dir: &Path, t: &PostTranslation) -> EngineResult<Option<EntityDiff>> {
    let abs_path = data_dir.join(&t.file_path);
    if !abs_path.exists() {
        return Ok(None);
    }

    let content = fs::read_to_string(&abs_path)?;
    let (fm, _body) = read_translation_file(&content).map_err(EngineError::Parse)?;

    let mut fields = Vec::new();

    compare_field(
        &mut fields,
        "translationFor",
        &t.translation_for,
        &fm.translation_for,
    );
    compare_field(&mut fields, "language", &t.language, &fm.language);
    compare_field(&mut fields, "title", &t.title, &fm.title);
    compare_field(
        &mut fields,
        "excerpt",
        &opt_to_str(&t.excerpt),
        &opt_to_str(&fm.excerpt),
    );

    if fields.is_empty() {
        Ok(None)
    } else {
        Ok(Some(EntityDiff {
            entity_type: "post_translation".to_string(),
            entity_id: t.id.clone(),
            file_path: t.file_path.clone(),
            fields,
        }))
    }
}

fn diff_media(data_dir: &Path, media: &Media) -> EngineResult<Option<EntityDiff>> {
    let abs_path = data_dir.join(&media.sidecar_path);
    if !abs_path.exists() {
        return Ok(None);
    }

    let content = fs::read_to_string(&abs_path)?;
    let sc = read_sidecar(&content).map_err(EngineError::Parse)?;

    let mut fields = Vec::new();

    compare_field(
        &mut fields,
        "title",
        &opt_to_str(&media.title),
        &opt_to_str(&sc.title),
    );
    compare_field(
        &mut fields,
        "alt",
        &opt_to_str(&media.alt),
        &opt_to_str(&sc.alt),
    );
    compare_field(
        &mut fields,
        "caption",
        &opt_to_str(&media.caption),
        &opt_to_str(&sc.caption),
    );
    compare_field(
        &mut fields,
        "author",
        &opt_to_str(&media.author),
        &opt_to_str(&sc.author),
    );
    compare_field(
        &mut fields,
        "tags",
        &tags_to_json(&media.tags),
        &tags_to_json(&sc.tags),
    );
    compare_field(
        &mut fields,
        "language",
        &opt_to_str(&media.language),
        &opt_to_str(&sc.language),
    );

    if fields.is_empty() {
        Ok(None)
    } else {
        Ok(Some(EntityDiff {
            entity_type: "media".to_string(),
            entity_id: media.id.clone(),
            file_path: media.sidecar_path.clone(),
            fields,
        }))
    }
}

fn diff_media_translation(
    data_dir: &Path,
    media: &Media,
    translation: &MediaTranslation,
) -> EngineResult<Option<EntityDiff>> {
    let relative_path = media_translation_sidecar_path(&media.file_path, &translation.language);
    let absolute_path = data_dir.join(&relative_path);
    if !absolute_path.is_file() {
        return Ok(None);
    }
    let sidecar = read_translation_sidecar(&fs::read_to_string(&absolute_path)?)
        .map_err(EngineError::Parse)?;
    let mut fields = Vec::new();
    compare_field(
        &mut fields,
        "translationFor",
        &translation.translation_for,
        &sidecar.translation_for,
    );
    compare_field(
        &mut fields,
        "language",
        &translation.language,
        &sidecar.language,
    );
    compare_field(
        &mut fields,
        "title",
        &opt_to_str(&translation.title),
        &opt_to_str(&sidecar.title),
    );
    compare_field(
        &mut fields,
        "alt",
        &opt_to_str(&translation.alt),
        &opt_to_str(&sidecar.alt),
    );
    compare_field(
        &mut fields,
        "caption",
        &opt_to_str(&translation.caption),
        &opt_to_str(&sidecar.caption),
    );

    Ok((!fields.is_empty()).then_some(EntityDiff {
        entity_type: "media_translation".into(),
        entity_id: translation.id.clone(),
        file_path: relative_path,
        fields,
    }))
}

fn diff_template(data_dir: &Path, tpl: &Template) -> EngineResult<Option<EntityDiff>> {
    let abs_path = data_dir.join(&tpl.file_path);
    if !abs_path.exists() {
        return Ok(None);
    }

    let content = fs::read_to_string(&abs_path)?;
    let (fm, _body) = read_template_file(&content).map_err(EngineError::Parse)?;

    let mut fields = Vec::new();

    compare_field(&mut fields, "title", &tpl.title, &fm.title);
    compare_field(&mut fields, "kind", tpl.kind.as_str(), &fm.kind);
    compare_field(
        &mut fields,
        "enabled",
        &bool_to_str(tpl.enabled),
        &bool_to_str(fm.enabled),
    );
    compare_field(
        &mut fields,
        "version",
        &tpl.version.to_string(),
        &fm.version.to_string(),
    );

    if fields.is_empty() {
        Ok(None)
    } else {
        Ok(Some(EntityDiff {
            entity_type: "template".to_string(),
            entity_id: tpl.id.clone(),
            file_path: tpl.file_path.clone(),
            fields,
        }))
    }
}

fn diff_script(data_dir: &Path, script: &Script) -> EngineResult<Option<EntityDiff>> {
    let abs_path = data_dir.join(&script.file_path);
    if !abs_path.exists() {
        return Ok(None);
    }

    let content = fs::read_to_string(&abs_path)?;
    let (fm, _body) = read_script_file(&content).map_err(EngineError::Parse)?;

    let mut fields = Vec::new();

    compare_field(&mut fields, "title", &script.title, &fm.title);
    compare_field(&mut fields, "kind", script.kind.as_str(), &fm.kind);
    compare_field(
        &mut fields,
        "entrypoint",
        &script.entrypoint,
        &fm.entrypoint,
    );
    compare_field(
        &mut fields,
        "enabled",
        &bool_to_str(script.enabled),
        &bool_to_str(fm.enabled),
    );
    compare_field(
        &mut fields,
        "version",
        &script.version.to_string(),
        &fm.version.to_string(),
    );

    if fields.is_empty() {
        Ok(None)
    } else {
        Ok(Some(EntityDiff {
            entity_type: "script".to_string(),
            entity_id: script.id.clone(),
            file_path: script.file_path.clone(),
            fields,
        }))
    }
}

fn detect_orphan_files(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
) -> EngineResult<Vec<OrphanFile>> {
    let mut orphans = Vec::new();

    // Collect all known file paths from DB
    let mut db_file_paths: HashSet<String> = HashSet::new();

    let posts = qp::list_posts_by_project(conn, project_id)?;
    for post in &posts {
        if !post.file_path.is_empty() {
            db_file_paths.insert(post.file_path.clone());
        }
        let translations = qt::list_post_translations_by_post(conn, &post.id)?;
        for t in &translations {
            if !t.file_path.is_empty() {
                db_file_paths.insert(t.file_path.clone());
            }
        }
    }

    let media_items = qm::list_media_by_project(conn, project_id)?;
    for m in &media_items {
        if !m.file_path.is_empty() {
            db_file_paths.insert(m.file_path.clone());
        }
        if !m.sidecar_path.is_empty() {
            db_file_paths.insert(m.sidecar_path.clone());
        }
        for translation in qmt::list_media_translations_by_media(conn, &m.id)? {
            db_file_paths.insert(media_translation_sidecar_path(
                &m.file_path,
                &translation.language,
            ));
        }
    }

    let templates = qtpl::list_templates_by_project(conn, project_id)?;
    for t in &templates {
        if !t.file_path.is_empty() {
            db_file_paths.insert(t.file_path.clone());
        }
    }

    let scripts = qs::list_scripts_by_project(conn, project_id)?;
    for s in &scripts {
        if !s.file_path.is_empty() {
            db_file_paths.insert(s.file_path.clone());
        }
    }

    // Walk filesystem directories and find orphans
    let dirs_to_walk = ["posts", "media", "templates", "scripts"];
    for dir_name in &dirs_to_walk {
        let dir_path = data_dir.join(dir_name);
        if !dir_path.exists() {
            continue;
        }
        for entry in WalkDir::new(&dir_path).into_iter().filter_map(|e| e.ok()) {
            let path = entry.path();
            if !path.is_file() {
                continue;
            }

            // Compute relative path from data_dir
            let rel_path = path
                .strip_prefix(data_dir)
                .unwrap_or(path)
                .to_string_lossy()
                .to_string();

            // Skip non-content files (thumbnails, etc.)
            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
            let is_content_file = match *dir_name {
                "posts" => ext == "md",
                "media" => ext == "meta",
                "templates" => ext == "liquid",
                "scripts" => ext == "lua",
                _ => false,
            };

            if !is_content_file {
                continue;
            }

            if !db_file_paths.contains(&rel_path) {
                orphans.push(OrphanFile {
                    file_path: rel_path,
                    reason: "file_without_db_entry".to_string(),
                });
            }
        }
    }

    // Check DB entries whose file_path doesn't exist on disk
    for post in &posts {
        if !post.file_path.is_empty() {
            let abs = data_dir.join(&post.file_path);
            if !abs.exists() {
                orphans.push(OrphanFile {
                    file_path: post.file_path.clone(),
                    reason: "db_entry_without_file".to_string(),
                });
            }
        }
        let translations = qt::list_post_translations_by_post(conn, &post.id)?;
        for t in &translations {
            if !t.file_path.is_empty() {
                let abs = data_dir.join(&t.file_path);
                if !abs.exists() {
                    orphans.push(OrphanFile {
                        file_path: t.file_path.clone(),
                        reason: "db_entry_without_file".to_string(),
                    });
                }
            }
        }
    }

    for m in &media_items {
        if !m.sidecar_path.is_empty() {
            let abs = data_dir.join(&m.sidecar_path);
            if !abs.exists() {
                orphans.push(OrphanFile {
                    file_path: m.sidecar_path.clone(),
                    reason: "db_entry_without_file".to_string(),
                });
            }
        }
        for translation in qmt::list_media_translations_by_media(conn, &m.id)? {
            let path = media_translation_sidecar_path(&m.file_path, &translation.language);
            if !data_dir.join(&path).is_file() {
                orphans.push(OrphanFile {
                    file_path: path,
                    reason: "db_entry_without_file".to_string(),
                });
            }
        }
    }

    for t in &templates {
        if !t.file_path.is_empty() {
            let abs = data_dir.join(&t.file_path);
            if !abs.exists() {
                orphans.push(OrphanFile {
                    file_path: t.file_path.clone(),
                    reason: "db_entry_without_file".to_string(),
                });
            }
        }
    }

    for s in &scripts {
        if !s.file_path.is_empty() {
            let abs = data_dir.join(&s.file_path);
            if !abs.exists() {
                orphans.push(OrphanFile {
                    file_path: s.file_path.clone(),
                    reason: "db_entry_without_file".to_string(),
                });
            }
        }
    }

    Ok(orphans)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::db::Database;
    use crate::db::fts::ensure_fts_tables;
    use crate::db::queries::media::insert_media;
    use crate::db::queries::post::insert_post;
    use crate::db::queries::project::{insert_project, make_test_project};
    use crate::db::queries::script::insert_script;
    use crate::db::queries::template::insert_template;
    use crate::engine::post::{archive_post, create_post, publish_post};
    use crate::model::{
        Media, Post, PostStatus, ProjectMetadata, PublishingPreferences, Script, ScriptKind,
        ScriptStatus, SshMode, Template, TemplateKind, TemplateStatus,
    };
    use crate::util::frontmatter::{
        ScriptFrontmatter, TemplateFrontmatter, write_script_file, write_template_file,
    };
    use crate::util::sidecar::MediaSidecar;
    use tempfile::TempDir;

    fn setup() -> (Database, TempDir) {
        let db = Database::open_in_memory().unwrap();
        db.migrate().unwrap();
        ensure_fts_tables(db.conn()).unwrap();
        insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
        let dir = TempDir::new().unwrap();
        (db, dir)
    }

    #[test]
    fn no_diffs_for_clean_state() {
        let (db, dir) = setup();

        // Create and publish a post via the engine
        let post = create_post(
            db.conn(),
            dir.path(),
            "p1",
            "Clean Post",
            Some("body text"),
            vec!["rust".into()],
            vec!["tech".into()],
            Some("Alice"),
            Some("en"),
            None,
        )
        .unwrap();

        publish_post(db.conn(), dir.path(), &post.id).unwrap();

        let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();

        // Published post: file was just written by publish, DB and file should match.
        // The only expected diff is updatedAt because publish sets it to now() in the DB
        // but the file was written with that same now(). They should match.
        // However, publish_post updates updatedAt multiple times, so the DB value may
        // differ from the file. Filter to only non-updatedAt diffs.
        let non_time_diffs: Vec<_> = report
            .diffs
            .iter()
            .filter(|d| d.fields.iter().any(|f| f.field_name != "updatedAt"))
            .collect();
        assert!(
            non_time_diffs.is_empty(),
            "expected no non-timestamp diffs, got: {non_time_diffs:?}"
        );
        assert!(report.orphans.is_empty(), "orphans: {:?}", report.orphans);
    }

    #[test]
    fn archived_published_post_reports_only_bds2_frontmatter_drift() {
        let (db, dir) = setup();
        let post = create_post(
            db.conn(),
            dir.path(),
            "p1",
            "Archived Post",
            Some("body text"),
            vec!["rust".into()],
            vec!["tech".into()],
            Some("Alice"),
            Some("en"),
            None,
        )
        .unwrap();
        let published = publish_post(db.conn(), dir.path(), &post.id).unwrap();
        let path = dir.path().join(&published.file_path);
        let original_file = fs::read(&path).unwrap();
        archive_post(db.conn(), dir.path(), &post.id).unwrap();

        let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();

        assert_eq!(fs::read(path).unwrap(), original_file);
        assert!(
            report
                .errors
                .iter()
                .all(|error| !error.starts_with("post ")),
            "post diff errors: {:?}",
            report.errors
        );
        assert!(report.orphans.is_empty());
        let diff = report
            .diffs
            .iter()
            .find(|diff| diff.entity_type == "post" && diff.entity_id == post.id)
            .expect("archived database status must differ from untouched published file");
        assert!(diff.fields.iter().any(|field| {
            field.field_name == "status"
                && field.db_value == "archived"
                && field.file_value == "published"
        }));
        assert!(
            diff.fields
                .iter()
                .all(|field| matches!(field.field_name.as_str(), "status" | "updatedAt")),
            "unexpected archived-post drift: {:?}",
            diff.fields
        );
    }

    #[test]
    fn detects_title_drift_in_post() {
        let (db, dir) = setup();

        let post = create_post(
            db.conn(),
            dir.path(),
            "p1",
            "Original Title",
            Some("body"),
            vec!["rust".into()],
            vec!["tech".into()],
            None,
            None,
            None,
        )
        .unwrap();

        let published = publish_post(db.conn(), dir.path(), &post.id).unwrap();

        // Manually edit the .md file to change the title
        let abs_path = dir.path().join(&published.file_path);
        let content = fs::read_to_string(&abs_path).unwrap();
        let modified = content.replace("Original Title", "Tampered Title");
        fs::write(&abs_path, modified).unwrap();

        let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();

        // Should detect title drift
        let title_diffs: Vec<_> = report
            .diffs
            .iter()
            .filter(|d| d.entity_type == "post")
            .flat_map(|d| d.fields.iter())
            .filter(|f| f.field_name == "title")
            .collect();

        assert_eq!(title_diffs.len(), 1);
        assert_eq!(title_diffs[0].db_value, "Original Title");
        assert_eq!(title_diffs[0].file_value, "Tampered Title");
    }

    #[test]
    fn detects_missing_project_description() {
        let (db, dir) = setup();
        crate::engine::meta::write_project_json(
            dir.path(),
            &ProjectMetadata {
                name: "Project p1".into(),
                description: None,
                public_url: None,
                main_language: None,
                default_author: None,
                max_posts_per_page: 50,
                image_import_concurrency: 4,
                blogmark_category: None,
                pico_theme: None,
                semantic_similarity_enabled: false,
                blog_languages: vec![],
            },
        )
        .unwrap();

        let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
        let project = report
            .diffs
            .iter()
            .find(|item| item.entity_type == "project")
            .unwrap();
        assert!(project.fields.iter().any(|field| {
            field.field_name == "description"
                && field.db_value == "A test project"
                && field.file_value.is_empty()
        }));
    }

    fn seed_portable_metadata(db: &Database, dir: &TempDir) {
        crate::engine::meta::startup_sync(dir.path()).unwrap();
        crate::engine::meta::sync_metadata_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
        crate::engine::meta::set_categories(
            db.conn(),
            dir.path(),
            "p1",
            &["article".into(), "news".into()],
        )
        .unwrap();
        let mut category_meta = std::collections::HashMap::new();
        category_meta.insert(
            "news".to_string(),
            crate::model::metadata::CategorySettings {
                title: Some("News".into()),
                titles: std::collections::BTreeMap::new(),
                render_in_lists: false,
                show_title: true,
                post_template_slug: Some("article".into()),
                list_template_slug: None,
            },
        );
        crate::engine::meta::set_category_meta(db.conn(), dir.path(), "p1", &category_meta)
            .unwrap();
        crate::engine::meta::set_publishing_preferences(
            db.conn(),
            dir.path(),
            "p1",
            &PublishingPreferences {
                ssh_host: Some("example.net".into()),
                ssh_user: Some("deploy".into()),
                ssh_remote_path: Some("/srv/blog".into()),
                ssh_mode: SshMode::Rsync,
            },
        )
        .unwrap();
    }

    #[test]
    fn detects_categories_category_meta_and_publishing_drift() {
        let (db, dir) = setup();
        seed_portable_metadata(&db, &dir);
        crate::engine::meta::write_categories_json(dir.path(), &["article".into()]).unwrap();
        crate::engine::meta::write_category_meta_json(dir.path(), &Default::default()).unwrap();
        crate::engine::meta::write_publishing_json(dir.path(), &PublishingPreferences::default())
            .unwrap();

        let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();

        for entity_type in ["categories", "category_meta", "publishing"] {
            assert!(
                report
                    .diffs
                    .iter()
                    .any(|item| item.entity_type == entity_type),
                "missing {entity_type} diff: {:?}",
                report.diffs
            );
        }
    }

    #[test]
    fn repairs_portable_metadata_in_both_directions() {
        let (db, dir) = setup();
        seed_portable_metadata(&db, &dir);
        crate::engine::meta::write_categories_json(dir.path(), &["filesystem".into()]).unwrap();
        crate::engine::meta::write_category_meta_json(dir.path(), &Default::default()).unwrap();
        crate::engine::meta::write_publishing_json(dir.path(), &PublishingPreferences::default())
            .unwrap();
        let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();

        let categories = report
            .diffs
            .iter()
            .find(|item| item.entity_type == "categories")
            .unwrap();
        repair_metadata_diff_item(
            db.conn(),
            dir.path(),
            "p1",
            RepairDirection::DatabaseToFile,
            categories,
        )
        .unwrap();
        assert_eq!(
            crate::engine::meta::read_categories_json(dir.path()).unwrap(),
            vec!["article", "news"]
        );
        assert!(
            crate::engine::meta::read_category_meta_json(dir.path())
                .unwrap()
                .contains_key("news")
        );
        assert_eq!(
            crate::engine::meta::read_publishing_json(dir.path())
                .unwrap()
                .ssh_host
                .as_deref(),
            Some("example.net")
        );

        crate::engine::meta::write_categories_json(dir.path(), &["filesystem".into()]).unwrap();
        crate::engine::meta::write_category_meta_json(dir.path(), &Default::default()).unwrap();
        crate::engine::meta::write_publishing_json(dir.path(), &PublishingPreferences::default())
            .unwrap();
        let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
        let categories = report
            .diffs
            .iter()
            .find(|item| item.entity_type == "categories")
            .unwrap();
        repair_metadata_diff_item(
            db.conn(),
            dir.path(),
            "p1",
            RepairDirection::FileToDatabase,
            categories,
        )
        .unwrap();
        assert!(
            compute_metadata_diff(db.conn(), dir.path(), "p1")
                .unwrap()
                .diffs
                .iter()
                .all(|item| !matches!(
                    item.entity_type.as_str(),
                    "categories" | "category_meta" | "publishing"
                ))
        );
    }

    #[test]
    fn ignores_python_scripts_during_orphan_scan() {
        let (db, dir) = setup();
        let scripts = dir.path().join("scripts");
        fs::create_dir_all(&scripts).unwrap();
        fs::write(scripts.join("legacy.py"), "print('legacy')").unwrap();

        let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();

        assert!(
            report
                .orphans
                .iter()
                .all(|orphan| !orphan.file_path.ends_with(".py"))
        );
    }

    #[test]
    fn repairs_one_post_diff_from_file_to_database() {
        let (db, dir) = setup();
        let database_target = create_post(
            db.conn(),
            dir.path(),
            "p1",
            "Database Target",
            None,
            vec![],
            vec![],
            None,
            None,
            None,
        )
        .unwrap();
        let filesystem_target = create_post(
            db.conn(),
            dir.path(),
            "p1",
            "Filesystem Target",
            None,
            vec![],
            vec![],
            None,
            None,
            None,
        )
        .unwrap();
        let post = create_post(
            db.conn(),
            dir.path(),
            "p1",
            "Database Title",
            Some("[database](/2024/01/01/database-target)"),
            vec![],
            vec![],
            None,
            Some("en"),
            None,
        )
        .unwrap();
        let published = publish_post(db.conn(), dir.path(), &post.id).unwrap();
        let path = dir.path().join(&published.file_path);
        let content = fs::read_to_string(&path).unwrap();
        fs::write(
            &path,
            content
                .replace("Database Title", "Filesystem Title")
                .replace(
                    "[database](/2024/01/01/database-target)",
                    "[filesystem](/2024/01/01/filesystem-target)",
                ),
        )
        .unwrap();
        let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
        let item = report
            .diffs
            .iter()
            .find(|item| item.entity_type == "post")
            .unwrap();

        repair_metadata_diff_item(
            db.conn(),
            dir.path(),
            "p1",
            RepairDirection::FileToDatabase,
            item,
        )
        .unwrap();

        assert_eq!(
            qp::get_post_by_id(db.conn(), &post.id).unwrap().title,
            "Filesystem Title"
        );
        let links =
            crate::db::queries::post_link::list_links_by_source(db.conn(), &post.id).unwrap();
        assert_eq!(links.len(), 1);
        assert_eq!(links[0].target_post_id, filesystem_target.id);
        assert_ne!(links[0].target_post_id, database_target.id);
    }

    #[test]
    fn repairs_one_post_diff_from_database_to_file_without_losing_body() {
        let (db, dir) = setup();
        let post = create_post(
            db.conn(),
            dir.path(),
            "p1",
            "Database Title",
            Some("body that must survive"),
            vec![],
            vec![],
            None,
            Some("en"),
            None,
        )
        .unwrap();
        let published = publish_post(db.conn(), dir.path(), &post.id).unwrap();
        let path = dir.path().join(&published.file_path);
        let content = fs::read_to_string(&path).unwrap();
        fs::write(&path, content.replace("Database Title", "Filesystem Title")).unwrap();
        let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
        let item = report
            .diffs
            .iter()
            .find(|item| item.entity_type == "post")
            .unwrap();

        repair_metadata_diff_item(
            db.conn(),
            dir.path(),
            "p1",
            RepairDirection::DatabaseToFile,
            item,
        )
        .unwrap();

        let repaired = fs::read_to_string(path).unwrap();
        assert!(repaired.contains("title: Database Title"));
        assert!(repaired.contains("body that must survive"));
    }

    #[test]
    fn detects_media_sidecar_drift() {
        let (db, dir) = setup();

        // Create media in DB with sidecar
        let media = Media {
            id: "m1".to_string(),
            project_id: "p1".to_string(),
            filename: "m1.jpg".to_string(),
            original_name: "photo.jpg".to_string(),
            mime_type: "image/jpeg".to_string(),
            size: 50000,
            width: Some(800),
            height: Some(600),
            title: Some("My Photo".to_string()),
            alt: Some("A nice photo".to_string()),
            caption: None,
            author: None,
            language: None,
            file_path: "media/2024/01/m1.jpg".to_string(),
            sidecar_path: "media/2024/01/m1.jpg.meta".to_string(),
            checksum: None,
            tags: vec![],
            created_at: 1000,
            updated_at: 2000,
        };
        insert_media(db.conn(), &media).unwrap();

        // Write sidecar with matching data initially, then change alt
        let sidecar = MediaSidecar {
            id: "m1".to_string(),
            original_name: "photo.jpg".to_string(),
            mime_type: "image/jpeg".to_string(),
            size: 50000,
            width: Some(800),
            height: Some(600),
            title: Some("My Photo".to_string()),
            alt: Some("TAMPERED ALT".to_string()),
            caption: None,
            author: None,
            language: None,
            created_at: 1000,
            updated_at: 2000,
            tags: vec![],
            linked_post_ids: vec![],
        };

        let sidecar_dir = dir.path().join("media/2024/01");
        fs::create_dir_all(&sidecar_dir).unwrap();
        fs::write(sidecar_dir.join("m1.jpg.meta"), sidecar.to_string()).unwrap();

        let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();

        let alt_diffs: Vec<_> = report
            .diffs
            .iter()
            .filter(|d| d.entity_type == "media")
            .flat_map(|d| d.fields.iter())
            .filter(|f| f.field_name == "alt")
            .collect();

        assert_eq!(alt_diffs.len(), 1);
        assert_eq!(alt_diffs[0].db_value, "A nice photo");
        assert_eq!(alt_diffs[0].file_value, "TAMPERED ALT");
    }

    #[test]
    fn detects_orphan_file() {
        let (db, dir) = setup();

        // Create a .md file in posts/ that is not in the DB
        let posts_dir = dir.path().join("posts/2024/01");
        fs::create_dir_all(&posts_dir).unwrap();
        fs::write(
            posts_dir.join("orphan.md"),
            "---\ntitle: Orphan\n---\nBody\n",
        )
        .unwrap();

        let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();

        let orphan_files: Vec<_> = report
            .orphans
            .iter()
            .filter(|o| o.reason == "file_without_db_entry")
            .collect();

        assert!(
            !orphan_files.is_empty(),
            "expected at least one orphan file"
        );
        assert!(
            orphan_files
                .iter()
                .any(|o| o.file_path.contains("orphan.md")),
            "expected orphan.md in orphans, got: {orphan_files:?}"
        );
    }

    #[test]
    fn detects_db_without_file() {
        let (db, dir) = setup();

        // Insert post in DB with file_path pointing to a non-existent file
        let post = Post {
            id: "ghost-post".to_string(),
            project_id: "p1".to_string(),
            title: "Ghost".to_string(),
            slug: "ghost".to_string(),
            excerpt: None,
            content: None,
            status: PostStatus::Published,
            author: None,
            language: None,
            do_not_translate: false,
            template_slug: None,
            file_path: "posts/2024/01/ghost.md".to_string(),
            checksum: None,
            tags: vec![],
            categories: vec![],
            published_title: None,
            published_content: None,
            published_tags: None,
            published_categories: None,
            published_excerpt: None,
            created_at: 1000,
            updated_at: 2000,
            published_at: Some(3000),
        };
        insert_post(db.conn(), &post).unwrap();

        let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();

        let db_orphans: Vec<_> = report
            .orphans
            .iter()
            .filter(|o| o.reason == "db_entry_without_file")
            .collect();

        assert!(
            !db_orphans.is_empty(),
            "expected at least one db_entry_without_file orphan"
        );
        assert!(
            db_orphans.iter().any(|o| o.file_path.contains("ghost.md")),
            "expected ghost.md in orphans, got: {db_orphans:?}"
        );
    }

    #[test]
    fn detects_template_drift() {
        let (db, dir) = setup();

        // Insert template in DB
        let tpl = Template {
            id: "tpl1".to_string(),
            project_id: "p1".to_string(),
            slug: "my-template".to_string(),
            title: "My Template".to_string(),
            kind: TemplateKind::Post,
            enabled: true,
            version: 1,
            file_path: "templates/my-template.liquid".to_string(),
            status: TemplateStatus::Published,
            content: None,
            created_at: 1000,
            updated_at: 2000,
        };
        insert_template(db.conn(), &tpl).unwrap();

        // Write template file with different title
        let fm = TemplateFrontmatter {
            id: "tpl1".to_string(),
            project_id: Some("p1".to_string()),
            slug: "my-template".to_string(),
            title: "CHANGED Template Title".to_string(),
            kind: "post".to_string(),
            enabled: true,
            version: 1,
            created_at: 1000,
            updated_at: 2000,
        };
        let file_content = write_template_file(&fm, "<div>body</div>");
        let tpl_dir = dir.path().join("templates");
        fs::create_dir_all(&tpl_dir).unwrap();
        fs::write(tpl_dir.join("my-template.liquid"), file_content).unwrap();

        let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();

        let title_diffs: Vec<_> = report
            .diffs
            .iter()
            .filter(|d| d.entity_type == "template")
            .flat_map(|d| d.fields.iter())
            .filter(|f| f.field_name == "title")
            .collect();

        assert_eq!(title_diffs.len(), 1);
        assert_eq!(title_diffs[0].db_value, "My Template");
        assert_eq!(title_diffs[0].file_value, "CHANGED Template Title");
    }

    #[test]
    fn detects_script_drift() {
        let (db, dir) = setup();

        // Insert script in DB
        let script = Script {
            id: "s1".to_string(),
            project_id: "p1".to_string(),
            slug: "my-script".to_string(),
            title: "My Script".to_string(),
            kind: ScriptKind::Utility,
            entrypoint: "main".to_string(),
            enabled: true,
            version: 1,
            file_path: "scripts/my-script.lua".to_string(),
            status: ScriptStatus::Published,
            content: None,
            created_at: 1000,
            updated_at: 2000,
        };
        insert_script(db.conn(), &script).unwrap();

        // Write script file with different title and version
        let fm = ScriptFrontmatter {
            id: "s1".to_string(),
            project_id: Some("p1".to_string()),
            slug: "my-script".to_string(),
            title: "CHANGED Script Title".to_string(),
            kind: "utility".to_string(),
            entrypoint: "main".to_string(),
            enabled: true,
            version: 5,
            created_at: 1000,
            updated_at: 2000,
        };
        let file_content = write_script_file(&fm, "-- lua code\nreturn 1");
        let scripts_dir = dir.path().join("scripts");
        fs::create_dir_all(&scripts_dir).unwrap();
        fs::write(scripts_dir.join("my-script.lua"), file_content).unwrap();

        let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();

        let script_diffs: Vec<_> = report
            .diffs
            .iter()
            .filter(|d| d.entity_type == "script")
            .collect();

        assert!(!script_diffs.is_empty(), "expected script diffs");

        let title_diffs: Vec<_> = script_diffs
            .iter()
            .flat_map(|d| d.fields.iter())
            .filter(|f| f.field_name == "title")
            .collect();
        assert_eq!(title_diffs.len(), 1);
        assert_eq!(title_diffs[0].db_value, "My Script");
        assert_eq!(title_diffs[0].file_value, "CHANGED Script Title");

        let version_diffs: Vec<_> = script_diffs
            .iter()
            .flat_map(|d| d.fields.iter())
            .filter(|f| f.field_name == "version")
            .collect();
        assert_eq!(version_diffs.len(), 1);
        assert_eq!(version_diffs[0].db_value, "1");
        assert_eq!(version_diffs[0].file_value, "5");
    }
}
