//! Shared types, errors, and traits used by every ckg crate.
//!
//! This crate defines the data model (`Symbol`, `Edge`, `RepoId`, `Kind`)
//! and the canonical `Error` type.
//!
//! ## I/O scope (F4)
//!
//! Originally this crate was advertised as "no I/O". In practice
//! `RepoId::from_path` calls `Path::canonicalize` + `std::env::current_dir`
//! to derive a stable repo identity, and `canonical_or_lexical_bytes`
//! emits a `tracing`-equivalent `eprintln!` on the cwd-resolution fallback
//! (the crate has no `tracing` dep by design). These are read-only,
//! best-effort filesystem probes — not networking, not database access,
//! not parsing of repo contents. The invariant is now stated as: no
//! networking, no DB access, no filesystem mutation, no reading of repo
//! content; identity helpers may probe the path's existence to choose
//! between canonical and lexical resolution.

use std::path::{Component, Path, PathBuf};

use serde::{Deserialize, Serialize};

pub mod id;

pub use id::{node_id, node_id_escape, node_id_unescape};

/// Resolve the default `$CKG_HOME` location for the current user: `~/.ckg`.
///
/// Returns `None` only if the OS doesn't expose a home directory.
pub fn default_home() -> Option<PathBuf> {
    Some(dirs::home_dir()?.join(".ckg"))
}

/// 24-char hex digest of `blake3(canonical_path)[..12]`. Stable across processes.
///
/// ## Determinism
///
/// `from_path` resolves symlinks via `canonicalize` when the path exists.
/// When canonicalize fails (e.g. dangling symlink, permission denied, path
/// not yet created), it falls back to a *lexical* normalization: the path
/// is absolutized against the current cwd and `.` / `..` segments are
/// collapsed without touching the filesystem. The previous fallback used
/// `Path::display()` which (a) was non-deterministic across cwd for
/// relative inputs and (b) replaced non-UTF8 bytes with U+FFFD — two
/// distinct non-UTF8 paths could collide. The new path hashes the raw
/// bytes (Unix) or the WTF-8 encoding (Windows via `OsStr`).
///
/// ## Collision risk
///
/// 96 bits of digest = ~1 in 2^48 collision probability per repo pair
/// (was 2^32 with the 64-bit truncation). Negligible across any
/// realistic shared workstation; storage layer still cross-checks the
/// recorded root_path on open as defense-in-depth (see I2). Bumped from
/// 16 hex chars (8 bytes) → 24 hex chars (12 bytes) in pre-release; no
/// migration needed since v0.1 isn't out.
///
/// The tuple field is private. Construct via `from_path` (the
/// production path) or `try_new` (raw-string ingest from manifest /
/// CLI input). Tests use `try_new(...).unwrap()`. The previous `pub`
/// field was a latent path-traversal hole — `RepoId("../../../etc")`
/// would have happily been used as a directory component under
/// `$CKG_HOME/workspace_folders/`.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RepoId(String);

impl RepoId {
    /// Derive a `RepoId` from a path. Canonicalises when the path exists;
    /// otherwise falls back to lexical normalization (cwd join + dot
    /// collapse) so the result is deterministic across processes that
    /// happen to be in different working directories.
    ///
    /// # Errors
    ///
    /// Returns `Err(Error::InvalidId(...))` when `path` is relative **and**
    /// does not exist on disk. Such a path would be absolutised against the
    /// current working directory at call time, producing a CWD-dependent
    /// (non-deterministic) ID that silently changes between processes.
    /// Callers must supply an absolute path or an existing path.
    ///
    /// # CORE-H12 fix
    ///
    /// The previous implementation used `debug_assert!` which was compiled
    /// out in release builds, allowing silent nondeterminism in production.
    /// The check is now a hard error in all build profiles.
    pub fn from_path(path: &Path) -> Result<Self> {
        if path.is_relative() && !path.exists() {
            return Err(Error::InvalidId(format!(
                "relative path '{}' does not exist; cannot derive deterministic RepoId",
                path.display()
            )));
        }
        let bytes = canonical_or_lexical_bytes(path);
        let hash = blake3::hash(&bytes);
        Ok(Self(hex::encode(&hash.as_bytes()[..12])))
    }

