//! Atomic SCHEMA_VERSION bump tool.
//!
//! Migrated from `scripts/bump-schema.sh` after several Codex AI review
//! iterations exposed atomicity gaps in the shell version. Rust's
//! `std::fs` gives temp-file + rename atomicity without the
//! cross-platform shell semantics that made the bash version fragile.
//!
//! Discipline: `docs/playbooks/schema-bump.md`.

use std::ffi::OsString;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};

const LIB_PATH: &str = "src/lib.rs";
const TOMBSTONE_DIR: &str = "docs/tombstones";
const SCHEMA_PREFIX: &str = "pub const SCHEMA_VERSION: &str = \"";

fn main() -> ExitCode {
    let args: Vec<String> = std::env::args().skip(1).collect();
    if args.len() != 2 {
        eprintln!("usage: lifeloop-bump-schema <new-version> \"<reason>\"");
        return ExitCode::from(2);
    }
    let new_version = &args[0];
    let reason = &args[1];

    match run(
        Path::new(LIB_PATH),
        Path::new(TOMBSTONE_DIR),
        new_version,
        reason,
    ) {
        Ok(report) => {
            println!("Bumped SCHEMA_VERSION: {} -> {}", report.old, report.new);
            println!("Wrote tombstone:       {}", report.tombstone_path.display());
            println!();
            println!("Manual follow-up before commit:");
            println!("  1. Update tests/wire_contract.rs assertions that pin schema_version.");
            println!(
                "  2. Fill in removal criteria and migration notes in {}.",
                report.tombstone_path.display()
            );
            println!("  3. Update README.md if it cites the schema version.");
            println!("  4. Run: make verify");
            ExitCode::SUCCESS
        }
        Err(err) => {
            eprintln!("error: {err}");
            ExitCode::FAILURE
        }
    }
}

#[derive(Debug)]
struct Report {
    old: String,
    new: String,
    tombstone_path: PathBuf,
}

fn run(
    lib_path: &Path,
    tombstone_dir: &Path,
    new_version: &str,
    reason: &str,
) -> io::Result<Report> {
    // ---------------------------------------------------------------------
    // Read current state.
    // ---------------------------------------------------------------------
    let lib_text = fs::read_to_string(lib_path)?;
    let old = extract_schema_version(&lib_text)
        .ok_or_else(|| {
            io::Error::new(
                io::ErrorKind::NotFound,
                format!("could not find SCHEMA_VERSION in {}", lib_path.display()),
            )
        })?
        .to_string();

    if old == new_version {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            format!("new version '{new_version}' is identical to current version"),
        ));
    }

    // ---------------------------------------------------------------------
    // Pre-flight: validate every mutation target before touching anything.
    // The shell version drifted toward partial-state bugs by mutating
    // before all preconditions were proven; the Rust version checks all
    // of them upfront and only then proceeds.
    // ---------------------------------------------------------------------
    fs::create_dir_all(tombstone_dir)?;
    let tombstone_path = tombstone_dir.join(format!("{old}.md"));
    if tombstone_path.exists() {
        return Err(io::Error::new(
            io::ErrorKind::AlreadyExists,
            format!(
                "tombstone {} already exists; refusing to overwrite",
                tombstone_path.display()
            ),
        ));
    }

    // Probe-write to confirm tombstone destination is writable. A failed
    // write here aborts before we mutate anything.
    let probe_path = tombstone_dir.join(format!(".probe.{}", std::process::id()));
    fs::write(&probe_path, b"").map_err(|e| {
        io::Error::new(
            e.kind(),
            format!(
                "tombstone destination {} is not writable: {e}",
                tombstone_dir.display()
            ),
        )
    })?;
    fs::remove_file(&probe_path)?;

    // Compute the rewritten lib.rs text. If the needle is missing we'll
    // catch it here with no I/O state mutated.
    let new_lib_text = rewrite_schema_version(&lib_text, &old, new_version).ok_or_else(|| {
        io::Error::new(
            io::ErrorKind::NotFound,
            format!(
                "SCHEMA_VERSION line for {} not found in {}",
                old,
                lib_path.display()
            ),
        )
    })?;

    let date = utc_today();
    let tombstone_content = render_tombstone(&old, new_version, &date, reason);

    // ---------------------------------------------------------------------
    // Stage temp files. Drop guard cleans them up on any error path.
    // ---------------------------------------------------------------------
    let pid = std::process::id();
    let lib_tmp = sibling_path(lib_path, &format!(".tmp.{pid}"));
    let lib_backup = sibling_path(lib_path, &format!(".backup.{pid}"));
    let ts_tmp = tombstone_dir.join(format!(".{old}.tmp.{pid}"));

    let _cleanup = Cleanup::new(vec![lib_tmp.clone(), ts_tmp.clone(), lib_backup.clone()]);

    // Snapshot lib.rs so we can restore it if the tombstone promotion
    // fails after the lib.rs promotion succeeds.
    fs::copy(lib_path, &lib_backup)?;

    fs::write(&lib_tmp, &new_lib_text)?;
    fs::write(&ts_tmp, &tombstone_content)?;

    // ---------------------------------------------------------------------
    // Promote both temp files into place. rename(2) on the same
    // filesystem is atomic, so each individual rename is safe; the only
    // partial-state window is between the two renames, and we close it
    // with explicit rollback.
    // ---------------------------------------------------------------------
    fs::rename(&lib_tmp, lib_path)?;
    if let Err(promote_err) = fs::rename(&ts_tmp, &tombstone_path) {
        match fs::rename(&lib_backup, lib_path) {
            Ok(()) => Err(io::Error::other(format!(
                "tombstone rename failed: {promote_err}; rolled back SCHEMA_VERSION in {}",
                lib_path.display()
            ))),
            Err(rollback_err) => Err(io::Error::other(format!(
                "CRITICAL: tombstone rename failed ({promote_err}) AND rollback also failed \
                 ({rollback_err}); {} is bumped without a matching tombstone — recover \
                 manually",
                lib_path.display()
            ))),
        }
    } else {
        Ok(Report {
            old,
            new: new_version.to_string(),
            tombstone_path,
        })
    }
}

