/**
 * lib/auth.js — Pluggable authentication adapter.
 *
 * Branches on MADARX_AUTH_MODE:
 *   'local'    (default) — synthetic single-user, no Supabase required.
 *   'supabase'           — full session enforcement via @supabase/ssr.
 *
 * API routes call requireSession(req) at the top of every handler.
 * Sensitive paths additionally call requireFreshMembership(session, orgId)
 * per RD-1 (hybrid cache + live-check model).
 */

import { createHash, timingSafeEqual } from 'node:crypto';
import net from 'node:net';
import { getDb, nowIso, newId } from './db.js';
import {
  getAuthMode,
  getSupabaseAuthConfig,
  getSupabaseServiceKey,
} from './auth-config.js';
import { claimEmailInvites } from './invites.js';

// Re-export the runtime resolver so existing importers of '@/lib/auth.js' keep
// working. AUTH_MODE is no longer a const — call getAuthMode() at request time.
export { getAuthMode, getSupabaseAuthConfig, getSupabaseServiceKey } from './auth-config.js';

const IS_NEXT_PRODUCTION_BUILD = process.env.NEXT_PHASE === 'phase-production-build';

// Deploy-time guard keys off the explicit env value only (the DB may not be
// open at module load, and the env override is the production source of truth).
if (
  (process.env.MADARX_AUTH_MODE || 'local').toLowerCase() === 'local' &&
  process.env.NODE_ENV === 'production' &&
  !IS_NEXT_PRODUCTION_BUILD
) {
  console.error('[auth] WARNING: MADARX_AUTH_MODE is not set to supabase in NODE_ENV=production. Configure Supabase auth (Settings → Account) or set MADARX_AUTH_MODE=supabase before exposing this deployment.');
}

// ── Local dev synthetic user ──────────────────────────────────────────────────

/**
 * Returns the synthetic single-user session for local dev mode.
 * This is the authoritative shape all session objects must conform to.
 */
export function getLocalDevUser() {
  return {
    user_id: 'local-user-1',
    email: process.env.MADARX_OWNER_EMAIL || 'owner@local.dev',
    display_name: 'Local Owner',
    org_id: 'personal-org',
    role: 'owner',
    auth_mode: 'local',
  };
}

// ── In-process session cache (Supabase mode) ─────────────────────────────────
// Keyed by SHA-256 hash of the access token, 30-second TTL.

const _sessionCache = new Map();
const SESSION_TTL_MS = 30_000;

// Upper bound on the Supabase Auth getUser() round-trip. A hung connection past
// this resolves to "no session" (clean 401) rather than wedging the worker.
const GETUSER_TIMEOUT_MS = 4000;

function _cacheKey(token) {
  return createHash('sha256').update(token).digest('hex');
}

function _cacheGet(token) {
  const key = _cacheKey(token);
  const entry = _sessionCache.get(key);
  if (!entry) return null;
  if (Date.now() > entry.expiresAt) {
    _sessionCache.delete(key);
    return null;
  }
  return entry.session;
}

function _cacheSet(token, session) {
  const key = _cacheKey(token);
  _sessionCache.set(key, { session, expiresAt: Date.now() + SESSION_TTL_MS });
}

// ── CIDR check helper (Node net, no external dep) ────────────────────────────

/**
 * Returns true if the given IPv4 address falls within the CIDR range.
 * Only IPv4 is supported. IPv6 CIDRs are skipped (returns false).
 */
function _ipInCidr(ip, cidr) {
  if (!ip || !cidr) return false;
  const [range, bits] = cidr.split('/');
  if (!range || bits === undefined) return false;
  if (!net.isIPv4(ip) || !net.isIPv4(range)) return false;
  const mask = bits === '' ? 32 : parseInt(bits, 10);
  if (isNaN(mask) || mask < 0 || mask > 32) return false;
  const toInt = (a) => a.split('.').reduce((acc, o) => (acc << 8) | parseInt(o, 10), 0) >>> 0;
  const ipInt = toInt(ip);
  const rangeInt = toInt(range);
  const maskInt = mask === 0 ? 0 : (~0 << (32 - mask)) >>> 0;
  return (ipInt & maskInt) === (rangeInt & maskInt);
}