    /// Validating constructor. Rejects values that would be unsafe to
    /// use as a path component — anything not exactly 24 lowercase-hex
    /// chars (the shape `from_path` produces).
    pub fn try_new(s: impl Into<String>) -> Result<Self> {
        let s: String = s.into();
        if Self::is_valid(&s) {
            Ok(Self(s))
        } else {
            // L6: use InvalidId variant instead of the catch-all Error::Other.
            Err(Error::InvalidId(format!(
                "invalid RepoId {s:?}: expected 24 lowercase-hex chars"
            )))
        }
    }

    /// True iff `s` is exactly 24 lowercase-hex chars (the shape
    /// produced by `from_path` via `hex::encode`). Used by storage-
    /// layer boundary asserts and the CLI raw-id fast path. Uppercase
    /// hex (A-F) is rejected to keep the on-disk shape canonical —
    /// `$CKG_HOME/workspace_folders/aabb...` and `AABB...` would be
    /// distinct directories on case-sensitive filesystems and the same
    /// directory on case-insensitive ones, both surprising.
    pub fn is_valid(s: &str) -> bool {
        s.len() == 24
            && s.chars()
                .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// Resolve `path` to canonical bytes for hashing. Tries `canonicalize`
/// first; on failure, lexically absolutizes against cwd and collapses
/// `.`/`..` segments without filesystem access. Operates on `OsStr`
/// bytes to preserve non-UTF8 paths.
fn canonical_or_lexical_bytes(path: &Path) -> Vec<u8> {
    if let Ok(c) = path.canonicalize() {
        return os_bytes(c.as_os_str());
    }
    // Lexical fallback: absolutize against cwd, then collapse `.`/`..`.
    let abs: PathBuf = if path.is_absolute() {
        path.to_path_buf()
    } else {
        std::env::current_dir()
            .unwrap_or_else(|e| {
                // M2: surface the fallback so operators see nondeterminism.
                // ckg-core has no `tracing` dep (by design: "no I/O" crate),
                // so use eprintln! for this last-resort warning.
                eprintln!(
                    "ckg-core: current_dir() failed ({e}); falling back to \".\" for path {}",
                    path.display()
                );
                PathBuf::from(".")
            })
            .join(path)
    };
    let mut out = PathBuf::new();
    for c in abs.components() {
        match c {
            Component::CurDir => {}
            Component::ParentDir => {
                out.pop();
            }
            other => out.push(other.as_os_str()),
        }
    }
    os_bytes(out.as_os_str())
}

#[cfg(unix)]
fn os_bytes(s: &std::ffi::OsStr) -> Vec<u8> {
    use std::os::unix::ffi::OsStrExt;
    s.as_bytes().to_vec()
}

#[cfg(not(unix))]
fn os_bytes(s: &std::ffi::OsStr) -> Vec<u8> {
    // Windows / WASI: OsStr is WTF-8; `to_string_lossy` may insert
    // U+FFFD on lone surrogates but Windows paths never contain them
    // in practice. `as_encoded_bytes` is stable in 1.74+.
    s.as_encoded_bytes().to_vec()
}

impl std::fmt::Display for RepoId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

/// All symbol kinds we recognise across languages.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Kind {
    File,
    Module,
    Class,
    Trait,
    Interface,
    Struct,
    Enum,
    Function,
    Method,
    Constructor,
    Constant,
    Variable,
    TypeAlias,
    Route,
    Process,
    Doc,
}

impl Kind {
    /// Snake_case string matching `serde(rename_all = "snake_case")`.
    /// L1: verified agreement between as_str() and Serialize output.
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::File => "file",
            Self::Module => "module",
            Self::Class => "class",
            Self::Trait => "trait",
            Self::Interface => "interface",
            Self::Struct => "struct",
            Self::Enum => "enum",
            Self::Function => "function",
            Self::Method => "method",
            Self::Constructor => "constructor",
            Self::Constant => "constant",
            Self::Variable => "variable",
            Self::TypeAlias => "type_alias",
            Self::Route => "route",
            Self::Process => "process",
            Self::Doc => "doc",
        }
    }
}

/// Edge kinds connecting symbols.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum EdgeKind {
    Calls,
    Imports,
    Extends,
    Implements,
    Defines,
    Documents,
    Tests,
    Awaits,
}

impl EdgeKind {
    /// PascalCase string matching the Cozo stored-relation names
    /// (`Calls`, `Imports`, …). L3: intentionally differs from
    /// `serde(rename_all = "snake_case")` because Cozo relation names
    /// are case-sensitive and the DDL uses PascalCase.
    pub fn as_relation(&self) -> &'static str {
        match self {
            Self::Calls => "Calls",
            Self::Imports => "Imports",
            Self::Extends => "Extends",
            Self::Implements => "Implements",
            Self::Defines => "Defines",
            Self::Documents => "Documents",
            Self::Tests => "Tests",
            Self::Awaits => "Awaits",
        }
    }
}

