//! M1 validation integration tests (Steps 19-21).
//!
//! - Step 19: Round-trip tests (create -> publish -> read -> verify)
//! - Step 20: Golden-file comparisons against fixture files
//! - Step 21: Metadata diff coverage matrix (every diffable field covered)

use bds_core::db::Database;
use bds_core::db::fts::ensure_fts_tables;
use bds_core::db::queries::project::insert_project;
use bds_core::db::queries::script as qs;
use bds_core::db::queries::template as qtpl;
use bds_core::engine::media;
use bds_core::engine::meta;
use bds_core::engine::metadata_diff;
use bds_core::engine::post;
use bds_core::engine::tag;
use bds_core::model::{
    PostStatus, Project, Script, ScriptKind, ScriptStatus, Template, TemplateKind, TemplateStatus,
};
use bds_core::util::frontmatter::{
    ScriptFrontmatter, TemplateFrontmatter, read_post_file, read_template_file,
    read_translation_file, write_script_file, write_template_file,
};
use bds_core::util::sidecar::{read_sidecar, read_translation_sidecar};
use image::DynamicImage;
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;

// ── Common helpers ──────────────────────────────────────────────────

fn make_test_project(id: &str, slug: &str) -> Project {
    Project {
        id: id.to_string(),
        name: format!("Project {id}"),
        slug: slug.to_string(),
        description: Some("A test project".into()),
        data_path: Some("/data".into()),
        is_active: false,
        created_at: 1000,
        updated_at: 2000,
    }
}

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();
    // Seed meta directory and tags.json for tag operations
    fs::create_dir_all(dir.path().join("meta")).unwrap();
    fs::write(dir.path().join("meta/tags.json"), "[]").unwrap();
    (db, dir)
}

fn fixture_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../../fixtures/compatibility-projects/rfc1437-sample")
}

fn create_test_image(dir: &Path) -> PathBuf {
    let path = dir.join("test_image.png");
    let img = DynamicImage::new_rgb8(10, 10);
    img.save(&path).unwrap();
    path
}

