import {createHash, randomUUID} from 'node:crypto'

import {afterAll, expect, test} from 'bun:test'
import {inArray} from 'drizzle-orm'

import app from '../app'
import {issueMagicToken, peekMagicToken} from '../auth/tokens'
import {db, schema} from '../db/client'
import {redis} from '../lib/redis'

const sha256 = (s: string) =>
  createHash('sha256').update(s).digest('hex')
const magicKey = (raw: string) => `magic:${sha256(raw)}`

const testEmail = () => `verify-${randomUUID()}@example.com`
const createdEmails: string[] = []
const issue = async () => {
  const email = testEmail()
  createdEmails.push(email)
  return issueMagicToken(email)
}

// per-ip limiter counts live in redis with real ttls, so a fixed subnet
// would inherit counts from a suite run less than a window ago
const ipSubnet = `10.${1 + Math.floor(Math.random() * 254)}.${1 + Math.floor(Math.random() * 254)}`
let ipCounter = 0
const nextIp = () => `${ipSubnet}.${++ipCounter}`

const getVerify = (token: string) =>
  app.request(
    `/auth/verify?token=${encodeURIComponent(token)}`,
    {},
    {ip: nextIp()},
  )

const postVerify = (token: string, ip = nextIp()) =>
  app.request(
    '/auth/verify',
    {method: 'POST', body: new URLSearchParams({token})},
    {ip},
  )

afterAll(async () => {
  if (createdEmails.length > 0) {
    const users = await db
      .select({id: schema.users.id})
      .from(schema.users)
      .where(inArray(schema.users.email, createdEmails))
    const ids = users.map((u) => u.id)
    if (ids.length > 0) {
      await db
        .delete(schema.sessions)
        .where(inArray(schema.sessions.userId, ids))
      await db
        .delete(schema.users)
        .where(inArray(schema.users.id, ids))
    }
  }
})

test('verify pages carry a csp allowing only the redirect script', async () => {
  const token = await issue()
  const res = await getVerify(token)
  const csp = res.headers.get('content-security-policy')
  expect(csp).toContain("default-src 'none'")
  expect(csp).toContain("'sha256-")
  expect(csp).toContain("frame-ancestors 'none'")
})

test('csp hash matches the rendered inline script', async () => {
  const token = await issue()
  const res = await postVerify(token)
  const csp = res.headers.get('content-security-policy')!
  const body = await res.text()
  const script = body.match(/<script>([\s\S]*?)<\/script>/)![1]!
  const hash = createHash('sha256').update(script).digest('base64')
  expect(csp).toContain(`'sha256-${hash}'`)
})

test('GET renders confirm page without consuming the token', async () => {
  const token = await issue()
  const first = await getVerify(token)
  const second = await getVerify(token)
  expect(first.status).toBe(200)
  expect(second.status).toBe(200)
  const body = await second.text()
  expect(body).toContain('method="post"')
  expect(body).toContain(token)
  expect(await redis.exists(magicKey(token))).toBe(1)
})

test('HEAD does not consume the token', async () => {
  const token = await issue()
  const res = await app.request(
    `/auth/verify?token=${encodeURIComponent(token)}`,
    {method: 'HEAD'},
    {ip: nextIp()},
  )
  expect(res.status).toBe(200)
  expect(await redis.exists(magicKey(token))).toBe(1)
})

test('GET with malformed token returns 400 without reflection', async () => {
  const res = await getVerify('<script>alert(1)</script>')
  expect(res.status).toBe(400)
  const body = await res.text()
  expect(body).not.toContain('<script>alert')

  const tooShort = await getVerify('a'.repeat(42))
  expect(tooShort.status).toBe(400)
})

test('GET with unknown token shows expired page', async () => {
  const res = await getVerify('a'.repeat(43))
  expect(res.status).toBe(400)
  expect(await res.text()).toContain('expired')
})

