// Phase 2b implementation — terminal.js
// xterm.js Terminal + FitAddon initialization (task-12)

// ——— Module-level state ———————————————————————————————————————————
let _term = null;
let _fitAddon = null;
let _ws = null;
let _reconnectTimer = null;
let _overlayTimer = null; // grace period before showing reconnect overlay
let _currentSession = null;
let _vpHandler = null;
let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn
let _searchAddon = null;
let _resizeObserver = null;
let _ctrlActive = false;   // mobile toolbar: Ctrl modifier toggle (one-shot)
let _altActive = false;    // mobile toolbar: Alt modifier toggle (one-shot)

// ——— Terminal instance cache ——————————————————————————————————————
// Maps sessionKey → {container, term, fitAddon, ws, searchAddon, resizeObserver,
//   reconnectTimer, overlayTimer, reconnectAttempts, currentSession, remoteId}
var _terminalCache = new Map();
var _MAX_CACHED = 5;          // LRU eviction when exceeded
var _activeSessionKey = null;  // currently visible session key
var _cacheOrder = [];          // LRU tracking (most recent at end)

// Session key format: remoteId ? (remoteId + ':' + name) : name
function _buildSessionKey(name, remoteId) {
  return remoteId ? (remoteId + ':' + name) : name;
}

// ——— Module-level encoding helpers ————————————————————————————————
// Hoisted here so the clipboard key handler (in openTerminal) can also use them.
const _encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
// TextDecoder: used to decode UTF-8 bytes received from ttyd before writing to xterm.js.
// xterm.js write(Uint8Array) treats each byte as Latin-1, not UTF-8 — multi-byte characters
// like ─ (U+2500, bytes E2 94 80) render as â (Latin-1 0xE2) without decoding first.
// Matches ttyd's official client pattern: textDecoder.decode(payload) → _term.write(string).
const _decoder = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null;

function _encodePayload(typeChar, str) {
  // Returns Uint8Array: [typeCharCode, ...utf8bytes]
  var strBytes = _encoder ? _encoder.encode(str) : new Uint8Array(Array.from(str).map(function(c) { return c.charCodeAt(0); }));
  var payload = new Uint8Array(1 + strBytes.length);
  payload[0] = typeChar;
  payload.set(strBytes, 1);
  return payload;
}

// ——— Clipboard helpers ———————————————————————————————————————————
// Ctrl+Shift+C: copy terminal selection to system clipboard
// Ctrl+Shift+V: handled natively by xterm.js (browser paste event → xterm → WebSocket)

function _copyToClipboard(text) {
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(text).catch(function() {});
  } else {
    // Fallback for non-HTTPS contexts (HTTP over LAN)
    var ta = document.createElement('textarea');
    ta.value = text;
    ta.style.position = 'fixed';
    ta.style.left = '-9999px';
    document.body.appendChild(ta);
    ta.select();
    try { document.execCommand('copy'); } catch(e) {}
    document.body.removeChild(ta);
  }
}

// ——— Cache helpers ———————————————————————————————————————————————

/**
 * Sync module-level variables to point at the active cached entry's values.
 * This keeps backward compat for search, font size changes, etc.
 */
function _syncModuleState(entry) {
  if (entry) {
    _term = entry.term;
    _fitAddon = entry.fitAddon;
    _ws = entry.ws;
    _searchAddon = entry.searchAddon;
    _resizeObserver = entry.resizeObserver;
    _reconnectTimer = entry.reconnectTimer;
    _overlayTimer = entry.overlayTimer;
    _reconnectAttempts = entry.reconnectAttempts;
    _currentSession = entry.currentSession;
  } else {
    _term = null;
    _fitAddon = null;
    _ws = null;
    _searchAddon = null;
    _resizeObserver = null;
    _reconnectTimer = null;
    _overlayTimer = null;
    _reconnectAttempts = 0;
    _currentSession = null;
  }
}

/**
 * Update the cached entry from current module-level variables.
 * Call this after any module-level state changes to keep the entry in sync.
 */
function _syncEntryFromModule(key) {
  var entry = _terminalCache.get(key);
  if (!entry) return;
  entry.term = _term;
  entry.fitAddon = _fitAddon;
  entry.ws = _ws;
  entry.searchAddon = _searchAddon;
  entry.resizeObserver = _resizeObserver;
  entry.reconnectTimer = entry.reconnectTimer; // managed per-entry
  entry.overlayTimer = entry.overlayTimer;     // managed per-entry
  entry.reconnectAttempts = _reconnectAttempts;
  entry.currentSession = _currentSession;
}

/**
 * Move a key to the end of _cacheOrder (most recently used).
 */
function _touchCacheOrder(key) {
  var idx = _cacheOrder.indexOf(key);
  if (idx !== -1) _cacheOrder.splice(idx, 1);
  _cacheOrder.push(key);
}

// ——— Forward declarations ——————————————————————————————————————————

