import { eq } from 'drizzle-orm'
import { Hono } from 'hono'
import type { Context } from 'hono'
import type { Querier, TransactionalDatabase } from '@platform-modules/db'
import {
  authorize,
  exchangeToken,
  isOAuthProviderError,
} from '@platform-modules/auth/oauth-provider'
import { hashToken } from '@platform-modules/util/tokens'

import { getDb, type DbEnv } from '../db.js'
import { fail, ok } from '../http.js'
import {
  accounts,
  credentials,
  oauthCodes,
  oauthTokens,
  plugins,
  pressZoneSchema,
  sites,
} from '../schema.js'

type PressZoneDb = TransactionalDatabase<typeof pressZoneSchema>
type PressZoneQuerier = Querier<typeof pressZoneSchema>
type OAuthRouteContext = Context<{ Bindings: DbEnv }>

type OAuthRouteDeps = {
  createDb?: (env: DbEnv) => PressZoneDb
  db?: PressZoneDb
  randomUuid?: () => string
}

type StringMap = Record<string, string>

type AuthorizeRequest = {
  clientId: string
  codeChallenge: string
  codeChallengeMethod: 'S256'
  redirectUri: string
  scope: string
  state: string | null
}

type TokenRequest = {
  accountId: string
  clientId: string
  clientSecret?: string
  code: string
  codeVerifier: string
  grantType: 'authorization_code'
  provider: string
}

class OAuthRouteInputError extends Error {
  override readonly name = 'OAuthRouteInputError'
}

function invalidRequest(message: string): Response {
  return fail('OAUTH_INVALID', message, 400)
}

function readRequiredString(fields: StringMap, key: string): string | null {
  const value = fields[key]
  return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
}

function readOptionalString(fields: StringMap, key: string): string | null {
  const value = fields[key]
  if (typeof value !== 'string') {
    return null
  }

  const normalized = value.trim()
  return normalized.length > 0 ? normalized : null
}

async function readRequestFields(context: OAuthRouteContext): Promise<StringMap | null> {
  if (context.req.method === 'GET') {
    return Object.fromEntries(new URL(context.req.url).searchParams.entries())
  }

  const contentType = context.req.header('content-type') ?? ''

  try {
    if (contentType.includes('application/json')) {
      const payload = await context.req.json()
      if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
        return null
      }

      return Object.fromEntries(
        Object.entries(payload).flatMap(([key, value]) =>
          typeof value === 'string' ? [[key, value]] : [],
        ),
      )
    }

    if (
      contentType.includes('application/x-www-form-urlencoded') ||
      contentType.includes('multipart/form-data')
    ) {
      const formData = await context.req.formData()
      return Object.fromEntries(
        Array.from(formData.entries()).flatMap(([key, value]) =>
          typeof value === 'string' ? [[key, value]] : [],
        ),
      )
    }
  } catch {
    return null
  }

  return Object.fromEntries(new URL(context.req.url).searchParams.entries())
}

function parseAuthorizeRequest(fields: StringMap | null): AuthorizeRequest | null {
  if (!fields) {
    return null
  }

  const clientId = readRequiredString(fields, 'client_id')
  const redirectUri = readRequiredString(fields, 'redirect_uri')
  const scope = readRequiredString(fields, 'scope')
  const codeChallenge = readRequiredString(fields, 'code_challenge')
  const codeChallengeMethod = readRequiredString(fields, 'code_challenge_method')

  if (
    !clientId ||
    !redirectUri ||
    !scope ||
    !codeChallenge ||
    codeChallengeMethod !== 'S256'
  ) {
    return null
  }

  return {
    clientId,
    redirectUri,
    scope,
    codeChallenge,
    codeChallengeMethod: 'S256',
    state: readOptionalString(fields, 'state'),
  }
}

function parseTokenRequest(fields: StringMap | null): TokenRequest | null {
  if (!fields) {
    return null
  }

  const grantType = readRequiredString(fields, 'grant_type')
  const code = readRequiredString(fields, 'code')
  const codeVerifier = readRequiredString(fields, 'code_verifier')
  const clientId = readRequiredString(fields, 'client_id')
  const clientSecret = readOptionalString(fields, 'client_secret') ?? undefined
  const accountId = readRequiredString(fields, 'account_id')
  const provider = readOptionalString(fields, 'provider') ?? 'wordpress'

  if (
    grantType !== 'authorization_code' ||
    !code ||
    !codeVerifier ||
    !clientId ||
    !accountId
  ) {
    return null
  }

  return {
    grantType: 'authorization_code',
    code,
    codeVerifier,
    clientId,
    clientSecret,
    accountId,
    provider,
  }
}

async function getCodeContext(db: PressZoneQuerier, code: string) {
  const codeHash = await hashToken(code)
  const [row] = await db
    .select({
      clientId: oauthCodes.clientId,
      redirectUri: oauthCodes.redirectUri,
    })
    .from(oauthCodes)
    .where(eq(oauthCodes.codeHash, codeHash))
    .limit(1)

  return row ?? null
}

