const tokenKey = "alas.remote.token";
const $ = (id) => document.getElementById(id);
function listen(id, event, handler) {
  const element = $(id);
  if (element && typeof element.addEventListener === "function") element.addEventListener(event, handler);
}
let ws, currentSession = null, messages = new Map();
let sessionTitles = new Map();
let canDrive = false, canDriveKnown = false;
let reconnectDelay = 1500;
let reconnectTimer = null;
let connectAttempt = 0;
let pairingPromise = null;
let pairingController = null;
const initialReconnectDelay = 1500;
const maxReconnectDelay = 30000;
let dismissedQuestion = null;   // {sessionId, requestId} the user closed; suppress re-shows of that exact prompt (ids aren't unique across sessions)
let lastSentText = null;        // text of the most recent sendPrompt, kept so a server promptRejected can restore it instead of losing the message
let lastSentAttachments = [];   // images of the most recent sendPrompt, restored alongside the text on promptRejected
let sessionConfig = null;       // {models,modes,currentModel,currentMode,autoRunEnabled,acceptsImages} for the open session, or null
let pendingAttachments = [];    // [{name, mimeType, dataBase64}] staged for the next sendPrompt
let renameTarget = null;
const ATTACH_CAP = 10 * 1000 * 1000;   // 10 MB running total — matches the server's maxAttachmentsBytes

// state ∈ {connecting, ok, bad} drives the chip's dot/border color via [data-state].
function setStatus(s, state) { const e = $("status"); e.textContent = s; e.dataset.state = state || "connecting"; }
function showGate(title, msg, retry) {
  $("gate-title").textContent = title;
  $("gate-msg").textContent = msg;
  $("gate-retry").classList.toggle("hidden", !retry);
  $("gate").classList.remove("hidden");
}
function hideGate() { $("gate").classList.add("hidden"); }

function showUnreachableGate() {
  showGate("Can't reach Alas", "Make sure your Mac is awake, Alas is running, and this device is on the same Wi-Fi or tailnet.", true);
}

function scheduleReconnect() {
  if (reconnectTimer) clearTimeout(reconnectTimer);
  reconnectTimer = setTimeout(() => {
    reconnectTimer = null;
    connect();
  }, reconnectDelay);
  reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay);
}

function retryConnection() {
  if (reconnectTimer) {
    clearTimeout(reconnectTimer);
    reconnectTimer = null;
  }
  if (pairingController) {
    pairingController.abort();
    pairingController = null;
    pairingPromise = null;
  }
  reconnectDelay = initialReconnectDelay;
  setStatus("Connecting...", "connecting");
  connect();
}

async function ensureToken() {
  // A freshly-scanned QR (?code present) always re-pairs, REPLACING any stored
  // token — otherwise a phone holding a stale/rejected token could never
  // recover by scanning a new code (it would keep reusing the dead token).
  const code = new URLSearchParams(location.search).get("code");
  if (code) {
    if (!pairingPromise) {
      const controller = new AbortController();
      pairingController = controller;
      pairingPromise = pairWithCode(code, controller.signal).finally(() => {
        if (pairingController === controller) pairingController = null;
        pairingPromise = null;
      });
    }
    return await pairingPromise;
  }
  const token = localStorage.getItem(tokenKey);
  if (token) return token;
  showGate("Pair this device", "On your Mac, open Alas → Settings → Remote and scan the QR code shown there.");
  throw new Error("no code");
}

async function pairWithCode(code, signal) {
  let res;
  try {
    res = await fetch("/pair", { method: "POST", body: JSON.stringify({ code, deviceName: navigator.userAgent.slice(0, 40) }), signal });
  } catch (err) {
    if (err && err.name === "AbortError") throw new Error("aborted");
    showUnreachableGate();
    throw new Error("net");
  }
  if (!res.ok) {
    localStorage.removeItem(tokenKey);
    showGate("Pairing link expired", "That code timed out. In Alas, open Settings → Remote, tap “New code”, and scan the fresh QR.");
    throw new Error("pair failed");
  }
  const token = (await res.json()).token;
  localStorage.setItem(tokenKey, token);
  history.replaceState({}, "", "/");   // strip code from URL (history + referrer)
  return token;
}

async function connect() {
  const attempt = ++connectAttempt;
  let token;
  try {
    token = await ensureToken();
  } catch (err) {
    if (attempt !== connectAttempt) return;
    if (err && err.message === "net") scheduleReconnect();
    return;   // no code/token yet (or pairing failed); status is set — wait for the user
  }
  if (attempt !== connectAttempt) return;
  if (ws) { try { ws.close(); } catch (_) {} }   // drop any prior (possibly half-open) socket
  const wsScheme = location.protocol === "https:" ? "wss:" : "ws:";
  const socket = new WebSocket(`${wsScheme}//${location.host}/ws`, [token]);   // token as subprotocol
  ws = socket;
  socket.onopen = () => {
    if (socket !== ws) return;
    setStatus("Connected", "ok");
    hideGate();
    if (reconnectTimer) {
      clearTimeout(reconnectTimer);
      reconnectTimer = null;
    }
    reconnectDelay = initialReconnectDelay;   // reset back-off after a good connection
    send({ type: "listSessions" });
    if (currentSession) send({ type: "subscribe", sessionId: currentSession });   // re-sync after reconnect
  };
  socket.onclose = () => {
    if (socket !== ws) return;
    setStatus("Reconnecting...", "bad");
    showUnreachableGate();
    scheduleReconnect();
  };
  socket.onmessage = (e) => {
    if (socket !== ws) return;
    handle(JSON.parse(e.data));
  };
}

function send(obj) { ws && ws.readyState === 1 && ws.send(JSON.stringify(obj)); }

function handle(msg) {
  switch (msg.type) {
    case "sessionList": renderSessions(msg.sessions); break;
    case "transcriptSnapshot": messages = new Map(); msg.messages.forEach(m => messages.set(m.stableId, m)); if (msg.sessionId === currentSession) { canDrive = msg.canDrive; canDriveKnown = true; renderMessages(true); renderDriveBar(msg.streamingState); } break;
    // The server re-sends the full transcript each time, keyed by stable position
    // ids, so replace (don't append) to avoid accumulating duplicate copies.
    case "transcriptDelta": messages = new Map(); msg.upserts.forEach(m => messages.set(m.stableId, m)); if (msg.sessionId === currentSession) { canDrive = msg.canDrive; canDriveKnown = true; renderMessages(false); renderDriveBar(msg.streamingState); } break;
    // Scope prompt events to the session currently open — a stale/in-flight
    // event for a session the user already left must not pop or close a sheet.
    case "permissionRequest": if (msg.sessionId === currentSession) showPermission(msg.sessionId, msg.payload); break;
    case "permissionResolved": if (msg.sessionId === currentSession) hidePermission(); break;
    case "questionRequest": if (msg.sessionId === currentSession) showQuestion(msg.sessionId, msg.payload); break;
    case "questionResolved": if (msg.sessionId === currentSession) { dismissedQuestion = null; hideQuestion(); } break;
    case "sessionConfig": if (msg.sessionId === currentSession) { sessionConfig = msg; renderConfigAffordances(); } break;
    case "sessionRenamed": applySessionRenamed(msg.sessionId, msg.title); break;
    case "sessionClosed": if (msg.sessionId === currentSession) showSessions(); break;
    case "promptRejected": if (msg.sessionId === currentSession) restoreRejectedPrompt(); break;
    case "error": setStatus("Error", "bad"); $("status").title = msg.message ?? ""; break;
    default: console.warn("unknown message type", msg.type);
  }
}

