import { and, count, eq, gt, isNotNull, like, not } from 'drizzle-orm'
import type { Querier, TransactionalDatabase } from '@platform-modules/db'
import { generateOpaqueToken, hashToken } from '@platform-modules/util/tokens'
import type {
  AuthEngine,
  Principal,
  RefreshResult,
  SignInCredentials,
  SignInResult,
  UserAdminEngine,
} from '../index.js'
import { InvalidSessionError, RateLimitedError, UserNotFoundError } from '../index.js'
import { buildVerificationSentinel, hashPassword, SCHEME, shouldUpgradeHash, verifyPassword, type PepperBinding } from './password.js'
import { rotateRefreshToken, SESSION_TTL_MS, bumpSessionVersion } from './refresh.js'
import {
  issueServiceToken,
  listServiceTokensForUser,
  resolveServiceToken,
  revokeServiceToken,
  revokeServiceTokensForUser,
} from './service-token.js'
import { authUsers, customAuthSchema, serviceTokens, userSessions, authUsersCreationSql, userSessionsCreationSql, authUsersStatusMigrationSql, serviceTokensCreationSql } from './schema.js'
import {
  ACCESS_TOKEN_TTL_SECS,
  REFRESH_TOKEN_TTL_SECS,
  signSession,
  verifySession,
} from './session.js'

export type RateLimitScope = 'auth.login' | 'auth.refresh'

export type RateLimitHook = (
  scope: RateLimitScope,
  subjectKey: string,
) => Promise<{ allowed: boolean }>

export type CustomEngineConfig<S extends Record<string, unknown> = typeof customAuthSchema> = {
  db: TransactionalDatabase<S>
  schema?: S
  jwtSecrets: string[]
  pepper: PepperBinding
  accessCookieName?: string
  refreshCookieName?: string
  rateLimit?: RateLimitHook
}

export class InvalidEngineConfigError extends Error {
  override readonly name = 'InvalidEngineConfigError'

  constructor(message: string, readonly field: 'jwtSecrets' | 'pepper') {
    super(message)
  }
}

export type UserSessionState = {
  sessionVersion: number
  roles: string[]
  status: 'active' | 'disabled'
}

function parseRoles(raw: string): string[] {
  try {
    const parsed = JSON.parse(raw) as unknown
    return Array.isArray(parsed) ? (parsed as string[]) : ['user']
  } catch {
    return ['user']
  }
}

function toPrincipal(userId: string, sessionId: string, roles: string[]): Principal {
  return { userId, sessionId, roles }
}

export async function lookupUserSessionState<S extends Record<string, unknown>>(
  db: Querier<S>,
  userId: string,
  usersTable: typeof authUsers = authUsers,
): Promise<UserSessionState | null> {
  const [row] = await db
    .select({
      sessionVersion: usersTable.sessionVersion,
      roles: usersTable.roles,
      status: usersTable.status,
    })
    .from(usersTable)
    .where(eq(usersTable.id, userId))
    .limit(1)
  if (!row) return null
  return {
    sessionVersion: row.sessionVersion,
    roles: parseRoles(row.roles),
    status: row.status === 'disabled' ? 'disabled' : 'active',
  }
}

async function assertUserExists<S extends Record<string, unknown>>(
  db: Querier<S>,
  usersTable: typeof authUsers,
  userId: string,
): Promise<void> {
  const [row] = await db
    .select({ id: usersTable.id })
    .from(usersTable)
    .where(eq(usersTable.id, userId))
    .limit(1)
  if (!row) {
    throw new UserNotFoundError(`user not found: ${userId}`, userId)
  }
}

