//! The serializable wire contract for the Foreman pipeline (B6 wire-freeze).
//!
//! The internal gate/harness/planner types stay free to evolve; THESE versioned
//! DTOs are the stable shape the daemon JSON-RPC and the four FFI binding
//! surfaces expose. Conversions are one-way (internal → DTO).
//!
//! Wire-freeze hygiene (from the B5 review), so this can be frozen at release:
//! - **String discriminants** on every enum (`snake_case` serde tags), never
//!   positional/integer — adding a variant can't shift another's meaning.
//! - A **`schema_version`** on the top-level report.
//! - **Structured** rejection evidence (typed containment / duplicate / build
//!   fields), not flattened prose — this is what a future post-gate replan reads.
//! - A forward-compatible **`unknown`** escape variant on each externally-matched
//!   enum, so an older client doesn't hard-fail on a newer server's state.
//! - The scheduler's **`ExpandedFootprint` is never representable here** — only
//!   the gate's verdict and declared `(file, symbol)` refs — so "scheduler output
//!   never feeds acceptance" holds on the wire, not just in Rust.

use serde::{Deserialize, Serialize};

use super::gate::{AcceptanceBasis, BuildTestStatus, GateEvidence, MergeVerdict, PolicyDecision};
use super::harness::{IntegrationBlame, IntegrationResult, Subtask, SubtaskOutcome};
use super::planner::DecomposeResult;

/// Bump when the serialized shape changes incompatibly.
pub const FOREMAN_SCHEMA_VERSION: u32 = 1;

/// A `(file, symbol)` location on the wire.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct SymbolRefDto {
    pub file: String,
    pub symbol: String,
}

/// A duplicate-declaration finding on the wire.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DuplicateDto {
    pub file: String,
    pub symbol: String,
    pub kind: String,
    pub count: usize,
}

/// The build/test leg outcome.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum BuildTestDto {
    NotConfigured,
    NotRun {
        reason: String,
    },
    Passed,
    /// `output` is the bounded stdout+stderr tail — a coding orchestrator's user
    /// (and a replan loop) needs the *why*, not just an exit code.
    Failed {
        code: Option<i32>,
        output: String,
    },
    #[serde(other)]
    Unknown,
}

/// Why a worktree was accepted.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AcceptanceBasisDto {
    Verified,
    Waived {
        class: String,
        reason: String,
    },
    #[serde(other)]
    Unknown,
}

/// The gate's evidence — structured, so a consumer (or replan) can attribute a
/// verdict without parsing prose.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GateEvidenceDto {
    pub subtask: String,
    pub changed_symbol_count: usize,
    pub footprint_declared: bool,
    pub containment_violations: Vec<SymbolRefDto>,
    pub unparsed_changed_files: Vec<String>,
    pub semantic_conflicts: Vec<DuplicateDto>,
    pub build_test: BuildTestDto,
    /// `None` = policy allowed; `Some(reasons)` = denied (reasons preserved,
    /// symmetric with the verdict's own `reasons`).
    pub policy_denied: Option<Vec<String>>,
}

/// The gate's verdict on a worktree (per-subtask or the integrated union).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum MergeVerdictDto {
    Accepted {
        basis: AcceptanceBasisDto,
        evidence: GateEvidenceDto,
    },
    Rejected {
        reasons: Vec<String>,
        evidence: GateEvidenceDto,
    },
    Inconclusive {
        reasons: Vec<String>,
        evidence: GateEvidenceDto,
    },
    /// A verdict outcome this version doesn't recognize (forward-compat). It is
    /// **lossy** — the unrecognized payload is not preserved — and **must** be
    /// treated as non-accepting by every consumer. Use [`is_accepting`] rather
    /// than matching, so an unknown future state can never be merged.
    ///
    /// [`is_accepting`]: MergeVerdictDto::is_accepting
    #[serde(other)]
    Unknown,
}

impl MergeVerdictDto {
    /// Fail-closed acceptance predicate: ONLY `Accepted` accepts. Every other
    /// state — Rejected, Inconclusive, and the forward-compat `Unknown` —
    /// returns `false`, so a consumer gating on this can never merge an
    /// unrecognized verdict.
    pub fn is_accepting(&self) -> bool {
        matches!(self, MergeVerdictDto::Accepted { .. })
    }
}

/// One farmed-out subtask's outcome.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubtaskReportDto {
    pub id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub verdict: Option<MergeVerdictDto>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

/// A patch that failed to apply, attributed to its subtask + targeted files.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApplyConflictDto {
    pub subtask_id: String,
    pub files: Vec<String>,
    pub detail: String,
}

