/* global React, ReactDOM */
const { useState, useEffect, useRef, useCallback, useMemo } = React;

// ============================================================
// Themes — each named after a real vintage display
// ============================================================
const THEMES = [
  {
    id: "ibm-5151",
    name: "IBM 5151",
    year: 1983,
    desc: "MDA amber phosphor monitor",
    swatch: "#ffb000",
  },
  {
    id: "dec-vt220",
    name: "DEC VT220",
    year: 1983,
    desc: "P1 green phosphor terminal",
    swatch: "#33ff66",
  },
  {
    id: "commodore-64",
    name: "Commodore 64",
    year: 1982,
    desc: "light blue on navy boot screen",
    swatch: "#8a9bff",
  },
  {
    id: "hercules",
    name: "Hercules HGC",
    year: 1982,
    desc: "monochrome white phosphor card",
    swatch: "#d4e6ff",
  },
  {
    id: "apple-ii",
    name: "Apple ][",
    year: 1977,
    desc: "warm green composite monitor",
    swatch: "#4cff8c",
  },
  {
    id: "plasma",
    name: "IBM PS/55",
    year: 1989,
    desc: "gas-plasma orange display",
    swatch: "#ff6a2a",
  },
];

function setTheme(id) {
  document.body.setAttribute("data-theme", id);
}
function getTheme() {
  return document.body.getAttribute("data-theme") || "ibm-5151";
}

// ============================================================
// Works data — loaded from works.json
// ============================================================
let WORKS = [];
let WORKS_LOADED = false;
let WORKS_ERR = null;

async function loadWorks() {
  try {
    const res = await fetch("works.json", { cache: "no-cache" });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    WORKS = await res.json();
    WORKS_LOADED = true;
  } catch (e) {
    WORKS_ERR = e.message || String(e);
    WORKS_LOADED = true;
  }
}

// ============================================================
// Whoami data — loaded from whoami.json
// ============================================================
let WHOAMI = null;
let WHOAMI_LOADED = false;
let WHOAMI_ERR = null;

async function loadWhoami() {
  try {
    const res = await fetch("whoami.json", { cache: "no-cache" });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    WHOAMI = await res.json();
    WHOAMI_LOADED = true;
  } catch (e) {
    WHOAMI_ERR = e.message || String(e);
    WHOAMI_LOADED = true;
  }
}

// ============================================================
// Blogs index — loaded from blogs.json
// each entry: { id, title, file, date? }
// `file` is resolved relative to the blogs/ folder
// ============================================================
let BLOGS = [];
let BLOGS_LOADED = false;
let BLOGS_ERR = null;
const BLOGS_PER_PAGE = 10;

async function loadBlogs() {
  try {
    const res = await fetch("blogs.json", { cache: "no-cache" });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const raw = await res.json();
    // newest first; entries without a parseable date sink to the bottom
    BLOGS = raw.slice().sort((a, b) => {
      const ta = a.date ? Date.parse(a.date) : NaN;
      const tb = b.date ? Date.parse(b.date) : NaN;
      const va = Number.isNaN(ta) ? -Infinity : ta;
      const vb = Number.isNaN(tb) ? -Infinity : tb;
      return vb - va;
    });
    BLOGS_LOADED = true;
  } catch (e) {
    BLOGS_ERR = e.message || String(e);
    BLOGS_LOADED = true;
  }
}

