Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-graphview/src/falkor.rs

//! **FalkorDB browser** — the egui-free view-model behind a graph-DB browse pane.
//!
//! A `Cypher query → nodes/edges → laid-out [`GraphModel`]` state machine, with NO
//! egui and NO transport baked in. The redis/RESP wire (FalkorDB speaks the Redis
//! `GRAPH.QUERY` command over a `MultiplexedConnection`) lives at the **consumer**
//! (korp / dwarves): it implements the [`CypherRunner`] seam and hands this
//! view-model an already-parsed [`CypherResult`]. That keeps this crate pure-Rust,
//! dependency-light, and headless-testable — a [`CannedRunner`] stands in for a live
//! DB so [`FalkorBrowser::run_query`] round-trips a query with no server.
//!
//! # Why a view-model and not a Facet
//! The interactive pane is [`crate::decorated_view::DecoratedGraphView`] (the egui
//! surface that paints a [`GraphModel`]). This browser is the *data half*: it turns a
//! Cypher string into that model (force-directed via [`crate::gpu_layout::relax_cpu`],
//! reused — no second layout engine) and publishes a `state_json` a headless driver
//! asserts on (node/edge counts, the round-tripped query, the label breakdown). A host
//! wires the two together: `browser.model()` → `DecoratedGraphView::with_model`.
//!
//! # Contract (FC-1/FC-3/FC-8)
//! Pure messages ([`Msg::SetQuery`]/[`Clear`](Msg::Clear)/[`Select`](Msg::Select)/
//! [`SetEndpoint`](Msg::SetEndpoint)) mutate only in-memory state through
//! [`update`](FalkorBrowser::update). The one I/O step — running the query — is the
//! explicit [`run_query`](FalkorBrowser::run_query) call that takes a
//! [`CypherRunner`], so the side effect is a typed argument, never hidden in `update`.

use serde::{Deserialize, Serialize};

use crate::gpu_layout::{relax_cpu, GraphCsr, LayoutParams};
use crate::model::{Color, GraphEdge, GraphModel, GraphNode, Pos};

/// Where a FalkorDB graph lives: host, Redis port, and the graph key
/// (`GRAPH.QUERY <graph> <cypher>`). Defaults to the korp demo endpoint
/// (`localhost:6379`, graph `korp_demo`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FalkorEndpoint {
    pub host: String,
    pub port: u16,
    /// The FalkorDB graph key the query targets.
    pub graph: String,
}

impl Default for FalkorEndpoint {
    fn default() -> Self {
        Self { host: "localhost".into(), port: 6379, graph: "korp_demo".into() }
    }
}

impl FalkorEndpoint {
    pub fn new(host: impl Into<String>, port: u16, graph: impl Into<String>) -> Self {
        Self { host: host.into(), port, graph: graph.into() }
    }
    /// A `host:port/graph` display string.
    pub fn display(&self) -> String {
        format!("{}:{}/{}", self.host, self.port, self.graph)
    }
}

/// One node in a Cypher result — the FalkorDB node record shape: an internal `id`,
/// zero-or-more `labels`, and an optional human `caption` (a `name`/`title`/`id`
/// property the caller picked out). The engine never invents captions; it falls back
/// to `#id` when `caption` is `None`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CypherNode {
    pub id: i64,
    #[serde(default)]
    pub labels: Vec<String>,
    #[serde(default)]
    pub caption: Option<String>,
}

impl CypherNode {
    pub fn new(id: i64, label: impl Into<String>, caption: impl Into<String>) -> Self {
        Self { id, labels: vec![label.into()], caption: Some(caption.into()) }
    }
    /// The node's primary label (its first), or `""` when unlabelled.
    pub fn primary_label(&self) -> &str {
        self.labels.first().map(String::as_str).unwrap_or("")
    }
    /// The display caption: the explicit caption, else `#<id>`.
    pub fn display_caption(&self) -> String {
        self.caption.clone().unwrap_or_else(|| format!("#{}", self.id))
    }
}

/// One relation in a Cypher result: endpoints by node `id` + a relationship `type`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CypherEdge {
    pub src: i64,
    pub dst: i64,
    #[serde(default)]
    pub rel_type: String,
}

impl CypherEdge {
    pub fn new(src: i64, dst: i64, rel_type: impl Into<String>) -> Self {
        Self { src, dst, rel_type: rel_type.into() }
    }
}

