/**
 * Cyclone API client.
 *
 * Talks to the FastAPI backend that lives at `VITE_API_BASE_URL`
 * (defaulting to an empty string → "no backend configured" mode). When the
 * base URL is empty, every method throws `notConfiguredError()`; the
 * higher-level hooks in `src/hooks/` catch that by reading from the
 * in-memory zustand store instead. This keeps the pages agnostic of the
 * switch.
 *
 * Endpoints:
 *   - `parse837(file, { onProgress })` POSTs to `/api/parse-837`. With
 *     `onProgress` set, streams NDJSON (one `{type,data}` per line) so the
 *     UI can render claims incrementally. Without it, asks the backend for
 *     a single JSON object via `Accept: application/json`.
 *   - `parse835(...)` mirrors the 837 shape for `/api/parse-835`.
 *   - `health()` GETs `/api/health` and returns `{ status, version }`.
 *   - `listBatches / getBatch / listClaims / listRemittances / listProviders
 *     / getProvider / listActivity` are plain JSON GETs against the
 *     persistence surface.
 *   - `listUnmatched / matchRemit / unmatchClaim` hit the reconciliation
 *     surface. POSTs throw `ApiError` so callers can branch on `.status`.
 */

import type {
  Ack,
  BatchDiff,
  ClaimDetail,
  ClaimOutput,
  ClaimPayment,
  Envelope,
  FinancialInfo,
  MatchResponse,
  NdjsonEvent,
  ParseResult837,
  ParseResult835,
  Payer,
  Payer835,
  Payee835,
  Provider,
  ReassociationTrace,
  Ta1Ack,
  UnmatchedClaim,
  UnmatchedResponse,
  BatchSummary as ParserBatchSummary,
} from "@/types";
import {
  authedFetch,
  authedFetchResponse,
} from "@/auth/api";

const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";

/**
 * Whether the app should hit the live backend.
 *
 * Two paths look identical at runtime:
 *
 *   - ``VITE_API_BASE_URL`` unset or empty → ``BASE_URL === ""`` and
 *     ``joinUrl()`` produces *relative* URLs (``/api/health`` etc.).
 *     Vite's dev proxy (and nginx in the Docker stack) intercept these
 *     and forward them to the FastAPI server, so the browser sees a
 *     same-origin call.
 *   - ``VITE_API_BASE_URL=https://api.example.com`` → absolute URLs
 *     that bypass the proxy and hit the backend directly (cross-origin
 *     CORS applies).
 *
 * We treat both as "configured" because the hooks above (``useClaims``
 * etc.) only need to know whether to attempt a network request vs.
 * fall back to the in-memory zustand store. The proxy is invisible to
 * the page — it always sees a successful fetch unless the backend is
 * actually down. The only way to opt out is to set the env var to the
 * literal string ``"disabled"`` (a sentinel we treat as "no backend"),
 * which is useful for offline Storybook-style previews.
 */
export const isConfigured = BASE_URL !== "disabled";

// ---------------------------------------------------------------------------
// Shared types
// ---------------------------------------------------------------------------

export type NdjsonProgressEvent =
  | { type: "envelope"; data: Envelope | null }
  | { type: "claim"; data: ClaimOutput }
  | { type: "claim_payment"; data: ClaimPayment }
  | { type: "financial_info"; data: FinancialInfo }
  | { type: "trace"; data: ReassociationTrace }
  | { type: "payer"; data: Payer | Payer835 }
  | { type: "payee"; data: Payee835 }
  | { type: "summary"; data: ParserBatchSummary };

export type ParseProgress = (event: NdjsonProgressEvent) => void;

export interface ParseOptions {
  /** Defaults to "co_medicaid" for 837 / "co_medicaid_835" for 835. */
  payer?: string;
  /** Promote validation warnings to errors on the backend. */
  strict?: boolean;
  /** Include raw EDI segments in the response (default: true). */
  includeRawSegments?: boolean;
  /**
   * Optional progress callback. When provided, the client streams NDJSON from
   * the backend and invokes the callback for every line. When omitted, the
   * client requests a single JSON object via `Accept: application/json`.
   */
  onProgress?: ParseProgress;
}

export interface HealthResponse {
  status: string;
  version: string;
}

// ---------------------------------------------------------------------------
// List-endpoint types (GET surfaces, sub-project 1)
// ---------------------------------------------------------------------------

