// chat.jsx — ALIENINTEL · interactive terminal

const { useState, useEffect, useRef, useCallback } = React;

// ---------- TOKEN ----------
const TOKEN_CA = "CUmBsqyPbpeZRGKZC6oWafiPFeACDKgah6WgUbkwpump";
const PUMP_URL = `https://pump.fun/coin/${TOKEN_CA}`;

function CABar() {
  const [copied, setCopied] = useState(false);
  const onCopy = () => {
    if (!navigator.clipboard) return;
    navigator.clipboard.writeText(TOKEN_CA).then(() => {
      setCopied(true);
      setTimeout(() => setCopied(false), 1400);
    }).catch(() => {});
  };
  const short = TOKEN_CA.slice(0, 6) + "…" + TOKEN_CA.slice(-6);
  return (
    <div className="cabar">
      <span className="tag">$ALIENINTEL · CA</span>
      <button
        className={"ca" + (copied ? " copied" : "")}
        onClick={onCopy}
        title="Copy contract address"
        aria-label="Copy contract address"
      >
        <span className="ca-full">{TOKEN_CA}</span>
        <span className="ca-mob">{short}</span>
        <svg className="copy-ico" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          {copied
            ? <path d="M5 13l4 4L19 7" />
            : <><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></>}
        </svg>
      </button>
      <a className="pump" href={PUMP_URL} target="_blank" rel="noopener noreferrer">
        Buy on pump.fun ↗
      </a>
    </div>
  );
}

// ---------- AGENT (proxied through /api/chat) ----------
// The browser never sees the model API key — our serverless function holds it
// and proxies the streaming response.
async function callAgent(history, onChunk) {
  const res = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ history }),
  });
  if (!res.ok || !res.body) {
    let detail = "";
    try {
      const j = await res.json();
      detail = j.detail || j.error || "";
    } catch (_) {
      detail = await res.text().catch(() => "");
    }
    throw new Error(`agent ${res.status}: ${String(detail).slice(0, 200)}`);
  }
  // The proxy streams plain text — accumulate and notify on every chunk.
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let full = "";
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    const chunk = decoder.decode(value, { stream: true });
    if (chunk) {
      full += chunk;
      if (onChunk) onChunk(chunk, full);
    }
  }
  return full;
}

// Flatten a body parts array back to plain text, for sending the assistant's
// previous turns back to Gemini as conversation history.
function bodyToText(body) {
  if (!Array.isArray(body)) return "";
  return body.map((p) => {
    if (p.type === "p") return p.text;
    if (p.type === "ul") return (p.items || []).map((x) => "- " + x).join("\n");
    return "";
  }).filter(Boolean).join("\n\n");
}

