//
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 MindTenet LLC. All rights reserved.
// SPDX-License-Identifier: AGPL-3.0-or-later
//

/**
 * UndefHijack - Embeddable terminal hijack control widget.
 *
 * Connects to the TermHub browser WebSocket endpoint (/ws/browser/{workerId}/term)
 * and provides live terminal viewing plus hijack controls (pause/step/release).
 *
 * Usage:
 *   const w = new UndefHijack(containerEl, { workerId: 'myworker' });
 *   w.connect();    // called automatically on construction
 *   w.disconnect(); // close WS
 *   w.dispose();    // tear down entirely
 */
// @ts-check

// ── Module-level guards ───────────────────────────────────────────────────────
let _hijackCssInjected = false;
let _hijackInstanceCount = 0;
// Capture script element synchronously (available only during initial parse)
/** @type {HTMLScriptElement | null} */
const _hijackScriptEl =
  typeof document !== "undefined" && document.currentScript instanceof HTMLScriptElement
    ? document.currentScript
    : null;

// ── CSS injection ─────────────────────────────────────────────────────────────
function _injectHijackCSS() {
  if (_hijackCssInjected) return;
  _hijackCssInjected = true;
  const link = document.createElement("link");
  link.rel = "stylesheet";
  link.href = _hijackScriptEl?.src ? `${_hijackScriptEl.src.replace(/[^/]*$/, "")}hijack.css` : "hijack.css";
  document.head.appendChild(link);
}

// ── UndefHijack class ─────────────────────────────────────────────────────────
class UndefHijack {
  /**
   * Create an embeddable hijack control widget.
   *
   * @param {HTMLElement} container - Element to mount the widget into.
   * @param {object} [config={}] - Configuration options.
   * @param {string} [config.wsUrl] - Full WS URL. If omitted, auto-built from workerId.
   * @param {string} [config.workerId] - Worker ID (used in auto URL and display title).
   * @param {string} [config.wsPathPrefix='/ws/browser'] - Path prefix for auto URL construction.
   * @param {string|null} [config.title] - Override toolbar title text.
   * @param {boolean} [config.showInput=true] - Show text-input bar when hijacked.
   * @param {boolean} [config.showAnalysis=true] - Show collapsible analysis panel.
   * @param {number} [config.heartbeatInterval=5000] - Heartbeat interval (ms) when hijacked.
   * @param {boolean} [config.mobileKeys=true] - Show collapsible special-key toolbar when hijacked.
   */
  constructor(container, config = {}) {
    this._container = container;
    this._config = {
      wsPathPrefix: "/ws/browser",
      showInput: true,
      showAnalysis: true,
      heartbeatInterval: 5000,
      mobileKeys: true,
      ...config,
    };
    this._uid = ++_hijackInstanceCount;

    // Instance state
    this._ws = null;
    this._term = null;
    this._fitAddon = null;
    this._ro = null;
    this._heartbeatTimer = null;
    this._reconnectTimer = null;
    this._reconnectAttempt = 0;
    this._hijacked = false;
    this._hijackedByMe = false;
    this._canHijack = false;
    this._workerOnline = false;
    this._inputMode = "hijack"; // "hijack" | "open"
    this._hijackControl = "ws"; // "ws" | "rest"
    this._hijackStepSupported = true;
    this._restHijackId = null;
    this._mobileKeysVisible = false;
    this._root = null;

    _injectHijackCSS();
    this._buildDOM();
    this.connect();
  }

  // ── Public API ──────────────────────────────────────────────────────────────

  /** Open the WebSocket connection. Called automatically on construction. */
  connect() {
    this._connectWs();
  }

  /** Close the WebSocket connection. */
  disconnect() {
    this._clearHeartbeat();
    if (this._ro) {
      this._ro.disconnect();
      this._ro = null;
    }
    if (this._reconnectTimer) {
      clearTimeout(this._reconnectTimer);
      this._reconnectTimer = null;
    }
    if (this._ws) {
      try {
        this._ws.close();
      } catch (_) {}
      this._ws = null;
    }
  }