fn extract_schema_version(text: &str) -> Option<&str> {
    text.lines().find_map(|line| {
        let rest = line.strip_prefix(SCHEMA_PREFIX)?;
        let end = rest.find('"')?;
        Some(&rest[..end])
    })
}

fn rewrite_schema_version(text: &str, old: &str, new: &str) -> Option<String> {
    let needle = format!("{SCHEMA_PREFIX}{old}\";");
    if !text.contains(&needle) {
        return None;
    }
    let replacement = format!("{SCHEMA_PREFIX}{new}\";");
    Some(text.replacen(&needle, &replacement, 1))
}

fn render_tombstone(old: &str, new: &str, date: &str, reason: &str) -> String {
    format!(
        "# Tombstone: {old}\n\
         \n\
         **Superseded by:** {new}\n\
         **Date:** {date}\n\
         **Reason:** {reason}\n\
         \n\
         ## Removal criteria\n\
         \n\
         <!-- AGENTS.md mandates explicit tombstones with removal criteria, not hidden\n\
              compat shims. Fill in a concrete condition under which this tombstone\n\
              can be deleted (e.g. \"after 2 quarters with zero {old} traffic in\n\
              production receipts\"). -->\n\
         TODO\n\
         \n\
         ## Migration\n\
         \n\
         <!-- Describe what consumers must do to move from {old} to {new}. -->\n\
         TODO\n"
    )
}

fn utc_today() -> String {
    Command::new("date")
        .args(["-u", "+%Y-%m-%d"])
        .output()
        .ok()
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "unknown".to_string())
}

/// Append `suffix` to a path's filename (preserving the rest of the path).
fn sibling_path(path: &Path, suffix: &str) -> PathBuf {
    let mut name: OsString = path.file_name().unwrap_or_default().to_owned();
    name.push(suffix);
    path.with_file_name(name)
}

/// Drop guard that removes a list of paths (best-effort) when it goes out
/// of scope. Used to clean up temp files on any error path; remove_file
/// errors (e.g. file already moved or never created) are intentionally
/// ignored.
struct Cleanup {
    paths: Vec<PathBuf>,
}

impl Cleanup {
    fn new(paths: Vec<PathBuf>) -> Self {
        Self { paths }
    }
}

