// Copyright 2021-2023 Buf Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import * as http2 from "http2";
import * as http from "http";
import { DubboError } from "@apachedubbo/dubbo";
import { createAsyncIterable } from "@apachedubbo/dubbo/protocol";
import { createNodeHttpClient } from "./node-universal-client.js";
import { useNodeServer } from "./use-node-server-helper.spec.js";

// Polyfill the Headers API for Node versions < 18
import "./node-headers-polyfill.js";

describe("node http/2 client closing with RST_STREAM with code CANCEL", function () {
  let serverReceivedRstCode: number | undefined;
  const server = useNodeServer(() =>
    http2.createServer().on("stream", (stream) => {
      stream.on("close", () => (serverReceivedRstCode = stream.rstCode));
    })
  );
  it("should send RST_STREAM frame to the server", async function () {
    return new Promise<void>((resolve) => {
      http2.connect(server.getUrl(), (session: http2.ClientHttp2Session) => {
        const stream = session.request(
          {
            ":method": "POST",
            ":path": "/",
          },
          {}
        );
        setTimeout(() => {
          stream.close(http2.constants.NGHTTP2_CANCEL, () => {
            // We are seeing a race condition in Node v16.20.0, where closing
            // the session right after closing a stream with an RST code
            // _sometimes_ sends an INTERNAL_ERROR code.
            // Simply delaying the session close until the next tick like
            // we do here seems to work around the issue.
            // We do _not_ guard against this case in the universal client,
            // since we were not able to reproduce the issue there.
            setTimeout(() => session.close(resolve), 0);
          });
        }, 0);
      });
    });

    while (serverReceivedRstCode === undefined) {
      // wait for the server to see the reset code
      await new Promise((resolve) => setTimeout(resolve, 1));
    }
    expect(serverReceivedRstCode).toBe(http2.constants.NGHTTP2_CANCEL);
  });
});