export interface ListClaimsParams {
  batch_id?: string;
  status?: string;
  payer?: string;
  provider_npi?: string;
  date_from?: string;
  date_to?: string;
  sort?: string;
  order?: "asc" | "desc";
  limit?: number;
  offset?: number;
}

export interface ListRemittancesParams {
  batch_id?: string;
  payer?: string;
  claim_id?: string;
  date_from?: string;
  date_to?: string;
  sort?: string;
  order?: "asc" | "desc";
  limit?: number;
  offset?: number;
}

export interface ListProvidersParams {
  npi?: string;
  state?: string;
  limit?: number;
  offset?: number;
}

export interface ListActivityParams {
  kind?: string;
  since?: string;
  limit?: number;
}

// ---------------------------------------------------------------------------
// Dashboard KPI types (SP27 Task 13).
//
// Returned by ``GET /api/dashboard/kpis`` — server-aggregated over the
// *entire* claim population. The Dashboard renders these directly; it
// no longer paginates ``/api/claims`` and reduces client-side (which
// silently produced wrong numbers with the previous ``limit: 100``
// cap).
// ---------------------------------------------------------------------------

export interface DashboardTotals {
  count: number;
  billed: number;
  received: number;
  outstandingAr: number;
  denied: number;
  denialRate: number;
  pending: number;
}

export interface DashboardMonthly {
  month: string;        // "YYYY-MM"
  label: string;        // "Jan"
  count: number;
  billed: number;
  received: number;
  denied: number;
  denialRate: number;
  ar: number;
}

export interface DashboardTopProvider {
  npi: string;
  label: string;
  claimCount: number;
  billed: number;
  denied: number;
}

export interface DashboardTopDenial {
  id: string;
  patientName: string;
  billedAmount: number;
  denialReason: string | null;
  submissionDate: string;
}

export interface DashboardKpis {
  totals: DashboardTotals;
  monthly: DashboardMonthly[];
  topProviders: DashboardTopProvider[];
  topDenials: DashboardTopDenial[];
}

export interface DashboardKpisParams {
  months?: number;
  top_n_providers?: number;
  top_n_denials?: number;
}

export interface PaginatedResponse<T> {
  items: T[];
  total: number;
  returned: number;
  has_more: boolean;
}

/**
 * Server-side aggregates returned alongside `/api/acks` — summed over
 * the *full* persisted row set (not just the visible page). The
 * `accepted` / `rejected` / `received` keys match the in-page `totals`
 * shape so the page can use `data.aggregates` as a drop-in for the
 * page-local fallback accumulator.
 */
export interface AckAggregates {
  accepted: number;
  rejected: number;
  received: number;
}

/**
 * Lightweight summary used by the GET /api/batches list endpoint. Distinct
 * from the parser's `BatchSummary` (which lives in `@/types` and carries the
 * full validation + per-rule counts) — this one is what the UI needs to
 * render a list row.
 */
export interface BatchSummary {
  id: string;
  kind: "837p" | "835";
  inputFilename: string;
  parsedAt: string;
  claimCount: number;
  /**
   * Per-claim ids for 837P batches. Empty for 835 (no re-export
   * endpoint) — the UI hides the one-click Re-export button when
   * this is empty. Lets the History tab call
   * `POST /api/batches/{id}/export-837` directly with the row's ids
   * instead of an extra round-trip to `/api/batches/{id}`.
   */
  claimIds: string[];
}

// ---------------------------------------------------------------------------
// Low-level helpers
// ---------------------------------------------------------------------------

function notConfiguredError(): Error {
  return new Error(
    "API not configured. Set VITE_API_BASE_URL in .env.local to use the backend."
  );
}

/**
 * Error thrown for non-2xx responses. Carries the HTTP `status` so callers can
 * branch on it (e.g. 404 vs 500) without parsing the message string.
 */
export class ApiError extends Error {
  constructor(public status: number, message: string) {
    super(message);
  }
}

async function readErrorBody(res: Response): Promise<string> {
  try {
    const t = await res.text();
    if (!t) return "";
    // FastAPI errors can be flat `{ "error": "...", "detail": "..." }`
    // or wrapped as `{ "detail": { "error": "...", "detail": "..." } }`
    // (the wrapper our app uses for structured errors). Walk into nested
    // `detail` objects until we hit a string field — guards against the
    // raw JSON leaking into the user-visible error message.
    try {
      let current: unknown = JSON.parse(t);
      for (let depth = 0; depth < 5; depth++) {
        if (!current || typeof current !== "object") break;
        const obj = current as { detail?: unknown; error?: unknown };
        if (typeof obj.detail === "string") return obj.detail;
        if (typeof obj.error === "string") return obj.error;
        if (obj.detail && typeof obj.detail === "object") {
          current = obj.detail;
          continue;
        }
        break;
      }
    } catch {
      // not JSON; fall through
    }
    return t;
  } catch {
    return "";
  }
}

