// Tests for terminal.js — muxterm rewrite
// Single WebSocket to muxterm, JSON control protocol, no terminal cache

import { createRequire } from 'node:module';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import fs from 'node:fs';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const require = createRequire(import.meta.url);

// ─── Helpers ─────────────────────────────────────────────────────────────────

/**
 * Load a fresh copy of terminal.js with isolated module-level state.
 * Returns helpers for testing the muxterm protocol.
 */
function loadTerminal() {
  const modulePath = join(__dirname, '..', 'terminal.js');
  delete require.cache[require.resolve(modulePath)];

  let capturedOnDataFn = null;
  let capturedOnResizeFn = null;
  let termWriteMessages = [];
  let lastWsInstance = null;
  let capturedWsUrl = null;
  let onDataCallCount = 0;
  let onResizeCallCount = 0;
  let focusCallCount = 0;
  let capturedReconnectFn = null;
  let capturedCloseHandler = null;
  let lastFetchUrl = null;
  let lastFetchOpts = null;
  let fetchReturnValue = { muxtermPort: 9999, token: 'test-token-abc' };

  const mockTerm = {
    cols: 80,
    rows: 24,
    open: () => {},
    onData: (fn) => { onDataCallCount++; capturedOnDataFn = fn; },
    onResize: (fn) => { onResizeCallCount++; capturedOnResizeFn = fn; },
    loadAddon: () => {},
    dispose: () => {},
    write: (data) => { termWriteMessages.push(data); },
    focus: () => { focusCallCount++; },
    attachCustomKeyEventHandler: () => {},
    getSelection: () => '',
    onSelectionChange: () => {},
    parser: { registerOscHandler: () => {} },
    options: { fontSize: 14 },
  };

  const sentMessages = [];

  class MockWebSocket {
    constructor(url) {
      this.url = url;
      this.readyState = 1; // OPEN
      this.binaryType = '';
      this._handlers = {};
      lastWsInstance = this;
      capturedWsUrl = url;
    }
    addEventListener(event, handler) {
      this._handlers[event] = handler;
      if (event === 'close') capturedCloseHandler = handler;
    }
    close() {}
    send(data) { sentMessages.push(data); }
  }
  MockWebSocket.OPEN = 1;
  MockWebSocket.CONNECTING = 0;

  const origSetTimeout = globalThis.setTimeout;
  globalThis.setTimeout = (fn, _ms) => {
    capturedReconnectFn = fn;
    return 0;
  };

  globalThis.WebSocket = MockWebSocket;
  globalThis.location = { protocol: 'http:', host: 'localhost', hostname: 'localhost' };
  globalThis.document = {
    getElementById: (id) => {
      if (id === 'terminal-container') return { appendChild: () => {}, addEventListener: () => {} };
      if (id === 'reconnect-overlay') return { classList: { add: () => {}, remove: () => {} } };
      return null;
    },
    querySelector: () => null,
    querySelectorAll: () => [],
    addEventListener: () => {},
    createElement: () => ({ style: {}, classList: { add: () => {}, remove: () => {} } }),
  };
  globalThis.window = {
    addEventListener: () => {},
    location: { href: '' },
    innerWidth: 1024,
    Terminal: function Terminal() { return mockTerm; },
    FitAddon: {
      FitAddon: function FitAddon() { return { fit: () => {} }; },
    },
    _openTerminal: undefined,
    _closeTerminal: undefined,
  };
  // navigator is a read-only getter in Node.js — use Object.defineProperty to override
  Object.defineProperty(globalThis, 'navigator', {
    value: { userAgent: '', clipboard: { writeText: () => Promise.resolve() }, maxTouchPoints: 0 },
    writable: true,
    configurable: true,
  });
  globalThis.fetch = function(url, opts) {
    lastFetchUrl = url;
    lastFetchOpts = opts;
    return Promise.resolve({
      ok: true,
      json: () => Promise.resolve(fetchReturnValue),
    });
  };

  require(modulePath);

  globalThis.setTimeout = origSetTimeout;

  return {
    openTerminal: globalThis.window._openTerminal,
    switchTerminal: globalThis.window._switchTerminal,
    closeTerminal: globalThis.window._closeTerminal,
    closeAllTerminals: globalThis.window._closeAllTerminals,
    destroyCachedTerminal: globalThis.window._destroyCachedTerminal,
    isTerminalCached: globalThis.window._isTerminalCached,
    get onDataCallCount() { return onDataCallCount; },
    get onResizeCallCount() { return onResizeCallCount; },
    get sentMessages() { return sentMessages; },
    get capturedWsUrl() { return capturedWsUrl; },
    get capturedOnDataFn() { return capturedOnDataFn; },
    get capturedOnResizeFn() { return capturedOnResizeFn; },
    get termWriteMessages() { return termWriteMessages; },
    get focusCallCount() { return focusCallCount; },
    get lastFetchUrl() { return lastFetchUrl; },
    get lastFetchOpts() { return lastFetchOpts; },
    get fetchReturnValue() { return fetchReturnValue; },
    set fetchReturnValue(v) { fetchReturnValue = v; },
    fireClose() { if (capturedCloseHandler) capturedCloseHandler(); },
    fireOpen() {
      if (lastWsInstance && lastWsInstance._handlers['open']) lastWsInstance._handlers['open']();
    },
    fireMessage(data) {
      if (lastWsInstance && lastWsInstance._handlers['message']) {
        lastWsInstance._handlers['message']({ data });
      }
    },
    fireReconnect() {
      if (capturedReconnectFn) { capturedReconnectFn(); capturedReconnectFn = null; }
    },
    patchTimeout(fn) {
      const orig = globalThis.setTimeout;
      globalThis.setTimeout = (cb, _ms) => { capturedReconnectFn = cb; return 0; };
      fn();
      globalThis.setTimeout = orig;
    },
  };
}

