//! The merge-verify gate — the soundness boundary of the Foreman pattern.
//!
//! Under the Path-B design (see
//! `docs/proposals/verified-parallel-coding-orchestrator.md` §7), static
//! footprint analysis is only an *advisory* scheduling hint. Correctness comes
//! entirely from this gate, which verifies each farmed-out worktree *before* its
//! changes are integrated.
//!
//! ## Fail-closed by construction
//!
//! The cardinal rule (enforced after @neo/@linus review of the first cut): a
//! verdict of [`MergeVerdict::Accepted`] requires **positive evidence** that the
//! required checks ran and affirmed. The *absence* of a failure is never
//! acceptance. Concretely:
//!
//! - A build/test that was **not configured** ⇒ [`MergeVerdict::Inconclusive`],
//!   never accepted — unless the caller supplies an explicit, audited
//!   [`NoVerifyWaiver`] (e.g. a docs-only change), which yields
//!   `Accepted { basis: Waived }`.
//! - An unparseable changed file, a policy denial, or any "we don't know" state
//!   resolves toward reject/inconclusive, not accept.
//!
//! ## Checks
//!
//! 1. **AST-diff containment** — [`car_ast::diff_symbols`] tells us which symbols
//!    the worktree actually changed; any outside the subtask's declared footprint
//!    is a violation (a non-deterministic agent editing beyond what it promised).
//!    Advisory: skipped when no footprint was declared.
//! 2. **Duplicate-declaration scan** — two definitions of the same `(name, kind)`
//!    introduced in one file (a CodeCRDT semantic-conflict class that physical
//!    worktree isolation cannot catch).
//! 3. **Policy consult** — the integration is checked against the shared
//!    [`PolicyEngine`] as a *gating input* (it can deny the merge), not just an
//!    audit. This is the "policy-aware" differentiator vs a bare `git merge`.
//! 4. **Build/test gate** — the load-bearing soundness leg; the compiler is also
//!    our broken-reference detector (which is why we don't hand-roll an unsound
//!    one). Runs only if the AST checks passed and policy allowed.
//! 5. **Audit** — every verdict is appended to the shared [`EventLog`] as a
//!    `GateAccepted` / `GateRejected` event, carrying the evidence provenance B3
//!    needs to *attribute* (not merely count) false-accepts.
//!
//! The pure verification logic ([`extract_changes`], [`containment_violations`],
//! [`duplicate_declarations`], [`decide`]) takes already-extracted content and is
//! fully unit-testable without git. [`verify_changes`] orchestrates the I/O
//! (build/test command, policy consult, audit emission); deriving [`FileChange`]s
//! from a real git worktree is the caller's job (B2).

use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::time::Duration;

use car_ast::{diff_symbols, parse_file, SymbolChange, SymbolKind};
use car_eventlog::EventKind;
use car_ir::{Action, ActionType, FailureBehavior};
use serde_json::{json, Value};

use crate::shared::SharedInfra;

// One canonical `(file, symbol)` DTO lives in car-ast (next to the symbol index
// and the footprint scheduler). The gate's containment and the B4 scheduler
// share it, so the planner (B5) populates a single schema, not two.
pub use car_ast::SymbolRef;

/// What a subtask declared it would change. Advisory: an empty footprint means
/// "no declaration", which disables the containment check (but never enables
/// acceptance on its own — the build/test gate is still required).
#[derive(Debug, Clone, Default)]
pub struct DeclaredFootprint {
    allowed: HashSet<SymbolRef>,
}

impl DeclaredFootprint {
    /// A footprint that declares nothing (containment check disabled).
    pub fn unconstrained() -> Self {
        Self::default()
    }

    pub fn from_refs(refs: impl IntoIterator<Item = SymbolRef>) -> Self {
        Self {
            allowed: refs.into_iter().collect(),
        }
    }

    pub fn is_declared(&self) -> bool {
        !self.allowed.is_empty()
    }

    pub fn allows(&self, r: &SymbolRef) -> bool {
        self.allowed.contains(r)
    }
}

/// One file's before/after content as observed in a worktree. `None` content
/// means the file did not exist on that side (whole-file add or delete).
#[derive(Debug, Clone)]
pub struct FileChange {
    pub path: String,
    pub before: Option<String>,
    pub after: Option<String>,
}

impl FileChange {
    fn content_changed(&self) -> bool {
        self.before.as_deref() != self.after.as_deref()
    }
}

