/**
 * The HOST side of auth (docs/backend-plan.md §3.4): sessions + tokens, the email-code flow, OAuth
 * binding. Runs where the runner imports `sdk/server` (the project worker), against the project's own
 * db handle (`server.dbs[0]` — `User`/`Session` are ordinary models, see ./models.ts). Installed on
 * `globalThis.__lecodesAuth` for the bundle's `auth` global (./global.ts); `resolve(ctx)` runs before
 * every endpoint / channel join so `auth.user` / `auth.session` are plain synchronous reads.
 *
 * Tokens: 32 random bytes (base64url) held by the client transport; only `sha256(token)` is stored.
 * A request without a valid token gets a fresh guest session and the token travels back in the
 * envelope (`session`); sign-in/out ROTATE the token the same way. OAuth binds without rotation (the
 * browser completes the flow, the app can't receive a new token) — see `oauthComplete`.
 */

import type { RequestContext } from "../context"
import type { AuthBinding } from "../db/types"
import { ApiError } from "../errors"
import type { AuthProvider } from "./models"
import type { AuthOps, AuthState, AuthUser, OAuthCredential, OAuthProfile } from "./types"

export type AuthMail = { to: string, code: string, appName: string }

export type AuthHostConfig = {
  /** The project's db (`server.dbs[0]`, any `defineDb` handle); `null` = no database → `auth.*` throws a clear error. */
  db: { readonly $auth: AuthBinding, readonly $schema: string } | null
  /** HMAC key for OAuth nonces (runner-wide secret). */
  secret: string
  /** `app.json` `auth.providers` (the compiler embeds them; the runner reads them off the manifest). */
  providers: AuthProvider[]
  appName?: string
  /** `<publicUrl>/p/<key>` — OAuth start links are minted under it; absent = OAuth unavailable. */
  publicBase?: string
  /** Nonce scope (the runner's project key): a nonce minted for one project can't bind a session of another. */
  scope?: string
  /** Sends the sign-in code (the platform mailer). Absent = codes are only echoed to the console. */
  mail?: (msg: AuthMail) => Promise<void>
  /** Echo every code to the server console (the draft slot: the developer is the only user). */
  echoCodes?: boolean
  /** Verifies a NATIVE-SDK credential with the provider (runner-side, knows the platform's app ids) → profile.
   *  Absent = `auth.oauth.signIn` is unavailable. Throw `ApiError` for a bad credential. */
  verifyOAuth?: (provider: Exclude<AuthProvider, "email">, credential: OAuthCredential) => Promise<OAuthProfile>
  /** Server console sink (default console). */
  log?: (level: "warn" | "info", text: string) => void
  now?: () => number
}

const CODE_TTL_MS = 10 * 60_000
const CODE_MAX_ATTEMPTS = 5
const SEND_MIN_GAP_MS = 30_000
const SEND_MAX_PER_10MIN = 3
const NONCE_TTL_MS = 10 * 60_000
const CACHE_TTL_MS = 60_000
const SEEN_UPDATE_MS = 60_000

