import { describe, it, expect, vi, beforeEach } from "vitest";

// permission-manager imports the side-effectful DB barrel and the logger at the
// top level. Stub both so importing the module does not spin up the real
// database / encryption stack. We then drive hasPermission via a spied
// getUserPermissions so we test the wildcard-matching logic in isolation.
vi.mock("../../database/db/index.js", () => ({ db: {} }));
vi.mock("../../utils/logger.js", () => ({
  databaseLogger: {
    debug: vi.fn(),
    info: vi.fn(),
    warn: vi.fn(),
    error: vi.fn(),
    success: vi.fn(),
  },
}));

const accessState = vi.hoisted(() => ({
  ownerId: "owner" as string,
  grant: null as {
    id: number;
    permissionLevel: string;
    expiresAt: string | null;
  } | null,
  touched: [] as number[],
  adminIds: new Set<string>(),
  rolePermissionCalls: 0,
  rolePermissions: [] as { permissions: string }[],
  ownedHostIds: new Set<number>(),
  visibleGrants: [] as { hostId: number }[],
  ownedQueryCalls: 0,
}));

vi.mock("../../database/repositories/factory.js", () => ({
  createCurrentHostResolutionRepository: () => ({
    isHostOwnedByUser: async (_hostId: number, userId: string) =>
      userId === accessState.ownerId,
    findHostOwnerId: async () => accessState.ownerId,
    listOwnedHostIds: async () => {
      accessState.ownedQueryCalls += 1;
      return accessState.ownedHostIds;
    },
  }),
  createCurrentRbacAccessRepository: () => ({
    listVisibleHostAccessEntries: async () => accessState.visibleGrants,
    findActiveHostAccess: async () => accessState.grant,
    touchHostAccess: async (id: number) => {
      accessState.touched.push(id);
    },
    deleteExpiredHostAccess: async () => 0,
  }),
  createCurrentRoleRepository: () => ({
    listUserRoleIds: async () => [],
    listUserRolePermissions: async () => {
      accessState.rolePermissionCalls += 1;
      return accessState.rolePermissions;
    },
    userHasAnyRoleName: async () => false,
  }),
  createCurrentUserRepository: () => ({
    findById: async (userId: string) =>
      accessState.adminIds.has(userId) ? { id: userId, isAdmin: true } : null,
  }),
}));

const { PermissionManager } = await import("../../utils/permission-manager.js");

type PermissionManagerInstance = ReturnType<
  typeof PermissionManager.getInstance
>;

describe("PermissionManager.hasPermission wildcard matching", () => {
  let manager: PermissionManagerInstance;

  function withPermissions(permissions: string[]) {
    vi.spyOn(manager, "getUserPermissions").mockResolvedValue(permissions);
  }

  beforeEach(() => {
    manager = PermissionManager.getInstance();
    vi.restoreAllMocks();
  });

  it("grants everything for the global wildcard '*'", async () => {
    withPermissions(["*"]);
    expect(await manager.hasPermission("u1", "hosts.read")).toBe(true);
    expect(await manager.hasPermission("u1", "anything.at.all")).toBe(true);
  });

  it("grants an exact permission match", async () => {
    withPermissions(["hosts.read", "hosts.write"]);
    expect(await manager.hasPermission("u1", "hosts.read")).toBe(true);
  });

  it("grants via a prefix wildcard", async () => {
    withPermissions(["hosts.*"]);
    expect(await manager.hasPermission("u1", "hosts.read")).toBe(true);
    expect(await manager.hasPermission("u1", "hosts.write")).toBe(true);
  });

  it("grants via a deep prefix wildcard", async () => {
    withPermissions(["admin.users.*"]);
    expect(await manager.hasPermission("u1", "admin.users.delete")).toBe(true);
  });

  it("denies when no exact or wildcard permission matches", async () => {
    withPermissions(["hosts.read"]);
    expect(await manager.hasPermission("u1", "hosts.write")).toBe(false);
    expect(await manager.hasPermission("u1", "credentials.read")).toBe(false);
  });

  it("denies when the user has no permissions", async () => {
    withPermissions([]);
    expect(await manager.hasPermission("u1", "hosts.read")).toBe(false);
  });

  it("does not let a narrower wildcard grant a sibling branch", async () => {
    withPermissions(["hosts.read.*"]);
    expect(await manager.hasPermission("u1", "hosts.write")).toBe(false);
  });
});

