/**
 * B5 · unit tests for the STAGING operator token-mint (`POST /auth/mint`) and the P5 capability
 * signing-key loader. Covers: the DPoP-bound short-lived token happy path (claims + PoP binding),
 * the deny-by-default gates (bad password / unknown+revoked subdomain / malformed body / wrong
 * method / oversized body), route claiming semantics, and PEM+base64 key loading round-trips.
 */
import { describe, it, expect, beforeAll } from 'vitest'
import { Readable } from 'node:stream'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { HostRecord } from 'control-plane/src/model/records.js'
import { verifyPaseto } from 'relay-auth/src/crypto/paseto.js'
import type { TokenBucketStore } from 'relay-auth'
import {
  createAuthMintRoute,
  loadSigningKeyFromEnv,
  MINT_RATE_BURST,
  type SubdomainHostLookup,
  type MintRateLimit,
} from '../src/servers/auth-mint.js'

const subtle = globalThis.crypto.subtle
const NOW = 1_800_000_000
const PASSWORD = 'staging-operator-secret'
// A syntactically valid base64url SHA-256 JWK thumbprint (exactly 43 chars).
const JKT = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNO12'.padEnd(43, 'X').slice(0, 43)

interface Loaded {
  readonly signingKey: CryptoKey
  readonly publicKey: CryptoKey
}

let keys: Loaded

beforeAll(async () => {
  const kp = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as {
    publicKey: CryptoKey
    privateKey: CryptoKey
  }
  keys = { signingKey: kp.privateKey, publicKey: kp.publicKey }
})

function mkHost(overrides: Partial<HostRecord> = {}): HostRecord {
  return {
    hostId: 'host-1',
    accountId: 'acct-1',
    subdomain: 'alice',
    agentPubkey: new Uint8Array(32),
    enrollFpr: 'fpr-host-1',
    status: 'online',
    lastSeen: '2026-01-01T00:00:00.000Z',
    createdAt: '2026-01-01T00:00:00.000Z',
    revokedAt: null,
    ...overrides,
  }
}

function fakeHosts(rec: HostRecord | null): SubdomainHostLookup {
  return { getBySubdomain: async () => rec }
}

interface CapturedRes {
  statusCode: number
  headers: Record<string, string>
  body: string
  headersSent: boolean
  writeHead(status: number, headers: Record<string, string>): CapturedRes
  end(chunk?: string): void
}

function fakeRes(): { res: ServerResponse; captured: CapturedRes; done: Promise<void> } {
  let resolveDone!: () => void
  const done = new Promise<void>((r) => (resolveDone = r))
  const captured: CapturedRes = {
    statusCode: 0,
    headers: {},
    body: '',
    headersSent: false,
    writeHead(status, headers) {
      this.statusCode = status
      this.headers = headers
      this.headersSent = true
      return this
    },
    end(chunk?: string) {
      if (chunk !== undefined) this.body += chunk
      resolveDone()
    },
  }
  return { res: captured as unknown as ServerResponse, captured, done }
}

function fakeReq(method: string, url: string, body?: string, remoteAddr = '203.0.113.7'): IncomingMessage {
  const chunks = body === undefined ? [] : [Buffer.from(body, 'utf8')]
  const req = Readable.from(chunks) as unknown as IncomingMessage
  ;(req as { method?: string }).method = method
  ;(req as { url?: string }).url = url
  ;(req as { socket?: { remoteAddress: string } }).socket = { remoteAddress: remoteAddr }
  return req
}

function mkRoute(hosts: SubdomainHostLookup): (req: IncomingMessage, res: ServerResponse) => boolean {
  return createAuthMintRoute({
    signingKey: keys.signingKey,
    hosts,
    operatorPassword: PASSWORD,
    now: () => NOW,
    onError: () => {},
  })
}

async function post(
  hosts: SubdomainHostLookup,
  bodyObj: unknown,
): Promise<CapturedRes> {
  const route = mkRoute(hosts)
  const { res, captured, done } = fakeRes()
  const claimed = route(fakeReq('POST', '/auth/mint', JSON.stringify(bodyObj)), res)
  expect(claimed).toBe(true)
  await done
  return captured
}

