/**
 * Codex OAuth PKCE flow for ChatGPT Plus/Pro subscription authentication.
 *
 * Paste-back flow (no local HTTP callback) — the dashboard is typically
 * served from a Pi via Cloudflare tunnel, so a browser-side `localhost:1455`
 * callback can't reach the host running this code. We send the user through
 * OpenAI's auth page; their browser redirects to the (unreachable) callback
 * URL but its URL bar contains the `code`. The user pastes that URL or code
 * back into the wizard, which POSTs it here for token exchange.
 *
 * Credentials are stored in ~/.codex/auth.json in the same shape Codex CLI
 * itself writes, so a spawned `codex app-server` process can use them directly.
 *
 * Shape on disk (chatgpt mode):
 *   {
 *     "OPENAI_API_KEY": null,
 *     "auth_mode": "chatgpt",
 *     "tokens": {
 *       "id_token": "<raw jwt>",
 *       "access_token": "<jwt>",
 *       "refresh_token": "...",
 *       "account_id": "<chatgpt_account_id from id_token claims>"
 *     },
 *     "last_refresh": "2026-05-03T12:34:56.789Z"
 *   }
 */

import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { log } from '../shared/logger.js';

const OAUTH_CONFIG = {
  AUTHORIZE_URL: 'https://auth.openai.com/oauth/authorize',
  TOKEN_URL: 'https://auth.openai.com/oauth/token',
  REDIRECT_URI: 'http://localhost:1455/auth/callback',
  CLIENT_ID: 'app_EMoamEEZ73f0CkXaXp7hrann',
  SCOPES: 'openid profile email offline_access',
};

const AUTH_DIR = path.join(os.homedir(), '.codex');
const AUTH_FILE = path.join(AUTH_DIR, 'auth.json');
const LEGACY_AUTH_FILE = path.join(AUTH_DIR, 'codedeck-auth.json');

/** Refresh access tokens this many ms before they actually expire. */
const REFRESH_LEEWAY_MS = 5 * 60 * 1000;

let codeVerifier: string | null = null;
let oauthState: string | null = null;

interface AuthDotJson {
  OPENAI_API_KEY: string | null;
  auth_mode?: 'apikey' | 'chatgpt' | 'chatgptAuthTokens' | 'agentIdentity';
  tokens?: {
    id_token: string;
    access_token: string;
    refresh_token: string;
    account_id?: string | null;
  };
  last_refresh?: string;
  [key: string]: unknown;
}

/* ── File I/O ── */

function readAuthFile(): AuthDotJson | null {
  try {
    if (!fs.existsSync(AUTH_FILE)) return null;
    return JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8'));
  } catch {
    return null;
  }
}

function writeAuthFile(auth: AuthDotJson): void {
  fs.mkdirSync(AUTH_DIR, { recursive: true });
  fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf-8');
  try { fs.chmodSync(AUTH_FILE, 0o600); } catch {}
}

/** Decode a JWT and return its parsed payload, or null on failure. */
function decodeJwt(token: string): Record<string, any> | null {
  try {
    const parts = token.split('.');
    if (parts.length < 2) return null;
    return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
  } catch {
    return null;
  }
}

/** Read the JWT `exp` claim as a Unix epoch (ms). Null if missing/invalid. */
function jwtExpiryMs(token: string): number | null {
  const payload = decodeJwt(token);
  if (!payload || typeof payload.exp !== 'number') return null;
  return payload.exp * 1000;
}

/** Pull `chatgpt_account_id` out of the id_token JWT claims, if present. */
function extractAccountId(idToken: string): string | undefined {
  const payload = decodeJwt(idToken);
  const authClaims = payload?.['https://api.openai.com/auth'] || {};
  return authClaims.chatgpt_account_id || undefined;
}