/// A single extracted symbol.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Symbol {
    pub id: String,
    pub qname: String,
    pub name: String,
    pub kind: Kind,
    pub file: String,
    pub line: u32,
    pub col: u32,
    pub is_public: bool,
    #[serde(default)]
    pub doc: String,
    pub hash: String,
}

impl Symbol {
    /// M8: Convenience constructor that calls `node_id` to derive the
    /// `id` field. Centralises the `node_id(repo_id, lang, path, qname, kind)`
    /// call that was previously duplicated in every extractor's `push` method.
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        repo_id: &str,
        lang: &str,
        file: String,
        qname: String,
        name: String,
        kind: Kind,
        line: u32,
        col: u32,
        is_public: bool,
        doc: String,
        hash: String,
    ) -> Self {
        let id = node_id(repo_id, lang, &file, &qname, kind);
        Self { id, qname, name, kind, file, line, col, is_public, doc, hash }
    }
}

/// A directed edge between two symbol ids.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Edge {
    pub kind: EdgeKind,
    pub src: String,
    pub dst: String,
    pub confidence: f32,
}

/// Canonical error type for the workspace.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
    #[error("json: {0}")]
    Json(#[from] serde_json::Error),
    #[error("storage: {0}")]
    Storage(String),
    #[error("parse: {0}")]
    Parse(String),
    #[error("unsupported language: {0}")]
    UnsupportedLanguage(String),
    #[error("not found: {0}")]
    NotFound(String),
    /// L6: dedicated variant for invalid IDs (e.g. malformed RepoId).
    /// Replaces `Error::Other` at RepoId::try_new so callers can
    /// match specifically on identity validation failures.
    #[error("invalid id: {0}")]
    InvalidId(String),
    /// MCP-C4: dedicated variant for user-supplied argument validation
    /// failures (missing required field, value out of range, etc.).
    /// `is_user_facing()` returns `true` for this variant so the MCP
    /// dispatch layer forwards the message to the client without leaking
    /// internal paths or schema details.
    #[error("{0}")]
    InvalidArg(String),
    #[error("{0}")]
    Other(String),
    /// Error variant that preserves a `source()` chain so
    /// `anyhow::Chain` and downstream `.source()` walkers can recover
    /// the original cause. Use via `Error::wrap("msg", original)` when
    /// you want to attach context without flattening into a String —
    /// the existing tuple variants (`Storage(String)`, `Parse(String)`,
    /// etc.) discard the source by design and remain unchanged so
    /// existing callers keep working. New code in error-translation
    /// boundaries should prefer `wrap`.
    #[error("{msg}")]
    Wrapped {
        msg: String,
        #[source]
        cause: Box<dyn std::error::Error + Send + Sync>,
    },
}

impl Error {
    /// Build a `Wrapped` error that retains the original error as its
    /// `source()`. Replaces the `Error::Storage(format!("...: {e}"))` pattern
    /// at error-translation boundaries where the source chain must be kept.
    pub fn wrap<E>(msg: impl Into<String>, cause: E) -> Self
    where
        E: std::error::Error + Send + Sync + 'static,
    {
        Error::Wrapped {
            msg: msg.into(),
            cause: Box::new(cause),
        }
    }

    /// MCP-C4: returns `true` if this error was caused by invalid or
    /// missing user-supplied input and is safe to forward verbatim to
    /// MCP clients.
    ///
    /// Conservative by design — only variants whose messages are fully
    /// controlled by this codebase (not derived from OS/IO/DB internals)
    /// return `true`. When in doubt, return `false` so the dispatch layer
    /// emits a generic "internal error" without leaking filesystem paths
    /// or database schema details.
    pub fn is_user_facing(&self) -> bool {
        matches!(self, Error::InvalidArg(_) | Error::InvalidId(_) | Error::NotFound(_))
    }
}

