/**
 * Unit tests for pagination state management functions.
 *
 * Covers getTeamsCurrentPaginationState, handleAdminTeamAction pagination
 * preservation, getPaginationParams/buildTableUrl namespace isolation (#3244),
 * and pagination component boundary behavior.
 */

import { describe, test, expect, beforeEach } from "vitest";
import {
  getTeamsCurrentPaginationState,
  handleAdminTeamAction,
} from "../../mcpgateway/admin_ui/teams.js";

// ---------------------------------------------------------------------------
// Local implementations of getPaginationParams and buildTableUrl.
// These mirror the inline <script> definitions in admin.html.
// Defined here as pure functions to test the logic without loading admin.js.
// ---------------------------------------------------------------------------
function getPaginationParams(tableName) {
  const urlParams = new URLSearchParams(window.location.search);
  const prefix = tableName + "_";
  return {
    page: Math.max(1, parseInt(urlParams.get(prefix + "page"), 10) || 1),
    perPage: Math.max(1, parseInt(urlParams.get(prefix + "size"), 10) || 10),
    includeInactive: urlParams.get(prefix + "inactive"),
  };
}

function buildTableUrl(tableName, baseUrl, additionalParams = {}) {
  const params = getPaginationParams(tableName);
  const urlParams = new URLSearchParams(window.location.search);
  const prefix = tableName + "_";
  const url = new URL(baseUrl, window.location.origin);
  url.searchParams.set("page", params.page);
  url.searchParams.set("per_page", params.perPage);

  for (const [key, value] of Object.entries(additionalParams)) {
    if (key === "include_inactive" && params.includeInactive !== null) {
      url.searchParams.set("include_inactive", params.includeInactive);
    } else if (value !== null && value !== undefined && value !== "") {
      url.searchParams.set(key, value);
    }
  }

  if (
    !Object.prototype.hasOwnProperty.call(
      additionalParams,
      "include_inactive"
    ) &&
    params.includeInactive !== null
  ) {
    url.searchParams.set("include_inactive", params.includeInactive);
  }

  const namespacedQuery = urlParams.get(prefix + "q");
  const namespacedTags = urlParams.get(prefix + "tags");
  if (namespacedQuery) url.searchParams.set("q", namespacedQuery);
  if (namespacedTags) url.searchParams.set("tags", namespacedTags);

  return url.pathname + url.search;
}

beforeEach(() => {
  document.body.textContent = "";
  // Reset URL to clean state
  window.history.replaceState({}, "", "/admin");
});

// ---------------------------------------------------------------------------
// getTeamsCurrentPaginationState
// ---------------------------------------------------------------------------
describe("getTeamsCurrentPaginationState", () => {
  test("returns default values when URL params are missing", () => {
    const state = getTeamsCurrentPaginationState();
    expect(state).toEqual({
      page: 1,
      perPage: 10,
    });
  });

  test("returns page from teams_page URL parameter", () => {
    window.history.replaceState({}, "", "/admin?teams_page=3&teams_size=10");
    const state = getTeamsCurrentPaginationState();
    expect(state.page).toBe(3);
    expect(state.perPage).toBe(10);
  });

  test("returns perPage from teams_size URL parameter", () => {
    window.history.replaceState({}, "", "/admin?teams_page=1&teams_size=25");
    const state = getTeamsCurrentPaginationState();
    expect(state.page).toBe(1);
    expect(state.perPage).toBe(25);
  });

  test("returns both page and perPage from URL parameters", () => {
    window.history.replaceState({}, "", "/admin?teams_page=5&teams_size=50");
    const state = getTeamsCurrentPaginationState();
    expect(state).toEqual({
      page: 5,
      perPage: 50,
    });
  });

  test("returns defaults when only teams_page is present", () => {
    window.history.replaceState({}, "", "/admin?teams_page=2");
    const state = getTeamsCurrentPaginationState();
    expect(state.page).toBe(2);
    expect(state.perPage).toBe(10);
  });

  test("returns defaults when only teams_size is present", () => {
    window.history.replaceState({}, "", "/admin?teams_size=20");
    const state = getTeamsCurrentPaginationState();
    expect(state.page).toBe(1);
    expect(state.perPage).toBe(20);
  });

  test("ignores other URL parameters", () => {
    window.history.replaceState(
      {},
      "",
      "/admin?teams_page=4&teams_size=15&other=value&foo=bar"
    );
    const state = getTeamsCurrentPaginationState();
    expect(state).toEqual({
      page: 4,
      perPage: 15,
    });
  });

  test("handles URL with hash fragment", () => {
    window.history.replaceState(
      {},
      "",
      "/admin?teams_page=2&teams_size=20#teams"
    );
    const state = getTeamsCurrentPaginationState();
    expect(state).toEqual({
      page: 2,
      perPage: 20,
    });
  });

  test("handles empty string values in URL params", () => {
    window.history.replaceState({}, "", "/admin?teams_page=&teams_size=");
    const state = getTeamsCurrentPaginationState();
    expect(state).toEqual({
      page: 1,
      perPage: 10,
    });
  });

  test("handles non-numeric values in URL params", () => {
    window.history.replaceState({}, "", "/admin?teams_page=abc&teams_size=xyz");
    const state = getTeamsCurrentPaginationState();
    expect(state).toEqual({
      page: 1,
      perPage: 10,
    });
  });

  test("clamps negative page to 1", () => {
    window.history.replaceState({}, "", "/admin?teams_page=-1&teams_size=10");
    const state = getTeamsCurrentPaginationState();
    expect(state.page).toBe(1);
  });

  test("clamps negative perPage to 1", () => {
    window.history.replaceState({}, "", "/admin?teams_page=1&teams_size=-5");
    const state = getTeamsCurrentPaginationState();
    expect(state.perPage).toBe(1);
  });

  test("clamps zero page to 1", () => {
    window.history.replaceState({}, "", "/admin?teams_page=0&teams_size=10");
    const state = getTeamsCurrentPaginationState();
    expect(state.page).toBe(1);
  });
});