function renderSessions(sessions) {
  const list = $("session-list"); list.innerHTML = "";
  sessionTitles = new Map(sessions.map(s => [s.id, s.title]));
  sessions.forEach(s => list.appendChild(renderSessionRow(s)));
  if (currentSession) setDetailTitle(currentSession);
}

function renderSessionRow(s) {
  const row = document.createElement("div");
  row.dataset.sessionId = s.id;
  row.className = "session-row";

  const open = document.createElement("button");
  open.type = "button";
  open.className = "session-open";
  open.onclick = () => openSession(s.id);

  const head = el("div", "session-head");
  const title = el("span", "session-title", s.title);
  const status = el("span", "status", s.status);
  head.append(title, status);
  open.append(head);

  const rename = el("button", "rename-btn", "✎");
  rename.type = "button";
  rename.setAttribute("aria-label", "Rename session");
  rename.onclick = () => showRenameSheet(s.id);

  if (s.worktree) {
    row.classList.add("session-row-card");
    open.append(el("div", "session-worktree", `${s.worktree.projectName} / ${s.worktree.worktreeName}`));
    const meta = el("div", "session-meta");
    const parts = sessionMetaParts(s.worktree);
    parts.forEach(part => meta.append(part));
    open.append(meta);
    row.title = s.worktree.path || "";
  } else {
    row.classList.add("session-row-minimal");
  }

  row.append(open, rename);
  return row;
}

function sessionMetaParts(worktree) {
  if (!worktree.metricsAvailable) return [el("span", "", "changes unavailable")];

  const parts = [];
  if (worktree.commitCount > 0) parts.push(el("span", "", plural(worktree.commitCount, "commit")));
  if (worktree.conflictCount > 0) parts.push(el("span", "meta-conflict", plural(worktree.conflictCount, "conflict")));
  if (worktree.changedFileCount > 0) parts.push(el("span", "", plural(worktree.changedFileCount, "file")));

  const line = [];
  if (worktree.addedLines > 0) line.push(el("span", "meta-add", "+" + worktree.addedLines));
  if (worktree.deletedLines > 0) line.push(el("span", "meta-del", "-" + worktree.deletedLines));
  if (line.length) {
    const group = el("span", "meta-lines");
    line.forEach(item => group.append(item));
    parts.push(group);
  }

  return parts.length ? parts : [el("span", "", "clean")];
}

function plural(count, singular) {
  return `${count} ${singular}${count === 1 ? "" : "s"}`;
}

function openSession(id) {
  currentSession = id; messages = new Map(); dismissedQuestion = null; canDrive = false; canDriveKnown = false;
  sessionConfig = null; clearAttachments();
  $("back").classList.remove("hidden"); $("nav-title").classList.add("hidden");   // bar shows ‹ Sessions
  $("detail-title").classList.remove("hidden"); $("detail-rename").classList.remove("hidden"); setDetailTitle(id);
  $("sessions").classList.add("hidden"); $("transcript").classList.remove("hidden");
  $("messages").innerHTML = ""; renderConfigAffordances(); renderDriveBar("idle"); send({ type: "subscribe", sessionId: id });
}
function showSessions() {
  if (currentSession) send({ type: "unsubscribe", sessionId: currentSession });
  currentSession = null; canDrive = false; canDriveKnown = false;
  sessionConfig = null; clearAttachments(); hideConfig(); renderConfigAffordances();
  hidePermission(); hideQuestion(); hideRenameSheet();          // never leave a sheet over the list
  $("back").classList.add("hidden"); $("nav-title").classList.remove("hidden");   // bar shows app title
  $("detail-title").classList.add("hidden"); $("detail-rename").classList.add("hidden");
  $("drivebar").classList.add("hidden");
  $("transcript").classList.add("hidden"); $("sessions").classList.remove("hidden");
  send({ type: "listSessions" });
}

function setDetailTitle(sessionId) {
  $("detail-title").textContent = sessionTitles.get(sessionId) || "Session";
}

function showRenameSheet(sessionId) {
  renameTarget = sessionId;
  const input = $("rename-input");
  input.value = sessionTitles.get(sessionId) || "";
  $("rename-sheet").classList.remove("hidden");
  requestAnimationFrame(() => { input.focus(); input.select(); });
}

function hideRenameSheet() {
  $("rename-sheet").classList.add("hidden");
  renameTarget = null;
}

function submitRename() {
  if (!renameTarget) return;
  const title = $("rename-input").value.trim();
  if (!title) return;
  send({ type: "renameSession", sessionId: renameTarget, title });
  hideRenameSheet();
}

function applySessionRenamed(sessionId, title) {
  sessionTitles.set(sessionId, title);
  const row = Array.from(document.querySelectorAll("[data-session-id]"))
    .find(candidate => candidate.dataset.sessionId === sessionId);
  const rowTitle = row && row.querySelector(".session-title");
  if (rowTitle) rowTitle.textContent = title;
  if (currentSession === sessionId) setDetailTitle(sessionId);
  if (renameTarget === sessionId) $("rename-input").value = title;
}

function renderMessages(forceBottom) {
  const box = $("messages");
  // #messages is the scroll container (fixed shell), so use its own metrics.
  const atBottom = forceBottom || (box.scrollTop + box.clientHeight >= box.scrollHeight - 120);
  // Preserve which tool/thought cards the user expanded across re-renders.
  const open = new Set();
  box.querySelectorAll(".m-collapsible.is-open").forEach(d => { if (d.dataset.sid) open.add(d.dataset.sid); });
  box.innerHTML = "";
  for (const [sid, m] of messages) box.appendChild(renderMessage(m, sid, open));
  if (atBottom) requestAnimationFrame(() => { box.scrollTop = box.scrollHeight; });
}

function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function jparse(s) { try { return JSON.parse(s); } catch { return null; } }

function linkifyBareUrls(text) {
  if (!text || (!text.includes("http://") && !text.includes("https://"))) return text || "";
  return rewriteMarkdownBareUrls(text, true);
}

