/**
 * Shared, context-free login / signup / verify-email flow logic — auth-write DO offload.
 *
 * The route handlers (signup.ts, verify-email.ts) keep the cheap fail-fast work
 * (rate-limit, zod, token presence) and then delegate the side-effecting body to
 * one of these functions — either inline (fallback) or inside AuthWriteDO, whose
 * 30s CPU budget and sticky isolate keep the flow off the contended stateless
 * pool that was killing these requests with `exceededResources`.
 *
 * These functions take `env` (not Hono `c`) so they run identically in the DO.
 * `runVerifyEmail` must still mint cookies via the existing `issueSessionForTenant`,
 * which is coupled to a Hono `Context`; it does so through a synthetic `Context`
 * built from the forwarded request metadata, then harvests the resulting
 * `Set-Cookie` strings so the caller can reconstruct an identical Response.
 */
import { Context } from 'hono'
import { deleteCookie, getCookie } from 'hono/cookie'
import {
  DEVICE_TRUST_COOKIE_NAME,
  generateOpaqueToken,
  hashToken,
  hashPassword,
  PENDING_2FA_TOKEN_PREFIX,
  PENDING_2FA_TTL_SECONDS,
  signSignedToken,
  verifyPassword,
  verifySignedToken,
  consumeSignedTokenJti,
  VERIFY_EMAIL_JTI_PREFIX,
} from '@zync/auth'
import {
  completeEmailVerification,
  createDb,
  createFreelancerSubscription,
  createPendingUser,
  findValidTrustedDevice,
  findUserByEmail,
  findUserById,
  getPrimaryMembershipWithTenant,
  getPrimaryMembership,
  getTenant2FASettings,
  getUser2FAStatus,
  getUserTheme,
  insertMagicLinkToken,
  markUserEmailVerified,
} from '@zync/db/queries'
import { sql } from '@zync/db'
import type { Env, TenantId, UserId } from '@zync/types'
import { sendVerificationEmail } from '../../adapters/email'
import { withDoHash } from '../../lib/password-hash-do'
import { appOriginForRequest } from '../../lib/origins'
import {
  assertSessionCapAllowsLogin,
  issueSessionForTenant,
  issueSessionForTenantWith2FA,
  issueSessionForUser,
  SessionCapExceededError,
} from '../../lib/issue-session'
import { clearFailures, isLockedOut, recordFailure } from '../../lib/login-throttle'
import { setThemeCookie } from '../../lib/theme-cookie'
import type { AppEnv } from '../../types'

const EMAIL_VERIFY_TTL_SECONDS = 60 * 60 * 24 // 24h
const VERIFY_TTL_SECONDS = 24 * 60 * 60

export interface LoginInput {
  email: string
  password: string
  reqUrl: string
  headers: Record<string, string>
}

/**
 * Execute the expensive successful-login path in AuthWriteDO's 30s CPU pool.
 * The synthetic Hono context preserves request metadata and captures every
 * Set-Cookie header in the Response returned to the edge Worker.
 */