export function createCustomEngine<S extends Record<string, unknown> = typeof customAuthSchema>(
  config: CustomEngineConfig<S>,
): AuthEngine & UserAdminEngine {
  const tables = {
    authUsers: (config.schema?.authUsers as typeof authUsers | undefined) ?? authUsers,
    userSessions: (config.schema?.userSessions as typeof userSessions | undefined) ?? userSessions,
    serviceTokens: (config.schema?.serviceTokens as typeof serviceTokens | undefined) ?? serviceTokens,
  }

  // Fail-closed construction: an empty HMAC key must never sign, and a missing current-pepper
  // secret would short-circuit the sentinel verify (re-opening the enumeration timing oracle).
  if (config.jwtSecrets.length === 0 || config.jwtSecrets.some((s) => s.length === 0)) {
    throw new InvalidEngineConfigError(
      'createCustomEngine: jwtSecrets must contain at least one non-empty secret',
      'jwtSecrets',
    )
  }
  if (!config.pepper.secrets[config.pepper.currentVersion]) {
    throw new InvalidEngineConfigError(
      `createCustomEngine: pepper secret missing for currentVersion "${config.pepper.currentVersion}"`,
      'pepper',
    )
  }

  const jwtSecrets = config.jwtSecrets

  return {
    async signIn(credentials: SignInCredentials): Promise<SignInResult> {
      if (config.rateLimit) {
        const rl = await config.rateLimit('auth.login', credentials.email.toLowerCase())
        if (!rl.allowed) {
          const err = new RateLimitedError('rate limited', 'auth.login') as RateLimitedError
          throw err
        }
      }

      const [user] = await config.db
        .select()
        .from(tables.authUsers)
        .where(eq(tables.authUsers.email, credentials.email.toLowerCase()))
        .limit(1)

      if (!user?.passwordHash || user.status === 'disabled') {
        // Constant-time guard: run the full KDF even for unknown/disabled email so response
        // latency is indistinguishable from a wrong-password attempt on a real account.
        // Without this, the fast no-KDF path leaks which emails are registered admins.
        // The sentinel is built from the canonical ITERATIONS, so its cost can never drift from
        // the real-account verify path (iteration-parity is the property that closes the oracle).
        const sentinel = buildVerificationSentinel(config.pepper.currentVersion)
        await verifyPassword(credentials.password, sentinel, config.pepper)
        throw new InvalidSessionError('invalid credentials', 'bad_credentials')
      }

      const ok = await verifyPassword(credentials.password, user.passwordHash, config.pepper)
      if (!ok) throw new InvalidSessionError('invalid credentials', 'bad_credentials')

      // Lazy pepper upgrade: re-hash under currentVersion so old versions can eventually be
      // removed from secrets. CAS on the WHERE clause prevents clobbering a concurrent
      // setPassword (if the hash changed between our read and this write, no-op is correct).
      // Must not deny an authenticated login — DB hiccup is best-effort.
      try {
        if (shouldUpgradeHash(user.passwordHash, config.pepper.currentVersion)) {
          const upgradedHash = await hashPassword(credentials.password, config.pepper)
          await config.db
            .update(tables.authUsers)
            .set({ passwordHash: upgradedHash })
            .where(
              and(
                eq(tables.authUsers.id, user.id),
                eq(tables.authUsers.passwordHash, user.passwordHash),
              ),
            )
        }
      } catch {
        // transient failure benign — auth already succeeded; next login retries the upgrade
      }

      const sessionId = crypto.randomUUID()
      const refreshToken = generateOpaqueToken()
      const refreshTokenHash = await hashToken(refreshToken)
      const expiresAt = new Date(Date.now() + SESSION_TTL_MS)
      const roles = parseRoles(user.roles)

      await config.db.insert(tables.userSessions).values({
        id: sessionId,
        userId: user.id,
        refreshTokenHash,
        expiresAt,
        status: 'active',
      })

      const accessToken = await signSession(
        {
          sub: user.id,
          sid: sessionId,
          sv: user.sessionVersion,
        },
        jwtSecrets[0]!,
        ACCESS_TOKEN_TTL_SECS,
      )

      return {
        principal: toPrincipal(user.id, sessionId, roles),
        accessToken,
        refreshToken,
      }
    },

    async signOut(sessionId: string): Promise<void> {
      await config.db
        .update(tables.userSessions)
        .set({ status: 'revoked' })
        .where(eq(tables.userSessions.id, sessionId))
    },

    async verifySession(token: string): Promise<Principal | null> {
      const claims = await verifySession(token, jwtSecrets)
      if (!claims) return null

      const [row] = await config.db
        .select({
          sessionVersion: tables.authUsers.sessionVersion,
          roles: tables.authUsers.roles,
          userStatus: tables.authUsers.status,
          sessionStatus: tables.userSessions.status,
        })
        .from(tables.userSessions)
        .innerJoin(tables.authUsers, eq(tables.userSessions.userId, tables.authUsers.id))
        .where(
          and(
            eq(tables.userSessions.id, claims.sid),
            eq(tables.authUsers.id, claims.sub),
            gt(tables.userSessions.expiresAt, new Date()),
          ),
        )
        .limit(1)

      if (!row) return null
      if (
        claims.sv < row.sessionVersion ||
        row.userStatus !== 'active' ||
        row.sessionStatus !== 'active'
      ) {
        return null
      }

      return toPrincipal(claims.sub, claims.sid, parseRoles(row.roles))
    },

    async refresh(refreshToken: string): Promise<RefreshResult | null> {
      if (config.rateLimit) {
        // subjectKey = hash prefix, never raw token bytes — the limiter store is not trusted
        // with a partial live credential.
        const rl = await config.rateLimit('auth.refresh', (await hashToken(refreshToken)).slice(0, 16))
        if (!rl.allowed) {
          const err = new RateLimitedError('rate limited', 'auth.refresh') as RateLimitedError
          throw err
        }
      }

      const result = await rotateRefreshToken(config.db, tables, refreshToken, jwtSecrets)

      if (result.kind === 'invalid') return null

      if (result.kind === 'reuse_detected') {
        return null
      }

      if (result.kind === 'grace' || result.kind === 'race_lost') {
        return {
          principal: toPrincipal(result.user.userId, result.user.sessionId, result.user.roles),
          accessToken: result.accessToken,
        }
      }

      return {
        principal: toPrincipal(result.user.userId, result.user.sessionId, result.user.roles),
        accessToken: result.accessToken,
        refreshToken: result.refreshToken,
      }
    },

    async createUser(input) {
      const userId = crypto.randomUUID()
      const passwordHash = await hashPassword(input.password, config.pepper)
      const roles = input.roles ?? ['user']
      await config.db.insert(tables.authUsers).values({
        id: userId,
        email: input.email.toLowerCase(),
        passwordHash,
        sessionVersion: 0,
        roles: JSON.stringify(roles),
        status: 'active',
        createdAt: new Date(),
      })
      return { userId }
    },

    async setPassword(userId: string, password: string): Promise<void> {
      const passwordHash = await hashPassword(password, config.pepper)
      await config.db.transaction(async (tx) => {
        await tx
          .update(tables.authUsers)
          .set({ passwordHash })
          .where(eq(tables.authUsers.id, userId))
        await tx
          .update(tables.userSessions)
          .set({ status: 'revoked' })
          .where(eq(tables.userSessions.userId, userId))
        await bumpSessionVersion(tx, tables.authUsers, userId)
      })
    },

    async verifyPassword(password: string, storedHash: string): Promise<boolean> {
      return verifyPassword(password, storedHash, config.pepper)
    },

    async listUsers(opts) {
      const limit = Math.min(Math.max(opts?.limit ?? 50, 1), 200)
      const offset = Math.max(opts?.offset ?? 0, 0)

      const [totalRow] = await config.db.select({ total: count() }).from(tables.authUsers)
      const rows = await config.db
        .select({
          id: tables.authUsers.id,
          email: tables.authUsers.email,
          roles: tables.authUsers.roles,
          status: tables.authUsers.status,
          createdAt: tables.authUsers.createdAt,
        })
        .from(tables.authUsers)
        .orderBy(tables.authUsers.createdAt, tables.authUsers.id)
        .limit(limit)
        .offset(offset)

      return {
        users: rows.map((row) => ({
          id: row.id,
          email: row.email,
          roles: parseRoles(row.roles),
          status: row.status === 'disabled' ? ('disabled' as const) : ('active' as const),
          createdAt: row.createdAt.toISOString(),
        })),
        total: Number(totalRow?.total ?? 0),
      }
    },

    async setUserRoles(userId: string, roles: string[]): Promise<void> {
      await assertUserExists(config.db, tables.authUsers, userId)
      await config.db
        .update(tables.authUsers)
        .set({ roles: JSON.stringify(roles) })
        .where(eq(tables.authUsers.id, userId))
      await bumpSessionVersion(config.db, tables.authUsers, userId)
    },

    async disableUser(userId: string): Promise<void> {
      await assertUserExists(config.db, tables.authUsers, userId)
      await config.db
        .update(tables.authUsers)
        .set({ status: 'disabled' })
        .where(eq(tables.authUsers.id, userId))
      await config.db
        .update(tables.userSessions)
        .set({ status: 'revoked' })
        .where(eq(tables.userSessions.userId, userId))
      // A disabled user MUST lose EVERY credential class — PATs too, not just session cookies.
      await revokeServiceTokensForUser(config.db, tables.serviceTokens, userId)
      await bumpSessionVersion(config.db, tables.authUsers, userId)
    },
  }
}