  /** Tear down entirely: xterm, WebSocket, ResizeObserver, and DOM. */
  dispose() {
    this.disconnect(); // handles _ro, _heartbeatTimer, _ws, _reconnectTimer
    if (this._term) {
      this._term.dispose();
      this._term = null;
    }
    this._fitAddon = null;
    if (this._root?.parentNode) {
      this._root.parentNode.removeChild(this._root);
    }
    this._root = null;
  }

  // ── Internal helpers ────────────────────────────────────────────────────────

  /** Query by ID within this instance's root (IDs are prefixed with h-{uid}-). */
  /**
   * @param {string} id
   * @returns {any}
   */
  _q(id) {
    return /** @type {any} */ (this._root).querySelector(`#h-${this._uid}-${id}`);
  }

  /** Escape HTML special characters to prevent XSS when interpolating into innerHTML. */
  /**
   * @param {unknown} s
   * @returns {string}
   */
  _escHtml(s) {
    const d = document.createElement("div");
    d.textContent = String(s);
    return d.innerHTML;
  }

  _resolveWsUrl() {
    const proto = location.protocol === "https:" ? "wss:" : "ws:";
    const url = this._config.wsUrl;
    if (url) {
      if (url.startsWith("/")) return `${proto}//${location.host}${url}`;
      return url; // already absolute ws:// or wss://
    }
    const workerId = encodeURIComponent(this._config.workerId || "default");
    const prefix = this._config.wsPathPrefix || "/ws/browser";
    return `${proto}//${location.host}${prefix}/${workerId}/term`;
  }

  _resolveHijackApiBase() {
    const workerId = encodeURIComponent(this._config.workerId || "default");
    return `/worker/${workerId}/hijack`;
  }

  /**
   * @param {'acquire'|'heartbeat'|'release'|'step'|'send'} action
   * @param {Record<string, unknown>} payload
   * @returns {Promise<Record<string, unknown>|null>}
   */
  async _restHijack(action, payload = {}) {
    const headers = { "content-type": "application/json" };
    const base = this._resolveHijackApiBase();
    let path = "";
    if (action === "acquire") {
      path = `${base}/acquire`;
    } else {
      if (!this._restHijackId) return null;
      path = `${base}/${encodeURIComponent(this._restHijackId)}/${action}`;
    }
    const resp = await fetch(path, {
      method: "POST",
      credentials: "include",
      headers,
      body: JSON.stringify(payload),
    });
    if (!resp.ok) return null;
    return /** @type {Record<string, unknown>} */ (await resp.json());
  }

  // ── DOM Construction ────────────────────────────────────────────────────────

  _buildDOM() {
    /** @param {string} id */
    const p = (id) => `h-${this._uid}-${id}`; // ID prefix helper
    const workerId = this._config.workerId || "";
    const title = this._config.title || (workerId ? `Terminal: ${workerId}` : "Terminal");
    const showAnalysis = this._config.showAnalysis !== false;

    const root = document.createElement("div");
    root.className = "undef-hijack";
    root.innerHTML = `
      <div class="hijack-toolbar">
        <span class="hijack-title">${this._escHtml(title)}</span>
        <span class="hijack-status">
          <span class="hijack-status-dot" id="${p("dot")}"></span>
          <span id="${p("statustext")}">Connecting…</span>
        </span>
        <div class="hijack-controls">
          <button class="hbtn primary" id="${p("hijack")}" disabled>Hijack</button>
          <button class="hbtn" id="${p("step")}" disabled>Step</button>
          <button class="hbtn danger" id="${p("release")}" disabled>Release</button>
          <button class="hbtn" id="${p("resync")}" disabled title="Request snapshot">⟳ Resync</button>
          <button class="hbtn" id="${p("analyze")}" disabled>Analyze</button>
          <button class="hbtn" id="${p("kbdtoggle")}" title="Mobile key toolbar">⌨</button>
        </div>
        <span class="hijack-prompt" id="${p("prompt")}" title="Current prompt ID"></span>
      </div>
      <div class="hijack-terminal" id="${p("terminal")}"></div>
      <div class="hijack-input-row" id="${p("inputrow")}">
        <input class="hijack-input-field" id="${p("inputfield")}"
          placeholder="Send keys… (Enter to send, e.g. \\r for Return)"
          autocomplete="off" spellcheck="false">
        <button class="hijack-input-send" id="${p("inputsend")}">Send</button>
      </div>
      <div class="mobile-keys" id="${p("mobilekeys")}"></div>
      ${
        showAnalysis
          ? `
      <details class="hijack-analysis" id="${p("analysis")}">
        <summary>Analysis</summary>
        <pre id="${p("analysistext")}"></pre>
      </details>`
          : ""
      }
    `;

    this._root = root;
    this._container.appendChild(root);
    this._bindEvents();
  }

