//! Pure-Rust HTML rendering for the post sidebar — no Leptos required.
//!
//! Graph data (mini-SVG, related nodes, ontology context per node) is loaded at
//! RUNTIME from `content_graph.json` delivered to the PV — it is NOT baked into
//! the binary. Regenerate the JSON with the host tool and deliver it to the PV;
//! no binary rebuild or image deploy is needed when content changes:
//!
//!   cargo run -p content-graph-ssr --features gen --bin gen-content-graph
//!
//! Used by the HTMX-SSR renderer; the Leptos hydration build uses the
//! equivalent Leptos components in `website-pages` instead.

use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::time::SystemTime;

use serde::Deserialize;

/// One related-content edge as rendered in the sidebar.
#[derive(Debug, Clone, Deserialize)]
pub struct RelatedNode {
    pub id: String,
    pub title: String,
    pub kind: String,
    pub url: String,
}

/// One ontology reference connected to a content node.
#[derive(Debug, Clone, Deserialize)]
pub struct OntologyRef {
    pub id: String,
    pub name: String,
    pub kind: String,
    /// Optional deep-link to the live daemon surface (ADR detail / graph
    /// focus). Absent in older content_graph.json → the ref renders as an
    /// inert label.
    #[serde(default)]
    pub url: Option<String>,
}

/// Per-node sidebar data, keyed by content id (and URL-slug aliases).
#[derive(Debug, Default, Deserialize)]
pub struct GraphData {
    #[serde(default)]
    mini: HashMap<String, String>,
    #[serde(default)]
    related: HashMap<String, Vec<RelatedNode>>,
    #[serde(default)]
    ontology: HashMap<String, Vec<OntologyRef>>,
}

/// Cache of `(file mtime, parsed data)`. The data is re-read only when the
/// file's mtime changes, so delivering a fresh `content_graph.json` to the PV
/// takes effect on the next request — no pod restart, no rebuild.
static GRAPH_CACHE: RwLock<Option<(Option<SystemTime>, Arc<GraphData>)>> = RwLock::new(None);

/// Resolve the JSON location. `CONTENT_GRAPH_PATH` wins; otherwise the PV
/// deploy path (prod, cwd = /var/www) then the dev build-output path. All
/// cwd-relative, so neither an env var nor an image rebuild is required —
/// delivering the JSON to the PV is sufficient.
fn graph_path() -> String {
    if let Ok(p) = std::env::var("CONTENT_GRAPH_PATH") {
        return p;
    }
    for cand in [
        "site/public/r/content_graph.json",
        "target/site/r/content_graph.json",
    ] {
        if std::path::Path::new(cand).exists() {
            return cand.to_string();
        }
    }
    "site/public/r/content_graph.json".to_string()
}

/// Read and parse the file. A missing or malformed file disables the sidebar
/// graph (empty data) rather than failing the request — the post still renders.
fn load_graph(path: &str) -> GraphData {
    match std::fs::read_to_string(path) {
        Ok(s) => serde_json::from_str::<GraphData>(&s).unwrap_or_else(|e| {
            tracing::error!(path = %path, error = %e, "content_graph.json parse failed — sidebar graph disabled");
            GraphData::default()
        }),
        Err(e) => {
            tracing::warn!(path = %path, error = %e, "content_graph.json absent — sidebar graph disabled");
            GraphData::default()
        }
    }
}

/// Return the graph data, reloading only when the file's mtime changes. This
/// gives hot-reload: a newly delivered `content_graph.json` is picked up on the
/// next request with no restart, while unchanged requests pay only a `stat`.
fn graph_data() -> Arc<GraphData> {
    let path = graph_path();
    let mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok();

    if let Some((cached_mtime, data)) = GRAPH_CACHE.read().expect("graph cache poisoned").as_ref() {
        if *cached_mtime == mtime {
            return Arc::clone(data);
        }
    }

    let data = Arc::new(load_graph(&path));
    *GRAPH_CACHE.write().expect("graph cache poisoned") = Some((mtime, Arc::clone(&data)));
    data
}

/// Render the full sidebar for a content post as a plain HTML string.
///
/// Returns an empty string when the node has no graph data.
pub fn render_sidebar(node_id: &str, lang: &str) -> String {
    let data = graph_data();
    let mini_html = render_graph_mini(&data, node_id);
    let related_html = render_related(&data, node_id, lang);
    let ontology_html = render_ontology(&data, node_id, lang);

    if mini_html.is_empty() && related_html.is_empty() && ontology_html.is_empty() {
        return String::new();
    }

    format!(
        r#"<aside class="space-y-4 shrink-0">{}{}{}</aside>"#,
        mini_html, related_html, ontology_html
    )
}