// ─── Source-level structural tests ───────────────────────────────────────────

test('terminal.js has no syntax errors', () => {
  // Acceptance criteria: `node -c terminal.js` shows no syntax errors
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(source.length > 0, 'terminal.js must not be empty');
  // If we got here via require() in loadTerminal(), it parses correctly.
  // Verify loadTerminal() doesn't throw:
  assert.doesNotThrow(() => loadTerminal(), 'terminal.js must load without errors');
});

test('no references to _terminalCache in terminal.js', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(!source.includes('_terminalCache'), 'must not contain _terminalCache');
});

test('no references to _cacheOrder in terminal.js', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(!source.includes('_cacheOrder'), 'must not contain _cacheOrder');
});

test('no references to 0x30 in terminal.js', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(!source.includes('0x30'), 'must not contain 0x30 (old ttyd protocol prefix)');
});

test('no references to 0x31 in terminal.js', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(!source.includes('0x31'), 'must not contain 0x31 (old ttyd protocol prefix)');
});

test('no references to _encodePayload in terminal.js', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(!source.includes('_encodePayload'), 'must not contain _encodePayload');
});

test('terminal.js uses /api/terminal-token fetch', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(source.includes('/api/terminal-token'), 'must fetch /api/terminal-token');
});

test('terminal.js has _muxtermPort state variable', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(source.includes('_muxtermPort'), 'must have _muxtermPort state variable');
});

test('terminal.js has _muxtermToken state variable', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(source.includes('_muxtermToken'), 'must have _muxtermToken state variable');
});

test('terminal.js has _openMuxtermSocket function', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(source.includes('_openMuxtermSocket'), 'must have _openMuxtermSocket function');
});

test('terminal.js sends {attach: sessionName} for session switching', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(source.includes('attach'), 'must send attach message for session switching');
});

test('terminal.js sends {detach: true} on closeTerminal', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(source.includes('detach'), 'must send detach message on close');
});

test('terminal.js sends {resize: {cols, rows}} JSON for resize', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(source.includes('"resize"') || source.includes('resize:') || source.includes("'resize'"),
    'must send JSON resize message');
});

// ─── Window exports ─────────────────────────────────────────────────────────

test('terminal.js exposes all required window exports', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  const requiredExports = [
    'window._openTerminal',
    'window._switchTerminal',
    'window._closeTerminal',
    'window._closeAllTerminals',
    'window._destroyCachedTerminal',
    'window._isTerminalCached',
    'window._openSearch',
    'window._closeSearch',
    'window._setTerminalFontSize',
  ];
  for (const exp of requiredExports) {
    assert.ok(source.includes(exp), `must expose ${exp}`);
  }
});

test('_switchTerminal equals openTerminal', () => {
  const t = loadTerminal();
  assert.strictEqual(t.switchTerminal, t.openTerminal,
    '_switchTerminal must be the same function as openTerminal');
});