function connectWebSocket(name, remoteId) {
  // Always connect to the same origin — remote sessions route through the
  // federation proxy (ws://host/federation/{remoteId}/terminal/ws) so that
  // no cross-origin WebSocket connections are made from the browser.
  var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
  var url;
  if (remoteId) {
    // Remote session via federation proxy — same origin, different path
    url = proto + '//' + location.host + '/federation/' + remoteId + '/terminal/ws';
  } else {
    // Local session: add ?session= query param so backend proxy can route to correct ttyd port
    url = proto + '//' + location.host + '/terminal/ws?session=' + encodeURIComponent(name);
  }
  const reconnectOverlay = document.getElementById('reconnect-overlay');
  // Use module-level _encodePayload (hoisted above connectWebSocket)
  var encodePayload = _encodePayload;

  // Register terminal event handlers once on this _term instance.
  // These handlers read the module-level _ws at call time (not a captured reference),
  // so they always target the live socket. createTerminal() disposes _term before
  // the next session, removing these handlers automatically.
  if (_term) {
    _term.onData(function(data) {
      if (_ws && _ws.readyState === WebSocket.OPEN) {
        var outData = data;
        // Mobile toolbar modifier keys (one-shot: auto-deactivate after use)
        if (_ctrlActive && data.length === 1) {
          var code = data.toUpperCase().charCodeAt(0);
          if (code >= 65 && code <= 90) {           // A-Z → Ctrl+letter
            outData = String.fromCharCode(code - 64);
          }
          _ctrlActive = false;
          var cb = document.querySelector('[data-modifier="ctrl"]');
          if (cb) cb.classList.remove('mobile-toolbar__key--active');
        } else if (_altActive && data.length === 1) {
          outData = '\x1b' + data;                  // Alt = ESC prefix
          _altActive = false;
          var ab = document.querySelector('[data-modifier="alt"]');
          if (ab) ab.classList.remove('mobile-toolbar__key--active');
        }
        // ttyd protocol: input is type 0x30 ('0') + UTF-8 keystroke bytes
        _ws.send(encodePayload(0x30, outData));
      }
    });
    _term.onResize(function(size) {
      if (_ws && _ws.readyState === WebSocket.OPEN) {
        // ttyd protocol: resize is type 0x31 ('1') + UTF-8 JSON
        _ws.send(encodePayload(0x31, JSON.stringify({ columns: size.cols, rows: size.rows })));
      }
    });
  }

  // _connectWebSocket — creates the WebSocket instance and registers all event handlers.
  // Called directly for normal reconnects (ttyd still alive), or after a brief delay
  // following the /connect POST (ttyd was dead and needed respawning).
  //
  // Local const `ws` captures this specific instance so each handler can check
  // `if (ws !== _ws) return;` (stale guard). Without it, rapid reconnects or
  // session switches cause old handlers to fire on the new _ws while it is still
  // CONNECTING → send error → close → reconnect → infinite loop (Bug 2).
  function _connectWebSocket() {
    // 'tty' subprotocol is REQUIRED — without it ttyd never starts the PTY.
    // Confirmed via raw Python WebSocket tests: ttyd accepts the TCP upgrade but
    // sits completely silent (no child process spawned) when subprotocol is omitted.
    const ws = new WebSocket(url, ['tty']);
    _ws = ws;
    ws.binaryType = 'arraybuffer';

    ws.addEventListener('open', function() {
      if (ws !== _ws) return; // stale connection — superseded by a newer one, ignore
      // NOTE: do NOT reset _reconnectAttempts here. The server-side proxy accepts
      // the WS before confirming ttyd is alive (auto-spawning if needed), but the
      // browser 'open' event fires as soon as the proxy accepts — not when ttyd
      // is actually ready. Resetting here caused the 0→1→0→1 bounce. Instead,
      // reset on first data message (proves ttyd is alive and relaying).
      // Cancel any pending overlay show (grace period may not have elapsed yet)
      if (_overlayTimer) { clearTimeout(_overlayTimer); _overlayTimer = null; }
      if (reconnectOverlay) reconnectOverlay.classList.add('hidden');
      // Step 1: TEXT frame auth handshake — ttyd checks AuthToken before starting PTY
      ws.send(JSON.stringify({ AuthToken: '' }));
      // Step 2: BINARY frame with initial terminal dimensions — [0x31] + JSON({columns, rows})
      if (_term) {
        ws.send(encodePayload(0x31, JSON.stringify({ columns: _term.cols, rows: _term.rows })));
      }
      // Auto-focus the terminal so user can type immediately without clicking
      if (_term) _term.focus();
    });

    ws.addEventListener('message', function(e) {
      if (ws !== _ws) return; // stale connection — superseded by a newer one, ignore
      if (!_term) return;
      // First data message proves ttyd is alive and relaying — safe to reset counter.
      // We deliberately do NOT reset in the 'open' handler: the server-side proxy
      // accepts the browser WS before ttyd is fully confirmed alive, so 'open'
      // firing alone doesn't mean data will flow. Resetting here prevents the
      // 0→1→0→1 bounce that kept the reconnect loop from escalating to /connect.
      if (_reconnectAttempts > 0) _reconnectAttempts = 0;
      if (e.data instanceof ArrayBuffer) {
        var msg = new Uint8Array(e.data);
        if (msg.length < 1) return;
        var msgType = msg[0];
        var payload = msg.slice(1);
        if (msgType === 0x30) {  // '0' = terminal output — write to xterm.js
          // decode: Uint8Array → UTF-8 string. write(Uint8Array) treats bytes as Latin-1.
          _term.write(_decoder ? _decoder.decode(payload) : payload);
        }
        // 0x31 ('1') = window title, 0x32 ('2') = preferences — ignore for now
      } else if (typeof e.data === 'string') {
        _term.write(e.data);  // fallback for text frames
      }
    });

    ws.addEventListener('close', function() {
      if (ws !== _ws) return; // stale connection — don't reconnect for old sockets
      if (!_currentSession) return; // intentional close — don't reconnect
      // Grace period: delay showing the overlay so brief reconnects (e.g. service
      // restart) are invisible to the user. If onopen fires within the grace window,
      // it cancels _overlayTimer and the user never sees "Reconnecting...".
      if (_overlayTimer) clearTimeout(_overlayTimer);
      _overlayTimer = setTimeout(function() {
        if (reconnectOverlay && _currentSession) reconnectOverlay.classList.remove('hidden');
        _overlayTimer = null;
      }, 1500);
      _reconnectAttempts++;
      // Exponential backoff: 1s, 2s, 4s, 8s, cap at 15s. Add jitter to avoid thundering herd.
      var delay = Math.min(1000 * Math.pow(2, _reconnectAttempts - 1), 15000);
      delay += Math.random() * 500; // jitter
      _reconnectTimer = setTimeout(connect, delay);
    });

    ws.addEventListener('error', function() {
      if (ws !== _ws) return; // stale connection — ignore
      console.warn('tmux-web: WebSocket error on', url);
    });
  }

  function connect() {
    // After 2 failed WS attempts, ttyd is likely dead (e.g. after service restart).
    // AWAIT the /connect POST before opening the WebSocket — ttyd must be alive first.
    // fetch() includes cookies automatically for same-origin requests so auth is transparent.
    //
    // Critical: this path uses .then() so _connectWebSocket() runs only AFTER the POST
    // response (plus an 800ms settle delay for ttyd to bind its port). The early return
    // prevents falling through to the direct _connectWebSocket() call below.
    if (_reconnectAttempts >= 2 && _currentSession) {
      var connectPath;
      if (remoteId) {
        // Remote session: route through federation proxy
        connectPath = '/api/federation/' + encodeURIComponent(remoteId) + '/connect/' + encodeURIComponent(_currentSession);
      } else {
        // Local session
        connectPath = '/api/sessions/' + encodeURIComponent(_currentSession) + '/connect';
      }
      fetch(connectPath, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
      })
        .catch(function() { return null; })
        .then(function() {
          // Brief delay for ttyd to bind its port after /connect spawns it
          setTimeout(_connectWebSocket, 800);
        });
      return; // Don't fall through — .then() handles the WebSocket creation
    }

    _connectWebSocket();
  }

  connect();
}
function initVisualViewport() {
  if (!window.visualViewport) return;
  if (_vpHandler) window.visualViewport.removeEventListener('resize', _vpHandler);

  _vpHandler = function() {
    if (!_term || !_fitAddon) return;
    var container = document.getElementById('terminal-container');
    if (!container) return;

    // Resize container to fill visual viewport above keyboard.
    // Account for header + mobile toolbar (if visible).
    var headerHeight = 44; // matches --header-height CSS custom property
    var toolbar = document.getElementById('mobile-toolbar');
    var toolbarHeight = (toolbar && !toolbar.classList.contains('hidden')) ? toolbar.offsetHeight : 0;
    var vvh = window.visualViewport.height;
    var termHeight = Math.max(100, vvh - headerHeight - toolbarHeight);
    container.style.height = termHeight + 'px';

    // Ensure terminal viewport stays pinned — prevent the page from
    // shifting upward when the soft keyboard opens on mobile.
    window.scrollTo(0, 0);

    // Refit xterm.js to new container size
    try { _fitAddon.fit(); } catch (_) {}
  };

  window.visualViewport.addEventListener('resize', _vpHandler);
}

