/**
 * lib/auth-config.js — Runtime (UI-driven) auth configuration.
 *
 * Single source of truth for which auth mode is active and how to reach
 * Supabase. Reads from the `config` key/value table and the encrypted secrets
 * vault so the operator can configure everything from Settings — no env edits,
 * no restart.
 *
 * Precedence:
 *   auth mode  : explicit process.env.MADARX_AUTH_MODE (emergency backstop)
 *                → config['auth_mode'] → 'local'
 *   url/anon   : vault (SUPABASE_URL / SUPABASE_ANON_KEY)
 *                → NEXT_PUBLIC_SUPABASE_* env → SUPABASE_* env
 *   service key: vault (SUPABASE_SERVICE_ROLE_KEY) → env
 *
 * The canonical SUPABASE_* vault names are shared with lib/shared-cloud.js and
 * the supabase tool adapter, so configuring auth here also lights up cloud sync.
 *
 * Server-only: pulls node:sqlite via lib/config.js / lib/secrets.js. Never
 * import this into client code.
 */

import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import { lookup } from 'node:dns/promises';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { getConfig, setConfig } from './config.js';
import { getSecret, setSecret } from './secrets.js';
import { DATA_DIR } from './paths.js';

export const AUTH_MODE_KEY = 'auth_mode';
const VALID_MODES = new Set(['local', 'supabase']);

// ── Recovery token (anti-lockout) ─────────────────────────────────────────────
// A random secret written to a 0600 file under data/. Dropping back to local
// mode without a session requires presenting this token — possession of it
// proves the caller can read the server's local filesystem (i.e. is a genuine
// local operator). This replaces any Host-header / IP heuristic, which is
// client-spoofable.
export const RECOVERY_TOKEN_FILE = path.join(DATA_DIR, '.auth-recovery-token');

export function ensureRecoveryToken() {
  try {
    if (fs.existsSync(RECOVERY_TOKEN_FILE)) {
      const existing = fs.readFileSync(RECOVERY_TOKEN_FILE, 'utf8').trim();
      if (existing) return existing;
    }
    fs.mkdirSync(DATA_DIR, { recursive: true });
    const token = randomBytes(24).toString('hex');
    fs.writeFileSync(RECOVERY_TOKEN_FILE, `${token}\n`, { mode: 0o600 });
    try { fs.chmodSync(RECOVERY_TOKEN_FILE, 0o600); } catch {}
    return token;
  } catch (err) {
    console.warn('[auth-config] ensureRecoveryToken failed:', err.message);
    return null;
  }
}

export function verifyRecoveryToken(provided) {
  if (!provided || typeof provided !== 'string') return false;
  let stored = null;
  try {
    stored = fs.readFileSync(RECOVERY_TOKEN_FILE, 'utf8').trim();
  } catch {
    return false; // no token file → recovery-by-token disabled
  }
  if (!stored) return false;
  const a = Buffer.from(provided.trim());
  const b = Buffer.from(stored);
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}

function envMode() {
  const raw = process.env.MADARX_AUTH_MODE;
  if (raw && String(raw).trim()) return String(raw).trim().toLowerCase();
  return null;
}

/** True when MADARX_AUTH_MODE is explicitly set — the UI cannot override it. */
export function isAuthModeEnvLocked() {
  const m = envMode();
  return Boolean(m && VALID_MODES.has(m));
}

export function getAuthMode() {
  const override = envMode();
  if (override && VALID_MODES.has(override)) return override;
  try {
    const stored = getConfig(AUTH_MODE_KEY, null);
    if (stored && VALID_MODES.has(stored)) return stored;
  } catch {
    // DB not ready (very early boot) — fall through to the safe default.
  }
  return 'local';
}

export function getSupabaseAuthConfig() {
  let url = null;
  let anonKey = null;
  try {
    url = getSecret('SUPABASE_URL');
    anonKey = getSecret('SUPABASE_ANON_KEY');
  } catch {
    // vault not ready — fall through to env.
  }
  url = url || process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL || null;
  anonKey =
    anonKey || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY || null;
  return { url: url || null, anonKey: anonKey || null };
}

export function getSupabaseServiceKey() {
  try {
    const k = getSecret('SUPABASE_SERVICE_ROLE_KEY');
    if (k) return k;
  } catch {
    // vault not ready
  }
  return process.env.SUPABASE_SERVICE_ROLE_KEY || null;
}

export function isSupabaseConfigured() {
  const { url, anonKey } = getSupabaseAuthConfig();
  return Boolean(url && anonKey);
}

export function isServiceKeyConfigured() {
  return Boolean(getSupabaseServiceKey());
}

/**
 * Persists auth config from the UI.
 * - mode → config table.
 * - url / anonKey / serviceKey → vault (global scope) under the canonical
 *   SUPABASE_* names. Empty values are skipped (preserves an existing secret).
 */
