//! The lifecycle write-decision core: the PURE `plan_writes` truth table, the impure `apply_writes`
//! shell, naming-override application, and the `regenerate`/`plan_only` orchestrators the binary
//! calls (WS-04, WATCH-01, WS-03 / D-04, D-05).
//!
//! ## The host owns writing; the child (the Pipeline) produces artifacts
//!
//! The host no longer extracts/lowers/generates in-process — the user's `.gnr8/` crate (the
//! `Pipeline`) does, as a child process (`docs/code-as-config.md`). The host's job is the trusted
//! WRITER: it runs the child (`crate::child::run_child` lives in the binary), receives an
//! [`crate::runner::ArtifactBundle`] of `(path, text)` [`crate::sdk::Artifact`]s, and decides what to
//! write. `regenerate`/`plan_only` therefore take the already-produced artifacts (the binary calls the
//! child first) and apply ONLY the write machinery below.
//!
//! ## The heart of the phase — a pure decision, a thin shell
//!
//! [`plan_writes`] is a PURE function over (the child's artifacts, the previous-run manifest, an
//! injected on-disk reader). It classifies each output as [`WriteAction::Write`],
//! [`WriteAction::Unchanged`] (no-op), or [`WriteAction::UserEdited`] (a human hand-edited a
//! generated file, or a pre-existing untracked file sits at an output path). Because it does NO
//! I/O — on-disk bytes come solely from the injected closure — the full truth table is exhaustively
//! unit-testable without a filesystem (RESEARCH Pattern 2 / Pitfall 3). The `--force` policy lives in
//! [`apply_writes`], NOT here, so the classification stays pure and the override decision stays in
//! one place.
//!
//! [`apply_writes`] is the impure half: it writes `Write` (and, under `force`, `UserEdited`) files,
//! skips `Unchanged`/`UserEdited`, records new hashes, and prunes manifest entries for paths no
//! longer produced (D-04). It resolves every output path against the project root and REJECTS paths
//! that escape it (path-traversal hardening, T-04-02-01 — output paths come from the user's pipeline,
//! so the `gosdk::write_to_dir` frame-name discipline is extended to those paths here).
//!
//! [`regenerate`] ties it together: take the child's artifacts, load the manifest, `plan_writes`,
//! `apply_writes`, save the manifest, and return a [`GenerateOutcome`] with written/unchanged/skipped
//! counts. [`plan_only`] runs the same write decision WITHOUT touching disk — the dry-run seam
//! `gnr8 check` uses. No production `unwrap`/`expect`/`panic` (RUST-04).

// Acronym-dense prose (WS-04, WATCH-01, OpenAPI, blake3, D-04, ...); allow `doc_markdown` module-wide
// (skill ch.2.4, mirrors manifest/mod.rs).
#![allow(clippy::doc_markdown)]

use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Component, Path};

use crate::graph::ApiGraph;
use crate::manifest::{self, blake3_hex, Manifest};
use crate::sdk::{Artifact, ArtifactMetadata};

/// The provenance tag recorded for every artifact the host writes.
///
/// The host writes whatever the child's pipeline emitted, so it no longer distinguishes "openapi" vs
/// "sdk" provenance per file (that was the in-process generator's concern). One tag suffices for the
/// ownership manifest — its purpose is to mark a path as gnr8-owned, not to record which target wrote it.
const SOURCE_GENERATED: &str = "generated";

/// Per-file classification produced by [`plan_writes`] (the WS-04 / WATCH-01 truth table).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteAction {
    /// New file (absent on disk) or a gnr8-owned file whose content changed ⇒ write it.
    Write,
    /// On-disk bytes are byte-identical to the freshly generated bytes ⇒ skip the write (no-op,
    /// WATCH-01 / D-05: no mtime churn).
    Unchanged,
    /// On-disk hash != the recorded hash (a human edited a generated file), or the path is present
    /// on disk but ABSENT from the manifest (a pre-existing hand-written output). Warn + skip unless
    /// `--force` (WS-04 / D-04, Pitfall 5).
    UserEdited,
}

/// One planned output: its project-relative path, the decided [`WriteAction`], the freshly generated
/// bytes, the blake3 hash of those bytes, and the generator provenance.
#[derive(Debug, Clone)]
pub struct PlannedFile {
    /// The project-relative output path (e.g. `"openapi.yaml"`, `"sdk/client.go"`).
    pub path: String,
    /// What [`plan_writes`] decided to do with this file.
    pub action: WriteAction,
    /// The freshly generated bytes for this path (deterministic — the child's pipeline produced them).
    pub new_bytes: Vec<u8>,
    /// The blake3 hash of `new_bytes` (recorded in the manifest when written).
    pub new_hash: String,
    /// Generator provenance ([`SOURCE_GENERATED`]).
    pub source: String,
}

/// The per-file write plan: the classification of every output for this generation.
#[derive(Debug, Clone, Default)]
pub struct WritePlan {
    /// One [`PlannedFile`] per generated output, in generation order.
    pub files: Vec<PlannedFile>,
}

impl WritePlan {
    /// Whether any planned file is stale (`Write`) or drifted (`UserEdited`) — the dry-run drift
    /// signal `gnr8 check` exits non-zero on (every file `Unchanged` ⇒ clean).
    #[must_use]
    pub fn has_drift(&self) -> bool {
        self.files
            .iter()
            .any(|f| matches!(f.action, WriteAction::Write | WriteAction::UserEdited))
    }
}

/// Counts of what a generation did, returned by [`apply_writes`]/[`regenerate`].
///
/// Derives `serde::Serialize` for the `--json` latency report (04-03 serializes it).
#[derive(Debug, Default, Clone, serde::Serialize)]
pub struct GenerateOutcome {
    /// Paths that were written (new or changed; under `force`, also overwritten user edits).
    pub written: Vec<String>,
    /// Paths that were byte-identical and therefore NOT rewritten (no-op).
    pub unchanged: Vec<String>,
    /// Paths that were protected (user-edited / pre-existing untracked) and skipped without `force`.
    pub skipped: Vec<String>,
    /// Generated-output files removed because this run no longer produces them.
    pub deleted: Vec<String>,
}