/** Build a query string, skipping undefined/empty params. */
function qs(params: Record<string, unknown> | undefined): string {
  if (!params) return "";
  const u = new URLSearchParams();
  for (const [k, v] of Object.entries(params)) {
    if (v === undefined || v === null || v === "") continue;
    u.set(k, String(v));
  }
  const s = u.toString();
  return s ? `?${s}` : "";
}

async function parseNdjsonStream(
  res: Response,
  onProgress: ParseProgress
): Promise<ParserBatchSummary> {
  if (!res.body) {
    throw new Error("Response had no body to stream.");
  }
  const reader = res.body.getReader();
  const decoder = new TextDecoder("utf-8");
  let buffer = "";
  let lastSummary: ParserBatchSummary | null = null;

  // Read the body chunk-by-chunk. A `while (true)` loop is the standard
  // pattern for ReadableStreamDefaultReader; `done` breaks us out.
  // eslint-disable-next-line no-constant-condition
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });

    // Split on newlines. The backend terminates every line with "\n".
    let nl = buffer.indexOf("\n");
    while (nl !== -1) {
      const line = buffer.slice(0, nl).replace(/\r$/, "");
      buffer = buffer.slice(nl + 1);
      if (line.length > 0) {
        let event: NdjsonEvent;
        try {
          event = JSON.parse(line) as NdjsonEvent;
        } catch (err) {
          throw new Error(
            `Failed to parse NDJSON line: ${
              err instanceof Error ? err.message : String(err)
            }\n${line}`
          );
        }
        onProgress(event as NdjsonProgressEvent);
        if (event.type === "summary") {
          lastSummary = event.data as ParserBatchSummary;
        }
      }
      nl = buffer.indexOf("\n");
    }
  }

  // Flush any trailing partial line (no final newline).
  const tail = buffer.replace(/\r$/, "");
  if (tail.length > 0) {
    const event = JSON.parse(tail) as NdjsonEvent;
    onProgress(event as NdjsonProgressEvent);
    if (event.type === "summary") {
      lastSummary = event.data as ParserBatchSummary;
    }
  }

  if (!lastSummary) {
    throw new Error("Stream ended without a summary line.");
  }
  return lastSummary;
}

// ---------------------------------------------------------------------------
// Public surface — parser endpoints
// ---------------------------------------------------------------------------

