import { describe, it, expect, vi, beforeEach } from "vitest";
import type { ServerWebSocket } from "bun";
import type { SocketData } from "./ws-bridge-types.js";

// ─── Hoisted mocks ──────────────────────────────────────────────────────────

// Mock Bun.spawn to avoid real PTY creation
const mockTerminalWrite = vi.hoisted(() => vi.fn());
const mockTerminalResize = vi.hoisted(() => vi.fn());
const mockTerminalClose = vi.hoisted(() => vi.fn());
const mockProcKill = vi.hoisted(() => vi.fn());

// Track spawned proc data callbacks so we can simulate PTY output
let lastDataCallback: ((terminal: unknown, data: Uint8Array) => void) | null = null;
let lastExitCallback: (() => void) | null = null;

vi.mock("bun", () => {
  return {
    // We don't actually need to provide the Bun global in the test,
    // but TerminalManager calls Bun.spawn
  };
});

// We need to mock at the global Bun.spawn level. Since TerminalManager
// accesses Bun.spawn directly, we set it up on globalThis.
const setupBunSpawnMock = () => {
  const mockProc = {
    pid: 12345,
    exitCode: null,
    kill: mockProcKill,
    exited: new Promise<number>(() => {}), // never resolves
    terminal: {
      write: mockTerminalWrite,
      resize: mockTerminalResize,
      close: mockTerminalClose,
    },
  };

  (globalThis as any).Bun = {
    spawn: vi.fn((_cmd: string[], opts: any) => {
      // Capture terminal callbacks if they exist
      if (opts?.terminal) {
        lastDataCallback = opts.terminal.data || null;
        lastExitCallback = opts.terminal.exit || null;
      }
      return mockProc;
    }),
  };

  return mockProc;
};

// Mock node:fs existsSync — TerminalManager uses it to validate cwd and resolve shell
vi.mock("node:fs", async (importOriginal) => {
  const actual = (await importOriginal()) as any;
  return {
    ...actual,
    existsSync: (path: string) => {
      // Let the test cwd and common shells pass validation
      if (path === "/test/workspace" || path === "C:\\test\\workspace") return true;
      if (path.includes("powershell") || path.includes("bash") || path === "/bin/bash" || path === "/bin/sh") return true;
      return actual.existsSync(path);
    },
  };
});

// Mock node:crypto for deterministic IDs
vi.mock("node:crypto", () => ({
  randomUUID: () => "term-uuid-001",
}));

// ─── Import under test (after mocks) ────────────────────────────────────────

import { TerminalManager } from "./terminal-manager.js";

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

/** Create a minimal mock browser WebSocket for terminal connections */
function createMockTerminalSocket(terminalId: string): ServerWebSocket<SocketData> {
  return {
    data: { kind: "terminal" as const, terminalId },
    send: vi.fn(),
    sendBinary: vi.fn(),
    close: vi.fn(),
  } as unknown as ServerWebSocket<SocketData>;
}

function createMockBrowserSocket(sessionId: string): ServerWebSocket<SocketData> {
  return {
    data: { kind: "browser" as const, sessionId },
    send: vi.fn(),
    close: vi.fn(),
  } as unknown as ServerWebSocket<SocketData>;
}

// =============================================================================
// Tests
// =============================================================================

