import { sql, and, eq } from 'drizzle-orm'
import { drizzle } from 'drizzle-orm/pglite'
import { PGlite } from '@electric-sql/pglite'
import { describe, expect, it, vi } from 'vitest'
import { createPgliteClient } from '@platform-modules/db/pglite'
import { getSession, UserNotFoundError } from '../index.js'
import {
  createCustomEngine,
  InvalidEngineConfigError,
  countStalePasswordHashes,
  customAuthSchema,
  hashPassword,
  authUsers,
  userSessions,
  serviceTokens,
  lookupUserSessionState,
} from './index.js'
import { issueServiceToken, resolveServiceToken } from './service-token.js'
import { rotateRefreshToken, GRACE_WINDOW_MS } from './refresh.js'
import { hashToken } from '@platform-modules/util/tokens'
import { signSession } from './session.js'
import { CREATE_AUTH_USERS, CREATE_USER_SESSIONS, CREATE_SERVICE_TOKENS } from './test-ddl.js'

const JWT_SECRET = 'engine-test-secret'
const pepper = { currentVersion: 'v1', secrets: { v1: 'pepper-binding' } }

async function setupEngine(rateLimit?: ReturnType<typeof vi.fn>) {
  const db = createPgliteClient({ schema: customAuthSchema })
  await db.execute(CREATE_AUTH_USERS)
  await db.execute(CREATE_USER_SESSIONS)
  await db.execute(CREATE_SERVICE_TOKENS)
  const engine = createCustomEngine({
    db,
    jwtSecrets: [JWT_SECRET],
    pepper,
    rateLimit,
  })
  return { db, engine }
}

