//! Built-in source catalog and merge with user-defined sources.
//!
//! # Examples
//!
//! ```
//! use pathlint::catalog;
//! use pathlint::config::Config;
//!
//! let cfg = Config::default();
//! let merged = catalog::merge_with_user(&cfg.source);
//! // The built-in catalog ships at least one source.
//! assert!(!merged.is_empty());
//! ```

use std::collections::BTreeMap;
use std::sync::OnceLock;

use serde::Deserialize;

use crate::config::{Relation, SourceDef};

// Generated by build.rs from plugins/*.toml. The plugin TOMLs
// are the source of truth; do not edit the generated file.
const EMBEDDED: &str = include_str!(concat!(env!("OUT_DIR"), "/embedded_catalog.toml"));

/// Schema of the build-script-generated `embedded_catalog.toml`.
///
/// Distinct from `crate::config::Config` because the embedded
/// catalog carries `catalog_version`, whereas user `pathlint.toml`
/// files must not. Splitting the types is what makes the
/// "user configs cannot declare catalog_version" rule a structural
/// (deny_unknown_fields) error instead of a post-parse check.
#[derive(Debug, Default, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub(crate) struct EmbeddedCatalogFile {
    #[serde(default)]
    pub catalog_version: Option<u32>,

    #[serde(default, rename = "source")]
    pub sources: BTreeMap<String, SourceDef>,

    #[serde(default, rename = "relation")]
    pub relations: Vec<Relation>,
}

/// Schema of an individual `plugins/<name>.toml` file. Matches
/// `EmbeddedCatalogFile` minus `catalog_version` — that field
/// belongs to `plugins/_index.toml` only and is concatenated at
/// the head of the embedded blob by `build.rs`.
///
/// Exposed via `#[doc(hidden)] pub` so `tests/plugin_validation.rs`
/// can shape-gate every plugin file at `cargo test` time. Not part
/// of the supported public API surface.
#[doc(hidden)]
#[derive(Debug, Default, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct PluginFileShape {
    #[serde(default, rename = "source")]
    pub sources: BTreeMap<String, SourceDef>,

    #[serde(default, rename = "relation")]
    pub relations: Vec<Relation>,
}

/// Parse the embedded catalog once and reuse the result. The TOML
/// is constant for the lifetime of the process, so the public
/// read functions below all share a single parse.
fn embedded() -> &'static EmbeddedCatalogFile {
    static CACHE: OnceLock<EmbeddedCatalogFile> = OnceLock::new();
    CACHE.get_or_init(|| toml::from_str(EMBEDDED).expect("embedded_catalog.toml must parse"))
}

/// Built-in source catalog. Cloned out of the cached
/// `EmbeddedCatalogFile` so callers can mutate the result (e.g.
/// merge user overrides) without affecting the singleton.
pub fn builtin() -> BTreeMap<String, SourceDef> {
    embedded().sources.clone()
}

/// Version of the catalog embedded in this binary. Bumped whenever
/// an existing source's path or semantics changes. Defaults to `0`
/// if the embedded file forgets to declare one (which would be a
/// build bug caught at code review).
pub fn embedded_version() -> u32 {
    embedded().catalog_version.unwrap_or(0)
}

/// Pure compatibility check: does this binary's embedded catalog
/// satisfy the user's `require_catalog` directive? `Ok(())` means
/// either no requirement was set or the embedded version meets or
/// exceeds it. `Err` carries a one-line user-facing message naming
/// both versions and the recommended fix.
///
/// Pure: no I/O, no globals — both versions are passed in. Unit-
/// testable without touching the embedded catalog or stderr.
pub fn version_check(require_catalog: Option<u32>, embedded: u32) -> Result<(), String> {
    let Some(required) = require_catalog else {
        return Ok(());
    };
    if embedded >= required {
        return Ok(());
    }
    Err(format!(
        "rules require catalog_version >= {required}, but this binary embeds version {embedded}. \
         Upgrade pathlint or lower require_catalog."
    ))
}

