//! Merge I/O — the ONLY place in `service::merge/` that may perform side effects.
//!
//! Currently, the bulk of the merge I/O is handled by `crate::agents::merge`
//! and `crate::io::story_metadata`. This file is the designated home for any
//! future I/O helpers that are extracted from merge-related MCP handlers.

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

/// Directory (relative to the project root) where full untruncated merge
/// reports are written, one file per merge attempt.
const MERGE_REPORTS_DIR: &str = "merge_reports";

/// Maximum number of report files retained per story; older files beyond
/// this count are pruned after each write.
const MAX_REPORTS_PER_STORY: usize = 5;

/// Write the full untruncated merge output to
/// `.huskies/merge_reports/{story_id}-{unix_timestamp}.log` and prune older
/// reports for the same story beyond [`MAX_REPORTS_PER_STORY`].
///
/// Returns the path to the written file (relative to `project_root`) for
/// inclusion in tool responses, or `None` if the write failed — a report is
/// a convenience artifact, so a filesystem hiccup here must not fail the
/// merge itself.
pub fn write_merge_report(project_root: &Path, story_id: &str, output: &str) -> Option<String> {
    let dir = project_root.join(".huskies").join(MERGE_REPORTS_DIR);
    std::fs::create_dir_all(&dir).ok()?;

    let timestamp = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    let file_name = format!("{story_id}-{timestamp}.log");
    std::fs::write(dir.join(&file_name), output).ok()?;

    prune_old_reports(&dir, story_id);

    Some(format!(".huskies/{MERGE_REPORTS_DIR}/{file_name}"))
}

/// Delete the oldest report files for `story_id` in `dir` beyond
/// [`MAX_REPORTS_PER_STORY`]. Filenames embed a unix timestamp, so lexical
/// sort order matches chronological order.
fn prune_old_reports(dir: &Path, story_id: &str) {
    let prefix = format!("{story_id}-");
    let Ok(read_dir) = std::fs::read_dir(dir) else {
        return;
    };
    let mut paths: Vec<PathBuf> = read_dir
        .flatten()
        .filter(|entry| {
            entry
                .file_name()
                .to_str()
                .is_some_and(|n| n.starts_with(&prefix) && n.ends_with(".log"))
        })
        .map(|entry| entry.path())
        .collect();
    paths.sort();

    if paths.len() > MAX_REPORTS_PER_STORY {
        for old in &paths[..paths.len() - MAX_REPORTS_PER_STORY] {
            let _ = std::fs::remove_file(old);
        }
    }
}

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

    #[test]
    fn write_merge_report_creates_file_with_full_output() {
        let tmp = tempfile::tempdir().unwrap();
        let output = "full untruncated gate output\nline 2\n";
        let path = write_merge_report(tmp.path(), "42_story_foo", output)
            .expect("write_merge_report should succeed");
        assert!(path.starts_with(".huskies/merge_reports/42_story_foo-"));
        assert!(path.ends_with(".log"));
        let full_path = tmp.path().join(&path);
        assert_eq!(std::fs::read_to_string(full_path).unwrap(), output);
    }

    #[test]
    fn write_merge_report_prunes_beyond_max_per_story() {
        let tmp = tempfile::tempdir().unwrap();
        let dir = tmp.path().join(".huskies").join(MERGE_REPORTS_DIR);
        std::fs::create_dir_all(&dir).unwrap();

        // Seed MAX_REPORTS_PER_STORY + 2 files with distinct, ordered names
        // (avoids relying on real-clock timestamps to establish ordering).
        for i in 0..(MAX_REPORTS_PER_STORY + 2) {
            std::fs::write(
                dir.join(format!("77_story-{i:010}.log")),
                format!("report {i}"),
            )
            .unwrap();
        }

        prune_old_reports(&dir, "77_story");

        let remaining: Vec<String> = std::fs::read_dir(&dir)
            .unwrap()
            .flatten()
            .map(|e| e.file_name().to_string_lossy().to_string())
            .collect();
        assert_eq!(remaining.len(), MAX_REPORTS_PER_STORY);
        // The two oldest (lowest-numbered) files must be gone.
        assert!(!remaining.contains(&"77_story-0000000000.log".to_string()));
        assert!(!remaining.contains(&"77_story-0000000001.log".to_string()));
        // The newest must survive.
        assert!(remaining.contains(&format!("77_story-{:010}.log", MAX_REPORTS_PER_STORY + 1)));
    }

    #[test]
    fn write_merge_report_prune_only_affects_matching_story() {
        let tmp = tempfile::tempdir().unwrap();
        let dir = tmp.path().join(".huskies").join(MERGE_REPORTS_DIR);
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(dir.join("99_other-0000000000.log"), "keep me").unwrap();

        for i in 0..(MAX_REPORTS_PER_STORY + 2) {
            std::fs::write(
                dir.join(format!("77_story-{i:010}.log")),
                format!("report {i}"),
            )
            .unwrap();
        }

        prune_old_reports(&dir, "77_story");

        assert!(
            dir.join("99_other-0000000000.log").exists(),
            "pruning one story's reports must not touch another story's files"
        );
    }

    #[test]
    fn write_merge_report_returns_none_when_project_root_is_unwritable_file() {
        // Passing a path that is a regular file (not a directory) makes
        // create_dir_all fail, exercising the graceful-`None` path.
        let tmp = tempfile::tempdir().unwrap();
        let not_a_dir = tmp.path().join("not_a_dir");
        std::fs::write(&not_a_dir, "i am a file").unwrap();
        assert!(write_merge_report(&not_a_dir, "1_story", "output").is_none());
    }
}
