//! Cordance CLI — library entry.
//!
//! All dispatch logic for the `cordance` binary lives here. The companion
//! `src/main.rs` is a thin shim that calls [`run`]. The umbrella package
//! [`cordance`](https://crates.io/crates/cordance) also calls into `run` so
//! `cargo install cordance` and `cargo install cordance-cli` install the
//! same binary.
//!
//! ## Subcommands
//! - `cordance init` — write `cordance.toml`.
//! - `cordance scan` — emit a Markdown report at `.cordance/scan-report.md`.
//! - `cordance pack` — compile the pack, emit every target.
//! - `cordance advise` — deterministic doctrine checks (no LLM).
//! - `cordance doctrine <topic>` — query engineering-doctrine.
//! - `cordance cortex push` — emit a `cordance-cortex-receipt-v1-candidate.json`.
//! - `cordance check` — drift detection vs `.cordance/sources.lock`.
//! - `cordance explain <rule>` — provenance of a generated rule.
//! - `cordance watch` — re-run pack on file changes.
//! - `cordance serve` — start the JSON-RPC MCP server (stdio).
//! - `cordance doctor` — pre-flight checks.
//! - `cordance completions <shell>` — emit shell completion scripts.

#![forbid(unsafe_code)]

mod advise_cmd;
mod check_cmd;
mod completions_cmd;
mod config;
mod cortex_cmd;
mod doctor_cmd;
mod doctrine_cmd;
mod explain_cmd;
mod init_cmd;
mod mcp;
mod pack_cmd;
mod scan_cmd;
mod serve_cmd;
mod watch_cmd;

use std::process::ExitCode;

use anyhow::Context;
use camino::Utf8PathBuf;
use clap::{CommandFactory, Parser, Subcommand};
use cordance_core::pack::PackTargets;

#[derive(Parser, Debug)]
#[command(
    name = "cordance",
    version,
    about = "Deterministic-first context-pack compiler.",
    long_about = "Cordance compiles project doctrine, ADRs, schemas, and source layout \
                  into auditable AI-agent context packs. Doctrine-shaped. Cortex-aware. \
                  Axiom-harness-compatible. No LLM at v0."
)]
struct Cli {
    /// Path to cordance.toml (default: ./cordance.toml).
    #[arg(long, env = "CORDANCE_CONFIG")]
    config: Option<String>,

    /// Repo to operate on (default: current directory).
    #[arg(long, default_value = ".")]
    target: String,

    /// Permit --target paths outside the current working directory.
    ///
    /// Default off: prevents accidental filesystem enumeration when an
    /// untrusted argument resolves outside the invocation's CWD (e.g.
    /// `--target /` or a relative path that climbs out via `..`). Pass
    /// this flag explicitly when running cordance against a sibling
    /// project, e.g. `cordance pack --target ../sibling
    /// --allow-outside-cwd`. UNC paths (`\\server\share`) and Windows
    /// extended-length prefixes (`\\?\`) are also gated by this flag.
    #[arg(long, global = true, default_value_t = false)]
    allow_outside_cwd: bool,

    #[command(subcommand)]
    cmd: Command,
}

#[derive(Subcommand, Debug)]
enum Command {
    /// Write a default cordance.toml.
    Init,

    /// Scan the repo and emit a JSON report.
    Scan {
        #[arg(long)]
        json: bool,
    },

    /// Compile the pack and emit all selected targets.
    Pack {
        /// `write` (default), `dry-run`, or `diff`.
        #[arg(long, default_value = "write")]
        output_mode: String,

        /// Comma-separated subset of targets, default = all.
        #[arg(long)]
        targets: Option<String>,

        /// LLM provider for candidate prose generation.
        /// Default: from cordance.toml `[llm].provider`, or "none".
        /// Accepted values: `none`, `ollama`.
        #[arg(long)]
        llm: Option<String>,

        /// Ollama model name (only used when `--llm ollama`).
        #[arg(long, default_value = "qwen2.5-coder:14b")]
        ollama_model: String,
    },

    /// Run deterministic doctrine checks.
    Advise {
        #[arg(long)]
        json: bool,
    },

    /// Query engineering-doctrine for a topic.
    Doctrine {
        topic: String,
    },