/** One-shot migration from the old `codedeck-auth.json` layout (pre-codex-native). */
function migrateLegacyFile(): void {
  if (fs.existsSync(AUTH_FILE)) {
    const existing = readAuthFile();
    if (existing?.tokens?.refresh_token) return;
  }
  if (!fs.existsSync(LEGACY_AUTH_FILE)) return;

  try {
    const legacy = JSON.parse(fs.readFileSync(LEGACY_AUTH_FILE, 'utf-8'));
    if (!legacy.access_token || !legacy.refresh_token) return;

    const existing = readAuthFile() || { OPENAI_API_KEY: null };
    const next: AuthDotJson = {
      ...existing,
      auth_mode: 'chatgpt',
      tokens: {
        id_token: legacy.id_token || '',
        access_token: legacy.access_token,
        refresh_token: legacy.refresh_token,
        account_id: legacy.chatgpt_account_id || extractAccountId(legacy.access_token),
      },
      last_refresh: new Date().toISOString(),
    };
    writeAuthFile(next);
    try { fs.unlinkSync(LEGACY_AUTH_FILE); } catch {}
    log.ok('Codex: migrated legacy codedeck-auth.json → auth.json');
  } catch (err: any) {
    log.warn(`Codex: legacy migration failed — ${err.message}`);
  }
}

/* ── Token exchange & refresh ── */

function storeTokens(tokens: { access_token: string; refresh_token?: string; id_token?: string }): void {
  const existing = readAuthFile() || { OPENAI_API_KEY: null };
  const prev = existing.tokens;
  const idToken = tokens.id_token ?? prev?.id_token ?? '';

  const next: AuthDotJson = {
    ...existing,
    OPENAI_API_KEY: existing.OPENAI_API_KEY ?? null,
    auth_mode: 'chatgpt',
    tokens: {
      id_token: idToken,
      access_token: tokens.access_token,
      refresh_token: tokens.refresh_token ?? prev?.refresh_token ?? '',
      account_id: idToken ? extractAccountId(idToken) ?? prev?.account_id : prev?.account_id,
    },
    last_refresh: new Date().toISOString(),
  };
  writeAuthFile(next);
}

async function refreshTokens(refreshToken: string): Promise<boolean> {
  try {
    log.ok('Codex: refreshing access token...');
    const response = await fetch(OAUTH_CONFIG.TOKEN_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        client_id: OAUTH_CONFIG.CLIENT_ID,
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
      }),
    });
    if (!response.ok) {
      log.warn(`Codex: refresh failed (${response.status})`);
      return false;
    }
    const tokens = await response.json();
    if (!tokens.access_token) {
      log.warn('Codex: refresh response missing access_token');
      return false;
    }
    storeTokens(tokens);
    log.ok('Codex: token refreshed');
    return true;
  } catch (err: any) {
    log.warn(`Codex: refresh error — ${err.message}`);
    return false;
  }
}

/**
 * Parse what the user pasted — accepts:
 *   - the full callback URL (`http://localhost:1455/auth/callback?code=...&state=...`)
 *   - just the query string (`?code=...&state=...` or `code=...&state=...`)
 *   - just the raw code (`ac_XXX...`)
 */
function parsePastedInput(input: string): { code: string; state?: string } | { error: string } {
  const trimmed = input.trim();
  if (!trimmed) return { error: 'Paste the URL or code from your browser.' };

  // Full URL or just a path
  if (/^https?:\/\//i.test(trimmed) || trimmed.startsWith('/')) {
    try {
      const url = new URL(trimmed.startsWith('/') ? `http://x${trimmed}` : trimmed);
      const code = url.searchParams.get('code');
      if (!code) return { error: 'URL is missing the code parameter.' };
      return { code, state: url.searchParams.get('state') || undefined };
    } catch {
      return { error: 'Could not parse the pasted URL.' };
    }
  }

  // Bare query string
  if (trimmed.includes('=') && (trimmed.includes('&') || trimmed.startsWith('?'))) {
    try {
      const qs = trimmed.startsWith('?') ? trimmed.slice(1) : trimmed;
      const params = new URLSearchParams(qs);
      const code = params.get('code');
      if (!code) return { error: 'Query string is missing the code parameter.' };
      return { code, state: params.get('state') || undefined };
    } catch {
      return { error: 'Could not parse the pasted query string.' };
    }
  }

  // Treat as raw code
  return { code: trimmed };
}

/* ── Public API ── */

export function startCodexOAuth(): { success: boolean; authUrl?: string; error?: string } {
  codeVerifier = crypto.randomBytes(32).toString('base64url');
  const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
  oauthState = crypto.randomUUID();

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: OAUTH_CONFIG.CLIENT_ID,
    redirect_uri: OAUTH_CONFIG.REDIRECT_URI,
    scope: OAUTH_CONFIG.SCOPES,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
    state: oauthState,
    id_token_add_organizations: 'true',
    codex_cli_simplified_flow: 'true',
  });

  log.ok('Codex OAuth flow started (paste-back mode)');
  return { success: true, authUrl: `${OAUTH_CONFIG.AUTHORIZE_URL}?${params.toString()}` };
}

