import { and, eq, isNull } from 'drizzle-orm'
import type { Querier, TransactionalDatabase } from '@platform-modules/db'
import { generateOpaqueToken, hashToken, verifyToken } from '@platform-modules/util/tokens'
import {
  InvalidOAuthClientNameError,
  InvalidOAuthClientSecretError,
  InvalidOAuthCodeChallengeError,
  InvalidOAuthCodeVerifierError,
  InvalidOAuthRedirectUrisError,
  InvalidOAuthScopeError,
  InvalidOAuthScopesError,
  OAuthClientNotFoundError,
  OAuthCodeAlreadyUsedError,
  OAuthCodeClientMismatchError,
  OAuthCodeExpiredError,
  OAuthCodeNotFoundError,
  OAuthCodePrincipalMissingError,
  OAuthRedirectUriMismatchError,
  MissingOAuthClientSecretError,
  UnsupportedCodeChallengeMethodError,
} from './errors.js'
import { verifyCodeVerifierS256 } from './pkce.js'
import { oauthClients, oauthCodes, oauthProviderCreationSql, oauthProviderMigrationSql, oauthProviderSchema, oauthTokens } from './schema.js'
import type {
  AuthorizeInput,
  ExchangeTokenInput,
  OAuthClient,
  OAuthAuthorizationPrincipal,
  OAuthTokenExchangeResult,
  RegisterOAuthClientInput,
  RegisterOAuthClientResult,
} from './types.js'

const ACCESS_TOKEN_TTL_SECS = 3600
const AUTHORIZATION_CODE_TTL_MS = 5 * 60 * 1000
const ACCESS_TOKEN_PREFIX_BYTES = 9
const ACCESS_TOKEN_SECRET_BYTES = 32
const CLIENT_SECRET_BYTES = 32

function normalizeName(name: string): string {
  if (typeof name !== 'string' || name.trim().length === 0) {
    throw new InvalidOAuthClientNameError()
  }

  return name.trim()
}

function isAbsoluteUrl(value: string): boolean {
  try {
    new URL(value)
    return true
  } catch {
    return false
  }
}

function normalizeRedirectUris(redirectUris: string[]): string[] {
  if (
    !Array.isArray(redirectUris) ||
    redirectUris.length === 0 ||
    redirectUris.some((redirectUri) => typeof redirectUri !== 'string' || !isAbsoluteUrl(redirectUri))
  ) {
    throw new InvalidOAuthRedirectUrisError()
  }

  return [...redirectUris]
}

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

  return [...scopes]
}

function normalizeRequestedScope(scope: string, allowedScopes: string[]): string {
  if (typeof scope !== 'string' || scope.trim().length === 0) {
    throw new InvalidOAuthScopeError(String(scope))
  }

  const requestedScopes = scope.trim().split(/\s+/)
  for (const requestedScope of requestedScopes) {
    if (!allowedScopes.includes(requestedScope)) {
      throw new InvalidOAuthScopeError(scope)
    }
  }

  return requestedScopes.join(' ')
}

function normalizeCodeChallenge(codeChallenge: string): string {
  if (typeof codeChallenge !== 'string' || codeChallenge.trim().length === 0) {
    throw new InvalidOAuthCodeChallengeError()
  }

  return codeChallenge.trim()
}

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

function mapClientRow(
  row:
    | {
        id: string
        name: string
        redirectUris: string
        scopes: string
        confidential: boolean
        createdAt: Date
      }
    | undefined,
): OAuthClient | null {
  if (!row) return null

  const redirectUris = parseStringArray(row.redirectUris)
  const scopes = parseStringArray(row.scopes)
  if (!redirectUris || !scopes) {
    return null
  }

  return {
    id: row.id,
    name: row.name,
    redirectUris,
    scopes,
    confidential: row.confidential,
    createdAt: row.createdAt,
  }
}

async function issueAccessToken<S extends Record<string, unknown>>(
  db: Querier<S>,
  input: {
    clientId: string
    codeId: string
    scope: string
    now: Date
    authorizationPrincipal: OAuthAuthorizationPrincipal
  },
): Promise<OAuthTokenExchangeResult> {
  const id = crypto.randomUUID()
  const prefix = generateOpaqueToken(ACCESS_TOKEN_PREFIX_BYTES)
  const secret = generateOpaqueToken(ACCESS_TOKEN_SECRET_BYTES)
  const accessTokenHash = await hashToken(secret)
  const expiresAt = new Date(input.now.getTime() + ACCESS_TOKEN_TTL_SECS * 1000)

  await db.insert(oauthTokens).values({
    id,
    clientId: input.clientId,
    codeId: input.codeId,
    accessTokenPrefix: prefix,
    accessTokenHash,
    scope: input.scope,
    tokenType: 'Bearer',
    createdAt: input.now,
    expiresAt,
  })

  return {
    token: {
      accessToken: `${prefix}.${secret}`,
      tokenType: 'Bearer',
      expiresIn: ACCESS_TOKEN_TTL_SECS,
      scope: input.scope,
    },
    authorizationPrincipal: input.authorizationPrincipal,
  }
}

