// @ts-nocheck — converted to .cts (runs via Node type-stripping); rich typing deferred (large dynamic OIDC protocol client). TODO: type incrementally.
'use strict';

/**
 * OIDC / authentik integration service.
 *
 * Uses openid-client@5 for full ID token validation including JWKS-backed
 * signature verification, issuer, audience, expiry, and nonce checks.
 *
 * Disabled by default. OIDC settings are resolved from the database first,
 * then environment variables when DB values are blank, then safe defaults.
 * Local username/password login continues to work regardless of this setting.
 *
 * ── authentik setup notes ────────────────────────────────────────────────────
 *
 *  1. In authentik, create an OAuth2/OpenID Provider:
 *       - Name: bill-tracker (or any)
 *       - Client type: Confidential
 *       - Authorization flow: explicit / implicit as appropriate
 *       - Redirect URI: must exactly match the Admin panel Redirect URI
 *         (e.g. https://bills.example.com/api/auth/oidc/callback)
 *
 *  2. Scopes: openid, email, profile, groups
 *     (groups scope is needed for group → role mapping)
 *
 *  3. Set the group claim: authentik sends groups as a flat list in the
 *     "groups" claim. Set the Admin panel admin group to the authentik group
 *     name that should receive the admin role in bill-tracker.
 *
 *  4. Configure OIDC in Admin → Authentication Methods:
 *       Issuer URL, Client ID, Client Secret, Redirect URI, Scopes,
 *       Auto-provision, and Admin group.
 *
 *  5. Optional bootstrap fallback environment variables:
 *       OIDC_ENABLED=true
 *       OIDC_ISSUER_URL=https://auth.example.com/application/o/bill-tracker/
 *       OIDC_CLIENT_ID=<client-id-from-authentik>
 *       OIDC_CLIENT_SECRET=<client-secret-from-authentik>
 *       OIDC_REDIRECT_URI=https://bills.example.com/api/auth/oidc/callback
 *       OIDC_SCOPES="openid email profile groups"
 *       OIDC_ADMIN_GROUP=bill-tracker-admins
 *       OIDC_DEFAULT_ROLE=user
 *       OIDC_AUTO_PROVISION=true
 *       OIDC_PROVIDER_NAME=authentik
 *
 *     DB settings take precedence. Env values are used only while the
 *     corresponding DB value is blank.
 *
 *  6. Keep local login enabled until authentik login is tested and working.
 */

const crypto = require('crypto');
const { Issuer } = require('openid-client');

const { getDb, getSetting, setSetting } = require('../db/database.cts');
const { encryptSecret, decryptSecret } = require('./encryptionService.cts');

// Decrypt the stored OIDC client secret, falling back to plaintext for
// values saved before encryption was introduced (pre-v0.79).
function getOidcClientSecret() {
  const stored = getSetting('oidc_client_secret');
  if (!stored) return '';
  try {
    return decryptSecret(stored);
  } catch {
    return stored; // legacy plaintext — still works until re-saved
  }
}

// ── Configuration ─────────────────────────────────────────────────────────────

function trimOrNull(value) {
  if (value === undefined || value === null) return null;
  const s = String(value).trim();
  return s || null;
}

function settingOrEnv(settingKey, envKey, fallback = null) {
  return trimOrNull(getSetting(settingKey)) || trimOrNull(process.env[envKey]) || fallback;
}

function boolSettingOrEnv(settingKey, envKey, fallback) {
  const dbValue = trimOrNull(getSetting(settingKey));
  if (dbValue !== null) return dbValue === 'true';
  const envValue = trimOrNull(process.env[envKey]);
  if (envValue !== null) return envValue === 'true';
  return fallback;
}

function normalizeScopes(value) {
  const scopes = String(value || 'openid email profile groups')
    .split(/\s+/)
    .filter(Boolean);
  return scopes.length ? scopes : ['openid', 'email', 'profile', 'groups'];
}

