import { describe, expect, test } from "bun:test";
import type { DecisionPolicy } from "../src/advanced.ts";
import { defineAuthz } from "../src/registry.ts";
import { mergeCatalogs } from "../src/catalog.ts";
import { memoryPermissionStore } from "../src/store.ts";
import { memoryAuditSink } from "../src/audit.ts";
import { createAuthzResolver, expandRoles, permissionMatches } from "../src/engine.ts";
import type { AuthzCatalog } from "../src/types.ts";

const catalog = mergeCatalogs([
  {
    source: "test.ts",
    module: defineAuthz({
      permissions: {
        "post:read": { public: true },
        "post:write": {},
        "post:delete": { risk: "high" },
        "post:comment:delete": {},
      },
      roles: {
        editor: ["post:*"],
        moderator: ["post:comment:*"],
        admin: ["role:editor", "post:delete"],
        cyclic: ["role:cyclic", "post:read"],
      },
      policies: {
        ownsPost: async (subject: { id?: string }, resource?: { authorId?: string }) =>
          resource?.authorId === subject?.id
            ? { allowed: true }
            : { allowed: false, reason: "not the author", policy: "ownsPost" },
        explodes: async () => {
          throw new Error("policy blew up");
        },
      },
      bindings: { "post:write": ["ownsPost"] },
    }),
  },
]);

const make = (store = memoryPermissionStore(), audit = memoryAuditSink()) => ({
  store,
  audit,
  resolver: createAuthzResolver({ catalog, store, audit, strict: false }),
});

describe("expandRoles", () => {
  test("expands wildcards and role inheritance", () => {
    expect([...expandRoles(catalog, ["admin"])].sort()).toEqual(["post:*", "post:delete"]);
  });
  test("terminates on cyclic inheritance", () => {
    expect([...expandRoles(catalog, ["cyclic"])]).toEqual(["post:read"]);
  });
});

describe("permissionMatches", () => {
  test("matches exact, root wildcard, and every namespace depth", () => {
    expect(permissionMatches(new Set(["post:read"]), "post:read")).toBe(true);
    expect(permissionMatches(new Set(["*"]), "anything:at:all")).toBe(true);
    expect(permissionMatches(new Set(["post:*"]), "post:comment:delete")).toBe(true);
    expect(permissionMatches(new Set(["post:comment:*"]), "post:comment:delete")).toBe(true);
    expect(permissionMatches(new Set(["post:comment:*"]), "post:write")).toBe(false);
  });
});

