/**
 * ChatGPT Codex OAuth (PKCE, public client)
 *
 * Mirrors the flow shipped in the open-source OpenAI Codex CLI so that
 * SANDCODE users who already pay for ChatGPT Plus / Pro / Team can sign in
 * with their account and use the Codex-eligible models (gpt-5, gpt-5-codex,
 * gpt-5.x) via the `chatgpt.com/backend-api/codex` backend — no separate
 * OpenAI Platform API credits required.
 *
 * Public spec facts this implementation depends on:
 *   - client_id    : `app_EMoamEEZ73f0CkXaXp7hrann`  (Codex CLI public ID)
 *   - authorize URL: https://auth.openai.com/oauth/authorize
 *   - token URL    : https://auth.openai.com/oauth/token
 *   - redirect     : http://localhost:1455/auth/callback (must match exactly)
 *   - scopes       : `openid profile email offline_access`
 *   - extra params : `codex_cli_simplified_flow=true`, `originator=codex_cli_rs`
 *   - account id   : `https://api.openai.com/auth` -> `chatgpt_account_id` claim
 *                    inside the **access_token** JWT
 */

import { createHash, randomBytes } from 'node:crypto'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { URL, URLSearchParams } from 'node:url'

export const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
export const CODEX_AUTH_BASE = 'https://auth.openai.com'
export const CODEX_AUTHORIZE_URL = `${CODEX_AUTH_BASE}/oauth/authorize`
export const CODEX_TOKEN_URL = `${CODEX_AUTH_BASE}/oauth/token`
export const CODEX_LOOPBACK_PORT = 1455
export const CODEX_REDIRECT_URI = `http://localhost:${CODEX_LOOPBACK_PORT}/auth/callback`
export const CODEX_SCOPES = 'openid profile email offline_access'

/** Shape of what we persist and pass around. `expires_at` is absolute ms. */
export interface CodexTokens {
  access_token: string
  refresh_token: string
  id_token?: string
  account_id: string | null
  expires_at: number
  token_type: 'Bearer'
  /** When the user last completed a full login (not a refresh). */
  issued_at: number
  /** Last time we refreshed (for age-based forced re-auth). */
  last_refresh: number
}

export interface AuthorizationFlow {
  verifier: string
  challenge: string
  state: string
  url: string
}

/** Base64url (RFC 7636 §4.1) — Node's built-in is NOT url-safe. */
function base64url(buf: Buffer): string {
  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

/** Generate a cryptographically random 43-128 char PKCE verifier. */
function generateVerifier(): string {
  // 32 bytes → 43 chars base64url. RFC 7636 recommends ≥ 43.
  return base64url(randomBytes(32))
}

function challengeFromVerifier(verifier: string): string {
  return base64url(createHash('sha256').update(verifier).digest())
}

/** Build the `?code_challenge=…` authorize URL and return it plus the verifier. */
export function createAuthorizationFlow(opts?: { originator?: string }): AuthorizationFlow {
  const verifier = generateVerifier()
  const challenge = challengeFromVerifier(verifier)
  const state = base64url(randomBytes(24))

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: CODEX_CLIENT_ID,
    redirect_uri: CODEX_REDIRECT_URI,
    scope: CODEX_SCOPES,
    code_challenge: challenge,
    code_challenge_method: 'S256',
    state,
    // Codex-CLI-specific flags. Without these OpenAI returns the long
    // multi-step consent flow; `codex_cli_simplified_flow` skips straight to
    // the account picker + consent screen.
    codex_cli_simplified_flow: 'true',
    originator: opts?.originator ?? 'codex_cli_rs',
  })

  const url = `${CODEX_AUTHORIZE_URL}?${params.toString()}`
  return { verifier, challenge, state, url }
}

// ─────────────────────────────────────────────────────────────
// Loopback server
// ─────────────────────────────────────────────────────────────

export interface LoopbackResult {
  code: string
  state: string
}

export interface LoopbackServerHandle {
  /** Resolves with the authorization code (or rejects if browser returned error). */
  waitForCode(expectedState: string, timeoutMs?: number): Promise<LoopbackResult>
  close(): void
  port: number
}