/// Classify each generated [`Artifact`] — the PURE heart of WS-04 / WATCH-01.
///
/// `artifacts` are the `(path, text)` files the child's pipeline produced. `manifest` is the
/// previous-run record. `on_disk` returns the path's CURRENT bytes (or `None` if absent). NO I/O:
/// on-disk bytes come solely from the injected closure, which is what makes the full truth table
/// unit-testable without a filesystem (the binary integration test feeds synthetic [`Artifact`]s, no
/// child needed). The five arms (RESEARCH Pattern 2):
///
/// 1. absent on disk ⇒ [`WriteAction::Write`]
/// 2. present, recorded, on-disk hash == recorded, new == disk ⇒ [`WriteAction::Unchanged`]
/// 3. present, recorded, on-disk hash == recorded, new != disk ⇒ [`WriteAction::Write`]
/// 4. present, recorded, on-disk hash != recorded ⇒ [`WriteAction::UserEdited`]
/// 5. present, NOT in manifest ⇒ [`WriteAction::UserEdited`] (protect a pre-existing output)
///
/// `--force` is NOT applied here (it lives in [`apply_writes`]) so the classification stays pure.
#[must_use]
pub fn plan_writes(
    artifacts: &[Artifact],
    manifest: &Manifest,
    on_disk: &dyn Fn(&str) -> Option<Vec<u8>>,
) -> WritePlan {
    let mut files = Vec::with_capacity(artifacts.len());
    for artifact in artifacts {
        let path = &artifact.path;
        let new_bytes = artifact.text.as_bytes();
        let new_hash = blake3_hex(new_bytes);
        // The five arms are the documented WS-04/WATCH-01 truth table; arms 1 and 3 deliberately
        // share the `Write` body but are kept as distinct, separately-commented cases for clarity
        // (collapsing them would hide the difference between "absent" and "owned-but-changed").
        #[allow(clippy::match_same_arms)]
        let action = match (on_disk(path), manifest.recorded_hash(path)) {
            // 1. absent on disk → always write (fresh generation or user deleted it).
            (None, _) => WriteAction::Write,
            // 4. present, recorded, but its CURRENT hash != what we last wrote → user hand-edited it.
            (Some(disk), Some(recorded)) if blake3_hex(&disk) != recorded => {
                WriteAction::UserEdited
            }
            // 2. present, gnr8-owned, byte-identical to the new output → no-op skip.
            (Some(disk), Some(_)) if disk == new_bytes => WriteAction::Unchanged,
            // 3. present, gnr8-owned, content changed → write the update.
            (Some(_), Some(_)) => WriteAction::Write,
            // 5. present but NOT in the manifest → never written by gnr8 → treat as a user file.
            (Some(_), None) => WriteAction::UserEdited,
        };
        files.push(PlannedFile {
            path: path.clone(),
            action,
            new_bytes: new_bytes.to_vec(),
            new_hash,
            source: SOURCE_GENERATED.to_string(),
        });
    }
    WritePlan { files }
}

/// Classify generated files from cached path/hash metadata.
///
/// This is used by warm no-op host paths where the child has proved the artifact cache is valid but
/// the host does not yet know whether it needs the full generated text. `new_bytes` is intentionally
/// empty in the returned plan; callers must only use it for dry-run reporting or when they have already
/// established that no file needs writing.
#[must_use]
pub fn plan_metadata_writes(
    artifacts: &[ArtifactMetadata],
    manifest: &Manifest,
    on_disk_hash: &dyn Fn(&str) -> Option<String>,
) -> WritePlan {
    let mut files = Vec::with_capacity(artifacts.len());
    for artifact in artifacts {
        let path = &artifact.path;
        let action = match (on_disk_hash(path), manifest.recorded_hash(path)) {
            (Some(disk_hash), Some(recorded)) if disk_hash != recorded => WriteAction::UserEdited,
            (Some(disk_hash), Some(_)) if disk_hash == artifact.hash => WriteAction::Unchanged,
            (None, _) | (Some(_), Some(_)) => WriteAction::Write,
            (Some(_), None) => WriteAction::UserEdited,
        };
        files.push(PlannedFile {
            path: path.clone(),
            action,
            new_bytes: Vec::new(),
            new_hash: artifact.hash.clone(),
            source: SOURCE_GENERATED.to_string(),
        });
    }
    WritePlan { files }
}

/// Materialize a [`WritePlan`] to disk (the impure half, RESEARCH Pattern 3).
///
/// For each file: `Write` (and `UserEdited` when `force`) → validate the path against `project_root`,
/// write the bytes, record the new hash in `manifest`, push to `written`. `Unchanged` → push to
/// `unchanged` (NO write — no mtime churn). `UserEdited` without `force` → push to `skipped` (the
/// CLI warns). After the loop, prune manifest entries for paths no longer produced (D-04).
///
/// Output paths come from user config, so each is resolved against `project_root` and REJECTED if it
/// escapes it (contains a `..`/root/prefix component) — path-traversal hardening (T-04-02-01,
/// extending the `gosdk::write_to_dir` frame-name discipline to user-supplied paths).
///
/// # Errors
///
/// Returns [`crate::CoreError::Io`] for a path that escapes the project root or a file that cannot be
/// written (with an actionable message). Never panics.
pub fn apply_writes(
    project_root: &Path,
    plan: &WritePlan,
    manifest: &mut Manifest,
    force: bool,
) -> Result<GenerateOutcome, crate::CoreError> {
    apply_writes_with_anchors(project_root, plan, manifest, force, &[])
}

