// Client-side terminal render self-evaluation + minimal self-correction.
//
// Layer A — content correctness: compute the SAME fingerprint as the server
// (lib/terminal-fingerprint.js) over the client's xterm visible grid; on mismatch
// the client render content has diverged -> request a single `reconcile`.
// Layer B — render correctness (buffer right, pixels stale): handled by the wiring
// (visibilitychange / WebGL context-loss -> force repaint); this module just exposes
// the decision logic + fingerprint.
//
// IMPORTANT: this is the browser MIRROR of lib/terminal-fingerprint.js. The two MUST
// stay byte-identical (FNV-1a, same header format, same /\s+$/ trim, EMPTY sentinel)
// or fingerprints diverge and the client reconciles forever. terminal-reconciler.test.js
// asserts parity against the lib version.
(function (global) {
  'use strict';

  var FNV_OFFSET = 0x811c9dc5, FNV_PRIME = 0x01000193, EMPTY_FINGERPRINT = '0:empty';
  function fnv1a(str, seed) {
    var h = seed >>> 0;
    for (var i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, FNV_PRIME); }
    return h >>> 0;
  }
  function fingerprintRows(rowStrings, meta) {
    var cols = Number(meta && meta.cols) || 0, rows = Number(meta && meta.rows) || 0;
    var scrollbackLen = Number(meta && meta.scrollbackLen) || 0;
    var h = FNV_OFFSET, nonEmpty = false;
    h = fnv1a(cols + 'x' + rows + '|' + scrollbackLen + '\n', h);
    for (var i = 0; i < rowStrings.length; i++) {
      var t = String(rowStrings[i] == null ? '' : rowStrings[i]).replace(/\s+$/, '');
      if (t.length) nonEmpty = true;
      h = fnv1a(i + ':' + t + '\n', h);
    }
    return nonEmpty ? (cols + 'x' + rows + ':' + h.toString(16)) : EMPTY_FINGERPRINT;
  }

  // Read the visible grid out of an xterm Terminal instance.
  function clientVisibleRows(term) {
    var buf = term.buffer && term.buffer.active;
    if (!buf) return { rows: [], cols: term.cols, rowCount: term.rows, scrollbackLen: 0 };
    var rowStrings = [];
    var base = buf.viewportY;
    for (var i = 0; i < term.rows; i++) {
      var line = buf.getLine(base + i);
      rowStrings.push(line ? line.translateToString(true) : '');
    }
    return {
      rows: rowStrings,
      cols: term.cols,
      rowCount: term.rows,
      scrollbackLen: Math.max(0, buf.length - term.rows),
    };
  }

  function clientFingerprint(term) {
    var v = clientVisibleRows(term);
    return fingerprintRows(v.rows, { cols: v.cols, rows: v.rowCount, scrollbackLen: v.scrollbackLen });
  }

  // Decide what to do given the server's authoritative fp/seq and local state.
  // opts: { userEditingWithFocus, localSeq, serverSeq, pendingWrites, serverFp, clientFp, stalledBehind }
  // Returns 'ok' | 'reconcile' | 'defer'.
  //   defer    — transient mismatch is expected (user typing w/ focus, not caught up,
  //              or writes still draining); do nothing.
  //   ok       — fingerprints match; client render is correct.
  //   reconcile— caught up and quiescent but fingerprints differ -> request resync.
  // stalledBehind — the client is behind serverSeq but output that would catch it up
  //   will NEVER arrive (server advanced via suppressed bytes, e.g. a Codex approval
  //   auto-redraw). Without this, seqLess() defers forever and the repair never fires.
  //   The caller (self-check tick) only sets this after TWO consecutive stalled ticks
  //   with no localSeq progress and diverged fingerprints, so transient streaming lag
  //   never trips it.
  function decide(opts) {
    if (opts.userEditingWithFocus) return 'defer';
    if (Number(opts.pendingWrites) > 0) return 'defer';
    // Lifecycle gate (the flash fix): the seq is "<outputBytes>:<lifecycle>". A
    // resume/restart bumps the lifecycle. If the server's last heartbeat fingerprint
    // is from a DIFFERENT lifecycle than the client's current one, that fp is a dead
    // signal — it can NEVER match the live buffer, so an fp compare would diverge
    // forever and the self-check would reconcile every tick (the measured cause of
    // persistent flashing on Claude/Cursor/idle TUI sessions: localSeq …:14 vs a
    // frozen serverSeq …:13). Defer until a fresh same-lifecycle heartbeat arrives
    // (the server now emits one on a lifecycle bump). seqLess already orders by
    // lifecycle first, but it only DEFERS when the client is BEHIND; a client that is
    // AHEAD by a lifecycle (the common resume case) slipped through to reconcile.
    if (parseSeq(opts.localSeq).life !== parseSeq(opts.serverSeq).life) return 'defer';
    // Only defer-on-behind while NOT stalled; a stalled-behind gap is server-only and
    // self-output will never close it, so fall through to the fp compare and reconcile.
    if (!opts.stalledBehind && seqLess(opts.localSeq, opts.serverSeq)) return 'defer';
    if (opts.serverFp === opts.clientFp) return 'ok';
    return 'reconcile';
  }

  // Codex idle-frame repair policy.
  //
  // A plain `reconcile` re-serializes the server's headless terminal, which mirrors
  // Codex's UN-SETTLED final-frame bytes verbatim. So for a stale Codex idle frame a
  // reconcile is structurally incapable of fixing it (both sides hold the same
  // un-settled grid) AND, worse, it reverts a user's manual reflow back to that grid
  // — the "reflow fixes it, then it reverts" class. Only a redraw-reflow (a PTY
  // SIGWINCH nudge that makes Codex repaint its settled frame from its own source
  // state) can actually settle it.
  //
  // So when a diverged+quiescent Codex session reaches this path we drive a
  // redraw-reflow instead of a reconcile, rate-limited by cooldownMs. Inside the
  // cooldown we return 'skip' — deliberately doing NOTHING rather than firing a plain
  // reconcile, so a stale headless serialize can never revert the just-settled frame.
  // opts: { now, lastReflowAt, cooldownMs }. Returns 'reflow' | 'skip'.
  function codexReconcileAction(opts) {
    var now = Number(opts && opts.now) || 0;
    var cooldownMs = Number(opts && opts.cooldownMs) || 12000;
    var lastReflowAt = Number(opts && opts.lastReflowAt) || 0;
    if (lastReflowAt && now - lastReflowAt < cooldownMs) return 'skip';
    return 'reflow';
  }

  // When classifyChurn has declared a non-Codex flash loop, decide how to break it.
  // A forced redraw-reflow SIGWINCHes the PTY so it repaints from source. On Claude that
  // re-emits the just-finished transcript into the MAIN scrollback (Claude's conversation
  // is not a contained alt-screen) — the recurring "duplicate scrollback / sudden flash"
  // on an idle session. Per the 40d53881 contract, a Claude redraw is only justified by
  // REAL garble (a stranded right-edge fragment). A benign Claude flash loop (no garble)
  // is an unconvergeable serializer round-trip artifact between the server @xterm/headless
  // (raw stream) and the client xterm (snapshot-applied): a redraw CANNOT converge it and
  // only dumps scrollback every backoff cycle, so we 'give-up' (memoize + stop reconciling
  // that exact frame until new output changes the fingerprint). Other providers keep the
  // forced redraw heal.
  //
  // opts: { provider, hasGarble }. Returns 'give-up' or 'redraw'.
  function flashHealAction(opts) {
    var provider = opts && opts.provider;
    var hasGarble = !!(opts && opts.hasGarble);
    if (provider === 'claude' && !hasGarble) return 'give-up';
    return 'redraw';
  }

  // Flash detection: is the active session stuck in a non-converging reconcile loop?
  //
  // The 3s self-check re-runs decide() on the idle active session. If the client/server
  // fingerprints diverge for a reason a plain `reconcile` can't fix (the snapshot is
  // re-serialized from the same diverged headless grid, or onSnapshot rejects it as a
  // routine-clean/render-health regression), decide() returns 'reconcile' every tick and
  // the screen flashes once per debounce window forever — until the user switches tab
  // away+back. `events` is a rolling list of NON-CONVERGING reconcile timestamps (the
  // caller only records a reconcile that produced no localSeq progress and left the
  // fingerprint diverged/rejected). When enough pile up inside the window, we declare a
  // flash loop so the caller can break it and escalate to a forced redraw-reflow (the
  // convergent recovery the Codex idle path already uses) instead of looping.
  //
  // opts: { now, windowMs, threshold }. Returns { flashing, count, windowMs }.
  function classifyChurn(events, opts) {
    var now = Number(opts && opts.now) || 0;
    var windowMs = Number(opts && opts.windowMs) || 15000;
    var threshold = Number(opts && opts.threshold) || 3;
    var count = 0;
    var list = events || [];
    for (var i = 0; i < list.length; i++) {
      if (now - Number(list[i]) <= windowMs) count++;
    }
    return { flashing: count >= threshold, count: count, windowMs: windowMs };
  }

  // Should the client adopt the server's heartbeat lifecycle into its own localSeq?
  //
  // The client's localSeq only advances from OUTPUT frames. A status change
  // (idle / waiting-for-input) bumps the server's terminal lifecycle and emits a
  // fresh heartbeat at the new lifecycle, but carries NO new output bytes — so the
  // client is stranded a lifecycle behind with nothing to catch it up. decide()'s
  // lifecycle gate then defers forever and the idle self-heal never fires (a real
  // bug: an idle session that suffers buffer corruption can't recover).
  //
  // When the heartbeat is a PURE lifecycle bump — server lifecycle is HIGHER but the
  // byte count is IDENTICAL — the client is genuinely current on content (no output
  // it hasn't seen), so it can safely adopt the server's lifecycle and let a normal
  // same-lifecycle fp compare run. This is the INVERSE of the flash bug (client
  // AHEAD by a lifecycle, where the server fp is dead and must never reconcile);
  // there server.life < local.life so this returns false. It also returns false when
  // byte counts differ (client is genuinely behind on output and should wait for the
  // frames, which will carry the new lifecycle themselves).
  function shouldAdoptServerLifecycle(localSeq, serverSeq) {
    var pl = parseSeq(localSeq), ps = parseSeq(serverSeq);
    return ps.life > pl.life && ps.bytes === pl.bytes;
  }

  // seq is "<outputBytes>:<lifecycle>". a < b if lifecycle is older, or same lifecycle
  // and fewer output bytes. Returns true when `a` is strictly behind `b`.
  function seqLess(a, b) {
    var pa = parseSeq(a), pb = parseSeq(b);
    if (pa.life !== pb.life) return pa.life < pb.life;
    return pa.bytes < pb.bytes;
  }
  function parseSeq(s) {
    var str = String(s == null ? '' : s);
    var idx = str.indexOf(':');
    if (idx < 0) return { bytes: Number(str) || 0, life: 0 };
    return { bytes: Number(str.slice(0, idx)) || 0, life: Number(str.slice(idx + 1)) || 0 };
  }

  global.TerminalReconciler = {
    fingerprintRows: fingerprintRows,
    clientFingerprint: clientFingerprint,
    clientVisibleRows: clientVisibleRows,
    decide: decide,
    codexReconcileAction: codexReconcileAction,
    flashHealAction: flashHealAction,
    classifyChurn: classifyChurn,
    shouldAdoptServerLifecycle: shouldAdoptServerLifecycle,
    seqLess: seqLess,
    EMPTY_FINGERPRINT: EMPTY_FINGERPRINT,
  };
})(typeof window !== 'undefined' ? window : globalThis);
