import { afterEach, expect, test } from "bun:test";
import { spawn, spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { deriveGiteaWebhookAttemptBudgetMs, deriveGiteaWebhookResponseHeaderTimeoutMs, renderGiteaWebhookCaddyHandle, validateGiteaWebhookTiming } from "./platform-infra-gitea-caddy";

const caddyBin = process.env.CADDY_BIN;
const integrationTest = caddyBin ? test : test.skip;
const cleanup: Array<() => void | Promise<void>> = [];

afterEach(async () => {
  while (cleanup.length > 0) await cleanup.pop()?.();
});

test("one YAML response budget owns Caddy retry and bridge cannot reset it", () => {
  const timing = { maxRetries: 1, tryIntervalMs: 250, dialTimeoutMs: 500, writeTimeoutMs: 500 };
  expect(() => validateGiteaWebhookTiming(6500, timing)).not.toThrow();
  expect(() => validateGiteaWebhookTiming(9000, timing)).toThrow(/below 9000/);
  expect(() => validateGiteaWebhookTiming(6500, { ...timing, maxRetries: 2 })).toThrow(/maxRetries must be 1/);
  expect(deriveGiteaWebhookAttemptBudgetMs(6500, { maxRetries: 1, tryIntervalMs: 250 })).toBe(3125);
  expect(deriveGiteaWebhookResponseHeaderTimeoutMs(6500, timing)).toBe(2125);
  const route = renderGiteaWebhookCaddyHandle(
    { publicPath: "/hook", remotePort: 8080 },
    { enabled: true, maxBodyBytes: 64, ...timing, retryStatusCodes: [502, 503, 504] },
    6500,
    600,
  );
  expect(route).toContain("lb_try_duration 6500ms");
  expect(route).toContain("dial_timeout 500ms");
  expect(route).toContain("write_timeout 500ms");
  expect(route).toContain("response_header_timeout 2125ms");
  expect(route).toContain("request_buffers 65");
  const disabled = renderGiteaWebhookCaddyHandle(
    { publicPath: "/hook", remotePort: 8080 },
    { enabled: false, maxBodyBytes: 64, ...timing, retryStatusCodes: [502, 503, 504] },
    6500,
    600,
  );
  expect(disabled).toContain("response_header_timeout 600s");
  expect(disabled).not.toContain("lb_retries");
});

integrationTest("Caddy replays exact POST bytes for retryable responses and rejects oversized bodies with 413", async () => {
  const version = spawnSync(caddyBin!, ["version"], { encoding: "utf8" });
  expect(`${version.stdout}${version.stderr}`).toMatch(/v2\.11\./);

  const backendPort = await freePort();
  const caddyPort = await freePort();
  let mode: "retry-once" | "client-error" | "exhaust" | "slow-budget" = "retry-once";
  let hits = 0;
  let bodies: Buffer[] = [];
  const backendTimers = new Set<ReturnType<typeof setTimeout>>();
  const backendHandler = (req: IncomingMessage, res: ServerResponse) => {
    const chunks: Buffer[] = [];
    req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
    req.on("end", () => {
      hits += 1;
      const body = Buffer.concat(chunks);
      bodies.push(body);
      if (mode === "slow-budget" && hits === 1) {
        const timer = setTimeout(() => {
          backendTimers.delete(timer);
          res.writeHead(503).end("slow-retry");
        }, 180);
        backendTimers.add(timer);
      } else if (mode === "slow-budget") {
        const timer = setTimeout(() => {
          backendTimers.delete(timer);
          if (!res.destroyed) res.writeHead(200).end("too-late");
        }, 600);
        backendTimers.add(timer);
      } else if (mode === "retry-once" && hits === 1) {
        res.writeHead(503).end("retry");
      } else if (mode === "client-error") {
        res.writeHead(422).end("invalid");
      } else if (mode === "exhaust") {
        res.writeHead(503).end("unavailable");
      } else {
        res.writeHead(200, { "content-type": "application/octet-stream" }).end(body);
      }
    });
  };
  let backend = createServer(backendHandler);
  await listen(backend, backendPort);
  cleanup.push(() => close(backend));

  const root = mkdtempSync(join(tmpdir(), "unidesk-caddy-retry-test-"));
  cleanup.push(() => rmSync(root, { recursive: true, force: true }));
  const route = renderGiteaWebhookCaddyHandle(
    { publicPath: "/hook", remotePort: backendPort },
    { enabled: true, maxBodyBytes: 64, maxRetries: 1, tryIntervalMs: 50, dialTimeoutMs: 20, writeTimeoutMs: 20, retryStatusCodes: [502, 503, 504] },
    500,
    600,
  );
  const caddyfile = join(root, "Caddyfile");
  writeFileSync(caddyfile, `{
  admin off
  auto_https off
}
http://127.0.0.1:${caddyPort} {
${route}
}
`);
  const caddy = spawn(caddyBin!, ["run", "--config", caddyfile, "--adapter", "caddyfile"], { stdio: ["ignore", "pipe", "pipe"] });
  const stderr: Buffer[] = [];
  caddy.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
  cleanup.push(async () => {
    caddy.kill("SIGTERM");
    await Promise.race([new Promise((resolve) => caddy.once("exit", resolve)), new Promise((resolve) => setTimeout(resolve, 1000))]);
  });
  await waitForCaddy(caddyPort, stderr);

  const exactBody = Buffer.from(Array.from({ length: 64 }, (_, index) => index));
  let response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
  expect(response.status).toBe(200);
  expect(Buffer.from(await response.arrayBuffer())).toEqual(exactBody);
  expect(hits).toBe(2);
  expect(bodies).toEqual([exactBody, exactBody]);

  mode = "client-error";
  hits = 0;
  bodies = [];
  response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
  expect(response.status).toBe(422);
  expect(hits).toBe(1);

  mode = "exhaust";
  hits = 0;
  bodies = [];
  response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
  expect(response.status).toBe(503);
  expect(hits).toBe(2);
  expect(bodies).toEqual([exactBody, exactBody]);

  hits = 0;
  bodies = [];
  response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: Buffer.alloc(65, 0x5a) });
  expect(response.status).toBe(413);
  expect(hits).toBe(0);

  mode = "retry-once";
  hits = 1;
  bodies = [];
  await close(backend);
  const recoveringRequest = fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
  await new Promise((resolve) => setTimeout(resolve, 25));
  backend = createServer(backendHandler);
  await listen(backend, backendPort);
  response = await recoveringRequest;
  expect(response.status).toBe(200);
  expect(Buffer.from(await response.arrayBuffer())).toEqual(exactBody);
  expect(bodies).toEqual([exactBody]);

  mode = "slow-budget";
  hits = 0;
  bodies = [];
  const startedAt = performance.now();
  response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
  const elapsedMs = performance.now() - startedAt;
  expect([502, 504]).toContain(response.status);
  expect(hits).toBe(2);
  expect(elapsedMs).toBeGreaterThanOrEqual(350);
  expect(elapsedMs).toBeLessThan(700);
  for (const timer of backendTimers) clearTimeout(timer);
  backendTimers.clear();

  await close(backend);
  hits = 0;
  const unavailableStartedAt = performance.now();
  response = await fetch(`http://127.0.0.1:${caddyPort}/hook`, { method: "POST", body: exactBody });
  const unavailableElapsedMs = performance.now() - unavailableStartedAt;
  expect(response.status).toBe(502);
  expect(hits).toBe(0);
  expect(unavailableElapsedMs).toBeLessThan(700);
});

async function freePort(): Promise<number> {
  const server = createServer();
  await listen(server, 0);
  const address = server.address();
  const port = typeof address === "object" && address ? address.port : 0;
  await close(server);
  return port;
}

async function listen(server: Server, port: number): Promise<void> {
  await new Promise<void>((resolve, reject) => {
    server.once("error", reject);
    server.listen(port, "127.0.0.1", resolve);
  });
}

async function close(server: Server): Promise<void> {
  if (!server.listening) return;
  await new Promise<void>((resolve) => server.close(() => resolve()));
}

async function waitForCaddy(port: number, stderr: Buffer[]): Promise<void> {
  const deadline = Date.now() + 4000;
  while (Date.now() < deadline) {
    try {
      await fetch(`http://127.0.0.1:${port}/not-a-hook`);
      return;
    } catch {
      await new Promise((resolve) => setTimeout(resolve, 30));
    }
  }
  throw new Error(`Caddy did not start: ${Buffer.concat(stderr).toString("utf8")}`);
}