function rewriteMarkdownBareUrls(text, preserveFencedCodeBlocks) {
  if (!preserveFencedCodeBlocks) return rewriteInlineBareUrls(text);

  let out = "";
  let lineStart = 0;
  let openingFence = null;
  let openingHtmlBlockTag = null;
  let allowsIndentedCodeBlock = true;
  let referenceDefinitionContinuation = null;
  const inlineState = {
    codeSpanDelimiterLength: null,
    referenceLabels: markdownReferenceLabels(text),
  };

  while (lineStart < text.length) {
    const newline = text.indexOf("\n", lineStart);
    const lineEnd = newline === -1 ? text.length : newline + 1;
    const line = text.slice(lineStart, lineEnd);

    if (openingFence) {
      out += line;
      const closingFence = closingCodeFenceDelimiter(line);
      const closedFence = closingFence && closesCodeFence(closingFence, openingFence);
      if (closedFence) openingFence = null;
      allowsIndentedCodeBlock = !!closedFence;
    } else if (openingHtmlBlockTag) {
      out += line;
      const closedHtmlBlock = closingRawHtmlBlockTag(line, openingHtmlBlockTag) ||
        rawHtmlBlockEndsAtBlankLine(line, openingHtmlBlockTag);
      if (closedHtmlBlock) openingHtmlBlockTag = null;
      allowsIndentedCodeBlock = closedHtmlBlock;
    } else if (inlineState.codeSpanDelimiterLength == null &&
               referenceDefinitionContinuation === "destination" &&
               markdownReferenceDefinitionDestinationContinuationLine(line)) {
      out += line;
      referenceDefinitionContinuation = "title";
      allowsIndentedCodeBlock = false;
    } else if (inlineState.codeSpanDelimiterLength == null &&
               referenceDefinitionContinuation === "title" &&
               markdownReferenceDefinitionTitleContinuationLine(line)) {
      out += line;
      referenceDefinitionContinuation = null;
      allowsIndentedCodeBlock = false;
    } else if (inlineState.codeSpanDelimiterLength == null) {
      const referenceDefinition = markdownReferenceDefinition(line);
      if (referenceDefinition) {
        out += line;
        referenceDefinitionContinuation = referenceDefinition.hasDestination ? "title" : "destination";
        allowsIndentedCodeBlock = false;
      } else {
        referenceDefinitionContinuation = null;
        const lineFence = openingCodeFenceDelimiter(line);
        if (lineFence) {
          out += line;
          openingFence = lineFence;
          allowsIndentedCodeBlock = false;
        } else {
          const htmlBlockTag = openingRawHtmlBlockTag(line);
          if (htmlBlockTag) {
            out += line;
            if (!closingRawHtmlBlockTag(line, htmlBlockTag)) openingHtmlBlockTag = htmlBlockTag;
            allowsIndentedCodeBlock = !openingHtmlBlockTag;
          } else if (allowsIndentedCodeBlock && markdownIndentedCodeBlockLine(line)) {
            out += line;
            allowsIndentedCodeBlock = true;
          } else {
            out += rewriteInlineBareUrls(line, inlineState, text, lineEnd);
            allowsIndentedCodeBlock = markdownAllowsIndentedCodeBlockAfterLine(line);
          }
        }
      }
    } else {
      out += rewriteInlineBareUrls(line, inlineState, text, lineEnd);
      allowsIndentedCodeBlock = markdownAllowsIndentedCodeBlockAfterLine(line);
    }

    lineStart = lineEnd;
  }

  return out;
}

function rewriteInlineBareUrls(text, state, remainingTextSource, remainingTextStart) {
  const inlineState = state || { codeSpanDelimiterLength: null };
  let out = "";
  let i = 0;

  while (i < text.length) {
    if (inlineState.codeSpanDelimiterLength != null) {
      if (text[i] === "`") {
        const closingLength = repeatedCharacterRunLength(text, i, "`");
        if (closingLength === inlineState.codeSpanDelimiterLength) {
          out += text.slice(i, i + closingLength);
          i += closingLength;
          inlineState.codeSpanDelimiterLength = null;
          continue;
        }
      }

      out += text[i];
      i += 1;
      continue;
    }

    if (text[i] === "`") {
      const delimiterLength = repeatedCharacterRunLength(text, i, "`");
      if (isFenceLikeBacktickDelimiterLine(text, i, delimiterLength)) {
        out += text.slice(i, i + delimiterLength);
        i += delimiterLength;
        continue;
      }

      const end = codeSpanEnd(text, i);
      if (end !== -1) {
        out += text.slice(i, end + 1);
        i = end + 1;
        continue;
      }

      out += text.slice(i, i + delimiterLength);
      i += delimiterLength;
      const remainingTextAfterSegment = remainingTextSource == null ? text.slice(i) : remainingTextSource.slice(remainingTextStart);
      if (hasCodeSpanEnd(remainingTextAfterSegment, delimiterLength)) {
        inlineState.codeSpanDelimiterLength = delimiterLength;
      }
      continue;
    }

    if (text[i] === "<" && startsWithWebScheme(text, i + 1)) {
      const end = text.indexOf(">", i + 1);
      if (end !== -1) {
        out += text.slice(i, end + 1);
        i = end + 1;
        continue;
      }
    }

    if (text[i] === "<") {
      const end = rawHtmlTagEnd(text, i);
      if (end !== -1) {
        out += text.slice(i, end + 1);
        i = end + 1;
        continue;
      }
    }

    if (text[i] === "[") {
      const end = markdownBracketedLinkEnd(text, i, inlineState.referenceLabels);
      if (end !== -1) {
        out += text.slice(i, end + 1);
        i = end + 1;
        continue;
      }
    }

    if (startsWithWebScheme(text, i) && hasValidBoundaryBefore(text, i)) {
      const rawEnd = rawUrlEnd(text, i);
      const trimmedEnd = trimmedUrlEnd(text, i, rawEnd);
      if (trimmedEnd > i) {
        const urlText = text.slice(i, trimmedEnd);
        if (isValidBareWebURL(urlText)) {
          out += "<" + urlText + ">";
          out += text.slice(trimmedEnd, rawEnd);
          i = rawEnd;
          continue;
        }
      }
    }

    out += text[i];
    i += 1;
  }

  return out;
}

function openingCodeFenceDelimiter(line) {
  return codeFenceDelimiter(line, true);
}

function closingCodeFenceDelimiter(line) {
  return codeFenceDelimiter(line, false);
}

function codeFenceDelimiter(line, allowsTrailingText) {
  let contentEnd = line.length;
  while (contentEnd > 0 && /\s/.test(line[contentEnd - 1])) contentEnd -= 1;

  let i = 0;
  let leadingSpaces = 0;
  while (i < contentEnd && line[i] === " ") {
    leadingSpaces += 1;
    i += 1;
  }
  if (leadingSpaces > 3 || i >= contentEnd) return null;

  const marker = line[i];
  if (marker !== "`" && marker !== "~") return null;

  const length = repeatedCharacterRunLength(line, i, marker);
  i += length;

  if (!allowsTrailingText && i !== contentEnd) return null;
  if (marker === "`" && line.slice(i, contentEnd).includes("`")) return null;
  return length >= 3 ? { marker, length } : null;
}

function closesCodeFence(candidate, openingFence) {
  return candidate.marker === openingFence.marker && candidate.length >= openingFence.length;
}

function markdownBlankLine(line) {
  return /^[\s]*$/.test(line);
}

function markdownAllowsIndentedCodeBlockAfterLine(line) {
  return markdownBlankLine(line) || markdownAtxHeadingLine(line) || markdownThematicBreakLine(line);
}

function markdownAtxHeadingLine(line) {
  let i = 0;
  let leadingSpaces = 0;
  while (i < line.length && line[i] === " ") {
    leadingSpaces += 1;
    if (leadingSpaces > 3) return false;
    i += 1;
  }

  let markerCount = 0;
  while (i < line.length && line[i] === "#" && markerCount < 6) {
    markerCount += 1;
    i += 1;
  }
  if (markerCount === 0) return false;
  if (i >= line.length) return true;
  return /\s/.test(line[i]);
}

