import { expect, it } from "bun:test";
import { bunEnv, bunExe, tmpdirSync } from "harness";
import { join } from "node:path";
import tls from "node:tls";

type TLSOptions = {
  cert: string;
  key: string;
  passphrase?: string;
};

import { expiredTls, invalidTls, tls as validTls } from "harness";

const CERT_LOCALHOST_IP = { ...validTls };
const CERT_EXPIRED = { ...expiredTls };

// Note: Do not use bun.sh as the example domain
// Cloudflare sometimes blocks automated requests to it.
// so it will cause flaky tests.
async function createServer(cert: TLSOptions, callback: (port: number) => Promise<any>) {
  using server = Bun.serve({
    port: 0,
    tls: cert,
    fetch() {
      return new Response("Hello World");
    },
  });
  await callback(server.port);
}

it("can handle multiple requests with non native checkServerIdentity", async () => {
  async function request() {
    let called = false;
    const result = await fetch("https://www.example.com", {
      keepalive: false,
      tls: {
        checkServerIdentity(hostname: string, cert: tls.PeerCertificate) {
          called = true;
          return tls.checkServerIdentity(hostname, cert);
        },
      },
    }).then((res: Response) => res.blob());
    expect(result?.size).toBeGreaterThan(0);
    expect(called).toBe(true);
  }
  const promises = [];
  for (let i = 0; i < 5; i++) {
    promises.push(request());
  }
  await Promise.all(promises);
});

it("fetch with valid tls should not throw", async () => {
  const promises = [`https://example.com`, `https://www.example.com`].map(async url => {
    const result = await fetch(url, { keepalive: false }).then((res: Response) => res.blob());
    expect(result?.size).toBeGreaterThan(0);
  });

  await Promise.all(promises);
});

it("fetch with valid tls and non-native checkServerIdentity should work", async () => {
  for (const isBusy of [true, false]) {
    let count = 0;
    const promises = [`https://example.com`, `https://www.example.com`].map(async url => {
      await fetch(url, {
        keepalive: false,
        tls: {
          checkServerIdentity(hostname: string, cert: tls.PeerCertificate) {
            count++;
            expect(url).toContain(hostname);
            return tls.checkServerIdentity(hostname, cert);
          },
        },
      }).then((res: Response) => res.blob());
    });
    if (isBusy) {
      const start = performance.now();
      while (performance.now() - start < 500) {}
    }
    await Promise.all(promises);
    expect(count).toBe(2);
  }
});

it("fetch with valid tls and non-native checkServerIdentity should work", async () => {
  let count = 0;
  const promises = [`https://example.com`, `https://www.example.com`].map(async url => {
    await fetch(url, {
      keepalive: false,
      tls: {
        checkServerIdentity(hostname: string, cert: tls.PeerCertificate) {
          count++;
          expect(url).toContain(hostname);
          throw new Error("CustomError");
        },
      },
    });
  });
  const start = performance.now();
  while (performance.now() - start < 1000) {}
  expect((await Promise.allSettled(promises)).every(p => p.status === "rejected")).toBe(true);
  expect(count).toBe(2);
});

it("fetch with rejectUnauthorized: false should not call checkServerIdentity", async () => {
  let count = 0;

  await fetch("https://example.com", {
    keepalive: false,
    tls: {
      rejectUnauthorized: false,
      checkServerIdentity(hostname: string, cert: tls.PeerCertificate) {
        count++;
        return tls.checkServerIdentity(hostname, cert);
      },
    },
  }).then((res: Response) => res.blob());
  expect(count).toBe(0);
});

it("fetch with self-sign tls should throw", async () => {
  await createServer(CERT_LOCALHOST_IP, async port => {
    const urls = [`https://localhost:${port}`, `https://127.0.0.1:${port}`];
    await Promise.all(
      urls.map(async url => {
        try {
          await fetch(url).then((res: Response) => res.blob());
          expect.unreachable();
        } catch (e: any) {
          expect(e.code).toBe("DEPTH_ZERO_SELF_SIGNED_CERT");
        }
      }),
    );
  });
});

