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

const mockFetch = vi.fn();

describe("createAdminRoutes — GET /admin", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", mockFetch);
    vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
    vi.resetModules();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
    mockFetch.mockReset();
  });

  function mockResponse(body: unknown, ok = true, status = 200) {
    return {
      ok,
      status,
      statusText: ok ? "OK" : "Error",
      json: () => Promise.resolve(body),
    };
  }

  /**
   * Sets up the two-fetch mock sequence for an authenticated session.
   * Call 1: GET /api/auth/session
   * Call 2: GET /api/admin/members/me
   */
  function setupAuthenticatedSession(permissions: string[]) {
    mockFetch.mockResolvedValueOnce(
      mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" })
    );
    mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
  }

  async function loadAdminRoutes() {
    const { createAdminRoutes } = await import("../admin.js");
    return createAdminRoutes("http://localhost:3000");
  }

  // ── Unauthenticated ─────────────────────────────────────────────────────

  it("redirects unauthenticated users to /login", async () => {
    // No atbb_session cookie → zero fetch calls
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin");
    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/login");
  });

  // ── No admin permissions → 403 ──────────────────────────────────────────

  it("returns 403 for authenticated user with no permissions", async () => {
    setupAuthenticatedSession([]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(403);
    const html = await res.text();
    expect(html).toContain("Access Denied");
  });

  it("returns 403 for authenticated user with only an unrelated permission", async () => {
    setupAuthenticatedSession(["space.atbb.permission.someOtherThing"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(403);
  });

  // ── Wildcard → all cards ─────────────────────────────────────────────────

  it("grants access and shows all cards for wildcard (*) permission", async () => {
    setupAuthenticatedSession(["*"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain('href="/admin/members"');
    expect(html).toContain('href="/admin/structure"');
    expect(html).toContain('href="/admin/modlog"');
  });

  // ── Single permission → only that card ──────────────────────────────────

  it("shows only Members card for user with only manageMembers", async () => {
    setupAuthenticatedSession(["space.atbb.permission.manageMembers"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain('href="/admin/members"');
    expect(html).not.toContain('href="/admin/structure"');
    expect(html).not.toContain('href="/admin/modlog"');
  });

  it("shows only Structure card for user with only manageCategories", async () => {
    setupAuthenticatedSession(["space.atbb.permission.manageCategories"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).not.toContain('href="/admin/members"');
    expect(html).toContain('href="/admin/structure"');
    expect(html).not.toContain('href="/admin/modlog"');
  });

  it("shows only Mod Log card for user with only moderatePosts", async () => {
    setupAuthenticatedSession(["space.atbb.permission.moderatePosts"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).not.toContain('href="/admin/members"');
    expect(html).not.toContain('href="/admin/structure"');
    expect(html).toContain('href="/admin/modlog"');
  });

  it("shows only Mod Log card for user with only banUsers", async () => {
    setupAuthenticatedSession(["space.atbb.permission.banUsers"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).not.toContain('href="/admin/members"');
    expect(html).not.toContain('href="/admin/structure"');
    expect(html).toContain('href="/admin/modlog"');
  });

  it("shows only Mod Log card for user with only lockTopics", async () => {
    setupAuthenticatedSession(["space.atbb.permission.lockTopics"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).not.toContain('href="/admin/members"');
    expect(html).not.toContain('href="/admin/structure"');
    expect(html).toContain('href="/admin/modlog"');
  });

  // ── Multi-permission combos ──────────────────────────────────────────────

  it("shows Members and Mod Log cards for manageMembers + moderatePosts", async () => {
    setupAuthenticatedSession([
      "space.atbb.permission.manageMembers",
      "space.atbb.permission.moderatePosts",
    ]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain('href="/admin/members"');
    expect(html).not.toContain('href="/admin/structure"');
    expect(html).toContain('href="/admin/modlog"');
  });

  // ── Page structure ───────────────────────────────────────────────────────

  it("renders 'Admin Panel' page title", async () => {
    setupAuthenticatedSession(["space.atbb.permission.manageMembers"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain("Admin Panel");
  });

  it("renders admin-nav-grid container", async () => {
    setupAuthenticatedSession(["space.atbb.permission.manageMembers"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain("admin-nav-grid");
  });
});

describe("createAdminRoutes — GET /admin/members", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", mockFetch);
    vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
    vi.resetModules();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
    mockFetch.mockReset();
  });

  function mockResponse(body: unknown, ok = true, status = 200) {
    return {
      ok,
      status,
      statusText: ok ? "OK" : "Error",
      json: () => Promise.resolve(body),
    };
  }

  function setupSession(permissions: string[]) {
    mockFetch.mockResolvedValueOnce(
      mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
    );
    mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
  }

  const SAMPLE_MEMBERS = [
    {
      did: "did:plc:alice",
      handle: "alice.bsky.social",
      role: "Owner",
      roleUri: "at://did:plc:forum/space.atbb.forum.role/owner",
      joinedAt: "2026-01-01T00:00:00.000Z",
    },
    {
      did: "did:plc:bob",
      handle: "bob.bsky.social",
      role: "Member",
      roleUri: "at://did:plc:forum/space.atbb.forum.role/member",
      joinedAt: "2026-01-05T00:00:00.000Z",
    },
  ];

  const SAMPLE_ROLES = [
    { id: "1", name: "Owner", uri: "at://did:plc:forum/space.atbb.forum.role/owner", priority: 0, permissions: ["*"] },
    { id: "2", name: "Member", uri: "at://did:plc:forum/space.atbb.forum.role/member", priority: 30, permissions: [] },
  ];

  async function loadAdminRoutes() {
    const { createAdminRoutes } = await import("../admin.js");
    return createAdminRoutes("http://localhost:3000");
  }

  it("redirects unauthenticated users to /login", async () => {
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members");
    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/login");
  });

  it("returns 403 for authenticated user without manageMembers", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(403);
  });

  it("renders member table with handles and role badges", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false })
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members", {
      headers: { cookie: "atbb_session=token" },
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("alice.bsky.social");
    expect(html).toContain("bob.bsky.social");
    expect(html).toContain("role-badge");
    expect(html).toContain("Owner");
  });

  it("renders joined date for members", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false })
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members", {
      headers: { cookie: "atbb_session=token" },
    });

    const html = await res.text();
    expect(html).toContain("Jan");
    expect(html).toContain("2026");
  });

  it("hides role assignment form when user lacks manageRoles", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false })
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members", {
      headers: { cookie: "atbb_session=token" },
    });

    const html = await res.text();
    expect(html).not.toContain("hx-post");
    expect(html).not.toContain("Assign");
  });

  it("shows role assignment form when user has manageRoles", async () => {
    setupSession([
      "space.atbb.permission.manageMembers",
      "space.atbb.permission.manageRoles",
    ]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false })
    );
    mockFetch.mockResolvedValueOnce(mockResponse({ roles: SAMPLE_ROLES }));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members", {
      headers: { cookie: "atbb_session=token" },
    });

    const html = await res.text();
    expect(html).toContain("hx-post");
    expect(html).toContain("/admin/members/did:plc:bob/role");
    expect(html).toContain("Assign");
  });

  it("shows empty state when no members", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ members: [], isTruncated: false })
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members", {
      headers: { cookie: "atbb_session=token" },
    });

    const html = await res.text();
    expect(html).toContain("No members");
  });

  it("shows truncated indicator when isTruncated is true", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ members: SAMPLE_MEMBERS, isTruncated: true })
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members", {
      headers: { cookie: "atbb_session=token" },
    });

    const html = await res.text();
    expect(html).toContain("+");
  });

  it("returns 503 on AppView network error fetching members", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    mockFetch.mockRejectedValueOnce(new Error("fetch failed"));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members", {
      headers: { cookie: "atbb_session=token" },
    });

    expect(res.status).toBe(503);
    const html = await res.text();
    expect(html).toContain("error-display");
  });

  it("returns 500 on AppView server error fetching members", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members", {
      headers: { cookie: "atbb_session=token" },
    });

    expect(res.status).toBe(500);
    const html = await res.text();
    expect(html).toContain("error-display");
  });

  it("redirects to /login when AppView members returns 401 (session expired)", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members", {
      headers: { cookie: "atbb_session=token" },
    });

    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/login");
  });

  it("renders page with empty role dropdown when roles fetch fails", async () => {
    setupSession([
      "space.atbb.permission.manageMembers",
      "space.atbb.permission.manageRoles",
    ]);
    // members fetch succeeds
    mockFetch.mockResolvedValueOnce(
      mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false })
    );
    // roles fetch fails
    mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members", {
      headers: { cookie: "atbb_session=token" },
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    // Page still renders with member data
    expect(html).toContain("alice.bsky.social");
    // Assign Role column still present (permission says yes, just no options)
    expect(html).toContain("hx-post");
  });
});