/// How a symbol changed between before and after.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeKind {
    Added,
    Removed,
    Modified,
    SignatureChanged,
}

/// A symbol the worktree actually changed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangedSymbol {
    pub file: String,
    pub symbol: String,
    pub change: ChangeKind,
}

impl ChangedSymbol {
    fn as_ref(&self) -> SymbolRef {
        SymbolRef::new(self.file.clone(), self.symbol.clone())
    }
}

/// A changed symbol that fell outside the declared footprint.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContainmentViolation {
    pub changed: ChangedSymbol,
}

/// Two definitions of the same `(name, kind)` in one file after integration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DuplicateDeclaration {
    pub file: String,
    pub symbol: String,
    pub kind: String,
    pub count: usize,
}

/// Whether a check ran and what it found. `NotRun` is never acceptance-eligible.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckOutcome {
    Passed,
    Failed,
    NotRun,
}

/// Outcome of the build/test leg. `NotConfigured` (nobody supplied a command)
/// and `NotRun` (skipped because an earlier check already failed or policy
/// denied) are deliberately distinct from each other and from `Passed` — so the
/// verdict logic and the audit trail can never confuse "forgot" with "ran".
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BuildTestStatus {
    NotConfigured,
    NotRun {
        reason: String,
    },
    Passed,
    Failed {
        code: Option<i32>,
        /// Combined stdout+stderr tail, bounded.
        output: String,
    },
}

/// Whether policy permits the integration. A `Deny` is absolute.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyDecision {
    Allow,
    Deny { reasons: Vec<String> },
}

/// An explicit, audited waiver letting a legitimately test-less change (e.g.
/// docs-only) be accepted without a build/test run. This is the *only* way a
/// missing build/test can yield acceptance — it must be deliberately
/// constructed by the caller, never defaulted.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NoVerifyWaiver {
    pub class: String,
    pub reason: String,
}

/// Why a worktree was accepted. There is no way to construct this from "nothing
/// failed" — it requires either a passed build/test or an explicit waiver.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AcceptanceBasis {
    /// Build/test ran and passed.
    Verified,
    /// No build/test, but an explicit [`NoVerifyWaiver`] authorized acceptance.
    Waived { class: String, reason: String },
}

/// The provenance of one gate run — what was checked, what ran, what each leg
/// found. B3 uses this to *attribute* false-accepts to a root cause, not merely
/// count them.
#[derive(Debug, Clone)]
pub struct GateEvidence {
    pub subtask: String,
    pub changed_symbols: Vec<ChangedSymbol>,
    pub footprint_declared: bool,
    pub containment: CheckOutcome,
    pub containment_violations: Vec<ContainmentViolation>,
    /// Files with a real content delta that `car-ast` could not parse (unknown
    /// extension or syntax error) — symbol-level checks are blind to these, so
    /// they rely entirely on the build/test leg.
    pub unparsed_changed_files: Vec<String>,
    pub duplicates: CheckOutcome,
    pub semantic_conflicts: Vec<DuplicateDeclaration>,
    pub build_test: BuildTestStatus,
    pub policy: PolicyDecision,
}

/// The gate's verdict on one farmed-out worktree. Three-valued and fail-closed:
/// `Inconclusive` means "we could not affirm safety" and must be treated by the
/// caller exactly like a rejection for integration purposes (it just signals a
/// different remediation — usually "configure a build/test", not "replan").
#[derive(Debug, Clone)]
pub enum MergeVerdict {
    Accepted {
        basis: AcceptanceBasis,
        evidence: GateEvidence,
    },
    Rejected {
        reasons: Vec<String>,
        evidence: GateEvidence,
    },
    Inconclusive {
        reasons: Vec<String>,
        evidence: GateEvidence,
    },
}

impl MergeVerdict {
    pub fn is_accepted(&self) -> bool {
        matches!(self, MergeVerdict::Accepted { .. })
    }

    /// True only for a `Verified` acceptance — a waiver-based acceptance is *not*
    /// build/test-verified. B3 distinguishes these.
    pub fn is_verified(&self) -> bool {
        matches!(
            self,
            MergeVerdict::Accepted {
                basis: AcceptanceBasis::Verified,
                ..
            }
        )
    }

    pub fn evidence(&self) -> &GateEvidence {
        match self {
            MergeVerdict::Accepted { evidence, .. }
            | MergeVerdict::Rejected { evidence, .. }
            | MergeVerdict::Inconclusive { evidence, .. } => evidence,
        }
    }