async function parse837(
  file: File,
  options: ParseOptions = {}
): Promise<ParseResult837 | ParserBatchSummary> {
  if (!isConfigured) throw notConfiguredError();
  const { payer = "co_medicaid", strict = false, includeRawSegments = true, onProgress } = options;

  const params = new URLSearchParams({
    payer,
    strict: String(strict),
    include_raw_segments: String(includeRawSegments),
  });

  const form = new FormData();
  form.append("file", file, file.name);

  const accept = onProgress ? "application/x-ndjson" : "application/json";
  // authedFetchResponse still hits fetch() (and still does the
  // 401-redirect for any non-/api/auth path), but hands back the raw
  // Response so we can stream NDJSON line-by-line below. For non-stream
  // callers it returns the parsed JSON.
  const res = await authedFetchResponse(
    `/api/parse-837?${params.toString()}`,
    {
      method: "POST",
      body: form,
      headers: { Accept: accept },
    }
  );

  if (!res.ok) {
    const detail = await readErrorBody(res);
    throw new Error(
      `${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
    );
  }

  if (onProgress) {
    return parseNdjsonStream(res, onProgress);
  }
  return (await res.json()) as ParseResult837;
}

async function parse835(
  file: File,
  options: ParseOptions = {}
): Promise<ParseResult835 | ParserBatchSummary> {
  if (!isConfigured) throw notConfiguredError();
  const { payer = "co_medicaid_835", strict = false, includeRawSegments = true, onProgress } = options;

  const params = new URLSearchParams({
    payer,
    strict: String(strict),
    include_raw_segments: String(includeRawSegments),
  });

  const form = new FormData();
  form.append("file", file, file.name);

  const accept = onProgress ? "application/x-ndjson" : "application/json";
  // See parse837 for why this uses authedFetchResponse instead of
  // authedFetch — NDJSON streaming needs the raw Response so we can
  // iterate `res.body` one chunk at a time.
  const res = await authedFetchResponse(
    `/api/parse-835?${params.toString()}`,
    {
      method: "POST",
      body: form,
      headers: { Accept: accept },
    }
  );

  if (!res.ok) {
    const detail = await readErrorBody(res);
    throw new Error(
      `${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
    );
  }

  if (onProgress) {
    return parseNdjsonStream(res, onProgress);
  }
  return (await res.json()) as ParseResult835;
}

/**
 * Upload a 999 ACK file for parsing. The backend persists the row
 * and returns both the parsed result and the persisted ack metadata
 * (including the regenerated raw 999 text for download).
 */
async function parse999(
  file: File
): Promise<{ ack: Ack & { raw_999_text: string }; parsed: unknown }> {
  if (!isConfigured) throw notConfiguredError();
  const form = new FormData();
  form.append("file", file, file.name);
  const body = await authedFetch<{
    ack: {
      id: number;
      accepted_count: number;
      rejected_count: number;
      received_count: number;
      ack_code: "A" | "E" | "R" | "P";
      source_batch_id: string;
      raw_999_text: string;
    };
    parsed: unknown;
  }>("/api/parse-999", {
    method: "POST",
    body: form,
    // Don't set Content-Type — the browser sets the multipart
    // boundary when the body is FormData. authedFetch only adds a
    // default Accept, which we override here.
    headers: { Accept: "application/json" },
  });
  return {
    ack: {
      id: body.ack.id,
      sourceBatchId: body.ack.source_batch_id,
      acceptedCount: body.ack.accepted_count,
      rejectedCount: body.ack.rejected_count,
      receivedCount: body.ack.received_count,
      ackCode: body.ack.ack_code,
      parsedAt: "",
      // raw_999_text is appended below (not part of the canonical Ack
      // type) so the UI can trigger a download without a second
      // round-trip to /api/acks/{id}.
      ...({ raw_999_text: body.ack.raw_999_text } as { raw_999_text: string }),
    } as Ack & { raw_999_text: string },
    parsed: body.parsed,
  };
}

async function health(): Promise<HealthResponse | null> {
  if (!isConfigured) return null;
  // health() is best-effort — used by the side-scan ping. Don't let a
  // 401 from authedFetch redirect the operator; just return null when
  // anything goes wrong.
  try {
    return await authedFetch<HealthResponse>("/api/health");
  } catch {
    return null;
  }
}

// ---------------------------------------------------------------------------
// Public surface — GET endpoints (sub-project 1)
// All throw `notConfiguredError` when no backend is wired. The hooks in
// `src/hooks/` are responsible for the in-memory zustand fallback.
// ---------------------------------------------------------------------------

async function listBatches(limit?: number): Promise<BatchSummary[]> {
  if (!isConfigured) throw notConfiguredError();
  const params: Record<string, unknown> = {};
  if (limit !== undefined) params.limit = limit;
  const body = await authedFetch<{ items: BatchSummary[] }>(
    `/api/batches${qs(params)}`
  );
  return body.items;
}

async function getBatch(id: string): Promise<ParseResult837 | ParseResult835> {
  if (!isConfigured) throw notConfiguredError();
  return authedFetch<ParseResult837 | ParseResult835>(
    `/api/batches/${encodeURIComponent(id)}`
  );
}

async function listClaims<T = unknown>(
  params: ListClaimsParams
): Promise<PaginatedResponse<T>> {
  if (!isConfigured) throw notConfiguredError();
  return authedFetch<PaginatedResponse<T>>(
    `/api/claims${qs(params as Record<string, unknown>)}`
  );
}

/**
 * Fetch one claim with the full SP4 detail-drawer context (header, state,
 * parties, validation, service lines, diagnoses, raw segments, recent
 * ``stateHistory``, and a populated ``matchedRemittance`` when paired).
 *
 * Drives ``GET /api/claims/{claim_id}``. Throws ``ApiError`` on non-2xx —
 * including 404, which the spec §3.4 calls out as a distinct "claim
 * doesn't exist" state in the drawer (separate from a transient fetch
 * failure). Callers should branch on ``error.status === 404`` to render
 * the not-found state instead of the generic error toast.
 */
async function getClaimDetail(id: string): Promise<ClaimDetail> {
  if (!isConfigured) throw notConfiguredError();
  try {
    return await authedFetch<ClaimDetail>(
      `/api/claims/${encodeURIComponent(id)}`
    );
  } catch (err) {
    // authedFetch throws the richer auth/api.ts ApiError (carries
    // `status` + `code` + `detail`). The drawer code branches on
    // `.status === 404` for the "claim doesn't exist" state, and
    // existing callers use `instanceof ApiError` (the lib/api.ts one)
    // to detect structured failures. Re-wrap into the lib/api.ts
    // ApiError so both keep working unchanged.
    if (
      err &&
      typeof err === "object" &&
      "status" in err &&
      typeof (err as { status: unknown }).status === "number"
    ) {
      const e = err as { status: number; code?: string; detail?: string };
      throw new ApiError(
        e.status,
        e.detail ?? e.code ?? "error"
      );
    }
    throw err;
  }
}

/**
 * Serialize a claim back to an X12 837P file via the backend's outbound
 * serializer (SP8).
 *
 * Returns both the regenerated X12 text and the filename the backend
 * suggested (from the `Content-Disposition: attachment; filename=...`
 * header). Callers usually pipe both into `downloadTextFile` so the user
 * gets a byte-faithful file named after the claim id.
 *
 * Throws `ApiError` on non-2xx — 404 (claim missing) and 422 (stored
 * `raw_json` unparseable / serializer rejected the payload) are both
 * reachable, and the caller may want to branch on `.status` to surface a
 * useful message ("claim data is corrupted — open the batch file
 * manually" vs. "this claim no longer exists").
 */
async function serializeClaim837(
  id: string,
): Promise<{ text: string; filename: string }> {
  if (!isConfigured) throw notConfiguredError();
  // authedFetchText gives us the body as a string with the same
  // 401-redirect + error-shape behavior as authedFetch. We need the
  // Response object for the Content-Disposition header, so this
  // function delegates to authedFetchResponse and reads text +
  // headers itself. No Accept negotiation — the endpoint always
  // returns text/x12.
  const res = await authedFetchResponse(
    `/api/claims/${encodeURIComponent(id)}/serialize-837`
  );
  if (!res.ok) {
    const detail = await readErrorBody(res);
    throw new ApiError(res.status, detail || res.statusText);
  }
  const text = await res.text();
  // Filename comes from Content-Disposition (set by the backend). Fall back
  // to a sensible default if the header is missing or malformed so the UI
  // always has something to hand to `downloadTextFile`.
  const cd = res.headers.get("content-disposition") ?? "";
  const match = /filename="?([^";]+)"?/i.exec(cd);
  const filename = match?.[1] ?? `claim-${id}.x12`;
  return { text, filename };
}