/// Materialize a [`WritePlan`] and prune stale files under declared target output anchors.
///
/// This is the same write policy as [`apply_writes`], plus cleanup:
/// - manifest-owned files no longer produced are deleted when unchanged from the last generated hash;
/// - with `force`, other files under output directory anchors that are no longer produced are deleted too.
///
/// # Errors
///
/// Returns [`crate::CoreError::Io`] for unsafe output paths or filesystem failures. Never panics.
pub fn apply_writes_with_anchors(
    project_root: &Path,
    plan: &WritePlan,
    manifest: &mut Manifest,
    force: bool,
    output_anchors: &[String],
) -> Result<GenerateOutcome, crate::CoreError> {
    let mut out = GenerateOutcome::default();
    for file in &plan.files {
        let should_write = match file.action {
            WriteAction::Write => true,
            WriteAction::UserEdited => force,
            WriteAction::Unchanged => false,
        };
        if should_write {
            let safe = safe_output_path(project_root, &file.path)?;
            if let Some(parent) = safe.parent() {
                std::fs::create_dir_all(parent).map_err(|err| crate::CoreError::Io {
                    message: format!("failed to create {}: {err}", parent.display()),
                })?;
            }
            std::fs::write(&safe, &file.new_bytes).map_err(|err| crate::CoreError::Io {
                message: format!("failed to write {}: {err}", safe.display()),
            })?;
            manifest.record(&file.path, &file.new_hash, &file.source);
            out.written.push(file.path.clone());
        } else if file.action == WriteAction::Unchanged {
            out.unchanged.push(file.path.clone());
        } else {
            // UserEdited without force → protected, skipped (CLI warns naming the file).
            out.skipped.push(file.path.clone());
        }
    }

    let current_paths: BTreeSet<String> = plan.files.iter().map(|f| f.path.clone()).collect();
    prune_stale_manifest_files(project_root, manifest, &current_paths, &mut out)?;
    prune_anchor_files(
        project_root,
        output_anchors,
        &current_paths,
        manifest,
        force,
        &mut out,
    )?;

    // D-04: drop manifest entries for paths this generation no longer produces.
    let current_paths_vec: Vec<String> = current_paths.iter().cloned().collect();
    manifest.prune_to(&current_paths_vec);
    Ok(out)
}

fn prune_stale_manifest_files(
    project_root: &Path,
    manifest: &Manifest,
    current_paths: &BTreeSet<String>,
    out: &mut GenerateOutcome,
) -> Result<(), crate::CoreError> {
    for entry in &manifest.files {
        if current_paths.contains(&entry.path) {
            continue;
        }
        let safe = safe_output_path(project_root, &entry.path)?;
        let Ok(bytes) = std::fs::read(&safe) else {
            continue;
        };
        if blake3_hex(&bytes) == entry.hash {
            std::fs::remove_file(&safe).map_err(|err| crate::CoreError::Io {
                message: format!(
                    "failed to delete stale generated file {}: {err}",
                    safe.display()
                ),
            })?;
            out.deleted.push(entry.path.clone());
        } else {
            out.skipped.push(entry.path.clone());
        }
    }
    Ok(())
}

fn prune_anchor_files(
    project_root: &Path,
    output_anchors: &[String],
    current_paths: &BTreeSet<String>,
    manifest: &Manifest,
    force: bool,
    out: &mut GenerateOutcome,
) -> Result<(), crate::CoreError> {
    if !force {
        return Ok(());
    }

    let mut seen = BTreeSet::new();
    for anchor in output_anchors {
        let anchor_path = safe_output_path(project_root, anchor)?;
        if anchor_path.is_file() {
            prune_anchor_file(
                project_root,
                anchor,
                current_paths,
                manifest,
                out,
                &mut seen,
            )?;
        } else if anchor_path.is_dir() {
            prune_anchor_dir(
                project_root,
                &anchor_path,
                current_paths,
                manifest,
                out,
                &mut seen,
            )?;
        }
    }
    Ok(())
}

fn prune_anchor_dir(
    project_root: &Path,
    dir: &Path,
    current_paths: &BTreeSet<String>,
    manifest: &Manifest,
    out: &mut GenerateOutcome,
    seen: &mut BTreeSet<String>,
) -> Result<(), crate::CoreError> {
    let mut stack = vec![dir.to_path_buf()];
    let mut dirs = Vec::new();
    while let Some(next) = stack.pop() {
        dirs.push(next.clone());
        let entries = std::fs::read_dir(&next).map_err(|err| crate::CoreError::Io {
            message: format!(
                "failed to read generated output dir {}: {err}",
                next.display()
            ),
        })?;
        for entry in entries {
            let entry = entry.map_err(|err| crate::CoreError::Io {
                message: format!(
                    "failed to read generated output dir {}: {err}",
                    next.display()
                ),
            })?;
            let path = entry.path();
            let kind = entry.file_type().map_err(|err| crate::CoreError::Io {
                message: format!(
                    "failed to inspect generated output {}: {err}",
                    path.display()
                ),
            })?;
            if kind.is_dir() {
                stack.push(path);
            } else if kind.is_file() {
                let rel = project_relative_path(project_root, &path)?;
                prune_anchor_file(project_root, &rel, current_paths, manifest, out, seen)?;
            }
        }
    }

    for dir in dirs.into_iter().rev() {
        let _ = std::fs::remove_dir(&dir);
    }
    Ok(())
}

