//! `--watch` loop for `mcptest run`.

use std::io;
use std::path::PathBuf;

use anyhow::Result;

use crate::cli::args::RunArgs;
use crate::cli::exit_codes::{EXIT_FAILURE, EXIT_INVALID_INVOCATION, EXIT_OK};
use crate::cli::{Cli, Reporter};
use crate::watch;

/// Reporter-flag compatibility check for `--watch`.
///
/// Returns Some(reason) when the reporter is incompatible. JSON, JUnit,
/// SARIF, HTML, Markdown, and GitLab all emit machine-readable artefacts
/// that re-running on every save would clobber, so we refuse them here
/// with a clear error pointing at the conflict.
pub(crate) fn incompatible_watch_reporter(reporter: Option<Reporter>) -> Option<&'static str> {
    match reporter {
        // The TTY-friendly formats: re-rendering them on every save is the
        // whole point of `--watch`.
        None
        | Some(Reporter::Pretty)
        | Some(Reporter::Minimal)
        | Some(Reporter::Agent)
        | Some(Reporter::Quiet) => None,
        // Every machine format re-runs and clobbers its artefact on each
        // loop iteration, so refuse them with a clear pointer at the conflict.
        Some(Reporter::Json) => Some("--reporter json overwrites JSON on every loop iteration"),
        Some(Reporter::Junit) => Some("--reporter junit produces XML, not a TTY-friendly view"),
        Some(Reporter::Md) => Some("--reporter md produces Markdown, not a TTY-friendly view"),
        Some(Reporter::Html) => Some("--reporter html produces an HTML file, not a TTY view"),
        Some(Reporter::Sarif) => Some("--reporter sarif produces SARIF JSON, not a TTY view"),
        Some(Reporter::Gitlab) => {
            Some("--reporter gitlab produces Code Quality JSON, not a TTY view")
        }
        Some(Reporter::Ndjson) => Some("--reporter ndjson streams JSON lines, not a TTY view"),
        Some(Reporter::Openinference) => {
            Some("--reporter openinference streams trace JSONL, not a TTY view")
        }
        Some(Reporter::Tap) => Some("--reporter tap produces TAP, not a TTY-friendly view"),
        Some(Reporter::Matrix) => {
            Some("--reporter matrix produces an HTML grid file, not a TTY view")
        }
        Some(Reporter::MatrixMd) => {
            Some("--reporter matrix-md produces a Markdown grid, not a TTY view")
        }
    }
}

/// Drive the `--watch` loop until Ctrl-C or the watcher channel closes.
pub(crate) fn run_watch_loop(
    cli: &Cli,
    args: &RunArgs,
    run_once: impl Fn(&Cli, &RunArgs) -> Result<i32>,
) -> Result<i32> {
    let cwd = std::env::current_dir()?;
    let config_path = match cli.global.config.clone() {
        Some(path) => path,
        // WOR-1350: shared default resolution (`mcptest.yaml`, then
        // `mcptest.yml`) so the watcher observes the file run_once loads.
        None => match crate::cli::default_config::resolve_default_config(&cwd) {
            Ok(resolved) => resolved.into_path(),
            Err(ambiguous) => {
                eprintln!("mcptest run --watch: {}", ambiguous.message());
                return Ok(EXIT_INVALID_INVOCATION);
            }
        },
    };
    let watched = collect_watched_paths(&config_path, cli.global.server_command.as_deref());
    let mut session = match watch::WatchSession::new(&watched) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("mcptest run --watch: cannot start watcher: {e}");
            return Ok(EXIT_FAILURE);
        }
    };
    if !cli.global.quiet {
        println!("watching {} files; press Ctrl-C to exit", watched.len());
    }
    let rt = tokio::runtime::Runtime::new()?;
    // Run once before waiting for events.
    let _ = run_once(cli, args)?;
    loop {
        let tick = rt.block_on(session.next_tick());
        match tick {
            watch::Tick::Changed { path } => {
                let mut stdout = io::stdout();
                let _ = watch::clear_terminal(&mut stdout);
                let _ = watch::print_change_banner(&mut stdout, &path);
                let _ = run_once(cli, args)?;
            }
            watch::Tick::Closed | watch::Tick::Interrupted => break,
        }
    }
    Ok(EXIT_OK)
}

/// Collect the paths the watcher should observe.
fn collect_watched_paths(
    config_path: &std::path::Path,
    server_command: Option<&str>,
) -> Vec<PathBuf> {
    let mut out = Vec::new();
    if config_path.exists() {
        out.push(config_path.to_path_buf());
    }
    // Best-effort YAML scan for `imports:` entries so a user splitting
    // their suite across files still gets reactive reruns.
    if let Ok(body) = std::fs::read_to_string(config_path) {
        out.extend(parse_imports(&body, config_path));
    }
    // The first token of `--server-command` is the binary path; watch
    // it so a rebuild triggers a rerun. shell-words splits arguments
    // the same way the runner does later.
    if let Some(cmd) = server_command {
        if let Ok(parts) = shell_words::split(cmd) {
            if let Some(first) = parts.first() {
                let p = PathBuf::from(first);
                if p.exists() {
                    out.push(p);
                }
            }
        }
    }
    out
}

/// Extract `imports:` paths from a YAML body. We deliberately use a
/// permissive scan rather than a full YAML parse: the watcher must work
/// even when the YAML is mid-edit and would not deserialize cleanly.
fn parse_imports(body: &str, config_path: &std::path::Path) -> Vec<PathBuf> {
    let value: serde_yaml::Value = match serde_yaml::from_str(body) {
        Ok(v) => v,
        Err(_) => return Vec::new(),
    };
    let Some(imports) = value
        .get("imports")
        .and_then(serde_yaml::Value::as_sequence)
    else {
        return Vec::new();
    };
    let base = config_path
        .parent()
        .unwrap_or_else(|| std::path::Path::new("."));
    imports
        .iter()
        .filter_map(|entry| entry.as_str().map(|s| base.join(s)))
        .filter(|p| p.exists())
        .collect()
}
