//! Handler for `mcptest judge <subcommand>`.
//!
//! Today the one verb is `judge certify`: read a labeled anchor set and a
//! rubric, compute the calibration metrics through `mcptest-core`, and emit a
//! provenance-stamped certification record. The exit code is the governance
//! signal (0 certified, 1 not), so CI can fail closed before a model-graded
//! assertion is allowed to gate. The deterministic math lives in
//! [`mcptest_core::checks::certification`]; this module is the IO and the clock.

use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::{anyhow, Context, Result};

use mcptest_core::checks::calibration::load_labels;
use mcptest_core::checks::certification::{
    build_record, evaluate, hash_bytes, CertificationThresholds, ReliabilityInput, ValidityWindow,
};

use crate::cli::args::{JudgeArgs, JudgeCertifyArgs, JudgeCommand};

/// Handle `mcptest judge <subcommand>`.
pub fn judge_command(args: JudgeArgs) -> Result<i32> {
    match args.command {
        JudgeCommand::Certify(certify) => certify_command(certify),
    }
}

/// Run `mcptest judge certify`. Returns exit code 0 when certified, 1 when a
/// threshold was missed. A read or parse failure is an error (also non-zero).
fn certify_command(args: JudgeCertifyArgs) -> Result<i32> {
    let samples = load_labels("judge certify", &args.labels).map_err(|e| anyhow!("{e}"))?;
    let rubric_bytes = fs::read(&args.rubric)
        .with_context(|| format!("failed to read rubric {}", args.rubric.display()))?;
    let labels_bytes = fs::read(&args.labels)
        .with_context(|| format!("failed to read labels {}", args.labels.display()))?;

    let reliability = parse_reliability(&args)?;
    let thresholds = CertificationThresholds {
        max_ece: args.max_ece,
        max_brier: args.max_brier,
        min_samples: args.min_samples,
    };
    let outcome = evaluate(&samples, &thresholds, reliability.as_ref());

    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0);
    let validity = Some(ValidityWindow {
        valid_from: epoch_to_rfc3339(now),
        valid_until: epoch_to_rfc3339(now + i64::from(args.valid_days) * 86_400),
    });

    let record = build_record(
        args.model.clone(),
        args.provider.clone(),
        hash_bytes(&rubric_bytes),
        hash_bytes(&labels_bytes),
        thresholds,
        outcome,
        validity,
        env!("CARGO_PKG_VERSION").to_string(),
    );

    let json = serde_json::to_string_pretty(&record).context("serialize certification record")?;
    match &args.output {
        Some(path) => {
            super::overwrite::ensure_overwritable(path, args.force)?;
            fs::write(path, format!("{json}\n"))
                .with_context(|| format!("failed to write {}", path.display()))?;
            let verdict = if record.certified {
                "certified"
            } else {
                "NOT certified"
            };
            println!("{verdict}: wrote {}", path.display());
        }
        None => println!("{json}"),
    }
    if !record.certified {
        for reason in &record.failure_reasons {
            eprintln!("not certified: {reason}");
        }
        return Ok(1);
    }
    Ok(0)
}

/// Parse the optional trusted-set reliability inputs. Both
/// `--observed-positive-rate` and `--reliability` must be present together or
/// both absent; one without the other is an error so a half-specified
/// correction never silently no-ops.
fn parse_reliability(args: &JudgeCertifyArgs) -> Result<Option<ReliabilityInput>> {
    match (args.observed_positive_rate, &args.reliability) {
        (None, None) => Ok(None),
        (Some(rate), Some(spec)) => {
            let (tp, fn_, tn, fp) = parse_confusion(spec)?;
            Ok(Some(ReliabilityInput {
                observed_positive_rate: rate,
                tp,
                fn_,
                tn,
                fp,
            }))
        }
        _ => Err(anyhow!(
            "--observed-positive-rate and --reliability must be given together"
        )),
    }
}