describe("createAdminRoutes — POST /admin/members/:did/role", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", mockFetch);
    vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
    vi.resetModules();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
    mockFetch.mockReset();
  });

  function mockResponse(body: unknown, ok = true, status = 200) {
    return {
      ok,
      status,
      statusText: ok ? "OK" : "Error",
      json: () => Promise.resolve(body),
    };
  }

  const SAMPLE_ROLES = [
    { id: "1", name: "Owner", uri: "at://did:plc:forum/space.atbb.forum.role/owner", priority: 0, permissions: ["*"] },
    { id: "2", name: "Member", uri: "at://did:plc:forum/space.atbb.forum.role/member", priority: 30, permissions: [] },
  ];

  function makeFormBody(overrides: Partial<Record<string, string>> = {}): string {
    return new URLSearchParams({
      roleUri: "at://did:plc:forum/space.atbb.forum.role/member",
      handle: "bob.bsky.social",
      joinedAt: "2026-01-05T00:00:00.000Z",
      currentRole: "Owner",
      currentRoleUri: "at://did:plc:forum/space.atbb.forum.role/owner",
      canManageRoles: "1",
      rolesJson: JSON.stringify(SAMPLE_ROLES),
      ...overrides,
    }).toString();
  }

  async function loadAdminRoutes() {
    const { createAdminRoutes } = await import("../admin.js");
    return createAdminRoutes("http://localhost:3000");
  }

  function setupPostSession(permissions: string[] = ["space.atbb.permission.manageRoles"]) {
    mockFetch.mockResolvedValueOnce(
      mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
    );
    mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
  }

  it("returns updated <tr> with new role name on success", async () => {
    setupPostSession();
    mockFetch.mockResolvedValueOnce(
      mockResponse({ success: true, roleAssigned: "Member", targetDid: "did:plc:bob" })
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members/did:plc:bob/role", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: makeFormBody(),
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("<tr");
    expect(html).toContain("Member");
    expect(html).toContain("bob.bsky.social");
  });

  it("returns row with friendly error on AppView 403", async () => {
    setupPostSession();
    mockFetch.mockResolvedValueOnce(mockResponse({}, false, 403));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members/did:plc:bob/role", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: makeFormBody(),
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("member-row__error");
    expect(html).toContain("equal or higher authority");
    expect(html).toContain("Owner"); // preserves current role
  });

  it("returns row with friendly error on AppView 404", async () => {
    setupPostSession();
    mockFetch.mockResolvedValueOnce(mockResponse({}, false, 404));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members/did:plc:bob/role", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: makeFormBody(),
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("member-row__error");
    expect(html).toContain("not found");
  });

  it("returns row with friendly error on AppView 500", async () => {
    setupPostSession();
    mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members/did:plc:bob/role", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: makeFormBody(),
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("member-row__error");
    expect(html).toContain("Something went wrong");
  });

  it("returns row with unavailable message on network error", async () => {
    setupPostSession();
    mockFetch.mockRejectedValueOnce(new Error("fetch failed"));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members/did:plc:bob/role", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: makeFormBody(),
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("member-row__error");
    expect(html).toContain("temporarily unavailable");
  });

  it("returns row with error and makes no AppView call when roleUri is missing", async () => {
    setupPostSession();
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members/did:plc:bob/role", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: makeFormBody({ roleUri: "" }),
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("member-row__error");
    expect(mockFetch).not.toHaveBeenCalledWith(
      expect.stringContaining("/api/admin/members/did:plc:bob/role"),
      expect.anything()
    );
  });

  it("re-renders form with new role pre-selected in dropdown on success", async () => {
    setupPostSession();
    mockFetch.mockResolvedValueOnce(
      mockResponse({ success: true, roleAssigned: "Member", targetDid: "did:plc:bob" })
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members/did:plc:bob/role", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: makeFormBody({
        roleUri: "at://did:plc:forum/space.atbb.forum.role/member",
      }),
    });

    const html = await res.text();
    // The newly assigned role URI should appear as the selected option value in the form
    expect(html).toContain("at://did:plc:forum/space.atbb.forum.role/member");
  });

  it("returns 401 error row for unauthenticated POST", async () => {
    // No session mock — no cookie
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members/did:plc:bob/role", {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: makeFormBody(),
    });

    expect(res.status).toBe(401);
    const html = await res.text();
    expect(html).toContain("member-row__error");
    expect(mockFetch).not.toHaveBeenCalledWith(
      expect.stringContaining("/api/admin/members/did:plc:bob/role"),
      expect.anything()
    );
  });

  it("returns 403 error row when user lacks manageRoles", async () => {
    setupPostSession(["space.atbb.permission.manageMembers"]); // has manageMembers but NOT manageRoles
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members/did:plc:bob/role", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: makeFormBody(),
    });

    expect(res.status).toBe(403);
    const html = await res.text();
    expect(html).toContain("member-row__error");
    // No AppView role assignment call should have been made
    expect(mockFetch).not.toHaveBeenCalledWith(
      expect.stringContaining("/api/admin/members/did:plc:bob/role"),
      expect.anything()
    );
  });

  it("returns row with session-expired error when AppView returns 401", async () => {
    setupPostSession();
    mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members/did:plc:bob/role", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: makeFormBody(),
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("member-row__error");
    expect(html).toContain("session has expired");
  });

  it("returns error row with reload message when rolesJson is malformed", async () => {
    setupPostSession();

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members/did:plc:bob/role", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: makeFormBody({ rolesJson: "not-valid-json{{" }),
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("member-row__error");
    expect(html).toContain("reload");
    // No AppView call should have been made
    // (setupPostSession consumed 2 calls, then we check no more were made)
    expect(mockFetch).toHaveBeenCalledTimes(2);
  });

  it("returns error row and makes no AppView call when targetDid lacks did: prefix", async () => {
    setupPostSession();

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members/notadid/role", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: makeFormBody({ handle: "bob.bsky.social" }),
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("member-row__error");
    expect(html).toContain("Invalid member identifier");
    // Session fetch calls consumed (2), but no AppView role call made
    expect(mockFetch).not.toHaveBeenCalledWith(
      expect.stringContaining("/api/admin/members/notadid/role"),
      expect.anything()
    );
  });
});

