import net from "node:net";
import { platform } from "node:os";
import { spawnCapture } from "./test-utils";

const IS_WINDOWS = platform() === "win32";

let server: net.Server;
let url: string;
let streamingUrl: string;
let echoUrl: string;

const { LLRT_LOG, ...TEST_ENV } = process.env;

const spawnAndCollectOutput = async (
  deniedUrl: URL,
  env: Record<string, string>
) => {
  const { stdout, stderr } = await spawnCapture(
    process.argv[0],
    [
      "-e",
      `fetch("${deniedUrl}").catch(console.error).then(() => fetch("${url}")).then(() => console.log("OK"))`,
    ],
    { env: { ...TEST_ENV, ...env } }
  );
  return { stdout, stderr };
};

beforeAll((done) => {
  server = net.createServer((socket) => {
    let requestData = "";
    socket.on("error", () => {}); //ignore errors as abort signals might cancel the socket
    socket.on("data", (data) => {
      requestData += data.toString();

      if (requestData.includes("POST /echo")) {
        // For chunked requests, wait for the final chunk marker
        if (requestData.toLowerCase().includes("transfer-encoding: chunked")) {
          if (!requestData.includes("0\r\n\r\n")) return; // Wait for more data
          // Extract body from chunked encoding
          const bodyStart = requestData.indexOf("\r\n\r\n") + 4;
          const chunkedBody = requestData.slice(bodyStart);
          let body = "";
          let pos = 0;
          while (pos < chunkedBody.length) {
            const sizeEnd = chunkedBody.indexOf("\r\n", pos);
            const size = parseInt(chunkedBody.slice(pos, sizeEnd), 16);
            if (size === 0) break;
            pos = sizeEnd + 2;
            body += chunkedBody.slice(pos, pos + size);
            pos += size + 2;
          }
          socket.write(
            `HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: ${body.length}\r\n\r\n${body}`
          );
        } else {
          // Non-chunked POST - extract Content-Length body
          const match = requestData.match(/Content-Length: (\d+)/i);
          if (match) {
            const contentLength = parseInt(match[1]);
            const bodyStart = requestData.indexOf("\r\n\r\n") + 4;
            if (requestData.length < bodyStart + contentLength) return; // Wait for more data
            const body = requestData.slice(
              bodyStart,
              bodyStart + contentLength
            );
            socket.write(
              `HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: ${body.length}\r\n\r\n${body}`
            );
          }
        }
        socket.end();
      } else if (requestData.includes("GET /streaming")) {
        // Chunked transfer encoding for streaming
        socket.write(
          "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nTransfer-Encoding: chunked\r\n\r\n"
        );
        socket.write("5\r\nchunk\r\n");
        socket.write("4\r\ndata\r\n");
        socket.write("4\r\ntest\r\n");
        socket.write("0\r\n\r\n");
        socket.end();
      } else if (requestData.includes("\r\n\r\n")) {
        socket.write(
          "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n<html></html>"
        );
        socket.end();
      }
    });
  });

  server.listen(() => {
    const { address, port } = server.address()! as any as net.AddressInfo;
    const host = IS_WINDOWS ? "localhost" : address;
    url = `http://${host}:${port}`;
    streamingUrl = `http://${host}:${port}/streaming`;
    echoUrl = `http://${host}:${port}/echo`;
    done();
  });
});

afterAll(() => {
  server?.close();
});