function _ipInAllowlist(ip, cidrList) {
  if (!Array.isArray(cidrList) || cidrList.length === 0) return true;
  return cidrList.some((cidr) => _ipInCidr(ip, cidr));
}

function _requestIp(req) {
  const xff = req?.headers?.get?.('x-forwarded-for') || req?.headers?.['x-forwarded-for'];
  if (xff) return String(xff).split(',')[0].trim();
  return null;
}

// ── Supabase SSR client (dynamic import, graceful if not installed) ───────────

let _supabaseReady = null; // null = unchecked, false = unavailable, true = available

async function _getSupabaseClient(req) {
  if (_supabaseReady === false) {
    throw new Error(
      '@supabase/ssr is not installed. Run: npm install @supabase/ssr @supabase/supabase-js'
    );
  }

  let createServerClient;
  try {
    const mod = await import('@supabase/ssr');
    createServerClient = mod.createServerClient;
    _supabaseReady = true;
  } catch {
    _supabaseReady = false;
    throw new Error(
      '@supabase/ssr is not installed. Run: npm install @supabase/ssr @supabase/supabase-js'
    );
  }

  const { url, anonKey } = getSupabaseAuthConfig();
  if (!url || !anonKey) {
    throw new Error(
      'Supabase URL and anon key are not configured. Set them in Settings → Account.'
    );
  }

  // Build cookie handlers from the Next.js Request object.
  // @supabase/ssr reads cookies from the request headers.
  const cookieStore = {
    getAll() {
      const cookieHeader = req?.headers?.get?.('cookie') || '';
      return cookieHeader
        .split(';')
        .map((c) => c.trim())
        .filter(Boolean)
        .map((c) => {
          const idx = c.indexOf('=');
          return { name: c.slice(0, idx), value: c.slice(idx + 1) };
        });
    },
    setAll() {
      // Route handlers cannot set cookies on the request; caller must handle
      // Set-Cookie on the response. No-op here — middleware owns cookie refresh.
    },
  };

  return createServerClient(url, anonKey, { cookies: cookieStore });
}

// ── Resolve org membership from SQLite cache ──────────────────────────────────

function _resolveOrgFromDb(userId) {
  const db = getDb();
  const membership = db
    .prepare(
      `SELECT m.org_id, m.role
       FROM org_memberships m
       WHERE m.user_id = ?
       ORDER BY CASE m.role
         WHEN 'owner'  THEN 0
         WHEN 'admin'  THEN 1
         WHEN 'member' THEN 2
         WHEN 'viewer' THEN 3
         ELSE 4
       END
       LIMIT 1`
    )
    .get(userId);
  return membership || null;
}

/**
 * First-login org bootstrap. Only fires on a genuinely fresh install: if ANY
 * membership already exists we do nothing (the user must be invited to an
 * existing org via /admin). Returns the new { org_id, role } or null.
 */