// ——— Terminal creation ————————————————————————————————————————————

/**
 * Create (or recreate) the xterm.js Terminal and FitAddon instances.
 * Disposes any existing terminal first.
 * Stores the results in module-level _term and _fitAddon.
 * @param {number} [fontSize=14] - font size in pixels, from server display settings
 */
function createTerminal(fontSize) {
  // Dispose any existing instance
  if (_term) {
    _term.dispose();
    _term = null;
    _fitAddon = null;
  }

  // Use the fontSize passed from app.js (getDisplaySettings().fontSize), defaulting to 14.
  var storedFontSize = (typeof fontSize === 'number' && fontSize > 0) ? fontSize : 14;

  const mobile = window.innerWidth < 600; // matches MOBILE_THRESHOLD in app.js
  const effectiveFontSize = mobile ? Math.min(storedFontSize, 12) : storedFontSize;

  _term = new window.Terminal({
    cursorBlink: true,
    fontSize: effectiveFontSize,
    fontFamily: "'SF Mono', 'Fira Code', Consolas, monospace",
    theme: {
      background: '#000000',
      foreground: '#c9d1d9',
      cursor: '#58a6ff',
    },
    scrollback: mobile ? 500 : 5000,
    allowProposedApi: true,
    // Override OSC 8 hyperlink handler — the default shows a confirm() dialog.
    // This opens links directly, matching native terminal behavior (Ghostty, iTerm2).
    linkHandler: {
      activate: function(event, uri) {
        window.open(uri, '_blank');
      },
    },
  });

  _fitAddon = new window.FitAddon.FitAddon();
  _term.loadAddon(_fitAddon);

  // --- Link handling ---
  // Two link systems work together:
  //
  // 1. WebLinksAddon: regex-detects plain-text URLs (http://..., https://...)
  //    and makes them clickable. Click opens in new tab.
  //
  // 2. OSC 8 hyperlinks: programs emit ESC]8;;URL\atext\aESC]8;;\a to make
  //    arbitrary text clickable (like Ghostty, iTerm2, etc.). xterm.js has
  //    built-in OscLinkProvider support, but its default handler shows a
  //    confirm() dialog. We override with a linkHandler that opens directly.
  //
  // Both open on plain click. Ctrl/Cmd+Click is not required — this is a
  // browser-based terminal where the link-vs-select distinction comes from
  // whether the user drags (select) or clicks (link).

  var WebLinksAddon = window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon;
  if (WebLinksAddon) {
    _term.loadAddon(new WebLinksAddon(function(event, uri) {
      window.open(uri, '_blank');
    }));
  }

  // Search addon — Ctrl+F to find text in terminal buffer
  var SearchAddon = window.SearchAddon && window.SearchAddon.SearchAddon;
  if (SearchAddon) {
    _searchAddon = new SearchAddon();
    _term.loadAddon(_searchAddon);
  }

  // Image addon — inline image rendering (Sixel, iTerm2 IIP, Kitty graphics)
  // Needed for tools like yazi file manager that use graphic protocols
  var ImageAddon = window.ImageAddon && window.ImageAddon.ImageAddon;
  if (ImageAddon) {
    _term.loadAddon(new ImageAddon());
  }
}