/// A parsed Cypher response: the nodes + relations a `GRAPH.QUERY` returned. This is
/// the seam type — a [`CypherRunner`] parses the FalkorDB reply into this; the
/// view-model never touches the wire.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct CypherResult {
    pub nodes: Vec<CypherNode>,
    pub edges: Vec<CypherEdge>,
}

impl CypherResult {
    pub fn node_count(&self) -> usize {
        self.nodes.len()
    }
    pub fn edge_count(&self) -> usize {
        self.edges.len()
    }
}

/// **The pluggable executor seam.** A consumer (korp/dwarves) implements this over a
/// live FalkorDB (`redis::Client` → `GRAPH.QUERY`), parsing the reply into a
/// [`CypherResult`]. The view-model depends only on this trait, so it builds and
/// tests with zero network deps. Object-safe (`&mut dyn CypherRunner`).
pub trait CypherRunner {
    /// Run `query` against `graph`, returning the parsed result or a human error
    /// string (surfaced as [`BrowserStatus::Error`]).
    fn run(&mut self, graph: &str, query: &str) -> Result<CypherResult, String>;
}

/// A headless stand-in for a live DB: returns a fixed [`CypherResult`] (or a canned
/// error) and records the last query it saw, so a robot test can prove the query
/// round-tripped through the view-model to the runner with no server running.
#[derive(Clone, Debug, Default)]
pub struct CannedRunner {
    result: CypherResult,
    error: Option<String>,
    /// The last `(graph, query)` this runner was handed — the round-trip witness.
    pub last: Option<(String, String)>,
}

impl CannedRunner {
    /// A runner that always returns `result`.
    pub fn ok(result: CypherResult) -> Self {
        Self { result, error: None, last: None }
    }
    /// A runner that always fails with `msg` (the fail-loud path).
    pub fn failing(msg: impl Into<String>) -> Self {
        Self { result: CypherResult::default(), error: Some(msg.into()), last: None }
    }
}

impl CypherRunner for CannedRunner {
    fn run(&mut self, graph: &str, query: &str) -> Result<CypherResult, String> {
        self.last = Some((graph.to_string(), query.to_string()));
        match &self.error {
            Some(e) => Err(e.clone()),
            None => Ok(self.result.clone()),
        }
    }
}

/// The outcome of the last query — the observable status (FC-1).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum BrowserStatus {
    /// No query run yet.
    #[default]
    Idle,
    /// The last query succeeded and returned this many nodes/edges.
    Ok { nodes: usize, edges: usize },
    /// The last query failed — the error surfaced fail-loud (never a silent empty graph).
    Error { message: String },
}

/// A robot-/host-addressable control message. The pure ones mutate only in-memory
/// state through [`FalkorBrowser::update`]; running the query is the separate
/// [`FalkorBrowser::run_query`] call (it needs a [`CypherRunner`], so the I/O is a
/// typed argument, not a hidden effect).
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
    /// Replace the editable Cypher text.
    SetQuery(String),
    /// Point at a different graph/endpoint.
    SetEndpoint(FalkorEndpoint),
    /// Select a node by its id string (`"#<id>"` space), or `None` to clear.
    Select(Option<String>),
    /// Drop the current result graph + status (back to Idle), keep the query text.
    Clear,
}

/// **The FalkorDB browser view-model.** Holds the endpoint, the editable query, the
/// last-run status, the laid-out result [`GraphModel`], a bounded query history, and
/// the selected node id. Egui-free; a host paints [`model`](Self::model) through
/// [`crate::decorated_view::DecoratedGraphView`].
#[derive(Clone, Debug)]
pub struct FalkorBrowser {
    endpoint: FalkorEndpoint,
    query: String,
    status: BrowserStatus,
    model: GraphModel,
    /// Distinct labels seen in the current result (categorical-colour order).
    labels: Vec<String>,
    selected: Option<String>,
    history: Vec<String>,
    /// Force-layout iteration budget for `run_query` (deterministic).
    iters: usize,
    /// Which lane laid the current result out, and the GPU job while it runs.
    layout: LayoutSlot,
}

