//! Self-contained HTML graph viewer assembly (C9: read-only render path).
//!
//! `render_html` walks the blueprint into the deterministic JSON model
//! (`aura_engine::model_to_json`) and inlines it, the vendored Graphviz-WASM, the
//! pan-zoom helper, and the ported viewer JS into one standalone `.html`. The
//! emitted page fetches nothing at view time. No `eval`/build is on this path
//! (C9); the model is hand-rolled JSON (C14, owned by `model_to_json`).

use aura_engine::Composite;

/// The shared `<head>` (charset, viewport, `<style>`) + the opening `<body>` tag,
/// up to the per-page `<title>` slot. Derived from the prototype
/// (`docs/design/prototypes/graph-render/index.html` lines 1-38), minus the two
/// `<script src="./…">` tags (the assets are inlined). The `{title}` placeholder
/// and the per-page `<header>` let one shell serve both the graph and the chart
/// page without one borrowing the other's label. The shared stylesheet body now
/// lives in `assets/aura.css` and is spliced back in verbatim via `include_str!`.
const SHELL_HEAD: &str = concat!(
    r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
"#,
    include_str!("../assets/aura.css"),
    r#"</style>
</head>
<body>
"#
);

/// The graph page's `<title>` text and `<header>` line (hover/expand/drill hint),
/// graph-specific — split out of the shared shell.
const GRAPH_HEAD: &str = r#"<header>
  <b>aura graph</b>
  <span id="crumb"></span>
  <span class="sub">hover · [+] expand · body drill</span>
</header>
<div id="stage"></div>
<div id="tip"></div>
<pre id="err"></pre>
"#;

/// The chart page's `<title>` text and `<header>` line — describes what the chart
/// view actually renders (timestamp-aligned trace series), not the graph viewer. The
/// `#xmode-toggle` button is wired by `chart-viewer.js` (`mount`) to flip the x-axis
/// between the ordinal (gap-collapsed) default and continuous (real-time) spacing.
const CHART_HEAD: &str = r#"<header>
  <b>aura chart</b>
  <span class="sub">timestamp-aligned trace series</span>
  <span id="ctx" class="sub"></span>
  <button id="xmode-toggle" type="button">x: collapsed (gaps off)</button>
</header>
<div id="stage"></div>
<pre id="err"></pre>
"#;

const VIZ_JS: &str = include_str!("../assets/viz-standalone.js");
const PANZOOM_JS: &str = include_str!("../assets/svg-pan-zoom.min.js");
const VIEWER_JS: &str = include_str!("../assets/graph-viewer.js");

const UPLOT_JS: &str = include_str!("../assets/uPlot.iife.min.js");
const UPLOT_CSS: &str = include_str!("../assets/uPlot.min.css");
const CHART_VIEWER_JS: &str = include_str!("../assets/chart-viewer.js");

/// Chart-page-only styling (the `#xmode-toggle` button) — injected by
/// `render_chart_html` after the vendored uPlot CSS, NOT into the shared `SHELL_HEAD`
/// (the graph page has no toggle and must not inherit its rule). Colors are
/// `var(--x)` tokens from `assets/aura.css`; they resolve because this block lands
/// in the same document, after the shell `<style>` that defines `:root`.
const CHART_CSS: &str = r#"
#xmode-toggle { margin-left: auto; background: var(--bg2); color: var(--fg);
  border: 1px solid var(--border); border-radius: 6px; padding: 4px 10px;
  font-family: ui-monospace, monospace; font-size: 12px; cursor: pointer; }
#xmode-toggle:hover { border-color: var(--blue); color: var(--accent); }
"#;

