import { FUNCTIONAL_ROLES, isFunctionalRole, type FunctionalRole } from './authorization.types';

/**
 * One entry in the GUID → role-slug map. Surfaced as an explicit
 * type so callers can build the map programmatically (e.g. tests,
 * a future Microsoft Graph sync) without resorting to a bare
 * `Record<string, string>` that loses the closed-set guarantee.
 */
export interface EntraGroupMapEntry {
  readonly groupId: string;
  readonly role: FunctionalRole;
}

/**
 * The resolved map keyed on Entra group GUID. Frozen at parse time
 * so the resolver cannot mutate it. Lookup is `O(1)`; the values
 * are guaranteed to belong to the closed `FUNCTIONAL_ROLES`
 * catalogue by the parser.
 */
export type EntraGroupMap = ReadonlyMap<string, FunctionalRole>;

/**
 * Thrown by `parseEntraGroupMap` when the operator-supplied
 * configuration is malformed. The map is loaded once at BFF boot —
 * a malformed file should crash the process, not silently degrade
 * authorization. The error message points at the offending key /
 * value so the runbook can fix the config without spelunking
 * through stack traces.
 */
export class EntraGroupMapError extends Error {
  override readonly name = 'EntraGroupMapError';
  constructor(message: string) {
    super(message);
  }
}

/**
 * Validates a raw `{ <guid>: <role-slug> }` mapping (as it would
 * arrive from `JSON.parse` of an `infra/<env>-tenant.entra.json`
 * file) into a frozen `EntraGroupMap`.
 *
 * Validation rules:
 *   - keys must look like a GUID (8-4-4-4-12 hex pattern, case-
 *     insensitive). Entra group GUIDs are stable; a typo here
 *     would silently strip a role from every sign-in.
 *   - values must be in `FUNCTIONAL_ROLES`. Catalogue drift is
 *     caught at boot, not at the first user who happens to be in
 *     the affected group.
 *   - duplicate GUIDs (case-insensitive) are rejected. Two slugs
 *     pointing at the same group would silently shadow one another
 *     depending on the JS engine's object-key iteration order.
 *
 * The function deliberately does *not* require every role in the
 * catalogue to have a GUID — `partenaire` ships empty in v1 by
 * design (ADR-0025 §"Test-tenant personas"), and dev tenants may
 * provision a subset for fast iteration. The BFF logs a one-line
 * boot summary listing which roles are mapped so the operator can
 * spot accidental omissions.
 */
export function parseEntraGroupMap(raw: Readonly<Record<string, string>>): EntraGroupMap {
  const map = new Map<string, FunctionalRole>();
  const lowercased = new Set<string>();

  for (const [groupId, role] of Object.entries(raw)) {
    if (!GUID_RE.test(groupId)) {
      throw new EntraGroupMapError(
        `Invalid GUID "${groupId}" in Entra group map (expected 8-4-4-4-12 hex).`,
      );
    }
    if (!isFunctionalRole(role)) {
      throw new EntraGroupMapError(
        `Unknown role slug "${role}" for group "${groupId}". ` +
          `Catalogue is closed; legal slugs are: ${FUNCTIONAL_ROLES.join(', ')}.`,
      );
    }
    const lower = groupId.toLowerCase();
    if (lowercased.has(lower)) {
      throw new EntraGroupMapError(`Duplicate group GUID "${groupId}" in Entra group map.`);
    }
    lowercased.add(lower);
    map.set(lower, role);
  }

  return map;
}

/**
 * Resolves the `groups` claim from an Entra ID token (a list of
 * group GUIDs the user belongs to) into a list of catalogue-valid
 * `FunctionalRole` slugs. The OIDC callback (`session-establisher`)
 * calls `resolve()` once per sign-in.
 *
 * Unknown GUIDs are ignored at the role level — `resolve()` simply
 * skips them — but reported via the `onUnknownGroup` callback so
 * the host application can log a WARN (per ADR-0025
 * §"Sources of truth — Entra-side configuration": "Unknown group
 * GUIDs are logged at WARN and ignored — they do not break sign-in,
 * but they signal a tenant misconfiguration that should be cleaned
 * up").
 *
 * Output is deduplicated and ordered to match the catalogue's
 * declaration order (`FUNCTIONAL_ROLES`). Deterministic output
 * keeps snapshot tests stable and makes log inspection easier.
 */
export class EntraGroupToRoleResolver {
  constructor(private readonly map: EntraGroupMap) {}

  /**
   * Number of entries in the map. Surfaced for the boot-time log
   * line the BFF emits ("resolver loaded with N mappings").
   */
  get size(): number {
    return this.map.size;
  }

  /**
   * Roles currently covered by the map. The BFF can log this set
   * at boot to spot accidental omissions (e.g. a dev tenant that
   * forgot to provision `dpo`). Returned sorted by catalogue order
   * for stable logs.
   */
  coveredRoles(): ReadonlyArray<FunctionalRole> {
    const covered = new Set(this.map.values());
    return FUNCTIONAL_ROLES.filter((role) => covered.has(role));
  }

  resolve(
    groupIds: ReadonlyArray<string>,
    onUnknownGroup?: (groupId: string) => void,
  ): ReadonlyArray<FunctionalRole> {
    const matched = new Set<FunctionalRole>();
    for (const raw of groupIds) {
      const role = this.map.get(raw.toLowerCase());
      if (role === undefined) {
        if (onUnknownGroup) {
          onUnknownGroup(raw);
        }
        continue;
      }
      matched.add(role);
    }
    return FUNCTIONAL_ROLES.filter((role) => matched.has(role));
  }
}

const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