function hasAuthorizationPrincipal(value: unknown): value is OAuthAuthorizationPrincipal {
  if (typeof value !== 'object' || value === null) return false

  const principal = value as Partial<OAuthAuthorizationPrincipal>
  return (
    typeof principal.subjectId === 'string' &&
    principal.subjectId.trim().length > 0 &&
    typeof principal.tenantId === 'string' &&
    principal.tenantId.trim().length > 0
  )
}

export async function registerClient<S extends Record<string, unknown>>(
  db: Querier<S>,
  input: RegisterOAuthClientInput,
): Promise<RegisterOAuthClientResult> {
  const id = typeof input.id === 'string' && input.id.trim().length > 0 ? input.id.trim() : crypto.randomUUID()
  const name = normalizeName(input.name)
  const redirectUris = normalizeRedirectUris(input.redirectUris)
  const scopes = normalizeScopes(input.scopes)
  const now = new Date()
  const clientSecret = input.confidential ? generateOpaqueToken(CLIENT_SECRET_BYTES) : undefined
  const clientSecretHash = clientSecret ? await hashToken(clientSecret) : null

  await db.insert(oauthClients).values({
    id,
    name,
    redirectUris: JSON.stringify(redirectUris),
    scopes: JSON.stringify(scopes),
    confidential: input.confidential,
    clientSecretHash,
    createdAt: now,
  })

  return {
    clientId: id,
    clientSecret,
  }
}

export async function getClient<S extends Record<string, unknown>>(
  db: Querier<S>,
  id: string,
): Promise<OAuthClient | null> {
  const [row] = await db
    .select({
      id: oauthClients.id,
      name: oauthClients.name,
      redirectUris: oauthClients.redirectUris,
      scopes: oauthClients.scopes,
      confidential: oauthClients.confidential,
      createdAt: oauthClients.createdAt,
    })
    .from(oauthClients)
    .where(eq(oauthClients.id, id))
    .limit(1)

  return mapClientRow(row)
}

export async function authorize<S extends Record<string, unknown>>(
  db: Querier<S>,
  input: AuthorizeInput,
  authorizationPrincipal: OAuthAuthorizationPrincipal,
): Promise<{ code: string }> {
  const principalSnapshot = {
    subjectId: authorizationPrincipal?.subjectId,
    tenantId: authorizationPrincipal?.tenantId,
  }
  if (!hasAuthorizationPrincipal(principalSnapshot)) {
    throw new OAuthCodePrincipalMissingError()
  }
  if (input.codeChallengeMethod !== 'S256') {
    throw new UnsupportedCodeChallengeMethodError(String(input.codeChallengeMethod))
  }

  const client = await getClient(db, input.clientId)
  if (!client) {
    throw new OAuthClientNotFoundError(input.clientId)
  }
  if (!client.redirectUris.includes(input.redirectUri)) {
    throw new OAuthRedirectUriMismatchError(input.redirectUri)
  }

  const scope = normalizeRequestedScope(input.scope, client.scopes)
  const codeChallenge = normalizeCodeChallenge(input.codeChallenge)
  const code = generateOpaqueToken()
  const codeHash = await hashToken(code)
  const now = new Date()

  await db.insert(oauthCodes).values({
    id: crypto.randomUUID(),
    clientId: client.id,
    redirectUri: input.redirectUri,
    scope,
    codeChallenge,
    codeChallengeMethod: 'S256',
    codeHash,
    subjectId: principalSnapshot.subjectId,
    tenantId: principalSnapshot.tenantId,
    createdAt: now,
    expiresAt: new Date(now.getTime() + AUTHORIZATION_CODE_TTL_MS),
    usedAt: null,
  })

  return { code }
}

