/**
 * Wire-protocol tests for @sandcode/browser-mcp.
 *
 * Exercises the round trip between the vendor-side NativeHostBridge and a
 * mock socket server that mimics chromeNativeHost.ts. Doesn't require a
 * real Chrome instance — instead simulates the bytes the native host
 * would write back when the extension responds. Catches regressions in:
 *   - tool list shape
 *   - 4-byte little-endian length-prefix framing
 *   - request_id round-trip correlation
 *   - error response forwarding
 *   - unknown tool short-circuits without touching the socket
 */

import { afterEach, beforeEach, describe, expect, test } from 'vitest'
import { type AddressInfo, type Server, createServer, type Socket } from 'net'
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { BROWSER_TOOLS, createBrowserIntegrationMcpServer } from './index.js'

// ─── Tool list shape ──────────────────────────────────────────────────────

describe('BROWSER_TOOLS', () => {
  test('exposes the 6 documented tools', () => {
    const names = BROWSER_TOOLS.map((t) => t.name).sort()
    expect(names).toEqual(
      [
        'javascript_tool',
        'read_console_messages',
        'tabs_close_mcp',
        'tabs_context_mcp',
        'tabs_create_mcp',
        'take_screenshot',
      ].sort(),
    )
  })

  test('every tool has a non-empty description and a valid inputSchema', () => {
    for (const tool of BROWSER_TOOLS) {
      expect(tool.name).toMatch(/^[a-z_]+$/)
      expect(tool.description.length).toBeGreaterThan(20)
      expect(tool.inputSchema.type).toBe('object')
      expect(typeof tool.inputSchema.properties).toBe('object')
    }
  })

  test('tool names match the chrome-extension TOOL_HANDLERS surface', () => {
    // Mirrors chrome-extension/src/background.js:TOOL_HANDLERS keys.
    const extensionHandlers = new Set([
      'ping',
      'tabs_context_mcp',
      'tabs_create_mcp',
      'tabs_close_mcp',
      'read_console_messages',
      'take_screenshot',
      'javascript_tool',
    ])
    for (const tool of BROWSER_TOOLS) {
      expect(
        extensionHandlers.has(tool.name),
        `vendor pkg advertises "${tool.name}" but the extension has no handler — ` +
          `add it to chrome-extension/src/background.js:TOOL_HANDLERS`,
      ).toBe(true)
    }
  })
})

// ─── NativeHostBridge wire format (via real MCP Client/Server pair) ───────

/**
 * Build a mock socket server that behaves like chromeNativeHost from the
 * vendor side's POV. Accepts framed JSON tool_request and writes back a
 * framed JSON {request_id, result|error} via the responder callback.
 */
function startMockNativeHost(
  socketPath: string,
  responder: (req: { method: string; params: unknown; request_id?: string }) => Promise<{
    result?: unknown
    error?: string
  }>,
): Promise<{ server: Server; close: () => Promise<void> }> {
  const openSockets = new Set<Socket>()
  const server = createServer((socket: Socket) => {
    openSockets.add(socket)
    socket.on('close', () => openSockets.delete(socket))
    let buf = Buffer.alloc(0)
    socket.on('data', async (chunk) => {
      buf = Buffer.concat([buf, chunk])
      while (buf.length >= 4) {
        const length = buf.readUInt32LE(0)
        if (buf.length < 4 + length) break
        const body = buf.subarray(4, 4 + length).toString('utf-8')
        buf = buf.subarray(4 + length)
        const parsed = JSON.parse(body) as {
          method: string
          params: unknown
          request_id?: string
        }
        const reply = await responder(parsed)
        const respBody = Buffer.from(
          JSON.stringify({ request_id: parsed.request_id, ...reply }),
          'utf-8',
        )
        const len = Buffer.alloc(4)
        len.writeUInt32LE(respBody.length, 0)
        socket.write(Buffer.concat([len, respBody]))
      }
    })
    socket.on('error', () => {
      /* ignore — test server, the client may disconnect abruptly */
    })
  })

  return new Promise((resolve, reject) => {
    server.listen(socketPath, () => {
      resolve({
        server,
        close: () =>
          new Promise<void>((res) => {
            // Force-disconnect any open clients first; on Windows
            // named pipes, server.close() blocks until all clients
            // disconnect, so leaving the bridge socket connected hangs.
            for (const s of openSockets) s.destroy()
            openSockets.clear()
            server.close(() => res())
          }),
      })
    })
    server.on('error', reject)
  })
}