describe("createAdminRoutes — GET /admin/structure", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", mockFetch);
    vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
    vi.resetModules();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
    mockFetch.mockReset();
  });

  function mockResponse(body: unknown, ok = true, status = 200) {
    return {
      ok,
      status,
      statusText: ok ? "OK" : "Error",
      json: () => Promise.resolve(body),
    };
  }

  function setupSession(permissions: string[]) {
    mockFetch.mockResolvedValueOnce(
      mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
    );
    mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
  }

  /**
   * Sets up mock responses for the structure page data fetches.
   * After the 2 session calls:
   *   Call 3: GET /api/categories
   *   Call 4+: GET /api/categories/:id/boards (one per category, parallel)
   */
  function setupStructureFetch(
    cats: Array<{ id: string; name: string; uri: string; sortOrder?: number }>,
    boardsByCategory: Record<string, Array<{ id: string; name: string }>> = {}
  ) {
    mockFetch.mockResolvedValueOnce(
      mockResponse({
        categories: cats.map((c) => ({
          id: c.id,
          did: "did:plc:forum",
          uri: c.uri,
          name: c.name,
          description: null,
          slug: null,
          sortOrder: c.sortOrder ?? 1,
          forumId: "1",
          createdAt: "2025-01-01T00:00:00.000Z",
          indexedAt: "2025-01-01T00:00:00.000Z",
        })),
      })
    );
    for (const cat of cats) {
      const boards = boardsByCategory[cat.id] ?? [];
      mockFetch.mockResolvedValueOnce(
        mockResponse({
          boards: boards.map((b) => ({
            id: b.id,
            did: "did:plc:forum",
            uri: `at://did:plc:forum/space.atbb.forum.board/${b.id}`,
            name: b.name,
            description: null,
            slug: null,
            sortOrder: 1,
            categoryId: cat.id,
            categoryUri: cat.uri,
            createdAt: "2025-01-01T00:00:00.000Z",
            indexedAt: "2025-01-01T00:00:00.000Z",
          })),
        })
      );
    }
  }

  async function loadAdminRoutes() {
    const { createAdminRoutes } = await import("../admin.js");
    return createAdminRoutes("http://localhost:3000");
  }

  it("redirects unauthenticated users to /login", async () => {
    mockFetch.mockResolvedValueOnce(
      mockResponse({ authenticated: false })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure");
    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/login");
  });

  it("returns 403 for authenticated user without manageCategories", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(403);
  });

  it("renders structure page with category and board names", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    setupStructureFetch(
      [{ id: "1", name: "General Discussion", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }],
      { "1": [{ id: "10", name: "General Chat" }] }
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure", {
      headers: { cookie: "atbb_session=token" },
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("General Discussion");
    expect(html).toContain("General Chat");
  });

  it("renders empty state when no categories exist", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    setupStructureFetch([]);

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure", {
      headers: { cookie: "atbb_session=token" },
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("No categories");
  });

  it("renders the add-category form", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    setupStructureFetch([]);

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure", {
      headers: { cookie: "atbb_session=token" },
    });

    const html = await res.text();
    expect(html).toContain('action="/admin/structure/categories"');
  });

  it("renders edit and delete actions for a category", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    setupStructureFetch(
      [{ id: "5", name: "Projects", uri: "at://did:plc:forum/space.atbb.forum.category/xyz" }],
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure", {
      headers: { cookie: "atbb_session=token" },
    });

    const html = await res.text();
    expect(html).toContain('action="/admin/structure/categories/5/edit"');
    expect(html).toContain('action="/admin/structure/categories/5/delete"');
  });

  it("renders edit and delete actions for a board", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    setupStructureFetch(
      [{ id: "1", name: "General", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }],
      { "1": [{ id: "20", name: "Showcase" }] }
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure", {
      headers: { cookie: "atbb_session=token" },
    });

    const html = await res.text();
    expect(html).toContain("Showcase");
    expect(html).toContain('action="/admin/structure/boards/20/edit"');
    expect(html).toContain('action="/admin/structure/boards/20/delete"');
  });

  it("renders add-board form with categoryUri hidden input", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    setupStructureFetch(
      [{ id: "1", name: "General", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }],
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure", {
      headers: { cookie: "atbb_session=token" },
    });

    const html = await res.text();
    expect(html).toContain('name="categoryUri"');
    expect(html).toContain('value="at://did:plc:forum/space.atbb.forum.category/abc"');
    expect(html).toContain('action="/admin/structure/boards"');
  });

  it("renders error banner when ?error= query param is present", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    setupStructureFetch([]);

    const routes = await loadAdminRoutes();
    const res = await routes.request(
      `/admin/structure?error=${encodeURIComponent("Cannot delete category with boards. Remove all boards first.")}`,
      { headers: { cookie: "atbb_session=token" } }
    );

    const html = await res.text();
    expect(html).toContain("Cannot delete category with boards");
  });

  it("returns 503 on AppView network error fetching categories", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    mockFetch.mockRejectedValueOnce(new Error("fetch failed"));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure", {
      headers: { cookie: "atbb_session=token" },
    });

    expect(res.status).toBe(503);
    const html = await res.text();
    expect(html).toContain("error-display");
  });

  it("returns 500 on AppView server error fetching categories", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure", {
      headers: { cookie: "atbb_session=token" },
    });

    expect(res.status).toBe(500);
    const html = await res.text();
    expect(html).toContain("error-display");
  });

  it("redirects to /login when AppView categories returns 401", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure", {
      headers: { cookie: "atbb_session=token" },
    });

    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/login");
  });
});

