//! `cordance pack` — the core compilation pipeline.

use std::collections::HashMap;
use std::io::Read;

use anyhow::{Context, Result};
use camino::Utf8PathBuf;
use cordance_core::lock::SourceLock;
use cordance_core::pack::{CordancePack, DoctrinePin, PackTargets, ProjectIdentity};
use sha2::{Digest, Sha256};

pub struct PackConfig {
    pub target: Utf8PathBuf,
    pub output_mode: OutputMode,
    pub selected_targets: PackTargets,
    /// Override for the doctrine root. When `None`, resolved from `cordance.toml`
    /// (defaulting to `../engineering-doctrine` relative to `target`).
    pub doctrine_root: Option<Utf8PathBuf>,
    /// Optional override for the LLM provider. `None` means "fall back to
    /// `cordance.toml [llm].provider`". The string `"none"` disables LLM
    /// enrichment even when the config file enables it.
    pub llm_provider: Option<String>,
    /// Optional override for the Ollama model name. `None` means "use the
    /// adapter default" (`qwen2.5-coder:14b`). Only consulted when the resolved
    /// provider is `ollama`.
    pub ollama_model: Option<String>,
    /// When `true`, suppress human-readable progress output on stdout
    /// (e.g. "would write: AGENTS.md (2301 bytes)"). The MCP server path
    /// MUST set this — stdout is reserved for JSON-RPC frames.
    pub quiet: bool,
}

#[derive(Clone, Copy, PartialEq, Eq)]
pub enum OutputMode {
    Write,
    DryRun,
    Diff,
}

impl OutputMode {
    pub fn from_str(s: &str) -> Self {
        match s {
            "dry-run" => Self::DryRun,
            "diff" => Self::Diff,
            _ => Self::Write,
        }
    }
}

