import type { AttributeMeta, AuthzCatalog, AuthzModule, PermissionMeta } from "./types.ts";
import type { DecisionPolicy } from "./advanced.ts";

export interface CatalogSource {
  /** File or package that declared this module, used in conflict messages. */
  source: string;
  module: AuthzModule;
}

/** Structural equality for declaration metadata. Key order is irrelevant. */
function deepEqual(a: unknown, b: unknown): boolean {
  if (Object.is(a, b)) return true;
  if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
  if (Array.isArray(a) !== Array.isArray(b)) return false;
  const left = a as Record<string, unknown>;
  const right = b as Record<string, unknown>;
  const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
  for (const key of keys) if (!deepEqual(left[key], right[key])) return false;
  return true;
}

/** A frozen Map that throws on mutation, so the catalog cannot drift after boot. */
function frozenMap<V>(entries: Iterable<[string, V]>): ReadonlyMap<string, V> {
  const map = new Map(entries);
  const reject = () => {
    throw new Error("WRN-AUTHZ-FROZEN: the authorization catalog is frozen after boot.");
  };
  map.set = reject as never;
  map.delete = reject as never;
  map.clear = reject as never;
  return map;
}

export function emptyCatalog(): AuthzCatalog {
  return {
    permissions: frozenMap<PermissionMeta>([]),
    roles: frozenMap<readonly string[]>([]),
    policies: frozenMap<DecisionPolicy<never, never>>([]),
    attributes: frozenMap<AttributeMeta>([]),
    bindings: frozenMap<readonly string[]>([]),
  };
}

export function mergeCatalogs(sources: CatalogSource[]): AuthzCatalog {
  const permissions = new Map<string, PermissionMeta>();
  const roles = new Map<string, readonly string[]>();
  const policies = new Map<string, DecisionPolicy<never, never>>();
  const attributes = new Map<string, AttributeMeta>();
  const bindings = new Map<string, Set<string>>();
  const origin = new Map<string, string>();

  const claim = (
    kind: string,
    key: string,
    source: string,
    existingValue: unknown,
    value: unknown,
  ) => {
    const previous = origin.get(`${kind}:${key}`);
    if (previous === undefined) {
      origin.set(`${kind}:${key}`, source);
      return;
    }
    if (!deepEqual(existingValue, value)) {
      throw new Error(
        `WRN-AUTHZ-CONFLICT: ${kind} '${key}' is declared differently in ${previous} and ${source}.`,
      );
    }
  };

  for (const { source, module } of sources) {
    for (const [id, meta] of Object.entries(module.permissions ?? {})) {
      claim("permission", id, source, permissions.get(id), meta);
      // Freeze a COPY, not the app's own declared object: `frozenMap` only
      // blocks the Map's mutators, so `catalog.permissions.get("x").risk =
      // "low"` would otherwise silently rewrite metadata past a catalog that
      // claims to be frozen after boot. Copying also avoids freezing (and
      // thus permanently locking) an object the declaring module might still
      // hold a live reference to.
      permissions.set(id, Object.freeze({ ...meta }));
    }
    for (const [name, grants] of Object.entries(module.roles ?? {})) {
      claim("role", name, source, roles.get(name), grants);
      // Same reasoning: without this, `catalog.roles.get("editor").push("*")`
      // succeeds and silently escalates a role to a full wildcard.
      roles.set(name, Object.freeze([...grants]));
    }
    for (const [name, policy] of Object.entries(module.policies ?? {})) {
      // Two closures are never deep-equal, so identity is the only sane test.
      const existing = policies.get(name);
      if (existing && existing !== policy) {
        throw new Error(
          `WRN-AUTHZ-CONFLICT: policy '${name}' is declared differently in ${origin.get(`policy:${name}`)} and ${source}.`,
        );
      }
      origin.set(`policy:${name}`, source);
      policies.set(name, policy);
    }
    for (const [name, meta] of Object.entries(module.attributes ?? {})) {
      claim("attribute", name, source, attributes.get(name), meta);
      attributes.set(name, Object.freeze({ ...meta }));
    }
    for (const [permission, names] of Object.entries(module.bindings ?? {})) {
      const set = bindings.get(permission) ?? new Set<string>();
      for (const name of names) set.add(name);
      bindings.set(permission, set);
    }
  }

  for (const [permission, names] of bindings) {
    for (const name of names) {
      if (!policies.has(name)) {
        throw new Error(
          `WRN-AUTHZ-CONFLICT: binding for '${permission}' names policy '${name}', which no module declares.`,
        );
      }
    }
  }

  return {
    permissions: frozenMap(permissions),
    roles: frozenMap(roles),
    policies: frozenMap(policies),
    attributes: frozenMap(attributes),
    bindings: frozenMap(
      [...bindings].map(([k, v]) => [k, Object.freeze([...v])] as [string, readonly string[]]),
    ),
  };
}