export async function exchangeCodexCode(input: string): Promise<{ success: boolean; error?: string }> {
  if (!codeVerifier || !oauthState) {
    return { success: false, error: 'Authentication wasn\'t started. Click "Authenticate" first.' };
  }

  const parsed = parsePastedInput(input);
  if ('error' in parsed) return { success: false, error: parsed.error };

  if (parsed.state && parsed.state !== oauthState) {
    log.warn(`Codex OAuth: state mismatch (got=${parsed.state}, expected=${oauthState})`);
    return {
      success: false,
      error: 'State mismatch — start the flow again from the wizard.',
    };
  }

  try {
    const payload = new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: OAUTH_CONFIG.CLIENT_ID,
      code: parsed.code,
      redirect_uri: OAUTH_CONFIG.REDIRECT_URI,
      code_verifier: codeVerifier,
    });

    const response = await fetch(OAUTH_CONFIG.TOKEN_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: payload.toString(),
    });

    if (!response.ok) {
      const body = await response.text().catch(() => '');
      log.warn(`Codex OAuth exchange failed (${response.status}): ${body.slice(0, 200)}`);
      return {
        success: false,
        error: `Authentication failed (${response.status}). Codes are single-use — start over and paste a fresh one.`,
      };
    }

    const tokens = await response.json();
    if (!tokens.access_token) {
      return { success: false, error: 'OAuth response missing access_token.' };
    }
    storeTokens(tokens);
    codeVerifier = null;
    oauthState = null;
    try { fs.unlinkSync(LEGACY_AUTH_FILE); } catch {}
    log.ok('Codex credentials stored');
    return { success: true };
  } catch (err: any) {
    return { success: false, error: err.message };
  }
}

export function cancelCodexOAuth(): void {
  codeVerifier = null;
  oauthState = null;
}

export async function getCodexAuthStatus(): Promise<{
  authenticated: boolean;
  plan?: string;
  error?: string;
}> {
  try {
    migrateLegacyFile();
    const auth = readAuthFile();
    const tokens = auth?.tokens;
    if (!tokens?.access_token) return { authenticated: false };

    const expMs = jwtExpiryMs(tokens.access_token);
    const valid = expMs ? Date.now() + REFRESH_LEEWAY_MS < expMs : true;

    if (!valid && tokens.refresh_token) {
      const ok = await refreshTokens(tokens.refresh_token);
      if (!ok) return { authenticated: false, error: 'Token expired and refresh failed' };
    }

    const fresh = readAuthFile();
    const idClaims = fresh?.tokens?.id_token ? decodeJwt(fresh.tokens.id_token) : null;
    const plan = idClaims?.['https://api.openai.com/auth']?.chatgpt_plan_type || 'plus';
    return { authenticated: true, plan };
  } catch (err: any) {
    return { authenticated: false, error: err.message };
  }
}

/**
 * Read a valid Codex access token, refreshing if expired. Returns null if unavailable.
 * This is what the Codex harness should call before each app-server invocation.
 */
export async function getCodexAccessToken(): Promise<string | null> {
  migrateLegacyFile();
  const auth = readAuthFile();
  const tokens = auth?.tokens;
  if (!tokens?.access_token) return null;

  const expMs = jwtExpiryMs(tokens.access_token);
  if (!expMs || Date.now() + REFRESH_LEEWAY_MS < expMs) {
    return tokens.access_token;
  }

  if (!tokens.refresh_token) return null;
  const ok = await refreshTokens(tokens.refresh_token);
  if (!ok) return null;

  return readAuthFile()?.tokens?.access_token ?? null;
}

/** Synchronous accessor — returns the stored access token without refreshing. */
export function readCodexAccessToken(): string | null {
  const auth = readAuthFile();
  const token = auth?.tokens?.access_token;
  if (!token) return null;
  const expMs = jwtExpiryMs(token);
  if (expMs && Date.now() >= expMs) return null;
  return token;
}