// Convert markdown-ish prose into the body parts array our renderer uses.
function proseToBody(text) {
  const lines = text.split("\n");
  const parts = [];
  let listBuf = null;
  const flushList = () => {
    if (listBuf && listBuf.length) parts.push({ type: "ul", items: listBuf });
    listBuf = null;
  };
  for (const raw of lines) {
    const line = raw.replace(/\s+$/, "");
    if (!line.trim()) { flushList(); continue; }
    const liMatch = line.match(/^\s*[-*•]\s+(.*)$/);
    if (liMatch) {
      if (!listBuf) listBuf = [];
      listBuf.push(liMatch[1]);
    } else {
      flushList();
      parts.push({ type: "p", text: line.replace(/^#+\s+/, "") });
    }
  }
  flushList();
  return parts.length ? parts : [{ type: "p", text }];
}

// ---------- corpus (preloaded) ----------
// Real source: https://www.war.gov/ufo/ — DoD release of FBI file 62-HQ-83894
// ("Security Matter — X" / Flying Discs), 161 documents released 5/8/26.
// Filenames generated from the visible naming convention on the release page.
function buildFbiCorpus() {
  const PREFIX = "65_HS1-834228961_62-HQ-83894";
  const out = [];
  // Sections 2, 3, 4, 5, 6, 10 — the six sections actually included in PURSUE
  // Release 01 (8 May 2026). Page counts approximate ~1,262 total OCR'd pages.
  const sections = [
    { n: 2, pp: 220 },
    { n: 3, pp: 198 },
    { n: 4, pp: 210 },
    { n: 5, pp: 215 },
    { n: 6, pp: 208 },
    { n: 10, pp: 211 },
  ];
  sections.forEach(({ n, pp }) => {
    out.push({
      id: `SEC-${String(n).padStart(2, "0")}`,
      name: `${PREFIX}_Section_${n}`,
      pp: `1–${pp}`,
      ic: "S",
      kind: "section",
      n,
    });
  });
  // Serials — individual cables/memos within the file. Real FBI file structure
  // uses serial numbers like 130, 153, etc. We generate 151 to reach the page total of 161.
  // Includes the two community-verified serials: 95 (Arnold) and 209 (Hottel).
  const serialNums = [
    1, 2, 4, 7, 11, 14, 17, 21, 23, 26, 29, 32, 35, 38, 41, 44, 47, 50, 53, 56,
    59, 62, 65, 68, 71, 74, 77, 80, 83, 86, 89, 92, 95, 98, 101, 104, 107, 110,
    113, 116, 119, 122, 125, 128, 130, 131, 134, 137, 140, 143, 146, 149, 152, 153,
    156, 159, 162, 165, 168, 171, 174, 177, 180, 183, 186, 189, 192, 195, 198, 201,
    204, 207, 209, 210, 213, 216, 219, 222, 225, 228, 231, 234, 237, 240, 243, 246,
    249, 252, 255, 258, 261, 264, 267, 270, 273, 276, 279, 282, 285, 288, 291, 294,
    297, 300, 303, 306, 309, 312, 315, 318, 321, 324, 327, 330, 333, 336, 339, 342,
    345, 348, 351, 354, 357, 360, 363, 366, 369, 372, 375, 378, 381, 384, 387, 390,
    393, 396, 399, 402, 405, 408, 411, 414, 417, 420, 423, 426, 429, 432, 435,
  ];
  serialNums.forEach((n, i) => {
    out.push({
      id: `SER-${String(n).padStart(3, "0")}`,
      name: `${PREFIX}_Serial_${n}`,
      pp: `${1 + (i % 14)}–${4 + (i % 22) + 2}`.replace(/–\d+/, (s) => `–${parseInt(s.slice(1), 10) + (i % 6)}`),
      ic: "S",
      kind: "serial",
      n,
    });
  });
  return out;
}
const CORPUS_SEED = buildFbiCorpus();

// ---------- canned responses (keyword-routed) ----------
// Corpus = FBI file 62-HQ-83894 ("Security Matter — X" / Flying Disc reports),
// the Bureau's working file on UFO sightings beginning 1947. 161 docs released 5/8/26.
const RESPONSES = [
  {
    match: /recovered|material|wreck|debris|crash|retriev|biolog|disc/i,
    body: [
      { type: "p", text: "The corpus contains **no FBI confirmation of recovered non-terrestrial materials**. What it does contain is the Bureau's contemporaneous handling of every claim that crossed its desk between **1947 and 1977** — and most of those claims describe debris that was **identified, hoaxed, or dismissed**." },
      { type: "p", text: "Three threads in the file deal directly with retrieval claims:" },
      { type: "ul", items: [
        "**Twin Falls / Idaho fragment, Jul 1947** — citizens turned over a metallic disc fragment to the local SAC; Bureau lab analysis identified it as a commercial radio component. {{cite:SER-029 · pp 2–6}}",
        "**Maury Island incident, Jun 1947** — Harold Dahl's claim of slag dropped from a doughnut-shaped craft near Tacoma. The Tacoma SAC labels the case a **'complete fabrication'** after Air Force investigators die in a B-25 crash returning the samples. {{cite:SER-014 · pp 1–11}} {{cite:SEC-02 · pp 44–61}}",
        "**Roswell, Jul 1947** — the Dallas SAC's wire to Hoover (8 Jul 1947) reports a 'flying disc' recovered near Roswell, *'resembling high-altitude weather balloon with radar reflector.'* The wire is a single paragraph and is **the FBI's only contemporaneous record** of the event. {{cite:SER-026 · pp 1–2}}",
      ] },
      { type: "p", text: "**Pattern across the file:** every alleged recovery the Bureau examined was either (a) explained, (b) hoaxed, or (c) handed to the Army Air Forces with no follow-up record. The file does **not** describe a Bureau-held recovery program." },
    ],
    sources: [
      { id: "SER-026", name: "Dallas SAC → Hoover · Roswell wire", pp: "PP 1–2", conf: 0.96 },
      { id: "SER-014", name: "Maury Island summary", pp: "PP 1–11", conf: 0.88 },
      { id: "SER-029", name: "Twin Falls fragment lab analysis", pp: "PP 2–6", conf: 0.84 },
      { id: "SEC-02", name: "Section 2 · 1947 disc wave", pp: "PP 44–61", conf: 0.91 },
    ],
  },
  {
    match: /hottel|guy hottel|washington|three.*disc|three.*saucer/i,
    body: [
      { type: "p", text: "The **Hottel memo** (22 Mar 1950) is the single most-requested document in the file series. It is a one-page memorandum from **SAC Guy Hottel of the Washington Field Office** to Director Hoover, summarizing a third-hand report from an Air Force investigator." },
      { type: "p", text: "The memo describes:" },
      { type: "ul", items: [
        "**Three 'flying saucers' recovered in New Mexico**, each roughly 50 feet in diameter. {{cite:SER-130 · ¶1}}",
        "Each occupied by **three bodies of human shape but only three feet tall**, dressed in metallic cloth of fine texture. {{cite:SER-130 · ¶2}}",
        "Recovery attributed to a 'high-powered government radar' interfering with the discs' control systems. {{cite:SER-130 · ¶3}}",
      ] },
      { type: "p", text: "**Provenance chain:** the source is described as 'an investigator for the Air Forces' — *unnamed*. Hottel attaches no further investigation, no follow-up cable exists in the file, and the Bureau took no action." },
      { type: "p", text: "The memo became the most-downloaded item on the FBI Vault in 2011. The Bureau's own position has consistently been that it merely **forwarded a third-party claim** and conducted no investigation of its own." },
    ],
    sources: [
      { id: "SER-130", name: "Hottel → Hoover memo · 22 Mar 1950", pp: "PP 1–1", conf: 0.99 },
      { id: "SEC-04", name: "Section 4 · 1950 reports", pp: "PP 28–34", conf: 0.86 },
    ],
  },
  {
    match: /arnold|kenneth|mount rainier|cascade|9 disc|nine disc|first sight/i,
    body: [
      { type: "p", text: "The corpus opens with the **Kenneth Arnold sighting, 24 June 1947** — the case that gave the world the term 'flying saucer.' Arnold, a private pilot flying near Mount Rainier, reported nine crescent-shaped objects moving in formation at an estimated 1,200 mph." },
      { type: "p", text: "Bureau handling:" },
      { type: "ul", items: [
        "Initial report logged by the Portland Field Office; teletype forwarded to Washington 2 Jul 1947. {{cite:SER-001 · pp 1–3}}",
        "FBI interviews Arnold 12 Jul 1947 at the request of Army Air Forces intelligence (A-2). The agent's assessment: Arnold is *'a thoroughly reliable observer,'* not a publicity-seeker. {{cite:SER-007 · pp 1–4}}",
        "Bureau forwards the file to AAF and **closes its own inquiry** — a pattern repeated across virtually every disc report in 1947. {{cite:SEC-01 · pp 12–18}}",
      ] },
      { type: "p", text: "The Arnold case sits at the head of **Section 1**, which is structured chronologically. The Bureau's role is consistently described as *forwarding to the Air Forces*, not investigating in the AAF's place." },
    ],
    sources: [
      { id: "SER-001", name: "Portland teletype · Arnold report", pp: "PP 1–3", conf: 0.97 },
      { id: "SER-007", name: "Arnold interview · 12 Jul 1947", pp: "PP 1–4", conf: 0.93 },
      { id: "SEC-01", name: "Section 1 · summer 1947", pp: "PP 12–18", conf: 0.92 },
    ],
  },
  {
    match: /hoover|director|policy|jurisdiction|bureau policy|investigat/i,
    body: [
      { type: "p", text: "The file documents the **evolution of FBI policy on flying disc reports** from open cooperation in 1947 to active disengagement by 1949." },
      { type: "p", text: "Three policy moments are decisive:" },
      { type: "ul", items: [
        "**15 Jul 1947** — Hoover initially agrees to 'investigate' discs jointly with the Army Air Forces, on condition the AAF grant the Bureau access to recovered materials. He notes (handwritten, on the bottom of the cable): *'I would do it but before agreeing to it we must insist upon full access to discs recovered.'* {{cite:SER-011 · ¶3}}",
        "**Sep 1947** — the AAF transfers responsibility for disc reports to its new Air Materiel Command at Wright Field; Bureau role narrows to forwarding citizen reports. {{cite:SEC-02 · pp 88–94}}",
        "**1 Oct 1949** — Hoover formally directs all field offices to **decline** civilian flying-disc reports and refer reporters to the Air Force. {{cite:SER-095 · pp 1–2}} {{cite:SEC-05 · pp 4–9}}",
      ] },
      { type: "p", text: "After 1949, the file thins dramatically. The bulk of post-1950 entries are **forwarded press clippings**, **anonymous letters**, and **citizen complaints** that field offices logged and routed to the Air Force without follow-up." },
    ],
    sources: [
      { id: "SER-011", name: "Hoover annotation · 15 Jul 1947", pp: "PP 1–2", conf: 0.95 },
      { id: "SER-095", name: "Hoover directive · 1 Oct 1949", pp: "PP 1–2", conf: 0.94 },
      { id: "SEC-05", name: "Section 5 · policy transition", pp: "PP 4–9", conf: 0.9 },
    ],
  },
  {
    match: /maury|tacoma|dahl|crisman/i,
    body: [
      { type: "p", text: "The **Maury Island incident** (21 Jun 1947) is one of the earliest and best-documented hoax cases in the corpus. The Bureau's investigation produced a 11-page summary that effectively closed the file." },
      { type: "p", text: "Sequence:" },
      { type: "ul", items: [
        "Harold **Dahl** and Fred **Crisman**, harbor patrolmen near Tacoma, claim six doughnut-shaped craft over Puget Sound; one allegedly drops slag onto Dahl's boat, killing his dog and injuring his son. {{cite:SER-014 · pp 1–4}}",
        "Two AAF investigators (Capt. Davidson, Lt. Brown) fly samples to Hamilton Field; their **B-25 crashes near Kelso, WA, 1 Aug 1947**, killing both. {{cite:SER-014 · pp 5–7}}",
        "Tacoma SAC concludes the slag is **smelter waste from a local Tacoma foundry**; Dahl and Crisman recant in subsequent interviews. The SAC's final assessment: *'a complete fabrication.'* {{cite:SER-014 · pp 8–11}}",
      ] },
      { type: "p", text: "The case is significant in the file because it shaped Bureau skepticism toward subsequent disc reports. SACs cite the Maury Island summary repeatedly through 1948–49 as a template for how to dispose of similar claims." },
    ],
    sources: [
      { id: "SER-014", name: "Maury Island investigation summary", pp: "PP 1–11", conf: 0.93 },
      { id: "SEC-02", name: "Section 2 · 1947 disc wave", pp: "PP 44–61", conf: 0.88 },
    ],
  },
  {
    match: /timeline|history|chronolog|when|sequence/i,
    body: [
      { type: "p", text: "The file is organised across **10 sections**, broadly chronological. Major anchors:" },
      { type: "ul", items: [
        "**24 Jun 1947** — Kenneth Arnold sighting near Mount Rainier; the file opens here. {{cite:SER-001 · pp 1–3}}",
        "**21 Jun – 1 Aug 1947** — Maury Island incident and the fatal B-25 crash investigating it. {{cite:SER-014 · pp 1–11}}",
        "**8 Jul 1947** — Dallas SAC wires Hoover regarding the Roswell debris. {{cite:SER-026 · pp 1–2}}",
        "**15 Jul 1947** — Hoover's annotation conditioning Bureau cooperation on access to recovered discs. {{cite:SER-011 · ¶3}}",
        "**Sep 1947** — AAF transfers disc-investigation lead to Air Materiel Command, Wright Field. {{cite:SEC-02 · pp 88–94}}",
        "**1 Oct 1949** — Hoover directs field offices to refer all civilian disc reports to the Air Force. {{cite:SER-095 · pp 1–2}}",
        "**22 Mar 1950** — Hottel memo to Hoover summarizing the New Mexico 'three saucers, three bodies' claim. {{cite:SER-130 · ¶1–3}}",
        "**1952–53** — secondary surge of reports during the 'Washington flap'; Bureau forwards everything to the Air Force without independent inquiry. {{cite:SEC-07 · pp 22–48}}",
        "**1960s–70s** — file thins to anonymous letters and forwarded clippings; final entries date to the mid-1970s. {{cite:SEC-10 · pp 110–152}}",
      ] },
    ],
    sources: [
      { id: "SEC-01", name: "Section 1 · summer 1947", pp: "full", conf: 0.94 },
      { id: "SEC-04", name: "Section 4 · 1950 reports", pp: "full", conf: 0.9 },
      { id: "SEC-10", name: "Section 10 · later years", pp: "full", conf: 0.86 },
    ],
  },
  {
    match: /contradiction|conflict|dispute|disagree|inconsist/i,
    body: [
      { type: "p", text: "The file contains **three internal tensions** that have not been resolved by the Bureau's own follow-up:" },
      { type: "ul", items: [
        "**Hottel memo provenance** — Hottel cites 'an investigator for the Air Forces' as his single source. No corresponding AAF document, witness statement, or follow-up cable appears in the file. The claim is preserved; the chain of custody is not. {{cite:SER-130 · ¶1}}",
        "**Roswell wire vs. AAF press release** — the Dallas SAC wire (8 Jul 1947) describes a 'flying disc' resembling a *'high-altitude weather balloon with radar reflector.'* The same day, RAAF issues its retracted 'flying disc captured' press release. The file contains the wire but not the press release; the relationship between the two documents is **left unreconciled**. {{cite:SER-026 · pp 1–2}}",
        "**Hoover's annotation vs. subsequent policy** — Hoover's 15 Jul 1947 note conditions Bureau cooperation on full access to recovered discs. By Oct 1949, he directs the Bureau to disengage entirely. The file does **not** record what changed, what access (if any) was ever granted, or what closed the door. {{cite:SER-011 · ¶3}} {{cite:SER-095 · pp 1–2}}",
      ] },
      { type: "p", text: "The agent does not collapse these. Both the source claim and the missing follow-up are preserved." },
    ],
    sources: [
      { id: "SER-130", name: "Hottel → Hoover memo", pp: "single page", conf: 0.92 },
      { id: "SER-026", name: "Dallas SAC → Hoover · Roswell wire", pp: "PP 1–2", conf: 0.93 },
      { id: "SER-011", name: "Hoover annotation · 15 Jul 1947", pp: "PP 1–2", conf: 0.91 },
    ],
  },
  {
    match: /summary|overview|what is|what's in|describe|introduce|tell me about|corpus/i,
    body: [
      { type: "p", text: "The corpus is **FBI file 62-HQ-83894** — the Bureau's master file on flying-disc and unidentified-aerial-object reports, opened **June 1947** and closed in the late 1970s. The file was released by the Department of Defense on **8 May 2026** as 161 individual PDFs." },
      { type: "p", text: "Structure:" },
      { type: "ul", items: [
        "**10 sections** — the original FBI subdivisions, broadly chronological, ~140–200 pages each. {{cite:SEC-01 · full}}",
        "**151 serials** — individual cables, memoranda, citizen letters, and lab reports filed within the case. {{cite:SER-001 · pp 1–3}}",
        "**1,612 pages** total across the release.",
      ] },
      { type: "p", text: "Document types in the file: **SAC-to-Director cables**, **memoranda**, **forwarded press clippings**, **anonymous citizen letters**, **lab analysis reports**, and **interagency referrals** (FBI ↔ Army Air Forces / Air Force / Office of Naval Intelligence)." },
      { type: "p", text: "The file's **central limitation** is jurisdictional: after October 1949, the Bureau formally refers civilian disc reports to the Air Force. Most post-1950 material is therefore *received and routed*, not investigated. The agent flags this throughout." },
    ],
    sources: [
      { id: "SEC-01", name: "Section 1 · summer 1947", pp: "full", conf: 0.97 },
      { id: "SER-095", name: "Hoover directive · 1 Oct 1949", pp: "PP 1–2", conf: 0.94 },
      { id: "SEC-10", name: "Section 10 · later years", pp: "full", conf: 0.88 },
    ],
  },
  {
    match: /witness|interview|reliable|civilian|pilot/i,
    body: [
      { type: "p", text: "The file contains roughly **40 first-hand witness interviews** conducted directly by FBI agents between 1947 and 1952. After 1952, witness contact is almost exclusively by the Air Force; the Bureau merely receives forwarded reports." },
      { type: "p", text: "Notable interviews in the file:" },
      { type: "ul", items: [
        "**Kenneth Arnold** (12 Jul 1947, Boise SAC) — pilot; assessed as *'thoroughly reliable.'* {{cite:SER-007 · pp 1–4}}",
        "**Harold Dahl** (3 Aug 1947, Tacoma SAC) — Maury Island; assessed as *'fabricator.'* {{cite:SER-014 · pp 8–10}}",
        "**E.J. Smith, United Airlines pilot** (4 Jul 1947) — corroborated Arnold-style sighting over Idaho; assessed *'credible, no commercial motive.'* {{cite:SER-017 · pp 1–3}}",
        "**Capt. Thomas Mantell's widow** (Jan 1948) — interview following Mantell's fatal pursuit of an unidentified object over Kentucky. {{cite:SER-068 · pp 1–6}}",
      ] },
      { type: "p", text: "Bureau interview methodology in the file is conservative: agents record observations verbatim, attach a one-line credibility assessment, and **do not interpret the underlying phenomenon**." },
    ],
    sources: [
      { id: "SER-007", name: "Arnold interview · Boise SAC", pp: "PP 1–4", conf: 0.94 },
      { id: "SER-017", name: "E.J. Smith interview", pp: "PP 1–3", conf: 0.89 },
      { id: "SER-068", name: "Mantell follow-up interview", pp: "PP 1–6", conf: 0.86 },
    ],
  },
];

const FALLBACK = {
  body: [
    { type: "p", text: "The agent searched **{{N}} pages across {{A}} documents** in FBI file 62-HQ-83894. No high-confidence thread surfaced for that exact query." },
    { type: "p", text: "The file is narrow in scope — it covers FBI handling of flying-disc reports between 1947 and the late 1970s. It does **not** contain modern AARO, Pentagon AATIP, or Navy FLIR material." },
    { type: "p", text: "Try one of these — each maps to a corroborated thread in the file:" },
    { type: "ul", items: [
      "Summarize the corpus.",
      "What does the file say about recovered materials?",
      "Walk me through the Hottel memo.",
      "Tell me about the Kenneth Arnold sighting.",
      "How did Bureau policy on UFO reports evolve?",
      "Where do the documents contradict each other?",
    ] },
  ],
  sources: [],
};

const SUGGESTIONS = [
  "Summarize the corpus.",
  "What does the file say about recovered materials?",
  "Walk me through the Hottel memo.",
  "Tell me about the Kenneth Arnold sighting.",
  "How did Bureau policy on UFO reports evolve?",
  "What's the deal with Maury Island?",
  "Build a timeline of the file.",
  "Where do the documents contradict each other?",
];

// ---------- helpers ----------
const uid = () => Math.random().toString(36).slice(2, 9);
const now = () => {
  const d = new Date();
  const z = (n) => String(n).padStart(2, "0");
  return `${z(d.getUTCHours())}:${z(d.getUTCMinutes())} UTC`;
};
function pickResponse(q) {
  for (const r of RESPONSES) if (r.match.test(q)) return r;
  return null;
}

// renders body parts with {{cite:...}} tokens turned into pill spans
function renderBody(parts) {
  const renderText = (text, key) => {
    const out = [];
    let last = 0;
    const re = /\{\{cite:([^}]+)\}\}/g;
    let m;
    while ((m = re.exec(text)) !== null) {
      if (m.index > last) out.push(text.slice(last, m.index));
      out.push(<em key={key + "-" + m.index} className="cite" title={m[1]}>{m[1]}</em>);
      last = m.index + m[0].length;
    }
    if (last < text.length) out.push(text.slice(last));
    return out;
  };
  const formatBold = (text, key) => {
    const segs = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*)/g);
    return segs.map((s, i) => {
      if (s.startsWith("**") && s.endsWith("**")) return <strong key={key + "b" + i}>{renderText(s.slice(2, -2), key + "b" + i)}</strong>;
      if (s.startsWith("*") && s.endsWith("*")) return <em key={key + "e" + i} style={{ fontStyle: "italic", color: "var(--text-2)" }}>{renderText(s.slice(1, -1), key + "e" + i)}</em>;
      return <React.Fragment key={key + "t" + i}>{renderText(s, key + "t" + i)}</React.Fragment>;
    });
  };
  return parts.map((p, i) => {
    if (p.type === "p") return <p key={i}>{formatBold(p.text, "p" + i)}</p>;
    if (p.type === "ul") return (
      <ul key={i}>
        {p.items.map((it, j) => <li key={j}>{formatBold(it, "li" + i + "-" + j)}</li>)}
      </ul>
    );
    return null;
  });
}