    fn audit_kind(&self) -> EventKind {
        if self.is_accepted() {
            EventKind::GateAccepted
        } else {
            EventKind::GateRejected
        }
    }
}

/// Configuration for a single gate run.
#[derive(Debug, Clone)]
pub struct GateConfig {
    /// Label for the subtask whose worktree is being verified (for audit).
    pub subtask: String,
    /// The integrated tree to run the build/test command in.
    pub cwd: PathBuf,
    /// Verify command (program + args), e.g. `["cargo", "test"]`. `None` ⇒ the
    /// build/test leg is `NotConfigured`, which can only be accepted via a
    /// `no_verify_waiver`.
    pub verify_command: Option<Vec<String>>,
    /// Explicit waiver for a legitimately test-less change. Without it, a missing
    /// build/test is `Inconclusive`, never accepted.
    pub no_verify_waiver: Option<NoVerifyWaiver>,
    /// Wall-clock budget for the verify command. On timeout the build/test leg
    /// resolves to `NotRun` ⇒ `Inconclusive` (we-don't-know), never `Verified`.
    pub verify_timeout: Duration,
    /// Max bytes of build/test output retained on failure.
    pub max_output_bytes: usize,
}

impl GateConfig {
    pub fn new(subtask: impl Into<String>, cwd: impl Into<PathBuf>) -> Self {
        Self {
            subtask: subtask.into(),
            cwd: cwd.into(),
            verify_command: None,
            no_verify_waiver: None,
            verify_timeout: Duration::from_secs(600),
            max_output_bytes: 8 * 1024,
        }
    }

    pub fn with_verify_command(mut self, cmd: Vec<String>) -> Self {
        self.verify_command = Some(cmd);
        self
    }

    pub fn with_no_verify_waiver(mut self, waiver: NoVerifyWaiver) -> Self {
        self.no_verify_waiver = Some(waiver);
        self
    }

    pub fn with_verify_timeout(mut self, timeout: Duration) -> Self {
        self.verify_timeout = timeout;
        self
    }
}

// ---- Pure verification core (no I/O — unit-testable) -----------------------

/// Extract the symbols each file change touched, plus the set of content-changed
/// files `car-ast` could not parse. A file with no content delta is ignored. A
/// file with a content delta whose present side fails to parse is recorded in
/// `unparsed_changed_files` (symbol checks are blind to it — only build/test
/// covers it).
pub fn extract_changes(changes: &[FileChange]) -> (Vec<ChangedSymbol>, Vec<String>) {
    let mut symbols = Vec::new();
    let mut unparsed = Vec::new();

    for change in changes {
        if !change.content_changed() {
            continue;
        }
        let before_opt = change
            .before
            .as_deref()
            .map(|s| parse_file(s, &change.path));
        let after_opt = change.after.as_deref().map(|s| parse_file(s, &change.path));

        // A *present* side that failed to parse means we cannot see this file's
        // symbols. Record it; the build/test gate is its only coverage.
        let before_failed = matches!(before_opt, Some(None));
        let after_failed = matches!(after_opt, Some(None));
        if before_failed || after_failed {
            unparsed.push(change.path.clone());
        }

        let before = before_opt.flatten();
        let after = after_opt.flatten();
        match (before, after) {
            (Some(old), Some(new)) => {
                for ch in diff_symbols(&old, &new) {
                    let (name, kind) = match ch {
                        SymbolChange::Added(s) => (s.name, ChangeKind::Added),
                        SymbolChange::Removed(s) => (s.name, ChangeKind::Removed),
                        SymbolChange::Modified {
                            new,
                            signature_changed,
                            ..
                        } => (
                            new.name,
                            if signature_changed {
                                ChangeKind::SignatureChanged
                            } else {
                                ChangeKind::Modified
                            },
                        ),
                    };
                    symbols.push(ChangedSymbol {
                        file: change.path.clone(),
                        symbol: name,
                        change: kind,
                    });
                }
            }
            (None, Some(new)) => {
                for s in new.all_symbols() {
                    symbols.push(ChangedSymbol {
                        file: change.path.clone(),
                        symbol: s.name.clone(),
                        change: ChangeKind::Added,
                    });
                }
            }
            (Some(old), None) => {
                for s in old.all_symbols() {
                    symbols.push(ChangedSymbol {
                        file: change.path.clone(),
                        symbol: s.name.clone(),
                        change: ChangeKind::Removed,
                    });
                }
            }
            (None, None) => {}
        }
    }
    (symbols, unparsed)
}

