use super::util::current_dir;
use koala_workflow::{
    classify, enforce, fail_closed, from_git, render_size_report, EnforceInput, Thresholds,
};
use std::path::PathBuf;
use std::process::ExitCode;

pub fn size(root: Option<PathBuf>, base: String, merge_base: bool) -> ExitCode {
    let root = root.unwrap_or_else(current_dir);
    let summary = match from_git(&root, &base, merge_base) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("error: {e}");
            return ExitCode::from(1);
        }
    };
    let size = classify(&summary, &Thresholds::default());
    print!("{}", render_size_report(&summary, size));
    if merge_base {
        println!("(diff: {base}...HEAD)");
    } else {
        println!("(diff: {base}..HEAD)");
    }
    ExitCode::SUCCESS
}

/// `koala-core workflow enforce` — auto-detects the current branch's
/// state vs `base` and reports which required gates aren't satisfied.
/// Defaults to fail-open for small/medium (silence is OK), fail-closed
/// for large/architectural.
pub fn enforce_cmd(
    root: Option<PathBuf>,
    base: String,
    merge_base: bool,
    human_review_flag: bool,
    from_gh_pr: Option<u32>,
) -> ExitCode {
    let root = root.unwrap_or_else(current_dir);
    let (human_review, gh_source) = resolve_human_review(human_review_flag, from_gh_pr);
    let summary = match from_git(&root, &base, merge_base) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("error: {e}");
            return ExitCode::from(1);
        }
    };
    let size = classify(&summary, &Thresholds::default());

    // Artifacts: any `.review/round-*` markdown file changed by this PR.
    let artifacts = changed_files(&root, &base, merge_base, ".review/round-").unwrap_or_default();
    // Feature changes: basename (without `.md`) of `wiki/features/*.md`
    // touched. Drives `koala.feature-sync` only — ADR evidence is kept
    // separate below so a mere ADR citation can't satisfy feature-sync.
    let feature_files =
        changed_files(&root, &base, merge_base, "wiki/features/").unwrap_or_default();
    let feature_changes: Vec<String> = feature_files
        .iter()
        .filter_map(|p| {
            let leaf = p.split('/').next_back()?.trim_end_matches(".md");
            if leaf.starts_with('_') {
                None
            } else {
                Some(leaf.to_string())
            }
        })
        .collect();
    // ADR evidence for `koala.adr`. Two forms count: a touched
    // `wiki/decisions/<NNNN>-...md` (the PR adds/supersedes an ADR), or
    // an `ADR-NNNN` reference in a commit message or touched feature doc
    // that resolves to an *accepted* ADR (the PR implements an existing
    // one — see issue #21).
    let mut adr_evidence: Vec<String> = changed_files(&root, &base, merge_base, "wiki/decisions/")
        .unwrap_or_default()
        .into_iter()
        .filter_map(|p| {
            let leaf = p.split('/').next_back()?.trim_end_matches(".md");
            if leaf.starts_with('_') {
                None
            } else {
                Some(format!("adr-{leaf}"))
            }
        })
        .collect();
    adr_evidence.extend(referenced_accepted_adrs(&root, &base, &feature_files));

    println!("PR size: {size}");
    println!("  artifacts:        {} touched", artifacts.len());
    println!("  feature changes:  {} touched", feature_changes.len());
    println!("  adr evidence:     {} found", adr_evidence.len());
    println!("  human review:     {human_review}{gh_source}");
    println!(
        "  default policy:   {}",
        if fail_closed(size) {
            "fail-closed"
        } else {
            "fail-open"
        }
    );
    println!();

    let findings = enforce(&EnforceInput {
        size,
        artifacts: &artifacts,
        feature_changes: &feature_changes,
        adr_evidence: &adr_evidence,
        human_review,
    });
    if findings.is_empty() {
        println!("✓ all required gates satisfied");
        ExitCode::SUCCESS
    } else {
        println!("✗ {} gate(s) blocking:", findings.len());
        for f in &findings {
            println!("  ✗ {}", f.gate.id);
            for line in f.reason.lines() {
                println!("        {line}");
            }
        }
        ExitCode::from(1)
    }
}

