// src/lib/seranking/client.test.ts — fetch injecté (DI, pas de mock.module).
import { describe, expect, test } from "bun:test";
import { bulkKeywordMetrics, limiterForKey, SerankingError } from "./client";
import { createRateLimiter } from "./rate-limit";

const noWait = createRateLimiter({ minIntervalMs: 0 });
const noSleep = () => Promise.resolve();

function fakeFetch(responses: Array<{ status: number; body: string }>): {
  fetch: typeof fetch;
  calls: Array<{ url: string; init: RequestInit }>;
} {
  const calls: Array<{ url: string; init: RequestInit }> = [];
  let i = 0;
  const impl = (async (url: RequestInfo | URL, init?: RequestInit) => {
    calls.push({ url: String(url), init: init ?? {} });
    const r = responses[Math.min(i, responses.length - 1)];
    i++;
    return new Response(r.body, { status: r.status });
  }) as typeof fetch;
  return { fetch: impl, calls };
}

describe("bulkKeywordMetrics", () => {
  test("POSTs keywords to the right URL and returns the raw body", async () => {
    const { fetch, calls } = fakeFetch([
      { status: 200, body: `[{"keyword":"a"}]` },
    ]);
    const body = await bulkKeywordMetrics({
      source: "fr",
      keywords: ["a", "b"],
      fetchImpl: fetch,
      limiter: noWait,
      sleep: noSleep,
    });
    expect(body).toBe(`[{"keyword":"a"}]`);
    expect(calls).toHaveLength(1);
    expect(calls[0].url).toBe(
      "https://api.seranking.com/v1/keywords/export?source=fr",
    );
    expect(calls[0].init.method).toBe("POST");
    const headers = calls[0].init.headers as Record<string, string>;
    expect(headers.Authorization).toMatch(/^Token /);
    expect(JSON.parse(String(calls[0].init.body))).toEqual({
      keywords: ["a", "b"],
    });
  });

  test("400 insufficient funds → insufficient-credits, no retry", async () => {
    const { fetch, calls } = fakeFetch([
      {
        status: 400,
        body: `{"message":"Insufficient funds - No credits left"}`,
      },
    ]);
    const err = await bulkKeywordMetrics({
      source: "fr",
      keywords: ["a"],
      fetchImpl: fetch,
      limiter: noWait,
      sleep: noSleep,
    }).catch((e) => e);
    expect(err).toBeInstanceOf(SerankingError);
    expect((err as SerankingError).kind).toBe("insufficient-credits");
    expect(calls).toHaveLength(1);
  });

  test("400 invalid source → invalid-source", async () => {
    const { fetch } = fakeFetch([
      { status: 400, body: `{"message":"Invalid source"}` },
    ]);
    const err = await bulkKeywordMetrics({
      source: "zz",
      keywords: ["a"],
      fetchImpl: fetch,
      limiter: noWait,
      sleep: noSleep,
    }).catch((e) => e);
    expect((err as SerankingError).kind).toBe("invalid-source");
  });

  test("403 → auth", async () => {
    const { fetch } = fakeFetch([
      { status: 403, body: `{"message":"License expired"}` },
    ]);
    const err = await bulkKeywordMetrics({
      source: "fr",
      keywords: ["a"],
      fetchImpl: fetch,
      limiter: noWait,
      sleep: noSleep,
    }).catch((e) => e);
    expect((err as SerankingError).kind).toBe("auth");
  });

  test("429 retried once then succeeds", async () => {
    const { fetch, calls } = fakeFetch([
      { status: 429, body: `{"message":"Too many requests"}` },
      { status: 200, body: `[]` },
    ]);
    const body = await bulkKeywordMetrics({
      source: "fr",
      keywords: ["a"],
      fetchImpl: fetch,
      limiter: noWait,
      sleep: noSleep,
    });
    expect(body).toBe(`[]`);
    expect(calls).toHaveLength(2);
  });

  test("429 twice → rate-limited error", async () => {
    const { fetch, calls } = fakeFetch([
      { status: 429, body: `{}` },
      { status: 429, body: `{}` },
    ]);
    const err = await bulkKeywordMetrics({
      source: "fr",
      keywords: ["a"],
      fetchImpl: fetch,
      limiter: noWait,
      sleep: noSleep,
    }).catch((e) => e);
    expect((err as SerankingError).kind).toBe("rate-limited");
    expect(calls).toHaveLength(2);
  });

  test("500 retried once; second 500 → http error", async () => {
    const { fetch, calls } = fakeFetch([
      { status: 500, body: `` },
      { status: 503, body: `` },
    ]);
    const err = await bulkKeywordMetrics({
      source: "fr",
      keywords: ["a"],
      fetchImpl: fetch,
      limiter: noWait,
      sleep: noSleep,
    }).catch((e) => e);
    expect((err as SerankingError).kind).toBe("http");
    expect(calls).toHaveLength(2);
  });

  test("timeout mid-body (res.text() rejects) → network error, retried then succeeds", async () => {
    const calls: string[] = [];
    let i = 0;
    const impl = (async (url: RequestInfo | URL) => {
      calls.push(String(url));
      i++;
      if (i === 1) {
        return {
          ok: true,
          status: 200,
          text: () =>
            Promise.reject(
              new DOMException("The operation timed out.", "TimeoutError"),
            ),
        } as Response;
      }
      return new Response(`[{"keyword":"a"}]`, { status: 200 });
    }) as typeof fetch;

    const body = await bulkKeywordMetrics({
      source: "fr",
      keywords: ["a"],
      fetchImpl: impl,
      limiter: noWait,
      sleep: noSleep,
    });
    expect(body).toBe(`[{"keyword":"a"}]`);
    expect(calls).toHaveLength(2);
  });

  test("timeout mid-body on both attempts → final error is SerankingError(network)", async () => {
    const impl = (async (_url: RequestInfo | URL, _init?: RequestInit) => {
      return {
        ok: true,
        status: 200,
        text: () =>
          Promise.reject(
            new DOMException("The operation timed out.", "TimeoutError"),
          ),
      } as Response;
    }) as typeof fetch;

    const err = await bulkKeywordMetrics({
      source: "fr",
      keywords: ["a"],
      fetchImpl: impl,
      limiter: noWait,
      sleep: noSleep,
    }).catch((e) => e);
    expect(err).toBeInstanceOf(SerankingError);
    expect((err as SerankingError).kind).toBe("network");
  });

  test("opts.apiKey (byok) prioritaire sur la clé plateforme (env)", async () => {
    const { fetch, calls } = fakeFetch([{ status: 200, body: `[]` }]);
    await bulkKeywordMetrics({
      source: "fr",
      keywords: ["a"],
      apiKey: "org-byok-key",
      fetchImpl: fetch,
      limiter: noWait,
      sleep: noSleep,
    });
    const headers = calls[0].init.headers as Record<string, string>;
    expect(headers.Authorization).toBe("Token org-byok-key");
  });
});

describe("limiterForKey", () => {
  test("même instance pour le même orgId, une différente pour un autre", () => {
    const a1 = limiterForKey("org-a");
    const a2 = limiterForKey("org-a");
    const b = limiterForKey("org-b");
    expect(a1).toBe(a2);
    expect(a1).not.toBe(b);
  });
});