describe('createAuthMintRoute — happy path', () => {
  it('mints a short-lived capability token bound to the client jkt (INV3 identity from store)', async () => {
    const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'alice' })

    expect(captured.statusCode).toBe(200)
    expect(captured.headers['cache-control']).toBe('no-store')
    const parsedBody = JSON.parse(captured.body) as { token: string }
    expect(typeof parsedBody.token).toBe('string')

    const claims = (await verifyPaseto(parsedBody.token, keys.publicKey)) as {
      sub: string
      aud: string
      host: string
      rights: string[]
      iat: number
      exp: number
      cnf: { jkt: string }
    }
    // Identity is the STORE row's, never the request body's (INV3).
    expect(claims.sub).toBe('acct-1')
    expect(claims.host).toBe('host-1')
    expect(claims.aud).toBe('alice')
    expect(claims.rights).toContain('attach')
    // DPoP proof-of-possession binding to the client-provided thumbprint.
    expect(claims.cnf.jkt).toBe(JKT)
    // Short-lived (<= 60 s).
    expect(claims.iat).toBe(NOW)
    expect(claims.exp - claims.iat).toBeLessThanOrEqual(60)
    expect(claims.exp - claims.iat).toBeGreaterThan(0)
    // The token itself must never leak into logs — asserted by construction (no console here).
  })
})

describe('createAuthMintRoute — deny by default', () => {
  it('rejects a wrong password with 401 (no token)', async () => {
    const captured = await post(fakeHosts(mkHost()), { password: 'wrong', jkt: JKT, subdomain: 'alice' })
    expect(captured.statusCode).toBe(401)
    expect(captured.body).not.toContain('token')
  })

  it('rejects an unknown subdomain with 404', async () => {
    const captured = await post(fakeHosts(null), { password: PASSWORD, jkt: JKT, subdomain: 'ghost' })
    expect(captured.statusCode).toBe(404)
  })

  it('rejects a revoked host with 403 (INV12: revoked never mints)', async () => {
    const captured = await post(
      fakeHosts(mkHost({ status: 'revoked', revokedAt: '2026-02-01T00:00:00.000Z' })),
      { password: PASSWORD, jkt: JKT, subdomain: 'alice' },
    )
    expect(captured.statusCode).toBe(403)
  })

  it('rejects a malformed jkt with 400', async () => {
    const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: 'too-short', subdomain: 'alice' })
    expect(captured.statusCode).toBe(400)
  })

  it('rejects a malformed subdomain with 400', async () => {
    const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'Not_Valid!' })
    expect(captured.statusCode).toBe(400)
  })

  it('rejects invalid JSON with 400', async () => {
    const route = mkRoute(fakeHosts(mkHost()))
    const { res, captured, done } = fakeRes()
    const claimed = route(fakeReq('POST', '/auth/mint', '{not json'), res)
    expect(claimed).toBe(true)
    await done
    expect(captured.statusCode).toBe(400)
  })

  it('rejects an oversized body with 413', async () => {
    const big = 'x'.repeat(5000)
    const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'alice', pad: big })
    expect(captured.statusCode).toBe(413)
  })
})

describe('createAuthMintRoute — routing semantics', () => {
  it('claims /auth/mint but 405s a non-POST method', () => {
    const route = mkRoute(fakeHosts(mkHost()))
    const { res, captured } = fakeRes()
    const claimed = route(fakeReq('GET', '/auth/mint', undefined), res)
    expect(claimed).toBe(true)
    expect(captured.statusCode).toBe(405)
  })

  it('does NOT claim a non-matching path (returns false, response untouched)', () => {
    const route = mkRoute(fakeHosts(mkHost()))
    const { res, captured } = fakeRes()
    const claimed = route(fakeReq('POST', '/index.html', undefined), res)
    expect(claimed).toBe(false)
    expect(captured.statusCode).toBe(0)
  })

  it('claims /auth/mint even with a query string', () => {
    const route = mkRoute(fakeHosts(null))
    const { res } = fakeRes()
    const claimed = route(fakeReq('GET', '/auth/mint?foo=1', undefined), res)
    expect(claimed).toBe(true)
  })
})

// ── F1 · per-IP mint throttle ─────────────────────────────────────────────────────────────────────

/** Lazy-refill token bucket mirroring the production Redis Lua — deterministic at a fixed `now`. */
function inMemoryBucket(): TokenBucketStore {
  const state = new Map<string, { tokens: number; ts: number }>()
  return {
    async take(key, refillPerSec, burst, now) {
      const s = state.get(key) ?? { tokens: burst, ts: now }
      const elapsed = now - s.ts
      let tokens = elapsed > 0 ? Math.min(burst, s.tokens + elapsed * refillPerSec) : s.tokens
      const allowed = tokens >= 1
      if (allowed) tokens -= 1
      state.set(key, { tokens, ts: now })
      return allowed
    },
  }
}

function mkRouteRl(
  hosts: SubdomainHostLookup,
  rateLimit: MintRateLimit,
): (req: IncomingMessage, res: ServerResponse) => boolean {
  return createAuthMintRoute({
    signingKey: keys.signingKey,
    hosts,
    operatorPassword: PASSWORD,
    now: () => NOW,
    rateLimit,
    onError: () => {},
  })
}