/// Parse `tp:90,fn:10,tn:80,fp:20` into the four confusion-matrix counts. Every
/// key must be present exactly once.
fn parse_confusion(spec: &str) -> Result<(u32, u32, u32, u32)> {
    let (mut tp, mut fn_, mut tn, mut fp) = (None, None, None, None);
    for part in spec.split(',') {
        let (key, value) = part.split_once(':').ok_or_else(|| {
            anyhow!("--reliability entry `{part}` must be `key:count` (e.g. tp:90)")
        })?;
        let count: u32 = value.trim().parse().with_context(|| {
            format!("--reliability count `{value}` is not a non-negative integer")
        })?;
        match key.trim() {
            "tp" => tp = Some(count),
            "fn" => fn_ = Some(count),
            "tn" => tn = Some(count),
            "fp" => fp = Some(count),
            other => return Err(anyhow!("--reliability has an unknown key `{other}`")),
        }
    }
    Ok((
        tp.ok_or_else(|| anyhow!("--reliability is missing `tp`"))?,
        fn_.ok_or_else(|| anyhow!("--reliability is missing `fn`"))?,
        tn.ok_or_else(|| anyhow!("--reliability is missing `tn`"))?,
        fp.ok_or_else(|| anyhow!("--reliability is missing `fp`"))?,
    ))
}

/// Format Unix epoch seconds as an RFC 3339 UTC timestamp, dependency-free.
/// Uses the civil-from-days algorithm so the validity window is human-readable
/// without pulling in a date crate. Mirrors the inverse `rfc3339_to_epoch` the
/// evidence verifier uses.
fn epoch_to_rfc3339(secs: i64) -> String {
    let days = secs.div_euclid(86_400);
    let rem = secs.rem_euclid(86_400);
    let (hour, minute, second) = (rem / 3600, (rem % 3600) / 60, rem % 60);
    // Howard Hinnant's civil_from_days: days since 1970-01-01 to (y, m, d).
    let z = days + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let year = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let day = doy - (153 * mp + 2) / 5 + 1;
    let month = if mp < 10 { mp + 3 } else { mp - 9 };
    let year = if month <= 2 { year + 1 } else { year };
    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}

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

    #[test]
    fn epoch_zero_is_the_unix_epoch() {
        assert_eq!(epoch_to_rfc3339(0), "1970-01-01T00:00:00Z");
    }

    #[test]
    fn a_known_epoch_formats_correctly() {
        // Verified against `date -u -r <epoch>`.
        assert_eq!(epoch_to_rfc3339(1_781_740_800), "2026-06-18T00:00:00Z");
        assert_eq!(epoch_to_rfc3339(1_700_000_000), "2023-11-14T22:13:20Z");
    }

    #[test]
    fn confusion_parses_all_four_counts() {
        assert_eq!(
            parse_confusion("tp:90,fn:10,tn:80,fp:20").unwrap(),
            (90, 10, 80, 20)
        );
    }

    #[test]
    fn confusion_rejects_a_missing_key() {
        assert!(parse_confusion("tp:1,fn:2,tn:3").is_err());
    }

    #[test]
    fn confusion_rejects_a_bad_count() {
        assert!(parse_confusion("tp:x,fn:2,tn:3,fp:4").is_err());
    }

    fn args_with(rate: Option<f64>, reliability: Option<&str>) -> JudgeCertifyArgs {
        JudgeCertifyArgs {
            labels: Path::new("labels.jsonl").to_path_buf(),
            rubric: Path::new("rubric.md").to_path_buf(),
            model: None,
            provider: None,
            max_ece: 0.10,
            max_brier: 0.25,
            min_samples: 10,
            observed_positive_rate: rate,
            reliability: reliability.map(str::to_string),
            valid_days: 90,
            output: None,
            force: false,
        }
    }

    #[test]
    fn reliability_requires_both_inputs_together() {
        assert!(parse_reliability(&args_with(Some(0.5), None)).is_err());
        assert!(parse_reliability(&args_with(None, Some("tp:1,fn:1,tn:1,fp:1"))).is_err());
        assert!(parse_reliability(&args_with(None, None)).unwrap().is_none());
        assert!(
            parse_reliability(&args_with(Some(0.5), Some("tp:1,fn:1,tn:1,fp:1")))
                .unwrap()
                .is_some()
        );
    }
}
