//! Migration framework scaffold (Iteration 6, ADR-0015 §9 /
//! requirement F3).
//!
//! The shape lands in v1 even though no migrator is
//! registered: the cost is small, the wiring is non-trivial,
//! and shipping the framework now lets the *first* real
//! migrator (v1 → v2, when that lands) be a tightly scoped
//! one-file change rather than "design migrations + write a
//! migrator + integrate."
//!
//! Public surface:
//!
//! - [`MigratorRegistry`] — ordered list of `MigrateFn`s, one
//!   per source version. Tests inject their own registries; the
//!   production-default registry is empty.
//! - [`migrate_to_latest`] — given a YAML body and a registry,
//!   detect the source version, run each migrator in
//!   sequence, and return the upgraded body. Writes the
//!   pre-migration body to `project.yaml.v<N>.bak` inside the
//!   project (a recovery aid; the .gitignore template excludes
//!   `project.yaml.v*.bak` so backups don't leak into git).
//!
//! What this does NOT do:
//!
//! - It does not write `project.yaml` itself. The runtime is
//!   responsible for atomically writing the upgraded body back.
//!   Keeping the migration step separate from the write step
//!   makes the order of operations explicit at the call site
//!   and trivially testable: pass in a body, get back a body.
//! - It does not parse anything beyond the leading `version:`
//!   line. Full schema parsing is `yaml::parse_schema`'s job
//!   and runs *after* migration so the parser only ever sees
//!   the latest format.

use std::path::Path;

use serde::Deserialize;

/// A pure migrator: takes a YAML body at version `N` and
/// returns the same project at version `N + 1`.
///
/// Migrators must not perform I/O. The framework is
/// responsible for the .bak copy and the write-back; the
/// migrator's job is purely the format transformation.
pub type MigrateFn = fn(&str) -> Result<String, MigrateError>;

/// The newest `project.yaml` format version this build writes and reads.
///
/// Must equal `MigratorRegistry::production().latest_version()` (asserted
/// in tests) and is what the YAML serializer stamps and the parser
/// accepts. Bumped 1 → 2 by **ADR-0005 Amendment 2** (drop `blob`).
pub const CURRENT_SCHEMA_VERSION: u32 = 2;

/// v1 → v2 migrator (ADR-0005 Amendment 2). `blob` was dropped from the
/// type vocabulary, so any `type: blob` column is rewritten to
/// `type: text` (the chosen conversion target — see the ADR). A pure,
/// line-oriented text transform: only column-definition lines (the
/// `- { name: …, type: … }` shape the serializer emits) are touched, so a
/// `blob` substring inside a CHECK expression or a default value is left
/// alone. Also bumps the `version:` field to 2 (the framework asserts the
/// advertised version, so this must stay in step).
fn migrate_v1_to_v2(body: &str) -> Result<String, MigrateError> {
    let mut out = String::with_capacity(body.len() + 1);
    for line in body.split_inclusive('\n') {
        if line.trim_end() == "version: 1" {
            out.push_str(&line.replacen("version: 1", "version: 2", 1));
        } else if line.contains("{ name:") {
            out.push_str(&line.replace(", type: blob", ", type: text"));
        } else {
            out.push_str(line);
        }
    }
    Ok(out)
}

/// Ordered list of migrators. `migrators[i]` runs from
/// version `i + 1` to version `i + 2` (so index 0 is v1→v2,
/// index 1 is v2→v3, etc.).
///
/// `latest_version()` is `1 + migrators.len()`. In v1 the
/// list is empty and `latest_version()` is `1`.
#[derive(Debug, Clone)]
pub struct MigratorRegistry {
    pub migrators: Vec<MigrateFn>,
}

impl MigratorRegistry {
    /// Production-default registry. Migrators are listed in
    /// source-version order (index 0 = v1→v2, …).
    #[must_use]
    pub fn production() -> Self {
        Self {
            migrators: vec![migrate_v1_to_v2],
        }
    }

    /// The newest schema version this build understands.
    #[must_use]
    pub const fn latest_version(&self) -> u32 {
        1u32.saturating_add(self.migrators.len() as u32)
    }
}

impl Default for MigratorRegistry {
    fn default() -> Self {
        Self::production()
    }
}

#[derive(Debug)]
pub enum MigrateError {
    VersionParse(String),
    NewerThanSupported {
        file: u32,
        latest: u32,
    },
    NoMigratorForVersion(u32),
    StepFailed {
        from: u32,
        to: u32,
        source: Box<Self>,
    },
    BadOutput(String),
    Io {
        path: std::path::PathBuf,
        source: std::io::Error,
    },
}

