import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createGhHttp } from "./gh-http";
import { createGhciAdapter } from "./ghci";

const FIXTURE_DIR = join(import.meta.dir, "../../test/fixtures/ghci");
const fixture = (name: string): string => readFileSync(join(FIXTURE_DIR, name), "utf8");

const TOKEN = "test-token-value";

interface Recorded {
  url: string;
  method: string;
  headers: Record<string, string>;
}

function headerMap(init: RequestInit | undefined): Record<string, string> {
  const raw = (init?.headers ?? {}) as Record<string, string>;
  return Object.fromEntries(Object.entries(raw).map(([key, value]) => [key.toLowerCase(), value]));
}

function jsonResponse(body: string, init: ResponseInit = {}): Response {
  return new Response(body, { status: 200, headers: { "content-type": "application/json" }, ...init });
}

function recordingFetcher(
  handler: (url: string, init: RequestInit | undefined) => Response,
): { fetcher: typeof fetch; calls: Recorded[] } {
  const calls: Recorded[] = [];
  const fetcher = (async (input: any, init?: RequestInit) => {
    const url = String(input);
    calls.push({ url, method: init?.method ?? "GET", headers: headerMap(init) });
    return handler(url, init);
  }) as unknown as typeof fetch;
  return { fetcher, calls };
}

function client(handler: (url: string, init: RequestInit | undefined) => Response) {
  const { fetcher, calls } = recordingFetcher(handler);
  return { http: createGhHttp({ fetcher, resolveToken: async () => TOKEN }), calls };
}

describe("gh-http request translation", () => {
  test("maps an api path to the REST base with versioned auth headers", async () => {
    const { http, calls } = client(() => jsonResponse('{"total_count":0,"workflow_runs":[]}'));
    const body = await http.run(["api", "/repos/o/r/actions/runs?per_page=100"]);

    expect(body).toBe('{"total_count":0,"workflow_runs":[]}');
    expect(calls[0]!.url).toBe("https://api.github.com/repos/o/r/actions/runs?per_page=100");
    expect(calls[0]!.method).toBe("GET");
    expect(calls[0]!.headers.accept).toBe("application/vnd.github+json");
    expect(calls[0]!.headers["x-github-api-version"]).toBe("2022-11-28");
    expect(calls[0]!.headers.authorization).toBe(`Bearer ${TOKEN}`);
  });

  test("posts graphql with string and typed variables", async () => {
    let payload = "";
    const { http, calls } = client((_url, init) => {
      payload = String(init?.body);
      return jsonResponse('{"data":{}}');
    });
    await http.run([
      "api",
      "graphql",
      "-f",
      "query=query Q($owner:String!){x}",
      "-F",
      "owner=alexcodeplace",
      "-F",
      "first=25",
      "-F",
      "flag=true",
    ]);

    expect(calls[0]!.url).toBe("https://api.github.com/graphql");
    expect(calls[0]!.method).toBe("POST");
    expect(JSON.parse(payload)).toEqual({
      query: "query Q($owner:String!){x}",
      variables: { owner: "alexcodeplace", first: 25, flag: true },
    });
  });

  test("rejects argv shapes the collector never issues", async () => {
    const { http } = client(() => jsonResponse("{}"));
    await expect(http.run(["run", "list"])).rejects.toThrow("unsupported gh invocation");
    await expect(http.run(["api", "/x", "--paginate"])).rejects.toThrow("unsupported gh api invocation");
    await expect(http.run(["api", "graphql", "-F", "owner=o"])).rejects.toThrow("gh graphql call without query");
  });
});

describe("gh-http failures", () => {
  test("throws a status-bearing error that the runner 404 probe matches", async () => {
    const { http } = client(() => new Response("Not Found", { status: 404 }));
    await expect(http.run(["api", "/repos/o/r/actions/runners?per_page=100"]))
      .rejects.toThrow(/\b404\b/);
  });

  test("never leaks the token into the error message", async () => {
    const { http } = client(() => new Response(`denied ${TOKEN}`, { status: 403 }));
    const error = await http.run(["api", "/repos/o/r/actions/runs"]).catch((err: Error) => err);
    expect(String(error)).not.toContain(TOKEN);
    expect(String(error)).toContain("403");
  });

  test("re-resolves the token once after a 401 and retries", async () => {
    let resolved = 0;
    const seen: string[] = [];
    const { fetcher } = recordingFetcher(() => new Response("{}", { status: 200 }));
    const wrapped = (async (input: any, init?: RequestInit) => {
      seen.push(headerMap(init).authorization!);
      if (seen.length === 1) return new Response("unauthorized", { status: 401 });
      return fetcher(input, init);
    }) as unknown as typeof fetch;
    const http = createGhHttp({
      fetcher: wrapped,
      resolveToken: async () => {
        resolved += 1;
        return `${TOKEN}-${resolved}`;
      },
    });

    await http.run(["api", "/repos/o/r/actions/runs"]);
    expect(resolved).toBe(2);
    expect(seen).toEqual([`Bearer ${TOKEN}-1`, `Bearer ${TOKEN}-2`]);
  });
});