const postExchange = (code: string, ip = nextIp()) =>
  app.request(
    '/auth/exchange',
    {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({code}),
    },
    {ip},
  )

const verifyAndExtractCode = async (token: string) => {
  const res = await postVerify(token)
  expect(res.status).toBe(200)
  const body = await res.text()
  const match = body.match(
    /crumbs:\/\/auth\/callback\?code=([A-Za-z0-9_-]{43})/,
  )
  expect(match).not.toBeNull()
  return match![1]!
}

test('POST consumes token once and exchange yields a session', async () => {
  const token = await issue()
  const email = createdEmails.at(-1)!

  const code = await verifyAndExtractCode(token)
  expect(await redis.ttl(`exchange:${sha256(code)}`)).toBeWithin(
    1,
    61,
  )

  const exchanged = await postExchange(code)
  expect(exchanged.status).toBe(200)
  const {token: session} = (await exchanged.json()) as {
    token: string
  }

  const me = await app.request('/auth/me', {
    headers: {Authorization: `Bearer ${session}`},
  })
  expect(me.status).toBe(200)
  const {user} = (await me.json()) as {user: {email: string}}
  expect(user.email).toBe(email)

  const again = await postVerify(token)
  expect(again.status).toBe(400)
  expect(await again.text()).toContain('expired')
})

test('exchange code is single use', async () => {
  const token = await issue()
  const code = await verifyAndExtractCode(token)
  expect((await postExchange(code)).status).toBe(200)
  expect((await postExchange(code)).status).toBe(401)
})

test('exchange rejects malformed and unknown codes', async () => {
  expect((await postExchange('nope')).status).toBe(400)
  expect((await postExchange('d'.repeat(43))).status).toBe(401)
})

test('exchange is rate limited per ip', async () => {
  const ip = `10.96.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`
  for (let i = 0; i < 10; i++) {
    const res = await postExchange('e'.repeat(43), ip)
    expect(res.status).toBe(401)
  }
  expect((await postExchange('e'.repeat(43), ip)).status).toBe(429)
})

test('POST with malformed token returns 400', async () => {
  const res = await postVerify('not-a-token')
  expect(res.status).toBe(400)
})

test('POST is rate limited per ip', async () => {
  const ip = `10.98.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`
  const garbage = 'b'.repeat(43)
  for (let i = 0; i < 10; i++) {
    const res = await postVerify(garbage, ip)
    expect(res.status).toBe(400)
  }
  const limited = await postVerify(garbage, ip)
  expect(limited.status).toBe(429)
})

test('reissue invalidates the previous magic token', async () => {
  const email = testEmail()
  const first = await issueMagicToken(email)
  const second = await issueMagicToken(email)
  expect(await peekMagicToken(first)).toBe(false)
  expect(await peekMagicToken(second)).toBe(true)
})

test('magic link requests are capped per email', async () => {
  const email = testEmail()
  for (let i = 0; i < 3; i++) {
    const res = await app.request(
      '/auth/request',
      {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({email}),
      },
      {ip: nextIp()},
    )
    expect(res.status).toBe(200)
  }
  const limited = await app.request(
    '/auth/request',
    {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({email}),
    },
    {ip: nextIp()},
  )
  expect(limited.status).toBe(429)
})

test('spoofed x-forwarded-for does not escape the limit', async () => {
  const ip = `10.97.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`
  const garbage = 'c'.repeat(43)
  for (let i = 0; i < 10; i++) {
    const res = await app.request(
      '/auth/verify',
      {
        method: 'POST',
        headers: {'x-forwarded-for': `1.2.3.${i}`},
        body: new URLSearchParams({token: garbage}),
      },
      {ip},
    )
    expect(res.status).toBe(400)
  }
  const limited = await app.request(
    '/auth/verify',
    {
      method: 'POST',
      headers: {'x-forwarded-for': '4.5.6.7'},
      body: new URLSearchParams({token: garbage}),
    },
    {ip},
  )
  expect(limited.status).toBe(429)
})