fn prune_anchor_file(
    project_root: &Path,
    rel: &str,
    current_paths: &BTreeSet<String>,
    manifest: &Manifest,
    out: &mut GenerateOutcome,
    seen: &mut BTreeSet<String>,
) -> Result<(), crate::CoreError> {
    if current_paths.contains(rel) || !seen.insert(rel.to_string()) {
        return Ok(());
    }
    let safe = safe_output_path(project_root, rel)?;
    if !safe.exists() {
        return Ok(());
    }

    if manifest.recorded_hash(rel).is_some() && !out.deleted.iter().any(|path| path == rel) {
        out.skipped.retain(|path| path != rel);
    }

    std::fs::remove_file(&safe).map_err(|err| crate::CoreError::Io {
        message: format!(
            "failed to delete stale generated file {}: {err}",
            safe.display()
        ),
    })?;
    if !out.deleted.iter().any(|path| path == rel) {
        out.deleted.push(rel.to_string());
    }
    Ok(())
}

fn project_relative_path(project_root: &Path, path: &Path) -> Result<String, crate::CoreError> {
    let rel = path
        .strip_prefix(project_root)
        .map_err(|err| crate::CoreError::Io {
            message: format!(
                "generated output path {} is not under project root {}: {err}",
                path.display(),
                project_root.display()
            ),
        })?;
    Ok(rel.to_string_lossy().replace('\\', "/"))
}

/// Resolve a user-config output path against `project_root`, REJECTING any path that escapes the root
/// (absolute, root-anchored, or containing a `..` component) — path-traversal defense (T-04-02-01).
///
/// # Errors
///
/// Returns [`crate::CoreError::Io`] for an empty path or one that would escape the project root.
fn safe_output_path(
    project_root: &Path,
    rel: &str,
) -> Result<std::path::PathBuf, crate::CoreError> {
    if rel.is_empty() {
        return Err(crate::CoreError::Io {
            message: "refusing to write an empty output path".to_string(),
        });
    }
    let candidate = Path::new(rel);
    for component in candidate.components() {
        match component {
            // A normal path segment is fine; current-dir (`.`) is harmless.
            Component::Normal(_) | Component::CurDir => {}
            // `..`, a root `/`, or a Windows prefix could escape the project root → reject.
            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
                return Err(crate::CoreError::Io {
                    message: format!(
                        "refusing to write output path {rel:?}: it escapes the project root \
                         (no absolute paths or `..` segments allowed)"
                    ),
                });
            }
        }
    }
    Ok(project_root.join(candidate))
}

/// Reject any artifact whose output path is empty or would escape the project root, BEFORE planning —
/// so the dry-run (`plan_only` / `gnr8 check`) and the write path (`regenerate` / `gnr8 generate`) agree
/// on path validity. Without this, a malformed target path is caught only at write time, so `check`
/// would mis-report it as "drift" and the planner would `read(root.join(".."))` off disk to hash it.
/// One definition of "is this path writable", mirroring the per-write guard in [`apply_writes`].
///
/// # Errors
///
/// Returns [`crate::CoreError::Io`] for the first empty or root-escaping output path.
fn validate_output_paths(
    project_root: &Path,
    artifacts: &[Artifact],
) -> Result<(), crate::CoreError> {
    for artifact in artifacts {
        safe_output_path(project_root, &artifact.path)?;
    }
    Ok(())
}

fn validate_metadata_output_paths(
    project_root: &Path,
    artifacts: &[ArtifactMetadata],
) -> Result<(), crate::CoreError> {
    for artifact in artifacts {
        safe_output_path(project_root, &artifact.path)?;
    }
    Ok(())
}

/// Operation/type name remaps applied to the graph by [`apply_naming`].
///
/// This is the in-IR form of the old `[naming.*]` knobs, now driven from code: the
/// [`crate::sdk::builtins::RenameOperation`] / [`crate::sdk::builtins::RenameType`] transforms build one
/// of these and call [`apply_naming`], so the rename semantics (and the `$ref`-rewrite guarantees) are
/// shared with the lifecycle path — one definition, no divergence. `BTreeMap` keeps the maps sorted so
/// the rename pass is deterministic (mirrors the graph's sorted-collection policy, GRAPH-02).
#[derive(Debug, Default, Clone)]
pub struct NamingOverrides {
    /// Operation-id remaps, e.g. `updateGoal → "UpdateGoal"`.
    pub operations: BTreeMap<String, String>,
    /// Generated type-name remaps (by id OR bare name), e.g. `CreateGoalInput → "NewGoal"`.
    pub types: BTreeMap<String, String>,
}

