// @vitest-environment happy-dom
// Tell React this is an `act`-aware test environment so react-query's
// internal state updates flush through without noisy console warnings.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
  true;

import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ActivityLog } from "./ActivityLog";
import { api } from "@/lib/api";
import type { Activity } from "@/types";

// Module-level mock — vitest hoists vi.mock above imports. We only stub
// the API method this page touches (`listActivity`). `isConfigured: true`
// is critical: without it, `useActivity` falls back to the empty zustand
// store instead of calling our mocked API (see useActivity.ts).
vi.mock("@/lib/api", () => ({
  api: {
    isConfigured: true,
    listActivity: vi.fn(),
    getRemittance: vi.fn(),
  },
}));

/**
 * Empty activity set by default — individual tests override the resolved
 * value with whatever shape they need to assert on.
 */
const EMPTY: { items: Activity[]; total: number; returned: number; has_more: boolean } = {
  items: [],
  total: 0,
  returned: 0,
  has_more: false,
};

const SAMPLE: Activity[] = [
  {
    id: "A-1",
    kind: "claim_submitted",
    message: "Claim CLM-1 submitted",
    timestamp: "2026-06-20T12:00:00Z",
  },
  {
    id: "A-2",
    kind: "claim_paid",
    message: "Claim CLM-2 paid",
    timestamp: "2026-06-20T11:00:00Z",
    npi: "1234567890",
    amount: 100,
  },
  {
    id: "A-3",
    kind: "remit_received",
    message: "Remit PCN-1 received",
    timestamp: "2026-06-20T10:00:00Z",
  },
];

type RenderOpts = { initialEntries?: string[] };

/**
 * Render the page wrapped in QueryClient + MemoryRouter. We use
 * `MemoryRouter` rather than the default `BrowserRouter` because
 * happy-dom's URL state has to be driven explicitly per test — a memory
 * router accepts `initialEntries` so each test gets a clean URL without
 * the `setLocation` dance that `useDrawerUrlState.test.ts` uses.
 */
function renderActivity({ initialEntries = ["/activity"] }: RenderOpts = {}): {
  unmount: () => void;
} {
  const container = document.createElement("div");
  document.body.appendChild(container);
  const qc = new QueryClient({
    defaultOptions: { queries: { retry: false, retryDelay: 0 } },
  });
  const root: Root = createRoot(container);
  act(() => {
    root.render(
      React.createElement(
        QueryClientProvider,
        { client: qc },
        React.createElement(
          MemoryRouter,
          { initialEntries },
          React.createElement(ActivityLog),
        ),
      ),
    );
  });
  return {
    unmount: () => {
      act(() => root.unmount());
      container.remove();
    },
  };
}

/**
 * Flush microtasks + react state updates until `predicate` holds. Radix
 * portals render asynchronously after a Select opens, so polling the
 * DOM is the only way to avoid flakiness without `@testing-library/dom`.
 */
async function settle(
  predicate: () => boolean,
  timeoutMs = 2000,
): Promise<void> {
  const start = Date.now();
  while (!predicate()) {
    if (Date.now() - start > timeoutMs) {
      throw new Error("settle: predicate did not hold within timeout");
    }
    await act(async () => {
      await new Promise((r) => setTimeout(r, 0));
    });
  }
}

/**
 * Open the Radix "Since" Select and click the option whose label
 * contains `label`. The trigger has `aria-label="Since"` and the
 * options portal into `document.body`.
 */
async function pickSinceOption(label: string): Promise<void> {
  // Find the trigger. Radix Select renders a button with role="combobox".
  const triggers = Array.from(
    document.body.querySelectorAll('button[role="combobox"]'),
  );
  const trigger = triggers.find(
    (t) => t.getAttribute("aria-label") === "Since",
  ) as HTMLButtonElement | undefined;
  expect(trigger).toBeDefined();
  await act(async () => {
    trigger!.click();
  });
  await settle(
    () =>
      Array.from(document.body.querySelectorAll('[role="option"]')).some(
        (o) => o.textContent?.includes(label) ?? false,
      ),
  );
  const option = Array.from(
    document.body.querySelectorAll('[role="option"]'),
  ).find((o) => o.textContent?.includes(label)) as HTMLElement | undefined;
  expect(option).toBeDefined();
  await act(async () => {
    option!.click();
  });
  // Wait for the portal to close so subsequent assertions see the new
  // query state, not the still-open menu.
  await settle(() => document.body.querySelector('[role="option"]') === null);
}

