//! `EvidenceBuilder` — the stateful builder that assembles a bundle.
//!
//! Lifecycle: `new` captures a `GitSnapshot` and creates the bundle
//! directory with a `<profile>-<ts>-<sha8>` name.
//! `hash_input` / `hash_output` / `record_command` / `run_capture`
//! accumulate content-layer state.
//! `finalize` writes `deterministic-manifest.json` + `SHA256SUMS`
//! (content layer) and then `index.json` (metadata layer), with a
//! TOCTOU re-check on `git_sha` so a mid-run repo mutation is caught.

use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use super::command::CommandRecord;
use super::command_failure::ToolCommandFailure;
use super::error::BuilderError;
use super::index::EvidenceIndex;
use super::outcome_record::TestOutcomeRecord;
use super::run_capture::run_capture as do_run_capture;
use super::test_summary::TestSummary;
use super::time::{utc_compact_stamp, utc_now_rfc3339};
use crate::git::{GitSnapshot, RealGitProvider};
use crate::hash::{hash_file_into, hash_file_relative_into, write_sha256sums};
use crate::policy::Profile;
use crate::traits::GitProvider;

mod config;
pub use config::EvidenceBuildConfig;

/// Builder for creating evidence bundles.
pub struct EvidenceBuilder {
    config: EvidenceBuildConfig,
    git_snapshot: GitSnapshot,
    git_provider: Box<dyn GitProvider>,
    bundle_dir: PathBuf,
    commands: Vec<CommandRecord>,
    inputs: BTreeMap<String, String>,
    outputs: BTreeMap<String, String>,
    test_summary: Option<TestSummary>,
    /// Per-test outcome records; non-empty triggers a
    /// `tests/test_outcomes.jsonl` artifact at finalize-adjacent
    /// time (see [`Self::write_test_outcomes`]).
    test_outcomes: Vec<TestOutcomeRecord>,
    /// Structural coverage report captured by the generate
    /// coverage phase. `None` when `--coverage=none` or the
    /// phase didn't run. When populated, aggregate percents
    /// feed the A-7 Obj-5/6 compliance evaluator.
    coverage_report: Option<crate::CoverageReport>,
    /// Captured-subprocess failures — non-zero exits from any
    /// `run_capture`'d command (cargo test, cargo check, etc.).
    /// Drives [`EvidenceIndex::bundle_complete`] (empty ⇒
    /// `true`) and the verify-time cross-check that a cert/
    /// record bundle carries no recorded failures.
    tool_command_failures: Vec<ToolCommandFailure>,
}

impl EvidenceBuilder {
    /// Create a new evidence builder. Uses the real git
    /// provider; for testing use [`Self::new_with_provider`].
    pub fn new(config: EvidenceBuildConfig) -> Result<Self, BuilderError> {
        Self::new_with_provider(config, RealGitProvider)
    }