/// Apply naming overrides to the graph IN PLACE before lowering (WS-03).
///
/// - Each `operation.id` present in `naming.operations` is remapped to its new value (the operationId
///   the OpenAPI/SDK output uses).
/// - Each schema matched by `naming.types` (by `id` OR bare `name`) is renamed: BOTH its `id` and
///   `name` become the new value, AND **every** `SchemaRef.ref_id` / neutral [`crate::graph::Type::Named`]
///   that pointed at the old id is rewritten to the new value. This is MANDATORY (PLAN-CHECK W2): a
///   referenced type renamed without updating its $refs would dangle and make `to_openapi` fail with
///   `CoreError::Lowering`. By keeping `id == name == new value` and rewriting every ref, the
///   component key, the resolved `$ref` name, and the references all stay consistent.
///
/// `naming.types` keys MUST reference the ORIGINAL type ids/names: all keys are resolved against the
/// pre-rename graph in one pass, so the order of the (sorted) `BTreeMap` can never make one rename
/// observe another's output (WR-02). A key with no matching operation/schema is a silent no-op (NOT an
/// error) — it is a documented "remap if present" escape hatch.
///
/// # Errors
///
/// Returns [`crate::CoreError::Config`] when `naming.types` would silently MIS-generate rather than
/// fail loud (WR-02, the "fail loud, never silently mis-generate" stance this phase takes elsewhere):
/// - **collapse** — two different keys rename distinct types to the SAME target name (two schemas would
///   share `id == name`, and `to_openapi`'s id-keyed `BTreeMap` would drop one);
/// - **collision** — a target name equals an existing schema that is NOT itself being renamed away (the
///   rename would shadow an unrelated type);
/// - **chain/cycle** — a target name equals the SOURCE of another rename in the same pass (e.g. `A="B"`
///   with `B="C"`), whose result would otherwise be order-dependent and surprising.
pub fn apply_naming(
    graph: &mut ApiGraph,
    naming: &NamingOverrides,
) -> Result<(), crate::CoreError> {
    // Operation-id remaps (independent of the type-rename collision analysis below).
    for op in &mut graph.operations {
        if let Some(new_id) = naming.operations.get(&op.id) {
            op.id = new_id.clone();
        }
    }

    // Resolve EVERY type key against the ORIGINAL (pre-rename) graph first, so iteration order can
    // never let one rename observe another's output (WR-02). Each resolved rename carries the schema's
    // (old_id, old_name, new_name). The COMPONENT KEY emitted by `to_openapi` is the bare `name`
    // (lower/mod.rs maps `schema.id -> schema.name`), so the uniqueness invariant we must protect is
    // over the bare NAMES post-rename, not the package-qualified ids.
    let original_names: std::collections::BTreeSet<&str> =
        graph.schemas.iter().map(|s| s.name.as_str()).collect();
    let mut renames: Vec<(String, String, String)> = Vec::new();
    let mut seen_old_ids: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
    // The bare names being vacated by renames (so a target may legitimately reuse a freed name).
    let mut freed_names: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
    for (key, new_name) in &naming.types {
        let Some((old_id, old_name)) = graph
            .schemas
            .iter()
            .find(|s| &s.id == key || &s.name == key)
            .map(|s| (s.id.clone(), s.name.clone()))
        else {
            continue; // unmatched key → silent no-op (remap-if-present escape hatch).
        };
        // Two keys (e.g. one by id, one by bare name) resolving to the SAME original schema is
        // ambiguous — reject rather than apply a sorted-order-dependent winner.
        if !seen_old_ids.insert(old_id.clone()) {
            return Err(crate::CoreError::Config {
                message: format!(
                    "naming.types: multiple overrides match the same type {old_id:?}; \
                     each type may be renamed at most once"
                ),
            });
        }
        freed_names.insert(old_name.clone());
        renames.push((old_id, old_name, new_name.clone()));
    }

    // Detect target collisions/collapses/chains against the ORIGINAL graph BEFORE mutating anything,
    // so a bad config fails loud and the graph is left untouched.
    let mut targets: std::collections::BTreeMap<&str, &str> = std::collections::BTreeMap::new();
    for (old_id, old_name, new_name) in &renames {
        if new_name == old_name {
            continue; // renaming a type to its own current name is a harmless no-op.
        }
        // Collapse: two distinct renames target the same new name.
        if let Some(prev_old) = targets.insert(new_name.as_str(), old_id.as_str()) {
            return Err(crate::CoreError::Config {
                message: format!(
                    "naming.types: {prev_old:?} and {old_id:?} both rename to {new_name:?}; \
                     a rename target must be unique (this would collapse two types into one)"
                ),
            });
        }
        // Chain/cycle: the target equals the SOURCE (current bare name) of ANOTHER rename
        // (e.g. A→B while B→C) — the outcome would be order-dependent, so reject it.
        if freed_names.contains(new_name.as_str()) && new_name.as_str() != old_name.as_str() {
            return Err(crate::CoreError::Config {
                message: format!(
                    "naming.types: target {new_name:?} of {old_id:?} is itself being renamed; \
                     chained renames are not supported — keys must reference original type names"
                ),
            });
        }
        // Collision: the target equals an existing type's bare name that is NOT being vacated by a
        // rename (the component key would shadow an unrelated type).
        if original_names.contains(new_name.as_str()) && !freed_names.contains(new_name.as_str()) {
            return Err(crate::CoreError::Config {
                message: format!(
                    "naming.types: target {new_name:?} of {old_id:?} collides with an existing \
                     type; choose a name that is not already a generated type"
                ),
            });
        }
    }

    // Apply every resolved rename in one pass (rename the schema id+name, then rewrite all refs).
    for (old_id, _old_name, new_name) in &renames {
        for schema in &mut graph.schemas {
            if &schema.id == old_id {
                schema.id.clone_from(new_name);
                schema.name.clone_from(new_name);
            }
        }
        for schema in &mut graph.schemas {
            rewrite_schema_type_ref(&mut schema.body, old_id, new_name);
        }
        for op in &mut graph.operations {
            if let Some(body) = op.request_body.as_mut() {
                if &body.ref_id == old_id {
                    body.ref_id.clone_from(new_name);
                }
            }
            for resp in &mut op.responses {
                if let Some(body) = resp.body.as_mut() {
                    if &body.ref_id == old_id {
                        body.ref_id.clone_from(new_name);
                    }
                }
            }
            for param in &mut op.params {
                rewrite_schema_type_ref(&mut param.schema, old_id, new_name);
            }
        }
    }
    Ok(())
}

/// Rewrite every [`crate::graph::Type::Named`] reference equal to `old_id` to `new_id`, recursing
/// through every type-bearing variant of the neutral [`crate::graph::Type`], so a renamed schema's
/// references stay valid (PLAN-CHECK W2). The match is exhaustive — no `_ =>` arm — so a future
/// [`crate::graph::Type`] variant fails to compile here until its recursion is handled (T-03).
fn rewrite_schema_type_ref(ty: &mut crate::graph::Type, old_id: &str, new_id: &str) {
    use crate::graph::Type;
    match ty {
        Type::Named(ref_id) => {
            if ref_id == old_id {
                *ref_id = new_id.to_string();
            }
        }
        Type::Array(inner) => rewrite_schema_type_ref(inner, old_id, new_id),
        Type::Map { key, value } => {
            rewrite_schema_type_ref(key, old_id, new_id);
            rewrite_schema_type_ref(value, old_id, new_id);
        }
        Type::Object(fields) => {
            for field in fields.iter_mut() {
                rewrite_schema_type_ref(&mut field.schema, old_id, new_id);
            }
        }
        Type::Union(variants) => {
            for variant in variants.iter_mut() {
                rewrite_schema_type_ref(variant, old_id, new_id);
            }
        }
        // Variants that carry no nested schema id: nothing to rewrite.
        Type::Primitive(_) | Type::WellKnown(_) | Type::Enum(_) | Type::Any {} => {}
    }
}