// ——— Search helpers ——————————————————————————————————————————————————————————————————————

function _openSearch() {
  var bar = document.getElementById('terminal-search-bar');
  var input = document.getElementById('terminal-search-input');
  if (bar) {
    bar.classList.remove('hidden');
    if (input) {
      input.focus();
      input.select();
    }
  }
}

function _closeSearch() {
  var bar = document.getElementById('terminal-search-bar');
  if (bar) bar.classList.add('hidden');
  if (_searchAddon) _searchAddon.clearDecorations();
  if (_term) _term.focus();
}

function _searchNext() {
  var input = document.getElementById('terminal-search-input');
  if (input && input.value && _searchAddon) {
    _searchAddon.findNext(input.value);
  }
}

function _searchPrev() {
  var input = document.getElementById('terminal-search-input');
  if (input && input.value && _searchAddon) {
    _searchAddon.findPrevious(input.value);
  }
}

// ——— Open / close —————————————————————————————————————————————————

/**
 * Open a terminal session inside #terminal-container.
 * @param {string} sessionName
 * @param {string} [remoteId]  Optional federation remote ID.
 *   When provided, the WebSocket connects via the federation proxy path
 *   ws://host/federation/{remoteId}/terminal/ws (same origin, no cross-origin).
 */
function openTerminal(sessionName, remoteId, fontSize) {
  // Null _currentSession first so any in-flight close handler on the old WS won't
  // schedule a reconnect (it checks `if (!_currentSession) return;`).
  _currentSession = null;
  _reconnectAttempts = 0; // reset backoff on new session open

  // Cancel any pending reconnect timer from the previous session.
  if (_reconnectTimer) {
    clearTimeout(_reconnectTimer);
    _reconnectTimer = null;
  }

  // Close existing WebSocket so it can't write to the new terminal (Bug 1 fix).
  if (_ws) {
    _ws.close();
    _ws = null;
  }

  _currentSession = sessionName;

  const container = document.getElementById('terminal-container');
  if (!container) {
    console.warn('[openTerminal] #terminal-container not found');
    return;
  }

  createTerminal(fontSize);

  _term.open(container);

  // --- Auto-refit on container resize (sidebar toggle, etc.) ---
  // xterm.js FitAddon only resizes on explicit fit() calls. A ResizeObserver
  // on the container handles ALL layout changes: sidebar toggle, window resize,
  // and any future CSS geometry change. Debounced to coalesce rapid events
  // (e.g. during CSS transition animation frames).
  if (_resizeObserver) { _resizeObserver.disconnect(); _resizeObserver = null; }
  if (typeof ResizeObserver !== 'undefined') {
    var _roTimer = null;
    _resizeObserver = new ResizeObserver(function() {
      clearTimeout(_roTimer);
      _roTimer = setTimeout(function() {
        if (_fitAddon) try { _fitAddon.fit(); } catch (_) {}
      }, 50);
    });
    _resizeObserver.observe(container);
  }

  // --- Clipboard integration ---
  // Copy: Ctrl+Shift+C intercepts and copies selection to system clipboard
  // Paste: handled natively by xterm.js (browser paste event → hidden textarea → onData → WebSocket)
  //   Cmd+V (macOS) and Ctrl+Shift+V (Linux) both trigger native browser paste events
  _term.attachCustomKeyEventHandler(function(e) {
    if (e.type !== 'keydown') return true;

    // Ctrl+Shift+C → copy selection to clipboard
    if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.code === 'KeyC')) {
      var sel = _term.getSelection();
      if (sel) _copyToClipboard(sel);
      return false;  // prevent xterm from processing
    }

    // Ctrl+F → open search bar
    if (e.ctrlKey && !e.shiftKey && (e.key === 'f' || e.key === 'F' || e.code === 'KeyF')) {
      _openSearch();
      return false;
    }

    return true;  // let xterm handle all other keys normally
  });

  // Auto-copy: when mouse selection ends, copy to system clipboard.
  // Matches terminal emulator conventions (iTerm2, WezTerm, ttyd native).
  // onSelectionChange fires whenever selection changes — copy if text is selected.
  // When selection is cleared (empty string), we skip the clipboard write.
  _term.onSelectionChange(function() {
    var sel = _term.getSelection();
    if (sel) {
      _copyToClipboard(sel);
    }
  });

  // OSC 52 clipboard integration — bridges tmux clipboard to the browser.
  // When tmux copies text (with `set-clipboard on` in .tmux.conf), it sends
  // an OSC 52 escape sequence to the terminal. xterm.js surfaces this via the
  // parser API. We intercept and write the decoded text to the system clipboard
  // so that: Ctrl+B [ → select → Enter (tmux copy) → system clipboard receives it.
  _term.parser.registerOscHandler(52, function(data) {
    // OSC 52 format: Pc ; Pd — Pc = selection target (c/p/q/s/0-7), Pd = base64 text
    var parts = data.split(';');
    if (parts.length >= 2) {
      try {
        var text = atob(parts[1]);
        _copyToClipboard(text);
      } catch (e) {
        // Invalid base64 or unsupported — silently ignore
      }
    }
    return true;  // Handled — don't pass to xterm's default handler
  });

  if (_fitAddon) {
    // requestAnimationFrame guarantees one full browser layout pass after the flex
    // container becomes visible before fit() measures dimensions.
    // iOS Safari defers flex layout — calling fit() synchronously here gives 0px width
    // → 2-column terminal. The RAF and 500ms fallback fix this race condition.
    // Falls back to immediate execution in Node.js test environments where RAF is absent.
    const fitAddonRef = _fitAddon;
    const raf = typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame : (fn) => fn();
    raf(function() {
      try { fitAddonRef.fit(); } catch (_) {}
      // 500ms fallback for slow mobile layout engines (e.g. first paint on low-end devices)
      setTimeout(function() {
        try { if (_fitAddon) _fitAddon.fit(); } catch (_) {}
      }, 500);
    });
  }

  // Wire search bar buttons + keyboard handlers (idempotent — elements are static)
  var searchInput = document.getElementById('terminal-search-input');
  var searchClose = document.getElementById('terminal-search-close');
  var searchNextBtn = document.getElementById('terminal-search-next');
  var searchPrevBtn = document.getElementById('terminal-search-prev');

  if (searchInput) {
    // Remove old listeners by replacing with cloned element (avoids duplicate handlers on reconnect)
    var newInput = searchInput.cloneNode(true);
    searchInput.parentNode.replaceChild(newInput, searchInput);
    searchInput = newInput;
    searchInput.addEventListener('input', function() {
      if (_searchAddon && searchInput.value) {
        _searchAddon.findNext(searchInput.value);
      } else if (_searchAddon) {
        _searchAddon.clearDecorations();
      }
    });
    searchInput.addEventListener('keydown', function(e) {
      if (e.key === 'Enter') {
        e.preventDefault();
        if (e.shiftKey) _searchPrev(); else _searchNext();
      }
      if (e.key === 'Escape') {
        e.preventDefault();
        _closeSearch();
      }
    });
  }
  if (searchClose) {
    var newClose = searchClose.cloneNode(true);
    searchClose.parentNode.replaceChild(newClose, searchClose);
    newClose.addEventListener('click', _closeSearch);
  }
  if (searchNextBtn) {
    var newNext = searchNextBtn.cloneNode(true);
    searchNextBtn.parentNode.replaceChild(newNext, searchNextBtn);
    newNext.addEventListener('click', _searchNext);
  }
  if (searchPrevBtn) {
    var newPrev = searchPrevBtn.cloneNode(true);
    searchPrevBtn.parentNode.replaceChild(newPrev, searchPrevBtn);
    newPrev.addEventListener('click', _searchPrev);
  }

  // --- Right-click context menu ---
  // Suppress the browser context menu on plain right-click inside the terminal
  // so tmux's own menu (when `set -g mouse on`) isn't covered by the browser's.
  // Shift+RMB and Ctrl+RMB still open the browser context menu as escape hatches.
  container.addEventListener('contextmenu', function(e) {
    if (e.shiftKey || e.ctrlKey || e.metaKey) return; // let modified clicks through
    e.preventDefault();
  });

  connectWebSocket(sessionName, remoteId);
  initVisualViewport(); /* defined in Task 14 */

  // --- Mobile enhancements ---
  _initAndroidIMEFix(container);
  _initMobileToolbar();
}

