// ANSI escape code → HTML converter
function ansiToHtml(raw) {
  if (!raw) return '';

  // 16-color palette (One Dark)
  const C16 = [
    '#21252b','#e06c75','#98c379','#d19a66',
    '#61afef','#c678dd','#56b6c2','#abb2bf',
    '#5c6370','#ff6b6b','#b5f5a0','#e5c07b',
    '#61afef','#c678dd','#56b6c2','#ffffff',
  ];

  function c256(n) {
    if (n < 16) return C16[n];
    if (n >= 232) { const v = 8 + (n - 232) * 10; return `rgb(${v},${v},${v})`; }
    n -= 16;
    const r = Math.floor(n / 36), g = Math.floor((n % 36) / 6), b = n % 6;
    const v = x => x ? 55 + x * 40 : 0;
    return `rgb(${v(r)},${v(g)},${v(b)})`;
  }

  function escHtml(s) {
    return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  }

  // Strip ALL remaining escape sequences from a text chunk
  // Covers: CSI (\x1b[...final), OSC (\x1b]...BEL), other single-char ESC sequences
  function stripEsc(s) {
    return s.replace(/\x1b(?:\[[^a-zA-Z]*[a-zA-Z]|\][^\x07]*\x07|.)/g, '');
  }

  let fg = null, bg = null, bold = false, italic = false, dim = false;

  function mkStyle() {
    const p = [];
    if (fg) p.push(`color:${fg}`);
    if (bg) p.push(`background:${bg}`);
    if (bold) p.push('font-weight:bold');
    if (italic) p.push('font-style:italic');
    if (dim && !fg) p.push('opacity:0.6');
    return p.join(';');
  }

  // Match only SGR sequences (color/attribute codes ending with 'm')
  const SGR_RE = /\x1b\[([0-9;]*)m/g;
  let html = '';
  let last = 0;
  let m;

  function flush(text) {
    if (!text) return;
    const s = mkStyle();
    const e = escHtml(stripEsc(text));
    if (!e) return;
    html += s ? `<span style="${s}">${e}</span>` : e;
  }

  while ((m = SGR_RE.exec(raw)) !== null) {
    flush(raw.slice(last, m.index));
    last = m.index + m[0].length;

    const codes = m[1] ? m[1].split(';').map(Number) : [0];
    let i = 0;
    while (i < codes.length) {
      const c = codes[i++];
      if (c === 0 || isNaN(c))            { fg = bg = null; bold = italic = dim = false; }
      else if (c === 1)                   bold = true;
      else if (c === 2)                   dim = true;
      else if (c === 3)                   italic = true;
      else if (c === 22)                  { bold = dim = false; }
      else if (c === 23)                  italic = false;
      else if (c >= 30 && c <= 37)        fg = C16[c - 30];
      else if (c === 38) {
        if (codes[i] === 5 && i + 1 < codes.length)      { fg = c256(codes[i+1]); i += 2; }
        else if (codes[i] === 2 && i + 3 < codes.length) { fg = `rgb(${codes[i+1]},${codes[i+2]},${codes[i+3]})`; i += 4; }
      }
      else if (c === 39)                  fg = null;
      else if (c >= 40 && c <= 47)        bg = C16[c - 40];
      else if (c === 48) {
        if (codes[i] === 5 && i + 1 < codes.length)      { bg = c256(codes[i+1]); i += 2; }
        else if (codes[i] === 2 && i + 3 < codes.length) { bg = `rgb(${codes[i+1]},${codes[i+2]},${codes[i+3]})`; i += 4; }
      }
      else if (c === 49)                  bg = null;
      else if (c >= 90 && c <= 97)        fg = C16[c - 90 + 8];
      else if (c >= 100 && c <= 107)      bg = C16[c - 100 + 8];
    }
  }

  flush(raw.slice(last));
  return html;
}

window.ansiToHtml = ansiToHtml;

class TerminalView {
  constructor(element) {
    this.el = element;
    this.lines = [];
    this.renderPending = false;
    this.autoScroll = true;
    this.claudeMode = false;
    this.claudeParser = null;
    this.claudeRenderer = null;
    this.codexMode = false;
    this.codexParser = null;
    this.codexRenderer = null;

    // autoScroll is an explicit gate (default on). It is NOT recomputed from
    // scroll position on every scroll event — that produced a stale-read race
    // where a render firing before the scroll event dispatched would yank the
    // view back to the bottom. Instead render() measures the real position at
    // render time. app.js sets this false only to pin position in PageUp mode.
  }

  setContent(lines) {
    this.lines = lines.length > 10000 ? lines.slice(lines.length - 10000) : lines;
    this.scheduleRender();
  }

  applyDiff(patches) {
    for (const patch of patches) {
      this.lines.splice(patch.startLine, patch.deleteCount, ...patch.insertLines);
    }
    if (this.lines.length > 10000) {
      this.lines = this.lines.slice(this.lines.length - 10000);
    }
    // Invalidate parser caches since lines were mutated in-place
    if (this.claudeParser) this.claudeParser._lastLines = null;
    if (this.codexParser) this.codexParser._lastLines = null;
    this.scheduleRender();
  }

  setCodexMode(active) {
    const changed = this.codexMode !== active;
    this.codexMode = active;
    if (active) {
      if (!this.codexParser) this.codexParser = new CodexParser();
      if (!this.codexRenderer) this.codexRenderer = new CodexRenderer(this.el);
      if (changed && this.lines.length > 0) this.scheduleRender();
    } else {
      // Same as setClaudeMode: only clear on a real transition out of codex mode.
      if (changed && this.codexRenderer) this.codexRenderer.clear();
    }
  }

  setClaudeMode(active, onAction) {
    const changed = this.claudeMode !== active;
    this.claudeMode = active;
    if (active) {
      if (!this.claudeParser) this.claudeParser = new ClaudeParser();
      if (!this.claudeRenderer) {
        this.claudeRenderer = new ClaudeRenderer(this.el);
      }
      // Attach status bar to container if not already there
      const container = this.el.parentElement;
      if (container && !this.claudeRenderer.statusBarEl.parentNode) {
        container.appendChild(this.claudeRenderer.statusBarEl);
      }
      this.claudeRenderer.setOnAction(onAction);
      // Re-render existing content in claude mode (fixes race with async mode detection)
      if (changed && this.lines.length > 0) this.scheduleRender();
    } else {
      // Only clear on an actual transition OUT of claude mode. A redundant
      // setClaudeMode(false) on an already-plain surface must NOT wipe the
      // output — claudeRenderer.clear() empties the shared `this.el`, which
      // would blank the live terminal/codex content until the next repaint.
      if (changed && this.claudeRenderer) this.claudeRenderer.clear();
    }
  }

  scheduleRender() {
    if (this.renderPending) return;
    this.renderPending = true;
    requestAnimationFrame(() => {
      try { this.render(); } catch (e) { console.error('[render]', e); }
      this.renderPending = false;
    });
  }

  isQrLine(line) {
    // Detect QR code lines: mostly Unicode block characters (▀▄█)
    const blockChars = (line.match(/[\u2580-\u2588]/g) || []).length;
    const nonSpace = line.replace(/\s/g, '').length;
    return nonSpace > 4 && blockChars / nonSpace > 0.3;
  }

  render() {
    const container = this.el.parentElement;
    // Measure the real scroll position BEFORE mutating the DOM. Only auto-follow
    // if the explicit gate is on AND the view is actually at the bottom right now.
    // Reading the live DOM here (instead of a scroll-event-derived cache) avoids
    // the stale-read race that yanked scrolled-up users back to the bottom.
    const wasAtBottom =
      container.scrollHeight - container.scrollTop - container.clientHeight < 40;
    const follow = this.autoScroll && wasAtBottom;

    if (this.codexMode && this.codexParser && this.codexRenderer) {
      const doc = this.codexParser.parse(this.lines);
      this.codexRenderer.render(doc);
      if (follow) container.scrollTop = container.scrollHeight;
      return;
    }

    if (this.claudeMode && this.claudeParser && this.claudeRenderer) {
      const doc = this.claudeParser.parse(this.lines);
      this.claudeRenderer.render(doc);
      if (follow) container.scrollTop = container.scrollHeight;
      return;
    }

    const output = [];
    let i = 0;
    while (i < this.lines.length) {
      if (this.isQrLine(this.lines[i])) {
        // Collect all consecutive QR lines
        const qrBlock = [];
        while (i < this.lines.length && this.isQrLine(this.lines[i])) {
          qrBlock.push(this.lines[i]);
          i++;
        }
        // Calculate font-size so QR block fits container width without clipping
        const maxLen = Math.max(...qrBlock.map(l => l.length));
        const containerWidth = (this.el.parentElement?.clientWidth || 375) - 32;
        // monospace char width ≈ 0.62 × font-size; cap at 13px (normal size)
        const fontSize = Math.max(3, Math.min(13, Math.floor(containerWidth / (maxLen * 0.62))));
        const escapedLines = qrBlock.map(l =>
          l.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
        ).join('\n');
        // Single block span — no \n between spans so no extra blank lines
        output.push(`<span class="qr-block" style="font-size:${fontSize}px">${escapedLines}</span>`);
      } else {
        output.push(this.highlightLine(this.lines[i]));
        i++;
      }
    }
    const html = output.join('\n');
    this.el.innerHTML = html;

    if (follow) container.scrollTop = container.scrollHeight;
  }

  highlightLine(line) {
    // cmux strips ANSI codes — use pattern-based coloring
    const stripped = line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
    let e = stripped
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;');

    // Diff lines
    if (/^\+[^+]/.test(stripped)) return `<span class="hl-added">${e}</span>`;
    if (/^-[^-]/.test(stripped)) return `<span class="hl-removed">${e}</span>`;

    // Tool-use markers ⏺●⬤
    e = e.replace(/([\u23FA\u25CF\u2B24])/g, '<span class="hl-tool">$1</span>');
    // Dingbats — spinner/thinking characters (✻ ✳ ✢ etc.)
    e = e.replace(/([\u2700-\u27BF])/g, '<span class="hl-thinking">$1</span>');
    // Output marker ⎿
    e = e.replace(/(\u23BF)/g, '<span class="hl-output">$1</span>');
    // Prompt marker ❯
    e = e.replace(/(\u276F)/g, '<span class="hl-prompt">$1</span>');
    // Success
    e = e.replace(/(\u2713|\u2714|passed|PASS)/g, '<span class="hl-success">$1</span>');
    // Error
    e = e.replace(/(\u2717|\u2718|error|ERROR|FAIL|failed)/g, '<span class="hl-error">$1</span>');
    // Box drawing
    e = e.replace(/([\u2500-\u257F]+)/g, '<span class="hl-box">$1</span>');
    // File paths
    e = e.replace(
      /(?<!\w)((?:\.?\.?\/)?(?:[\w.-]+\/)+[\w.-]+\.[\w]+)/g,
      '<span class="hl-path">$1</span>'
    );
    // Line numbers
    e = e.replace(/^(\s*\d+\s*[|:])/, '<span class="hl-linenum">$1</span>');

    return e;
  }

  scrollToBottom() {
    const container = this.el.parentElement;
    container.scrollTop = container.scrollHeight;
    this.autoScroll = true;
  }

  clear() {
    this.lines = [];
    this.el.innerHTML = '';
  }
}

window.TerminalView = TerminalView;