/// Merge user-defined sources on top of the built-in catalog. User
/// entries with the same name override field-by-field; new names are
/// added.
pub fn merge_with_user(user: &BTreeMap<String, SourceDef>) -> BTreeMap<String, SourceDef> {
    let mut out = builtin();
    for (name, user_def) in user {
        let merged = match out.get(name) {
            Some(existing) => existing.merge(user_def),
            None => user_def.clone(),
        };
        out.insert(name.clone(), merged);
    }
    out
}

/// Typed view over a `&[Relation]` slice. 0.0.18 introduced this
/// so consumers (sort / doctor / trace / format / cycle check) can
/// iterate just the relation kind they care about, instead of
/// `match Relation { ... }` over the whole sum type at every call
/// site.
///
/// The wire shape (`pathlint::config::Relation` plus `[[relation]]
/// kind = "..."`) is unchanged — `RelationIndex` is purely an
/// internal accessor sugar. Borrows the input slice; cheap to
/// construct.
///
/// codex review on 0.0.18 plan suggested this shape over a
/// section-split TOML rewrite (which would have been a large
/// BREAKING change for plugin authors). `RelationIndex` keeps the
/// external contract intact while letting consumers code against
/// "give me the things I care about" instead of an open match.
pub struct RelationIndex<'a> {
    relations: &'a [Relation],
}

/// Snapshot of `Relation::ServedByVia` borrowed from the underlying
/// slice. Mirrors the variant fields so consumers don't have to
/// destructure the enum themselves.
pub struct ProvenanceRef<'a> {
    pub host: &'a str,
    pub guest_pattern: &'a str,
    pub guest_provider: &'a str,
    pub installer_token: Option<&'a str>,
}

impl<'a> RelationIndex<'a> {
    /// Wrap a relation slice. Pure, allocation-free.
    pub fn from_slice(relations: &'a [Relation]) -> Self {
        Self { relations }
    }

    /// Iterate every `AliasOf` relation as `(parent, children)`.
    pub fn iter_aliases(&self) -> impl Iterator<Item = (&'a str, &'a [String])> {
        self.relations.iter().filter_map(|r| match r {
            Relation::AliasOf { parent, children } => Some((parent.as_str(), children.as_slice())),
            _ => None,
        })
    }

    /// Iterate every `ConflictsWhenBothInPath` relation as
    /// `(sources, diagnostic)`.
    pub fn iter_conflicts(&self) -> impl Iterator<Item = (&'a [String], &'a str)> {
        self.relations.iter().filter_map(|r| match r {
            Relation::ConflictsWhenBothInPath {
                sources,
                diagnostic,
            } => Some((sources.as_slice(), diagnostic.as_str())),
            _ => None,
        })
    }

    /// Iterate every `ServedByVia` relation as a `ProvenanceRef`.
    pub fn iter_provenances(&self) -> impl Iterator<Item = ProvenanceRef<'a>> {
        self.relations.iter().filter_map(|r| match r {
            Relation::ServedByVia {
                host,
                guest_pattern,
                guest_provider,
                installer_token,
            } => Some(ProvenanceRef {
                host: host.as_str(),
                guest_pattern: guest_pattern.as_str(),
                guest_provider: guest_provider.as_str(),
                installer_token: installer_token.as_deref(),
            }),
            _ => None,
        })
    }

    /// Iterate every `DependsOn` relation as `(source, target)`.
    pub fn iter_depends_on(&self) -> impl Iterator<Item = (&'a str, &'a str)> {
        self.relations.iter().filter_map(|r| match r {
            Relation::DependsOn { source, target } => Some((source.as_str(), target.as_str())),
            _ => None,
        })
    }

    /// Iterate every `PreferOrderOver` relation as
    /// `(earlier, later)`.
    pub fn iter_prefer_orders(&self) -> impl Iterator<Item = (&'a str, &'a str)> {
        self.relations.iter().filter_map(|r| match r {
            Relation::PreferOrderOver { earlier, later } => {
                Some((earlier.as_str(), later.as_str()))
            }
            _ => None,
        })
    }
}

