//! Per-run persistence under `.mcptest/runs`.
//!
//! Why this module exists: the agent reporter truncates large `actual`
//! values so a tool result stays cheap to read, which leaves the agent
//! with no way to see the rest short of re-running the suite. The run
//! command persists the full (redacted) report plus one output file per
//! failed test here, keyed by run id, and the `mcptest mcp-server`
//! resource `mcptest://runs/{run_id}/tests/{name}/output` reads it back.
//! Runs are keyed by id, never by a "last run" singleton, so concurrent
//! runs cannot clobber each other. Retention is bounded: only the newest
//! [`RUN_RETENTION`] runs are kept.

use std::path::{Path, PathBuf};

use thiserror::Error;

use crate::redaction::Redactor;
use crate::report::Report;

/// How many runs the store keeps before pruning the oldest. Matches the
/// window the `mcptest://runs/recent` resource lists, so everything an
/// agent can discover is still on disk.
pub const RUN_RETENTION: usize = 20;

/// Errors raised while persisting run artifacts. Reading is best-effort
/// (`Option`) because a missing artifact is an expected state, not a fault.
#[derive(Debug, Error)]
pub enum RunStoreError {
    /// Persisting requires a real run id; an empty id would produce
    /// colliding artifact paths.
    #[error("run id is empty; persisting run artifacts requires a real run id")]
    EmptyRunId,
    /// An artifact file or directory could not be written.
    #[error("could not write run artifact at {path}: {source}")]
    Io {
        /// The artifact path that failed.
        path: PathBuf,
        /// The underlying I/O error.
        #[source]
        source: std::io::Error,
    },
    /// The report could not be serialized to JSON.
    #[error("could not serialize the run report: {0}")]
    Serialize(#[from] serde_json::Error),
}

/// On-disk store for run reports and per-test failure outputs.
///
/// Layout under the store directory: `<run_id>.json` holds the redacted
/// report, and `<run_id>/tests/<sanitized test name>.txt` holds the full
/// failure output for each failed test.
#[derive(Debug, Clone)]
pub struct RunStore {
    dir: PathBuf,
}

impl RunStore {
    /// Open a store over an explicit directory. Used by readers (the MCP
    /// server workspace) and by tests that point at fixtures.
    pub fn new(dir: impl Into<PathBuf>) -> Self {
        Self { dir: dir.into() }
    }

    /// Open the conventional store for a workspace root:
    /// `<root>/.mcptest/runs`, next to the last-run state and cassettes.
    pub fn for_workspace(root: impl AsRef<Path>) -> Self {
        Self::new(root.as_ref().join(".mcptest").join("runs"))
    }

    /// The directory this store reads and writes.
    pub fn dir(&self) -> &Path {
        &self.dir
    }

    /// Persist one run: the redacted report as `<run_id>.json` plus one
    /// full-output file per failed test, then prune to [`RUN_RETENTION`].
    ///
    /// The redactor is applied on write so persisted artifacts carry
    /// exactly what the reporters would have shown, never more.
    pub fn persist(&self, report: &Report, redactor: &Redactor) -> Result<PathBuf, RunStoreError> {
        if report.run_id.is_empty() {
            return Err(RunStoreError::EmptyRunId);
        }
        let view = report.redacted(redactor);
        let id = sanitize_component(&view.run_id);
        create_dir(&self.dir)?;
        let json_path = self.dir.join(format!("{id}.json"));
        write_file(&json_path, &serde_json::to_string_pretty(&view)?)?;
        for test in &view.tests {
            let Some(failure) = &test.failure else {
                continue;
            };
            let tests_dir = self.dir.join(&id).join("tests");
            create_dir(&tests_dir)?;
            let out_path = tests_dir.join(format!("{}.txt", sanitize_component(&test.name)));
            write_file(&out_path, &full_failure_output(failure))?;
        }
        self.prune(RUN_RETENTION);
        Ok(json_path)
    }

    /// Read the full persisted output for one test in one run. `None` when
    /// the run or the test left no output (a passing test persists none).
    pub fn read_test_output(&self, run_id: &str, test_name: &str) -> Option<String> {
        let path = self
            .dir
            .join(sanitize_component(run_id))
            .join("tests")
            .join(format!("{}.txt", sanitize_component(test_name)));
        std::fs::read_to_string(path).ok()
    }

