// lib/oauth/flow.js — OAuth2 authorization-code flow primitives.
//
// Local-first and provider-agnostic. Three responsibilities:
//   1. CSRF state — a single-use, server-stored random state value (oauth_states
//      table). createAuthState() issues one; consumeAuthState() validates +
//      burns it exactly once. Replay or expiry is rejected.
//   2. PKCE — S256 code_verifier/code_challenge generation (RFC 7636).
//   3. Token I/O — exchangeCodeForTokens() and refreshAccessToken(), both taking
//      an injectable fetchFn so tests use a mock and never hit a live provider.
//
// SECURITY: this module never persists access/refresh tokens — that is the
// caller's job (the vault, via setSecret). The state ledger stores only the PKCE
// verifier and non-secret scope metadata. Tokens never appear in audit/log here.

import { createHash, randomBytes } from 'node:crypto';
import { getDb, nowIso } from '../db.js';

const DEFAULT_TTL_MS = 10 * 60 * 1000; // 10 minutes

function base64url(buf) {
  return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

/** Generate a PKCE verifier + S256 challenge pair. */
export function generatePkce() {
  const verifier = base64url(randomBytes(32));
  const challenge = base64url(createHash('sha256').update(verifier).digest());
  return { code_verifier: verifier, code_challenge: challenge, code_challenge_method: 'S256' };
}

/**
 * Issue a single-use state value and persist it (with the PKCE verifier and
 * scope metadata) so the callback can validate the round-trip.
 *
 * @returns {{ state, code_verifier?, code_challenge?, code_challenge_method? }}
 */
export function createAuthState({ service_key, provider, scope = null, redirect_uri = null, pkce = false, ttl_ms = DEFAULT_TTL_MS } = {}) {
  if (!service_key) throw new Error('service_key is required');
  const db = getDb();
  const state = base64url(randomBytes(24));
  const now = Date.now();
  const created_at = nowIso();
  const expires_at = new Date(now + (Number(ttl_ms) || DEFAULT_TTL_MS)).toISOString();

  let pkceParts = null;
  if (pkce) pkceParts = generatePkce();

  db.prepare(`
    INSERT INTO oauth_states (state, service_key, provider, scope_json, code_verifier, redirect_uri, created_at, expires_at, consumed_at)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)
  `).run(
    state,
    String(service_key),
    String(provider || 'custom'),
    scope ? JSON.stringify(scope) : null,
    pkceParts?.code_verifier || null,
    redirect_uri || null,
    created_at,
    expires_at,
  );

  return {
    state,
    code_verifier: pkceParts?.code_verifier || null,
    code_challenge: pkceParts?.code_challenge || null,
    code_challenge_method: pkceParts?.code_challenge_method || null,
  };
}

/**
 * Validate + burn a state value. Returns the stored row (scope + verifier) on
 * success, or throws. Single-use: a second call with the same state fails.
 */
export function consumeAuthState(state) {
  if (!state) { const e = new Error('missing state'); e.code = 'state_invalid'; throw e; }
  const db = getDb();
  const row = db.prepare('SELECT * FROM oauth_states WHERE state = ?').get(String(state));
  if (!row) { const e = new Error('unknown or already-consumed state'); e.code = 'state_invalid'; throw e; }
  if (row.consumed_at) { const e = new Error('state already used'); e.code = 'state_replayed'; throw e; }
  if (row.expires_at && Date.parse(row.expires_at) < Date.now()) {
    const e = new Error('state expired'); e.code = 'state_expired'; throw e;
  }
  // Burn it. The WHERE clause guards against a concurrent double-consume.
  const res = db.prepare('UPDATE oauth_states SET consumed_at = ? WHERE state = ? AND consumed_at IS NULL')
    .run(nowIso(), String(state));
  if (res.changes !== 1) { const e = new Error('state already used'); e.code = 'state_replayed'; throw e; }

  let scope = null;
  try { scope = row.scope_json ? JSON.parse(row.scope_json) : null; } catch { scope = null; }
  return {
    state: row.state,
    service_key: row.service_key,
    provider: row.provider,
    scope,
    code_verifier: row.code_verifier || null,
    redirect_uri: row.redirect_uri || null,
  };
}

/**
 * Build the provider authorize URL the "Sign in with…" button redirects to.
 *
 * @param {object} provider  resolved provider (resolveProvider output)
 * @param {object} opts      { client_id, redirect_uri, state, code_challenge }
 */
export function buildAuthorizeUrl(provider, opts = {}) {
  if (!provider?.authorize_url) throw new Error('provider.authorize_url required');
  if (!opts.client_id) throw new Error('client_id required');
  if (!opts.redirect_uri) throw new Error('redirect_uri required');
  const url = new URL(provider.authorize_url);
  url.searchParams.set('response_type', 'code');
  url.searchParams.set('client_id', String(opts.client_id));
  url.searchParams.set('redirect_uri', String(opts.redirect_uri));
  if (provider.scopes?.length) url.searchParams.set('scope', provider.scopes.join(' '));
  if (opts.state) url.searchParams.set('state', String(opts.state));
  if (provider.pkce && opts.code_challenge) {
    url.searchParams.set('code_challenge', String(opts.code_challenge));
    url.searchParams.set('code_challenge_method', 'S256');
  }
  for (const [k, v] of Object.entries(provider.extra_auth_params || {})) {
    if (v != null) url.searchParams.set(k, String(v));
  }
  return url.toString();
}

function parseTokenResponse(data) {
  if (!data || typeof data !== 'object') return null;
  const access_token = data.access_token || null;
  if (!access_token) return null;
  return {
    access_token,
    refresh_token: data.refresh_token || null,
    token_type: data.token_type || 'Bearer',
    expires_in: Number.isFinite(data.expires_in) ? data.expires_in : null,
    scope: data.scope || null,
  };
}

async function postForm(tokenUrl, params, fetchFn) {
  const body = new URLSearchParams(params).toString();
  const res = await fetchFn(tokenUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
    body,
  });
  let data = null;
  try { data = await res.json(); } catch { data = null; }
  return { res, data };
}

