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

use glob::glob;

use super::{
    edge_resolver::resolve_edges,
    ncl_parser::{
        parse_adr, parse_content_ncl, parse_ontology_core, ContentParserConfig,
        OntologyParserConfig,
    },
    node_types::{AdrRaw, ContentGraph, GraphMeta, GraphNode, OntologyInstance},
    BuildError,
};

// ── Config ────────────────────────────────────────────────────────────────────

/// All paths needed to generate the content graph.
/// Populated from environment variables in `build.rs`.
pub struct GraphGeneratorConfig {
    /// Root of the site's content directory (e.g., site/content/).
    pub content_dir: PathBuf,

    /// Root of the site's nickel schema directory for `--import-path`.
    /// Must contain `content/metadata/` with the schema files.
    pub nickel_schema_dir: PathBuf,

    /// Path to `rustelo/.ontology/core.ncl`.
    pub framework_ontology: PathBuf,

    /// Path to `{impl}/.ontology/core.ncl`.
    pub impl_ontology: PathBuf,

    /// Glob pattern matching framework ADR files (e.g., `rustelo/adrs/*.ncl`).
    /// Skips files starting with `_` automatically.
    pub framework_adrs_glob: String,

    /// Glob pattern matching implementation ADR files.
    pub impl_adrs_glob: String,

    /// Path to the ontoref ontology directory, for `--import-path` on ontology
    /// files. Typically: `$ONTOREF_NICKEL_IMPORT_PATH` =
    /// `/path/to/ontoref/ontology`.
    pub ontoref_import_path: PathBuf,
}

/// `<root>/.ontoref/ontology/core.ncl` if it exists, else the pre-migration
/// `<root>/.ontology/core.ncl`.
///
/// Returns the LEGACY path when neither exists, so the caller's own existence
/// check reports the familiar location rather than a path the project has never
/// had.
fn first_existing_ontology(root: &Path) -> PathBuf {
    // In a CONSTELLATION the spine lives at the parent of the code repo
    // (`rustelo/.ontoref/`, with the crates under `rustelo/code/`). A
    // framework_root that resolves to `code/` therefore has to look one level
    // up — ADR-062's layout, and the reason `code/.ontoref/` does not exist.
    //
    // Four candidates, most-migrated first. The last is the pre-migration layout,
    // so a project that never moved its spine still works untouched.
    // Walk UP. A site nested inside a constellation (`ontoref/outreach/site`) has
    // no spine of its own — it SHARES the constellation's, three levels above.
    // So the search climbs rather than guessing a depth: a fixed number of `..`
    // is a hardcoded layout wearing a disguise, and this generator has already
    // been bitten once by exactly that.
    let mut here = root.to_path_buf();
    for _ in 0..6 {
        for c in [
            here.join(".ontoref").join("ontology").join("core.ncl"),
            here.join(".ontology").join("core.ncl"),
        ] {
            if c.exists() {
                return c;
            }
        }
        match here.parent() {
            Some(p) => here = p.to_path_buf(),
            None => break,
        }
    }
    // None found: return the modern path so the caller's existence check names the
    // location the project SHOULD have, not one it never had.
    root.join(".ontoref").join("ontology").join("core.ncl")
}

impl GraphGeneratorConfig {
    /// Build config from environment variables set in the implementation's
    /// `build.rs`.
    ///
    /// Required env vars (no default):
    ///   CONTENT_GRAPH_FRAMEWORK_ONTOLOGY
    ///   CONTENT_GRAPH_FRAMEWORK_ADRS_GLOB
    ///   ONTOREF_NICKEL_IMPORT_PATH
    ///
    /// Optional env vars (override defaults derived from `workspace_root`):
    ///   CONTENT_GRAPH_CONTENT_DIR      (default: {root}/site/content)
    ///   CONTENT_GRAPH_NICKEL_SCHEMA_DIR (default: {root}/rustelo/nickel)
    ///   CONTENT_GRAPH_IMPL_ONTOLOGY    (default: {root}/.ontology/core.ncl)
    ///   CONTENT_GRAPH_IMPL_ADRS_GLOB   (default: {root}/adrs/adr-*.ncl)
    pub fn from_env() -> Result<Self, BuildError> {
        let var = |name: &'static str| -> Result<String, BuildError> {
            std::env::var(name).map_err(|_| BuildError::MissingEnv(name))
        };

