//! Host tool: generate `content_graph.json` (the runtime sidebar data) from
//! `site/content` + the ontoref ontologies, WITHOUT touching the server binary.
//!
//! The server loads this JSON at runtime (see the crate lib), so regenerating
//! it and delivering it to the PV refreshes the post-sidebar graph with no
//! rebuild and no image deploy. Requires:
//!   ONTOREF_NICKEL_IMPORT_PATH  — path to ontoref ontology NCL files
//!
//! Framework root resolution order:
//!   1. RUSTELO_ROOT env var (explicit override)
//!   2. <project_root>/../rustelo/code  (constellation layout)
//!   3. <project_root>/../rustelo       (flat layout)
//!
//! Usage (run from the project workspace):
//!   cargo run -p rustelo-content-graph-ssr --features gen --bin
//! gen-content-graph   cargo run -p rustelo-content-graph-ssr --features gen
//! --bin gen-content-graph -- <out.json>

use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::PathBuf;

use content_graph::build::graph_generator::{generate, GraphGeneratorConfig};
use content_graph::build::node_types::{ContentGraph, EdgeKind, GraphNode, OntologyLevel};
use serde::Serialize;

#[derive(Serialize)]
struct RelatedOut {
    id: String,
    title: String,
    kind: String,
    url: String,
}

#[derive(Serialize)]
struct OntologyOut {
    id: String,
    name: String,
    kind: String,
    /// Deep-link to the live daemon surface for this node (ADR-057 prize):
    /// ADR nodes resolve to the ADR detail page, ontology nodes to the graph
    /// focus.
    #[serde(skip_serializing_if = "Option::is_none")]
    url: Option<String>,
}

#[derive(Serialize, Default)]
struct GraphDataOut {
    mini: BTreeMap<String, String>,
    related: BTreeMap<String, Vec<RelatedOut>>,
    ontology: BTreeMap<String, Vec<OntologyOut>>,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let manifest = rustelo_utils::ManifestResolver::load()?;
    let root = manifest.root();

    let framework_root = resolve_framework_root(root)?;

    let ontoref_path = std::env::var("ONTOREF_NICKEL_IMPORT_PATH")
        .map_err(|_| "ONTOREF_NICKEL_IMPORT_PATH not set — cannot generate content graph")?;

    let config =
        GraphGeneratorConfig::with_root(root, &framework_root, std::path::Path::new(&ontoref_path));

    // Check the ontology files EXIST before handing them to nickel. Without this, a
    // missing file comes back as a bare "IO error" from a subprocess, three
    // layers down, with no mention of the path it was asked to read — which is
    // exactly how a graph with zero nodes shipped, rendering an empty page that
    // nothing complained about.
    for (label, var, path) in [
        ("framework", "FRAMEWORK", &config.framework_ontology),
        ("implementation", "IMPL", &config.impl_ontology),
    ] {
        if !path.exists() {
            return Err(format!(
                "{label} ontology not found at {}\nLooked for `.ontoref/ontology/core.ncl` first \
                 (post-migration layout), then the legacy `.ontology/core.ncl`.\nOverride with \
                 CONTENT_GRAPH_{}_ONTOLOGY.",
                path.display(),
                var
            )
            .into());
        }
    }

    let graph = generate(&config).map_err(|e| format!("graph generation failed: {e}"))?;

    let daemon = std::env::var("CONTENT_GRAPH_DAEMON_BASE")
        .unwrap_or_else(|_| "https://ontoref.dev".to_string());
    let daemon = daemon.trim_end_matches('/');

    let data = build_graph_data(&graph, daemon);

    let out_path = std::env::args().nth(1).unwrap_or_else(|| {
        root.join("site/public/r/content_graph.json")
            .to_string_lossy()
            .into_owned()
    });
    if let Some(parent) = std::path::Path::new(&out_path).parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(&out_path, serde_json::to_string(&data)?)?;