/// L4: `Result<T>` is the workspace-wide alias for `std::result::Result<T, Error>`.
/// Kept as an alias (not a newtype) so every crate that `use ckg_core::Result`
/// can return it from `?`-chained functions without extra wrapping.
pub type Result<T> = std::result::Result<T, Error>;

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

    /// `Error::wrap` preserves the original error as its `source()` so
    /// anyhow chain walkers and `.source()` consumers can recover the cause.
    #[test]
    fn wrapped_error_preserves_source_chain() {
        use std::error::Error as _;
        let inner = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
        let wrapped = Error::wrap("opening repo", inner);
        // Top-level Display includes the message.
        assert!(wrapped.to_string().contains("opening repo"));
        // source() yields the original io::Error.
        let src = wrapped.source().expect("source must be present");
        assert!(src.to_string().contains("missing"));
    }

    /// `RepoId::try_new` rejects values that would be unsafe to use as path components.
    #[test]
    fn repo_id_try_new_validates_24_hex() {
        // Happy path: 24 lowercase-hex.
        assert!(RepoId::try_new("0123456789abcdef01234567").is_ok());
        assert!(RepoId::try_new("aaaaaaaaaaaaaaaaaaaaaaaa").is_ok());

        // Wrong length.
        assert!(RepoId::try_new("aaaaaaaaaaaaaaaa").is_err()); // 16 (old format)
        assert!(RepoId::try_new("a".repeat(23)).is_err());
        assert!(RepoId::try_new("a".repeat(25)).is_err());
        assert!(RepoId::try_new("").is_err());

        // Non-hex chars (the path-traversal vector).
        assert!(RepoId::try_new("../../../etc/passwd00000").is_err());
        assert!(RepoId::try_new("/etcpasswd00000000000000").is_err());
        assert!(RepoId::try_new("ABCDEFabcdef0123456789ab").is_err()); // uppercase
        assert!(RepoId::try_new("zzzzzzzzzzzzzzzzzzzzzzzz").is_err()); // non-hex letters
    }

    #[test]
    fn repo_id_is_24_hex_chars() {
        // Absolute path — always valid regardless of existence.
        let id = RepoId::from_path(&PathBuf::from("/tmp/ckg-test-fake"))
            .expect("absolute path must succeed");
        assert_eq!(id.as_str().len(), 24);
        assert!(id.as_str().chars().all(|c| c.is_ascii_hexdigit()));
    }

    #[test]
    fn repo_id_is_deterministic() {
        let a = RepoId::from_path(&PathBuf::from("/tmp/ckg-test-fake"))
            .expect("absolute path must succeed");
        let b = RepoId::from_path(&PathBuf::from("/tmp/ckg-test-fake"))
            .expect("absolute path must succeed");
        assert_eq!(a, b);
    }

    /// CORE-H1: `from_path` output always passes `is_valid`. If the
    /// hex-encode shape ever changes, this test catches it.
    #[test]
    fn repo_id_from_path_passes_is_valid() {
        let inputs = [
            "/tmp/ckg-test-fake",
            "/home/user/projects/myapp",
            "/var/lib/ckg/workspace",
            "/Users/someone/Workspaces/repo",
        ];
        for input in inputs {
            let id = RepoId::from_path(&PathBuf::from(input))
                .expect("absolute path must always succeed");
            assert!(
                RepoId::is_valid(id.as_str()),
                "from_path({input:?}) produced {id:?} which fails is_valid"
            );
        }
    }

    /// CORE-H12: `from_path` must hard-error on relative non-existent paths
    /// in all build profiles (not only debug via `debug_assert!`).
    #[test]
    fn from_path_relative_nonexistent_is_hard_error() {
        let nonexistent_relative = PathBuf::from("this/path/does/not/exist/anywhere");
        assert!(
            !nonexistent_relative.exists(),
            "precondition: path must not exist for this test"
        );
        let result = RepoId::from_path(&nonexistent_relative);
        match result {
            Err(Error::InvalidId(msg)) => {
                assert!(
                    msg.contains("relative path"),
                    "error message should mention 'relative path', got: {msg}"
                );
                assert!(
                    msg.contains("deterministic RepoId"),
                    "error message should mention 'deterministic RepoId', got: {msg}"
                );
            }
            Ok(id) => panic!("expected Err(InvalidId) but got Ok({id:?})"),
            Err(other) => panic!("expected Err(InvalidId) but got Err({other:?})"),
        }
    }

    /// `from_path` with a relative path that DOES exist must succeed
    /// (canonicalize resolves it deterministically).
    #[test]
    fn from_path_relative_existing_succeeds() {
        // "." always exists.
        let result = RepoId::from_path(std::path::Path::new("."));
        assert!(result.is_ok(), "existing relative path '.' must succeed: {result:?}");
        let id = result.unwrap();
        assert!(RepoId::is_valid(id.as_str()), "produced id must be valid hex");
    }
}