async function postRl(
  route: (req: IncomingMessage, res: ServerResponse) => boolean,
  bodyObj: unknown,
  remoteAddr: string,
): Promise<CapturedRes> {
  const { res, captured, done } = fakeRes()
  route(fakeReq('POST', '/auth/mint', JSON.stringify(bodyObj), remoteAddr), res)
  await done
  return captured
}

describe('createAuthMintRoute — F1 per-IP throttle', () => {
  const GOOD = { password: PASSWORD, jkt: JKT, subdomain: 'alice' }

  it('admits the burst then returns 429 once the bucket is exhausted (same IP)', async () => {
    const route = mkRouteRl(fakeHosts(mkHost()), { buckets: inMemoryBucket(), salt: 's' })
    const ip = '198.51.100.4'
    for (let i = 0; i < MINT_RATE_BURST; i++) {
      const ok = await postRl(route, GOOD, ip)
      expect(ok.statusCode).toBe(200)
    }
    const throttled = await postRl(route, GOOD, ip)
    expect(throttled.statusCode).toBe(429)
    expect(throttled.body).not.toContain('token')
  })

  it('throttles BEFORE the password compare (exhausted IP + WRONG password → 429, not 401)', async () => {
    const route = mkRouteRl(fakeHosts(mkHost()), { buckets: inMemoryBucket(), salt: 's', burst: 1 })
    const ip = '198.51.100.9'
    expect((await postRl(route, GOOD, ip)).statusCode).toBe(200) // drains the single token
    const wrong = await postRl(route, { ...GOOD, password: 'nope' }, ip)
    expect(wrong.statusCode).toBe(429) // throttle wins over the 401 password check
  })

  it('keeps per-IP buckets independent (a throttled IP does not affect another)', async () => {
    const route = mkRouteRl(fakeHosts(mkHost()), { buckets: inMemoryBucket(), salt: 's', burst: 1 })
    expect((await postRl(route, GOOD, '10.0.0.1')).statusCode).toBe(200)
    expect((await postRl(route, GOOD, '10.0.0.1')).statusCode).toBe(429) // IP-A exhausted
    expect((await postRl(route, GOOD, '10.0.0.2')).statusCode).toBe(200) // IP-B unaffected
  })

  it('keys the bucket on a SALTED HASH, never the raw client IP', async () => {
    const seenKeys: string[] = []
    const spyBucket: TokenBucketStore = {
      async take(key) {
        seenKeys.push(key)
        return true
      },
    }
    const rawIp = '203.0.113.55'
    const route = mkRouteRl(fakeHosts(mkHost()), { buckets: spyBucket, salt: 'pepper' })
    await postRl(route, GOOD, rawIp)
    expect(seenKeys).toHaveLength(1)
    expect(seenKeys[0]).toMatch(/^mint:[0-9a-f]{32}$/)
    expect(seenKeys[0]).not.toContain(rawIp)
  })
})

describe('loadSigningKeyFromEnv', () => {
  async function exportPkcs8Pem(): Promise<{ pem: string; b64: string; publicKey: CryptoKey }> {
    const kp = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as {
      publicKey: CryptoKey
      privateKey: CryptoKey
    }
    const der = new Uint8Array(await subtle.exportKey('pkcs8', kp.privateKey))
    const b64 = Buffer.from(der).toString('base64')
    const pem = `-----BEGIN PRIVATE KEY-----\n${b64.match(/.{1,64}/g)!.join('\n')}\n-----END PRIVATE KEY-----\n`
    return { pem, b64, publicKey: kp.publicKey }
  }

  async function canSign(priv: CryptoKey, pub: CryptoKey): Promise<boolean> {
    const data = new Uint8Array([1, 2, 3, 4])
    const sig = new Uint8Array(await subtle.sign({ name: 'Ed25519' }, priv, data))
    return subtle.verify({ name: 'Ed25519' }, pub, sig, data)
  }

  it('loads a PKCS#8 PEM into a usable signing key', async () => {
    const { pem, publicKey } = await exportPkcs8Pem()
    const key = await loadSigningKeyFromEnv(pem)
    expect(await canSign(key, publicKey)).toBe(true)
  })

  it('loads a bare base64 PKCS#8 DER into a usable signing key', async () => {
    const { b64, publicKey } = await exportPkcs8Pem()
    const key = await loadSigningKeyFromEnv(b64)
    expect(await canSign(key, publicKey)).toBe(true)
  })

  it('throws on an empty value', async () => {
    await expect(loadSigningKeyFromEnv('   ')).rejects.toThrow()
  })

  it('throws on a non-key value (never leaking material)', async () => {
    await expect(loadSigningKeyFromEnv('not-a-real-key')).rejects.toThrow()
  })
})