export async function runLogin(env: Env, input: LoginInput): Promise<Response> {
  const c = new Context<AppEnv>(
    new Request(input.reqUrl, { headers: input.headers }),
    { env },
  )

  if (await isLockedOut(env, input.email)) {
    return c.json({ error: 'Account temporarily locked. Try again later.' }, 429)
  }

  const db = createDb(env)
  const user = await findUserByEmail(db, input.email)
  if (!user || !(await verifyPassword(input.password, user.passwordHash, withDoHash(env)))) {
    await recordFailure(env, input.email)
    return c.json({ error: 'Invalid email or password' }, 401)
  }

  await clearFailures(env, input.email)
  const membership = await getPrimaryMembershipWithTenant(db, user.id as UserId)
  if (!membership) {
    return c.json({ error: 'No active workspace for this account' }, 403)
  }

  const [user2fa, tenant2fa] = await Promise.all([
    getUser2FAStatus(db, user.id as UserId),
    getTenant2FASettings(db, membership.tenantId as TenantId),
  ])
  const twoFactorEnabled = user2fa?.twoFactorEnabled ?? false
  const enforce2fa = tenant2fa?.enforce2fa ?? false

  if (twoFactorEnabled || enforce2fa) {
    const rawDeviceTrustToken = getCookie(c, DEVICE_TRUST_COOKIE_NAME)
    if (rawDeviceTrustToken) {
      const tokenHash = await hashToken(rawDeviceTrustToken)
      const trustedDevice = await findValidTrustedDevice(
        db,
        tokenHash,
        user.id as UserId,
        membership.tenantId,
      )
      if (trustedDevice) {
        const cap = await assertSessionCapAllowsLogin(
          db,
          user.id as UserId,
          membership.tenantId as TenantId,
        )
        if (!cap.allowed) return c.json({ error: cap.message }, 429)
        const issued = await issueSessionForTenantWith2FA(
          c,
          db,
          user.id as UserId,
          membership.tenantId as TenantId,
          { enforce2fa, twoFactorVerified: true },
        )
        if (!issued) return c.json({ error: 'No active workspace for this account' }, 403)
        return c.json({ expiresAt: issued.expiresAt }, 200)
      }
      deleteCookie(c, DEVICE_TRUST_COOKIE_NAME, {
        httpOnly: true,
        secure: true,
        sameSite: 'Strict',
        domain: '.zync.is',
        path: '/',
      })
    }
  }

  if (twoFactorEnabled) {
    const plainToken = `${PENDING_2FA_TOKEN_PREFIX}${generateOpaqueToken()}`
    const tokenHash = await hashToken(plainToken)
    await insertMagicLinkToken(db, {
      tenantId: membership.tenantId as TenantId,
      userId: user.id as UserId,
      tokenHash,
      purpose: 'pending_2fa',
      expiresAt: new Date(Date.now() + PENDING_2FA_TTL_SECONDS * 1000),
    })
    return c.json({
      requires_2fa: true,
      session_token: plainToken,
      phone_suffix: user2fa?.twoFactorPhoneSuffix ?? null,
      allow_remember_device: !(tenant2fa?.disable2faRememberDevice ?? false),
    }, 200)
  }

  if (enforce2fa) {
    const plainToken = `${PENDING_2FA_TOKEN_PREFIX}${generateOpaqueToken()}`
    const tokenHash = await hashToken(plainToken)
    await insertMagicLinkToken(db, {
      tenantId: membership.tenantId as TenantId,
      userId: user.id as UserId,
      tokenHash,
      purpose: 'pending_2fa_setup',
      expiresAt: new Date(Date.now() + PENDING_2FA_TTL_SECONDS * 1000),
    })
    return c.json({ requires_2fa_setup: true, session_token: plainToken }, 200)
  }

  const cap = await assertSessionCapAllowsLogin(
    db,
    user.id as UserId,
    membership.tenantId as TenantId,
  )
  if (!cap.allowed) return c.json({ error: cap.message }, 429)

  const issued = await issueSessionForUser(c, db, user.id as UserId)
  if (!issued) return c.json({ error: 'No active workspace for this account' }, 403)

  const uiTheme = await getUserTheme(db, user.id as UserId, membership.tenantId as TenantId)
  setThemeCookie(c, uiTheme)
  await db.execute(sql`UPDATE users SET last_login_at = NOW() WHERE id = ${user.id}`)
  return c.json({ expiresAt: issued.expiresAt }, 200)
}

export interface SignupInput {
  email: string
  password: string
  name: string
  appOrigin?: string
}

export interface SignupResult {
  status: number
  body: unknown
}

export interface VerifyEmailInput {
  token: string
  ip: string | null
  ray: string | null
  userAgent: string | null
  country: string | null
  reqUrl: string
}

export interface VerifyEmailResult {
  status: number
  redirect?: string
  setCookies?: string[]
  body?: unknown
}

/**
 * Signup body (post rate-limit + zod). Idempotent on re-entry: an existing email
 * short-circuits to its userId without re-hashing or re-sending — so an inline
 * retry after a partial DO run is safe.
 */
export async function runSignup(env: Env, input: SignupInput): Promise<SignupResult> {
  const db = createDb(env)

  // Do not reveal existing accounts beyond the unique constraint: if the email
  // is taken, respond as if a verification mail was sent (no enumeration).
  const existing = await findUserByEmail(db, input.email)
  if (existing) {
    const membership = await getPrimaryMembership(db, existing.id as UserId)
    return { status: 200, body: { userId: existing.id, tenantId: membership?.tenantId ?? null } }
  }

  const passwordHash = await hashPassword(input.password, withDoHash(env))
  const { id } = await createPendingUser(db, {
    email: input.email,
    passwordHash,
    name: input.name,
  })

  const tenantName = input.name ? `${input.name}'s Workspace` : 'My Workspace'
  const membership = await completeEmailVerification(db, {
    userId: id,
    tenantName,
    markEmailVerified: false,
  })
  await createFreelancerSubscription(db, membership.tenantId)
  await db.execute(sql`UPDATE users SET email_verified_at = NULL WHERE id = ${id}`)

  const token = await signSignedToken(
    { sub: id, purpose: 'email_verify' },
    env.JWT_SECRET,
    EMAIL_VERIFY_TTL_SECONDS,
  )
  await sendVerificationEmail(env, input.email, token, id, input.appOrigin)

  return { status: 201, body: { userId: id, tenantId: membership.tenantId } }
}

