// @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 { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { MemoryRouter, useLocation } from "react-router-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Upload } from "./Upload";
import { useAppStore } from "@/store";
import type { ParsedBatch } from "@/types";
import type { BatchSummary } from "@/lib/api";

// Mock the api surface so the History tab never touches the network.
vi.mock("@/lib/api", async (importOriginal) => {
  const actual = await importOriginal<typeof import("@/lib/api")>();
  return {
    ...actual,
    api: {
      ...actual.api,
      isConfigured: true,
      listBatches: vi.fn(),
      exportBatch837: vi.fn(),
    },
  };
});

import { api } from "@/lib/api";

// The Re-export ZIP button ultimately calls `downloadBlob(filename, blob)`
// from `@/lib/download`; that helper mutates the DOM (creates a link and
// clicks it). Stub the whole module so we never touch the real DOM and
// the test can assert the filename we asked to be downloaded.
vi.mock("@/lib/download", () => ({
  downloadBlob: vi.fn(),
  downloadTextFile: vi.fn(),
}));

import { downloadBlob } from "@/lib/download";

// The Re-export path toasts via sonner. Mock it to keep the test output
// quiet and let assertions about success/error fire without spam.
vi.mock("sonner", () => ({
  toast: {
    success: vi.fn(),
    error: vi.fn(),
    message: vi.fn(),
  },
}));

import { toast } from "sonner";

// The Upload page also relies on useAuth (via RoleGate). Mock it to an
// authenticated admin so the parse affordances render — we don't test
// RoleGate behavior here, only the History tab.
vi.mock("@/auth/useAuth", () => ({
  useAuth: () => ({
    user: { id: 1, username: "test", role: "admin" },
    status: "authenticated",
  }),
}));

// ---------------------------------------------------------------------------
// Test harness — Upload page lives behind a router (for useSearchParams)
// AND a QueryClient (for useBatches). Wrap both.
// ---------------------------------------------------------------------------

interface MountOpts {
  initialEntries?: string[];
  /** Pre-populated query data for `api.listBatches`. */
  batches?: BatchSummary[];
  /** Whether listBatches should resolve or reject. */
  batchesStatus?: "success" | "error" | "pending";
}

interface Harness {
  container: HTMLDivElement;
  unmount: () => void;
  tracker: { pathname: string; search: string };
}

function mountUpload(opts: MountOpts = {}): Harness {
  const {
    initialEntries = ["/upload"],
    batches = [],
    batchesStatus = "success",
  } = opts;

  // Wire the listBatches mock based on the requested status.
  const listBatchesMock = api.listBatches as unknown as ReturnType<
    typeof vi.fn
  >;
  listBatchesMock.mockReset();
  if (batchesStatus === "success") {
    listBatchesMock.mockResolvedValue(batches);
  } else if (batchesStatus === "error") {
    listBatchesMock.mockRejectedValue(new Error("network down"));
  } else {
    // pending — never resolve
    listBatchesMock.mockReturnValue(new Promise(() => {}));
  }

  const container = document.createElement("div");
  document.body.appendChild(container);
  const tracker = { pathname: "/upload", search: "" };
  const Tracker = () => {
    const loc = useLocation();
    React.useEffect(() => {
      tracker.pathname = loc.pathname;
      tracker.search = loc.search;
    }, [loc.pathname, loc.search]);
    return null;
  };
  const qc = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
    },
  });
  const root: Root = createRoot(container);
  act(() => {
    root.render(
      React.createElement(
        MemoryRouter,
        { initialEntries },
        React.createElement(
          QueryClientProvider,
          { client: qc },
          React.createElement(Tracker),
          React.createElement(Upload),
        ),
      ),
    );
  });
  return {
    container,
    unmount: () => {
      act(() => root.unmount());
      container.remove();
    },
    tracker,
  };
}