describe('createCustomEngine', () => {
  it('verifySession reads roles LIVE from auth_users (not JWT claims)', async () => {
    const { db, engine } = await setupEngine()
    await engine.createUser({ email: 'live@example.com', password: 'pw', roles: ['admin'] })
    const signIn = await engine.signIn({ email: 'live@example.com', password: 'pw' })

    await db
      .update(authUsers)
      .set({ roles: JSON.stringify(['editor']) })
      .where(eq(authUsers.email, 'live@example.com'))

    const principal = await engine.verifySession(signIn.accessToken)
    expect(principal?.roles).toEqual(['editor'])
  })

  it('verifySession returns null for a disabled user', async () => {
    const { db, engine } = await setupEngine()
    await engine.createUser({ email: 'disabled@example.com', password: 'pw', roles: ['admin'] })
    const signIn = await engine.signIn({ email: 'disabled@example.com', password: 'pw' })

    await db
      .update(authUsers)
      .set({ status: 'disabled' })
      .where(eq(authUsers.email, 'disabled@example.com'))

    await expect(engine.verifySession(signIn.accessToken)).resolves.toBeNull()
    const state = await lookupUserSessionState(db, signIn.principal.userId)
    expect(state?.status).toBe('disabled')
  })

  it('signOut deletes only the targeted device session row', async () => {
    const { db, engine } = await setupEngine()
    const { userId } = await engine.createUser({ email: 'a@example.com', password: 'pw' })
    const signInA = await engine.signIn({ email: 'a@example.com', password: 'pw' })

    const refreshB = 'device-b-refresh-token-11111111'
    const sessionB = crypto.randomUUID()
    await db.insert(userSessions).values({
      id: sessionB,
      userId,
      refreshTokenHash: await hashToken(refreshB),
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      status: 'active',
    })

    await engine.signOut(signInA.principal.sessionId)

    const rows = await db.select().from(userSessions)
    const active = rows.filter((r) => r.status === 'active')
    expect(active).toHaveLength(1)
    expect(active[0]?.id).toBe(sessionB)

    await expect(engine.refresh(refreshB)).resolves.not.toBeNull()
  })

  it('sessionVersion bump invalidates every device access token', async () => {
    const { db, engine } = await setupEngine()
    await engine.createUser({ email: 'b@example.com', password: 'pw' })
    const signIn = await engine.signIn({ email: 'b@example.com', password: 'pw' })

    await db
      .update(authUsers)
      .set({ sessionVersion: 1 })
      .where(eq(authUsers.email, 'b@example.com'))

    await expect(engine.verifySession(signIn.accessToken)).resolves.toBeNull()
    await expect(
      getSession(new Headers({ authorization: `Bearer ${signIn.accessToken}` }), engine),
    ).resolves.toBeNull()
  })

  it('fires rate-limit hook on repeated login', async () => {
    const rateLimit = vi.fn(async () => ({ allowed: false }))
    const { engine } = await setupEngine(rateLimit)
    await engine.createUser({ email: 'c@example.com', password: 'pw' })
    await expect(engine.signIn({ email: 'c@example.com', password: 'pw' })).rejects.toMatchObject({
      name: 'RateLimitedError',
    })
    expect(rateLimit).toHaveBeenCalledWith('auth.login', 'c@example.com')
  })

  it('constant-time login: unknown + disabled email run the KDF at the SAME iteration cost as a wrong-password attempt (no enumeration oracle)', async () => {
    const { db, engine } = await setupEngine()
    await engine.createUser({ email: 'real@example.com', password: 'correct-horse', roles: ['admin'] })
    await engine.createUser({ email: 'off@example.com', password: 'pw', roles: ['admin'] })
    await db.update(authUsers).set({ status: 'disabled' }).where(eq(authUsers.email, 'off@example.com'))

    const iterationsOf = (call: unknown[] | undefined): number =>
      (call![0] as { iterations: number }).iterations

    const spy = vi.spyOn(crypto.subtle, 'deriveBits')

    // Reference path: a REAL account with the wrong password — the latency an attacker measures.
    spy.mockClear()
    await expect(engine.signIn({ email: 'real@example.com', password: 'wrong' })).rejects.toMatchObject({
      reason: 'bad_credentials',
    })
    expect(spy).toHaveBeenCalledTimes(1)
    const realIter = iterationsOf(spy.mock.calls[0])

    // Unknown email MUST still run the KDF once, at the SAME iteration cost (else timing leaks existence).
    spy.mockClear()
    await expect(engine.signIn({ email: 'ghost@example.com', password: 'wrong' })).rejects.toMatchObject({
      reason: 'bad_credentials',
    })
    expect(spy).toHaveBeenCalledTimes(1)
    expect(iterationsOf(spy.mock.calls[0])).toBe(realIter)

    // Disabled account — same constant-time guarantee.
    spy.mockClear()
    await expect(engine.signIn({ email: 'off@example.com', password: 'wrong' })).rejects.toMatchObject({
      reason: 'bad_credentials',
    })
    expect(spy).toHaveBeenCalledTimes(1)
    expect(iterationsOf(spy.mock.calls[0])).toBe(realIter)

    spy.mockRestore()
  })

  it('secaudit P2: consumed RT past grace bumps sv and revokes session', async () => {
    const { db, engine } = await setupEngine()
    await engine.createUser({ email: 'd@example.com', password: 'pw' })
    const signIn = await engine.signIn({ email: 'd@example.com', password: 'pw' })
    const oldRt = signIn.refreshToken

    const rotated = await rotateRefreshToken(db, customAuthSchema, oldRt, [JWT_SECRET])
    expect(rotated.kind).toBe('rotated')

    await db.execute(sql`
      UPDATE user_sessions
      SET last_refreshed_at = NOW() - (${GRACE_WINDOW_MS} + 1000) * INTERVAL '1 millisecond'
      WHERE id = ${signIn.principal.sessionId}
    `)

    await expect(engine.refresh(oldRt)).resolves.toBeNull()
    await expect(engine.verifySession(signIn.accessToken)).resolves.toBeNull()

    const at = await signSession(
      {
        sub: signIn.principal.userId,
        sid: signIn.principal.sessionId,
        sv: 0,
      },
      JWT_SECRET,
      900,
    )
    await expect(engine.verifySession(at)).resolves.toBeNull()
  })
})