/// **The largest answer the browser lays out on the CPU** — [`relax_cpu`] is the
/// all-pairs force layout, O(n²) per step (140 steps): 1 000 nodes is ~1.4·10⁸ pair
/// visits, 100 000 would be ~1.4·10¹². Above it the layout is the GPU's (a host-lent
/// device, [`crate::gpu_layout::install_host_device`]), and with no device the state
/// says `no GPU` and the nodes stay on the seed spiral — there is no CPU lane behind it.
/// With a device EVERY size goes to the GPU (the 2026-09-28 GPU ruling).
pub const CPU_LAYOUT_MAX: usize = 1_000;

/// Which lane laid the current result out, plus the in-flight GPU job. `Clone` yields
/// the readouts without the job (a job is VRAM buffers and a map callback; a cloned
/// browser keeps the placeholder positions it was cloned with).
#[derive(Default)]
struct LayoutSlot {
    /// `"cpu"`, `"gpu-job"`, `"no GPU"` or `""` (nothing laid out yet).
    lane: &'static str,
    /// The repulsion kernel of the GPU job (`"all-pairs"` / `"grid"`), `""` for none.
    kernel: &'static str,
    /// Why the GPU lane was not (or no longer) used — `""` when it was.
    note: &'static str,
    /// Layout units → world units for the job's positions.
    world_scale: f32,
    #[cfg(feature = "gpu")]
    job: Option<Box<crate::gpu_layout::LayoutJob>>,
}

impl Clone for LayoutSlot {
    fn clone(&self) -> Self {
        Self { lane: self.lane, kernel: self.kernel, note: self.note, world_scale: self.world_scale, ..Self::default() }
    }
}

impl std::fmt::Debug for LayoutSlot {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("LayoutSlot").field("lane", &self.lane).field("kernel", &self.kernel).field("note", &self.note).finish()
    }
}

/// World-space scale applied to the (unit-ish) relaxed layout so chips don't overlap.
const LAYOUT_SCALE: f32 = 90.0;
/// Rest length fed to the force layout — a few `ideal_len` apart before scaling.
const LAYOUT_IDEAL: f32 = 3.0;

/// A small categorical palette (dark-friendly, 8 distinct hues) that colours nodes by
/// their primary label. Local to the browser: there is no shared POD-`Color`
/// categorical palette to reuse yet, and this is presentation only.
const LABEL_PALETTE: [Color; 8] = [
    Color::rgb(0x5b, 0x8f, 0xf9), // blue
    Color::rgb(0x61, 0xcd, 0xbb), // teal
    Color::rgb(0xf6, 0xc8, 0x5f), // amber
    Color::rgb(0xef, 0x86, 0x6b), // coral
    Color::rgb(0xa0, 0x6f, 0xf5), // violet
    Color::rgb(0x9d, 0xc3, 0x5e), // green
    Color::rgb(0xe0, 0x6b, 0xa8), // magenta
    Color::rgb(0x7a, 0x9a, 0xa8), // slate
];

/// The default starter query — every node + relationship, capped (the "show me the
/// graph" button a browser opens on).
pub const DEFAULT_QUERY: &str = "MATCH (n)-[r]->(m) RETURN n, r, m LIMIT 100";

impl Default for FalkorBrowser {
    fn default() -> Self {
        Self {
            endpoint: FalkorEndpoint::default(),
            query: DEFAULT_QUERY.to_string(),
            status: BrowserStatus::Idle,
            model: GraphModel::default(),
            labels: Vec::new(),
            selected: None,
            history: Vec::new(),
            iters: 140,
            layout: LayoutSlot::default(),
        }
    }
}

impl FalkorBrowser {
    /// A fresh browser pointed at `endpoint`, seeded with the default query.
    pub fn new(endpoint: FalkorEndpoint) -> Self {
        Self { endpoint, ..Self::default() }
    }

    /// **Discovery-contract `local()`** — a browser whose *already-run* result is a
    /// small canned constellation (3 repos → crates → a dep edge), so the pane paints a
    /// real laid-out graph with no DB. Mirrors korp's `korp_demo` shape.
    pub fn local() -> Self {
        let mut b = FalkorBrowser {
            query: "MATCH (r:Repo)-[:HAS]->(c:Crate) RETURN r, c".into(),
            ..Default::default()
        };
        let mut runner = CannedRunner::ok(demo_result());
        // Drive the real path so `local()` and a live run produce byte-identical state.
        b.run_query(&mut runner);
        b
    }