/// A union duplicate declaration. `candidate_subtask_ids` are the subtasks whose
/// patches touched the offending *file* — candidates, not proven culprits (file
/// granularity can implicate a subtask that edited the file but not the symbol).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DuplicateBlameDto {
    pub file: String,
    pub symbol: String,
    pub candidate_subtask_ids: Vec<String>,
}

/// The union build/test failure that rejected the merge. `candidate_subtask_ids`
/// is the whole integrated set — a build failure can't be localized further
/// without mapping test output back to a symbol (not done).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildTestFailureDto {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub code: Option<i32>,
    pub output_tail: String,
    pub candidate_subtask_ids: Vec<String>,
}

/// Structured attribution of *why* a union integration failed — the wire form of
/// the harness `IntegrationBlame`. What a post-gate regional replan reads to retry
/// the failing region, and what a UI reads to show "why did this run fail".
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IntegrationBlameDto {
    pub apply_conflicts: Vec<ApplyConflictDto>,
    pub duplicate_conflicts: Vec<DuplicateBlameDto>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub build_test: Option<BuildTestFailureDto>,
}

/// The integrated-union outcome.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IntegrationReportDto {
    pub applied: usize,
    pub apply_conflicts: Vec<String>,
    pub integrated_cleanly: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub verdict: Option<MergeVerdictDto>,
    /// Structured failure attribution — present only when the union did not
    /// integrate cleanly.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub blame: Option<IntegrationBlameDto>,
}

/// The full report of a Foreman run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ForemanReport {
    pub schema_version: u32,
    pub subtasks: Vec<SubtaskReportDto>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub integration: Option<IntegrationReportDto>,
}

impl ForemanReport {
    /// Build the wire report from a farm-out's outcomes and (optional)
    /// integration result.
    pub fn from_run(outcomes: &[SubtaskOutcome], integration: Option<&IntegrationResult>) -> Self {
        Self {
            schema_version: FOREMAN_SCHEMA_VERSION,
            subtasks: outcomes.iter().map(SubtaskReportDto::from).collect(),
            integration: integration.map(IntegrationReportDto::from),
        }
    }
}

/// One planned subtask on the wire — carries only the DECLARED `(file, symbol)`
/// footprint, never the scheduler's expanded blast radius.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlanSubtaskDto {
    pub id: String,
    pub prompt: String,
    pub files: Vec<String>,
    pub writes: Vec<SymbolRefDto>,
    pub reads: Vec<SymbolRefDto>,
}

/// The decomposition plan — the SCHEDULER surface, deliberately distinct from
/// [`MergeVerdictDto`] (the gate surface) so a consumer can never mistake a
/// scheduling hint (`levels`, `prefer_single_session`) for a safety verdict.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlanReport {
    pub schema_version: u32,
    /// `true` when a valid plan was produced (parsed + no declared conflicts).
    pub valid: bool,
    /// Advisory: farming out buys no parallel speedup — prefer a single session.
    pub prefer_single_session: bool,
    pub attempts: u32,
    /// Parse / decomposition issues (empty when `valid`).
    pub issues: Vec<String>,
    /// Scheduled levels (subtask ids) — a SCHEDULING hint, not a verdict.
    pub levels: Vec<Vec<String>>,
    pub subtasks: Vec<PlanSubtaskDto>,
}

impl From<&DecomposeResult> for PlanReport {
    fn from(r: &DecomposeResult) -> Self {
        Self {
            schema_version: FOREMAN_SCHEMA_VERSION,
            valid: r.is_valid(),
            prefer_single_session: r.prefer_single_session,
            attempts: r.attempts,
            issues: r.issues.clone(),
            levels: r.levels.clone(),
            subtasks: r.subtasks.iter().map(PlanSubtaskDto::from).collect(),
        }
    }
}

impl From<&Subtask> for PlanSubtaskDto {
    fn from(s: &Subtask) -> Self {
        // Sort symbol refs for deterministic wire output (footprint sets are
        // unordered). Declared footprint only — the expanded set is never here.
        let (mut writes, mut reads): (Vec<SymbolRefDto>, Vec<SymbolRefDto>) = match &s.footprint {
            Some(fp) => (
                fp.writes
                    .iter()
                    .map(|r| SymbolRefDto {
                        file: r.file.clone(),
                        symbol: r.symbol.clone(),
                    })
                    .collect(),
                fp.reads
                    .iter()
                    .map(|r| SymbolRefDto {
                        file: r.file.clone(),
                        symbol: r.symbol.clone(),
                    })
                    .collect(),
            ),
            None => (Vec::new(), Vec::new()),
        };
        writes.sort();
        reads.sort();
        Self {
            id: s.id.clone(),
            prompt: s.prompt.clone(),
            files: s.files.clone(),
            writes,
            reads,
        }
    }
}