describe("createAdminRoutes — POST /admin/structure/categories", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", mockFetch);
    vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
    vi.resetModules();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
    mockFetch.mockReset();
  });

  function mockResponse(body: unknown, ok = true, status = 200) {
    return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) };
  }

  function setupSession(permissions: string[]) {
    mockFetch.mockResolvedValueOnce(
      mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
    );
    mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
  }

  async function loadAdminRoutes() {
    const { createAdminRoutes } = await import("../admin.js");
    return createAdminRoutes("http://localhost:3000");
  }

  function postForm(body: Record<string, string>) {
    const params = new URLSearchParams(body);
    return {
      method: "POST",
      headers: {
        cookie: "atbb_session=token",
        "content-type": "application/x-www-form-urlencoded",
      },
      body: params.toString(),
    };
  }

  it("redirects to /login when unauthenticated", async () => {
    mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false }));
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/categories", postForm({ name: "General" }));
    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/login");
  });

  it("returns 403 without manageCategories permission", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/categories", postForm({ name: "General" }));
    expect(res.status).toBe(403);
  });

  it("redirects to /admin/structure on success", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.category/abc", cid: "bafyrei..." }, true, 201)
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request(
      "/admin/structure/categories",
      postForm({ name: "General", description: "Talk about anything", sortOrder: "1" })
    );

    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/admin/structure");
  });

  it("redirects with ?error= when name is missing", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);

    const routes = await loadAdminRoutes();
    const res = await routes.request(
      "/admin/structure/categories",
      postForm({ name: "" })
    );

    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("/admin/structure");
    expect(location).toContain("error=");
  });

  it("redirects with ?error= on AppView error", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ error: "Unexpected error" }, false, 500)
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request(
      "/admin/structure/categories",
      postForm({ name: "General" })
    );

    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("/admin/structure");
    expect(location).toContain("error=");
  });

  it("redirects with ?error= on network error", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    mockFetch.mockRejectedValueOnce(new Error("fetch failed"));

    const routes = await loadAdminRoutes();
    const res = await routes.request(
      "/admin/structure/categories",
      postForm({ name: "General" })
    );

    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("/admin/structure");
    expect(location).toContain("error=");
  });

  it("redirects with ?error= for negative sort order", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);

    const routes = await loadAdminRoutes();
    const res = await routes.request(
      "/admin/structure/categories",
      postForm({ name: "General", sortOrder: "-1" })
    );

    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("error=");
  });
});

