import type { AuthorizationDecision } from "./advanced.ts";
import { safeRecord, type AuthzAuditSink } from "./audit.ts";
import type { PermissionStore } from "./store.ts";
import type { AuthzCatalog, AuthzScope } from "./types.ts";

export interface AuthzResolverOptions {
  catalog: AuthzCatalog;
  store: PermissionStore;
  audit?: AuthzAuditSink;
  /**
   * Throw on an unregistered permission instead of denying. Defaults to true
   * outside production, so typos surface during development.
   */
  strict?: boolean;
  /** Record allows as well as denies. Off by default to bound write volume. */
  auditAllows?: boolean;
}

export interface DecideInput {
  subject: { id?: string; [key: string]: unknown } | null | undefined;
  permission: string;
  resource?: unknown;
  scope?: AuthzScope;
}

export interface AuthzResolver {
  /**
   * Effective permissions with denied entries removed — for coarse gating such
   * as hiding a menu section.
   *
   * NOT authoritative. A set of strings cannot express "everything under
   * `post:*` except `post:delete`", so a narrow deny beneath a broad grant is
   * not representable here: the set still contains `post:*` while `decide()`
   * correctly refuses `post:delete`. Gate individual actions with `decide()`
   * (or `can()` / `filterCan()`), never by matching against this set.
   */
  permissionsFor(subjectId: string, scope?: AuthzScope): Promise<Set<string>>;
  decide(input: DecideInput): Promise<AuthorizationDecision>;
}

/** Expand roles into their granted entries, following `role:` and stopping on cycles. */
export function expandRoles(catalog: AuthzCatalog, roles: readonly string[]): Set<string> {
  const out = new Set<string>();
  const seen = new Set<string>();
  const walk = (role: string) => {
    if (seen.has(role)) return;
    seen.add(role);
    for (const entry of catalog.roles.get(role) ?? []) {
      if (entry.startsWith("role:")) walk(entry.slice(5));
      else out.add(entry);
    }
  };
  for (const role of roles) walk(role);
  return out;
}

/**
 * Exact match, root wildcard, or a namespace wildcard at any depth.
 *
 * Do NOT gate access by matching against `permissionsFor()`'s result — that set
 * cannot represent a narrow deny beneath a broad grant, so the composition
 * returns true where `decide()` refuses. Use `decide()` / `can()` instead.
 */
export function permissionMatches(granted: Set<string>, permission: string): boolean {
  if (granted.has("*") || granted.has(permission)) return true;
  for (let at = permission.indexOf(":"); at !== -1; at = permission.indexOf(":", at + 1)) {
    if (granted.has(`${permission.slice(0, at)}:*`)) return true;
  }
  return false;
}

/**
 * True if any entry in the deny list covers `permission`. Denies honour the
 * same depth-aware wildcards as grants, so denying "post:*" blocks
 * post:comment:delete rather than being accepted and silently doing nothing.
 */
export function deniedBy(denies: readonly string[], permission: string): boolean {
  // A non-conforming store (e.g. denies: "post:write" instead of an array)
  // must not silently discard an explicit deny: new Set("post:write") would
  // iterate the string's characters instead of throwing, so the deny would
  // match nothing and fail open. Array.isArray guards the SHAPE, not just
  // the length, so a truthy-but-non-array denies value denies by falling
  // through to the caller's catch instead of matching nothing here.
  if (!Array.isArray(denies)) return false;
  return denies.length ? permissionMatches(new Set(denies), permission) : false;
}

function isProduction(): boolean {
  return (process.env.NODE_ENV ?? "development") === "production";
}

