import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { ApiError, api } from "./api";

const originalFetch = global.fetch;
const mockFetch = vi.fn();

describe("api GET helpers", () => {
  beforeEach(() => {
    global.fetch = mockFetch as unknown as typeof fetch;
    mockFetch.mockReset();
  });
  afterEach(() => {
    global.fetch = originalFetch;
    vi.restoreAllMocks();
  });

  it("listBatches hits /api/batches and unwraps body.items", async () => {
    mockFetch.mockResolvedValueOnce(
      new Response(JSON.stringify({ items: [] }), {
        status: 200,
        headers: { "content-type": "application/json" },
      })
    );
    const out = await api.listBatches();
    expect(out).toEqual([]);
    expect(mockFetch).toHaveBeenCalledTimes(1);
    const called = mockFetch.mock.calls[0][0] as string;
    expect(called).toContain("/api/batches");
    expect(called).not.toContain("?");
  });

  it("listClaims builds the right URL with status + sort + order", async () => {
    mockFetch.mockResolvedValueOnce(
      new Response(
        JSON.stringify({ items: [], total: 0, returned: 0, has_more: false }),
        { status: 200, headers: { "content-type": "application/json" } }
      )
    );
    await api.listClaims({
      status: "submitted",
      sort: "billedAmount",
      order: "desc",
    });
    const called = mockFetch.mock.calls[0][0] as string;
    expect(called).toContain("/api/claims?");
    expect(called).toContain("status=submitted");
    expect(called).toContain("sort=billedAmount");
    expect(called).toContain("order=desc");
  });

  it("getBatch returns the result with the right id in the URL", async () => {
    mockFetch.mockResolvedValueOnce(
      new Response(
        JSON.stringify({
          envelope: {
            sender_id: "X",
            receiver_id: "Y",
            control_number: "1",
            transaction_date: "2026-01-01",
          },
          claims: [],
          summary: {},
          kind: "835",
        }),
        { status: 200, headers: { "content-type": "application/json" } }
      )
    );
    const out = await api.getBatch("abc");
    expect(out).toBeTruthy();
    const called = mockFetch.mock.calls[0][0] as string;
    expect(called).toContain("/api/batches/abc");
  });

  it("getClaimDetail hits /api/claims/{id} and returns the parsed body", async () => {
    // Minimal valid ClaimDetail shape — every spec-mandated key present,
    // including the optional `matchedRemittance: null` for an unpaired
    // claim (the dominant UI render path).
    const body = {
      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,
        },
      ],
    };
    mockFetch.mockResolvedValueOnce(
      new Response(JSON.stringify(body), {
        status: 200,
        headers: { "content-type": "application/json" },
      })
    );
    const out = await api.getClaimDetail("CLM-1");
    expect(out).toEqual(body);
    expect(mockFetch).toHaveBeenCalledTimes(1);
    const called = mockFetch.mock.calls[0][0] as string;
    // URL must include the encoded id, no trailing query string.
    expect(called).toContain("/api/claims/CLM-1");
    expect(called).not.toContain("?");
  });

  it("getClaimDetail throws an ApiError on 404", async () => {
    // The backend raises `{"error":"Not found","detail":"Claim X not found"}`
    // on missing ids (see api.py:get_claim_detail_endpoint). The client
    // must surface it as an ApiError so the drawer can distinguish
    // "doesn't exist" from a transient fetch failure (spec §3.4).
    mockFetch.mockResolvedValueOnce(
      new Response(
        JSON.stringify({ error: "Not found", detail: "Claim ghost not found" }),
        {
          status: 404,
          headers: { "content-type": "application/json" },
        }
      )
    );
    let caught: unknown;
    try {
      await api.getClaimDetail("ghost");
    } catch (err) {
      caught = err;
    }
    expect(caught).toBeInstanceOf(ApiError);
    expect((caught as ApiError).status).toBe(404);
  });

  it("serializeClaim837 returns the regenerated text + filename from Content-Disposition", async () => {
    const x12 = "ISA*00*          *00*          *ZZ*CYCLONE        *ZZ*RECEIVER       *260620*1200*^*00501*000000001*0*P*:~IEA*0*000000001~";
    mockFetch.mockResolvedValueOnce(
      new Response(x12, {
        status: 200,
        headers: {
          "content-type": "text/x12",
          // Backend emits: attachment; filename="claim-{id}.x12"
          "content-disposition": 'attachment; filename="claim-CLM-1.x12"',
        },
      })
    );
    const out = await api.serializeClaim837("CLM-1");
    expect(out.text).toBe(x12);
    expect(out.filename).toBe("claim-CLM-1.x12");
    // URL must hit the new SP8 endpoint with the encoded id.
    const called = mockFetch.mock.calls[0][0] as string;
    expect(called).toContain("/api/claims/CLM-1/serialize-837");
    // No Accept header negotiation needed — backend always returns text/x12.
    const init = mockFetch.mock.calls[0][1] as RequestInit | undefined;
    const headers = (init?.headers ?? {}) as Record<string, string>;
    expect(headers.Accept).toBeUndefined();
  });

  it("serializeClaim837 falls back to a default filename if Content-Disposition is missing", async () => {
    mockFetch.mockResolvedValueOnce(
      new Response("ISA*00*~", { status: 200 })
    );
    const out = await api.serializeClaim837("CLM-2");
    expect(out.filename).toBe("claim-CLM-2.x12");
  });

  it("serializeClaim837 throws ApiError on 404", async () => {
    mockFetch.mockResolvedValueOnce(
      new Response(
        JSON.stringify({ error: "Not found", detail: "Claim ghost not found" }),
        { status: 404, headers: { "content-type": "application/json" } }
      )
    );
    await expect(api.serializeClaim837("ghost")).rejects.toBeInstanceOf(ApiError);
  });

  it("exportBatch837 returns the blob and filename from a 200 ZIP response", async () => {
    const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); // ZIP magic
    mockFetch.mockResolvedValueOnce(
      new Response(zipBytes, {
        status: 200,
        headers: {
          "content-type": "application/zip",
          "content-disposition":
            'attachment; filename="batch-abc123-3-claims.zip"',
        },
      })
    );
    const out = await api.exportBatch837("abc123", ["CLM-1", "CLM-2", "CLM-3"]);
    expect(out.filename).toBe("batch-abc123-3-claims.zip");
    expect(out.blob).toBeInstanceOf(Blob);
    // POST to the batch-scoped endpoint with claim_ids in the body.
    const called = mockFetch.mock.calls[0][0] as string;
    expect(called).toContain("/api/batches/abc123/export-837");
    const init = mockFetch.mock.calls[0][1] as RequestInit;
    expect(init.method).toBe("POST");
    const body = JSON.parse(init.body as string);
    expect(body).toEqual({ claim_ids: ["CLM-1", "CLM-2", "CLM-3"] });
  });

  it("exportBatch837 parses the X-Cyclone-Serialize-Errors header into serializeErrors", async () => {
    const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
    const errs = [
      { claim_id: "CLM-2", reason: "no raw_json" },
      { claim_id: "CLM-3", reason: "raw_json invalid: ..." },
    ];
    mockFetch.mockResolvedValueOnce(
      new Response(zipBytes, {
        status: 200,
        headers: {
          "content-type": "application/zip",
          "content-disposition":
            'attachment; filename="batch-abc123-1-claims.zip"',
          "x-cyclone-serialize-errors": JSON.stringify(errs),
        },
      })
    );
    const out = await api.exportBatch837("abc123", ["CLM-1", "CLM-2", "CLM-3"]);
    expect(out.serializeErrors).toEqual(errs);
  });

  it("exportBatch837 returns an empty serializeErrors array when the header is missing", async () => {
    const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
    mockFetch.mockResolvedValueOnce(
      new Response(zipBytes, {
        status: 200,
        headers: {
          "content-type": "application/zip",
          "content-disposition": 'attachment; filename="batch-x-1-claims.zip"',
        },
      })
    );
    const out = await api.exportBatch837("x", ["CLM-1"]);
    expect(out.serializeErrors).toEqual([]);
  });

  it("exportBatch837 falls back to a default filename when Content-Disposition is missing", async () => {
    const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
    mockFetch.mockResolvedValueOnce(
      new Response(zipBytes, {
        status: 200,
        headers: { "content-type": "application/zip" },
      })
    );
    const out = await api.exportBatch837("xyz", ["CLM-1"]);
    expect(out.filename).toBe("batch-xyz-claims.zip");
  });

  it("exportBatch837 throws ApiError on 404", async () => {
    mockFetch.mockResolvedValueOnce(
      new Response(
        JSON.stringify({ error: "Not found", detail: "unknown batch: ghost" }),
        { status: 404, headers: { "content-type": "application/json" } }
      )
    );
    await expect(
      api.exportBatch837("ghost", ["CLM-1"])
    ).rejects.toBeInstanceOf(ApiError);
  });

  it("exportBatch837 throws on empty claim_ids without making a request", async () => {
    await expect(api.exportBatch837("any", [])).rejects.toThrow();
    expect(mockFetch).not.toHaveBeenCalled();
  });

  it("getBatchDiff surfaces a clear ApiError when a backend 400 wraps detail in a nested object", async () => {
    // The backend can wrap structured errors as
    // `{"detail": {"error": "Missing param", "detail": "..."}}`. The
    // client must walk into the nested `detail` so the user sees the
    // human-readable message rather than a blob of raw JSON.
    mockFetch.mockResolvedValueOnce(
      new Response(
        JSON.stringify({
          detail: {
            error: "Missing param",
            detail: "Both ?a=<batch_id> and ?b=<batch_id> are required.",
          },
        }),
        { status: 400, headers: { "content-type": "application/json" } }
      )
    );
    let caught: unknown;
    try {
      await api.getBatchDiff("a8ebd5f564a547908e7c60d6a129621e", "930f25e07eab4ac0908c9770689385e1");
    } catch (err) {
      caught = err;
    }
    expect(caught).toBeInstanceOf(ApiError);
    expect((caught as ApiError).status).toBe(400);
    // The unwrapped message must surface — not the raw JSON.
    expect((caught as ApiError).message).toBe(
      "Both ?a=<batch_id> and ?b=<batch_id> are required."
    );
  });

  it("getBatchDiff throws ApiError(400) before hitting the network if either id is empty", async () => {
    // Defense-in-depth: the hook's `enabled` guard should prevent this
    // from ever firing, but if it ever does, the client must refuse to
    // make a malformed request and surface a clear local error.
    await expect(api.getBatchDiff("", "930f25e07eab4ac0908c9770689385e1")).rejects.toMatchObject({
      status: 400,
      message: expect.stringContaining("?a=<batch_id>"),
    });
    await expect(api.getBatchDiff("a8ebd5f564a547908e7c60d6a129621e", "")).rejects.toMatchObject({
      status: 400,
      message: expect.stringContaining("?b=<batch_id>"),
    });
    // Network must not be touched.
    expect(mockFetch).not.toHaveBeenCalled();
  });
});