describe("createAdminRoutes — POST /admin/structure/categories/:id/edit", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", mockFetch);
    vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
    vi.resetModules();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
    mockFetch.mockReset();
  });

  function mockResponse(body: unknown, ok = true, status = 200) {
    return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) };
  }

  function setupSession(permissions: string[]) {
    mockFetch.mockResolvedValueOnce(
      mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
    );
    mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
  }

  async function loadAdminRoutes() {
    const { createAdminRoutes } = await import("../admin.js");
    return createAdminRoutes("http://localhost:3000");
  }

  function postForm(body: Record<string, string>) {
    const params = new URLSearchParams(body);
    return {
      method: "POST",
      headers: {
        cookie: "atbb_session=token",
        "content-type": "application/x-www-form-urlencoded",
      },
      body: params.toString(),
    };
  }

  it("redirects to /login when unauthenticated", async () => {
    mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false }));
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" }));
    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/login");
  });

  it("returns 403 without manageCategories", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" }));
    expect(res.status).toBe(403);
  });

  it("redirects to /admin/structure on success", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ uri: "at://...", cid: "bafyrei..." }, true, 200)
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request(
      "/admin/structure/categories/5/edit",
      postForm({ name: "Updated Name", description: "", sortOrder: "2" })
    );

    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/admin/structure");
  });

  it("redirects with ?error= when name is missing", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "" }));
    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("error=");
  });

  it("redirects with ?error= on AppView error", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    mockFetch.mockResolvedValueOnce(mockResponse({ error: "Not found" }, false, 404));
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" }));
    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("error=");
  });

  it("redirects with ?error= on network error", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" }));
    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("error=");
  });

  it("redirects with ?error= for negative sort order", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request(
      "/admin/structure/categories/5/edit",
      postForm({ name: "Updated", sortOrder: "-5" })
    );
    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("error=");
  });
});