describe("ActivityLog page filters", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    (api.listActivity as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
      EMPTY,
    );
    // Default for the per-remit detail fetch — the drawer fetches
    // this whenever `?remit=` is in the URL or `remit_received`
    // events drill in. Return a never-resolving promise so the
    // drawer stays in the loading state; the smoke tests only
    // assert the drawer mounts.
    (
      api.getRemittance as unknown as ReturnType<typeof vi.fn>
    ).mockReturnValue(new Promise(() => {}));
  });

  it("test_renders_filter_controls_when_mounted", async () => {
    const { unmount } = renderActivity();
    // ActivityFilters mounts synchronously with the page; no async wait.
    expect(
      document.body.querySelector('[data-testid="activity-filters"]'),
    ).not.toBeNull();

    // All six kind chips render (one per ActivityKind enum value).
    const chips = document.body.querySelectorAll(
      '[data-testid^="kind-chip-"]',
    );
    expect(chips.length).toBe(6);

    // "Since" trigger is present. The label "All time" is the default.
    expect(document.body.textContent).toContain("All time");

    // Clear button is NOT present when no filter is active.
    expect(
      document.body.querySelector('[data-testid="clear-filters"]'),
    ).toBeNull();

    unmount();
  });

  it("test_selecting_a_kind_updates_url_and_triggers_refetch", async () => {
    (api.listActivity as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [SAMPLE[0]],
      total: 1,
      returned: 1,
      has_more: false,
    });

    const { unmount } = renderActivity({ initialEntries: ["/activity"] });

    // Wait for the initial (empty) fetch to settle so we can compare
    // the pre-click and post-click call args cleanly.
    await settle(() => (api.listActivity as ReturnType<typeof vi.fn>).mock.calls.length >= 1);
    const callsBefore = (api.listActivity as ReturnType<typeof vi.fn>).mock.calls.length;

    // Click the "Submitted" kind chip.
    const chip = document.body.querySelector(
      '[data-testid="kind-chip-claim_submitted"]',
    ) as HTMLButtonElement | null;
    expect(chip).not.toBeNull();
    await act(async () => {
      chip!.click();
    });

    // URL must reflect the selection. We're inside a MemoryRouter, so
    // `window.location` doesn't update — instead the hook's setSearchParams
    // pushes a new history entry inside the router. The assertion below
    // reaches into React Router's `useLocation` by reading the rendered
    // search input — but the simpler invariant is that `api.listActivity`
    // is called again with `{ kind: "claim_submitted" }`.
    await settle(
      () =>
        (api.listActivity as ReturnType<typeof vi.fn>).mock.calls.length >
        callsBefore,
    );
    const lastCall = (api.listActivity as ReturnType<typeof vi.fn>).mock.calls.at(
      -1,
    )![0] as { kind?: string };
    expect(lastCall.kind).toBe("claim_submitted");

    unmount();
  });

  it("test_since_select_changes_the_api_call_param", async () => {
    const { unmount } = renderActivity({ initialEntries: ["/activity"] });

    // Wait for the initial fetch (no `since`) so we can compare.
    await settle(
      () => (api.listActivity as ReturnType<typeof vi.fn>).mock.calls.length >= 1,
    );
    const callsBefore = (api.listActivity as ReturnType<typeof vi.fn>).mock.calls.length;

    // Pick "Last 1 hour" from the dropdown.
    await pickSinceOption("Last 1 hour");

    // A new fetch should have fired with a non-empty `since` ISO string.
    await settle(
      () =>
        (api.listActivity as ReturnType<typeof vi.fn>).mock.calls.length >
        callsBefore,
    );
    const calls = (api.listActivity as ReturnType<typeof vi.fn>).mock.calls;
    const lastCall = calls.at(-1)![0] as { since?: string };
    expect(lastCall.since).toBeDefined();
    // The backend compares against ISO timestamps lexicographically, so
    // the page must convert "1h" → ISO before sending. We don't pin the
    // exact value (clock drifts), but it must be parseable as a recent
    // timestamp and lie within the last hour from now.
    const sinceMs = Date.parse(lastCall.since!);
    expect(Number.isFinite(sinceMs)).toBe(true);
    expect(Date.now() - sinceMs).toBeGreaterThanOrEqual(0);
    expect(Date.now() - sinceMs).toBeLessThanOrEqual(60 * 60 * 1000 + 5_000);

    unmount();
  });

  it("test_clear_filters_resets_url_and_refetches", async () => {
    // Mount with both filters already set in the URL — Clear should drop
    // them and trigger an unfiltered refetch.
    (api.listActivity as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: SAMPLE,
      total: SAMPLE.length,
      returned: SAMPLE.length,
      has_more: false,
    });

    const { unmount } = renderActivity({
      initialEntries: ["/activity?kind=claim_paid&since=24h"],
    });

    // Wait for the initial filtered fetch.
    await settle(
      () => (api.listActivity as ReturnType<typeof vi.fn>).mock.calls.length >= 1,
    );
    const initialCall = (api.listActivity as ReturnType<typeof vi.fn>).mock.calls[0]![0] as {
      kind?: string;
      since?: string;
    };
    expect(initialCall.kind).toBe("claim_paid");
    expect(initialCall.since).toBeDefined();

    // Sanity: the "Submitted" chip is NOT active (only "Paid" is selected).
    const paidChip = document.body.querySelector(
      '[data-testid="kind-chip-claim_paid"]',
    ) as HTMLButtonElement | null;
    expect(paidChip?.getAttribute("aria-checked")).toBe("true");
    const submittedChip = document.body.querySelector(
      '[data-testid="kind-chip-claim_submitted"]',
    ) as HTMLButtonElement | null;
    expect(submittedChip?.getAttribute("aria-checked")).toBe("false");

    // Clear button must be visible while filters are active.
    const clearBtn = document.body.querySelector(
      '[data-testid="clear-filters"]',
    ) as HTMLButtonElement | null;
    expect(clearBtn).not.toBeNull();
    const callsBeforeClear = (api.listActivity as ReturnType<typeof vi.fn>).mock.calls.length;

    await act(async () => {
      clearBtn!.click();
    });

    // All kind chips should now report unchecked.
    await settle(() => {
      const checked = document.body.querySelectorAll(
        '[role="checkbox"][aria-checked="true"]',
      );
      return checked.length === 0;
    });
    expect(
      document.body.querySelectorAll('[role="checkbox"][aria-checked="true"]')
        .length,
    ).toBe(0);

    // Clear button disappears once filters are inactive.
    await settle(
      () => document.body.querySelector('[data-testid="clear-filters"]') === null,
    );
    expect(
      document.body.querySelector('[data-testid="clear-filters"]'),
    ).toBeNull();

    // A new fetch should have fired with no kind/since params.
    await settle(
      () =>
        (api.listActivity as ReturnType<typeof vi.fn>).mock.calls.length >
        callsBeforeClear,
    );
    const calls = (api.listActivity as ReturnType<typeof vi.fn>).mock.calls;
    const lastCall = calls.at(-1)![0] as { kind?: string; since?: string };
    expect(lastCall.kind).toBeUndefined();
    expect(lastCall.since).toBeUndefined();

    unmount();
  });

  it("test_url_params_on_initial_load_populate_the_filter_ui", async () => {
    (api.listActivity as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [SAMPLE[1]],
      total: 1,
      returned: 1,
      has_more: false,
    });

    // Deep link with two kinds + a since window — the most aggressive
    // initial-load case.
    const { unmount } = renderActivity({
      initialEntries: [
        "/activity?kind=claim_paid&kind=remit_received&since=7d",
      ],
    });

    // Wait for the initial fetch.
    await settle(
      () => (api.listActivity as ReturnType<typeof vi.fn>).mock.calls.length >= 1,
    );

    // Both selected chips must report aria-checked=true.
    await settle(() => {
      const paid = document.body.querySelector(
        '[data-testid="kind-chip-claim_paid"]',
      );
      const remit = document.body.querySelector(
        '[data-testid="kind-chip-remit_received"]',
      );
      return (
        paid?.getAttribute("aria-checked") === "true" &&
        remit?.getAttribute("aria-checked") === "true"
      );
    });
    expect(
      document.body
        .querySelector('[data-testid="kind-chip-claim_paid"]')
        ?.getAttribute("aria-checked"),
    ).toBe("true");
    expect(
      document.body
        .querySelector('[data-testid="kind-chip-remit_received"]')
        ?.getAttribute("aria-checked"),
    ).toBe("true");

    // Other chips must report aria-checked=false.
    expect(
      document.body
        .querySelector('[data-testid="kind-chip-claim_submitted"]')
        ?.getAttribute("aria-checked"),
    ).toBe("false");

    // "Since" Select must reflect "Last 7 days". Radix Select renders the
    // current value inside the trigger's `SelectValue` slot.
    expect(document.body.textContent).toContain("Last 7 days");

    // API call: with TWO kinds selected, the page drops `kind` from the
    // backend params (it only handles single-kind server-side filtering)
    // and the since ISO timestamp is sent instead of the "7d" slug.
    const lastCall = (api.listActivity as ReturnType<typeof vi.fn>).mock.calls.at(
      -1,
    )![0] as { kind?: string; since?: string };
    expect(lastCall.kind).toBeUndefined();
    expect(lastCall.since).toBeDefined();
    expect(Date.parse(lastCall.since!)).toBeGreaterThan(
      Date.now() - 8 * 24 * 60 * 60 * 1000,
    );

    unmount();
  });

  it("test_unknown_url_params_are_ignored", async () => {
    // Garbage in the URL (unknown kind, unknown since value) must not
    // throw, must not pass garbage to the API, and must leave the UI in
    // the "no filters" state.
    const { unmount } = renderActivity({
      initialEntries: ["/activity?kind=not_a_kind&since=nope"],
    });

    await settle(
      () => (api.listActivity as ReturnType<typeof vi.fn>).mock.calls.length >= 1,
    );

    const lastCall = (api.listActivity as ReturnType<typeof vi.fn>).mock.calls.at(
      -1,
    )![0] as { kind?: string; since?: string };
    expect(lastCall.kind).toBeUndefined();
    expect(lastCall.since).toBeUndefined();

    // No chip is checked.
    expect(
      document.body.querySelectorAll(
        '[role="checkbox"][aria-checked="true"]',
      ).length,
    ).toBe(0);

    // "Since" trigger shows the default ("All time").
    expect(document.body.textContent).toContain("All time");

    // Clear button hidden.
    expect(
      document.body.querySelector('[data-testid="clear-filters"]'),
    ).toBeNull();

    unmount();
  });
});