function normalizeTokenAuthMethod(value) {
  return value === 'client_secret_post' ? 'client_secret_post' : 'client_secret_basic';
}

/**
 * Returns the effective OIDC config, or null if required settings are missing.
 * DB settings win; env vars are optional fallback/bootstrap defaults.
 * Never returns client_secret in a form intended for clients.
 */
function getOidcConfig() {
  const issuerUrl = settingOrEnv('oidc_issuer_url', 'OIDC_ISSUER_URL');
  const clientId = settingOrEnv('oidc_client_id', 'OIDC_CLIENT_ID');
  const clientSecret = getOidcClientSecret() || trimOrNull(process.env['OIDC_CLIENT_SECRET']);
  const redirectUri = settingOrEnv('oidc_redirect_uri', 'OIDC_REDIRECT_URI');
  const tokenAuthMethod = settingOrEnv(
    'oidc_token_auth_method',
    'OIDC_TOKEN_AUTH_METHOD',
    'client_secret_basic',
  );

  if (!issuerUrl || !clientId || !clientSecret || !redirectUri) {
    return null;
  }

  return {
    enabled: true,
    issuerUrl,
    clientId,
    clientSecret, // server-side only; never returned to clients or logged
    tokenEndpointAuthMethod: normalizeTokenAuthMethod(tokenAuthMethod),
    redirectUri,
    scopes: normalizeScopes(
      settingOrEnv('oidc_scopes', 'OIDC_SCOPES', 'openid email profile groups'),
    ),
    adminGroup: settingOrEnv('oidc_admin_group', 'OIDC_ADMIN_GROUP'),
    defaultRole: 'user',
    autoProvision: boolSettingOrEnv('oidc_auto_provision', 'OIDC_AUTO_PROVISION', true),
    providerName: settingOrEnv('oidc_provider_name', 'OIDC_PROVIDER_NAME', 'authentik'),
  };
}

function getOidcConfigStatus() {
  const issuerUrl = settingOrEnv('oidc_issuer_url', 'OIDC_ISSUER_URL');
  const clientId = settingOrEnv('oidc_client_id', 'OIDC_CLIENT_ID');
  const clientSecret = getOidcClientSecret() || trimOrNull(process.env['OIDC_CLIENT_SECRET']);
  const redirectUri = settingOrEnv('oidc_redirect_uri', 'OIDC_REDIRECT_URI');

  const missing = [];
  if (!issuerUrl) missing.push('issuer URL');
  if (!clientId) missing.push('client ID');
  if (!clientSecret) missing.push('client secret');
  if (!redirectUri) missing.push('redirect URI');

  return {
    oidc_configured: !!(issuerUrl && clientId && clientSecret && redirectUri),
    oidc_issuer_url_set: !!issuerUrl,
    oidc_client_id_set: !!clientId,
    oidc_client_secret_set: !!clientSecret,
    oidc_redirect_uri_set: !!redirectUri,
    oidc_missing_fields: missing,
  };
}

function getAdminOidcSettings() {
  const effectiveConfig = getOidcConfig();
  return {
    oidc_provider_name: settingOrEnv('oidc_provider_name', 'OIDC_PROVIDER_NAME', 'authentik'),
    oidc_issuer_url: settingOrEnv('oidc_issuer_url', 'OIDC_ISSUER_URL', ''),
    oidc_client_id: settingOrEnv('oidc_client_id', 'OIDC_CLIENT_ID', ''),
    oidc_token_auth_method: normalizeTokenAuthMethod(
      settingOrEnv('oidc_token_auth_method', 'OIDC_TOKEN_AUTH_METHOD', 'client_secret_basic'),
    ),
    oidc_redirect_uri: settingOrEnv('oidc_redirect_uri', 'OIDC_REDIRECT_URI', ''),
    oidc_scopes: normalizeScopes(
      settingOrEnv('oidc_scopes', 'OIDC_SCOPES', 'openid email profile groups'),
    ).join(' '),
    oidc_admin_group: settingOrEnv('oidc_admin_group', 'OIDC_ADMIN_GROUP', ''),
    oidc_default_role: 'user',
    oidc_auto_provision: boolSettingOrEnv('oidc_auto_provision', 'OIDC_AUTO_PROVISION', true),
    oidc_client_secret_set: getOidcConfigStatus().oidc_client_secret_set,
    oidc_env_fallback_used: !effectiveConfig
      ? false
      : ['oidc_issuer_url', 'oidc_client_id', 'oidc_client_secret', 'oidc_redirect_uri'].some(
          (k) => !trimOrNull(getSetting(k)),
        ),
  };
}

