import { describe, it, expect } from 'vitest'
import { revoke, revokeToken } from '../src/revocation/revoke.js'
import { isTokenRevoked, killsScope } from '../src/revocation/check.js'
import { REVOCATION_PUSH_BUDGET_MS } from '../src/types.js'
import type { KillSignal, RevocationBus } from '../src/types.js'
import { fakeHostRegistry, fakeRevocationStore, makeHost, uuid } from './_helpers.js'

const NOW = 1_700_000_000

/** A fake bus + a fake P1 subscriber holding an already-open, authorized stream. */
function fakeBusWithSubscriber(stream: { hostAccountId: string; hostId: string; open: boolean }) {
  const published: KillSignal[] = []
  const bus: RevocationBus = {
    publish: async (signal) => {
      published.push(signal)
      // P1 subscriber: inject an RST for every covered stream (force-close).
      if (killsScope(signal, stream.hostAccountId, stream.hostId)) stream.open = false
    },
  }
  return { bus, published }
}

describe('revocation (INV12)', () => {
  it('host revoke publishes a KillSignal that tears down the live tunnel within budget', async () => {
    const hostId = uuid()
    const hosts = fakeHostRegistry([makeHost('acct-A', hostId)])
    const store = fakeRevocationStore()
    const stream = { hostAccountId: 'acct-A', hostId, open: true }
    const { bus, published } = fakeBusWithSubscriber(stream)

    const t0 = Date.now()
    const signal = await revoke({ kind: 'host', hostId }, store, hosts, bus, NOW)
    const elapsedMs = Date.now() - t0

    expect(published).toHaveLength(1)
    expect(signal.scope).toEqual({ kind: 'host', hostId })
    expect(stream.open).toBe(false) // live shell dropped, not left alive
    expect(elapsedMs).toBeLessThan(REVOCATION_PUSH_BUDGET_MS)
  })

  it('account revocation covers only that account, not another tenant', () => {
    const signal: KillSignal = { scope: { kind: 'account', accountId: 'acct-A' }, at: NOW, reason: 'x' }
    expect(killsScope(signal, 'acct-A', uuid())).toBe(true)
    expect(killsScope(signal, 'acct-B', uuid())).toBe(false) // foreign tenant untouched
  })

  it('global revocation covers all hosts (GOAWAY)', () => {
    const signal: KillSignal = { scope: { kind: 'global' }, at: NOW, reason: 'drain' }
    expect(killsScope(signal, 'acct-A', uuid())).toBe(true)
    expect(killsScope(signal, 'acct-B', uuid())).toBe(true)
  })

  it('revoked token jti stops validating; a subsequent reconnect is refused', async () => {
    const store = fakeRevocationStore()
    await revokeToken('jti-1', NOW + 60, store)
    expect(await isTokenRevoked('jti-1', store)).toBe(true)
    expect(await isTokenRevoked('jti-2', store)).toBe(false)
  })

  it('revocation is idempotent (re-publish is a no-op teardown)', async () => {
    const hostId = uuid()
    const hosts = fakeHostRegistry([makeHost('acct-A', hostId)])
    const store = fakeRevocationStore()
    const stream = { hostAccountId: 'acct-A', hostId, open: true }
    const { bus, published } = fakeBusWithSubscriber(stream)
    await revoke({ kind: 'host', hostId }, store, hosts, bus, NOW)
    await revoke({ kind: 'host', hostId }, store, hosts, bus, NOW)
    expect(published).toHaveLength(2)
    expect(stream.open).toBe(false)
  })
})