/**
 * Returns the count of password hashes NOT on the current pepper version.
 * Zero means every active hash has been lazily upgraded — safe to drop the old version
 * from `PepperBinding.secrets` without opening a timing gap. See `shouldUpgradeHash` runbook.
 */
export async function countStalePasswordHashes<S extends Record<string, unknown>>(
  db: Querier<S>,
  currentPepperVersion: string,
  usersTable: typeof authUsers = authUsers,
): Promise<number> {
  // Pepper version must be a safe label (alphanumeric + hyphen/dot) — no LIKE metacharacters.
  // NB: `\w` would admit `_`, which is itself a SQL LIKE single-char wildcard — exclude it explicitly.
  if (!/^[a-zA-Z0-9.-]+$/.test(currentPepperVersion)) throw new Error(`invalid pepper version: ${currentPepperVersion}`)
  const prefix = `${SCHEME}$${currentPepperVersion}$`
  // Note: rows where storedIter > ITERATIONS (high-cost imports) will never be auto-upgraded by
  // signIn's lazy path (shouldUpgradeHash guards that). countStalePasswordHashes counts them as
  // stale — a deliberate conservative choice. Reach zero by manually re-hashing such rows.
  const [row] = await db
    .select({ n: count() })
    .from(usersTable)
    .where(and(isNotNull(usersTable.passwordHash), not(like(usersTable.passwordHash, `${prefix}%`))))
  return Number(row?.n ?? 0)
}

export {
  authUsers,
  userSessions,
  serviceTokens,
  customAuthSchema,
  authUsersCreationSql,
  userSessionsCreationSql,
  authUsersStatusMigrationSql,
  serviceTokensCreationSql,
  hashPassword,
  verifyPassword,
  signSession,
  verifySession as verifyAccessToken,
  rotateRefreshToken,
  bumpSessionVersion,
  issueServiceToken,
  listServiceTokensForUser,
  resolveServiceToken,
  revokeServiceToken,
  revokeServiceTokensForUser,
  ACCESS_TOKEN_TTL_SECS,
  REFRESH_TOKEN_TTL_SECS,
}
export type { PepperBinding } from './password.js'
export { shouldUpgradeHash } from './password.js'
export type { AccessClaims } from './session.js'