describe("createAuthzResolver.decide", () => {
  test("allows a public permission for an anonymous subject", async () => {
    const { resolver } = make();
    const result = await resolver.decide({ subject: null, permission: "post:read" });
    expect(result.allowed).toBe(true);
  });

  test("denies a non-public permission for an anonymous subject", async () => {
    const { resolver } = make();
    const result = await resolver.decide({ subject: null, permission: "post:delete" });
    expect(result.allowed).toBe(false);
  });

  test("allows via a role-derived wildcard", async () => {
    const { store, resolver } = make();
    await store.assignRole("u1", "moderator");
    const result = await resolver.decide({
      subject: { id: "u1" },
      permission: "post:comment:delete",
    });
    expect(result.allowed).toBe(true);
  });

  test("an explicit deny beats a role and beats '*'", async () => {
    const { store, resolver } = make();
    await store.assignRole("u1", "admin");
    await store.grant("u1", "post:delete", "deny");
    const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" });
    expect(result.allowed).toBe(false);
    expect(result.reason).toMatch(/explicit deny/i);
  });

  test("a bound policy can deny a permission the role grants", async () => {
    const { store, resolver } = make();
    await store.assignRole("u1", "editor");
    const denied = await resolver.decide({
      subject: { id: "u1" },
      permission: "post:write",
      resource: { authorId: "someone-else" },
    });
    expect(denied.allowed).toBe(false);
    expect(denied.policy).toBe("ownsPost");

    const allowed = await resolver.decide({
      subject: { id: "u1" },
      permission: "post:write",
      resource: { authorId: "u1" },
    });
    expect(allowed.allowed).toBe(true);
  });

  test("a throwing policy denies rather than escaping", async () => {
    const throwing = mergeCatalogs([
      {
        source: "t.ts",
        module: defineAuthz({
          permissions: { "x:go": {} },
          policies: {
            explodes: async () => {
              throw new Error("boom");
            },
          },
          bindings: { "x:go": ["explodes"] },
        }),
      },
    ]);
    const store = memoryPermissionStore();
    await store.grant("u1", "x:go", "allow");
    const resolver = createAuthzResolver({ catalog: throwing, store, strict: false });
    const result = await resolver.decide({ subject: { id: "u1" }, permission: "x:go" });
    expect(result.allowed).toBe(false);
  });

  test("a store failure denies and does not throw", async () => {
    const broken = {
      ...memoryPermissionStore(),
      assignmentsFor: async () => {
        throw new Error("db down");
      },
    };
    const resolver = createAuthzResolver({ catalog, store: broken, strict: false });
    const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:read" });
    expect(result.allowed).toBe(false);
  });

  test("an unregistered permission denies when strict is off", async () => {
    const { resolver } = make();
    const result = await resolver.decide({ subject: { id: "u1" }, permission: "ghost:perm" });
    expect(result.allowed).toBe(false);
    expect(result.reason).toMatch(/not registered/i);
  });

  test("an unregistered permission throws when strict is on", async () => {
    const resolver = createAuthzResolver({
      catalog,
      store: memoryPermissionStore(),
      strict: true,
    });
    await expect(
      resolver.decide({ subject: { id: "u1" }, permission: "ghost:perm" }),
    ).rejects.toThrow(/ghost:perm/);
  });

  test("denials are audited and allows are not, by default", async () => {
    const { store, audit, resolver } = make();
    await store.assignRole("u1", "moderator");
    await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" });
    await resolver.decide({ subject: { id: "u1" }, permission: "post:read" });
    expect(audit.events).toHaveLength(1);
    expect(audit.events[0]!.allowed).toBe(false);
  });

  test("auditAllows records both verdicts", async () => {
    const store = memoryPermissionStore();
    const audit = memoryAuditSink();
    const resolver = createAuthzResolver({
      catalog,
      store,
      audit,
      strict: false,
      auditAllows: true,
    });
    await resolver.decide({ subject: null, permission: "post:read" });
    expect(audit.events).toHaveLength(1);
    expect(audit.events[0]!.allowed).toBe(true);
  });

  test("tenant scope selects the right assignments", async () => {
    const { store, resolver } = make();
    await store.assignRole("u1", "editor", { tenantId: "t1" });
    const inside = await resolver.decide({
      subject: { id: "u1" },
      permission: "post:write",
      resource: { authorId: "u1" },
      scope: { tenantId: "t1" },
    });
    const outside = await resolver.decide({
      subject: { id: "u1" },
      permission: "post:write",
      resource: { authorId: "u1" },
      scope: { tenantId: "t2" },
    });
    expect(inside.allowed).toBe(true);
    expect(outside.allowed).toBe(false);
  });
});