export async function exchangeToken<S extends Record<string, unknown>>(
  db: TransactionalDatabase<S>,
  input: ExchangeTokenInput,
): Promise<OAuthTokenExchangeResult> {
  const codeHash = await hashToken(input.code)
  const [row] = await db
    .select({
      id: oauthCodes.id,
      clientId: oauthCodes.clientId,
      scope: oauthCodes.scope,
      codeChallenge: oauthCodes.codeChallenge,
      codeChallengeMethod: oauthCodes.codeChallengeMethod,
      redirectUri: oauthCodes.redirectUri,
      subjectId: oauthCodes.subjectId,
      tenantId: oauthCodes.tenantId,
      expiresAt: oauthCodes.expiresAt,
      usedAt: oauthCodes.usedAt,
      confidential: oauthClients.confidential,
      clientSecretHash: oauthClients.clientSecretHash,
    })
    .from(oauthCodes)
    .innerJoin(oauthClients, eq(oauthClients.id, oauthCodes.clientId))
    .where(eq(oauthCodes.codeHash, codeHash))
    .limit(1)

  if (!row) {
    throw new OAuthCodeNotFoundError()
  }
  if (row.clientId !== input.clientId) {
    throw new OAuthCodeClientMismatchError(input.clientId)
  }
  if (row.redirectUri !== input.redirectUri) {
    throw new OAuthRedirectUriMismatchError(input.redirectUri)
  }
  if (row.usedAt) {
    throw new OAuthCodeAlreadyUsedError()
  }

  const now = new Date()
  if (row.expiresAt.getTime() <= now.getTime()) {
    throw new OAuthCodeExpiredError()
  }
  if (row.codeChallengeMethod !== 'S256') {
    throw new UnsupportedCodeChallengeMethodError(row.codeChallengeMethod)
  }
  const authorizationPrincipal = {
    subjectId: row.subjectId,
    tenantId: row.tenantId,
  }
  if (!hasAuthorizationPrincipal(authorizationPrincipal)) {
    throw new OAuthCodePrincipalMissingError()
  }
  if (!(await verifyCodeVerifierS256(input.codeVerifier, row.codeChallenge))) {
    throw new InvalidOAuthCodeVerifierError()
  }
  if (row.confidential) {
    if (!input.clientSecret) {
      throw new MissingOAuthClientSecretError(input.clientId)
    }
    if (!row.clientSecretHash || !(await verifyToken(input.clientSecret, row.clientSecretHash))) {
      throw new InvalidOAuthClientSecretError(input.clientId)
    }
  }

  return db.transaction(async (tx) => {
    const used = await tx
      .update(oauthCodes)
      .set({ usedAt: now })
      .where(and(eq(oauthCodes.id, row.id), isNull(oauthCodes.usedAt)))
      .returning({ id: oauthCodes.id })

    if (used.length === 0) {
      throw new OAuthCodeAlreadyUsedError()
    }

    return issueAccessToken(tx, {
      clientId: row.clientId,
      codeId: row.id,
      scope: row.scope,
      now,
      authorizationPrincipal,
    })
  })
}

export {
  oauthClients,
  oauthCodes,
  oauthProviderCreationSql,
  oauthProviderMigrationSql,
  oauthProviderSchema,
  oauthTokens,
}
export type { OAuthProviderSchema } from './schema.js'
export type { OAuthProviderError } from './errors.js'
export {
  isInvalidOAuthClientNameError,
  isInvalidOAuthClientSecretError,
  isInvalidOAuthCodeChallengeError,
  isInvalidOAuthCodeVerifierError,
  isInvalidOAuthRedirectUrisError,
  isInvalidOAuthScopeError,
  isInvalidOAuthScopesError,
  isOAuthClientNotFoundError,
  isOAuthCodeAlreadyUsedError,
  isOAuthCodeClientMismatchError,
  isOAuthCodeExpiredError,
  isOAuthCodeNotFoundError,
  isOAuthCodePrincipalMissingError,
  isOAuthProviderError,
  isOAuthRedirectUriMismatchError,
  isMissingOAuthClientSecretError,
  isUnsupportedCodeChallengeMethodError,
  InvalidOAuthClientNameError,
  InvalidOAuthClientSecretError,
  InvalidOAuthCodeChallengeError,
  InvalidOAuthCodeVerifierError,
  InvalidOAuthRedirectUrisError,
  InvalidOAuthScopeError,
  InvalidOAuthScopesError,
  OAuthClientNotFoundError,
  OAuthCodeAlreadyUsedError,
  OAuthCodeClientMismatchError,
  OAuthCodeExpiredError,
  OAuthCodeNotFoundError,
  OAuthCodePrincipalMissingError,
  OAuthRedirectUriMismatchError,
  MissingOAuthClientSecretError,
  UnsupportedCodeChallengeMethodError,
} from './errors.js'
export { deriveCodeChallengeS256, verifyCodeVerifierS256 } from './pkce.js'
export type {
  AuthorizeInput,
  ExchangeTokenInput,
  OAuthAuthorizationPrincipal,
  OAuthClient,
  OAuthTokenExchangeResult,
  OAuthTokenResult,
  RegisterOAuthClientInput,
  RegisterOAuthClientResult,
} from './types.js'
