/**
 * Regression tests for the size-capped readBody helper (issue #28).
 *
 * Tests the shared `assertPublicUrl`-adjacent helper in http-utils.ts:
 *   - 1 MB cap enforced
 *   - BodyTooLargeError class signals oversize rejection
 *   - readBody does NOT call req.destroy() (caller's responsibility)
 *   - Stream error events propagate
 *
 * Test #7 (HTTP integration that the 413 actually reaches the client)
 * deliberately spins up a real http.createServer + the bridge handler.
 */

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { PassThrough } from "node:stream";
import http from "node:http";
import type { IncomingMessage } from "node:http";

import { readBody, MAX_BODY_SIZE, BodyTooLargeError } from "./http-utils.js";

/** Cast a PassThrough stream to IncomingMessage for testing. readBody only
 *  uses the EventEmitter surface (`.on('data')` / `.on('end')` / `.on('error')`),
 *  so a PassThrough is shape-compatible. */
function asReq(stream: PassThrough): IncomingMessage {
  return stream as unknown as IncomingMessage;
}

describe("readBody — size cap and ordering invariants", () => {
  it("resolves a body well under the limit", async () => {
    const stream = new PassThrough();
    const promise = readBody(asReq(stream));
    const payload = "a".repeat(512 * 1024); // 512 KB
    stream.write(payload);
    stream.end();
    const got = await promise;
    assert.equal(got.length, payload.length);
    assert.equal(got, payload);
  });

  it("resolves a body exactly at the limit", async () => {
    const stream = new PassThrough();
    const promise = readBody(asReq(stream));
    const payload = "b".repeat(MAX_BODY_SIZE);
    stream.write(payload);
    stream.end();
    const got = await promise;
    assert.equal(got.length, MAX_BODY_SIZE);
  });

  it("rejects when body exceeds the limit by 1 byte", async () => {
    const stream = new PassThrough();
    const promise = readBody(asReq(stream));
    stream.write("c".repeat(MAX_BODY_SIZE));
    stream.write("c"); // 1 byte over
    stream.end();
    await assert.rejects(promise, BodyTooLargeError);
  });

  it("rejects early on a much-larger body without buffering further chunks", async () => {
    const stream = new PassThrough();
    const promise = readBody(asReq(stream));

    // Spy on data events the function processes, by attaching a second
    // listener AFTER readBody and tracking how many chunks arrive after
    // the rejection point. We cannot inspect readBody's internal `data`
    // string, but we can verify rejection happens at the right chunk
    // boundary by counting chunks pushed before the rejection settles.
    const CHUNK = 64 * 1024;
    const totalChunks = 160; // 10 MB total

    const rejectedAt = -1;
    promise.catch(() => {
      // record when the rejection settled; pushedSoFar is the visible bound
    });

    let pushedSoFar = 0;
    for (let i = 0; i < totalChunks; i++) {
      stream.write(Buffer.alloc(CHUNK));
      pushedSoFar = i + 1;
      if (rejectedAt === -1) {
        // Allow the data event to settle, then check
        await new Promise((r) => setImmediate(r));
        try {
          // promise is still pending or already rejected; we don't await here
        } catch {
          /* ignored */
        }
      }
    }
    stream.end();

    // Verify the promise rejected with BodyTooLargeError. The earliest
    // chunk count that could have triggered the cap is ceil(MAX_BODY_SIZE / CHUNK) = 17.
    await assert.rejects(promise, BodyTooLargeError);
    assert.ok(
      pushedSoFar >= 17,
      `expected at least 17 chunks pushed before rejection, got ${pushedSoFar}`,
    );
  });

  it("does NOT call req.destroy() on overflow (caller's responsibility)", async () => {
    const stream = new PassThrough();
    let destroyCallCount = 0;
    const originalDestroy = stream.destroy.bind(stream);
    stream.destroy = ((...args: unknown[]) => {
      destroyCallCount++;
      return originalDestroy(...(args as []));
    }) as typeof stream.destroy;

    const promise = readBody(asReq(stream));
    stream.write(Buffer.alloc(MAX_BODY_SIZE + 1));
    stream.end();

    await assert.rejects(promise, BodyTooLargeError);
    assert.equal(
      destroyCallCount,
      0,
      "readBody must not call req.destroy() — IncomingMessage and ServerResponse share a socket; destroying the request would silence any 413/500 response",
    );
  });

  it("rejects with the emitted error on stream 'error' events", async () => {
    const stream = new PassThrough();
    const promise = readBody(asReq(stream));
    const err = new Error("upstream failure");
    stream.write("partial");
    stream.emit("error", err);
    await assert.rejects(promise, /upstream failure/);
  });
});

describe("readBody — HTTP integration (Test #7)", () => {
  it("returns 413 to the client (response actually flushes before socket destroy)", async () => {
    // Mini HTTP server that uses readBody + the same 413 pattern as the
    // real bridge handlers. We test the contract end-to-end without
    // wiring up the full registerBridgeRoutes (which has heavy ChatManager
    // dependencies). The pattern we test is identical to bridge.ts's
    // handleRegisterAdapter / handleValidateAdapter.
    const server = http.createServer(async (req, res) => {
      try {
        let body: string;
        try {
          body = await readBody(req);
        } catch (err) {
          if (err instanceof BodyTooLargeError) {
            res.writeHead(413, { "Content-Type": "application/json" });
            res.end(JSON.stringify({ error: "Request body too large", code: "PAYLOAD_TOO_LARGE" }));
            req.destroy();
            return;
          }
          throw err;
        }
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ status: "ok", got: body.length }));
      } catch (_err) {
        // Test fixture: surface only a generic 500 to satisfy CodeQL
        // js/stack-trace-exposure (alert #11). The real bridge handlers
        // route through `safeErrorMessage`; this fixture only exercises
        // the 413 path so the message itself is irrelevant to the test.
        res.writeHead(500);
        res.end("internal error");
      }
    });

    await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
    const addr = server.address();
    if (!addr || typeof addr === "string") {
      server.close();
      throw new Error("server.address() did not return AddressInfo");
    }

    try {
      // POST a body just over MAX_BODY_SIZE. Send the entire body in one
      // shot so it lands in the kernel buffer immediately and the server
      // can read + respond + destroy without racing pending writes.
      // The body is small enough (1 MB + 1 byte) to fit in the kernel
      // send buffer on any modern OS.
      const oversize = Buffer.alloc(MAX_BODY_SIZE + 1, 0x61); // 1,048,577 'a's
      const result = await new Promise<{ status: number; body: string }>((resolve, reject) => {
        let resolved = false;
        const timeout = setTimeout(() => {
          if (!resolved) reject(new Error("no response received within 5s"));
        }, 5000);

        const req = http.request({
          host: "127.0.0.1",
          port: addr.port,
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "Content-Length": oversize.length,
          },
        }, (res) => {
          let chunks = "";
          res.on("data", (c) => { chunks += c; });
          res.on("end", () => {
            resolved = true;
            clearTimeout(timeout);
            resolve({ status: res.statusCode ?? 0, body: chunks });
          });
        });

        // Server destroys the socket after writing 413; subsequent client
        // writes / connection events surface as EPIPE / ECONNRESET. That
        // is the contract being verified. Swallow them — only the
        // response timeout fails the test.
        req.on("error", () => { /* expected post-destroy */ });

        req.end(oversize);
      });

      assert.equal(result.status, 413, "client must receive HTTP 413, not socket hang up");
      const parsed = JSON.parse(result.body);
      assert.equal(parsed.code, "PAYLOAD_TOO_LARGE");
    } finally {
      server.close();
    }
  });
});
