// Runtime authorization for gscAdmin.
//
// Two sources of authority, evaluated in this order:
//   1. Base roles in the JWT — set at login from the gosecCloud realm
//      `gsc-roles` LDAP mapper (FreeIPA group → realm role). These are
//      always-on permissions; static for the life of the session.
//   2. Active JIT grants in `admin.privilege_grants` — written by the
//      PAM request flow, expire after the policy's max_duration_hours.
//      DB-backed so privileges activate/revoke without forcing a
//      relogin. Cached in-process for 15 s to keep the hot path cheap.
//
// See docs/pam-plan.md for the broader architecture.

import { prisma } from "@/database/prisma";
import type { SessionUser } from "@/auth";

// --- Errors ----------------------------------------------------------

export class ForbiddenError extends Error {
  constructor(public readonly role: string) {
    super(`Forbidden: missing role '${role}'`);
    this.name = "ForbiddenError";
  }
}

// --- In-process TTL cache --------------------------------------------
//
// Single map shared across requests in this Node process. Key is the
// `gscsid:role` pair; value is the boolean grant + the epoch ms when
// the entry expires. We accept ≤15 s of staleness for revocations and
// new grants — the plan locks this in as decision #4.

const CACHE_TTL_MS = 15_000;

type CacheEntry = { hit: boolean; expiresAt: number };
const cache = new Map<string, CacheEntry>();

function cacheKey(gscsid: string, role: string): string {
  return `${gscsid}:${role}`;
}

function readCache(gscsid: string, role: string): boolean | undefined {
  const key = cacheKey(gscsid, role);
  const entry = cache.get(key);
  if (!entry) return undefined;
  if (entry.expiresAt <= Date.now()) {
    cache.delete(key);
    return undefined;
  }
  return entry.hit;
}

function writeCache(gscsid: string, role: string, hit: boolean): void {
  cache.set(cacheKey(gscsid, role), {
    hit,
    expiresAt: Date.now() + CACHE_TTL_MS,
  });
}

/**
 * Invalidate any cached authz decisions for a single grant. Call this
 * right after writing/revoking a grant so the issuing pod sees the
 * change immediately (other pods catch up within the TTL).
 */
export function invalidateAuthzCache(gscsid: string, role: string): void {
  cache.delete(cacheKey(gscsid, role));
}

// --- DB lookup -------------------------------------------------------

async function hasActiveGrant(gscsid: string, role: string): Promise<boolean> {
  const cached = readCache(gscsid, role);
  if (cached !== undefined) return cached;

  const grant = await prisma.privilegeGrant.findFirst({
    where: {
      gscsid,
      roleName: role,
      status: "active",
      expiresAt: { gt: new Date() },
    },
    select: { id: true },
  });

  const hit = grant !== null;
  writeCache(gscsid, role, hit);
  return hit;
}

// --- Public API ------------------------------------------------------

/**
 * True if the user holds `role` either as a base realm role or via an
 * active JIT grant. Safe to call on every privileged code path —
 * single indexed DB hit at most every 15 s per (user, role).
 */
// Identity for PAM grants is the gscSID — see lib/session-helpers.ts
// and the gsc-identity-boundaries memory. The kit's session.user.id
// is a NextAuth UUID and must NOT be used as a cross-service key.
type AuthzUser = Pick<SessionUser, "roles"> & { gscSid?: string };

export async function hasRole(
  user: AuthzUser | null | undefined,
  role: string,
): Promise<boolean> {
  const gscsid = user?.gscSid;
  if (!gscsid) return false;
  if (user!.roles.includes(role)) return true;
  return await hasActiveGrant(gscsid, role);
}

/**
 * Throws ForbiddenError if the user lacks `role`. Use in server
 * actions / route handlers / pages where missing access should 403.
 */
export async function requireRole(
  user: AuthzUser | null | undefined,
  role: string,
): Promise<void> {
  if (!(await hasRole(user, role))) {
    throw new ForbiddenError(role);
  }
}

/**
 * Page-friendly variant: if the user can't reach `role`, bounce them
 * to /access?need=<role> instead of throwing.
 */
export async function requireElevation(
  user: AuthzUser | null | undefined,
  role: string,
): Promise<void> {
  if (await hasRole(user, role)) return;
  const { redirect } = await import("next/navigation");
  redirect(`/access?need=${encodeURIComponent(role)}`);
}

/**
 * Variant: true if the user holds *any* of the listed roles. Useful
 * for "operator OR admin" gates.
 */
export async function hasAnyRole(
  user: AuthzUser | null | undefined,
  roles: readonly string[],
): Promise<boolean> {
  for (const r of roles) {
    if (await hasRole(user, r)) return true;
  }
  return false;
}

/**
 * Variant of requireRole that admits any of the listed roles.
 */
export async function requireAnyRole(
  user: AuthzUser | null | undefined,
  roles: readonly string[],
): Promise<void> {
  if (!(await hasAnyRole(user, roles))) {
    throw new ForbiddenError(roles.join(" | "));
  }
}

/**
 * Roles the user can JIT-elevate to right now. Derived from the
 * `*_eligible` realm roles already in the JWT — no DB hit required.
 * Each entry is the *target* role (without the `_eligible` suffix).
 *
 * Example: JWT has `gsccrm_admins_eligible` → returns `["gsccrm_admins"]`.
 */
export function eligibleRoles(user: Pick<SessionUser, "roles"> | null | undefined): string[] {
  if (!user?.roles) return [];
  return user.roles
    .filter((r) => r.endsWith("_eligible"))
    .map((r) => r.slice(0, -"_eligible".length));
}
