import { vi } from 'vitest'
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vp/test'
import { Registration } from 'webauthx/server'

import { createServer, type Server } from '../../../../test/utils.js'
import * as WebAuthnCeremony from '../../../core/WebAuthnCeremony.js'
import * as Kv from '../../Kv.js'
import { type SessionPayload, webAuthn } from './webAuthn.js'

vi.mock('webauthx/server', { spy: true })

let server: Server
let ceremony: WebAuthnCeremony.WebAuthnCeremony

beforeAll(async () => {
  server = await createServer(
    webAuthn({
      kv: Kv.memory(),
      origin: 'http://localhost',
      rpId: 'localhost',
    }).listener,
  )
  ceremony = WebAuthnCeremony.server({ url: server.url })
})

afterAll(async () => {
  await server.closeAsync()
})

describe('POST /register/options', () => {
  test('default: returns registration options', async () => {
    const { options } = await ceremony.getRegistrationOptions({ name: 'Test' })
    expect(options.publicKey).toBeDefined()
    expect(options.publicKey!.rp.id).toMatchInlineSnapshot(`"localhost"`)
    expect(options.publicKey!.rp.name).toMatchInlineSnapshot(`"localhost"`)
    expect(typeof options.publicKey!.challenge).toMatchInlineSnapshot(`"string"`)
  })

  test('behavior: each call generates a unique challenge', async () => {
    const { options: a } = await ceremony.getRegistrationOptions({ name: 'Test' })
    const { options: b } = await ceremony.getRegistrationOptions({ name: 'Test' })
    expect(a.publicKey!.challenge).not.toBe(b.publicKey!.challenge)
  })
})

describe('POST /login/options', () => {
  test('default: returns authentication options', async () => {
    const { options } = await ceremony.getAuthenticationOptions()
    expect(options.publicKey).toBeDefined()
    expect(options.publicKey!.rpId).toMatchInlineSnapshot(`"localhost"`)
    expect(typeof options.publicKey!.challenge).toMatchInlineSnapshot(`"string"`)
  })

  test('behavior: each call generates a unique challenge', async () => {
    const { options: a } = await ceremony.getAuthenticationOptions()
    const { options: b } = await ceremony.getAuthenticationOptions()
    expect(a.publicKey!.challenge).not.toBe(b.publicKey!.challenge)
  })

  test('behavior: restricts authentication to multiple credentials', async () => {
    const { options } = await ceremony.getAuthenticationOptions({
      credentialId: ['Y3JlZC0x', 'Y3JlZC0y'],
    })

    expect(options.publicKey?.allowCredentials).toMatchInlineSnapshot(`
      [
        {
          "id": "Y3JlZC0x",
          "type": "public-key",
        },
        {
          "id": "Y3JlZC0y",
          "type": "public-key",
        },
      ]
    `)
  })
})

describe('POST /register', () => {
  test('error: invalid credential → 400', async () => {
    const response = await fetch(`${server.url}/register`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ id: 'fake', clientDataJSON: 'bad', attestationObject: 'bad' }),
    })
    expect(response.status).toBe(400)
    const body = await response.json()
    expect(body.error).toBeTypeOf('string')
  })
})

describe('kv', () => {
  test('store without atomic create is accepted', () => {
    const kv: Kv.Kv = {
      async get() {
        return undefined
      },
      async set() {},
      async delete() {},
    }

    expect(() =>
      webAuthn({
        kv,
        origin: 'http://localhost',
        rpId: 'localhost',
      }),
    ).not.toThrow()
  })
})

describe('POST /login', () => {
  test('error: unknown credential → 400', async () => {
    const response = await fetch(`${server.url}/login`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        id: 'unknown',
        metadata: { authenticatorData: '0x00', clientDataJSON: '{"challenge":"0xdead"}' },
        raw: {
          id: 'unknown',
          type: 'public-key',
          authenticatorAttachment: null,
          rawId: 'unknown',
          response: { clientDataJSON: 'e30' },
        },
        signature: '0x00',
      }),
    })
    expect(response.status).toBe(400)
    const body = await response.json()
    expect(body.error).toMatchInlineSnapshot(`"Missing or expired challenge"`)
  })
})