/// Built-in relations declared by `plugins/<name>.toml`. Returned
/// in the order they appear in the embedded catalog (which is the
/// order set by `plugins/_index.toml`).
pub fn builtin_relations() -> Vec<Relation> {
    embedded().relations.clone()
}

/// Combined relation list: built-ins followed by anything the user
/// declared in their `pathlint.toml`. No deduplication — declaring
/// the same relation twice is harmless and the user's intent should
/// not be silently dropped.
pub fn merge_with_user_relations(user: &[Relation]) -> Vec<Relation> {
    let mut out = builtin_relations();
    out.extend(user.iter().cloned());
    out
}

/// Verify that relations forming a directed acyclic graph (DAG)
/// actually do. Today this checks `served_by_via` (host →
/// guest_provider) and `depends_on` (source → target). Other kinds
/// (`alias_of`, `conflicts_when_both_in_path`) are symmetric and
/// have no direction; they are ignored here.
///
/// Pure: takes a relation slice, returns `Err(message)` listing the
/// cycle on first detection. The expensive case is rare (relations
/// are at most a few dozen), so a straightforward visited-set DFS
/// is plenty.
pub fn check_acyclic(relations: &[Relation]) -> Result<(), String> {
    use std::collections::BTreeSet;

    // 0.0.18: read directional relations through RelationIndex so
    // each accessor names exactly the kind it depends on. AliasOf
    // and ConflictsWhenBothInPath are symmetric / set semantics
    // and have no `iter_*` call here (and therefore don't need
    // an explicit `_ => {}` arm).
    let index = RelationIndex::from_slice(relations);
    let mut edges: Vec<(String, String)> = Vec::new();
    for prov in index.iter_provenances() {
        edges.push((prov.host.to_string(), prov.guest_provider.to_string()));
    }
    for (source, target) in index.iter_depends_on() {
        edges.push((source.to_string(), target.to_string()));
    }
    for (earlier, later) in index.iter_prefer_orders() {
        edges.push((earlier.to_string(), later.to_string()));
    }
    let mut adj: BTreeMap<String, Vec<String>> = BTreeMap::new();
    for (from, to) in &edges {
        adj.entry(from.clone()).or_default().push(to.clone());
    }

    // White / gray / black DFS for cycle detection.
    let mut color: BTreeMap<String, u8> = BTreeMap::new();
    fn visit(
        node: &str,
        adj: &BTreeMap<String, Vec<String>>,
        color: &mut BTreeMap<String, u8>,
        stack: &mut Vec<String>,
    ) -> Result<(), String> {
        let entry = color.entry(node.to_string()).or_insert(0);
        match *entry {
            1 => {
                // gray — back edge → cycle.
                let cycle_start = stack.iter().position(|n| n == node).unwrap_or(0);
                let mut cycle = stack[cycle_start..].join(" -> ");
                cycle.push_str(&format!(" -> {node}"));
                return Err(format!("relation cycle: {cycle}"));
            }
            2 => return Ok(()), // already finished
            _ => {}
        }
        *entry = 1;
        stack.push(node.to_string());
        let neighbours: Vec<String> = adj.get(node).cloned().unwrap_or_default();
        for next in neighbours {
            visit(&next, adj, color, stack)?;
        }
        stack.pop();
        color.insert(node.to_string(), 2);
        Ok(())
    }

    let nodes: BTreeSet<String> = edges
        .iter()
        .flat_map(|(a, b)| [a.clone(), b.clone()])
        .collect();
    for node in &nodes {
        let mut stack = Vec::new();
        visit(node, &adj, &mut color, &mut stack)?;
    }
    Ok(())
}

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

    #[test]
    fn embedded_catalog_parses() {
        let cat = builtin();
        assert!(cat.contains_key("cargo"));
        assert!(cat.contains_key("winget"));
        assert!(cat.contains_key("brew_arm"));
        assert!(cat.contains_key("pkg"));
    }

    #[test]
    fn cargo_has_per_os_paths() {
        let cat = builtin();
        let cargo = &cat["cargo"];
        assert!(cargo.path_for(Os::Windows).is_some());
        assert!(cargo.path_for(Os::Linux).is_some());
        assert!(cargo.path_for(Os::Macos).is_some());
        assert!(cargo.path_for(Os::Termux).is_some()); // unix fallback
    }

    #[test]
    fn user_override_replaces_only_specified_field() {
        let mut user = BTreeMap::new();
        user.insert(
            "mise".to_string(),
            SourceDef {
                windows: Some("D:/tools/mise".into()),
                ..Default::default()
            },
        );
        let merged = merge_with_user(&user);
        let mise = &merged["mise"];
        assert_eq!(mise.path_for(Os::Windows), Some("D:/tools/mise"));
        // unix fallback from the built-in survives.
        assert!(mise.path_for(Os::Linux).is_some());
    }

    #[test]
    fn user_can_add_new_source() {
        let mut user = BTreeMap::new();
        user.insert(
            "my_dotfiles_bin".to_string(),
            SourceDef {
                unix: Some("$HOME/dotfiles/bin".into()),
                ..Default::default()
            },
        );
        let merged = merge_with_user(&user);
        assert!(merged.contains_key("my_dotfiles_bin"));
    }

    #[test]
    fn linux_only_source_is_none_on_windows() {
        let cat = builtin();
        let apt = &cat["apt"];
        assert!(apt.path_for(Os::Linux).is_some());
        assert!(apt.path_for(Os::Windows).is_none());
        assert!(apt.path_for(Os::Macos).is_none());
        assert!(apt.path_for(Os::Termux).is_none());
    }

    #[test]
    fn user_override_can_replace_all_known_fields() {
        let mut user = BTreeMap::new();
        user.insert(
            "cargo".to_string(),
            SourceDef {
                description: Some("user-overridden".into()),
                windows: Some("X:/cargo".into()),
                unix: Some("/x/cargo".into()),
                ..Default::default()
            },
        );
        let merged = merge_with_user(&user);
        let cargo = &merged["cargo"];
        assert_eq!(cargo.description.as_deref(), Some("user-overridden"));
        assert_eq!(cargo.path_for(Os::Windows), Some("X:/cargo"));
        assert_eq!(cargo.path_for(Os::Linux), Some("/x/cargo"));
    }

    #[test]
    fn embedded_version_is_at_least_one() {
        // Bumping catalog_version is a deliberate act; default of 0
        // would mean somebody removed the declaration. Guard the
        // floor at 1 — the version we shipped in 0.0.3.
        assert!(embedded_version() >= 1);
    }

    #[test]
    fn version_check_passes_when_no_requirement_set() {
        assert!(version_check(None, 0).is_ok());
        assert!(version_check(None, 9999).is_ok());
    }

    #[test]
    fn version_check_passes_when_embedded_meets_required() {
        assert!(version_check(Some(2), 2).is_ok());
        assert!(version_check(Some(2), 3).is_ok());
    }

    #[test]
    fn version_check_fails_when_embedded_below_required() {
        let err = version_check(Some(7), 3).unwrap_err();
        assert!(err.contains("7"), "error must name required: {err}");
        assert!(err.contains("3"), "error must name embedded: {err}");
        assert!(
            err.contains("Upgrade") || err.contains("require_catalog"),
            "error must hint at fix: {err}"
        );
    }

    #[test]
    fn mise_layered_sources_are_present() {
        let cat = builtin();
        assert!(cat.contains_key("mise"));
        assert!(cat.contains_key("mise_shims"));
        assert!(cat.contains_key("mise_installs"));
    }

    #[test]
    fn builtin_relations_includes_mise_alias_and_conflict() {
        let rels = builtin_relations();
        // Catalog declares at least the mise alias_of and the
        // mise_activate_both conflict.
        assert!(
            rels.iter().any(|r| matches!(
                r,
                Relation::AliasOf { parent, .. } if parent == "mise"
            )),
            "alias_of mise missing: {rels:?}"
        );
        assert!(
            rels.iter().any(|r| matches!(
                r,
                Relation::ConflictsWhenBothInPath { diagnostic, .. }
                    if diagnostic == "mise_activate_both"
            )),
            "mise_activate_both conflict missing: {rels:?}"
        );
    }

    #[test]
    fn merge_user_relations_appends_after_builtin() {
        let user = vec![Relation::DependsOn {
            source: "paru".into(),
            target: "pacman".into(),
        }];
        let merged = merge_with_user_relations(&user);
        // user relation must be at the end.
        let last = merged.last().unwrap();
        assert!(matches!(
            last,
            Relation::DependsOn { source, target }
                if source == "paru" && target == "pacman"
        ));
    }

    #[test]
    fn check_acyclic_accepts_simple_chain() {
        let rels = vec![
            Relation::DependsOn {
                source: "a".into(),
                target: "b".into(),
            },
            Relation::DependsOn {
                source: "b".into(),
                target: "c".into(),
            },
        ];
        assert!(check_acyclic(&rels).is_ok());
    }

    #[test]
    fn check_acyclic_rejects_two_node_cycle() {
        let rels = vec![
            Relation::DependsOn {
                source: "a".into(),
                target: "b".into(),
            },
            Relation::DependsOn {
                source: "b".into(),
                target: "a".into(),
            },
        ];
        let err = check_acyclic(&rels).unwrap_err();
        assert!(err.contains("cycle"), "{err}");
    }

    #[test]
    fn check_acyclic_rejects_three_node_cycle_through_served_by_via() {
        let rels = vec![
            Relation::ServedByVia {
                host: "a".into(),
                guest_pattern: "*".into(),
                guest_provider: "b".into(),
                installer_token: None,
            },
            Relation::ServedByVia {
                host: "b".into(),
                guest_pattern: "*".into(),
                guest_provider: "c".into(),
                installer_token: None,
            },
            Relation::ServedByVia {
                host: "c".into(),
                guest_pattern: "*".into(),
                guest_provider: "a".into(),
                installer_token: None,
            },
        ];
        assert!(check_acyclic(&rels).is_err());
    }

    #[test]
    fn check_acyclic_rejects_cycle_through_prefer_order_over() {
        let rels = vec![
            Relation::PreferOrderOver {
                earlier: "a".into(),
                later: "b".into(),
            },
            Relation::PreferOrderOver {
                earlier: "b".into(),
                later: "a".into(),
            },
        ];
        assert!(check_acyclic(&rels).is_err());
    }

    #[test]
    fn check_acyclic_ignores_alias_of_and_conflicts() {
        // These kinds have no direction; even self-referential
        // shape (parent == child) does not count as a cycle here.
        let rels = vec![
            Relation::AliasOf {
                parent: "a".into(),
                children: vec!["a".into(), "b".into()],
            },
            Relation::ConflictsWhenBothInPath {
                sources: vec!["a".into(), "b".into()],
                diagnostic: "x".into(),
            },
        ];
        assert!(check_acyclic(&rels).is_ok());
    }

    #[test]
    fn builtin_relations_form_a_dag() {
        // The catalog itself must not contain cycles. Guard against
        // a future plugin author accidentally introducing one.
        check_acyclic(&builtin_relations()).expect("built-in relations must be acyclic");
    }

    #[test]
    fn mise_shims_path_is_a_subdirectory_of_mise() {
        let cat = builtin();
        let mise = cat["mise"].path_for(Os::Linux).unwrap();
        let shims = cat["mise_shims"].path_for(Os::Linux).unwrap();
        let installs = cat["mise_installs"].path_for(Os::Linux).unwrap();
        // mise_shims and mise_installs must each live inside the
        // mise root, so any binary path that matches a subordinate
        // source automatically also matches the parent `mise`.
        assert!(shims.starts_with(mise), "{shims} not under {mise}");
        assert!(installs.starts_with(mise), "{installs} not under {mise}");
    }
}
