import type { Context, Middleware } from "@wrnexus/core";
import type { AuthorizationDecision } from "./advanced.ts";
import { safeRecord, type AuthzAuditSink } from "./audit.ts";
import { createAuthzResolver, type AuthzResolver, type AuthzResolverOptions } from "./engine.ts";
import type { AuthzScope } from "./types.ts";

/**
 * `can` is deliberately not a Context member: @wrnexus/core must not depend on
 * @wrnexus/authz. The per-request resolver lives here instead.
 */
export const AUTHZ_LOCALS_KEY = "_authz";

interface RequestAuthz {
  resolver: AuthzResolver;
  /**
   * Same sink `decide()` records through. Stashed here too so a denial that
   * never reaches the resolver (e.g. `guardPermission`'s `getResource`
   * throwing) can still be audited, instead of vanishing from the trail.
   */
  audit: AuthzAuditSink | undefined;
  /** Memo for object resources, keyed by identity so two rows never collide. */
  byRef: WeakMap<object, Map<string, Promise<AuthorizationDecision>>>;
  /** Memo for symbol resources, keyed by identity for the same reason. */
  bySymbol: Map<symbol, Map<string, Promise<AuthorizationDecision>>>;
  /** Memo for primitive and absent resources. */
  byValue: Map<string, Promise<AuthorizationDecision>>;
}

function readAuthz(ctx: Context): RequestAuthz {
  const value = ctx.locals[AUTHZ_LOCALS_KEY] as RequestAuthz | undefined;
  if (!value) {
    throw new Error(
      "WRN-AUTHZ-SETUP: authzMiddleware() is not registered for this request. " +
        "Add it to app/middleware before calling can()/guardPermission().",
    );
  }
  return value;
}

/** Install the per-request resolver. Register early, after sessionAuth. */
export function authzMiddleware(options: AuthzResolverOptions): Middleware {
  const resolver = createAuthzResolver(options);
  return (ctx, next) => {
    const request: RequestAuthz = {
      resolver,
      audit: options.audit,
      byRef: new WeakMap(),
      bySymbol: new Map(),
      byValue: new Map(),
    };
    ctx.locals[AUTHZ_LOCALS_KEY] = request;
    return next();
  };
}

/**
 * Read the tenant from the context at decision time, not at middleware time:
 * a request that switches tenant mid-flight must not keep the old scope.
 */
function currentScope(ctx: Context): AuthzScope | undefined {
  const tenantId = ctx.tenant?.id;
  return typeof tenantId === "string" && tenantId !== "" ? { tenantId } : undefined;
}

/**
 * Same normalization `decide()` applies before handing a subject id to the
 * audit sink: a non-empty string, or undefined (never a raw non-string id
 * leaking into an audit record).
 */
function subjectIdOf(ctx: Context): string | undefined {
  const rawId = (ctx.user as { id?: unknown } | null | undefined)?.id;
  return typeof rawId === "string" && rawId !== "" ? rawId : undefined;
}

/**
 * Object resources are memoised by identity (`byRef`), never by serialising
 * their contents — serialisation is what let unrelated resources collide
 * (same `id` shape, circular references, BigInt fields, throwing getters all
 * funnelled into one bucket). Symbols are memoised by identity too (`bySymbol`)
 * since `String(symbol)` collapses distinct symbols with the same description.
 * Primitive/absent resources are memoised by a
 * `[scope, permission, typeof, String(value)]` tuple, with `-0` rendered
 * distinctly from `0` since `String(-0) === "0"` would otherwise merge them.
 *
 * Subject and scope are both part of the key. A request that reassigns
 * ctx.user (impersonation, step-up auth, session revocation) or ctx.tenant
 * must not be served the previous principal's verdict from the memo.
 */
export function decideFor(
  ctx: Context,
  permission: string,
  resource?: unknown,
): Promise<AuthorizationDecision> {
  const request = readAuthz(ctx);
  const scope = currentScope(ctx);
  const subjectId = (ctx.user as { id?: unknown } | null | undefined)?.id;
  const key = JSON.stringify([
    scope?.tenantId ?? "",
    permission,
    typeof subjectId,
    String(subjectId),
  ]);

  const run = () =>
    request.resolver.decide({
      subject: ctx.user as { id?: string } | null | undefined,
      permission,
      resource,
      scope,
    });

  // Symbols carry identity that String() erases, so they memo by identity too.
  // They are held in a plain Map rather than the WeakMap: the memo is discarded
  // with the request, so there is nothing to leak.
  if (typeof resource === "symbol") {
    let perSymbol = request.bySymbol.get(resource);
    if (!perSymbol) request.bySymbol.set(resource, (perSymbol = new Map()));
    const cached = perSymbol.get(key);
    if (cached) return cached;
    const pending = run();
    perSymbol.set(key, pending);
    return pending;
  }

  const isObjectResource =
    resource !== null &&
    resource !== undefined &&
    (typeof resource === "object" || typeof resource === "function");

  if (isObjectResource) {
    const resourceObject = resource as object;
    let inner = request.byRef.get(resourceObject);
    if (!inner) {
      inner = new Map();
      request.byRef.set(resourceObject, inner);
    }
    const cached = inner.get(key);
    if (cached) return cached;
    const pending = run();
    inner.set(key, pending);
    return pending;
  }

  const rendered = Object.is(resource, -0) ? "-0" : String(resource);
  const valueKey = JSON.stringify([key, typeof resource, rendered]);
  const cached = request.byValue.get(valueKey);
  if (cached) return cached;
  const pending = run();
  request.byValue.set(valueKey, pending);
  return pending;
}