// ---------------------------------------------------------------------------
// Integration: handleAdminTeamAction with pagination preservation
// ---------------------------------------------------------------------------
describe("handleAdminTeamAction pagination preservation", () => {
  let lastHtmxUrl;

  beforeEach(() => {
    lastHtmxUrl = undefined;

    // Set up DOM elements needed for team refresh
    const unifiedList = document.createElement("div");
    unifiedList.id = "unified-teams-list";
    document.body.appendChild(unifiedList);

    const searchInput = document.createElement("input");
    searchInput.id = "team-search";
    searchInput.value = "";
    document.body.appendChild(searchInput);

    // Mock htmx.ajax
    window.htmx = {
      ajax: (method, url, options) => {
        lastHtmxUrl = url;
        return Promise.resolve();
      },
    };
  });

  test("preserves pagination state when refreshing teams list", async () => {
    window.history.replaceState(
      {},
      "",
      "/admin?teams_page=3&teams_size=25#teams"
    );

    const event = new window.CustomEvent("adminTeamAction", {
      detail: {
        refreshUnifiedTeamsList: true,
        delayMs: 0,
      },
    });

    handleAdminTeamAction(event);

    // Wait for setTimeout to complete
    await new Promise((resolve) => setTimeout(resolve, 10));

    expect(lastHtmxUrl).toBeDefined();
    expect(lastHtmxUrl).toContain("page=3");
    expect(lastHtmxUrl).toContain("per_page=25");
  });

  test("uses default pagination when URL params are missing", async () => {
    window.history.replaceState({}, "", "/admin#teams");

    const event = new window.CustomEvent("adminTeamAction", {
      detail: {
        refreshUnifiedTeamsList: true,
        delayMs: 0,
      },
    });

    handleAdminTeamAction(event);

    // Wait for setTimeout to complete
    await new Promise((resolve) => setTimeout(resolve, 10));

    expect(lastHtmxUrl).toBeDefined();
    expect(lastHtmxUrl).toContain("page=1");
    expect(lastHtmxUrl).toContain("per_page=10");
  });

  test("preserves search query along with pagination", async () => {
    window.history.replaceState(
      {},
      "",
      "/admin?teams_page=2&teams_size=20#teams"
    );
    const searchInput = document.getElementById("team-search");
    searchInput.value = "test team query";

    const event = new window.CustomEvent("adminTeamAction", {
      detail: {
        refreshUnifiedTeamsList: true,
        delayMs: 0,
      },
    });

    handleAdminTeamAction(event);

    // Wait for setTimeout to complete
    await new Promise((resolve) => setTimeout(resolve, 10));

    expect(lastHtmxUrl).toBeDefined();
    expect(lastHtmxUrl).toContain("page=2");
    expect(lastHtmxUrl).toContain("per_page=20");
    // Accept both URL encodings for space: %20 or +
    expect(lastHtmxUrl).toMatch(/q=test(\+|%20)team(\+|%20)query/);
  });

  test("uses page 1 after search resets pagination, not stale URL state", async () => {
    // Simulate: user was on page 3, then searched (which resets to page 1),
    // then triggers a CRUD action. The CRUD refresh should use page 1, not
    // the stale teams_page=3 from the URL.
    window.history.replaceState(
      {},
      "",
      "/admin?teams_page=3&teams_size=25#teams"
    );

    // Simulate what performTeamSearch does: sync URL to page 1
    const currentUrl = new URL(window.location.href);
    const urlParams = new URLSearchParams(currentUrl.searchParams);
    urlParams.set("teams_page", "1");
    const newUrl =
      currentUrl.pathname + "?" + urlParams.toString() + currentUrl.hash;
    window.history.replaceState({}, "", newUrl);

    // Verify URL was synced to page 1
    const urlAfterSearch = new URL(window.location.href);
    expect(urlAfterSearch.searchParams.get("teams_page")).toBe("1");

    // Now trigger a CRUD action
    const event = new window.CustomEvent("adminTeamAction", {
      detail: {
        refreshUnifiedTeamsList: true,
        delayMs: 0,
      },
    });

    handleAdminTeamAction(event);

    await new Promise((resolve) => setTimeout(resolve, 10));

    // CRUD refresh should use page 1, not stale page 3
    expect(lastHtmxUrl).toBeDefined();
    expect(lastHtmxUrl).toContain("page=1");
    expect(lastHtmxUrl).not.toContain("page=3");
  });
});