/* ────────────────────────────────────────────────────────────────────────────
 * Device-code flow — preferred for headless / remote dashboards.
 *
 *   1. POST {AUTH_BASE}/api/accounts/deviceauth/usercode  body {client_id}
 *      → { device_auth_id, user_code, interval }
 *   2. Poll POST {AUTH_BASE}/api/accounts/deviceauth/token body {device_auth_id, user_code}
 *      403/404 = pending, 2xx = { authorization_code, code_challenge, code_verifier }
 *   3. POST {AUTH_BASE}/oauth/token body {grant_type, client_id, code, code_verifier,
 *      redirect_uri="{AUTH_BASE}/deviceauth/callback"} → standard token response
 *
 * The user opens DEVICE_VERIFICATION_URL and types user_code there. We poll
 * in the background; the wizard polls /api/auth/codex/device/status for the
 * current state.
 * ──────────────────────────────────────────────────────────────────────────── */

const AUTH_BASE = 'https://auth.openai.com';
const DEVICE_USER_CODE_URL = `${AUTH_BASE}/api/accounts/deviceauth/usercode`;
const DEVICE_POLL_URL = `${AUTH_BASE}/api/accounts/deviceauth/token`;
const DEVICE_REDIRECT_URI = `${AUTH_BASE}/deviceauth/callback`;
const DEVICE_VERIFICATION_URL = `${AUTH_BASE}/codex/device`;
const DEVICE_TIMEOUT_MS = 15 * 60 * 1000;
const DEVICE_DEFAULT_INTERVAL_SEC = 5;

interface DeviceLoginState {
  /** Bumped on each `startDeviceCodeLogin` so a stale poll loop can self-cancel. */
  generation: number;
  state: 'idle' | 'pending' | 'success' | 'error';
  userCode?: string;
  verificationUrl?: string;
  expiresAt?: number; // epoch ms
  error?: string;
}

let deviceLogin: DeviceLoginState = { generation: 0, state: 'idle' };

export function getDeviceCodeStatus(): {
  state: DeviceLoginState['state'];
  userCode?: string;
  verificationUrl?: string;
  expiresInSec?: number;
  error?: string;
} {
  const expiresInSec = deviceLogin.expiresAt
    ? Math.max(0, Math.round((deviceLogin.expiresAt - Date.now()) / 1000))
    : undefined;
  return {
    state: deviceLogin.state,
    userCode: deviceLogin.userCode,
    verificationUrl: deviceLogin.verificationUrl,
    expiresInSec,
    error: deviceLogin.error,
  };
}

export function cancelDeviceCodeLogin(): void {
  // Bumping the generation makes any in-flight poll loop a no-op on its next tick.
  deviceLogin = { generation: deviceLogin.generation + 1, state: 'idle' };
  log.ok('Codex device-code login cancelled');
}

export async function startDeviceCodeLogin(): Promise<{
  success: boolean;
  userCode?: string;
  verificationUrl?: string;
  error?: string;
}> {
  // Cancel any existing in-flight login before starting a new one.
  const generation = deviceLogin.generation + 1;
  deviceLogin = { generation, state: 'pending' };

  try {
    const res = await fetch(DEVICE_USER_CODE_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'User-Agent': 'bloby-bot',
      },
      body: JSON.stringify({ client_id: OAUTH_CONFIG.CLIENT_ID }),
    });

    if (!res.ok) {
      const body = await res.text().catch(() => '');
      const error = `Failed to request device code (${res.status}). ${body.slice(0, 200)}`;
      deviceLogin = { generation, state: 'error', error };
      log.warn(`Codex device-code start failed: ${error}`);
      return { success: false, error };
    }

    const data = await res.json();
    const userCode: string = data.user_code || data.usercode;
    const deviceAuthId: string = data.device_auth_id;
    const intervalSec: number = Number(data.interval) || DEVICE_DEFAULT_INTERVAL_SEC;
    if (!userCode || !deviceAuthId) {
      const error = 'Device code response missing user_code or device_auth_id.';
      deviceLogin = { generation, state: 'error', error };
      return { success: false, error };
    }

    deviceLogin = {
      generation,
      state: 'pending',
      userCode,
      verificationUrl: DEVICE_VERIFICATION_URL,
      expiresAt: Date.now() + DEVICE_TIMEOUT_MS,
    };
    log.ok(`Codex device-code login started (code=${userCode}, poll every ${intervalSec}s)`);

    // Fire-and-forget background poll.
    void pollDeviceCode(generation, deviceAuthId, userCode, intervalSec);

    return { success: true, userCode, verificationUrl: DEVICE_VERIFICATION_URL };
  } catch (err: any) {
    const error = err?.message || String(err);
    deviceLogin = { generation, state: 'error', error };
    log.warn(`Codex device-code start error: ${error}`);
    return { success: false, error };
  }
}