// ——— switchTerminal — cache-aware session switching ————————————————

/**
 * Switch to a terminal session, using cached instance if available.
 * This is the preferred entry point for session switching — eliminates
 * xterm.js teardown/recreation when switching between already-visited sessions.
 * @param {string} sessionName
 * @param {string} [remoteId]  Optional federation remote ID.
 * @param {number} [fontSize=14]
 */
function switchTerminal(sessionName, remoteId, fontSize) {
  var key = _buildSessionKey(sessionName, remoteId);

  // 1. Already showing this session — just focus and return
  if (_activeSessionKey === key) {
    if (_term) _term.focus();
    return;
  }

  // 2. Hide the currently active entry (don't dispose — keep it cached)
  if (_activeSessionKey && _terminalCache.has(_activeSessionKey)) {
    var activeEntry = _terminalCache.get(_activeSessionKey);
    // Save current module state back to the active entry before switching
    activeEntry.ws = _ws;
    activeEntry.reconnectTimer = _reconnectTimer;
    activeEntry.overlayTimer = _overlayTimer;
    activeEntry.reconnectAttempts = _reconnectAttempts;
    if (activeEntry.container) {
      activeEntry.container.style.display = 'none';
    }
  }

  // 3. If target is in cache — check WebSocket liveness before the fast path
  if (_terminalCache.has(key)) {
    var entry = _terminalCache.get(key);
    var wsIsLive = entry.ws &&
                   (entry.ws.readyState === WebSocket.OPEN ||
                    entry.ws.readyState === WebSocket.CONNECTING);
    if (wsIsLive) {
      // Fast path: live connection — swap containers, no network round-trip
      if (entry.container) {
        entry.container.style.display = '';
      }
      _syncModuleState(entry);
      _activeSessionKey = key;
      _touchCacheOrder(key);
      if (_fitAddon) {
        try { _fitAddon.fit(); } catch (_) {}
      }
      if (_term) _term.focus();
      return;
    }
    // WebSocket died while this session was in the background.
    // The close handler's stale-guard (ws !== _ws) suppresses reconnects for
    // non-active sessions, so the entry is left with a dead socket.
    // Destroy the stale entry and fall through to create a fresh terminal.
    // openSession() already POST'd /connect so the backend ttyd is ready.
    destroyCachedEntry(key);
  }

  // 4. Not in cache — create new entry
  // Evict LRU if at capacity
  if (_terminalCache.size >= _MAX_CACHED && _cacheOrder.length > 0) {
    var evictKey = _cacheOrder[0];
    destroyCachedEntry(evictKey);
  }

  // Create a new sub-container div inside #terminal-container
  var parentContainer = document.getElementById('terminal-container');
  if (!parentContainer) {
    console.warn('[switchTerminal] #terminal-container not found');
    return;
  }

  var subContainer = document.createElement('div');
  subContainer.className = 'terminal-instance';
  subContainer.setAttribute('data-session', key);
  subContainer.style.width = '100%';
  subContainer.style.height = '100%';
  parentContainer.appendChild(subContainer);

  // Reset module-level state for fresh creation.
  // Null _term/_fitAddon BEFORE createTerminal() so it does NOT dispose the
  // previously-active session's terminal — that terminal lives on in its own
  // cache entry and must stay alive for instant switching later.
  _term = null;
  _fitAddon = null;
  _currentSession = null;
  _reconnectAttempts = 0;
  _reconnectTimer = null;
  _overlayTimer = null;
  _ws = null;

  _currentSession = sessionName;

  createTerminal(fontSize);
  _term.open(subContainer);

  // Auto-refit on container resize
  _resizeObserver = null;
  if (typeof ResizeObserver !== 'undefined') {
    var _roTimer = null;
    _resizeObserver = new ResizeObserver(function() {
      clearTimeout(_roTimer);
      _roTimer = setTimeout(function() {
        if (_fitAddon) try { _fitAddon.fit(); } catch (_) {}
      }, 50);
    });
    _resizeObserver.observe(subContainer);
  }

  // Clipboard integration
  _term.attachCustomKeyEventHandler(function(e) {
    if (e.type !== 'keydown') return true;
    if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.code === 'KeyC')) {
      var sel = _term.getSelection();
      if (sel) _copyToClipboard(sel);
      return false;
    }
    if (e.ctrlKey && !e.shiftKey && (e.key === 'f' || e.key === 'F' || e.code === 'KeyF')) {
      _openSearch();
      return false;
    }
    return true;
  });

  _term.onSelectionChange(function() {
    var sel = _term.getSelection();
    if (sel) _copyToClipboard(sel);
  });

  _term.parser.registerOscHandler(52, function(data) {
    var parts = data.split(';');
    if (parts.length >= 2) {
      try {
        var text = atob(parts[1]);
        _copyToClipboard(text);
      } catch (e) {}
    }
    return true;
  });

  if (_fitAddon) {
    var fitAddonRef = _fitAddon;
    var raf = typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame : function(fn) { fn(); };
    raf(function() {
      try { fitAddonRef.fit(); } catch (_) {}
      setTimeout(function() {
        if (_fitAddon) try { _fitAddon.fit(); } catch (_) {}
      }, 500);
    });
  }

  // Right-click context menu suppression
  subContainer.addEventListener('contextmenu', function(e) {
    if (e.shiftKey || e.ctrlKey || e.metaKey) return;
    e.preventDefault();
  });

  connectWebSocket(sessionName, remoteId);
  initVisualViewport();
  _initAndroidIMEFix(subContainer);
  _initMobileToolbar();

  // Wire search bar (idempotent)
  var searchInput = document.getElementById('terminal-search-input');
  var searchClose = document.getElementById('terminal-search-close');
  var searchNextBtn = document.getElementById('terminal-search-next');
  var searchPrevBtn = document.getElementById('terminal-search-prev');
  if (searchInput) {
    var newInput = searchInput.cloneNode(true);
    searchInput.parentNode.replaceChild(newInput, searchInput);
    searchInput = newInput;
    searchInput.addEventListener('input', function() {
      if (_searchAddon && searchInput.value) _searchAddon.findNext(searchInput.value);
      else if (_searchAddon) _searchAddon.clearDecorations();
    });
    searchInput.addEventListener('keydown', function(e) {
      if (e.key === 'Enter') { e.preventDefault(); if (e.shiftKey) _searchPrev(); else _searchNext(); }
      if (e.key === 'Escape') { e.preventDefault(); _closeSearch(); }
    });
  }
  if (searchClose) { var nc = searchClose.cloneNode(true); searchClose.parentNode.replaceChild(nc, searchClose); nc.addEventListener('click', _closeSearch); }
  if (searchNextBtn) { var nn = searchNextBtn.cloneNode(true); searchNextBtn.parentNode.replaceChild(nn, searchNextBtn); nn.addEventListener('click', _searchNext); }
  if (searchPrevBtn) { var np = searchPrevBtn.cloneNode(true); searchPrevBtn.parentNode.replaceChild(np, searchPrevBtn); np.addEventListener('click', _searchPrev); }

  // Store in cache
  var newEntry = {
    container: subContainer,
    term: _term,
    fitAddon: _fitAddon,
    ws: _ws,
    searchAddon: _searchAddon,
    resizeObserver: _resizeObserver,
    reconnectTimer: _reconnectTimer,
    overlayTimer: _overlayTimer,
    reconnectAttempts: _reconnectAttempts,
    currentSession: _currentSession,
    remoteId: remoteId || '',
  };
  _terminalCache.set(key, newEntry);
  _activeSessionKey = key;
  _touchCacheOrder(key);
}