impl std::fmt::Display for MigrateError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::VersionParse(detail) => f.write_str(&crate::t!(
                "persistence.migrate.version_parse",
                detail = detail,
            )),
            Self::NewerThanSupported { file, latest } => f.write_str(&crate::t!(
                "persistence.migrate.newer_than_supported",
                file = file,
                latest = latest,
            )),
            Self::NoMigratorForVersion(v) => {
                f.write_str(&crate::t!("persistence.migrate.no_migrator", version = v,))
            }
            Self::StepFailed { from, to, source } => f.write_str(&crate::t!(
                "persistence.migrate.step_failed",
                from = from,
                to = to,
                source = source,
            )),
            Self::BadOutput(detail) => f.write_str(&crate::t!(
                "persistence.migrate.bad_output",
                detail = detail,
            )),
            Self::Io { path, source } => f.write_str(&crate::t!(
                "persistence.migrate.io",
                path = path.display(),
                source = source,
            )),
        }
    }
}

impl std::error::Error for MigrateError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::StepFailed { source, .. } => Some(source.as_ref()),
            Self::Io { source, .. } => Some(source),
            _ => None,
        }
    }
}

/// Result of running [`migrate_to_latest`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigrationOutcome {
    /// The upgraded body. When no migration was needed this
    /// is identical to the input body.
    pub body: String,
    /// Source version found in the input. `None` if the
    /// input parsed but its version equals `latest_version`
    /// (no migration ran); `Some(N)` if a migration ran from
    /// `N` to `latest_version`.
    pub migrated_from: Option<u32>,
}

/// Whether `body` declares at least one `type: blob` column.
///
/// The runtime uses this as the signal to force a `.db` rebuild after the
/// v1→v2 migration: the migration rewrites the YAML, but the derived
/// `.db` keeps a `STRICT … BLOB` engine column + `"blob"` metadata that
/// load otherwise uses as-is (ADR-0005 Amendment 2). Scoped to
/// column-definition lines, mirroring [`migrate_v1_to_v2`], so a `blob`
/// substring inside a CHECK expression or default doesn't trip it.
#[must_use]
pub fn body_declares_blob_column(body: &str) -> bool {
    body.lines()
        .any(|l| l.contains("{ name:") && l.contains(", type: blob"))
}

/// Detect the version of `body` and migrate it to the
/// registry's `latest_version()`.
///
/// If the body is already at the latest version, returns the
/// body unchanged with `migrated_from = None`. Otherwise:
///
/// 1. Writes `<project_path>/project.yaml.v<N>.bak` with the
///    original body (so the recovery aid is in place before
///    we start mutating).
/// 2. Runs each registered migrator in sequence from
///    `file_version` to `latest_version`.
/// 3. Returns the upgraded body for the caller to write back.
///
/// A future-version body (file_version > latest_version)
/// errors out — older builds shouldn't try to interpret
/// newer formats they don't understand.
pub fn migrate_to_latest(
    body: &str,
    registry: &MigratorRegistry,
    project_path: &Path,
) -> Result<MigrationOutcome, MigrateError> {
    let file_version = read_version(body)?;
    let latest = registry.latest_version();
    if file_version == latest {
        return Ok(MigrationOutcome {
            body: body.to_string(),
            migrated_from: None,
        });
    }
    if file_version > latest {
        return Err(MigrateError::NewerThanSupported {
            file: file_version,
            latest,
        });
    }

    // Write the .bak before any transformation runs so a
    // mid-migration crash leaves the original recoverable.
    let bak_path = project_path.join(format!(
        "{}.v{}.bak",
        crate::project::PROJECT_YAML,
        file_version
    ));
    std::fs::write(&bak_path, body).map_err(|source| MigrateError::Io {
        path: bak_path.clone(),
        source,
    })?;

    let mut current_body = body.to_string();
    for v in file_version..latest {
        let idx = (v - 1) as usize;
        let migrator = registry
            .migrators
            .get(idx)
            .ok_or(MigrateError::NoMigratorForVersion(v))?;
        let next_body = migrator(&current_body).map_err(|e| MigrateError::StepFailed {
            from: v,
            to: v + 1,
            source: Box::new(e),
        })?;
        // Sanity: the new body must declare the next version.
        // If a migrator forgets to bump, we'd loop endlessly
        // through the chain — catch it here.
        let advertised =
            read_version(&next_body).map_err(|e| MigrateError::BadOutput(e.to_string()))?;
        if advertised != v + 1 {
            return Err(MigrateError::BadOutput(format!(
                "v{v}→v{} migrator left version field at {advertised}",
                v + 1,
            )));
        }
        current_body = next_body;
    }

    Ok(MigrationOutcome {
        body: current_body,
        migrated_from: Some(file_version),
    })
}