    /// Drop everything but the newest `keep` runs (by file mtime).
    ///
    /// Best-effort: a prune failure must never fail the run that just
    /// finished, so individual remove errors are discarded.
    fn prune(&self, keep: usize) {
        let Ok(entries) = std::fs::read_dir(&self.dir) else {
            return;
        };
        let mut runs: Vec<(std::time::SystemTime, PathBuf)> = entries
            .flatten()
            .filter(|e| {
                e.path()
                    .extension()
                    .and_then(|x| x.to_str())
                    .map(|s| s == "json")
                    .unwrap_or(false)
            })
            .filter_map(|e| {
                let modified = e.metadata().ok()?.modified().ok()?;
                Some((modified, e.path()))
            })
            .collect();
        runs.sort_by_key(|entry| std::cmp::Reverse(entry.0));
        for (_, json_path) in runs.into_iter().skip(keep) {
            // Best-effort removal of the report and its outputs dir; a
            // stale leftover is reclaimed on the next prune.
            let _ = std::fs::remove_file(&json_path);
            if let Some(stem) = json_path.file_stem() {
                let _ = std::fs::remove_dir_all(self.dir.join(stem));
            }
        }
    }
}

/// Generate a fresh run id (UUIDv7, time-sortable). Lives here so the run
/// command and the `run_tool_test` verb mint ids the same way.
pub fn new_run_id() -> String {
    uuid::Uuid::now_v7().to_string()
}

/// The `mcptest://` resource URI carrying the full output for one test in
/// one run. The reporter prints this next to a truncated value; the MCP
/// server resolves it via [`RunStore::read_test_output`].
pub fn test_output_uri(run_id: &str, test_name: &str) -> String {
    format!(
        "mcptest://runs/{}/tests/{}/output",
        sanitize_component(run_id),
        sanitize_component(test_name)
    )
}

/// Reduce a run id or test name to a safe, URI-stable path component.
///
/// Keeps ASCII alphanumerics plus `.`, `_`, and `-`; everything else maps
/// to `_`. Idempotent, so the URI segment and the on-disk file name are
/// always the same string. `.`/`..`/empty collapse to `_` so a hostile id
/// can never traverse out of the store.
pub fn sanitize_component(name: &str) -> String {
    let mapped: String = name
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
                c
            } else {
                '_'
            }
        })
        .collect();
    if mapped.is_empty() || mapped == "." || mapped == ".." {
        "_".to_string()
    } else {
        mapped
    }
}

/// The full output persisted for one failed test: the failure message plus
/// the structured diff when present. This is the untruncated counterpart
/// of the agent reporter's clipped `assert:` / `actual:` fields.
fn full_failure_output(failure: &crate::report::TestFailure) -> String {
    match &failure.diff {
        Some(diff) => format!("{}\n{}", failure.message, diff),
        None => failure.message.clone(),
    }
}

fn create_dir(path: &Path) -> Result<(), RunStoreError> {
    std::fs::create_dir_all(path).map_err(|source| RunStoreError::Io {
        path: path.to_path_buf(),
        source,
    })
}