function trimOrEmpty(value) {
  if (value === undefined || value === null) return '';
  return String(value).trim();
}

function boolSetting(value, fallback) {
  if (value === undefined) return fallback;
  if (typeof value === 'string') return value === 'true';
  return !!value;
}

function serviceError(message, status = 400) {
  return Object.assign(new Error(message), { status });
}

function computeSubmittedOidcConfigured(body = {}) {
  const current = getAdminOidcSettings();
  const next = {
    issuer:
      body.oidc_issuer_url !== undefined
        ? trimOrEmpty(body.oidc_issuer_url)
        : current.oidc_issuer_url,
    clientId:
      body.oidc_client_id !== undefined ? trimOrEmpty(body.oidc_client_id) : current.oidc_client_id,
    redirectUri:
      body.oidc_redirect_uri !== undefined
        ? trimOrEmpty(body.oidc_redirect_uri)
        : current.oidc_redirect_uri,
    clientSecret: current.oidc_client_secret_set ? 'set' : '',
  };

  if (body.oidc_client_secret_clear === true) {
    next.clientSecret = process.env.OIDC_CLIENT_SECRET ? 'set' : '';
  }
  if (trimOrEmpty(body.oidc_client_secret)) {
    next.clientSecret = 'set';
  }

  return !!(next.issuer && next.clientId && next.clientSecret && next.redirectUri);
}

function buildSubmittedOidcConfig(body = {}) {
  const current = getAdminOidcSettings();
  const status = getOidcConfigStatus();

  const issuerUrl =
    body.oidc_issuer_url !== undefined
      ? trimOrEmpty(body.oidc_issuer_url)
      : current.oidc_issuer_url;
  const clientId =
    body.oidc_client_id !== undefined ? trimOrEmpty(body.oidc_client_id) : current.oidc_client_id;
  const redirectUri =
    body.oidc_redirect_uri !== undefined
      ? trimOrEmpty(body.oidc_redirect_uri)
      : current.oidc_redirect_uri;
  const tokenAuthMethod =
    body.oidc_token_auth_method !== undefined
      ? trimOrEmpty(body.oidc_token_auth_method)
      : current.oidc_token_auth_method;
  const scopes =
    body.oidc_scopes !== undefined ? trimOrEmpty(body.oidc_scopes) : current.oidc_scopes;
  const providerName =
    body.oidc_provider_name !== undefined
      ? trimOrEmpty(body.oidc_provider_name)
      : current.oidc_provider_name;

  let clientSecret = status.oidc_client_secret_set ? '__saved__' : '';
  if (body.oidc_client_secret_clear === true) clientSecret = process.env.OIDC_CLIENT_SECRET || '';
  if (trimOrEmpty(body.oidc_client_secret)) clientSecret = trimOrEmpty(body.oidc_client_secret);

  if (!issuerUrl || !clientId || !clientSecret || !redirectUri) return null;

  return {
    enabled: true,
    issuerUrl,
    clientId,
    clientSecret:
      clientSecret === '__saved__'
        ? getOidcClientSecret() || process.env.OIDC_CLIENT_SECRET || ''
        : clientSecret,
    tokenEndpointAuthMethod:
      tokenAuthMethod === 'client_secret_post' ? 'client_secret_post' : 'client_secret_basic',
    redirectUri,
    scopes: (scopes || 'openid email profile groups').split(/\s+/).filter(Boolean),
    adminGroup:
      body.oidc_admin_group !== undefined
        ? trimOrEmpty(body.oidc_admin_group)
        : current.oidc_admin_group,
    defaultRole: 'user',
    autoProvision:
      body.oidc_auto_provision !== undefined
        ? !!body.oidc_auto_provision
        : current.oidc_auto_provision,
    providerName: providerName || 'authentik',
  };
}