// ---------------------------------------------------------------------------
// getPaginationParams namespace isolation (#3244)
// ---------------------------------------------------------------------------
describe("getPaginationParams namespace isolation", () => {
  test("each table reads only its own namespaced URL params", () => {
    window.history.replaceState(
      {},
      "",
      "/admin?servers_page=3&servers_size=50&tools_page=1&tools_size=25"
    );

    const servers = getPaginationParams("servers");
    const tools = getPaginationParams("tools");

    expect(servers.page).toBe(3);
    expect(servers.perPage).toBe(50);
    expect(tools.page).toBe(1);
    expect(tools.perPage).toBe(25);
  });

  test("missing params for one table do not leak from another", () => {
    window.history.replaceState(
      {},
      "",
      "/admin?servers_page=5&servers_size=100"
    );

    const servers = getPaginationParams("servers");
    const tools = getPaginationParams("tools");

    expect(servers.page).toBe(5);
    expect(servers.perPage).toBe(100);
    // Tools should get defaults, not servers' values
    expect(tools.page).toBe(1);
    expect(tools.perPage).toBe(10);
  });

  test("all five section namespaces are independent", () => {
    window.history.replaceState(
      {},
      "",
      "/admin?servers_page=1&servers_size=10" +
        "&tools_page=2&tools_size=25" +
        "&gateways_page=3&gateways_size=50" +
        "&tokens_page=4&tokens_size=100" +
        "&agents_page=5&agents_size=200"
    );

    const sections = ["servers", "tools", "gateways", "tokens", "agents"];
    const expectedPages = [1, 2, 3, 4, 5];
    const expectedSizes = [10, 25, 50, 100, 200];

    sections.forEach((name, i) => {
      const state = getPaginationParams(name);
      expect(state.page).toBe(expectedPages[i]);
      expect(state.perPage).toBe(expectedSizes[i]);
    });
  });
});

// ---------------------------------------------------------------------------
// buildTableUrl namespace isolation (#3244)
// ---------------------------------------------------------------------------
describe("buildTableUrl namespace isolation", () => {
  test("builds URL using only the specified table's params", () => {
    window.history.replaceState(
      {},
      "",
      "/admin?servers_page=5&servers_size=100&tools_page=2&tools_size=25"
    );

    const serversUrl = buildTableUrl("servers", "/admin/servers/partial");
    const toolsUrl = buildTableUrl("tools", "/admin/tools/partial");

    expect(serversUrl).toContain("page=5");
    expect(serversUrl).toContain("per_page=100");
    expect(toolsUrl).toContain("page=2");
    expect(toolsUrl).toContain("per_page=25");
  });

  test("does not cross-contaminate search queries between tables", () => {
    window.history.replaceState(
      {},
      "",
      "/admin?servers_q=myserver&tools_q=mytool&servers_page=1&servers_size=10&tools_page=1&tools_size=10"
    );

    const serversUrl = buildTableUrl("servers", "/admin/servers/partial");
    const toolsUrl = buildTableUrl("tools", "/admin/tools/partial");

    expect(serversUrl).toContain("q=myserver");
    expect(serversUrl).not.toContain("q=mytool");
    expect(toolsUrl).toContain("q=mytool");
    expect(toolsUrl).not.toContain("q=myserver");
  });
});

// ---------------------------------------------------------------------------
// Pagination component: goToPage boundary behavior (#3244)
// ---------------------------------------------------------------------------
describe("pagination component goToPage behavior", () => {
  /**
   * Simulate the Alpine.js pagination component's goToPage logic.
   * This mirrors pagination_controls.html x-data methods.
   */
  function createPaginationComponent(opts) {
    const pages = [];
    const component = {
      currentPage: opts.currentPage || 1,
      perPage: opts.perPage || 50,
      totalItems: opts.totalItems || 0,
      totalPages: opts.totalPages || 0,
      hasNext: opts.hasNext || false,
      hasPrev: opts.hasPrev || false,
      _loadedPages: pages,

      goToPage(page) {
        if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
          this.currentPage = page;
          pages.push(page);
        }
      },

      prevPage() {
        if (this.hasPrev) {
          this.goToPage(this.currentPage - 1);
        }
      },

      nextPage() {
        if (this.hasNext) {
          this.goToPage(this.currentPage + 1);
        }
      },

      changePageSize(size) {
        this.perPage = parseInt(size, 10);
        this.currentPage = 1;
        pages.push(1);
      },
    };
    return component;
  }

  test("goToPage is a no-op when totalPages is 0 (cascade poison scenario)", () => {
    const component = createPaginationComponent({
      currentPage: 1,
      totalItems: 0,
      totalPages: 0,
      hasNext: false,
      hasPrev: false,
    });

    component.goToPage(1);
    component.goToPage(2);
    component.goToPage(3);

    expect(component._loadedPages).toHaveLength(0);
    expect(component.currentPage).toBe(1);
  });

  test("goToPage works when totalPages > 0 (correct pagination)", () => {
    const component = createPaginationComponent({
      currentPage: 1,
      totalItems: 75,
      totalPages: 2,
      hasNext: true,
      hasPrev: false,
    });

    component.goToPage(2);

    expect(component._loadedPages).toEqual([2]);
    expect(component.currentPage).toBe(2);
  });

  test("navigation buttons hidden when totalPages is 0", () => {
    // Mirrors: <template x-if="totalPages > 0"> in pagination_controls.html
    const component = createPaginationComponent({
      totalItems: 0,
      totalPages: 0,
    });

    const navigationVisible = component.totalPages > 0;
    expect(navigationVisible).toBe(false);
  });

  test("navigation buttons shown when totalPages > 0", () => {
    const component = createPaginationComponent({
      totalItems: 75,
      totalPages: 2,
    });

    const navigationVisible = component.totalPages > 0;
    expect(navigationVisible).toBe(true);
  });

  test("two independent sections have independent pagination state", () => {
    const servers = createPaginationComponent({
      currentPage: 1,
      totalItems: 0,
      totalPages: 0,
      hasNext: false,
      hasPrev: false,
    });

    const tools = createPaginationComponent({
      currentPage: 1,
      totalItems: 75,
      totalPages: 2,
      hasNext: true,
      hasPrev: false,
    });

    servers.goToPage(2);
    expect(servers.currentPage).toBe(1);
    expect(servers.totalPages).toBe(0);

    tools.goToPage(2);
    expect(tools.currentPage).toBe(2);
    expect(tools.totalPages).toBe(2);

    // Verify they didn't affect each other
    expect(servers.currentPage).toBe(1);
    expect(servers.totalItems).toBe(0);
    expect(tools.totalItems).toBe(75);
  });

  test("prevPage and nextPage respect bounds", () => {
    const component = createPaginationComponent({
      currentPage: 1,
      totalItems: 150,
      totalPages: 3,
      hasNext: true,
      hasPrev: false,
    });

    component.prevPage();
    expect(component.currentPage).toBe(1);

    component.hasNext = true;
    component.goToPage(2);
    expect(component.currentPage).toBe(2);

    component.goToPage(3);
    expect(component.currentPage).toBe(3);

    component.goToPage(4);
    expect(component.currentPage).toBe(3);
  });
});