    /// Cortex subcommands.
    #[command(subcommand)]
    Cortex(CortexCmd),

    /// Validate the repo against the most recent pack's sources.lock.
    Check,

    /// Show the doctrine/ADR/schema sources behind a generated rule.
    Explain {
        rule: String,
    },

    /// Watch target for changes and re-run pack automatically.
    Watch {
        /// Debounce delay in milliseconds.
        #[arg(long, default_value = "500")]
        debounce_ms: u64,
    },

    /// Start a JSON-RPC MCP server (stdio) exposing context, advise, check.
    Serve,

    /// Emit shell completion scripts.
    Completions {
        /// Shell type: bash, zsh, fish, powershell.
        shell: String,
    },

    /// Run pre-flight checks (axiom, doctrine, Ollama reachability).
    Doctor,
}

#[derive(Subcommand, Debug)]
enum CortexCmd {
    /// Emit a cordance-cortex-receipt-v1-candidate JSON ready for Cortex acceptance.
    Push {
        #[arg(long)]
        dry_run: bool,
    },
}

/// Run the cordance CLI: parse argv, install tracing, dispatch the subcommand,
/// and return a process [`ExitCode`].
///
/// Both `cordance-cli`'s thin `main.rs` and the [`cordance`](https://crates.io/crates/cordance)
/// umbrella package's `main.rs` call this directly. Embedding `cordance` as a
/// library (e.g. inside a test harness or a higher-level CLI) is also
/// supported — call `cordance_cli::run()` from your own `main()`.
#[must_use]
pub fn run() -> ExitCode {
    let cli = Cli::parse();

    // MCP stdout is reserved for JSON-RPC frames. `serve` installs its own
    // stderr-only subscriber inside `serve_cmd::run`; all other subcommands
    // get the normal tracing fmt subscriber (which targets stdout by default
    // but is acceptable because they are not speaking a wire protocol).
    if !matches!(cli.cmd, Command::Serve) {
        let _ = tracing_subscriber::fmt()
            .with_env_filter(
                tracing_subscriber::EnvFilter::try_from_env("CORDANCE_LOG")
                    .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
            )
            .try_init();
    }

    match dispatch(&cli) {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            // Print the full error chain so the operator sees both the
            // context (e.g. "loading cordance.toml at ...") and the
            // underlying cause (e.g. "toml parse error in ...: ...").
            // Without this, a `with_context()` wrapper hides the root
            // cause behind its summary and the user is told "something
            // went wrong" with no actionable path.
            eprint!("cordance: {e}");
            for cause in e.chain().skip(1) {
                eprint!("\n  caused by: {cause}");
            }
            eprintln!();
            ExitCode::from(1)
        }
    }
}

fn dispatch(cli: &Cli) -> anyhow::Result<()> {
    let target = validate_cli_target(&cli.target, cli.allow_outside_cwd)?;
    // ADR `single-source-of-truth.md` + round-2 bughunt #3: a malformed
    // `cordance.toml` must fail loudly with the parse error path printed,
    // not silently downgrade to defaults.
    let cfg = config::Config::load_strict(&target)
        .with_context(|| format!("loading cordance.toml at {target}"))?;

    match &cli.cmd {
        Command::Init => {
            init_cmd::run(&target)?;
        }

        Command::Scan { json } => {
            scan_cmd::run(&target, *json)?;
        }

        Command::Pack {
            output_mode,
            targets,
            llm,
            ollama_model,
        } => {
            let selected_targets = PackTargets::from_csv(targets.as_deref())
                .with_context(|| "parsing --targets")?;
            let pack_config = pack_cmd::PackConfig {
                target,
                output_mode: pack_cmd::OutputMode::from_str(output_mode),
                selected_targets,
                doctrine_root: None,
                llm_provider: llm.clone(),
                ollama_model: Some(ollama_model.clone()),
                quiet: false,
            };
            let pack = pack_cmd::run(&pack_config)?;
            let counts = pack.outputs.len();
            println!("cordance pack: {counts} outputs written");
        }

        Command::Advise { json } => {
            let pack_config = pack_cmd::PackConfig {
                target,
                output_mode: pack_cmd::OutputMode::DryRun,
                selected_targets: PackTargets::default(),
                doctrine_root: None,
                llm_provider: None,
                ollama_model: None,
                quiet: false,
            };
            let pack = pack_cmd::run(&pack_config)?;
            advise_cmd::run(&pack, *json)?;
        }

        Command::Doctrine { topic } => {
            doctrine_cmd::run(topic, &cfg.doctrine_root(&target))?;
        }

        Command::Cortex(CortexCmd::Push { dry_run }) => {
            let pack_config = pack_cmd::PackConfig {
                target: target.clone(),
                output_mode: pack_cmd::OutputMode::DryRun,
                quiet: false,
                selected_targets: PackTargets {
                    cortex_receipt: true,
                    ..Default::default()
                },
                doctrine_root: None,
                llm_provider: None,
                ollama_model: None,
            };
            let pack = pack_cmd::run(&pack_config)?;
            cortex_cmd::run_push(&pack, &target, *dry_run)?;
        }

        Command::Check => {
            let code = check_cmd::run(&target)?;
            std::process::exit(code);
        }

        Command::Watch { debounce_ms } => {
            watch_cmd::run(&target, *debounce_ms)?;
        }

        Command::Serve => {
            serve_cmd::run(&cfg, &target)?;
        }

        Command::Completions { shell } => {
            completions_cmd::run(shell, Cli::command())?;
        }

        Command::Explain { rule } => {
            explain_cmd::run(rule, &target)?;
        }

        Command::Doctor => {
            doctor_cmd::run(&cfg, &target)?;
        }
    }

    Ok(())
}