function buildAuthModeStatus() {
  const oidcConfigured = getOidcConfigStatus().oidc_configured;
  const localEnabled = getSetting('local_login_enabled') !== 'false';
  const oidcEnabled = getSetting('oidc_login_enabled') === 'true';
  const oidcAdminGroup = getAdminOidcSettings().oidc_admin_group;
  const canDisableLocal = oidcConfigured && oidcEnabled && !!oidcAdminGroup;

  const warnings = [];
  if (!localEnabled && !oidcConfigured) {
    warnings.push('Local login is disabled but OIDC is not configured; users may be locked out.');
  }
  if (!localEnabled && !oidcEnabled) {
    warnings.push('No login method is enabled. Re-enable local login or configure OIDC.');
  }
  if (oidcEnabled && !oidcConfigured) {
    warnings.push(
      'authentik/OIDC login is enabled but configuration is incomplete, so the login button will stay hidden.',
    );
  }
  if (!localEnabled && !oidcAdminGroup) {
    warnings.push('Local login is disabled but no OIDC admin group is configured.');
  }

  return {
    auth_mode: getSetting('auth_mode') || 'multi',
    default_user_id: getSetting('default_user_id') || null,
    local_login_enabled: localEnabled,
    oidc_login_enabled: oidcEnabled,
    oidc_configured: oidcConfigured,
    ...getOidcConfigStatus(),
    ...getAdminOidcSettings(),
    can_disable_local: canDisableLocal,
    warnings,
  };
}