describe('UserAdminEngine', () => {
  it('setUserRoles on unknown userId throws UserNotFoundError', async () => {
    const { engine } = await setupEngine()
    await expect(
      engine.setUserRoles('00000000-0000-4000-8000-000000000000', ['editor']),
    ).rejects.toBeInstanceOf(UserNotFoundError)
  })

  it('setUserRoles updates roles and bumps session_version', async () => {
    const { db, engine } = await setupEngine()
    const { userId } = await engine.createUser({ email: 'roles@example.com', password: 'pw', roles: ['admin'] })
    const signIn = await engine.signIn({ email: 'roles@example.com', password: 'pw' })

    await engine.setUserRoles(userId, ['editor'])

    const [row] = await db.select().from(authUsers).where(eq(authUsers.id, userId))
    expect(JSON.parse(row!.roles)).toEqual(['editor'])
    expect(row!.sessionVersion).toBeGreaterThan(0)
    await expect(engine.verifySession(signIn.accessToken)).resolves.toBeNull()
  })

  it('disableUser soft-disables, revokes sessions, and fails live resolve', async () => {
    const { db, engine } = await setupEngine()
    const { userId } = await engine.createUser({ email: 'off@example.com', password: 'pw', roles: ['editor'] })
    const signIn = await engine.signIn({ email: 'off@example.com', password: 'pw' })
    const { token: pat } = await issueServiceToken(db, customAuthSchema, { userId, label: 'pat' })

    await engine.disableUser(userId)

    const [user] = await db.select().from(authUsers).where(eq(authUsers.id, userId))
    expect(user!.status).toBe('disabled')

    const sessions = await db.select().from(userSessions).where(eq(userSessions.userId, userId))
    expect(sessions.every((s) => s.status === 'revoked')).toBe(true)
    await expect(engine.verifySession(signIn.accessToken)).resolves.toBeNull()

    // Disabled user loses EVERY credential class — PATs revoked (belt) AND fail-closed on status (suspenders).
    const tokens = await db.select().from(serviceTokens).where(eq(serviceTokens.userId, userId))
    expect(tokens.every((t) => t.status === 'revoked')).toBe(true)
    await expect(resolveServiceToken(db, customAuthSchema, pat)).resolves.toBeNull()
  })

  it('listUsers omits password_hash and paginates', async () => {
    const { engine } = await setupEngine()
    for (let i = 0; i < 3; i++) {
      await engine.createUser({ email: `u${i}@example.com`, password: 'pw', roles: ['editor'] })
    }

    const page = await engine.listUsers({ limit: 2, offset: 0 })
    expect(page.users).toHaveLength(2)
    expect(page.total).toBe(3)
    for (const user of page.users) {
      expect(user).not.toHaveProperty('passwordHash')
      expect(user).not.toHaveProperty('password_hash')
      expect(user.status).toBe('active')
      expect(user.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/)
    }
  })

  it('allows empty roles array in setUserRoles', async () => {
    const { db, engine } = await setupEngine()
    const { userId } = await engine.createUser({ email: 'empty@example.com', password: 'pw', roles: ['editor'] })
    await engine.setUserRoles(userId, [])
    const [row] = await db.select().from(authUsers).where(eq(authUsers.id, userId))
    expect(JSON.parse(row!.roles)).toEqual([])
  })
})