describe("createAuthzResolver fail-closed regressions", () => {
  test("a public permission bound to an always-denying policy denies for an anonymous subject", async () => {
    const publicPolicyCatalog = mergeCatalogs([
      {
        source: "pub.ts",
        module: defineAuthz({
          permissions: { "feed:view": { public: true } },
          policies: {
            neverAllow: async () => ({
              allowed: false,
              reason: "embargoed",
              policy: "neverAllow",
            }),
          },
          bindings: { "feed:view": ["neverAllow"] },
        }),
      },
    ]);
    const resolver = createAuthzResolver({
      catalog: publicPolicyCatalog,
      store: memoryPermissionStore(),
      strict: false,
    });
    const result = await resolver.decide({ subject: null, permission: "feed:view" });
    expect(result.allowed).toBe(false);
    expect(result.policy).toBe("neverAllow");
  });

  test("a policy returning a truthy non-boolean 'allowed' denies", async () => {
    const truthyCatalog = mergeCatalogs([
      {
        source: "truthy.ts",
        module: defineAuthz({
          permissions: { "x:truthy": {} },
          policies: {
            truthy: (async () => ({ allowed: "yes" })) as unknown as DecisionPolicy<never, never>,
          },
          bindings: { "x:truthy": ["truthy"] },
        }),
      },
    ]);
    const store = memoryPermissionStore();
    await store.grant("u1", "x:truthy", "allow");
    const resolver = createAuthzResolver({ catalog: truthyCatalog, store, strict: false });
    const result = await resolver.decide({ subject: { id: "u1" }, permission: "x:truthy" });
    expect(result.allowed).toBe(false);
  });

  test("a binding naming a policy the catalog lacks denies", async () => {
    const missingPolicyCatalog: AuthzCatalog = {
      permissions: new Map([["x:missing", {}]]),
      roles: new Map(),
      policies: new Map(),
      attributes: new Map(),
      bindings: new Map([["x:missing", ["ghostPolicy"]]]),
    };
    const store = memoryPermissionStore();
    await store.grant("u1", "x:missing", "allow");
    const resolver = createAuthzResolver({ catalog: missingPolicyCatalog, store, strict: false });
    const result = await resolver.decide({ subject: { id: "u1" }, permission: "x:missing" });
    expect(result.allowed).toBe(false);
    expect(result.policy).toBe("ghostPolicy");
  });

  test("a wildcard deny blocks a permission the role explicitly grants", async () => {
    const { store, resolver } = make();
    await store.assignRole("u1", "admin");
    await store.grant("u1", "post:*", "deny");
    const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" });
    expect(result.allowed).toBe(false);
    expect(result.reason).toMatch(/explicit deny/i);
  });

  test("permissionsFor subtracts permissions covered by a wildcard deny", async () => {
    const { store, resolver } = make();
    await store.assignRole("u1", "editor");
    await store.grant("u1", "post:*", "deny");
    const granted = await resolver.permissionsFor("u1");
    expect(permissionMatches(granted, "post:delete")).toBe(false);
  });

  test("permissionsFor cannot represent a narrow deny under a broad grant (decide remains authoritative)", async () => {
    // A set of strings can't express "post:* except post:delete": the grant
    // entry "post:*" survives the subtraction (it isn't itself covered by the
    // narrower deny "post:delete"), so a set-based check would wrongly say
    // this permission is available. decide() has no such limitation — it
    // checks the specific permission against the deny list directly, not
    // through the granted-entries set — and correctly refuses it. This is a
    // pinned, deliberate divergence, not a bypass: callers must gate
    // individual actions with decide()/can(), never by matching this set.
    const { store, resolver } = make();
    await store.assignRole("u1", "editor");
    await store.grant("u1", "post:delete", "deny");

    const granted = await resolver.permissionsFor("u1");
    expect(granted.has("post:*")).toBe(true);
    expect(permissionMatches(granted, "post:delete")).toBe(true);

    const decision = await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" });
    expect(decision.allowed).toBe(false);
    expect(decision.reason).toMatch(/explicit deny/i);
  });

  test("a store returning a non-array `denies` (e.g. a string) denies rather than silently allowing", async () => {
    // new Set("post:write") would iterate CHARACTERS, not the permission, so
    // a store returning a malformed `denies` shape must not let an otherwise
    // role-granted permission slip through as allowed. Uses "post:comment:delete"
    // (granted via the "moderator" role's "post:comment:*" wildcard) rather
    // than "post:write", specifically because "post:write" is bound to the
    // "ownsPost" policy in this test catalog — a resource-ownership check
    // that would itself deny an unowned resource and mask the exact bug this
    // test exists to catch, passing for the wrong reason even without the fix.
    const store = memoryPermissionStore();
    await store.assignRole("u1", "moderator"); // moderator -> post:comment:* wildcard grant
    const malformed = {
      ...store,
      assignmentsFor: async (subjectId: string, scope?: { tenantId?: string }) => {
        const real = await store.assignmentsFor(subjectId, scope);
        return { ...real, denies: "post:comment:delete" as unknown as string[] };
      },
    };
    const resolver = createAuthzResolver({ catalog, store: malformed, strict: false });
    const result = await resolver.decide({
      subject: { id: "u1" },
      permission: "post:comment:delete",
    });
    expect(result.allowed).toBe(false);
  });

  test("a store omitting `denies` entirely denies rather than throwing out of decide()", async () => {
    const store = memoryPermissionStore();
    await store.assignRole("u1", "editor");
    const malformed = {
      ...store,
      assignmentsFor: async (subjectId: string, scope?: { tenantId?: string }) => {
        const real = await store.assignmentsFor(subjectId, scope);
        const { denies: _denies, ...withoutDenies } = real;
        return withoutDenies as unknown as typeof real;
      },
    };
    const resolver = createAuthzResolver({ catalog, store: malformed, strict: false });
    // If decide() still threw/rejected instead of denying, this `await` would
    // reject and fail the test right here rather than reaching the assertion.
    const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:write" });
    expect(result.allowed).toBe(false);
  });

  test("non-string subject ids deny rather than falling back to anonymous", async () => {
    const { resolver } = make();
    const invalidIds: unknown[] = [0, "", 123, {}];
    for (const id of invalidIds) {
      const result = await resolver.decide({
        subject: { id } as unknown as { id?: string },
        permission: "post:read",
      });
      expect(result.allowed).toBe(false);
      expect(result.reason).toMatch(/invalid subject/i);
    }
  });

  test("an empty-string subject id is not recorded as the audited subjectId", async () => {
    const { audit, resolver } = make();
    await resolver.decide({
      subject: { id: "" } as unknown as { id?: string },
      permission: "post:read",
    });
    expect(audit.events).toHaveLength(1);
    expect(audit.events[0]!.subjectId).toBeUndefined();
  });
});