test('_closeAllTerminals equals closeTerminal', () => {
  const t = loadTerminal();
  assert.strictEqual(t.closeAllTerminals, t.closeTerminal,
    '_closeAllTerminals must be the same function as closeTerminal');
});

test('_destroyCachedTerminal is a no-op function', () => {
  const t = loadTerminal();
  assert.strictEqual(typeof t.destroyCachedTerminal, 'function',
    '_destroyCachedTerminal must be a function');
  assert.doesNotThrow(() => t.destroyCachedTerminal('anything'),
    '_destroyCachedTerminal must not throw (no-op)');
});

test('_isTerminalCached always returns false', () => {
  const t = loadTerminal();
  assert.strictEqual(typeof t.isTerminalCached, 'function',
    '_isTerminalCached must be a function');
  assert.strictEqual(t.isTerminalCached('anything'), false,
    '_isTerminalCached must always return false');
  assert.strictEqual(t.isTerminalCached(), false,
    '_isTerminalCached must return false with no args');
});

// ─── connectWebSocket fetches /api/terminal-token ───────────────────────────

test('connectWebSocket fetches /api/terminal-token', async () => {
  const t = loadTerminal();

  const orig = globalThis.setTimeout;
  globalThis.setTimeout = (_fn, _ms) => 0;

  t.openTerminal('my-session');

  globalThis.setTimeout = orig;

  // Wait for async fetch to resolve
  await new Promise(r => setTimeout(r, 10));

  assert.ok(t.lastFetchUrl, 'must have called fetch');
  assert.ok(t.lastFetchUrl.includes('/api/terminal-token'),
    `fetch URL must include /api/terminal-token, got: ${t.lastFetchUrl}`);
});

// ─── WebSocket connects to muxterm port with token ──────────────────────────

test('_openMuxtermSocket connects to ws://host:muxtermPort/ws?token=...', async () => {
  const t = loadTerminal();

  const orig = globalThis.setTimeout;
  globalThis.setTimeout = (_fn, _ms) => 0;

  t.openTerminal('my-session');

  globalThis.setTimeout = orig;

  // Wait for async fetch + WS creation
  await new Promise(r => setTimeout(r, 20));

  assert.ok(t.capturedWsUrl, 'WebSocket URL must be captured');
  assert.ok(
    t.capturedWsUrl.includes(':9999/ws'),
    `WS URL must include muxterm port 9999 and /ws path, got: ${t.capturedWsUrl}`,
  );
  assert.ok(
    t.capturedWsUrl.includes('token='),
    `WS URL must include token query param, got: ${t.capturedWsUrl}`,
  );
});

// ─── On open: sends attach JSON ─────────────────────────────────────────────

test('on WS open sends {attach: sessionName} JSON text frame', async () => {
  const t = loadTerminal();

  const orig = globalThis.setTimeout;
  globalThis.setTimeout = (_fn, _ms) => 0;

  t.openTerminal('my-session');

  globalThis.setTimeout = orig;

  await new Promise(r => setTimeout(r, 20));

  // Fire the open event
  t.fireOpen();

  // Find the attach message (should be a JSON string)
  const attachMsg = t.sentMessages.find(m => {
    if (typeof m !== 'string') return false;
    try {
      const parsed = JSON.parse(m);
      return 'attach' in parsed;
    } catch { return false; }
  });

  assert.ok(attachMsg, 'must send an attach JSON text frame on open');
  const parsed = JSON.parse(attachMsg);
  assert.strictEqual(parsed.attach, 'my-session',
    'attach message must contain session name');
});

// ─── On message: binary data writes to terminal ─────────────────────────────

test('binary WS message writes directly to terminal (no prefix stripping)', async () => {
  const t = loadTerminal();

  const orig = globalThis.setTimeout;
  globalThis.setTimeout = (_fn, _ms) => 0;

  t.openTerminal('my-session');

  globalThis.setTimeout = orig;

  await new Promise(r => setTimeout(r, 20));

  // Simulate receiving binary data (ArrayBuffer)
  const encoder = new TextEncoder();
  const data = encoder.encode('hello world');
  t.fireMessage(data.buffer);

  assert.ok(t.termWriteMessages.length >= 1, 'term.write must be called');
  // The entire buffer is written — no prefix byte stripped
  const last = t.termWriteMessages[t.termWriteMessages.length - 1];
  // Should contain the full "hello world" string
  if (typeof last === 'string') {
    assert.ok(last.includes('hello world'), 'must write the full binary payload');
  } else {
    // It's a Uint8Array — check it matches
    const decoded = new TextDecoder().decode(last);
    assert.ok(decoded.includes('hello world'), 'must write the full binary payload');
  }
});