function markdownThematicBreakLine(line) {
  let i = 0;
  let leadingSpaces = 0;
  while (i < line.length && line[i] === " ") {
    leadingSpaces += 1;
    if (leadingSpaces > 3) return false;
    i += 1;
  }

  let marker = null;
  let markerCount = 0;
  while (i < line.length) {
    const ch = line[i];
    if (ch === "\n" || ch === "\r") break;
    if (ch === " " || ch === "\t") {
      i += 1;
      continue;
    }
    if (marker === null) {
      if (ch !== "-" && ch !== "_" && ch !== "*") return false;
      marker = ch;
    }
    if (ch !== marker) return false;
    markerCount += 1;
    i += 1;
  }

  return markerCount >= 3;
}

function markdownIndentedCodeBlockLine(line) {
  let leadingSpaces = 0;
  for (let i = 0; i < line.length; i += 1) {
    if (line[i] === "\t") return true;
    if (line[i] !== " ") return false;
    leadingSpaces += 1;
    if (leadingSpaces >= 4) return true;
  }
  return false;
}

const rawHtmlBlockTags = new Set([
  "address", "article", "aside", "base", "basefont", "blockquote", "body",
  "caption", "center", "col", "colgroup", "dd", "details", "dialog", "dir",
  "div", "dl", "dt", "fieldset", "figcaption", "figure", "footer", "form",
  "frame", "frameset", "h1", "h2", "h3", "h4", "h5", "h6", "head", "header",
  "hr", "html", "iframe", "legend", "li", "link", "main", "menu", "menuitem",
  "nav", "noframes", "ol", "optgroup", "option", "p", "param", "section",
  "summary", "table", "tbody", "td", "tfoot", "th", "thead", "title", "tr",
  "track", "ul", "pre", "script", "style",
]);

const rawHtmlVoidBlockTags = new Set([
  "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta",
  "param", "source", "track", "wbr",
]);

const rawHtmlExplicitCloseBlockTags = new Set(["pre", "script", "style"]);

function openingRawHtmlBlockTag(line) {
  let i = 0;
  let leadingSpaces = 0;
  while (i < line.length && line[i] === " ") {
    leadingSpaces += 1;
    i += 1;
  }
  if (leadingSpaces > 3 || line[i] !== "<") return null;

  const rest = line.slice(i);
  if (rest.startsWith("<!--")) return "#comment";
  if (rest.startsWith("<?")) return "#processing-instruction";
  if (rest.startsWith("<![CDATA[")) return "#cdata";
  if (/^<![A-Z]/.test(rest)) return "#declaration";

  const match = rest.match(/^<([A-Za-z][A-Za-z0-9-]*)(?:\s|>|\/>)/);
  if (!match) return null;

  const tag = match[1].toLowerCase();
  if (!rawHtmlBlockTags.has(tag) || rawHtmlVoidBlockTags.has(tag)) return null;
  return tag;
}

function rawHtmlBlockEndsAtBlankLine(line, tag) {
  return !tag.startsWith("#") && !rawHtmlExplicitCloseBlockTags.has(tag) && markdownBlankLine(line);
}

function closingRawHtmlBlockTag(line, tag) {
  if (tag === "#comment") return line.includes("-->");
  if (tag === "#processing-instruction") return line.includes("?>");
  if (tag === "#cdata") return line.includes("]]>");
  if (tag === "#declaration") return line.includes(">");
  return new RegExp("</\\s*" + escapeRegExp(tag) + "\\s*>", "i").test(line);
}