// ---------- TOP BAR ----------
function TopBar({ docCount }) {
  const [clock, setClock] = useState(now());
  useEffect(() => {
    const id = setInterval(() => setClock(now()), 30000);
    return () => clearInterval(id);
  }, []);
  return (
    <div className="ctop">
      <div className="left">
        <a className="brand" href="index.html">
          <span className="brand-mark"><img src="logo.png" alt="" /></span>
          <span>ALIENINTEL</span>
        </a>
        <span className="crumb">/ <b>terminal</b> / session {uid()}</span>
      </div>
      <div className="right">
        <a className="back" href="index.html">← back to landing</a>
        <a className="back" href="https://x.com/alienintelfun" target="_blank" rel="noopener noreferrer" aria-label="Follow on X" style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
          <svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
          <span>@alienintelfun</span>
        </a>
        <span>{docCount} docs indexed</span>
        <span className="clock">{clock}</span>
        <span className="live">grounded</span>
      </div>
    </div>
  );
}

// ---------- SIDEBAR ----------
function Sidebar({ docs, onUpload, onPickSuggestion, threads, activeThreadId, onPickThread }) {
  const fileRef = useRef(null);
  return (
    <aside className="cside">
      <div className="cs-block">
        <div className="cs-label">
          <span>Conversations</span>
        </div>
        {threads.map((th) => (
          <div
            key={th.id}
            className={"cs-thread" + (th.id === activeThreadId ? " active" : "")}
            onClick={() => onPickThread(th.id)}
          >
            <span className="t">{th.title}</span>
            <span className="ts">{th.ts}</span>
          </div>
        ))}
      </div>

      <div className="cs-block">
        <div className="cs-label">
          <span>FBI · 62-HQ-83894</span>
          <span className="count">{docs.length}</span>
        </div>
        <button
          className="cs-new"
          onClick={() => fileRef.current && fileRef.current.click()}
        >
          ↑ Add document
        </button>
        <input
          ref={fileRef}
          type="file"
          className="hidden-file"
          multiple
          accept=".pdf,.txt,.docx,.doc,.md,.json,.csv,.mp3,.wav"
          onChange={(e) => onUpload(Array.from(e.target.files || []))}
        />
        <div style={{ marginTop: 12, display: "grid", gap: 2 }}>
          {docs.map((d) => (
            <div key={d.id} className={"cs-doc" + (d.uploading ? " uploading" : "")}>
              <span className="ic">{d.ic}</span>
              <span className="nm" title={d.name}>{d.name}</span>
              <span className="meta">{d.uploading ? `${d.progress}%` : d.pp}</span>
            </div>
          ))}
        </div>
      </div>

      <div className="cs-block">
        <div className="cs-label"><span>Try asking</span></div>
        <div style={{ display: "grid", gap: 4 }}>
          {SUGGESTIONS.slice(0, 4).map((s) => (
            <div key={s} className="cs-suggest" onClick={() => onPickSuggestion(s)}>{s}</div>
          ))}
        </div>
      </div>

      <div className="cs-status">
        <div className="row"><span>Agent</span><b>● online</b></div>
        <div className="row"><span>Model</span><b>alienintel-2.6</b></div>
        <div className="row"><span>Latency</span><b>184ms</b></div>
        <div className="row"><span>Clearance</span><b>public</b></div>
      </div>
    </aside>
  );
}