// ════════════════════════════════════════════════════════════════════
// Step 19: Round-trip Integration Tests
// ════════════════════════════════════════════════════════════════════

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

    // Create a post with full metadata
    let created = post::create_post(
        db.conn(),
        dir.path(),
        "p1",
        "Round-Trip Post",
        Some("This is the body of the post."),
        vec!["rust".into(), "testing".into()],
        vec!["tech".into()],
        Some("Alice"),
        Some("en"),
        None,
    )
    .unwrap();

    assert_eq!(created.status, PostStatus::Draft);

    // Publish the post
    let published = post::publish_post(db.conn(), dir.path(), &created.id).unwrap();
    assert_eq!(published.status, PostStatus::Published);
    assert!(!published.file_path.is_empty());

    // Read the file from disk
    let abs_path = dir.path().join(&published.file_path);
    assert!(abs_path.exists(), "published file must exist on disk");
    let file_content = fs::read_to_string(&abs_path).unwrap();

    // Parse via read_post_file
    let (fm, body) = read_post_file(&file_content).unwrap();

    // Verify all frontmatter fields match the DB values
    assert_eq!(fm.id, published.id);
    assert_eq!(fm.title, "Round-Trip Post");
    assert_eq!(fm.slug, published.slug);
    assert_eq!(fm.status, "published");
    assert_eq!(fm.tags, vec!["rust", "testing"]);
    assert_eq!(fm.categories, vec!["tech"]);
    assert_eq!(fm.author.as_deref(), Some("Alice"));
    assert_eq!(fm.language.as_deref(), Some("en"));
    assert!(fm.published_at.is_some());
    assert_eq!(fm.published_at, published.published_at);
    assert_eq!(fm.created_at, published.created_at);

    // Verify body content matches
    assert_eq!(body, "This is the body of the post.");
}

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

    // Create post + publish
    let created = post::create_post(
        db.conn(),
        dir.path(),
        "p1",
        "Translated Post",
        Some("English body."),
        vec![],
        vec![],
        None,
        None,
        None,
    )
    .unwrap();

    // Create translation before publishing
    let _translation = post::upsert_translation(
        db.conn(),
        dir.path(),
        &created.id,
        "de",
        "Uebersetzter Beitrag",
        Some("Zusammenfassung"),
        Some("Deutscher Inhalt."),
    )
    .unwrap();

    // Publish (also publishes translations)
    let published = post::publish_post(db.conn(), dir.path(), &created.id).unwrap();

    // Find the translation file on disk
    let trans_rel_path = format!(
        "posts/{}/{}/{}.de.md",
        &published.file_path[6..10],  // YYYY
        &published.file_path[11..13], // MM
        published.slug
    );
    let trans_abs = dir.path().join(&trans_rel_path);
    assert!(trans_abs.exists(), "translation file must exist on disk");

    // Parse the translation file
    let content = fs::read_to_string(&trans_abs).unwrap();
    let (fm, body) = read_translation_file(&content).unwrap();

    // Verify fields
    assert_eq!(fm.translation_for, created.id);
    assert_eq!(fm.language, "de");
    assert_eq!(fm.title, "Uebersetzter Beitrag");
    assert_eq!(fm.excerpt.as_deref(), Some("Zusammenfassung"));
    assert_eq!(body, "Deutscher Inhalt.");
}

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

    // Create a test PNG
    let source_path = create_test_image(dir.path());

    // Import media
    let imported = media::import_media(
        db.conn(),
        dir.path(),
        "p1",
        &source_path,
        "test_photo.png",
        Some("My Photo Title"),
        Some("A test photo alt text"),
        Some("Caption text"),
        Some("Bob"),
        Some("de"),
        vec!["nature".into(), "macro".into()],
    )
    .unwrap();

    // Read sidecar from disk
    let abs_sidecar = dir.path().join(&imported.sidecar_path);
    assert!(abs_sidecar.exists(), "sidecar file must exist");
    let sidecar_content = fs::read_to_string(&abs_sidecar).unwrap();
    let sc = read_sidecar(&sidecar_content).unwrap();

    // Verify sidecar fields match DB
    assert_eq!(sc.id, imported.id);
    assert_eq!(sc.original_name, "test_photo.png");
    assert_eq!(sc.mime_type, "image/png");
    assert!(sc.size > 0);
    assert_eq!(sc.width, Some(10));
    assert_eq!(sc.height, Some(10));
    assert_eq!(sc.title.as_deref(), Some("My Photo Title"));
    assert_eq!(sc.alt.as_deref(), Some("A test photo alt text"));
    assert_eq!(sc.caption.as_deref(), Some("Caption text"));
    assert_eq!(sc.author.as_deref(), Some("Bob"));
    assert_eq!(sc.language.as_deref(), Some("de"));
    assert_eq!(sc.tags, vec!["nature", "macro"]);
    assert_eq!(sc.created_at, imported.created_at);
    assert_eq!(sc.updated_at, imported.updated_at);
}

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

    let source_path = create_test_image(dir.path());
    let imported = media::import_media(
        db.conn(),
        dir.path(),
        "p1",
        &source_path,
        "photo.png",
        Some("Title"),
        Some("Alt"),
        None,
        None,
        None,
        vec![],
    )
    .unwrap();

    // Create translation
    let _t = media::upsert_media_translation(
        db.conn(),
        dir.path(),
        &imported.id,
        "fr",
        Some("Titre francais"),
        Some("Texte alternatif"),
        Some("Legende"),
    )
    .unwrap();

    // Read the translation sidecar from disk
    let trans_sidecar_rel =
        bds_core::util::media_translation_sidecar_path(&imported.file_path, "fr");
    let abs_trans_sidecar = dir.path().join(&trans_sidecar_rel);
    assert!(abs_trans_sidecar.exists(), "translation sidecar must exist");

    let content = fs::read_to_string(&abs_trans_sidecar).unwrap();
    let sc = read_translation_sidecar(&content).unwrap();

    assert_eq!(sc.translation_for, imported.id);
    assert_eq!(sc.language, "fr");
    assert_eq!(sc.title.as_deref(), Some("Titre francais"));
    assert_eq!(sc.alt.as_deref(), Some("Texte alternatif"));
    assert_eq!(sc.caption.as_deref(), Some("Legende"));
}

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

    // Create tags via the engine
    tag::create_tag(db.conn(), dir.path(), "p1", "rust", Some("#ff0000")).unwrap();
    tag::create_tag(db.conn(), dir.path(), "p1", "go", None).unwrap();
    tag::create_tag(db.conn(), dir.path(), "p1", "python", Some("#3776ab")).unwrap();

    // Read tags.json from disk
    let entries = meta::read_tags_json(dir.path()).unwrap();

    // Tags should be sorted case-insensitively
    assert_eq!(entries.len(), 3);
    assert_eq!(entries[0].name, "go");
    assert_eq!(entries[1].name, "python");
    assert_eq!(entries[2].name, "rust");

    // Verify colors
    assert!(entries[0].color.is_none());
    assert_eq!(entries[1].color.as_deref(), Some("#3776ab"));
    assert_eq!(entries[2].color.as_deref(), Some("#ff0000"));
}