// ——— Cache entry teardown ————————————————————————————————————————

/**
 * Full teardown of one cached entry: close WS, dispose term, remove container div, delete from cache.
 * @param {string} key - session key to destroy
 */
function destroyCachedEntry(key) {
  var entry = _terminalCache.get(key);
  if (!entry) return;

  // Clean up timers
  if (entry.reconnectTimer) clearTimeout(entry.reconnectTimer);
  if (entry.overlayTimer) clearTimeout(entry.overlayTimer);

  // Close WebSocket
  if (entry.ws) {
    // Null out currentSession on entry so close handler won't reconnect
    entry.currentSession = null;
    entry.ws.close();
  }

  // Disconnect ResizeObserver
  if (entry.resizeObserver) entry.resizeObserver.disconnect();

  // Dispose xterm.js Terminal
  if (entry.term) entry.term.dispose();

  // Remove container div from DOM
  if (entry.container && entry.container.parentNode) {
    entry.container.parentNode.removeChild(entry.container);
  }

  // Remove from cache and LRU order
  _terminalCache.delete(key);
  var idx = _cacheOrder.indexOf(key);
  if (idx !== -1) _cacheOrder.splice(idx, 1);

  // If this was the active session, clear module state
  if (_activeSessionKey === key) {
    _activeSessionKey = null;
    _syncModuleState(null);
  }
}