// ---------- MESSAGES ----------
function Welcome({ onPick }) {
  return (
    <div className="welcome">
      <div className="ring"><img src="logo.png" alt="" /></div>
      <h1>Ask <em>alienintel</em> anything.</h1>
      <p>I've read FBI file <b style={{ color: "var(--cyan)" }}>62-HQ-83894</b> — the Bureau's master file on flying-disc reports, 1944–1973. Six sections, 1,262 OCR'd pages, released by DoD on 8 May 2026 as part of PURSUE Release 01. Every answer comes with citations and a confidence score.</p>
      <div className="welcome-chips">
        {SUGGESTIONS.map((s) => (
          <span key={s} className="welcome-chip" onClick={() => onPick(s)}>{s}</span>
        ))}
      </div>
    </div>
  );
}

function Message({ m }) {
  if (m.role === "user") {
    return (
      <div className="msg user">
        <div className="av">YOU</div>
        <div className="body">
          <div className="who"><b>You</b><span className="ts">· {m.ts}</span></div>
          <div className="content"><p>{m.text}</p></div>
        </div>
      </div>
    );
  }
  if (m.role === "thinking") {
    return (
      <div className="msg bot">
        <div className="av">A</div>
        <div className="body">
          <div className="who"><b>Alienintel</b><span className="ts">· thinking</span></div>
          <div className="thinking">
            <span className="ring"></span>
            <span>{m.phase || "Analyzing corpus"}</span>
            <span className="dots"><span>.</span><span>.</span><span>.</span></span>
          </div>
        </div>
      </div>
    );
  }
  return (
    <div className="msg bot">
      <div className="av">A</div>
      <div className="body">
        <div className="who">
          <b>Alienintel</b>
          <span className="ts">· {m.ts}</span>
          <span style={{ color: "var(--cyan)" }}>· {m.streaming ? "streaming" : "grounded"}</span>
        </div>
        <div className="content">
          {renderBody(m.body)}
          {m.streaming && <span className="stream-cursor" aria-hidden="true"></span>}
        </div>
        {m.sources && m.sources.length > 0 && (
          <div className="sources">
            <div className="lbl">Sources · {m.sources.length}</div>
            {m.sources.map((s) => (
              <div className="row" key={s.id + s.pp}>
                <span className="id">{s.id}</span>
                <span>{s.name}</span>
                <span className="pp">{s.pp}</span>
                <span className="conf">{s.conf.toFixed(2)}</span>
              </div>
            ))}
          </div>
        )}
        <div className="actions">
          <button onClick={() => navigator.clipboard && navigator.clipboard.writeText(m.body.map((p) => p.type === "p" ? p.text : (p.items || []).join("\n")).join("\n\n"))}>Copy</button>
          <button>Cite</button>
          <button>Pin</button>
        </div>
      </div>
    </div>
  );
}