// ════════════════════════════════════════════════════════════════════
// Step 20: Legacy Fixture Compatibility and Canonical Reserialization
// ════════════════════════════════════════════════════════════════════

#[test]
fn test_legacy_post_fixture_reserializes_as_bds2_canonical_output() {
    let path = fixture_dir().join("posts/2005/11/esmeralda.md");
    let expected = fs::read_to_string(&path).unwrap();
    let (fm, body) = read_post_file(&expected).unwrap();

    // Verify parsed fixture data
    assert_eq!(fm.id, "40a83ab1-423d-4310-aac4-642d84675007");
    assert_eq!(fm.title, "Esmeralda");
    assert_eq!(fm.slug, "esmeralda");
    assert_eq!(fm.status, "published");
    assert_eq!(fm.language.as_deref(), Some("es"));
    assert_eq!(fm.created_at, 1131883200000);
    assert_eq!(fm.published_at, Some(1131883200000));
    assert_eq!(
        fm.tags,
        vec!["fotografie", "makro", "natur", "spinne", "tiere"]
    );
    assert_eq!(fm.categories, vec!["picture"]);

    // Write back via write_post_file, then reconstruct using from_post-style
    let yaml = fm.to_yaml();
    let actual = bds_core::util::frontmatter::format_frontmatter(&yaml, &body);

    assert!(actual.find("language: es").unwrap() < actual.find("createdAt:").unwrap());
    assert!(actual.find("publishedAt:").unwrap() < actual.find("tags:").unwrap());
    assert!(actual.ends_with('\n'));
}

#[test]
fn test_legacy_translation_fixture_matches_bds2_canonical_output() {
    let path = fixture_dir().join("posts/2005/11/esmeralda.en.md");
    let expected = fs::read_to_string(&path).unwrap();
    let (fm, body) = read_translation_file(&expected).unwrap();

    // Verify parsed data
    assert_eq!(fm.translation_for, "40a83ab1-423d-4310-aac4-642d84675007");
    assert_eq!(fm.language, "en");
    assert_eq!(fm.title, "Esmeralda");

    // Write back and compare byte-for-byte
    let yaml = fm.to_yaml();
    let actual = bds_core::util::frontmatter::format_frontmatter(&yaml, &body);
    assert_eq!(
        actual, expected,
        "golden output mismatch for esmeralda.en.md"
    );
}