describe("createAdminRoutes — POST /admin/structure/categories/:id/delete", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", mockFetch);
    vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
    vi.resetModules();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
    mockFetch.mockReset();
  });

  function mockResponse(body: unknown, ok = true, status = 200) {
    return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) };
  }

  function setupSession(permissions: string[]) {
    mockFetch.mockResolvedValueOnce(
      mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
    );
    mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
  }

  async function loadAdminRoutes() {
    const { createAdminRoutes } = await import("../admin.js");
    return createAdminRoutes("http://localhost:3000");
  }

  function postForm(body: Record<string, string> = {}) {
    const params = new URLSearchParams(body);
    return {
      method: "POST",
      headers: {
        cookie: "atbb_session=token",
        "content-type": "application/x-www-form-urlencoded",
      },
      body: params.toString(),
    };
  }

  it("redirects to /login when unauthenticated", async () => {
    mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false }));
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/categories/5/delete", postForm());
    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/login");
  });

  it("returns 403 without manageCategories", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/categories/5/delete", postForm());
    expect(res.status).toBe(403);
  });

  it("redirects to /admin/structure on success", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    mockFetch.mockResolvedValueOnce(mockResponse({}, true, 200));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/categories/5/delete", postForm());

    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/admin/structure");
  });

  it("redirects with ?error= on AppView error (e.g. 409 has boards)", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ error: "Cannot delete category with boards. Remove all boards first." }, false, 409)
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/categories/5/delete", postForm());

    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("/admin/structure");
    expect(location).toContain("error=");
    expect(decodeURIComponent(location)).toContain("Cannot delete category with boards");
  });

  it("redirects with ?error= on network error", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    mockFetch.mockRejectedValueOnce(new Error("fetch failed"));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/categories/5/delete", postForm());

    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("error=");
  });
});

describe("createAdminRoutes — POST /admin/structure/boards", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", mockFetch);
    vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
    vi.resetModules();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
    mockFetch.mockReset();
  });

  function mockResponse(body: unknown, ok = true, status = 200) {
    return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) };
  }

  function setupSession(permissions: string[]) {
    mockFetch.mockResolvedValueOnce(
      mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
    );
    mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
  }

  async function loadAdminRoutes() {
    const { createAdminRoutes } = await import("../admin.js");
    return createAdminRoutes("http://localhost:3000");
  }

  function postForm(body: Record<string, string>) {
    const params = new URLSearchParams(body);
    return {
      method: "POST",
      headers: {
        cookie: "atbb_session=token",
        "content-type": "application/x-www-form-urlencoded",
      },
      body: params.toString(),
    };
  }

  it("redirects to /login when unauthenticated", async () => {
    mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false }));
    const routes = await loadAdminRoutes();
    const res = await routes.request(
      "/admin/structure/boards",
      postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" })
    );
    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/login");
  });

  it("returns 403 without manageCategories permission", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request(
      "/admin/structure/boards",
      postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" })
    );
    expect(res.status).toBe(403);
  });

  it("redirects to /admin/structure on success", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.board/xyz", cid: "bafyrei..." }, true, 201)
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request(
      "/admin/structure/boards",
      postForm({
        name: "General Chat",
        description: "Chat about anything",
        sortOrder: "1",
        categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc",
      })
    );

    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/admin/structure");
  });

  it("redirects with ?error= when name is missing", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request(
      "/admin/structure/boards",
      postForm({ name: "", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" })
    );
    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("/admin/structure");
    expect(location).toContain("error=");
  });

  it("redirects with ?error= when categoryUri is missing", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request(
      "/admin/structure/boards",
      postForm({ name: "General Chat", categoryUri: "" })
    );
    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("error=");
  });

  it("redirects with ?error= on AppView error", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ error: "Category not found" }, false, 404)
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request(
      "/admin/structure/boards",
      postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" })
    );

    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("error=");
  });

  it("redirects with ?error= on network error", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    mockFetch.mockRejectedValueOnce(new Error("fetch failed"));

    const routes = await loadAdminRoutes();
    const res = await routes.request(
      "/admin/structure/boards",
      postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" })
    );

    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("error=");
  });

  it("redirects with ?error= for negative sort order", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request(
      "/admin/structure/boards",
      postForm({
        name: "General Chat",
        categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc",
        sortOrder: "-2",
      })
    );
    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("error=");
  });
});

