// @vitest-environment happy-dom
// ClaimDrawer wires `useClaimDetail` (TanStack Query) + a window-level
// keyboard listener. Both paths need an act-aware environment or React
// logs noisy warnings. Mirror the convention from useClaimDetail.test.ts.
(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 { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ClaimDrawer } from "./ClaimDrawer";
import { api, ApiError } from "@/lib/api";
import type { ClaimDetail } from "@/types";

// Module-level mock — vitest hoists `vi.mock` above imports. We mock both
// `api.getClaimDetail` (the only method the hook calls) AND `ApiError`
// (the class used in the hook's retry predicate + here for instanceof
// checks in the orchestrator's error-kind branch). Same pattern as
// useClaimDetail.test.ts.
vi.mock("@/lib/api", () => {
  class ApiError extends Error {
    constructor(public status: number, message: string) {
      super(message);
    }
  }
  return {
    api: {
      getClaimDetail: vi.fn(),
    },
    ApiError,
  };
});

/**
 * Minimal valid ClaimDetail fixture — every required key present so the
 * component typechecks. Mirrors the SAMPLE_DETAIL in
 * useClaimDetail.test.ts; same shape the api.getClaimDetail test uses.
 */
const SAMPLE_DETAIL: ClaimDetail = {
  id: "CLM-1",
  batchId: "B-1",
  state: "submitted",
  stateLabel: "Submitted",
  billedAmount: 123.45,
  patientName: "Jane Doe",
  providerNpi: "1234567890",
  providerName: "Acme Clinic",
  payerName: "Medicaid",
  payerId: "MEDCO",
  submissionDate: "2026-01-15T00:00:00Z",
  serviceDateFrom: "2026-01-10",
  serviceDateTo: "2026-01-10",
  parsedAt: "2026-01-15T00:00:01Z",
  diagnoses: [{ code: "E11.9", qualifier: "ABK" }],
  serviceLines: [
    {
      lineNumber: 1,
      procedureQualifier: "HC",
      procedureCode: "99213",
      modifiers: [],
      charge: 100.0,
      units: 1,
      unitType: "UN",
      serviceDate: "2026-01-10",
    },
  ],
  parties: {
    billingProvider: {
      name: "Acme Clinic",
      npi: "1234567890",
      taxId: "12-3456789",
      address: {
        line1: "123 Main St",
        line2: null,
        city: "Denver",
        state: "CO",
        zip: "80202",
      },
    },
    subscriber: {
      firstName: "Jane",
      lastName: "Doe",
      memberId: "M-1",
      dob: null,
      gender: "F",
    },
    payer: { name: "Medicaid", id: "MEDCO" },
  },
  validation: { passed: true, errors: [], warnings: [] },
  rawSegments: [["ISA*00*"]],
  matchedRemittance: null,
  stateHistory: [
    {
      kind: "claim_submitted",
      ts: "2026-01-15T00:00:00Z",
      batchId: "B-1",
      remittanceId: null,
    },
  ],
};

type RenderArgs = {
  claimId: string | null;
  claims?: { id: string }[];
  onClose?: () => void;
  onNavigate?: (id: string) => void;
  onToggleHelp?: () => void;
};

/**
 * Test harness for `ClaimDrawer`. Renders into a real DOM container
 * wrapped in `QueryClientProvider` (the hook uses TanStack Query) and
 * exposes a `fireKey` helper that dispatches keydown events on `window`
 * — `useDrawerKeyboard` listens there, not on a specific element.
 *
 * `detail` controls how the mocked `api.getClaimDetail` resolves:
 *   - ClaimDetail → resolves with that object
 *   - Error       → rejects with that error (use a real `ApiError` for
 *                   404 or a plain `Error` for network failure)
 *   - null        → never resolves (for the loading state)
 */
function renderDrawer(
  props: RenderArgs,
  detail: ClaimDetail | Error | null = SAMPLE_DETAIL
): {
  container: HTMLDivElement;
  unmount: () => void;
  fireKey: (key: string) => void;
} {
  const onClose = props.onClose ?? vi.fn<() => void>();
  const onNavigate = props.onNavigate ?? vi.fn<(id: string) => void>();
  const onToggleHelp = props.onToggleHelp ?? vi.fn<() => void>();
  const claims = props.claims ?? [{ id: "CLM-1" }];

  const mock = api.getClaimDetail as unknown as ReturnType<typeof vi.fn>;
  if (detail === null) {
    // Never-resolving promise — leaves the query in the loading state.
    mock.mockReturnValue(new Promise(() => {}));
  } else if (detail instanceof Error) {
    mock.mockRejectedValue(detail);
  } else {
    mock.mockResolvedValue(detail);
  }

  const container = document.createElement("div");
  document.body.appendChild(container);
  const qc = new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
        // The hook's retry predicate still retries non-404 errors up
        // to 3 times. Default retryDelay is exponential backoff
        // (1s + 2s + 4s ≈ 7s) which is way too slow for unit tests.
        // We override it to fire retries immediately.
        retryDelay: 0,
      },
    },
  });
  const root: Root = createRoot(container);
  act(() => {
    root.render(
      React.createElement(
        QueryClientProvider,
        { client: qc },
        React.createElement(ClaimDrawer, {
          claimId: props.claimId,
          claims,
          onClose,
          onNavigate,
          onToggleHelp,
        })
      )
    );
  });

  return {
    container,
    unmount: () => {
      act(() => root.unmount());
      container.remove();
    },
    fireKey: (key: string) => {
      // useDrawerKeyboard listens on `window`; dispatch a real
      // KeyboardEvent so the listener picks it up.
      act(() => {
        window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
      });
    },
  };
}