/// The workspace directory whose contents are NEVER analyzed (it holds gnr8's own config + cache,
/// not user Go source). Excluded from the analyzed graph alongside the configured output paths.
pub(crate) const WORKSPACE_DIR: &str = ".gnr8";

/// Whether a module-relative source `file` lives under (or AT) a configured output `anchor` — the
/// graph-side twin of the watch loop-safety filter (`watch::is_under_any_output`).
///
/// Both answer the SAME question — "is this path one of gnr8's own generated outputs?" — so gnr8
/// never ingests or loops on its own output (WATCH-01 / WATCH-02). The watch filter compares absolute,
/// canonicalized paths; this compares the module-relative provenance paths the graph carries against
/// the project-relative config anchors, matching only on a path-separator boundary so a sibling like
/// `sdklib/x.go` is NOT mistaken for being under the `sdk` anchor.
///
/// `pub(crate)` so the code-as-config pipeline (`crate::sdk`) reuses the EXACT same loop-safety
/// predicate the host path uses — one definition, no divergence.
pub(crate) fn is_under_output(file: &str, anchor: &str) -> bool {
    let anchor = anchor.trim_end_matches('/');
    if anchor.is_empty() {
        return false;
    }
    file == anchor || file.starts_with(&format!("{anchor}/"))
}

/// Drop from `graph` every operation, schema, AND diagnostic whose provenance file is one of gnr8's
/// OWN generated outputs (any path under/at one of `anchors`, e.g. the SDK dir or the OpenAPI file) —
/// the architectural loop-safety primitive: gnr8 must never ingest or report on its own output.
///
/// Filtering removes a generated op, its generated schemas, and its diagnostics together (they share
/// the generated file's provenance), so no dangling `$ref` is introduced: real-source operations
/// reference only real-source schemas. `anchors` are project-relative (the provenance paths the graph
/// carries are relative to the same analyzed-module root the outputs are under).
///
/// `pub(crate)` so both the host lifecycle ([`exclude_output_paths`]) and the code-as-config pipeline
/// (`crate::sdk::Pipeline`) share ONE exclusion implementation (no second source of truth).
pub(crate) fn exclude_output_anchors(graph: &mut ApiGraph, anchors: &[&str]) {
    let is_generated = |file: &str| anchors.iter().any(|anchor| is_under_output(file, anchor));
    graph
        .operations
        .retain(|op| !is_generated(&op.provenance.file));
    graph
        .schemas
        .retain(|schema| !is_generated(&schema.provenance.file));
    graph.diagnostics.retain(|diag| !is_generated(&diag.file));
}

/// Compute the [`WritePlan`] for the child's `artifacts` WITHOUT touching disk — the dry-run seam
/// `gnr8 check` uses.
///
/// The host has already run the child (`crate::child::run_child` in the binary) and holds its
/// [`Artifact`]s. This loads the manifest from `.gnr8/` and classifies each artifact against the real
/// on-disk bytes via [`plan_writes`], but performs NO writes and does NOT save the manifest. The caller
/// inspects [`WritePlan::has_drift`] to decide an exit code.
///
/// # Errors
///
/// Returns [`crate::CoreError::Io`] for a root-escaping output path, or propagates a manifest read I/O
/// error as its typed `CoreError`. Never panics.
pub fn plan_only(
    project_root: &Path,
    artifacts: &[Artifact],
) -> Result<WritePlan, crate::CoreError> {
    validate_output_paths(project_root, artifacts)?;
    let manifest = manifest::load(&project_root.join(WORKSPACE_DIR))?;
    let disk = read_artifacts_from_disk(project_root, artifacts);
    let on_disk = move |path: &str| -> Option<Vec<u8>> { disk.get(path).cloned().flatten() };
    Ok(plan_writes(artifacts, &manifest, &on_disk))
}

/// Compute the dry-run write plan from cached artifact path/hash metadata.
///
/// The resulting plan is suitable for drift reporting (`gnr8 check` / `gnr8 doctor`). Its
/// `new_bytes` fields are empty because the metadata cache deliberately avoids loading generated text.
///
/// # Errors
///
/// Returns [`crate::CoreError::Io`] for a root-escaping output path, or propagates manifest I/O.
pub fn plan_only_cached(
    project_root: &Path,
    artifacts: &[ArtifactMetadata],
) -> Result<WritePlan, crate::CoreError> {
    validate_metadata_output_paths(project_root, artifacts)?;
    let manifest = manifest::load(&project_root.join(WORKSPACE_DIR))?;
    let disk_hashes = read_artifact_hashes_from_disk(project_root, artifacts);
    let on_disk_hash =
        move |path: &str| -> Option<String> { disk_hashes.get(path).cloned().flatten() };
    Ok(plan_metadata_writes(artifacts, &manifest, &on_disk_hash))
}