const SUCCESS_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>SANDCODE · Codex sign-in</title>
<style>body{background:#0a0a0a;color:#f5a623;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;display:grid;place-items:center;height:100vh;margin:0}
h1{font-size:1.25rem;letter-spacing:.04em;margin:0 0 .5rem}p{color:#999;font-size:.9rem}</style></head>
<body><div><h1>◈ SANDCODE — signed in</h1><p>You can close this tab and return to the terminal.</p></div></body></html>`

const FAILURE_HTML = (msg: string) => `<!doctype html><html><head><meta charset="utf-8"><title>SANDCODE · Sign-in failed</title>
<style>body{background:#0a0a0a;color:#ff6b6b;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;display:grid;place-items:center;height:100vh;margin:0}
h1{font-size:1.25rem;letter-spacing:.04em;margin:0 0 .5rem}p{color:#999;font-size:.9rem;max-width:40ch;text-align:center}</style></head>
<body><div><h1>◈ Sign-in failed</h1><p>${msg}</p></div></body></html>`

/**
 * Start the loopback server on 1455. Only `/auth/callback` is handled — any
 * other path returns 404. Caller must `close()` when done.
 */
export async function startLoopbackServer(
  port: number = CODEX_LOOPBACK_PORT,
): Promise<LoopbackServerHandle> {
  let resolveCode: ((r: LoopbackResult) => void) | null = null
  let rejectCode: ((e: Error) => void) | null = null

  const handler = (req: IncomingMessage, res: ServerResponse): void => {
    // Only accept the exact callback path. Reject anything else so a random
    // localhost probe can't trigger a false state match.
    if (!req.url || !req.url.startsWith('/auth/callback')) {
      res.writeHead(404, { 'content-type': 'text/plain' })
      res.end('not found')
      return
    }

    const parsed = new URL(req.url, `http://localhost:${port}`)
    const code = parsed.searchParams.get('code')
    const state = parsed.searchParams.get('state') ?? ''
    const error = parsed.searchParams.get('error')
    const errorDesc = parsed.searchParams.get('error_description') ?? error ?? ''

    if (error || !code) {
      res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' })
      res.end(FAILURE_HTML(errorDesc || 'no authorization code returned'))
      rejectCode?.(new Error(errorDesc || 'OAuth callback missing code'))
      return
    }

    res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
    res.end(SUCCESS_HTML)
    resolveCode?.({ code, state })
  }

  const server = createServer(handler)

  await new Promise<void>((resolve, reject) => {
    server.once('error', reject)
    server.listen(port, '127.0.0.1', () => {
      server.off('error', reject)
      resolve()
    })
  })

  return {
    port,
    waitForCode(expectedState, timeoutMs = 5 * 60 * 1000) {
      return new Promise<LoopbackResult>((resolve, reject) => {
        const timer = setTimeout(() => {
          reject(new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for sign-in`))
        }, timeoutMs)
        resolveCode = (r) => {
          clearTimeout(timer)
          if (r.state !== expectedState) {
            reject(new Error('State mismatch — possible CSRF, aborting'))
            return
          }
          resolve(r)
        }
        rejectCode = (e) => {
          clearTimeout(timer)
          reject(e)
        }
      })
    },
    close() {
      server.close()
    },
  }
}

// ─────────────────────────────────────────────────────────────
// Token exchange
// ─────────────────────────────────────────────────────────────

interface RawTokenResponse {
  access_token: string
  refresh_token: string
  id_token?: string
  token_type: string
  expires_in: number
  scope?: string
}

async function postForm(url: string, params: URLSearchParams): Promise<RawTokenResponse> {
  const res = await fetch(url, {
    method: 'POST',
    headers: {
      'content-type': 'application/x-www-form-urlencoded',
      accept: 'application/json',
      'user-agent': 'sandcode-codex-oauth/1',
    },
    body: params.toString(),
  })
  const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
  if (!res.ok) {
    const err = (body.error_description as string) || (body.error as string) || res.statusText
    throw new Error(`Codex OAuth ${res.status}: ${err}`)
  }
  return body as unknown as RawTokenResponse
}

export function decodeJwtPayload(jwt: string): Record<string, unknown> | null {
  const parts = jwt.split('.')
  if (parts.length < 2) return null
  try {
    const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/')
    const pad = b64 + '='.repeat((4 - (b64.length % 4)) % 4)
    const json = Buffer.from(pad, 'base64').toString('utf8')
    return JSON.parse(json) as Record<string, unknown>
  } catch {
    return null
  }
}

function extractAccountId(accessToken: string): string | null {
  const claims = decodeJwtPayload(accessToken)
  if (!claims) return null
  const auth = claims['https://api.openai.com/auth'] as
    | { chatgpt_account_id?: string }
    | undefined
  return auth?.chatgpt_account_id ?? null
}

function buildTokens(raw: RawTokenResponse, previous?: CodexTokens): CodexTokens {
  const now = Date.now()
  return {
    access_token: raw.access_token,
    // Some refresh responses omit refresh_token and expect the caller to
    // keep using the prior one — preserve it.
    refresh_token: raw.refresh_token || previous?.refresh_token || '',
    id_token: raw.id_token ?? previous?.id_token,
    account_id: extractAccountId(raw.access_token) ?? previous?.account_id ?? null,
    expires_at: now + raw.expires_in * 1000,
    token_type: 'Bearer',
    issued_at: previous?.issued_at ?? now,
    last_refresh: now,
  }
}

export async function exchangeCodeForTokens(
  code: string,
  verifier: string,
): Promise<CodexTokens> {
  const raw = await postForm(
    CODEX_TOKEN_URL,
    new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: CODEX_CLIENT_ID,
      code,
      redirect_uri: CODEX_REDIRECT_URI,
      code_verifier: verifier,
    }),
  )
  return buildTokens(raw)
}

export async function refreshTokens(previous: CodexTokens): Promise<CodexTokens> {
  if (!previous.refresh_token) {
    throw new Error('No refresh_token available — please /login-codex again')
  }
  const raw = await postForm(
    CODEX_TOKEN_URL,
    new URLSearchParams({
      grant_type: 'refresh_token',
      client_id: CODEX_CLIENT_ID,
      refresh_token: previous.refresh_token,
      scope: CODEX_SCOPES,
    }),
  )
  return buildTokens(raw, previous)
}

/** Refresh-ahead window: refresh if token expires within this many ms. */
export const REFRESH_AHEAD_MS = 60 * 1000

/** Hard re-auth threshold (OpenAI invalidates refresh tokens after ~28d). */
export const FORCE_RELOGIN_AFTER_MS = 27 * 24 * 60 * 60 * 1000

export function needsRefresh(tokens: CodexTokens, now: number = Date.now()): boolean {
  return tokens.expires_at - now <= REFRESH_AHEAD_MS
}

export function refreshTokenLikelyExpired(tokens: CodexTokens, now: number = Date.now()): boolean {
  return now - tokens.last_refresh >= FORCE_RELOGIN_AFTER_MS
}
