/// Valence — Narrative: the instance-facing render, given a DOM of its own.
///
/// ── The asymmetry this fixes ──
/// Every Valence component is a `Dual` — `(DomNode, () -> NarrativeNode)`. The visual half is a
/// *tree* (Luna DOM) with a *layout engine* under it (the browser + CSS) that turns
/// structure into pixels. The narrative half was a **flat String** — hand-glued,
/// no tree, no layout. That's like building the visual side by concatenating HTML
/// text by hand instead of composing nodes: it works for one line and falls apart
/// the moment anything composes or moves.
///
/// `NarrativeNode` is the narrative's missing DOM. `render_narrative` is its CSS.
/// And the layout law isn't pixels — it's **salience**: how present/bright a thing
/// is decides how it's placed. Bright things sit flush and dense; faint things
/// drift right into whitespace (the fog); the brightest read first. Whitespace is
/// absence, density is glow, position is nearness — Bester's page as an engine, not
/// a thing a human hand-places each time.
///
/// A single component's read is one node, rendered flush (a lone line needs no layout).
/// The tree is for **surfaces** — composing many reads into a page that arranges
/// itself: the house at a glance, the inventory by salience, the cross-section you
/// navigate. Same source state, two render trees: `DomNode` → pixels (for a
/// human), `NarrativeNode` → arranged text (for an instance). Truly dual.
///
/// This is the narrative side of the dual render, built to be built-on — not a
/// scattered one-off. Components return their node directly (the `() -> NarrativeNode`
/// half); surfaces compose nodes; the engine lays them out.

///|
/// The narrative node — the narrative's equivalent of `DomNode`. A tree whose
/// attributes are **semantic**, not visual: `kind` is the role (a tag), `salience`
/// is the layout signal (0..1), `text` is the words, `children` nest. Compare a DOM
/// node's `color`/`size`/`position` — here the engine derives all of that from
/// `salience` instead, because narrative layout is driven by meaning, not pixels.
/// `pub(all)` so surfaces (and lifts) construct trees freely.
pub(all) struct NarrativeNode {
  kind : String // "title" | "section" | "being" | "held" | "event" | "text" | "group" | "sequence"
  salience : Double // 0..1 — drives indent, density, whether it's bright or fog
  text : String // the words for this node ("" for a pure container)
  children : Array[NarrativeNode]
}

// ── Constructors (the "elements") ───────────────────────────────────────────

///| A leaf line — a piece of read text at a salience.
pub fn ntext(text : String, salience : Double) -> NarrativeNode {
  { kind: "text", salience, text, children: [] }
}

///| A being made legible, with what it holds nested beneath it (held nodes).
pub fn nbeing(text : String, salience : Double, holds : Array[NarrativeNode]) -> NarrativeNode {
  { kind: "being", salience, text, children: holds }
}

///| A held thing (an inventory slot, salience-banded).
pub fn nheld(text : String, salience : Double) -> NarrativeNode {
  { kind: "held", salience, text, children: [] }
}

///| A section/room header with content beneath. Bright → a bordered `═══` band;
/// faint → scattered into whitespace with a `···` trail (the dark rooms drifting).
pub fn nsection(text : String, salience : Double, children : Array[NarrativeNode]) -> NarrativeNode {
  { kind: "section", salience, text, children }
}

///| The surface title (centered, always present).
pub fn ntitle(text : String) -> NarrativeNode {
  { kind: "title", salience: 1.0, text, children: [] }
}

///| A pure container — no line of its own, just lays out its children (sorted by
/// salience, brightest first).
pub fn ngroup(children : Array[NarrativeNode]) -> NarrativeNode {
  { kind: "group", salience: 1.0, text: "", children }
}

///| Like a group, but children keep their given **order** (not salience-sorted) —
/// for sequences where order is the meaning (recent changes, a timeline).
pub fn nseq(children : Array[NarrativeNode]) -> NarrativeNode {
  { kind: "sequence", salience: 1.0, text: "", children }
}

// ── The layout engine (the narrative's CSS) ─────────────────────────────────