/// Write the child's `artifacts`, writing only changed files, and return the outcome counts.
///
/// The orchestrator the binary calls after running the child (RESEARCH §architecture). The host owns
/// only the WRITE machinery now — the child's pipeline produced the bytes. Steps: load the manifest
/// from `.gnr8/`, [`plan_writes`] against the real on-disk bytes, [`apply_writes`] (honoring `force`),
/// then `manifest.save`. Returns the [`GenerateOutcome`] (written/unchanged/skipped).
///
/// `force=false` ⇒ user-edited / pre-existing outputs are protected (warn + skip at the CLI);
/// `force=true` ⇒ they are overwritten. A no-op run (unchanged source ⇒ identical artifacts) writes
/// zero files (WATCH-01).
///
/// # Errors
///
/// Propagates any manifest/write I/O error (including a traversal-escaping output path) as its typed
/// `CoreError`. Never panics.
pub fn regenerate(
    project_root: &Path,
    artifacts: &[Artifact],
    force: bool,
) -> Result<GenerateOutcome, crate::CoreError> {
    regenerate_with_anchors(project_root, artifacts, &[], force)
}

/// Write artifacts and prune stale files under target output anchors.
///
/// # Errors
///
/// Propagates any manifest/write/prune I/O error as its typed [`CoreError`](crate::CoreError).
/// Never panics.
pub fn regenerate_with_anchors(
    project_root: &Path,
    artifacts: &[Artifact],
    output_anchors: &[String],
    force: bool,
) -> Result<GenerateOutcome, crate::CoreError> {
    validate_output_paths(project_root, artifacts)?;
    let gnr8_dir = project_root.join(WORKSPACE_DIR);
    let mut manifest = manifest::load(&gnr8_dir)?;

    let disk = read_artifacts_from_disk(project_root, artifacts);
    let on_disk = move |path: &str| -> Option<Vec<u8>> { disk.get(path).cloned().flatten() };
    let plan = plan_writes(artifacts, &manifest, &on_disk);

    let outcome =
        apply_writes_with_anchors(project_root, &plan, &mut manifest, force, output_anchors)?;
    manifest.save(&gnr8_dir)?;
    Ok(outcome)
}

/// Try to complete a generation from cached artifact metadata without loading generated text.
///
/// Returns `Ok(Some(outcome))` when every produced file is either unchanged or protected/skipped, so
/// full text is unnecessary. Returns `Ok(None)` when at least one file must be written; the caller
/// should then load the full artifact cache and use [`regenerate_with_anchors`].
///
/// # Errors
///
/// Propagates manifest/write/prune I/O errors as typed [`crate::CoreError`] values.
pub fn regenerate_cached_with_anchors(
    project_root: &Path,
    artifacts: &[ArtifactMetadata],
    output_anchors: &[String],
    force: bool,
) -> Result<Option<GenerateOutcome>, crate::CoreError> {
    validate_metadata_output_paths(project_root, artifacts)?;
    let gnr8_dir = project_root.join(WORKSPACE_DIR);
    let mut manifest = manifest::load(&gnr8_dir)?;

    let disk_hashes = read_artifact_hashes_from_disk(project_root, artifacts);
    let on_disk_hash =
        move |path: &str| -> Option<String> { disk_hashes.get(path).cloned().flatten() };
    let plan = plan_metadata_writes(artifacts, &manifest, &on_disk_hash);
    if plan.files.iter().any(|file| {
        matches!(file.action, WriteAction::Write)
            || (force && matches!(file.action, WriteAction::UserEdited))
    }) {
        return Ok(None);
    }

    let mut out = GenerateOutcome::default();
    for file in &plan.files {
        match file.action {
            WriteAction::Unchanged => {
                out.unchanged.push(file.path.clone());
                manifest.record(&file.path, &file.new_hash, &file.source);
            }
            WriteAction::UserEdited => out.skipped.push(file.path.clone()),
            WriteAction::Write => return Ok(None),
        }
    }

    let current_paths: BTreeSet<String> = plan.files.iter().map(|f| f.path.clone()).collect();
    prune_stale_manifest_files(project_root, &manifest, &current_paths, &mut out)?;
    prune_anchor_files(
        project_root,
        output_anchors,
        &current_paths,
        &manifest,
        force,
        &mut out,
    )?;

    let current_paths_vec: Vec<String> = current_paths.iter().cloned().collect();
    manifest.prune_to(&current_paths_vec);
    manifest.save(&gnr8_dir)?;
    Ok(Some(out))
}

fn read_artifacts_from_disk(
    project_root: &Path,
    artifacts: &[Artifact],
) -> HashMap<String, Option<Vec<u8>>> {
    if artifacts.is_empty() {
        return HashMap::new();
    }

    let workers = std::thread::available_parallelism().map_or(4, usize::from);
    let workers = workers.clamp(1, artifacts.len());
    let chunk_size = artifacts.len().div_ceil(workers);
    let root = project_root.to_path_buf();
    let paths: Vec<String> = artifacts
        .iter()
        .map(|artifact| artifact.path.clone())
        .collect();

    std::thread::scope(|scope| {
        let mut handles = Vec::new();
        for chunk in paths.chunks(chunk_size) {
            let root = root.clone();
            let chunk = chunk.to_vec();
            handles.push(scope.spawn(move || {
                chunk
                    .into_iter()
                    .map(|path| {
                        let bytes = std::fs::read(root.join(&path)).ok();
                        (path, bytes)
                    })
                    .collect::<Vec<_>>()
            }));
        }

        let mut disk = HashMap::with_capacity(paths.len());
        for handle in handles {
            if let Ok(entries) = handle.join() {
                disk.extend(entries);
            }
        }
        disk
    })
}

fn read_artifact_hashes_from_disk(
    project_root: &Path,
    artifacts: &[ArtifactMetadata],
) -> HashMap<String, Option<String>> {
    let paths: Vec<String> = artifacts
        .iter()
        .map(|artifact| artifact.path.clone())
        .collect();
    crate::sdk::hash_project_files(project_root, &paths)
}

