import { eq } from 'drizzle-orm'
import type { Querier } from '@platform-modules/db'
import { generateOpaqueToken, hashToken, verifyToken } from '@platform-modules/util/tokens'
import {
  ApiKeyExpiredError,
  ApiKeyLastUsedAtUpdateError,
  ApiKeyOwnerMismatchError,
  ApiKeyRevokedError,
  InvalidApiKeyOwnerError,
  InvalidApiKeyScopesError,
} from './errors.js'
import { apiKeys, apiKeysCreationSql, apiKeysSchema } from './schema.js'
import type {
  IssuedApiKey,
  IssueApiKeyInput,
  VerifiedApiKey,
  VerifyApiKeyOptions,
} from './types.js'

const LAST_USED_THROTTLE_MS = 60 * 60 * 1000
const PREFIX_BYTES = 9
const SECRET_BYTES = 32
const MAX_ISSUE_ATTEMPTS = 5

let dummySecretHash: Promise<string> | null = null

function normalizeOwner(owner: string): string {
  if (typeof owner !== 'string' || owner.trim().length === 0) {
    throw new InvalidApiKeyOwnerError(owner)
  }

  return owner
}

function normalizeScopes(scopes: string[]): string[] {
  if (
    !Array.isArray(scopes) ||
    scopes.some((scope) => typeof scope !== 'string' || scope.trim().length === 0)
  ) {
    throw new InvalidApiKeyScopesError()
  }

  return [...scopes]
}

function parsePresentedKey(presented: string): { prefix: string; secret: string } | null {
  if (typeof presented !== 'string') return null

  const dot = presented.indexOf('.')
  if (dot <= 0 || dot !== presented.lastIndexOf('.') || dot === presented.length - 1) {
    return null
  }

  return {
    prefix: presented.slice(0, dot),
    secret: presented.slice(dot + 1),
  }
}

function parseScopes(raw: string): string[] | null {
  try {
    const parsed = JSON.parse(raw) as unknown
    if (!Array.isArray(parsed) || parsed.some((scope) => typeof scope !== 'string')) {
      return null
    }
    return [...parsed]
  } catch {
    return null
  }
}

function getDummySecretHash(): Promise<string> {
  dummySecretHash ??= hashToken('api-key-dummy-secret')
  return dummySecretHash
}

function isUniqueConflict(error: unknown): boolean {
  if (typeof error !== 'object' || error === null) return false
  const code = (error as { code?: unknown }).code
  if (code === '23505') return true
  const message = (error as { message?: unknown }).message
  return typeof message === 'string' && /unique|duplicate/i.test(message)
}

export async function issueApiKey<S extends Record<string, unknown>>(
  db: Querier<S>,
  input: IssueApiKeyInput,
): Promise<IssuedApiKey> {
  const owner = normalizeOwner(input.owner)
  const issuedByOwner = normalizeOwner(input.issuedBy.owner)
  if (owner !== issuedByOwner) {
    throw new ApiKeyOwnerMismatchError(owner, issuedByOwner)
  }
  const scopes = normalizeScopes(input.scopes)
  const expiresAt = input.expiresAt ?? null

  for (let attempt = 0; attempt < MAX_ISSUE_ATTEMPTS; attempt++) {
    const id = crypto.randomUUID()
    const prefix = generateOpaqueToken(PREFIX_BYTES)
    const secret = generateOpaqueToken(SECRET_BYTES)
    const secretHash = await hashToken(secret)
    const now = new Date()

    try {
      await db.insert(apiKeys).values({
        id,
        prefix,
        secretHash,
        owner,
        scopes: JSON.stringify(scopes),
        createdAt: now,
        lastUsedAt: null,
        revokedAt: null,
        expiresAt,
      })

      return {
        id,
        key: `${prefix}.${secret}`,
      }
    } catch (error) {
      if (!isUniqueConflict(error) || attempt === MAX_ISSUE_ATTEMPTS - 1) {
        throw error
      }
    }
  }

  throw new Error('issueApiKey: exhausted prefix generation attempts')
}

export async function verifyApiKey<S extends Record<string, unknown>>(
  db: Querier<S>,
  presented: string,
  options: VerifyApiKeyOptions = {},
): Promise<VerifiedApiKey | null> {
  const parsed = parsePresentedKey(presented)
  if (!parsed) return null

  const [row] = await db
    .select({
      id: apiKeys.id,
      owner: apiKeys.owner,
      scopes: apiKeys.scopes,
      secretHash: apiKeys.secretHash,
      lastUsedAt: apiKeys.lastUsedAt,
      revokedAt: apiKeys.revokedAt,
      expiresAt: apiKeys.expiresAt,
    })
    .from(apiKeys)
    .where(eq(apiKeys.prefix, parsed.prefix))
    .limit(1)

  const secretMatches = await verifyToken(parsed.secret, row?.secretHash ?? (await getDummySecretHash()))
  if (!row || !secretMatches) {
    return null
  }

  const now = options.now ?? new Date()
  if (row.revokedAt) {
    throw new ApiKeyRevokedError(row.id)
  }
  if (row.expiresAt && new Date(row.expiresAt).getTime() <= now.getTime()) {
    throw new ApiKeyExpiredError(row.id)
  }

  const scopes = parseScopes(row.scopes)
  if (!scopes) return null

  const lastUsedAt = row.lastUsedAt ? new Date(row.lastUsedAt).getTime() : 0
  if (now.getTime() - lastUsedAt >= LAST_USED_THROTTLE_MS) {
    try {
      await db
        .update(apiKeys)
        .set({ lastUsedAt: now })
        .where(eq(apiKeys.id, row.id))
    } catch (error) {
      options.onLastUsedAtUpdateError?.(new ApiKeyLastUsedAtUpdateError(row.id, error))
    }
  }

  return {
    id: row.id,
    owner: row.owner,
    scopes,
  }
}

export {
  ApiKeyExpiredError,
  ApiKeyLastUsedAtUpdateError,
  ApiKeyOwnerMismatchError,
  ApiKeyRevokedError,
  InvalidApiKeyOwnerError,
  InvalidApiKeyScopesError,
  apiKeys,
  apiKeysCreationSql,
  apiKeysSchema,
}
export type { ApiKeyError } from './errors.js'
export {
  isApiKeyExpiredError,
  isApiKeyError,
  isApiKeyLastUsedAtUpdateError,
  isApiKeyOwnerMismatchError,
  isApiKeyRevokedError,
  isInvalidApiKeyOwnerError,
  isInvalidApiKeyScopesError,
} from './errors.js'
export type { ApiKeysSchema } from './schema.js'
export type { IssuedApiKey, IssueApiKeyInput, VerifiedApiKey, VerifyApiKeyOptions } from './types.js'