    /// Create a new evidence builder with a custom git provider
    /// for tests. The provider feeds the initial snapshot and
    /// the TOCTOU re-check at [`Self::finalize`] time.
    pub fn new_with_provider<G: GitProvider + 'static>(
        config: EvidenceBuildConfig,
        provider: G,
    ) -> Result<Self, BuilderError> {
        // Determine strict mode from profile (cert and record require strictness)
        let strict = matches!(config.profile, Profile::Cert | Profile::Record);

        // Snapshot git state at the START (strict mode for cert/record)
        let git_snapshot = GitSnapshot::capture_with(&provider, strict)?;

        // Check for shallow clone (shared with CLI's preflight).
        crate::git::check_shallow_clone()?;

        // Check git clean requirements
        if (config.require_clean_git || config.fail_on_dirty) && git_snapshot.dirty {
            let dirty_files = match provider.dirty_files() {
                Ok(files) => files,
                Err(e) => {
                    // Git reported dirty via `git_snapshot.dirty` above, but
                    // we couldn't list the files. The cert check still fires
                    // (error returned below); surface the list-failure root
                    // cause so the audit trail can reconstruct why the user
                    // got a "dirty tree" error with no file list attached.
                    tracing::warn!(
                        error = %e,
                        "git reported dirty tree but could not list dirty files; \
                         error message will omit the file list"
                    );
                    Vec::new()
                }
            };
            let suffix = if dirty_files.is_empty() {
                String::new()
            } else {
                let capped: Vec<_> = dirty_files.iter().take(10).cloned().collect();
                let more = if dirty_files.len() > 10 {
                    format!("\n  ... and {} more", dirty_files.len() - 10)
                } else {
                    String::new()
                };
                format!(
                    "\n\nDirty files:\n  {}{}\n\nTo fix:\n  git add <files> && git commit -m \"...\"\n\nTo override (dev only):\n  cargo xtask evidence --profile dev",
                    capped.join("\n  "),
                    more
                )
            };
            return Err(BuilderError::DirtyGitTree {
                profile: config.profile,
                suffix,
            });
        }

        // Create bundle directory with profile prefix, timestamp, and SHA.
        // Format: <profile>-<YYYYMMDD-HHMMSSZ>-<sha8>
        // The profile prefix makes it visually obvious which profile generated
        // the bundle and prevents accidental submission of dev bundles as cert.
        let ts = utc_compact_stamp();
        let sha_short = if git_snapshot.sha.len() >= 8 {
            &git_snapshot.sha[..8]
        } else {
            &git_snapshot.sha
        };
        let bundle_dir = config
            .output_root
            .join(format!("{}-{}-{}", config.profile, ts, sha_short));

        if bundle_dir.exists() {
            return Err(BuilderError::BundleExists { path: bundle_dir });
        }

        fs::create_dir_all(&bundle_dir).map_err(|source| BuilderError::Io {
            op: "creating",
            path: bundle_dir.clone(),
            source,
        })?;
        let tests_dir = bundle_dir.join("tests");
        fs::create_dir_all(&tests_dir).map_err(|source| BuilderError::Io {
            op: "creating",
            path: tests_dir,
            source,
        })?;
        let trace_dir = bundle_dir.join("trace");
        fs::create_dir_all(&trace_dir).map_err(|source| BuilderError::Io {
            op: "creating",
            path: trace_dir,
            source,
        })?;

        Ok(Self {
            config,
            git_snapshot,
            git_provider: Box::new(provider),
            bundle_dir,
            commands: Vec::new(),
            inputs: BTreeMap::new(),
            outputs: BTreeMap::new(),
            test_summary: None,
            test_outcomes: Vec::new(),
            coverage_report: None,
            tool_command_failures: Vec::new(),
        })
    }

    /// Record a captured-subprocess failure. Called by
    /// [`Self::run_capture`] on non-zero exit and by phase
    /// orchestrators that caught a [`BuilderError::RunCommand`]
    /// spawn failure.
    ///
    /// Non-empty `tool_command_failures` flips
    /// [`EvidenceIndex::bundle_complete`] to `false` at
    /// [`Self::finalize`] time; verify cross-checks that
    /// cert/record bundles never ship with recorded failures.
    pub fn record_command_failure(&mut self, failure: ToolCommandFailure) {
        self.tool_command_failures.push(failure);
    }

    /// Read-only view of the captured-subprocess failures.
    /// Callers (the CLI's generate-exit-code logic) use this to
    /// decide whether to propagate a non-zero exit on cert/
    /// record profile even when the bundle assembled cleanly.
    pub fn tool_command_failures(&self) -> &[ToolCommandFailure] {
        &self.tool_command_failures
    }

    /// Get the bundle directory path.
    pub fn bundle_dir(&self) -> &Path {
        &self.bundle_dir
    }

    /// Hash a file and add to inputs.
    pub fn hash_input(&mut self, path: &str) -> Result<(), BuilderError> {
        Ok(hash_file_into(&mut self.inputs, path)?)
    }

    /// Hash a file with relative path and add to outputs.
    pub fn hash_output(&mut self, path: &Path) -> Result<(), BuilderError> {
        Ok(hash_file_relative_into(
            &mut self.outputs,
            path,
            &self.bundle_dir,
        )?)
    }

    /// Record a command execution.
    pub fn record_command(&mut self, record: CommandRecord) {
        self.commands.push(record);
    }

    /// Run a command, capture its output, write stdout/stderr
    /// to the bundle, and record any non-zero exit on
    /// `self.tool_command_failures`. Library layer — no
    /// presentation logging (CLI owns severity/format).
    pub fn run_capture(
        &mut self,
        cmd: Command,
        rel_dir: &str,
        output_name_base: &str,
        display_name: &str,
    ) -> Result<(Vec<u8>, Vec<u8>), BuilderError> {
        let outcome = do_run_capture(
            cmd,
            rel_dir,
            output_name_base,
            display_name,
            &self.bundle_dir,
        )?;
        self.commands.push(outcome.record);
        if let Some(failure) = outcome.failure {
            self.tool_command_failures.push(failure);
        }
        Ok((outcome.stdout_norm, outcome.stderr_norm))
    }

    /// Write the inputs hashes file.
    pub fn write_inputs(&self) -> Result<PathBuf, BuilderError> {
        let path = self.bundle_dir.join("inputs_hashes.json");
        let bytes =
            serde_json::to_vec_pretty(&self.inputs).map_err(|source| BuilderError::Serialize {
                kind: "inputs_hashes.json",
                source,
            })?;
        fs::write(&path, bytes).map_err(|source| BuilderError::Io {
            op: "writing",
            path: path.clone(),
            source,
        })?;
        Ok(path)
    }

    /// Write the outputs hashes file.
    pub fn write_outputs(&self) -> Result<PathBuf, BuilderError> {
        let path = self.bundle_dir.join("outputs_hashes.json");
        let bytes =
            serde_json::to_vec_pretty(&self.outputs).map_err(|source| BuilderError::Serialize {
                kind: "outputs_hashes.json",
                source,
            })?;
        fs::write(&path, bytes).map_err(|source| BuilderError::Io {
            op: "writing",
            path: path.clone(),
            source,
        })?;
        Ok(path)
    }

    /// Write the commands file.
    pub fn write_commands(&self) -> Result<PathBuf, BuilderError> {
        let path = self.bundle_dir.join("commands.json");
        let bytes = serde_json::to_vec_pretty(&self.commands).map_err(|source| {
            BuilderError::Serialize {
                kind: "commands.json",
                source,
            }
        })?;
        fs::write(&path, bytes).map_err(|source| BuilderError::Io {
            op: "writing",
            path: path.clone(),
            source,
        })?;
        Ok(path)
    }

    /// Store test results for inclusion in the evidence index.
    pub fn set_test_summary(&mut self, summary: TestSummary) {
        self.test_summary = Some(summary);
    }

    /// Store per-test outcome records; consumed by
    /// [`Self::write_test_outcomes`].
    pub fn set_test_outcomes(&mut self, outcomes: Vec<TestOutcomeRecord>) {
        self.test_outcomes = outcomes;
    }

    /// `true` iff records were captured. Used by the compliance
    /// generator to upgrade A-7 Obj-3/Obj-4 Partial → Met.
    pub fn has_test_outcomes(&self) -> bool {
        !self.test_outcomes.is_empty()
    }

    /// Accessor for the coverage-facing impl in
    /// `builder_coverage.rs`. The sibling module extends
    /// [`EvidenceBuilder`] with `set_coverage_report` +
    /// aggregate getters without pushing this file past the
    /// 500-line workspace limit.
    pub(super) fn coverage_report_ref(&self) -> Option<&crate::CoverageReport> {
        self.coverage_report.as_ref()
    }

    pub(super) fn coverage_report_mut(&mut self) -> &mut Option<crate::CoverageReport> {
        &mut self.coverage_report
    }

    /// Populate `requirement_uids` on each stored
    /// [`TestOutcomeRecord`] by joining against the trace's
    /// [`crate::trace::TestEntry`] list. Call after
    /// [`Self::set_test_outcomes`] and before
    /// [`Self::write_test_outcomes`] so the serialized JSONL
    /// carries the back-links.
    pub fn enrich_test_outcomes_with_llrs(&mut self, test_entries: &[crate::trace::TestEntry]) {
        crate::trace::resolve_llr_backlinks(&mut self.test_outcomes, test_entries);
    }

    /// Serialize records to `tests/test_outcomes.jsonl`. Call
    /// before [`Self::finalize`] so `write_sha256sums` covers
    /// the file.
    pub fn write_test_outcomes(&self) -> Result<Option<PathBuf>, BuilderError> {
        super::outcome_record::write_outcomes_jsonl(&self.bundle_dir, &self.test_outcomes)
    }

    /// Pass/fail verdict derived from the stored `TestSummary`.
    ///
    /// - `None` when no test run was recorded (`cargo test` was
    ///   skipped, the command failed to execute, or
    ///   `parse_cargo_test_output` could not find a result line).
    /// - `Some(true)` when `failed == 0`.
    /// - `Some(false)` when any test failed.
    ///
    /// Note the asymmetry with "tests present": a summary with
    /// `total == 0` reports `Some(true)` — there were no failures
    /// because there were no tests. Callers that care about the
    /// distinction should check `test_summary` directly.
    pub fn tests_passed(&self) -> Option<bool> {
        self.test_summary.as_ref().map(|s| s.failed == 0)
    }

    /// Finalize the bundle by writing SHA256SUMS (content layer) then index.json (metadata layer).
    ///
    /// The two-layer design ensures determinism:
    /// 1. SHA256SUMS is written first, covering all content-layer files (everything
    ///    except `index.json` and `SHA256SUMS` itself).
    /// 2. The `content_hash` is the SHA-256 of the SHA256SUMS file contents.
    /// 3. `index.json` is written last with `content_hash` embedded. Because
    ///    `index.json` is excluded from SHA256SUMS, timestamps do not affect
    ///    the content hash.
    pub fn finalize(&self, trace_outputs: Vec<PathBuf>) -> Result<PathBuf, BuilderError> {
        // TOCTOU check: verify git HEAD hasn't changed since builder was created.
        // A changed HEAD means source files may have been modified between the
        // initial snapshot and finalize, invalidating the evidence chain.
        if self.git_snapshot.sha != "unknown" {
            if let Ok(current_sha) = self.git_provider.sha() {
                let current_sha = current_sha.trim().to_string();
                if current_sha != self.git_snapshot.sha {
                    return Err(BuilderError::Toctou {
                        snapshot_sha: self.git_snapshot.sha.clone(),
                        current_sha,
                    });
                }
            }
        }

        let ts = utc_now_rfc3339();
        let sha256sums_path = self.bundle_dir.join("SHA256SUMS");

        // Step 1: Project env.json onto the cross-host-stable subset
        // and write `deterministic-manifest.json`. The manifest is
        // the committed artifact whose hash becomes the cross-host
        // reproducibility contract; writing it before SHA256SUMS is
        // assembled means `write_sha256sums` picks it up for free
        // and the integrity chain binds it like any other content
        // file.
        let env_path = self.bundle_dir.join("env.json");
        let env_bytes = fs::read(&env_path).map_err(|source| BuilderError::Io {
            op: "reading",
            path: env_path.clone(),
            source,
        })?;
        let env_fp: crate::env::EnvFingerprint =
            serde_json::from_slice(&env_bytes).map_err(BuilderError::ParseEnv)?;
        let manifest = env_fp.deterministic_manifest();
        let manifest_path = self.bundle_dir.join("deterministic-manifest.json");
        let manifest_bytes =
            serde_json::to_vec_pretty(&manifest).map_err(|source| BuilderError::Serialize {
                kind: "deterministic-manifest.json",
                source,
            })?;
        fs::write(&manifest_path, manifest_bytes).map_err(|source| BuilderError::Io {
            op: "writing",
            path: manifest_path.clone(),
            source,
        })?;

        // Step 1.5: When boundary policy claims `forbid_build_rs` or
        // `forbid_proc_macros`, write a deterministic projection of
        // `cargo metadata --format-version 1` into the bundle as
        // `cargo_metadata.json`. The artifact lets verify-time re-run
        // the same checks against the bundle the bundle claimed at
        // generate time (LLR-072). Land it before `write_sha256sums`
        // so the integrity chain auto-binds it like every other
        // content file.
        if self.config.boundary_policy.forbid_build_rs
            || self.config.boundary_policy.forbid_proc_macros
        {
            write_cargo_metadata_projection(&self.bundle_dir)?;
        }

        // Step 2: Write SHA256SUMS covering the content layer only.
        // index.json does not exist yet so it is naturally excluded.
        write_sha256sums(&self.bundle_dir, &sha256sums_path)?;

        // Step 3: Compute full content_hash and the narrower
        // deterministic_hash.
        let content_hash = crate::hash::sha256_file(&sha256sums_path)?;
        let deterministic_hash = crate::hash::sha256_file(&manifest_path)?;

        // Step 4: Build and write index.json (metadata layer).
        let idx = EvidenceIndex {
            schema_version: crate::schema_versions::INDEX.to_string(),
            boundary_schema_version: crate::schema_versions::BOUNDARY.to_string(),
            trace_schema_version: crate::schema_versions::TRACE.to_string(),
            profile: self.config.profile,
            timestamp_rfc3339: ts,
            git_sha: self.git_snapshot.sha.clone(),
            git_branch: self.git_snapshot.branch.clone(),
            git_dirty: self.git_snapshot.dirty,
            engine_crate_version: env!("CARGO_PKG_VERSION").to_string(),
            engine_git_sha: env!("EVIDENCE_ENGINE_GIT_SHA").to_string(),
            engine_build_source: env!("EVIDENCE_ENGINE_BUILD_SOURCE").to_string(),
            inputs_hashes_file: "inputs_hashes.json".to_string(),
            outputs_hashes_file: "outputs_hashes.json".to_string(),
            commands_file: "commands.json".to_string(),
            env_fingerprint_file: "env.json".to_string(),
            trace_roots: self.config.trace_roots.clone(),
            trace_outputs: trace_outputs
                .iter()
                .map(|p| {
                    crate::util::normalize_bundle_path(
                        p.strip_prefix(&self.bundle_dir).unwrap_or(p),
                    )
                })
                .collect(),
            bundle_complete: self.tool_command_failures.is_empty(),
            content_hash,
            deterministic_hash,
            test_summary: self.test_summary.clone(),
            tool_command_failures: self.tool_command_failures.clone(),
            dal_map: self
                .config
                .dal_map
                .iter()
                .map(|(k, v)| (k.clone(), v.to_string()))
                .collect(),
            boundary_policy: self.config.boundary_policy.clone(),
        };

        let index_path = self.bundle_dir.join("index.json");
        let index_bytes =
            serde_json::to_vec_pretty(&idx).map_err(|source| BuilderError::Serialize {
                kind: "index.json",
                source,
            })?;
        fs::write(&index_path, index_bytes).map_err(|source| BuilderError::Io {
            op: "writing",
            path: index_path.clone(),
            source,
        })?;

        Ok(self.bundle_dir.clone())
    }
}

mod cargo_metadata_artifact;
use cargo_metadata_artifact::write_cargo_metadata_projection;