    eprintln!(
        "✅ content_graph.json → {out_path} — mini:{} related:{} ontology:{}",
        data.mini.len(),
        data.related.len(),
        data.ontology.len(),
    );
    Ok(())
}

/// Locate the rustelo framework root.
///
/// Priority: `RUSTELO_ROOT` → constellation sibling (`../rustelo/code`) → flat
/// sibling (`../rustelo`) → the constellation GRANDPARENT
/// (`../../rustelo/code`), which is where a site nested one level deeper lives
/// (`ontoref/outreach/site` → `/rustelo/code`).
///
/// AND IT NO LONGER LIES. The previous version ended in
///
///     .canonicalize().unwrap_or_else(|_|
/// project_root.join("..").join("rustelo"))
///
/// — it handed back THE PATH THAT HAD JUST FAILED TO RESOLVE. The caller then
/// joined `.ontology/core.ncl` onto a directory that does not exist, nickel
/// reported "IO error" with no mention of what it had been asked to read, and
/// the graph came out with zero nodes. The page rendered. Nothing complained.
///
/// A resolver that cannot find something must say so. Returning a wrong answer
/// instead of NO answer is how a failure travels three layers before it
/// surfaces as an empty screen.
fn resolve_framework_root(project_root: &std::path::Path) -> Result<PathBuf, String> {
    if let Ok(r) = std::env::var("RUSTELO_ROOT") {
        let p = PathBuf::from(&r);
        if p.exists() {
            return Ok(p);
        }
        return Err(format!(
            "RUSTELO_ROOT is set to '{r}' but that path does not exist"
        ));
    }

    let candidates = [
        project_root.join("..").join("rustelo").join("code"),
        project_root.join("..").join("rustelo"),
        project_root
            .join("..")
            .join("..")
            .join("rustelo")
            .join("code"),
        project_root.join("..").join("..").join("rustelo"),
        project_root
            .join("..")
            .join("..")
            .join("..")
            .join("rustelo")
            .join("code"),
    ];
    for c in &candidates {
        if let Ok(p) = c.canonicalize() {
            return Ok(p);
        }
    }
    Err(format!(
        "cannot locate the rustelo framework root. Tried:\n{}\nSet RUSTELO_ROOT to point at it.",
        candidates
            .iter()
            .map(|c| format!("  {}", c.display()))
            .collect::<Vec<_>>()
            .join("\n")
    ))
}

fn build_graph_data(graph: &ContentGraph, daemon: &str) -> GraphDataOut {
    let mut out = GraphDataOut::default();

    let slug_to_id: HashMap<String, String> = graph
        .nodes
        .iter()
        .filter_map(|n| {
            let slug = n.url_slug()?;
            (slug != n.id()).then(|| (slug.to_owned(), n.id().to_owned()))
        })
        .collect();

    build_related(graph, &slug_to_id, &mut out.related);
    build_ontology(graph, &slug_to_id, daemon, &mut out.ontology);
    build_mini(graph, &slug_to_id, daemon, &mut out.mini);

    out
}

/// Deep-link for an ontology/ADR node id (ADR-057 two-tier prize). ADR nodes
/// resolve to the daemon ADR detail page; every other node to the graph focus.
fn node_deep_link(daemon: &str, id: &str, is_adr: bool) -> String {
    if is_adr {
        format!("{daemon}/adr/{id}")
    } else {
        format!("{daemon}/graph?focus={id}")
    }
}