const enc = new TextEncoder()
const b64url = (bytes: ArrayBuffer | Uint8Array): string => {
  const u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
  let s = ""
  for (const b of u) s += String.fromCharCode(b)
  return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
const hex = (bytes: ArrayBuffer): string => [...new Uint8Array(bytes)].map(b => b.toString(16).padStart(2, "0")).join("")
export const sha256 = async (text: string): Promise<string> => hex(await crypto.subtle.digest("SHA-256", enc.encode(text)))
const randomToken = (): string => b64url(crypto.getRandomValues(new Uint8Array(32)))
const randomCode = (): string => String(crypto.getRandomValues(new Uint32Array(1))[0] % 1_000_000).padStart(6, "0")

const hmac = async (secret: string, text: string): Promise<string> => {
  const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"])
  return b64url(await crypto.subtle.sign("HMAC", key, enc.encode(text)))
}
/** OAuth nonce: `<sessionId>.<expiresAt>.<hmac>` — stateless, bound to a session (and a `scope`: the project), short-lived. */
export const signNonce = async (secret: string, sessionId: number, expiresAt: number, scope = ""): Promise<string> =>
  `${sessionId}.${expiresAt}.${await hmac(secret, `${scope}|${sessionId}.${expiresAt}`)}`
export const verifyNonce = async (secret: string, nonce: string, now = Date.now(), scope = ""): Promise<{ sessionId: number } | null> => {
  const [sid, exp, sig] = nonce.split(".")
  const sessionId = Number(sid), expiresAt = Number(exp)
  if (!Number.isInteger(sessionId) || !Number.isFinite(expiresAt) || !sig) return null
  if (expiresAt < now) return null
  if (sig !== await hmac(secret, `${scope}|${sid}.${exp}`)) return null
  return { sessionId }
}

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const normalizeEmail = (email: unknown): string => {
  const e = typeof email === "string" ? email.trim().toLowerCase() : ""
  if (!e || e.length > 254 || !EMAIL_RE.test(e)) throw new ApiError(400, "Invalid email")
  return e
}

/** Built-in user shape from a row (custom scalar fields ride along at runtime). */
const toUser = (row: any): AuthUser => row as AuthUser

export type AuthHost = {
  /** Resolve the bearer token of `ctx` into `ctx.auth` (minting a guest session when needed). No db → leaves it undefined. */
  resolve(ctx: RequestContext): Promise<void>
  ops: AuthOps
  /** Install `ops` for the bundle's `auth` global (`globalThis.__lecodesAuth`). */
  install(): void
  /**
   * OAuth callback: bind the session named by `nonce` to the user identified by `profile` (existing
   * `<provider>Id`, else a verified-email match, else a new user). Runs in the worker; the runner's
   * broker route calls it with what the provider returned.
   */
  oauthComplete(provider: Exclude<AuthProvider, "email">, nonce: string, profile: OAuthProfile): Promise<{ userId: number }>
  /** Test hook / diagnostics: forget cached sessions and users. */
  clearCache(): void
}

export const createAuthHost = (cfg: AuthHostConfig): AuthHost => {
  const db = cfg.db as any
  const binding = cfg.db?.$auth ?? null
  const hasUser = !!binding?.user
  const now = cfg.now ?? (() => Date.now())
  const log = cfg.log ?? ((level, text) => (level === "warn" ? console.warn(text) : console.info(text)))
  const providers = new Set(cfg.providers)

  // token → session (60 s), user id → user row (60 s). Cleared on every mutation — correctness over reuse.
  const sessionCache = new Map<string, { sessionId: number, userId: number | null, at: number, seenAt: number }>()
  const userCache = new Map<number, { user: AuthUser | null, at: number }>()
  const clearCache = () => { sessionCache.clear(); userCache.clear() }

  const requireDb = () => {
    if (!db) throw new Error("auth: this project has no database — sessions need a defineDb()")
    return db
  }
  const requireUserModel = (what: string) => {
    if (!hasUser) throw new Error(`${what} needs a user model: export const User = auth.model.user({...}) in your schema`)
  }
  const stateOf = (ctx: RequestContext): AuthState => {
    const s = ctx.auth as AuthState | undefined
    if (!s) throw new Error("auth: this project has no database — sessions need a defineDb()")
    return s
  }

  const sessionSelect = () => (hasUser ? { id: true, revoked: true, lastSeenAt: true, user: { id: true } } : { id: true, revoked: true, lastSeenAt: true })
  const loadUser = async (userId: number | null): Promise<AuthUser | null> => {
    if (userId === null || !hasUser) return null
    const c = userCache.get(userId)
    if (c && now() - c.at < CACHE_TTL_MS) return c.user
    const row = await db.user.where({ id: userId }).first()
    const user = row ? toUser(row) : null
    userCache.set(userId, { user, at: now() })
    return user
  }
  const mint = async (ctx: RequestContext): Promise<AuthState> => {
    const token = randomToken()
    const { id } = await db.session.insert({ tokenHash: await sha256(token) })
    const state: AuthState = { sessionId: id, userId: null, user: null, issuedToken: token }
    sessionCache.set(token, { sessionId: id, userId: null, at: now(), seenAt: now() })
    ctx.auth = state
    ctx.sessionToken = token
    return state
  }
  const rotate = async (ctx: RequestContext): Promise<void> => {
    const s = stateOf(ctx)
    const token = randomToken()
    await db.session.update(s.sessionId, { tokenHash: await sha256(token) })
    clearCache()
    s.issuedToken = token
    ctx.sessionToken = token
  }
  const bind = async (ctx: RequestContext, userId: number | null, opts: { rotate: boolean }): Promise<AuthUser | null> => {
    const s = stateOf(ctx)
    await db.session.update(s.sessionId, { user: userId === null ? null : { $connect: { id: userId } } })
    clearCache()
    s.userId = userId
    s.user = await loadUser(userId)
    if (opts.rotate) await rotate(ctx)
    return s.user
  }

  const resolve = async (ctx: RequestContext): Promise<void> => {
    if (!db) return
    const token = ctx.sessionToken
    if (token) {
      const c = sessionCache.get(token)
      if (c && now() - c.at < CACHE_TTL_MS) {
        ctx.auth = { sessionId: c.sessionId, userId: c.userId, user: await loadUser(c.userId) }
        return
      }
      const row = await db.session.where({ tokenHash: await sha256(token) }).select(sessionSelect()).first()
      if (row && !row.revoked) {
        const userId: number | null = hasUser && row.user ? row.user.id : null
        if (now() - (row.lastSeenAt ?? 0) > SEEN_UPDATE_MS) db.session.update(row.id, { lastSeenAt: now() }).then(undefined, () => {})
        sessionCache.set(token, { sessionId: row.id, userId, at: now(), seenAt: now() })
        ctx.auth = { sessionId: row.id, userId, user: await loadUser(userId) }
        return
      }
    }
    await mint(ctx)
  }

  // ── email codes ──
  const sends = new Map<string, number[]>()   // email → send timestamps (per worker; attempts persist on the row)
  const codeHash = (sessionId: number, code: string) => sha256(`${sessionId}:${code}`)

  const sendCode = async (ctx: RequestContext, rawEmail: string): Promise<void> => {
    requireDb()
    if (!providers.has("email")) throw new Error(`auth.email is not enabled — add "email" to auth.providers in app.json`)
    requireUserModel("auth.email")
    const s = stateOf(ctx)
    const email = normalizeEmail(rawEmail)
    const t = now()
    const recent = (sends.get(email) ?? []).filter(x => t - x < 10 * 60_000)
    if (recent.length && t - recent[recent.length - 1] < SEND_MIN_GAP_MS) throw new ApiError(429, `Try again in ${Math.ceil((SEND_MIN_GAP_MS - (t - recent[recent.length - 1])) / 1000)} s`)
    if (recent.length >= SEND_MAX_PER_10MIN) throw new ApiError(429, "Too many codes requested — try again later")
    const code = randomCode()
    await db.session.update(s.sessionId, { pendingEmail: email, codeHash: await codeHash(s.sessionId, code), codeExpires: t + CODE_TTL_MS, codeAttempts: 0 })
    recent.push(t); sends.set(email, recent)
    const appName = cfg.appName || "the app"
    if (cfg.mail) {
      try { await cfg.mail({ to: email, code, appName }) }
      catch (e) { log("warn", `[auth] could not send the sign-in code to ${email}: ${(e as Error).message}`) }
    }
    if (!cfg.mail || cfg.echoCodes) log("info", `[auth] sign-in code for ${email}: ${code}${cfg.mail ? "" : " (no mailer configured — the code was not emailed)"}`)
  }

  const verify = async (ctx: RequestContext, rawEmail: string, code: string): Promise<AuthUser> => {
    requireDb()
    requireUserModel("auth.email")
    const s = stateOf(ctx)
    const email = normalizeEmail(rawEmail)
    const row = await db.session.where({ id: s.sessionId }).select({ pendingEmail: true, codeHash: true, codeExpires: true, codeAttempts: true }).first()
    if (!row?.codeHash || row.pendingEmail !== email || !row.codeExpires || row.codeExpires < now()) throw new ApiError(400, "Code expired — request a new one")
    if (row.codeAttempts >= CODE_MAX_ATTEMPTS) throw new ApiError(429, "Too many attempts — request a new code")
    if (typeof code !== "string" || await codeHash(s.sessionId, code.trim()) !== row.codeHash) {
      await db.session.update(s.sessionId, { codeAttempts: { $increment: 1 } })
      throw new ApiError(400, "Invalid code")
    }
    const existing = await db.user.where({ email }).first()
    const userId: number = existing ? existing.id : (await db.user.insert({ email })).id
    await db.session.update(s.sessionId, { pendingEmail: null, codeHash: null, codeExpires: null, codeAttempts: 0 })
    const user = await bind(ctx, userId, { rotate: true })
    return user!
  }

  // ── oauth ──
  const oauthUrl = async (ctx: RequestContext, provider: Exclude<AuthProvider, "email">): Promise<string> => {
    requireDb()
    if (!providers.has(provider)) throw new Error(`auth.oauth: "${provider}" is not enabled — add it to auth.providers in app.json`)
    requireUserModel("auth.oauth")
    if (!cfg.publicBase) throw new Error("auth.oauth: not available in this environment (no public runner URL)")
    const s = stateOf(ctx)
    const nonce = await signNonce(cfg.secret, s.sessionId, now() + NONCE_TTL_MS, cfg.scope)
    return `${cfg.publicBase}/auth/${provider}/start?s=${encodeURIComponent(nonce)}`
  }

  /** The user a provider identity maps to: by `<provider>Id`, else a verified-email match (linked), else created. */
  const userForProfile = async (provider: Exclude<AuthProvider, "email">, profile: OAuthProfile): Promise<number> => {
    const idField = `${provider}Id`
    if (!profile.sub) throw new ApiError(502, `${provider} returned no account id`)
    let user = await db.user.where({ [idField]: profile.sub }).first()
    if (!user && profile.email && profile.emailVerified !== false) {
      const byEmail = await db.user.where({ email: profile.email.toLowerCase() }).first()
      if (byEmail) { await db.user.update(byEmail.id, { [idField]: profile.sub }); user = { ...byEmail, [idField]: profile.sub } }
    }
    if (!user) {
      const data: Record<string, unknown> = { [idField]: profile.sub }
      if (profile.email && profile.emailVerified !== false) data.email = profile.email.toLowerCase()
      if (profile.name) data.name = profile.name
      if (profile.avatar) data.avatar = profile.avatar
      const { id } = await db.user.insert(data)
      user = { id }
    }
    return user.id
  }

  const oauthComplete = async (provider: Exclude<AuthProvider, "email">, nonce: string, profile: OAuthProfile): Promise<{ userId: number }> => {
    requireDb()
    requireUserModel("auth.oauth")
    const n = await verifyNonce(cfg.secret, nonce, now(), cfg.scope)
    if (!n) throw new ApiError(400, "This sign-in link has expired — start again from the app")
    const session = await db.session.where({ id: n.sessionId }).select({ id: true, revoked: true, user: { id: true } }).first()
    if (!session || session.revoked) throw new ApiError(400, "This sign-in link is no longer valid — start again from the app")
    const userId = await userForProfile(provider, profile)
    // A session already signed in as someone else is not re-bound by a stray link (fixation guard).
    if (session.user && session.user.id !== userId) throw new ApiError(409, "This device is already signed in as another user — sign out first")
    if (!session.user) await db.session.update(session.id, { user: { $connect: { id: userId } } })
    clearCache()
    return { userId }
  }

  // Native-SDK credential (client plugin OAuth.signIn) → verified with the provider → bound to THIS session, rotated
  // like any in-band sign-in (the same client receives the new token).
  const oauthSignIn = async (ctx: RequestContext, provider: Exclude<AuthProvider, "email">, credential: OAuthCredential): Promise<AuthUser> => {
    requireDb()
    if (!providers.has(provider)) throw new Error(`auth.oauth: "${provider}" is not enabled — add it to auth.providers in app.json`)
    requireUserModel("auth.oauth")
    if (!cfg.verifyOAuth) throw new Error("auth.oauth.signIn: not available in this environment (no provider verification configured)")
    if (!credential || typeof credential !== "object" || (typeof credential.accessToken !== "string" && typeof credential.idToken !== "string")) throw new ApiError(400, "A provider credential (accessToken or idToken) is required")
    const profile = await cfg.verifyOAuth(provider, credential)
    // Apple only sends the name on the first sign-in — the app forwards what the sheet returned
    if (!profile.name && typeof credential.name === "string" && credential.name.trim()) profile.name = credential.name.trim()
    const userId = await userForProfile(provider, profile)
    return (await bind(ctx, userId, { rotate: true }))!
  }

  const ops: AuthOps = {
    email: { sendCode, verify },
    oauth: { url: oauthUrl, signIn: oauthSignIn },
    signIn: async (ctx, userId) => {
      requireDb(); requireUserModel("auth.signIn")
      const row = await db.user.where({ id: userId }).first()
      if (!row) throw new ApiError(404, "No such user")
      return (await bind(ctx, userId, { rotate: true }))!
    },
    signOut: async (ctx) => { requireDb(); await bind(ctx, null, { rotate: true }) },
    sessions: (ctx) => {
      requireDb()
      const s = stateOf(ctx)
      return s.userId !== null && hasUser ? db.session.where({ user: { id: s.userId } }) : db.session.where({ id: s.sessionId })
    },
  }

  return {
    resolve, ops, oauthComplete, clearCache,
    install: () => { (globalThis as any).__lecodesAuth = ops },
  }
}
