use std::path::Path;
use std::sync::Arc;

use crate::db::DbConnection as Connection;
use diesel::prelude::*;

use crate::db::fts;
use crate::engine::EngineResult;
use crate::engine::media;
use crate::engine::post;
use crate::engine::script_rebuild;
use crate::engine::template_rebuild;
use crate::i18n::{UiLocale, translate, translate_with};

/// Report from a full rebuild operation.
#[derive(Debug, Default)]
pub struct FullRebuildReport {
    pub posts_created: usize,
    pub posts_updated: usize,
    pub translations_created: usize,
    pub translations_updated: usize,
    pub media_created: usize,
    pub media_updated: usize,
    pub media_translations_created: usize,
    pub media_translations_updated: usize,
    pub templates_created: usize,
    pub templates_updated: usize,
    pub scripts_created: usize,
    pub scripts_updated: usize,
    pub thumbnails_generated: usize,
    pub thumbnail_media_failed: usize,
    pub errors: Vec<String>,
}

#[derive(Debug, Default, PartialEq, Eq)]
pub struct IncrementalRebuildReport {
    pub differences_applied: usize,
    pub orphans_imported: usize,
    pub orphans_failed: usize,
}

/// Apply portable filesystem changes to one project's cache database.
pub fn rebuild_incremental(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
) -> EngineResult<IncrementalRebuildReport> {
    rebuild_incremental_with_progress(conn, data_dir, project_id, |_| true)
}