async function flush(): Promise<void> {
  // React Query schedules its settled state through a microtask +
  // setTimeout chain. Two ticks is the minimum to settle a mocked
  // promise end-to-end; three to be safe across happy-dom
  // implementations.
  for (let i = 0; i < 3; i++) {
    await act(async () => {
      await new Promise((r) => setTimeout(r, 0));
    });
  }
}

/**
 * Poll a predicate until it holds or we time out. Used for assertions
 * that have to wait for the query to settle (which takes more than a
 * single `flush()` in some happy-dom runs).
 */
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));
    });
  }
}

/**
 * Click a Radix Tabs.Trigger in happy-dom. Radix listens for
 * `pointerdown` (not just `click`) to fire its onValueChange handler,
 * so the synthetic event sequence matters.
 */
async function clickTab(trigger: HTMLElement): Promise<void> {
  await act(async () => {
    trigger.dispatchEvent(
      new PointerEvent("pointerdown", {
        bubbles: true,
        cancelable: true,
        pointerType: "mouse",
      }),
    );
    trigger.dispatchEvent(
      new MouseEvent("mousedown", { bubbles: true, cancelable: true }),
    );
    trigger.dispatchEvent(
      new MouseEvent("mouseup", { bubbles: true, cancelable: true }),
    );
    trigger.click();
    await flush();
  });
}

// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------

const BATCH_837_A: BatchSummary = {
  id: "BATCH-A",
  kind: "837p",
  inputFilename: "tp-001-837P.txt",
  parsedAt: "2026-06-23T12:00:00Z",
  claimCount: 12,
  claimIds: ["CLM-001", "CLM-002", "CLM-003"],
};

const BATCH_837_B: BatchSummary = {
  id: "BATCH-B",
  kind: "837p",
  inputFilename: "tp-002-837P.txt",
  parsedAt: "2026-06-22T12:00:00Z",
  claimCount: 7,
  claimIds: ["CLM-101", "CLM-102"],
};