// Pack pipeline orchestrator. Round-4 codereview-8 flagged this for v1.0
// extraction into a `PackPipeline` trait; until that lands, the function is
// intentionally a sequence of well-commented steps (config → scan → doctrine
// → IR → LLM enrichment → emitters → lock → advise → evidence map) and the
// line count reflects pipeline depth, not nesting depth.
#[allow(clippy::too_many_lines)]
pub fn run(config: &PackConfig) -> Result<CordancePack> {
    // 0. Load project config. Strict: a malformed `cordance.toml` must fail
    //    the build loudly with the file path attached so the operator can
    //    fix it. Silent downgrade-to-defaults was the round-2 bughunt #3
    //    finding.
    let cfg = crate::config::Config::load_strict(&config.target)
        .with_context(|| format!("loading cordance.toml at {}", config.target))?;

    // 1. Detect project name + kind
    let name = detect_project_name(&config.target);
    let kind = detect_project_kind(&config.target);
    let host_os = std::env::consts::OS.to_string();
    // The axiom version is the LATEST pin cordance compiles against. We
    // capture it now so the source lock can detect axiom-algorithm drift
    // even on empty packs (see ADR 0014 / BUILD_SPEC §13).
    let axiom_pin = Some(cfg.axiom_version(&config.target));

    // 2. Scan sources. Capture any scan error so we can record it on the
    //    pack's residual_risk audit trail instead of silently degrading to
    //    an empty source list (the failure mode flagged by codereview
    //    CRITICAL — `unwrap_or_default()` makes "scan broke" look identical
    //    to "no files").
    //
    //    Round-5 bughunt CRITICAL R5-bughunt-1: drop blocked entries from the
    //    pack IR. `cordance_scan::scan_repo` surfaces blocked surfaces with
    //    `blocked: true` so `cordance scan` can render them in the operator
    //    audit report, but `pack.sources` must not contain them — otherwise
    //    every blocked file's hash would be embedded in `pack.json`, and the
    //    cordance-internal `.cordance/*` artifacts (now blocked-by-default)
    //    would create a hash self-reference loop where consecutive
    //    `cordance pack` runs produce byte-different `pack.json` outputs.
    let (sources, blocked_count, scan_error) = match cordance_scan::scan_repo(&config.target) {
        Ok(all) => {
            let total = all.len();
            let unblocked: Vec<_> = all.into_iter().filter(|r| !r.blocked).collect();
            let blocked_n = total - unblocked.len();
            if blocked_n > 0 {
                tracing::debug!(
                    blocked = blocked_n,
                    total,
                    "cordance pack: dropped blocked entries from pack.sources"
                );
            }
            (unblocked, blocked_n, None)
        }
        Err(e) => {
            tracing::warn!(error = %e, "cordance pack: source scan failed");
            (vec![], 0, Some(e.to_string()))
        }
    };
    let _ = blocked_count; // reserved for a future residual_risk note if needed

    // 3. Resolve doctrine root: explicit override wins, then config, then default.
    // Keep the configured source string (unresolved) for use in DoctrinePin.source_path
    // so that generated files don't embed absolute machine paths.
    let doctrine_source_str = config
        .doctrine_root
        .as_ref()
        .map_or_else(|| cfg.doctrine.source.clone(), |p| p.as_str().to_string());
    let doctrine_root = config
        .doctrine_root
        .clone()
        .unwrap_or_else(|| cfg.doctrine_root(&config.target));

    // 4. Load doctrine.
    //
    // Round-3 redteam #2: the previous call to `load_doctrine_or_default`
    // bypassed every hardening that lives in `load_doctrine_with_fallback`:
    //  - fallback URL validation (https-only, no userinfo, no `..`, no IP
    //    literals, no IDNA homograph silently rewriting to github.com),
    //  - SHA-keyed cache directories that defeat short-prefix collision,
    //  - explicit-pin verification against the cached HEAD,
    //  - "auto"-pin self-consistency check (round-3 bughunt #4).
    // Those were only exercised in unit tests. We now route the
    // production path through the hardened loader so a missing sibling
    // triggers a properly-validated HTTPS clone, and a tampered cache is
    // caught before any prose is read.
    //
    // A doctrine failure is non-fatal — packs still produce useful output
    // without doctrine entries — but it MUST appear on the audit trail so
    // the operator can see we silently skipped doctrine. The fallback URL
    // and the optional pin come from cordance.toml; `"auto"` is mapped to
    // `None` so `load_doctrine_with_fallback` treats it as "no pin
    // assertion" but still applies the self-consistency cross-check.
    let pin_commit: Option<&str> = if cfg.doctrine.pin_commit == "auto" {
        None
    } else {
        Some(cfg.doctrine.pin_commit.as_str())
    };
    let mut doctrine_load_warning: Option<String> = None;
    // Doctrine cache must live OUTSIDE the target tree. Round-3 scoped it to
    // `<target>/.cordance/cache/doctrine/` for per-target isolation, but that
    // exposed round-4 redteam #1: a hostile target can pre-populate that path
    // with a self-consistent fake git repo (same dirname as its HEAD commit)
    // and the auto-pin loader will load it without any network call. Moving
    // the cache to an operator-trusted location (`dirs::cache_dir()` namespace,
    // resolved by `cordance_core::paths::doctrine_cache_dir_for_url`) makes
    // it attacker-uncontrollable. Each fallback URL gets its own
    // sha256-prefixed subdirectory so different operators / forks don't
    // share cache state.
    let doctrine_cache_dir =
        cordance_core::paths::doctrine_cache_dir_for_url(&cfg.doctrine.fallback_repo);
    let doctrine_idx = match cordance_doctrine::load_doctrine_with_fallback(
        &doctrine_root,
        &cfg.doctrine.fallback_repo,
        Some(&doctrine_cache_dir),
        pin_commit,
    ) {
        Ok(idx) => idx,
        Err(err) => {
            tracing::warn!(error = %err, "doctrine load failed; using empty index");
            doctrine_load_warning = Some(format!("doctrine load failed: {err}"));
            cordance_doctrine::DoctrineIndex::empty(doctrine_root.clone())
        }
    };
    let doctrine_pins: Vec<DoctrinePin> = if let Some(commit) = &doctrine_idx.commit {
        vec![DoctrinePin {
            repo: doctrine_idx.repo.clone(),
            commit: commit.clone(),
            // Use the configured source string (relative) to avoid absolute machine paths.
            // Normalise to forward slashes so the path is portable across host OSes.
            source_path: Utf8PathBuf::from(
                format!("{doctrine_source_str}/doctrine/SEMANTIC_INDEX.md")
                    .replace('\\', "/"),
            ),
        }]
    } else {
        vec![]
    };

    // 5. Build the pack IR
    let mut pack = CordancePack {
        schema: cordance_core::schema::CORDANCE_PACK_V1.into(),
        project: ProjectIdentity {
            name,
            repo_root: config.target.clone(),
            kind,
            host_os,
            axiom_pin,
        },
        sources,
        doctrine_pins,
        targets: config.selected_targets.clone(),
        outputs: vec![],
        source_lock: SourceLock::empty(),
        advise: cordance_core::advise::AdviseReport::empty(),
        residual_risk: vec!["claim_ceiling=candidate".into()],
    };

    // 5a. If the source scan failed, surface that loudly on the audit trail.
    //     The pack still runs to completion (downstream emitters tolerate an
    //     empty source set), but the residual_risk note guarantees the
    //     condition is never silently invisible.
    if let Some(err_msg) = scan_error {
        pack.residual_risk
            .push(format!("scan error: {err_msg}"));
    }

    // 5a-bis. Same treatment for doctrine load failures (round-3 redteam #2).
    //     The pack still ships, but the operator can see we ran with an
    //     empty doctrine index and which underlying error caused it
    //     (network failure vs. pin mismatch vs. URL rejected vs. tampered
    //     cache). This is the audit-trail half of "fail loudly without
    //     aborting the pipeline".
    if let Some(warning) = doctrine_load_warning {
        pack.residual_risk.push(warning);
    }

    // 5b. Optional LLM enrichment. ADR 0002: bounded candidate prose only.
    //     This runs BEFORE emitters and writes only to `pack.residual_risk`
    //     and to a side-channel file (`.cordance/llm-candidate.json`).
    //     LLM output MUST NEVER replace emitter content.
    maybe_run_llm(config, &cfg, &mut pack);

    // 6. Collect target emitters (AGENTS.md, CLAUDE.md, .cursor/, .codex/,
    //    harness-target) and dispatch. Pack.json is dispatched separately
    //    AFTER pack.outputs is populated (round-7 codereview #1 CRITICAL).
    let emitters = build_emitters(&config.selected_targets);
    let all_outputs = dispatch_emitters(&emitters, config, &pack)?;

    pack.outputs = all_outputs;
    pack.source_lock = SourceLock::compute_from_pack(&pack);

    // 7. Persist sources.lock metadata. No-op outside Write mode.
    write_sources_lock(config, &pack)?;

    // 8. Run advise
    if let Ok(report) = cordance_advise::run_all(&pack) {
        pack.advise = report;
    }

    // 9. Emit the pack.json AND evidence map LAST so they observe the
    //    populated `pack.outputs` / `pack.source_lock` / `pack.advise`.
    //
    //    Round-7 codereview #1 CRITICAL: `PackJsonEmitter` was previously
    //    pushed into `build_emitters` and dispatched with the target emitters
    //    BEFORE `pack.outputs` and `pack.source_lock` were populated. The
    //    serialized pack.json on disk had `outputs: []` and an empty internal
    //    source_lock — silently wrong for every consumer that reads the IR.
    //    Moving pack.json into the post-emit step (parallel to the evidence
    //    map) closes that.
    //
    //    These post-step outputs are intentionally NOT folded back into
    //    `pack.outputs` (the pack and map are metadata about the pack, not
    //    target artifacts like AGENTS.md). Errors here still propagate to
    //    the CLI so the operator sees a non-zero exit code (round-7
    //    redteam #1).
    let post_emit_emitters: Vec<Box<dyn cordance_emit::TargetEmitter>> = vec![
        Box::new(cordance_emit::pack_json::PackJsonEmitter),
        Box::new(cordance_emit::evidence_map::EvidenceMapEmitter),
    ];
    let _post_outputs = dispatch_emitters(&post_emit_emitters, config, &pack)?;

    Ok(pack)
}