fn changed_files(
    root: &std::path::Path,
    base: &str,
    merge_base: bool,
    prefix: &str,
) -> Option<Vec<String>> {
    let separator = if merge_base { "..." } else { ".." };
    let range = format!("{base}{separator}HEAD");
    let out = std::process::Command::new("git")
        .arg("-C")
        .arg(root)
        .args(["diff", "--name-only", &range, "--"])
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    let s = String::from_utf8_lossy(&out.stdout);
    Some(
        s.lines()
            .filter(|l| l.starts_with(prefix))
            .map(|l| l.to_string())
            .collect(),
    )
}

/// Scan commit messages in `base..HEAD` and the contents of the given
/// touched `wiki/features/*.md` files for `ADR-NNNN` / `adr-NNNN`
/// references, returning an `adr-<NNNN>` entry for each distinct
/// reference that resolves to an **accepted** ADR in the repo's
/// decision registry. Dangling or non-accepted references are dropped:
/// the gate must not be satisfiable by citing a fake or rejected ADR.
fn referenced_accepted_adrs(
    root: &std::path::Path,
    base: &str,
    feature_files: &[String],
) -> Vec<String> {
    let mut text = commit_messages(root, base).unwrap_or_default();
    for rel in feature_files {
        if let Ok(contents) = std::fs::read_to_string(root.join(rel)) {
            text.push('\n');
            text.push_str(&contents);
        }
    }
    // `0*(\d+)` strips zero-padding so `ADR-0006` parses to 6.
    let re = regex::Regex::new(r"(?i)adr-0*(\d+)").expect("static regex");
    let mut ids: Vec<u32> = re
        .captures_iter(&text)
        .filter_map(|c| c.get(1)?.as_str().parse::<u32>().ok())
        .collect();
    ids.sort_unstable();
    ids.dedup();
    if ids.is_empty() {
        return Vec::new();
    }
    let Ok(registry) = koala_adr::Registry::load(root) else {
        return Vec::new();
    };
    ids.into_iter()
        .filter(|id| {
            registry
                .find(*id)
                .is_some_and(|e| e.frontmatter.status == koala_adr::Status::Accepted)
        })
        .map(|id| format!("adr-{id:04}"))
        .collect()
}

/// Commit messages (subject + body) for commits unique to HEAD vs
/// `base`. Two-dot range regardless of merge-base diff semantics — we
/// want commits introduced by the branch, not a symmetric difference.
fn commit_messages(root: &std::path::Path, base: &str) -> Option<String> {
    let range = format!("{base}..HEAD");
    let out = std::process::Command::new("git")
        .arg("-C")
        .arg(root)
        .args(["log", "--format=%B", &range])
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    Some(String::from_utf8_lossy(&out.stdout).into_owned())
}

/// Decide the `human_review` boolean. Precedence:
/// 1. `--from-gh-pr <N>` wins if present and `gh` is reachable.
/// 2. Otherwise the explicit `--human-review` flag.
///
/// Returns the bool plus a printable provenance string for the
/// terminal output.
fn resolve_human_review(flag: bool, gh_pr: Option<u32>) -> (bool, String) {
    let Some(pr) = gh_pr else {
        return (flag, String::new());
    };
    match gh_pr_review_decision(pr) {
        Ok(decision) => {
            let approved = decision == "APPROVED";
            (approved, format!(" (gh PR #{pr}: {decision})"))
        }
        Err(e) => {
            eprintln!("warning: gh PR lookup failed ({e}); falling back to --human-review flag");
            (flag, String::from(" (gh lookup failed)"))
        }
    }
}

fn gh_pr_review_decision(pr: u32) -> Result<String, String> {
    // `KOALA_GH_BIN` overrides the gh binary path. Tests use this to
    // install a stub script; CI / dev rely on the default `gh` lookup.
    let bin = std::env::var("KOALA_GH_BIN").unwrap_or_else(|_| "gh".into());
    let out = std::process::Command::new(&bin)
        .args([
            "pr",
            "view",
            &pr.to_string(),
            "--json",
            "reviewDecision",
            "--jq",
            ".reviewDecision",
        ])
        .output()
        .map_err(|e| format!("spawn `{bin}`: {e}"))?;
    if !out.status.success() {
        return Err(format!(
            "gh exit {}: {}",
            out.status,
            String::from_utf8_lossy(&out.stderr).trim()
        ));
    }
    let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
    if s.is_empty() || s == "null" {
        // PR exists but no review decision yet.
        Ok("PENDING".to_string())
    } else {
        Ok(s)
    }
}