function escapeRegExp(text) {
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function repeatedCharacterRunLength(text, start, character) {
  let count = 0;
  let i = start;
  while (i < text.length && text[i] === character) {
    count += 1;
    i += 1;
  }
  return count;
}

function isFenceLikeBacktickDelimiterLine(text, start, delimiterLength) {
  if (delimiterLength < 3) return false;

  let lineStart = start;
  while (lineStart > 0 && text[lineStart - 1] !== "\n") lineStart -= 1;
  for (let i = lineStart; i < start; i += 1) {
    if (text[i] !== " ") return false;
  }

  let lineEnd = start;
  while (lineEnd < text.length && text[lineEnd] !== "\n") lineEnd += 1;
  return !text.slice(start + delimiterLength, lineEnd).includes("`");
}

function codeSpanEnd(text, start) {
  const delimiterLength = repeatedCharacterRunLength(text, start, "`");
  let i = start + delimiterLength;
  let currentLineIsBlank = false;

  while (i < text.length) {
    const ch = text[i];
    if (ch === "\n") {
      if (currentLineIsBlank) return -1;
      currentLineIsBlank = true;
      i += 1;
    } else if (/\s/.test(ch)) {
      i += 1;
    } else if (ch === "`") {
      currentLineIsBlank = false;
      const closingLength = repeatedCharacterRunLength(text, i, "`");
      if (closingLength === delimiterLength) return i + closingLength - 1;
      i += closingLength;
    } else {
      currentLineIsBlank = false;
      i += 1;
    }
  }

  return -1;
}

function hasCodeSpanEnd(text, delimiterLength) {
  let i = 0;
  let currentLineIsBlank = true;

  while (i < text.length) {
    const ch = text[i];
    if (ch === "\n") {
      if (currentLineIsBlank) return false;
      currentLineIsBlank = true;
      i += 1;
    } else if (/\s/.test(ch)) {
      i += 1;
    } else if (ch === "`") {
      currentLineIsBlank = false;
      const closingLength = repeatedCharacterRunLength(text, i, "`");
      if (closingLength === delimiterLength) return true;
      i += closingLength;
    } else {
      currentLineIsBlank = false;
      i += 1;
    }
  }

  return false;
}

function rawHtmlTagEnd(text, start) {
  if (!isRawHtmlTagStart(text, start)) return -1;

  let quote = null;
  for (let i = start + 1; i < text.length; i += 1) {
    const ch = text[i];
    if (quote) {
      if (ch === quote) quote = null;
    } else if (ch === "\"" || ch === "'") {
      quote = ch;
    } else if (ch === ">") {
      return i;
    }
  }

  return -1;
}

function isRawHtmlTagStart(text, start) {
  if (start >= text.length || text[start] !== "<" || start + 1 >= text.length) return false;
  const next = text[start + 1];
  return /[A-Za-z!/]/.test(next) || next === "?";
}

function startsWithWebScheme(text, i) {
  return text.startsWith("https://", i) || text.startsWith("http://", i);
}

function hasValidBoundaryBefore(text, i) {
  if (i === 0) return true;
  const previous = text[i - 1];
  if (/[*_~]/.test(previous)) {
    let delimiterRunStart = i - 1;
    while (delimiterRunStart > 0 && text[delimiterRunStart - 1] === previous) delimiterRunStart -= 1;
    if (delimiterRunStart === 0) return true;
    return /\s|[\(\[\{"']/.test(text[delimiterRunStart - 1]);
  }
  return /\s|[\(\[\{"']/.test(previous);
}

function rawUrlEnd(text, start) {
  let i = start;
  while (i < text.length && !/[\s<>\x00-\x1F\x7F]/.test(text[i])) {
    if (text[i] === "]" && text[i + 1] === "[") break;
    i += 1;
  }
  return i;
}

function trimmedUrlEnd(text, start, rawEnd) {
  let end = rawEnd;
  const surplusClosings = unbalancedClosingSurplus(text, start, rawEnd);
  const emphasisDelimiterRun = markdownEmphasisDelimiterRunBeforeUrlStart(text, start);
  let remainingEmphasisDelimitersToTrim = emphasisDelimiterRun ? emphasisDelimiterRun.length : 0;
  while (end > start) {
    const ch = text[end - 1];
    if (emphasisDelimiterRun &&
        remainingEmphasisDelimitersToTrim > 0 &&
        ch === emphasisDelimiterRun.delimiter) {
      remainingEmphasisDelimitersToTrim -= 1;
      end -= 1;
      continue;
    }
    if (".,;:!?\"'".includes(ch)) {
      end -= 1;
      continue;
    }
    if (surplusClosings[ch] > 0) {
      surplusClosings[ch] -= 1;
      end -= 1;
      continue;
    }
    break;
  }
  return end;
}

function markdownEmphasisDelimiterRunBeforeUrlStart(text, start) {
  if (start === 0) return null;
  const delimiter = text[start - 1];
  if (!/[*_~]/.test(delimiter)) return null;
  let length = 1;
  for (let i = start - 2; i >= 0 && text[i] === delimiter; i -= 1) length += 1;
  return { delimiter, length };
}

function unbalancedClosingSurplus(text, start, end) {
  const counts = {
    "(": 0, ")": 0,
    "[": 0, "]": 0,
    "{": 0, "}": 0,
    "<": 0, ">": 0,
  };
  for (let i = start; i < end; i += 1) {
    if (counts[text[i]] != null) counts[text[i]] += 1;
  }
  return {
    ")": Math.max(0, counts[")"] - counts["("]),
    "]": Math.max(0, counts["]"] - counts["["]),
    "}": Math.max(0, counts["}"] - counts["{"]),
    ">": Math.max(0, counts[">"] - counts["<"]),
  };
}

function isValidBareWebURL(text) {
  let hostStart;
  if (text.startsWith("https://")) {
    hostStart = "https://".length;
  } else if (text.startsWith("http://")) {
    hostStart = "http://".length;
  } else {
    return false;
  }

  if (hostStart >= text.length) return false;
  let hostEnd = text.length;
  for (const marker of ["/", "?", "#"]) {
    const markerIndex = text.indexOf(marker, hostStart);
    if (markerIndex !== -1) hostEnd = Math.min(hostEnd, markerIndex);
  }
  return /[A-Za-z0-9]/.test(text.slice(hostStart, hostEnd));
}

function markdownBracketedLinkEnd(text, start, referenceLabels) {
  const labelEnd = markdownLabelEnd(text, start);
  if (labelEnd === -1) return -1;

  const label = normalizeMarkdownReferenceLabel(text.slice(start + 1, labelEnd));
  if (referenceLabels && referenceLabels.has(label)) return labelEnd;

  const next = labelEnd + 1;
  if (next >= text.length || text.slice(next).trim() === "") {
    return referenceLabels && referenceLabels.has(label) ? labelEnd : -1;
  }

  if (text[next] === "(") {
    const destinationEnd = markdownDestinationEnd(text, next);
    if (destinationEnd !== -1) return destinationEnd;
  }

  if (text[next] === "[") {
    const referenceEnd = markdownLabelEnd(text, next);
    if (referenceEnd !== -1) {
      const referenceLabel = text.slice(next + 1, referenceEnd);
      const normalizedReference = normalizeMarkdownReferenceLabel(referenceLabel) || label;
      return referenceLabels && referenceLabels.has(normalizedReference) ? referenceEnd : -1;
    }
  }

  return -1;
}

function markdownReferenceLabels(text) {
  const labels = new Set();
  let lineStart = 0;
  let openingFence = null;
  let openingHtmlBlockTag = null;
  let pendingDefinitionLabel = null;

  while (lineStart < text.length) {
    const newline = text.indexOf("\n", lineStart);
    const lineEnd = newline === -1 ? text.length : newline + 1;
    const line = text.slice(lineStart, lineEnd);

    if (openingFence) {
      const closingFence = closingCodeFenceDelimiter(line);
      if (closingFence && closesCodeFence(closingFence, openingFence)) openingFence = null;
    } else if (openingHtmlBlockTag) {
      if (closingRawHtmlBlockTag(line, openingHtmlBlockTag) ||
          rawHtmlBlockEndsAtBlankLine(line, openingHtmlBlockTag)) {
        openingHtmlBlockTag = null;
      }
    } else if (pendingDefinitionLabel !== null) {
      if (markdownReferenceDefinitionDestinationContinuationLine(line)) {
        labels.add(normalizeMarkdownReferenceLabel(pendingDefinitionLabel));
      }
      pendingDefinitionLabel = null;
    } else {
      const lineFence = openingCodeFenceDelimiter(line);
      if (lineFence) {
        openingFence = lineFence;
      } else {
        const htmlBlockTag = openingRawHtmlBlockTag(line);
        if (htmlBlockTag) {
          if (!closingRawHtmlBlockTag(line, htmlBlockTag)) openingHtmlBlockTag = htmlBlockTag;
          lineStart = lineEnd;
          continue;
        }

        const definition = markdownReferenceDefinition(line);
        if (definition) {
          if (definition.hasDestination) {
            labels.add(normalizeMarkdownReferenceLabel(definition.label));
          } else {
            pendingDefinitionLabel = definition.label;
          }
        }
      }
    }

    lineStart = lineEnd;
  }

  return labels;
}

function markdownReferenceDefinitionLabel(line) {
  const definition = markdownReferenceDefinition(line);
  return definition ? definition.label : null;
}

function markdownReferenceDefinition(line) {
  let i = 0;
  let leadingSpaces = 0;
  while (i < line.length && line[i] === " ") {
    leadingSpaces += 1;
    i += 1;
  }
  if (leadingSpaces > 3 || line[i] !== "[") return null;

  const labelEnd = markdownLabelEnd(line, i);
  if (labelEnd === -1 || line[labelEnd + 1] !== ":") return null;

  const label = line.slice(i + 1, labelEnd);
  if (!normalizeMarkdownReferenceLabel(label)) return null;

  const afterColon = line.slice(labelEnd + 2).trim();
  if (afterColon.length === 0) return { label, hasDestination: false };
  if (!markdownReferenceDefinitionDestinationContent(afterColon)) return null;
  return { label, hasDestination: true };
}

function markdownReferenceDefinitionDestinationContinuationLine(line) {
  return markdownReferenceDefinitionDestinationContent(line.trim());
}

function markdownReferenceDefinitionTitleContinuationLine(line) {
  const content = indentedReferenceDefinitionContinuationContent(line);
  if (content === null) return false;

  return markdownReferenceDefinitionTitleContent(content.trim());
}

function markdownReferenceDefinitionDestinationContent(content) {
  if (!content) return false;

  let i = 0;
  if (content[i] === "<") {
    i += 1;
    let isEscaped = false;
    while (i < content.length) {
      const ch = content[i];
      if (isEscaped) {
        isEscaped = false;
      } else if (ch === "\\") {
        isEscaped = true;
      } else if (ch === ">") {
        const rest = content.slice(i + 1).trim();
        return rest.length === 0 || markdownReferenceDefinitionTitleContent(rest);
      } else if (ch === "\n" || ch === "\r") {
        return false;
      }
      i += 1;
    }
    return false;
  }

  while (i < content.length && !/\s/.test(content[i])) i += 1;
  if (i === 0) return false;

  const rest = content.slice(i).trim();
  return rest.length === 0 || markdownReferenceDefinitionTitleContent(rest);
}

function markdownReferenceDefinitionTitleContent(content) {
  if (!content) return false;

  const opener = content[0];
  const closer = opener === "(" ? ")" : opener;
  if (opener !== "\"" && opener !== "'" && opener !== "(") return false;

  let isEscaped = false;
  for (let j = 1; j < content.length; j += 1) {
    const ch = content[j];
    if (isEscaped) {
      isEscaped = false;
    } else if (ch === "\\") {
      isEscaped = true;
    } else if (ch === closer) {
      return j === content.length - 1;
    }
  }

  return false;
}

function indentedReferenceDefinitionContinuationContent(line) {
  let contentEnd = line.length;
  while (contentEnd > 0 && /\s/.test(line[contentEnd - 1])) contentEnd -= 1;

  let i = 0;
  let leadingWhitespace = 0;
  while (i < contentEnd && (line[i] === " " || line[i] === "\t")) {
    leadingWhitespace += 1;
    i += 1;
  }
  if (leadingWhitespace === 0 || i >= contentEnd) return null;
  return line.slice(i, contentEnd);
}

function normalizeMarkdownReferenceLabel(label) {
  return label.trim().replace(/\s+/g, " ").toLowerCase();
}

function markdownLabelEnd(text, start) {
  if (start >= text.length || text[start] !== "[") return -1;

  let depth = 0;
  let isEscaped = false;
  for (let i = start; i < text.length; i += 1) {
    const ch = text[i];
    if (isEscaped) {
      isEscaped = false;
    } else if (ch === "\\") {
      isEscaped = true;
    } else if (ch === "[") {
      depth += 1;
    } else if (ch === "]") {
      depth -= 1;
      if (depth === 0) return i;
    }
  }

  return -1;
}

function markdownDestinationEnd(text, start) {
  if (start >= text.length || text[start] !== "(") return -1;

  let depth = 1;
  let isEscaped = false;
  let quote = null;
  let isAngleBracketDestination = false;
  let hasDestinationContent = false;
  let canStartTitleQuote = false;

  for (let i = start + 1; i < text.length; i += 1) {
    const ch = text[i];
    if (isEscaped) {
      isEscaped = false;
    } else if (ch === "\\") {
      isEscaped = true;
    } else if (quote) {
      if (ch === quote) quote = null;
    } else if (isAngleBracketDestination) {
      if (ch === ">") isAngleBracketDestination = false;
      hasDestinationContent = true;
      canStartTitleQuote = false;
    } else if (canStartTitleQuote && (ch === "\"" || ch === "'")) {
      quote = ch;
      canStartTitleQuote = false;
    } else if (ch === "<" && !hasDestinationContent && depth === 1) {
      isAngleBracketDestination = true;
      hasDestinationContent = true;
      canStartTitleQuote = false;
    } else if (ch === "(") {
      depth += 1;
      hasDestinationContent = true;
      canStartTitleQuote = false;
    } else if (ch === ")") {
      depth -= 1;
      if (depth === 0) return i;
      hasDestinationContent = true;
      canStartTitleQuote = false;
    } else if (/\s/.test(ch)) {
      if (hasDestinationContent && depth === 1) canStartTitleQuote = true;
    } else {
      if (canStartTitleQuote) return -1;
      hasDestinationContent = true;
      canStartTitleQuote = false;
    }
  }

  return -1;
}

// Render markdown to sanitized HTML (agent/user prose is untrusted — DOMPurify
// strips any script/event-handler injection). Falls back to escaped plain text
// if the libraries didn't load.
function md(text) {
  if (typeof marked === "undefined" || typeof DOMPurify === "undefined") {
    const d = document.createElement("div"); d.textContent = text || ""; return d.innerHTML;
  }
  return DOMPurify.sanitize(marked.parse(linkifyBareUrls(text), { gfm: true, breaks: true }));
}
function cap(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; }

const TOOL_VERB = { read: "Read", search: "Searched", execute: "Ran", run: "Ran", edit: "Edit" };
const TOOL_STATUS = { completed: ["✓", "ok"], failed: ["✕", "err"], in_progress: ["•", "run"], pending: ["•", "run"], canceled: ["■", "run"], cancelled: ["■", "run"] };

// Mirrors the native ACP pane: plain agent prose, accent user bubbles, a
// collapsed "Thinking…" row, and collapsed tool/structured cards.
function renderMessage(m, sid, open) {
  let node;
  if (m.kind === "user" || m.kind === "agent") {
    node = el("div", "msg md m-" + m.kind);
    node.innerHTML = md(m.text);            // markdown-rendered like the native pane
  } else if (m.kind === "systemNotice") {
    node = el("div", "msg m-systemNotice", m.text || "");
  } else if (m.kind === "thought") {
    node = thoughtCard(m.text || "");
  } else if (m.kind === "toolCall") {
    node = toolCard(jparse(m.json) || {});
  } else if (m.kind === "fileEdit") {
    const o = jparse(m.json) || {};
    node = structCard("Edit", o.path || o.title || "file", o.diff || o.content || m.json || "");
  } else if (m.kind === "plan") {
    node = structCard("Plan", "", planText(jparse(m.json)) || m.json || "");
  } else {
    node = el("div", "msg m-agent", m.text || "");
  }
  if (node.classList?.contains("m-collapsible")) {            // restore prior expand state
    node.dataset.sid = sid;
    if (open && open.has(sid)) setCardOpen(node, true);
  }
  return node;
}

function thoughtCard(text) {
  const d = el("div", "msg m-thought m-collapsible");
  const button = el("button", "thought-toggle", "Thinking…");
  button.type = "button";
  button.dataset.cardToggle = "true";
  button.setAttribute("aria-expanded", "false");
  const body = el("div", "thought-body", text);
  body.dataset.cardBody = "true";
  body.hidden = true;
  button.onclick = () => setCardOpen(d, !d.classList.contains("is-open"));
  d.append(button, body);
  return d;
}

function toolCard(tc) {
  const verb = TOOL_VERB[tc.kind] || (tc.kind ? cap(tc.kind) : "Tool");
  const name = tc.title || (tc.locations && tc.locations[0]) || "";
  const card = structCard(verb, name && name.toLowerCase() !== verb.toLowerCase() ? name : "",
                          toolBody(tc), tc.preview);
  const [ch, scls] = TOOL_STATUS[tc.status] || [tc.status || "", "run"];
  card.querySelector(".tool-chev").insertAdjacentElement("beforebegin", el("span", "tool-status " + scls, ch));
  return card;
}

function toolBody(tc) {
  if (typeof tc.content === "string" && tc.content) return tc.content;
  const rows = [
    ["toolCallId", tc.toolCallId],
    ["title", tc.title],
    ["kind", tc.kind],
    ["status", tc.status],
    ["preview", tc.preview],
    ["locations", Array.isArray(tc.locations) ? tc.locations.join("\n") : ""],
    ["terminalIds", Array.isArray(tc.terminalIds) ? tc.terminalIds.join("\n") : ""]
  ].filter(([, value]) => typeof value === "string" && value.length > 0);
  if (!rows.length) return "Tool invoked.";
  return rows.map(([key, value]) => `${key}: ${value}`).join("\n");
}

function structCard(verb, name, body, preview) {
  const d = el("div", "msg m-tool m-collapsible");
  const button = el("button", "tool-toggle");
  button.type = "button";
  button.dataset.cardToggle = "true";
  button.setAttribute("aria-expanded", "false");
  button.append(el("span", "tool-verb", verb));
  button.append(el("span", "tool-name", name || ""));
  button.append(el("span", "tool-preview", preview || ""));
  button.append(el("span", "tool-chev", "⌄"));
  const content = el("pre", "tool-body", body || "");
  content.dataset.cardBody = "true";
  content.hidden = true;
  button.onclick = () => setCardOpen(d, !d.classList.contains("is-open"));
  d.append(button, content);
  return d;
}

function setCardOpen(card, isOpen) {
  card.classList.toggle("is-open", isOpen);
  const body = card.querySelector("[data-card-body]");
  if (body) body.hidden = !isOpen;
  const toggle = card.querySelector("[data-card-toggle]");
  if (toggle) toggle.setAttribute("aria-expanded", isOpen ? "true" : "false");
}

function planText(o) {
  if (Array.isArray(o)) return o.map(it => "• " + (it.content || it.title || JSON.stringify(it)) + (it.status ? "  (" + it.status + ")" : "")).join("\n");
  return o ? JSON.stringify(o, null, 2) : "";
}

let permState = null;
function showPermission(sessionId, payload) {
  permState = { sessionId, requestId: payload.requestId };
  $("perm-tool").textContent = "Allow “" + payload.toolName + "”?";
  const box = $("perm-options"); box.innerHTML = "";
  payload.options.forEach(o => {
    const b = document.createElement("button");
    b.textContent = o.name;
    b.className = o.kind.startsWith("allow") ? "btn-allow" : "btn-deny";
    b.onclick = () => {
      // "once" kinds send null so the server reproduces the local prompt's
      // mapping (once → don't persist / re-ask next time); only "*_always"
      // persists. Sending "session" here would silently auto-approve later calls.
      send({ type: "permissionDecision", sessionId, requestId: payload.requestId, optionId: o.optionId, persistScope: o.kind.endsWith("always") ? "project" : null });
      hidePermission();
    };
    box.appendChild(b);
  });
  $("permission").classList.remove("hidden");
}
function hidePermission() { $("permission").classList.add("hidden"); permState = null; }

// --- Question sheet ---
let questionState = null;
// Map<questionId, Set<optionId>> — tracks selected option ids per question.
let questionSelections = new Map();

function showQuestion(sessionId, payload) {
  const questions = payload.questions || [];
  if (questions.length === 0) { hideQuestion(); return; }   // nothing to answer — don't show a trapping empty sheet
  if (dismissedQuestion && dismissedQuestion.sessionId === sessionId && dismissedQuestion.requestId === payload.requestId) { hideQuestion(); return; }   // user already closed this exact prompt
  questionState = { sessionId, requestId: payload.requestId };
  questionSelections = new Map();

  const titleEl = $("question-title");
  if (payload.title) {
    titleEl.textContent = payload.title;
    titleEl.classList.remove("hidden");
  } else {
    titleEl.textContent = "";
    titleEl.classList.add("hidden");
  }

  const body = $("question-body");
  body.innerHTML = "";

  questions.forEach(q => {
    questionSelections.set(q.id, new Set());

    const block = document.createElement("div");
    block.className = "question-block";

    const prompt = document.createElement("p");
    prompt.className = "question-prompt";
    prompt.textContent = q.prompt;
    block.appendChild(prompt);

    q.options.forEach(o => {
      const btn = document.createElement("button");
      btn.className = "option-btn";
      btn.textContent = o.label;
      btn.onclick = () => {
        const sel = questionSelections.get(q.id);
        if (q.allowMultiple) {
          if (sel.has(o.id)) {
            sel.delete(o.id);
            btn.classList.remove("is-selected");
          } else {
            sel.add(o.id);
            btn.classList.add("is-selected");
          }
        } else {
          // Single-select: deselect all siblings, then select this one.
          const allBtns = block.querySelectorAll(".option-btn");
          sel.clear();
          allBtns.forEach(b => b.classList.remove("is-selected"));
          sel.add(o.id);
          btn.classList.add("is-selected");
        }
        updateSubmitState();
      };
      block.appendChild(btn);
    });

    body.appendChild(block);
  });

  updateSubmitState();
  $("question").classList.remove("hidden");
}

function updateSubmitState() {
  // Submit is enabled only when every question has at least one selected option.
  let complete = questionSelections.size > 0;
  questionSelections.forEach(sel => { if (sel.size === 0) complete = false; });
  $("question-submit").disabled = !complete;
}

function submitQuestion() {
  if (!questionState) return;
  const answers = [];
  questionSelections.forEach((sel, questionId) => {
    answers.push({ questionId, selectedOptionIds: Array.from(sel) });
  });
  send({ type: "questionAnswer", sessionId: questionState.sessionId, requestId: questionState.requestId, answers });
  hideQuestion();
}

function hideQuestion() {
  $("question").classList.add("hidden");
  questionState = null;
  questionSelections = new Map();
}

function dismissQuestion() {
  if (questionState) dismissedQuestion = { sessionId: questionState.sessionId, requestId: questionState.requestId };   // keep this exact prompt dismissed across re-sends
  hideQuestion();
}
function renderDriveBar(streamingState) {
  // Keep the whole bar hidden until the first snapshot tells us the real
  // canDrive — otherwise the take-over banner flashes while opening a session
  // we actually own, and an empty bar strip shows before any state arrives.
  const known = !!currentSession && canDriveKnown;
  $("drivebar").classList.toggle("hidden", !known);
  if (!known) return;
  // The composer is always available — typing/acting as a non-writer
  // auto-takes the wheel (ensureWriter). The take-over PILL only appears when
  // we don't hold the lease, as a no-typing way to grab it.
  $("composer").classList.remove("hidden");
  $("takeover").classList.toggle("hidden", canDrive);
  // A turn is interruptible in every non-idle state — including while it's
  // blocked on a permission/question prompt — mirroring the native composer
  // (composerAction returns .stop for sending/streaming/awaiting*). Without
  // the awaiting states, dismissing a prompt sheet would strand the user on
  // Send with no way to cancel the running turn.
  const busy = streamingState !== "idle";
  $("send").classList.toggle("hidden", busy);
  $("stop").classList.toggle("hidden", !busy);
}

// takeOver seizes the lease synchronously server-side and messages are ordered,
// so a follow-up action sent right after lands as the writer.
function ensureWriter() {
  if (!canDrive && currentSession) send({ type: "takeOver", sessionId: currentSession });
}

function autoGrowPrompt() {
  const ta = $("prompt");
  ta.style.height = "auto";                          // shrink back before measuring
  ta.style.height = Math.min(ta.scrollHeight, window.innerHeight * 0.4) + "px";
}
function sendPrompt() {
  const ta = $("prompt");
  const text = ta.value.trim();
  if (!currentSession) return;
  if (!text && pendingAttachments.length === 0) return;   // nothing to send
  ensureWriter();                                    // grab the wheel first; ordered before the prompt
  send({ type: "sendPrompt", sessionId: currentSession, text, attachments: pendingAttachments });
  lastSentText = text;                               // keep until the server accepts (or rejects) it
  lastSentAttachments = pendingAttachments;          // ditto for the staged images
  ta.value = "";
  clearAttachments();
  autoGrowPrompt();                                  // collapse back to one row
}

// The server dropped our prompt (lease went stale, materialize failed, etc.).
// Put the text AND staged images back so the message isn't silently lost — but
// only if the user hasn't started composing a new one.
function restoreRejectedPrompt() {
  const ta = $("prompt");
  // Only restore if the user hasn't started a new message — no new text AND no
  // newly-staged images — otherwise we'd splice the failed prompt's content
  // into their new draft.
  const composingNew = !!ta.value || pendingAttachments.length > 0;
  if (!composingNew) {
    if (lastSentText) { ta.value = lastSentText; autoGrowPrompt(); }
    if (lastSentAttachments.length) { pendingAttachments = lastSentAttachments.slice(); renderChips(); }
  }
  lastSentText = null;
  lastSentAttachments = [];
}

// --- Session config sheet (⚙) + attachments (📎) ---
// Show/hide the 📎 button and rebuild the config sheet from the latest
// sessionConfig. Called on every sessionConfig and when entering/leaving a
// session so affordances reflect the open session (or nothing).
function renderConfigAffordances() {
  const cfg = sessionConfig;
  $("attach").classList.toggle("hidden", !(cfg && cfg.acceptsImages));
  renderConfigSheet();
}

function renderConfigSheet() {
  const cfg = sessionConfig;
  const models = (cfg && cfg.models) || [];
  const modes = (cfg && cfg.modes) || [];
  $("cfg-model-section").classList.toggle("hidden", models.length === 0);
  $("cfg-mode-section").classList.toggle("hidden", modes.length === 0);

  const fill = (box, items, current, act) => {
    box.innerHTML = "";
    items.forEach(it => {
      const btn = el("button", "option-btn", it.name);
      if (it.id === current) btn.classList.add("is-selected");
      btn.onclick = () => { ensureWriter(); act(it.id); };
      box.appendChild(btn);
    });
  };
  fill($("cfg-models"), models, cfg && cfg.currentModel, id => send({ type: "setModel", sessionId: currentSession, modelId: id }));
  fill($("cfg-modes"), modes, cfg && cfg.currentMode, id => send({ type: "setMode", sessionId: currentSession, modeId: id }));
  $("cfg-autorun").checked = !!(cfg && cfg.autoRunEnabled);
}

function showConfig() { renderConfigSheet(); $("cfg").classList.remove("hidden"); }
function hideConfig() { $("cfg").classList.add("hidden"); }

// --- Attachments ---
function b64Bytes(b64) { return Math.floor(b64.length * 3 / 4); }   // decoded size estimate, good enough for the cap
function attachedBytes() { return pendingAttachments.reduce((n, a) => n + b64Bytes(a.dataBase64), 0); }

function clearAttachments() { pendingAttachments = []; renderChips(); }

function renderChips() {
  const box = $("chips");
  box.innerHTML = "";
  pendingAttachments.forEach((a, i) => {
    const chip = el("div", "chip");
    const img = el("img");
    img.src = "data:" + a.mimeType + ";base64," + a.dataBase64;
    img.alt = a.name;
    const x = el("button", "chip-x", "✕");
    x.setAttribute("aria-label", "Remove attachment");
    x.onclick = () => { pendingAttachments.splice(i, 1); renderChips(); };
    chip.append(img, x);
    box.appendChild(chip);
  });
  box.classList.toggle("hidden", pendingAttachments.length === 0);
}

function readAttachment(file) {
  return new Promise((resolve, reject) => {
    const r = new FileReader();
    r.onload = () => {
      const s = String(r.result);
      const comma = s.indexOf(",");                  // strip the "data:<mime>;base64," prefix
      resolve(comma >= 0 ? s.slice(comma + 1) : s);
    };
    r.onerror = () => reject(r.error);
    r.readAsDataURL(file);
  });
}

async function onFilesPicked(files) {
  for (const file of files) {
    const b64 = await readAttachment(file);
    if (attachedBytes() + b64Bytes(b64) > ATTACH_CAP) {
      alert("Attachments can total at most 10 MB.");
      break;
    }
    pendingAttachments.push({ name: file.name, mimeType: file.type, dataBase64: b64 });
    renderChips();
  }
}

$("config").onclick = showConfig;
$("cfg-close").onclick = hideConfig;
$("cfg").onclick = (e) => { if (e.target.id === "cfg") hideConfig(); };
$("cfg-autorun").onchange = (e) => { ensureWriter(); send({ type: "setAutoRun", sessionId: currentSession, enabled: e.target.checked }); };
$("detail-rename").onclick = () => { if (currentSession) showRenameSheet(currentSession); };
$("rename-submit").onclick = submitRename;
$("rename-cancel").onclick = hideRenameSheet;
$("rename-sheet").onclick = (e) => { if (e.target.id === "rename-sheet") hideRenameSheet(); };
listen("rename-input", "keydown", (e) => {
  if (e.key === "Enter") { e.preventDefault(); submitRename(); }
  if (e.key === "Escape") { e.preventDefault(); hideRenameSheet(); }
});
$("attach").onclick = () => $("file").click();
$("file").onchange = async (e) => {
  const files = Array.from(e.target.files || []);
  $("file").value = "";                              // reset so the same file can be re-picked
  await onFilesPicked(files);
};

$("takeover").onclick = () => { if (currentSession) send({ type: "takeOver", sessionId: currentSession }); };
$("send").onclick = sendPrompt;
$("stop").onclick = () => { if (!currentSession) return; ensureWriter(); send({ type: "stop", sessionId: currentSession }); };
listen("prompt", "input", autoGrowPrompt);
listen("prompt", "keydown", (e) => {
  if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendPrompt(); }
});

$("question-submit").onclick = submitQuestion;
// Explicit Close + backdrop tap — a sheet can always be dismissed, and a closed
// question stays closed even if the server keeps re-sending it.
$("question-close").onclick = dismissQuestion;
$("perm-close").onclick = hidePermission;
$("question").onclick = (e) => { if (e.target.id === "question") dismissQuestion(); };
$("permission").onclick = (e) => { if (e.target.id === "permission") hidePermission(); };

$("back").onclick = showSessions;
$("gate-retry").onclick = retryConnection;

// iOS overlays the keyboard without shrinking the layout viewport, so a
// 100dvh shell ends up taller than the visible area when the field is focused.
// Drive the shell height from the *visual* viewport (which does shrink for the
// keyboard) and keep the transcript pinned to the bottom as it resizes.
const vp = window.visualViewport;
if (vp) {
  const syncViewport = () => {
    // Pin the fixed shell to the visual viewport's box: height shrinks for the
    // keyboard, and top follows offsetTop so the shell doesn't slide off-screen
    // when iOS scrolls the page to reveal the focused field.
    document.body.style.height = vp.height + "px";
    document.body.style.top = vp.offsetTop + "px";
    const box = $("messages");
    if (box) box.scrollTop = box.scrollHeight;
  };
  vp.addEventListener("resize", syncViewport);
  vp.addEventListener("scroll", syncViewport);
  syncViewport();
}

connect();