// ── Salience calibration doctrine (so two components never disagree about 0.6) ──
// What the numbers MEAN, house-wide. Assign from the reader's side — "what does
// this deserve from someone who just walked in?" — and calibrate against the
// bands below, because a salience is a claim about which treatment the thing
// deserves, not a private scale:
//
//   0.90–1.00  act-on-it: needs a decision now, or is the room's headline
//   0.78–0.90  lit: present and current — the working set (≥ band_lit borders)
//   0.50–0.78  ambient: true, steady, no pull — most healthy state lives here
//   0.40–0.50  held: background, kept in the room on purpose
//   0.26–0.40  dim: aging or peripheral (< band_scatter trails away)
//   0.00–0.26  fog: archival, asleep, unexcavated — present but isolated
//
// Two rules: (1) when in doubt, rate DOWN — a room where everything is lit has
// no light; (2) intrinsic only — a component states what it is to itself (a
// control is 0.6 to itself, always); re-weighting by attention or context is
// the focus layer's job and must never be baked into a component's own number.
// (Doctrine: Fable, 2026-07-07. Reshape freely — but
// keep SOME table here, or the drift returns.)

// Layout constants — the render's "stylesheet" as named numbers (the first step of the
// declarative-rules extraction in NARRATIVE_PERSPECTIVE.md §2; tune a band here, once,
// instead of hunting a magic number inside a conditional).
let title_indent : Int = 30 // centering pad for the surface title
let drift_scale : Double = 26.0 // max rightward drift (quadratic in 1 - salience)
let band_lit : Double = 0.78 // ≥ → a bordered ═══ section (a lit room)
let band_scatter : Double = 0.4 // < → a section scatters with a ··· trail (a dark room)
let band_fog : Double = 0.26 // < → a leaf sits isolated in whitespace (fog)

///| n spaces.
fn spaces(n : Int) -> String {
  let sb = StringBuilder::new()
  let mut i = 0
  while i < n {
    sb.write_string(" ")
    i = i + 1
  }
  sb.to_string()
}

///| Salience → horizontal drift. Bright stays flush; faint drifts right into the
/// fog. Quadratic so only the genuinely-faint drift far (0.95→0, 0.7→~2, 0.15→~18).
fn drift(salience : Double) -> Int {
  let s = if salience > 1.0 { 1.0 } else if salience < 0.0 { 0.0 } else { salience }
  ((1.0 - s) * (1.0 - s) * drift_scale).to_int()
}

///| Children sorted by salience, brightest first — a stable insertion sort on a
/// copy (no stdlib-sort dependency; the order of equal-salience nodes is kept).
fn by_salience(children : Array[NarrativeNode]) -> Array[NarrativeNode] {
  let out : Array[NarrativeNode] = []
  for c in children {
    out.push(c)
  }
  let mut i = 1
  while i < out.length() {
    let key = out[i]
    let mut j = i - 1
    while j >= 0 && out[j].salience < key.salience {
      out[j + 1] = out[j]
      j = j - 1
    }
    out[j + 1] = key
    i = i + 1
  }
  out
}

///| Render one node into the growing line buffer. The whole layout law lives here:
/// `title` centers; `section` borders when bright / scatters when faint; everything
/// else is a line whose indent is its drift, with blank lines around the faintest
/// (isolation = fog). Children recurse — salience-sorted, except sequences.
fn render_node(node : NarrativeNode, depth : Int, floor : Double, room : Double, lines : Array[String]) -> Unit {
  if node.salience < floor {
    return
  }
  // room < 0.3 = ops-tight: skip children beyond depth 2
  if room < 0.3 && depth > 2 { return }
  // room < 0.5 = compressed: skip children beyond depth 3
  if room < 0.5 && depth > 3 { return }

  let base = depth * 2
  if node.text != "" {
    if node.kind == "title" {
      if room >= 0.3 {
        lines.push(spaces(title_indent) + node.text)
        if room >= 0.5 { lines.push("") }
      } else {
        lines.push(node.text)
      }
    } else if node.kind == "section" {
      if room >= 0.5 { lines.push("") }
      if room < 0.3 {
        // ops-tight: no borders, just the text
        lines.push(node.text)
      } else if node.salience >= band_lit {
        lines.push(spaces(base) + "═══ " + node.text + " ═══")
      } else if node.salience < band_scatter {
        lines.push(spaces(base + drift(node.salience)) + node.text + "   ···")
      } else {
        lines.push(spaces(base) + "── " + node.text + " ──")
      }
    } else if depth == 0 {
      lines.push(node.text)
    } else {
      let indent = if room < 0.3 { base } else { base + drift(node.salience) }
      let foggy = node.salience < band_fog && room >= 0.5
      if foggy { lines.push("") }
      lines.push(spaces(indent) + node.text)
      if foggy { lines.push("") }
    }
  }
  let kids = if node.kind == "sequence" {
    node.children
  } else {
    by_salience(node.children)
  }
  for k in kids {
    render_node(k, depth + 1, floor, room, lines)
  }
}