        Ok(Self {
            content_dir: PathBuf::from(var("CONTENT_GRAPH_CONTENT_DIR")?),
            nickel_schema_dir: PathBuf::from(var("CONTENT_GRAPH_NICKEL_SCHEMA_DIR")?),
            framework_ontology: PathBuf::from(var("CONTENT_GRAPH_FRAMEWORK_ONTOLOGY")?),
            impl_ontology: PathBuf::from(var("CONTENT_GRAPH_IMPL_ONTOLOGY")?),
            framework_adrs_glob: var("CONTENT_GRAPH_FRAMEWORK_ADRS_GLOB")?,
            impl_adrs_glob: var("CONTENT_GRAPH_IMPL_ADRS_GLOB")?,
            ontoref_import_path: PathBuf::from(var("ONTOREF_NICKEL_IMPORT_PATH")?),
        })
    }

    /// Build config with paths derived from the implementation's workspace
    /// root.
    ///
    /// `workspace_root` is the absolute path to the directory containing
    /// `rustelo.manifest.toml` (the implementation workspace root).
    /// `framework_root` is the absolute path to the rustelo framework root
    /// (sibling of the implementation, e.g. `../../rustelo` from the
    /// workspace). `ontoref_import_path` is the external ontoref ontology
    /// directory.
    ///
    /// Env vars with the same names override any field if set.
    pub fn with_root(
        workspace_root: &Path,
        framework_root: &Path,
        ontoref_import_path: &Path,
    ) -> Self {
        let env_path = |name: &str, default: PathBuf| -> PathBuf {
            std::env::var(name).map(PathBuf::from).unwrap_or(default)
        };
        let env_str =
            |name: &str, default: String| -> String { std::env::var(name).unwrap_or(default) };

        Self {
            content_dir: env_path(
                "CONTENT_GRAPH_CONTENT_DIR",
                workspace_root.join("site").join("content"),
            ),
            nickel_schema_dir: env_path(
                "CONTENT_GRAPH_NICKEL_SCHEMA_DIR",
                framework_root.join("resources").join("nickel"),
            ),
            // FOLLOW THE ONTOLOGY WHEN IT MOVES.
            //
            // These defaulted to `<root>/.ontology/core.ncl` — the layout ontoref used BEFORE it
            // moved its spine to `.ontoref/ontology/`. The ontology moved and this generator did
            // not follow, so on any migrated project it asked nickel for a file that is not there
            // and got back "IO error" with no hint of what it had been looking for. The graph came
            // out with zero nodes, the page rendered, and nothing complained.
            //
            // It is the same failure as everything else this generator feeds: a witness that stays
            // where it was while the thing it watches moves, and reports GREEN because it looked
            // where it always looked and found nothing wrong there.
            //
            // `.ontoref/ontology/` first, `.ontology/` second — a pre-migration project still
            // works.
            framework_ontology: env_path(
                "CONTENT_GRAPH_FRAMEWORK_ONTOLOGY",
                first_existing_ontology(framework_root),
            ),
            impl_ontology: env_path(
                "CONTENT_GRAPH_IMPL_ONTOLOGY",
                first_existing_ontology(workspace_root),
            ),
            framework_adrs_glob: env_str(
                "CONTENT_GRAPH_FRAMEWORK_ADRS_GLOB",
                framework_root
                    .join("adrs")
                    .join("adr-*.ncl")
                    .display()
                    .to_string(),
            ),
            impl_adrs_glob: env_str(
                "CONTENT_GRAPH_IMPL_ADRS_GLOB",
                workspace_root
                    .join("adrs")
                    .join("adr-*.ncl")
                    .display()
                    .to_string(),
            ),
            ontoref_import_path: env_path(
                "ONTOREF_NICKEL_IMPORT_PATH",
                ontoref_import_path.to_path_buf(),
            ),
        }
    }
}

// ── Generator
// ─────────────────────────────────────────────────────────────────