    /// The endpoint (host/port/graph).
    pub fn endpoint(&self) -> &FalkorEndpoint {
        &self.endpoint
    }
    /// The current editable Cypher text.
    pub fn query(&self) -> &str {
        &self.query
    }
    /// The last-run status.
    pub fn status(&self) -> &BrowserStatus {
        &self.status
    }
    /// The laid-out result graph (paint this through `DecoratedGraphView`).
    pub fn model(&self) -> &GraphModel {
        &self.model
    }
    /// Distinct labels in the current result, in categorical-colour order.
    pub fn labels(&self) -> &[String] {
        &self.labels
    }
    /// The bounded query history (most-recent last).
    pub fn history(&self) -> &[String] {
        &self.history
    }
    /// The selected node id, if any.
    pub fn selected(&self) -> Option<&str> {
        self.selected.as_deref()
    }

    /// **FC-8 pure mutation** — apply one non-I/O [`Msg`]. Returns nothing to run: the
    /// only side effect in this view-model is [`run_query`](Self::run_query), which is
    /// its own call.
    pub fn update(&mut self, msg: Msg) {
        match msg {
            Msg::SetQuery(q) => self.query = q,
            Msg::SetEndpoint(e) => self.endpoint = e,
            Msg::Select(id) => self.selected = id,
            Msg::Clear => {
                self.layout = LayoutSlot::default();
                self.model = GraphModel::default();
                self.labels.clear();
                self.selected = None;
                self.status = BrowserStatus::Idle;
            }
        }
    }

    /// **The one I/O step.** Run the current [`query`](Self::query) against the endpoint
    /// via `runner`, then map the [`CypherResult`] into a force-laid-out
    /// [`GraphModel`]. On success the status becomes [`BrowserStatus::Ok`] with the
    /// counts and the query is pushed to history; on failure the graph is left cleared
    /// and the error surfaces fail-loud in [`BrowserStatus::Error`] — never a silent
    /// empty graph. Returns a reference to the new status.
    pub fn run_query(&mut self, runner: &mut dyn CypherRunner) -> &BrowserStatus {
        let query = self.query.clone();
        match runner.run(&self.endpoint.graph, &query) {
            Ok(result) => {
                let (model, labels) = self.lay_out(&result);
                let (nodes, edges) = (model.nodes.len(), model.edges.len());
                self.model = model;
                self.labels = labels;
                // Selection may no longer resolve; keep it only if the id still exists.
                if let Some(sel) = &self.selected && !self.model.nodes.iter().any(|n| &n.id == sel) {
                    self.selected = None;
                }
                self.push_history(query);
                self.status = BrowserStatus::Ok { nodes, edges };
            }
            Err(message) => {
                self.model = GraphModel::default();
                self.labels.clear();
                self.selected = None;
                self.layout = LayoutSlot::default();
                self.status = BrowserStatus::Error { message };
            }
        }

        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-graphview::FalkorBrowser::run_query",
            "cypher_round_trip",
            matches!(self.status, BrowserStatus::Ok { .. }),
            &format!(
                "graph={} status={:?} nodes={} edges={}",
                self.endpoint.graph,
                self.status,
                self.model.nodes.len(),
                self.model.edges.len(),
            ),
        );