/// Flag changed symbols outside a declared footprint. Empty when no footprint
/// was declared (containment disabled).
pub fn containment_violations(
    changed: &[ChangedSymbol],
    footprint: &DeclaredFootprint,
) -> Vec<ContainmentViolation> {
    if !footprint.is_declared() {
        return Vec::new();
    }
    changed
        .iter()
        .filter(|c| !footprint.allows(&c.as_ref()))
        .map(|c| ContainmentViolation { changed: c.clone() })
        .collect()
}

/// Scan the integrated `after` side for duplicate declarations: two definitions
/// of the same `(name, kind)` in one file. Uses `all_symbols()` so method-level
/// duplicates inside `impl`/class blocks are caught (the common CodeCRDT case),
/// not just top-level ones. Cross-file duplicates are normal and not flagged.
pub fn duplicate_declarations(changes: &[FileChange]) -> Vec<DuplicateDeclaration> {
    let mut out = Vec::new();
    for change in changes {
        let Some(parsed) = change
            .after
            .as_deref()
            .and_then(|src| parse_file(src, &change.path))
        else {
            continue;
        };
        let mut counts: HashMap<(String, SymbolKind), usize> = HashMap::new();
        for sym in parsed.all_symbols() {
            // Imports legitimately repeat; definitions should not.
            if matches!(sym.kind, SymbolKind::Import) {
                continue;
            }
            *counts.entry((sym.name.clone(), sym.kind)).or_insert(0) += 1;
        }
        for ((name, kind), count) in counts {
            if count > 1 {
                out.push(DuplicateDeclaration {
                    file: change.path.clone(),
                    symbol: name,
                    kind: format!("{kind:?}"),
                    count,
                });
            }
        }
    }
    out
}

/// The pure acceptance decision. This is the most safety-critical logic in the
/// gate, so it lives here — pure and exhaustively testable — rather than inline
/// in the async orchestrator. Acceptance requires *affirmative* evidence;
/// every "we don't know" path resolves to `Rejected` or `Inconclusive`.
pub fn decide(evidence: GateEvidence, waiver: Option<&NoVerifyWaiver>) -> MergeVerdict {
    // 1. Policy denial is absolute.
    if let PolicyDecision::Deny { reasons } = &evidence.policy {
        let reasons = reasons
            .iter()
            .map(|r| format!("policy denied integration: {r}"))
            .collect();
        return MergeVerdict::Rejected { reasons, evidence };
    }

    // 2. Positive detection of a problem ⇒ reject.
    let mut reasons = Vec::new();
    for v in &evidence.containment_violations {
        reasons.push(format!(
            "changed {}::{} outside declared footprint",
            v.changed.file, v.changed.symbol
        ));
    }
    for d in &evidence.semantic_conflicts {
        reasons.push(format!(
            "{} duplicate {} declarations of {} in {}",
            d.count, d.kind, d.symbol, d.file
        ));
    }
    if let BuildTestStatus::Failed { code, output } = &evidence.build_test {
        reasons.push(format!(
            "build/test failed (exit {code:?}): {}",
            tail(output, 300)
        ));
    }
    if !reasons.is_empty() {
        return MergeVerdict::Rejected { reasons, evidence };
    }

    // 3. Affirmative acceptance — requires a passed build/test, OR an explicit
    //    waiver. Anything else is "we don't know" ⇒ Inconclusive (fail-closed).
    match &evidence.build_test {
        BuildTestStatus::Passed => MergeVerdict::Accepted {
            basis: AcceptanceBasis::Verified,
            evidence,
        },
        BuildTestStatus::NotConfigured => match waiver {
            Some(w) => MergeVerdict::Accepted {
                basis: AcceptanceBasis::Waived {
                    class: w.class.clone(),
                    reason: w.reason.clone(),
                },
                evidence,
            },
            None => MergeVerdict::Inconclusive {
                reasons: vec![
                    "build/test not configured and no waiver supplied — cannot affirm safety"
                        .to_string(),
                ],
                evidence,
            },
        },
        BuildTestStatus::NotRun { reason } => MergeVerdict::Inconclusive {
            reasons: vec![format!("build/test did not run: {reason}")],
            evidence,
        },
        // Unreachable: a Failed build/test was handled in step 2.
        BuildTestStatus::Failed { .. } => MergeVerdict::Rejected {
            reasons: vec!["build/test failed".to_string()],
            evidence,
        },
    }
}