/// Assemble the self-contained interactive graph viewer page for `root`.
///
/// Order is load-bearing: the Graphviz-WASM and pan-zoom globals (`Viz`,
/// `svgPanZoom`) and the model (`window.AURA_MODEL`) must be defined before the
/// viewer script runs.
pub fn render_html(root: &Composite) -> String {
    let model = aura_engine::model_to_json(root);
    let mut html = String::with_capacity(
        SHELL_HEAD.len() + GRAPH_HEAD.len() + VIZ_JS.len() + PANZOOM_JS.len() + VIEWER_JS.len() + model.len() + 256,
    );
    html.push_str(&SHELL_HEAD.replace("{title}", "aura graph"));
    html.push_str(GRAPH_HEAD);
    html.push_str("<script>");
    html.push_str(VIZ_JS);
    html.push_str("</script>\n<script>");
    html.push_str(PANZOOM_JS);
    html.push_str("</script>\n<script>window.AURA_MODEL = ");
    html.push_str(&model);
    html.push_str(";</script>\n<script>");
    html.push_str(VIEWER_JS);
    html.push_str("</script>\n</body>\n</html>\n");
    html
}

/// Which way the chart draws the (timestamp-aligned) series.
#[derive(Clone, Copy)]
pub enum ChartMode {
    /// One plot, all series superimposed, each on its own y-scale.
    Overlay,
    /// One stacked plot per series, all sharing the timestamp x-axis.
    Panels,
}

/// How `decimate` reduces a series within each bucket (#111). An *envelope* series
/// (a cumulative equity curve) keeps its min+max so drawdown spikes survive; a
/// *level* series (the bounded exposure, f64 ∈ [-1,+1]) takes the per-bucket mean,
/// so a high-flip signal shows its net/duty-cycle level instead of collapsing to a
/// -1..+1 band (every bucket of a multi-year exposure straddles many flips, so
/// min/max would be ±1 everywhere). Server-side only — never serialized into the
/// page (the viewer reads decimated points, not how they were reduced). Rendering
/// min/max as range bars / OHLC is a separate follow-up; this only fixes the
/// reduction.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ReduceKind {
    /// Per-bucket min and max — the envelope (equity, unbounded cumulative).
    #[default]
    MinMax,
    /// Per-bucket mean — the net level (exposure, bounded ∈ [-1,+1]).
    Mean,
}

/// One chart series: a name, its own y-scale id (so disparate magnitudes stay
/// legible), one `Option<f64>` per shared-x slot (`null` = a gap where the tap did
/// not fire at that timestamp), and how `decimate` reduces it per bucket.
#[derive(serde::Serialize)]
pub struct Series {
    pub name: String,
    pub y_scale_id: String,
    pub points: Vec<Option<f64>>,
    /// Per-bucket reduction for `decimate` (#111); server-side only, not part of the
    /// served payload.
    #[serde(skip)]
    pub reduce: ReduceKind,
}

/// Run-context for the page header (#102): serialized into `window.AURA_TRACES.meta`
/// and rendered client-side by `chart-viewer.js`'s pure `buildHeader`. A single run
/// carries its manifest fields + bound params; a family carries its shared identity
/// (commit/broker — one frozen artifact, one broker profile) plus the SPANNING
/// window across its members (see the `window` field) + member count — per-member
/// identity is the series label (`member.key`), not repeated here.
#[derive(Default, serde::Serialize)]
pub struct ChartMeta {
    /// "run" | "family".
    pub kind: String,
    /// The chart name (run name or family id).
    pub name: String,
    /// Git commit of the run's frozen artifact (C18); `buildHeader` shows the short form.
    pub commit: String,
    /// Inclusive data window `(from, to)` epoch-ns. For a single run, the run's
    /// manifest window. For a family, the SPAN across all members —
    /// `(min member.from, max member.to)` — NOT the first member's window: a
    /// walk-forward family's members are disjoint OOS windows, so only the span
    /// labels the family's true coverage (and for sweep/MC, whose members share a
    /// window, the span collapses to it).
    pub window: (i64, i64),
    /// Broker profile label, e.g. "sim-optimal(pip_size=1)".
    pub broker: String,
    /// RNG seed (0 for a seed-free synthetic run).
    pub seed: u64,
    /// The charted taps (single run: all charted taps or the `--tap` pick; family:
    /// the one compared tap).
    pub taps: Vec<String>,
    /// Family member count; `None` for a single run.
    pub members: Option<usize>,
    /// Bound params (single run only; empty for a family, where params are the
    /// per-member series labels). Stringified `name -> display` for the header.
    pub params: Vec<(String, String)>,
}

