/**
 * Regression coverage for issue #1248 (P3 performance).
 *
 * Bug: `useParcelAnalyzeQuery`'s queryFn polled GET /fetch-status (+ re-POST
 * /analyze) in a `for (i < 60)` loop with a non-abortable `setTimeout` and never
 * read the TanStack Query v5 AbortSignal. On unmount / смена cad|horizon the
 * loop kept firing requests up to the 2-min hard cap — zombie-polling.
 *
 * Fix: queryFn destructures `{ signal }`, forwards it to every apiFetch call,
 * sleeps via `abortableSleep`, and throws AbortError before each iteration once
 * `signal.aborted`. The sibling mutation flow (useSiteAnalysis #1242) already
 * did this; both now share `@/lib/abortableSleep`.
 *
 * Strategy: `@tanstack/react-query` is mocked so the hook's `useQuery(options)`
 * call simply captures the options object. We then invoke `options.queryFn`
 * directly with a real AbortSignal and a per-URL `fetch` stub, under fake
 * timers, and assert on abort behaviour + the happy path.
 */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// ── Capture the options passed to useQuery ───────────────────────────────────
// The captured options carry the polling queryFn we want to drive directly.
interface CapturedQueryOptions {
  queryKey: readonly unknown[];
  queryFn: (ctx: { signal: AbortSignal }) => Promise<unknown>;
}

// vi.mock is hoisted; keep the captured ref in a hoisted holder so both the
// factory and the tests can reach it.
const captured = vi.hoisted<{ options: CapturedQueryOptions | null }>(() => ({
  options: null,
}));

vi.mock("@tanstack/react-query", () => ({
  keepPreviousData: Symbol("keepPreviousData"),
  useQuery: (options: CapturedQueryOptions) => {
    captured.options = options;
    return {};
  },
}));

import { useParcelAnalyzeQuery } from "../site-finder-api";

// ── fetch router ─────────────────────────────────────────────────────────────

interface MockSpec {
  status: number;
  body?: unknown;
}

/** Minimal Response stub — only the fields apiFetch/apiFetchWithStatus read. */
function makeResponse({ status, body }: MockSpec): Response {
  const text = body == null ? "" : JSON.stringify(body);
  return {
    ok: status >= 200 && status < 300,
    status,
    text: () => Promise.resolve(text),
  } as unknown as Response;
}

/**
 * Routes the stubbed fetch by URL + method and records every call so we can
 * assert that nothing fires after abort. `statusSeq` is the queue of
 * /fetch-status responses, consumed in order.
 */
function installFetchRouter(opts: {
  firstAnalyze: MockSpec;
  statusSeq: MockSpec[];
  secondAnalyze?: MockSpec;
}) {
  const calls: Array<{ url: string; method: string }> = [];
  let analyzeCount = 0;
  let statusIdx = 0;

  const fn = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
    const url = String(input);
    const method = (init?.method ?? "GET").toUpperCase();
    calls.push({ url, method });

    // Mirror the browser: a pre-aborted signal makes fetch reject immediately.
    if (init?.signal?.aborted) {
      return Promise.reject(new DOMException("Aborted", "AbortError"));
    }

    if (url.includes("/analyze")) {
      analyzeCount += 1;
      const spec =
        analyzeCount === 1
          ? opts.firstAnalyze
          : (opts.secondAnalyze ?? { status: 200, body: {} });
      return Promise.resolve(makeResponse(spec));
    }
    if (url.includes("/fetch-status")) {
      const spec = opts.statusSeq[statusIdx] ?? {
        status: 200,
        body: { status: "fetching" },
      };
      statusIdx += 1;
      return Promise.resolve(makeResponse(spec));
    }
    return Promise.reject(new Error(`unexpected fetch: ${method} ${url}`));
  });

  vi.stubGlobal("fetch", fn);
  return {
    calls,
    statusCalls: () =>
      calls.filter((c) => c.url.includes("/fetch-status")).length,
    analyzeCalls: () => calls.filter((c) => c.url.includes("/analyze")).length,
  };
}

const CAD = "66:41:0701045:42";

/**
 * Run the hook so the mocked `useQuery` captures its options, then return the
 * polling queryFn. Reads `captured.options` via a fresh binding so TS control-
 * flow doesn't pin it (the hook mutates it opaquely through the mock).
 *
 * `useQuery` is fully mocked (it just records its options, no React state), so
 * the rules-of-hooks invariant does not apply to this call — disable locally.
 */