#[test]
fn test_legacy_sidecar_fixture_reserializes_as_bds2_canonical_output() {
    let path = fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
    let expected = fs::read_to_string(&path).unwrap();
    let sc = read_sidecar(&expected).unwrap();

    // Verify parsed data
    assert_eq!(sc.id, "eb0cf9d7-6fbd-4b74-9be3-759d6e16f240");
    assert_eq!(sc.original_name, "CRW_1121.jpg");
    assert_eq!(sc.mime_type, "image/jpeg");
    assert_eq!(sc.size, 706358);
    assert_eq!(sc.width, Some(1800));
    assert_eq!(sc.height, Some(1200));
    assert_eq!(sc.title.as_deref(), Some("Esmeralda"));

    // Write back via to_string() in canonical bDS2 order.
    let actual = sc.to_string();
    assert!(actual.find("linkedPostIds:").unwrap() < actual.find("tags:").unwrap());
    assert!(actual.ends_with("---"));
}

#[test]
fn test_golden_meta_files() {
    let fd = fixture_dir();

    // project.json: parse and verify key fields
    let project_json = fs::read_to_string(fd.join("meta/project.json")).unwrap();
    let project_meta: bds_core::model::ProjectMetadata =
        serde_json::from_str(&project_json).unwrap();
    assert_eq!(project_meta.name, "rfc1437");
    assert_eq!(project_meta.main_language.as_deref(), Some("de"));
    assert_eq!(project_meta.default_author.as_deref(), Some("hugo"));
    assert_eq!(project_meta.max_posts_per_page, 50);
    assert_eq!(
        project_meta.public_url.as_deref(),
        Some("https://www.rfc1437.de")
    );
    assert!(project_meta.semantic_similarity_enabled);
    assert_eq!(project_meta.blog_languages, vec!["en"]);

    // categories.json: parse and verify sorted order
    let categories_json = fs::read_to_string(fd.join("meta/categories.json")).unwrap();
    let categories: Vec<String> = serde_json::from_str(&categories_json).unwrap();
    assert_eq!(
        categories,
        vec![
            "article",
            "aside",
            "kochbuch",
            "metaowl",
            "page",
            "picture",
            "spielelog",
            "wiki"
        ]
    );
    // Verify they are sorted
    let mut sorted = categories.clone();
    sorted.sort();
    assert_eq!(categories, sorted, "categories should be sorted");

    // tags.json: parse and verify structure
    let tags_json = fs::read_to_string(fd.join("meta/tags.json")).unwrap();
    let tags: Vec<bds_core::model::TagEntry> = serde_json::from_str(&tags_json).unwrap();
    assert!(!tags.is_empty());
    // Verify sorted by name
    let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
    let mut sorted_names = names.clone();
    sorted_names.sort_by_key(|a| a.to_lowercase());
    assert_eq!(names, sorted_names, "tags should be sorted by name");
    // Spot-check a known tag
    assert!(tags.iter().any(|t| t.name == "rust"));
    assert!(tags.iter().any(|t| t.name == "fotografie"));
}

#[test]
fn test_legacy_template_fixture_reserializes_as_bds2_canonical_output() {
    let path = fixture_dir().join("templates/testvorlage.liquid");
    let expected = fs::read_to_string(&path).unwrap();
    let (fm, body) = read_template_file(&expected).unwrap();

    // Verify parsed data
    assert_eq!(fm.id, "38704737-b7e7-4dd4-b010-9208bcf80ef6");
    assert_eq!(
        fm.project_id.as_deref(),
        Some("1979237c-034d-41f6-99a0-f35eb57b3f6c")
    );
    assert_eq!(fm.slug, "testvorlage");
    assert_eq!(fm.title, "Testvorlage");
    assert_eq!(fm.kind, "post");
    assert!(fm.enabled);
    assert_eq!(fm.version, 3);

    // Write back with bDS2 scalar and timestamp quoting.
    let actual = write_template_file(&fm, &body);
    assert!(actual.contains("id: 38704737-b7e7-4dd4-b010-9208bcf80ef6\n"));
    assert!(actual.contains("createdAt: '2026-02-27T20:33:00.000Z'"));
    assert!(actual.ends_with('\n'));
}