async function getTokenRecord(db: PressZoneQuerier, accessToken: string) {
  const secret = accessToken.split('.')[1]
  if (!secret) {
    return null
  }

  const accessTokenHash = await hashToken(secret)
  const [row] = await db
    .select({
      id: oauthTokens.id,
    })
    .from(oauthTokens)
    .where(eq(oauthTokens.accessTokenHash, accessTokenHash))
    .limit(1)

  return row ?? null
}

async function persistCredential(
  db: PressZoneDb,
  input: TokenRequest,
  randomUuid: () => string,
) {
  return db.transaction(async (tx) => {
    const tokenRequest = {
      code: input.code,
      codeVerifier: input.codeVerifier,
      clientId: input.clientId,
      ...(input.clientSecret ? { clientSecret: input.clientSecret } : {}),
    }
    const token = await exchangeToken(tx, tokenRequest)

    const codeContext = await getCodeContext(tx as PressZoneDb, input.code)
    if (!codeContext || codeContext.clientId !== input.clientId) {
      throw new Error('OAuth authorization code context not found after exchange')
    }

    const [account] = await tx
      .select({ id: accounts.id })
      .from(accounts)
      .where(eq(accounts.id, input.accountId))
      .limit(1)
    if (!account) {
      throw new OAuthRouteInputError(
        `Account not found for oauth credential persistence: ${input.accountId}`,
      )
    }

    const [plugin] = await tx
      .select({ key: plugins.key })
      .from(plugins)
      .where(eq(plugins.key, input.clientId))
      .limit(1)
    if (!plugin) {
      throw new OAuthRouteInputError(`Plugin not found for oauth client: ${input.clientId}`)
    }

    const tokenRecord = await getTokenRecord(tx as PressZoneDb, token.accessToken)
    if (!tokenRecord) {
      throw new Error('OAuth access token record not found after exchange')
    }

    const siteId = randomUuid()

    await tx.insert(sites).values({
      id: siteId,
      accountId: input.accountId,
      pluginKey: plugin.key,
      displayUrl: codeContext.redirectUri,
      status: 'active',
    })

    await tx.insert(credentials).values({
      id: randomUuid(),
      accountId: input.accountId,
      siteId,
      provider: input.provider,
      apiKeyId: null,
      oauthTokenId: tokenRecord.id,
    })

    return token
  })
}

function createHandlers(deps: OAuthRouteDeps) {
  const createDb = deps.createDb ?? ((env: DbEnv) => getDb(env) as PressZoneDb)
  const randomUuid = deps.randomUuid ?? (() => crypto.randomUUID())

  function resolveDb(env: DbEnv): PressZoneDb {
    return deps.db ?? createDb(env)
  }

  return {
    authorize: async (context: OAuthRouteContext) => {
      const fields = await readRequestFields(context)
      const input = parseAuthorizeRequest(fields)
      if (!input) {
        return invalidRequest('Invalid oauth authorize request')
      }

      try {
        const result = await authorize(resolveDb(context.env), {
          clientId: input.clientId,
          redirectUri: input.redirectUri,
          scope: input.scope,
          codeChallenge: input.codeChallenge,
          codeChallengeMethod: input.codeChallengeMethod,
        })

        return ok(input.state ? { code: result.code, state: input.state } : { code: result.code })
      } catch (error) {
        if (isOAuthProviderError(error)) {
          return invalidRequest(error instanceof Error ? error.message : String(error))
        }

        throw error
      }
    },
    callback: async (context: OAuthRouteContext) => {
      const params = new URL(context.req.url).searchParams
      const code = params.get('code')?.trim()

      if (!code) {
        return invalidRequest('Missing oauth authorization code')
      }

      const state = params.get('state')?.trim()

      return ok(state ? { code, state } : { code })
    },
    token: async (context: OAuthRouteContext) => {
      const fields = await readRequestFields(context)
      const input = parseTokenRequest(fields)
      if (!input) {
        return invalidRequest('Invalid oauth token request')
      }

      try {
        const token = await persistCredential(resolveDb(context.env), input, randomUuid)
        return ok(token)
      } catch (error) {
        if (isOAuthProviderError(error)) {
          return invalidRequest(error instanceof Error ? error.message : String(error))
        }
        if (error instanceof OAuthRouteInputError) {
          return invalidRequest(error.message)
        }

        throw error
      }
    },
  }
}

export function createOauthRoute(deps: OAuthRouteDeps = {}) {
  const handlers = createHandlers(deps)

  return new Hono<{ Bindings: DbEnv }>()
    .get('/oauth/authorize', handlers.authorize)
    .post('/oauth/authorize', handlers.authorize)
    .get('/oauth/callback', handlers.callback)
    .post('/oauth/token', handlers.token)
}

export const oauthRoute = createOauthRoute()