/**
 * Close the current terminal session and clean up all resources.
 * If the active session has a cache entry, destroys it.
 */
function closeTerminal() {
  // If we have a cached active session, destroy its cache entry
  if (_activeSessionKey && _terminalCache.has(_activeSessionKey)) {
    destroyCachedEntry(_activeSessionKey);
  }

  if (_vpHandler) {
    if (window.visualViewport) window.visualViewport.removeEventListener('resize', _vpHandler);
    _vpHandler = null;
  }

  if (_reconnectTimer) {
    clearTimeout(_reconnectTimer);
    _reconnectTimer = null;
  }

  if (_overlayTimer) {
    clearTimeout(_overlayTimer);
    _overlayTimer = null;
  }

  if (_ws) {
    _ws.close();
    _ws = null;
  }

  if (_resizeObserver) { _resizeObserver.disconnect(); _resizeObserver = null; }

  // Clean up mobile enhancements
  _ctrlActive = false;
  _altActive = false;
  var mobileToolbar = document.getElementById('mobile-toolbar');
  if (mobileToolbar) mobileToolbar.classList.add('hidden');

  if (_term) {
    _term.dispose();
    _term = null;
    _fitAddon = null;
    _searchAddon = null;
  }

  _closeSearch();
  _currentSession = null;
  _reconnectAttempts = 0; // reset backoff on intentional close
  _activeSessionKey = null;
}

/**
 * Destroy all cached terminal entries.
 */
function closeAllTerminals() {
  var keys = Array.from(_terminalCache.keys());
  for (var i = 0; i < keys.length; i++) {
    destroyCachedEntry(keys[i]);
  }
  _activeSessionKey = null;
  _cacheOrder = [];
  // Also clean up any non-cached module state
  closeTerminal();
}

/**
 * Check if a session is cached with a live (open) WebSocket.
 * @param {string} sessionKey
 * @returns {boolean}
 */
function isTerminalCached(sessionKey) {
  var entry = _terminalCache.get(sessionKey);
  if (!entry) return false;
  return entry.ws && entry.ws.readyState === WebSocket.OPEN;
}