fn write_file(path: &Path, content: &str) -> Result<(), RunStoreError> {
    std::fs::write(path, content).map_err(|source| RunStoreError::Io {
        path: path.to_path_buf(),
        source,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::report::{TestFailure, TestStatus};
    use tempfile::tempdir;

    fn failing_report(run_id: &str, diff: &str) -> Report {
        let mut r = Report::sample_all_pass();
        r.run_id = run_id.to_string();
        r.summary.failed = 1;
        r.summary.passed = 0;
        r.tests[0].status = TestStatus::Fail;
        r.tests[0].failure = Some(TestFailure {
            message: "assertion failed".into(),
            diff: Some(diff.to_string()),
            network_diagnostic: None,
        });
        r
    }

    #[test]
    fn persists_report_json_and_full_test_output() {
        let dir = tempdir().unwrap();
        let store = RunStore::new(dir.path());
        let report = failing_report("run-1", "  expected: a\n  actual: a-very-long-value");
        store.persist(&report, &Redactor::new()).unwrap();
        assert!(dir.path().join("run-1.json").exists());
        let out = store.read_test_output("run-1", "smoke test").unwrap();
        assert!(out.contains("assertion failed"));
        assert!(out.contains("a-very-long-value"));
    }

    #[test]
    fn test_names_with_spaces_round_trip_through_sanitization() {
        let dir = tempdir().unwrap();
        let store = RunStore::new(dir.path());
        let mut report = failing_report("run-2", "  actual: x");
        report.tests[0].name = "create issue rejects empty title".into();
        store.persist(&report, &Redactor::new()).unwrap();
        let out = store
            .read_test_output("run-2", "create issue rejects empty title")
            .unwrap();
        assert!(out.contains("actual: x"));
    }

    #[test]
    fn redaction_applies_to_persisted_output_like_reporters() {
        let dir = tempdir().unwrap();
        let store = RunStore::new(dir.path());
        // The whole diff is a token-shaped value, the same case the
        // reporter pipeline redacts via Report::redacted.
        let report = failing_report("run-3", "ghp_abcdefghijklmnop1234");
        store.persist(&report, &Redactor::new()).unwrap();
        let out = store.read_test_output("run-3", "smoke test").unwrap();
        assert!(!out.contains("ghp_abcdefghijklmnop1234"));
        assert!(out.contains("[REDACTED:token]"));
        let json = std::fs::read_to_string(dir.path().join("run-3.json")).unwrap();
        assert!(!json.contains("ghp_abcdefghijklmnop1234"));
    }

    #[test]
    fn retention_prunes_the_oldest_runs_and_their_outputs() {
        let dir = tempdir().unwrap();
        let store = RunStore::new(dir.path());
        let total = RUN_RETENTION + 3;
        for i in 0..total {
            let report = failing_report(&format!("run-{i:03}"), "  actual: x");
            store.persist(&report, &Redactor::new()).unwrap();
            // Backdate each file (older for smaller i) so the newest-first
            // ordering is unambiguous on filesystems with coarse timestamps.
            let json = dir.path().join(format!("run-{i:03}.json"));
            let earlier =
                std::time::SystemTime::now() - std::time::Duration::from_secs((total - i) as u64);
            let file = std::fs::File::options().write(true).open(&json).unwrap();
            file.set_modified(earlier).unwrap();
        }
        let kept: Vec<_> = std::fs::read_dir(dir.path())
            .unwrap()
            .flatten()
            .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
            .collect();
        assert_eq!(kept.len(), RUN_RETENTION);
        assert!(store.read_test_output("run-000", "smoke test").is_none());
        assert!(!dir.path().join("run-000").exists());
        assert!(store
            .read_test_output(&format!("run-{:03}", RUN_RETENTION + 2), "smoke test")
            .is_some());
    }

    #[test]
    fn persisting_an_empty_run_id_is_rejected() {
        let dir = tempdir().unwrap();
        let store = RunStore::new(dir.path());
        let mut report = failing_report("x", "  actual: x");
        report.run_id = String::new();
        let err = store.persist(&report, &Redactor::new()).unwrap_err();
        assert!(matches!(err, RunStoreError::EmptyRunId));
    }

    #[test]
    fn sanitization_blocks_path_traversal_components() {
        assert_eq!(sanitize_component(".."), "_");
        assert_eq!(sanitize_component("."), "_");
        assert_eq!(sanitize_component(""), "_");
        assert_eq!(sanitize_component("../../etc/passwd"), ".._.._etc_passwd");
        assert_eq!(sanitize_component("a b/c"), "a_b_c");
        // Idempotent: sanitizing a sanitized name is a no-op, so the URI
        // segment and the file stem always agree.
        let once = sanitize_component("status check #1");
        assert_eq!(sanitize_component(&once), once);
    }

    #[test]
    fn test_output_uri_uses_sanitized_segments() {
        assert_eq!(
            test_output_uri("run-1", "status check"),
            "mcptest://runs/run-1/tests/status_check/output"
        );
    }

    #[test]
    fn two_runs_persist_independently_without_clobbering() {
        let dir = tempdir().unwrap();
        let store = RunStore::new(dir.path());
        let a = failing_report("run-a", "  actual: from-run-a");
        let b = failing_report("run-b", "  actual: from-run-b");
        store.persist(&a, &Redactor::new()).unwrap();
        store.persist(&b, &Redactor::new()).unwrap();
        assert!(store
            .read_test_output("run-a", "smoke test")
            .unwrap()
            .contains("from-run-a"));
        assert!(store
            .read_test_output("run-b", "smoke test")
            .unwrap()
            .contains("from-run-b"));
    }

    #[test]
    fn new_run_ids_are_unique_and_nonempty() {
        let a = new_run_id();
        let b = new_run_id();
        assert!(!a.is_empty());
        assert_ne!(a, b);
    }
}