fn build_related(
    graph: &ContentGraph,
    slug_to_id: &HashMap<String, String>,
    out: &mut BTreeMap<String, Vec<RelatedOut>>,
) {
    let mut adjacency: HashMap<String, HashSet<(String, String)>> = HashMap::new();
    for edge in &graph.edges {
        if matches!(edge.kind, EdgeKind::SharedTag | EdgeKind::RelatedFrom) {
            continue;
        }
        let kind_label = format!("{:?}", edge.kind);
        adjacency
            .entry(edge.from.clone())
            .or_default()
            .insert((edge.to.clone(), kind_label.clone()));
        if matches!(edge.kind, EdgeKind::RelatedTo) {
            adjacency
                .entry(edge.to.clone())
                .or_default()
                .insert((edge.from.clone(), kind_label));
        }
    }

    let titles: HashMap<&str, &str> = graph.nodes.iter().map(|n| (n.id(), n.title())).collect();

    let node_urls: HashMap<&str, String> = graph
        .nodes
        .iter()
        .filter_map(|n| match n {
            GraphNode::Content { id, url, .. } => Some((id.as_str(), url.clone())),
            GraphNode::Ontology {
                id,
                level: OntologyLevel::Practice,
                artifact_paths,
                ..
            } => {
                let url = artifact_paths
                    .first()
                    .and_then(|p| ontology_artifact_to_url(p))?;
                Some((id.as_str(), url))
            }
            _ => None,
        })
        .collect();

    let entry_for = |related: &HashSet<(String, String)>| -> Vec<RelatedOut> {
        let mut v: Vec<&(String, String)> = related.iter().collect();
        v.sort();
        v.into_iter()
            .map(|(rid, kind)| RelatedOut {
                id: rid.clone(),
                title: titles
                    .get(rid.as_str())
                    .copied()
                    .unwrap_or(rid.as_str())
                    .to_owned(),
                kind: kind.clone(),
                url: node_urls.get(rid.as_str()).cloned().unwrap_or_default(),
            })
            .collect()
    };

    for (id, related) in &adjacency {
        out.insert(id.clone(), entry_for(related));
    }
    for (slug, id) in slug_to_id {
        if let Some(related) = adjacency.get(id) {
            out.insert(slug.clone(), entry_for(related));
        }
    }
}

fn build_ontology(
    graph: &ContentGraph,
    slug_to_id: &HashMap<String, String>,
    daemon: &str,
    out: &mut BTreeMap<String, Vec<OntologyOut>>,
) {
    // When gen-adr-pages produces sidecar NCLs with id = "adr-NNN", dedup_nodes
    // (rustelo framework) promotes those Content nodes over the Adr nodes because
    // Content("en") sorts first.  The Content nodes carry tags = ["adr", ...], so
    // we use that tag to restore Adr semantics without touching the framework.
    let is_adr_content = |n: &GraphNode| -> bool { n.tags().iter().any(|t| t == "adr") };

    let ontology_ids: HashSet<String> = graph
        .nodes
        .iter()
        .filter_map(|n| {
            if matches!(n, GraphNode::Ontology { .. } | GraphNode::Adr { .. }) || is_adr_content(n)
            {
                Some(n.id().to_owned())
            } else {
                None
            }
        })
        .collect();

    let adr_ids: HashSet<&str> = graph
        .nodes
        .iter()
        .filter_map(|n| {
            if matches!(n, GraphNode::Adr { .. }) || is_adr_content(n) {
                Some(n.id())
            } else {
                None
            }
        })
        .collect();

    let ontology_names: HashMap<&str, &str> = graph
        .nodes
        .iter()
        .filter_map(|n| match n {
            GraphNode::Ontology { id, name, .. } => Some((id.as_str(), name.as_str())),
            GraphNode::Adr { id, title, .. } => Some((id.as_str(), title.as_str())),
            _ => {
                if is_adr_content(n) {
                    Some((n.id(), n.title()))
                } else {
                    None
                }
            }
        })
        .collect();

    // For ADR-tagged Content nodes, use their site-local URL (e.g. /adr/009)
    // instead of the daemon deep-link now that the site hosts the ADR pages.
    let adr_content_urls: HashMap<&str, &str> = graph
        .nodes
        .iter()
        .filter_map(|n| match n {
            GraphNode::Content { id, url, .. } if is_adr_content(n) => {
                Some((id.as_str(), url.as_str()))
            }
            _ => None,
        })
        .collect();

    let mut ctx: HashMap<String, HashSet<(String, String, String)>> = HashMap::new();
    for edge in &graph.edges {
        if ontology_ids.contains(&edge.from) {
            continue;
        }
        if ontology_ids.contains(&edge.to) {
            let name = ontology_names
                .get(edge.to.as_str())
                .copied()
                .unwrap_or(edge.to.as_str());
            ctx.entry(edge.from.clone()).or_default().insert((
                edge.to.clone(),
                name.to_owned(),
                format!("{:?}", edge.kind),
            ));
        }
    }

    let entry_for = |refs: &HashSet<(String, String, String)>| -> Vec<OntologyOut> {
        let mut v: Vec<&(String, String, String)> = refs.iter().collect();
        v.sort();
        v.into_iter()
            .map(|(id, name, kind)| OntologyOut {
                url: adr_content_urls
                    .get(id.as_str())
                    .map(|u| u.to_string())
                    .or_else(|| Some(node_deep_link(daemon, id, adr_ids.contains(id.as_str())))),
                id: id.clone(),
                name: name.clone(),
                kind: kind.clone(),
            })
            .collect()
    };

    for (id, refs) in &ctx {
        out.insert(id.clone(), entry_for(refs));
    }
    for (slug, id) in slug_to_id {
        if let Some(refs) = ctx.get(id) {
            out.insert(slug.clone(), entry_for(refs));
        }
    }
}