fn render_graph_mini(data: &GraphData, node_id: &str) -> String {
    let Some(svg) = data.mini.get(node_id) else {
        return String::new();
    };

    format!(
        r#"<div class="content-graph-mini-wrapper"><div class="content-graph-mini-preview" aria-label="Knowledge graph preview" role="img">{svg}</div></div>"#
    )
}

fn render_related(data: &GraphData, node_id: &str, lang: &str) -> String {
    let Some(related) = data.related.get(node_id).filter(|r| !r.is_empty()) else {
        return String::new();
    };

    let header = if lang == "es" {
        "Contenido relacionado"
    } else {
        "Related content"
    };
    let mut items = String::new();
    for node in related {
        let badge_css = kind_badge_css(&node.kind);
        let badge_label = kind_label(&node.kind);
        let content = if node.url.is_empty() {
            format!(
                r#"<span class="text-sm ds-text leading-snug">{}</span>"#,
                esc(&node.title)
            )
        } else {
            format!(
                r#"<a href="{}" class="text-sm ds-text leading-snug hover:underline">{}</a>"#,
                esc_attr(&node.url),
                esc(&node.title)
            )
        };
        items.push_str(&format!(
            r#"<li class="flex items-start gap-2"><span class="ds-badge text-xs mt-0.5 shrink-0 {badge_css}">{badge_label}</span>{content}</li>"#,
        ));
    }

    format!(
        r#"<aside class="content-graph-related ds-card ds-bg-base-200 p-4 space-y-3"><h3 class="text-sm font-semibold ds-text uppercase tracking-wide opacity-60">{header}</h3><ul class="space-y-2">{items}</ul></aside>"#,
    )
}

fn render_ontology(data: &GraphData, node_id: &str, lang: &str) -> String {
    let Some(refs) = data.ontology.get(node_id).filter(|r| !r.is_empty()) else {
        return String::new();
    };

    let header = if lang == "es" {
        "Contexto ontológico"
    } else {
        "Ontology context"
    };
    let mut items = String::new();
    for r in refs {
        let icon = ref_icon(&r.kind);
        let css = ref_css(&r.kind);
        let inner = format!(
            r#"<span class="font-mono opacity-80">{}</span><span class="opacity-50">{}</span>"#,
            esc(&r.id),
            esc(&r.name),
        );
        // ADR-057 two-tier: link to the live daemon surface when present, else
        // fall back to an inert label (daemon-free / older content_graph.json).
        let body = match &r.url {
            Some(u) => format!(
                r#"<a href="{}" title="{}" class="flex items-center gap-1 hover:underline" target="_blank" rel="noopener">{inner}</a>"#,
                esc(u),
                esc(&r.name),
            ),
            None => inner,
        };
        items.push_str(&format!(
            r#"<li class="flex items-center gap-1 text-xs ds-text"><span class="{css}">{icon}</span>{body}</li>"#,
        ));
    }

    format!(
        r#"<div class="content-graph-ontology-context mt-6 pt-4 ds-border-t space-y-2"><p class="text-xs font-semibold ds-text uppercase tracking-wide opacity-50">{header}</p><ul class="flex flex-wrap gap-2">{items}</ul></div>"#,
    )
}

fn kind_label(kind: &str) -> &'static str {
    match kind {
        "Implements" | "ImplementedBy" => "implements",
        "RelatedTo" | "RelatedFrom" => "related",
        "PartOf" | "ParentOf" => "part of",
        "References" | "ReferencedBy" => "references",
        "Extends" | "ExtendedBy" => "extends",
        "ManifestsIn" => "manifests in",
        "Resolves" => "resolves",
        "Complements" => "complements",
        "DependsOn" => "depends on",
        "DocumentedIn" => "documented in",
        _ => "related",
    }
}

fn kind_badge_css(kind: &str) -> &'static str {
    match kind {
        "Implements" | "ImplementedBy" => "ds-badge-primary",
        "RelatedTo" | "RelatedFrom" => "ds-badge-secondary",
        "PartOf" | "ParentOf" => "ds-badge-accent",
        "References" | "ReferencedBy" => "ds-badge-neutral",
        _ => "ds-badge-ghost",
    }
}

fn ref_icon(kind: &str) -> &'static str {
    match kind {
        "Implements" | "ImplementedBy" => "◆",
        "ManifestsIn" => "→",
        "Resolves" => "✓",
        "Complements" | "DependsOn" => "~",
        "DocumentedIn" | "References" => "↗",
        _ => "·",
    }
}

fn ref_css(kind: &str) -> &'static str {
    match kind {
        "Implements" | "ImplementedBy" => "text-violet-500",
        "ManifestsIn" => "text-blue-500",
        "Resolves" => "text-emerald-500",
        _ => "text-amber-500",
    }
}

fn esc(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

fn esc_attr(s: &str) -> String {
    esc(s).replace('"', "&quot;")
}