  // ── xterm ─────────────────────────────────────────────────────────────────

  _ensureTerm() {
    if (this._term) return this._term;
    const terminalCtor = /** @type {any} */ (window).Terminal;
    if (!terminalCtor) throw new Error("xterm.js not loaded");

    const termDiv = this._q("terminal");
    this._term = new terminalCtor({
      convertEol: false,
      cursorBlink: true,
      fontFamily: "'Fira Code', 'Cascadia Code', 'Consolas', monospace",
      fontSize: 13,
      theme: { background: "#0b0f14" },
      allowTransparency: true,
    });
    this._term.open(termDiv);
    this._term.focus();

    const fitAddonGlobal = /** @type {any} */ (
      /** @type {any} */ (window).FitAddon ?? /** @type {any} */ (globalThis).FitAddon
    );
    if (fitAddonGlobal) {
      this._fitAddon = new fitAddonGlobal.FitAddon();
      this._term.loadAddon(this._fitAddon);
      requestAnimationFrame(() => {
        try {
          this._fitAddon.fit();
        } catch (_) {}
      });
      this._ro = new ResizeObserver(() => {
        try {
          this._fitAddon.fit();
        } catch (_) {}
      });
      this._ro.observe(termDiv);
    }

    // Forward keyboard input to WS when hijacked or in open mode
    this._term.onData((/** @type {string} */ data) => {
      if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
      if (this._inputMode !== "open" && !this._hijackedByMe) return;
      this._wsSend({ type: "input", data });
    });

    return this._term;
  }

  // ── WebSocket ─────────────────────────────────────────────────────────────

  /**
   * @param {Record<string, unknown>} obj
   */
  _wsSend(obj) {
    if (this._ws && this._ws.readyState === WebSocket.OPEN) {
      this._ws.send(JSON.stringify(obj));
    }
  }

  _clearHeartbeat() {
    if (this._heartbeatTimer) {
      clearInterval(this._heartbeatTimer);
      this._heartbeatTimer = null;
    }
  }

  _startHeartbeat() {
    this._clearHeartbeat();
    this._heartbeatTimer = setInterval(() => {
      if (!this._hijackedByMe) return;
      if (this._hijackControl === "rest") {
        this._restHijack("heartbeat", { lease_s: 60 }).catch(() => {});
        return;
      }
      if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
      this._wsSend({ type: "heartbeat" });
    }, this._config.heartbeatInterval || 5000);
  }