// ─── On message: JSON control messages ──────────────────────────────────────

test('string WS message parsed as JSON control message', async () => {
  const t = loadTerminal();

  const orig = globalThis.setTimeout;
  globalThis.setTimeout = (_fn, _ms) => 0;

  t.openTerminal('my-session');

  globalThis.setTimeout = orig;

  await new Promise(r => setTimeout(r, 20));

  // Send a JSON control message (string)
  t.fireMessage(JSON.stringify({ attached: 'my-session' }));

  // Control messages should NOT be written to terminal as raw text
  const hasAttachedWritten = t.termWriteMessages.some(m =>
    typeof m === 'string' && m.includes('"attached"'));
  assert.ok(!hasAttachedWritten,
    'control messages must NOT be written directly to terminal');
});

// ─── onData sends raw binary (no 0x30 prefix) ──────────────────────────────

test('onData sends raw binary keystroke without any prefix byte', async () => {
  const t = loadTerminal();

  const orig = globalThis.setTimeout;
  globalThis.setTimeout = (_fn, _ms) => 0;

  t.openTerminal('my-session');

  globalThis.setTimeout = orig;

  await new Promise(r => setTimeout(r, 20));
  t.fireOpen();

  const initCount = t.sentMessages.length;

  assert.ok(t.capturedOnDataFn, 'onData callback must have been registered');
  t.capturedOnDataFn('a');

  assert.ok(t.sentMessages.length > initCount, 'onData should send a message');

  const msg = t.sentMessages[t.sentMessages.length - 1];
  // Must be binary (ArrayBuffer or Uint8Array)
  assert.ok(
    msg instanceof ArrayBuffer || msg instanceof Uint8Array,
    `keystroke must be sent as binary, got ${Object.prototype.toString.call(msg)}`,
  );

  // The first byte must NOT be 0x30 — it should be the actual character
  const bytes = msg instanceof Uint8Array ? msg : new Uint8Array(msg);
  assert.notStrictEqual(bytes[0], 0x30,
    'first byte must NOT be 0x30 (old ttyd prefix) — raw binary only');
  // For 'a' (0x61), first byte should be 0x61
  assert.strictEqual(bytes[0], 0x61,
    'first byte of raw binary for "a" must be 0x61');
});

// ─── onResize sends JSON text frame {resize: {cols, rows}} ─────────────────

test('onResize sends JSON text frame {resize: {cols, rows}} — not binary', async () => {
  const t = loadTerminal();

  const orig = globalThis.setTimeout;
  globalThis.setTimeout = (_fn, _ms) => 0;

  t.openTerminal('my-session');

  globalThis.setTimeout = orig;

  await new Promise(r => setTimeout(r, 20));
  t.fireOpen();

  const initCount = t.sentMessages.length;

  assert.ok(t.capturedOnResizeFn, 'onResize callback must have been registered');
  t.capturedOnResizeFn({ cols: 100, rows: 30 });

  assert.ok(t.sentMessages.length > initCount, 'onResize should send a message');

  const msg = t.sentMessages[t.sentMessages.length - 1];
  assert.strictEqual(typeof msg, 'string',
    `resize must be sent as JSON text frame, got ${Object.prototype.toString.call(msg)}`);

  const parsed = JSON.parse(msg);
  assert.ok('resize' in parsed, 'resize message must have "resize" key');
  assert.strictEqual(parsed.resize.cols, 100, 'cols must match');
  assert.strictEqual(parsed.resize.rows, 30, 'rows must match');
});

// ─── closeTerminal sends {detach: true} ─────────────────────────────────────

