import { describe, it, expect, beforeEach } from 'vitest'
import { issueCapabilityToken } from '../src/capability/issue.js'
import {
  verifyCapabilityToken,
  verifyDpopProof,
  buildDpopProof,
  readCnfJkt,
  subAccountId,
  hasRight,
  resetDpopCacheForTest,
} from '../src/capability/verify.js'
import { jwkThumbprint } from '../src/crypto/thumbprint.js'
import { CapabilityError } from '../src/capability/errors.js'
import { encodeBase64UrlBytes } from 'relay-contracts'
import { setupP5SigningKey, makeEphemeral, principal, uuid } from './_helpers.js'

const NOW = 1_700_000_000
const AUD = 'alice.term.example.com'

async function issueFor(
  signingKey: CryptoKey,
  opts: { accountId?: string; host?: string; rights?: readonly ('attach' | 'manage' | 'kill')[]; ttl?: number; cnfJkt?: string },
) {
  const cnfJkt = opts.cnfJkt ?? (await jwkThumbprint((await makeEphemeral()).publicRaw))
  return issueCapabilityToken(
    {
      principal: principal(opts.accountId ?? 'acct-A'),
      aud: AUD,
      host: opts.host ?? uuid(),
      rights: opts.rights ?? ['attach'],
      ttlSeconds: opts.ttl ?? 45,
      cnfJkt,
    },
    signingKey,
    NOW,
  )
}