// ---- Orchestration (I/O: policy consult, build/test command, audit) --------

/// Run the full gate on a worktree's changes and return a fail-closed verdict.
/// `infra` provides the shared policy engine (a gating input) and event log (the
/// audit trail). The build/test command runs only if the AST checks passed and
/// policy allowed — integrating a tree we already know is unsafe is wasted work.
pub async fn verify_changes(
    config: &GateConfig,
    changes: &[FileChange],
    footprint: &DeclaredFootprint,
    infra: &SharedInfra,
) -> MergeVerdict {
    let (changed_symbols, unparsed_changed_files) = extract_changes(changes);
    let containment_list = containment_violations(&changed_symbols, footprint);
    let duplicates = duplicate_declarations(changes);

    let containment = if !footprint.is_declared() {
        CheckOutcome::NotRun
    } else if containment_list.is_empty() {
        CheckOutcome::Passed
    } else {
        CheckOutcome::Failed
    };
    let duplicate_outcome = if duplicates.is_empty() {
        CheckOutcome::Passed
    } else {
        CheckOutcome::Failed
    };

    let policy = consult_policy(config, changes, infra).await;

    let ast_failed = !containment_list.is_empty() || !duplicates.is_empty();
    let build_test = if ast_failed {
        BuildTestStatus::NotRun {
            reason: "AST checks already failed".to_string(),
        }
    } else if let PolicyDecision::Deny { .. } = &policy {
        BuildTestStatus::NotRun {
            reason: "policy denied integration".to_string(),
        }
    } else {
        run_verify_command(config).await
    };

    let evidence = GateEvidence {
        subtask: config.subtask.clone(),
        changed_symbols,
        footprint_declared: footprint.is_declared(),
        containment,
        containment_violations: containment_list,
        unparsed_changed_files,
        duplicates: duplicate_outcome,
        semantic_conflicts: duplicates,
        build_test,
        policy,
    };

    let verdict = decide(evidence, config.no_verify_waiver.as_ref());
    emit_audit(&verdict, infra).await;
    verdict
}

/// Consult the shared [`PolicyEngine`] about integrating this worktree, modeled
/// as a `foreman.integrate` tool action carrying the changed file paths. This
/// lets operators write policies (e.g. deny merges touching `Cargo.lock` or a
/// protected path) that *gate* the merge, not merely observe it.
async fn consult_policy(
    config: &GateConfig,
    changes: &[FileChange],
    infra: &SharedInfra,
) -> PolicyDecision {
    let files: Vec<Value> = changes
        .iter()
        .filter(|c| c.content_changed())
        .map(|c| json!(c.path))
        .collect();
    let mut parameters = HashMap::new();
    parameters.insert("subtask".to_string(), json!(config.subtask));
    parameters.insert("files".to_string(), json!(files));

    let action = Action {
        id: format!("foreman-integrate-{}", config.subtask),
        action_type: ActionType::ToolCall,
        tool: Some("foreman.integrate".to_string()),
        parameters,
        preconditions: vec![],
        expected_effects: HashMap::new(),
        state_dependencies: vec![],
        read_set: vec![],
        write_set: vec![],
        assumptions: vec![],
        idempotent: true,
        max_retries: 0,
        failure_behavior: FailureBehavior::Skip,
        timeout_ms: None,
        metadata: HashMap::new(),
    };

    let violations = infra.policies.read().await.check(&action, &infra.state);
    if violations.is_empty() {
        PolicyDecision::Allow
    } else {
        PolicyDecision::Deny {
            reasons: violations
                .into_iter()
                .map(|v| format!("{}: {}", v.policy_name, v.reason))
                .collect(),
        }
    }
}