export function _bootstrapFirstOrg(sbUser) {
  try {
    const db = getDb();
    // Exclude the synthetic local owner (seeded by the auth-v1 migration as
    // local-user-1) from the "fresh install" check. The migration ALWAYS seeds
    // that membership, so counting it would permanently disable first-Supabase-
    // login bootstrap — stranding the first real user with a session that has no
    // org membership (role: undefined), which then 403s every owner/admin gate.
    const total = db
      .prepare("SELECT COUNT(*) AS c FROM org_memberships WHERE user_id != 'local-user-1'")
      .get().c;
    if (total > 0) return null; // a real membership exists — invite-only from here

    const now = nowIso();
    const orgId = newId('org');
    const emailLocal = (sbUser.email || 'owner').split('@')[0].replace(/[^a-z0-9-]/gi, '').toLowerCase() || 'owner';
    const slug = `${emailLocal}-org`.slice(0, 60);
    const name = sbUser.user_metadata?.full_name || sbUser.email || 'Personal Org';

    db.prepare(
      `INSERT INTO organizations (id, slug, name, plan, created_at, updated_at)
       VALUES (?, ?, ?, 'free', ?, ?)`
    ).run(orgId, slug, name, now, now);

    db.prepare(
      `INSERT INTO org_memberships (id, org_id, user_id, role, accepted_at)
       VALUES (?, ?, ?, 'owner', ?)`
    ).run(newId('mem'), orgId, sbUser.id, now);

    return { org_id: orgId, role: 'owner' };
  } catch (err) {
    console.warn('[auth] first-login org bootstrap failed:', err.message);
    return null;
  }
}

/**
 * Phase 2 — first-login email-invite claim. Bounded to the no-membership state
 * (getSession calls this only when the user resolves to no org), so the claim
 * RPC equivalent never runs on every request. H3: a claim is honored ONLY for a
 * verified email (auth.users.email_confirmed_at present), matched on a
 * normalized address, to prevent email-squatting. Returns the first claimed
 * { org_id, role } or null.
 */
export async function _claimEmailInvitesAtLogin(sbUser, accessToken) {
  try {
    if (!sbUser?.email || !sbUser?.email_confirmed_at) return null; // H3
    // Cloud-first: a member machine holds the invite only in the cloud. The
    // claim_email_invites() RPC runs under the member's own JWT and re-checks the
    // verified email server-side; the granted membership is mirrored locally.
    try {
      const { claimEmailInvitesViaCloud } = await import('./shared-cloud.js');
      const cloud = await claimEmailInvitesViaCloud({ user_id: sbUser.id, supabase_access_token: accessToken });
      if (cloud.length) return cloud[0];
    } catch {
      // Cloud not configured / RPC absent / project not migrated — fall back.
    }
    // Local claim (owner machine, or the local mirror).
    const claimed = claimEmailInvites(sbUser.id, sbUser.email);
    return claimed.length ? claimed[0] : null;
  } catch (err) {
    console.warn('[auth] email-invite claim failed:', err.message);
    return null;
  }
}

// Canonical membership ordering — mirrors the ORDER BY in _resolveOrgFromDb so
// the cloud-reconcile path and the local-table path pick the same primary org.
const _ROLE_RANK = { owner: 0, admin: 1, member: 2, viewer: 3 };

/**
 * Picks the primary membership from a list, by highest privilege then org_id for
 * a deterministic tiebreak. Returns null for an empty list.
 */
export function _pickPrimaryMembership(memberships) {
  if (!Array.isArray(memberships) || memberships.length === 0) return null;
  return [...memberships].sort((a, b) => {
    const ra = _ROLE_RANK[a.role] ?? 4;
    const rb = _ROLE_RANK[b.role] ?? 4;
    if (ra !== rb) return ra - rb;
    return String(a.org_id).localeCompare(String(b.org_id));
  })[0];
}

/**
 * Mirrors the user's existing cloud org memberships into local SQLite at login.
 * Thin wrapper over shared-cloud's mirror; returns the mirrored memberships
 * (`[{ org_id, role }]`) so the caller can prefer a cloud-confirmed org over a
 * local-only row. Never throws — the cloud is best-effort here, and getSession
 * falls through to the local table / invite claim / bootstrap on an empty list.
 */
export async function _mirrorCloudMembershipsAtLogin(sbUser, accessToken) {
  try {
    const { mirrorCloudMembershipsForUser } = await import('./shared-cloud.js');
    return await mirrorCloudMembershipsForUser({
      user_id: sbUser.id,
      supabase_access_token: accessToken,
    });
  } catch (err) {
    console.warn('[auth] cloud membership mirror failed:', err.message);
    return [];
  }
}