function applyAuthModeSettings(body = {}) {
  const {
    auth_mode,
    default_user_id,
    local_login_enabled,
    oidc_login_enabled,
    oidc_enabled,
    oidc_provider_name,
    oidc_issuer_url,
    oidc_client_id,
    oidc_client_secret,
    oidc_client_secret_clear,
    oidc_token_auth_method,
    oidc_redirect_uri,
    oidc_scopes,
    oidc_auto_provision,
    oidc_admin_group,
    oidc_default_role,
  } = body;

  if (auth_mode !== undefined) {
    if (!['multi', 'single'].includes(auth_mode)) {
      throw serviceError('auth_mode must be "multi" or "single"');
    }
    if (auth_mode === 'single') {
      if (!default_user_id) throw serviceError('default_user_id is required for single mode');
      const u = getDb()
        .prepare("SELECT id FROM users WHERE id=? AND role='user'")
        .get(default_user_id);
      if (!u) throw serviceError('User not found or not a regular user', 404);
    }
  }

  const oidcConfigured = computeSubmittedOidcConfigured(body);
  const nextLocal = boolSetting(local_login_enabled, getSetting('local_login_enabled') !== 'false');
  const requestedOidc = oidc_login_enabled !== undefined ? oidc_login_enabled : oidc_enabled;
  const nextOidc = boolSetting(requestedOidc, getSetting('oidc_login_enabled') === 'true');
  const nextAdminGroup =
    oidc_admin_group !== undefined
      ? trimOrEmpty(oidc_admin_group)
      : getAdminOidcSettings().oidc_admin_group;

  // Single-user mode bypasses the login screen entirely — lockout checks don't apply
  const isSingleMode =
    auth_mode === 'single' || (auth_mode === undefined && getSetting('auth_mode') === 'single');
  if (!isSingleMode) {
    if (!nextLocal && !nextOidc) {
      throw serviceError('Cannot disable all login methods. At least one must remain enabled.');
    }
    if (!nextLocal && !oidcConfigured) {
      throw serviceError('Cannot disable local login until authentik/OIDC is fully configured.');
    }
    if (!nextLocal && !nextAdminGroup) {
      throw serviceError('Cannot disable local login until an OIDC admin group is configured.');
    }
    if (nextOidc && !oidcConfigured) {
      throw serviceError(
        'Cannot enable OIDC login until issuer URL, client ID, client secret, and redirect URI are configured.',
      );
    }
  }

  if (auth_mode !== undefined) {
    if (auth_mode === 'single') setSetting('default_user_id', default_user_id);
    setSetting('auth_mode', auth_mode);
  }
  if (local_login_enabled !== undefined)
    setSetting('local_login_enabled', nextLocal ? 'true' : 'false');
  if (oidc_login_enabled !== undefined || oidc_enabled !== undefined) {
    setSetting('oidc_login_enabled', nextOidc ? 'true' : 'false');
  }

  if (oidc_provider_name !== undefined) {
    const name = String(oidc_provider_name).slice(0, 100).trim();
    if (name) setSetting('oidc_provider_name', name);
  }
  if (oidc_issuer_url !== undefined)
    setSetting('oidc_issuer_url', trimOrEmpty(oidc_issuer_url).slice(0, 500));
  if (oidc_client_id !== undefined)
    setSetting('oidc_client_id', trimOrEmpty(oidc_client_id).slice(0, 500));
  if (oidc_token_auth_method !== undefined) {
    const method =
      oidc_token_auth_method === 'client_secret_post'
        ? 'client_secret_post'
        : 'client_secret_basic';
    setSetting('oidc_token_auth_method', method);
  }
  if (oidc_redirect_uri !== undefined)
    setSetting('oidc_redirect_uri', trimOrEmpty(oidc_redirect_uri).slice(0, 500));
  if (oidc_scopes !== undefined) {
    const scopes =
      trimOrEmpty(oidc_scopes).split(/\s+/).filter(Boolean).join(' ') ||
      'openid email profile groups';
    setSetting('oidc_scopes', scopes.slice(0, 500));
  }
  if (oidc_client_secret_clear === true) setSetting('oidc_client_secret', '');
  if (trimOrEmpty(oidc_client_secret)) {
    setSetting('oidc_client_secret', encryptSecret(trimOrEmpty(oidc_client_secret).slice(0, 1000)));
  }
  if (oidc_auto_provision !== undefined)
    setSetting('oidc_auto_provision', !!oidc_auto_provision ? 'true' : 'false');
  if (oidc_admin_group !== undefined)
    setSetting('oidc_admin_group', String(oidc_admin_group).slice(0, 200).trim());
  if (oidc_default_role !== undefined) {
    setSetting('oidc_default_role', 'user');
  }

  if (
    oidc_issuer_url !== undefined ||
    oidc_client_id !== undefined ||
    oidc_client_secret !== undefined ||
    oidc_client_secret_clear === true ||
    oidc_token_auth_method !== undefined ||
    oidc_redirect_uri !== undefined
  ) {
    invalidateClientCache();
  }

  return { success: true, ...buildAuthModeStatus() };
}

/**
 * Returns whether OIDC login is both configured and enabled by admin.
 */
function isOidcLoginActive() {
  if (getSetting('oidc_login_enabled') !== 'true') return false;
  return getOidcConfig() !== null;
}

/** Safe public OIDC info — no secrets, suitable for unauthenticated responses. */
function getPublicOidcInfo() {
  try {
    const active = isOidcLoginActive();
    if (!active) return { oidc_enabled: false };
    const cfg = getOidcConfig();
    const providerName = cfg?.providerName || 'authentik';
    return {
      oidc_enabled: true,
      oidc_provider_name: providerName,
      oidc_login_url: '/api/auth/oidc/login',
    };
  } catch {
    return { oidc_enabled: false };
  }
}

// ── openid-client Issuer / Client cache ───────────────────────────────────────