impl Drop for Cleanup {
    fn drop(&mut self) {
        for p in &self.paths {
            let _ = fs::remove_file(p);
        }
    }
}

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

    const SAMPLE_LIB: &str = "//! header\n\
                              \n\
                              use serde::Serialize;\n\
                              \n\
                              pub const SCHEMA_VERSION: &str = \"lifeloop.v0\";\n\
                              \n\
                              fn other() {}\n";

    #[test]
    fn extract_finds_version() {
        assert_eq!(extract_schema_version(SAMPLE_LIB), Some("lifeloop.v0"));
    }

    #[test]
    fn extract_returns_none_when_missing() {
        assert_eq!(extract_schema_version("// no schema here\n"), None);
    }

    #[test]
    fn rewrite_replaces_version_once() {
        let result = rewrite_schema_version(SAMPLE_LIB, "lifeloop.v0", "lifeloop.v1").unwrap();
        assert!(result.contains("pub const SCHEMA_VERSION: &str = \"lifeloop.v1\";"));
        assert!(!result.contains("\"lifeloop.v0\""));
    }

    #[test]
    fn rewrite_returns_none_when_old_not_present() {
        let result = rewrite_schema_version(SAMPLE_LIB, "lifeloop.v9", "lifeloop.v10");
        assert!(result.is_none());
    }

    #[test]
    fn render_tombstone_includes_all_fields() {
        let s = render_tombstone(
            "lifeloop.v0",
            "lifeloop.v1",
            "2026-05-08",
            "renamed turn.ended -> turn.completed",
        );
        assert!(s.starts_with("# Tombstone: lifeloop.v0\n"));
        assert!(s.contains("**Superseded by:** lifeloop.v1\n"));
        assert!(s.contains("**Date:** 2026-05-08\n"));
        assert!(s.contains("**Reason:** renamed turn.ended -> turn.completed\n"));
        assert!(s.contains("## Removal criteria"));
        assert!(s.contains("## Migration"));
    }

    #[test]
    fn sibling_path_appends_suffix() {
        let p = Path::new("src/lib.rs");
        let s = sibling_path(p, ".tmp.123");
        assert_eq!(s, PathBuf::from("src/lib.rs.tmp.123"));
    }

    // -----------------------------------------------------------------
    // Filesystem-level integration tests using a per-test temp dir.
    // -----------------------------------------------------------------

    fn fresh_tempdir() -> PathBuf {
        // PID + nanos can collide under parallel cargo-test execution because
        // the OS clock resolution is coarser than ns and all tests share the
        // same PID. The atomic counter guarantees per-call uniqueness.
        use std::sync::atomic::{AtomicU64, Ordering};
        static SEQ: AtomicU64 = AtomicU64::new(0);
        let seq = SEQ.fetch_add(1, Ordering::Relaxed);
        let base = std::env::temp_dir().join(format!(
            "lifeloop-bump-schema-test-{}-{}-{seq}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        fs::create_dir_all(&base).unwrap();
        base
    }

    fn cleanup(dir: &Path) {
        let _ = fs::remove_dir_all(dir);
    }

    fn seed_lib(dir: &Path, version: &str) -> PathBuf {
        let p = dir.join("lib.rs");
        fs::write(
            &p,
            format!(
                "//! header\n\npub const SCHEMA_VERSION: &str = \"{version}\";\n\nfn x() {{}}\n"
            ),
        )
        .unwrap();
        p
    }

    #[test]
    fn happy_path_bumps_lib_and_writes_tombstone() {
        let dir = fresh_tempdir();
        let lib = seed_lib(&dir, "lifeloop.v0");
        let ts_dir = dir.join("tombstones");

        let report = run(&lib, &ts_dir, "lifeloop.v1", "renamed event").unwrap();
        assert_eq!(report.old, "lifeloop.v0");
        assert_eq!(report.new, "lifeloop.v1");
        assert_eq!(report.tombstone_path, ts_dir.join("lifeloop.v0.md"));

        let new_lib = fs::read_to_string(&lib).unwrap();
        assert!(new_lib.contains("\"lifeloop.v1\""));
        assert!(!new_lib.contains("\"lifeloop.v0\""));

        let ts = fs::read_to_string(&report.tombstone_path).unwrap();
        assert!(ts.contains("# Tombstone: lifeloop.v0"));
        assert!(ts.contains("**Reason:** renamed event"));

        cleanup(&dir);
    }

    #[test]
    fn refuses_when_versions_match() {
        let dir = fresh_tempdir();
        let lib = seed_lib(&dir, "lifeloop.v0");
        let ts_dir = dir.join("tombstones");

        let err = run(&lib, &ts_dir, "lifeloop.v0", "noop").unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);

        // Lib unchanged, tombstone dir not even created.
        let lib_text = fs::read_to_string(&lib).unwrap();
        assert!(lib_text.contains("\"lifeloop.v0\""));
        assert!(!ts_dir.exists());

        cleanup(&dir);
    }

    #[test]
    fn refuses_when_tombstone_already_exists() {
        let dir = fresh_tempdir();
        let lib = seed_lib(&dir, "lifeloop.v0");
        let ts_dir = dir.join("tombstones");
        fs::create_dir_all(&ts_dir).unwrap();
        let existing = ts_dir.join("lifeloop.v0.md");
        fs::write(&existing, "pre-existing\n").unwrap();

        let err = run(&lib, &ts_dir, "lifeloop.v1", "x").unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);

        // Lib unchanged, pre-existing tombstone untouched.
        assert!(
            fs::read_to_string(&lib)
                .unwrap()
                .contains("\"lifeloop.v0\"")
        );
        assert_eq!(fs::read_to_string(&existing).unwrap(), "pre-existing\n");

        cleanup(&dir);
    }

    #[test]
    fn refuses_when_lib_lacks_schema_version() {
        let dir = fresh_tempdir();
        let lib = dir.join("lib.rs");
        fs::write(&lib, "fn nothing() {}\n").unwrap();
        let ts_dir = dir.join("tombstones");

        let err = run(&lib, &ts_dir, "lifeloop.v1", "x").unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::NotFound);

        cleanup(&dir);
    }
}