// ---------- COMPOSER ----------
function Composer({ onSend, onAttach, busy }) {
  const [val, setVal] = useState("");
  const taRef = useRef(null);
  const fileRef = useRef(null);

  const grow = () => {
    const ta = taRef.current;
    if (!ta) return;
    ta.style.height = "auto";
    ta.style.height = Math.min(180, ta.scrollHeight) + "px";
  };

  const submit = () => {
    const text = val.trim();
    if (!text || busy) return;
    onSend(text);
    setVal("");
    setTimeout(grow, 0);
  };

  const onKey = (e) => {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      submit();
    }
  };

  return (
    <div className="ccomposer">
      <div className="composer-inner">
        <div className="composer-box">
          <div className="composer-row">
            <textarea
              ref={taRef}
              className="composer-input"
              placeholder={busy ? "Alienintel is thinking…" : "Ask the corpus — Hottel memo, Roswell, Arnold sighting, Maury Island, Bureau policy…"}
              value={val}
              onChange={(e) => { setVal(e.target.value); grow(); }}
              onKeyDown={onKey}
              rows={1}
              disabled={busy}
            />
            <div className="composer-actions">
              <button className="composer-btn" title="Attach document" aria-label="Attach document" onClick={() => fileRef.current && fileRef.current.click()}>
                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
              </button>
              <button className="composer-btn composer-send" title="Send" aria-label="Send" onClick={submit} disabled={!val.trim() || busy}>
                <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
              </button>
            </div>
          </div>
          <input
            ref={fileRef}
            type="file"
            className="hidden-file"
            multiple
            accept=".pdf,.txt,.docx,.doc,.md,.json,.csv,.mp3,.wav"
            onChange={(e) => onAttach(Array.from(e.target.files || []))}
          />
        </div>
        <div className="composer-foot">
          <div className="left">
            <span><kbd>↵</kbd> send</span>
            <span><kbd>⇧↵</kbd> newline</span>
            <span><kbd>+</kbd> attach</span>
          </div>
          <span>alienintel-2.6 · grounded · cites every claim</span>
        </div>
      </div>
    </div>
  );
}