/// Persist `pack.source_lock` to `<target>/.cordance/sources.lock` when the
/// pack is running in Write mode. Other modes are a no-op.
///
/// Round-5 redteam #4: a hostile target can pre-plant
/// `<target>/.cordance/sources.lock` as a symlink to operator-owned files
/// (`~/.bashrc`, `~/.ssh/authorized_keys`, …). Route through
/// `safe_write_with_mkdir` so the helper refuses to follow the link.
///
/// Round-7 bughunt #3 (R7-bughunt-3): the previous shape used
/// `serde_json::to_string_pretty(...).unwrap_or_default()` and
/// `safe_write_with_mkdir(...).ok()`, which silently turned a serialise
/// failure into a 0-byte `sources.lock` (and an I/O failure into no file at
/// all). Both shapes broke the next `cordance check` with an unhelpful
/// downstream parse error rather than a real signal. Propagate both errors
/// via `?` so the operator sees the actual cause.
fn write_sources_lock(config: &PackConfig, pack: &CordancePack) -> Result<()> {
    if config.output_mode != OutputMode::Write {
        return Ok(());
    }
    let lock_path = config.target.join(".cordance").join("sources.lock");
    let lock_json = serde_json::to_string_pretty(&pack.source_lock)
        .context("serialising sources.lock")?;
    cordance_core::fs::safe_write_with_mkdir(lock_path.as_std_path(), lock_json.as_bytes())
        .context("writing sources.lock")?;
    Ok(())
}

