import './test-crypto-shim.js'
import { describe, expect, it, vi } from 'vitest'
import type { DurableObjectState, MessageBatch, Queue, WebSocket } from '@cloudflare/workers-types'
import type { RealtimeEvent } from './index.js'
import {
  acceptHibernatable,
  broadcastFrame,
  computeOffline,
  makeQueueConsumer,
  publishEvent,
  verifyInternalSecret,
} from './server.js'

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, sockets }
}

describe('publishEvent', () => {
  it('stamps id and timestamp without mutating the input', async () => {
    const input = { type: 'ping' as const, scope: 'tenant-1', payload: { n: 1 } }
    const frozen = structuredClone(input)
    const send = vi.fn(async (_event: unknown) => {})
    await publishEvent({ send } as unknown as Queue, input)
    expect(input).toEqual(frozen)
    expect(send).toHaveBeenCalledOnce()
    const sent = send.mock.calls[0]![0] as RealtimeEvent
    expect(sent.id).toMatch(
      /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
    )
    expect(sent.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/)
    expect(sent.type).toBe('ping')
    expect(sent.scope).toBe('tenant-1')
  })
})

describe('acceptHibernatable', () => {
  it('accepts with tags for scoped fan-out', () => {
    const { ctx } = createFakeCtx()
    const ws = createFakeWebSocket()
    acceptHibernatable(ctx, ws, { tags: ['u1'] })
    expect(ctx.getWebSockets('u1')).toContain(ws)
  })

  it('accepts with attachment for rich per-socket state', () => {
    const { ctx } = createFakeCtx()
    const ws = createFakeWebSocket()
    const attachment = { userId: 'u1', displayName: 'Ada' }
    acceptHibernatable(ctx, ws, { attachment })
    expect(ws.deserializeAttachment()).toEqual(attachment)
  })
})

describe('broadcastFrame', () => {
  it('reaches both connected sockets', () => {
    const { ctx } = createFakeCtx()
    const u1 = createFakeWebSocket()
    const u2 = createFakeWebSocket()
    acceptHibernatable(ctx, u1, { tags: ['u1'] })
    acceptHibernatable(ctx, u2, { tags: ['u2'] })
    broadcastFrame(ctx, '{"v":1,"type":"msg"}')
    expect(u1.sent).toEqual(['{"v":1,"type":"msg"}'])
    expect(u2.sent).toEqual(['{"v":1,"type":"msg"}'])
  })

  it('targetTag reaches u1 NOT u2 (no-leak security)', () => {
    const { ctx } = createFakeCtx()
    const u1 = createFakeWebSocket()
    const u2 = createFakeWebSocket()
    acceptHibernatable(ctx, u1, { tags: ['u1'] })
    acceptHibernatable(ctx, u2, { tags: ['u2'] })
    broadcastFrame(ctx, '{"v":1,"type":"secret"}', { targetTag: 'u1' })
    expect(u1.sent).toEqual(['{"v":1,"type":"secret"}'])
    expect(u2.sent).toEqual([])
  })

  it('exclude skips the sender socket', () => {
    const { ctx } = createFakeCtx()
    const u1 = createFakeWebSocket()
    const u2 = createFakeWebSocket()
    acceptHibernatable(ctx, u1, { tags: ['u1'] })
    acceptHibernatable(ctx, u2, { tags: ['u2'] })
    broadcastFrame(ctx, '{"v":1,"type":"typing"}', { exclude: u1 })
    expect(u1.sent).toEqual([])
    expect(u2.sent).toEqual(['{"v":1,"type":"typing"}'])
  })

  it('ignores closed-socket send throws', () => {
    const { ctx } = createFakeCtx()
    const open = createFakeWebSocket()
    const closed = createFakeWebSocket(true)
    acceptHibernatable(ctx, open)
    acceptHibernatable(ctx, closed)
    expect(() => broadcastFrame(ctx, 'frame')).not.toThrow()
    expect(open.sent).toEqual(['frame'])
  })
})

describe('verifyInternalSecret', () => {
  const expected = 'super-secret-token-value'

  it('accepts the correct secret', () => {
    expect(verifyInternalSecret(expected, expected)).toBe(true)
  })

  it('rejects a wrong secret of equal length', () => {
    const wrong = 'super-secret-token-wrong'
    expect(wrong.length).toBe(expected.length)
    expect(verifyInternalSecret(wrong, expected)).toBe(false)
  })

  it('rejects a short secret via length-guard before compare', () => {
    expect(verifyInternalSecret('short', expected)).toBe(false)
    expect(verifyInternalSecret(null, expected)).toBe(false)
  })
})

describe('makeQueueConsumer', () => {
  type E = RealtimeEvent<'ping', { n: number }>

  function fakeMessage(body: E) {
    return {
      id: '1',
      timestamp: new Date(),
      body,
      attempts: 1,
      ack: vi.fn(),
      retry: vi.fn(),
    }
  }

  function fakeBatch(...messages: ReturnType<typeof fakeMessage>[]): MessageBatch<E> {
    return {
      queue: 'realtime',
      messages,
      retryAll: vi.fn(),
      ackAll: vi.fn(),
    } as unknown as MessageBatch<E>
  }

  it('acks on successful dispatch without double-deliver', async () => {
    const dispatch = vi.fn(async () => {})
    const consumer = makeQueueConsumer<E>(dispatch)
    const msg = fakeMessage({
      id: 'e1',
      type: 'ping',
      scope: 't1',
      payload: { n: 1 },
      timestamp: new Date().toISOString(),
    })
    await consumer(fakeBatch(msg), {})
    expect(dispatch).toHaveBeenCalledOnce()
    expect(msg.ack).toHaveBeenCalledOnce()
    expect(msg.retry).not.toHaveBeenCalled()
  })

  it('retries on dispatch throw without ack (no double-deliver)', async () => {
    const dispatch = vi.fn(async () => {
      throw new Error('do unreachable')
    })
    const consumer = makeQueueConsumer<E>(dispatch)
    const msg = fakeMessage({
      id: 'e2',
      type: 'ping',
      scope: 't1',
      payload: { n: 2 },
      timestamp: new Date().toISOString(),
    })
    await consumer(fakeBatch(msg), {})
    expect(dispatch).toHaveBeenCalledOnce()
    expect(msg.retry).toHaveBeenCalledOnce()
    expect(msg.ack).not.toHaveBeenCalled()
  })
})

describe('computeOffline', () => {
  it('returns participants minus online', () => {
    const participants = ['u1', 'u2', 'u3'] as const
    expect(computeOffline(participants, new Set(['u2']))).toEqual(['u1', 'u3'])
    expect(computeOffline(participants, new Set(['u1', 'u2', 'u3']))).toEqual([])
    expect(computeOffline(participants, new Set())).toEqual(['u1', 'u2', 'u3'])
  })
})