        &self.status
    }

    /// Map `result` and lay it out on the lane its size and the host allow (see
    /// [`CPU_LAYOUT_MAX`]). A GPU job leaves the seed spiral in place; [`poll_layout`]
    /// (Self::poll_layout) swaps the settled positions in.
    fn lay_out(&mut self, result: &CypherResult) -> (GraphModel, Vec<String>) {
        self.layout = LayoutSlot::default();
        let (mut model, labels, dense) = map_result(result);
        let n = model.nodes.len();
        if n == 0 {
            return (model, labels);
        }
        #[cfg(feature = "gpu")]
        if crate::gpu_layout::host_device_installed() {
            use crate::gpu_layout::{with_host_device, LayoutJob, LayoutState};
            // Small: the CPU lane's own params + seed, positions back in its units —
            // the GPU twin of `relax_cpu` (the same look). Large: the navigator's
            // proven 100k lane (default params, the grid kernel above GRID_MIN_NODES),
            // fitted to the box and scaled to the world the CPU lane would have spanned.
            let (params, seed, normalize, world_scale) = if n <= CPU_LAYOUT_MAX {
                let p = LayoutParams { ideal_len: LAYOUT_IDEAL, ..Default::default() };
                (p, LayoutState::seed(n, p.ideal_len * (n as f32).sqrt()), false, LAYOUT_SCALE)
            } else {
                let p = LayoutParams::default();
                (p, LayoutState::seed(n, p.ideal_len * (n as f32).sqrt()), true, large_world_scale(n))
            };
            let csr = GraphCsr::from_edges(n, &dense, None);
            let iters = self.iters;
            if let Some(job) = with_host_device(|d, _| LayoutJob::start_with(d, csr, params, &seed, iters, normalize)) {
                let mut placeholder = seed.pos;
                if normalize {
                    facett_core::barnes_hut::normalize_box(&mut placeholder);
                }
                place(&mut model, &placeholder, world_scale);
                self.layout.lane = "gpu-job";
                self.layout.kernel = job.kernel();
                self.layout.world_scale = world_scale;
                self.layout.job = Some(Box::new(job));
                return (model, labels);
            }
        }
        if n <= CPU_LAYOUT_MAX {
            let csr = GraphCsr::from_edges(n, &dense, None);
            let params = LayoutParams { ideal_len: LAYOUT_IDEAL, ..Default::default() };
            place(&mut model, &relax_cpu(&csr, &params, self.iters).pos, LAYOUT_SCALE);
            self.layout.lane = "cpu";
        } else {
            let mut spiral = crate::gpu_layout::LayoutState::seed(n, (n as f32).sqrt()).pos;
            facett_core::barnes_hut::normalize_box(&mut spiral);
            place(&mut model, &spiral, large_world_scale(n));
            self.layout.lane = "no GPU";
            self.layout.note = "no GPU: above CPU_LAYOUT_MAX nodes the force layout runs on the GPU only, and no host device is installed — the nodes stay on the seed spiral";
        }
        (model, labels)
    }

    /// **Advance the GPU layout job** by one bounded slice (never waits on the GPU; call
    /// once per frame while [`layout_pending`](Self::layout_pending)). `true` when the
    /// settled positions landed in [`model`](Self::model) THIS call — the host re-seeds
    /// its view then (one model change, one upload). A job that fails says why in
    /// [`layout_note`](Self::layout_note); at or below [`CPU_LAYOUT_MAX`] the existing CPU
    /// lane then lays the graph out, above it the seed spiral stays.
    pub fn poll_layout(&mut self) -> bool {
        #[cfg(feature = "gpu")]
        {
            use crate::gpu_layout::{with_host_device, JobState};
            let Some(job) = self.layout.job.as_mut() else { return false };
            let state = with_host_device(|d, q| job.poll(d, q))
                .unwrap_or(JobState::Failed("the host device was cleared while the layout ran"));
            match state {
                JobState::Pending => false,
                JobState::Ready(pos) => {
                    self.layout.job = None;
                    place(&mut self.model, &pos, self.layout.world_scale);
                    true
                }
                JobState::Failed(why) => {
                    self.layout.job = None;
                    self.layout.note = why;
                    let n = self.model.nodes.len();
                    if n <= CPU_LAYOUT_MAX {
                        let dense = dense_edges_of(&self.model);
                        let csr = GraphCsr::from_edges(n, &dense, None);
                        let params = LayoutParams { ideal_len: LAYOUT_IDEAL, ..Default::default() };
                        place(&mut self.model, &relax_cpu(&csr, &params, self.iters).pos, LAYOUT_SCALE);
                        self.layout.lane = "cpu";
                        true
                    } else {
                        self.layout.lane = "no GPU";
                        false
                    }
                }
            }
        }
        #[cfg(not(feature = "gpu"))]
        {
            false
        }
    }

    /// A GPU layout job is in flight (the host keeps repainting and polling).
    #[must_use]
    pub fn layout_pending(&self) -> bool {
        #[cfg(feature = "gpu")]
        {
            self.layout.job.is_some()
        }
        #[cfg(not(feature = "gpu"))]
        {
            false
        }
    }

    /// Which lane laid the current result out: `"cpu"`, `"gpu-job"` (pending or
    /// settled — see [`layout_pending`](Self::layout_pending)), `"no GPU"`, or `""`.
    #[must_use]
    pub fn layout_lane(&self) -> &'static str {
        self.layout.lane
    }

    /// The GPU job's repulsion kernel: `"all-pairs"` below
    /// `gpu_layout::GRID_MIN_NODES`, `"grid"` above, `""` with no job.
    #[must_use]
    pub fn layout_kernel(&self) -> &'static str {
        self.layout.kernel
    }

    /// Why the GPU lane was not used, or failed — `""` when it ran.
    #[must_use]
    pub fn layout_note(&self) -> &'static str {
        self.layout.note
    }

    /// The layout readout as JSON (the `layout` key of [`state_json`](Self::state_json)).
    #[must_use]
    pub fn layout_json(&self) -> serde_json::Value {
        serde_json::json!({
            "lane": self.layout_lane(),
            "pending": self.layout_pending(),
            "kernel": self.layout_kernel(),
            "note": self.layout_note(),
            "cpu_max": CPU_LAYOUT_MAX,
        })
    }

    fn push_history(&mut self, query: String) {
        if self.history.last() == Some(&query) {
            return;
        }
        self.history.push(query);
        // Keep the last 50 — a browser history, not an unbounded leak.
        let over = self.history.len().saturating_sub(50);
        if over > 0 {
            self.history.drain(0..over);
        }
    }

    /// **FC-3** — the complete observable state as JSON: the endpoint, the round-tripped
    /// query, the status + node/edge counts, the label breakdown, the selection, and a
    /// compact node/edge dump. This is what a headless robot asserts on.
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "endpoint": self.endpoint,
            "graph": self.endpoint.graph,
            "query": self.query,
            "status": self.status,
            "node_count": self.model.nodes.len(),
            "edge_count": self.model.edges.len(),
            "labels": self.labels,
            "label_count": self.labels.len(),
            "selected": self.selected,
            "history_len": self.history.len(),
            "layout": self.layout_json(),
            "nodes": self.model.nodes.iter().map(|n| serde_json::json!({
                "id": n.id,
                "label": n.label,
                "x": n.pos.x,
                "y": n.pos.y,
            })).collect::<Vec<_>>(),
            "edges": self.model.edges.iter().map(|e| serde_json::json!({
                "from": e.from,
                "to": e.to,
                "rel": e.label,
            })).collect::<Vec<_>>(),
        })
    }
}

