/**
 * B1 — shared-store `EnforceDeps` for relay-auth's `onUpgrade` pipeline, backed by the SAME
 * Postgres + Redis as the control-plane (P3). This replaces the Phase-0 in-RAM fakes
 * (`memory-stores.ts`) so the data-plane and the control-plane read one world of truth
 * (restart-safe, INV7).
 *
 * Design:
 *  - hosts / sessions / audit -> Postgres via the CP repository adapter (`createPgStores(query)`).
 *    The CP `HostRecord` IS the relay-auth `HostRecord` (both re-export the frozen relay-contracts
 *    §4.2 shape), so `getById` is a direct pass-through; sessions/audit remap to the port shapes.
 *  - revocation / buckets -> Redis. `consumeOnce` is a single-use `SET NX` burn (Finding-4); the
 *    token bucket is an atomic Lua script (no read-modify-write race).
 *
 * SECURITY:
 *  - INV3: `accountId` is only ever read from authenticated material (the CP registry rows / the
 *    principal), never from client input. This adapter surfaces stored rows; it never fabricates an
 *    accountId from a request field.
 *  - Fail-closed: store/Redis errors are NOT swallowed — they reject and the enforcement pipeline
 *    denies the upgrade (deny-by-default).
 *  - INV9/INV10: audit rows carry metadata only (no keys, no terminal payload).
 */
import { createPgStores } from 'control-plane/src/store/pg.js'
import type { QueryFn } from 'control-plane/src/db/pool.js'
import type {
  AuditEvent,
  EnforceDeps,
  HostRegistryPort,
  RevocationStore,
  SessionRegistryPort,
  TokenBucketStore,
} from 'relay-auth'
import { NO_STEPUP_POLICY } from 'relay-auth/src/human/stepup/stepup.js'

// ── Redis surface (ioredis-compatible, injected loosely so tests can pass a mock) ────────────────

/**
 * The minimal slice of an ioredis client this adapter uses. A real `ioredis` `Redis` instance
 * structurally satisfies this (its methods are supersets), and a unit-test mock can implement it.
 */
export interface RedisLike {
  exists(key: string): Promise<number>
  set(key: string, value: string, mode?: 'NX'): Promise<string | null>
  expireat(key: string, timestamp: number): Promise<number>
  eval(script: string, numKeys: number, ...args: (string | number)[]): Promise<unknown>
}

export interface RelayEnforceDepsConfig {
  readonly query: QueryFn
  readonly redis: RedisLike
}

// ── Redis key namespaces (opaque to relay-auth) ──────────────────────────────────────────────────

const REVOKED_PREFIX = 'revoked:' as const
const USED_PREFIX = 'used:' as const
const BUCKET_PREFIX = 'bucket:' as const

/** Buffer added to the computed refill window before a fully-refilled bucket key may be reaped. */
const BUCKET_TTL_BUFFER_SEC = 1

/**
 * Atomic token bucket (lazy refill). One round-trip, no read-modify-write race:
 *   KEYS[1]=bucket key · ARGV: refillPerSec, burst(capacity), now(epoch sec), ttl(sec).
 * Returns 1 when a token was available (and consumed), 0 when throttled.
 */
const TOKEN_BUCKET_LUA = `
local key   = KEYS[1]
local refill = tonumber(ARGV[1])
local burst  = tonumber(ARGV[2])
local now    = tonumber(ARGV[3])
local ttl    = tonumber(ARGV[4])
local state  = redis.call('HMGET', key, 't', 'ts')
local tokens = tonumber(state[1])
local ts     = tonumber(state[2])
if tokens == nil then
  tokens = burst
  ts = now
end
local elapsed = now - ts
if elapsed > 0 then
  tokens = math.min(burst, tokens + elapsed * refill)
end
local allowed = 0
if tokens >= 1 then
  tokens = tokens - 1
  allowed = 1
end
redis.call('HSET', key, 't', tokens, 'ts', now)
redis.call('EXPIRE', key, ttl)
return allowed
`

/** Seconds a bucket needs to fully refill from empty, plus a small reap buffer. */
function bucketTtlSec(refillPerSec: number, burst: number): number {
  const refillWindow = refillPerSec > 0 ? Math.ceil(burst / refillPerSec) : Math.ceil(burst)
  return Math.max(1, refillWindow) + BUCKET_TTL_BUFFER_SEC
}

// ── Redis-backed ports ───────────────────────────────────────────────────────────────────────────

function redisRevocationStore(redis: RedisLike): RevocationStore {
  return {
    async isRevoked(jti) {
      return (await redis.exists(REVOKED_PREFIX + jti)) > 0
    },
    async revokeJti(jti, exp) {
      await redis.set(REVOKED_PREFIX + jti, '1')
      await redis.expireat(REVOKED_PREFIX + jti, exp)
    },
    async consumeOnce(jti, exp) {
      // First-use wins: SET NX returns 'OK' only when the key did not exist (Finding-4).
      const set = await redis.set(USED_PREFIX + jti, '1', 'NX')
      if (set !== 'OK') return false
      await redis.expireat(USED_PREFIX + jti, exp)
      return true
    },
  }
}

function redisTokenBucketStore(redis: RedisLike): TokenBucketStore {
  return {
    async take(key, refillPerSec, burst, now) {
      const ttl = bucketTtlSec(refillPerSec, burst)
      const allowed = await redis.eval(
        TOKEN_BUCKET_LUA,
        1,
        BUCKET_PREFIX + key,
        refillPerSec,
        burst,
        now,
        ttl,
      )
      return Number(allowed) === 1
    },
  }
}

// ── Postgres-backed ports (over the CP repository adapter) ────────────────────────────────────────

/** Map a relay-auth `AuditEvent` onto the CP `audit_log` row shape (metadata only, INV10). */
function toAuditRow(e: AuditEvent): {
  action: string
  principalId: string
  accountId: string
  hostId: string | null
  ts: string
  meta: Record<string, string>
} {
  const meta: Record<string, string> = {
    outcome: e.outcome,
    reason: e.reason,
    remoteAddrHash: e.remoteAddrHash,
  }
  if (e.sessionId !== null) meta.sessionId = e.sessionId
  if (e.jti !== null) meta.jti = e.jti
  return {
    action: e.action,
    principalId: e.principalId,
    accountId: e.accountId,
    hostId: e.hostId,
    ts: e.ts,
    meta,
  }
}

// ── Assembly ──────────────────────────────────────────────────────────────────────────────────────

/**
 * Build the relay-auth `EnforceDeps` over the shared Postgres (`query`) + Redis (`redis`).
 * `stepUpPolicyFor` returns `NO_STEPUP_POLICY` (staging single-operator; Phase 2 -> per-host WebAuthn).
 */
export function createRelayEnforceDeps({ query, redis }: RelayEnforceDepsConfig): EnforceDeps {
  const stores = createPgStores(query)

  const hosts: HostRegistryPort = {
    // CP HostRecord === relay-auth HostRecord (both = relay-contracts §4.2) — direct pass-through.
    getById: (hostId) => stores.hosts.get(hostId),
  }

  const sessions: SessionRegistryPort = {
    getById: async (sessionId) => {
      const rec = await stores.sessions.get(sessionId)
      return rec === null ? null : { hostId: rec.hostId, accountId: rec.accountId }
    },
  }

  return {
    hosts,
    sessions,
    revocation: redisRevocationStore(redis),
    buckets: redisTokenBucketStore(redis),
    audit: {
      append: (e) => stores.audit.append(toAuditRow(e)),
    },
    stepUpPolicyFor: () => NO_STEPUP_POLICY,
  }
}