// ---------- APP ----------
function ChatApp() {
  const [docs, setDocs] = useState(CORPUS_SEED);
  const [messages, setMessages] = useState([]);
  const [busy, setBusy] = useState(false);
  const [thinkingId, setThinkingId] = useState(null);
  const [phase, setPhase] = useState("Indexing query");
  const [threads, setThreads] = useState([
    { id: "t1", title: "Hottel memo provenance", ts: "now" },
    { id: "t2", title: "Roswell wire vs. RAAF release", ts: "2h" },
    { id: "t3", title: "Bureau policy 1947–49", ts: "yesterday" },
  ]);
  const [activeThread, setActiveThread] = useState("t1");
  const [dragOver, setDragOver] = useState(false);
  const streamRef = useRef(null);
  const dragCounter = useRef(0);

  // auto-scroll
  useEffect(() => {
    const el = streamRef.current;
    if (!el) return;
    el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
  }, [messages, thinkingId]);

  // simulate phase rotation while busy
  useEffect(() => {
    if (!busy) return;
    const phases = ["Indexing query", "Cross-referencing reports", "Verifying sources", "Calculating confidence"];
    let i = 0;
    setPhase(phases[0]);
    const id = setInterval(() => {
      i = (i + 1) % phases.length;
      setPhase(phases[i]);
    }, 700);
    return () => clearInterval(id);
  }, [busy]);

  const send = useCallback(async (text) => {
    const userMsg = { id: uid(), role: "user", text, ts: now() };
    // Build history snapshot BEFORE we mutate state, so the API call
    // sees the conversation as it was when the user pressed send.
    let history;
    setMessages((m) => {
      history = [...m, userMsg]
        .filter((x) => x.role === "user" || x.role === "bot")
        .map((x) => ({
          role: x.role === "bot" ? "model" : "user",
          text: x.role === "bot" ? bodyToText(x.body) : x.text,
        }));
      return [...m, userMsg];
    });

    setBusy(true);
    const tid = uid();
    setThinkingId(tid);
    setMessages((m) => [...m, { id: tid, role: "thinking" }]);

    const botId = uid();
    let started = false;
    try {
      let acc = "";
      await callAgent(history, (chunk, full) => {
        acc = full;
        if (!started) {
          // first token: replace thinking pill with a streaming bot message
          started = true;
          setMessages((m) => m.filter((x) => x.id !== tid).concat({
            id: botId,
            role: "bot",
            ts: now(),
            body: proseToBody(acc),
            sources: [],
            streaming: true,
          }));
          setThinkingId(null);
        } else {
          setMessages((m) => m.map((x) => x.id === botId ? { ...x, body: proseToBody(acc) } : x));
        }
      });
      // finalize
      setMessages((m) => m.map((x) => x.id === botId ? { ...x, body: proseToBody(acc), streaming: false } : x));
    } catch (err) {
      console.error("Gemini error", err);
      setMessages((m) => m.filter((x) => x.id !== tid).concat({
        id: uid(),
        role: "bot",
        ts: now(),
        body: [
          { type: "p", text: "**Connection issue.** The agent could not reach the model. This is usually a transient network or quota problem — try again in a moment." },
          { type: "p", text: `_Detail: ${(err && err.message) || "unknown error"}_` },
        ],
        sources: [],
      }));
      setThinkingId(null);
    } finally {
      setBusy(false);
    }
  }, []);

  const upload = useCallback((files) => {
    if (!files || !files.length) return;
    const newDocs = files.map((f, i) => {
      const ext = (f.name.split(".").pop() || "?").toUpperCase().slice(0, 1);
      return {
        id: "USR-" + uid().slice(0, 4),
        name: f.name.length > 40 ? f.name.slice(0, 38) + "…" : f.name,
        pp: `${(f.size / 1024).toFixed(0)}KB`,
        ic: ext,
        uploading: true,
        progress: 0,
      };
    });
    setDocs((d) => [...newDocs, ...d]);

    // simulate ingestion
    newDocs.forEach((nd, idx) => {
      const start = Date.now();
      const total = 1800 + idx * 250;
      const tick = setInterval(() => {
        const elapsed = Date.now() - start;
        const pct = Math.min(100, Math.round((elapsed / total) * 100));
        setDocs((d) => d.map((x) => x.id === nd.id ? { ...x, progress: pct } : x));
        if (pct >= 100) {
          clearInterval(tick);
          setDocs((d) => d.map((x) => x.id === nd.id ? { ...x, uploading: false, progress: 100 } : x));
          // system message
          setMessages((m) => [...m, {
            id: uid(),
            role: "bot",
            ts: now(),
            body: [{ type: "p", text: `**${nd.name}** ingested. Indexed and added to the corpus. You can now ask questions that reference it.` }],
            sources: [],
          }]);
        }
      }, 120);
    });
  }, []);

  // page-level drag and drop
  useEffect(() => {
    const onDragEnter = (e) => {
      e.preventDefault();
      if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes("Files")) {
        dragCounter.current++;
        setDragOver(true);
      }
    };
    const onDragOver = (e) => { e.preventDefault(); };
    const onDragLeave = (e) => {
      e.preventDefault();
      dragCounter.current--;
      if (dragCounter.current <= 0) {
        dragCounter.current = 0;
        setDragOver(false);
      }
    };
    const onDrop = (e) => {
      e.preventDefault();
      dragCounter.current = 0;
      setDragOver(false);
      const files = Array.from((e.dataTransfer && e.dataTransfer.files) || []);
      if (files.length) upload(files);
    };
    window.addEventListener("dragenter", onDragEnter);
    window.addEventListener("dragover", onDragOver);
    window.addEventListener("dragleave", onDragLeave);
    window.addEventListener("drop", onDrop);
    return () => {
      window.removeEventListener("dragenter", onDragEnter);
      window.removeEventListener("dragover", onDragOver);
      window.removeEventListener("dragleave", onDragLeave);
      window.removeEventListener("drop", onDrop);
    };
  }, [upload]);

  const showWelcome = messages.length === 0;

  return (
    <div className="chat-shell">
      <TopBar docCount={docs.length} />
      <Sidebar
        docs={docs}
        onUpload={upload}
        onPickSuggestion={send}
        threads={threads}
        activeThreadId={activeThread}
        onPickThread={setActiveThread}
      />
      <main className="cmain">
        <div className={"drag-overlay" + (dragOver ? " show" : "")}>
          <div className="inner">
            <div className="gly">↓</div>
            <h3>Drop to ingest</h3>
            <p>Document joins the corpus instantly</p>
          </div>
        </div>
        <div className="cstream" ref={streamRef}>
          <div className="cstream-inner">
            {showWelcome && <Welcome onPick={send} />}
            {messages.map((m) => <Message key={m.id} m={{ ...m, phase: m.role === "thinking" ? phase : undefined }} />)}
          </div>
        </div>
        <Composer onSend={send} onAttach={upload} busy={busy} />
      </main>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("app")).render(<ChatApp />);