// ---------------------------------------------------------------------------
// pagination_controls: data-extra-params safe handling
//
// The pagination_controls.html template stores extra query params (team_id, q,
// gateway_id, etc.) in a `data-extra-params` JSON attribute rather than
// inlining them inside the x-data JS string. This prevents Alpine.js parse
// errors when values contain double-quotes or other special characters.
//
// These tests verify the equivalent runtime logic: JSON.parse of the attribute
// value, forwarding to URLSearchParams, filtering, and team_id fallback.
// ---------------------------------------------------------------------------
describe("pagination_controls data-extra-params handling", () => {
  /**
   * Replicates the loadPage() extraParams block from pagination_controls.html.
   * Reads a JSON string (as produced by Jinja's `tojson | forceescape` and then
   * decoded by the browser), builds URL search params, and applies the team_id
   * fallback from the browser URL.
   */
  function buildUrlWithExtraParams(
    baseUrl,
    extraParamsJson,
    currentSearch = ""
  ) {
    const url = new URL(baseUrl, "http://localhost");
    const extraParams = JSON.parse(extraParamsJson || "{}");
    Object.entries(extraParams).forEach(([k, v]) => {
      if (k !== "include_inactive" && v !== null && v !== undefined) {
        url.searchParams.set(k, String(v));
      }
    });
    // team_id fallback: pick from browser URL when not already in extraParams
    const currentUrlParams = new URLSearchParams(currentSearch);
    const teamIdFromUrl = currentUrlParams.get("team_id");
    if (teamIdFromUrl && !extraParams.team_id) {
      url.searchParams.set("team_id", teamIdFromUrl);
    }
    return url;
  }

  test("forwards extra params to URL", () => {
    const json = JSON.stringify({ q: "hello", gateway_id: "42" });
    const url = buildUrlWithExtraParams("/admin/servers", json);
    expect(url.searchParams.get("q")).toBe("hello");
    expect(url.searchParams.get("gateway_id")).toBe("42");
  });

  test("filters out include_inactive from extra params", () => {
    const json = JSON.stringify({ q: "test", include_inactive: "true" });
    const url = buildUrlWithExtraParams("/admin/servers", json);
    expect(url.searchParams.get("q")).toBe("test");
    expect(url.searchParams.has("include_inactive")).toBe(false);
  });

  test("filters out null values from extra params", () => {
    const json = JSON.stringify({ q: null, gateway_id: "5" });
    const url = buildUrlWithExtraParams("/admin/servers", json);
    expect(url.searchParams.has("q")).toBe(false);
    expect(url.searchParams.get("gateway_id")).toBe("5");
  });

  test("picks up team_id from browser URL when not in extra params", () => {
    const url = buildUrlWithExtraParams(
      "/admin/servers",
      "{}",
      "?team_id=abc123"
    );
    expect(url.searchParams.get("team_id")).toBe("abc123");
  });

  test("extra params team_id takes precedence over URL team_id", () => {
    const json = JSON.stringify({ team_id: "from-params" });
    const url = buildUrlWithExtraParams(
      "/admin/servers",
      json,
      "?team_id=from-url"
    );
    expect(url.searchParams.get("team_id")).toBe("from-params");
  });

  test("handles values with double quotes via JSON.parse without XSS", () => {
    // Jinja tojson encodes " as \u0022 inside the JSON string; when the browser
    // parses the data attribute the value is the literal JSON text, and
    // JSON.parse recovers the original string with the " character intact.
    // URLSearchParams then percent-encodes it, so it never appears raw in the URL.
    const json = JSON.stringify({ q: 'foo"bar' });
    const url = buildUrlWithExtraParams("/admin/servers", json);
    expect(url.searchParams.get("q")).toBe('foo"bar');
    // Must be percent-encoded (%22) in the serialised URL, never a raw "
    expect(url.toString()).toContain("%22");
    expect(url.toString()).not.toMatch(/q=foo"bar/);
  });

  test("handles values with single quotes safely", () => {
    const json = JSON.stringify({ q: "it's a test" });
    const url = buildUrlWithExtraParams("/admin/servers", json);
    expect(url.searchParams.get("q")).toBe("it's a test");
  });

  test("handles values with script tags safely", () => {
    const json = JSON.stringify({ q: "<script>alert(1)</script>" });
    const url = buildUrlWithExtraParams("/admin/servers", json);
    expect(url.searchParams.get("q")).toBe("<script>alert(1)</script>");
    // The URL string must percent-encode < and >, not contain raw HTML
    expect(url.toString()).not.toContain("<script>");
  });

  test("handles empty extra params string as empty object", () => {
    const url = buildUrlWithExtraParams("/admin/servers", "");
    // No extra params added, no 'undefined' leaking into URL
    expect(url.search).toBe("");
    expect(url.toString()).not.toContain("undefined");
  });

  test("handles empty extra params JSON object with no team_id in URL", () => {
    const url = buildUrlWithExtraParams("/admin/servers", "{}", "");
    expect(url.search).toBe("");
  });

  test("all extra param keys are forwarded when none are filtered", () => {
    const json = JSON.stringify({
      q: "search term",
      gateway_id: "7",
      tags: "alpha,beta",
      relationship: "linked",
      visibility: "public",
    });
    const url = buildUrlWithExtraParams("/admin/servers", json);
    expect(url.searchParams.get("q")).toBe("search term");
    expect(url.searchParams.get("gateway_id")).toBe("7");
    expect(url.searchParams.get("tags")).toBe("alpha,beta");
    expect(url.searchParams.get("relationship")).toBe("linked");
    expect(url.searchParams.get("visibility")).toBe("public");
  });
});

// ---------------------------------------------------------------------------
// Pagination swapStyle used by loadPage (#3396)
//
// Table-targeted pagination must use outerHTML swap to prevent nested <table>
// elements. When htmx.ajax receives swap: 'innerHTML' and the response body
// starts with <table>, the browser ejects the inner table as a sibling,
// leaving the visible table blank.
// ---------------------------------------------------------------------------
describe("pagination loadPage swapStyle (#3396)", () => {
  /**
   * Create a pagination component that records htmx.ajax calls
   * instead of actually performing them.
   */
  function createComponentWithAjaxSpy(swapStyle) {
    const ajaxCalls = [];
    const htmx = {
      ajax(method, url, opts) {
        ajaxCalls.push({ method, url, ...opts });
      },
    };
    const component = {
      currentPage: 1,
      perPage: 50,
      totalItems: 75,
      totalPages: 2,
      hasNext: true,
      hasPrev: false,
      targetSelector: "#tools-table",
      swapStyle,
      tableName: "tools",
      baseUrl: "/admin/tools/partial",
      $el: {
        dataset: { extraParams: "{}" },
      },

      updateBrowserUrl() {},

      loadPage(page) {
        const url = new URL(this.baseUrl, "http://localhost");
        url.searchParams.set("page", page);
        url.searchParams.set("per_page", this.perPage);

        htmx.ajax("GET", url.toString(), {
          target: this.targetSelector,
          swap: this.swapStyle,
          indicator: "#tools-loading",
        });
      },
    };
    return { component, ajaxCalls };
  }

  test("outerHTML swapStyle is passed to htmx.ajax for table targets", () => {
    const { component, ajaxCalls } = createComponentWithAjaxSpy("outerHTML");
    component.loadPage(2);
    expect(ajaxCalls).toHaveLength(1);
    expect(ajaxCalls[0].swap).toBe("outerHTML");
    expect(ajaxCalls[0].target).toBe("#tools-table");
  });

  test("innerHTML swapStyle is passed to htmx.ajax for non-table targets", () => {
    const { component, ajaxCalls } = createComponentWithAjaxSpy("innerHTML");
    component.targetSelector = "#tokens-table";
    component.loadPage(2);
    expect(ajaxCalls).toHaveLength(1);
    expect(ajaxCalls[0].swap).toBe("innerHTML");
  });

  test("swapStyle defaults to innerHTML when not explicitly set", () => {
    // Mirrors pagination_controls.html: swapStyle: '{{ hx_swap|default('innerHTML') }}'
    const defaultSwap = undefined || "innerHTML";
    const { component, ajaxCalls } = createComponentWithAjaxSpy(defaultSwap);
    component.loadPage(2);
    expect(ajaxCalls[0].swap).toBe("innerHTML");
  });
});

// ---------------------------------------------------------------------------
// Alpine.js reinit on OOB-swapped pagination controls (#3039)
//
// After HTMX settles an OOB swap of a pagination-controls div, Alpine.js
// may not automatically detect and initialise the new x-data component
// (race with MutationObserver). The htmx:afterSettle listener in
// initializeTabState() calls Alpine.initTree() on any uninitialized
// pagination controls.
// ---------------------------------------------------------------------------
describe("Alpine.js reinit on OOB-swapped pagination controls (#3039)", () => {
    /** Dispatch a synthetic htmx:afterSettle event on document.body. */
    function fireAfterSettle() {
        const event = new win.Event("htmx:afterSettle", { bubbles: true });
        doc.body.dispatchEvent(event);
    }

    /** Create a pagination-controls div with an x-data child, optionally pre-initialized. */
    function createPaginationDiv(id, { initialized = false } = {}) {
        const container = doc.createElement("div");
        container.id = id;
        const xDataEl = doc.createElement("div");
        xDataEl.setAttribute("x-data", "{ currentPage: 1 }");
        if (initialized) {
            xDataEl._x_dataStack = [{ currentPage: 1 }];
        }
        container.appendChild(xDataEl);
        doc.body.appendChild(container);
        return container;
    }

    beforeEach(() => {
        // Clear any previous Alpine mock
        delete win.Alpine;
    });

    test("calls Alpine.initTree on uninitialized pagination controls", () => {
        const initTreeCalls = [];
        win.Alpine = {
            initTree: (el) => initTreeCalls.push(el),
        };

        const div = createPaginationDiv("tools-pagination-controls");

        fireAfterSettle();

        expect(initTreeCalls).toHaveLength(1);
        expect(initTreeCalls[0]).toBe(div);
    });

    test("skips already-initialized pagination controls", () => {
        const initTreeCalls = [];
        win.Alpine = {
            initTree: (el) => initTreeCalls.push(el),
        };

        createPaginationDiv("tools-pagination-controls", {
            initialized: true,
        });

        fireAfterSettle();

        expect(initTreeCalls).toHaveLength(0);
    });

    test("handles multiple pagination controls, only inits uninitialized ones", () => {
        const initTreeCalls = [];
        win.Alpine = {
            initTree: (el) => initTreeCalls.push(el),
        };

        createPaginationDiv("servers-pagination-controls", {
            initialized: true,
        });
        const uninitDiv = createPaginationDiv("tools-pagination-controls");
        createPaginationDiv("gateways-pagination-controls", {
            initialized: true,
        });

        fireAfterSettle();

        expect(initTreeCalls).toHaveLength(1);
        expect(initTreeCalls[0]).toBe(uninitDiv);
    });

    test("does not error when Alpine is not loaded", () => {
        // Alpine is undefined (deleted in beforeEach)
        createPaginationDiv("tools-pagination-controls");

        expect(() => fireAfterSettle()).not.toThrow();
    });

    test("does not error when Alpine.initTree is not a function", () => {
        win.Alpine = { version: "3.x" }; // no initTree

        createPaginationDiv("tools-pagination-controls");

        expect(() => fireAfterSettle()).not.toThrow();
    });

    test("does not call initTree when pagination div has no x-data child", () => {
        const initTreeCalls = [];
        win.Alpine = {
            initTree: (el) => initTreeCalls.push(el),
        };

        // Create a div with the right ID but no x-data child
        const container = doc.createElement("div");
        container.id = "tools-pagination-controls";
        doc.body.appendChild(container);

        fireAfterSettle();

        expect(initTreeCalls).toHaveLength(0);
    });

    test("matches metrics top-performers pagination-controls-visible IDs", () => {
        const initTreeCalls = [];
        win.Alpine = {
            initTree: (el) => initTreeCalls.push(el),
        };

        // Metrics partials use IDs like "top-tools-pagination-controls-visible"
        // which contain "-pagination-controls" but don't end with it.
        const metricsDiv = createPaginationDiv(
            "top-tools-pagination-controls-visible",
        );

        fireAfterSettle();

        expect(initTreeCalls).toHaveLength(1);
        expect(initTreeCalls[0]).toBe(metricsDiv);
    });

    test("still enables disabled toggles alongside Alpine reinit", () => {
        win.Alpine = { initTree: () => {} };

        // Create a target element for the toggle
        const target = doc.createElement("div");
        target.id = "tools-table";
        doc.body.appendChild(target);

        // Create a disabled toggle
        const toggle = doc.createElement("input");
        toggle.type = "checkbox";
        toggle.className = "show-inactive-toggle";
        toggle.disabled = true;
        toggle.setAttribute("hx-target", "#tools-table");
        doc.body.appendChild(toggle);

        createPaginationDiv("tools-pagination-controls");

        fireAfterSettle();

        expect(toggle.disabled).toBe(false);
    });
});

// ---------------------------------------------------------------------------
// updateFilterStatus (#3647)
//
// Shows "Filters active" text in status spans when URL params indicate
// active search/tag/inactive filters for a table.
// ---------------------------------------------------------------------------
describe("updateFilterStatus (#3647)", () => {
    function createFilterStatusSpan(tableName) {
        const span = doc.createElement("span");
        span.id = tableName + "-filter-status";
        doc.body.appendChild(span);
        return span;
    }

    test("shows empty text when no filters are active", () => {
        const span = createFilterStatusSpan("tools");
        win.history.replaceState({}, "", "/admin");

        win.updateFilterStatus();

        expect(span.textContent).toBe("");
    });

    test("shows 'Filters active' when query filter is active", () => {
        const span = createFilterStatusSpan("tools");
        win.history.replaceState({}, "", "/admin?tools_q=search");

        win.updateFilterStatus();

        expect(span.textContent).toBe("Filters active");
    });

    test("shows status when tags filter is active", () => {
        const span = createFilterStatusSpan("tools");
        win.history.replaceState({}, "", "/admin?tools_tags=prod");

        win.updateFilterStatus();

        expect(span.textContent).toBe("Filters active");
    });

    test("shows status when inactive filter is active", () => {
        const span = createFilterStatusSpan("tools");
        win.history.replaceState({}, "", "/admin?tools_inactive=true");

        win.updateFilterStatus();

        expect(span.textContent).toBe("Filters active");
    });

    test("does not show status when inactive is false", () => {
        const span = createFilterStatusSpan("tools");
        win.history.replaceState({}, "", "/admin?tools_inactive=false");

        win.updateFilterStatus();

        expect(span.textContent).toBe("");
    });

    test("updates multiple table statuses independently", () => {
        const toolsSpan = createFilterStatusSpan("tools");
        const serversSpan = createFilterStatusSpan("servers");
        win.history.replaceState({}, "", "/admin?tools_q=search");

        win.updateFilterStatus();

        expect(toolsSpan.textContent).toBe("Filters active");
        expect(serversSpan.textContent).toBe("");
    });

    test("clears status when filters are removed", () => {
        const span = createFilterStatusSpan("tools");

        // First set active filters
        win.history.replaceState({}, "", "/admin?tools_q=search");
        win.updateFilterStatus();
        expect(span.textContent).toBe("Filters active");

        // Then clear filters
        win.history.replaceState({}, "", "/admin");
        win.updateFilterStatus();
        expect(span.textContent).toBe("");
    });

    test("no-ops when status span is missing from DOM", () => {
        win.history.replaceState({}, "", "/admin?tools_q=search");

        // Should not throw
        expect(() => win.updateFilterStatus()).not.toThrow();
    });
});

// ---------------------------------------------------------------------------
// htmx:afterSettle rehydration listener (#3647)
//
// After HTMX content swaps targeting table containers, the listener
// rehydrates search inputs from URL state and updates filter status text.
// ---------------------------------------------------------------------------
describe("htmx:afterSettle rehydration listener (#3647)", () => {
    function fireAfterSettleWithTarget(targetId) {
        const targetEl =
            doc.getElementById(targetId) || doc.createElement("div");
        if (!targetEl.id) targetEl.id = targetId;
        const event = new win.CustomEvent("htmx:afterSettle", {
            bubbles: true,
            detail: { target: targetEl },
        });
        doc.body.dispatchEvent(event);
    }

    test("triggers rehydration for -table suffix target", () => {
        const el = doc.createElement("div");
        el.id = "tools-table";
        doc.body.appendChild(el);

        const span = doc.createElement("span");
        span.id = "tools-filter-status";
        doc.body.appendChild(span);

        win.history.replaceState({}, "", "/admin?tools_q=hello");
        fireAfterSettleWithTarget("tools-table");

        // updateFilterStatus was called — status span should be updated
        expect(span.textContent).toBe("Filters active");
    });

    test("triggers rehydration for -table-body suffix target", () => {
        const el = doc.createElement("div");
        el.id = "tools-table-body";
        doc.body.appendChild(el);

        const span = doc.createElement("span");
        span.id = "tools-filter-status";
        doc.body.appendChild(span);

        win.history.replaceState({}, "", "/admin?tools_tags=v1");
        fireAfterSettleWithTarget("tools-table-body");

        expect(span.textContent).toBe("Filters active");
    });

    test("triggers rehydration for -list-container suffix target", () => {
        const el = doc.createElement("div");
        el.id = "servers-list-container";
        doc.body.appendChild(el);

        const span = doc.createElement("span");
        span.id = "servers-filter-status";
        doc.body.appendChild(span);

        win.history.replaceState({}, "", "/admin?servers_q=test");
        fireAfterSettleWithTarget("servers-list-container");

        expect(span.textContent).toBe("Filters active");
    });

    test("triggers rehydration for -pagination-controls suffix target", () => {
        const el = doc.createElement("div");
        el.id = "tools-pagination-controls";
        doc.body.appendChild(el);

        const span = doc.createElement("span");
        span.id = "tools-filter-status";
        doc.body.appendChild(span);

        win.history.replaceState({}, "", "/admin?tools_q=test");
        fireAfterSettleWithTarget("tools-pagination-controls");

        expect(span.textContent).toBe("Filters active");
    });

    test("does not trigger for unrelated target IDs", () => {
        const span = doc.createElement("span");
        span.id = "tools-filter-status";
        span.textContent = "initial";
        doc.body.appendChild(span);

        win.history.replaceState({}, "", "/admin?tools_q=test");
        fireAfterSettleWithTarget("some-random-div");

        // Status should not have changed
        expect(span.textContent).toBe("initial");
    });

    test("does not throw when detail is undefined", () => {
        const event = new win.Event("htmx:afterSettle", { bubbles: true });
        expect(() => doc.body.dispatchEvent(event)).not.toThrow();
    });

    test("does not throw when target has no id", () => {
        const event = new win.CustomEvent("htmx:afterSettle", {
            bubbles: true,
            detail: { target: doc.createElement("div") },
        });
        expect(() => doc.body.dispatchEvent(event)).not.toThrow();
    });
});

// ---------------------------------------------------------------------------
// pagination_controls: URL-param fallback for filter persistence (#3647)
//
// loadPage() trusts DOM inputs when the element exists (even if empty,
// meaning the user cleared the filter).  It only falls back to namespaced
// URL params when the element itself is absent (bookmarked URL, restricted
// iframe, or ID mismatch like catalog/a2a-agents).
// ---------------------------------------------------------------------------
describe("pagination_controls URL-param filter fallback (#3647)", () => {
    /**
     * Simulates the merged loadPage() URL-building logic from
     * pagination_controls.html.
     *
     * @param {object} opts
     * @param {string} opts.baseUrl
     * @param {string} [opts.extraParamsJson='{}']
     * @param {string} opts.tableName
     * @param {object|null} [opts.searchEl]  null = element not in DOM
     * @param {object|null} [opts.tagEl]     null = element not in DOM
     * @param {string} [opts.currentSearch='']
     */
    function buildUrlWithFallback(opts) {
        const url = new URL(opts.baseUrl, "http://localhost");
        url.searchParams.set("page", "1");
        url.searchParams.set("per_page", "50");

        const extraParams = JSON.parse(opts.extraParamsJson || "{}");
        Object.entries(extraParams).forEach(([k, v]) => {
            if (k !== "include_inactive" && v !== null && v !== undefined) {
                url.searchParams.set(k, String(v));
            }
        });

        const currentUrlParams = new URLSearchParams(opts.currentSearch || "");
        if (opts.tableName) {
            const searchInput = opts.searchEl ?? null;
            const tagInput = opts.tagEl ?? null;
            const livePrefix = opts.tableName + "_";

            const finalQuery =
                searchInput !== null
                    ? (searchInput.value || "").trim()
                    : currentUrlParams.get(livePrefix + "q") || "";
            const finalTags =
                tagInput !== null
                    ? (tagInput.value || "").trim()
                    : currentUrlParams.get(livePrefix + "tags") || "";

            if (finalQuery) {
                url.searchParams.set("q", finalQuery);
            } else {
                url.searchParams.delete("q");
            }
            if (finalTags) {
                url.searchParams.set("tags", finalTags);
            } else {
                url.searchParams.delete("tags");
            }
        }

        const teamIdFromUrl = currentUrlParams.get("team_id");
        if (teamIdFromUrl && !extraParams.team_id) {
            url.searchParams.set("team_id", teamIdFromUrl);
        }
        return url;
    }

    test("DOM input takes precedence over URL params", () => {
        const url = buildUrlWithFallback({
            baseUrl: "/admin/tools/partial",
            tableName: "tools",
            searchEl: { value: "dom-value" },
            currentSearch: "?tools_q=url-value",
        });
        expect(url.searchParams.get("q")).toBe("dom-value");
    });

    test("empty DOM input clears filter (does NOT fall back to stale URL)", () => {
        const url = buildUrlWithFallback({
            baseUrl: "/admin/tools/partial",
            tableName: "tools",
            searchEl: { value: "" },
            currentSearch: "?tools_q=stale-value",
        });
        expect(url.searchParams.has("q")).toBe(false);
    });

    test("empty DOM tags input clears tags (does NOT fall back to stale URL)", () => {
        const url = buildUrlWithFallback({
            baseUrl: "/admin/tools/partial",
            tableName: "tools",
            tagEl: { value: "" },
            currentSearch: "?tools_tags=stale-tags",
        });
        expect(url.searchParams.has("tags")).toBe(false);
    });

    test("falls back to URL param when DOM element is absent", () => {
        const url = buildUrlWithFallback({
            baseUrl: "/admin/tools/partial",
            tableName: "tools",
            searchEl: null,
            currentSearch: "?tools_q=url-value",
        });
        expect(url.searchParams.get("q")).toBe("url-value");
    });

    test("falls back to URL tags when DOM element is absent", () => {
        const url = buildUrlWithFallback({
            baseUrl: "/admin/tools/partial",
            tableName: "tools",
            tagEl: null,
            currentSearch: "?tools_tags=prod,staging",
        });
        expect(url.searchParams.get("tags")).toBe("prod,staging");
    });

    test("uses correct namespaced prefix per table", () => {
        const toolsUrl = buildUrlWithFallback({
            baseUrl: "/admin/tools/partial",
            tableName: "tools",
            searchEl: null,
            currentSearch: "?tools_q=alpha&servers_q=beta",
        });
        expect(toolsUrl.searchParams.get("q")).toBe("alpha");

        const serversUrl = buildUrlWithFallback({
            baseUrl: "/admin/servers/partial",
            tableName: "servers",
            searchEl: null,
            currentSearch: "?tools_q=alpha&servers_q=beta",
        });
        expect(serversUrl.searchParams.get("q")).toBe("beta");
    });

    test("extraParams are preserved when DOM overrides q", () => {
        const url = buildUrlWithFallback({
            baseUrl: "/admin/tools/partial",
            extraParamsJson: JSON.stringify({ gateway_id: "7", q: "old" }),
            tableName: "tools",
            searchEl: { value: "new" },
        });
        expect(url.searchParams.get("q")).toBe("new");
        expect(url.searchParams.get("gateway_id")).toBe("7");
    });

    test("team_id from URL preserved when not in extraParams", () => {
        const url = buildUrlWithFallback({
            baseUrl: "/admin/tools/partial",
            tableName: "tools",
            currentSearch: "?team_id=t1",
        });
        expect(url.searchParams.get("team_id")).toBe("t1");
    });

    test("extraParams team_id takes precedence over URL team_id", () => {
        const url = buildUrlWithFallback({
            baseUrl: "/admin/tools/partial",
            extraParamsJson: JSON.stringify({ team_id: "from-params" }),
            tableName: "tools",
            currentSearch: "?team_id=from-url",
        });
        expect(url.searchParams.get("team_id")).toBe("from-params");
    });

    test("no q/tags when DOM elements exist but are empty and URL has nothing", () => {
        const url = buildUrlWithFallback({
            baseUrl: "/admin/tools/partial",
            tableName: "tools",
            searchEl: { value: "" },
            tagEl: { value: "" },
        });
        expect(url.searchParams.has("q")).toBe(false);
        expect(url.searchParams.has("tags")).toBe(false);
    });

    test("namespaces are isolated: tools_q does not leak to servers", () => {
        const url = buildUrlWithFallback({
            baseUrl: "/admin/servers/partial",
            tableName: "servers",
            searchEl: null,
            currentSearch: "?tools_q=should-not-leak",
        });
        expect(url.searchParams.has("q")).toBe(false);
    });

    test("whitespace-only DOM input is treated as cleared (no fallback)", () => {
        const url = buildUrlWithFallback({
            baseUrl: "/admin/tools/partial",
            tableName: "tools",
            searchEl: { value: "   " },
            currentSearch: "?tools_q=from-url",
        });
        expect(url.searchParams.has("q")).toBe(false);
    });

    test("q from DOM and tags from URL fallback work simultaneously", () => {
        const url = buildUrlWithFallback({
            baseUrl: "/admin/tools/partial",
            tableName: "tools",
            searchEl: { value: "dom-search" },
            tagEl: null,
            currentSearch: "?tools_tags=url-tags",
        });
        expect(url.searchParams.get("q")).toBe("dom-search");
        expect(url.searchParams.get("tags")).toBe("url-tags");
    });
});