describe("gh-http conditional requests", () => {
  test("replays the cached body on 304 without re-reading the network payload", async () => {
    const body = '{"total_count":3,"workflow_runs":[]}';
    let requests = 0;
    const { http, calls } = client(() => {
      requests += 1;
      return requests === 1
        ? jsonResponse(body, { headers: { etag: 'W/"abc"' } })
        : new Response(null, { status: 304 });
    });

    const first = await http.run(["api", "/repos/o/r/actions/runs?per_page=100"]);
    const second = await http.run(["api", "/repos/o/r/actions/runs?per_page=100"]);

    expect(first).toBe(body);
    expect(second).toBe(body);
    expect(calls[0]!.headers["if-none-match"]).toBeUndefined();
    expect(calls[1]!.headers["if-none-match"]).toBe('W/"abc"');
  });

  test("caches per URL so a different query is not answered from another entry", async () => {
    const bodies = new Map([
      ["https://api.github.com/repos/o/r/actions/runs?per_page=100", '{"total_count":1,"workflow_runs":[]}'],
      ["https://api.github.com/repos/o/r/actions/runs?status=queued", '{"total_count":9,"workflow_runs":[]}'],
    ]);
    const { http, calls } = client((url) =>
      jsonResponse(bodies.get(url)!, { headers: { etag: `W/"${url.length}"` } }),
    );

    await http.run(["api", "/repos/o/r/actions/runs?per_page=100"]);
    const queued = await http.run(["api", "/repos/o/r/actions/runs?status=queued"]);

    expect(JSON.parse(queued).total_count).toBe(9);
    expect(calls[1]!.headers["if-none-match"]).toBeUndefined();
  });

  test("omits if-none-match once an entry is evicted by the cache bound", async () => {
    const { http, calls } = client((url) => jsonResponse("{}", { headers: { etag: `W/"${url}"` } }));
    await http.run(["api", "/repos/o/r/actions/runs?page=0"]);
    for (let index = 1; index <= 128; index += 1) {
      await http.run(["api", `/repos/o/r/actions/runs?page=${index}`]);
    }
    await http.run(["api", "/repos/o/r/actions/runs?page=0"]);

    expect(calls.at(-1)!.headers["if-none-match"]).toBeUndefined();
  });

  test("does not cache a body above the size bound", async () => {
    const huge = `{"pad":"${"x".repeat(4_000_001)}"}`;
    const { http, calls } = client(() => jsonResponse(huge, { headers: { etag: 'W/"big"' } }));
    await http.run(["api", "/repos/o/r/actions/runs"]);
    await http.run(["api", "/repos/o/r/actions/runs"]);

    expect(calls[1]!.headers["if-none-match"]).toBeUndefined();
  });
});

describe("gh-http log tail", () => {
  test("follows the log redirect without forwarding credentials and keeps the tail bytes", async () => {
    const blob = "https://blob.example/logs/1";
    const { fetcher, calls } = recordingFetcher((url) =>
      url === blob
        ? new Response("0123456789")
        : new Response(null, { status: 302, headers: { location: blob } }),
    );
    const http = createGhHttp({ fetcher, resolveToken: async () => TOKEN });

    const tail = await http.logTail(["api", "/repos/o/r/actions/jobs/7/logs"], 4);

    expect(new TextDecoder().decode(tail)).toBe("6789");
    expect(calls[0]!.headers.authorization).toBe(`Bearer ${TOKEN}`);
    expect(calls[1]!.url).toBe(blob);
    expect(calls[1]!.headers.authorization).toBeUndefined();
  });

  test("throws on a failing log request", async () => {
    const { fetcher } = recordingFetcher(() => new Response("gone", { status: 410 }));
    const http = createGhHttp({ fetcher, resolveToken: async () => TOKEN });
    await expect(http.logTail(["api", "/repos/o/r/actions/jobs/7/logs"], 16)).rejects.toThrow(/\b410\b/);
  });
});

describe("ghci adapter over gh-http", () => {
  const HISTORY = fixture("snapshot-history.json");
  const RUNNERS = fixture("snapshot-runners.json");
  const OPEN = fixture("snapshot-open.json");
  const TRAIN_RUNS = fixture("snapshot-train-runs.json");
  const JOBS = fixture("snapshot-jobs.json");

  function restBody(url: string): string {
    if (url.includes("actions/runs?per_page=100")) return HISTORY;
    if (url.includes("status=queued")) return '{"total_count":2,"workflow_runs":[]}';
    if (url.includes("status=in_progress")) return '{"total_count":1,"workflow_runs":[]}';
    if (url.includes("actions/runners?per_page=100")) return RUNNERS;
    if (url.includes("actions/runs?branch=")) return TRAIN_RUNS;
    if (url.includes("/jobs?filter=latest")) return JOBS;
    return '{"total_count":0,"workflow_runs":[]}';
  }

  test("keeps completeness flags true when every conditional request answers 304", async () => {
    const served = new Set<string>();
    let notModified = 0;
    const { fetcher, calls } = recordingFetcher((url, init) => {
      if (init?.method === "POST") return jsonResponse(OPEN);
      if (served.has(url)) {
        notModified += 1;
        return new Response(null, { status: 304 });
      }
      served.add(url);
      return jsonResponse(restBody(url), { headers: { etag: `W/"${url.length}"` } });
    });
    const adapter = createGhciAdapter({
      repos: ["alexcodeplace/multideal"],
      runGh: createGhHttp({ fetcher, resolveToken: async () => TOKEN }).run,
      runHistoryStore: {},
    });

    const first = (await adapter.poll()).panels[0]!.data as any;
    const second = (await adapter.poll()).panels[0]!.data as any;

    expect(notModified).toBeGreaterThan(0);
    expect(calls.some((call) => call.headers["if-none-match"] !== undefined)).toBe(true);
    expect(second.repos[0].queueComplete).toBe(true);
    expect(second.repos[0].queueDepth).toBe(first.repos[0].queueDepth);
    expect(second.repos[0].historyComplete).toBe(first.repos[0].historyComplete);
    expect(second.repos[0].runnersComplete).toBe(true);
    expect(second.repos[0].degraded).toEqual(first.repos[0].degraded);
    expect(second.repos[0].runs).toEqual(first.repos[0].runs);
  });
});
