import { test, expect, describe } from "bun:test";
import { createContext } from "@wrnexus/core";
import {
  defineRbac,
  hasRole,
  authorize,
  authorizeDecision,
  requireRole,
  requirePermission,
  any,
  all,
  attr,
  decision,
  owner,
  type Policy,
  type Subject,
} from "../src/index.ts";

const rbac = defineRbac({
  admin: ["*"],
  editor: ["post:read", "post:write"],
  viewer: ["post:read"],
  moderator: ["role:editor", "comment:delete"], // inherits editor
});

test("RBAC: roles, wildcards, namespaces, inheritance", () => {
  expect(rbac.can({ roles: ["viewer"] }, "post:read")).toBe(true);
  expect(rbac.can({ roles: ["viewer"] }, "post:write")).toBe(false);
  expect(rbac.can({ roles: ["admin"] }, "anything:goes")).toBe(true); // "*"
  expect(rbac.can({ roles: ["moderator"] }, "post:write")).toBe(true); // inherited from editor
  expect(rbac.can({ roles: ["moderator"] }, "comment:delete")).toBe(true);
  expect(rbac.can(undefined, "post:read")).toBe(false);
  expect(defineRbac({ ed: ["post:*"] }).can({ roles: ["ed"] }, "post:write")).toBe(true); // ns wildcard
});

test("hasRole", () => {
  expect(hasRole({ roles: ["a", "b"] }, "a")).toBe(true);
  expect(hasRole({ roles: ["a"] }, "a", "b")).toBe(false);
});

interface User extends Record<string, unknown> {
  id?: string;
  roles?: string[];
  tenant?: string;
}
interface Post {
  authorId: string;
}

test("PBAC/ABAC: policies compose (any/all) + attribute match", async () => {
  const ownsPost: Policy<User, Post> = (u, post) => u.id === post?.authorId;
  const isAdmin: Policy<User> = (u) => (u.roles ?? []).includes("admin");
  const canEdit = any(ownsPost, isAdmin);

  expect(await canEdit({ id: "u1" }, { authorId: "u1" })).toBe(true); // owner
  expect(await canEdit({ id: "u2", roles: ["admin"] }, { authorId: "u1" })).toBe(true); // admin
  expect(await canEdit({ id: "u2" }, { authorId: "u1" })).toBe(false);

  const sameTenant = all(isAdmin, attr<User>("tenant", "acme"));
  expect(await sameTenant({ roles: ["admin"], tenant: "acme" })).toBe(true);
  expect(await sameTenant({ roles: ["admin"], tenant: "other" })).toBe(false);
});

function ctx(user?: unknown) {
  const url = new URL("http://x/admin");
  const c = createContext(new Request(url), url);
  c.user = user;
  return c;
}

test("guards: authorize / requireRole / requirePermission", async () => {
  const ok = () => new Response("ok");
  expect((await requireRole("admin")(ctx({ roles: ["admin"] }), ok)).status).toBe(200);
  expect((await requireRole("admin")(ctx({ roles: ["viewer"] }), ok)).status).toBe(403);
  expect((await requirePermission(rbac, "post:write")(ctx({ roles: ["editor"] }), ok)).status).toBe(
    200,
  );
  expect((await requirePermission(rbac, "post:write")(ctx({ roles: ["viewer"] }), ok)).status).toBe(
    403,
  );
  expect(
    (await authorize((c) => (c.user as User)?.id === "u1")(ctx({ id: "u1" }), ok)).status,
  ).toBe(200);
});

test("explainable decisions only include denial reasons when denied", async () => {
  const policy = decision("owner", (subject: User) => subject.id === "u1");
  expect(await policy({ id: "u1" })).toEqual({
    allowed: true,
    reason: undefined,
    policy: "owner",
  });
  expect(await policy({ id: "u2" })).toEqual({
    allowed: false,
    reason: "Policy denied access",
    policy: "owner",
  });
});

test("owner() denies rather than matching two absent ids", async () => {
  // A subject with no id, checked against a resource with no ownership key,
  // must never be treated as the owner: undefined !== undefined here means
  // "we don't know", not "match".
  const noId: Subject = {};
  const resourceWithKey = { userId: "u1" };
  const resourceWithoutKey: Record<string, unknown> = { title: "t" };
  const realSubject: Subject = { id: "u1" };

  // Subject has no id at all.
  expect((await owner()(noId, resourceWithKey)).allowed).toBe(false);

  // Resource lacks the ownership key.
  expect((await owner()(realSubject, resourceWithoutKey)).allowed).toBe(false);

  // Both sides absent — the exact bug scenario (Object.is(undefined, undefined) === true).
  expect((await owner()(noId, resourceWithoutKey)).allowed).toBe(false);

  // Resource entirely absent.
  expect((await owner()(realSubject, undefined)).allowed).toBe(false);

  // A genuine match still allows.
  expect((await owner()(realSubject, resourceWithKey)).allowed).toBe(true);

  // Custom keys still work and still deny on absence.
  interface CustomResource extends Record<string, unknown> {
    ownerId?: string;
  }
  const customOwns = owner<Subject, CustomResource>("id", "ownerId");
  expect((await customOwns({ id: "u1" }, { ownerId: "u1" })).allowed).toBe(true);
  expect((await customOwns({ id: "u1" }, {})).allowed).toBe(false);
});

describe("authorizeDecision disclosure", () => {
  const ctx = { user: { id: "u1" } } as unknown as import("@wrnexus/core").Context;
  const denier = async () => ({ allowed: false, reason: "secret internal rule", policy: "isVip" });

  test("does not leak reason or policy by default", async () => {
    const res = await authorizeDecision(denier)(ctx, async () => new Response("ok"));
    expect(res.status).toBe(403);
    expect(await res.json()).toEqual({ ok: false, error: "Forbidden" });
  });

  test("exposeReason opts back in", async () => {
    const res = await authorizeDecision(denier, { exposeReason: true })(
      ctx,
      async () => new Response("ok"),
    );
    const body = (await res.json()) as Record<string, unknown>;
    expect(body.reason).toBe("secret internal rule");
    expect(body.policy).toBe("isVip");
  });

  test("still calls next when allowed", async () => {
    const res = await authorizeDecision(async () => ({ allowed: true }))(
      ctx,
      async () => new Response("passed"),
    );
    expect(await res.text()).toBe("passed");
  });
});