/// The canned demo result behind [`FalkorBrowser::local`] — a tiny constellation:
/// two repos, three crates, a cross-repo dependency.
fn demo_result() -> CypherResult {
    CypherResult {
        nodes: vec![
            CypherNode::new(1, "Repo", "facett"),
            CypherNode::new(2, "Repo", "nornir"),
            CypherNode::new(3, "Crate", "facett-core"),
            CypherNode::new(4, "Crate", "facett-graphview"),
            CypherNode::new(5, "Crate", "nornir-viz"),
        ],
        edges: vec![
            CypherEdge::new(1, 3, "HAS"),
            CypherEdge::new(1, 4, "HAS"),
            CypherEdge::new(2, 5, "HAS"),
            CypherEdge::new(4, 3, "DEPENDS_ON"),
            CypherEdge::new(5, 4, "DEPENDS_ON"),
        ],
    }
}

/// The world half-extent per layout unit for an answer above [`CPU_LAYOUT_MAX`]: the
/// GPU positions come back fitted to `[-0.9, 0.9]²`, and this spreads them over the
/// disc the CPU lane's spacing (`LAYOUT_IDEAL` × `LAYOUT_SCALE` per node) would cover.
fn large_world_scale(n: usize) -> f32 {
    LAYOUT_IDEAL * LAYOUT_SCALE * (n as f32).sqrt() * 0.5 / 0.9
}

/// Write layout positions (`pos[i]` for node `i`, layout units) into `model` at `scale`.
fn place(model: &mut GraphModel, pos: &[[f32; 2]], scale: f32) {
    for (node, p) in model.nodes.iter_mut().zip(pos) {
        node.pos = Pos::new(p[0] * scale, p[1] * scale);
    }
}

/// The model's edges over dense node indices (the CSR the layout takes).
#[cfg(feature = "gpu")]
fn dense_edges_of(model: &GraphModel) -> Vec<(u32, u32)> {
    let index: std::collections::HashMap<&str, u32> =
        model.nodes.iter().enumerate().map(|(i, n)| (n.id.as_str(), i as u32)).collect();
    model.edges.iter().filter_map(|e| Some((*index.get(e.from.as_str())?, *index.get(e.to.as_str())?))).collect()
}