/// Ensure the `project.yaml` at `project_path` is migrated
/// to the registry's latest version, writing the upgraded
/// body back to disk if a migration ran.
///
/// Convenience wrapper that pairs [`migrate_to_latest`] with
/// the read/write IO. Used by the runtime on every project
/// open (before the rebuild path or DB-existence check
/// touches anything).
///
/// A missing `project.yaml` is `Ok(MigrationOutcome { body:
/// "", migrated_from: None })` — a brand-new project that
/// the skeleton hasn't filled in yet falls into this branch
/// and is left alone.
pub fn ensure_project_yaml_migrated(
    project_path: &Path,
    registry: &MigratorRegistry,
) -> Result<MigrationOutcome, MigrateError> {
    let yaml_path = project_path.join(crate::project::PROJECT_YAML);
    let body = match std::fs::read_to_string(&yaml_path) {
        Ok(b) => b,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            return Ok(MigrationOutcome {
                body: String::new(),
                migrated_from: None,
            });
        }
        Err(source) => {
            return Err(MigrateError::Io {
                path: yaml_path,
                source,
            });
        }
    };
    let outcome = migrate_to_latest(&body, registry, project_path)?;
    if outcome.migrated_from.is_some() {
        std::fs::write(&yaml_path, &outcome.body).map_err(|source| MigrateError::Io {
            path: yaml_path,
            source,
        })?;
    }
    Ok(outcome)
}