describe('challenge replay', () => {
  test('behavior: challenge consumed after register/options → re-fetching is required', async () => {
    // Get options twice — each should have a unique challenge stored in KV
    const { options: a } = await ceremony.getRegistrationOptions({ name: 'Replay' })
    const { options: b } = await ceremony.getRegistrationOptions({ name: 'Replay' })
    expect(a.publicKey!.challenge).not.toBe(b.publicKey!.challenge)
  })

  test('behavior: challenge consumed after login/options → re-fetching is required', async () => {
    const { options: a } = await ceremony.getAuthenticationOptions()
    const { options: b } = await ceremony.getAuthenticationOptions()
    expect(a.publicKey!.challenge).not.toBe(b.publicKey!.challenge)
  })
})

describe('hooks', () => {
  test('behavior: onRegister error does not call hook', async () => {
    let called = false
    const hookServer = await createServer(
      webAuthn({
        kv: Kv.memory(),
        origin: 'http://localhost',
        rpId: 'localhost',
        onRegister() {
          called = true
          return Response.json({ extra: true })
        },
      }).listener,
    )

    const response = await fetch(`${hookServer.url}/register`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ id: 'fake', clientDataJSON: 'bad', attestationObject: 'bad' }),
    })
    expect(response.status).toBe(400)
    expect(called).toBe(false)

    await hookServer.closeAsync()
  })

  test('behavior: onRegister rejection removes the credential', async () => {
    const kv = Kv.memory()
    await kv.set('challenge:0x01', { created: Date.now(), name: 'Test' })
    const publicKey =
      '0x04ab891400140fc4f8e941ce0ff90e419de9470acaca613bbd717a4775435031a7d884318e919fd3b3e5a631d866d8a380b44063e70f0c381ee16e0652f7f97554'
    vi.mocked(Registration.verify).mockReturnValueOnce({
      aaguid: 'fbfc3007-154e-4ecc-8c0b-6e020557d7bd',
      credential: { publicKey },
    } as never)
    const hookServer = await createServer(
      webAuthn({
        kv,
        origin: 'http://localhost',
        rpId: 'localhost',
        onRegister() {
          throw new Error('Authenticator not allowed')
        },
      }).listener,
    )

    try {
      const clientDataJSON = 'eyJjaGFsbGVuZ2UiOiJBUSJ9'
      const response = await fetch(`${hookServer.url}/register`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          attestationObject: 'AA',
          clientDataJSON,
          id: 'credential',
          publicKey,
          raw: {
            authenticatorAttachment: null,
            id: 'credential',
            rawId: 'Y3JlZGVudGlhbA',
            response: { clientDataJSON },
            type: 'public-key',
          },
        }),
      })

      expect(response.status).toMatchInlineSnapshot(`400`)
      expect(await response.json()).toMatchInlineSnapshot(`
        {
          "error": "Authenticator not allowed",
        }
      `)
      expect(await kv.get('credential:credential')).toBeUndefined()
      expect(await kv.get('challenge:0x01')).toBeUndefined()
    } finally {
      await hookServer.closeAsync()
    }
  })

  test('behavior: onAuthenticate error does not call hook', async () => {
    let called = false
    const hookServer = await createServer(
      webAuthn({
        kv: Kv.memory(),
        origin: 'http://localhost',
        rpId: 'localhost',
        onAuthenticate() {
          called = true
          return Response.json({ extra: true })
        },
      }).listener,
    )

    const response = await fetch(`${hookServer.url}/login`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        id: 'unknown',
        metadata: { authenticatorData: '0x00', clientDataJSON: '{"challenge":"0xdead"}' },
        raw: {
          id: 'unknown',
          type: 'public-key',
          authenticatorAttachment: null,
          rawId: 'unknown',
          response: { clientDataJSON: 'e30' },
        },
        signature: '0x00',
      }),
    })
    expect(response.status).toBe(400)
    expect(called).toBe(false)

    await hookServer.closeAsync()
  })
})