test('closeTerminal sends {detach: true} before closing', async () => {
  const t = loadTerminal();

  const orig = globalThis.setTimeout;
  globalThis.setTimeout = (_fn, _ms) => 0;

  t.openTerminal('my-session');

  globalThis.setTimeout = orig;

  await new Promise(r => setTimeout(r, 20));
  t.fireOpen();

  t.closeTerminal();

  const detachMsg = t.sentMessages.find(m => {
    if (typeof m !== 'string') return false;
    try {
      const parsed = JSON.parse(m);
      return parsed.detach === true;
    } catch { return false; }
  });

  assert.ok(detachMsg, 'closeTerminal must send {detach: true} JSON text frame');
});

// ─── Reconnect with exponential backoff ─────────────────────────────────────

test('WS close schedules reconnect (exponential backoff)', async () => {
  const t = loadTerminal();

  const orig = globalThis.setTimeout;
  globalThis.setTimeout = (_fn, _ms) => 0;

  t.openTerminal('my-session');

  globalThis.setTimeout = orig;

  await new Promise(r => setTimeout(r, 20));

  let reconnectScheduled = false;
  let capturedDelay = 0;
  const origST = globalThis.setTimeout;
  globalThis.setTimeout = (fn, ms) => {
    reconnectScheduled = true;
    capturedDelay = ms;
    return 0;
  };

  t.fireClose();

  globalThis.setTimeout = origST;

  assert.ok(reconnectScheduled, 'WS close must schedule a reconnect');
  // First reconnect delay: ~1000ms + jitter (up to 500ms)
  assert.ok(capturedDelay >= 1000 && capturedDelay <= 1600,
    `first reconnect delay should be ~1000-1500ms, got: ${capturedDelay}`);
});

// ─── Terminal creation with addons ──────────────────────────────────────────

test('createTerminal loads WebLinksAddon, SearchAddon, ImageAddon', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(source.includes('WebLinksAddon'), 'must load WebLinksAddon');
  assert.ok(source.includes('SearchAddon'), 'must load SearchAddon');
  assert.ok(source.includes('ImageAddon'), 'must load ImageAddon');
  assert.ok(source.includes('FitAddon'), 'must load FitAddon');
});

// ─── setTerminalFontSize ────────────────────────────────────────────────────

test('terminal.js exposes window._setTerminalFontSize', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(source.includes('window._setTerminalFontSize'),
    'must expose window._setTerminalFontSize');
});

// ─── Mobile support functions ───────────────────────────────────────────────

test('terminal.js has _initAndroidIMEFix function', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(source.includes('_initAndroidIMEFix'), 'must have _initAndroidIMEFix');
});

test('terminal.js has _initMobileToolbar function', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(source.includes('_initMobileToolbar'), 'must have _initMobileToolbar');
});

test('terminal.js has initVisualViewport function', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(source.includes('initVisualViewport'), 'must have initVisualViewport');
});

// ─── Clipboard integration ──────────────────────────────────────────────────

test('terminal.js has clipboard integration', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(source.includes('attachCustomKeyEventHandler'), 'must register custom key handler');
  assert.ok(source.includes('getSelection'), 'must use getSelection() for copy');
  assert.ok(source.includes('clipboard'), 'must interact with clipboard API');
});

test('terminal.js auto-copies selection via onSelectionChange', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(source.includes('onSelectionChange'), 'must have onSelectionChange handler');
});

test('terminal.js registers OSC 52 handler', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(source.includes('registerOscHandler'), 'must register OSC handler');
  assert.ok(source.includes('atob'), 'must decode base64 OSC 52 payload');
});

// ─── Search ─────────────────────────────────────────────────────────────────

test('terminal.js has search functions', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(source.includes('_openSearch'), 'must have _openSearch');
  assert.ok(source.includes('_closeSearch'), 'must have _closeSearch');
  assert.ok(source.includes('findNext'), 'must have findNext');
});

// ─── binaryType = arraybuffer ───────────────────────────────────────────────

test('terminal.js sets binaryType to arraybuffer', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(source.includes("binaryType") && source.includes("arraybuffer"),
    'must set binaryType to arraybuffer');
});

// ─── No ttyd protocol artifacts ─────────────────────────────────────────────

test('terminal.js has no ttyd subprotocol reference', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  // Old code used ['tty'] subprotocol for ttyd
  assert.ok(!source.includes("'tty'") || source.includes('// ') ,
    'should not reference tty subprotocol (old ttyd protocol)');
});

test('terminal.js has no AuthToken reference', () => {
  const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
  assert.ok(!source.includes('AuthToken'),
    'must not contain AuthToken (old ttyd auth handshake)');
});