// ── Upsert a user row in the SQLite cache (Supabase sign-in) ─────────────────

function _upsertUserRow(sbUser) {
  const db = getDb();
  const now = nowIso();
  db.prepare(
    `INSERT INTO users (id, email, display_name, avatar_url, auth_provider, last_sign_in_at, created_at)
     VALUES (?, ?, ?, ?, 'supabase', ?, ?)
     ON CONFLICT(id) DO UPDATE SET
       email           = excluded.email,
       display_name    = excluded.display_name,
       avatar_url      = excluded.avatar_url,
       last_sign_in_at = excluded.last_sign_in_at`
  ).run(
    sbUser.id,
    sbUser.email,
    sbUser.user_metadata?.full_name || sbUser.user_metadata?.name || null,
    sbUser.user_metadata?.avatar_url || null,
    now,
    now
  );
}

// ── getSession ────────────────────────────────────────────────────────────────

/**
 * Returns a session object or null.
 * - local mode: always returns getLocalDevUser() (never null).
 * - supabase mode: reads Supabase session cookie; returns null if unauthenticated.
 */
export async function getSession(req, { _client } = {}) {
  if (getAuthMode() === 'local') {
    return getLocalDevUser();
  }

  // Supabase mode: resolve the inbound cookies. API routes pass a Request
  // (headers.get); server components / server actions have no Request, so we
  // fall back to next/headers. Without this fallback, requireSession(null) from
  // a server-component guard sees zero cookies and treats a logged-in user as
  // unauthenticated — bouncing every server-guarded page (/hail, /admin,
  // /settings/account) to /login in supabase mode.
  const cookieHeader = await _resolveCookieHeader(req);
  const accessToken = _extractAccessToken(cookieHeader);
  if (accessToken) {
    const cached = _cacheGet(accessToken);
    if (cached) return cached;
  }

  // Build a Request-shaped stand-in when there is none, so _getSupabaseClient's
  // cookie reader (which reads req.headers.get('cookie')) sees the resolved
  // cookies regardless of caller context.
  const reqForClient = req || {
    headers: {
      get: (name) => (String(name).toLowerCase() === 'cookie' ? cookieHeader : null),
    },
  };

  // Test seam: an injected client bypasses the @supabase/ssr build (mirrors the
  // _loadStore / testConnection seams elsewhere in this module).
  let supabase = _client || null;
  if (!supabase) {
    try {
      supabase = await _getSupabaseClient(reqForClient);
    } catch (err) {
      console.error('[auth] getSession: failed to build Supabase client:', err.message);
      return null;
    }
  }

  // The Supabase Auth round-trip is the one unguarded failure point on the hot
  // path. A *resolved* { error } is handled below, but a REJECTION (transient
  // Auth outage, token-refresh error, rate limit, cold project, DNS/TLS blip, or
  // a hung connection) would otherwise propagate out of getSession →
  // requireSession → the route catch → a bare 500. Fail CLOSED instead: treat any
  // failure as "no session" so requireSession throws a clean 401. The timeout
  // race bounds a hung connection so a stalled Auth endpoint can't wedge the
  // request worker (mirrors requireFreshMembership's 4s live-check budget).
  let user;
  try {
    const result = await Promise.race([
      supabase.auth.getUser(),
      new Promise((_, reject) =>
        setTimeout(() => reject(new Error('getUser timed out')), GETUSER_TIMEOUT_MS)
      ),
    ]);
    if (result?.error || !result?.data?.user) return null;
    user = result.data.user;
  } catch (err) {
    console.error('[auth] getSession: supabase.auth.getUser() failed:', err.message);
    return null; // fail closed → 401, never a 500
  }

  // Upsert local user cache row.
  try {
    _upsertUserRow(user);
  } catch (err) {
    console.warn('[auth] upsertUserRow failed:', err.message);
  }

  // Resolve org membership from SQLite.
  let membership = _resolveOrgFromDb(user.id);

  // Cloud reconcile. In supabase mode the cloud `org_memberships` table is
  // authoritative, so we mirror the user's EXISTING cloud memberships down and
  // prefer a cloud-confirmed org over any local-only row.
  //
  //   • Admin/owner instance (service-role key present): reconcile on EVERY login.
  //     This SELF-HEALS a locally-bootstrapped "phantom" org — one created by
  //     `_bootstrapFirstOrg` during a fresh install or a transient cloud blip,
  //     which has no cloud row and would otherwise make `requireFreshMembership`
  //     lock the user out with `membership_revoked` forever (the phantom local
  //     row stops the lazy mirror from ever re-running).
  //   • Member instance (no service key) / cloud unreachable: stay lazy — only
  //     reconcile when there is no local membership yet, to avoid an extra cloud
  //     round-trip on every login (members get memberships via invite claim).
  const cloudFirst = Boolean(getSupabaseServiceKey());
  if (cloudFirst || !membership) {
    const cloudMemberships = await _mirrorCloudMembershipsAtLogin(user, accessToken);
    if (cloudMemberships.length) {
      // Prefer the cloud-confirmed membership over the (possibly phantom) local
      // row. The phantom row is left in place but is never selected, so the
      // session org always matches what the live-check validates against.
      membership = _pickPrimaryMembership(cloudMemberships) || membership;
    }
  }

  // Phase 2 — claim a pending email pre-invite into the INVITING org before the
  // fresh-install bootstrap. An invited member must land in the org they were
  // invited to, not get bootstrapped into a brand-new personal org.
  if (!membership) {
    membership = await _claimEmailInvitesAtLogin(user, accessToken);
  }

  // First-login bootstrap: when the operator flips to Supabase mode from the UI
  // and signs in for the first time on a fresh install (no memberships at all,
  // and no claimable invite), make them the owner of a personal org so they
  // aren't stranded with no access.
  if (!membership) {
    membership = _bootstrapFirstOrg(user);
  }

  if (!membership) {
    // User has no membership yet — authenticated but not in any org.
    // Return a minimal session so the caller can redirect to onboarding.
    return {
      user_id: user.id,
      email: user.email,
      display_name: user.user_metadata?.full_name || null,
      org_id: null,
      role: null,
      auth_mode: 'supabase',
      supabase_access_token: accessToken,
    };
  }

  const session = {
    user_id: user.id,
    email: user.email,
    display_name: user.user_metadata?.full_name || user.user_metadata?.name || null,
    org_id: membership.org_id,
    role: membership.role,
    auth_mode: 'supabase',
    supabase_access_token: accessToken,
  };

  if (accessToken) {
    _cacheSet(accessToken, session);
  }

  return session;
}

