// @vitest-environment happy-dom
(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 { createRoot, type Root } from "react-dom/client";

// Mock the api + download + toast surfaces so the hook can be exercised
// in isolation. Each test sets up its own expectations on these.
vi.mock("@/lib/api", () => ({
  api: {
    exportBatch837: vi.fn(),
  },
}));
vi.mock("@/lib/download", () => ({
  downloadBlob: vi.fn(),
}));
vi.mock("sonner", () => ({
  toast: {
    success: vi.fn(),
    warning: vi.fn(),
    error: vi.fn(),
  },
}));

import { useBatchExport, type BatchExportItem } from "./useBatchExport";
import { api } from "@/lib/api";
import { downloadBlob } from "@/lib/download";
import { toast } from "sonner";

const mockExportBatch837 = vi.mocked(api.exportBatch837);
const mockDownloadBlob = vi.mocked(downloadBlob);
const mockToastSuccess = vi.mocked(toast.success);
const mockToastWarning = vi.mocked(toast.warning);
const mockToastError = vi.mocked(toast.error);

function makeItem(claimId: string): BatchExportItem {
  return { kind: "837p", data: { claim_id: claimId } };
}
function make835Item(id: string): BatchExportItem {
  return { kind: "835", data: { payer_claim_control_number: id } };
}

/**
 * Render the hook with a fixed initial list and capture its return
 * value. The `rerenderWith` helper re-creates the harness with a new
 * initial list — that's how we simulate "new claims streamed in"
 * without poking at internal useState setters from outside the
 * component.
 */
function setup(initial: BatchExportItem[]) {
  let latest: ReturnType<typeof useBatchExport> | null = null;
  const container = document.createElement("div");
  document.body.append(container);

  function Harness({ items }: { items: BatchExportItem[] }) {
    latest = useBatchExport(items);
    return null;
  }

  const root: Root = createRoot(container);
  function render(items: BatchExportItem[]) {
    act(() => {
      root.render(React.createElement(Harness, { items }));
    });
  }
  render(initial);

  return {
    get latest(): ReturnType<typeof useBatchExport> {
      if (!latest) throw new Error("hook result not yet captured");
      return latest;
    },
    /** Re-render with a new items list (e.g. to simulate streaming). */
    rerenderWith(items: BatchExportItem[]) {
      render(items);
    },
    unmount() {
      act(() => root.unmount());
      container.remove();
    },
  };
}

describe("useBatchExport", () => {
  beforeEach(() => {
    mockExportBatch837.mockReset();
    mockDownloadBlob.mockReset();
    mockToastSuccess.mockReset();
    mockToastWarning.mockReset();
    mockToastError.mockReset();
  });
  afterEach(() => {
    vi.restoreAllMocks();
  });

  it("auto-selects all 837P claims passed in initially", () => {
    const h = setup([makeItem("CLM-1"), makeItem("CLM-2"), makeItem("CLM-3")]);
    expect(h.latest.selectedClaimIds.size).toBe(3);
    expect(h.latest.total837InStream).toBe(3);
    h.unmount();
  });

  it("ignores 835 items when counting / selecting", () => {
    const h = setup([makeItem("CLM-1"), make835Item("PCN-1")]);
    expect(h.latest.selectedClaimIds.size).toBe(1);
    expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(true);
    expect(h.latest.total837InStream).toBe(1);
    h.unmount();
  });

  it("auto-selects a new 837P claim that streams in", () => {
    const h = setup([makeItem("CLM-1")]);
    expect(h.latest.selectedClaimIds.size).toBe(1);
    h.rerenderWith([makeItem("CLM-1"), makeItem("CLM-2")]);
    expect(h.latest.selectedClaimIds.size).toBe(2);
    expect(h.latest.selectedClaimIds.has("CLM-2")).toBe(true);
    h.unmount();
  });

  it("does not deselect existing claims when a new one streams in", () => {
    const h = setup([makeItem("CLM-1")]);
    act(() => {
      h.latest.onToggleSelect("CLM-1"); // deselect
    });
    expect(h.latest.selectedClaimIds.size).toBe(0);
    h.rerenderWith([makeItem("CLM-1"), makeItem("CLM-2")]);
    // CLM-1 is still deselected (user's choice is preserved), CLM-2
    // is auto-selected.
    expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(false);
    expect(h.latest.selectedClaimIds.has("CLM-2")).toBe(true);
    h.unmount();
  });

  it("onToggleSelect adds and removes", () => {
    const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
    act(() => {
      h.latest.onToggleSelect("CLM-1"); // toggle off
    });
    expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(false);
    act(() => {
      h.latest.onToggleSelect("CLM-1"); // toggle back on
    });
    expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(true);
    h.unmount();
  });

  it("onToggleAll deselects when everything is selected", () => {
    const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
    expect(h.latest.selectedClaimIds.size).toBe(2);
    act(() => {
      h.latest.onToggleAll();
    });
    expect(h.latest.selectedClaimIds.size).toBe(0);
    h.unmount();
  });

  it("onToggleAll selects everything when partial / none are selected", () => {
    const h = setup([makeItem("CLM-1"), makeItem("CLM-2"), makeItem("CLM-3")]);
    act(() => {
      h.latest.onToggleSelect("CLM-1"); // partial: only CLM-2 and CLM-3
    });
    act(() => {
      h.latest.onToggleSelect("CLM-2");
    });
    act(() => {
      h.latest.onToggleAll();
    });
    expect(h.latest.selectedClaimIds.size).toBe(3);
    h.unmount();
  });

  it("onExport does nothing when no batch id is recorded", async () => {
    const h = setup([makeItem("CLM-1")]);
    await h.latest.onExport();
    expect(mockExportBatch837).not.toHaveBeenCalled();
    expect(mockToastError).toHaveBeenCalled();
    h.unmount();
  });

  it("onExport does nothing when selection is empty (after recordBatchId)", async () => {
    const h = setup([makeItem("CLM-1")]);
    h.latest.recordBatchId("server-uuid-123");
    act(() => {
      h.latest.onToggleSelect("CLM-1"); // deselect
    });
    await h.latest.onExport();
    expect(mockExportBatch837).not.toHaveBeenCalled();
    h.unmount();
  });

  it("onExport calls api.exportBatch837 with the recorded batch id and selected ids, then downloadBlob + success toast", async () => {
    const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
    h.latest.recordBatchId("server-uuid-456");
    const exportedBlob = new Blob([new Uint8Array([1, 2, 3])], {
      type: "application/zip",
    });
    mockExportBatch837.mockResolvedValueOnce({
      blob: exportedBlob,
      filename: "batch-server-uuid-456-2-claims.zip",
      serializeErrors: [],
    });
    await h.latest.onExport();
    expect(mockExportBatch837).toHaveBeenCalledWith(
      "server-uuid-456",
      expect.arrayContaining(["CLM-1", "CLM-2"]),
    );
    expect(mockDownloadBlob).toHaveBeenCalledTimes(1);
    // Regression: downloadBlob's signature is (filename, blob). An earlier
    // build had the args swapped, which made URL.createObjectURL receive
    // the filename string and throw "Overload resolution failed" — pin
    // the order here so a swap can't sneak back in.
    expect(mockDownloadBlob).toHaveBeenCalledWith(
      "batch-server-uuid-456-2-claims.zip",
      exportedBlob,
    );
    expect(mockToastSuccess).toHaveBeenCalledWith(
      "Exported 2 claims as X12",
      expect.objectContaining({ description: "batch-server-uuid-456-2-claims.zip" }),
    );
    expect(mockToastWarning).not.toHaveBeenCalled();
    h.unmount();
  });

  it("onExport shows a warning toast with serialize-error details on partial failure", async () => {
    const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
    h.latest.recordBatchId("server-uuid-789");
    mockExportBatch837.mockResolvedValueOnce({
      blob: new Blob([new Uint8Array([1])]),
      filename: "batch-server-uuid-789-1-claims.zip",
      serializeErrors: [{ claim_id: "CLM-2", reason: "no raw_json" }],
    });
    await h.latest.onExport();
    expect(mockDownloadBlob).toHaveBeenCalledTimes(1);
    expect(mockToastSuccess).not.toHaveBeenCalled();
    expect(mockToastWarning).toHaveBeenCalledWith(
      "Exported 1 · 1 couldn't be regenerated",
      expect.objectContaining({
        description: expect.stringContaining("CLM-2: no raw_json"),
      }),
    );
    h.unmount();
  });

  it("onExport shows an error toast and resets exporting=false on network failure", async () => {
    const h = setup([makeItem("CLM-1")]);
    h.latest.recordBatchId("server-uuid-abc");
    mockExportBatch837.mockRejectedValueOnce(new Error("network down"));
    await h.latest.onExport();
    expect(mockToastError).toHaveBeenCalledWith("network down");
    expect(h.latest.exporting).toBe(false);
    h.unmount();
  });

  it("exporting is true while the export is in flight, false after", async () => {
    const h = setup([makeItem("CLM-1")]);
    h.latest.recordBatchId("server-uuid-xyz");

    let resolveExport: (v: unknown) => void = () => {};
    mockExportBatch837.mockReturnValueOnce(
      new Promise((res) => {
        resolveExport = res;
      }) as ReturnType<typeof api.exportBatch837>,
    );

    const exportPromise = h.latest.onExport();
    // Yield once so the exporting=true state has flushed.
    await act(async () => {
      await Promise.resolve();
    });
    expect(h.latest.exporting).toBe(true);

    resolveExport({
      blob: new Blob(),
      filename: "x.zip",
      serializeErrors: [],
    });
    // The state update in onExport's `finally` block happens on a
    // microtask after the promise resolves; wrapping the await in
    // act() flushes it.
    await act(async () => {
      await exportPromise;
    });
    expect(h.latest.exporting).toBe(false);
    h.unmount();
  });
});