/**
 * Exchange an authorization code for tokens.
 *
 * @param {object} provider  resolved provider config (needs token_url)
 * @param {object} opts      { code, client_id, client_secret, redirect_uri, code_verifier }
 * @param {Function} fetchFn injectable fetch (tests pass a mock)
 */
export async function exchangeCodeForTokens(provider, opts = {}, fetchFn = fetch) {
  if (!provider?.token_url) throw new Error('provider.token_url required');
  if (!opts.code) throw new Error('code required');
  const params = {
    grant_type: 'authorization_code',
    code: opts.code,
    client_id: opts.client_id || '',
    redirect_uri: opts.redirect_uri || '',
  };
  if (opts.client_secret) params.client_secret = opts.client_secret;
  if (opts.code_verifier) params.code_verifier = opts.code_verifier;

  const { res, data } = await postForm(provider.token_url, params, fetchFn);
  if (!res.ok) {
    return { ok: false, error: 'token_exchange_failed', http_status: res.status, detail: data?.error || null };
  }
  const tokens = parseTokenResponse(data);
  if (!tokens) return { ok: false, error: 'no_access_token' };
  return { ok: true, ...tokens };
}

/**
 * Re-mint an access token from a refresh token. Used by http_api_call on a 401.
 *
 * @param {object} provider  resolved provider config (needs token_url)
 * @param {object} opts      { refresh_token, client_id, client_secret }
 * @param {Function} fetchFn injectable fetch
 */
export async function refreshAccessToken(provider, opts = {}, fetchFn = fetch) {
  if (!provider?.token_url) throw new Error('provider.token_url required');
  if (!opts.refresh_token) throw new Error('refresh_token required');
  const params = {
    grant_type: 'refresh_token',
    refresh_token: opts.refresh_token,
    client_id: opts.client_id || '',
  };
  if (opts.client_secret) params.client_secret = opts.client_secret;

  const { res, data } = await postForm(provider.token_url, params, fetchFn);
  if (!res.ok) {
    return { ok: false, error: 'refresh_failed', http_status: res.status, detail: data?.error || null };
  }
  const tokens = parseTokenResponse(data);
  if (!tokens) return { ok: false, error: 'no_access_token' };
  // Providers often omit a new refresh_token on refresh — keep the old one.
  return { ok: true, ...tokens, refresh_token: tokens.refresh_token || opts.refresh_token };
}
