import { MockAgent } from "undici";
import { describe, expect, it } from "vitest";
import {
  HttpStatusError,
  PoliteHttp,
  StorePausedError,
  type HttpTransport,
  type HttpTransportRequest,
} from "../src/core/politeHttp.js";

const ORIGIN = "https://store.example";

function makeHttp(agent: MockAgent, opts: { minGapMs?: number; breakerThreshold?: number } = {}) {
  return new PoliteHttp({
    dispatcher: agent,
    minGapMs: opts.minGapMs ?? 0,
    breakerThreshold: opts.breakerThreshold ?? 5,
    breakerPauseMs: 60_000,
  });
}

function mockAgent(): MockAgent {
  const agent = new MockAgent();
  agent.disableNetConnect();
  return agent;
}

describe("PoliteHttp", () => {
  it("runs the politeness and cookie core over an injected transport", async () => {
    const requests: HttpTransportRequest[] = [];
    const transport: HttpTransport = async (request) => {
      requests.push(request);
      return requests.length === 1
        ? { status: 200, headers: { "Set-Cookie": ["session=abc; Path=/"] }, body: "first" }
        : { status: 200, headers: {}, body: "second" };
    };
    const http = new PoliteHttp({ transport, minGapMs: 0 });

    expect(await http.get("s", `${ORIGIN}/a`)).toBe("first");
    expect(await http.postJson("s", `${ORIGIN}/b`, { part: "W71252" })).toBe("second");

    expect(requests[1]).toMatchObject({
      method: "POST",
      body: '{"part":"W71252"}',
      headers: {
        Cookie: "session=abc",
        "Content-Type": "application/json;charset=UTF-8",
      },
    });
  });

  it("keeps retry and breaker behavior above an injected transport", async () => {
    let calls = 0;
    const transport: HttpTransport = async () => {
      calls++;
      return { status: 503, headers: {}, body: "down" };
    };
    const http = new PoliteHttp({
      transport,
      minGapMs: 0,
      breakerThreshold: 1,
      breakerPauseMs: 60_000,
    });

    await expect(http.get("s", `${ORIGIN}/down`)).rejects.toThrow(HttpStatusError);
    expect(calls).toBe(2);
    await expect(http.get("s", `${ORIGIN}/down`)).rejects.toThrow(StorePausedError);
    expect(calls).toBe(2);
  });

  it("serializes an injected transport and preserves the same-store gap", async () => {
    const hits: number[] = [];
    const transport: HttpTransport = async () => {
      hits.push(Date.now());
      return { status: 200, headers: {}, body: "ok" };
    };
    const http = new PoliteHttp({ transport, minGapMs: 120 });

    await Promise.all([http.get("s", `${ORIGIN}/a`), http.get("s", `${ORIGIN}/b`)]);
    expect(hits).toHaveLength(2);
    expect(hits[1]! - hits[0]!).toBeGreaterThanOrEqual(100);
  });

  it("returns the body and remembers cookies for the next request", async () => {
    const agent = mockAgent();
    const pool = agent.get(ORIGIN);
    pool
      .intercept({ path: "/a", method: "GET" })
      .reply(200, "first", { headers: { "set-cookie": "session=abc; Path=/; HttpOnly" } });
    let sentCookie: string | undefined;
    pool.intercept({ path: "/b", method: "GET" }).reply((req) => {
      const headers = req.headers as Record<string, string>;
      const key = Object.keys(headers).find((k) => k.toLowerCase() === "cookie");
      sentCookie = key ? headers[key] : undefined;
      return { statusCode: 200, data: "second" };
    });

    const http = makeHttp(agent);
    expect(await http.get("s", `${ORIGIN}/a`)).toBe("first");
    expect(await http.get("s", `${ORIGIN}/b`)).toBe("second");
    expect(sentCookie).toBe("session=abc");
  });

  it("retries once on a 5xx and succeeds", async () => {
    const agent = mockAgent();
    const pool = agent.get(ORIGIN);
    pool.intercept({ path: "/flaky", method: "GET" }).reply(500, "boom");
    pool.intercept({ path: "/flaky", method: "GET" }).reply(200, "ok");

    expect(await makeHttp(agent).get("s", `${ORIGIN}/flaky`)).toBe("ok");
  });

  it("does not retry a 4xx", async () => {
    const agent = mockAgent();
    const pool = agent.get(ORIGIN);
    pool.intercept({ path: "/missing", method: "GET" }).reply(404, "not here");
    // No second intercept: a retry would throw undici's mock "no matching interceptor"
    // instead of our 404.
    const err = await makeHttp(agent).get("s", `${ORIGIN}/missing`).catch((e) => e);
    expect(err).toBeInstanceOf(HttpStatusError);
    expect((err as HttpStatusError).status).toBe(404);
  });

  it("trips the breaker on repeated 5xx and pauses the store", async () => {
    const agent = mockAgent();
    const pool = agent.get(ORIGIN);
    // 2 requests × (1 attempt + 1 retry) = 4 responses before the breaker trips.
    pool.intercept({ path: "/down", method: "GET" }).reply(503, "down").times(4);

    const http = makeHttp(agent, { breakerThreshold: 2 });
    await expect(http.get("s", `${ORIGIN}/down`)).rejects.toThrow(HttpStatusError);
    await expect(http.get("s", `${ORIGIN}/down`)).rejects.toThrow(HttpStatusError);
    await expect(http.get("s", `${ORIGIN}/down`)).rejects.toThrow(StorePausedError);
  });

  it("does not count 404s toward the breaker", async () => {
    const agent = mockAgent();
    const pool = agent.get(ORIGIN);
    pool.intercept({ path: "/missing", method: "GET" }).reply(404, "not here").times(3);

    const http = makeHttp(agent, { breakerThreshold: 2 });
    for (let i = 0; i < 3; i++) {
      const err = await http.get("s", `${ORIGIN}/missing`).catch((e) => e);
      expect(err).toBeInstanceOf(HttpStatusError); // never StorePausedError
    }
  });

  it("keeps the politeness gap even for concurrent requests to the same store", async () => {
    const agent = mockAgent();
    const pool = agent.get(ORIGIN);
    const hits: number[] = [];
    pool
      .intercept({ path: "/gap", method: "GET" })
      .reply(() => {
        hits.push(Date.now());
        return { statusCode: 200, data: "ok" };
      })
      .times(2);

    const http = makeHttp(agent, { minGapMs: 120 });
    await Promise.all([http.get("s", `${ORIGIN}/gap`), http.get("s", `${ORIGIN}/gap`)]);
    expect(hits).toHaveLength(2);
    expect(Math.abs(hits[1]! - hits[0]!)).toBeGreaterThanOrEqual(100);
  });

  it("does not delay requests to a different store", async () => {
    const agent = mockAgent();
    const pool = agent.get(ORIGIN);
    pool.intercept({ path: "/one", method: "GET" }).reply(200, "1");
    pool.intercept({ path: "/two", method: "GET" }).reply(200, "2");

    const http = makeHttp(agent, { minGapMs: 5000 });
    const started = Date.now();
    await Promise.all([http.get("a", `${ORIGIN}/one`), http.get("b", `${ORIGIN}/two`)]);
    expect(Date.now() - started).toBeLessThan(2000);
  });
});
