import type { DevframeNodeContext, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from '../../types'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createRpcClient } from 'devframe/rpc/client'
import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client'
import { open } from 'devframe/utils/open'
import { getPort } from 'get-port-please'
import { describe, expect, it, vi } from 'vitest'
import { WebSocket } from 'ws'
import { getTempAuthCode } from '../../node/auth/state'
import { defineDevframe } from '../../types/devframe'
import { createDevServer, resolveDevServerPort } from '../dev'

// `--open` is exercised directly (asserting the URL handed to the OS opener)
// rather than actually launching a browser in CI.
vi.mock('devframe/utils/open', () => ({ open: vi.fn(async () => {}) }))

function connectWsClient(host: string, port: number, authToken?: string) {
  return createRpcClient<DevframeRpcServerFunctions, DevframeRpcClientFunctions>(
    {} as DevframeRpcClientFunctions,
    { channel: createWsRpcChannel({ url: `ws://${host}:${port}/__ws`, authToken }) },
  )
}

const HANDSHAKE = { authToken: '', ua: 'test', origin: 'http://localhost' }

function makeTmpDist(): string {
  const dir = mkdtempSync(join(tmpdir(), 'devframe-dev-'))
  writeFileSync(join(dir, 'index.html'), '<!doctype html><title>test</title>', 'utf-8')
  return dir
}