pub fn rebuild_incremental_with_progress(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
    mut on_progress: impl FnMut(f32) -> bool,
) -> EngineResult<IncrementalRebuildReport> {
    let report = super::metadata_diff::compute_metadata_diff_with_progress(
        conn,
        data_dir,
        project_id,
        |current, total| on_progress(0.5 * current as f32 / total.max(1) as f32),
    )?;
    if !report.errors.is_empty() {
        return Err(crate::engine::EngineError::Validation(
            report.errors.join("; "),
        ));
    }
    let orphans = report
        .orphans
        .iter()
        .filter(|orphan| orphan.reason == "file_without_db_entry")
        .collect::<Vec<_>>();
    let total = report.diffs.len() + orphans.len();
    let mut current = 0;
    for item in &report.diffs {
        super::metadata_diff::repair_metadata_diff_item(
            conn,
            data_dir,
            project_id,
            super::metadata_diff::RepairDirection::FileToDatabase,
            item,
        )?;
        current += 1;
        if !on_progress(0.5 + 0.5 * current as f32 / total.max(1) as f32) {
            return Err(crate::engine::EngineError::Cancelled);
        }
    }
    let mut result = IncrementalRebuildReport {
        differences_applied: report.diffs.len(),
        ..Default::default()
    };
    for orphan in orphans {
        match super::metadata_diff::import_orphan_file(conn, data_dir, project_id, orphan) {
            Ok(()) => result.orphans_imported += 1,
            Err(_) => result.orphans_failed += 1,
        }
        current += 1;
        if !on_progress(0.5 + 0.5 * current as f32 / total.max(1) as f32) {
            return Err(crate::engine::EngineError::Cancelled);
        }
    }
    if total == 0 && !on_progress(1.0) {
        return Err(crate::engine::EngineError::Cancelled);
    }
    Ok(result)
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RebuildProgress {
    LoadingProjectMetadata,
    ScanningPosts,
    PostItem {
        current: usize,
        total: usize,
        name: String,
    },
    ScanningMedia,
    MediaItem {
        current: usize,
        total: usize,
        name: String,
    },
    RebuildingTemplates,
    TemplateItem {
        current: usize,
        total: usize,
        name: String,
    },
    RebuildingScripts,
    ScriptItem {
        current: usize,
        total: usize,
        name: String,
    },
    ImportingTags,
    RebuildingThumbnails,
    RefreshingSemanticIndex,
    Complete,
}

impl RebuildProgress {
    pub fn localized(&self, locale: UiLocale) -> String {
        match self {
            Self::LoadingProjectMetadata => {
                translate(locale, "engine.progress.loadingProjectMetadata")
            }
            Self::ScanningPosts => translate(locale, "engine.progress.scanningPosts"),
            Self::PostItem {
                current,
                total,
                name,
            } => localize_item(locale, "engine.progress.postItem", *current, *total, name),
            Self::ScanningMedia => translate(locale, "engine.progress.scanningMedia"),
            Self::MediaItem {
                current,
                total,
                name,
            } => localize_item(locale, "engine.progress.mediaItem", *current, *total, name),
            Self::RebuildingTemplates => translate(locale, "engine.progress.rebuildingTemplates"),
            Self::TemplateItem {
                current,
                total,
                name,
            } => localize_item(locale, "engine.checkingItem", *current, *total, name),
            Self::RebuildingScripts => translate(locale, "engine.progress.rebuildingScripts"),
            Self::ScriptItem {
                current,
                total,
                name,
            } => localize_item(locale, "engine.checkingItem", *current, *total, name),
            Self::ImportingTags => translate(locale, "engine.progress.importingTags"),
            Self::RebuildingThumbnails => translate(locale, "engine.progress.rebuildingThumbnails"),
            Self::RefreshingSemanticIndex => {
                translate(locale, "engine.progress.refreshingSemanticIndex")
            }
            Self::Complete => translate(locale, "engine.progress.rebuildComplete"),
        }
    }
}

fn localize_item(locale: UiLocale, key: &str, current: usize, total: usize, name: &str) -> String {
    let current = current.to_string();
    let total = total.to_string();
    translate_with(
        locale,
        key,
        &[("current", &current), ("total", &total), ("name", name)],
    )
}

/// Progress callback: (percent 0.0..1.0, semantic progress event).
pub type ProgressFn = Arc<dyn Fn(f32, &RebuildProgress) -> bool + Send + Sync>;

/// Orchestrate a full rebuild from filesystem into the database.
///
/// Replaces the project-scoped database state with project metadata, content,
/// and relationships reconstructed from the filesystem.
pub fn rebuild_from_filesystem(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
) -> EngineResult<FullRebuildReport> {
    rebuild_from_filesystem_with_progress(conn, data_dir, project_id, None)
}

/// Like `rebuild_from_filesystem` but accepts an optional progress callback.
pub fn rebuild_from_filesystem_with_progress(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
    on_progress: Option<ProgressFn>,
) -> EngineResult<FullRebuildReport> {
    conn.begin_savepoint()?;
    match rebuild_from_filesystem_inner(conn, data_dir, project_id, on_progress) {
        Ok(report) => {
            conn.release_savepoint()?;
            Ok(report)
        }
        Err(error) => {
            let _ = conn.rollback_savepoint();
            Err(error)
        }
    }
}

fn rebuild_from_filesystem_inner(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
    on_progress: Option<ProgressFn>,
) -> EngineResult<FullRebuildReport> {
    let mut report = FullRebuildReport::default();
    let progress = |pct: f32, event: RebuildProgress| -> EngineResult<()> {
        if let Some(ref f) = on_progress
            && !f(pct, &event)
        {
            return Err(crate::engine::EngineError::Cancelled);
        }
        Ok(())
    };

    // Phase weights: posts 0.0..0.35, media 0.35..0.70, templates 0.70..0.85, scripts 0.85..1.0

    // 1. Load portable project metadata and clear all reconstructible rows.
    progress(0.0, RebuildProgress::LoadingProjectMetadata)?;
    fts::ensure_fts_tables(conn)?;
    crate::engine::meta::startup_sync(data_dir)?;
    crate::engine::meta::sync_metadata_from_filesystem(conn, data_dir, project_id)?;
    clear_project_rows(conn, project_id)?;

    // 2. Rebuild posts  (0.00 .. 0.35)
    progress(0.01, RebuildProgress::ScanningPosts)?;
    let post_item_cb: Option<post::ItemProgressFn> = on_progress.as_ref().map(|cb| {
        let cb = Arc::clone(cb);
        let f: post::ItemProgressFn = Box::new(move |current, total, name| {
            let phase_pct = if total > 0 {
                current as f32 / total as f32
            } else {
                1.0
            };
            let global_pct = 0.01 + phase_pct * 0.34;
            cb(
                global_pct,
                &RebuildProgress::PostItem {
                    current,
                    total,
                    name: name.to_string(),
                },
            )
        });
        f
    });
    let post_report = post::rebuild_posts_from_filesystem_with_progress(
        conn,
        data_dir,
        project_id,
        post_item_cb,
    )?;
    report.posts_created = post_report.posts_created;
    report.posts_updated = post_report.posts_updated;
    report.translations_created = post_report.translations_created;
    report.translations_updated = post_report.translations_updated;
    report.errors.extend(post_report.errors);

    // 3. Rebuild media  (0.35 .. 0.70)
    progress(0.35, RebuildProgress::ScanningMedia)?;
    let media_item_cb: Option<media::ItemProgressFn> = on_progress.as_ref().map(|cb| {
        let cb = Arc::clone(cb);
        let f: media::ItemProgressFn = Box::new(move |current, total, name| {
            let phase_pct = if total > 0 {
                current as f32 / total as f32
            } else {
                1.0
            };
            let global_pct = 0.35 + phase_pct * 0.35;
            cb(
                global_pct,
                &RebuildProgress::MediaItem {
                    current,
                    total,
                    name: name.to_string(),
                },
            )
        });
        f
    });
    let media_report = media::rebuild_media_from_filesystem_with_progress(
        conn,
        data_dir,
        project_id,
        media_item_cb,
    )?;
    report.media_created = media_report.media_created;
    report.media_updated = media_report.media_updated;
    report.media_translations_created = media_report.translations_created;
    report.media_translations_updated = media_report.translations_updated;
    report.errors.extend(media_report.errors);

    // 4. Rebuild templates  (0.70 .. 0.85)
    progress(0.70, RebuildProgress::RebuildingTemplates)?;
    let template_progress = on_progress.as_ref().map(|callback| {
        let callback = Arc::clone(callback);
        Box::new(move |current: usize, total: usize, name: &str| {
            callback(
                0.70 + current as f32 / total.max(1) as f32 * 0.15,
                &RebuildProgress::TemplateItem {
                    current,
                    total,
                    name: name.to_string(),
                },
            )
        }) as template_rebuild::ItemProgressFn
    });
    let tpl_report = template_rebuild::rebuild_templates_from_filesystem_with_progress(
        conn,
        data_dir,
        project_id,
        template_progress,
    )?;
    report.templates_created = tpl_report.created;
    report.templates_updated = tpl_report.updated;
    report.errors.extend(tpl_report.errors);

    // 5. Rebuild scripts  (0.85 .. 0.95)
    progress(0.85, RebuildProgress::RebuildingScripts)?;
    let script_progress = on_progress.as_ref().map(|callback| {
        let callback = Arc::clone(callback);
        Box::new(move |current: usize, total: usize, name: &str| {
            callback(
                0.85 + current as f32 / total.max(1) as f32 * 0.10,
                &RebuildProgress::ScriptItem {
                    current,
                    total,
                    name: name.to_string(),
                },
            )
        }) as script_rebuild::ItemProgressFn
    });
    let script_report = script_rebuild::rebuild_scripts_from_filesystem_with_progress(
        conn,
        data_dir,
        project_id,
        script_progress,
    )?;
    report.scripts_created = script_report.created;
    report.scripts_updated = script_report.updated;
    report.errors.extend(script_report.errors);

    // 6. Restore relationships and tags (0.95 .. 1.0)
    progress(0.95, RebuildProgress::ImportingTags)?;
    super::tag::import_tags_from_file(conn, data_dir, project_id)?;
    super::tag::sync_tags_from_posts(conn, project_id)?;
    let link_progress = on_progress.as_ref().map(|callback| {
        let callback = Arc::clone(callback);
        Box::new(move |current: usize, total: usize, name: &str| {
            callback(
                0.95 + current as f32 / total.max(1) as f32 * 0.01,
                &RebuildProgress::PostItem {
                    current,
                    total,
                    name: name.to_string(),
                },
            )
        }) as post::ItemProgressFn
    });
    post::rebuild_all_links_with_progress(conn, data_dir, project_id, link_progress)?;
    let media_link_progress = on_progress.as_ref().map(|callback| {
        let callback = Arc::clone(callback);
        Box::new(move |current: usize, total: usize, name: &str| {
            callback(
                0.96 + current as f32 / total.max(1) as f32 * 0.01,
                &RebuildProgress::MediaItem {
                    current,
                    total,
                    name: name.to_string(),
                },
            )
        }) as media::ItemProgressFn
    });
    media::rebuild_media_links_with_progress(conn, data_dir, project_id, media_link_progress)?;

    if !report.errors.is_empty() {
        return Err(crate::engine::EngineError::Validation(format!(
            "rebuild failed: {}",
            report.errors.join("; ")
        )));
    }

    progress(0.97, RebuildProgress::RebuildingThumbnails)?;
    let thumbnail_report = media::regenerate_missing_thumbnails_with_progress(
        conn,
        data_dir,
        project_id,
        |current, total, name| {
            on_progress.as_ref().is_none_or(|callback| {
                callback(
                    0.97 + current as f32 / total.max(1) as f32 * 0.02,
                    &RebuildProgress::MediaItem {
                        current,
                        total,
                        name: name.to_string(),
                    },
                )
            })
        },
    )?;
    report.thumbnails_generated = thumbnail_report.thumbnails_generated;
    report.thumbnail_media_failed = thumbnail_report.media_failed;

    progress(0.99, RebuildProgress::RefreshingSemanticIndex)?;
    crate::engine::embedding::EmbeddingService::production(conn, data_dir)
        .index_unindexed_with_progress(project_id, |current, total| {
            on_progress.as_ref().is_none_or(|callback| {
                callback(
                    0.99 + current as f32 / total.max(1) as f32 * 0.01,
                    &RebuildProgress::RefreshingSemanticIndex,
                )
            })
        })?;

    progress(1.0, RebuildProgress::Complete)?;
    Ok(report)
}

fn clear_project_rows(conn: &Connection, project_id: &str) -> EngineResult<()> {
    use crate::db::schema::{
        generated_file_hashes, import_definitions, media, media_translations, post_links,
        post_media, post_translations, posts, scripts, tags, templates,
    };

    let post_ids = crate::db::queries::post::list_posts_by_project(conn, project_id)?
        .into_iter()
        .map(|post| post.id)
        .collect::<Vec<_>>();
    let media_ids = crate::db::queries::media::list_media_by_project(conn, project_id)?
        .into_iter()
        .map(|media| media.id)
        .collect::<Vec<_>>();
    for id in &post_ids {
        fts::remove_post_from_index(conn, id)?;
    }
    for id in &media_ids {
        fts::remove_media_from_index(conn, id)?;
    }

    conn.with(|connection| {
        diesel::delete(
            post_links::table.filter(
                post_links::source_post_id
                    .eq_any(&post_ids)
                    .or(post_links::target_post_id.eq_any(&post_ids)),
            ),
        )
        .execute(connection)?;
        diesel::delete(post_media::table.filter(post_media::project_id.eq(project_id)))
            .execute(connection)?;
        diesel::delete(
            post_translations::table.filter(post_translations::project_id.eq(project_id)),
        )
        .execute(connection)?;
        diesel::delete(
            media_translations::table.filter(media_translations::project_id.eq(project_id)),
        )
        .execute(connection)?;
        diesel::delete(
            generated_file_hashes::table.filter(generated_file_hashes::project_id.eq(project_id)),
        )
        .execute(connection)?;
        diesel::delete(
            import_definitions::table.filter(import_definitions::project_id.eq(project_id)),
        )
        .execute(connection)?;
        diesel::delete(tags::table.filter(tags::project_id.eq(project_id))).execute(connection)?;
        diesel::delete(scripts::table.filter(scripts::project_id.eq(project_id)))
            .execute(connection)?;
        diesel::delete(templates::table.filter(templates::project_id.eq(project_id)))
            .execute(connection)?;
        diesel::delete(media::table.filter(media::project_id.eq(project_id)))
            .execute(connection)?;
        diesel::delete(posts::table.filter(posts::project_id.eq(project_id)))
            .execute(connection)?;
        Ok(())
    })?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::db::Database;
    use crate::db::queries::media as qm;
    use crate::db::queries::post as qp;
    use crate::db::queries::project::{insert_project, make_test_project};
    use crate::db::queries::script as qs;
    use crate::db::queries::tag as qtag;
    use crate::db::queries::template as qtpl;
    use crate::model::metadata::ProjectMetadata;
    use crate::model::{
        Script, ScriptKind, ScriptStatus, Tag, Template, TemplateKind, TemplateStatus,
    };
    use std::fs;
    use tempfile::TempDir;

    #[test]
    fn progress_events_use_the_selected_ui_locale() {
        assert_eq!(
            RebuildProgress::LoadingProjectMetadata.localized(UiLocale::It),
            "Caricamento dei metadati del progetto…"
        );
        assert_eq!(
            RebuildProgress::PostItem {
                current: 2,
                total: 4,
                name: "Bonjour".into(),
            }
            .localized(UiLocale::Fr),
            "Articles : 2/4 — Bonjour"
        );
        assert_eq!(
            RebuildProgress::Complete.localized(UiLocale::De),
            "Neuaufbau abgeschlossen"
        );
    }

    fn setup() -> (Database, TempDir) {
        let db = Database::open_in_memory().unwrap();
        db.migrate().unwrap();
        fts::ensure_fts_tables(db.conn()).unwrap();
        insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
        let dir = TempDir::new().unwrap();
        crate::engine::meta::write_project_json(
            dir.path(),
            &ProjectMetadata {
                name: "Project p1".into(),
                description: Some("A test project".into()),
                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();
        (db, dir)
    }

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

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

        assert_eq!(report.posts_created, 0);
        assert_eq!(report.posts_updated, 0);
        assert_eq!(report.translations_created, 0);
        assert_eq!(report.translations_updated, 0);
        assert_eq!(report.media_created, 0);
        assert_eq!(report.media_updated, 0);
        assert_eq!(report.media_translations_created, 0);
        assert_eq!(report.media_translations_updated, 0);
        assert_eq!(report.templates_created, 0);
        assert_eq!(report.templates_updated, 0);
        assert_eq!(report.scripts_created, 0);
        assert_eq!(report.scripts_updated, 0);
        assert!(report.errors.is_empty());
    }

    #[test]
    fn cancellation_rolls_back_the_full_rebuild_savepoint() {
        let (db, dir) = setup();
        let post = crate::engine::post::create_post(
            db.conn(),
            dir.path(),
            "p1",
            "Preserved",
            Some("body"),
            vec![],
            vec![],
            None,
            Some("en"),
            None,
        )
        .unwrap();

        let result = rebuild_from_filesystem_with_progress(
            db.conn(),
            dir.path(),
            "p1",
            Some(Arc::new(|_, event| {
                !matches!(event, RebuildProgress::ScanningPosts)
            })),
        );

        assert!(matches!(result, Err(crate::engine::EngineError::Cancelled)));
        assert!(qp::get_post_by_id(db.conn(), &post.id).is_ok());
    }

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

        // Write a post fixture
        let posts_dir = dir.path().join("posts").join("2024").join("01");
        fs::create_dir_all(&posts_dir).unwrap();

        let post_content = "\
---
id: test-post-1
title: Test Post
slug: test-post
status: published
createdAt: '2024-01-15T12:00:00.000Z'
updatedAt: '2024-01-15T12:00:00.000Z'
tags:
  - test
categories: []
publishedAt: '2024-01-15T12:00:00.000Z'
---
Hello from rebuild test!
";
        fs::write(posts_dir.join("test-post.md"), post_content).unwrap();

        // Write a media fixture: sidecar + dummy binary
        let media_dir = dir.path().join("media").join("2024").join("01");
        fs::create_dir_all(&media_dir).unwrap();

        let media_file = media_dir.join("test-media-1.jpg");
        fs::write(&media_file, b"fake-jpeg-data").unwrap();

        let sidecar_content = "\
---
id: test-media-1
originalName: \"photo.jpg\"
mimeType: image/jpeg
size: 12345
width: 800
height: 600
createdAt: 2024-01-15T12:00:00.000Z
updatedAt: 2024-01-15T12:00:00.000Z
tags: []
---
";
        fs::write(media_dir.join("test-media-1.jpg.meta"), sidecar_content).unwrap();

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

        assert_eq!(report.posts_created, 1);
        assert_eq!(report.media_created, 1);
        assert!(report.errors.is_empty(), "errors: {:?}", report.errors);

        // Verify post in DB
        let post = qp::get_post_by_id(db.conn(), "test-post-1").unwrap();
        assert_eq!(post.title, "Test Post");
        assert_eq!(post.slug, "test-post");

        // Verify media in DB
        let m = qm::get_media_by_id(db.conn(), "test-media-1").unwrap();
        assert_eq!(m.original_name, "photo.jpg");
    }

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

        crate::engine::meta::write_project_json(
            dir.path(),
            &ProjectMetadata {
                name: "Rebuilt Project".into(),
                description: None,
                public_url: None,
                main_language: Some("en".into()),
                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!["en".into()],
            },
        )
        .unwrap();

        qs::insert_script(
            db.conn(),
            &Script {
                id: "stale-script".into(),
                project_id: "p1".into(),
                slug: "stale-script".into(),
                title: "Stale Script".into(),
                kind: ScriptKind::Utility,
                entrypoint: "main".into(),
                enabled: true,
                version: 1,
                file_path: "scripts/stale.lua".into(),
                status: ScriptStatus::Published,
                content: None,
                created_at: 1,
                updated_at: 1,
            },
        )
        .unwrap();
        qtpl::insert_template(
            db.conn(),
            &Template {
                id: "stale-template".into(),
                project_id: "p1".into(),
                slug: "stale-template".into(),
                title: "Stale Template".into(),
                kind: TemplateKind::Post,
                enabled: true,
                version: 1,
                file_path: "templates/stale.liquid".into(),
                status: TemplateStatus::Published,
                content: None,
                created_at: 1,
                updated_at: 1,
            },
        )
        .unwrap();
        qp::insert_post(
            db.conn(),
            &qp::make_test_post("stale-post", "p1", "stale-post"),
        )
        .unwrap();
        qm::insert_media(db.conn(), &qm::make_test_media("stale-media", "p1")).unwrap();
        qtag::insert_tag(
            db.conn(),
            &Tag {
                id: "stale-tag".into(),
                project_id: "p1".into(),
                name: "stale-tag".into(),
                color: None,
                post_template_slug: None,
                created_at: 1,
                updated_at: 1,
            },
        )
        .unwrap();
        db.conn()
            .with(|connection| {
                use crate::db::schema::import_definitions;
                diesel::insert_into(import_definitions::table)
                    .values((
                        import_definitions::id.eq("stale-import"),
                        import_definitions::project_id.eq("p1"),
                        import_definitions::name.eq("Stale Import"),
                        import_definitions::created_at.eq(1_i64),
                        import_definitions::updated_at.eq(1_i64),
                    ))
                    .execute(connection)?;
                Ok(())
            })
            .unwrap();

        // Post
        let posts_dir = dir.path().join("posts").join("2024").join("01");
        fs::create_dir_all(&posts_dir).unwrap();
        let post_content = "\
---
id: test-post-1
title: Test Post
slug: test-post
status: published
createdAt: '2024-01-15T12:00:00.000Z'
updatedAt: '2024-01-15T12:00:00.000Z'
tags:
  - test
categories: []
publishedAt: '2024-01-15T12:00:00.000Z'
---
Hello from rebuild test!
";
        fs::write(posts_dir.join("test-post.md"), post_content).unwrap();

        // Media
        let media_dir = dir.path().join("media").join("2024").join("01");
        fs::create_dir_all(&media_dir).unwrap();
        fs::write(media_dir.join("test-media-1.jpg"), b"fake-jpeg").unwrap();
        let sidecar_content = "\
---
id: test-media-1
originalName: \"photo.jpg\"
mimeType: image/jpeg
size: 12345
width: 800
height: 600
createdAt: 2024-01-15T12:00:00.000Z
updatedAt: 2024-01-15T12:00:00.000Z
tags: []
linkedPostIds: [\"test-post-1\"]
---
";
        fs::write(media_dir.join("test-media-1.jpg.meta"), sidecar_content).unwrap();

        // Template
        let tpl_dir = dir.path().join("templates");
        fs::create_dir_all(&tpl_dir).unwrap();
        let tpl_content = "\
---
id: \"test-tpl-1\"
slug: \"test-template\"
title: \"Test Template\"
kind: \"post\"
enabled: true
version: 1
createdAt: \"2024-01-15T12:00:00.000Z\"
updatedAt: \"2024-01-15T12:00:00.000Z\"
---
<html>{{ content }}</html>
";
        fs::write(tpl_dir.join("test-template.liquid"), tpl_content).unwrap();

        // Script
        let scripts_dir = dir.path().join("scripts");
        fs::create_dir_all(&scripts_dir).unwrap();
        let script_content = "\
---
id: \"test-script-1\"
slug: \"test-script\"
title: \"Test Script\"
kind: \"macro\"
entrypoint: \"render\"
enabled: true
version: 1
createdAt: \"2024-01-15T12:00:00.000Z\"
updatedAt: \"2024-01-15T12:00:00.000Z\"
---
function render() end
";
        fs::write(scripts_dir.join("test-script.lua"), script_content).unwrap();
        fs::write(scripts_dir.join("legacy.py"), "print('ignore me')").unwrap();

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

        assert_eq!(report.posts_created, 1);
        assert_eq!(report.media_created, 1);
        assert_eq!(report.templates_created, 1);
        assert_eq!(report.scripts_created, 1);
        assert!(report.errors.is_empty(), "errors: {:?}", report.errors);

        // Verify all entities in DB
        let post = qp::get_post_by_id(db.conn(), "test-post-1").unwrap();
        assert_eq!(post.title, "Test Post");

        let m = qm::get_media_by_id(db.conn(), "test-media-1").unwrap();
        assert_eq!(m.original_name, "photo.jpg");

        let tpl = qtpl::get_template_by_id(db.conn(), "test-tpl-1").unwrap();
        assert_eq!(tpl.title, "Test Template");

        let script = qs::get_script_by_id(db.conn(), "test-script-1").unwrap();
        assert_eq!(script.title, "Test Script");
        assert!(qs::get_script_by_id(db.conn(), "stale-script").is_err());
        assert!(qtpl::get_template_by_id(db.conn(), "stale-template").is_err());
        assert!(qp::get_post_by_id(db.conn(), "stale-post").is_err());
        assert!(qm::get_media_by_id(db.conn(), "stale-media").is_err());
        assert!(qtag::get_tag_by_id(db.conn(), "stale-tag").is_err());
        let import_count = db
            .conn()
            .with(|connection| {
                use crate::db::schema::import_definitions::dsl::*;
                import_definitions
                    .filter(project_id.eq("p1"))
                    .count()
                    .get_result::<i64>(connection)
            })
            .unwrap();
        assert_eq!(import_count, 0);
        assert_eq!(
            qs::list_scripts_by_project(db.conn(), "p1").unwrap().len(),
            1
        );

        let project = crate::db::queries::project::get_project_by_id(db.conn(), "p1").unwrap();
        assert_eq!(project.name, "Rebuilt Project");
        assert!(project.description.is_none());
        assert_eq!(
            crate::engine::meta::read_categories_snapshot(db.conn(), "p1")
                .unwrap()
                .unwrap(),
            vec!["article", "aside", "page", "picture"]
        );
        assert_eq!(
            crate::engine::meta::read_publishing_snapshot(db.conn(), "p1")
                .unwrap()
                .unwrap(),
            crate::model::PublishingPreferences::default()
        );

        let links =
            crate::db::queries::post_media::list_post_media_by_media(db.conn(), "test-media-1")
                .unwrap();
        assert_eq!(links.len(), 1);
        assert_eq!(links[0].post_id, "test-post-1");
    }

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

        // Write one of each entity type
        let posts_dir = dir.path().join("posts").join("2024").join("01");
        fs::create_dir_all(&posts_dir).unwrap();
        let post_content = "\
---
id: idem-post
title: Idempotent Post
slug: idem-post
status: draft
createdAt: '2024-01-15T12:00:00.000Z'
updatedAt: '2024-01-15T12:00:00.000Z'
tags: []
categories: []
---
Body text.
";
        fs::write(posts_dir.join("idem-post.md"), post_content).unwrap();

        let tpl_dir = dir.path().join("templates");
        fs::create_dir_all(&tpl_dir).unwrap();
        let tpl_content = "\
---
id: \"idem-tpl\"
slug: \"idem-tpl\"
title: \"Idempotent Template\"
kind: \"post\"
enabled: true
version: 1
createdAt: \"2024-01-15T12:00:00.000Z\"
updatedAt: \"2024-01-15T12:00:00.000Z\"
---
<html>{{ content }}</html>
";
        fs::write(tpl_dir.join("idem-tpl.liquid"), tpl_content).unwrap();

        // First rebuild
        let r1 = rebuild_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
        assert_eq!(r1.posts_created, 1);
        assert_eq!(r1.posts_updated, 0);
        assert_eq!(r1.templates_created, 1);
        assert_eq!(r1.templates_updated, 0);
        assert!(r1.errors.is_empty(), "errors: {:?}", r1.errors);

        // A full rebuild clears the project rows before importing them again.
        let r2 = rebuild_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
        assert_eq!(r2.posts_created, 1);
        assert_eq!(r2.posts_updated, 0);
        assert_eq!(r2.templates_created, 1);
        assert_eq!(r2.templates_updated, 0);
        assert!(r2.errors.is_empty(), "errors: {:?}", r2.errors);
    }

    #[test]
    fn failed_rebuild_rolls_back_truncation() {
        let (db, dir) = setup();
        qp::insert_post(db.conn(), &qp::make_test_post("keep-me", "p1", "keep-me")).unwrap();
        let posts_dir = dir.path().join("posts");
        fs::create_dir_all(&posts_dir).unwrap();
        fs::write(posts_dir.join("broken.md"), "not frontmatter").unwrap();

        assert!(rebuild_from_filesystem(db.conn(), dir.path(), "p1").is_err());
        assert!(qp::get_post_by_id(db.conn(), "keep-me").is_ok());
    }
}
