//! The Cairn editor's HTTP transport (v0.2, plan P1).
//!
//! A front-end *peer* to the CLI and MCP server (design.md §7): it
//! holds no model logic. `GET /` serves the zero-build projectional
//! editor page; `POST /rpc` forwards a JSON-RPC body straight into the
//! one `cairn_mcp_server::Session` dispatch the stdio server already
//! uses — same protocol, different transport (Principle 9). The
//! accept loop is single-threaded, mirroring `serve_http_with` /
//! `serve_http_live_on`: an authoring session is inherently serial.
//!
//! P1 is the transport; the page here is the minimal shell P2 fills in
//! with the real `render_addressed`-driven UI.

use cairn_mcp_server::Session;
use serde_json::Value;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};

/// The editor page (P2): the zero-build projectional UI. One
/// self-contained document, vanilla JS, no framework/bundler — the
/// decided stack (AD3), the same ethos as `live_client_script`. It
/// `render_addressed`s a root and rebuilds the projection as nested
/// `data-h` spans (byte ranges → DOM via TextEncoder, so non-ASCII
/// names are exact); clicking a span selects the deepest node and
/// shows its hash/range/slice. Editing lands in P3.
pub fn editor_page() -> &'static str {
    r##"<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Cairn editor</title>
<style>
 body{font:14px system-ui,sans-serif;margin:0;display:flex;flex-direction:column;height:100vh}
 header{padding:8px 12px;border-bottom:1px solid #ccc;display:flex;gap:8px;align-items:center}
 header input{font:13px monospace;flex:1;padding:4px}
 main{flex:1;display:flex;min-height:0}
 #proj{flex:1;margin:0;padding:12px;overflow:auto;white-space:pre;
   font:13px/1.5 ui-monospace,Menlo,monospace}
 #panel{width:320px;border-left:1px solid #ccc;padding:12px;overflow:auto;
   font-size:12px}
 .n{border-radius:2px}
 .n:hover{background:#eef}
 .sel{background:#ffe9a8;outline:1px solid #d8a400}
 h1{font-size:14px;margin:0 8px 0 0}
 code{word-break:break-all}
 .err{color:#b00}
</style></head>
<body>
<header>
 <h1>Cairn editor</h1>
 <input id="root" placeholder="root node hash" spellcheck="false">
 <button id="render">Render</button>
 <button id="sample">Load sample</button>
 <button id="new">New function</button>
 <input id="snap" placeholder="snapshot name" spellcheck="false"
   size="14">
 <button id="ckpt">Checkpoint</button>
 <button id="branch">Branch</button>
 <button id="resolve">Resolve</button>
 <span id="snapmsg"></span>
</header>
<main>
 <pre id="proj">Load a sample, or paste a root hash and Render.</pre>
 <div id="panel">No selection.</div>
</main>
<script>
const $=s=>document.querySelector(s);
let seq=0;
async function rpc(name,args){
  seq++;
  const r=await fetch("/rpc",{method:"POST",
    headers:{"Content-Type":"application/json"},
    body:JSON.stringify({jsonrpc:"2.0",id:seq,method:"tools/call",
      params:{name:name,arguments:args}})});
  const v=await r.json();
  const c=v.result&&v.result.content&&v.result.content[0];
  const t=c&&c.text;
  if(v.result&&v.result.isError) throw new Error(t||"tool error");
  return t?JSON.parse(t):v.result;
}
function esc(s){return s.replace(/&/g,"&amp;").replace(/</g,"&lt;")
  .replace(/>/g,"&gt;");}
// Flat (hash,start,end) byte spans (they strictly nest) → a forest.
function tree(spans){
  const ss=spans.slice().sort((a,b)=>a.start-b.start||b.end-a.end);
  const roots=[],stack=[];
  for(const s of ss){
    const node={hash:s.hash,start:s.start,end:s.end,kids:[]};
    while(stack.length&&stack[stack.length-1].end<=s.start)stack.pop();
    (stack.length?stack[stack.length-1].kids:roots).push(node);
    stack.push(node);
  }
  return roots;
}
function emit(bytes,dec,lo,hi,kids){
  let h="",p=lo;
  for(const k of kids){
    if(k.start>p)h+=esc(dec.decode(bytes.slice(p,k.start)));
    h+='<span class="n" data-h="'+k.hash+'" data-s="'+k.start+
       '" data-e="'+k.end+'">'+emit(bytes,dec,k.start,k.end,k.kids)+
       "</span>";
    p=k.end;
  }
  if(hi>p)h+=esc(dec.decode(bytes.slice(p,hi)));
  return h;
}
let CUR=null;
async function render(root){
  try{
    const a=await rpc("render_addressed",{root:root});
    const bytes=new TextEncoder().encode(a.text);
    const dec=new TextDecoder();
    $("#proj").innerHTML=emit(bytes,dec,0,bytes.length,tree(a.spans));
    CUR=root;
    $("#panel").textContent="Rendered. Click a node.";
  }catch(e){ $("#proj").innerHTML='<span class="err">'+esc(""+e)+
    "</span>"; }
}
// Apply an edit: rpc, require report Complete if present, re-render
// the (possibly new) root, surface errors in #emsg.
async function apply(name,args){
  try{
    const r=await rpc(name,args);
    if(r.report&&r.report.status&&r.report.status!=="Complete")
      throw new Error("rejected: "+JSON.stringify(r.report));
    await render(r.root||CUR);
  }catch(e){ const m=$("#emsg");
    if(m)m.innerHTML='<span class="err">'+esc(""+e)+"</span>"; }
}
$("#proj").addEventListener("click",e=>{
  const el=e.target.closest(".n"); if(!el)return;
  document.querySelectorAll(".sel").forEach(x=>x.classList.remove("sel"));
  el.classList.add("sel");
  const txt=el.textContent, H=el.dataset.h, tr=txt.trim();
  // Classify by the renderer's unambiguous leaf forms (no new
  // protocol needed): the §5 projection prints these distinctly.
  const isInt=/^-?\d+$/.test(tr);
  const isBool=tr==="true"||tr==="false";
  const isStr=tr.length>1&&tr[0]==='"'&&tr[tr.length-1]==='"';
  const mHole=tr.match(/^<hole: (.*)>$/);
  let h="<b>node</b><br><code>"+esc(H)+"</code><br><br><b>bytes</b> "+
    el.dataset.s+".."+el.dataset.e+"<br><br><b>renders as</b><br><code>"+
    esc(txt)+"</code><br>";
  if(isInt)
    h+='<hr><b>Number literal</b><br><input id="ev" value="'+esc(tr)+
      '" size="12"> <button id="set">Set</button>';
  else if(isBool)
    h+='<hr><b>Bool literal</b><br><select id="ev">'+
      '<option'+(tr==="true"?" selected":"")+'>true</option>'+
      '<option'+(tr==="false"?" selected":"")+'>false</option>'+
      '</select> <button id="set">Set</button>';
  else if(isStr){
    let v=tr; try{v=JSON.parse(tr);}catch(_){}
    h+='<hr><b>String literal</b><br><input id="ev" size="20"> '+
      '<button id="set">Set</button>';
    setTimeout(()=>{if($("#ev"))$("#ev").value=v;},0);
  } else if(mHole)
    h+='<hr><b>fill hole</b> (expects '+esc(mHole[1])+')<br>'+
      '<select id="ek"><option>Number</option><option>String</option>'+
      '<option>Bool</option></select> '+
      '<input id="ev" size="14"> <button id="fill">Fill</button>';
  // Always available: structural replace by hash, and references.
  h+='<hr><b>replace with node</b><br><input id="rh" size="16" '+
     'placeholder="replacement hash"> <button id="rep">Replace</button>'+
     '<hr><button id="refs">Find references</button>'+
     '<div id="refl"></div><div id="emsg"></div>';
  $("#panel").innerHTML=h;
  const lit=()=>{
    const k=$("#ek")?$("#ek").value:(isInt?"Number":isBool?"Bool":"String");
    const raw=$("#ev").value;
    if(k==="Number"){const n=parseInt(raw,10);
      if(!Number.isInteger(n))throw new Error("not an integer");
      return {Lit:n};}
    if(k==="Bool")return {Bool:raw==="true"};
    return {Str:raw};
  };
  if($("#set"))$("#set").onclick=async()=>{ try{
    const made=await rpc("put_expr",{expr:lit()});
    await apply("replace_node",
      {root:CUR,target:H,replacement:made.hash});
  }catch(e){$("#emsg").innerHTML='<span class="err">'+esc(""+e)+
    "</span>";}};
  if($("#fill"))$("#fill").onclick=async()=>{ try{
    const made=await rpc("put_expr",{expr:lit()});
    await apply("fill_hole",
      {root:CUR,hole:H,replacement:made.hash});
  }catch(e){$("#emsg").innerHTML='<span class="err">'+esc(""+e)+
    "</span>";}};
  $("#rep").onclick=()=>{const rh=$("#rh").value.trim();
    if(rh)apply("replace_node",{root:CUR,target:H,replacement:rh});};
  $("#refs").onclick=async()=>{ try{
    const r=await rpc("find_references",{root:CUR,target:H});
    const xs=r.references||[];
    $("#refl").innerHTML="<br>"+xs.length+" reference(s)"+
      (xs.length?"<br><code>"+xs.map(esc).join("<br>")+"</code>":"");
  }catch(e){$("#emsg").innerHTML='<span class="err">'+esc(""+e)+
    "</span>";}};
});
$("#render").onclick=()=>{const r=$("#root").value.trim();if(r)render(r);};
$("#sample").onclick=async()=>{
  try{
    const m=await rpc("apply_module",{module:{name:"sample",types:[],
      functions:[{name:"double",type_params:[],
        params:[{name:"n",ty:"Number",min_confidence:"External"}],
        produces:{ty:"Number",confidence:"External"},
        requires:[],on_failure:[],steps:[],
        result:{BinOp:{op:"Mul",lhs:{Ref:"n"},rhs:{Lit:2}}}}]}});
    $("#root").value=m.hash; render(m.hash);
  }catch(e){ $("#panel").innerHTML='<span class="err">'+esc(""+e)+
    "</span>"; }
};
// The v0.1 version-control surface, in the editor: checkpoint (name
// an immutable root), branch (a movable label), resolve (name → root,
// then render). Operates on the current root / the name field.
function snap(msg){ $("#snapmsg").textContent=" "+msg; }
$("#ckpt").onclick=async()=>{
  const n=$("#snap").value.trim();
  if(!CUR||!n){ snap("need a rendered root and a name"); return; }
  try{ await rpc("checkpoint",{root:CUR,name:n});
    snap("checkpointed “"+n+"”"); }
  catch(e){ snap("error: "+e); }
};
$("#branch").onclick=async()=>{
  const n=$("#snap").value.trim();
  if(!CUR||!n){ snap("need a rendered root and a name"); return; }
  try{ await rpc("branch",{root:CUR,name:n});
    snap("branched “"+n+"”"); }
  catch(e){ snap("error: "+e); }
};
$("#resolve").onclick=async()=>{
  const n=$("#snap").value.trim(); if(!n) return;
  try{
    const r=await rpc("resolve",{name:n});
    if(r&&r.hash){ $("#root").value=r.hash; snap("resolved “"+n+"”");
      await render(r.hash); }
    else snap("no snapshot “"+n+"”");
  }catch(e){ snap("error: "+e); }
};
// Author a new function via the draft tools (begin_edit → … →
// commit_edit), the same surface an agent uses. v1 is intentionally
// minimal — name, produced type, and a result ExprSpec (params/steps
// are a later depth slice, recorded — not missing).
$("#new").onclick=()=>{
  $("#panel").innerHTML=
    '<b>new function</b><br>'+
    'name <input id="nfn" value="answer" size="14"><br><br>'+
    'produces <select id="nft"><option>Number</option>'+
    '<option>String</option><option>Bool</option></select><br><br>'+
    'result (ExprSpec JSON)<br>'+
    '<textarea id="nfr" rows="3" cols="34">{"Lit":42}</textarea><br>'+
    '<button id="nfc">Create</button><div id="emsg"></div>';
  $("#nfc").onclick=async()=>{
    try{
      const spec=JSON.parse($("#nfr").value);
      await rpc("begin_edit",{});
      await rpc("create_function",{name:$("#nfn").value});
      await rpc("set_produces",
        {type:$("#nft").value,confidence:"External"});
      await rpc("set_effects",{effects:[]});
      await rpc("set_yield",{value:spec});
      const c=await rpc("commit_edit",{});
      const st=c.report&&c.report.status;
      if(st!=="Complete")
        throw new Error("not complete: "+JSON.stringify(c.report));
      $("#root").value=c.hash; await render(c.hash);
    }catch(e){ $("#emsg").innerHTML='<span class="err">'+
      esc(""+e)+"</span>"; }
  };
};
</script>
</body>
</html>
"##
}

/// Bind `addr` and serve until a listener error. `store`: a persistent
/// `.cairn` path (like the MCP server's `CAIRN_STORE`) or `None` for an
/// ephemeral in-memory session.
pub fn serve(addr: &str, store: Option<&str>) -> std::io::Result<()> {
    let session = match store {
        Some(p) => Session::open(p).map_err(std::io::Error::other)?,
        None => Session::new(),
    };
    let listener = TcpListener::bind(addr)?;
    serve_on(listener, session)
}

/// The accept loop over a pre-bound listener (so it is loopback-
/// testable without a process, like the live server). One request per
/// connection; returns only on a listener error.
pub fn serve_on(
    listener: TcpListener,
    mut session: Session,
) -> std::io::Result<()> {
    for stream in listener.incoming() {
        let Ok(mut stream) = stream else { continue };
        if let Some((method, path, body)) = read_request(&stream) {
            let (status, ctype, out) =
                route(&mut session, &method, &path, &body);
            let resp = format!(
                "HTTP/1.1 {status}\r\nContent-Length: {}\r\n\
                 Content-Type: {ctype}\r\n\
                 Access-Control-Allow-Origin: *\r\n\r\n{out}",
                out.len(),
            );
            let _ = stream.write_all(resp.as_bytes());
        }
    }
    Ok(())
}

/// Parse a request to `(method, path, body)`. `None` on a malformed
/// head (the connection is then dropped, like the other servers).
fn read_request(stream: &TcpStream) -> Option<(String, String, String)> {
    let mut reader = BufReader::new(stream);
    let mut line = String::new();
    reader.read_line(&mut line).ok()?;
    let mut parts = line.split_whitespace();
    let method = parts.next().unwrap_or("GET").to_string();
    let path = parts.next().unwrap_or("/").to_string();
    let mut content_length = 0usize;
    loop {
        let mut h = String::new();
        if reader.read_line(&mut h).is_err() {
            break;
        }
        let h = h.trim_end();
        if h.is_empty() {
            break;
        }
        if let Some((name, value)) = h.split_once(':') {
            if name.eq_ignore_ascii_case("content-length") {
                content_length = value.trim().parse().unwrap_or(0);
            }
        }
    }
    let mut body = String::new();
    if content_length > 0 {
        let mut buf = vec![0u8; content_length];
        if reader.read_exact(&mut buf).is_ok() {
            body = String::from_utf8_lossy(&buf).into_owned();
        }
    }
    Some((method, path, body))
}

/// `GET /` → the page; `POST /rpc` → the MCP dispatch; else 404.
/// Returns `(status line, content-type, body)`.
fn route(
    session: &mut Session,
    method: &str,
    path: &str,
    body: &str,
) -> (&'static str, &'static str, String) {
    let route_path = path.split('?').next().unwrap_or(path);
    match (method, route_path) {
        ("GET", "/") => (
            "200 OK",
            "text/html; charset=utf-8",
            editor_page().to_string(),
        ),
        ("POST", "/rpc") => match serde_json::from_str::<Value>(body) {
            Ok(req) => (
                "200 OK",
                "application/json",
                session.handle(req).to_string(),
            ),
            Err(e) => (
                "400 Bad Request",
                "application/json",
                serde_json::json!({
                    "jsonrpc": "2.0",
                    "error": { "code": -32700, "message":
                        format!("parse error: {e}") }
                })
                .to_string(),
            ),
        },
        _ => ("404 Not Found", "text/plain", "not found".to_string()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use std::time::Duration;

    fn req(port: u16, raw: &str) -> String {
        let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
        s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
        s.write_all(raw.as_bytes()).unwrap();
        let mut buf = String::new();
        let _ = s.read_to_string(&mut buf);
        buf
    }

    /// The transport works headless: `GET /` is the page, and a
    /// JSON-RPC `tools/call` over `POST /rpc` reaches the real Session
    /// dispatch (apply_module → render_addressed round-trips).
    #[test]
    fn the_editor_transport_serves_the_page_and_proxies_rpc() {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let port = listener.local_addr().unwrap().port();
        std::thread::spawn(move || {
            let _ = serve_on(listener, Session::new());
        });
        // give the loop a moment to start accepting
        std::thread::sleep(Duration::from_millis(50));

        // GET / → the editor page.
        let page = req(port, "GET / HTTP/1.1\r\nHost: x\r\n\r\n");
        assert!(page.contains("200 OK"));
        assert!(page.contains("text/html"));
        assert!(page.contains("<h1>Cairn editor</h1>"));
        // P2: the projectional UI is served (DOM behavior itself is the
        // recorded browser-infra gap; the bytes are verified here).
        assert!(page.contains("fetch(\"/rpc\""), "rpc client");
        assert!(page.contains("render_addressed"), "renders the projection");
        assert!(page.contains("data-h=\""), "nested hash spans");
        assert!(page.contains("TextEncoder"), "byte-exact span slicing");
        assert!(page.contains("apply_module"), "the Load-sample path");
        // E-a: the full edit vocabulary is wired into the page.
        assert!(page.contains("put_expr"), "literal construction");
        assert!(page.contains("fill_hole"), "hole filling");
        assert!(page.contains("find_references"), "references");
        assert!(page.contains("replace_node"), "structural replace");
        assert!(page.contains("begin_edit"), "new-function authoring");
        assert!(page.contains("commit_edit"), "draft commit");
        assert!(page.contains("checkpoint"), "snapshot");
        assert!(page.contains("resolve"), "snapshot restore");

        // POST /rpc apply_module, then render_addressed at that root —
        // the same protocol the UI will issue.
        let rpc = |id: i64, name: &str, args: Value| -> Value {
            let body = json!({
                "jsonrpc": "2.0", "id": id, "method": "tools/call",
                "params": { "name": name, "arguments": args }
            })
            .to_string();
            let raw = format!(
                "POST /rpc HTTP/1.1\r\nHost: x\r\n\
                 Content-Length: {}\r\n\r\n{body}",
                body.len()
            );
            let resp = req(port, &raw);
            let json_body = resp.split("\r\n\r\n").nth(1).unwrap();
            let v: Value = serde_json::from_str(json_body).unwrap();
            let text = v["result"]["content"][0]["text"].as_str().unwrap();
            serde_json::from_str(text).unwrap()
        };

        let module = json!({ "module": {
            "name": "m", "types": [],
            "functions": [{
                "name": "k", "type_params": [], "params": [],
                "produces": { "ty": "Number", "confidence": "External" },
                "requires": [], "on_failure": [], "steps": [],
                "result": { "Lit": 7 }
            }]
        }});
        let applied = rpc(1, "apply_module", module);
        let root = applied["hash"].as_str().unwrap().to_string();

        let a = rpc(2, "render_addressed", json!({ "root": root }));
        let text = a["text"].as_str().unwrap();
        assert!(text.contains("yield 7"), "projection over HTTP: {text}");
        assert!(!text.contains('\u{1}'), "no sentinel leak");
        assert!(!a["spans"].as_array().unwrap().is_empty());

        // Unknown route → 404.
        let nf = req(port, "GET /nope HTTP/1.1\r\nHost: x\r\n\r\n");
        assert!(nf.contains("404 Not Found"));
    }

    /// One `tools/call` over POST /rpc → the unwrapped tool payload.
    fn rpc(port: u16, id: i64, name: &str, args: Value) -> Value {
        let body = json!({
            "jsonrpc": "2.0", "id": id, "method": "tools/call",
            "params": { "name": name, "arguments": args }
        })
        .to_string();
        let raw = format!(
            "POST /rpc HTTP/1.1\r\nHost: x\r\n\
             Content-Length: {}\r\n\r\n{body}",
            body.len()
        );
        let resp = req(port, &raw);
        let json_body = resp.split("\r\n\r\n").nth(1).unwrap();
        let v: Value = serde_json::from_str(json_body).unwrap();
        let text = v["result"]["content"][0]["text"].as_str().unwrap();
        serde_json::from_str(text).unwrap()
    }

    /// P3: the full edit loop over the editor's HTTP transport — the
    /// exact sequence the UI's "Set" issues. apply_module a sample
    /// `double(n) = n * 2`, address the `2`, mint `5` via put_expr,
    /// replace_node, re-render → `n * 5`. The click is the only
    /// browser-only step (recorded infra gap); the protocol path is
    /// proven here end to end.
    #[test]
    fn the_editor_edits_a_literal_end_to_end_over_http() {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let port = listener.local_addr().unwrap().port();
        std::thread::spawn(move || {
            let _ = serve_on(listener, Session::new());
        });
        std::thread::sleep(Duration::from_millis(50));

        let applied = rpc(
            port,
            1,
            "apply_module",
            json!({ "module": {
                "name": "s", "types": [],
                "functions": [{
                    "name": "double", "type_params": [],
                    "params": [{ "name": "n", "ty": "Number",
                        "min_confidence": "External" }],
                    "produces": { "ty": "Number", "confidence": "External" },
                    "requires": [], "on_failure": [], "steps": [],
                    "result": { "BinOp": { "op": "Mul",
                        "lhs": { "Ref": "n" }, "rhs": { "Lit": 2 } } }
                }]
            }}),
        );
        let root = applied["hash"].as_str().unwrap().to_string();

        let a = rpc(port, 2, "render_addressed", json!({ "root": root }));
        let text = a["text"].as_str().unwrap().to_string();
        assert!(text.contains("n * 2"), "sample renders: {text}");

        // Cursor on the `2` literal → its hash (what a click resolves).
        let off = text.find("* 2").unwrap() + 2;
        let at = rpc(port, 3, "node_at",
            json!({ "root": root, "offset": off }));
        let lit2 = at["hash"].as_str().expect("hash at the literal");

        // Mint a fresh `5` and structurally replace.
        let made = rpc(port, 4, "put_expr", json!({ "expr": { "Lit": 5 } }));
        let new_lit = made["hash"].as_str().unwrap();
        let rep = rpc(port, 5, "replace_node", json!({
            "root": root, "target": lit2, "replacement": new_lit
        }));
        assert_eq!(rep["report"]["status"], "Complete");
        let root2 = rep["root"].as_str().unwrap().to_string();

        let a2 = rpc(port, 6, "render_addressed", json!({ "root": root2 }));
        let text2 = a2["text"].as_str().unwrap();
        assert!(
            text2.contains("n * 5") && !text2.contains("n * 2"),
            "the edit took, end to end over HTTP: {text2}"
        );
    }

    /// E-a: the full edit vocabulary the UI exposes, each over /rpc
    /// (the click is the only browser-only step): String and Bool
    /// literal edits, fill_hole, and find_references.
    #[test]
    fn the_editor_edit_vocabulary_over_http() {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let port = listener.local_addr().unwrap().port();
        std::thread::spawn(move || {
            let _ = serve_on(listener, Session::new());
        });
        std::thread::sleep(Duration::from_millis(50));

        let func = |name: &str, ty: &str, result: Value| {
            json!({ "module": { "name": "m", "types": [], "functions": [{
                "name": name, "type_params": [], "params": [],
                "produces": { "ty": ty, "confidence": "External" },
                "requires": [], "on_failure": [], "steps": [],
                "result": result
            }]}})
        };
        let root = |v: &Value| v["hash"].as_str().unwrap().to_string();
        let render = |port, id, r: &str| -> String {
            rpc(port, id, "render_addressed", json!({ "root": r }))["text"]
                .as_str()
                .unwrap()
                .to_string()
        };
        let at = |port, id, r: &str, off: usize| -> String {
            rpc(port, id, "node_at", json!({ "root": r, "offset": off }))
                ["hash"]
                .as_str()
                .unwrap()
                .to_string()
        };

        // String literal: "hi" → "bye".
        let m = rpc(port, 1, "apply_module",
            func("s", "String", json!({ "Str": "hi" })));
        let r = root(&m);
        let t = render(port, 2, &r);
        assert!(t.contains("\"hi\""), "string renders quoted: {t}");
        let sh = at(port, 3, &r, t.find("\"hi\"").unwrap());
        let made = rpc(port, 4, "put_expr",
            json!({ "expr": { "Str": "bye" } }));
        let rep = rpc(port, 5, "replace_node", json!({ "root": r,
            "target": sh, "replacement": made["hash"] }));
        assert_eq!(rep["report"]["status"], "Complete");
        let t2 = render(port, 6, rep["root"].as_str().unwrap());
        assert!(t2.contains("\"bye\"") && !t2.contains("\"hi\""), "{t2}");

        // Bool literal: true → false.
        let m = rpc(port, 7, "apply_module",
            func("b", "Bool", json!({ "Bool": true })));
        let r = root(&m);
        let t = render(port, 8, &r);
        let bh = at(port, 9, &r, t.find("true").unwrap());
        let made = rpc(port, 10, "put_expr",
            json!({ "expr": { "Bool": false } }));
        let rep = rpc(port, 11, "replace_node", json!({ "root": r,
            "target": bh, "replacement": made["hash"] }));
        assert_eq!(rep["report"]["status"], "Complete");
        let t2 = render(port, 12, rep["root"].as_str().unwrap());
        assert!(t2.contains("false") && !t2.contains("true"), "{t2}");

        // fill_hole: a hole-bodied function → fill with 7.
        let m = rpc(port, 13, "apply_module",
            func("h", "Number", json!({ "Hole": { "expects": "Number" } })));
        let r = root(&m);
        let t = render(port, 14, &r);
        assert!(t.contains("<hole:"), "hole renders: {t}");
        let hh = at(port, 15, &r, t.find("<hole:").unwrap());
        let made = rpc(port, 16, "put_expr", json!({ "expr": { "Lit": 7 } }));
        let rep = rpc(port, 17, "fill_hole", json!({ "root": r,
            "hole": hh, "replacement": made["hash"] }));
        assert_eq!(rep["report"]["status"], "Complete");
        let t2 = render(port, 18, rep["root"].as_str().unwrap());
        assert!(t2.contains("yield 7") && !t2.contains("<hole"), "{t2}");

        // find_references: `2 + 2` — the shared Lit 2 is referenced.
        let m = rpc(port, 19, "apply_module", func("t", "Number",
            json!({ "BinOp": { "op": "Add",
                "lhs": { "Lit": 2 }, "rhs": { "Lit": 2 } } })));
        let r = root(&m);
        let t = render(port, 20, &r);
        let two = at(port, 21, &r, t.find("2 + 2").unwrap());
        let refs = rpc(port, 22, "find_references",
            json!({ "root": r, "target": two }));
        assert!(
            !refs["references"].as_array().unwrap().is_empty(),
            "the shared literal is referenced: {refs}"
        );
    }

    /// E-b: authoring a new function from nothing, over /rpc — the
    /// draft surface (begin_edit … commit_edit) the UI's "New
    /// function" issues, then render the committed result.
    #[test]
    fn the_editor_authors_a_function_over_http() {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let port = listener.local_addr().unwrap().port();
        std::thread::spawn(move || {
            let _ = serve_on(listener, Session::new());
        });
        std::thread::sleep(Duration::from_millis(50));

        rpc(port, 1, "begin_edit", json!({}));
        rpc(port, 2, "create_function", json!({ "name": "answer" }));
        rpc(port, 3, "set_produces",
            json!({ "type": "Number", "confidence": "External" }));
        rpc(port, 4, "set_effects", json!({ "effects": [] }));
        rpc(port, 5, "set_yield", json!({ "value": { "Lit": 42 } }));
        let c = rpc(port, 6, "commit_edit", json!({}));
        assert_eq!(c["report"]["status"], "Complete",
            "authored function verifies clean: {c}");
        let hash = c["hash"].as_str().unwrap().to_string();

        let t = rpc(port, 7, "render_addressed",
            json!({ "root": hash }))["text"]
            .as_str()
            .unwrap()
            .to_string();
        assert!(
            t.contains("function answer") && t.contains("yield 42"),
            "the authored function renders: {t}"
        );
    }

    /// E-c: the version-control surface over /rpc — checkpoint is an
    /// immutable snapshot (an edit doesn't move it), branch is a label,
    /// resolve restores; an unknown name is null.
    #[test]
    fn the_editor_snapshots_and_restores_over_http() {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let port = listener.local_addr().unwrap().port();
        std::thread::spawn(move || {
            let _ = serve_on(listener, Session::new());
        });
        std::thread::sleep(Duration::from_millis(50));

        let m = rpc(port, 1, "apply_module", json!({ "module": {
            "name": "m", "types": [], "functions": [{
                "name": "t", "type_params": [], "params": [],
                "produces": { "ty": "Number", "confidence": "External" },
                "requires": [], "on_failure": [], "steps": [],
                "result": { "Lit": 1 }
            }]
        }}));
        let r0 = m["hash"].as_str().unwrap().to_string();
        rpc(port, 2, "checkpoint", json!({ "root": r0, "name": "v1" }));
        rpc(port, 3, "branch", json!({ "root": r0, "name": "main" }));

        let t = rpc(port, 4, "render_addressed",
            json!({ "root": r0 }))["text"].as_str().unwrap().to_string();
        let lit1 = rpc(port, 5, "node_at",
            json!({ "root": r0, "offset": t.find("yield 1").unwrap()+6 }))
            ["hash"].as_str().unwrap().to_string();
        let made = rpc(port, 6, "put_expr", json!({ "expr": { "Lit": 2 } }));
        let rep = rpc(port, 7, "replace_node", json!({ "root": r0,
            "target": lit1, "replacement": made["hash"] }));
        let r1 = rep["root"].as_str().unwrap().to_string();
        assert_ne!(r0, r1);

        // The immutable snapshot is unaffected by the edit.
        let v1 = rpc(port, 8, "resolve", json!({ "name": "v1" }));
        assert_eq!(v1["hash"].as_str().unwrap(), r0);
        let main = rpc(port, 9, "resolve", json!({ "name": "main" }));
        assert_eq!(main["hash"].as_str().unwrap(), r0);
        let none = rpc(port, 10, "resolve", json!({ "name": "nope" }));
        assert!(none["hash"].is_null());

        // Restore renders the pre-edit tree; the edited root renders 2.
        let t0 = rpc(port, 11, "render_addressed",
            json!({ "root": r0 }))["text"].as_str().unwrap().to_string();
        let t1 = rpc(port, 12, "render_addressed",
            json!({ "root": r1 }))["text"].as_str().unwrap().to_string();
        assert!(t0.contains("yield 1") && !t0.contains("yield 2"), "{t0}");
        assert!(t1.contains("yield 2") && !t1.contains("yield 1"), "{t1}");
    }

    /// UI↔protocol drift guard (the automatable half of "the editor
    /// works in a browser"): every tool the page's JS calls
    /// (`rpc("X"` / `apply("X"`) must be advertised by the server's
    /// `tools/list`. A renamed/removed tool fails here, headless —
    /// without this only a human in a browser would notice.
    #[test]
    fn the_editor_page_only_calls_tools_the_server_advertises() {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let port = listener.local_addr().unwrap().port();
        std::thread::spawn(move || {
            let _ = serve_on(listener, Session::new());
        });
        std::thread::sleep(Duration::from_millis(50));

        // Pull every tool name the JS references.
        let page = super::editor_page();
        let mut wanted: Vec<String> = Vec::new();
        for marker in ["rpc(\"", "apply(\""] {
            let mut rest = page;
            while let Some(i) = rest.find(marker) {
                rest = &rest[i + marker.len()..];
                if let Some(end) = rest.find('"') {
                    wanted.push(rest[..end].to_string());
                    rest = &rest[end..];
                }
            }
        }
        wanted.sort();
        wanted.dedup();
        assert!(!wanted.is_empty(), "the page calls tools");

        // The server's advertised surface (tools/list is a method, not
        // a tools/call — read it raw, not via the payload unwrapper).
        let body = serde_json::json!({
            "jsonrpc": "2.0", "id": 1, "method": "tools/list"
        })
        .to_string();
        let resp = req(
            port,
            &format!(
                "POST /rpc HTTP/1.1\r\nHost: x\r\n\
                 Content-Length: {}\r\n\r\n{body}",
                body.len()
            ),
        );
        let v: Value =
            serde_json::from_str(resp.split("\r\n\r\n").nth(1).unwrap())
                .unwrap();
        let have: std::collections::HashSet<String> = v["result"]["tools"]
            .as_array()
            .unwrap()
            .iter()
            .map(|t| t["name"].as_str().unwrap().to_string())
            .collect();

        for w in &wanted {
            assert!(
                have.contains(w),
                "editor JS calls `{w}` but the server does not advertise \
                 it (UI↔protocol drift): advertised = {:?}",
                {
                    let mut h: Vec<&String> = have.iter().collect();
                    h.sort();
                    h
                }
            );
        }
    }
}