// Cache keyed by issuer/client/redirect so Admin config changes pick up a
// fresh client. TTL prevents stale JWKS when the provider rotates keys.
let _cachedClient = null;
let _cachedClientKey = null;
let _cacheTs = 0;
const CLIENT_CACHE_TTL = 60 * 60 * 1000; // 1 hour — JWKS key rotation window

/**
 * Returns a cached openid-client Client for the given config.
 * Performs OIDC discovery (fetching jwks_uri and endpoints) on first call
 * or when the cache has expired. Signature verification uses the discovered
 * JWKS URI automatically via openid-client@5.
 */
async function getOidcClient(config) {
  const now = Date.now();
  const clientKey = [
    config.issuerUrl,
    config.clientId,
    config.redirectUri,
    config.tokenEndpointAuthMethod,
  ].join('|');
  if (_cachedClient && _cachedClientKey === clientKey && now - _cacheTs < CLIENT_CACHE_TTL) {
    return _cachedClient;
  }

  const issuer = await Issuer.discover(config.issuerUrl);
  _cachedClient = new issuer.Client({
    client_id: config.clientId,
    client_secret: config.clientSecret,
    token_endpoint_auth_method: config.tokenEndpointAuthMethod,
    redirect_uris: [config.redirectUri],
    response_types: ['code'],
  });
  _cachedClientKey = clientKey;
  _cacheTs = now;
  return _cachedClient;
}

async function testOidcConfiguration(config = getOidcConfig()) {
  if (!config) {
    return {
      ok: false,
      error: 'OIDC configuration is incomplete.',
      missing: getOidcConfigStatus().oidc_missing_fields,
    };
  }

  try {
    const client = await getOidcClient(config);
    const metadata = client.issuer?.metadata || {};
    const missingMetadata = [];
    if (!metadata.authorization_endpoint) missingMetadata.push('authorization_endpoint');
    if (!metadata.token_endpoint) missingMetadata.push('token_endpoint');
    if (!metadata.jwks_uri) missingMetadata.push('jwks_uri');

    if (missingMetadata.length) {
      return {
        ok: false,
        error: `OIDC discovery is missing required metadata: ${missingMetadata.join(', ')}.`,
      };
    }

    return {
      ok: true,
      issuer: metadata.issuer || config.issuerUrl,
      provider_name: config.providerName,
      redirect_uri: config.redirectUri,
      scopes: config.scopes.join(' '),
    };
  } catch (err) {
    const message = err.message || 'OIDC discovery failed.';
    const looks404 = /404|not found/i.test(message);
    return {
      ok: false,
      error: looks404
        ? 'OIDC discovery returned 404. Check the Issuer URL; it should be the provider issuer, not /authorize/ or another endpoint.'
        : message,
    };
  }
}

/** Invalidates the client cache (e.g. after a config change or key rotation). */
function invalidateClientCache() {
  _cachedClient = null;
  _cachedClientKey = null;
  _cacheTs = 0;
}

// ── PKCE helpers ──────────────────────────────────────────────────────────────

function generateCodeVerifier() {
  return crypto.randomBytes(32).toString('base64url');
}

function generateCodeChallenge(verifier) {
  return crypto.createHash('sha256').update(verifier).digest('base64url');
}

// ── Login state (PKCE + nonce, stored in DB with 5-minute TTL) ───────────────

const STATE_TTL_MS = 5 * 60 * 1000;

function sanitizeRedirectTo(value) {
  if (!value || typeof value !== 'string') return null;
  const s = value.trim();
  if (!s.startsWith('/') || s.startsWith('//') || /[:\0]/.test(s)) return null;
  return s.slice(0, 200);
}

