/**
 * OAuth / OpenID Connect configuration for Redlight.
 *
 * Supports generic OIDC providers (Keycloak, Authentik, Google, GitHub, etc.)
 * configured at runtime via admin settings stored in the database.
 *
 * Security:
 * - PKCE (S256) on every authorization request
 * - Anti-CSRF via cryptographic `state` parameter stored server-side
 * - State entries expire after 10 minutes and are single-use
 * - Client secrets are stored AES-256-GCM encrypted in the DB
 * - Only https callback URLs in production
 * - Token exchange uses server-side secret, never exposed to the browser
 */

import crypto from 'crypto';
import { getDb } from './database.js';
import { log } from './logger.js';

// ── Encryption helpers for client secrets ──────────────────────────────────
// Derive a key from JWT_SECRET (always available)
const ENCRYPTION_KEY = crypto
  .createHash('sha256')
  .update(process.env.JWT_SECRET || '')
  .digest(); // 32 bytes → AES-256

/**
 * Encrypt a plaintext string with AES-256-GCM.
 * Returns "iv:authTag:ciphertext" (all hex-encoded).
 */
export function encryptSecret(plaintext) {
  const iv = crypto.randomBytes(12);
  const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
  let encrypted = cipher.update(plaintext, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  const authTag = cipher.getAuthTag().toString('hex');
  return `${iv.toString('hex')}:${authTag}:${encrypted}`;
}

/**
 * Decrypt an AES-256-GCM encrypted string.
 */
export function decryptSecret(encryptedStr) {
  const [ivHex, authTagHex, ciphertext] = encryptedStr.split(':');
  const iv = Buffer.from(ivHex, 'hex');
  const authTag = Buffer.from(authTagHex, 'hex');
  const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
  decipher.setAuthTag(authTag);
  let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
  decrypted += decipher.final('utf8');
  return decrypted;
}

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

/** Generate a cryptographically random code_verifier (RFC 7636). */
export function generateCodeVerifier() {
  return crypto.randomBytes(32).toString('base64url');
}

/** Compute the S256 code_challenge from a code_verifier. */
export function computeCodeChallenge(verifier) {
  return crypto.createHash('sha256').update(verifier).digest('base64url');
}

// ── State management (anti-CSRF) ───────────────────────────────────────────

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

/**
 * Create and persist an OAuth state token with associated PKCE verifier.
 * @param {string} provider – provider key (e.g. 'oidc')
 * @param {string} codeVerifier – PKCE code_verifier
 * @param {string|null} returnTo – optional return URL after login
 * @returns {Promise<string>} state token
 */
export async function createOAuthState(provider, codeVerifier, returnTo = null) {
  const state = crypto.randomBytes(32).toString('hex');
  const expiresAt = new Date(Date.now() + STATE_TTL_MS).toISOString();
  const db = getDb();
  await db.run(
    'INSERT INTO oauth_states (state, provider, code_verifier, return_to, expires_at) VALUES (?, ?, ?, ?, ?)',
    [state, provider, codeVerifier, returnTo, expiresAt],
  );
  return state;
}

/**
 * Consume (validate + delete) an OAuth state token.
 * Returns the stored data or null if invalid/expired.
 * @param {string} state
 * @returns {Promise<{ provider: string, code_verifier: string, return_to: string|null } | null>}
 */
export async function consumeOAuthState(state) {
  if (!state || typeof state !== 'string' || state.length > 128) return null;
  const db = getDb();
  const row = await db.get(
    'SELECT * FROM oauth_states WHERE state = ?',
    [state],
  );
  if (!row) return null;

  // Always delete (single-use)
  await db.run('DELETE FROM oauth_states WHERE state = ?', [state]);

  // Check expiry
  if (new Date(row.expires_at) < new Date()) return null;

  return {
    provider: row.provider,
    code_verifier: row.code_verifier,
    return_to: row.return_to,
  };
}

/**
 * Garbage-collect expired OAuth states (called periodically).
 */
export async function cleanupExpiredStates() {
  try {
    const db = getDb();
    await db.run('DELETE FROM oauth_states WHERE expires_at < CURRENT_TIMESTAMP');
  } catch (err) {
    log.auth.warn(`OAuth state cleanup failed: ${err.message}`);
  }
}

// ── Provider configuration ─────────────────────────────────────────────────

/**
 * Load the stored OAuth provider config from the settings table.
 * Returns null if OAuth is not configured.
 * @returns {Promise<{ issuer: string, clientId: string, clientSecret: string, displayName: string, autoRegister: boolean } | null>}
 */
export async function getOAuthConfig() {
  try {
    const db = getDb();
    const row = await db.get("SELECT value FROM settings WHERE key = 'oauth_config'");
    if (!row?.value) return null;

    const config = JSON.parse(row.value);
    if (!config.issuer || !config.clientId || !config.encryptedSecret) return null;

    return {
      issuer: config.issuer,
      clientId: config.clientId,
      clientSecret: decryptSecret(config.encryptedSecret),
      displayName: config.displayName || 'SSO',
      autoRegister: config.autoRegister !== false,
    };
  } catch (err) {
    log.auth.error(`Failed to load OAuth config: ${err.message}`);
    return null;
  }
}

/**
 * Save OAuth provider config to the settings table.
 * The client secret is encrypted before storage.
 */
export async function saveOAuthConfig({ issuer, clientId, clientSecret, displayName, autoRegister }) {
  const db = getDb();
  const config = {
    issuer,
    clientId,
    encryptedSecret: encryptSecret(clientSecret),
    displayName: displayName || 'SSO',
    autoRegister: autoRegister !== false,
  };
  const value = JSON.stringify(config);

  const existing = await db.get("SELECT key FROM settings WHERE key = 'oauth_config'");
  if (existing) {
    await db.run("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = 'oauth_config'", [value]);
  } else {
    await db.run("INSERT INTO settings (key, value) VALUES ('oauth_config', ?) RETURNING key", [value]);
  }
}

/**
 * Remove OAuth configuration.
 */
export async function deleteOAuthConfig() {
  const db = getDb();
  await db.run("DELETE FROM settings WHERE key = 'oauth_config'");
}

// ── OIDC Discovery ─────────────────────────────────────────────────────────

// Cache discovered OIDC endpoints { authorization_endpoint, token_endpoint, userinfo_endpoint, ... }
const discoveryCache = new Map();
const DISCOVERY_TTL_MS = 30 * 60 * 1000; // 30 minutes

/**
 * Fetch and cache the OpenID Connect discovery document for the given issuer.
 * @param {string} issuer
 * @returns {Promise<object>}
 */
export async function discoverOIDC(issuer) {
  const cached = discoveryCache.get(issuer);
  if (cached && Date.now() - cached.fetchedAt < DISCOVERY_TTL_MS) {
    return cached.data;
  }

  // Normalize issuer URL
  const base = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer;
  const url = `${base}/.well-known/openid-configuration`;

  const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
  if (!response.ok) {
    throw new Error(`OIDC discovery failed for ${issuer}: HTTP ${response.status}`);
  }

  const data = await response.json();
  if (!data.authorization_endpoint || !data.token_endpoint) {
    throw new Error(`OIDC discovery response missing required endpoints`);
  }

  discoveryCache.set(issuer, { data, fetchedAt: Date.now() });
  return data;
}

/**
 * Exchange an authorization code for tokens.
 * @param {object} oidcConfig – discovery document
 * @param {string} code
 * @param {string} redirectUri
 * @param {string} clientId
 * @param {string} clientSecret
 * @param {string} codeVerifier – PKCE verifier
 * @returns {Promise<object>} token response
 */
export async function exchangeCode(oidcConfig, code, redirectUri, clientId, clientSecret, codeVerifier) {
  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    redirect_uri: redirectUri,
    client_id: clientId,
    client_secret: clientSecret,
    code_verifier: codeVerifier,
  });

  const response = await fetch(oidcConfig.token_endpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: body.toString(),
    signal: AbortSignal.timeout(15_000),
  });

  if (!response.ok) {
    const errText = await response.text().catch(() => '');
    throw new Error(`Token exchange failed: HTTP ${response.status} – ${errText.slice(0, 200)}`);
  }

  return response.json();
}

/**
 * Fetch user info from the provider's userinfo endpoint.
 * @param {string} userInfoUrl
 * @param {string} accessToken
 * @returns {Promise<object>}
 */
export async function fetchUserInfo(userInfoUrl, accessToken) {
  const response = await fetch(userInfoUrl, {
    headers: { Authorization: `Bearer ${accessToken}` },
    signal: AbortSignal.timeout(10_000),
  });

  if (!response.ok) {
    throw new Error(`UserInfo fetch failed: HTTP ${response.status}`);
  }

  return response.json();
}
