import { timingSafeEqual as nodeTimingSafeEqual } from 'node:crypto'
import { Buffer } from 'node:buffer'
import { describe, expect, it, vi } from 'vitest'
import type { DurableObjectState, MessageBatch, Queue, WebSocket } from '@cloudflare/workers-types'
import type { RealtimeEvent } from '@platform-modules/realtime'

const subtle = globalThis.crypto.subtle as Crypto['subtle'] & {
  timingSafeEqual?: (a: ArrayBuffer | ArrayBufferView, b: ArrayBuffer | ArrayBufferView) => boolean
}
subtle.timingSafeEqual ??= (a, b) =>
  nodeTimingSafeEqual(Buffer.from(a as ArrayBuffer), Buffer.from(b as ArrayBuffer))

import {
  acceptHibernatable,
  broadcastFrame,
  computeOffline,
  makeQueueConsumer,
  publishEvent,
  verifyInternalSecret,
} from '@platform-modules/realtime/server'

type FakeWebSocket = WebSocket & {
  sent: string[]
  _attachment?: unknown
  _closed?: boolean
}

function createFakeWebSocket(closed = false): FakeWebSocket {
  const ws = {
    sent: [] as string[],
    _attachment: undefined as unknown,
    _closed: closed,
    send(data: string) {
      if (this._closed) throw new Error('WebSocket is closed')
      this.sent.push(data)
    },
    serializeAttachment(attachment: unknown) {
      this._attachment = attachment
    },
    deserializeAttachment() {
      return this._attachment
    },
  }
  return ws as FakeWebSocket
}

function createFakeCtx() {
  const sockets = new Map<FakeWebSocket, string[]>()
  const ctx = {
    acceptWebSocket(ws: FakeWebSocket, tags?: string[]) {
      sockets.set(ws, tags ?? [])
    },
    getWebSockets(tag?: string) {
      if (tag) {
        return [...sockets.entries()]
          .filter(([, tags]) => tags.includes(tag))
          .map(([ws]) => ws)
      }
      return [...sockets.keys()]
    },
  }
  return { ctx: ctx as unknown as DurableObjectState }
}

/** Minimal fake DO exercising module helpers (Gate 3). */
class FakeRealtimeRoom {
  readonly ctx: DurableObjectState
  readonly online = new Set<string>()

  constructor(ctx: DurableObjectState) {
    this.ctx = ctx
  }

  connect(ws: FakeWebSocket, userId: string) {
    acceptHibernatable(this.ctx, ws, { tags: [userId], attachment: { userId } })
    this.online.add(userId)
  }

  disconnect(userId: string) {
    this.online.delete(userId)
  }

  broadcast(frame: string, opts?: { targetTag?: string; exclude?: WebSocket }) {
    broadcastFrame(this.ctx, frame, opts)
  }
}

describe('realtime consumer fixture (Gate 3)', () => {
  it('broadcastFrame reaches both connected sockets', () => {
    const { ctx } = createFakeCtx()
    const room = new FakeRealtimeRoom(ctx)
    const u1 = createFakeWebSocket()
    const u2 = createFakeWebSocket()
    room.connect(u1, 'u1')
    room.connect(u2, 'u2')
    room.broadcast('{"v":1,"type":"msg"}')
    expect(u1.sent).toEqual(['{"v":1,"type":"msg"}'])
    expect(u2.sent).toEqual(['{"v":1,"type":"msg"}'])
  })

  it('targetTag scopes delivery — u1 only, not u2 (no-leak)', () => {
    const { ctx } = createFakeCtx()
    const room = new FakeRealtimeRoom(ctx)
    const u1 = createFakeWebSocket()
    const u2 = createFakeWebSocket()
    room.connect(u1, 'u1')
    room.connect(u2, 'u2')
    room.broadcast('{"v":1,"type":"secret"}', { targetTag: 'u1' })
    expect(u1.sent).toEqual(['{"v":1,"type":"secret"}'])
    expect(u2.sent).toEqual([])
  })

  it('verifyInternalSecret rejects wrong and short secrets', () => {
    const expected = 'broadcast-internal-secret-value'
    expect(verifyInternalSecret(expected, expected)).toBe(true)
    expect(verifyInternalSecret('broadcast-internal-secret-wrong', expected)).toBe(false)
    expect(verifyInternalSecret('short', expected)).toBe(false)
    expect(verifyInternalSecret(null, expected)).toBe(false)
  })

  it('publishEvent → makeQueueConsumer delivers once; dispatch throw retries (no double-deliver)', async () => {
    type E = RealtimeEvent<'notify', { text: string }>
    const delivered: E[] = []
    const dispatch = vi.fn(async (event: E) => {
      delivered.push(event)
    })
    const consumer = makeQueueConsumer<E>(dispatch)

    const queueMessages: E[] = []
    await publishEvent(
      {
        send: async (body: E) => {
          queueMessages.push(body)
        },
      } as unknown as Queue,
      { type: 'notify', scope: 'room-1', payload: { text: 'hi' } },
    )
    expect(queueMessages).toHaveLength(1)
    expect(queueMessages[0]!.id).toBeTruthy()

    const okMsg = {
      id: 'm1',
      timestamp: new Date(),
      body: queueMessages[0]!,
      attempts: 1,
      ack: vi.fn(),
      retry: vi.fn(),
    }
    const batch = {
      queue: 'realtime',
      messages: [okMsg],
      retryAll: vi.fn(),
      ackAll: vi.fn(),
    } as unknown as MessageBatch<E>
    await consumer(batch, {})
    expect(delivered).toHaveLength(1)
    expect(okMsg.ack).toHaveBeenCalledOnce()
    expect(okMsg.retry).not.toHaveBeenCalled()

    const failDispatch = vi.fn(async () => {
      throw new Error('do down')
    })
    const failConsumer = makeQueueConsumer<E>(failDispatch)
    const failMsg = {
      id: 'm2',
      timestamp: new Date(),
      body: queueMessages[0]!,
      attempts: 1,
      ack: vi.fn(),
      retry: vi.fn(),
    }
    await failConsumer({ ...batch, messages: [failMsg] }, {})
    expect(failDispatch).toHaveBeenCalledOnce()
    expect(failMsg.retry).toHaveBeenCalledOnce()
    expect(failMsg.ack).not.toHaveBeenCalled()
  })

  it('computeOffline reflects connect/disconnect', () => {
    const { ctx } = createFakeCtx()
    const room = new FakeRealtimeRoom(ctx)
    const participants = ['u1', 'u2', 'u3'] as const
    room.connect(createFakeWebSocket(), 'u1')
    room.connect(createFakeWebSocket(), 'u2')
    expect(computeOffline(participants, room.online)).toEqual(['u3'])
    room.disconnect('u2')
    expect(computeOffline(participants, room.online)).toEqual(['u2', 'u3'])
  })

  it('exports are wired — a missing helper fails at import', () => {
    expect(typeof broadcastFrame).toBe('function')
    expect(typeof verifyInternalSecret).toBe('function')
    expect(typeof publishEvent).toBe('function')
    expect(typeof makeQueueConsumer).toBe('function')
    expect(typeof computeOffline).toBe('function')
  })
})