async fn run_verify_command(config: &GateConfig) -> BuildTestStatus {
    let Some(cmd) = &config.verify_command else {
        return BuildTestStatus::NotConfigured;
    };
    let Some((program, args)) = cmd.split_first() else {
        return BuildTestStatus::NotConfigured;
    };

    // Fail closed if the tree we were told to verify in doesn't exist. A stale or
    // wrong cwd that happens to build elsewhere must never yield acceptance.
    if !config.cwd.is_dir() {
        return BuildTestStatus::NotRun {
            reason: format!("verify cwd does not exist: {}", config.cwd.display()),
        };
    }

    let run = tokio::process::Command::new(program)
        .args(args)
        .current_dir(&config.cwd)
        // On timeout the future is dropped; kill the child so a hung verify
        // command can't linger as an orphan across a benchmark run.
        .kill_on_drop(true)
        .output();

    // A hung verify command resolves to NotRun (we-don't-know ⇒ Inconclusive),
    // never to a pass.
    let output = match tokio::time::timeout(config.verify_timeout, run).await {
        Ok(res) => res,
        Err(_) => {
            return BuildTestStatus::NotRun {
                reason: format!("verify command timed out after {:?}", config.verify_timeout),
            };
        }
    };

    match output {
        Ok(out) if out.status.success() => BuildTestStatus::Passed,
        Ok(out) => {
            let mut combined = String::from_utf8_lossy(&out.stdout).into_owned();
            combined.push_str(&String::from_utf8_lossy(&out.stderr));
            BuildTestStatus::Failed {
                code: out.status.code(),
                output: tail(&combined, config.max_output_bytes),
            }
        }
        Err(e) => BuildTestStatus::Failed {
            code: None,
            output: format!("failed to launch verify command: {e}"),
        },
    }
}

async fn emit_audit(verdict: &MergeVerdict, infra: &SharedInfra) {
    let evidence = verdict.evidence();
    let (outcome, basis, reasons) = match verdict {
        MergeVerdict::Accepted { basis, .. } => {
            let basis_str = match basis {
                AcceptanceBasis::Verified => "verified".to_string(),
                AcceptanceBasis::Waived { class, .. } => format!("waived:{class}"),
            };
            ("accepted", Some(basis_str), Vec::new())
        }
        MergeVerdict::Rejected { reasons, .. } => ("rejected", None, reasons.clone()),
        MergeVerdict::Inconclusive { reasons, .. } => ("inconclusive", None, reasons.clone()),
    };

    let mut data = HashMap::new();
    data.insert("subtask".to_string(), json!(evidence.subtask));
    data.insert("outcome".to_string(), json!(outcome));
    if let Some(basis) = basis {
        data.insert("basis".to_string(), json!(basis));
    }
    data.insert(
        "changed_symbols".to_string(),
        json!(evidence.changed_symbols.len()),
    );
    data.insert(
        "containment_violations".to_string(),
        json!(evidence.containment_violations.len()),
    );
    data.insert(
        "unparsed_changed_files".to_string(),
        json!(evidence.unparsed_changed_files),
    );
    data.insert(
        "semantic_conflicts".to_string(),
        json!(evidence.semantic_conflicts.len()),
    );
    data.insert(
        "build_test".to_string(),
        json!(match &evidence.build_test {
            BuildTestStatus::NotConfigured => "not_configured",
            BuildTestStatus::NotRun { .. } => "not_run",
            BuildTestStatus::Passed => "passed",
            BuildTestStatus::Failed { .. } => "failed",
        }),
    );
    if !reasons.is_empty() {
        data.insert("reasons".to_string(), json!(reasons));
    }

    infra
        .log
        .lock()
        .await
        .append(verdict.audit_kind(), None, None, data);
}