describe("createAdminRoutes — POST /admin/structure/boards/:id/edit", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", mockFetch);
    vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
    vi.resetModules();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
    mockFetch.mockReset();
  });

  function mockResponse(body: unknown, ok = true, status = 200) {
    return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) };
  }

  function setupSession(permissions: string[]) {
    mockFetch.mockResolvedValueOnce(
      mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
    );
    mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
  }

  async function loadAdminRoutes() {
    const { createAdminRoutes } = await import("../admin.js");
    return createAdminRoutes("http://localhost:3000");
  }

  function postForm(body: Record<string, string>) {
    const params = new URLSearchParams(body);
    return {
      method: "POST",
      headers: {
        cookie: "atbb_session=token",
        "content-type": "application/x-www-form-urlencoded",
      },
      body: params.toString(),
    };
  }

  it("redirects to /login when unauthenticated", async () => {
    mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false }));
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" }));
    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/login");
  });

  it("returns 403 without manageCategories", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" }));
    expect(res.status).toBe(403);
  });

  it("redirects to /admin/structure on success", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    mockFetch.mockResolvedValueOnce(mockResponse({ uri: "at://...", cid: "bafyrei..." }, true, 200));

    const routes = await loadAdminRoutes();
    const res = await routes.request(
      "/admin/structure/boards/10/edit",
      postForm({ name: "Updated Board", description: "", sortOrder: "3" })
    );

    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/admin/structure");
  });

  it("redirects with ?error= when name is missing", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "" }));
    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("error=");
  });

  it("redirects with ?error= on AppView error", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    mockFetch.mockResolvedValueOnce(mockResponse({ error: "Board not found" }, false, 404));
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" }));
    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("error=");
  });

  it("redirects with ?error= on network error", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" }));
    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("error=");
  });

  it("redirects with ?error= for negative sort order", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request(
      "/admin/structure/boards/10/edit",
      postForm({ name: "Updated Board", sortOrder: "-3" })
    );
    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("error=");
  });
});

describe("createAdminRoutes — POST /admin/structure/boards/:id/delete", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", mockFetch);
    vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
    vi.resetModules();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
    mockFetch.mockReset();
  });

  function mockResponse(body: unknown, ok = true, status = 200) {
    return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) };
  }

  function setupSession(permissions: string[]) {
    mockFetch.mockResolvedValueOnce(
      mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
    );
    mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
  }

  async function loadAdminRoutes() {
    const { createAdminRoutes } = await import("../admin.js");
    return createAdminRoutes("http://localhost:3000");
  }

  function postForm(body: Record<string, string> = {}) {
    const params = new URLSearchParams(body);
    return {
      method: "POST",
      headers: {
        cookie: "atbb_session=token",
        "content-type": "application/x-www-form-urlencoded",
      },
      body: params.toString(),
    };
  }

  it("redirects to /login when unauthenticated", async () => {
    mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false }));
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/boards/10/delete", postForm());
    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/login");
  });

  it("returns 403 without manageCategories", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/boards/10/delete", postForm());
    expect(res.status).toBe(403);
  });

  it("redirects to /admin/structure on success", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    mockFetch.mockResolvedValueOnce(mockResponse({}, true, 200));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/boards/10/delete", postForm());

    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/admin/structure");
  });

  it("redirects with ?error= on AppView error (e.g. 409 has posts)", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ error: "Cannot delete board with posts. Remove all posts first." }, false, 409)
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/boards/10/delete", postForm());

    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(decodeURIComponent(location)).toContain("Cannot delete board with posts");
  });

  it("redirects with ?error= on network error", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    mockFetch.mockRejectedValueOnce(new Error("fetch failed"));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/structure/boards/10/delete", postForm());

    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("error=");
  });
});