async function listRemittances<T = unknown>(
  params: ListRemittancesParams
): Promise<PaginatedResponse<T>> {
  if (!isConfigured) throw notConfiguredError();
  return authedFetch<PaginatedResponse<T>>(
    `/api/remittances${qs(params as Record<string, unknown>)}`
  );
}

/**
 * Server-aggregated KPI tiles for the Remittances page. Returns the
 * full-population ``count``, ``total_paid``, and ``total_adjustments``
 * — NOT a page-limited sample — so the KPI tiles can't silently
 * understate the true DB population the way a page-local
 * ``items.reduce(...)`` would (commits ``59c3275``, ``d81b6ed``).
 */
export interface RemittanceSummary {
  count: number;
  total_paid: number;
  total_adjustments: number;
}

async function listRemittanceSummary(
  params: ListRemittancesParams = {}
): Promise<RemittanceSummary> {
  if (!isConfigured) throw notConfiguredError();
  return authedFetch<RemittanceSummary>(
    `/api/remittances/summary${qs(params as Record<string, unknown>)}`
  );
}

/**
 * Fetch one remittance with its labeled CAS `adjustments` array.
 * Throws `ApiError` on 404 so callers can branch on `.status`.
 */
async function getRemittance<T = unknown>(id: string): Promise<T> {
  if (!isConfigured) throw notConfiguredError();
  return authedFetch<T>(`/api/remittances/${encodeURIComponent(id)}`);
}