describe("SP21 Task 4.7: ActivityLog → RemitDrawer wiring", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    (api.listActivity as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
      EMPTY,
    );
    (
      api.getRemittance as unknown as ReturnType<typeof vi.fn>
    ).mockReturnValue(new Promise(() => {}));
  });

  it("clicking a remit_received event opens the RemitDrawer", async () => {
    (api.listActivity as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [
        {
          id: "A-1",
          kind: "remit_received",
          message: "Remit PCN-1 received",
          timestamp: "2026-06-20T10:00:00Z",
          remittanceId: "REM-1",
        },
      ],
      total: 1,
      returned: 1,
      has_more: false,
    });

    const { unmount } = renderActivity();

    // Wait for the row to render so we can click it.
    await settle(() =>
      document.body.textContent?.includes("Remit PCN-1 received") ?? false,
    );

    // Drawer should not be mounted before the click.
    expect(document.body.querySelector('[data-testid="remit-drawer"]')).toBeNull();

    // Find the <li role="button"> row and click it. ActivityFeed renders
    // each row as a button-role <li> when `onItemClick` is provided, with
    // an aria-label like "View remit received: <message>".
    const row = document.body.querySelector('[aria-label^="View remit received"]') as
      | HTMLLIElement
      | null;
    expect(row).not.toBeNull();
    await act(async () => {
      row!.click();
    });

    // After click, the RemitDrawer should be mounted (with the skeleton,
    // since the never-resolving getRemittance keeps it in loading state).
    await settle(
      () => document.body.querySelector('[data-testid="remit-drawer"]') !== null,
    );
    expect(
      document.body.querySelector('[data-testid="remit-drawer"]'),
    ).not.toBeNull();

    unmount();
  });

  it("deep-link ?remit=ID opens the RemitDrawer on mount", async () => {
    (api.listActivity as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [
        {
          id: "A-1",
          kind: "remit_received",
          message: "Remit PCN-1 received",
          timestamp: "2026-06-20T10:00:00Z",
          remittanceId: "REM-7",
        },
      ],
      total: 1,
      returned: 1,
      has_more: false,
    });

    // The hook reads `?remit=` from `window.location.search`, so set
    // BOTH the MemoryRouter initial entry (for the page's URL display)
    // AND `window.happyDOM.setURL` (for the hook).
    (window as unknown as { happyDOM: { setURL: (u: string) => void } })
      .happyDOM.setURL("http://localhost/activity?remit=REM-7");

    const { unmount } = renderActivity({
      initialEntries: ["/activity?remit=REM-7"],
    });

    await settle(
      () => document.body.querySelector('[data-testid="remit-drawer"]') !== null,
    );
    expect(
      document.body.querySelector('[data-testid="remit-drawer"]'),
    ).not.toBeNull();

    unmount();

    // Reset URL so any subsequent tests see a clean /activity URL.
    (window as unknown as { happyDOM: { setURL: (u: string) => void } })
      .happyDOM.setURL("http://localhost/activity");
  });
});