it("fetch with invalid tls should throw", async () => {
  await createServer(CERT_EXPIRED, async port => {
    await Promise.all(
      [`https://localhost:${port}`, `https://127.0.0.1:${port}`].map(async url => {
        try {
          await fetch(url).then((res: Response) => res.blob());
          expect.unreachable();
        } catch (e: any) {
          expect(e.code).toBe("CERT_HAS_EXPIRED");
        }
      }),
    );
  });
});

it("fetch with checkServerIdentity failing should throw", async () => {
  try {
    await fetch(`https://example.com`, {
      keepalive: false,
      tls: {
        checkServerIdentity() {
          return new Error("CustomError");
        },
      },
    }).then((res: Response) => res.blob());

    expect.unreachable();
  } catch (e: any) {
    expect(e.message).toBe("CustomError");
  }
});

it("fetch with self-sign certificate tls + rejectUnauthorized: false should not throw", async () => {
  await createServer(CERT_LOCALHOST_IP, async port => {
    const urls = [`https://localhost:${port}`, `https://127.0.0.1:${port}`];
    await Promise.all(
      urls.map(async url => {
        try {
          const result = await fetch(url, { tls: { rejectUnauthorized: false } }).then((res: Response) => res.text());
          expect(result).toBe("Hello World");
        } catch {
          expect.unreachable();
        }
      }),
    );
  });
});

it("fetch with invalid tls + rejectUnauthorized: false should not throw", async () => {
  await createServer(CERT_EXPIRED, async port => {
    const urls = [`https://localhost:${port}`, `https://127.0.0.1:${port}`];
    await Promise.all(
      urls.map(async url => {
        try {
          const result = await fetch(url, { tls: { rejectUnauthorized: false } }).then((res: Response) => res.text());
          expect(result).toBe("Hello World");
        } catch (e) {
          expect.unreachable();
        }
      }),
    );
  });
});

it("fetch should respect rejectUnauthorized env", async () => {
  await createServer(CERT_EXPIRED, async port => {
    const url = `https://localhost:${port}`;

    const promises = [];
    for (let i = 0; i < 2; i++) {
      const proc = Bun.spawn({
        env: {
          ...bunEnv,
          SERVER: url,
          NODE_TLS_REJECT_UNAUTHORIZED: i.toString(),
        },
        stderr: "inherit",
        stdout: "inherit",
        stdin: "inherit",
        cmd: [bunExe(), join(import.meta.dir, "fetch-reject-authorized-env-fixture.js")],
      });

      promises.push(proc.exited);
    }

    const [exitCode1, exitCode2] = await Promise.all(promises);
    expect(exitCode1).toBe(0);
    expect(exitCode2).toBe(1);
  });
});

it("fetch timeout works on tls", async () => {
  using server = Bun.serve({
    tls: validTls,
    hostname: "localhost",
    port: 0,
    rejectUnauthorized: false,
    async fetch() {
      async function* body() {
        yield "Hello, ";
        await Bun.sleep(700); // should only take 200ms-350ms
        yield "World!";
      }
      return new Response(body);
    },
  });
  const start = performance.now();
  const TIMEOUT = 200;
  const THRESHOLD = 150;

  try {
    await fetch(server.url, {
      signal: AbortSignal.timeout(TIMEOUT),
      tls: { ca: validTls.cert },
    }).then(res => res.text());
    expect.unreachable();
  } catch (e) {
    expect(e.name).toBe("TimeoutError");
  } finally {
    const total = performance.now() - start;
    expect(total).toBeGreaterThanOrEqual(TIMEOUT - THRESHOLD);
    expect(total).toBeLessThanOrEqual(TIMEOUT + THRESHOLD);
  }
});
for (const timeout of [0, 1, 10, 20, 100, 300]) {
  it(`fetch should abort as soon as possible under tls using AbortSignal.timeout(${timeout})`, async () => {
    using server = Bun.serve({
      port: 0,
      tls: CERT_LOCALHOST_IP,
      async fetch() {
        await Bun.sleep(1000);
        return new Response("Hello World");
      },
    });
    const THRESHOLD = 50;

    const time = performance.now();
    try {
      await fetch(server.url, {
        //@ts-ignore
        tls: { ca: CERT_LOCALHOST_IP.cert },
        signal: AbortSignal.timeout(timeout),
      }).then(res => res.text());
      expect.unreachable();
    } catch (err) {
      expect((err as Error).name).toBe("TimeoutError");
    } finally {
      const diff = performance.now() - time;
      expect(diff).toBeLessThanOrEqual(timeout + THRESHOLD);
      expect(diff).toBeGreaterThanOrEqual(timeout - THRESHOLD);
    }
  });
}

