// ── Symi UI — Live Gateway App ────────────────────────────────────────

const responseArea = document.getElementById("response-area");
const promptInput = document.getElementById("prompt-input");
const sendBtn = document.getElementById("send-btn");
const newSessionBtn = document.getElementById("new-session-btn");
const stopBtn = document.getElementById("stop-btn");

let isStreaming = false;
let workingActive = false;
let activeSubagentCount = 0;
let currentRunId = null;
let streamBubble = null;
let streamContent = null;
let streamStart = 0;

// ── Feed cache (localStorage fallback) ──────────────────────────
const FEED_CACHE_KEY = "symi:feed-cache";
const FEED_CACHE_MAX = 200; // max messages to cache

const feedCache = {
  save(messages) {
    try {
      const trimmed = Array.isArray(messages) ? messages.slice(-FEED_CACHE_MAX) : [];
      localStorage.setItem(FEED_CACHE_KEY, JSON.stringify(trimmed));
    } catch {
      // localStorage full or unavailable — ignore
    }
  },
  load() {
    try {
      const raw = localStorage.getItem(FEED_CACHE_KEY);
      return raw ? JSON.parse(raw) : [];
    } catch {
      return [];
    }
  },
  append(message) {
    try {
      const cached = this.load();
      cached.push(message);
      this.save(cached);
    } catch {
      // ignore
    }
  },
  clear() {
    try {
      localStorage.removeItem(FEED_CACHE_KEY);
    } catch {
      // ignore
    }
  },
};

// ── Lazy-loading pagination state ────────────────────────────────
let historyStartIndex = 0; // index of first loaded message in full transcript
let historyHasMore = false; // server says there are older messages
let isLoadingMore = false; // guard against concurrent fetches

// ── Prompt queue ──────────────────────────────────────────────────
let chatQueue = []; // [{id, text}]

// ── Watchdog + elapsed timer state ────────────────────────────────
let watchdogTimer = null; // fires if no activity for WATCHDOG_MS
let elapsedTimer = null; // ticks every second to show run duration
let activityStart = 0; // timestamp of when current run began
let elapsedBaseText = ""; // base sub-text for the elapsed display
let WATCHDOG_MS = 300000; // 300 seconds of silence → failure (profile-overridable)
// Track time of last upstream agent/chat event so the elapsed-timer tick can
// surface a "still reasoning" hint when reasoning runs silently for >5s
// (some models batch reasoning without streaming deltas; the orb timer alone
// looks frozen even though work is happening).
let lastEventTs = 0;
const EXTENDED_SILENCE_MS = 5000;

// ── Model profile state (updated via "profile" gateway event) ────
let activeProfile = null;
// NOTE: Client-side dedup removed — server-side per-client seq tracking in
// glass-ui-ws.ts prevents duplicate broadcasts.  See Phase 5 of v3 plan.