#[cfg(test)]
mod tests {
    // Tests legitimately use unwrap/expect/panic (rust-best-practices skill ch.4 + ch.5); scope the
    // allow so the workspace-wide RUST-04 deny stays intact for production code.
    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]

    use super::{apply_writes, apply_writes_with_anchors, safe_output_path, WriteAction};
    use crate::manifest::Manifest;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn temp_root(name: &str) -> std::path::PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let dir = std::env::temp_dir().join(format!("gnr8-{name}-{nanos}"));
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn safe_output_path_rejects_traversal_and_absolute() {
        let root = std::path::Path::new("/tmp/proj");
        assert!(safe_output_path(root, "sdk/client.go").is_ok());
        assert!(safe_output_path(root, "../escape.go").is_err());
        assert!(safe_output_path(root, "sdk/../../etc/passwd").is_err());
        assert!(safe_output_path(root, "/etc/passwd").is_err());
        assert!(safe_output_path(root, "").is_err());
    }

    #[test]
    fn apply_writes_rejects_a_traversal_output_path() {
        // A plan whose path escapes the root must error, not write.
        let plan = super::WritePlan {
            files: vec![super::PlannedFile {
                path: "../escape.go".to_string(),
                action: WriteAction::Write,
                new_bytes: b"x".to_vec(),
                new_hash: "h".to_string(),
                source: "sdk".to_string(),
            }],
        };
        let mut manifest = Manifest::default();
        let result = apply_writes(
            std::path::Path::new("/tmp/proj-does-not-exist"),
            &plan,
            &mut manifest,
            false,
        );
        assert!(
            matches!(result, Err(crate::CoreError::Io { .. })),
            "a traversal path must be rejected with CoreError::Io, got {result:?}"
        );
    }

    #[test]
    fn plan_only_rejects_a_traversal_output_path() {
        // The dry-run seam must reject an escaping path with the SAME typed error as the write path, so
        // `gnr8 check` never mis-classifies it as drift (and the planner never hashes a file outside the
        // root). Parity with `apply_writes_rejects_a_traversal_output_path`.
        let artifacts = vec![crate::sdk::Artifact {
            path: "../escape.go".to_string(),
            text: "x".to_string(),
        }];
        let result = super::plan_only(std::path::Path::new("/tmp/proj-does-not-exist"), &artifacts);
        assert!(
            matches!(result, Err(crate::CoreError::Io { .. })),
            "a traversal path must be rejected with CoreError::Io in dry-run too, got {result:?}"
        );
    }

    #[test]
    fn apply_writes_deletes_unchanged_manifest_owned_stale_files() {
        let root = temp_root("stale-owned");
        std::fs::create_dir_all(root.join("sdk")).unwrap();
        std::fs::write(root.join("sdk/old.go"), "package sdk\n").unwrap();

        let mut manifest = Manifest::default();
        manifest.record(
            "sdk/old.go",
            &crate::manifest::blake3_hex(b"package sdk\n"),
            "generated",
        );
        let plan = super::WritePlan {
            files: vec![super::PlannedFile {
                path: "sdk/new.go".to_string(),
                action: WriteAction::Write,
                new_bytes: b"package sdk\n// new\n".to_vec(),
                new_hash: crate::manifest::blake3_hex(b"package sdk\n// new\n"),
                source: "generated".to_string(),
            }],
        };

        let outcome = apply_writes_with_anchors(&root, &plan, &mut manifest, false, &[]).unwrap();

        assert!(!root.join("sdk/old.go").exists());
        assert!(root.join("sdk/new.go").exists());
        assert_eq!(outcome.deleted, vec!["sdk/old.go"]);
        assert_eq!(manifest.recorded_hash("sdk/old.go"), None);
        assert!(manifest.recorded_hash("sdk/new.go").is_some());
        let _ = std::fs::remove_dir_all(root);
    }

    #[test]
    fn force_prunes_untracked_files_under_output_anchors() {
        let root = temp_root("anchor-prune");
        std::fs::create_dir_all(root.join("sdk/nested")).unwrap();
        std::fs::write(root.join("sdk/nested/old.go"), "package sdk\n").unwrap();
        let plan = super::WritePlan::default();
        let mut manifest = Manifest::default();

        let outcome =
            apply_writes_with_anchors(&root, &plan, &mut manifest, true, &["sdk".to_string()])
                .unwrap();

        assert!(!root.join("sdk/nested/old.go").exists());
        assert_eq!(outcome.deleted, vec!["sdk/nested/old.go"]);
        let _ = std::fs::remove_dir_all(root);
    }

    /// WATCH-01 loop-safety twin: the pure output-anchor test matches a file AT or UNDER an anchor,
    /// only on a path-separator boundary, so gnr8's own generated files are excluded from analysis
    /// while a sibling sharing the anchor as a string prefix is NOT mis-excluded.
    #[test]
    fn is_under_output_matches_only_on_a_separator_boundary() {
        use super::is_under_output;
        // Under the SDK dir → generated, exclude.
        assert!(is_under_output("sdk/client.go", "sdk"));
        assert!(is_under_output("sdk/models.go", "sdk"));
        // The dir itself (exact match).
        assert!(is_under_output("sdk", "sdk"));
        // A trailing slash on the anchor is tolerated.
        assert!(is_under_output("sdk/client.go", "sdk/"));
        // The OpenAPI artifact path (exact file).
        assert!(is_under_output("openapi.yaml", "openapi.yaml"));
        // The workspace dir.
        assert!(is_under_output(".gnr8/cache/manifest.json", ".gnr8"));

        // A sibling sharing the anchor as a STRING prefix is NOT under it (no false exclusion).
        assert!(!is_under_output("sdklib/x.go", "sdk"));
        assert!(!is_under_output("internal/goal/ports/http.go", "sdk"));
        // An empty anchor never matches (a no-op anchor must not swallow the whole graph).
        assert!(!is_under_output("internal/dto.go", ""));
    }
}