describe('adapters/dev', () => {
  it('createDevServer starts, exposes __connection.json, and closes', async () => {
    const distDir = makeTmpDist()
    const devframe = defineDevframe({
      id: 'devframe-test',
      name: 'Devframe Test',
      version: '0.0.0',
      packageName: 'devframe-test',
      homepage: 'https://example.test',
      description: 'Test devframe.',
      setup: () => {},
    })

    const host = '127.0.0.1'
    const port = await getPort({ port: 19999, host })
    const handle = await createDevServer(devframe, {
      host,
      port,
      distDir,
      openBrowser: false,
    })

    try {
      expect(handle.port).toBe(port)
      // The advertised origin is dialable: the loopback IP normalizes to
      // `localhost` so a client can actually open it.
      expect(handle.origin).toBe(`http://localhost:${port}`)

      const res = await fetch(`http://${host}:${port}/__connection.json`)
      expect(res.ok).toBe(true)
      const meta = await res.json()
      // Proxy-safe: the WS endpoint is advertised as a same-origin route
      // relative to `__connection.json`, never a baked-in host/port.
      expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__ws' } })
    }
    finally {
      await handle.close()
    }
  })

  it('advertises a dialable origin when bound to the wildcard host', async () => {
    const distDir = makeTmpDist()
    const devframe = defineDevframe({
      id: 'devframe-test-wildcard',
      name: 'Wildcard Host',
      version: '0.0.0',
      packageName: 'devframe-test',
      homepage: 'https://example.test',
      description: 'Test devframe.',
      setup: () => {},
    })

    // Binding to `0.0.0.0` listens on every interface, but that address isn't
    // dialable from a browser — the advertised origin must fall back to a
    // loopback host so the page (and its same-origin WS) actually connect.
    const host = '0.0.0.0'
    const port = await getPort({ port: 19795, host })
    const handle = await createDevServer(devframe, {
      host,
      port,
      distDir,
      openBrowser: false,
    })

    try {
      expect(handle.origin).toBe(`http://localhost:${port}`)
      // The socket still listens on the wildcard host, reachable via loopback.
      const res = await fetch(`http://localhost:${port}/__connection.json`)
      expect(res.ok).toBe(true)
    }
    finally {
      await handle.close()
    }
  })

  it('createDevServer binds the WS endpoint to the advertised route', async () => {
    const distDir = makeTmpDist()
    const devframe = defineDevframe({
      id: 'devframe-test-ws',
      name: 'Devframe WS',
      version: '0.0.0',
      packageName: 'devframe-test',
      homepage: 'https://example.test',
      description: 'Test devframe.',
      setup: () => {},
    })

    const host = '127.0.0.1'
    const port = await getPort({ port: 19899, host })
    const handle = await createDevServer(devframe, {
      host,
      port,
      distDir,
      openBrowser: false,
    })

    try {
      // Connects on the bound route.
      const ok = new WebSocket(`ws://${host}:${port}/__ws`)
      await expect(new Promise((resolve, reject) => {
        ok.on('open', () => resolve('open'))
        ok.on('error', reject)
      })).resolves.toBe('open')
      ok.close()

      // A connection off-route is left unhandled (no upgrade handler claims
      // it) and the socket is closed without an open event.
      const off = new WebSocket(`ws://${host}:${port}/not-the-ws-route`)
      await expect(new Promise((resolve, reject) => {
        off.on('open', () => reject(new Error('should not open off-route')))
        off.on('close', () => resolve('closed'))
        off.on('error', () => resolve('closed'))
      })).resolves.toBe('closed')
    }
    finally {
      await handle.close()
    }
  })

  it('ws config: custom route on the same server', async () => {
    const devframe = defineDevframe({
      id: 'devframe-ws-route',
      name: 'WS Route',
      version: '0.0.0',
      packageName: 'devframe-test',
      homepage: 'https://example.test',
      description: 'Test devframe.',
      setup: () => {},
      cli: { ws: { route: '__sockets' } },
    })
    const host = '127.0.0.1'
    const port = await getPort({ port: 19880, host })
    const handle = await createDevServer(devframe, { host, port, openBrowser: false })

    try {
      const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json()
      expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__sockets' } })

      const ok = new WebSocket(`ws://${host}:${port}/__sockets`)
      await expect(new Promise((resolve, reject) => {
        ok.on('open', () => resolve('open'))
        ok.on('error', reject)
      })).resolves.toBe('open')
      ok.close()
    }
    finally {
      await handle.close()
    }
  })

  it('ws config: dedicated port binds a separate socket server', async () => {
    const devframe = defineDevframe({
      id: 'devframe-ws-port',
      name: 'WS Port',
      version: '0.0.0',
      packageName: 'devframe-test',
      homepage: 'https://example.test',
      description: 'Test devframe.',
      setup: () => {},
    })
    const host = '127.0.0.1'
    const port = await getPort({ port: 19870, host })
    const wsPort = await getPort({ port: 19871, host })
    const handle = await createDevServer(devframe, {
      host,
      port,
      openBrowser: false,
      ws: { port: wsPort },
    })

    try {
      const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json()
      expect(meta).toEqual({
        backend: 'websocket',
        websocket: { port: wsPort, path: '__ws' },
      })

      // The socket is reachable on its own port, rooted at `/<route>`.
      const ok = new WebSocket(`ws://${host}:${wsPort}/__ws`)
      await expect(new Promise((resolve, reject) => {
        ok.on('open', () => resolve('open'))
        ok.on('error', reject)
      })).resolves.toBe('open')
      ok.close()

      // Nothing on the HTTP port handles upgrades in this mode.
      const httpAddr = handle.port
      expect(httpAddr).toBe(port)
    }
    finally {
      await handle.close()
    }
  })

  it('ws config: remote url is advertised verbatim', async () => {
    const devframe = defineDevframe({
      id: 'devframe-ws-remote',
      name: 'WS Remote',
      version: '0.0.0',
      packageName: 'devframe-test',
      homepage: 'https://example.test',
      description: 'Test devframe.',
      setup: () => {},
      cli: { ws: { url: 'wss://devtools.example.com/relay/__ws' } },
    })
    const host = '127.0.0.1'
    const port = await getPort({ port: 19860, host })
    const handle = await createDevServer(devframe, { host, port, openBrowser: false })

    try {
      const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json()
      expect(meta).toEqual({
        backend: 'websocket',
        websocket: 'wss://devtools.example.com/relay/__ws',
      })
    }
    finally {
      await handle.close()
    }
  })

  it('createDevServer runs in bridge mode when no distDir is configured', async () => {
    const devframe = defineDevframe({
      id: 'devframe-test-nodist',
      name: 'No Dist',
      version: '0.0.0',
      packageName: 'devframe-test',
      homepage: 'https://example.test',
      description: 'Test devframe.',
      setup: () => {},
    })
    const host = '127.0.0.1'
    const port = await getPort({ port: 19990, host })
    const handle = await createDevServer(devframe, {
      host,
      port,
      openBrowser: false,
    })

    try {
      // Connection meta is still served — the bridge endpoint that lets
      // a host-served SPA discover the WS backend.
      const res = await fetch(`http://${host}:${port}/__connection.json`)
      expect(res.ok).toBe(true)
      const meta = await res.json()
      expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__ws' } })

      // The SPA mount is absent — without a distDir, no static handler
      // is wired, so the basePath returns a 404 from h3 instead of an
      // index.html.
      const spa = await fetch(`http://${host}:${port}/`)
      expect(spa.status).toBe(404)
    }
    finally {
      await handle.close()
    }
  })

  it('gates by default: an unset `auth` auto-wires the interactive OTP handler', async () => {
    const devframe = defineDevframe({
      id: 'devframe-auth-default',
      name: 'Auth Default',
      version: '0.0.0',
      packageName: 'devframe-test',
      homepage: 'https://example.test',
      description: 'Test devframe.',
      setup: (ctx: DevframeNodeContext) => {
        ctx.rpc.register({ name: 'test:probe', type: 'query', handler: () => 'ok' })
      },
    })
    const host = '127.0.0.1'
    const port = await getPort({ port: 19410, host })
    // No `banner` override is exposed through the adapter, so silence the
    // default stdout banner for the test run.
    const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
    const handle = await createDevServer(devframe, { host, port, openBrowser: false })

    try {
      const client = connectWsClient(host, port)
      // Untrusted: only `anonymous:` methods are reachable; the probe rejects.
      const handshake = await client.$call('anonymous:devframe:auth' as any, HANDSHAKE)
      expect(handshake).toEqual({ isTrusted: false })
      await expect(client.$call('test:probe' as any)).rejects.toThrow()

      // The interactive exchange method is wired — the printed code trusts.
      const code = getTempAuthCode()
      const exchange = await client.$call('anonymous:devframe:auth:exchange' as any, { code, ua: 'test', origin: 'http://localhost' }) as { authToken: string | null }
      expect(exchange.authToken).toBeTruthy()
      await expect(client.$call('test:probe' as any)).resolves.toBe('ok')
      client.$close()
    }
    finally {
      spy.mockRestore()
      await handle.close()
    }
  })

  it('`--open` embeds the current OTP so the opened tab authenticates automatically', async () => {
    const devframe = defineDevframe({
      id: 'devframe-auth-open',
      name: 'Auth Open',
      version: '0.0.0',
      packageName: 'devframe-test',
      homepage: 'https://example.test',
      description: 'Test devframe.',
      setup: () => {},
    })
    const host = '127.0.0.1'
    const port = await getPort({ port: 19415, host })
    const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
    const mockedOpen = vi.mocked(open)
    mockedOpen.mockClear()
    const code = getTempAuthCode()
    const handle = await createDevServer(devframe, { host, port, openBrowser: true })

    try {
      expect(mockedOpen).toHaveBeenCalledTimes(1)
      const [target] = mockedOpen.mock.calls[0]
      expect(target).toBe(`http://localhost:${port}/#devframe_otp=${code}`)
    }
    finally {
      spy.mockRestore()
      await handle.close()
    }
  })

  it('`--open` opens the plain origin when `auth: false` (no handler to embed a code)', async () => {
    const devframe = defineDevframe({
      id: 'devframe-auth-open-off',
      name: 'Auth Open Off',
      version: '0.0.0',
      packageName: 'devframe-test',
      homepage: 'https://example.test',
      description: 'Test devframe.',
      cli: { auth: false },
      setup: () => {},
    })
    const host = '127.0.0.1'
    const port = await getPort({ port: 19416, host })
    const mockedOpen = vi.mocked(open)
    mockedOpen.mockClear()
    const handle = await createDevServer(devframe, { host, port, openBrowser: true })

    try {
      expect(mockedOpen).toHaveBeenCalledTimes(1)
      const [target] = mockedOpen.mock.calls[0]
      expect(target).toBe(`http://localhost:${port}/`)
    }
    finally {
      await handle.close()
    }
  })

  it('opts out with `auth: false`: the server auto-trusts and skips the gate', async () => {
    const devframe = defineDevframe({
      id: 'devframe-auth-off',
      name: 'Auth Off',
      version: '0.0.0',
      packageName: 'devframe-test',
      homepage: 'https://example.test',
      description: 'Test devframe.',
      cli: { auth: false },
      setup: (ctx: DevframeNodeContext) => {
        ctx.rpc.register({ name: 'test:probe', type: 'query', handler: () => 'ok' })
      },
    })
    const host = '127.0.0.1'
    const port = await getPort({ port: 19420, host })
    const handle = await createDevServer(devframe, { host, port, openBrowser: false })

    try {
      const client = connectWsClient(host, port)
      const handshake = await client.$call('anonymous:devframe:auth' as any, HANDSHAKE)
      expect(handshake).toEqual({ isTrusted: true })
      // Ungated: the probe resolves without any code exchange.
      await expect(client.$call('test:probe' as any)).resolves.toBe('ok')
      client.$close()
    }
    finally {
      await handle.close()
    }
  })

  it('the `--no-auth` flag (flags.auth === false) opts out of the gate', async () => {
    const devframe = defineDevframe({
      id: 'devframe-auth-flag',
      name: 'Auth Flag',
      version: '0.0.0',
      packageName: 'devframe-test',
      homepage: 'https://example.test',
      description: 'Test devframe.',
      setup: (ctx: DevframeNodeContext) => {
        ctx.rpc.register({ name: 'test:probe', type: 'query', handler: () => 'ok' })
      },
    })
    const host = '127.0.0.1'
    const port = await getPort({ port: 19430, host })
    const handle = await createDevServer(devframe, { host, port, openBrowser: false, flags: { auth: false } })

    try {
      const client = connectWsClient(host, port)
      const handshake = await client.$call('anonymous:devframe:auth' as any, HANDSHAKE)
      expect(handshake).toEqual({ isTrusted: true })
      await expect(client.$call('test:probe' as any)).resolves.toBe('ok')
      client.$close()
    }
    finally {
      await handle.close()
    }
  })

  it('the `auth: false` option overrides a gated `def.cli.auth` (hosted adapter defers to the host)', async () => {
    const devframe = defineDevframe({
      id: 'devframe-auth-option-off',
      name: 'Auth Option Off',
      version: '0.0.0',
      packageName: 'devframe-test',
      homepage: 'https://example.test',
      description: 'Test devframe.',
      // Standalone would gate; a hosted caller passes `auth: false` to defer.
      cli: { auth: true },
      setup: (ctx: DevframeNodeContext) => {
        ctx.rpc.register({ name: 'test:probe', type: 'query', handler: () => 'ok' })
      },
    })
    const host = '127.0.0.1'
    const port = await getPort({ port: 19440, host })
    const handle = await createDevServer(devframe, { host, port, openBrowser: false, auth: false })

    try {
      const client = connectWsClient(host, port)
      const handshake = await client.$call('anonymous:devframe:auth' as any, HANDSHAKE)
      expect(handshake).toEqual({ isTrusted: true })
      await expect(client.$call('test:probe' as any)).resolves.toBe('ok')
      client.$close()
    }
    finally {
      await handle.close()
    }
  })

  it('the `auth: true` option forces the gate on even when `def.cli.auth` is false', async () => {
    const devframe = defineDevframe({
      id: 'devframe-auth-option-on',
      name: 'Auth Option On',
      version: '0.0.0',
      packageName: 'devframe-test',
      homepage: 'https://example.test',
      description: 'Test devframe.',
      cli: { auth: false },
      setup: (ctx: DevframeNodeContext) => {
        ctx.rpc.register({ name: 'test:probe', type: 'query', handler: () => 'ok' })
      },
    })
    const host = '127.0.0.1'
    const port = await getPort({ port: 19450, host })
    // Silence the default OTP stdout banner for the test run.
    const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
    const handle = await createDevServer(devframe, { host, port, openBrowser: false, auth: true })

    try {
      const client = connectWsClient(host, port)
      // Gated: an empty handshake token is refused until a code is exchanged.
      const handshake = await client.$call('anonymous:devframe:auth' as any, HANDSHAKE)
      expect(handshake).toEqual({ isTrusted: false })
      const code = getTempAuthCode()
      const exchange = await client.$call('anonymous:devframe:auth:exchange' as any, { code, ua: 'test', origin: 'http://localhost' }) as { authToken: string | null }
      expect(exchange.authToken).toBeTruthy()
      client.$close()
    }
    finally {
      spy.mockRestore()
      await handle.close()
    }
  })

  it('the `--no-auth` flag forces the gate off even when the `auth: true` option opts in', async () => {
    const devframe = defineDevframe({
      id: 'devframe-auth-flag-wins',
      name: 'Auth Flag Wins',
      version: '0.0.0',
      packageName: 'devframe-test',
      homepage: 'https://example.test',
      description: 'Test devframe.',
      setup: (ctx: DevframeNodeContext) => {
        ctx.rpc.register({ name: 'test:probe', type: 'query', handler: () => 'ok' })
      },
    })
    const host = '127.0.0.1'
    const port = await getPort({ port: 19460, host })
    const handle = await createDevServer(devframe, { host, port, openBrowser: false, auth: true, flags: { auth: false } })

    try {
      const client = connectWsClient(host, port)
      const handshake = await client.$call('anonymous:devframe:auth' as any, HANDSHAKE)
      expect(handshake).toEqual({ isTrusted: true })
      await expect(client.$call('test:probe' as any)).resolves.toBe('ok')
      client.$close()
    }
    finally {
      await handle.close()
    }
  })

  it('forwards onPeerConnect/onPeerDisconnect to the underlying startHttpAndWs', async () => {
    const devframe = defineDevframe({
      id: 'devframe-peer-hooks',
      name: 'Peer Hooks',
      version: '0.0.0',
      packageName: 'devframe-test',
      homepage: 'https://example.test',
      description: 'Test devframe.',
      setup: () => {},
    })
    const host = '127.0.0.1'
    const port = await getPort({ port: 19470, host })
    const onPeerConnect = vi.fn()
    const onPeerDisconnect = vi.fn()
    const handle = await createDevServer(devframe, {
      host,
      port,
      openBrowser: false,
      auth: false,
      onPeerConnect,
      onPeerDisconnect,
    })

    try {
      const ws = new WebSocket(`ws://${host}:${port}/__ws`)
      await new Promise<void>((resolve, reject) => {
        ws.on('open', () => resolve())
        ws.on('error', reject)
      })
      await vi.waitFor(() => {
        expect(onPeerConnect).toHaveBeenCalledTimes(1)
      })
      expect(onPeerDisconnect).not.toHaveBeenCalled()

      ws.close()
      await vi.waitFor(() => {
        expect(onPeerDisconnect).toHaveBeenCalledTimes(1)
      })
    }
    finally {
      await handle.close()
    }
  })

  it('resolveDevServerPort honors def.cli.port as the preferred default', async () => {
    const preferred = await getPort({ port: 19500, host: '127.0.0.1' })
    const devframe = defineDevframe({
      id: 'devframe-test-port',
      name: 'Port Test',
      version: '0.0.0',
      packageName: 'devframe-test',
      homepage: 'https://example.test',
      description: 'Test devframe.',
      setup: () => {},
      cli: { port: preferred },
    })
    const port = await resolveDevServerPort(devframe, { host: '127.0.0.1' })
    expect(port).toBe(preferred)
  })

  it('resolveDevServerPort: defaultPort overrides def.cli.port', async () => {
    const override = await getPort({ port: 19600, host: '127.0.0.1' })
    const devframe = defineDevframe({
      id: 'devframe-test-port-override',
      name: 'Port Override',
      version: '0.0.0',
      packageName: 'devframe-test',
      homepage: 'https://example.test',
      description: 'Test devframe.',
      setup: () => {},
      cli: { port: 9999 },
    })
    const port = await resolveDevServerPort(devframe, {
      host: '127.0.0.1',
      defaultPort: override,
    })
    expect(port).toBe(override)
  })

  it('registers the instance in the registry and unregisters on close', async () => {
    const registryDir = mkdtempSync(join(tmpdir(), 'devframe-registry-'))
    vi.stubEnv('DEVFRAME_INSTANCES_DIR', registryDir)
    // The global vitest setup disables registration for every other test.
    vi.stubEnv('DEVFRAME_DISABLE_INSTANCE_REGISTRY', '0')
    try {
      const devframe = defineDevframe({
        id: 'devframe-test-registry',
        name: 'Registry Test',
        version: '0.0.0',
        packageName: 'devframe-test',
        homepage: 'https://example.test',
        description: 'Test devframe.',
        setup: () => {},
      })
      const server = await createDevServer(devframe, {
        host: '127.0.0.1',
        port: 0,
        auth: false,
        mcp: true,
      })

      const { readDevframeInstances } = await import('../../node/instance-registry')
      const records = readDevframeInstances({ instancesDir: registryDir })
      expect(records).toHaveLength(1)
      expect(records[0]).toMatchObject({
        id: 'devframe-test-registry',
        port: server.port,
        basePath: '/',
        mcp: { path: '/__mcp' },
      })
      expect(records[0]!.origin).toContain(`:${server.port}`)

      await server.close()
      expect(readDevframeInstances({ instancesDir: registryDir })).toEqual([])
    }
    finally {
      vi.unstubAllEnvs()
    }
  })
})