/// Extract just the `version:` field from a YAML body,
/// without parsing the rest of the document.
fn read_version(body: &str) -> Result<u32, MigrateError> {
    #[derive(Deserialize)]
    struct VersionOnly {
        version: u32,
    }
    let v: VersionOnly =
        serde_norway::from_str(body).map_err(|e| MigrateError::VersionParse(e.to_string()))?;
    Ok(v.version)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn tempdir() -> tempfile::TempDir {
        tempfile::tempdir().expect("create tempdir")
    }

    fn v1_body() -> String {
        "version: 1\nproject:\n  created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n"
            .to_string()
    }

    /// A project at the current (latest) version — nothing to migrate.
    fn v2_body() -> String {
        "version: 2\nproject:\n  created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n"
            .to_string()
    }

    #[test]
    fn production_registry_latest_version_matches_current_schema_version() {
        let r = MigratorRegistry::production();
        assert_eq!(r.latest_version(), CURRENT_SCHEMA_VERSION);
        assert_eq!(r.latest_version(), 2);
    }

    #[test]
    fn no_migration_runs_when_body_already_latest() {
        let tmp = tempdir();
        let outcome =
            migrate_to_latest(&v2_body(), &MigratorRegistry::production(), tmp.path()).unwrap();
        assert_eq!(outcome.body, v2_body());
        assert_eq!(outcome.migrated_from, None);
        // No .bak written when nothing migrated.
        assert!(
            !tmp.path().join("project.yaml.v2.bak").exists(),
            "no .bak when no migration"
        );
    }

    /// ADR-0005 Amendment 2: the real production v1→v2 migrator rewrites a
    /// `blob` column to `text` and bumps the version, leaving everything
    /// else (incl. a column literally named `blob`, and a `blob` substring
    /// inside a CHECK) untouched.
    #[test]
    fn production_v1_to_v2_converts_blob_columns_to_text() {
        let tmp = tempdir();
        let body = concat!(
            "version: 1\n",
            "project:\n  created_at: x\n",
            "tables:\n",
            "  - name: Files\n",
            "    primary_key: [id]\n",
            "    columns:\n",
            "      - { name: id, type: serial }\n",
            "      - { name: payload, type: blob }\n",
            "      - { name: blob, type: text }\n",
            "    check_constraints:\n",
            "      - \"note <> 'type: blob'\"\n",
            "relationships: []\n",
        );
        let outcome = migrate_to_latest(body, &MigratorRegistry::production(), tmp.path()).unwrap();
        assert_eq!(outcome.migrated_from, Some(1));
        assert!(outcome.body.contains("version: 2"));
        // The blob column became text.
        assert!(
            outcome.body.contains("{ name: payload, type: text }"),
            "blob column converted: {}",
            outcome.body
        );
        // A column NAMED blob is untouched; the CHECK string is untouched.
        assert!(outcome.body.contains("{ name: blob, type: text }"));
        assert!(outcome.body.contains("note <> 'type: blob'"));
        assert!(!outcome.body.contains("type: blob }"));
    }

    #[test]
    fn newer_than_supported_errors() {
        let body = "version: 99\nproject:\n  created_at: x\n";
        let err = migrate_to_latest(body, &MigratorRegistry::production(), Path::new("/tmp"))
            .expect_err("must reject");
        assert!(
            matches!(
                err,
                MigrateError::NewerThanSupported {
                    file: 99,
                    latest: 2
                }
            ),
            "got: {err:?}",
        );
    }

    #[test]
    fn malformed_version_errors() {
        let body = "tables: []\n";
        let err = migrate_to_latest(body, &MigratorRegistry::production(), Path::new("/tmp"))
            .expect_err("must reject");
        assert!(matches!(err, MigrateError::VersionParse(_)), "got: {err:?}");
    }

    // --- Exercise the framework with a fake v1→v2 migrator
    //     so we know the chain runs even without a real one. ---

    fn fake_v1_to_v2(body: &str) -> Result<String, MigrateError> {
        // Trivial transformation: bump the version number in
        // place. Exercises the registry plumbing without
        // committing to any real schema change.
        Ok(body.replace("version: 1", "version: 2"))
    }

    fn registry_with_v1_to_v2() -> MigratorRegistry {
        MigratorRegistry {
            migrators: vec![fake_v1_to_v2 as MigrateFn],
        }
    }

    #[test]
    fn registry_with_one_migrator_advertises_latest_version_2() {
        let r = registry_with_v1_to_v2();
        assert_eq!(r.latest_version(), 2);
    }

    #[test]
    fn migrate_runs_chain_and_writes_bak() {
        let tmp = tempdir();
        let outcome = migrate_to_latest(&v1_body(), &registry_with_v1_to_v2(), tmp.path()).unwrap();
        assert_eq!(outcome.migrated_from, Some(1));
        assert!(outcome.body.contains("version: 2"));
        let bak = tmp.path().join("project.yaml.v1.bak");
        assert!(bak.exists(), "expected .v1.bak to be written");
        let bak_body = std::fs::read_to_string(&bak).unwrap();
        assert!(bak_body.contains("version: 1"));
    }

    #[test]
    fn migrator_that_forgets_to_bump_version_is_caught() {
        let bad: MigrateFn = |body| Ok(body.to_string()); // no change
        let registry = MigratorRegistry {
            migrators: vec![bad],
        };
        let tmp = tempdir();
        let err = migrate_to_latest(&v1_body(), &registry, tmp.path()).expect_err("must fail");
        assert!(matches!(err, MigrateError::BadOutput(_)), "got: {err:?}");
    }

    #[test]
    fn ensure_yaml_migrated_no_op_when_already_latest() {
        let tmp = tempdir();
        let yaml_path = tmp.path().join("project.yaml");
        std::fs::write(&yaml_path, v2_body()).unwrap();
        let outcome =
            ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap();
        assert_eq!(outcome.migrated_from, None);
        // File unchanged.
        let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
        assert_eq!(on_disk, v2_body());
        assert!(!tmp.path().join("project.yaml.v2.bak").exists());
    }

    #[test]
    fn ensure_yaml_migrated_writes_upgraded_body_and_bak() {
        let tmp = tempdir();
        let yaml_path = tmp.path().join("project.yaml");
        std::fs::write(&yaml_path, v1_body()).unwrap();
        let outcome = ensure_project_yaml_migrated(tmp.path(), &registry_with_v1_to_v2()).unwrap();
        assert_eq!(outcome.migrated_from, Some(1));
        let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
        assert!(on_disk.contains("version: 2"), "got: {on_disk}");
        let bak = tmp.path().join("project.yaml.v1.bak");
        assert!(bak.exists());
        assert!(
            std::fs::read_to_string(&bak)
                .unwrap()
                .contains("version: 1")
        );
    }

    #[test]
    fn ensure_yaml_migrated_handles_missing_yaml() {
        let tmp = tempdir();
        // No project.yaml exists.
        let outcome =
            ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap();
        assert_eq!(outcome.migrated_from, None);
        assert!(outcome.body.is_empty());
    }

    #[test]
    fn migrator_that_returns_internal_error_propagates() {
        let bad: MigrateFn = |_| Err(MigrateError::VersionParse("simulated".to_string()));
        let registry = MigratorRegistry {
            migrators: vec![bad],
        };
        let tmp = tempdir();
        let err = migrate_to_latest(&v1_body(), &registry, tmp.path()).expect_err("must fail");
        match err {
            MigrateError::StepFailed { from, to, .. } => {
                assert_eq!(from, 1);
                assert_eq!(to, 2);
            }
            other => panic!("expected StepFailed, got {other:?}"),
        }
    }
}