// ── Utility ───────────────────────────────────────────────────────
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function escapeHtml(t) {
  return t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// Alias used by panel scripts (subagents.js, connections.js, scheduling.js)
window.escHtml = escapeHtml;

// ── Status indicator (top-right of response area) ─────────────────
const statusEl = document.createElement("div");
statusEl.className = "conn-status";
statusEl.innerHTML =
  '<span class="conn-dot dot-yellow pulse"></span><span class="conn-label">connecting…</span>';
responseArea.before(statusEl);

function setStatus(state) {
  // 'connecting' | 'online' | 'offline'
  const dot = statusEl.querySelector(".conn-dot");
  const label = statusEl.querySelector(".conn-label");
  dot.className = `conn-dot ${state === "online" ? "dot-green pulse" : state === "offline" ? "dot-red" : "dot-yellow pulse"}`;
  label.textContent =
    state === "online" ? "live" : state === "offline" ? "disconnected" : "connecting…";
}

// ── Render helpers ─────────────────────────────────────────────────
function addUserMessage(text) {
  const msg = document.createElement("div");
  msg.className = "message user-msg";
  msg.innerHTML = `<div class="bubble">${escapeHtml(text)}</div><div class="message-clearfix"></div>`;
  responseArea.appendChild(msg);
  msg.scrollIntoView({ behavior: "smooth", block: "end" });
}

// ── Thinking bubble ────────────────────────────────────────────────
function createThinkingBubble() {
  const msg = document.createElement("div");
  msg.className = "message symi-msg thinking-msg";
  msg.innerHTML = `
    <div class="bubble thinking-bubble">
      <div class="think-header">
        <span class="think-badge">◈ PROCESSING</span>
        <span class="think-dots"><span>.</span><span>.</span><span>.</span></span>
      </div>
      <div class="think-bar-wrap"><div class="think-bar"></div></div>
    </div>
    <div class="message-clearfix"></div>
  `;
  responseArea.appendChild(msg);
  msg.scrollIntoView({ behavior: "smooth", block: "end" });
  return msg;
}

// ── Live thinking → Reasoning panel ───────────────────────────────
// Buffers thinking deltas per run and flushes to the Reasoning panel as
// a single entry on thinking_end.  Keeps the main response bubble clean
// (it only receives real text deltas) while surfacing the reasoning in
// the dedicated panel.
const thinkingBuffers = new Map();

function handleThinkingAgentEvent(evt) {
  if (!evt || !evt.data) {
    return;
  }
  const runId = evt.runId ?? "live";
  const phase = evt.data.phase;
  const delta = typeof evt.data.delta === "string" ? evt.data.delta : "";
  const content = typeof evt.data.content === "string" ? evt.data.content : "";

  if (phase === "thinking_start") {
    thinkingBuffers.set(runId, "");
    return;
  }
  if (phase === "thinking_delta") {
    // Prefer appending deltas; fall back to the full content field if the
    // provider only sends cumulative content.
    const prev = thinkingBuffers.get(runId) ?? "";
    thinkingBuffers.set(runId, delta ? prev + delta : content || prev);
    return;
  }
  if (phase === "thinking_end") {
    const buffered = (thinkingBuffers.get(runId) ?? "").trim();
    thinkingBuffers.delete(runId);
    if (buffered && typeof window.appendToReasoningPanel === "function") {
      window.appendToReasoningPanel({ type: "thinking", thinking: buffered });
    }
  }
}

// ── Stream bubble lifecycle ────────────────────────────────────────
function openStreamBubble() {
  streamStart = Date.now();
  const msg = document.createElement("div");
  msg.className = "message symi-msg stream-msg";
  msg.innerHTML = `<div class="bubble stream-bubble streaming"><div class="bubble-content"></div></div><div class="message-clearfix"></div>`;
  responseArea.appendChild(msg);
  streamBubble = msg.querySelector(".stream-bubble");
  streamContent = msg.querySelector(".bubble-content");
  msg.scrollIntoView({ behavior: "smooth", block: "end" });
  return msg;
}

function updateStream(text) {
  if (!streamContent) {
    return;
  }
  streamContent.textContent += text;
  responseArea.scrollTop = responseArea.scrollHeight;
}

function closeStreamBubble(model) {
  if (!streamBubble) {
    return;
  }
  streamBubble.classList.remove("streaming");
  streamBubble.classList.add("done");

  // Apply full markdown rendering to the final text.  Build the replacement
  // BEFORE wiping the existing content so a failing/empty render can't leave
  // the bubble visibly blank ("response appeared then disappeared").
  const rawText = streamContent?.textContent ?? "";
  if (streamContent && rawText) {
    let textBlock = null;
    try {
      textBlock = renderBlock({ type: "text", text: rawText });
    } catch (err) {
      console.warn("[app] renderBlock failed during closeStreamBubble:", err);
    }
    if (textBlock && textBlock.textContent.trim()) {
      streamContent.innerHTML = "";
      streamContent.appendChild(textBlock);
      window.applyHighlighting(streamContent);
    }
    // else: preserve original textContent — the plain stream is better than
    // an empty bubble.
  }

  const elapsed = Date.now() - streamStart;
  const chars = Math.round(rawText.length / 4);

  // Add copy button
  const copyBtn = document.createElement("button");
  copyBtn.className = "msg-copy-btn msg-copy-visible";
  copyBtn.title = "Copy";
  copyBtn.addEventListener("click", function () {
    window.copyMessage(this);
  });
  copyBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
    <rect x="9" y="9" width="13" height="13" rx="2" stroke="currentColor" stroke-width="2"/>
    <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="2"/>
  </svg>`;
  streamBubble.appendChild(copyBtn);

  const footer = document.createElement("div");
  footer.className = "telemetry";
  footer.innerHTML = `
    <span class="tele-item">✦ ${model.includes("/") ? model.split("/").slice(1).join("/") : model}</span>
    <span class="tele-sep">·</span>
    <span class="tele-item">${chars} tokens</span>
    <span class="tele-sep">·</span>
    <span class="tele-item">${elapsed}ms</span>
  `;
  streamBubble.appendChild(footer);
  requestAnimationFrame(() => {
    footer.classList.add("tele-visible");
    // Scroll to bottom immediately after markdown + footer are in the DOM
    responseArea.scrollTop = responseArea.scrollHeight;
  });

  // Second scroll after the fit-content width transition finishes (0.3s)
  setTimeout(() => {
    responseArea.scrollTop = responseArea.scrollHeight;
  }, 350);

  streamBubble = null;
  streamContent = null;
}

// ── Elapsed timer ─────────────────────────────────────────────────
function startElapsedTimer(baseText) {
  stopElapsedTimer();
  activityStart = Date.now();
  elapsedBaseText = baseText;
  lastEventTs = activityStart;
  elapsedTimer = setInterval(() => {
    const secs = Math.floor((Date.now() - activityStart) / 1000);
    const m = Math.floor(secs / 60);
    const s = String(secs % 60).padStart(2, "0");
    const silentMs = Date.now() - lastEventTs;
    const showHint = elapsedBaseText.startsWith("Reasoning") && silentMs > EXTENDED_SILENCE_MS;
    const hint = showHint ? " (still reasoning)" : "";
    if (asoSub) {
      asoSub.textContent = `${elapsedBaseText}${hint}  ${m}:${s}`;
    }
  }, 1000);
}

function stopElapsedTimer() {
  if (elapsedTimer) {
    clearInterval(elapsedTimer);
    elapsedTimer = null;
  }
  lastEventTs = 0;
}

// ── Watchdog ───────────────────────────────────────────────────────
function armWatchdog() {
  clearWatchdog();
  watchdogTimer = setTimeout(() => handleRunFailure("timeout"), WATCHDOG_MS);
}

function clearWatchdog() {
  if (watchdogTimer) {
    clearTimeout(watchdogTimer);
    watchdogTimer = null;
  }
}

// ── Unified run failure handler ────────────────────────────────────
// Called when: watchdog fires, gateway disconnects mid-run, or server
// sends an explicit error event. Cleans up all state and shows the user
// a clear error message + ERROR orb state.
function handleRunFailure(reason) {
  // Stop timers immediately
  stopElapsedTimer();
  clearWatchdog();
  if (typeof window.closeLiveThinking === "function") {
    window.closeLiveThinking();
  }

  // Clean up any in-flight UI elements
  if (thinkingEl) {
    thinkingEl.remove();
    thinkingEl = null;
  }
  if (streamBubble) {
    // Don't render markdown on a partial/failed response — just mark it failed
    streamBubble.classList.remove("streaming");
    streamBubble.classList.add("done", "stream-failed");
    streamBubble = null;
    streamContent = null;
  }

  // Human-readable reason labels
  const labels = {
    timeout: `No response received after ${WATCHDOG_MS / 1000}s — the agent may still be running in the background`,
    disconnected: "Connection lost mid-run — gateway disconnected",
  };
  const label = labels[reason] || reason;

  // Inject error bubble into the feed
  const errEl = document.createElement("div");
  errEl.className = "message symi-msg";
  errEl.innerHTML = `
    <div class="bubble stream-bubble done run-error-bubble">
      <div class="run-error-icon">⚠</div>
      <div class="bubble-content run-error-text">${escapeHtml(label)}</div>
    </div>
    <div class="message-clearfix"></div>
  `;
  responseArea.appendChild(errEl);
  requestAnimationFrame(() => {
    responseArea.scrollTop = responseArea.scrollHeight;
  });

  // Show ERROR state on orb, then reset input
  setAgentStatus("error");
  hideWorkingIndicator();
  isStreaming = false;
  currentRunId = null;
  stopBtn.style.opacity = "";
  stopBtn.disabled = false;
  enableInput();
  updateQueueBtn(); // keep queue button visible if items remain
}

// ── Render history (on first connect) ─────────────────────────────
function renderHistory(messages) {
  const isEmpty = !messages || messages.length === 0;
  const hasExistingContent = responseArea.querySelectorAll(".message").length > 0;

  // Guard: if server sends empty history but the DOM already has messages,
  // keep the existing feed — prevents blank screen on transient failures.
  if (isEmpty && hasExistingContent) {
    return;
  }

  // Fallback: server is empty and DOM is empty — try localStorage cache
  if (isEmpty) {
    const cached = feedCache.load();
    if (cached.length > 0) {
      messages = cached;
    } else {
      return; // nothing to render anywhere
    }
  } else {
    // Server sent real history — cache it for future fallback
    feedCache.save(messages);
  }

  // Clear reasoning panel in sync with the feed — prevents double-entries on reconnect
  if (typeof window.clearReasoningPanel === "function") {
    window.clearReasoningPanel();
  }
  responseArea.innerHTML = "";
  responseArea.prepend(loadMoreSentinel);
  for (const m of messages) {
    if (!m || !m.role) {
      continue;
    }

    // Route toolResult messages to the Reasoning Panel — they're tool output, not user-facing
    if (m.role === "toolResult" || m.role === "tool") {
      const content = Array.isArray(m.content) ? m.content : [];
      const text = content
        .map((b) => b.text ?? "")
        .join("\n")
        .trim();
      if (text && typeof window.appendToReasoningPanel === "function") {
        window.appendToReasoningPanel({
          type: "tool_result",
          content: m.content,
          is_error: m.isError === true,
        });
      }
      continue;
    }

    // Skip system/compaction messages entirely
    if (m.role === "system") {
      continue;
    }

    // Route plugin context user messages to Reasoning Panel
    if (m.role === "user") {
      const txt = Array.isArray(m.content)
        ? m.content
            .filter((b) => b.type === "text")
            .map((b) => b.text ?? "")
            .join("")
            .trim()
        : extractText(m.content).trim();
      if (/^\[.+\]\s+(Not connected|Connected as)\b/i.test(txt)) {
        if (typeof window.appendToReasoningPanel === "function") {
          window.appendToReasoningPanel({ type: "text", text: "[Plugin context]" });
        }
        continue;
      }
      if (/^Review your response against the original user request/i.test(txt)) {
        if (typeof window.appendToReasoningPanel === "function") {
          window.appendToReasoningPanel({ type: "text", text: "[Post-generation verification]" });
        }
        continue;
      }
    }

    const hasContent = Array.isArray(m.content)
      ? m.content.some((b) => b.type === "text" && (b.text ?? "").trim())
      : extractText(m.content).trim();
    if (!hasContent) {
      continue;
    }

    const el = window.renderMessage(m);
    if (!el) {
      continue;
    }
    responseArea.appendChild(el);
    window.applyHighlighting(el);
  }

  // Update sentinel visibility
  updateLoadMoreSentinel();

  // Scroll to very bottom after history renders
  requestAnimationFrame(() => {
    responseArea.scrollTop = responseArea.scrollHeight;
  });
}

// ── Lazy-loading: sentinel + IntersectionObserver ─────────────────
const loadMoreSentinel = document.createElement("div");
loadMoreSentinel.className = "load-more-sentinel";
loadMoreSentinel.id = "load-more-sentinel";
loadMoreSentinel.textContent = "Loading older messages…";
loadMoreSentinel.style.display = "none";
responseArea.prepend(loadMoreSentinel);

function updateLoadMoreSentinel() {
  loadMoreSentinel.style.display = historyHasMore ? "" : "none";
}

const loadMoreObserver = new IntersectionObserver(
  (entries) => {
    if (entries[0]?.isIntersecting && historyHasMore && !isLoadingMore) {
      void loadMoreHistory();
    }
  },
  { root: responseArea, threshold: 0.1 },
);
loadMoreObserver.observe(loadMoreSentinel);

async function loadMoreHistory() {
  if (isLoadingMore || !historyHasMore || historyStartIndex <= 0) {
    return;
  }
  isLoadingMore = true;
  loadMoreSentinel.textContent = "Loading older messages…";

  try {
    const result = await gateway.rpc("chat.history", {
      sessionKey: window.SESSION_KEY,
      limit: 100,
      before: historyStartIndex,
    });

    const msgs = result.messages ?? [];
    historyStartIndex = result.startIndex ?? 0;
    historyHasMore = result.hasMore ?? false;

    if (msgs.length === 0) {
      historyHasMore = false;
      updateLoadMoreSentinel();
      return;
    }

    // Remember scroll position so we can restore it after prepending
    const prevScrollHeight = responseArea.scrollHeight;
    const prevScrollTop = responseArea.scrollTop;

    // Build fragment of older messages (reuse same filtering as renderHistory)
    const frag = document.createDocumentFragment();
    for (const m of msgs) {
      if (!m || !m.role) {
        continue;
      }
      if (m.role === "toolResult" || m.role === "tool") {
        const content = Array.isArray(m.content) ? m.content : [];
        const text = content
          .map((b) => b.text ?? "")
          .join("\n")
          .trim();
        if (text && typeof window.appendToReasoningPanel === "function") {
          window.appendToReasoningPanel({
            type: "tool_result",
            content: m.content,
            is_error: m.isError === true,
          });
        }
        continue;
      }
      if (m.role === "system") {
        continue;
      }

      // Route plugin context & verification user messages to Reasoning Panel
      if (m.role === "user") {
        const txt = Array.isArray(m.content)
          ? m.content
              .filter((b) => b.type === "text")
              .map((b) => b.text ?? "")
              .join("")
              .trim()
          : extractText(m.content).trim();
        if (/^Review your response against the original user request/i.test(txt)) {
          if (typeof window.appendToReasoningPanel === "function") {
            window.appendToReasoningPanel({ type: "text", text: "[Post-generation verification]" });
          }
          continue;
        }
      }

      const hasContent = Array.isArray(m.content)
        ? m.content.some((b) => b.type === "text" && (b.text ?? "").trim())
        : extractText(m.content).trim();
      if (!hasContent) {
        continue;
      }
      const el = window.renderMessage(m);
      if (!el) {
        continue;
      }
      frag.appendChild(el);
      window.applyHighlighting(el);
    }

    // Insert after sentinel (which stays at top)
    loadMoreSentinel.after(frag);

    // Restore scroll position so the view doesn't jump
    requestAnimationFrame(() => {
      const newScrollHeight = responseArea.scrollHeight;
      responseArea.scrollTop = prevScrollTop + (newScrollHeight - prevScrollHeight);
    });

    updateLoadMoreSentinel();
  } catch (err) {
    console.warn("[lazy-load] Failed to load older history:", err);
    loadMoreSentinel.textContent = "Failed to load — scroll up to retry";
  } finally {
    isLoadingMore = false;
  }
}

// ── Gateway event handler ──────────────────────────────────────────
let thinkingEl = null;

function handleGatewayEvent(event) {
  // Handle sub-agent lifecycle events (independent of isStreaming)
  if (event.event === "subagent") {
    const s = event.payload;
    if (s && s.phase === "started") {
      activeSubagentCount++;
      showWorkingIndicator();
    } else if (s && s.phase === "completed") {
      activeSubagentCount = Math.max(0, activeSubagentCount - 1);
      if (activeSubagentCount === 0 && !isStreaming) {
        hideWorkingIndicator();
      }
    }
    return;
  }

  // Handle agent lifecycle/tool events — these prove the agent is alive.
  // No isStreaming guard: agent events must be processed even before the
  // first text delta arrives (critical for Gemma/vLLM tool-use phases).
  if (event.event === "agent") {
    const a = event.payload;
    if (a) {
      // Cross-session filter: agent events for OTHER sessions (cron-fired
      // channel runs, slack/msteams inbound, scheduled jobs) must not arm the
      // user's watchdog. Without this guard, every background run resets the
      // watchdog and produces phantom "Run timed out" warnings when no
      // follow-up event arrives within WATCHDOG_MS.
      if (a.sessionKey && a.sessionKey !== window.SESSION_KEY) {
        return;
      }
      // Cross-run filter: late agent events from a finished run (e.g.
      // lifecycle:end fires after chat:final cleared currentRunId) must not
      // re-arm a fresh watchdog timer.
      if (a.runId && currentRunId && a.runId !== currentRunId) {
        return;
      }
      // Any agent event resets the watchdog — the agent is working
      armWatchdog();
      showWorkingIndicator();
      lastEventTs = Date.now();

      // Update the sub-label with what the agent is actually doing
      if (a.stream === "tool" && a.data) {
        if (a.data.phase === "start" && a.data.name) {
          elapsedBaseText = `Running tool: ${a.data.name}`;
        } else if (a.data.phase === "result") {
          elapsedBaseText = "Reasoning through request";
        }
      } else if (a.stream === "compaction" && a.data) {
        if (a.data.phase === "start") {
          elapsedBaseText = "Compacting context";
        } else if (a.data.phase === "end") {
          elapsedBaseText = "Reasoning through request";
        }
      } else if (a.stream === "thinking") {
        elapsedBaseText = "Reasoning";
        // Stream the thinking content into the Reasoning panel (bottom-left).
        // Deltas are buffered per run; flushed as a single entry on
        // thinking_end so the panel gets one entry per thinking block, not
        // one per token.
        handleThinkingAgentEvent(a);
      } else if (a.stream === "lifecycle" && a.data) {
        if (a.data.phase === "start") {
          elapsedBaseText = "Reasoning through request";
        }
      }
    }
    return;
  }

  if (event.event !== "chat") {
    return;
  }
  const p = event.payload;
  if (!p || p.sessionKey !== window.SESSION_KEY) {
    return;
  }

  if (p.runId && currentRunId && p.runId !== currentRunId) {
    // Cross-run chat event (e.g. cron-fired channel run, chat.injectMessage,
    // late event from a finished run). Not relevant to the user's active turn.
    return;
  }
  if (isStreaming) {
    // Any chat event for our session and our run proves the agent is alive —
    // reset watchdog.
    armWatchdog();
    lastEventTs = Date.now();
  }

  // Handle "thinking" state broadcast from gateway (agent started processing)
  if (p.state === "thinking") {
    if (p.runId && currentRunId && p.runId !== currentRunId) {
      return; // Cross-run thinking event
    }
    if (!isStreaming) {
      return; // Ignore stale thinking events
    }
    setAgentStatus("thinking");
    showWorkingIndicator();
    armWatchdog();
    return;
  }

  if (p.state === "delta") {
    // Capture runId on first delta so abort can target the exact run
    if (p.runId && !currentRunId) {
      currentRunId = p.runId;
    }

    // First delta — remove thinking bubble, open stream bubble
    if (thinkingEl) {
      thinkingEl.remove();
      thinkingEl = null;
    }
    if (!streamBubble) {
      openStreamBubble();
      setAgentStatus("streaming");
    }

    const text = extractText(p.message);
    if (text) {
      updateStream(text);
    }
    // Stream thinking deltas live to the Reasoning Panel
    const thinkingText =
      typeof window.extractThinkingText === "function" ? window.extractThinkingText(p.message) : "";
    if (thinkingText && typeof window.updateLiveThinking === "function") {
      window.updateLiveThinking(thinkingText);
    }
  } else if (p.state === "final") {
    stopElapsedTimer();
    clearWatchdog();
    if (typeof window.closeLiveThinking === "function") {
      window.closeLiveThinking();
    }
    // If final arrives with message content but no prior deltas (burst response),
    // render the full message directly instead of closing an empty stream bubble.
    if (!streamBubble && p.message) {
      const finalText = extractText(p.message?.content ?? p.message);
      if (finalText && finalText.trim()) {
        if (thinkingEl) {
          thinkingEl.remove();
          thinkingEl = null;
        }
        const rendered = window.renderMessage({
          role: "assistant",
          content: p.message.content ?? [{ type: "text", text: finalText }],
          timestamp: p.message.timestamp ?? Date.now(),
        });
        if (rendered) {
          responseArea.appendChild(rendered);
          window.applyHighlighting(rendered);
          responseArea.scrollTop = responseArea.scrollHeight;
        }
      }
    } else {
      closeStreamBubble(window.activeModelId || "unknown model");
    }
    // Cache the completed assistant message for feed persistence
    if (p.message) {
      const finalText = extractText(p.message?.content ?? p.message);
      if (finalText && finalText.trim()) {
        feedCache.append({
          role: "assistant",
          content: p.message.content ?? [{ type: "text", text: finalText }],
          timestamp: p.message.timestamp ?? Date.now(),
        });
      }
    }
    setAgentStatus("done");
    // Sync sub-agent count from server and conditionally hide working indicator
    if (typeof p.activeSubagentCount === "number") {
      activeSubagentCount = p.activeSubagentCount;
    }
    if (activeSubagentCount <= 0) {
      hideWorkingIndicator();
    }
    isStreaming = false;
    currentRunId = null;
    stopBtn.style.opacity = ""; // reset stop button visual state
    stopBtn.disabled = false;
    enableInput();
    drainQueue();
  } else if (p.state === "aborted") {
    // Clean abort (user-initiated or model decided to stop) — not an error
    stopElapsedTimer();
    clearWatchdog();
    if (typeof window.closeLiveThinking === "function") {
      window.closeLiveThinking();
    }
    if (streamBubble) {
      closeStreamBubble(window.activeModelId || "unknown model");
    }
    if (thinkingEl) {
      thinkingEl.remove();
      thinkingEl = null;
    }
    setAgentStatus("idle");
    hideWorkingIndicator();
    isStreaming = false;
    currentRunId = null;
    stopBtn.style.opacity = ""; // reset stop button visual state
    stopBtn.disabled = false;
    enableInput();
    drainQueue();
  } else if (p.state === "error") {
    // Server-reported error — route through unified failure handler.
    // The gateway emits the diagnostic at top-level `errorMessage`
    // (camelCase); fall back to nested `error.message` for forward-compat
    // and the legacy default if neither shape is present.
    const message = p.errorMessage || p.error?.message || "Server reported an error";
    handleRunFailure(message);
  }
}

// ── Agent Status Orb ───────────────────────────────────────────────
const asoPanel = document.getElementById("agent-status-panel");
const asoLabel = document.getElementById("aso-label");
const asoSub = document.getElementById("aso-sub");
const asoWorking = document.getElementById("aso-working");
let asoDoneTimer = null;

const ASO_STATES = {
  idle: { state: "idle", label: "STANDBY", sub: "Awaiting your prompt" },
  waiting: { state: "waiting", label: "WAITING", sub: "Sending to Landman\u2026" },
  thinking: { state: "thinking", label: "PROCESSING", sub: "Reasoning through request" },
  streaming: { state: "streaming", label: "RESPONDING", sub: "Generating response" },
  done: { state: "done", label: "COMPLETE", sub: "Response ready" },
  error: { state: "error", label: "FAILED", sub: "Run ended unexpectedly" },
};

function setAgentStatus(key) {
  if (!asoPanel) {
    return;
  }

  // Clear any pending auto-transition timers
  if (asoDoneTimer) {
    clearTimeout(asoDoneTimer);
    asoDoneTimer = null;
  }

  const s = ASO_STATES[key] || ASO_STATES.idle;
  asoPanel.dataset.state = s.state;
  if (asoLabel) {
    asoLabel.textContent = s.label;
  }
  if (asoSub) {
    asoSub.textContent = s.sub;
  }

  // Pulse the reasoning live-dot while agent is actively thinking/streaming
  const reasoningDot = document.getElementById("reasoning-live-dot");
  if (reasoningDot) {
    const active = key === "thinking" || key === "streaming" || key === "waiting";
    reasoningDot.classList.toggle("active", active);
  }

  // ── Elapsed timer management ──────────────────────────────────
  if (key === "waiting") {
    // For burst-style models (Gemma/vLLM), arm watchdog immediately on send
    // so the user gets feedback if the model never responds.
    if (activeProfile?.ui?.armWatchdogOnSend !== false) {
      armWatchdog();
    }
    startElapsedTimer("Sending to agent");
  } else if (key === "thinking") {
    // Start elapsed timer and watchdog when processing begins
    armWatchdog();
    elapsedBaseText = "Reasoning through request";
  } else if (key === "streaming") {
    // Transition from thinking → streaming: keep watchdog running (will be
    // reset on each delta), switch elapsed timer base text
    elapsedBaseText = "Generating response";
  } else {
    // Any terminal/non-active state: stop elapsed timer and watchdog
    stopElapsedTimer();
    clearWatchdog();
  }

  // ── Auto-revert timers ────────────────────────────────────────
  if (key === "done") {
    asoDoneTimer = setTimeout(() => setAgentStatus("idle"), 3000);
  }
  if (key === "error") {
    asoDoneTimer = setTimeout(() => setAgentStatus("idle"), 4000);
  }
}

// ── Working indicator helpers ─────────────────────────────────────
function showWorkingIndicator() {
  if (!workingActive) {
    workingActive = true;
    if (asoWorking) {
      asoWorking.classList.add("active");
    }
  }
}
function hideWorkingIndicator() {
  if (workingActive) {
    workingActive = false;
    if (asoWorking) {
      asoWorking.classList.remove("active");
    }
  }
}

// ── Input state ────────────────────────────────────────────────────
function disableInput() {
  // Keep textarea enabled so user can type their next queued message while AI responds
  sendBtn.disabled = true;
  newSessionBtn.disabled = true;
  sendBtn.classList.add("btn-loading");
}

function enableInput() {
  sendBtn.disabled = false;
  newSessionBtn.disabled = false;
  sendBtn.classList.remove("btn-loading");
  promptInput.focus();
}

// ── Queue helpers ──────────────────────────────────────────────────
const queueBtn = document.getElementById("queue-btn");
const queueCancelX = document.getElementById("queue-cancel-x");

function updateQueueBtn() {
  const streaming = isStreaming;
  const count = chatQueue.length;

  if (streaming || count > 0) {
    // Show stop + queue buttons, hide New Session
    stopBtn.classList.add("visible");
    queueBtn.classList.add("visible");
    newSessionBtn.style.display = "none";

    if (count > 0) {
      queueBtn.classList.add("has-queue");
      queueBtn.querySelector(".queue-btn-label").textContent = `Queued (${count})`;
    } else {
      queueBtn.classList.remove("has-queue");
      queueBtn.querySelector(".queue-btn-label").textContent = "Queue ↵";
    }
  } else {
    // Hide stop + queue, restore New Session
    stopBtn.classList.remove("visible");
    queueBtn.classList.remove("visible", "has-queue");
    newSessionBtn.style.display = "";
  }
}

function queueMessage(text) {
  if (!text.trim()) {
    return;
  }
  chatQueue = [...chatQueue, { id: Date.now(), text: text.trim() }];
  updateQueueBtn();
}

function clearQueue() {
  chatQueue = [];
  updateQueueBtn();
}

function drainQueue() {
  if (chatQueue.length === 0) {
    updateQueueBtn();
    return;
  }
  const [next, ...rest] = chatQueue;
  chatQueue = rest;
  updateQueueBtn();
  // Small delay so the UI settles before starting the next send
  setTimeout(() => sendText(next.text), 120);
}

// ── Core send (no input reading — accepts text directly) ───────────
async function sendText(text) {
  if (!gateway.connected) {
    return;
  }
  isStreaming = true;
  showWorkingIndicator();
  disableInput();
  updateQueueBtn();

  addUserMessage(text);
  feedCache.append({
    role: "user",
    content: [{ type: "text", text }],
    timestamp: Date.now(),
  });
  await sleep(200);

  // Mark the start of this run in the Reasoning Panel
  if (typeof window.injectReasoningRunSeparator === "function") {
    window.injectReasoningRunSeparator();
  }

  setAgentStatus("waiting");
  thinkingEl = createThinkingBubble();
  setAgentStatus("thinking");

  try {
    await gateway.send(text);
  } catch (err) {
    handleRunFailure(err.message || "Failed to send message");
  }
}

// ── Send handler (reads input, queues if busy) ─────────────────────
async function handleSend() {
  const text = promptInput.value.trim();
  if (!text) {
    return;
  }

  // Clear the input immediately regardless of streaming state
  promptInput.value = "";
  promptInput.style.height = "auto";

  if (isStreaming) {
    // AI is busy — queue it
    queueMessage(text);
    return;
  }

  if (!gateway.connected) {
    return;
  }
  await sendText(text);
}

// ── New Session handler ────────────────────────────────────────────
async function handleNewSession() {
  if (isStreaming || !gateway.connected) {
    return;
  }

  promptInput.disabled = true; // lock textarea during reset
  disableInput();
  setAgentStatus("waiting");

  try {
    // Send the /new slash command — gateway resets the session
    await gateway.send("/new");

    // Fade out existing messages, then show cleared state
    responseArea.style.transition = "opacity 0.25s ease";
    responseArea.style.opacity = "0";

    await sleep(260);

    responseArea.innerHTML = "";
    responseArea.prepend(loadMoreSentinel);
    responseArea.style.opacity = "1";
    historyStartIndex = 0;
    historyHasMore = false;
    feedCache.clear(); // clear cached feed — new session starts fresh
    updateLoadMoreSentinel();
    if (typeof window.clearReasoningPanel === "function") {
      window.clearReasoningPanel();
    }
    if (typeof window.clearSymipulsePanel === "function") {
      window.clearSymipulsePanel();
    }

    // Show empty-state indicator
    const cleared = document.createElement("div");
    cleared.className = "session-cleared";
    cleared.innerHTML = `
      <div class="session-cleared-icon">◈</div>
      <div class="session-cleared-text">NEW SESSION STARTED</div>
    `;
    responseArea.appendChild(cleared);

    setAgentStatus("idle");
    activeSubagentCount = 0;
    hideWorkingIndicator();
    clearQueue(); // discard any queued prompts — new session = clean slate

    // Show archive toast — lets user jump directly to history if they regret it
    if (typeof window.showArchiveToast === "function") {
      window.showArchiveToast();
    }

    // After a short pause, remove the cleared indicator and let history load
    await sleep(1800);
    cleared.style.transition = "opacity 0.4s ease";
    cleared.style.opacity = "0";
    await sleep(420);
    cleared.remove();
  } catch (err) {
    handleRunFailure(err.message || "Failed to start new session");
    return;
  }

  promptInput.disabled = false; // re-enable textarea after reset
  enableInput();
}

// ── Stop handler ───────────────────────────────────────────────────
async function handleStop() {
  if (!isStreaming || !gateway.connected) {
    return;
  }
  try {
    await gateway.abort(currentRunId);
    // The gateway will send an 'aborted' event which cleans up state.
    // Visually dim the stop button while we wait for confirmation.
    stopBtn.style.opacity = "0.45";
    stopBtn.disabled = true;
  } catch (err) {
    console.warn("[stop] abort failed:", err.message);
  }
}

sendBtn.addEventListener("click", handleSend);
newSessionBtn.addEventListener("click", handleNewSession);
stopBtn.addEventListener("click", handleStop);

// Queue button — queue current input (if idle text present) or show queued count
queueBtn.addEventListener("click", (e) => {
  // Cancel X — clears the queue
  if (e.target === queueCancelX || queueCancelX.contains(e.target)) {
    clearQueue();
    return;
  }
  // Main button click — queue the current input if no item queued yet
  if (chatQueue.length === 0) {
    const text = promptInput.value.trim();
    if (text) {
      promptInput.value = "";
      promptInput.style.height = "auto";
      queueMessage(text);
    }
  }
});
promptInput.addEventListener("keydown", (e) => {
  if (e.key === "Enter" && !e.shiftKey) {
    // When the slash-command autocomplete is open, Enter selects the
    // highlighted command (handled by slash-autocomplete.js) — don't
    // submit the partial text to the agent. preventDefault() blocks the
    // browser default but not other listeners, so we explicitly bail.
    const slashOpen = document.getElementById("slash-autocomplete")?.classList.contains("open");
    if (slashOpen) {
      return;
    }
    e.preventDefault();
    void handleSend();
  }
});

// ── Textarea auto-resize (up to 3 lines) ──────────────────────────
function autoResizePrompt() {
  promptInput.style.height = "auto";
  promptInput.style.height = promptInput.scrollHeight + "px";
}

promptInput.addEventListener("input", autoResizePrompt);

// Clicking anywhere on the prompt bar (padding, icon, gaps) focuses the input
// Click anywhere on the main input row (not buttons) focuses the input
document.getElementById("prompt-bar").addEventListener("click", (e) => {
  const inSend = sendBtn.contains(e.target) || e.target === sendBtn;
  const inNewSession = newSessionBtn.contains(e.target) || e.target === newSessionBtn;
  const inHistory = document.getElementById("history-btn").contains(e.target);
  const inQueue = queueBtn.contains(e.target);
  const inStop = stopBtn.contains(e.target);
  // Only focus if clicking in the main row area (not the actions row)
  const inActionsRow = e.target.closest(".prompt-row-actions");
  if (!inSend && !inNewSession && !inHistory && !inQueue && !inStop && !inActionsRow) {
    promptInput.focus();
  }
});

// ── Model profile listener ────────────────────────────────────────
window.addEventListener("symi:profile-changed", (e) => {
  activeProfile = e.detail;
  WATCHDOG_MS = activeProfile?.ui?.watchdogMs ?? 300000;

  // Update model label in header if present
  const modelLabel = document.getElementById("active-model-label");
  if (modelLabel) {
    modelLabel.textContent = activeProfile?.label ?? "";
  }
  const modelBadge = document.getElementById("model-badge");
  if (modelBadge) {
    modelBadge.textContent = activeProfile?.ui?.badge ?? "";
  }

  console.log(
    "[app] profile updated:",
    activeProfile?.label,
    "watchdog:",
    WATCHDOG_MS,
    "armOnSend:",
    activeProfile?.ui?.armWatchdogOnSend,
  );
});

// ── Gateway setup ──────────────────────────────────────────────────
// ── Session reload (called after session restore from history drawer) ──
window.reloadSession = async function () {
  feedCache.clear();
  responseArea.innerHTML = "";
  responseArea.prepend(loadMoreSentinel);
  historyStartIndex = 0;
  historyHasMore = false;
  updateLoadMoreSentinel();
  if (typeof window.clearReasoningPanel === "function") {
    window.clearReasoningPanel();
  }
  if (typeof window.clearSymipulsePanel === "function") {
    window.clearSymipulsePanel();
  }
  setAgentStatus("idle");
  activeSubagentCount = 0;
  hideWorkingIndicator();

  // Show restored indicator
  const restored = document.createElement("div");
  restored.className = "session-cleared";
  restored.innerHTML = `
    <div class="session-cleared-icon">◈</div>
    <div class="session-cleared-text">SESSION RESTORED</div>
  `;
  responseArea.appendChild(restored);

  // Load history from the restored session
  try {
    const result = await gateway.rpc("chat.history", { sessionKey: window.SESSION_KEY });
    if (result?.messages) {
      renderHistory(result.messages);
    }
  } catch (err) {
    console.error("[app] Failed to load restored session history:", err);
  }

  // Remove indicator after a pause
  setTimeout(() => {
    restored.style.transition = "opacity 0.4s ease";
    restored.style.opacity = "0";
    setTimeout(() => restored.remove(), 420);
  }, 1500);
};

const gateway = new SymiGateway();
window.gateway = gateway;

gateway.addEventListener("connect", () => {
  setStatus("online");
  enableInput();
  // History arrives via separate 'history' event pushed by the server
  // Bridge to window events for panel scripts
  window.dispatchEvent(new Event("gateway:connected"));
});

gateway.addEventListener("history", (e) => {
  const msgs = Array.isArray(e.detail?.messages) ? e.detail.messages : [];
  historyStartIndex = e.detail?.startIndex ?? 0;
  historyHasMore = e.detail?.hasMore ?? false;
  renderHistory(msgs);
  // Server-side retries were exhausted — keep the cached feed visible but
  // show a small banner so the user knows they're on stale data and can
  // take action (reload, check gateway logs, /new, etc.).
  if (e.detail?.error === "history.unavailable") {
    showHistoryUnavailableBanner();
  } else {
    clearHistoryUnavailableBanner();
  }
});

function showHistoryUnavailableBanner() {
  let banner = document.getElementById("history-unavailable-banner");
  if (!banner) {
    banner = document.createElement("div");
    banner.id = "history-unavailable-banner";
    banner.className = "history-unavailable-banner";
    banner.textContent =
      "History unavailable — showing cached view. Gateway retry failed; try reloading.";
    const dismiss = document.createElement("button");
    dismiss.type = "button";
    dismiss.className = "history-unavailable-dismiss";
    dismiss.textContent = "×";
    dismiss.addEventListener("click", clearHistoryUnavailableBanner);
    banner.appendChild(dismiss);
    // Insert at the top of the response area so it's the first thing seen.
    if (responseArea?.parentElement) {
      responseArea.parentElement.insertBefore(banner, responseArea);
    } else {
      document.body.prepend(banner);
    }
  }
  banner.style.display = "";
}

function clearHistoryUnavailableBanner() {
  const banner = document.getElementById("history-unavailable-banner");
  if (banner) {
    banner.remove();
  }
}

gateway.addEventListener("disconnect", () => {
  setStatus("offline");
  // If the gateway drops while a run is in progress, surface the failure
  // immediately rather than leaving the UI in a permanently locked state.
  if (isStreaming) {
    handleRunFailure("disconnected");
  }
});

gateway.addEventListener("event", (e) => {
  handleGatewayEvent(e.detail);
  // Forward to native debug panel if open
  if (window.__debugPanelCapture) {
    window.__debugPanelCapture(e.detail?.event ?? "unknown", e.detail?.payload);
  }
});

// Start connecting
disableInput();
gateway.start();