/**
 * Wire up a Client ↔ Server pair via in-memory transport so tests exercise
 * the public MCP surface (tools/call) instead of poking SDK internals.
 */
async function createClientFor(
  context: Parameters<typeof createBrowserIntegrationMcpServer>[0],
): Promise<{ client: Client; close: () => Promise<void> }> {
  const server = createBrowserIntegrationMcpServer(context)
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
  const client = new Client({ name: 'test-client', version: '0.0.0' }, { capabilities: {} })
  await Promise.all([server.connect(serverTransport), client.connect(clientTransport)])
  return {
    client,
    close: async () => {
      await client.close()
      await server.close()
    },
  }
}

describe('NativeHostBridge round-trip', () => {
  let tmp: string
  let socketPath: string
  let mock: { server: Server; close: () => Promise<void> } | null = null
  let session: { client: Client; close: () => Promise<void> } | null = null

  beforeEach(() => {
    tmp = mkdtempSync(join(tmpdir(), 'sandcode-browser-mcp-test-'))
    // On Windows, named pipes need the \\.\pipe\ prefix; on POSIX use a
    // socket file inside the temp dir.
    socketPath =
      process.platform === 'win32'
        ? `\\\\.\\pipe\\sandcode-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
        : join(tmp, 'mock.sock')
  })

  afterEach(async () => {
    if (session) {
      await session.close().catch(() => {})
      session = null
    }
    if (mock) {
      await mock.close().catch(() => {})
      mock = null
    }
    try {
      rmSync(tmp, { recursive: true, force: true })
    } catch {
      /* tmp may already be gone on win32 */
    }
  })

  test('CallTool resolves with the mock response', async () => {
    mock = await startMockNativeHost(socketPath, async (req) => {
      expect(req.method).toBe('tabs_context_mcp')
      expect(req.request_id).toMatch(/^[0-9a-f-]{36}$/i)
      return { result: { tabs: [{ tabId: 1, url: 'https://example.com' }] } }
    })

    session = await createClientFor({
      socketPath,
      getSocketPaths: () => [socketPath],
      logger: { info: () => {}, warn: () => {} },
    })

    const result = (await session.client.callTool({
      name: 'tabs_context_mcp',
      arguments: {},
    })) as { isError?: boolean; content: Array<{ type: string; text: string }> }

    expect(result.isError).toBeFalsy()
    expect(result.content[0].type).toBe('text')
    const parsed = JSON.parse(result.content[0].text)
    expect(parsed.tabs[0].url).toBe('https://example.com')
  }, 15_000)

  test('CallTool surfaces error responses with isError', async () => {
    mock = await startMockNativeHost(socketPath, async () => ({
      error: 'tabId 99 not found',
    }))

    session = await createClientFor({
      socketPath,
      getSocketPaths: () => [socketPath],
      logger: { info: () => {}, warn: () => {} },
    })

    const result = (await session.client.callTool({
      name: 'tabs_close_mcp',
      arguments: { tabId: 99 },
    })) as { isError?: boolean; content: Array<{ type: string; text: string }> }

    expect(result.isError).toBe(true)
    expect(result.content[0].text).toContain('tabId 99 not found')
  }, 15_000)

  test('Unknown tool name returns isError without hitting the socket', async () => {
    // No mock server — the bridge should never try to connect.
    session = await createClientFor({
      socketPath: '/tmp/nonexistent-' + Date.now() + '.sock',
      getSocketPaths: () => [],
      logger: { info: () => {}, warn: () => {} },
    })

    const result = (await session.client.callTool({
      name: 'definitely_not_a_real_tool',
      arguments: {},
    })) as { isError?: boolean; content: Array<{ type: string; text: string }> }

    expect(result.isError).toBe(true)
    expect(result.content[0].text).toContain('Unknown browser tool')
  }, 5_000)
})