  _connectWs() {
    if (this._ws) {
      try {
        this._ws.close();
      } catch (_) {}
      this._ws = null;
    }
    // Do NOT reset _hijacked/_hijackedByMe here: the server will confirm the
    // actual state via 'hello'/'hijack_state' once the socket opens.  Resetting
    // eagerly would briefly re-enable the Hijack button even when another client
    // holds the lock, and could prompt a spurious hijack_request click.
    // (State is correctly reset to false in ws.onclose when the connection drops.)

    /** @type {WebSocket} */
    let ws;
    try {
      ws = new WebSocket(this._resolveWsUrl());
    } catch (e) {
      const err = e instanceof Error ? e : new Error(String(e));
      this._setStatus("bad", `Failed: ${err.message}`);
      return;
    }
    this._ws = ws;

    ws.onopen = () => {
      if (ws !== this._ws) return; // stale handler: a newer socket already replaced this one
      this._reconnectAttempt = 0;
      this._setStatus("live", "Connected (watching)");
      this._updateButtons();
      this._wsSend({ type: "snapshot_req" });
      this._startHeartbeat();
    };

    ws.onmessage = (e) => {
      let msg;
      try {
        msg = JSON.parse(e.data);
      } catch (_) {
        return;
      }
      if (!msg || !msg.type) return;
      this._handleMessage(msg);
    };

    ws.onclose = () => {
      if (ws !== this._ws) return; // stale handler from a replaced socket
      this._clearHeartbeat();
      this._hijacked = false;
      this._hijackedByMe = false;
      this._canHijack = false;
      this._workerOnline = false;
      this._inputMode = "hijack";
      this._hijackControl = "ws";
      this._hijackStepSupported = true;
      this._restHijackId = null;
      this._updateStatus();
      this._updateButtons();
      this._ws = null;
      this._scheduleReconnect();
    };

    ws.onerror = () => {
      try {
        ws.close();
      } catch (_) {}
    };
  }

  _scheduleReconnect() {
    if (this._reconnectTimer) return; // already scheduled
    const delays = [1, 2, 5, 10, 30];
    const attempt = this._reconnectAttempt;
    const delaySec = /** @type {number} */ (delays[Math.min(attempt, delays.length - 1)] || 30);
    this._reconnectAttempt = attempt + 1;
    this._setStatus("bad", `Reconnecting in ${delaySec}s…`);
    this._reconnectTimer = setTimeout(() => {
      this._reconnectTimer = null;
      this._connectWs();
    }, delaySec * 1000);
  }

  _buildMobileKeys() {
    const container = this._q("mobilekeys");
    if (!container) return;
    const keys = [
      { label: "ESC", data: "\x1b" },
      { label: "↑", data: "\x1b[A" },
      { label: "↓", data: "\x1b[B" },
      { label: "→", data: "\x1b[C" },
      { label: "←", data: "\x1b[D" },
      { label: "Tab", data: "\t" },
      { label: "^C", data: "\x03" },
      { label: "^D", data: "\x04" },
      { label: "^Z", data: "\x1a" },
    ];
    for (const { label, data } of keys) {
      const btn = document.createElement("button");
      btn.className = "mkey";
      btn.textContent = label;
      btn.addEventListener("click", () => {
        if (this._inputMode !== "open" && !this._hijackedByMe) return;
        this._wsSend({ type: "input", data });
      });
      container.appendChild(btn);
    }
  }

  // ── Message dispatch ──────────────────────────────────────────────────────