// ============================================================
// Tiny markdown renderer — terminal flavored
// supports: # h1 / ## h2 / ### h3
//           **bold**, *italic*, `code`
//           - / * unordered list
//           1. ordered list
//           ```fenced code blocks```
//           > blockquote
//           [text](url)
//           | tables |
// ============================================================
function escapeHTML(s){
  return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
function inlineMD(s){
  s = escapeHTML(s);
  // inline code
  s = s.replace(/`([^`]+)`/g, '<code class="md-code">$1</code>');
  // bold
  s = s.replace(/\*\*([^*]+)\*\*/g, '<span class="md-bold">$1</span>');
  // italic (single *)
  s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<span class="md-italic">$2</span>');
  // links [text](url)  — in a terminal we just show text + (url) dimmed
  s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g,
    '<span class="md-link">$1</span><span class="dim"> ($2)</span>');
  return s;
}
function renderMarkdown(md){
  const lines = md.split(/\r?\n/);
  const out = [];
  let i = 0;
  while (i < lines.length){
    const line = lines[i];
    // fenced code
    if (/^```/.test(line)){
      const lang = line.replace(/^```/,"").trim();
      const buf = [];
      i++;
      while (i < lines.length && !/^```/.test(lines[i])){
        buf.push(escapeHTML(lines[i]));
        i++;
      }
      i++; // closing fence
      out.push(`<pre class="md-pre"><code>${buf.join("\n")}</code></pre>`);
      continue;
    }
    // headings
    let m;
    if ((m = line.match(/^###\s+(.*)$/))){ out.push(`<div class="md-h3">${inlineMD(m[1])}</div>`); i++; continue; }
    if ((m = line.match(/^##\s+(.*)$/))) { out.push(`<div class="md-h2">${inlineMD(m[1])}</div>`); i++; continue; }
    if ((m = line.match(/^#\s+(.*)$/)))  { out.push(`<div class="md-h1">${inlineMD(m[1])}</div>`); i++; continue; }
    // blockquote
    if (/^>\s?/.test(line)){
      const buf = [];
      while (i < lines.length && /^>\s?/.test(lines[i])){
        buf.push(inlineMD(lines[i].replace(/^>\s?/, "")));
        i++;
      }
      out.push(`<div class="md-quote">${buf.join("<br>")}</div>`);
      continue;
    }
    // unordered list
    if (/^[-*]\s+/.test(line)){
      const buf = [];
      while (i < lines.length && /^[-*]\s+/.test(lines[i])){
        buf.push(`<div class="md-li">  • ${inlineMD(lines[i].replace(/^[-*]\s+/, ""))}</div>`);
        i++;
      }
      out.push(buf.join(""));
      continue;
    }
    // ordered list
    if (/^\d+\.\s+/.test(line)){
      const buf = [];
      let n = 1;
      while (i < lines.length && /^\d+\.\s+/.test(lines[i])){
        buf.push(`<div class="md-li">  ${n}. ${inlineMD(lines[i].replace(/^\d+\.\s+/, ""))}</div>`);
        n++; i++;
      }
      out.push(buf.join(""));
      continue;
    }
    // table
    if (/^\|.*\|\s*$/.test(line) && i+1 < lines.length && /^\|[\s\-:|]+\|\s*$/.test(lines[i+1])){
      const header = line.split("|").slice(1,-1).map(c => c.trim());
      i += 2;
      const rows = [];
      while (i < lines.length && /^\|.*\|\s*$/.test(lines[i])){
        rows.push(lines[i].split("|").slice(1,-1).map(c => c.trim()));
        i++;
      }
      const widths = header.map((h, ci) =>
        Math.max(h.length, ...rows.map(r => (r[ci]||"").length))
      );
      const fmt = (cells) => "  " + cells.map((c, ci) => (c||"").padEnd(widths[ci], " ")).join("  │  ");
      const sep = "  " + widths.map(w => "─".repeat(w)).join("──┼──");
      const lines2 = [
        `<div class="md-tbl-h">${escapeHTML(fmt(header))}</div>`,
        `<div class="md-tbl-h">${escapeHTML(sep)}</div>`,
        ...rows.map(r => `<div class="md-tbl">${escapeHTML(fmt(r))}</div>`),
      ];
      out.push(lines2.join(""));
      continue;
    }
    // blank
    if (line.trim() === ""){
      out.push(`<div>&nbsp;</div>`); i++; continue;
    }
    // paragraph
    out.push(`<div class="md-p">${inlineMD(line)}</div>`);
    i++;
  }
  return out.join("");
}

// ============================================================
// ASCII art
// ============================================================
const SKULL = String.raw`
     _________                            
    / ======= \                           
   / __________\                          
  | ___________ |                         
  | | -       | |                         
  | |         | |                         
  | |_________| |____________________
  \=____________/   Yuhan Lee (lxb)  )
  / """"""""""" \                   / 
 / ::::::::::::: \              =D-'  
(_________________)
`.trimEnd();

const NEOFETCH_SKULL = String.raw`
     |*\_/*|________   
    ||_/-\_|______  |  
    | |           | |  
    | |   0   0   | |  
    | |     -     | |  
    | |   \___/   | |  
    | |___________| |  
    |_______________|  
       _|________|_    
      / ********** \   
    /  ************  \ 
   --------------------
`.trimEnd();

// ============================================================
// Filesystem mock
// ============================================================
const FS = {
  files: [
    { name: ".bashrc",          type: "f", size: "1.2K", date: "Apr 14" },
    { name: ".config/",         type: "d", size: "4.0K", date: "Apr 22" },
    { name: ".ssh/",            type: "d", size: "4.0K", date: "Mar 03" },
    { name: "Documents/",       type: "d", size: "4.0K", date: "Apr 26" },
    { name: "exploits/",        type: "d", size: "4.0K", date: "Apr 27" },
    { name: "manifesto.txt",    type: "f", size: "  847", date: "Apr 27" },
    { name: "mycat.png",        type: "f", size: "2.1K", date: "Apr 25" },
    { name: "rice.conf",        type: "f", size: "  312", date: "Apr 19" },
    { name: "todo.md",          type: "f", size: "   42", date: "Apr 27" },
  ]
};

// ============================================================
// Boot / greet messages
// ============================================================
const BOOT = [
  { t: 0,    text: "[    0.000000] vintage-tty v3.14 :: handshake established" },
  { t: 90,   text: "[    0.014221] decrypting payload  ░░░░░░░░░░  OK" },
  { t: 180,  text: "[    0.041337] mounting /dev/null over /opt/reality" },
  { t: 280,  text: "[    0.063402] you are not supposed to be here." },
  { t: 380,  text: "[    0.072115] the others are watching the others." },
  // { t: 480,  text: "" },
  { t: 520,  text: SKULL, cls: "hot" },
  { t: 560,  text: "" },
  { t: 600,  text: "  >>  the lights flicker because we are.", cls: "dim" },
  { t: 700,  text: "  >>  every keystroke is a small confession.", cls: "dim" },
  { t: 800,  text: "  >>  type `help` if you forgot the words.", cls: "dim" },
  { t: 900,  text: "" },
];

// ============================================================
// Commands
// ============================================================
const SUDO_QUOTES = [
  "Nice try.",
  "lxb is not in the sudoers file.  This incident will be reported.",
  "Permission denied.  Have you tried asking nicely?",
  "[sudo] password for lxb:  (just kidding, it's not that easy)",
  "ERROR: insufficient privilege.  insufficient nerve.",
];

function neofetchOutput() {
  const lines = [
    { k: "OS",        v: "Fedora Linux 42 (Workstation Edition) x86_64" },
    { k: "Host",      v: "lixiaobai@fedora" },
    { k: "Kernel",    v: "Linux 6.19.11-100.fc42.x86_64" },
    { k: "Uptime",    v: "2 hours, 24 mins" },
    { k: "Packages",  v: "7821 (rpm), 46 (flatpak)" },
    { k: "Shell",     v: "zsh 5.9" },
    { k: "WM",        v: "niri (Wayland)" },
    { k: "Terminal",  v: "claude" },
    { k: "CPU",       v: "13th Gen Intel(R) Core(TM) i7-13700H (20) @ 5.00 GHz" },
    { k: "GPU",       v: "NVIDIA GeForce RTX 4060 Max-Q / Mobile [Discrete]" },
    { k: "Memory",    v: "4.87 GiB / 15.40 GiB (32%)" },
    { k: "Disk",      v: "297.30 GiB / 952.28 GiB (31%) [btrfs]" },
    { k: "Local IP",  v: "172.20.10.3/28 (wlp0s20f3)" },
    { k: "Locale",    v: "en_US.UTF-8" },
  ];
  return { kind: "neofetch", lines };
}

function helpOutput() {
  const cmds = [
    ["neofetch",      "get system info"],
    ["whoami",        "about me"],
    ["works",         "list my works / projects"],
    ["work <id>",     "show details for one work"],
    ["blogs [page]",  "list blog posts (10 per page)"],
    ["blog <id>",     "read a blog post"],
    ["ls",            "list files in $HOME"],
    // ["mycat",         "display photo of my cat to stdout"],
    ["date",          "current local date/time"],
    ["themes",        "list available phosphor themes"],
    ["theme <name>",  "switch theme (e.g. theme dec-vt220)"],
    ["sudo <cmd>",    "elevate. (good luck.)"],
    ["clear",         "wipe the screen"],
    ["help",          "you are reading it"],
  ];
  return {
    kind: "lines",
    lines: [
      { text: "available commands :: ", cls: "dim" },
      { text: "" },
      ...cmds.map(([c, d]) => ({
        text: `  ${c.padEnd(14, " ")} ${d}`,
      })),
      { text: "" },
      { text: "↑/↓ scroll command history.", cls: "dim" },
    ],
  };
}

function lsOutput(args) {
  const long = args.includes("-l") || args.includes("-la") || args.includes("-al");
  const all  = args.includes("-a") || args.includes("-la") || args.includes("-al");
  const items = FS.files.filter(f => all || !f.name.startsWith("."));
  if (long) {
    return {
      kind: "lines",
      lines: [
        { text: `total ${items.length * 4}`, cls: "dim" },
        ...items.map(f => ({
          text: `${f.type === "d" ? "drwxr-xr-x" : "-rw-r--r--"}  1 lxb lxb  ${f.size.padStart(5," ")}  ${f.date}  ${f.name}`,
          cls: f.type === "d" ? "hot" : "",
        })),
      ],
    };
  }
  // grid layout
  const names = items.map(f => f.type === "d" ? f.name : f.name);
  const cols = 4;
  const colWidth = Math.max(...names.map(n => n.length)) + 2;
  const rows = [];
  for (let i = 0; i < names.length; i += cols) {
    rows.push(names.slice(i, i + cols).map(n => n.padEnd(colWidth, " ")).join(""));
  }
  return { kind: "lines", lines: rows.map(r => ({ text: r })) };
}

function dateOutput() {
  const d = new Date();
  const opts = { weekday:"short", year:"numeric", month:"short", day:"2-digit" };
  const s = d.toLocaleDateString("en-US", opts) + "  " + d.toLocaleTimeString("en-GB") + "  CRT-TZ";
  return { kind: "lines", lines: [{ text: s }] };
}

function themesOutput() {
  return { kind: "themes" };
}

// ============================================================
// Works commands
// ============================================================
function worksOutput() {
  if (!WORKS_LOADED) return { kind: "lines", lines: [{ text: "works: still loading…", cls: "dim" }] };
  if (WORKS_ERR)     return { kind: "lines", lines: [
    { text: `works: failed to load works.json (${WORKS_ERR})` },
    { text: `serve this page over http(s) — file:// blocks fetch.`, cls: "dim" },
  ]};
  if (WORKS.length === 0) return { kind: "lines", lines: [{ text: "no works yet. add some to works.json.", cls: "dim" }] };
  return { kind: "works-list" };
}

function workOutput(args){
  if (!WORKS_LOADED) return { kind: "lines", lines: [{ text: "works: still loading…", cls: "dim" }] };
  if (WORKS_ERR)     return { kind: "lines", lines: [{ text: `works: ${WORKS_ERR}` }]};
  if (args.length === 0){
    return { kind: "lines", lines: [
      { text: "usage: work <id>", cls: "dim" },
      { text: "(see `works` for the list)", cls: "dim" },
    ]};
  }
  const id = args[0].toLowerCase();
  const w = WORKS.find(x => x.id.toLowerCase() === id);
  if (!w){
    return { kind: "lines", lines: [
      { text: `work: not found: '${id}'` },
      { text: "try `works` to see ids.", cls: "dim" },
    ]};
  }
  return { kind: "work-detail", work: w };
}

// ============================================================
// Blogs commands
// ============================================================
function blogsOutput(args) {
  if (!BLOGS_LOADED) return { kind: "lines", lines: [{ text: "blogs: still loading…", cls: "dim" }] };
  if (BLOGS_ERR)     return { kind: "lines", lines: [
    { text: `blogs: failed to load blogs.json (${BLOGS_ERR})` },
    { text: "is the server running from the project root?", cls: "dim" },
  ]};
  if (BLOGS.length === 0) return { kind: "lines", lines: [{ text: "no blogs yet.", cls: "dim" }] };

  const totalPages = Math.max(1, Math.ceil(BLOGS.length / BLOGS_PER_PAGE));
  let page = 1;
  if (args.length > 0) {
    const n = parseInt(args[0], 10);
    if (Number.isNaN(n) || n < 1) {
      return { kind: "lines", lines: [
        { text: `blogs: invalid page '${args[0]}'` },
        { text: `usage: blogs [page]   (1 .. ${totalPages})`, cls: "dim" },
      ]};
    }
    if (n > totalPages) {
      return { kind: "lines", lines: [
        { text: `blogs: page ${n} out of range (only ${totalPages} page${totalPages === 1 ? "" : "s"})` },
      ]};
    }
    page = n;
  }
  return { kind: "blogs-list", page, totalPages };
}

function blogOutput(args){
  if (!BLOGS_LOADED) return { kind: "lines", lines: [{ text: "blogs: still loading…", cls: "dim" }] };
  if (BLOGS_ERR)     return { kind: "lines", lines: [{ text: `blogs: ${BLOGS_ERR}` }]};
  if (args.length === 0){
    return { kind: "lines", lines: [
      { text: "usage: blog <id>", cls: "dim" },
      { text: "(see `blogs` for the list)", cls: "dim" },
    ]};
  }
  const id = args[0].toLowerCase();
  const b = BLOGS.find(x => x.id.toLowerCase() === id);
  if (!b){
    return { kind: "lines", lines: [
      { text: `blog: not found: '${id}'` },
      { text: "try `blogs` to see ids.", cls: "dim" },
    ]};
  }
  return { kind: "blog-detail", blog: b };
}

function themeOutput(args) {
  if (args.length === 0) {
    const cur = getTheme();
    return {
      kind: "lines",
      lines: [
        { text: `current theme: ${cur}` },
        { text: `usage: theme <name>     (try \`themes\` to list)`, cls: "dim" },
      ],
    };
  }
  const target = args[0].toLowerCase();
  const found = THEMES.find(t => t.id === target);
  if (!found) {
    // soft-match common shortcuts
    const aliases = {
      amber: "ibm-5151", ibm: "ibm-5151",
      green: "dec-vt220", vt220: "dec-vt220", dec: "dec-vt220",
      blue: "commodore-64", c64: "commodore-64", commodore: "commodore-64",
      white: "hercules", herc: "hercules",
      apple: "apple-ii", ii: "apple-ii",
      orange: "plasma", red: "plasma",
    };
    const aliased = aliases[target];
    if (aliased) {
      const t = THEMES.find(x => x.id === aliased);
      setTheme(t.id);
      return {
        kind: "lines",
        lines: [
          { text: `theme set: ${t.name} (${t.id})  ── ${t.desc}` },
        ],
      };
    }
    return {
      kind: "lines",
      lines: [
        { text: `theme: unknown phosphor: '${target}'`, cls: "" },
        { text: `try \`themes\` to list available displays.`, cls: "dim" },
      ],
    };
  }
  setTheme(found.id);
  return {
    kind: "lines",
    lines: [
      { text: `theme set: ${found.name} (${found.id})  ── ${found.desc}` },
    ],
  };
}

function whoamiOutput() {
  if (!WHOAMI_LOADED) return { kind: "lines", lines: [{ text: "whoami: still loading…", cls: "dim" }] };
  if (WHOAMI_ERR)     return { kind: "lines", lines: [
    { text: `whoami: failed to load whoami.json (${WHOAMI_ERR})` },
    { text: "is the server running from the project root?", cls: "dim" },
  ]};
  if (!WHOAMI) return { kind: "lines", lines: [{ text: "whoami: empty whoami.json.", cls: "dim" }] };
  return { kind: "whoami", who: WHOAMI };
}

function mycatOutput() {
  return {
    kind: "image",
    src: "mycat.png",
    caption: "mycat.png  --  327x247  --  phosphor-encoded",
  };
}

function sudoOutput(rest) {
  const q = SUDO_QUOTES[Math.floor(Math.random() * SUDO_QUOTES.length)];
  const cmd = rest.join(" ");
  return {
    kind: "lines",
    lines: [
      { text: cmd ? `sudo: attempting :: ${cmd}` : "sudo: missing operand", cls: "dim" },
      { text: q },
    ],
  };
}

function unknownOutput(cmd) {
  return {
    kind: "lines",
    lines: [
      { text: `bash: ${cmd}: command not found` },
      { text: `did you mean: help?`, cls: "dim" },
    ],
  };
}

function runCommand(raw) {
  const trimmed = raw.trim();
  if (!trimmed) return null;
  const parts = trimmed.split(/\s+/);
  const cmd = parts[0].toLowerCase();
  const args = parts.slice(1);
  switch (cmd) {
    case "neofetch": return neofetchOutput();
    case "whoami":   return whoamiOutput();
    case "ls":       return lsOutput(args);
    case "help":     return helpOutput();
    case "date":     return dateOutput();
    // case "mycat":    return mycatOutput();
    case "themes":   return themesOutput();
    case "theme":    return themeOutput(args);
    case "works":    return worksOutput();
    case "work":     return workOutput(args);
    case "blogs":    return blogsOutput(args);
    case "blog":     return blogOutput(args);
    case "sudo":     return sudoOutput(args);
    case "clear":    return { kind: "clear" };
    case "exit":
    case "logout":
      return { kind: "lines", lines: [{ text: "you can check out any time you like.", cls: "dim" }] };
    default:         return unknownOutput(cmd);
  }
}

// ============================================================
// Output renderers
// ============================================================
function NeofetchBlock({ lines }) {
  const ascii = NEOFETCH_SKULL;
  return (
    <div className="line nf">
      <pre className="ascii pre" style={{ margin: 0 }}>{ascii}</pre>
      <div>
        <div className="nf-header">lxb@fedora</div>
        <div className="dim">────────────</div>
        {lines.map((l, i) => (
          <div key={i}>
            <span className="nf-key">{l.k}</span>
            <span className="dim">: </span>
            <span className="nf-val">{l.v}</span>
          </div>
        ))}
        <div style={{ marginTop: 8 }} className="dim">colors:</div>
        <div className="colorbar">
          {[0.25, 0.4, 0.55, 0.7, 0.85, 1.0].map((a, i) => (
            <span key={i} style={{ background: `color-mix(in oklab, var(--fg) ${Math.round(a*100)}%, transparent)` }} />
          ))}
        </div>
      </div>
    </div>
  );
}

function ImageBlock({ src, caption }) {
  return (
    <div className="line">
      <div className="img-out">
        <img src={src} alt={caption} />
      </div>
      <div className="img-cap">{caption}</div>
    </div>
  );
}

function LinesBlock({ lines }) {
  return (
    <>
      {lines.map((l, i) => (
        <div key={i} className={`line ${l.cls || ""}`}>
          {l.text === "" ? "\u00A0" : (l.text.includes("\n") ? <pre className="pre" style={{ margin: 0 }}>{l.text}</pre> : l.text)}
        </div>
      ))}
    </>
  );
}

function ThemesBlock() {
  const cur = getTheme();
  return (
    <div className="line">
      <div className="dim">available phosphor themes — type `theme &lt;id&gt;` to switch</div>
      <div>&nbsp;</div>
      <div className="themes-table">
        {THEMES.map(t => (
          <React.Fragment key={t.id}>
            <div className={t.id === cur ? "active" : "inactive"}>
              <span className="swatch" style={{ background: t.swatch, boxShadow: `0 0 8px ${t.swatch}` }} />
              <span className="hot">{t.id}</span>
            </div>
            <div className="dim">[{t.year}]</div>
            <div>{t.name}</div>
            <div className="dim">{t.desc}</div>
          </React.Fragment>
        ))}
      </div>
    </div>
  );
}

function WorksListBlock() {
  return (
    <div className="line">
      <div className="dim">my works — type `work &lt;id&gt;` for details</div>
      <div>&nbsp;</div>
      <div className="works-table">
        {WORKS.map(w => (
          <React.Fragment key={w.id}>
            <div><span className="hot">{w.id}</span></div>
            <div>{w.title}</div>
            <div className="dim">{w.description}</div>
          </React.Fragment>
        ))}
      </div>
      <div>&nbsp;</div>
      <div className="dim">{WORKS.length} work{WORKS.length === 1 ? "" : "s"} indexed.</div>
    </div>
  );
}

function WhoamiBlock({ who }) {
  const html = useMemo(() => renderMarkdown(who.descriptions || ""), [who]);
  return (
    <div className="line work-detail">
      {who.avatar ? (
        <div className="img-out" style={{ maxWidth: 140 }}>
          <img
            src={who.avatar}
            alt={who.name}
            style={{ maxWidth: 128, width: "100%", height: "auto" }}
            onError={(e) => { e.currentTarget.style.display = "none"; }}
            onLoad={() => {
              const term = document.querySelector(".term");
              if (term) term.scrollTop = term.scrollHeight;
            }}
          />
        </div>
      ) : null}
      <div className="work-meta">
        <span className="hot" style={{ fontSize: 36, lineHeight: 1.1 }}>{who.name}</span>
      </div>
      <div>&nbsp;</div>
      <div className="md-body" dangerouslySetInnerHTML={{ __html: html }} />
    </div>
  );
}

function BlogsListBlock({ page, totalPages }) {
  const start = (page - 1) * BLOGS_PER_PAGE;
  const slice = BLOGS.slice(start, start + BLOGS_PER_PAGE);
  return (
    <div className="line">
      <div className="dim">my blogs — type `blog &lt;id&gt;` to read</div>
      <div>&nbsp;</div>
      <div className="works-table">
        {slice.map(b => (
          <React.Fragment key={b.id}>
            <div><span className="hot">{b.id}</span></div>
            <div>{b.title}</div>
            <div className="dim">{b.date || ""}</div>
          </React.Fragment>
        ))}
      </div>
      <div>&nbsp;</div>
      <div className="dim">
        page {page} / {totalPages} ── {BLOGS.length} entr{BLOGS.length === 1 ? "y" : "ies"} indexed
        {totalPages > 1 ? `  ──  next: \`blogs ${page < totalPages ? page + 1 : 1}\`` : ""}
      </div>
    </div>
  );
}

function BlogDetailBlock({ blog }) {
  const [md, setMd] = useState(null);
  const [err, setErr] = useState(null);
  useEffect(() => {
    let cancelled = false;
    fetch(`blogs/${blog.file}`, { cache: "no-cache" })
      .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.text(); })
      .then(t => {
        if (cancelled) return;
        setMd(t);
        // markdown landed after layout — re-pin the cmd line to the top
        const term = document.querySelector(".term");
        if (!term) return;
        const cmds = term.querySelectorAll(".cmd-line");
        const cmdEl = cmds[cmds.length - 1];
        if (cmdEl) {
          const top = cmdEl.getBoundingClientRect().top - term.getBoundingClientRect().top + term.scrollTop;
          term.scrollTop = top;
        }
      })
      .catch(e => { if (!cancelled) setErr(e.message || String(e)); });
    return () => { cancelled = true; };
  }, [blog]);

  const html = useMemo(() => (md ? renderMarkdown(md) : ""), [md]);
  return (
    <div className="line work-detail">
      <div className="work-meta">
        <span className="hot">┌─ {blog.title}</span>
        <span className="dim"> [{blog.id}]</span>
      </div>
      {blog.date ? <div className="work-meta dim">└─ {blog.date}</div> : null}
      <div>&nbsp;</div>
      {err ? (
        <div>blog: failed to load <span className="dim">({err})</span></div>
      ) : md == null ? (
        <div className="dim">loading…</div>
      ) : (
        <div className="md-body" dangerouslySetInnerHTML={{ __html: html }} />
      )}
    </div>
  );
}

function WorkDetailBlock({ work }) {
  const html = useMemo(() => renderMarkdown(work.detail || ""), [work]);
  return (
    <div className="line work-detail">
      <div className="work-meta">
        <span className="hot">┌─ {work.title}</span>
        <span className="dim"> [{work.id}]</span>
      </div>
      <div className="work-meta dim">└─ {work.description}</div>
      <div>&nbsp;</div>
      {work.image ? (
        <>
          <div className="img-out">
            <img src={work.image} alt={work.title} onError={(e) => { e.currentTarget.style.display = "none"; }} />
          </div>
          <div className="img-cap">{work.image}</div>
          <div>&nbsp;</div>
        </>
      ) : null}
      <div className="md-body" dangerouslySetInnerHTML={{ __html: html }} />
    </div>
  );
}

// Render boot lines that include the multi-line skull
function BootLine({ text, cls }) {
  if (text.includes("\n")) {
    return <pre className={`pre ${cls || ""}`} style={{ margin: 0 }}>{text}</pre>;
  }
  return <div className={`line ${cls || ""}`}>{text === "" ? "\u00A0" : text}</div>;
}

// ============================================================
// Prompt — old-school bash style: [user@host pwd]$
// ============================================================
function Prompt({ path = "~" }) {
  return (
    <span>
      {"["}
      <span>lxb@fedora</span>
      <span> </span>
      <span>{path}</span>
      {"]$ "}
    </span>
  );
}

// ============================================================
// Main terminal
// ============================================================
function Terminal() {
  // history of [{type:'boot'|'cmd'|'output', ...}]
  const [items, setItems] = useState([]);
  const [input, setInput] = useState("");
  const [caret, setCaret] = useState(0);
  const [cmdHistory, setCmdHistory] = useState([]);
  const [histIdx, setHistIdx] = useState(-1);  // -1 means "live input"
  const [bootDone, setBootDone] = useState(false);
  const [focused, setFocused] = useState(true);

  const inputRef = useRef(null);
  const termRef = useRef(null);

  // Boot sequence
  useEffect(() => {
    loadWorks();
    loadWhoami();
    loadBlogs();
    const timers = [];
    BOOT.forEach((b, i) => {
      timers.push(setTimeout(() => {
        setItems(prev => [...prev, { type: "boot", text: b.text, cls: b.cls }]);
        if (i === BOOT.length - 1) setBootDone(true);
      }, b.t));
    });
    return () => timers.forEach(clearTimeout);
  }, []);

  // Auto-scroll on new items: long-form outputs (work/blog detail) pin their
  // cmd line to the top so markdown can be read from the start; everything
  // else snaps to bottom.
  const isPinned = (it) =>
    it && it.type === "output" && it.payload &&
    (it.payload.kind === "work-detail" || it.payload.kind === "blog-detail");

  useEffect(() => {
    if (!termRef.current) return;
    const term = termRef.current;
    const last = items[items.length - 1];
    if (isPinned(last)) {
      const cmds = term.querySelectorAll(".cmd-line");
      const cmdEl = cmds[cmds.length - 1];
      if (cmdEl) {
        const top = cmdEl.getBoundingClientRect().top - term.getBoundingClientRect().top + term.scrollTop;
        term.scrollTop = top;
      }
      return;
    }
    term.scrollTop = term.scrollHeight;
  }, [items]);

  // While typing, follow the bottom — but never override a pinned long-form output.
  useEffect(() => {
    if (!termRef.current) return;
    if (isPinned(items[items.length - 1])) return;
    termRef.current.scrollTop = termRef.current.scrollHeight;
  }, [input]);

  // Keep focus
  const refocus = useCallback(() => {
    if (inputRef.current) inputRef.current.focus();
  }, []);
  useEffect(() => { refocus(); }, [refocus, bootDone]);
  useEffect(() => {
    const onClick = () => refocus();
    document.addEventListener("click", onClick);
    return () => document.removeEventListener("click", onClick);
  }, [refocus]);

  const submit = (raw) => {
    const result = runCommand(raw);
    setItems(prev => {
      const next = [...prev, { type: "cmd", text: raw }];
      if (!result) return next;
      if (result.kind === "clear") return [];
      return [...next, { type: "output", payload: result }];
    });
    if (raw.trim()) {
      setCmdHistory(prev => [...prev, raw]);
    }
    setHistIdx(-1);
    setInput("");
    setCaret(0);
  };

  const recallHistory = (val) => {
    setInput(val);
    setCaret(val.length);
  };

  const onKeyDown = (e) => {
    if (e.key === "Enter") {
      e.preventDefault();
      submit(input);
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      if (cmdHistory.length === 0) return;
      const newIdx = histIdx === -1 ? cmdHistory.length - 1 : Math.max(0, histIdx - 1);
      setHistIdx(newIdx);
      recallHistory(cmdHistory[newIdx]);
    } else if (e.key === "ArrowDown") {
      e.preventDefault();
      if (histIdx === -1) return;
      const newIdx = histIdx + 1;
      if (newIdx >= cmdHistory.length) {
        setHistIdx(-1);
        recallHistory("");
      } else {
        setHistIdx(newIdx);
        recallHistory(cmdHistory[newIdx]);
      }
    } else if (e.key === "l" && (e.ctrlKey || e.metaKey)) {
      e.preventDefault();
      setItems([]);
    } else if (e.key === "c" && e.ctrlKey) {
      e.preventDefault();
      setItems(prev => [...prev, { type: "cmd", text: input + "^C" }]);
      setInput("");
      setCaret(0);
      setHistIdx(-1);
    }
  };

  const syncCaret = (e) => {
    const pos = e.target.selectionStart;
    if (pos != null) setCaret(pos);
  };

  return (
    <>
      <div className="term" ref={termRef} onClick={refocus}>
        {items.map((it, i) => {
          if (it.type === "boot") {
            return <BootLine key={i} text={it.text} cls={it.cls} />;
          }
          if (it.type === "cmd") {
            return (
              <div key={i} className="line prompt-line cmd-line">
                <Prompt />
                <span>{it.text}</span>
              </div>
            );
          }
          if (it.type === "output") {
            const p = it.payload;
            if (p.kind === "neofetch") return <NeofetchBlock key={i} lines={p.lines} />;
            if (p.kind === "image")        return <ImageBlock      key={i} src={p.src} caption={p.caption} />;
            if (p.kind === "lines")        return <LinesBlock      key={i} lines={p.lines} />;
            if (p.kind === "themes")       return <ThemesBlock     key={i} />;
            if (p.kind === "works-list")   return <WorksListBlock  key={i} />;
            if (p.kind === "work-detail")  return <WorkDetailBlock key={i} work={p.work} />;
            if (p.kind === "whoami")       return <WhoamiBlock     key={i} who={p.who} />;
            if (p.kind === "blogs-list")   return <BlogsListBlock  key={i} page={p.page} totalPages={p.totalPages} />;
            if (p.kind === "blog-detail")  return <BlogDetailBlock key={i} blog={p.blog} />;
          }
          return null;
        })}

        {/* live prompt */}
        {bootDone && (
          <div className="line prompt-line">
            <Prompt />
            <span className="input-wrap" style={{ position: "relative" }}>
              <span className="typed">{input.slice(0, caret)}</span>
              <span className={`cursor ${focused ? "" : "idle"}`} />
              <span className="typed">{input.slice(caret)}</span>
              <input
                ref={inputRef}
                className="hidden-input"
                type="text"
                autoFocus
                spellCheck="false"
                autoCapitalize="off"
                autoComplete="off"
                value={input}
                onChange={e => {
                  setInput(e.target.value);
                  setCaret(e.target.selectionStart ?? e.target.value.length);
                  setHistIdx(-1);
                }}
                onKeyDown={onKeyDown}
                onKeyUp={syncCaret}
                onClick={syncCaret}
                onSelect={syncCaret}
                onFocus={(e) => { setFocused(true); syncCaret(e); }}
                onBlur={() => setFocused(false)}
              />
            </span>
          </div>
        )}
      </div>
    </>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<Terminal />);