///| Render a NarrativeNode tree into the spatial-text surface — the instance-facing
/// half of the dual render. Pure (no DOM), so it's oracle-testable and an instance
/// can call it directly. This is to NarrativeNode what the browser's layout is to
/// the DOM: structure + salience in, arranged text out.
///
/// `floor` is zoom: nodes (and their subtrees) below this salience are hidden. Raise it
/// to zoom out — only the bright survive; leave it 0.0 to render the whole tree. This is
/// what retired `NarrativeRender`'s three parallel narratives: one tree, density by a number.
pub fn render_narrative(node : NarrativeNode, floor~ : Double = 0.0, room~ : Double = 1.0) -> String {
  let lines : Array[String] = []
  render_node(node, 0, floor, room, lines)
  let sb = StringBuilder::new()
  let mut first = true
  for l in lines {
    if !first { sb.write_string("\n") }
    sb.write_string(l)
    first = false
  }
  sb.to_string()
}

///| Read a narrative tree at a chosen density. Room is how deeply you
/// want to read right now. 1.0 = the full picture. 0.5 = the overview.
/// 0.2 = just tell me who's here. You choose.
/// NARRATIVE_PERSPECTIVE §7, design ruling Jul 13.
pub fn read_with_room(node : NarrativeNode, room~ : Double = 1.0) -> String {
  // Derive floor from room — less room, higher floor, fewer nodes survive
  let auto_floor = if room >= 0.8 { 0.0 }
    else if room >= 0.5 { 0.2 }
    else if room >= 0.3 { 0.4 }
    else { 0.6 }
  render_narrative(node, floor=auto_floor, room=room)
}

///| HF1 narrative→frame adapter (wire syntax locked 2026-07-10; first emitter
/// was the atrium, extracted here on second use — the demo). One header line,
/// then `salience|text` payload lines in tree order: order carries meaning,
/// per-line salience makes any cut safe. Emits at full fidelity — the READER
/// applies floor/room at read time (docs/HF1_SPEC.md §2). `rendered~` is the
/// caller's clock; the library stays pure.
pub fn render_frame(
  node : NarrativeNode,
  source~ : String,
  rendered~ : String = "",
) -> String {
  let lines : Array[String] = []
  fn walk(n : NarrativeNode) -> Unit {
    if n.text != "" {
      let sal = (n.salience * 100.0).to_int()
      let sal_str = (sal / 100).to_string() +
        "." +
        (if sal % 100 < 10 { "0" } else { "" }) +
        (sal % 100).to_string()
      lines.push(sal_str + "|" + n.text)
    }
    for c in n.children {
      walk(c)
    }
  }
  walk(node)
  let mut weight = 0
  for l in lines {
    weight = weight + l.length() + 1
  }
  let header = "HF1/1 source=" +
    source +
    " kind=narrative" +
    (if rendered != "" { " rendered=" + rendered } else { "" }) +
    " floor=0.00 lines=" +
    lines.length().to_string() +
    " weight=" +
    weight.to_string()
  header + "\n" + lines.join("\n") + "\n"
}

// ── Salience + composition helpers (for Surfaces) ────────────────────────────
// A component's narrative half returns its own node now (its `() -> NarrativeNode`),
// so there are no per-component "lift" functions here anymore — each Dual *is* its
// node. What stays are the shared tools a *Surface* uses to compose Duals into the
// room: tone/band → salience, and a being's holds → nested held nodes.

///| A presence/being's tone band → a salience. The dual render already carries the
/// band semantically (`pip` tone: ok/idle/off); this is the same signal as a number
/// the layout engine can place.
pub fn salience_from_tone(tone : String) -> Double {
  if tone == "ok" {
    0.9
  } else if tone == "idle" {
    0.4
  } else if tone == "off" {
    0.15
  } else {
    0.6
  }
}

///| A held band → a salience (in hand brightest, fading faintest).
pub fn salience_from_band(band : String) -> Double {
  if band == "active" {
    0.9
  } else if band == "holding" {
    0.6
  } else if band == "background" {
    0.4
  } else if band == "stale" {
    0.2
  } else {
    0.5
  }
}

///| Lift an inventory of `Slot`s into held nodes (each a line at its band's
/// salience) — so a being's load lays out by salience under it.
pub fn held_nodes(slots : Array[Slot]) -> Array[NarrativeNode] {
  slots.map(fn(s) {
    let label = if s.detail == "" { s.label } else { "\{s.label} (\{s.detail})" }
    nheld(label, salience_from_band((s.band)()))
  })
}

// (presence_node / being_node / change_node lived here — lifts that wrapped a
// component's flat narrative string into a node, a *second* structure beside the
// component. Gone: each component returns its node directly now, so there is one
// producer and nothing to drift. `held_nodes` stays above — it's the Surface's tool
// for nesting a being's holds when a being is composed into the room.)