/**
 * Fetch the side-by-side diff between two batches.
 *
 * Drives ``GET /api/batch-diff?a=<batch_id>&b=<batch_id>``. Both ids
 * are required; the endpoint returns 400 when either is missing and
 * 404 when the id is unknown — both surface here as ``ApiError`` so
 * the page can branch on ``.status`` like every other diff / fetch
 * surface in the app.
 */
async function getBatchDiff(a: string, b: string): Promise<BatchDiff> {
  if (!isConfigured) throw notConfiguredError();
  // Defense-in-depth: even though the `useBatchDiff` hook gates on both
  // ids being non-empty, refuse to fire the request if either is
  // missing. Surfaces a clear local error instead of letting the
  // backend return its generic "Missing param" 400.
  if (typeof a !== "string" || a.length === 0) {
    throw new ApiError(400, "Missing param: ?a=<batch_id> is required.");
  }
  if (typeof b !== "string" || b.length === 0) {
    throw new ApiError(400, "Missing param: ?b=<batch_id> is required.");
  }
  return authedFetch<BatchDiff>(`/api/batch-diff?${qs({ a, b })}`);
}

async function listProviders<T = unknown>(
  params: ListProvidersParams = {}
): Promise<PaginatedResponse<T>> {
  if (!isConfigured) throw notConfiguredError();
  return authedFetch<PaginatedResponse<T>>(
    `/api/providers${qs(params as Record<string, unknown>)}`
  );
}

/**
 * Fetch one configured provider by NPI, used by the provider drill-down
 * drawer (SP21 Task 2.2 / 1.6).
 *
 * Drives ``GET /api/config/providers/{npi}``. Note the ``/api/config/``
 * prefix — this is the config-side route namespace, distinct from the
 * persistence-side ``/api/providers`` used by ``listProviders``. The
 * response is the full ``Provider`` shape (including the optional
 * ``recent_claims`` and ``recent_activity`` arrays populated by Task
 * 1.6 when the backend can serve them).
 *
 * Throws ``ApiError`` on non-2xx — including 404, which the drawer may
 * want to branch on for a "provider no longer configured" state.
 */
async function getProvider(npi: string): Promise<Provider> {
  if (!isConfigured) throw notConfiguredError();
  const res = await fetch(
    joinUrl(`/api/config/providers/${encodeURIComponent(npi)}`),
    { headers: { Accept: "application/json" } }
  );
  if (!res.ok) {
    const detail = await readErrorBody(res);
    throw new ApiError(res.status, detail || res.statusText);
  }
  return (await res.json()) as Provider;
}

/**
 * Aggregate stats for one payer, used by the peek modal on Dashboard /
 * Claims tables (SP21 universal drill-down). Drives
 * `GET /api/payers/{payer_id}/summary`.
 *
 * `denial_rate` is a fraction in `[0, 1]` (the API does NOT pre-multiply).
 * `top_providers` is the top 3 (or fewer) by claim count, ordered
 * server-side.
 */
export interface PayerSummary {
  payer_id: string;
  name: string;
  claim_count: number;
  billed_total: number;
  received_total: number;
  denial_rate: number;
  top_providers: Array<{ npi: string; count: number }>;
}