fn build_mini(
    graph: &ContentGraph,
    slug_to_id: &HashMap<String, String>,
    daemon: &str,
    out: &mut BTreeMap<String, String>,
) {
    let adjacency: HashMap<String, Vec<String>> = {
        let mut m: HashMap<String, HashSet<String>> = HashMap::new();
        for edge in &graph.edges {
            if matches!(edge.kind, EdgeKind::SharedTag) {
                continue;
            }
            m.entry(edge.from.clone())
                .or_default()
                .insert(edge.to.clone());
            m.entry(edge.to.clone())
                .or_default()
                .insert(edge.from.clone());
        }
        m.into_iter()
            .map(|(k, s)| (k, s.into_iter().collect()))
            .collect()
    };

    let node_lookup: HashMap<&str, &GraphNode> = graph.nodes.iter().map(|n| (n.id(), n)).collect();

    for node in &graph.nodes {
        if !matches!(node, GraphNode::Content { .. }) {
            continue;
        }
        let neighbors: Vec<&GraphNode> = adjacency
            .get(node.id())
            .map(|ids| {
                ids.iter()
                    .filter_map(|id| node_lookup.get(id.as_str()).copied())
                    .collect()
            })
            .unwrap_or_default();
        if neighbors.is_empty() {
            continue;
        }
        let svg = render_ego_svg(node, &neighbors, daemon);
        out.insert(node.id().to_owned(), svg.clone());
        if let Some(slug) = node.url_slug() {
            if slug != node.id() {
                out.insert(slug.to_owned(), svg);
            }
        }
    }
    for (slug, id) in slug_to_id {
        if let Some(svg) = out.get(id).cloned() {
            out.entry(slug.clone()).or_insert(svg);
        }
    }
}