// ════════════════════════════════════════════════════════════════════
// Step 21: Metadata Diff Coverage Matrix
// ════════════════════════════════════════════════════════════════════

// ── Post diff: 13 fields ────────────────────────────────────────────

/// Helper: create a post, publish it, then modify a specific field in the
/// file on disk. Run the diff and return all detected field names.
fn post_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
    let (db, dir) = setup();

    let created = post::create_post(
        db.conn(),
        dir.path(),
        "p1",
        "Diff Test Post",
        Some("body text"),
        vec!["tag1".into()],
        vec!["cat1".into()],
        Some("Author"),
        Some("en"),
        Some("tpl-slug"),
    )
    .unwrap();

    let published = post::publish_post(db.conn(), dir.path(), &created.id).unwrap();
    let abs_path = dir.path().join(&published.file_path);
    let content = fs::read_to_string(&abs_path).unwrap();

    // Apply modification to file
    let modified = modify_fn(&content);
    fs::write(&abs_path, modified).unwrap();

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

    report
        .diffs
        .iter()
        .filter(|d| d.entity_type == "post")
        .flat_map(|d| d.fields.iter())
        .map(|f| f.field_name.clone())
        .collect()
}

#[test]
fn test_diff_detects_post_title() {
    let fields = post_diff_for_field(|content| {
        content.replace("title: Diff Test Post", "title: CHANGED Title")
    });
    assert!(
        fields.contains(&"title".to_string()),
        "expected 'title' in diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_post_slug() {
    let fields = post_diff_for_field(|content| {
        content.replace("slug: diff-test-post", "slug: changed-slug")
    });
    assert!(
        fields.contains(&"slug".to_string()),
        "expected 'slug' in diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_post_status() {
    let fields =
        post_diff_for_field(|content| content.replace("status: published", "status: draft"));
    assert!(
        fields.contains(&"status".to_string()),
        "expected 'status' in diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_post_tags() {
    let fields = post_diff_for_field(|content| content.replace("  - tag1", "  - changed-tag"));
    assert!(
        fields.contains(&"tags".to_string()),
        "expected 'tags' in diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_post_categories() {
    let fields = post_diff_for_field(|content| content.replace("  - cat1", "  - changed-cat"));
    assert!(
        fields.contains(&"categories".to_string()),
        "expected 'categories' in diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_post_excerpt() {
    // The post was created without excerpt. Add one in the file.
    let fields = post_diff_for_field(|content| {
        content.replace(
            "templateSlug: tpl-slug",
            "templateSlug: tpl-slug\nexcerpt: Added excerpt",
        )
    });
    assert!(
        fields.contains(&"excerpt".to_string()),
        "expected 'excerpt' in diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_post_author() {
    let fields = post_diff_for_field(|content| {
        content.replace("author: Author", "author: Different Author")
    });
    assert!(
        fields.contains(&"author".to_string()),
        "expected 'author' in diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_post_language() {
    let fields = post_diff_for_field(|content| content.replace("language: en", "language: de"));
    assert!(
        fields.contains(&"language".to_string()),
        "expected 'language' in diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_post_do_not_translate() {
    // Post was created with do_not_translate=false (omitted from file).
    // Add it to the file.
    let fields = post_diff_for_field(|content| {
        content.replace(
            "templateSlug: tpl-slug",
            "doNotTranslate: true\ntemplateSlug: tpl-slug",
        )
    });
    assert!(
        fields.contains(&"doNotTranslate".to_string()),
        "expected 'doNotTranslate' in diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_post_template_slug() {
    let fields = post_diff_for_field(|content| {
        content.replace("templateSlug: tpl-slug", "templateSlug: different-tpl")
    });
    assert!(
        fields.contains(&"templateSlug".to_string()),
        "expected 'templateSlug' in diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_post_created_at() {
    let fields = post_diff_for_field(|content| {
        // Replace the createdAt ISO timestamp with a different one
        // The fixture will have something like createdAt: '2026-...'
        // Replace with a very different date
        let mut result = content.to_string();
        if let Some(start) = result.find("createdAt: '") {
            let val_start = start + "createdAt: '".len();
            if let Some(end) = result[val_start..].find('\'') {
                let old_val = &result[val_start..val_start + end].to_string();
                result = result.replace(
                    &format!("createdAt: '{old_val}'"),
                    "createdAt: '2000-01-01T00:00:00.000Z'",
                );
            }
        }
        result
    });
    assert!(
        fields.contains(&"createdAt".to_string()),
        "expected 'createdAt' in diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_post_updated_at() {
    let fields = post_diff_for_field(|content| {
        let mut result = content.to_string();
        if let Some(start) = result.find("updatedAt: '") {
            let val_start = start + "updatedAt: '".len();
            if let Some(end) = result[val_start..].find('\'') {
                let old_val = &result[val_start..val_start + end].to_string();
                result = result.replace(
                    &format!("updatedAt: '{old_val}'"),
                    "updatedAt: '2000-01-01T00:00:00.000Z'",
                );
            }
        }
        result
    });
    assert!(
        fields.contains(&"updatedAt".to_string()),
        "expected 'updatedAt' in diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_post_published_at() {
    let fields = post_diff_for_field(|content| {
        let mut result = content.to_string();
        if let Some(start) = result.find("publishedAt: '") {
            let val_start = start + "publishedAt: '".len();
            if let Some(end) = result[val_start..].find('\'') {
                let old_val = &result[val_start..val_start + end].to_string();
                result = result.replace(
                    &format!("publishedAt: '{old_val}'"),
                    "publishedAt: '2000-01-01T00:00:00.000Z'",
                );
            }
        }
        result
    });
    assert!(
        fields.contains(&"publishedAt".to_string()),
        "expected 'publishedAt' in diff fields, got: {fields:?}"
    );
}

// ── Media diff: 6 fields ────────────────────────────────────────────

/// Helper: import media, then modify a field in the sidecar file.
/// Returns the list of diff field names detected.
fn media_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
    let (db, dir) = setup();

    let source_path = create_test_image(dir.path());
    let imported = media::import_media(
        db.conn(),
        dir.path(),
        "p1",
        &source_path,
        "photo.png",
        Some("Original Title"),
        Some("Original Alt"),
        Some("Original Caption"),
        Some("Original Author"),
        Some("en"),
        vec!["original-tag".into()],
    )
    .unwrap();

    let abs_sidecar = dir.path().join(&imported.sidecar_path);
    let content = fs::read_to_string(&abs_sidecar).unwrap();

    let modified = modify_fn(&content);
    fs::write(&abs_sidecar, modified).unwrap();

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

    report
        .diffs
        .iter()
        .filter(|d| d.entity_type == "media")
        .flat_map(|d| d.fields.iter())
        .map(|f| f.field_name.clone())
        .collect()
}

#[test]
fn test_diff_detects_media_title() {
    let fields = media_diff_for_field(|content| {
        content.replace("title: \"Original Title\"", "title: \"Changed Title\"")
    });
    assert!(
        fields.contains(&"title".to_string()),
        "expected 'title' in media diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_media_alt() {
    let fields = media_diff_for_field(|content| {
        content.replace("alt: \"Original Alt\"", "alt: \"Changed Alt\"")
    });
    assert!(
        fields.contains(&"alt".to_string()),
        "expected 'alt' in media diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_media_caption() {
    let fields = media_diff_for_field(|content| {
        content.replace(
            "caption: \"Original Caption\"",
            "caption: \"Changed Caption\"",
        )
    });
    assert!(
        fields.contains(&"caption".to_string()),
        "expected 'caption' in media diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_media_author() {
    let fields = media_diff_for_field(|content| {
        content.replace("author: \"Original Author\"", "author: \"Changed Author\"")
    });
    assert!(
        fields.contains(&"author".to_string()),
        "expected 'author' in media diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_media_tags() {
    let fields = media_diff_for_field(|content| {
        content.replace("tags: [\"original-tag\"]", "tags: [\"changed-tag\"]")
    });
    assert!(
        fields.contains(&"tags".to_string()),
        "expected 'tags' in media diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_media_language() {
    let fields = media_diff_for_field(|content| content.replace("language: en", "language: de"));
    assert!(
        fields.contains(&"language".to_string()),
        "expected 'language' in media diff fields, got: {fields:?}"
    );
}

// ── Template diff: 4 fields ─────────────────────────────────────────

/// Helper: insert a template in DB + write file, then modify a field in
/// the file. Returns the list of diff field names.
fn template_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
    let (db, dir) = setup();

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

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

    // Apply modification
    let modified = modify_fn(&file_content);
    fs::write(tpl_dir.join("diff-template.liquid"), modified).unwrap();

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

    report
        .diffs
        .iter()
        .filter(|d| d.entity_type == "template")
        .flat_map(|d| d.fields.iter())
        .map(|f| f.field_name.clone())
        .collect()
}

#[test]
fn test_diff_detects_template_title() {
    let fields = template_diff_for_field(|content| {
        content.replace("title: Diff Template", "title: Changed Template")
    });
    assert!(
        fields.contains(&"title".to_string()),
        "expected 'title' in template diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_template_kind() {
    let fields = template_diff_for_field(|content| content.replace("kind: post", "kind: list"));
    assert!(
        fields.contains(&"kind".to_string()),
        "expected 'kind' in template diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_template_enabled() {
    let fields =
        template_diff_for_field(|content| content.replace("enabled: true", "enabled: false"));
    assert!(
        fields.contains(&"enabled".to_string()),
        "expected 'enabled' in template diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_template_version() {
    let fields = template_diff_for_field(|content| content.replace("version: 1", "version: 99"));
    assert!(
        fields.contains(&"version".to_string()),
        "expected 'version' in template diff fields, got: {fields:?}"
    );
}

// ── Script diff: 5 fields ───────────────────────────────────────────

/// Helper: insert a script in DB + write file, then modify a field in the
/// file. Returns the list of diff field names.
fn script_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
    let (db, dir) = setup();

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

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

    // Apply modification
    let modified = modify_fn(&file_content);
    fs::write(scripts_dir.join("diff-script.lua"), modified).unwrap();

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

    report
        .diffs
        .iter()
        .filter(|d| d.entity_type == "script")
        .flat_map(|d| d.fields.iter())
        .map(|f| f.field_name.clone())
        .collect()
}

#[test]
fn test_diff_detects_script_title() {
    let fields = script_diff_for_field(|content| {
        content.replace("title: Diff Script", "title: Changed Script")
    });
    assert!(
        fields.contains(&"title".to_string()),
        "expected 'title' in script diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_script_kind() {
    let fields =
        script_diff_for_field(|content| content.replace("kind: utility", "kind: transform"));
    assert!(
        fields.contains(&"kind".to_string()),
        "expected 'kind' in script diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_script_entrypoint() {
    let fields =
        script_diff_for_field(|content| content.replace("entrypoint: main", "entrypoint: run"));
    assert!(
        fields.contains(&"entrypoint".to_string()),
        "expected 'entrypoint' in script diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_script_enabled() {
    let fields =
        script_diff_for_field(|content| content.replace("enabled: true", "enabled: false"));
    assert!(
        fields.contains(&"enabled".to_string()),
        "expected 'enabled' in script diff fields, got: {fields:?}"
    );
}

#[test]
fn test_diff_detects_script_version() {
    let fields = script_diff_for_field(|content| content.replace("version: 1", "version: 42"));
    assert!(
        fields.contains(&"version".to_string()),
        "expected 'version' in script diff fields, got: {fields:?}"
    );
}