describe("fetch", () => {
  it("should fetch a website", async () => {
    const res = await fetch(url);

    expect(res.status).toEqual(200);
    expect(
      res.headers.get("content-type")?.startsWith("text/html")
    ).toBeTruthy();
  });

  it("should fetch a website with url and options", async () => {
    const options = {
      method: "GET",
      url,
    };

    const request = new Request(url);

    const res = await fetch(request, options);
    expect(res.status).toEqual(200);
    expect(
      res.headers.get("content-type")?.startsWith("text/html")
    ).toBeTruthy();
  });

  it("should fetch a website with different resource options", async () => {
    let res = await fetch(new Request(url));
    expect(res.status).toEqual(200);
    expect(
      res.headers.get("content-type")?.startsWith("text/html")
    ).toBeTruthy();

    res = await fetch(new URL(url));
    expect(res.status).toEqual(200);
    expect(
      res.headers.get("content-type")?.startsWith("text/html")
    ).toBeTruthy();

    res = await fetch("", { url } as any);
    expect(res.status).toEqual(200);
    expect(
      res.headers.get("content-type")?.startsWith("text/html")
    ).toBeTruthy();
  });

  it("should fetch a website in parallel", async () => {
    await Promise.all(new Array(10).fill(0).map(() => fetch(url)));
  });

  // Regression test for https://github.com/awslabs/llrt/issues/1482:
  // a connection failure must reject with exactly `TypeError: Failed to fetch`
  // so the AWS SDK retry middleware classifies it as a transient error.
  it("should reject connection failures with TypeError: Failed to fetch", async () => {
    // Port 65000 is unused, so the connection is refused immediately.
    await expect(fetch("http://127.0.0.1:65000")).rejects.toThrow(
      new TypeError("Failed to fetch")
    );
    try {
      await fetch("http://127.0.0.1:65000");
    } catch (err: any) {
      expect(err).toBeInstanceOf(TypeError);
      expect(err.message).toEqual("Failed to fetch");
      expect(err.cause).toBeDefined(); // underlying error preserved for diagnostics
    }
  });

  it("is not allowed to fetch", async () => {
    const deniedUrl = new URL("https://www.amazon.com");
    const { stdout, stderr } = await spawnAndCollectOutput(deniedUrl, {
      LLRT_NET_DENY: "amazon.com",
    });

    expect(stderr.trim()).toEqual(`Error: URL denied: ${deniedUrl.hostname}`);
    expect(stdout.trim()).toEqual("OK");
  });

  it("is only allowed to fetch", async () => {
    const deniedUrl = new URL("https://www.amazon.com");
    const { stdout, stderr } = await spawnAndCollectOutput(deniedUrl, {
      LLRT_NET_ALLOW: url,
    });

    expect(stderr.trim()).toEqual(
      `Error: URL not allowed: ${deniedUrl.hostname}`
    );
    expect(stdout.trim()).toEqual("OK");
  });

  it("should be abortable using signals", async () => {
    const abortController = new AbortController();
    const res = fetch(url, { signal: abortController.signal });
    abortController.abort();
    try {
      await res;
    } catch (err: any) {
      expect(err.name).toBe("AbortError");
    }
  });
  it("should be abortable using request signal", async () => {
    const abortController = new AbortController();
    const req = new Request(url, { signal: abortController.signal });
    abortController.abort("aborted");
    try {
      await fetch(req);
    } catch (err: any) {
      expect(abortController.signal.reason).toBe("aborted");
    }
  });
  it("should be processing data-url", async () => {
    const s = "hello";
    const base64 = Buffer.from(s).toString("base64");
    const dataURIPrefix = "data:application/octet-stream;base64,";
    const url = dataURIPrefix + base64;
    const resp = await fetch(url);
    const buf = await resp.arrayBuffer();
    const str = Buffer.from(buf).toString("ascii");
    expect(str).toEqual(s);
  });

  it("should stream response body using getReader", async () => {
    const res = await fetch(streamingUrl);
    const reader = res.body!.getReader();
    const chunks: string[] = [];

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      chunks.push(new TextDecoder().decode(value));
    }

    expect(chunks.join("")).toEqual("chunkdatatest");
  });

  it("should stream response body using for-await", async () => {
    const res = await fetch(streamingUrl);
    const chunks: string[] = [];

    for await (const chunk of res.body!) {
      chunks.push(new TextDecoder().decode(chunk));
    }

    expect(chunks.join("")).toEqual("chunkdatatest");
  });

  it("should cancel stream with reader.cancel()", async () => {
    const res = await fetch(streamingUrl);
    const reader = res.body!.getReader();

    const { value } = await reader.read();
    const text = new TextDecoder().decode(value);
    expect(text.startsWith("chunk")).toBe(true);

    await reader.cancel();
    const { done } = await reader.read();
    expect(done).toBe(true);
  });

  it("should send POST request with body", async () => {
    const res = await fetch(echoUrl, {
      method: "POST",
      body: "hello world",
    });

    const text = await res.text();
    expect(text).toEqual("hello world");
  });

  it("should send streaming request body using ReadableStream", async () => {
    const chunks = ["hello", " ", "world"];
    let index = 0;
    const stream = new ReadableStream({
      pull(controller) {
        if (index < chunks.length) {
          controller.enqueue(new TextEncoder().encode(chunks[index++]));
        } else {
          controller.close();
        }
      },
    });

    const res = await fetch(echoUrl, {
      method: "POST",
      body: stream,
      duplex: "half",
    });

    const text = await res.text();
    expect(text).toEqual("hello world");
  });

  it("should reject streaming body with non-Uint8Array chunks", async () => {
    const stream = new ReadableStream({
      start(controller) {
        controller.enqueue("string chunk"); // Invalid - must be Uint8Array per spec
        controller.close();
      },
    });

    await expect(
      fetch(echoUrl, {
        method: "POST",
        body: stream,
        duplex: "half",
      })
    ).rejects.toThrow(/Uint8Array/);
  });

  it("should send large streaming request body", async () => {
    const chunkCount = 100;
    const chunkSize = 1024;
    let sentChunks = 0;

    const stream = new ReadableStream({
      pull(controller) {
        if (sentChunks < chunkCount) {
          const chunk = new Uint8Array(chunkSize).fill(65 + (sentChunks % 26));
          controller.enqueue(chunk);
          sentChunks++;
        } else {
          controller.close();
        }
      },
    });

    const res = await fetch(echoUrl, {
      method: "POST",
      body: stream,
      duplex: "half",
    });

    const data = await res.arrayBuffer();
    expect(data.byteLength).toEqual(chunkCount * chunkSize);
  });

  it("should send Blob as request body", async () => {
    const blob = new Blob(["blob", " ", "content"], { type: "text/plain" });

    const res = await fetch(echoUrl, {
      method: "POST",
      body: blob,
    });

    const text = await res.text();
    expect(text).toEqual("blob content");
  });

  it("should send ArrayBuffer as request body", async () => {
    const encoder = new TextEncoder();
    const buffer = encoder.encode("arraybuffer data").buffer;

    const res = await fetch(echoUrl, {
      method: "POST",
      body: buffer,
    });

    const text = await res.text();
    expect(text).toEqual("arraybuffer data");
  });

  it("should send Uint8Array as request body", async () => {
    const data = new Uint8Array([104, 101, 108, 108, 111]); // "hello"

    const res = await fetch(echoUrl, {
      method: "POST",
      body: data,
    });

    const text = await res.text();
    expect(text).toEqual("hello");
  });

  it("should handle empty POST body", async () => {
    const res = await fetch(url, {
      method: "POST",
    });

    expect(res.status).toEqual(200);
  });

  it("should read response body as arrayBuffer", async () => {
    const res = await fetch(streamingUrl);
    const buffer = await res.arrayBuffer();
    const text = new TextDecoder().decode(buffer);
    expect(text).toEqual("chunkdatatest");
  });

  it("should read response body as bytes", async () => {
    const res = await fetch(streamingUrl);
    const bytes = await res.bytes();
    const text = new TextDecoder().decode(bytes);
    expect(text).toEqual("chunkdatatest");
  });

  it("should handle response.body being null for empty responses", async () => {
    const res = await fetch(url);
    expect(res.body).not.toBeNull();
  });

  it("should not allow reading body twice", async () => {
    const res = await fetch(streamingUrl);
    await res.text();

    expect(res.bodyUsed).toBe(true);
    await expect(res.text()).rejects.toThrow();
  });

  it("should clone response and read both bodies", async () => {
    const res = await fetch("data:text/plain,hello%20world");
    const cloned = res.clone();

    const text1 = await res.text();
    const text2 = await cloned.text();

    expect(text1).toEqual("hello world");
    expect(text2).toEqual("hello world");
  });

  it("should tee response body stream", async () => {
    const res = await fetch(streamingUrl);
    const [stream1, stream2] = res.body!.tee();

    const reader1 = stream1.getReader();
    const reader2 = stream2.getReader();

    let text1 = "";
    let text2 = "";

    while (true) {
      const { done, value } = await reader1.read();
      if (done) break;
      text1 += new TextDecoder().decode(value);
    }

    while (true) {
      const { done, value } = await reader2.read();
      if (done) break;
      text2 += new TextDecoder().decode(value);
    }

    expect(text1).toEqual("chunkdatatest");
    expect(text2).toEqual("chunkdatatest");
  });

  it("should tee and read streams at different rates", async () => {
    const res = await fetch(streamingUrl);
    const [stream1, stream2] = res.body!.tee();

    // Read all of stream1 first
    const reader1 = stream1.getReader();
    let text1 = "";
    while (true) {
      const { done, value } = await reader1.read();
      if (done) break;
      text1 += new TextDecoder().decode(value);
    }

    // Then read all of stream2
    const reader2 = stream2.getReader();
    let text2 = "";
    while (true) {
      const { done, value } = await reader2.read();
      if (done) break;
      text2 += new TextDecoder().decode(value);
    }

    expect(text1).toEqual(text2);
  });

  // ── Streaming request body edge cases ──

  it("should handle ReadableStream that closes immediately as request body", async () => {
    const stream = new ReadableStream({
      start(controller) {
        controller.close();
      },
    });

    // LLRT's HTTP client may error on empty streams - this documents current behavior
    try {
      const res = await fetch(echoUrl, {
        method: "POST",
        body: stream,
        duplex: "half",
      });
      expect(res.status).toEqual(200);
    } catch (err: any) {
      // Empty stream may cause client error - acceptable limitation
      expect(err).toBeDefined();
    }
  });

  it("should handle ReadableStream that errors as request body", async () => {
    const stream = new ReadableStream({
      start(controller) {
        controller.error(new Error("stream error"));
      },
    });

    try {
      const res = await fetch(echoUrl, {
        method: "POST",
        body: stream,
        duplex: "half",
      });
      await res.text();
      expect(true).toBe(false); // Should not reach here
    } catch (err: any) {
      expect(err).toBeDefined();
      // Error could be the stream error or a connection error depending on timing
      expect(err.message).toBeDefined();
    }
  });

  it("should handle single-chunk ReadableStream as request body", async () => {
    const stream = new ReadableStream({
      start(controller) {
        controller.enqueue(new TextEncoder().encode("single"));
        controller.close();
      },
    });

    const res = await fetch(echoUrl, {
      method: "POST",
      body: stream,
      duplex: "half",
    });

    expect(await res.text()).toEqual("single");
  });

  // ── Response body consumption edge cases ──

  it("should not allow reading body twice on fetched response", async () => {
    const res = await fetch(echoUrl, {
      method: "POST",
      body: "once",
    });
    await res.text();
    expect(res.bodyUsed).toBe(true);
    await expect(res.arrayBuffer()).rejects.toThrow();
  });

  it("should clone fetched response and read both bodies", async () => {
    const res = await fetch(echoUrl, {
      method: "POST",
      body: "clone me",
    });
    const res2 = res.clone();
    const text1 = await res.text();
    const text2 = await res2.text();
    expect(text1).toEqual("clone me");
    expect(text2).toEqual("clone me");
  });

  it("should read fetched response body as different types", async () => {
    const res = await fetch(echoUrl, {
      method: "POST",
      body: "typed",
    });
    const buf = await res.arrayBuffer();
    expect(new TextDecoder().decode(buf)).toEqual("typed");
  });

  it("should read fetched response body as bytes", async () => {
    const res = await fetch(echoUrl, {
      method: "POST",
      body: "bytes",
    });
    const bytes = await res.bytes();
    expect(new TextDecoder().decode(bytes)).toEqual("bytes");
  });

  it("should read fetched response body as blob", async () => {
    const res = await fetch(echoUrl, {
      method: "POST",
      body: "blobby",
    });
    const blob = await res.blob();
    expect(await blob.text()).toEqual("blobby");
  });
});