fn build_emitters(
    targets: &PackTargets,
) -> Vec<Box<dyn cordance_emit::TargetEmitter>> {
    let mut v: Vec<Box<dyn cordance_emit::TargetEmitter>> = vec![];
    if targets.claude_code {
        v.push(Box::new(cordance_emit::agents_md::AgentsMdEmitter));
        v.push(Box::new(cordance_emit::claude_md::ClaudeMdEmitter));
    }
    if targets.cursor {
        v.push(Box::new(cordance_emit::cursor::CursorEmitter));
    }
    if targets.codex {
        v.push(Box::new(cordance_emit::codex::CodexEmitter));
    }
    if targets.axiom_harness_target {
        v.push(Box::new(
            cordance_emit::harness_target::HarnessTargetEmitter,
        ));
    }
    // The `cortex_receipt` flag exists in `PackTargets` but is intentionally
    // NOT plumbed as a `TargetEmitter` here. Round-7 codereview #2 asked to
    // either plumb or remove. The round-7 fix lane initially plumbed it via
    // a `CortexReceiptEmitter`, but doing so broke `pack.json` determinism:
    // `cordance_cortex::build_receipt` legitimately stamps the receipt with
    // `Utc::now()` (cortex receipts are operator-initiated attestations of
    // a submission moment, NOT byte-deterministic pack artifacts), so two
    // consecutive `cordance pack` runs produced byte-different
    // `.cordance/cortex-receipt.json`, which propagated into pack.outputs
    // → pack.json → source_lock and broke runs-2-3 byte-determinism.
    //
    // The right routing is: `cordance pack` produces the deterministic
    // target outputs (AGENTS.md, CLAUDE.md, .cursor/, .codex/,
    // pai-axiom-project-harness-target.json). The cortex receipt is
    // produced by the dedicated `cordance cortex push` subcommand
    // (see `cortex_cmd.rs`), which takes the same pack as input and
    // applies a wall-clock at submission time. `cordance pack --targets
    // cortex-receipt` is therefore a no-op on the emitter side; the
    // `cortex_receipt: true` flag is preserved because `cortex_cmd::run`
    // uses it to signal "this pack run is for a cortex push" and bypass
    // unrelated target emitters.
    let _ = targets.cortex_receipt;
    // NOTE: `PackJsonEmitter` is NOT pushed here. It runs as a post-step in
    // `pack_cmd::run` AFTER `pack.outputs` / `pack.source_lock` are
    // populated, so the serialized pack.json reflects the final pack state
    // rather than an empty pre-emit snapshot. Round-7 codereview #1 CRITICAL.
    v
}