export function setAuthConfig({ mode, url, anonKey, serviceKey } = {}) {
  // Persist the vault secrets FIRST. If a setSecret call throws (e.g. the master
  // key isn't loaded), we must NOT have already flipped auth_mode to 'supabase' —
  // that strands the app in supabase mode with no credentials, gating every route
  // behind a login that can't resolve its config (missing_supabase_env).
  if (url && String(url).trim()) setSecret('SUPABASE_URL', String(url).trim(), { source: 'manual' });
  if (anonKey && String(anonKey).trim()) {
    setSecret('SUPABASE_ANON_KEY', String(anonKey).trim(), { source: 'manual' });
  }
  if (serviceKey && String(serviceKey).trim()) {
    setSecret('SUPABASE_SERVICE_ROLE_KEY', String(serviceKey).trim(), { source: 'manual' });
  }

  if (mode !== undefined && mode !== null && mode !== '') {
    const m = String(mode).toLowerCase();
    if (!VALID_MODES.has(m)) throw new Error(`invalid auth mode: ${mode}`);
    if (m === 'supabase') {
      // Never enable the supabase gate without resolvable credentials (from the
      // secrets just written, or already present). Prevents a self-lockout.
      const { url: effUrl, anonKey: effAnon } = getSupabaseAuthConfig();
      if (!effUrl || !effAnon) {
        throw new Error('cannot switch to supabase mode without a configured SUPABASE_URL and SUPABASE_ANON_KEY');
      }
      // Make sure the recovery token exists before the gate can lock anyone out.
      ensureRecoveryToken();
    }
    setConfig(AUTH_MODE_KEY, m);
  }
  return { mode: getAuthMode() };
}

// ── SSRF guard for the connection test ────────────────────────────────────────
// The test target is operator-supplied, so block fetches to loopback, private,
// link-local, and other non-public addresses (e.g. cloud metadata 169.254.169.254).

function ipv4ToInt(ip) {
  return ip.split('.').reduce((acc, o) => (acc << 8) | parseInt(o, 10), 0) >>> 0;
}

function isBlockedIpv4(ip) {
  const n = ipv4ToInt(ip);
  const inCidr = (range, bits) => (n & (bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0)) === (ipv4ToInt(range) & ((bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0)));
  return (
    inCidr('0.0.0.0', 8) ||        // unspecified / this-network
    inCidr('10.0.0.0', 8) ||       // RFC1918
    inCidr('100.64.0.0', 10) ||    // CGNAT
    inCidr('127.0.0.0', 8) ||      // loopback
    inCidr('169.254.0.0', 16) ||   // link-local (incl. cloud metadata)
    inCidr('172.16.0.0', 12) ||    // RFC1918
    inCidr('192.168.0.0', 16) ||   // RFC1918
    inCidr('192.0.0.0', 24) ||     // IETF protocol
    inCidr('198.18.0.0', 15) ||    // benchmarking
    inCidr('224.0.0.0', 4) ||      // multicast
    inCidr('240.0.0.0', 4)         // reserved
  );
}

function isBlockedAddress(ip) {
  const fam = net.isIP(ip);
  if (fam === 4) return isBlockedIpv4(ip);
  if (fam === 6) {
    const lc = ip.toLowerCase();
    if (lc === '::1' || lc === '::') return true;       // loopback / unspecified
    if (lc.startsWith('fe80') || lc.startsWith('fe9') || lc.startsWith('fea') || lc.startsWith('feb')) return true; // link-local fe80::/10
    if (lc.startsWith('fc') || lc.startsWith('fd')) return true; // unique-local fc00::/7
    const m = lc.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/); // IPv4-mapped
    if (m) return isBlockedIpv4(m[1]);
    return false;
  }
  return true; // not a valid IP literal → block
}

/**
 * Validates Supabase URL + anon key against the GoTrue health endpoint.
 * Requires https and a public host (SSRF guard). Returns { ok, error? };
 * never throws and never leaks the upstream status/body to the caller.
 */
export async function testSupabaseConnection({ url, anonKey } = {}) {
  if (!url || !anonKey) return { ok: false, error: 'missing_url_or_key' };
  let parsed;
  try {
    parsed = new URL(url);
  } catch {
    return { ok: false, error: 'invalid_url' };
  }
  if (parsed.protocol !== 'https:') return { ok: false, error: 'must_be_https' };

  // Resolve the host and reject any non-public address (SSRF).
  try {
    const host = parsed.hostname;
    if (net.isIP(host)) {
      if (isBlockedAddress(host)) return { ok: false, error: 'blocked_host' };
    } else {
      const addrs = await lookup(host, { all: true });
      if (!addrs.length || addrs.some((a) => isBlockedAddress(a.address))) {
        return { ok: false, error: 'blocked_host' };
      }
    }
  } catch {
    return { ok: false, error: 'unreachable' };
  }

  try {
    const resp = await fetch(`${parsed.origin}/auth/v1/health`, {
      headers: { apikey: anonKey, Authorization: `Bearer ${anonKey}` },
      redirect: 'error', // block redirect-based SSRF bypass
      signal: AbortSignal.timeout(4000),
    });
    // Collapse upstream detail to a generic result — do not echo status/body.
    return resp.ok ? { ok: true } : { ok: false, error: 'unreachable' };
  } catch {
    return { ok: false, error: 'unreachable' };
  }
}