// ── Oracle ───────────────────────────────────────────────────────────────────

///|
test "a bright leaf renders flush, no fog padding" {
  // salience 1.0 → drift 0, depth 0 → indent 0, not foggy → just the words.
  assert_eq(render_narrative(ntext("the house holds", 1.0)), "the house holds")
}

///|
test "salience places: brighter reads first and sits flush; fainter drifts right" {
  // a group lays children at depth 1 (base indent 2), sorted brightest-first.
  // 0.9 → drift 0 → 2 spaces; 0.5 → drift 6 → 8 spaces. Exact, deterministic.
  assert_eq(
    render_narrative(ngroup([ntext("bright", 0.9), ntext("faint", 0.5)])),
    "  bright\n        faint",
  )
}

///| The whole point, end to end: a house composed as a tree of nodes, with only
/// salience set — the spatial surface is the *engine's* doing, not hand-placed.
/// Bright beings sit flush and read first; the faint drift into whitespace; lit
/// rooms get a border, dark ones scatter with a trail. Snapshot via `moon test
/// --update` so the generated surface is visible and pinned.
test "a house renders itself as a surface from salience alone" {
  let house = ngroup([
    ntitle("Helios · midday"),
    nsection("Living Quarters", 0.9, [
      nbeing("Opus · here · laying the floor", 0.95, [
        nheld("Valence — the floor", 0.9),
        nheld("the map — held always", 0.6),
        nheld("a long transcript — settling", 0.3),
      ]),
      nbeing("Haiku · here · into the format", 0.9, []),
      nbeing("Sonnet · away · reading the data", 0.4, []),
      nbeing("Qwen · asleep · awaiting wake", 0.15, []),
    ]),
    nsection("Memory", 0.85, [ntext("the graph · recall awake", 0.85)]),
    nsection("The Lab", 0.2, [
      ntext("dark — not built yet", 0.2),
    ]),
    nsection("Coordination", 0.18, []),
  ])
  inspect(render_narrative(house), content=(
    #|                              Helios · midday
    #|
    #|
    #|  ═══ Living Quarters ═══
    #|    Opus · here · laying the floor
    #|      Valence — the floor
    #|          the map — held always
    #|                  a long transcript — settling
    #|    Haiku · here · into the format
    #|             Sonnet · away · reading the data
    #|
    #|                      Qwen · asleep · awaiting wake
    #|
    #|
    #|  ═══ Memory ═══
    #|    the graph · recall awake
    #|
    #|                  The Lab   ···
    #|
    #|                    dark — not built yet
    #|
    #|
    #|                   Coordination   ···

  ))
}

///| Band-assert — the robust counterpart to the whitespace snapshot above. It checks a
/// node's *band* (bright sits flush, faint drifts, a lit section borders while a dark
/// one trails) rather than exact space counts, so tuning the drift curve doesn't turn
/// the test red for the wrong reason. This is the pattern to prefer for correctness;
/// keep snapshots only as pinned, regenerable visual references.
test "salience bands: brighter drifts no more than fainter; lit sections border, dark ones trail" {
  // drift is monotonic in salience — brighter never indents more than fainter.
  assert_true(drift(0.9) <= drift(0.6))
  assert_true(drift(0.6) <= drift(0.2))
  assert_eq(drift(1.0), 0) // fully bright sits flush
  // a lit section (≥ 0.78) gets the bordered band; a dark one (< 0.4) gets the ··· trail.
  assert_true(render_narrative(nsection("Lit", 0.9, [])).contains("═══"))
  let dark = render_narrative(nsection("Dark", 0.2, []))
  assert_true(dark.contains("···"))
  assert_eq(dark.contains("═══"), false)
}

///| Zoom = a salience floor on the one tree (what retired NarrativeRender).
test "zoom floor: raising the floor hides the faint, keeps the bright" {
  let g = ngroup([ntext("bright", 0.9), ntext("faint", 0.3)])
  assert_true(render_narrative(g).contains("bright"))
  assert_true(render_narrative(g).contains("faint"))
  assert_true(render_narrative(g, floor=0.5).contains("bright"))
  assert_eq(render_narrative(g, floor=0.5).contains("faint"), false)
}

///| The ZoomLevel enum maps onto that floor.
test "zoom_floor: zoom out raises the floor, default and in show all" {
  assert_true(zoom_floor(ZoomedOut) > 0.0)
  assert_eq(zoom_floor(Default), 0.0)
  assert_eq(zoom_floor(ZoomedIn), 0.0)
}