/// Round-7 redteam #1 CRITICAL: `dispatch_emitters` used to print emitter
/// failures via `eprintln!` and continue. Failed emits (including
/// `safe_write` refusals against attacker-planted symlinks/junctions) would
/// produce a "succeed-loud-but-exit-0" outcome: stderr complained about the
/// emitter but the pack reported `"N outputs written"` and the process
/// exited 0. The operator could not see the failure. Now every emitter
/// error stops dispatch and propagates as `anyhow::Error` so the CLI
/// surfaces it with a non-zero exit code.
fn dispatch_emitters(
    emitters: &[Box<dyn cordance_emit::TargetEmitter>],
    config: &PackConfig,
    pack: &CordancePack,
) -> Result<Vec<cordance_core::pack::PackOutput>> {
    let mut all_outputs = vec![];
    match config.output_mode {
        OutputMode::Write => {
            for emitter in emitters {
                let outputs = emitter
                    .emit(pack, &config.target)
                    .with_context(|| format!("emitter '{}' failed", emitter.name()))?;
                all_outputs.extend(outputs);
            }
        }
        OutputMode::DryRun => {
            for emitter in emitters {
                // Round-4 bughunt #3: plan() now takes repo_root so it can
                // apply the same fence-merge logic as emit(). Otherwise the
                // sha plan records (rendered) and the sha emit writes
                // (merged) diverge, and `cordance check` reports spurious
                // drift on every fenced output after the first pack.
                let outputs = emitter
                    .plan(pack, &config.target)
                    .with_context(|| format!("emitter '{}' plan failed", emitter.name()))?;
                if !config.quiet {
                    for o in &outputs {
                        println!("would write: {} ({} bytes)", o.path, o.bytes);
                    }
                }
                all_outputs.extend(outputs);
            }
        }
        OutputMode::Diff => {
            for emitter in emitters {
                let outputs = emitter
                    .plan(pack, &config.target)
                    .with_context(|| format!("emitter '{}' diff-plan failed", emitter.name()))?;
                for o in &outputs {
                    let abs = config.target.join(&o.path);
                    let line = if abs.exists() {
                        let on_disk = std::fs::read_to_string(&abs).unwrap_or_default();
                        let on_disk_sha = hex::encode(Sha256::digest(on_disk.as_bytes()));
                        if on_disk_sha == o.sha256 {
                            format!("unchanged: {}", o.path)
                        } else {
                            format!("changed: {}", o.path)
                        }
                    } else {
                        format!("new: {}", o.path)
                    };
                    if !config.quiet {
                        println!("{line}");
                    }
                    all_outputs.push(o.clone());
                }
            }
        }
    }
    Ok(all_outputs)
}

/// Read `{root}/Cargo.toml` (when present) and return the project name from
/// either `[package].name` or `[workspace.package].name`. Falls back to the
/// canonicalised directory name, then the raw last path component.
///
/// Replaces the round-1/-2/-3-flagged hand-rolled line scanner. The `toml`
/// crate handles `name = "..."`, `name="..."` (no space), workspace inheritance
/// (`[workspace.package].name`), and quoting subtleties uniformly.
fn detect_project_name(root: &Utf8PathBuf) -> String {
    if let Some(name) = read_cargo_package_name(root) {
        return name;
    }
    // Fallback: canonicalize to resolve `.` and get the real directory name.
    if let Ok(canonical) = std::fs::canonicalize(root.as_std_path()) {
        if let Some(dir_name) = canonical.file_name() {
            if let Some(s) = dir_name.to_str() {
                return s.to_string();
            }
        }
    }
    root.file_name().unwrap_or("unknown").to_string()
}

/// Helper: deserialise `{root}/Cargo.toml` and pull out the project name.
///
/// Returns `None` when the file is absent, unreadable, malformed, or simply
/// declares neither `[package].name` nor `[workspace.package].name`. The
/// minimal struct shape ignores every other Cargo field so a real Cargo.toml
/// with `[dependencies]`, `[features]`, `[[bin]]`, etc. parses cleanly.
fn read_cargo_package_name(root: &Utf8PathBuf) -> Option<String> {
    #[derive(serde::Deserialize, Default)]
    struct PkgSection {
        name: Option<String>,
    }
    #[derive(serde::Deserialize, Default)]
    struct WorkspaceSection {
        package: Option<PkgSection>,
    }
    #[derive(serde::Deserialize, Default)]
    struct CargoToml {
        package: Option<PkgSection>,
        workspace: Option<WorkspaceSection>,
    }

    let cargo = root.join("Cargo.toml");
    if !cargo.exists() {
        return None;
    }
    let content = std::fs::read_to_string(&cargo).ok()?;
    let parsed: CargoToml = toml::from_str(&content).ok()?;

    if let Some(pkg) = parsed.package {
        if let Some(name) = pkg.name {
            if !name.is_empty() {
                return Some(name);
            }
        }
    }
    if let Some(ws) = parsed.workspace {
        if let Some(pkg) = ws.package {
            if let Some(name) = pkg.name {
                if !name.is_empty() {
                    return Some(name);
                }
            }
        }
    }
    None
}