describe('pepper rotation', () => {
  async function setupRotationDb() {
    const db = createPgliteClient({ schema: customAuthSchema })
    await db.execute(CREATE_AUTH_USERS)
    await db.execute(CREATE_USER_SESSIONS)
    await db.execute(CREATE_SERVICE_TOKENS)
    return db
  }

  it('lazy pepper upgrade: signIn re-hashes stale hash to currentVersion, enabling safe secret removal', async () => {
    const db = await setupRotationDb()
    const pepperV1 = { currentVersion: 'v1', secrets: { v1: 'pv1', v2: 'pv2' } }
    const engineV1 = createCustomEngine({ db, jwtSecrets: [JWT_SECRET], pepper: pepperV1 })
    const { userId } = await engineV1.createUser({ email: 'upgrade@example.com', password: 'hunter2' })

    const [before] = await db.select({ h: authUsers.passwordHash }).from(authUsers).where(eq(authUsers.id, userId))
    expect(before!.h).toContain('$v1$')

    // Sign in with v2 as currentVersion (both secrets present so verify still works).
    const pepperV2 = { currentVersion: 'v2', secrets: { v1: 'pv1', v2: 'pv2' } }
    const engineV2 = createCustomEngine({ db, jwtSecrets: [JWT_SECRET], pepper: pepperV2 })
    await engineV2.signIn({ email: 'upgrade@example.com', password: 'hunter2' })

    const [after] = await db.select({ h: authUsers.passwordHash }).from(authUsers).where(eq(authUsers.id, userId))
    expect(after!.h).toContain('$v2$')

    // Key property: once upgraded, login succeeds WITHOUT the v1 secret — safe to drop it.
    const pepperV2Only = { currentVersion: 'v2', secrets: { v2: 'pv2' } }
    const engineV2Only = createCustomEngine({ db, jwtSecrets: [JWT_SECRET], pepper: pepperV2Only })
    await expect(engineV2Only.signIn({ email: 'upgrade@example.com', password: 'hunter2' })).resolves.toBeDefined()
  })

  it('lazy pepper upgrade CAS: WHERE password_hash = <read-value> is a no-op when hash changed (concurrent setPassword wins)', async () => {
    const db = await setupRotationDb()
    const pepperV1 = { currentVersion: 'v1', secrets: { v1: 'pv1', v2: 'pv2' } }
    const engineV1 = createCustomEngine({ db, jwtSecrets: [JWT_SECRET], pepper: pepperV1 })
    await engineV1.createUser({ email: 'cas@example.com', password: 'old-pw' })

    const [userRow] = await db
      .select({ id: authUsers.id, h: authUsers.passwordHash })
      .from(authUsers)
      .where(eq(authUsers.email, 'cas@example.com'))
    const userId = userRow!.id
    const v1Hash = userRow!.h

    // Simulate concurrent setPassword racing ahead of the lazy upgrade write.
    const pepperV2 = { currentVersion: 'v2', secrets: { v1: 'pv1', v2: 'pv2' } }
    const engineV2 = createCustomEngine({ db, jwtSecrets: [JWT_SECRET], pepper: pepperV2 })
    await engineV2.setPassword(userId, 'new-pw')
    const [resetRow] = await db
      .select({ h: authUsers.passwordHash })
      .from(authUsers)
      .where(eq(authUsers.id, userId))
    const resetHash = resetRow!.h

    // Now replicate what signIn's lazy upgrade tries: UPDATE … WHERE id=X AND password_hash=v1Hash.
    // The WHERE must NOT match (v1Hash ≠ resetHash) — proving the CAS prevents the clobber.
    const staleUpgradedHash = await hashPassword('old-pw', pepperV2)
    await db
      .update(authUsers)
      .set({ passwordHash: staleUpgradedHash })
      .where(and(eq(authUsers.id, userId), eq(authUsers.passwordHash, v1Hash!)))

    const [finalRow] = await db
      .select({ h: authUsers.passwordHash })
      .from(authUsers)
      .where(eq(authUsers.id, userId))
    const finalHash = finalRow!.h

    // DB must still hold the admin's reset hash, not the stale lazy upgrade.
    expect(finalHash).toBe(resetHash)
    expect(finalHash).not.toBe(staleUpgradedHash)

    // Admin's new password must still work; old password must not.
    await expect(engineV2.signIn({ email: 'cas@example.com', password: 'new-pw' })).resolves.toBeDefined()
    await expect(engineV2.signIn({ email: 'cas@example.com', password: 'old-pw' })).rejects.toMatchObject({ reason: 'bad_credentials' })
  })

  it('lazy pepper upgrade CAS: signIn call path does not clobber a concurrent setPassword (Proxy intercept)', async () => {
    // This test proves the CAS predicate (eq(passwordHash, readValue)) inside signIn's lazy-upgrade
    // UPDATE is load-bearing. The SQL-level test above does not call signIn — deleting the CAS line
    // from signIn would leave that test green. This test routes the race through the actual signIn
    // call path so the suite fails if the CAS predicate is removed.
    //
    // Mechanism: a JS Proxy wraps the raw PGlite instance and intercepts the lazy-upgrade UPDATE
    // (discriminated by password_hash in SQL + 3 params: [upgradedHash, id, originalHash]).
    // Before forwarding that UPDATE, setPassword is called on the underlying DB — simulating a
    // concurrent password reset. The CAS WHERE clause then makes the UPDATE a no-op.
    const underlying = new PGlite()
    const realDb = drizzle(underlying, { schema: customAuthSchema })

    await realDb.execute(CREATE_AUTH_USERS)
    await realDb.execute(CREATE_USER_SESSIONS)
    await realDb.execute(CREATE_SERVICE_TOKENS)

    const pepperV1 = { currentVersion: 'v1', secrets: { v1: 'pv1', v2: 'pv2' } }
    const pepperV2 = { currentVersion: 'v2', secrets: { v1: 'pv1', v2: 'pv2' } }

    const { userId } = await createCustomEngine({ db: realDb, jwtSecrets: [JWT_SECRET], pepper: pepperV1 }).createUser({
      email: 'cas-e2e@example.com',
      password: 'old-pw',
    })

    // Direct engine uses realDb (un-proxied) — setPassword called from intercept goes here, not back through proxy.
    const directEngine = createCustomEngine({ db: realDb, jwtSecrets: [JWT_SECRET], pepper: pepperV2 })

    let intercepted = false

    // Proxy the raw PGlite client: intercept the CAS lazy-upgrade UPDATE, race setPassword first.
    // Discriminator: password_hash in SQL text + 3 params = [upgradedHash, id, originalHash].
    // setPassword UPDATE has 2 params [newHash, id] and won't re-trigger (directEngine → realDb, not proxy).
    const proxied = new Proxy(underlying, {
      get(target, prop, receiver) {
        if (prop !== 'query') return Reflect.get(target, prop, receiver)
        return async (queryString: string, params: unknown[], options?: unknown) => {
          if (!intercepted && /password_hash/i.test(queryString) && Array.isArray(params) && params.length === 3) {
            intercepted = true
            await directEngine.setPassword(userId, 'new-pw')
          }
          return (target.query as (q: string, p: unknown[], o?: unknown) => Promise<unknown>).call(
            target,
            queryString,
            params,
            options,
          )
        }
      },
    })

    const proxiedDb = drizzle(proxied, { schema: customAuthSchema })
    const engine = createCustomEngine({ db: proxiedDb, jwtSecrets: [JWT_SECRET], pepper: pepperV2 })

    // signIn: SELECT user → verify old-pw OK → shouldUpgrade=true → hashPassword → UPDATE intercepted
    // → setPassword races in first → CAS WHERE (originalHash ≠ newHash) → no-op → signIn succeeds
    await engine.signIn({ email: 'cas-e2e@example.com', password: 'old-pw' })

    // Proxy must have fired — otherwise the test is not exercising the guard at all.
    expect(intercepted).toBe(true)

    // new-pw wins: the reset hash must survive in the DB, not the stale lazy-upgrade hash.
    await expect(directEngine.signIn({ email: 'cas-e2e@example.com', password: 'new-pw' })).resolves.toBeDefined()
    await expect(directEngine.signIn({ email: 'cas-e2e@example.com', password: 'old-pw' })).rejects.toMatchObject({
      reason: 'bad_credentials',
    })
  })

  it('countStalePasswordHashes: returns 0 only when all hashes are on currentVersion', async () => {
    const db = await setupRotationDb()
    const pepperV1 = { currentVersion: 'v1', secrets: { v1: 'pv1', v2: 'pv2' } }
    const engineV1 = createCustomEngine({ db, jwtSecrets: [JWT_SECRET], pepper: pepperV1 })
    await engineV1.createUser({ email: 'u1@example.com', password: 'pw' })
    await engineV1.createUser({ email: 'u2@example.com', password: 'pw' })

    // Both users on v1 — stale count against v2 is 2.
    expect(await countStalePasswordHashes(db, 'v2')).toBe(2)

    // Upgrade one user by signing in with v2 current.
    const pepperV2 = { currentVersion: 'v2', secrets: { v1: 'pv1', v2: 'pv2' } }
    const engineV2 = createCustomEngine({ db, jwtSecrets: [JWT_SECRET], pepper: pepperV2 })
    await engineV2.signIn({ email: 'u1@example.com', password: 'pw' })

    expect(await countStalePasswordHashes(db, 'v2')).toBe(1)

    await engineV2.signIn({ email: 'u2@example.com', password: 'pw' })
    expect(await countStalePasswordHashes(db, 'v2')).toBe(0)
  })

  it('countStalePasswordHashes: rejects a pepper version carrying LIKE metacharacters (no silent under-count)', async () => {
    const db = await setupRotationDb()
    // `_` and `%` are SQL LIKE wildcards — if admitted, the prefix match would under-count stale
    // rows and could prompt a premature secret drop (reopening the timing oracle this guard closes).
    await expect(countStalePasswordHashes(db, 'v_1')).rejects.toThrow(/invalid pepper version/)
    await expect(countStalePasswordHashes(db, 'v%')).rejects.toThrow(/invalid pepper version/)
    // A normal label is accepted.
    await expect(countStalePasswordHashes(db, 'v2')).resolves.toBe(0)
  })
})

