// @vitest-environment node
// `streamTail` pipes a `fetch()` response body through a `TextDecoderStream`
// and a manual NDJSON-line splitter. We mock `fetch` with `vi.stubGlobal`
// and drive a fake `ReadableStream` from the test, so no DOM/happy-dom
// is needed — Node 22's globals (Response, ReadableStream, TextDecoderStream)
// are sufficient.

import { afterEach, describe, expect, it, vi } from "vitest";
import { streamTail, type TailEvent } from "./tail-stream";

/**
 * Build a controllable ReadableStream<Uint8Array> and a tiny driver. The
 * stream's controller is captured at construction so the test can decide
 * exactly when each NDJSON line is enqueued and when to close. The body
 * is wrapped in a `Response` (matching what `fetch()` would return).
 */
function makeStream() {
  const encoder = new TextEncoder();
  let controllerRef: ReadableStreamDefaultController<Uint8Array> | undefined;
  const stream = new ReadableStream<Uint8Array>({
    start(c) {
      controllerRef = c;
    },
  });
  return {
    response: new Response(stream, {
      status: 200,
      headers: { "content-type": "application/x-ndjson" },
    }),
    push: (line: string) => {
      controllerRef?.enqueue(encoder.encode(line + "\n"));
    },
    close: () => controllerRef?.close(),
  };
}

/**
 * Drain an async iterable into an array. Useful for asserting the full
 * emission list without manually awaiting each `.next()`.
 */
async function collect<T>(iter: AsyncIterableIterator<T>): Promise<T[]> {
  const out: T[] = [];
  for await (const v of iter) out.push(v);
  return out;
}

describe("streamTail", () => {
  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it("test_parses_well_formed_ndjson_into_typed_events", async () => {
    const driver = makeStream();
    const fetchMock = vi.fn().mockResolvedValue(driver.response);
    vi.stubGlobal("fetch", fetchMock);

    const gen = streamTail("claims");
    driver.push('{"type":"item","data":{"id":"CLM-1"}}');
    driver.push('{"type":"item","data":{"id":"CLM-2"}}');
    driver.push('{"type":"item","data":{"id":"CLM-3"}}');
    driver.close();

    const first = await gen.next();
    const second = await gen.next();
    const third = await gen.next();
    const tail = await gen.next();

    expect(first.done).toBe(false);
    expect(second.done).toBe(false);
    expect(third.done).toBe(false);
    expect(tail.done).toBe(true);
    expect([first.value, second.value, third.value]).toEqual<TailEvent[]>([
      { type: "item", data: { id: "CLM-1" } },
      { type: "item", data: { id: "CLM-2" } },
      { type: "item", data: { id: "CLM-3" } },
    ]);

    // The fetch call itself must target /api/claims/stream with the
    // NDJSON Accept header.
    expect(fetchMock).toHaveBeenCalledTimes(1);
    const [calledUrl, calledInit] = fetchMock.mock.calls[0] as [string, RequestInit];
    expect(calledUrl).toBe("/api/claims/stream");
    expect(calledInit.headers).toMatchObject({ Accept: "application/x-ndjson" });
  });

  it("test_yields_typed_error_on_error_line", async () => {
    const driver = makeStream();
    vi.stubGlobal("fetch", vi.fn().mockResolvedValue(driver.response));
    const gen = streamTail("remittances");
    driver.push('{"type":"error","data":{"message":"x"}}');
    driver.close();
    const events = await collect(gen);
    expect(events).toEqual<TailEvent[]>([
      { type: "error", data: { message: "x" } },
    ]);
  });

  it("test_handles_heartbeat_silently", async () => {
    // "Silently" here means: still yields the correctly-typed heartbeat
    // (no special filtering) — the consumer (useTailStream) decides what
    // to do with it. The lib just has to forward it.
    const driver = makeStream();
    vi.stubGlobal("fetch", vi.fn().mockResolvedValue(driver.response));
    const gen = streamTail("activity");
    driver.push('{"type":"heartbeat","data":{"ts":"2026-06-20T12:00:00Z"}}');
    driver.close();
    const events = await collect(gen);
    expect(events).toEqual<TailEvent[]>([
      { type: "heartbeat", data: { ts: "2026-06-20T12:00:00Z" } },
    ]);
  });

  it("test_abort_signal_cancels_mid_stream", async () => {
    const driver = makeStream();
    vi.stubGlobal("fetch", vi.fn().mockResolvedValue(driver.response));
    const ac = new AbortController();
    const gen = streamTail("claims", { signal: ac.signal });

    // First event arrives normally.
    driver.push('{"type":"item","data":{"id":"CLM-1"}}');
    const first = await gen.next();
    expect(first.done).toBe(false);

    // Abort before any more events arrive, then close the upstream so
    // any pending read completes. The generator must exit cleanly
    // (no throw) and not yield further.
    ac.abort();
    driver.close();
    const second = await gen.next();
    expect(second.done).toBe(true);
  });

  it("test_malformed_line_skipped_next_valid_still_emitted", async () => {
    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
    const driver = makeStream();
    vi.stubGlobal("fetch", vi.fn().mockResolvedValue(driver.response));
    const gen = streamTail("claims");
    driver.push('{"type":"item","data":{"id":"CLM-1"}}');
    driver.push("this is not json");
    driver.push('{"type":"item","data":{"id":"CLM-2"}}');
    driver.close();
    const events = await collect(gen);
    expect(events).toEqual<TailEvent[]>([
      { type: "item", data: { id: "CLM-1" } },
      { type: "item", data: { id: "CLM-2" } },
    ]);
    // The malformed line must produce a console.warn so operators can spot
    // a buggy backend without crashing the whole tail.
    expect(warnSpy).toHaveBeenCalled();
    warnSpy.mockRestore();
  });
});