/**
 * Drive React Query's internal micro-task queue until it stops
 * transitioning (or we time out). TanStack's queries resolve on
 * promise micro-tasks, then re-render, then settle — a single
 * `await new Promise(r => setTimeout(r, 0))` isn't always enough
 * for the hook's retry loop (3 retries on a plain Error) or for
 * the success path to propagate through React's commit phase.
 *
 * Each tick flushes a micro-task and a macro-task. We exit early
 * as soon as `body` matches `predicate`, so the common case (a
 * single successful resolve) returns fast.
 *
 * The render harness sets `retryDelay: 0` on the QueryClient so
 * retries fire back-to-back — no exponential-backoff wait. Default
 * timeout here is therefore modest (2s).
 */
async function settle(
  predicate: (body: HTMLElement) => boolean,
  timeoutMs = 2000
): Promise<HTMLElement> {
  const body = document.body;
  const start = Date.now();
  while (!predicate(body)) {
    if (Date.now() - start > timeoutMs) {
      throw new Error(
        `settle: predicate did not hold within ${timeoutMs}ms (body=${body.innerHTML.slice(0, 200)})`
      );
    }
    await act(async () => {
      await new Promise((r) => setTimeout(r, 0));
    });
  }
  return body;
}

describe("ClaimDrawer", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  afterEach(() => {
    // Defensive: clear any pending listeners between tests.
    vi.restoreAllMocks();
  });

  it("test_renders_nothing_when_claimId_is_null", () => {
    const { container, unmount } = renderDrawer({ claimId: null });

    // The component must render no DOM at all when closed.
    expect(container.children.length).toBe(0);
    // And must NOT hit the network — the hook short-circuits when
    // claimId is null (per useClaimDetail contract).
    expect(api.getClaimDetail).not.toHaveBeenCalled();

    unmount();
  });

  it("test_renders_skeleton_while_loading", async () => {
    // detail = null → never-resolving promise → permanent loading.
    const { unmount } = renderDrawer({ claimId: "CLM-1" }, null);

    // Radix's Dialog portals content into document.body, so we query
    // there rather than the React container we rendered into.
    const body = await settle((b) =>
      b.querySelector('[data-testid="claim-drawer-skeleton"]') !== null
    );
    expect(body.querySelector('[data-testid="claim-drawer-skeleton"]'))
      .not.toBeNull();

    unmount();
  });

  it("test_renders_error_state_on_404", async () => {
    const { unmount } = renderDrawer(
      { claimId: "ghost" },
      new ApiError(404, "Claim ghost not found")
    );

    const body = await settle((b) =>
      b.querySelector('[data-testid="claim-drawer-error-not_found"]') !== null
    );

    const errorEl = body.querySelector(
      '[data-testid="claim-drawer-error-not_found"]'
    );
    expect(errorEl).not.toBeNull();

    // Close button is wired up.
    const closeBtn = body.querySelector(
      '[data-testid="error-close"]'
    ) as HTMLButtonElement | null;
    expect(closeBtn).not.toBeNull();

    unmount();
  });

  it("test_renders_error_state_on_network_failure", async () => {
    const { unmount } = renderDrawer(
      { claimId: "CLM-1" },
      new Error("network down")
    );

    // The hook retries non-404 errors up to 3 times; the error UI
    // only appears after the final retry gives up. The polling
    // helper waits out the retries.
    const body = await settle((b) =>
      b.querySelector('[data-testid="claim-drawer-error-network"]') !== null
    );

    const errorEl = body.querySelector(
      '[data-testid="claim-drawer-error-network"]'
    );
    expect(errorEl).not.toBeNull();

    // Retry button is shown for network failures.
    const retryBtn = body.querySelector(
      '[data-testid="error-retry"]'
    ) as HTMLButtonElement | null;
    expect(retryBtn).not.toBeNull();

    unmount();
  });

  it("test_renders_all_sections_on_success", async () => {
    const { unmount } = renderDrawer({ claimId: "CLM-1" });

    // Wait for the header to render (it's the first section in the
    // success path). All other sections will be present by the time
    // the header shows up because they render in the same commit.
    const body = await settle((b) =>
      b.querySelector('[data-testid="claim-drawer-header"]') !== null
    );

    // All 7 sections that render unconditionally (matched remit is null
    // in the fixture). `validation-passed` is the testid for the happy
    // path (validation.passed=true, no errors/warnings); the
    // `validation-panel` testid is reserved for the failed state.
    expect(body.querySelector('[data-testid="claim-drawer-header"]'))
      .not.toBeNull();
    expect(body.querySelector('[data-testid="validation-passed"]'))
      .not.toBeNull();
    expect(body.querySelector('[data-testid="service-lines-table"]'))
      .not.toBeNull();
    expect(body.querySelector('[data-testid="diagnoses-list"]'))
      .not.toBeNull();
    expect(body.querySelector('[data-testid="parties-grid"]'))
      .not.toBeNull();
    expect(body.querySelector('[data-testid="raw-segments-panel"]'))
      .not.toBeNull();
    expect(body.querySelector('[data-testid="state-history-section"]'))
      .not.toBeNull();
    // matchedRemittance is null in the fixture → card not rendered.
    expect(body.querySelector('[data-testid="matched-remit-card"]'))
      .toBeNull();

    unmount();
  });

  it("test_j_key_calls_onNavigate_with_next_claim_id", () => {
    const onNavigate = vi.fn<(id: string) => void>();
    const { fireKey, unmount } = renderDrawer({
      claimId: "B",
      claims: [{ id: "A" }, { id: "B" }, { id: "C" }],
      onNavigate,
    });

    fireKey("j");
    expect(onNavigate).toHaveBeenCalledTimes(1);
    expect(onNavigate).toHaveBeenCalledWith("C");

    unmount();
  });

  it("test_k_key_calls_onNavigate_with_previous_claim_id", () => {
    const onNavigate = vi.fn<(id: string) => void>();
    const { fireKey, unmount } = renderDrawer({
      claimId: "B",
      claims: [{ id: "A" }, { id: "B" }, { id: "C" }],
      onNavigate,
    });

    fireKey("k");
    expect(onNavigate).toHaveBeenCalledTimes(1);
    expect(onNavigate).toHaveBeenCalledWith("A");

    unmount();
  });

  it("test_j_wraps_around_at_end", () => {
    const onNavigate = vi.fn<(id: string) => void>();
    const { fireKey, unmount } = renderDrawer({
      claimId: "C", // last
      claims: [{ id: "A" }, { id: "B" }, { id: "C" }],
      onNavigate,
    });

    fireKey("j");
    expect(onNavigate).toHaveBeenCalledTimes(1);
    expect(onNavigate).toHaveBeenCalledWith("A"); // wraps to first

    unmount();
  });

  it("test_k_wraps_around_at_start", () => {
    const onNavigate = vi.fn<(id: string) => void>();
    const { fireKey, unmount } = renderDrawer({
      claimId: "A", // first
      claims: [{ id: "A" }, { id: "B" }, { id: "C" }],
      onNavigate,
    });

    fireKey("k");
    expect(onNavigate).toHaveBeenCalledTimes(1);
    expect(onNavigate).toHaveBeenCalledWith("C"); // wraps to last

    unmount();
  });

  it("test_escape_key_calls_onClose", () => {
    const onClose = vi.fn<() => void>();
    const { fireKey, unmount } = renderDrawer({
      claimId: "CLM-1",
      onClose,
    });

    fireKey("Escape");
    expect(onClose).toHaveBeenCalledTimes(1);

    unmount();
  });

  it("test_question_mark_calls_onToggleHelp", () => {
    const onToggleHelp = vi.fn<() => void>();
    const { fireKey, unmount } = renderDrawer({
      claimId: "CLM-1",
      onToggleHelp,
    });

    fireKey("?");
    expect(onToggleHelp).toHaveBeenCalledTimes(1);

    unmount();
  });

  it("test_keyboard_disabled_when_claimId_is_null", () => {
    const onNavigate = vi.fn<(id: string) => void>();
    const { fireKey, unmount } = renderDrawer({
      claimId: null,
      claims: [{ id: "A" }, { id: "B" }, { id: "C" }],
      onNavigate,
    });

    fireKey("j");
    fireKey("k");
    expect(onNavigate).not.toHaveBeenCalled();

    unmount();
  });

  it("test_current_claim_not_in_claims_list_does_nothing", () => {
    const onNavigate = vi.fn<(id: string) => void>();
    const { fireKey, unmount } = renderDrawer({
      claimId: "GHOST", // not in claims list
      claims: [{ id: "A" }, { id: "B" }, { id: "C" }],
      onNavigate,
    });

    fireKey("j");
    fireKey("k");
    expect(onNavigate).not.toHaveBeenCalled();

    unmount();
  });
});