fn detect_project_kind(root: &Utf8PathBuf) -> String {
    if root.join("Cargo.toml").exists() {
        return "rust-workspace".into();
    }
    if root.join("package.json").exists() {
        return "typescript-node".into();
    }
    if root.join("pyproject.toml").exists() || root.join("setup.py").exists() {
        return "python".into();
    }
    if root.join("go.mod").exists() {
        return "go".into();
    }
    "unknown".into()
}

/// Resolve the effective LLM provider from CLI override + project config.
///
/// CLI override wins over `cordance.toml`. `"none"` (explicit) and `None`
/// (absent) both disable enrichment.
fn resolve_llm_provider(config: &PackConfig, cfg: &crate::config::Config) -> Option<String> {
    config
        .llm_provider
        .clone()
        .or_else(|| Some(cfg.llm.provider.clone()))
        .filter(|p| p != "none")
}

/// Run the optional LLM enrichment pass. ADR 0002: candidate prose only.
///
/// All output flows to:
///   - `pack.residual_risk` — an audit note that a candidate was attached
///   - `{target}/.cordance/llm-candidate.json` — the raw candidate document
///
/// Emitters never see this content. Network or schema failures are downgraded
/// to a `warning` on stderr and a `residual_risk` note; they never abort `pack`.
fn maybe_run_llm(config: &PackConfig, cfg: &crate::config::Config, pack: &mut CordancePack) {
    let Some(provider) = resolve_llm_provider(config, cfg) else {
        return;
    };

    if provider != "ollama" {
        pack.residual_risk
            .push(format!("LLM provider '{provider}' is not supported; skipping enrichment"));
        return;
    }

    // ADR 0015: build the adapter from [llm.ollama] config; CLI --ollama-model
    // overrides the configured model when provided.
    let mut adapter = cordance_llm::OllamaAdapter::from_config(&cfg.llm.ollama);
    if let Some(override_model) = &config.ollama_model {
        override_model.clone_into(&mut adapter.model);
    }
    let base_url = adapter.base_url.clone();
    let model = adapter.model.clone();

    if !adapter.is_available() {
        eprintln!(
            "cordance: warning — Ollama not reachable at {base_url}, skipping LLM enrichment"
        );
        pack.residual_risk
            .push(format!("LLM enrichment skipped: ollama/{model} at {base_url} unreachable"));
        return;
    }

    // Bound the source set: take up to 20 non-blocked sources. The adapter is
    // strict about citation, so we use the same slice for both the prompt and
    // the citation allow-list.
    let sources: Vec<_> = pack
        .sources
        .iter()
        .filter(|s| !s.blocked)
        .take(20)
        .cloned()
        .collect();
    let source_ids: Vec<String> = sources.iter().map(|s| s.id.clone()).collect();

    let prompt = cordance_llm::prompt::bounded_pack_summary_prompt(
        &sources,
        &[],
        "Summarise this project's main purpose in one paragraph as a candidate observation.",
    );

    // Round-2 fix-A: wire 4-gram grounding. The validator was already in
    // place but `generate()` (no grounding) was being called, so the
    // hardening never reached production. Load the cited source bodies
    // (bounded per-file to keep memory and prompt size predictable) and
    // hand them to `generate_with_grounding`.
    let source_content_map = load_source_content_map(pack, &source_ids);

    match adapter.generate_with_grounding(&prompt, &source_ids, &source_content_map) {
        Ok(candidate) => {
            let claim_count = candidate.claims.len();
            pack.residual_risk.push(format!(
                "LLM candidate prose attached: {claim_count} claim(s) from ollama/{model}"
            ));

            let candidate_path = config.target.join(".cordance").join("llm-candidate.json");
            match serde_json::to_string_pretty(&candidate) {
                Ok(json) => {
                    // Round-5 redteam #4: route through the symlink-refusing
                    // helper so a hostile target can't redirect this write
                    // at the operator's own files. `safe_write_with_mkdir`
                    // handles parent-dir creation, so the explicit
                    // `create_dir_all` above is no longer needed.
                    if let Err(e) = cordance_core::fs::safe_write_with_mkdir(
                        candidate_path.as_std_path(),
                        json.as_bytes(),
                    ) {
                        eprintln!(
                            "cordance: warning — failed to write {candidate_path}: {e}"
                        );
                    }
                }
                Err(e) => {
                    eprintln!(
                        "cordance: warning — failed to serialise LLM candidate: {e}"
                    );
                }
            }
        }
        Err(e) => {
            eprintln!("cordance: warning — Ollama generation failed: {e}");
            pack.residual_risk
                .push(format!("LLM enrichment failed: {e}"));
        }
    }
}