// Successful login requires a real authenticator, so the cookie / session
// surfaces are exercised by manually seeding the session in the shared
// `kv` and hitting `getSession` and `/logout` directly.
describe('session — getSession & /logout', () => {
  let kv: Kv.Kv
  let handler: ReturnType<typeof webAuthn>
  let s: Server

  const seedSession = async (token = 'tok-default'): Promise<SessionPayload> => {
    const issuedAt = Math.floor(Date.now() / 1000)
    const payload: SessionPayload = {
      credentialId: 'cred-1',
      publicKey: '0xpub',
      userId: 'user-1',
      issuedAt,
      expiresAt: issuedAt + 60,
    }
    await kv.set(`session:${token}`, payload, { ttl: 60 })
    return payload
  }

  afterEach(async () => {
    if (s) await s.closeAsync()
  })

  test('default (cookie mode): getSession resolves bearer or cookie; /logout clears cookie + revokes', async () => {
    kv = Kv.memory()
    handler = webAuthn({ kv, origin: 'http://localhost', rpId: 'localhost' })
    s = await createServer(handler.listener)

    await seedSession('tok-A')

    const bearerSession = await handler.getSession(
      new Request('http://localhost/', { headers: { authorization: 'Bearer tok-A' } }),
    )
    expect(bearerSession?.credentialId).toBe('cred-1')

    const cookieSession = await handler.getSession(
      new Request('http://localhost/', { headers: { cookie: 'accounts_webauthn=tok-A' } }),
    )
    expect(cookieSession?.credentialId).toBe('cred-1')

    const noAuthSession = await handler.getSession(new Request('http://localhost/'))
    expect(noAuthSession).toBeUndefined()

    const logout = await fetch(`${s.url}/logout`, {
      method: 'POST',
      headers: { authorization: 'Bearer tok-A' },
    })
    expect(logout.status).toBe(204)
    const setCookie = logout.headers.get('set-cookie')
    expect(setCookie).toContain('accounts_webauthn=')
    expect(setCookie).toContain('Max-Age=0')

    expect(await kv.get('session:tok-A')).toBeUndefined()
    const after = await handler.getSession(
      new Request('http://localhost/', { headers: { authorization: 'Bearer tok-A' } }),
    )
    expect(after).toBeUndefined()
  })

  test('cookie: false: getSession ignores cookies; /logout returns 204 without Set-Cookie', async () => {
    kv = Kv.memory()
    handler = webAuthn({ kv, cookie: false, origin: 'http://localhost', rpId: 'localhost' })
    s = await createServer(handler.listener)

    await seedSession('tok-B')

    const bearer = await handler.getSession(
      new Request('http://localhost/', { headers: { authorization: 'Bearer tok-B' } }),
    )
    expect(bearer?.credentialId).toBe('cred-1')

    const cookieIgnored = await handler.getSession(
      new Request('http://localhost/', { headers: { cookie: 'accounts_webauthn=tok-B' } }),
    )
    expect(cookieIgnored).toBeUndefined()

    const logout = await fetch(`${s.url}/logout`, {
      method: 'POST',
      headers: { authorization: 'Bearer tok-B' },
    })
    expect(logout.status).toBe(204)
    expect(logout.headers.get('set-cookie')).toBeNull()
    expect(await kv.get('session:tok-B')).toBeUndefined()
  })

  test('custom cookieName is honored on getSession and /logout', async () => {
    kv = Kv.memory()
    handler = webAuthn({
      kv,
      cookieName: 'custom_cookie',
      origin: 'http://localhost',
      rpId: 'localhost',
    })
    s = await createServer(handler.listener)

    await seedSession('tok-C')

    expect(
      await handler.getSession(
        new Request('http://localhost/', { headers: { cookie: 'custom_cookie=tok-C' } }),
      ),
    ).toBeTruthy()
    expect(
      await handler.getSession(
        new Request('http://localhost/', { headers: { cookie: 'accounts_webauthn=tok-C' } }),
      ),
    ).toBeUndefined()

    const logout = await fetch(`${s.url}/logout`, { method: 'POST' })
    expect(logout.headers.get('set-cookie')).toContain('custom_cookie=')
  })

  test('/logout returns 204 even without a session token', async () => {
    kv = Kv.memory()
    handler = webAuthn({ kv, origin: 'http://localhost', rpId: 'localhost' })
    s = await createServer(handler.listener)

    const res = await fetch(`${s.url}/logout`, { method: 'POST' })
    expect(res.status).toBe(204)
  })

  test('session: false: getSession always undefined; /logout route is not mounted (404)', async () => {
    kv = Kv.memory()
    handler = webAuthn({ kv, origin: 'http://localhost', rpId: 'localhost', session: false })
    s = await createServer(handler.listener)

    // Even with a manually-seeded session in kv, getSession ignores it.
    await seedSession('tok-D')
    const bearer = await handler.getSession(
      new Request('http://localhost/', { headers: { authorization: 'Bearer tok-D' } }),
    )
    expect(bearer).toBeUndefined()

    const logout = await fetch(`${s.url}/logout`, {
      method: 'POST',
      headers: { authorization: 'Bearer tok-D' },
    })
    expect(logout.status).toBe(404)
    // The seeded entry must survive — no logout route, nothing to delete.
    expect(await kv.get('session:tok-D')).toBeDefined()
  })
})