/// Build the full `ContentGraph` from all content and ontology sources.
pub fn generate(config: &GraphGeneratorConfig) -> Result<ContentGraph, BuildError> {
    let content_parser_cfg = ContentParserConfig {
        nickel_schema_dir: config.nickel_schema_dir.clone(),
    };

    let framework_ontology_cfg = OntologyParserConfig {
        ontoref_import_path: config.ontoref_import_path.clone(),
        instance: OntologyInstance::Framework,
    };

    let impl_ontology_cfg = OntologyParserConfig {
        ontoref_import_path: config.ontoref_import_path.clone(),
        instance: OntologyInstance::Implementation,
    };

    let mut graph = ContentGraph::default();

    // Ontology nodes + edges (both instances)
    let (fw_nodes, fw_edges) =
        parse_ontology_core(&config.framework_ontology, &framework_ontology_cfg)?;
    let (im_nodes, im_edges) = parse_ontology_core(&config.impl_ontology, &impl_ontology_cfg)?;

    for raw in &fw_nodes {
        graph
            .nodes
            .push(super::ncl_parser::ontology_node_to_graph_node(
                raw,
                OntologyInstance::Framework,
            ));
    }
    for raw in &im_nodes {
        graph
            .nodes
            .push(super::ncl_parser::ontology_node_to_graph_node(
                raw,
                OntologyInstance::Implementation,
            ));
    }

    // ADR nodes (both instances)
    let fw_adrs = collect_adrs(&config.framework_adrs_glob, &config.ontoref_import_path)?;
    let im_adrs = collect_adrs(&config.impl_adrs_glob, &config.ontoref_import_path)?;

    for adr in &fw_adrs {
        graph.nodes.push(super::ncl_parser::adr_to_graph_node(
            adr,
            OntologyInstance::Framework,
        ));
    }
    for adr in &im_adrs {
        graph.nodes.push(super::ncl_parser::adr_to_graph_node(
            adr,
            OntologyInstance::Implementation,
        ));
    }

    // Content nodes + their graph metadata
    let mut content_metas: Vec<(String, GraphMeta)> = Vec::new();

    for (node, meta) in collect_content_nodes(&config.content_dir, &content_parser_cfg)? {
        let id = node.id().to_owned();
        graph.nodes.push(node);
        content_metas.push((id, meta));
    }

    // Deduplicate nodes by id (ontology nodes can appear in both instances if the
    // same id is reused — last write wins by keeping first occurrence)
    dedup_nodes(&mut graph.nodes);

    // Resolve all edges with precedence: Declared > AutoBacklink > Implicit
    let all_ontology_edges: Vec<_> = fw_edges.into_iter().chain(im_edges).collect();
    resolve_edges(
        &mut graph,
        &content_metas,
        &all_ontology_edges,
        OntologyInstance::Framework,
    );

    // Remove edges whose from/to node does not exist in the graph
    let node_ids: std::collections::HashSet<String> =
        graph.nodes.iter().map(|n| n.id().to_owned()).collect();

    graph
        .edges
        .retain(|e| node_ids.contains(&e.from) && node_ids.contains(&e.to));

    Ok(graph)
}

// ── Internal helpers
// ──────────────────────────────────────────────────────────

fn collect_content_nodes(
    content_dir: &Path,
    cfg: &ContentParserConfig,
) -> Result<Vec<(GraphNode, GraphMeta)>, BuildError> {
    let pattern = content_dir.join("**").join("*.ncl").display().to_string();

    let mut results = Vec::new();

    for entry in glob(&pattern).map_err(|e| BuildError::Glob(e.to_string()))? {
        let path = entry.map_err(|e| BuildError::Glob(e.to_string()))?;

        // Skip schema/config files and any file starting with underscore.
        // Note: "index.ncl" is the canonical file for projects — do NOT skip it.
        let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
        if stem.starts_with('_') {
            continue;
        }

        // Skip known schema/config NCL files (content-kinds, list_*)
        if matches!(stem, "content-kinds" | "list_categories" | "list_tags") {
            continue;
        }

        if let Some(pair) = parse_content_ncl(&path, cfg)? {
            results.push(pair);
        }
    }

    Ok(results)
}

fn collect_adrs(glob_pattern: &str, ontoref_import_path: &Path) -> Result<Vec<AdrRaw>, BuildError> {
    let mut adrs = Vec::new();

    for entry in glob(glob_pattern).map_err(|e| BuildError::Glob(e.to_string()))? {
        let path = entry.map_err(|e| BuildError::Glob(e.to_string()))?;

        let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
        if stem.starts_with('_')
            || matches!(stem, "adr-defaults" | "adr-constraints" | "adr-schema")
        {
            continue;
        }

        if let Some(adr) = parse_adr(&path, ontoref_import_path)? {
            adrs.push(adr);
        }
    }

    Ok(adrs)
}

fn dedup_nodes(nodes: &mut Vec<GraphNode>) {
    // For content nodes with the same logical id (bilingual content), keep the
    // English ("en") variant as the canonical node so URLs use the primary
    // language. Sort: content nodes with lang "en" come before other languages,
    // so first-seen wins in the retain below.
    nodes.sort_by(|a, b| {
        let lang_a = if let GraphNode::Content { lang, .. } = a {
            lang.as_str()
        } else {
            ""
        };
        let lang_b = if let GraphNode::Content { lang, .. } = b {
            lang.as_str()
        } else {
            ""
        };
        // "en" < everything else → en nodes sort first
        let en_a = if lang_a == "en" { 0u8 } else { 1 };
        let en_b = if lang_b == "en" { 0u8 } else { 1 };
        en_a.cmp(&en_b)
    });

    let mut seen = std::collections::HashSet::new();
    nodes.retain(|n| seen.insert(n.id().to_owned()));
}
