//! Record-mode resume journal for the model matrix.
//!
//! `mcptest run --record` fans one agent test out across every model the
//! YAML names; each (agent, model) pair is one "cell". A wide matrix can
//! die partway (a provider rate-limits after 80 of 100 cells), and today
//! re-running redoes all 100. This module is the durable journal that
//! lets a resumed `--record` run skip the cells it already recorded and
//! passed this session, so only the missing or failed cells re-run. It is
//! the record-mode analogue of the cassette replay-skip: cassettes already
//! short-circuit non-record runs, the journal closes the gap for record.
//!
//! The journal is a plain JSON file (no database) so it diffs and travels
//! next to the cassettes it complements. It carries a suite fingerprint so
//! a resumed run refuses to reuse stale progress after the suite changed.

use std::collections::BTreeSet;
use std::hash::{Hash, Hasher};
use std::path::Path;

use serde::{Deserialize, Serialize};
use thiserror::Error;

/// On-disk schema version. Bumped when the journal JSON shape changes
/// incompatibly; an unknown version loads as "absent" (start fresh)
/// rather than risking a misread of a future layout.
pub const JOURNAL_SCHEMA: &str = "v1";

/// Errors raised while writing a journal.
///
/// Reading never errors out of band: [`RunJournal::load`] folds every
/// read problem into `Ok(None)` ("start fresh") because a missing or
/// corrupt journal must never abort a run. Writing can fail (a full or
/// read-only disk), and the caller decides whether to warn or stop.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum JournalError {
    /// The journal file could not be written or renamed into place.
    #[error("journal i/o: {0}")]
    Io(#[from] std::io::Error),
    /// The journal could not be serialized to JSON.
    #[error("journal serialize: {0}")]
    Serde(#[from] serde_json::Error),
}

/// Durable record of which matrix cells a `--record` run has already
/// completed successfully, plus the fingerprint of the suite that
/// produced them.
///
/// Serialized to JSON. The completed set holds canonical cell keys built
/// by [`cell_key`] so the writer (mark-complete after a cell passes) and
/// the reader (skip-check before dispatch) cannot drift on key format.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunJournal {
    /// Schema version, see [`JOURNAL_SCHEMA`].
    pub schema_version: String,
    /// Stable hash of the suite that produced the completed cells. On
    /// resume, a journal whose fingerprint differs from the current
    /// suite is treated as stale and ignored, so prior cells are never
    /// reused after a prompt, expectation, or model edit.
    pub suite_fingerprint: String,
    /// Canonical keys ([`cell_key`]) of the cells recorded and passed so
    /// far. A `BTreeSet` keeps the on-disk JSON ordering stable so the
    /// file diffs cleanly between incremental saves.
    pub completed: BTreeSet<String>,
}

/// Canonical key for one matrix cell.
///
/// The single source of truth for how a (agent, model) pair maps to a
/// journal entry. Both the mark-complete writer and the skip-check reader
/// call this so the two halves cannot disagree. The NUL separator cannot
/// appear in an agent name or model id, so the join is unambiguous.
pub fn cell_key(agent: &str, model_id: &str) -> String {
    format!("{agent}\u{0}{model_id}")
}

/// Hash a canonical serialized form of the suite into a stable
/// fingerprint string.
///
/// The caller passes the suite's canonical serialized representation
/// (the JSON of the resolved suite YAML), so any change to the agents,
/// their models, prompts, or expectations changes the input and thus the
/// fingerprint. We use the std hasher rather than a crypto digest: this
/// is a change-detector, not a security boundary, and pulling in a hash
/// crate would violate the no-new-dependency rule.
pub fn fingerprint(serialized: &str) -> String {
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    serialized.hash(&mut hasher);
    format!("{:016x}", hasher.finish())
}

impl RunJournal {
    /// Start a fresh journal stamped with `fingerprint`.
    ///
    /// Used both when `--resume` is off (so a future resume has a file to
    /// read) and when a loaded journal is stale and we discard it.
    pub fn for_suite(fingerprint: impl Into<String>) -> Self {
        RunJournal {
            schema_version: JOURNAL_SCHEMA.to_string(),
            suite_fingerprint: fingerprint.into(),
            completed: BTreeSet::new(),
        }
    }

    /// Load a journal from `path`.
    ///
    /// Returns `Ok(None)` when the file is absent, unreadable, not valid
    /// JSON, or carries an unknown schema version. A resume must never
    /// panic or abort on a damaged journal, so every read problem maps to
    /// "no usable journal, start fresh." The caller compares the returned
    /// fingerprint against the current suite to decide whether to reuse it.
    pub fn load(path: &Path) -> Result<Option<Self>, JournalError> {
        let raw = match std::fs::read_to_string(path) {
            Ok(raw) => raw,
            // Any read failure (absent, permissions) is "start fresh".
            Err(_) => return Ok(None),
        };
        let journal: RunJournal = match serde_json::from_str(&raw) {
            Ok(journal) => journal,
            // Corrupt or truncated JSON (a crash mid-write before the
            // atomic rename landed) is "start fresh", not an error.
            Err(_) => return Ok(None),
        };
        if journal.schema_version != JOURNAL_SCHEMA {
            return Ok(None);
        }
        Ok(Some(journal))
    }