/// Map a [`CypherResult`] into an UNPLACED [`GraphModel`] (every node at the origin),
/// the distinct-label order and the edges over dense indices — the layout lane places
/// it. Nodes are coloured categorically by primary label; edges keep their
/// relationship type as a mid-edge label. Pure + deterministic in `result`.
fn map_result(result: &CypherResult) -> (GraphModel, Vec<String>, Vec<(u32, u32)>) {
    let n = result.nodes.len();
    if n == 0 {
        return (GraphModel::default(), Vec::new(), Vec::new());
    }

    // id → dense index (node ids are FalkorDB internal ids, not contiguous).
    let mut index: std::collections::HashMap<i64, u32> = std::collections::HashMap::with_capacity(n);
    for (i, node) in result.nodes.iter().enumerate() {
        index.insert(node.id, i as u32);
    }

    // Distinct labels in first-seen order → categorical colour slot.
    let mut labels: Vec<String> = Vec::new();
    let mut slot_of: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
    for node in &result.nodes {
        let l = node.primary_label();
        if !l.is_empty() && !slot_of.contains_key(l) {
            slot_of.insert(l, labels.len());
            labels.push(l.to_string());
        }
    }
    let colour_of = |label: &str| -> Color {
        match slot_of.get(label) {
            Some(slot) => LABEL_PALETTE[slot % LABEL_PALETTE.len()],
            None => Color::GRAY,
        }
    };

    // Edges over dense indices (drop dangling endpoints the result didn't include).
    let dense_edges: Vec<(u32, u32)> = result
        .edges
        .iter()
        .filter_map(|e| Some((*index.get(&e.src)?, *index.get(&e.dst)?)))
        .collect();

    let nodes = result
        .nodes
        .iter()
        .map(|node| GraphNode {
            id: node.id.to_string(),
            label: node.display_caption(),
            fill: colour_of(node.primary_label()),
            stroke: Color::WHITE,
            pos: Pos::new(0.0, 0.0),
        })
        .collect();

    let edges = result
        .edges
        .iter()
        .filter(|e| index.contains_key(&e.src) && index.contains_key(&e.dst))
        .map(|e| GraphEdge {
            from: e.src.to_string(),
            to: e.dst.to_string(),
            color: Color::rgb(150, 160, 180),
            dashed: false,
            label: (!e.rel_type.is_empty()).then(|| e.rel_type.clone()),
        })
        .collect();

    (GraphModel { nodes, edges }, labels, dense_edges)
}

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

    #[test]
    fn endpoint_default_is_korp_demo() {
        let e = FalkorEndpoint::default();
        assert_eq!(e.port, 6379);
        assert_eq!(e.graph, "korp_demo");
        assert_eq!(e.display(), "localhost:6379/korp_demo");
    }

    /// The headline robot property: a Cypher query round-trips through the view-model to
    /// the runner, and the parsed result becomes a laid-out graph the `state_json`
    /// reports. RED-when-broken if the query is dropped or the mapping miscounts.
    #[test]
    fn cypher_query_round_trips_into_state_json() {
        let mut b = FalkorBrowser::new(FalkorEndpoint::new("db.local", 6380, "warehouse"));
        let q = "MATCH (r:Repo)-[:HAS]->(c:Crate) RETURN r, c";
        b.update(Msg::SetQuery(q.into()));

        let mut runner = CannedRunner::ok(demo_result());
        let status = b.run_query(&mut runner).clone();

        // The runner saw the exact (graph, query) the view-model holds — the round trip.
        assert_eq!(runner.last.as_ref().unwrap().0, "warehouse", "graph key routed");
        assert_eq!(runner.last.as_ref().unwrap().1, q, "query text routed verbatim");

        // The status + state_json report the mapped node/edge counts (5 nodes, 5 edges).
        assert_eq!(status, BrowserStatus::Ok { nodes: 5, edges: 5 });
        let j = b.state_json();
        assert_eq!(j["query"], q, "the query round-trips into state_json");
        assert_eq!(j["node_count"], 5);
        assert_eq!(j["edge_count"], 5);
        assert_eq!(j["graph"], "warehouse");
        // Two distinct labels (Repo, Crate) → two categorical colour slots.
        assert_eq!(j["label_count"], 2);
        assert_eq!(j["status"]["kind"], "ok");
        // Positions were assigned (the force layout ran, not all-origin).
        let spread = j["nodes"].as_array().unwrap().iter().any(|n| {
            n["x"].as_f64().unwrap().abs() > 1.0 || n["y"].as_f64().unwrap().abs() > 1.0
        });
        assert!(spread, "the force layout placed nodes off the origin");
    }

    #[test]
    fn local_paints_a_canned_constellation_with_no_db() {
        let b = FalkorBrowser::local();
        let j = b.state_json();
        assert_eq!(j["node_count"], 5, "the demo constellation");
        assert_eq!(j["edge_count"], 5);
        assert_eq!(j["status"]["kind"], "ok");
        // Node captions came through (names, not #id).
        let labels: Vec<&str> =
            j["nodes"].as_array().unwrap().iter().map(|n| n["label"].as_str().unwrap()).collect();
        assert!(labels.contains(&"facett-graphview"), "captions map through: {labels:?}");
    }

    /// A failing runner surfaces the error fail-loud and leaves NO silent partial graph.
    #[test]
    fn query_error_surfaces_and_clears_the_graph() {
        let mut b = FalkorBrowser::local(); // starts with a populated graph
        assert!(!b.model().nodes.is_empty());
        let mut runner = CannedRunner::failing("connection refused (:6379)");
        b.run_query(&mut runner);
        assert!(matches!(b.status(), BrowserStatus::Error { .. }), "fail-loud, not empty-ok");
        let j = b.state_json();
        assert_eq!(j["status"]["kind"], "error");
        assert_eq!(j["status"]["message"], "connection refused (:6379)");
        assert_eq!(j["node_count"], 0, "the stale graph is cleared, never left partial");
    }

    #[test]
    fn clear_resets_to_idle_keeping_query() {
        let mut b = FalkorBrowser::local();
        let q = b.query().to_string();
        b.update(Msg::Clear);
        assert_eq!(b.status(), &BrowserStatus::Idle);
        assert_eq!(b.model().nodes.len(), 0);
        assert_eq!(b.query(), q, "Clear keeps the editable query text");
    }

    #[test]
    fn selection_is_dropped_when_it_no_longer_resolves() {
        let mut b = FalkorBrowser::local();
        b.update(Msg::Select(Some("3".into()))); // facett-core, id 3 — exists
        assert_eq!(b.selected(), Some("3"));
        // Re-run with a result that omits node 3 → the selection must not dangle.
        let smaller = CypherResult { nodes: vec![CypherNode::new(1, "Repo", "facett")], edges: vec![] };
        let mut runner = CannedRunner::ok(smaller);
        b.run_query(&mut runner);
        assert_eq!(b.selected(), None, "a selection that no longer resolves is cleared");
        assert_eq!(b.state_json()["node_count"], 1);
    }

    #[test]
    fn history_dedups_consecutive_and_bounds() {
        let mut b = FalkorBrowser::default();
        let mut runner = CannedRunner::ok(demo_result());
        b.update(Msg::SetQuery("MATCH (n) RETURN n".into()));
        b.run_query(&mut runner);
        b.run_query(&mut runner); // same query twice → one history entry
        assert_eq!(b.history().len(), 1, "consecutive duplicate queries fold");
        b.update(Msg::SetQuery("MATCH (n:Repo) RETURN n".into()));
        b.run_query(&mut runner);
        assert_eq!(b.history().len(), 2);
    }

    #[test]
    fn empty_result_lays_out_cleanly() {
        let mut b = FalkorBrowser::default();
        let mut runner = CannedRunner::ok(CypherResult::default());
        b.run_query(&mut runner);
        assert_eq!(b.status(), &BrowserStatus::Ok { nodes: 0, edges: 0 });
        assert_eq!(b.state_json()["node_count"], 0);
    }

    /// The result → GraphModel mapping feeds straight into the interactive pane.
    #[test]
    fn model_feeds_the_decorated_view() {
        use crate::decorated_view::DecoratedGraphView;
        use facett_core::Facet;
        let b = FalkorBrowser::local();
        let view = DecoratedGraphView::new("FalkorDB").with_model(b.model().clone());
        assert_eq!(view.state_json()["node_count"], 5, "the browser model paints in the pane");
        assert_eq!(view.state_json()["edge_count"], 5);
    }
}