function buildHeaders(input: VerifyEmailInput): Headers {
  const headers = new Headers()
  if (input.ip) headers.set('CF-Connecting-IP', input.ip)
  if (input.userAgent) headers.set('User-Agent', input.userAgent)
  if (input.country) headers.set('CF-IPCountry', input.country)
  if (input.ray) headers.set('CF-Ray', input.ray)
  return headers
}

/**
 * Verify-email body: verify token, consume the single-use JTI, provision the
 * tenant + OWNER membership, and mint a session. The JTI consume is the
 * at-most-once guard — `completeEmailVerification` is NOT idempotent (it always
 * inserts a fresh tenant), so this flow must run at most once per token. Callers
 * MUST NOT inline-retry after a DO runtime failure (see auth-write-do.ts).
 */
export async function runVerifyEmail(env: Env, input: VerifyEmailInput): Promise<VerifyEmailResult> {
  const onboardingUrl = `${appOriginForRequest(input.reqUrl)}/onboarding`

  let claims: Record<string, unknown>
  try {
    claims = await verifySignedToken(input.token, env.JWT_SECRET)
  } catch {
    return { status: 400, body: { error: 'Invalid or expired token' } }
  }
  if (claims.purpose !== 'email_verify' || typeof claims.sub !== 'string') {
    return { status: 400, body: { error: 'Invalid token' } }
  }
  if (typeof claims.jti !== 'string' || !claims.jti) {
    return { status: 400, body: { error: 'Invalid token' } }
  }
  const userId = claims.sub as UserId

  // Consume the JTI before any side effects — prevents link replay until expiry.
  const exp = typeof claims.exp === 'number' ? claims.exp : Math.floor(Date.now() / 1000) + VERIFY_TTL_SECONDS
  const jtiTtl = Math.max(1, exp - Math.floor(Date.now() / 1000))
  const consumed = await consumeSignedTokenJti(env.KV, VERIFY_EMAIL_JTI_PREFIX, claims.jti, jtiTtl)
  if (!consumed) {
    return { status: 400, body: { error: 'Invalid or expired token' } }
  }

  const db = createDb(env)
  const user = await findUserById(db, userId)
  if (!user) return { status: 400, body: { error: 'Invalid token' } }

  if (user.emailVerifiedAt) {
    return { status: 302, redirect: onboardingUrl }
  }

  const existingMembership = await getPrimaryMembership(db, userId)
  let sessionTenantId = existingMembership?.tenantId as TenantId | undefined

  if (existingMembership) {
    await markUserEmailVerified(db, userId)
  } else {
    const tenantName = user.name ? `${user.name}'s Workspace` : 'My Workspace'
    const result = await completeEmailVerification(db, {
      userId,
      tenantName,
      actorIp: input.ip,
      requestId: input.ray,
    })
    await createFreelancerSubscription(db, result.tenantId)
    sessionTenantId = result.tenantId as TenantId
  }

  // Reuse issueSessionForTenant (cookie + session-record logic) via a synthetic
  // Context backed by the forwarded request metadata. The Set-Cookie headers it
  // appends are harvested below so the caller can rebuild an identical Response.
  const synthCtx = new Context<AppEnv>(new Request(input.reqUrl, { headers: buildHeaders(input) }), { env })

  let issued
  try {
    issued = await issueSessionForTenant(
      synthCtx,
      db,
      userId,
      sessionTenantId as TenantId,
    )
  } catch (error) {
    if (error instanceof SessionCapExceededError) {
      return { status: 429, body: { error: error.message } }
    }
    throw error
  }
  if (!issued) {
    return { status: 500, body: { error: 'Failed to issue session' } }
  }

  // getSetCookie() is a runtime Headers method (Workers/undici) absent from the
  // project's DOM lib typings; cast narrowly to read the per-cookie strings.
  const setCookies = (synthCtx.res.headers as unknown as { getSetCookie(): string[] }).getSetCookie()
  return { status: 302, redirect: onboardingUrl, setCookies }
}