// `parse_targets` lived here through round 3 as a hand-rolled substring
// scanner that matched `cursor` inside `no-cursor` and `supercursor`. Round-4
// codereview #4 / bughunt #9 consolidated the logic into
// `cordance_core::pack::PackTargets::from_csv`, which exact-matches each
// trimmed token and returns a typed `ParseTargetsError::UnknownTarget` for
// anything else. Both the CLI dispatcher above and `mcp::tools::pack` route
// through it now, eliminating the divergence between the two parsers.

/// Validate a CLI `--target` argument before any subcommand runs.
///
/// This is the CLI-side counterpart to [`mcp::validation::validate_target`]:
/// every untrusted target string is canonicalised and must resolve under the
/// invocation's current working directory unless the caller explicitly opted
/// out with `--allow-outside-cwd`.
///
/// Non-existent paths are permitted as long as the deepest existing ancestor
/// canonicalises into CWD — this is what makes `cordance init --target ./new`
/// usable while still rejecting `--target /etc/passwd/foo`.
///
/// UNC paths (`\\server\share`) and Windows extended-length prefixes (`\\?\`)
/// are rejected without `--allow-outside-cwd`, because they cannot meaningfully
/// be "inside" a posix-ish CWD and are a common bypass shape.
fn validate_cli_target(target: &str, allow_outside: bool) -> anyhow::Result<Utf8PathBuf> {
    if target.is_empty() {
        anyhow::bail!("target is empty");
    }
    if target.as_bytes().contains(&0) {
        anyhow::bail!("target contains NUL byte");
    }

    if !allow_outside {
        // `\\?\` and `\\server\share` always start with two backslashes; reject
        // them up-front before we even try to canonicalise. This keeps the
        // "must live under CWD" promise simple — a UNC path has no meaningful
        // ancestor relationship with a local CWD.
        if target.starts_with("\\\\") || target.starts_with("//") {
            anyhow::bail!(
                "UNC and extended-length paths are not permitted without --allow-outside-cwd"
            );
        }
    }

    let raw = Utf8PathBuf::from(target);
    let cwd = std::env::current_dir().context("cannot resolve current directory")?;
    let cwd_canonical = dunce::canonicalize(&cwd).context("cannot canonicalise cwd")?;

    // Resolve target absolutely. For non-existent paths, canonicalise the
    // deepest existing ancestor and re-attach the trailing components — this
    // mirrors how `validate_target` handles `.` in the MCP layer but is more
    // permissive: the CLI is allowed to point at a path that does not yet
    // exist (e.g. `cordance init --target ./new-repo`).
    let abs_path: std::path::PathBuf = if raw.is_absolute() {
        raw.as_std_path().to_path_buf()
    } else {
        cwd.join(raw.as_std_path())
    };

    // Round-2 bughunt #1 + round-3 bughunt #1: a *dangling* symlink (its
    // target does not exist) never enters the `abs_path.exists()` branch,
    // and the ancestor walk below silently follows it via component-by-
    // component pop. To plug that bypass we explicitly walk every existing
    // component of the requested path and reject any symlink whose resolved
    // target escapes CWD. The round-3 refinement: relative symlink targets
    // containing `..` segments must be normalised before the prefix check,
    // because `Path::starts_with` is syntactic and would otherwise accept
    // a target like `<cwd>/../../etc/passwd`. Windows directory junctions
    // are also reparse points and are now caught alongside symlinks.
    if !allow_outside {
        reject_symlinks_pointing_outside(&abs_path, &cwd_canonical)?;
    }

    let canonical = if abs_path.exists() {
        dunce::canonicalize(&abs_path)
            .with_context(|| format!("cannot canonicalise {target}"))?
    } else {
        let mut probe = abs_path.clone();
        while !probe.exists() {
            match probe.parent() {
                Some(p) => probe = p.to_path_buf(),
                None => anyhow::bail!("target path has no existing ancestor: {target}"),
            }
        }
        let canon_ancestor = dunce::canonicalize(&probe)
            .with_context(|| format!("cannot canonicalise ancestor of {target}"))?;
        let suffix = abs_path.strip_prefix(&probe).unwrap_or(&abs_path);
        canon_ancestor.join(suffix)
    };

    if !allow_outside && !canonical.starts_with(&cwd_canonical) {
        anyhow::bail!(
            "target is outside the current working directory; pass --allow-outside-cwd \
             to permit it explicitly"
        );
    }

    Utf8PathBuf::try_from(canonical)
        .map_err(|_| anyhow::anyhow!("target path is not valid UTF-8"))
}