// ——— Expose to app.js ——————————————————————————————————————————————
window._switchTerminal = switchTerminal;          // NEW — preferred entry point (cache-aware)
window._openTerminal = openTerminal;              // KEEP for backward compat (creates fresh, no cache check)
window._closeTerminal = closeTerminal;
window._closeAllTerminals = closeAllTerminals;    // NEW — destroy all cached entries
window._destroyCachedTerminal = destroyCachedEntry; // NEW — for deleting specific sessions
window._isTerminalCached = isTerminalCached;      // NEW — check if session is cached with live WS
window._openSearch = _openSearch;
window._closeSearch = _closeSearch;

// ---------------------------------------------------------------------------
// setTerminalFontSize — live font-size update without reconnecting
// ---------------------------------------------------------------------------

/**
 * Update the terminal font size at runtime without reconnecting.
 * Modifies _term.options.fontSize and refits the terminal to recalculate dimensions.
 * No-op when no terminal is open.
 * @param {number} size - font size in pixels
 */
function setTerminalFontSize(size) {
  if (!_term) return;
  _term.options.fontSize = size;
  if (_fitAddon) {
    try { _fitAddon.fit(); } catch (_) {}
  }
}

window._setTerminalFontSize = setTerminalFontSize;

// ---------------------------------------------------------------------------
// Android IME fix — prevent double-space-to-period from duplicating input
// Android keyboards use insertReplacementText beforeinput events to perform
// auto-corrections (e.g. " " → ". "). xterm.js's hidden textarea gets confused
// by these replacements, causing the entire composition buffer to replay.
// ---------------------------------------------------------------------------

function _initAndroidIMEFix(container) {
  if (!/Android/i.test(navigator.userAgent)) return;

  // Wait a tick for xterm.js to create its hidden textarea
  setTimeout(function() {
    var ta = container.querySelector('.xterm-helper-textarea');
    if (!ta) return;

    ta.addEventListener('beforeinput', function(e) {
      if (e.inputType === 'insertReplacementText') {
        e.preventDefault();
        e.stopImmediatePropagation();
        var data = e.data || '';
        if (data && _ws && _ws.readyState === WebSocket.OPEN) {
          // Send backspace (delete the char being replaced) + replacement text
          _ws.send(_encodePayload(0x30, '\x08' + data));
        }
        // Clear textarea to prevent stale IME state
        ta.value = '';
      }
    }, true); // capture phase — run before xterm.js handlers
  }, 100);
}

// ---------------------------------------------------------------------------
// Mobile toolbar — virtual keys for Esc, Tab, Ctrl, Alt, arrows, specials
// Ctrl and Alt are one-shot modifier toggles: tap Ctrl, then type a letter
// on the soft keyboard → sends Ctrl+letter, modifier auto-deactivates.
// ---------------------------------------------------------------------------

function _initMobileToolbar() {
  var isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
  if (!isTouchDevice) return;

  var toolbar = document.getElementById('mobile-toolbar');
  if (!toolbar) return;

  toolbar.classList.remove('hidden');
  _ctrlActive = false;
  _altActive = false;

  var ctrlBtn = toolbar.querySelector('[data-modifier="ctrl"]');
  var altBtn = toolbar.querySelector('[data-modifier="alt"]');

  // Single delegated handler for all toolbar keys.
  // Use 'pointerdown' + preventDefault to avoid focusing xterm's hidden textarea
  // (which brings up the soft keyboard). Modifier toggles (Ctrl/Alt) DO focus
  // intentionally so the user can type the next character on the soft keyboard.
  toolbar.addEventListener('pointerdown', function(e) {
    var btn = e.target.closest('.mobile-toolbar__key');
    if (!btn) return;

    // --- Modifier toggles: focus terminal to bring up keyboard ---
    var modifier = btn.dataset.modifier;
    if (modifier === 'ctrl') {
      e.preventDefault();
      _ctrlActive = !_ctrlActive;
      btn.classList.toggle('mobile-toolbar__key--active', _ctrlActive);
      if (_altActive && altBtn) { _altActive = false; altBtn.classList.remove('mobile-toolbar__key--active'); }
      // Focus terminal so soft keyboard appears for the next character
      if (_ctrlActive && _term) _term.focus();
      return;
    }
    if (modifier === 'alt') {
      e.preventDefault();
      _altActive = !_altActive;
      btn.classList.toggle('mobile-toolbar__key--active', _altActive);
      if (_ctrlActive && ctrlBtn) { _ctrlActive = false; ctrlBtn.classList.remove('mobile-toolbar__key--active'); }
      // Focus terminal so soft keyboard appears for the next character
      if (_altActive && _term) _term.focus();
      return;
    }

    // --- Direct key presses: send sequence WITHOUT focusing (no keyboard popup) ---
    e.preventDefault();  // prevent focus transfer to xterm textarea
    var key = btn.dataset.key;
    var input = btn.dataset.input;
    var seq = '';

    if (key) {
      switch (key) {
        case 'Escape':     seq = '\x1b'; break;
        case 'Tab':        seq = '\t'; break;
        case 'ArrowUp':    seq = '\x1b[A'; break;
        case 'ArrowDown':  seq = '\x1b[B'; break;
        case 'ArrowRight': seq = '\x1b[C'; break;
        case 'ArrowLeft':  seq = '\x1b[D'; break;
      }
    } else if (input) {
      seq = input;
    }

    if (seq && _ws && _ws.readyState === WebSocket.OPEN) {
      _ws.send(_encodePayload(0x30, seq));
    }
    // Do NOT focus terminal — keeps soft keyboard hidden
  });
}