// ---- one-way conversions (internal → DTO) ----

impl From<&BuildTestStatus> for BuildTestDto {
    fn from(s: &BuildTestStatus) -> Self {
        match s {
            BuildTestStatus::NotConfigured => BuildTestDto::NotConfigured,
            BuildTestStatus::NotRun { reason } => BuildTestDto::NotRun {
                reason: reason.clone(),
            },
            BuildTestStatus::Passed => BuildTestDto::Passed,
            BuildTestStatus::Failed { code, output } => BuildTestDto::Failed {
                code: *code,
                output: output.clone(),
            },
        }
    }
}

impl From<&AcceptanceBasis> for AcceptanceBasisDto {
    fn from(b: &AcceptanceBasis) -> Self {
        match b {
            AcceptanceBasis::Verified => AcceptanceBasisDto::Verified,
            AcceptanceBasis::Waived { class, reason } => AcceptanceBasisDto::Waived {
                class: class.clone(),
                reason: reason.clone(),
            },
        }
    }
}

impl From<&GateEvidence> for GateEvidenceDto {
    fn from(e: &GateEvidence) -> Self {
        // Destructure so a new field on GateEvidence is a COMPILE ERROR here, not
        // a silent omission from the frozen wire shape. Adding a field becomes a
        // deliberate decision: wire it, or `_`-ignore it on purpose.
        let GateEvidence {
            subtask,
            changed_symbols,
            footprint_declared,
            containment: _, // a CheckOutcome summary; the violations carry the detail
            containment_violations,
            unparsed_changed_files,
            duplicates: _, // a CheckOutcome summary; semantic_conflicts carry the detail
            semantic_conflicts,
            build_test,
            policy,
        } = e;
        Self {
            subtask: subtask.clone(),
            changed_symbol_count: changed_symbols.len(),
            footprint_declared: *footprint_declared,
            containment_violations: containment_violations
                .iter()
                .map(|v| SymbolRefDto {
                    file: v.changed.file.clone(),
                    symbol: v.changed.symbol.clone(),
                })
                .collect(),
            unparsed_changed_files: unparsed_changed_files.clone(),
            semantic_conflicts: semantic_conflicts
                .iter()
                .map(|d| DuplicateDto {
                    file: d.file.clone(),
                    symbol: d.symbol.clone(),
                    kind: d.kind.clone(),
                    count: d.count,
                })
                .collect(),
            build_test: BuildTestDto::from(build_test),
            policy_denied: match policy {
                PolicyDecision::Deny { reasons } => Some(reasons.clone()),
                PolicyDecision::Allow => None,
            },
        }
    }
}

impl From<&MergeVerdict> for MergeVerdictDto {
    fn from(v: &MergeVerdict) -> Self {
        match v {
            MergeVerdict::Accepted { basis, evidence } => MergeVerdictDto::Accepted {
                basis: AcceptanceBasisDto::from(basis),
                evidence: GateEvidenceDto::from(evidence),
            },
            MergeVerdict::Rejected { reasons, evidence } => MergeVerdictDto::Rejected {
                reasons: reasons.clone(),
                evidence: GateEvidenceDto::from(evidence),
            },
            MergeVerdict::Inconclusive { reasons, evidence } => MergeVerdictDto::Inconclusive {
                reasons: reasons.clone(),
                evidence: GateEvidenceDto::from(evidence),
            },
        }
    }
}

impl From<&SubtaskOutcome> for SubtaskReportDto {
    fn from(o: &SubtaskOutcome) -> Self {
        Self {
            id: o.subtask_id.clone(),
            verdict: o.verdict.as_ref().map(MergeVerdictDto::from),
            error: o.error.clone(),
        }
    }
}

// Destructure each source struct (no `..`) so a future field addition is a
// compile error here — a silently-dropped wire field is the failure mode this
// pattern exists to prevent (matches `GateEvidenceDto::from`).
impl From<&super::harness::ApplyConflict> for ApplyConflictDto {
    fn from(c: &super::harness::ApplyConflict) -> Self {
        let super::harness::ApplyConflict {
            subtask_id,
            files,
            detail,
        } = c;
        Self {
            subtask_id: subtask_id.clone(),
            files: files.clone(),
            detail: detail.clone(),
        }
    }
}

impl From<&super::harness::DuplicateBlame> for DuplicateBlameDto {
    fn from(d: &super::harness::DuplicateBlame) -> Self {
        let super::harness::DuplicateBlame {
            file,
            symbol,
            candidate_subtask_ids,
        } = d;
        Self {
            file: file.clone(),
            symbol: symbol.clone(),
            candidate_subtask_ids: candidate_subtask_ids.clone(),
        }
    }
}