/// Walk every existing ancestor of `path` and reject any reparse point
/// (POSIX symlink, Windows symlink, OR Windows directory junction) whose
/// resolved target points outside `cwd_canonical`. Catches dangling symlinks
/// (whose target does not exist) and reparse points embedded as intermediate
/// components, which `dunce::canonicalize` would otherwise quietly follow or
/// preserve.
///
/// Round-3 bughunt #1 (relative-symlink escape):
/// `Path::starts_with` is a syntactic prefix check. A relative symlink target
/// of `../../../etc/passwd` joined onto the link's parent yields a path that
/// *textually* contains the parent directory as its prefix — so a naive
/// `starts_with` says "yes, inside cwd" even though `..` segments make the
/// effective path escape. We MUST normalise `..` segments before the prefix
/// check, even for paths that do not exist on disk (dangling targets).
///
/// Round-3 bughunt (cross-platform): Windows directory junctions are reparse
/// points that `std::fs::FileType::is_symlink` returns `false` for, but they
/// behave like symlinks for the purpose of jumping out of the cwd subtree.
/// [`is_reparse_or_symlink`] also checks the Windows reparse-point file
/// attribute so junctions are caught.
///
/// "Resolved target" semantics:
/// * Absolute link targets are checked verbatim.
/// * Relative link targets are joined onto the symlink's parent and then
///   syntactically normalised (`..` pops, `.` is ignored).
/// * If the resolved target itself exists, it is canonicalised (so a chain
///   of symlinks that ultimately escapes is still caught).
/// * Windows junctions: we can't read the target without unsafe Win32 APIs,
///   so we conservatively reject every reparse point we can't classify
///   without `--allow-outside-cwd`.
fn reject_symlinks_pointing_outside(
    path: &std::path::Path,
    cwd_canonical: &std::path::Path,
) -> anyhow::Result<()> {
    let mut probe: std::path::PathBuf = path.to_path_buf();
    loop {
        if is_reparse_or_symlink(&probe) {
            // POSIX symlinks have a target we can read via `read_link`.
            // Windows junctions also respond to `read_link` on modern
            // Rust (>=1.83), but the target is an unparsed UNC-prefixed
            // path; treat read failures conservatively as "can't prove
            // it's inside cwd" and reject.
            match std::fs::read_link(&probe) {
                Ok(link_target) => {
                    let resolved = if link_target.is_absolute() {
                        link_target.clone()
                    } else {
                        probe
                            .parent()
                            .map_or_else(|| link_target.clone(), |p| p.join(&link_target))
                    };
                    // Critical: normalise `..` segments BEFORE canonicalising
                    // or starts_with-checking. For an existing target we
                    // canonicalise (which also resolves `..`); for a dangling
                    // target the manual normaliser below is the only thing
                    // that pops `..` so we don't trust a syntactic prefix.
                    let resolved_canonical = if resolved.exists() {
                        dunce::canonicalize(&resolved).with_context(|| {
                            format!(
                                "cannot canonicalise symlink target {}",
                                resolved.display()
                            )
                        })?
                    } else {
                        normalise_path_segments(&resolved)
                    };
                    if !resolved_canonical.starts_with(cwd_canonical) {
                        anyhow::bail!(
                            "target path contains a symlink pointing outside cwd; \
                             pass --allow-outside-cwd to permit"
                        );
                    }
                }
                Err(_) => {
                    // A reparse point whose target we can't read. We don't
                    // know if it's safe; refuse it unless the operator
                    // explicitly opts in via --allow-outside-cwd. Windows
                    // directory junctions can land here on older toolchains
                    // and on edge-case reparse tags.
                    anyhow::bail!(
                        "target path contains a reparse point whose target cannot be \
                         resolved; pass --allow-outside-cwd to permit"
                    );
                }
            }
        }
        if !probe.pop() {
            break;
        }
    }
    Ok(())
}