/// Read the cited source files into a `{source_id → content}` map suitable
/// for [`cordance_llm::OllamaAdapter::generate_with_grounding`].
///
/// The validator only needs enough text to compare against 4-gram windows of
/// each claim, so we cap each entry at 4 KiB. This keeps the prompt-adjacent
/// memory bounded even on repos with large source files and keeps the
/// grounding check cheap.
///
/// Blocked sources are skipped (they would not have been cited anyway since
/// the prompt builder filters them out). I/O errors are silently ignored
/// because a missing file simply means the validator will not be able to
/// ground a claim against it — that is the correct behaviour, not a fatal
/// pack error.
fn load_source_content_map(
    pack: &CordancePack,
    cited_ids: &[String],
) -> HashMap<String, String> {
    const MAX_PER_SOURCE: u64 = 4 * 1024;

    let mut map: HashMap<String, String> = HashMap::with_capacity(cited_ids.len());
    for id in cited_ids {
        let Some(record) = pack.sources.iter().find(|s| &s.id == id) else {
            continue;
        };
        if record.blocked {
            continue;
        }
        let abs = pack.project.repo_root.join(&record.path);
        let Ok(file) = std::fs::File::open(abs.as_std_path()) else {
            continue;
        };
        let mut buf = String::new();
        if file.take(MAX_PER_SOURCE).read_to_string(&mut buf).is_ok() {
            map.insert(id.clone(), buf);
        }
    }
    map
}

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

    fn write_cargo(content: &str) -> (tempfile::TempDir, Utf8PathBuf) {
        let dir = tempfile::tempdir().expect("tempdir");
        let target = Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
            .expect("tempdir is utf8");
        std::fs::write(target.join("Cargo.toml"), content).expect("write Cargo.toml");
        (dir, target)
    }

    #[test]
    fn detect_project_name_reads_package_section() {
        let (_d, target) = write_cargo(
            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\n",
        );
        assert_eq!(detect_project_name(&target), "my-crate");
    }

    #[test]
    fn detect_project_name_handles_no_space_around_equals() {
        // The hand-rolled scanner used to break on `name="x"` (no whitespace).
        let (_d, target) = write_cargo("[package]\nname=\"compact\"\n");
        assert_eq!(detect_project_name(&target), "compact");
    }

    #[test]
    fn detect_project_name_reads_workspace_package_section() {
        // Cargo workspace inheritance shape that the line scanner missed.
        let (_d, target) = write_cargo(
            "[workspace]\nmembers = [\"foo\"]\n\n[workspace.package]\nname = \"ws-name\"\nversion = \"0.1.0\"\n",
        );
        assert_eq!(detect_project_name(&target), "ws-name");
    }

    #[test]
    fn detect_project_name_prefers_package_over_workspace_package() {
        // When both are present, `[package].name` wins (it's the crate-level
        // override that ws inheritance falls back from).
        let (_d, target) = write_cargo(
            "[package]\nname = \"actual\"\n\n[workspace.package]\nname = \"inherited\"\n",
        );
        assert_eq!(detect_project_name(&target), "actual");
    }

    #[test]
    fn detect_project_name_ignores_bin_name() {
        // The old scanner would match `name = "..."` inside `[[bin]]`. The
        // toml-typed parser correctly scopes to `[package]`.
        let (_d, target) = write_cargo(
            "[package]\nname = \"libname\"\n\n[[bin]]\nname = \"binname\"\npath = \"src/main.rs\"\n",
        );
        assert_eq!(detect_project_name(&target), "libname");
    }

    #[test]
    fn detect_project_name_falls_back_when_cargo_toml_absent() {
        let dir = tempfile::tempdir().expect("tempdir");
        let target = Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
            .expect("tempdir is utf8");
        // Without Cargo.toml we expect the canonical directory name as the
        // fallback. That directory name is the tempdir's last component.
        let detected = detect_project_name(&target);
        let expected = target.file_name().unwrap_or("unknown").to_string();
        // tempdir canonicalisation can resolve the path through `/private` on
        // macOS, so allow either the raw last component or any non-empty
        // string — the contract is "non-empty, not a hand-rolled placeholder".
        assert!(!detected.is_empty());
        assert!(
            detected == expected
                || std::fs::canonicalize(target.as_std_path())
                    .ok()
                    .and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))
                    .is_some_and(|c| detected == c),
            "detected={detected} expected={expected}"
        );
    }

    #[test]
    fn detect_project_name_falls_back_when_cargo_toml_malformed() {
        // Unparseable TOML must not panic — fall back to the dir name.
        let (_d, target) = write_cargo("this is not = = valid toml");
        let detected = detect_project_name(&target);
        assert!(!detected.is_empty(), "must produce some non-empty fallback");
    }

    #[test]
    fn detect_project_name_falls_back_when_name_missing() {
        let (_d, target) = write_cargo("[package]\nversion = \"0.1.0\"\n");
        let detected = detect_project_name(&target);
        assert!(!detected.is_empty());
        // The result must not be `version` or any literal scraped from the toml.
        assert_ne!(detected, "0.1.0");
    }

    /// Round-7 bughunt #3 (R7-bughunt-3): the previous `write_sources_lock`
    /// shape used `unwrap_or_default()` on the serde result and `.ok()` on
    /// the write call, silently turning either failure into a 0-byte
    /// `sources.lock` (or no file at all). The fixed shape propagates both
    /// errors via `?`. This test pins the I/O-failure half of the contract:
    /// pre-plant `<target>/.cordance` as a REGULAR FILE, so the helper's
    /// `create_dir_all(<target>/.cordance)` must error (can't make a
    /// directory where a file already lives) — and `write_sources_lock`
    /// must surface that as `Err`, not `Ok(())`.
    ///
    /// The serialise-failure half cannot be exercised in a unit test today
    /// because every field of `SourceLock` is plain-JSON-friendly (`String`,
    /// `Option<String>`, `Vec<SourceLockEntry>`). The contract still
    /// matters: a future schema change that adds a non-serialisable shape
    /// (a `Map<NonSerialize, _>`, an `f64::NAN`, …) would otherwise
    /// silently land a 0-byte lock on every pack — the `?` in
    /// `to_string_pretty(...).context(...)?` is the load-bearing line.
    #[test]
    fn write_sources_lock_propagates_io_failure_instead_of_swallowing() {
        let dir = tempfile::tempdir().expect("tempdir");
        let target = Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
            .expect("tempdir is utf8");

        // Plant `.cordance` as a regular FILE at the target root. The
        // helper's `create_dir_all(<target>/.cordance)` cannot succeed
        // because a non-directory file already occupies that path.
        std::fs::write(target.join(".cordance"), b"blocker").expect("seed blocker file");

        let config = PackConfig {
            target: target.clone(),
            output_mode: OutputMode::Write,
            selected_targets: PackTargets::default(),
            doctrine_root: None,
            llm_provider: None,
            ollama_model: None,
            quiet: true,
        };
        let pack = CordancePack {
            schema: "test".into(),
            project: ProjectIdentity {
                name: "test".into(),
                kind: "unknown".into(),
                repo_root: target,
                host_os: "test".into(),
                axiom_pin: None,
            },
            sources: vec![],
            doctrine_pins: vec![],
            targets: PackTargets::default(),
            outputs: vec![],
            source_lock: SourceLock::empty(),
            advise: cordance_core::advise::AdviseReport::empty(),
            residual_risk: vec![],
        };

        let result = write_sources_lock(&config, &pack);
        assert!(
            result.is_err(),
            "expected write_sources_lock to surface the create_dir_all I/O failure, got Ok"
        );
        let err = result.unwrap_err();
        let msg = format!("{err:#}");
        assert!(
            msg.contains("writing sources.lock"),
            "error chain must mention `writing sources.lock`; got: {msg}"
        );
    }
}