describe("createAdminRoutes — GET /admin/modlog", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", mockFetch);
    vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
    vi.resetModules();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
    mockFetch.mockReset();
  });

  function mockResponse(body: unknown, ok = true, status = 200) {
    return {
      ok,
      status,
      statusText: ok ? "OK" : "Error",
      json: () => Promise.resolve(body),
    };
  }

  function setupSession(permissions: string[]) {
    mockFetch.mockResolvedValueOnce(
      mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
    );
    mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
  }

  async function loadAdminRoutes() {
    const { createAdminRoutes } = await import("../admin.js");
    return createAdminRoutes("http://localhost:3000");
  }

  const SAMPLE_ACTIONS = [
    {
      id: "1",
      action: "space.atbb.modAction.ban",
      moderatorDid: "did:plc:alice",
      moderatorHandle: "alice.bsky.social",
      subjectDid: "did:plc:bob",
      subjectHandle: "bob.bsky.social",
      subjectPostUri: null,
      reason: "Spam",
      createdAt: "2026-02-26T12:01:00.000Z",
    },
    {
      id: "2",
      action: "space.atbb.modAction.delete",
      moderatorDid: "did:plc:alice",
      moderatorHandle: "alice.bsky.social",
      subjectDid: null,
      subjectHandle: null,
      subjectPostUri: "at://did:plc:bob/space.atbb.post/abc123",
      reason: "Inappropriate",
      createdAt: "2026-02-26T11:30:00.000Z",
    },
  ];

  // ── Auth & permission gates ──────────────────────────────────────────────

  it("redirects unauthenticated users to /login", async () => {
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog");
    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/login");
  });

  it("returns 403 for user without any mod permission", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(403);
    const html = await res.text();
    expect(html).toContain("permission");
  });

  it("allows access for moderatePosts permission", async () => {
    setupSession(["space.atbb.permission.moderatePosts"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: [], total: 0, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(200);
  });

  it("allows access for banUsers permission", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: [], total: 0, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(200);
  });

  it("allows access for lockTopics permission", async () => {
    setupSession(["space.atbb.permission.lockTopics"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: [], total: 0, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(200);
  });

  // ── Table rendering ──────────────────────────────────────────────────────

  it("renders table with moderator handle and action label", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain("alice.bsky.social");
    expect(html).toContain("Ban");
    expect(html).toContain("bob.bsky.social");
    expect(html).toContain("Spam");
  });

  it("maps space.atbb.modAction.delete to 'Hide' label", async () => {
    setupSession(["space.atbb.permission.moderatePosts"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain("Hide");
  });

  it("shows post URI in subject column for post-targeting actions", async () => {
    setupSession(["space.atbb.permission.moderatePosts"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain("at://did:plc:bob/space.atbb.post/abc123");
  });

  it("shows handle in subject column for user-targeting actions", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain("bob.bsky.social");
  });

  it("shows empty state when no actions", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: [], total: 0, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain("No moderation actions");
  });

  // ── Pagination ───────────────────────────────────────────────────────────

  it("renders 'Page 1 of 2' indicator for 51 total actions", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain("Page 1 of 2");
  });

  it("shows Next link when more pages exist", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain('href="/admin/modlog?offset=50"');
    expect(html).toContain("Next");
  });

  it("hides Next link on last page", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 50, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog?offset=50", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).not.toContain('href="/admin/modlog?offset=100"');
  });

  it("shows Previous link when not on first page", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 50, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog?offset=50", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain('href="/admin/modlog?offset=0"');
    expect(html).toContain("Previous");
  });

  it("hides Previous link on first page", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).not.toContain('href="/admin/modlog?offset=-50"');
    expect(html).not.toContain("Previous");
  });

  it("passes offset query param to AppView", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 100, offset: 50, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    await routes.request("/admin/modlog?offset=50", {
      headers: { cookie: "atbb_session=token" },
    });
    // Third fetch call (index 2) is the modlog API call
    const modlogCall = mockFetch.mock.calls[2];
    expect(modlogCall[0]).toContain("offset=50");
    expect(modlogCall[0]).toContain("limit=50");
  });

  it("ignores invalid offset and defaults to 0", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
    );
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog?offset=notanumber", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(200);
    const modlogCall = mockFetch.mock.calls[2];
    expect(modlogCall[0]).toContain("offset=0");
  });

  // ── Error handling ───────────────────────────────────────────────────────

  it("returns 503 on AppView network error", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(503);
    const html = await res.text();
    expect(html).toContain("error-display");
  });

  it("returns 500 on AppView server error", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(500);
    const html = await res.text();
    expect(html).toContain("error-display");
  });

  it("returns 500 when AppView returns non-JSON response body", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce({
      ok: true,
      status: 200,
      statusText: "OK",
      json: () => Promise.reject(new SyntaxError("Unexpected token '<' in JSON")),
    });
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(500);
    const html = await res.text();
    expect(html).toContain("error-display");
  });

  it("redirects to /login when AppView returns 401", async () => {
    setupSession(["space.atbb.permission.banUsers"]);
    mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401));
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/modlog", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/login");
  });
});