  /**
   * @param {any} msg
   */
  _handleMessage(msg) {
    switch (msg.type) {
      case "term":
        this._workerOnline = true;
        if (msg.data) {
          try {
            this._ensureTerm().write(msg.data);
          } catch (_) {}
        }
        break;

      case "snapshot": {
        this._workerOnline = true;
        const promptId = msg.prompt_detected?.prompt_id;
        this._setPromptId(promptId || "");
        try {
          const t = this._ensureTerm();
          t.reset();
          t.write("\u001b[2J\u001b[H");
          t.write((msg.screen || "").replace(/\n/g, "\r\n"));
        } catch (_) {}
        break;
      }

      case "analysis": {
        const pre = this._q("analysistext");
        if (pre) {
          pre.textContent = msg.formatted || "(no analysis)";
          const details = this._q("analysis");
          if (details) details.open = true;
        }
        break;
      }

      case "hello": {
        // {type, worker_id, can_hijack, hijacked, hijacked_by_me, input_mode, role}
        this._canHijack = !!msg.can_hijack;
        this._hijacked = !!msg.hijacked;
        this._hijackedByMe = !!msg.hijacked_by_me;
        this._workerOnline = !!msg.worker_online;
        if (msg.input_mode) this._inputMode = msg.input_mode;
        this._hijackControl = msg.hijack_control || msg.capabilities?.hijack_control || "ws";
        const stepSupported = msg.hijack_step_supported ?? msg.capabilities?.hijack_step_supported;
        this._hijackStepSupported = stepSupported !== false;
        this._updateStatus();
        this._updateButtons();
        break;
      }

      case "worker_connected":
        this._workerOnline = true;
        this._updateStatus();
        this._updateButtons();
        break;

      case "hijack_state":
        // {type, hijacked, owner: "me"|"other"|null, lease_expires_at, input_mode}
        this._hijacked = !!msg.hijacked;
        this._hijackedByMe = msg.owner === "me";
        if (!this._hijackedByMe) this._restHijackId = null;
        if (msg.input_mode) this._inputMode = msg.input_mode;
        // Keep the heartbeat interval in sync with ownership.
        if (this._hijackedByMe) {
          this._startHeartbeat();
        } else {
          this._clearHeartbeat();
        }
        this._updateStatus();
        this._updateButtons();
        break;

      case "worker_disconnected":
        this._workerOnline = false;
        this._hijacked = false;
        this._hijackedByMe = false;
        this._clearHeartbeat();
        this._updateStatus();
        this._updateButtons();
        break;

      case "input_mode_changed":
        if (msg.input_mode) this._inputMode = msg.input_mode;
        this._updateStatus();
        this._updateButtons();
        break;

      case "heartbeat_ack":
        break; // lease refreshed — no visible change needed

      case "error":
        this._setStatus("bad", `Error: ${msg.message || "unknown"}`);
        break;
    }
  }

  // ── UI State ──────────────────────────────────────────────────────────────

  /**
   * @param {string} level
   * @param {string} text
   */
  _setStatus(level, text) {
    const dot = this._q("dot");
    const txt = this._q("statustext");
    if (dot) {
      dot.className = `hijack-status-dot ${level}`;
    }
    if (txt) txt.textContent = text;
  }

  _updateStatus() {
    const connected = !!(this._ws && this._ws.readyState === WebSocket.OPEN);
    if (!connected) {
      this._setStatus("bad", "Disconnected");
    } else if (this._hijackedByMe) {
      this._setStatus("warn", "Hijacked (you)");
    } else if (this._hijacked) {
      this._setStatus("bad", "Hijacked (other)");
    } else if (!this._workerOnline) {
      this._setStatus("bad", "Worker offline");
    } else if (this._inputMode === "open") {
      this._setStatus("live", "Connected (shared)");
    } else {
      this._setStatus("live", "Connected (watching)");
    }

    // Show/hide text-input row based on whether we can send input
    const canInput = this._hijackedByMe || this._inputMode === "open";
    if (this._config.showInput !== false) {
      const row = this._q("inputrow");
      if (row) row.classList.toggle("visible", connected && canInput);
    }

    // Show/hide mobile-keys row
    if (this._config.mobileKeys !== false) {
      const mkRow = this._q("mobilekeys");
      if (mkRow) mkRow.classList.toggle("visible", connected && canInput && this._mobileKeysVisible);
    }
  }

  _updateButtons() {
    const connected = !!(this._ws && this._ws.readyState === WebSocket.OPEN);
    const hijackBtn = this._q("hijack");
    const stepBtn = this._q("step");
    const releaseBtn = this._q("release");
    const resyncBtn = this._q("resync");
    const analyzeBtn = this._q("analyze");

    if (!connected) {
      [hijackBtn, stepBtn, releaseBtn, resyncBtn, analyzeBtn].forEach((b) => {
        if (b) b.disabled = true;
      });
      return;
    }
    const isOpen = this._inputMode === "open";
    const hideHijack = isOpen || !this._canHijack;
    if (hijackBtn) hijackBtn.disabled = hideHijack || this._hijacked || !this._workerOnline;
    if (stepBtn) stepBtn.disabled = hideHijack || !this._hijackedByMe || !this._hijackStepSupported;
    if (releaseBtn) releaseBtn.disabled = hideHijack || !this._hijackedByMe;
    if (resyncBtn) resyncBtn.disabled = !this._workerOnline;
    if (analyzeBtn) analyzeBtn.disabled = hideHijack || !this._hijackedByMe;
    // Hide hijack controls for non-admin roles and in open mode
    if (hijackBtn) hijackBtn.style.display = hideHijack ? "none" : "";
    if (stepBtn) stepBtn.style.display = hideHijack ? "none" : "";
    if (releaseBtn) releaseBtn.style.display = hideHijack ? "none" : "";
  }