async function getPayerSummary(payerId: string): Promise<PayerSummary> {
  if (!isConfigured) throw notConfiguredError();
  const res = await fetch(
    joinUrl(`/api/payers/${encodeURIComponent(payerId)}/summary`)
  );
  if (!res.ok) {
    const detail = await readErrorBody(res);
    throw new Error(
      `${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
    );
  }
  return (await res.json()) as PayerSummary;
}

async function listActivity<T = unknown>(
  params: ListActivityParams = {}
): Promise<PaginatedResponse<T>> {
  if (!isConfigured) throw notConfiguredError();
  return authedFetch<PaginatedResponse<T>>(
    `/api/activity${qs(params as Record<string, unknown>)}`
  );
}

/**
 * Fetch server-aggregated Dashboard KPIs.
 *
 * Drives ``GET /api/dashboard/kpis``. Computes billed / received /
 * denial rate / pending AR / top providers / top denials server-side
 * over the *full* claim population so the Dashboard's numbers are
 * always correct regardless of dataset size. With 60k+ claims in
 * production, fetching ``/api/claims?limit=100`` and reducing
 * client-side silently produced wrong KPIs — this endpoint replaces
 * that pattern.
 */
async function getDashboardKpis(
  params: DashboardKpisParams = {}
): Promise<DashboardKpis> {
  if (!isConfigured) throw notConfiguredError();
  return authedFetch<DashboardKpis>(
    `/api/dashboard/kpis${qs(params as Record<string, unknown>)}`
  );
}

// ---------------------------------------------------------------------------
// Public surface — reconciliation endpoints (sub-project 2)
// POSTs throw `ApiError` so callers can inspect `.status`; the GET is shaped
// like the other list endpoints above.
// ---------------------------------------------------------------------------

async function listUnmatched(): Promise<UnmatchedResponse> {
  if (!isConfigured) throw notConfiguredError();
  return authedFetch<UnmatchedResponse>(`/api/reconciliation/unmatched`);
}

async function matchRemit(
  claimId: string,
  remitId: string
): Promise<MatchResponse> {
  if (!isConfigured) throw notConfiguredError();
  return authedFetch<MatchResponse>(`/api/reconciliation/match`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ claim_id: claimId, remit_id: remitId }),
  });
}

async function unmatchClaim(claimId: string): Promise<{ claim: UnmatchedClaim }> {
  if (!isConfigured) throw notConfiguredError();
  return authedFetch<{ claim: UnmatchedClaim }>(`/api/reconciliation/unmatch`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ claim_id: claimId }),
  });
}

// ---------------------------------------------------------------------------
// Public surface — 999 ACKs (SP3 P3 T16)
// Re-shapes the snake_case backend response into the camelCase `Ack` shape
// used throughout the UI.
// ---------------------------------------------------------------------------

/**
 * Raw snake_case row from `GET /api/acks`. Re-shaped by `mapAck`
 * before the UI sees it.
 */
interface RawAckRow {
  id: number;
  source_batch_id: string;
  accepted_count: number;
  rejected_count: number;
  received_count: number;
  ack_code: "A" | "E" | "R" | "P";
  parsed_at: string;
}

function mapAck(row: RawAckRow): Ack {
  return {
    id: row.id,
    sourceBatchId: row.source_batch_id,
    acceptedCount: row.accepted_count,
    rejectedCount: row.rejected_count,
    receivedCount: row.received_count,
    ackCode: row.ack_code,
    parsedAt: row.parsed_at,
  };
}

async function listAcks(
  params: { limit?: number; offset?: number } = {},
): Promise<
  PaginatedResponse<Ack> & {
    aggregates: AckAggregates;
  }
> {
  if (!isConfigured) throw notConfiguredError();
  const query: Record<string, unknown> = {};
  if (params.limit !== undefined) query.limit = params.limit;
  if (params.offset !== undefined) query.offset = params.offset;
  const body = await authedFetch<{
    items: RawAckRow[];
    total: number;
    returned: number;
    has_more: boolean;
    aggregates: { accepted_count: number; rejected_count: number; received_count: number };
  }>(`/api/acks${qs(query)}`);
  return {
    items: body.items.map(mapAck),
    total: body.total,
    returned: body.returned,
    has_more: body.has_more,
    // Adapt the wire-format `*_count` keys to the in-page `totals` shape
    // so the page can treat server-side and client-side-fallback objects
    // interchangeably. See useAcks.ts for the matching AckAggregates type.
    aggregates: {
      accepted: body.aggregates.accepted_count,
      rejected: body.aggregates.rejected_count,
      received: body.aggregates.received_count,
    },
  };
}

async function getAck(id: number): Promise<Ack & { rawJson: unknown }> {
  if (!isConfigured) throw notConfiguredError();
  const row = await authedFetch<RawAckRow & { raw_json: unknown }>(
    `/api/acks/${encodeURIComponent(String(id))}`
  );
  return { ...mapAck(row), rawJson: row.raw_json };
}

// ---------------------------------------------------------------------------
// Public surface — TA1 ACKs
// The TA1 is the lowest-level X12 envelope ack (one per inbound
// ISA/IEA interchange), distinct from the per-batch 999. Colorado
// Medicaid's Gainwell MFT only ships 999s today, but historically
// they've sent TA1s, so the UI shows them whenever one is on file.
// ---------------------------------------------------------------------------

interface RawTa1Row {
  id: number;
  control_number: string;
  ack_code: "A" | "E" | "R";
  note_code: string | null;
  interchange_date: string | null;
  interchange_time: string | null;
  sender_id: string | null;
  receiver_id: string | null;
  source_batch_id: string;
  parsed_at: string | null;
}

function mapTa1Ack(row: RawTa1Row): Ta1Ack {
  return {
    id: row.id,
    controlNumber: row.control_number,
    ackCode: row.ack_code,
    noteCode: row.note_code,
    interchangeDate: row.interchange_date,
    interchangeTime: row.interchange_time,
    senderId: row.sender_id,
    receiverId: row.receiver_id,
    sourceBatchId: row.source_batch_id,
    parsedAt: row.parsed_at,
  };
}

async function listTa1Acks(
  params: { limit?: number } = {},
): Promise<PaginatedResponse<Ta1Ack>> {
  if (!isConfigured) throw notConfiguredError();
  const query: Record<string, unknown> = {};
  if (params.limit !== undefined) query.limit = params.limit;
  const body = await authedFetch<{
    items: RawTa1Row[];
    total: number;
  }>(`/api/ta1-acks${qs(query)}`);
  // The TA1 list endpoint returns `{ total, items }` (no `has_more` /
  // `returned` — it's a simple cap-based list, not a paginated one).
  // Synthesize the `PaginatedResponse` shape so the UI can share the
  // same hook contract as `listAcks`.
  return {
    items: body.items.map(mapTa1Ack),
    total: body.total,
    returned: body.items.length,
    has_more: body.items.length < body.total,
  };
}

/**
 * Download a ZIP of regenerated X12 837 files for a parsed batch.
 *
 * Drives `POST /api/batches/{batchId}/export-837` with the requested
 * claim_ids in the body. The backend returns a binary ZIP whose
 * entries follow the HCPF X12 File Naming Standards template
 * ``{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12`` (one file per
 * successfully serialized claim, with a per-claim millisecond offset
 * so every entry has a unique 17-digit timestamp). The ZIP itself is
 * named ``batch-{batchId}-{N}-claims.zip`` via Content-Disposition,
 * where N is the success count.
 *
 * Per-claim serialization failures are surfaced via the
 * `X-Cyclone-Serialize-Errors` response header (JSON-encoded array of
 * `{claim_id, reason}`). The ZIP still contains the successful claims;
 * the failures are returned alongside so the UI can show a partial-
 * success toast like "Exported 18 · 2 couldn't be regenerated".
 *
 * Throws `ApiError` on non-2xx — 404 (batch missing) is the most
 * likely case. The client-side guard rejects empty `claimIds` without
 * making a request.
 */
export interface BatchExportResult {
  blob: Blob;
  filename: string;
  serializeErrors: Array<{ claim_id: string; reason: string }>;
}

async function exportBatch837(
  batchId: string,
  claimIds: string[],
): Promise<BatchExportResult> {
  if (!isConfigured) throw notConfiguredError();
  if (claimIds.length === 0) {
    throw new Error("claimIds is empty");
  }
  const res = await fetch(
    joinUrl(`/api/batches/${encodeURIComponent(batchId)}/export-837`),
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Accept: "application/zip",
      },
      body: JSON.stringify({ claim_ids: claimIds }),
    },
  );
  if (!res.ok) {
    const detail = await readErrorBody(res);
    throw new ApiError(res.status, detail || res.statusText);
  }
  const blob = await res.blob();
  const cd = res.headers.get("content-disposition") ?? "";
  const match = /filename="?([^";]+)"?/i.exec(cd);
  const filename = match?.[1] ?? `batch-${batchId}-claims.zip`;
  const errHeader = res.headers.get("x-cyclone-serialize-errors");
  let serializeErrors: Array<{ claim_id: string; reason: string }> = [];
  if (errHeader) {
    try {
      serializeErrors = JSON.parse(errHeader);
    } catch {
      // Malformed header — treat as empty rather than failing the download.
      serializeErrors = [];
    }
  }
  return { blob, filename, serializeErrors };
}

export const api = {
  isConfigured,
  baseUrl: BASE_URL,
  health,
  parse837,
  parse835,
  parse999,
  listBatches,
  getBatch,
  getBatchDiff,
  listClaims,
  getClaimDetail,
  serializeClaim837,
  exportBatch837,
  listRemittances,
  listRemittanceSummary,
  getRemittance,
  listProviders,
  getProvider,
  getPayerSummary,
  listActivity,
  listUnmatched,
  matchRemit,
  unmatchClaim,
  listAcks,
  getAck,
  listTa1Acks,
  getDashboardKpis,
};