/** Extracts access token from Supabase session cookie (best-effort). */
function _extractAccessToken(cookieHeader) {
  // Supabase SSR stores the session as a JSON-encoded cookie chunked across
  // multiple keys. The access_token is embedded in the JSON value.
  // We just use the matched cookie value as the cache key via SHA-256 so we
  // don't need to parse the JWT format.
  // Returns null (not the full cookie header) when no sb-*-auth-token is found (A7).
  const match = cookieHeader.match(/sb-[^=]+-auth-token(?:-\d+)?=([^;]+)/);
  return match ? decodeURIComponent(match[1]) : null;
}

// ── Cookie resolution (Request vs server-component context) ───────────────────

/** Serializes a next/headers-style cookie store into a `name=value; …` header. */
export function _cookieHeaderFromStore(store) {
  const all = store?.getAll?.() || [];
  return all.map((c) => `${c.name}=${c.value}`).join('; ');
}

/**
 * Resolves the inbound Cookie header for the current call.
 *
 * - API routes pass a Request → read `req.headers.get('cookie')`.
 * - Server components / server actions have no Request → read the request
 *   cookies via next/headers. This is the fix for the /login redirect loop on
 *   server-guarded pages in supabase mode (requireSession(null) previously saw
 *   no cookies and 401'd a logged-in user).
 *
 * @param {Request|null} req
 * @param {() => Promise<{ getAll(): Array<{name,value}> }>} [_loadStore]
 *        Test seam: inject a cookie-store loader. Defaults to next/headers.
 */