/// Syntactically normalise a path: pop a component for every `..`, ignore
/// every `.`. Used on dangling symlink targets where neither `exists()` nor
/// `canonicalize()` will resolve `..` for us.
///
/// This is intentionally *not* equivalent to `canonicalize`: it does not
/// resolve symlinks, does not access the filesystem, and does not detect
/// pops past the root. For our use case (proving the *parent path* of a
/// dangling link target either lives inside cwd or doesn't), syntactic
/// normalisation is enough — a path that pops past root will land at an
/// empty `PathBuf`, which won't `starts_with(cwd_canonical)` and is rightly
/// rejected.
fn normalise_path_segments(p: &std::path::Path) -> std::path::PathBuf {
    let mut out = std::path::PathBuf::new();
    for component in p.components() {
        match component {
            std::path::Component::ParentDir => {
                out.pop();
            }
            std::path::Component::CurDir => {}
            other => out.push(other),
        }
    }
    out
}

/// Return `true` when `path` is a POSIX symlink OR a Windows reparse point
/// (symlink-file, symlink-dir, or directory junction).
///
/// `std::fs::FileType::is_symlink()` returns `false` for Windows directory
/// junctions even though they short-circuit the filesystem in the same way a
/// symlink does. On Windows we additionally read the file attributes and check
/// for `FILE_ATTRIBUTE_REPARSE_POINT` (0x400) so junctions are caught.
fn is_reparse_or_symlink(path: &std::path::Path) -> bool {
    let Ok(meta) = std::fs::symlink_metadata(path) else {
        return false;
    };
    if meta.file_type().is_symlink() {
        return true;
    }
    #[cfg(windows)]
    {
        use std::os::windows::fs::MetadataExt;
        const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
        if meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
            return true;
        }
    }
    false
}

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

    #[test]
    fn cli_parses() {
        Cli::command().debug_assert();
    }

    #[test]
    fn validate_cli_target_accepts_dot() {
        let resolved = validate_cli_target(".", false).expect("cwd should be allowed");
        let cwd = std::env::current_dir().expect("cwd");
        let cwd_canon = dunce::canonicalize(&cwd).expect("canon cwd");
        assert!(resolved.as_std_path().starts_with(&cwd_canon));
    }

    #[test]
    fn validate_cli_target_rejects_empty() {
        let err = validate_cli_target("", false).expect_err("empty must error");
        assert!(err.to_string().contains("empty"));
    }

    #[test]
    fn validate_cli_target_rejects_nul() {
        let err = validate_cli_target("foo\0bar", false).expect_err("NUL must error");
        assert!(err.to_string().contains("NUL"));
    }

    #[test]
    fn validate_cli_target_rejects_unc_without_flag() {
        let err = validate_cli_target("\\\\server\\share", false)
            .expect_err("UNC must error without --allow-outside-cwd");
        assert!(err.to_string().contains("UNC"));
    }

    /// Round-2 bughunt #1: a dangling symlink whose target lives outside
    /// CWD must be rejected up-front. Before this fix, `validate_cli_target`
    /// happily climbed the ancestor chain (the link target did not exist,
    /// so it never tripped the canonical-prefix check) and `cordance init`
    /// would write through the link at I/O time.
    #[test]
    #[cfg(unix)]
    fn cli_rejects_dangling_symlink_pointing_outside_cwd() {
        let dir = tempfile::tempdir().expect("tempdir");
        let link_path = dir.path().join("evil-link");
        std::os::unix::fs::symlink("/etc/passwd-nonexistent", &link_path)
            .expect("symlink");
        // Run with cwd set to `dir` so the symlink lives inside cwd but its
        // target does not. `validate_cli_target` resolves relative paths
        // against the process cwd, so we pass an absolute path to keep the
        // test independent of where the test runner was launched from.
        let result = validate_cli_target(
            link_path.to_str().expect("symlink path is utf8"),
            false,
        );
        assert!(
            result.is_err(),
            "dangling symlink to /etc should be rejected, got {result:?}"
        );
        let msg = format!("{:?}", result.err().unwrap_or_else(|| anyhow::anyhow!("?")));
        assert!(
            msg.contains("symlink") || msg.contains("outside"),
            "expected symlink/outside-cwd error, got {msg}"
        );
    }

    /// Round-3 bughunt #1: a relative symlink target that escapes cwd via
    /// `..` segments must be rejected. The earlier guard relied on
    /// `Path::starts_with`, which is a textual-prefix check and was happy
    /// to accept `<cwd>/<dir>/../<dir>/../../etc/passwd` since the leading
    /// substring `<cwd>` did show up in the joined path. The fix
    /// (normalise_path_segments) pops `..` segments before the prefix
    /// check so the resolved target is treated semantically.
    #[test]
    #[cfg(unix)]
    fn cli_rejects_relative_symlink_escaping_cwd_via_dotdot() {
        let dir = tempfile::tempdir().expect("tempdir");
        let link_path = dir.path().join("trap");
        // The target is RELATIVE (no leading slash) and contains enough
        // `..` segments to pop past the temp dir and any plausible cwd.
        std::os::unix::fs::symlink(
            "../../../../../../../../../../../etc/passwd-nonexistent",
            &link_path,
        )
        .expect("symlink");
        let result = validate_cli_target(
            link_path.to_str().expect("symlink path is utf8"),
            false,
        );
        assert!(
            result.is_err(),
            "relative symlink with .. escape must be rejected, got {result:?}"
        );
        let msg = format!("{:?}", result.err().unwrap_or_else(|| anyhow::anyhow!("?")));
        assert!(
            msg.contains("symlink") || msg.contains("outside"),
            "expected symlink/outside-cwd error, got {msg}"
        );
    }

    /// Unit test for the path-normalisation helper. Documents the contract:
    ///   - `..` segments pop a component from the in-progress output.
    ///   - `.` segments are dropped.
    ///   - All other segments (root, prefix, normal) are preserved.
    ///
    /// Without this normaliser, `Path::starts_with` would accept paths whose
    /// `..` segments would otherwise climb past the cwd prefix.
    #[test]
    fn normalise_path_segments_pops_dotdot() {
        let normalised = normalise_path_segments(std::path::Path::new(
            "/some/base/../../etc/passwd",
        ));
        // After two `..` pops from /some/base we have /, then /etc, /etc/passwd.
        assert_eq!(normalised, std::path::PathBuf::from("/etc/passwd"));
    }

    #[test]
    fn normalise_path_segments_drops_curdir() {
        let normalised = normalise_path_segments(std::path::Path::new(
            "/some/./base/./file",
        ));
        assert_eq!(normalised, std::path::PathBuf::from("/some/base/file"));
    }

    #[test]
    fn normalise_path_segments_handles_relative() {
        let normalised = normalise_path_segments(std::path::Path::new(
            "foo/bar/../baz",
        ));
        assert_eq!(normalised, std::path::PathBuf::from("foo/baz"));
    }
}