/// Keep the last `max_bytes` bytes of `s`, on a char boundary, prefixed when
/// truncated so a reader knows output was elided.
fn tail(s: &str, max_bytes: usize) -> String {
    if s.len() <= max_bytes {
        return s.to_string();
    }
    let mut start = s.len() - max_bytes;
    while start < s.len() && !s.is_char_boundary(start) {
        start += 1;
    }
    format!("…[truncated]\n{}", &s[start..])
}

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

    fn rs(path: &str, before: Option<&str>, after: Option<&str>) -> FileChange {
        FileChange {
            path: path.to_string(),
            before: before.map(str::to_string),
            after: after.map(str::to_string),
        }
    }

    // ---- pure core ----

    #[test]
    fn detects_modified_and_added_symbols() {
        let (changed, unparsed) = extract_changes(&[rs(
            "src/lib.rs",
            Some("pub fn alpha() {}\n"),
            Some("pub fn alpha() -> u8 { 1 }\npub fn beta() {}\n"),
        )]);
        assert!(unparsed.is_empty());
        let names: Vec<_> = changed.iter().map(|c| c.symbol.as_str()).collect();
        assert!(names.contains(&"alpha"), "alpha changed: {names:?}");
        assert!(names.contains(&"beta"), "beta added: {names:?}");
    }

    #[test]
    fn unparseable_changed_file_is_recorded() {
        // A .toml change car-ast cannot parse, with a real content delta.
        let (changed, unparsed) = extract_changes(&[rs(
            "Cargo.toml",
            Some("[package]\n"),
            Some("[package]\nx=1\n"),
        )]);
        assert!(changed.is_empty(), "no symbols from an unparseable file");
        assert_eq!(unparsed, vec!["Cargo.toml".to_string()]);
    }

    #[test]
    fn containment_flags_out_of_footprint_edits() {
        let (changed, _) = extract_changes(&[rs(
            "src/lib.rs",
            Some("pub fn allowed() {}\npub fn sneaky() {}\n"),
            Some("pub fn allowed() -> u8 { 1 }\npub fn sneaky() -> u8 { 2 }\n"),
        )]);
        let footprint = DeclaredFootprint::from_refs([SymbolRef::new("src/lib.rs", "allowed")]);
        let violations = containment_violations(&changed, &footprint);
        assert_eq!(violations.len(), 1);
        assert_eq!(violations[0].changed.symbol, "sneaky");
    }

    #[test]
    fn duplicate_declarations_catches_method_level() {
        // Two `handle` methods in one impl block — nested as children, so this
        // only passes if we scan all_symbols(), not just top-level.
        let after =
            "pub struct S;\nimpl S {\n  pub fn handle(&self) {}\n  pub fn handle(&self) {}\n}\n";
        let dups =
            duplicate_declarations(&[rs("src/lib.rs", Some("pub struct S;\n"), Some(after))]);
        assert!(
            dups.iter().any(|d| d.symbol == "handle" && d.count == 2),
            "method-level duplicate must be caught: {dups:?}"
        );
    }

    // ---- the decision rule (fail-closed) ----

    fn clean_evidence(build_test: BuildTestStatus) -> GateEvidence {
        GateEvidence {
            subtask: "t".to_string(),
            changed_symbols: vec![],
            footprint_declared: false,
            containment: CheckOutcome::NotRun,
            containment_violations: vec![],
            unparsed_changed_files: vec![],
            duplicates: CheckOutcome::Passed,
            semantic_conflicts: vec![],
            build_test,
            policy: PolicyDecision::Allow,
        }
    }

    #[test]
    fn skipped_build_test_is_inconclusive_not_accepted() {
        // THE fail-open hole the first cut had: no build/test must NOT accept.
        let verdict = decide(clean_evidence(BuildTestStatus::NotConfigured), None);
        assert!(
            matches!(verdict, MergeVerdict::Inconclusive { .. }),
            "unconfigured build/test must be inconclusive, got {verdict:?}"
        );
        assert!(!verdict.is_accepted());
    }

    #[test]
    fn passed_build_test_yields_verified_acceptance() {
        let verdict = decide(clean_evidence(BuildTestStatus::Passed), None);
        assert!(verdict.is_verified());
    }

    #[test]
    fn explicit_waiver_accepts_without_build_test_but_not_verified() {
        let waiver = NoVerifyWaiver {
            class: "docs-only".to_string(),
            reason: "README change".to_string(),
        };
        let verdict = decide(
            clean_evidence(BuildTestStatus::NotConfigured),
            Some(&waiver),
        );
        assert!(verdict.is_accepted(), "explicit waiver accepts");
        assert!(!verdict.is_verified(), "but it is NOT build/test-verified");
    }

    #[test]
    fn failed_build_test_rejects() {
        let verdict = decide(
            clean_evidence(BuildTestStatus::Failed {
                code: Some(101),
                output: "boom".to_string(),
            }),
            None,
        );
        assert!(matches!(verdict, MergeVerdict::Rejected { .. }));
    }

    #[test]
    fn policy_denial_rejects_even_with_passing_build() {
        let mut ev = clean_evidence(BuildTestStatus::Passed);
        ev.policy = PolicyDecision::Deny {
            reasons: vec!["protected path".to_string()],
        };
        let verdict = decide(ev, None);
        assert!(matches!(verdict, MergeVerdict::Rejected { .. }));
    }

    #[test]
    fn containment_violation_rejects_even_with_passing_build() {
        let mut ev = clean_evidence(BuildTestStatus::Passed);
        ev.containment_violations = vec![ContainmentViolation {
            changed: ChangedSymbol {
                file: "src/lib.rs".to_string(),
                symbol: "sneaky".to_string(),
                change: ChangeKind::Modified,
            },
        }];
        assert!(matches!(decide(ev, None), MergeVerdict::Rejected { .. }));
    }

    // ---- orchestration ----

    #[tokio::test]
    async fn verify_accepts_clean_change_with_passing_command_and_audits() {
        let infra = SharedInfra::new();
        let change = rs(
            "src/lib.rs",
            Some("pub fn a() {}\n"),
            Some("pub fn a() -> u8 { 1 }\n"),
        );
        let config = GateConfig::new("subtask-1", std::env::temp_dir())
            .with_verify_command(vec!["true".to_string()]);
        let verdict = verify_changes(
            &config,
            &[change],
            &DeclaredFootprint::unconstrained(),
            &infra,
        )
        .await;
        assert!(
            verdict.is_verified(),
            "clean change + passing build = verified"
        );
        let log = infra.log.lock().await;
        assert_eq!(log.events()[0].kind, EventKind::GateAccepted);
    }

    #[tokio::test]
    async fn verify_without_command_is_inconclusive() {
        let infra = SharedInfra::new();
        let change = rs(
            "src/lib.rs",
            Some("pub fn a() {}\n"),
            Some("pub fn a() -> u8 { 1 }\n"),
        );
        // No verify command, no waiver — must NOT accept.
        let config = GateConfig::new("subtask-2", std::env::temp_dir());
        let verdict = verify_changes(
            &config,
            &[change],
            &DeclaredFootprint::unconstrained(),
            &infra,
        )
        .await;
        assert!(!verdict.is_accepted());
        assert!(matches!(verdict, MergeVerdict::Inconclusive { .. }));
        let log = infra.log.lock().await;
        assert_eq!(log.events()[0].kind, EventKind::GateRejected);
    }

    #[tokio::test]
    async fn verify_rejects_containment_escape_and_skips_build() {
        let infra = SharedInfra::new();
        let change = rs(
            "src/lib.rs",
            Some("pub fn allowed() {}\npub fn sneaky() {}\n"),
            Some("pub fn allowed() {}\npub fn sneaky() -> u8 { 2 }\n"),
        );
        let footprint = DeclaredFootprint::from_refs([SymbolRef::new("src/lib.rs", "allowed")]);
        let config = GateConfig::new("subtask-3", std::env::temp_dir())
            .with_verify_command(vec!["true".to_string()]);
        let verdict = verify_changes(&config, &[change], &footprint, &infra).await;
        assert!(matches!(verdict, MergeVerdict::Rejected { .. }));
        // AST failure short-circuits the build/test.
        assert!(matches!(
            verdict.evidence().build_test,
            BuildTestStatus::NotRun { .. }
        ));
    }

    #[tokio::test]
    async fn policy_can_deny_integration() {
        let infra = SharedInfra::new();
        // Deny any foreman integration touching a protected path.
        infra.policies.write().await.register(
            "protect-cargo-lock",
            Box::new(|action: &Action, _| {
                let touches = action
                    .parameters
                    .get("files")
                    .and_then(|f| f.as_array())
                    .map(|arr| arr.iter().any(|v| v.as_str() == Some("Cargo.lock")))
                    .unwrap_or(false);
                if touches {
                    Some("integration touches protected Cargo.lock".to_string())
                } else {
                    None
                }
            }),
            "block merges touching Cargo.lock",
        );
        let change = rs("Cargo.lock", Some("a = 1\n"), Some("a = 2\n"));
        let config = GateConfig::new("subtask-4", std::env::temp_dir())
            .with_verify_command(vec!["true".to_string()]);
        let verdict = verify_changes(
            &config,
            &[change],
            &DeclaredFootprint::unconstrained(),
            &infra,
        )
        .await;
        assert!(matches!(verdict, MergeVerdict::Rejected { .. }));
    }

    #[tokio::test]
    async fn missing_verify_cwd_is_inconclusive_not_accepted() {
        let infra = SharedInfra::new();
        let change = rs(
            "src/lib.rs",
            Some("pub fn a() {}\n"),
            Some("pub fn a() -> u8 { 1 }\n"),
        );
        // A verify command IS configured, but the cwd does not exist — we cannot
        // run it, so we must not accept. Fail closed to Inconclusive.
        let config = GateConfig::new("subtask-5", "/nonexistent/foreman/tree")
            .with_verify_command(vec!["true".to_string()]);
        let verdict = verify_changes(
            &config,
            &[change],
            &DeclaredFootprint::unconstrained(),
            &infra,
        )
        .await;
        assert!(!verdict.is_accepted());
        assert!(matches!(verdict, MergeVerdict::Inconclusive { .. }));
    }
}