describe("universal node http client", function () {
  describe("against an unresolvable host", function () {
    for (const httpVersion of ["2", "1.1"] as const) {
      describe(`over http ${httpVersion}`, function () {
        it("should raise Code.Unavailable", async function () {
          const client = createNodeHttpClient({
            httpVersion,
          });
          try {
            await client({
              url: "https://unresolvable-host.some.domain",
              method: "POST",
              header: new Headers(),
            });
          } catch (e) {
            expect(DubboError.from(e).message).toBe(
              "[unavailable] getaddrinfo ENOTFOUND unresolvable-host.some.domain"
            );
          }
        });
      });
    }
  });

  describe("against a server that closes immediately", function () {
    describe("over http/2", function () {
      let serverReceivedRequest = false;
      const server = useNodeServer(() =>
        http2.createServer((request, response) => {
          serverReceivedRequest = true;
          response.stream.close(http2.constants.NGHTTP2_CANCEL);
        })
      );
      it("should reject the response promise with Code.Canceled", async function () {
        const client = server.getClient();
        try {
          await client({
            url: server.getUrl(),
            method: "POST",
            header: new Headers(),
          });
          fail("expected error");
        } catch (e) {
          expect(e).toBeInstanceOf(DubboError);
          expect(DubboError.from(e).message).toBe(
            "[canceled] http/2 stream closed with error code CANCEL (0x8)"
          );
        }
        expect(serverReceivedRequest).toBeTrue();
      });
    });
    describe("over http/1.1", function () {
      let serverReceivedRequest = false;
      const server = useNodeServer(() =>
        http.createServer((req, res) => {
          serverReceivedRequest = true;
          res.destroy();
        })
      );
      it("should reject the response promise", async function () {
        const client = server.getClient();
        try {
          await client({
            url: server.getUrl(),
            method: "POST",
            header: new Headers(),
          });
          fail("expected error");
        } catch (e) {
          expect(e).toBeInstanceOf(DubboError);
          expect(DubboError.from(e).message).toBe("[aborted] socket hang up");
        }
        expect(serverReceivedRequest).toBeTrue();
      });
    });
  });

  describe("against a server that closes before the first response byte", function () {
    describe("over http/2", function () {
      const server = useNodeServer(() =>
        http2.createServer((req, res) => {
          res.writeHead(200);
          // Calling close in the same tick as writeHead appears to prevent
          // headers from being sent. The client response promise will reject,
          // instead of the response body.
          setTimeout(() => res.stream.close(http2.constants.NGHTTP2_CANCEL), 0);
        })
      );
      it("should reject the response promise with Code.Canceled", async function () {
        const client = server.getClient();
        const res = await client({
          url: server.getUrl(),
          method: "POST",
          header: new Headers(),
        });
        try {
          for await (const chunk of res.body) {
            expect(chunk)
              .withContext("response body iterable should be empty")
              .toBeUndefined();
          }
          fail("expected error");
        } catch (e) {
          expect(e).toBeInstanceOf(DubboError);
          expect(e).toBeInstanceOf(DubboError);
          expect(DubboError.from(e).message).toBe(
            "[canceled] http/2 stream closed with error code CANCEL (0x8)"
          );
        }
      });
    });
    describe("over http/1.1", function () {
      const server = useNodeServer(() =>
        http.createServer((req, res) => {
          res.writeHead(200);
          res.flushHeaders();
          res.destroy();
        })
      );
      it("should reject the response promise", async function () {
        const client = server.getClient();
        const res = await client({
          url: server.getUrl(),
          method: "POST",
          header: new Headers(),
        });
        try {
          for await (const chunk of res.body) {
            expect(chunk)
              .withContext("response body iterable should be empty")
              .toBeUndefined();
          }
          fail("expected error");
        } catch (e) {
          expect(e).toBeInstanceOf(DubboError);
          expect(DubboError.from(e).message).toBe("[aborted] aborted");
        }
      });
    });
  });

  describe("against a server that closes mid request", function () {
    describe("over http/2", function () {
      let serverReceivedBytes = 0;
      const server = useNodeServer(() =>
        http2.createServer((req, res) => {
          void (async () => {
            for await (const chunk of req) {
              serverReceivedBytes += (chunk as Uint8Array).byteLength;
              res.stream.close(http2.constants.NGHTTP2_CANCEL);
              break;
            }
          })();
        })
      );
      it("should reject the response promise with Code.Canceled", async function () {
        const client = server.getClient();

        async function* body() {
          yield new Uint8Array(32);
          await new Promise<void>(() => {
            // never resolves
          });
        }

        try {
          await client({
            url: server.getUrl(),
            method: "POST",
            header: new Headers(),
            body: body(),
          });
          fail("expected error");
        } catch (e) {
          expect(e).toBeInstanceOf(DubboError);
          expect(DubboError.from(e).message).toBe(
            "[canceled] http/2 stream closed with error code CANCEL (0x8)"
          );
        }
        expect(serverReceivedBytes).toBe(32);
      });
    });
    describe("over http/1.1", function () {
      let serverReceivedBytes = 0;
      const server = useNodeServer(() =>
        http.createServer((req, res) => {
          void (async () => {
            for await (const chunk of req) {
              serverReceivedBytes += (chunk as Uint8Array).byteLength;
              res.destroy();
              break;
            }
          })();
        })
      );
      it("should reject the response promise", async function () {
        const client = server.getClient();

        async function* body() {
          yield new Uint8Array(32);
          await new Promise<void>(() => {
            // never resolves
          });
        }

        try {
          await client({
            url: server.getUrl(),
            method: "POST",
            header: new Headers(),
            body: body(),
          });
          fail("expected error");
        } catch (e) {
          expect(e).toBeInstanceOf(DubboError);
          expect(DubboError.from(e).message).toBe("[aborted] socket hang up");
        }
        expect(serverReceivedBytes).toBe(32);
      });
    });
  });

  describe("against a server that closes mid response", function () {
    describe("over http/2", function () {
      let serverSentBytes = 0;
      const server = useNodeServer(() =>
        http2.createServer((req, res) => {
          void (async () => {
            res.writeHead(200);
            await new Promise<void>((resolve, reject) =>
              res.write(new Uint8Array(64), (e: Error | undefined) =>
                e ? reject(e) : resolve()
              )
            );
            serverSentBytes += 64;
            res.stream.close(http2.constants.NGHTTP2_CANCEL);
          })();
        })
      );
      it("should reject the response promise with Code.Canceled", async function () {
        const client = server.getClient();
        const res = await client({
          url: server.getUrl(),
          method: "POST",
          header: new Headers(),
        });
        try {
          for await (const chunk of res.body) {
            expect(chunk.byteLength).toBe(64);
          }
          fail("expected error");
        } catch (e) {
          expect(e).toBeInstanceOf(DubboError);
          expect(e).toBeInstanceOf(DubboError);
          expect(DubboError.from(e).message).toBe(
            "[canceled] http/2 stream closed with error code CANCEL (0x8)"
          );
        }
        expect(serverSentBytes).toBe(64);
      });
    });
    describe("over http/1.1", function () {
      let serverSentBytes = 0;
      const server = useNodeServer(() =>
        http.createServer((req, res) => {
          void (async () => {
            res.writeHead(200);
            await new Promise<void>((resolve, reject) =>
              res.write(new Uint8Array(64), (e) => (e ? reject(e) : resolve()))
            );
            serverSentBytes += 64;
            res.destroy();
          })();
        })
      );
      it("should reject the response promise", async function () {
        const client = server.getClient();
        const res = await client({
          url: server.getUrl(),
          method: "POST",
          header: new Headers(),
        });
        try {
          for await (const chunk of res.body) {
            expect(chunk.byteLength).toBe(64);
          }
          fail("expected error");
        } catch (e) {
          expect(e).toBeInstanceOf(DubboError);
          expect(DubboError.from(e).message).toMatch(
            /\[aborted] (aborted|read ECONNRESET)/
          );
        }
        expect(serverSentBytes).toBe(64);
      });
    });
  });

  describe("with a signal that is already aborted", function () {
    describe("over http/2", function () {
      let serverReceivedRequest = false;
      const server = useNodeServer(() =>
        http2.createServer(() => (serverReceivedRequest = true))
      );
      it("should raise error with Code.Canceled and never hit the server", async function () {
        const client = server.getClient();
        const signal = AbortSignal.abort();
        // client should raise error
        try {
          await client({
            url: server.getUrl(),
            method: "POST",
            header: new Headers(),
            signal,
          });
          fail("expected error");
        } catch (e) {
          expect(e).toBeInstanceOf(DubboError);
          expect(DubboError.from(e).message).toBe(
            "[canceled] This operation was aborted"
          );
        }
        // request should never hit the server
        expect(serverReceivedRequest).toBeFalse();
      });
    });
    describe("over http/1.1", function () {
      let serverReceivedRequest = false;
      const server = useNodeServer(() =>
        http.createServer(() => (serverReceivedRequest = true))
      );
      it("should raise error with Code.Canceled and never hit the server", async function () {
        const client = server.getClient();
        const signal = AbortSignal.abort();
        // client should raise error
        try {
          await client({
            url: server.getUrl(),
            method: "POST",
            header: new Headers(),
            signal,
          });
          fail("expected error");
        } catch (e) {
          expect(e).toBeInstanceOf(DubboError);
          expect(DubboError.from(e).message).toBe(
            "[canceled] This operation was aborted"
          );
        }
        // request should never hit the server
        expect(serverReceivedRequest).toBeFalse();
      });
    });
  });

  describe("with a signal aborting before first request byte", function () {
    describe("over http/2", function () {
      let serverReceivedRstCode: number | undefined;
      let serverReceivedBytes = 0;
      const server = useNodeServer(() =>
        http2.createServer((req, res) => {
          res.stream.on(
            "close",
            () => (serverReceivedRstCode = res.stream.rstCode)
          );
          void (async () => {
            for await (const chunk of req) {
              serverReceivedBytes += (chunk as Uint8Array).byteLength;
            }
          })();
        })
      );
      it("should raise error with code canceled and send RST_STREAM with code CANCEL", async function () {
        // set up a client that aborts while still streaming the request body
        const client = server.getClient();
        const ac = new AbortController();

        async function* body() {
          await new Promise<void>((resolve) => setTimeout(resolve, 50));
          ac.abort();
          yield new Uint8Array(32);
        }

        // client should raise error
        try {
          await client({
            url: server.getUrl(),
            method: "POST",
            header: new Headers(),
            body: body(),
            signal: ac.signal,
          });
          fail("expected error");
        } catch (e) {
          expect(e).toBeInstanceOf(DubboError);
          expect(DubboError.from(e).message).toBe(
            "[canceled] This operation was aborted"
          );
        }

        // server should receive chunks until client cancelled
        while (serverReceivedRstCode === undefined) {
          // wait for the server to see the reset code
          await new Promise((resolve) => setTimeout(resolve, 1));
        }
        expect(serverReceivedRstCode).toBe(http2.constants.NGHTTP2_CANCEL);
        expect(serverReceivedBytes).toBe(0);
      });
    });
    describe("over http/1.1", function () {
      let serverReceivedRequest = false;
      let serverReceivedBytes = 0;
      let serverRequestClosed = false;
      let serverResponseClosed = false;
      let serverRequestEmittedAborted = false;
      let serverRequestEmittedError: (Error & { code?: string }) | undefined;
      let serverRequestIterableErrored: (Error & { code?: string }) | undefined;
      const server = useNodeServer(() =>
        http.createServer((req, res) => {
          serverReceivedRequest = true;
          req.on("aborted", () => (serverRequestEmittedAborted = true));
          req.on("error", (e) => (serverRequestEmittedError = e));
          req.on("close", () => (serverRequestClosed = true));
          res.on("close", () => (serverResponseClosed = true));
          void (async () => {
            try {
              for await (const chunk of req) {
                serverReceivedBytes += (chunk as Uint8Array).byteLength;
              }
            } catch (e) {
              serverRequestIterableErrored = e as Error & { code?: string };
            }
          })();
        })
      );
      it("should raise error with code canceled", async function () {
        // set up a client that aborts while still streaming the request body
        const client = server.getClient();
        const ac = new AbortController();

        async function* body() {
          await new Promise<void>((resolve) => setTimeout(resolve, 50));
          ac.abort();
          yield new Uint8Array(32);
        }

        // client should raise error
        try {
          await client({
            url: server.getUrl(),
            method: "POST",
            header: new Headers(),
            body: body(),
            signal: ac.signal,
          });
          fail("expected error");
        } catch (e) {
          expect(e).toBeInstanceOf(DubboError);
          expect(DubboError.from(e).message).toBe(
            "[canceled] This operation was aborted"
          );
        }

        // server should receive chunks until client cancelled
        expect(serverReceivedRequest)
          .withContext("serverReceivedRequest")
          .toBeTrue();
        while (!serverResponseClosed) {
          // wait for the server to see the response being closed
          await new Promise((resolve) => setTimeout(resolve, 1));
        }
        expect(serverRequestClosed).toBeTrue();
        expect(serverResponseClosed).toBeTrue();
        expect(serverRequestEmittedAborted).toBeTrue();
        expect(serverRequestEmittedError?.code).toBe("ECONNRESET");
        expect(serverRequestIterableErrored?.code).toBe("ECONNRESET");
        expect(serverReceivedBytes).toBe(0);
      });
    });
  });

  describe("with a signal aborting mid request", function () {
    describe("over http/2", function () {
      let serverReceivedRstCode: number | undefined;
      let serverReceivedBytes = 0;
      const server = useNodeServer(() =>
        http2.createServer((req, res) => {
          res.stream.on(
            "close",
            () => (serverReceivedRstCode = res.stream.rstCode)
          );
          void (async () => {
            for await (const chunk of req) {
              serverReceivedBytes += (chunk as Uint8Array).byteLength;
            }
          })();
        })
      );
      it("should raise error with code canceled and send RST_STREAM with code CANCEL", async function () {
        // set up a client that aborts while still streaming the request body
        const client = server.getClient();
        const ac = new AbortController();

        async function* body() {
          yield new Uint8Array(32);
          await new Promise<void>((resolve) => setTimeout(resolve, 50));
          ac.abort();
          yield new Uint8Array(32);
        }

        // client should raise error
        try {
          await client({
            url: server.getUrl(),
            method: "POST",
            header: new Headers(),
            body: body(),
            signal: ac.signal,
          });
          fail("expected error");
        } catch (e) {
          expect(e).toBeInstanceOf(DubboError);
          expect(DubboError.from(e).message).toBe(
            "[canceled] This operation was aborted"
          );
        }

        // server should receive chunks until client cancelled
        while (serverReceivedRstCode === undefined) {
          // wait for the server to see the reset code
          await new Promise((resolve) => setTimeout(resolve, 1));
        }
        expect(serverReceivedRstCode).toBe(http2.constants.NGHTTP2_CANCEL);
        expect(serverReceivedBytes).toBe(32);
      });
    });
    describe("over http/1.1", function () {
      let serverReceivedRequest = false;
      let serverReceivedBytes = 0;
      let serverRequestClosed = false;
      let serverResponseClosed = false;
      let serverRequestEmittedAborted = false;
      let serverRequestEmittedError: (Error & { code?: string }) | undefined;
      let serverRequestIterableErrored: (Error & { code?: string }) | undefined;
      const server = useNodeServer(() =>
        http.createServer((req, res) => {
          serverReceivedRequest = true;
          req.on("aborted", () => (serverRequestEmittedAborted = true));
          req.on("error", (e) => (serverRequestEmittedError = e));
          req.on("close", () => (serverRequestClosed = true));
          res.on("close", () => (serverResponseClosed = true));
          void (async () => {
            try {
              for await (const chunk of req) {
                serverReceivedBytes += (chunk as Uint8Array).byteLength;
              }
            } catch (e) {
              serverRequestIterableErrored = e as Error & { code?: string };
            }
          })();
        })
      );
      it("should raise error with code canceled", async function () {
        // set up a client that aborts while still streaming the request body
        const client = server.getClient();
        const ac = new AbortController();

        async function* body() {
          yield new Uint8Array(32);
          await new Promise<void>((resolve) => setTimeout(resolve, 50));
          ac.abort();
          yield new Uint8Array(32);
        }

        // client should raise error
        try {
          await client({
            url: server.getUrl(),
            method: "POST",
            header: new Headers(),
            body: body(),
            signal: ac.signal,
          });
          fail("expected error");
        } catch (e) {
          expect(e).toBeInstanceOf(DubboError);
          expect(DubboError.from(e).message).toBe(
            "[canceled] This operation was aborted"
          );
        }

        // server should receive chunks until client cancelled
        expect(serverReceivedRequest).toBeTrue();
        while (!serverResponseClosed) {
          // wait for the server to see the response being closed
          await new Promise((resolve) => setTimeout(resolve, 1));
        }
        expect(serverRequestClosed).toBeTrue();
        expect(serverResponseClosed).toBeTrue();
        expect(serverRequestEmittedAborted).toBeTrue();
        expect(serverRequestEmittedError?.code).toBe("ECONNRESET");
        expect(serverRequestIterableErrored?.code).toBe("ECONNRESET");
        expect(serverReceivedBytes).toBe(32);
      });
    });
  });

  describe("with a signal aborting mid response", function () {
    describe("over http/2", function () {
      let serverReceivedRstCode: number | undefined;
      let serverSentBytes = 0;
      const server = useNodeServer(() =>
        http2.createServer((req, res) => {
          res.stream.on(
            "close",
            () => (serverReceivedRstCode = res.stream.rstCode)
          );
          void (async () => {
            res.writeHead(200);
            res.write(new Uint8Array(64));
            serverSentBytes += 64;
            await new Promise<void>(() => {
              // never resolves
            });
          })();
        })
      );
      it("should raise error with code canceled and send RST_STREAM with code CANCEL", async function () {
        // set up a client that aborts while still streaming the request body
        const client = server.getClient();
        const ac = new AbortController();

        const res = await client({
          url: server.getUrl(),
          method: "POST",
          header: new Headers(),
          body: createAsyncIterable([]),
          signal: ac.signal,
        });

        // should raise error with code canceled
        try {
          for await (const chunk of res.body) {
            expect(chunk.byteLength).toBe(64);
            ac.abort();
          }
          fail("expected error");
        } catch (e) {
          expect(e).toBeInstanceOf(DubboError);
          expect(DubboError.from(e).message).toBe(
            "[canceled] This operation was aborted"
          );
        }

        // server should receive RST_STREAM with code CANCEL
        while (serverReceivedRstCode === undefined) {
          // wait for the server to see the reset code
          await new Promise((resolve) => setTimeout(resolve, 1));
        }
        expect(serverReceivedRstCode).toBe(http2.constants.NGHTTP2_CANCEL);
        expect(serverSentBytes).toBe(64);
      });
    });
    describe("over http/1.1", function () {
      let serverSentBytes = 0;
      let serverRequestClosed = false;
      let serverResponseClosed = false;
      const server = useNodeServer(() =>
        http.createServer((req, res) => {
          req.on("close", () => (serverRequestClosed = true));
          res.on("close", () => (serverResponseClosed = true));
          void (async () => {
            res.writeHead(200);
            res.write(new Uint8Array(64));
            serverSentBytes += 64;
            await new Promise<void>(() => {
              // never resolves
            });
          })();
        })
      );
      it("should raise error with code canceled", async function () {
        // set up a client that aborts while still streaming the request body
        const client = server.getClient();
        const ac = new AbortController();

        const res = await client({
          url: server.getUrl(),
          method: "POST",
          header: new Headers(),
          body: createAsyncIterable([]),
          signal: ac.signal,
        });

        // should raise error with code canceled
        try {
          for await (const chunk of res.body) {
            expect(chunk.byteLength).toBe(64);
            ac.abort();
          }
          fail("expected error");
        } catch (e) {
          expect(e).toBeInstanceOf(DubboError);
          expect(DubboError.from(e).message).toBe(
            "[canceled] This operation was aborted"
          );
        }

        // server should see request close
        while (!serverResponseClosed) {
          // wait for the server to see the response being closed
          await new Promise((resolve) => setTimeout(resolve, 1));
        }
        expect(serverRequestClosed).toBeTrue();
        expect(serverResponseClosed).toBeTrue();
        expect(serverSentBytes).toBe(64);
      });
    });
  });
});