export async function _resolveCookieHeader(req, _loadStore) {
  if (req?.headers?.get) {
    return req.headers.get('cookie') || '';
  }
  try {
    const load = _loadStore || (async () => {
      const { cookies } = await import('next/headers');
      return cookies();
    });
    const store = await load();
    return _cookieHeaderFromStore(store);
  } catch {
    // No request scope (e.g. cron/daemon context) — no cookies to resolve.
    return '';
  }
}

// ── requireSession ────────────────────────────────────────────────────────────

/**
 * Returns the session, or throws an HTTP error object.
 *
 * Local mode: never throws (always returns dev user).
 * Supabase mode:
 *   - Throws { status: 401, error: 'unauthenticated' } if no valid session.
 *   - Enforces organizations.session_timeout_seconds if set.
 *   - Enforces organizations.ip_allowlist_json (CIDR check via Node net).
 *     On IP block: writes session.ip_denied audit row, throws 403.
 */
export async function requireSession(req, opts) {
  const session = await getSession(req, opts);

  if (!session) {
    throw { status: 401, error: 'unauthenticated' };
  }

  // Local mode — no further checks.
  if (getAuthMode() === 'local') return session;

  // Supabase mode: org-level policy enforcement.
  if (session.org_id) {
    try {
      const db = getDb();
      const org = db
        .prepare('SELECT ip_allowlist_json, session_timeout_seconds FROM organizations WHERE id = ?')
        .get(session.org_id);

      if (org) {
        // Session timeout enforcement.
        if (org.session_timeout_seconds) {
          const user = db.prepare('SELECT last_sign_in_at FROM users WHERE id = ?').get(session.user_id);
          if (user?.last_sign_in_at) {
            const elapsed = (Date.now() - new Date(user.last_sign_in_at).getTime()) / 1000;
            if (elapsed > org.session_timeout_seconds) {
              throw { status: 401, error: 'session_expired' };
            }
          }
        }

        // IP allowlist enforcement.
        if (org.ip_allowlist_json) {
          let cidrList;
          try {
            cidrList = JSON.parse(org.ip_allowlist_json);
          } catch {
            cidrList = [];
          }
          const ip = _requestIp(req);
          if (!_ipInAllowlist(ip, cidrList)) {
            // Write audit row before throwing.
            try {
              db.prepare(
                `INSERT INTO audit_logs (id, at, type, summary, status, meta, user_id, org_id, ip)
                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
              ).run(
                newId('audit'),
                nowIso(),
                'session.ip_denied',
                `Access denied: IP ${ip} not in allowlist`,
                'denied',
                JSON.stringify({ ip, org_id: session.org_id }),
                session.user_id,
                session.org_id,
                ip
              );
            } catch (auditErr) {
              console.error('[auth] Failed to write ip_denied audit row:', auditErr.message);
            }
            throw { status: 403, error: 'ip_not_allowed' };
          }
        }
      }
    } catch (err) {
      if (err.status) throw err; // re-throw our own HTTP errors
      console.error('[auth] requireSession org policy check failed:', err.message);
    }
  }

  return session;
}

// ── Org onboarding guard (Phase 5, Q3: server-component guard) ─────────────────

/**
 * True when an authenticated user has no org yet and should be routed to
 * /onboarding/org (Create org / Join with code). Pure — no redirect, no DB.
 * Local mode always has the synthetic personal org, so this only fires in
 * supabase mode for a signed-in user whose membership is still null.
 */
export function needsOrgOnboarding(session) {
  return Boolean(session && session.user_id && !session.org_id);
}

/**
 * Server-component guard. Resolves the session, redirects unauthenticated users
 * to /login and org-less users to /onboarding/org; otherwise returns the
 * session. Use at the top of a protected server page.
 */
export async function requireOrgOrRedirect(req, { next: nextPath = '/' } = {}) {
  const session = await getSession(req);
  const { redirect } = await import('next/navigation');
  if (!session) redirect(`/login?next=${encodeURIComponent(nextPath)}`);
  if (needsOrgOnboarding(session)) redirect('/onboarding/org');
  return session;
}

// ── RD-1: In-process membership freshness cache ───────────────────────────────
// Keyed by `${userId}:${orgId}`, 5-minute TTL.
// Entries: { verified_at: number, role: string }

const _membershipCache = new Map();
const MEMBERSHIP_TTL_MS = 5 * 60 * 1000; // 5 minutes

function _membershipCacheKey(userId, orgId) {
  return `${userId}:${orgId}`;
}

function _membershipCacheGet(userId, orgId) {
  const entry = _membershipCache.get(_membershipCacheKey(userId, orgId));
  if (!entry) return null;
  if (Date.now() - entry.verified_at > MEMBERSHIP_TTL_MS) {
    _membershipCache.delete(_membershipCacheKey(userId, orgId));
    return null;
  }
  return entry;
}

function _membershipCacheSet(userId, orgId, role) {
  _membershipCache.set(_membershipCacheKey(userId, orgId), {
    verified_at: Date.now(),
    role,
  });
}

// ── requireFreshMembership (RD-1) ─────────────────────────────────────────────

/**
 * RD-1: Hybrid cache + live check for sensitive actions.
 *
 * Local mode: no-op pass-through (always resolves).
 *
 * Supabase mode (MADARX_AUTH_MODE=supabase):
 *   - Checks in-process LRU keyed by `${userId}:${orgId}` with 5-minute TTL.
 *   - On cache miss: queries Supabase via service-role key to verify
 *     org_memberships row still exists (hard DELETE model — row presence implies active membership).
 *   - Falls back to local SQLite cache if Supabase is unreachable.
 *   - On confirmed revocation: writes 'auth.membership.revoked_detected' audit,
 *     throws { status: 403, error: 'membership_revoked' }.
 *   - On Supabase unavailability: writes 'auth.live_check_unavailable' audit,
 *     throws { status: 503, error: 'supabase_unavailable' } (fail closed).
 *
 * Controlled by MADARX_MEMBERSHIP_LIVE_CHECK (default 'on').
 * Set to 'off' for dev/offline use (membership revocation not detected).
 *
 * @param {{ user_id: string, org_id?: string }} session
 * @param {string} [orgId] — override org from session
 */
export async function requireFreshMembership(session, orgId) {
  if (getAuthMode() === 'local') return; // no-op in local mode

  const liveCheck = (process.env.MADARX_MEMBERSHIP_LIVE_CHECK ?? 'on').toLowerCase();
  if (liveCheck === 'off') {
    console.warn(
      '[auth] MADARX_MEMBERSHIP_LIVE_CHECK=off — membership revocation will not be detected. ' +
      'This mode is for dev/offline use only.'
    );
    return;
  }

  const userId = session?.user_id;
  const targetOrg = orgId || session?.org_id;
  if (!userId || !targetOrg) return; // no identity context to check

  // ── Cache hit: skip live check ────────────────────────────────────────────
  const cached = _membershipCacheGet(userId, targetOrg);
  if (cached) return; // membership confirmed fresh

  // ── Live check via Supabase service-role query ────────────────────────────
  const supabaseUrl = getSupabaseAuthConfig().url;
  const serviceKey = getSupabaseServiceKey();

  if (supabaseUrl && serviceKey) {
    try {
      // Query Supabase REST API directly — no SDK dependency needed.
      // Uses PostgREST filter syntax: user_id=eq.X&org_id=eq.Y
      // hard DELETE in SQLite; matching Supabase row implies active membership
      const url = `${supabaseUrl}/rest/v1/org_memberships` +
        `?user_id=eq.${encodeURIComponent(userId)}` +
        `&org_id=eq.${encodeURIComponent(targetOrg)}` +
        `&select=role` +
        `&limit=1`;

      const resp = await fetch(url, {
        headers: {
          apikey: serviceKey,
          Authorization: `Bearer ${serviceKey}`,
          Accept: 'application/json',
        },
        signal: AbortSignal.timeout(4000), // 4-second timeout
      });

      if (!resp.ok) {
        throw new Error(`Supabase returned HTTP ${resp.status}`);
      }

      const rows = await resp.json();

      if (!Array.isArray(rows) || rows.length === 0) {
        // Confirmed revoked by live check.
        _writeMembershipRevokedAudit(userId, targetOrg);
        throw { status: 403, error: 'membership_revoked', sign_out_required: true };
      }

      // Membership confirmed — refresh cache.
      _membershipCacheSet(userId, targetOrg, rows[0].role);
      return;

    } catch (err) {
      if (err.status) throw err; // re-throw our own HTTP errors (403 above)

      // Supabase unavailable — fail closed (do not allow access).
      console.error('[auth] requireFreshMembership: Supabase live check failed:', err.message);
      _writeLiveCheckUnavailableAudit(userId, targetOrg, err.message);
      throw { status: 503, error: 'supabase_unavailable', message: 'Membership live check unavailable' };
    }
  }

  // ── Supabase not configured — fall back to SQLite local cache ────────────
  // This path runs when SUPABASE_SERVICE_ROLE_KEY is absent (e.g. early setup
  // before keys are migrated to the vault). It is weaker than the live check
  // but still enforces the local membership table.
  try {
    const db = getDb();
    const membership = db
      .prepare(
        `SELECT id, role FROM org_memberships
         WHERE user_id = ? AND org_id = ?
         LIMIT 1`
      )
      .get(userId, targetOrg);

    if (!membership) {
      _writeMembershipRevokedAudit(userId, targetOrg);
      throw { status: 403, error: 'membership_revoked', sign_out_required: true };
    }

    // Cache local hit.
    _membershipCacheSet(userId, targetOrg, membership.role);
  } catch (err) {
    if (err.status) throw err;
    console.error('[auth] requireFreshMembership SQLite fallback failed:', err.message);
    // On DB failure, fail closed.
    _writeLiveCheckUnavailableAudit(userId, targetOrg, err.message);
    throw { status: 503, error: 'supabase_unavailable', message: 'Membership check unavailable' };
  }
}

function _writeMembershipRevokedAudit(userId, orgId) {
  try {
    const db = getDb();
    db.prepare(
      `INSERT INTO audit_logs (id, at, type, summary, status, meta, user_id, org_id)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
    ).run(
      newId('audit'),
      nowIso(),
      'auth.membership.revoked_detected',
      `Membership revoked detected for user ${userId} in org ${orgId}`,
      'denied',
      JSON.stringify({ user_id: userId, org_id: orgId }),
      userId,
      orgId
    );
  } catch (auditErr) {
    console.error('[auth] Failed to write membership_revoked audit row:', auditErr.message);
  }
}

function _writeLiveCheckUnavailableAudit(userId, orgId, reason) {
  try {
    const db = getDb();
    db.prepare(
      `INSERT INTO audit_logs (id, at, type, summary, status, meta, user_id, org_id)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
    ).run(
      newId('audit'),
      nowIso(),
      'auth.live_check_unavailable',
      `Membership live check unavailable for user ${userId} in org ${orgId}`,
      'error',
      JSON.stringify({ user_id: userId, org_id: orgId, reason }),
      userId,
      orgId
    );
  } catch (auditErr) {
    console.error('[auth] Failed to write live_check_unavailable audit row:', auditErr.message);
  }
}