impl From<&super::harness::BuildTestFailure> for BuildTestFailureDto {
    fn from(f: &super::harness::BuildTestFailure) -> Self {
        let super::harness::BuildTestFailure {
            code,
            output_tail,
            candidate_subtask_ids,
        } = f;
        Self {
            code: *code,
            output_tail: output_tail.clone(),
            candidate_subtask_ids: candidate_subtask_ids.clone(),
        }
    }
}

impl From<&IntegrationBlame> for IntegrationBlameDto {
    fn from(b: &IntegrationBlame) -> Self {
        let IntegrationBlame {
            apply_conflicts,
            duplicate_conflicts,
            build_test,
        } = b;
        Self {
            apply_conflicts: apply_conflicts.iter().map(ApplyConflictDto::from).collect(),
            duplicate_conflicts: duplicate_conflicts
                .iter()
                .map(DuplicateBlameDto::from)
                .collect(),
            build_test: build_test.as_ref().map(BuildTestFailureDto::from),
        }
    }
}

impl From<&IntegrationResult> for IntegrationReportDto {
    fn from(r: &IntegrationResult) -> Self {
        Self {
            applied: r.applied,
            apply_conflicts: r.apply_conflicts.clone(),
            integrated_cleanly: r.integrated_cleanly(),
            verdict: r.verdict.as_ref().map(MergeVerdictDto::from),
            blame: r.blame.as_ref().map(IntegrationBlameDto::from),
        }
    }
}

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

    fn evidence() -> GateEvidenceDto {
        GateEvidenceDto {
            subtask: "a".into(),
            changed_symbol_count: 1,
            footprint_declared: true,
            containment_violations: vec![],
            unparsed_changed_files: vec![],
            semantic_conflicts: vec![],
            build_test: BuildTestDto::Passed,
            policy_denied: None,
        }
    }

    #[test]
    fn verdict_uses_stable_string_discriminants() {
        let v = MergeVerdictDto::Accepted {
            basis: AcceptanceBasisDto::Verified,
            evidence: evidence(),
        };
        let json = serde_json::to_value(&v).unwrap();
        assert_eq!(json["outcome"], "accepted");
        assert_eq!(json["basis"]["kind"], "verified");
        assert_eq!(json["evidence"]["build_test"]["status"], "passed");
    }

    #[test]
    fn report_carries_schema_version_and_round_trips() {
        let report = ForemanReport {
            schema_version: FOREMAN_SCHEMA_VERSION,
            subtasks: vec![SubtaskReportDto {
                id: "a".into(),
                verdict: Some(MergeVerdictDto::Rejected {
                    reasons: vec!["build/test failed".into()],
                    evidence: evidence(),
                }),
                error: None,
            }],
            integration: Some(IntegrationReportDto {
                applied: 1,
                apply_conflicts: vec![],
                integrated_cleanly: true,
                verdict: None,
                blame: None,
            }),
        };
        let json = serde_json::to_string(&report).unwrap();
        assert!(json.contains("\"schema_version\":1"));
        let back: ForemanReport = serde_json::from_str(&json).unwrap();
        assert_eq!(report, back, "DTO round-trips losslessly");
    }

    #[test]
    fn failure_output_and_policy_reasons_survive_the_wire() {
        let mut ev = evidence();
        ev.build_test = BuildTestDto::Failed {
            code: Some(101),
            output: "error[E0308]: mismatched types".into(),
        };
        ev.policy_denied = Some(vec!["protected path".into()]);
        let json = serde_json::to_value(&ev).unwrap();
        assert_eq!(
            json["build_test"]["output"],
            "error[E0308]: mismatched types"
        );
        assert_eq!(json["policy_denied"][0], "protected path");
    }

    #[test]
    fn is_accepting_is_fail_closed() {
        let accepted = MergeVerdictDto::Accepted {
            basis: AcceptanceBasisDto::Verified,
            evidence: evidence(),
        };
        assert!(accepted.is_accepting());
        assert!(!MergeVerdictDto::Unknown.is_accepting());
        assert!(!MergeVerdictDto::Inconclusive {
            reasons: vec![],
            evidence: evidence()
        }
        .is_accepting());
    }

    #[test]
    fn unknown_variant_is_forward_compatible() {
        // A future server emits an outcome this client doesn't know.
        let json = r#"{"outcome":"quarantined","evidence":{}}"#;
        let v: MergeVerdictDto = serde_json::from_str(json).unwrap();
        assert_eq!(
            v,
            MergeVerdictDto::Unknown,
            "unknown outcome degrades, not panics"
        );

        let bt: BuildTestDto = serde_json::from_str(r#"{"status":"sandboxed"}"#).unwrap();
        assert_eq!(bt, BuildTestDto::Unknown);
    }
}