    /// True when `(agent, model_id)` is already recorded and passed.
    ///
    /// The skip-check in the record loop calls this before dispatching a
    /// cell so a resumed run does not re-spend on work already done.
    pub fn is_complete(&self, agent: &str, model_id: &str) -> bool {
        self.completed.contains(&cell_key(agent, model_id))
    }

    /// Mark `(agent, model_id)` complete.
    ///
    /// Called only after a cell's assertions pass in record mode. A cell
    /// that failed its assertions is left out so a resumed run re-runs it.
    pub fn mark_complete(&mut self, agent: &str, model_id: &str) {
        self.completed.insert(cell_key(agent, model_id));
    }

    /// Write the journal to `path` atomically.
    ///
    /// Writes a sibling temp file then renames it over `path`, so a crash
    /// mid-write leaves the previous journal intact instead of a truncated
    /// one. Saved incrementally after every completed cell so an aborted
    /// run keeps the progress it made.
    pub fn save(&self, path: &Path) -> Result<(), JournalError> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let body = serde_json::to_string_pretty(self)?;
        // Rename is atomic within a directory, so the temp file sits next
        // to the target rather than in a system temp dir on another mount.
        let tmp = tmp_sibling(path);
        std::fs::write(&tmp, body)?;
        std::fs::rename(&tmp, path)?;
        Ok(())
    }
}

/// Build a sibling temp path for the atomic save.
///
/// Keeping the temp file in the same directory guarantees the rename is a
/// same-filesystem move (atomic) rather than a cross-device copy.
fn tmp_sibling(path: &Path) -> std::path::PathBuf {
    let mut name = path
        .file_name()
        .map(|n| n.to_os_string())
        .unwrap_or_else(|| std::ffi::OsString::from(".run-journal.json"));
    name.push(".tmp");
    match path.parent() {
        Some(parent) => parent.join(name),
        None => std::path::PathBuf::from(name),
    }
}

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

    #[test]
    fn cell_key_is_stable_and_separates_fields() {
        // The writer and reader must produce the identical key, and the
        // NUL join must keep "a" + "bc" distinct from "ab" + "c".
        assert_eq!(
            cell_key("weather", "claude-x"),
            cell_key("weather", "claude-x")
        );
        assert_ne!(cell_key("a", "bc"), cell_key("ab", "c"));
    }

    #[test]
    fn fingerprint_changes_when_input_changes() {
        let before = fingerprint("agents: [a]\nprompt: hi");
        let after = fingerprint("agents: [a]\nprompt: bye");
        assert_ne!(before, after);
        // Same input is stable across calls.
        assert_eq!(before, fingerprint("agents: [a]\nprompt: hi"));
    }

    #[test]
    fn mark_and_is_complete_round_trip() {
        let mut journal = RunJournal::for_suite("fp");
        assert!(!journal.is_complete("weather", "claude-x"));
        journal.mark_complete("weather", "claude-x");
        assert!(journal.is_complete("weather", "claude-x"));
        // A different model on the same agent is still incomplete.
        assert!(!journal.is_complete("weather", "gpt-5"));
    }

    #[test]
    fn save_then_load_round_trips() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("cassettes/.run-journal.json");
        let mut journal = RunJournal::for_suite("fp-123");
        journal.mark_complete("a", "m1");
        journal.mark_complete("b", "m2");
        journal.save(&path).expect("save");

        let loaded = RunJournal::load(&path).expect("load").expect("present");
        assert_eq!(loaded.suite_fingerprint, "fp-123");
        assert!(loaded.is_complete("a", "m1"));
        assert!(loaded.is_complete("b", "m2"));
        assert_eq!(loaded, journal);
    }

    #[test]
    fn load_missing_file_returns_none() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("absent.json");
        assert!(RunJournal::load(&path).expect("ok").is_none());
    }

    #[test]
    fn load_corrupt_json_returns_none_not_error() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("corrupt.json");
        std::fs::write(&path, "{ this is not valid json").unwrap();
        // A crash mid-write must not abort the resumed run.
        assert!(RunJournal::load(&path).expect("ok").is_none());
    }

    #[test]
    fn load_unknown_schema_version_returns_none() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("future.json");
        let mut journal = RunJournal::for_suite("fp");
        journal.schema_version = "v999".into();
        std::fs::write(&path, serde_json::to_string(&journal).unwrap()).unwrap();
        assert!(RunJournal::load(&path).expect("ok").is_none());
    }

    #[test]
    fn fingerprint_mismatch_is_caller_detectable_on_load() {
        // The journal does not self-invalidate; it stores the fingerprint
        // so the caller can compare against the current suite. This test
        // documents that contract: a loaded journal surfaces its stamp.
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join(".run-journal.json");
        RunJournal::for_suite("old-fp").save(&path).expect("save");
        let loaded = RunJournal::load(&path).expect("ok").expect("present");
        assert_ne!(loaded.suite_fingerprint, fingerprint("new suite text"));
    }

    #[test]
    fn save_overwrites_previous_journal_in_place() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join(".run-journal.json");
        RunJournal::for_suite("fp").save(&path).expect("first");
        let mut journal = RunJournal::for_suite("fp");
        journal.mark_complete("a", "m");
        journal.save(&path).expect("second");
        let loaded = RunJournal::load(&path).expect("ok").expect("present");
        assert!(loaded.is_complete("a", "m"));
        // No stray temp file left behind after the rename.
        assert!(!tmp.path().join(".run-journal.json.tmp").exists());
    }
}