/// The serve-ready chart payload: the shared timestamp axis, the per-(tap,column)
/// series, and the run-context `meta`. These are exactly the fields `chart-viewer.js`
/// reads; injected verbatim as `window.AURA_TRACES` (plus the `mode`). `meta` carries
/// the manifest context for the header (#102); the full manifest stays on disk.
pub struct ChartData {
    pub xs: Vec<i64>,
    pub series: Vec<Series>,
    pub meta: ChartMeta,
}

/// Assemble the self-contained uPlot chart page for one run's aligned traces.
///
/// Order is load-bearing (mirrors `render_html`): the uPlot global and CSS, then the
/// injected `window.AURA_TRACES`, must be defined before `chart-viewer.js` runs.
pub fn render_chart_html(data: &ChartData, mode: ChartMode) -> String {
    let mode_str = match mode {
        ChartMode::Overlay => "overlay",
        ChartMode::Panels => "panels",
    };
    let traces = serde_json::json!({
        "mode": mode_str,
        "xs": data.xs,
        "series": data.series,
        "meta": data.meta,
    })
    .to_string();

    let mut html = String::with_capacity(
        SHELL_HEAD.len() + CHART_HEAD.len() + UPLOT_JS.len() + UPLOT_CSS.len() + CHART_CSS.len() + CHART_VIEWER_JS.len() + traces.len() + 256,
    );
    html.push_str(&SHELL_HEAD.replace("{title}", "aura chart"));
    html.push_str(CHART_HEAD);
    html.push_str("<style>");
    html.push_str(UPLOT_CSS);
    html.push_str(CHART_CSS);
    html.push_str("</style>\n<script>");
    html.push_str(UPLOT_JS);
    html.push_str("</script>\n<script>window.AURA_TRACES = ");
    html.push_str(&traces);
    html.push_str(";</script>\n<script>");
    html.push_str(CHART_VIEWER_JS);
    html.push_str("</script>\n</body>\n</html>\n");
    html
}

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

    /// `aura graph`'s page is self-contained (no remote fetch), embeds the
    /// deterministic model as the viewer's data source, carries the Graphviz-WASM
    /// bootstrap, and keeps the C4 four-colour palette. Structural, not pixel:
    /// the DOT/SVG are Graphviz-version-dependent and are exercised by hand
    /// against the prototype.
    #[test]
    fn render_html_is_self_contained_and_embeds_the_model() {
        let env = crate::project::Env::std();
        let bp = aura_engine::blueprint_from_json(
            include_str!("../examples/r_sma_open.json"),
            &|t| env.resolve(t),
        )
        .expect("the shipped r-sma example reloads into a renderable blueprint");
        let html = render_html(&bp);
        // a complete HTML document
        assert!(html.starts_with("<!doctype html>"), "not an HTML doc");
        assert!(html.trim_end().ends_with("</html>"), "HTML doc not closed");
        // the graph page wears its own label (not the chart's)
        assert!(html.contains("<title>aura graph</title>"), "graph page title missing");
        assert!(html.contains("<b>aura graph</b>"), "graph header label missing");
        // self-contained: every asset is inlined, no remote <script src>
        assert!(!html.contains("<script src"), "assets must be inlined, found a remote script tag");
        // the deterministic model is injected as the viewer's data source
        assert!(html.contains("window.AURA_MODEL = {\"root\":"), "model JSON not injected");
        // a known node from the sample harness round-trips via the model
        assert!(html.contains("\"type\":\"Bias\""), "sample model content missing");
        // the vendored Graphviz-WASM is present (its bootstrap global)
        assert!(html.contains("Viz.instance"), "viewer bootstrap (Viz.instance) missing");
        // C4: the four-scalar palette, no fifth colour
        assert!(
            html.contains("i64: \"#f9e2af\"") && html.contains("timestamp: \"#cba6f7\""),
            "C4 four-colour palette missing"
        );
        // the chart-only toggle control must not leak onto the graph page
        assert!(!html.contains("xmode-toggle"), "chart toggle leaked onto the graph page");
    }

    fn sample_chart_data() -> ChartData {
        ChartData {
            xs: vec![1, 2, 3],
            series: vec![
                Series { name: "equity".into(), y_scale_id: "y_0".into(), points: vec![Some(0.0), Some(0.001), Some(0.004)], reduce: ReduceKind::MinMax },
                Series { name: "exposure".into(), y_scale_id: "y_1".into(), points: vec![Some(1.0), Some(1.0), Some(-1.0)], reduce: ReduceKind::Mean },
            ],
            meta: ChartMeta {
                kind: "run".into(),
                name: "sample".into(),
                commit: "abc1234def".into(),
                window: (1, 3),
                broker: "sim-optimal(pip_size=1)".into(),
                seed: 0,
                taps: vec!["equity".into(), "exposure".into()],
                members: None,
                params: vec![],
            },
        }
    }

    #[test]
    fn render_chart_html_is_self_contained_and_injects_traces() {
        let html = render_chart_html(&sample_chart_data(), ChartMode::Overlay);
        assert!(html.starts_with("<!doctype html>"), "not an HTML doc");
        assert!(html.trim_end().ends_with("</html>"), "HTML doc not closed");
        // self-contained: no remote asset
        assert!(!html.contains("<script src"), "assets must be inlined");
        // data injected as the viewer's source, with the mode reflected
        assert!(html.contains("window.AURA_TRACES = {"), "AURA_TRACES not injected");
        assert!(html.contains("\"mode\":\"overlay\""), "overlay mode not reflected");
        assert!(html.contains("\"equity\""), "series name missing from injected data");
        // the vendored uPlot bundle + first-party viewer are inlined
        assert!(html.contains("uPlot=function"), "vendored uPlot bundle missing");
        assert!(html.contains("buildCharts"), "chart-viewer.js not inlined");
    }

    /// The chart page labels itself as a chart, never borrowing the graph viewer's
    /// `<title>`/header/breadcrumb — a served chart must not claim to be a graph.
    #[test]
    fn render_chart_html_wears_its_own_chart_label() {
        let html = render_chart_html(&sample_chart_data(), ChartMode::Overlay);
        assert!(html.contains("<title>aura chart</title>"), "chart page title not chart-specific");
        assert!(html.contains("<b>aura chart</b>"), "chart header label not chart-specific");
        // the x-axis toggle control is part of the chart chrome
        assert!(html.contains("id=\"xmode-toggle\""), "x-axis toggle control missing from the chart page");
        // none of the graph viewer's chrome leaked in
        assert!(!html.contains("aura graph"), "graph label leaked onto the chart page");
        assert!(!html.contains("hover · [+] expand · body drill"), "graph breadcrumb leaked onto the chart page");
    }

    /// `window.AURA_TRACES.meta` now carries the run-context the header reads
    /// (#102): the run's commit / window / broker / taps, threaded from the
    /// manifest (this flipped the earlier "manifest is NOT carried" pin).
    #[test]
    fn render_chart_html_injects_run_context() {
        let html = render_chart_html(&sample_chart_data(), ChartMode::Overlay);
        assert!(html.contains("\"meta\""), "run-context meta not injected");
        assert!(html.contains("abc1234def"), "manifest commit not injected");
        assert!(html.contains("sim-optimal(pip_size=1)"), "manifest broker not injected");
        assert!(html.contains("\"kind\":\"run\""), "meta kind not injected");
    }

    #[test]
    fn render_chart_html_reflects_panels_mode() {
        let html = render_chart_html(&sample_chart_data(), ChartMode::Panels);
        assert!(html.contains("\"mode\":\"panels\""), "panels mode not reflected");
    }
}