describe('capability token (§4.3)', () => {
  let signingKey: CryptoKey
  beforeEach(async () => {
    ;({ signingKey } = await setupP5SigningKey())
    resetDpopCacheForTest()
  })

  it('round-trips issue → verify preserving host/rights/jti and sub===accountId', async () => {
    const host = uuid()
    const raw = await issueFor(signingKey, { accountId: 'acct-A', host, rights: ['attach', 'manage'] })
    const tok = await verifyCapabilityToken(raw, AUD, NOW + 1)
    expect(tok.host).toBe(host)
    expect(tok.rights).toEqual(['attach', 'manage'])
    expect(tok.jti.length).toBeGreaterThan(0)
    expect(subAccountId(tok)).toBe('acct-A')
  })

  it('sets sub to principal.accountId, never a client value', async () => {
    const raw = await issueFor(signingKey, { accountId: 'acct-A' })
    const tok = await verifyCapabilityToken(raw, AUD, NOW)
    expect(tok.sub).toBe('acct-A')
  })

  it('issues and verifies the new enroll right — issue.ts consumes it via CapabilityRightSchema (FIX C-native-1)', async () => {
    const cnfJkt = await jwkThumbprint((await makeEphemeral()).publicRaw)
    const raw = await issueCapabilityToken(
      {
        principal: principal('acct-A'),
        aud: AUD,
        host: uuid(),
        rights: ['enroll'],
        ttlSeconds: 45,
        cnfJkt,
      },
      signingKey,
      NOW,
    )
    const tok = await verifyCapabilityToken(raw, AUD, NOW + 1)
    expect(tok.rights).toEqual(['enroll'])
    expect(hasRight(tok, 'enroll')).toBe(true)
  })

  it('rejects an expired token', async () => {
    const raw = await issueFor(signingKey, { ttl: 30 })
    await expect(verifyCapabilityToken(raw, AUD, NOW + 31)).rejects.toMatchObject({ reason: 'expired' })
  })

  it('rejects a not-yet-valid token (iat in the future beyond skew)', async () => {
    const raw = await issueFor(signingKey, {})
    await expect(verifyCapabilityToken(raw, AUD, NOW - 100)).rejects.toMatchObject({
      reason: 'not_yet_valid',
    })
  })

  it('refuses an over-long TTL at issue (no long-lived reusable token)', async () => {
    await expect(issueFor(signingKey, { ttl: 61 })).rejects.toMatchObject({ reason: 'ttl_too_long' })
  })

  it('clamps a too-short TTL up to the 30s floor', async () => {
    const raw = await issueFor(signingKey, { ttl: 5 })
    const tok = await verifyCapabilityToken(raw, AUD, NOW)
    expect(tok.exp - tok.iat).toBe(30)
  })

  it('rejects a wildcard host at issue', async () => {
    await expect(issueFor(signingKey, { host: '*' })).rejects.toMatchObject({ reason: 'wildcard_host' })
  })

  it('rejects wrong aud (Host-confusion, INV1)', async () => {
    const raw = await issueFor(signingKey, {})
    await expect(verifyCapabilityToken(raw, 'bob.term.example.com', NOW)).rejects.toMatchObject({
      reason: 'aud_mismatch',
    })
  })

  it('rejects a tampered payload (signature fails)', async () => {
    const raw = await issueFor(signingKey, {})
    const tampered = raw.slice(0, -4) + (raw.endsWith('AAAA') ? 'BBBB' : 'AAAA')
    await expect(verifyCapabilityToken(tampered, AUD, NOW)).rejects.toBeInstanceOf(CapabilityError)
  })

  it('rejects a token signed by a different key', async () => {
    const other = await setupP5SigningKey() // reconfigures verify key to a DIFFERENT pair
    const raw = await issueFor(other.signingKey, {})
    // reconfigure back to the original key so the verifier uses the wrong public key
    await setupP5SigningKey()
    await expect(verifyCapabilityToken(raw, AUD, NOW)).rejects.toMatchObject({ reason: 'bad_signature' })
  })

  it('enforces least-privilege rights (INV15)', async () => {
    const raw = await issueFor(signingKey, { rights: ['attach'] })
    const tok = await verifyCapabilityToken(raw, AUD, NOW)
    expect(hasRight(tok, 'attach')).toBe(true)
    expect(hasRight(tok, 'kill')).toBe(false)
  })

  describe('DPoP proof-of-possession', () => {
    it('accepts a proof from the bound ephemeral key and rejects a different key', async () => {
      const eph = await makeEphemeral()
      const cnfJkt = await jwkThumbprint(eph.publicRaw)
      const raw = await issueFor(signingKey, { cnfJkt })
      const tok = await verifyCapabilityToken(raw, AUD, NOW)
      expect(readCnfJkt(tok)).toBe(cnfJkt)

      const htu = 'https://alice.term.example.com/ws'
      const good = await buildDpopProof(eph.privateKey, eph.publicRaw, {
        htu,
        htm: 'GET',
        jti: uuid(),
        iat: NOW,
      })
      expect(await verifyDpopProof(tok, { proofJws: good, htu, htm: 'GET' }, NOW)).toBe(true)

      const wrong = await makeEphemeral()
      const bad = await buildDpopProof(wrong.privateKey, wrong.publicRaw, {
        htu,
        htm: 'GET',
        jti: uuid(),
        iat: NOW,
      })
      expect(await verifyDpopProof(tok, { proofJws: bad, htu, htm: 'GET' }, NOW)).toBe(false)
    })

    it('rejects a replayed DPoP proof (same jti reused)', async () => {
      const eph = await makeEphemeral()
      const cnfJkt = await jwkThumbprint(eph.publicRaw)
      const raw = await issueFor(signingKey, { cnfJkt })
      const tok = await verifyCapabilityToken(raw, AUD, NOW)
      const htu = 'https://alice.term.example.com/ws'
      const jti = uuid()
      const proof = await buildDpopProof(eph.privateKey, eph.publicRaw, { htu, htm: 'GET', jti, iat: NOW })
      expect(await verifyDpopProof(tok, { proofJws: proof, htu, htm: 'GET' }, NOW)).toBe(true)
      expect(await verifyDpopProof(tok, { proofJws: proof, htu, htm: 'GET' }, NOW)).toBe(false)
    })

    // F4: a proof whose jwk.x decodes to a NON-32-byte blob makes importEd25519PublicRaw throw.
    // verifyDpopProof must be TOTALLY fail-safe and RESOLVE to false, never reject.
    it('resolves false (never throws) when the proof key is not a valid 32-byte Ed25519 key', async () => {
      // A 16-byte "key" — importEd25519PublicRaw will reject this raw length.
      const shortBlob = new Uint8Array(16).fill(7)
      // cnf.jkt is computed over the SAME 16-byte blob so the thumbprint check passes and
      // execution reaches the risky importEd25519PublicRaw call.
      const cnfJkt = await jwkThumbprint(shortBlob)
      const raw = await issueFor(signingKey, { cnfJkt })
      const tok = await verifyCapabilityToken(raw, AUD, NOW)

      const htu = 'https://alice.term.example.com/ws'
      const enc = (o: unknown) => encodeBase64UrlBytes(new TextEncoder().encode(JSON.stringify(o)))
      const h = enc({ typ: 'dpop+ed25519', jwk: { crv: 'Ed25519', kty: 'OKP', x: encodeBase64UrlBytes(shortBlob) } })
      const p = enc({ htu, htm: 'GET', jti: uuid(), iat: NOW })
      const s = encodeBase64UrlBytes(new Uint8Array(64)) // any signature bytes
      const proofJws = `${h}.${p}.${s}`

      const verify = verifyDpopProof(tok, { proofJws, htu, htm: 'GET' }, NOW)
      await expect(verify).resolves.toBe(false)
    })
  })

  it('rejects a malformed cnf.jkt at issue (not a 43-char base64url thumbprint)', async () => {
    await expect(issueFor(signingKey, { cnfJkt: 'short' })).rejects.toMatchObject({ reason: 'bad_cnf' })
  })
})