describe("TerminalManager", () => {
  let manager: TerminalManager;

  beforeEach(() => {
    vi.clearAllMocks();
    lastDataCallback = null;
    lastExitCallback = null;
    setupBunSpawnMock();
    manager = new TerminalManager();
  });

  // ─── spawn ────────────────────────────────────────────────────────────────

  describe("spawn", () => {
    it("returns a terminal ID and calls Bun.spawn", () => {
      const cwd = process.platform === "win32" ? "C:\\test\\workspace" : "/test/workspace";
      const id = manager.spawn(cwd);

      expect(id).toBe("term-uuid-001");
      expect((globalThis as any).Bun.spawn).toHaveBeenCalledTimes(1);
    });

    it("passes custom columns and rows to the terminal config", () => {
      const cwd = process.platform === "win32" ? "C:\\test\\workspace" : "/test/workspace";
      manager.spawn(cwd, 120, 40);

      const spawnCall = (globalThis as any).Bun.spawn.mock.calls[0];
      const opts = spawnCall[1];
      expect(opts.terminal.cols).toBe(120);
      expect(opts.terminal.rows).toBe(40);
    });

    it("throws when cwd does not exist (non-container)", () => {
      expect(() => manager.spawn("/nonexistent/dir")).toThrow(
        "Terminal working directory does not exist",
      );
    });

    it("uses docker exec command when containerId is provided", () => {
      const cwd = process.platform === "win32" ? "C:\\test\\workspace" : "/test/workspace";
      manager.spawn(cwd, 80, 24, { containerId: "abc123" });

      const spawnCall = (globalThis as any).Bun.spawn.mock.calls[0];
      const cmd = spawnCall[0] as string[];
      expect(cmd[0]).toBe("docker");
      expect(cmd).toContain("abc123");
    });

    it("throws when Bun.spawn does not attach a terminal handle", () => {
      // Override spawn to return a proc without .terminal
      (globalThis as any).Bun.spawn = vi.fn(() => ({
        pid: 99,
        kill: vi.fn(),
        terminal: undefined,
      }));

      const cwd = process.platform === "win32" ? "C:\\test\\workspace" : "/test/workspace";
      expect(() => manager.spawn(cwd)).toThrow("Terminal PTY not available");
    });
  });

  // ─── getInfo ──────────────────────────────────────────────────────────────

  describe("getInfo", () => {
    it("returns terminal info after spawn", () => {
      const cwd = process.platform === "win32" ? "C:\\test\\workspace" : "/test/workspace";
      const id = manager.spawn(cwd);
      const info = manager.getInfo(id);

      expect(info).toEqual({ id, cwd, containerId: undefined });
    });

    it("returns null for unknown terminal ID", () => {
      expect(manager.getInfo("nonexistent")).toBeNull();
    });

    it("returns first terminal when no ID provided", () => {
      const cwd = process.platform === "win32" ? "C:\\test\\workspace" : "/test/workspace";
      manager.spawn(cwd);
      const info = manager.getInfo();

      expect(info).not.toBeNull();
      expect(info!.id).toBe("term-uuid-001");
    });

    it("returns null when no terminals exist and no ID provided", () => {
      expect(manager.getInfo()).toBeNull();
    });
  });

  // ─── kill ─────────────────────────────────────────────────────────────────

  describe("kill", () => {
    it("kills the process and removes the instance", () => {
      const cwd = process.platform === "win32" ? "C:\\test\\workspace" : "/test/workspace";
      const id = manager.spawn(cwd);

      manager.kill(id);

      expect(mockProcKill).toHaveBeenCalled();
      expect(manager.getInfo(id)).toBeNull();
    });

    it("is a no-op for unknown terminal ID", () => {
      // Should not throw
      manager.kill("nonexistent");
    });
  });

  // ─── addBrowserSocket / removeBrowserSocket ───────────────────────────────

  describe("addBrowserSocket / removeBrowserSocket", () => {
    it("attaches and detaches a browser socket", () => {
      const cwd = process.platform === "win32" ? "C:\\test\\workspace" : "/test/workspace";
      const id = manager.spawn(cwd);
      const ws = createMockTerminalSocket(id);

      // Should not throw
      manager.addBrowserSocket(ws);
      manager.removeBrowserSocket(ws);
    });

    it("ignores sockets with non-terminal kind", () => {
      const cwd = process.platform === "win32" ? "C:\\test\\workspace" : "/test/workspace";
      manager.spawn(cwd);
      const ws = createMockBrowserSocket("session-1");

      // Should not throw — silently ignored because kind !== "terminal"
      manager.addBrowserSocket(ws);
      manager.removeBrowserSocket(ws);
    });

    it("ignores sockets for unknown terminal IDs", () => {
      const ws = createMockTerminalSocket("nonexistent");

      // Should not throw
      manager.addBrowserSocket(ws);
      manager.removeBrowserSocket(ws);
    });
  });

  // ─── handleBrowserMessage ─────────────────────────────────────────────────

  describe("handleBrowserMessage", () => {
    it("forwards input messages to the terminal", () => {
      const cwd = process.platform === "win32" ? "C:\\test\\workspace" : "/test/workspace";
      const id = manager.spawn(cwd);
      const ws = createMockTerminalSocket(id);
      manager.addBrowserSocket(ws);

      const msg = JSON.stringify({ type: "input", data: "ls -la\n" });
      manager.handleBrowserMessage(ws, msg);

      expect(mockTerminalWrite).toHaveBeenCalledWith("ls -la\n");
    });

    it("handles resize messages", () => {
      const cwd = process.platform === "win32" ? "C:\\test\\workspace" : "/test/workspace";
      const id = manager.spawn(cwd);
      const ws = createMockTerminalSocket(id);
      manager.addBrowserSocket(ws);

      const msg = JSON.stringify({ type: "resize", cols: 200, rows: 50 });
      manager.handleBrowserMessage(ws, msg);

      expect(mockTerminalResize).toHaveBeenCalledWith(200, 50);
    });

    it("ignores malformed JSON messages without throwing", () => {
      const cwd = process.platform === "win32" ? "C:\\test\\workspace" : "/test/workspace";
      const id = manager.spawn(cwd);
      const ws = createMockTerminalSocket(id);
      manager.addBrowserSocket(ws);

      // Should not throw
      manager.handleBrowserMessage(ws, "not-json");
    });

    it("ignores messages with unknown type", () => {
      const cwd = process.platform === "win32" ? "C:\\test\\workspace" : "/test/workspace";
      const id = manager.spawn(cwd);
      const ws = createMockTerminalSocket(id);
      manager.addBrowserSocket(ws);

      const msg = JSON.stringify({ type: "unknown", data: "test" });
      manager.handleBrowserMessage(ws, msg);

      // terminal.write should not be called for unknown message types
      expect(mockTerminalWrite).not.toHaveBeenCalled();
    });

    it("ignores messages from non-terminal sockets", () => {
      const cwd = process.platform === "win32" ? "C:\\test\\workspace" : "/test/workspace";
      manager.spawn(cwd);
      const ws = createMockBrowserSocket("session-1");

      const msg = JSON.stringify({ type: "input", data: "test" });
      manager.handleBrowserMessage(ws, msg);

      expect(mockTerminalWrite).not.toHaveBeenCalled();
    });
  });

  // ─── resize ───────────────────────────────────────────────────────────────

  describe("resize", () => {
    it("resizes the terminal PTY", () => {
      const cwd = process.platform === "win32" ? "C:\\test\\workspace" : "/test/workspace";
      const id = manager.spawn(cwd);

      manager.resize(id, 160, 48);

      expect(mockTerminalResize).toHaveBeenCalledWith(160, 48);
    });

    it("is a no-op for unknown terminal ID", () => {
      // Should not throw
      manager.resize("nonexistent", 80, 24);
      expect(mockTerminalResize).not.toHaveBeenCalled();
    });
  });

  // ─── killAll ──────────────────────────────────────────────────────────────

  describe("killAll", () => {
    it("kills a spawned terminal via killAll", () => {
      const cwd = process.platform === "win32" ? "C:\\test\\workspace" : "/test/workspace";
      manager.spawn(cwd);

      expect(manager.getInfo("term-uuid-001")).not.toBeNull();

      manager.killAll();

      expect(manager.getInfo("term-uuid-001")).toBeNull();
      expect(mockProcKill).toHaveBeenCalled();
    });

    it("is a no-op when no terminals exist", () => {
      // Should not throw
      manager.killAll();
    });
  });

  // ─── PTY output broadcasting ──────────────────────────────────────────────

  describe("PTY output broadcasting", () => {
    it("broadcasts PTY data to connected browser sockets", () => {
      const cwd = process.platform === "win32" ? "C:\\test\\workspace" : "/test/workspace";
      const id = manager.spawn(cwd);
      const ws = createMockTerminalSocket(id);
      manager.addBrowserSocket(ws);

      // Simulate PTY output via the data callback captured during spawn
      expect(lastDataCallback).not.toBeNull();
      const testData = new TextEncoder().encode("hello world");
      lastDataCallback!(null, testData);

      expect(ws.sendBinary).toHaveBeenCalledWith(testData);
    });

    it("handles send errors gracefully when socket is closed", () => {
      const cwd = process.platform === "win32" ? "C:\\test\\workspace" : "/test/workspace";
      const id = manager.spawn(cwd);
      const ws = createMockTerminalSocket(id);
      (ws.sendBinary as any).mockImplementation(() => { throw new Error("socket closed"); });
      manager.addBrowserSocket(ws);

      // Should not throw even when sendBinary throws
      expect(lastDataCallback).not.toBeNull();
      const testData = new TextEncoder().encode("test");
      expect(() => lastDataCallback!(null, testData)).not.toThrow();
    });
  });

  // ─── PTY exit handling ────────────────────────────────────────────────────

  describe("PTY exit handling", () => {
    it("sends exit message to all connected browser sockets on PTY exit", () => {
      const cwd = process.platform === "win32" ? "C:\\test\\workspace" : "/test/workspace";
      const id = manager.spawn(cwd);
      const ws = createMockTerminalSocket(id);
      manager.addBrowserSocket(ws);

      // Simulate PTY exit via the exit callback captured during spawn
      expect(lastExitCallback).not.toBeNull();
      lastExitCallback!();

      // Should have sent an exit message
      expect(ws.send).toHaveBeenCalledWith(
        expect.stringContaining('"type":"exit"'),
      );
    });
  });

  // ─── Orphan timer ─────────────────────────────────────────────────────────

  describe("orphan timer", () => {
    it("starts orphan timer when last browser socket disconnects", () => {
      vi.useFakeTimers();
      const cwd = process.platform === "win32" ? "C:\\test\\workspace" : "/test/workspace";
      const id = manager.spawn(cwd);
      const ws = createMockTerminalSocket(id);

      manager.addBrowserSocket(ws);
      manager.removeBrowserSocket(ws);

      // Terminal should still exist immediately after disconnect
      expect(manager.getInfo(id)).not.toBeNull();

      // After 5 seconds, orphan timer kills it
      vi.advanceTimersByTime(5_000);
      expect(manager.getInfo(id)).toBeNull();
      vi.useRealTimers();
    });

    it("cancels orphan timer when a new browser socket connects", () => {
      vi.useFakeTimers();
      const cwd = process.platform === "win32" ? "C:\\test\\workspace" : "/test/workspace";
      const id = manager.spawn(cwd);
      const ws1 = createMockTerminalSocket(id);
      const ws2 = createMockTerminalSocket(id);

      manager.addBrowserSocket(ws1);
      manager.removeBrowserSocket(ws1);

      // Reconnect before timer fires
      manager.addBrowserSocket(ws2);

      vi.advanceTimersByTime(5_000);

      // Terminal should still be alive because a new socket was added
      expect(manager.getInfo(id)).not.toBeNull();
      vi.useRealTimers();
    });
  });

  // ─── Container terminal cwd validation ────────────────────────────────────

  describe("container terminal cwd", () => {
    it("skips cwd validation for container terminals", () => {
      // A cwd that doesn't exist on the host should be fine for container terminals
      const id = manager.spawn("/nonexistent/container/path", 80, 24, {
        containerId: "container-123",
      });

      expect(id).toBe("term-uuid-001");
      expect((globalThis as any).Bun.spawn).toHaveBeenCalledTimes(1);

      // Should pass undefined cwd to Bun.spawn (container handles cwd via docker exec)
      const spawnCall = (globalThis as any).Bun.spawn.mock.calls[0];
      const opts = spawnCall[1];
      expect(opts.cwd).toBeUndefined();
    });
  });
});