describe('credential rotation + fail-closed config (spec amendment 2026-07-02)', () => {
  it('setPassword revokes every session: old refresh AND access tokens die, new password signs in', async () => {
    const { engine } = await setupEngine()
    const { userId } = await engine.createUser({ email: 'rot@example.com', password: 'old-pw' })
    const signIn = await engine.signIn({ email: 'rot@example.com', password: 'old-pw' })

    await engine.setPassword(userId, 'new-pw')

    // sv bump alone cannot kill the refresh path — the revoked session row must.
    await expect(engine.refresh(signIn.refreshToken)).resolves.toBeNull()
    await expect(engine.verifySession(signIn.accessToken)).resolves.toBeNull()
    const again = await engine.signIn({ email: 'rot@example.com', password: 'new-pw' })
    expect(again.accessToken).toBeTruthy()
  })

  it('setPassword does NOT revoke service tokens (PAT decoupling)', async () => {
    const { db, engine } = await setupEngine()
    const { userId } = await engine.createUser({ email: 'pat@example.com', password: 'pw' })
    const { token } = await issueServiceToken(db, { authUsers, serviceTokens }, { userId, label: 'ci' })

    await engine.setPassword(userId, 'new-pw')

    await expect(
      resolveServiceToken(db, { authUsers, serviceTokens }, token),
    ).resolves.toMatchObject({ userId })
  })

  it('createCustomEngine fails closed on empty jwtSecrets / empty secret / missing current pepper secret', async () => {
    const db = createPgliteClient({ schema: customAuthSchema })
    expect(() => createCustomEngine({ db, jwtSecrets: [], pepper })).toThrow(
      InvalidEngineConfigError,
    )
    expect(() => createCustomEngine({ db, jwtSecrets: [''], pepper })).toThrow(/jwtSecrets/)
    expect(() =>
      createCustomEngine({
        db,
        jwtSecrets: [JWT_SECRET],
        pepper: { currentVersion: 'v9', secrets: { v1: 'x' } },
      }),
    ).toThrow(/pepper/)
  })

  it('verifySession rejects an access token whose session row has expired', async () => {
    const { db, engine } = await setupEngine()
    await engine.createUser({ email: 'expired@example.com', password: 'pw' })
    const signIn = await engine.signIn({ email: 'expired@example.com', password: 'pw' })

    await db
      .update(userSessions)
      .set({ expiresAt: new Date(Date.now() - 1000) })
      .where(eq(userSessions.id, signIn.principal.sessionId))

    await expect(engine.verifySession(signIn.accessToken)).resolves.toBeNull()
  })

  it('refresh rate-limit subjectKey is a sha256 prefix of the token, never raw token bytes', async () => {
    const rateLimit = vi.fn(async () => ({ allowed: true }))
    const { engine } = await setupEngine(rateLimit)
    await engine.createUser({ email: 'rl2@example.com', password: 'pw' })
    const signIn = await engine.signIn({ email: 'rl2@example.com', password: 'pw' })

    await engine.refresh(signIn.refreshToken)

    const expectedKey = (await hashToken(signIn.refreshToken)).slice(0, 16)
    expect(rateLimit).toHaveBeenCalledWith('auth.refresh', expectedKey)
  })

  it('listUsers pages deterministically — no row repeated or skipped across pages', async () => {
    const { engine } = await setupEngine()
    for (const e of ['p1@example.com', 'p2@example.com', 'p3@example.com']) {
      await engine.createUser({ email: e, password: 'pw' })
    }
    const p1 = await engine.listUsers({ limit: 2, offset: 0 })
    const p2 = await engine.listUsers({ limit: 2, offset: 2 })
    const ids = [...p1.users, ...p2.users].map((u) => u.id)
    expect(new Set(ids).size).toBe(3)
    expect(p1.total).toBe(3)
  })
})
