/**
 * Blueprint composition proof — saas-admin (preset: registry.json presets.saas).
 *
 * This is NOT a re-test of each module's surface (audit.test.ts etc. already do that). It proves the
 * ONE thing a blueprint exists to prove: that the preset's modules COMPOSE into a real flow and the
 * SEAM BETWEEN THEM holds. The flow: a tenant-scoped admin suspends a member —
 *
 *   auth (who is calling?) → tenancy (what may they do IN THIS tenant?) → gate → audit + mail + jobs
 *
 * The security property under test is FAIL-CLOSED composition: capabilities are tenant-scoped, so a
 * caller acting on a tenant they don't belong to — or with no session — is rejected at the gate
 * BEFORE any audit row, email, or job is produced. billing is touched (proration-refund intent) to
 * give the cleanup job its at-most-once key.
 */
import { beforeEach, describe, expect, it } from 'vitest'
import {
  InvalidSessionError,
  PermissionDeniedError,
  getSession,
  requirePermission,
  type AuthEngine,
  type Principal,
} from '@platform-modules/auth'
import { listAudit, logAudit } from '@platform-modules/audit'
import { bearer, createFakeAuthEngine } from '../src/blueprints/saas-admin/wiring/auth'
import { seedTenancy, type SeededTenancy } from '../src/blueprints/saas-admin/wiring/tenancy'
import { createAuditDb, type AuditDb } from '../src/blueprints/saas-admin/wiring/audit'
import { createCaptureMail, type CaptureMail } from '../src/blueprints/saas-admin/wiring/mail'
import {
  CLEANUP_JOB,
  createCleanupJobs,
  createMemoryIdempotencyStore,
  type CleanupEnv,
} from '../src/blueprints/saas-admin/wiring/jobs'
import { prorationRefundIntent } from '../src/blueprints/saas-admin/wiring/billing'

type SuspendDeps = {
  authEngine: AuthEngine
  tenancy: SeededTenancy['tenancy']
  auditDb: AuditDb
  mail: CaptureMail['mail']
  jobs: ReturnType<typeof createCleanupJobs>
  jobEnv: CleanupEnv
  idem: ReturnType<typeof createMemoryIdempotencyStore>
}

/**
 * The host-owned glue a real app writes (the blueprint NAMES it; here it lives in the test as the
 * composition under proof). Note the ORDER: the gate runs before any side effect — that ordering is
 * the fail-closed property the cross-tenant / unauthenticated cases assert.
 */
async function runAdminSuspend(
  deps: SuspendDeps,
  input: { callerHeaders: Headers; tenantId: string; targetMemberId: string },
) {
  // 1. AUTH — resolve the caller's identity from the request.
  const base: Principal | null = await getSession(input.callerHeaders, deps.authEngine)
  // 2. TENANCY — scope the caller's capabilities to THIS tenant ([] unless an active member).
  const capabilities = base ? await deps.tenancy.resolveCapabilities(base.userId, input.tenantId) : []
  const principal: Principal | null = base ? { ...base, tenantId: input.tenantId, capabilities } : null
  // 3. GATE — fail-closed: throws InvalidSessionError (no session) | PermissionDeniedError (no cap).
  const authed = requirePermission('member:suspend')(principal)

  // ---- past the gate only ----  (the member-row mutation itself is host domain, not the blueprint's)
  const refund = prorationRefundIntent(input.tenantId, input.targetMemberId, 1250) // billing touch ($12.50 in minor units)
  const logged = await logAudit(deps.auditDb, {
    actorId: authed.userId,
    actorType: 'user',
    action: 'member.suspend',
    entityType: 'member',
    entityId: input.targetMemberId,
    tenantId: input.tenantId,
    metadata: { refundKey: refund.key },
  })
  await deps.mail.send({
    from: 'admin@acme.test',
    to: 'member@acme.test',
    subject: 'Your membership was suspended',
    text: `An administrator suspended your access to tenant ${input.tenantId}.`,
  }) // mail touch
  await deps.jobs.dispatch(
    deps.jobEnv,
    { type: CLEANUP_JOB, payload: { tenantId: input.tenantId, userId: input.targetMemberId } },
    { idempotency: { key: refund.key, store: deps.idem } },
  ) // jobs touch
  return { logged, refund }
}

describe('blueprint: saas-admin — tenant-scoped admin action composes auth → tenancy → audit (+ mail/jobs/billing)', () => {
  let seeded: SeededTenancy
  let deps: SuspendDeps
  let capture: CaptureMail

  beforeEach(async () => {
    seeded = await seedTenancy()
    // The admin's base session carries identity + global role but NO capabilities — capabilities are
    // tenant-scoped and come from tenancy.resolveCapabilities, never from the session itself.
    const adminPrincipal: Principal = {
      userId: seeded.adminUserId,
      sessionId: 's-admin',
      roles: ['admin'],
    }
    capture = createCaptureMail()
    deps = {
      authEngine: createFakeAuthEngine(new Map([['tok-admin', adminPrincipal]])),
      tenancy: seeded.tenancy,
      auditDb: await createAuditDb(),
      mail: capture.mail,
      jobs: createCleanupJobs(),
      jobEnv: { cleaned: [] },
      idem: createMemoryIdempotencyStore(),
    }
  })

  it('permitted admin in their own tenant: gate passes → audit-logged, member notified, cleanup job ran', async () => {
    const res = await runAdminSuspend(deps, {
      callerHeaders: bearer('tok-admin'),
      tenantId: seeded.tenantA.id,
      targetMemberId: seeded.memberUserId,
    })
    expect(res.logged.logged).toBe(true)

    const page = await listAudit(deps.auditDb, { tenant: seeded.tenantA.id })
    expect(page.items).toHaveLength(1)
    expect(page.items[0]?.action).toBe('member.suspend')
    expect(page.items[0]?.actorId).toBe(seeded.adminUserId)
    expect(page.items[0]?.entityId).toBe(seeded.memberUserId)

    expect(capture.sent).toHaveLength(1)
    expect(capture.sent[0]?.subject).toContain('suspended')
    // the job HANDLER actually executed (not merely dispatched) — composition reaches jobs
    expect(deps.jobEnv.cleaned).toEqual([seeded.memberUserId])
  })

  it('cross-tenant: same valid admin acting on a tenant they are NOT a member of → caps empty → PermissionDenied, zero side effects', async () => {
    await expect(
      runAdminSuspend(deps, {
        callerHeaders: bearer('tok-admin'),
        tenantId: seeded.tenantB.id, // admin has no membership here → resolveCapabilities returns []
        targetMemberId: 'victim',
      }),
    ).rejects.toBeInstanceOf(PermissionDeniedError)

    // fail-closed — nothing was logged, mailed, or enqueued because the gate ran first
    const page = await listAudit(deps.auditDb, { tenant: seeded.tenantB.id })
    expect(page.items).toHaveLength(0)
    expect(capture.sent).toHaveLength(0)
    expect(deps.jobEnv.cleaned).toHaveLength(0)
  })

  it('unauthenticated caller: no session token → InvalidSessionError before any module side effect', async () => {
    await expect(
      runAdminSuspend(deps, {
        callerHeaders: new Headers(), // no bearer token
        tenantId: seeded.tenantA.id,
        targetMemberId: seeded.memberUserId,
      }),
    ).rejects.toBeInstanceOf(InvalidSessionError)

    const page = await listAudit(deps.auditDb, { tenant: seeded.tenantA.id })
    expect(page.items).toHaveLength(0)
    expect(capture.sent).toHaveLength(0)
  })
})