const BATCH_835: BatchSummary = {
  id: "BATCH-C",
  kind: "835",
  inputFilename: "tp-003-835.txt",
  parsedAt: "2026-06-21T12:00:00Z",
  claimCount: 4,
  claimIds: [], // 835 has no re-export endpoint
};

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("Upload page — History tab", () => {
  beforeEach(() => {
    useAppStore.setState({ parsedBatches: [] });
    vi.mocked(downloadBlob).mockReset();
    vi.mocked(toast.success).mockReset();
    vi.mocked(toast.error).mockReset();
  });

  afterEach(() => {
    useAppStore.setState({ parsedBatches: [] });
  });

  it("renders the Upload tab by default and shows the History trigger with a count badge", async () => {
    const { container, unmount } = mountUpload({
      batches: [BATCH_837_A, BATCH_837_B],
    });
    await settle(() => container.textContent?.includes("· 2") === true);

    // Both triggers are present.
    const triggers = container.querySelectorAll('[role="tab"]');
    expect(triggers.length).toBeGreaterThanOrEqual(2);
    expect(container.textContent).toContain("Upload");
    expect(container.textContent).toContain("History");

    // Count badge shows the persisted-batch count.
    expect(container.textContent).toContain("· 2");

    unmount();
  });

  it("clicking the History trigger shows the batch table and hides the dropzone", async () => {
    const { container, unmount } = mountUpload({
      batches: [BATCH_837_A, BATCH_837_B],
    });
    await flush();

    // Before click: the dropzone is rendered.
    expect(
      container.querySelector('[aria-label="File upload"]'),
    ).not.toBeNull();

    // Click the History trigger.
    const historyTrigger = Array.from(
      container.querySelectorAll('[role="tab"]'),
    ).find((el) => el.textContent?.startsWith("History")) as HTMLElement;
    expect(historyTrigger).toBeDefined();
    await clickTab(historyTrigger);

    // Now the dropzone is hidden and the history table is visible.
    expect(container.querySelector('[aria-label="File upload"]')).toBeNull();
    expect(container.textContent).toContain("Batch history");
    expect(container.textContent).toContain("tp-001-837P.txt");
    expect(container.textContent).toContain("tp-002-837P.txt");
    unmount();
  });

  it("clicking the History trigger mirrors ?tab=history in the URL", async () => {
    const { container, unmount, tracker } = mountUpload({
      batches: [BATCH_837_A],
    });
    await flush();

    const historyTrigger = Array.from(
      container.querySelectorAll('[role="tab"]'),
    ).find((el) => el.textContent?.startsWith("History")) as HTMLElement;
    await clickTab(historyTrigger);

    expect(tracker.search).toContain("tab=history");
    unmount();
  });

  it("?tab=history in the initial URL deep-links directly to the History tab", async () => {
    const { container, unmount } = mountUpload({
      initialEntries: ["/upload?tab=history"],
      batches: [BATCH_837_A, BATCH_837_B],
    });
    await flush();

    // Dropzone is hidden — we landed on the History tab.
    expect(container.querySelector('[aria-label="File upload"]')).toBeNull();
    expect(container.textContent).toContain("Batch history");
    expect(container.textContent).toContain("tp-001-837P.txt");
    unmount();
  });

  it("renders one row per batch with sequential # column", async () => {
    const { container, unmount } = mountUpload({
      initialEntries: ["/upload?tab=history"],
      batches: [BATCH_837_A, BATCH_837_B, BATCH_835],
    });
    await settle(() =>
      container.querySelectorAll("tbody tr").length === 3,
    );

    const rows = container.querySelectorAll("tbody tr");
    expect(rows.length).toBe(3);
    // Newest first → #03 (BATCH-A), #02 (BATCH-B), #01 (BATCH-C).
    // The # column counts down from the total so the newest row reads
    // as the highest ordinal.
    expect(rows[0]?.textContent).toContain("#03");
    expect(rows[0]?.textContent).toContain("tp-001-837P.txt");
    expect(rows[1]?.textContent).toContain("#02");
    expect(rows[1]?.textContent).toContain("tp-002-837P.txt");
    expect(rows[2]?.textContent).toContain("#01");
    expect(rows[2]?.textContent).toContain("tp-003-835.txt");
    unmount();
  });

  it("renders a Re-export ZIP button for each 837P row", async () => {
    const { container, unmount } = mountUpload({
      initialEntries: ["/upload?tab=history"],
      batches: [BATCH_837_A, BATCH_837_B, BATCH_835],
    });
    await settle(
      () =>
        container.querySelectorAll('[data-testid="history-row-reexport"]')
          .length === 2,
    );

    const buttons = container.querySelectorAll(
      '[data-testid="history-row-reexport"]',
    );
    // Two 837P rows get a button; the 835 row does not.
    expect(buttons.length).toBe(2);
    unmount();
  });

  it("hides the Re-export button on 835 rows (no claimIds)", async () => {
    const { container, unmount } = mountUpload({
      initialEntries: ["/upload?tab=history"],
      batches: [BATCH_835],
    });
    await settle(() =>
      container.textContent?.includes("No re-export") === true,
    );

    expect(
      container.querySelector('[data-testid="history-row-reexport"]'),
    ).toBeNull();
    // A muted "No re-export" placeholder is shown instead.
    expect(container.textContent).toContain("No re-export");
    unmount();
  });

  it("clicking Re-export ZIP calls api.exportBatch837 with the row's claimIds and downloads the blob", async () => {
    const { container, unmount } = mountUpload({
      initialEntries: ["/upload?tab=history"],
      batches: [BATCH_837_A],
    });
    await settle(
      () =>
        container.querySelector('[data-testid="history-row-reexport"]') !==
        null,
    );

    const exportMock = api.exportBatch837 as unknown as ReturnType<
      typeof vi.fn
    >;
    exportMock.mockResolvedValue({
      blob: new Blob(["zip-bytes"]),
      filename: "BATCH-A-claims.zip",
      serializeErrors: [],
    });

    const button = container.querySelector(
      '[data-testid="history-row-reexport"]',
    ) as HTMLButtonElement;
    expect(button).not.toBeNull();

    await act(async () => {
      button.click();
      await flush();
    });

    expect(exportMock).toHaveBeenCalledTimes(1);
    expect(exportMock).toHaveBeenCalledWith("BATCH-A", [
      "CLM-001",
      "CLM-002",
      "CLM-003",
    ]);
    expect(downloadBlob).toHaveBeenCalledTimes(1);
    expect(downloadBlob).toHaveBeenCalledWith(
      "BATCH-A-claims.zip",
      expect.any(Blob),
    );
    expect(toast.success).toHaveBeenCalledTimes(1);
    unmount();
  });

  it("surfaces a toast.error when re-export fails", async () => {
    const { container, unmount } = mountUpload({
      initialEntries: ["/upload?tab=history"],
      batches: [BATCH_837_A],
    });
    await settle(
      () =>
        container.querySelector('[data-testid="history-row-reexport"]') !==
        null,
    );

    const exportMock = api.exportBatch837 as unknown as ReturnType<
      typeof vi.fn
    >;
    exportMock.mockRejectedValue(new Error("server gone"));

    const button = container.querySelector(
      '[data-testid="history-row-reexport"]',
    ) as HTMLButtonElement;
    await act(async () => {
      button.click();
      await flush();
    });

    expect(toast.error).toHaveBeenCalledTimes(1);
    expect(toast.success).not.toHaveBeenCalled();
    expect(downloadBlob).not.toHaveBeenCalled();
    unmount();
  });

  it("shows a loading state on first paint while the query is pending", async () => {
    const { container, unmount } = mountUpload({
      initialEntries: ["/upload?tab=history"],
      batchesStatus: "pending",
    });
    await flush();

    expect(container.textContent).toContain("Loading archive");
    // No rows yet.
    expect(
      container.querySelector('[data-testid="history-row-reexport"]'),
    ).toBeNull();
    unmount();
  });

  it("shows an error state when the query rejects", async () => {
    const { container, unmount } = mountUpload({
      initialEntries: ["/upload?tab=history"],
      batchesStatus: "error",
    });
    await settle(() => container.textContent?.includes("Couldn't load") === true);

    expect(container.textContent).toContain("Couldn't load the archive");
    unmount();
  });

  it("shows an empty state when there are no batches", async () => {
    const { container, unmount } = mountUpload({
      initialEntries: ["/upload?tab=history"],
      batches: [],
    });
    await flush();

    expect(container.textContent).toContain("No batches in the archive");
    unmount();
  });

  it("falls back to in-memory parsedBatches when the backend returns no rows (sample-data mode)", async () => {
    const sample: ParsedBatch = {
      id: "MEM-1",
      kind: "837p",
      inputFilename: "mem-batch.txt",
      parsedAt: "2026-06-24T12:00:00Z",
      claimCount: 2,
      passed: 2,
      failed: 0,
      claimIds: ["SAMPLE-1", "SAMPLE-2"],
      summary: {
        input_file: "mem-batch.txt",
        total_claims: 2,
        passed: 2,
        failed: 0,
        failed_claim_ids: [],
        issues_by_rule: {},
      },
    };
    useAppStore.setState({ parsedBatches: [sample] });

    const { container, unmount } = mountUpload({
      initialEntries: ["/upload?tab=history"],
      // Server returned nothing — fallback path activates.
      batches: [],
    });
    await settle(() =>
      container.textContent?.includes("mem-batch.txt") === true,
    );

    expect(container.textContent).toContain("mem-batch.txt");
    expect(
      container.querySelector('[data-testid="history-row-reexport"]'),
    ).not.toBeNull();
    unmount();
  });
});