function createLoginState(redirectTo) {
  const db = getDb();
  const id = crypto.randomUUID();
  const nonce = crypto.randomBytes(16).toString('hex');
  const codeVerifier = generateCodeVerifier();
  const now = new Date();
  const expiresAt = new Date(now.getTime() + STATE_TTL_MS).toISOString();

  db.prepare(
    `
    INSERT INTO oidc_states (id, nonce, code_verifier, redirect_to, created_at, expires_at)
    VALUES (?, ?, ?, ?, ?, ?)
  `,
  ).run(id, nonce, codeVerifier, sanitizeRedirectTo(redirectTo), now.toISOString(), expiresAt);

  // Prune stale states to keep the table small
  db.prepare("DELETE FROM oidc_states WHERE expires_at <= datetime('now')").run();

  return { id, nonce, codeVerifier };
}

/** Retrieve and immediately delete — prevents state replay attacks. */
function consumeLoginState(stateId) {
  if (!stateId || typeof stateId !== 'string') return null;
  const db = getDb();
  const row = db
    .prepare(
      `
    SELECT id, nonce, code_verifier, redirect_to
    FROM oidc_states
    WHERE id = ? AND expires_at > datetime('now')
  `,
    )
    .get(stateId);
  if (!row) return null;
  db.prepare('DELETE FROM oidc_states WHERE id = ?').run(stateId);
  return row;
}

// ── Authorization URL ─────────────────────────────────────────────────────────

async function buildAuthorizationUrl(config, state) {
  const client = await getOidcClient(config);
  const codeChallenge = generateCodeChallenge(state.codeVerifier);

  // client.authorizationUrl() uses the discovered authorization_endpoint
  return client.authorizationUrl({
    scope: config.scopes.join(' '),
    redirect_uri: config.redirectUri,
    state: state.id,
    nonce: state.nonce,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  });
}

// ── Token exchange + full ID token verification ───────────────────────────────

/**
 * Exchanges the authorization code for tokens and performs complete ID token
 * validation using openid-client@5:
 *
 *   ✓ State parameter matches saved state (replay protection)
 *   ✓ PKCE code_verifier / code_challenge round-trip
 *   ✓ JWT signature verification using JWKS from OIDC discovery
 *   ✓ Issuer validation (iss claim must match configured issuer URL)
 *   ✓ Audience validation (aud claim must include client_id)
 *   ✓ Expiry validation (exp must be in the future)
 *   ✓ Not-before validation (nbf with 30s clock-skew tolerance)
 *   ✓ Nonce validation (replay attack prevention)
 *   ✓ sub claim present and non-empty
 *
 * Tokens are never logged. Returns verified claims on success.
 */
async function exchangeAndVerifyTokens(config, code, stateId, savedState) {
  const client = await getOidcClient(config);

  // client.callback() handles token exchange + full validation in one step.
  // It throws OPError (provider error) or RPError (validation failure) on any problem.
  const tokenSet = await client.callback(
    config.redirectUri,
    { code, state: stateId }, // parameters from the callback URL
    {
      code_verifier: savedState.code_verifier,
      nonce: savedState.nonce,
      state: stateId, // ensures state param matches expected value
    },
  );

  const claims = tokenSet.claims();

  if (!claims.sub) throw new Error('ID token missing required sub claim');

  return claims;
}

// ── Role / group mapping ──────────────────────────────────────────────────────

/**
 * Maps OIDC group claims to a local role.
 * Admin role is NEVER granted unless the user is explicitly in the configured
 * admin group. Missing group claim → defaults to user role.
 * Default role is always user; admin is granted only by explicit group match.
 */
function mapRoleFromClaims(claims, config) {
  const adminGroup = settingOrEnv('oidc_admin_group', 'OIDC_ADMIN_GROUP') || config.adminGroup;
  const defaultRole = 'user';

  if (!adminGroup) return defaultRole;

  const groups = claims.groups || [];
  const list = Array.isArray(groups) ? groups : [groups].filter(Boolean);

  return list.includes(adminGroup) ? 'admin' : defaultRole;
}

// ── User provisioning ─────────────────────────────────────────────────────────