it("fetch should use NODE_EXTRA_CA_CERTS", async () => {
  using server = Bun.serve({
    port: 0,
    tls: validTls,
    fetch() {
      return new Response("OK");
    },
  });
  const cert_path = join(tmpdirSync(), "cert.pem");
  await Bun.write(cert_path, validTls.cert);

  const proc = Bun.spawn({
    env: {
      ...bunEnv,
      SERVER: server.url,
      NODE_EXTRA_CA_CERTS: cert_path,
    },
    stderr: "inherit",
    stdout: "inherit",
    stdin: "inherit",
    cmd: [bunExe(), join(import.meta.dir, "fetch.tls.extra-cert.fixture.js")],
  });

  expect(await proc.exited).toBe(0);
});

it("fetch should use NODE_EXTRA_CA_CERTS even if the used CA is not first in bundle", async () => {
  using server = Bun.serve({
    port: 0,
    tls: validTls,
    fetch() {
      return new Response("OK");
    },
  });

  const bundlePath = join(tmpdirSync(), "bundle.pem");
  const bundleContent = `${expiredTls.cert}\n${validTls.cert}`;
  await Bun.write(bundlePath, bundleContent);

  const proc = Bun.spawn({
    env: {
      ...bunEnv,
      SERVER: server.url,
      NODE_EXTRA_CA_CERTS: bundlePath,
    },
    stderr: "inherit",
    stdout: "inherit",
    stdin: "inherit",
    cmd: [bunExe(), join(import.meta.dir, "fetch.tls.extra-cert.fixture.js")],
  });

  expect(await proc.exited).toBe(0);
});

it("fetch should ignore invalid NODE_EXTRA_CA_CERTS", async () => {
  using server = Bun.serve({
    port: 0,
    tls: validTls,
    fetch() {
      return new Response("OK");
    },
  });

  for (const invalid of ["not-exist.pem", "", " "]) {
    const proc = Bun.spawn({
      env: {
        ...bunEnv,
        SERVER: server.url,
        NODE_EXTRA_CA_CERTS: invalid,
      },
      stderr: "pipe",
      stdout: "inherit",
      stdin: "inherit",
      cmd: [bunExe(), join(import.meta.dir, "fetch.tls.extra-cert.fixture.js")],
    });

    expect(await proc.exited).toBe(1);
    expect(await Bun.readableStreamToText(proc.stderr)).toContain("DEPTH_ZERO_SELF_SIGNED_CERT");
  }
});

it("fetch should ignore NODE_EXTRA_CA_CERTS if it's contains invalid cert", async () => {
  using server = Bun.serve({
    port: 0,
    tls: validTls,
    fetch() {
      return new Response("OK");
    },
  });

  const mixedValidAndInvalidCertsBundlePath = join(tmpdirSync(), "mixed-valid-and-invalid-certs-bundle.pem");
  await Bun.write(mixedValidAndInvalidCertsBundlePath, `${invalidTls.cert}\n${validTls.cert}`);

  const mixedInvalidAndValidCertsBundlePath = join(tmpdirSync(), "mixed-invalid-and-valid-certs-bundle.pem");
  await Bun.write(mixedInvalidAndValidCertsBundlePath, `${validTls.cert}\n${invalidTls.cert}`);

  for (const invalid of [mixedValidAndInvalidCertsBundlePath, mixedInvalidAndValidCertsBundlePath]) {
    const proc = Bun.spawn({
      env: {
        ...bunEnv,
        SERVER: server.url,
        NODE_EXTRA_CA_CERTS: invalid,
      },
      stderr: "pipe",
      stdout: "inherit",
      stdin: "inherit",
      cmd: [bunExe(), join(import.meta.dir, "fetch.tls.extra-cert.fixture.js")],
    });

    expect(await proc.exited).toBe(1);
    const stderr = await Bun.readableStreamToText(proc.stderr);
    expect(stderr).toContain("DEPTH_ZERO_SELF_SIGNED_CERT");
    expect(stderr).toContain("ignoring extra certs");
  }
});