export async function can(ctx: Context, permission: string, resource?: unknown): Promise<boolean> {
  return (await decideFor(ctx, permission, resource)).allowed;
}

/**
 * Replicates `packages/core/src/auth.ts`'s `wantsJson` (not imported: authz
 * may only pull TYPES from @wrnexus/core, never runtime code).
 */
function wantsJson(ctx: Context): boolean {
  if (ctx.url.pathname.startsWith("/api/")) return true;
  const accept = ctx.req.headers.get("accept") ?? "";
  return accept.includes("application/json") && !accept.includes("text/html");
}

/**
 * Refuse anything but a same-origin, same-app path: no scheme/host
 * (`https://evil.example.com/...`), no protocol-relative target (`//evil...`
 * is host-relative in a browser, not path-relative), no backslashes (some
 * user agents treat `\` as `/`, which can smuggle a host past a naive
 * `startsWith("/")` check), and no control characters (CR/LF header/response
 * splitting, etc). Written as a codepoint loop rather than a control-char
 * regex literal, which tooling in this repo mangles.
 */
function isLocalPath(target: string): boolean {
  if (!target.startsWith("/")) return false;
  if (target.startsWith("//")) return false;
  if (target.includes("\\")) return false;
  for (const ch of target) {
    const code = ch.codePointAt(0) ?? 0;
    if (code < 0x20 || code === 0x7f) return false;
  }
  return true;
}

/**
 * Header values must be Latin-1, so a localized path would otherwise throw
 * inside `new Response` and 500 on a denial path. Encode ONLY the codepoints
 * that cannot be sent: encodeURI would also escape "%", corrupting a target
 * that already carries a percent-encoded return path.
 */
function headerSafePath(value: string): string {
  let out = "";
  for (const character of value) {
    out += character.codePointAt(0)! <= 0x7f ? character : encodeURIComponent(character);
  }
  return out;
}

const NO_STORE_HEADERS = { "cache-control": "private, no-store" } as const;

export interface GuardOptions {
  /** Load the resource a bound policy needs. */
  getResource?: (ctx: Context) => unknown;
  /** Include reason and policy name in the 403 body. Off by default. */
  exposeReason?: boolean;
  /** Redirect page requests here instead of returning 403. Ignored for JSON/API requests and for any non-local target. */
  redirectTo?: string;
}

/**
 * Guard a route on a registered permission. Named `guardPermission` because
 * `requirePermission(rbac, permission)` already exists with a different shape.
 */
export function guardPermission(permission: string, options: GuardOptions = {}): Middleware {
  return async (ctx, next) => {
    let resource: unknown;
    if (options.getResource) {
      try {
        resource = await options.getResource(ctx);
      } catch (error) {
        console.error(`[wrnexus:authz] getResource threw for '${permission}'; denying`, error);
        // This denial never reaches decideFor()/decide()/finish() — the
        // resource load failed before there was anything to decide — so
        // without recording here it would be invisible to the audit trail:
        // an attacker probing ids that make the loader throw gets a clean
        // 403 stream no operator can see. Keep the response body opaque
        // (no loader message), same as every other guardPermission denial.
        const { audit } = readAuthz(ctx);
        safeRecord(audit, {
          subjectId: subjectIdOf(ctx),
          scope: currentScope(ctx),
          permission,
          allowed: false,
          reason: "Resource unavailable",
          at: Date.now(),
        });
        return Response.json(
          { ok: false, error: "Forbidden" },
          { status: 403, headers: NO_STORE_HEADERS },
        );
      }
    }

    const result = await decideFor(ctx, permission, resource);
    if (result.allowed) return next();

    if (options.redirectTo && !wantsJson(ctx)) {
      if (isLocalPath(options.redirectTo)) {
        return new Response(null, {
          status: 303,
          // headerSafePath, not encodeURI: a non-ASCII local path (e.g. a
          // localized login route) is valid config but not a valid raw
          // header value, while encodeURI would also mangle a target that
          // already carries a percent-encoded return path.
          headers: { location: headerSafePath(options.redirectTo), ...NO_STORE_HEADERS },
        });
      }
      // JSON.stringify, not string interpolation: this branch exists precisely
      // for targets containing CR/LF, which must not reach the log verbatim.
      console.error(
        `[wrnexus:authz] guardPermission redirectTo ${JSON.stringify(options.redirectTo)} is not a local path; falling back to 403`,
      );
    }

    return Response.json(
      options.exposeReason
        ? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy }
        : { ok: false, error: "Forbidden" },
      { status: 403, headers: NO_STORE_HEADERS },
    );
  };
}

/** Keep only the items the current subject may act on. */
export async function filterCan<T>(
  ctx: Context,
  permission: string,
  items: readonly T[],
): Promise<T[]> {
  const verdicts = await Promise.all(
    items.map(async (item) => ({ item, allowed: await can(ctx, permission, item) })),
  );
  return verdicts.filter((entry) => entry.allowed).map((entry) => entry.item);
}