describe("PermissionManager.canAccessHost level hierarchy", () => {
  const manager = PermissionManager.getInstance();
  const actions = ["connect", "view", "edit", "manage"] as const;
  const levels = ["connect", "view", "edit", "manage"] as const;
  const rank = { connect: 1, view: 2, edit: 3, manage: 4 } as const;

  beforeEach(() => {
    vi.restoreAllMocks();
    accessState.ownerId = "owner";
    accessState.grant = null;
    accessState.touched = [];
    accessState.adminIds = new Set();
  });

  it("grants the owner every action including delete", async () => {
    for (const action of [...actions, "delete"] as const) {
      const info = await manager.canAccessHost("owner", 42, action);
      expect(info).toMatchObject({ hasAccess: true, isOwner: true });
    }
  });

  it("denies everything without a grant", async () => {
    const info = await manager.canAccessHost("stranger", 42, "connect");
    expect(info).toMatchObject({ hasAccess: false, isShared: false });
  });

  it("enforces the connect < view < edit < manage hierarchy", async () => {
    for (const level of levels) {
      accessState.grant = { id: 5, permissionLevel: level, expiresAt: null };
      for (const action of actions) {
        const info = await manager.canAccessHost("recipient", 42, action);
        expect(info.hasAccess).toBe(rank[level] >= rank[action]);
        expect(info.permissionLevel).toBe(level);
        expect(info.isShared).toBe(true);
      }
    }
  });

  it("never grants delete to a shared recipient", async () => {
    accessState.grant = { id: 5, permissionLevel: "manage", expiresAt: null };
    const info = await manager.canAccessHost("recipient", 42, "delete");
    expect(info.hasAccess).toBe(false);
  });

  it("normalizes the legacy 'view' string mapping and unknown levels to connect", async () => {
    accessState.grant = { id: 5, permissionLevel: "bogus", expiresAt: null };
    const connect = await manager.canAccessHost("recipient", 42, "connect");
    expect(connect.hasAccess).toBe(true);
    expect(connect.permissionLevel).toBe("connect");

    const view = await manager.canAccessHost("recipient", 42, "view");
    expect(view.hasAccess).toBe(false);
  });

  it("only touches the grant timestamp on connect", async () => {
    accessState.grant = { id: 5, permissionLevel: "manage", expiresAt: null };
    await manager.canAccessHost("recipient", 42, "manage");
    expect(accessState.touched).toEqual([]);
    await manager.canAccessHost("recipient", 42, "connect");
    expect(accessState.touched).toEqual([5]);
  });

  it("grants admins owner-equivalent access to any host via bypass", async () => {
    accessState.adminIds = new Set(["adminUser"]);
    for (const action of actions) {
      const info = await manager.canAccessHost("adminUser", 42, action);
      expect(info).toMatchObject({
        hasAccess: true,
        isOwner: false,
        isAdminBypass: true,
        permissionLevel: "manage",
      });
    }
  });

  it("upgrades an under-privileged admin's share access via bypass", async () => {
    accessState.adminIds = new Set(["adminUser"]);
    accessState.grant = { id: 7, permissionLevel: "connect", expiresAt: null };
    const info = await manager.canAccessHost("adminUser", 42, "manage");
    expect(info).toMatchObject({ hasAccess: true, isAdminBypass: true });
  });

  it("does not grant a non-admin stranger admin bypass", async () => {
    const info = await manager.canAccessHost("stranger", 42, "manage");
    expect(info.hasAccess).toBe(false);
    expect(info.isAdminBypass).toBeUndefined();
  });
});