export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolver {
  const { catalog, store, audit } = options;
  const strict = options.strict ?? !isProduction();

  /**
   * Single source of truth for "what does this subject hold?". Returns the
   * raw assignments too, because `decide` needs `denies` and `permissionsFor`
   * does not — do NOT duplicate this logic in either caller.
   */
  const loadEffective = async (subjectId: string, scope?: AuthzScope) => {
    const assignments = await store.assignmentsFor(subjectId, scope);
    const granted = expandRoles(catalog, assignments.roles);
    for (const grant of assignments.grants) granted.add(grant);
    return { assignments, granted };
  };

  const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise<Set<string>> => {
    const { assignments, granted } = await loadEffective(subjectId, scope);
    if (!assignments.denies.length) return granted;
    // Hoist the deny set: rebuilding it per entry makes this O(grants x denies)
    // allocations on a per-request path whose input size an operator controls.
    const denySet = new Set(assignments.denies);
    const effective = new Set<string>();
    for (const entry of granted) {
      if (!permissionMatches(denySet, entry)) effective.add(entry);
    }
    return effective;
  };

  const finish = (input: DecideInput, result: AuthorizationDecision): AuthorizationDecision => {
    if (!result.allowed || options.auditAllows) {
      const rawId = input.subject?.id;
      safeRecord(audit, {
        subjectId: typeof rawId === "string" && rawId !== "" ? rawId : undefined,
        scope: input.scope,
        permission: input.permission,
        allowed: result.allowed,
        reason: result.reason,
        policy: result.policy,
        at: Date.now(),
      });
    }
    return result;
  };

  /**
   * Run every policy bound to `permission`. Returns the denial verdict of the
   * first failing/missing/throwing policy, or `null` if all bound policies
   * passed (including "no policies bound" — an implicit allow).
   */
  const runPolicies = async (
    input: DecideInput,
    permission: string,
  ): Promise<AuthorizationDecision | null> => {
    const { subject, resource } = input;
    for (const name of catalog.bindings.get(permission) ?? []) {
      const policy = catalog.policies.get(name);
      if (!policy) {
        console.error(`[wrnexus:authz] policy '${name}' is not registered; denying`);
        return { allowed: false, reason: "Policy unavailable", policy: name };
      }
      try {
        const verdict = await (
          policy as unknown as (
            s: unknown,
            r: unknown,
          ) => AuthorizationDecision | Promise<AuthorizationDecision>
        )(subject, resource);
        if (verdict?.allowed !== true) {
          return {
            allowed: false,
            reason: verdict?.reason ?? "Policy denied access",
            policy: verdict?.policy ?? name,
          };
        }
      } catch (error) {
        console.error(`[wrnexus:authz] policy '${name}' threw; denying`, error);
        return { allowed: false, reason: "Policy error", policy: name };
      }
    }
    return null;
  };

  return {
    permissionsFor,

    async decide(input) {
      const { permission, scope } = input;
      const meta = catalog.permissions.get(permission);

      if (!meta) {
        if (strict) {
          throw new Error(
            `WRN-AUTHZ-UNKNOWN: permission '${permission}' is not registered. ` +
              `Declare it with defineAuthz() in app/authz/.`,
          );
        }
        return finish(input, {
          allowed: false,
          reason: `Permission '${permission}' is not registered`,
        });
      }

      const rawId: unknown = input.subject?.id;
      const subjectId = typeof rawId === "string" && rawId !== "" ? rawId : undefined;
      if (rawId !== undefined && rawId !== null && subjectId === undefined) {
        console.error("[wrnexus:authz] subject.id must be a non-empty string; denying");
        return finish(input, { allowed: false, reason: "Invalid subject" });
      }

      if (!subjectId) {
        if (!meta.public) {
          return finish(input, { allowed: false, reason: "Authentication required" });
        }
        const denied = await runPolicies(input, permission);
        return finish(input, denied ?? { allowed: true, reason: "public permission" });
      }

      let assignments;
      let granted: Set<string>;
      try {
        ({ assignments, granted } = await loadEffective(subjectId, scope));

        // A store returning a non-array `denies` (e.g. a single string, or
        // omitting the field entirely) violates the PermissionStore contract.
        // Treat that exactly like assignmentsFor() itself throwing — fail
        // closed — rather than letting a malformed shape flow into
        // deniedBy(): a string denies would otherwise iterate as
        // CHARACTERS (new Set("post:write") is a set of letters, not the
        // permission), so an explicit deny would silently match nothing and
        // be discarded, and an omitted `denies` would throw past this
        // function entirely if it weren't caught here.
        if (!Array.isArray(assignments.denies)) {
          throw new TypeError(
            "WRN-AUTHZ-STORE: assignmentsFor() must return an array for `denies`",
          );
        }

        // 1. Explicit deny wins over everything, including "*", honouring wildcards.
        if (deniedBy(assignments.denies, permission)) {
          return finish(input, { allowed: false, reason: "explicit deny" });
        }

        // 2. Must hold the permission at all.
        if (!meta.public && !permissionMatches(granted, permission)) {
          return finish(input, { allowed: false, reason: "Missing permission" });
        }
      } catch (error) {
        console.error("[wrnexus:authz] permission store failed; denying", error);
        return finish(input, { allowed: false, reason: "Authorization store unavailable" });
      }

      // 3. Every bound policy must pass.
      const denied = await runPolicies(input, permission);
      return finish(input, denied ?? { allowed: true });
    },
  };
}