async function pollDeviceCode(
  generation: number,
  deviceAuthId: string,
  userCode: string,
  intervalSec: number,
): Promise<void> {
  const startedAt = Date.now();

  while (true) {
    // Stale generation = a newer login was started or this one was cancelled.
    if (deviceLogin.generation !== generation) {
      log.ok(`Codex device-code poll for gen ${generation} stopped (superseded)`);
      return;
    }

    if (Date.now() - startedAt > DEVICE_TIMEOUT_MS) {
      deviceLogin = {
        generation,
        state: 'error',
        error: 'Device-code login timed out (15 minutes). Try again.',
      };
      log.warn('Codex device-code login timed out');
      return;
    }

    let res: Response;
    try {
      res = await fetch(DEVICE_POLL_URL, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'User-Agent': 'bloby-bot',
        },
        body: JSON.stringify({ device_auth_id: deviceAuthId, user_code: userCode }),
      });
    } catch (err: any) {
      // Transient network errors — log and keep polling within timeout.
      log.warn(`Codex device-code poll network error: ${err?.message || err}`);
      await sleep(intervalSec * 1000);
      continue;
    }

    if (res.status === 403 || res.status === 404) {
      // Pending — user hasn't approved yet.
      await sleep(intervalSec * 1000);
      continue;
    }

    if (!res.ok) {
      const body = await res.text().catch(() => '');
      const error = `Device-code poll failed (${res.status}). ${body.slice(0, 200)}`;
      if (deviceLogin.generation === generation) {
        deviceLogin = { generation, state: 'error', error };
      }
      log.warn(error);
      return;
    }

    // 2xx — server returned the authorization_code + PKCE verifier.
    let data: { authorization_code?: string; code_verifier?: string };
    try {
      data = await res.json();
    } catch (err: any) {
      const error = `Device-code poll: malformed JSON (${err?.message || err})`;
      if (deviceLogin.generation === generation) {
        deviceLogin = { generation, state: 'error', error };
      }
      return;
    }

    if (!data.authorization_code || !data.code_verifier) {
      const error = 'Device-code poll: response missing authorization_code or code_verifier.';
      if (deviceLogin.generation === generation) {
        deviceLogin = { generation, state: 'error', error };
      }
      return;
    }

    // Final step: exchange for tokens.
    const exchanged = await exchangeDeviceCode(data.authorization_code, data.code_verifier);
    if (deviceLogin.generation !== generation) return;

    if (exchanged.success) {
      deviceLogin = { generation, state: 'success' };
      log.ok('Codex device-code login complete');
    } else {
      deviceLogin = {
        generation,
        state: 'error',
        error: exchanged.error || 'Token exchange failed.',
      };
    }
    return;
  }
}

async function exchangeDeviceCode(
  authorizationCode: string,
  codeVerifierFromServer: string,
): Promise<{ success: boolean; error?: string }> {
  try {
    const payload = new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: OAUTH_CONFIG.CLIENT_ID,
      code: authorizationCode,
      redirect_uri: DEVICE_REDIRECT_URI,
      code_verifier: codeVerifierFromServer,
    });
    const res = await fetch(OAUTH_CONFIG.TOKEN_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'User-Agent': 'bloby-bot',
      },
      body: payload.toString(),
    });
    if (!res.ok) {
      const body = await res.text().catch(() => '');
      log.warn(`Codex device-code token exchange failed (${res.status}): ${body.slice(0, 200)}`);
      return { success: false, error: `Token exchange failed (${res.status}).` };
    }
    const tokens = await res.json();
    if (!tokens.access_token) {
      return { success: false, error: 'Token exchange response missing access_token.' };
    }
    storeTokens(tokens);
    try { fs.unlinkSync(LEGACY_AUTH_FILE); } catch {}
    return { success: true };
  } catch (err: any) {
    return { success: false, error: err?.message || String(err) };
  }
}

function sleep(ms: number): Promise<void> {
  return new Promise((r) => setTimeout(r, ms));
}