describe("PermissionManager.getUserPermissions caching", () => {
  let manager: PermissionManagerInstance;

  beforeEach(() => {
    vi.restoreAllMocks();
    manager = PermissionManager.getInstance();
    accessState.rolePermissionCalls = 0;
    accessState.rolePermissions = [{ permissions: '["hosts.read"]' }];
    manager.invalidateUserPermissionCache("cache-user");
  });

  it("serves repeat lookups from cache instead of re-querying roles", async () => {
    expect(await manager.getUserPermissions("cache-user")).toEqual([
      "hosts.read",
    ]);
    expect(await manager.getUserPermissions("cache-user")).toEqual([
      "hosts.read",
    ]);

    expect(accessState.rolePermissionCalls).toBe(1);
  });

  it("re-reads roles after an explicit invalidation", async () => {
    await manager.getUserPermissions("cache-user");
    manager.invalidateUserPermissionCache("cache-user");
    accessState.rolePermissions = [{ permissions: '["hosts.write"]' }];

    expect(await manager.getUserPermissions("cache-user")).toEqual([
      "hosts.write",
    ]);
    expect(accessState.rolePermissionCalls).toBe(2);
  });

  it("expires an entry once its own TTL has passed", async () => {
    vi.useFakeTimers();
    try {
      await manager.getUserPermissions("cache-user");
      // Just past the 5 minute TTL.
      vi.advanceTimersByTime(5 * 60 * 1000 + 1);
      await manager.getUserPermissions("cache-user");

      expect(accessState.rolePermissionCalls).toBe(2);
    } finally {
      vi.useRealTimers();
    }
  });

  it("keeps a still-fresh entry when the sweep runs", async () => {
    vi.useFakeTimers();
    try {
      await manager.getUserPermissions("cache-user");
      // Fire the periodic sweep without crossing this entry's own TTL. The
      // old implementation cleared the whole map here, expiring every active
      // user at once.
      vi.advanceTimersByTime(5 * 60 * 1000 - 1000);
      await manager.getUserPermissions("cache-user");

      expect(accessState.rolePermissionCalls).toBe(1);
    } finally {
      vi.useRealTimers();
    }
  });

  it("returns an empty set rather than throwing when role lookup fails", async () => {
    manager.invalidateUserPermissionCache("boom-user");
    accessState.rolePermissions = [{ permissions: "not-json" }];

    expect(await manager.getUserPermissions("boom-user")).toEqual([]);
  });
});

describe("PermissionManager.filterAccessibleHostIds", () => {
  let manager: PermissionManagerInstance;

  beforeEach(() => {
    vi.restoreAllMocks();
    manager = PermissionManager.getInstance();
    accessState.adminIds = new Set();
    accessState.ownedHostIds = new Set();
    accessState.visibleGrants = [];
    accessState.ownedQueryCalls = 0;
  });

  it("keeps hosts the user owns", async () => {
    accessState.ownedHostIds = new Set([1, 2]);

    const allowed = await manager.filterAccessibleHostIds("u1", [1, 2, 3]);

    expect([...allowed].sort()).toEqual([1, 2]);
  });

  it("keeps hosts shared with the user", async () => {
    accessState.visibleGrants = [{ hostId: 7 }];

    const allowed = await manager.filterAccessibleHostIds("u1", [7, 8]);

    expect([...allowed]).toEqual([7]);
  });

  it("combines owned and shared without duplicating", async () => {
    accessState.ownedHostIds = new Set([1]);
    accessState.visibleGrants = [{ hostId: 1 }, { hostId: 2 }];

    const allowed = await manager.filterAccessibleHostIds("u1", [1, 2, 3]);

    expect([...allowed].sort()).toEqual([1, 2]);
  });

  it("excludes another tenant's hosts", async () => {
    accessState.ownedHostIds = new Set([1]);

    const allowed = await manager.filterAccessibleHostIds("u1", [1, 99, 100]);

    expect(allowed.has(99)).toBe(false);
    expect(allowed.has(100)).toBe(false);
  });

  it("gives an admin every host without per-host lookups", async () => {
    accessState.adminIds = new Set(["admin1"]);

    const allowed = await manager.filterAccessibleHostIds(
      "admin1",
      [1, 2, 3, 4],
    );

    expect([...allowed].sort()).toEqual([1, 2, 3, 4]);
  });

  it("resolves the whole fleet with a single owned-hosts query", async () => {
    accessState.ownedHostIds = new Set(
      Array.from({ length: 500 }, (_, i) => i + 1),
    );
    const ids = Array.from({ length: 500 }, (_, i) => i + 1);

    const allowed = await manager.filterAccessibleHostIds("u1", ids);

    expect(allowed.size).toBe(500);
    // The point of the batch path: cost does not scale with host count.
    expect(accessState.ownedQueryCalls).toBe(1);
  });

  it("short-circuits an empty list without querying", async () => {
    const allowed = await manager.filterAccessibleHostIds("u1", []);

    expect(allowed.size).toBe(0);
    expect(accessState.ownedQueryCalls).toBe(0);
  });

  it("fails closed when the lookup throws", async () => {
    accessState.ownedHostIds = null as unknown as Set<number>;

    const allowed = await manager.filterAccessibleHostIds("u1", [1, 2]);

    expect(allowed.size).toBe(0);
  });
});