fn render_ego_svg(ego: &GraphNode, neighbors: &[&GraphNode], daemon: &str) -> String {
    let (cx, cy) = (180.0_f32, 180.0_f32);
    let radius = 120.0_f32;
    let n = neighbors.len().max(1) as f32;
    let start_angle = -std::f32::consts::FRAC_PI_2;

    let lines: String = neighbors
        .iter()
        .enumerate()
        .map(|(i, _)| {
            let angle = start_angle + (i as f32 / n) * std::f32::consts::TAU;
            let nx = cx + radius * angle.cos();
            let ny = cy + radius * angle.sin();
            format!(r#"  <line x1="{cx:.1}" y1="{cy:.1}" x2="{nx:.1}" y2="{ny:.1}" class="graph-edge"/>"#)
        })
        .collect::<Vec<_>>()
        .join("\n");

    let neighbor_circles: String = neighbors
        .iter()
        .enumerate()
        .map(|(i, node)| {
            let angle = start_angle + (i as f32 / n) * std::f32::consts::TAU;
            let nx = cx + radius * angle.cos();
            let ny = cy + radius * angle.sin();
            let css = node_css_class(node);
            let label = truncate(node.title(), 22);
            let label_y = if angle.sin() < -0.3 {
                ny - 24.0
            } else {
                ny + 28.0
            };
            let circle = format!(
                r#"  <circle cx="{nx:.1}" cy="{ny:.1}" r="14" class="graph-node {css}"/>
  <text x="{nx:.1}" y="{label_y:.1}" class="graph-label">{label}</text>"#,
            );
            // Ontology/ADR nodes deep-link to the live daemon graph focus (ADR-057
            // prize). Content neighbours stay inert here (they have their own pages).
            match node {
                GraphNode::Ontology { .. } | GraphNode::Adr { .. } => {
                    // Graph nodes route to the live graph focus (ADR-057 "route to
                    // live graph"); the text panel handles ADR→detail separately.
                    let href = node_deep_link(daemon, node.id(), false);
                    format!(
                        "  <a href=\"{href}\" target=\"_blank\" rel=\"noopener\">\n{circle}\n  \
                         </a>",
                    )
                }
                _ => circle,
            }
        })
        .collect::<Vec<_>>()
        .join("\n");

    let ego_css = node_css_class(ego);
    let ego_label = truncate(ego.title(), 30);

    format!(
        r#"<svg viewBox="0 0 360 360" xmlns="http://www.w3.org/2000/svg" class="content-graph-mini" role="img" aria-label="Knowledge graph: {ego_label}">
  <style>{MINI_SVG_STYLES}</style>
{lines}
  <circle cx="{cx:.1}" cy="{cy:.1}" r="22" class="graph-node ego {ego_css}"/>
  <text x="{cx:.1}" y="{:.1}" class="graph-label ego">{ego_label}</text>
{neighbor_circles}
</svg>"#,
        cy + 36.0,
    )
}

fn node_css_class(node: &GraphNode) -> &'static str {
    match node {
        GraphNode::Content { .. } => "content",
        GraphNode::Ontology { .. } => "ontology",
        GraphNode::Adr { .. } => "adr",
    }
}

fn truncate(s: &str, max: usize) -> String {
    let chars: Vec<char> = s.chars().collect();
    if chars.len() <= max {
        s.to_owned()
    } else {
        chars[..max - 1].iter().collect::<String>() + "…"
    }
}

fn ontology_artifact_to_url(path: &str) -> Option<String> {
    let rest = path.strip_prefix("site/content/")?;
    let segs: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
    if segs.len() < 3 {
        return None;
    }
    if !["en", "es"].contains(&segs[1]) {
        return None;
    }
    let mut parts = vec![segs[0]];
    parts.extend_from_slice(&segs[2..]);
    Some(format!("/{}", parts.join("/")))
}

const MINI_SVG_STYLES: &str = r#"
  .graph-node          { fill: var(--cg-node, #6b7280); stroke: none; }
  .graph-node.ego      { fill: var(--cg-ego,  #3b82f6); }
  .graph-node.ontology { fill: var(--cg-onto, #8b5cf6); }
  .graph-node.adr      { fill: var(--cg-adr,  #f59e0b); }
  .graph-edge          { stroke: var(--cg-edge, #d1d5db); stroke-width: 1.5; }
  .graph-label         { font: 10px sans-serif; fill: var(--cg-label, #374151); text-anchor: middle; }
  .graph-label.ego     { font-weight: 600; }
"#;