function getQueryFn(): CapturedQueryOptions["queryFn"] {
  // eslint-disable-next-line react-hooks/rules-of-hooks
  useParcelAnalyzeQuery(CAD, 12);
  const options = captured.options;
  if (options === null) throw new Error("useQuery options not captured");
  return options.queryFn;
}

function runQueryFn(signal: AbortSignal): Promise<unknown> {
  return getQueryFn()({ signal });
}

describe("useParcelAnalyzeQuery — poll loop honours AbortSignal (#1248)", () => {
  beforeEach(() => {
    captured.options = null;
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
    vi.unstubAllGlobals();
    vi.restoreAllMocks();
  });

  it("aborts the poll loop and stops issuing requests after abort", async () => {
    const controller = new AbortController();
    const router = installFetchRouter({
      firstAnalyze: {
        status: 202,
        body: { status: "fetching", job_id: 1, eta_seconds: 30 },
      },
      // Always "fetching" → the loop would otherwise run to the 60-iter cap.
      statusSeq: Array.from({ length: 60 }, () => ({
        status: 200,
        body: {
          status: "fetching",
          job_id: 1,
          error_msg: null,
          eta_seconds: 10,
        },
      })),
    });

    const promise = runQueryFn(controller.signal).catch((e: unknown) => e);

    // First POST /analyze (202) resolves and the loop enters its first sleep.
    await vi.advanceTimersByTimeAsync(0);
    expect(router.analyzeCalls()).toBe(1);

    // One poll cycle: sleep 2s → GET /fetch-status ("fetching").
    await vi.advanceTimersByTimeAsync(2000);
    expect(router.statusCalls()).toBeGreaterThanOrEqual(1);

    // Abort mid-flight (как unmount / смена cad). abortableSleep wakes at once.
    controller.abort();
    await vi.advanceTimersByTimeAsync(0);

    const result = await promise;
    expect(result).toBeInstanceOf(DOMException);
    expect((result as DOMException).name).toBe("AbortError");

    // No further requests after abort — even if the full 2-min cap elapses.
    const callsAtAbort = router.calls.length;
    await vi.advanceTimersByTimeAsync(60 * 2000);
    expect(router.calls.length).toBe(callsAtAbort);
  });

  it("does not poll at all when the signal is already aborted", async () => {
    const controller = new AbortController();
    controller.abort();
    const router = installFetchRouter({
      firstAnalyze: {
        status: 202,
        body: { status: "fetching", job_id: 1, eta_seconds: 30 },
      },
      statusSeq: [],
    });

    const result = await runQueryFn(controller.signal).catch((e: unknown) => e);

    expect(result).toBeInstanceOf(DOMException);
    expect((result as DOMException).name).toBe("AbortError");
    // First POST /analyze sees the aborted signal → rejects, never polls.
    expect(router.statusCalls()).toBe(0);
  });

  it("happy path: polls until ready then resolves with the 200 analysis", async () => {
    const controller = new AbortController();
    const payload = { cad_num: CAD, score: 72, source: "nspd" };
    const router = installFetchRouter({
      firstAnalyze: {
        status: 202,
        body: { status: "fetching", job_id: 1, eta_seconds: 30 },
      },
      statusSeq: [
        {
          status: 200,
          body: {
            status: "fetching",
            job_id: 1,
            error_msg: null,
            eta_seconds: 10,
          },
        },
        {
          status: 200,
          body: { status: "ready", job_id: 1, error_msg: null, eta_seconds: 0 },
        },
      ],
      secondAnalyze: { status: 200, body: payload },
    });

    const promise = runQueryFn(controller.signal);

    // first POST 202 → sleep → status(fetching) → sleep → status(ready) → re-POST 200
    await vi.advanceTimersByTimeAsync(0);
    await vi.advanceTimersByTimeAsync(2000);
    await vi.advanceTimersByTimeAsync(2000);
    await vi.advanceTimersByTimeAsync(0);

    await expect(promise).resolves.toEqual(payload);
    expect(router.analyzeCalls()).toBe(2); // first + re-trigger
    expect(router.statusCalls()).toBe(2);
  });
});