/**
 * Finds an existing user by OIDC subject, optionally links by verified email,
 * or auto-provisions a new user.
 *
 * Lookup order:
 *  1. auth_provider='oidc' + external_subject=sub  (most stable)
 *  2. email match on existing local user (only if email_verified=true)
 *  3. Auto-provision if OIDC_AUTO_PROVISION=true
 *
 * OIDC-provisioned users have an empty password_hash and cannot use local
 * password login (enforced in authService.login()).
 */
async function findOrProvisionUser(claims, config) {
  const db = getDb();
  const sub = claims.sub;
  const provider = 'oidc';

  const autoProvision = config.autoProvision === true;

  // 1. Stable sub-based lookup
  let user = db
    .prepare('SELECT * FROM users WHERE auth_provider = ? AND external_subject = ?')
    .get(provider, sub);

  // 2. Email-based link — only if email is present and verified
  if (!user && claims.email && claims.email_verified === true) {
    const existing = db
      .prepare("SELECT * FROM users WHERE email = ? AND auth_provider = 'local' LIMIT 1")
      .get(claims.email);
    if (existing) {
      db.prepare(
        `
        UPDATE users
        SET auth_provider = ?, external_subject = ?, email = ?, updated_at = datetime('now')
        WHERE id = ?
      `,
      ).run(provider, sub, claims.email, existing.id);
      user = db.prepare('SELECT * FROM users WHERE id = ?').get(existing.id);
      console.log(`[oidc] Linked existing local account #${user.id} to OIDC identity`);
    }
  }

  // 3. Auto-provision
  if (!user) {
    if (!autoProvision) {
      throw Object.assign(new Error('No matching account and auto-provisioning is disabled.'), {
        status: 403,
      });
    }

    const role = mapRoleFromClaims(claims, config);
    const baseUsername = (claims.preferred_username || claims.email || sub)
      .slice(0, 60)
      .replace(/[^a-zA-Z0-9._@-]/g, '_');
    const displayName = claims.name || claims.preferred_username || null;
    const email = claims.email || null;

    let finalUsername = baseUsername;
    let suffix = 0;
    while (db.prepare('SELECT id FROM users WHERE username = ?').get(finalUsername)) {
      suffix++;
      finalUsername = `${baseUsername}_${suffix}`;
    }

    const result = db
      .prepare(
        `
      INSERT INTO users
        (username, password_hash, role, auth_provider, external_subject,
         email, display_name, first_login, must_change_password, last_login_at)
      VALUES (?, '', ?, ?, ?, ?, ?, 0, 0, datetime('now'))
    `,
      )
      .run(finalUsername, role, provider, sub, email, displayName);

    user = db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid);
    console.log(`[oidc] Provisioned new ${role} user from OIDC login`);
  }

  if (user.active === 0) {
    throw Object.assign(new Error('This account is inactive.'), { status: 403 });
  }

  // Refresh login timestamp; only seed display_name from provider on first login
  // (display_name IS NULL). Once the user has set one manually, preserve it.
  db.prepare(
    `
    UPDATE users
    SET last_login_at = datetime('now'),
        display_name  = CASE WHEN display_name IS NULL THEN ? ELSE display_name END,
        updated_at    = datetime('now')
    WHERE id = ?
  `,
  ).run(claims.name || null, user.id);

  return db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
}

module.exports = {
  applyAuthModeSettings,
  buildAuthModeStatus,
  buildSubmittedOidcConfig,
  computeSubmittedOidcConfigured,
  getOidcConfig,
  getOidcConfigStatus,
  getAdminOidcSettings,
  isOidcLoginActive,
  getPublicOidcInfo,
  getOidcClient,
  testOidcConfiguration,
  invalidateClientCache,
  createLoginState,
  consumeLoginState,
  buildAuthorizationUrl,
  exchangeAndVerifyTokens,
  mapRoleFromClaims,
  findOrProvisionUser,
  // Exported for testing
  generateCodeVerifier,
  generateCodeChallenge,
  sanitizeRedirectTo,
};