  /**
   * @param {string} id
   */
  _setPromptId(id) {
    const el = this._q("prompt");
    if (el) el.textContent = id ? `prompt: ${id}` : "";
  }

  // ── Event Binding ─────────────────────────────────────────────────────────

  _bindEvents() {
    this._q("hijack").addEventListener("click", () => {
      if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
      if (this._hijackControl === "rest") {
        this._restHijack("acquire", { owner: "dashboard", lease_s: 60 })
          .then((data) => {
            if (data && typeof data.hijack_id === "string") this._restHijackId = data.hijack_id;
          })
          .finally(() => this._wsSend({ type: "snapshot_req" }));
        return;
      }
      this._wsSend({ type: "hijack_request" });
    });

    this._q("step").addEventListener("click", () => {
      if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
      if (!this._hijackedByMe) return;
      if (this._hijackControl === "rest") {
        this._restHijack("step").catch(() => {});
      } else {
        this._wsSend({ type: "hijack_step" });
      }
      // Request snapshot + analysis shortly after the worker acts
      for (const ms of [250, 1000]) {
        setTimeout(() => {
          if (this._ws && this._ws.readyState === WebSocket.OPEN) this._wsSend({ type: "snapshot_req" });
        }, ms);
      }
      for (const ms of [450, 1200]) {
        setTimeout(() => {
          if (this._ws && this._ws.readyState === WebSocket.OPEN) this._wsSend({ type: "analyze_req" });
        }, ms);
      }
    });

    this._q("release").addEventListener("click", () => {
      if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
      if (this._hijackControl === "rest") {
        this._restHijack("release")
          .then(() => {
            this._restHijackId = null;
          })
          .finally(() => this._wsSend({ type: "snapshot_req" }));
        return;
      }
      this._wsSend({ type: "hijack_release" });
    });

    this._q("resync").addEventListener("click", () => {
      if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
      this._wsSend({ type: "snapshot_req" });
    });

    this._q("analyze").addEventListener("click", () => {
      if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
      if (!this._hijackedByMe) return;
      this._wsSend({ type: "analyze_req" });
    });

    // Mobile key toolbar toggle
    if (this._config.mobileKeys !== false) {
      this._buildMobileKeys();
      const kbdToggle = this._q("kbdtoggle");
      if (kbdToggle) {
        kbdToggle.addEventListener("click", () => {
          this._mobileKeysVisible = !this._mobileKeysVisible;
          this._updateStatus();
        });
      }
    }

    // Text input send — for pasting strings or escape sequences when hijacked
    const inputField = this._q("inputfield");
    const inputSend = this._q("inputsend");
    if (inputField) {
      const doSend = () => {
        const raw = inputField.value;
        if (!raw || (this._inputMode !== "open" && !this._hijackedByMe)) return;
        if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
        // Unescape \\r → \r, \\n → \n, \\t → \t, \\e → ESC
        const data = raw.replace(/\\r/g, "\r").replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\e/g, "\x1b");
        this._wsSend({ type: "input", data });
        inputField.value = "";
        try {
          this._ensureTerm().focus();
        } catch (_) {}
      };
      inputField.addEventListener("keydown", (/** @type {KeyboardEvent} */ e) => {
        if (e.key === "Enter") {
          e.preventDefault();
          doSend();
        }
      });
      if (inputSend) inputSend.addEventListener("click", doSend);
    }
  }
}

// ── Global exposure for CDN / script-tag use ──────────────────────────────────
if (typeof window !== "undefined") /** @type {any} */ (window).UndefHijack = UndefHijack;
