import type {
  ServerRequestEvent,
  ServerRequestMode,
} from '@qwik.dev/router/middleware/request-handler';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { Http2ServerRequest } from 'node:http2';
import type { QwikRouterNodeRequestOptions } from '.';
import {
  DEFAULT_REQUEST_BODY_LIMIT,
  RequestBodyLimitError,
  validateRequestBodyLimit,
} from '../request-handler/request-body-limit';
import type { ClientConn } from '../request-handler/types';
import { normalizeRequestUrl } from '../shared/url';

export function computeOrigin(
  req: IncomingMessage | Http2ServerRequest,
  opts?: QwikRouterNodeRequestOptions
) {
  return opts?.getOrigin?.(req) ?? opts?.origin ?? process.env.ORIGIN ?? fallbackOrigin(req);
}

function fallbackOrigin(req: IncomingMessage | Http2ServerRequest) {
  const { PROTOCOL_HEADER, HOST_HEADER } = process.env;
  const headers = req.headers;
  const protocol =
    (PROTOCOL_HEADER && headers[PROTOCOL_HEADER]) ||
    ((req.socket as any).encrypted || (req.connection as any).encrypted ? 'https' : 'http');
  const hostHeader = HOST_HEADER ?? (req instanceof Http2ServerRequest ? ':authority' : 'host');
  const host = headers[hostHeader];

  return `${protocol}://${host}`;
}

export function getUrl(req: IncomingMessage | Http2ServerRequest, origin: string) {
  return normalizeUrl((req as any).originalUrl || req.url || '/', origin);
}

// Client disconnects are expected during streaming SSR: the socket is gone,
// so the adapter should stop writing instead of surfacing them as app errors.
function isClientAbortWriteError(error: unknown) {
  if (!(error instanceof Error)) {
    return false;
  }
  const code = (error as Error & { code?: string }).code;
  if (
    code === 'EPIPE' ||
    code === 'ECONNRESET' ||
    code === 'ERR_STREAM_DESTROYED' ||
    code === 'ERR_STREAM_WRITE_AFTER_END'
  ) {
    return true;
  }

  return false;
}

// ensure no HTTP/2-specific headers are being set
const invalidHeadersPattern = /^:(method|scheme|authority|path)$/i;

export function normalizeUrl(url: string, base: string) {
  return normalizeRequestUrl(url, base);
}

function createNodeResponseSink(res: ServerResponse) {
  let closed = res.closed || res.destroyed;
  let responseError: unknown;
  let resolveClosed: () => void;
  const closedPromise = closed
    ? Promise.resolve()
    : new Promise<void>((resolve) => {
        resolveClosed = resolve;
        res.once('close', () => {
          closed = true;
          resolve();
        });
      });

  res.on('error', (error) => {
    if (responseError === undefined) {
      responseError = error;
    }
    if (!closed) {
      closed = true;
      resolveClosed();
    }
  });

  const shouldPropagateResponseError = () =>
    responseError !== undefined && !isClientAbortWriteError(responseError);

  const write = (chunk: Uint8Array) => {
    if (closed || res.closed || res.destroyed) {
      // If the response has already been closed or destroyed (for example the client has disconnected)
      // then writing into it will cause an error. So just stop writing since no one
      // is listening.
      if (shouldPropagateResponseError()) {
        return Promise.reject(responseError);
      }
      return;
    }

    return new Promise<void>((resolve, reject) => {
      let settled = false;
      let onDrain: (() => void) | undefined;

      const cleanupDrain = () => {
        if (onDrain) {
          res.off('drain', onDrain);
          onDrain = undefined;
        }
      };

      const finish = () => {
        if (settled) {
          return;
        }
        settled = true;
        cleanupDrain();

        // Let Node process the completed write before SSR keeps emitting more chunks.
        setImmediate(resolve);
      };

      const finishClosed = () => {
        closed = true;
        finish();
      };

      const fail = (error: unknown) => {
        if (settled) {
          return;
        }
        settled = true;
        closed = true;
        cleanupDrain();
        reject(error);
      };

      closedPromise.then(() => {
        if (shouldPropagateResponseError()) {
          fail(responseError);
          return;
        }
        finish();
      });

      let ok: boolean;
      try {
        ok = res.write(chunk, (error) => {
          if (error) {
            if (isClientAbortWriteError(error)) {
              finishClosed();
              return;
            }
            fail(error);
            return;
          }
          finish();
        });
      } catch (error) {
        if (isClientAbortWriteError(error)) {
          finishClosed();
          return;
        }
        fail(error);
        return;
      }

      // Some response wrappers (notably `compression`) replace res.write
      // with a 2-arg signature and silently drop the per-write callback,
      // which would otherwise hang the SSR walker forever. Resolve via
      // Node's standard backpressure protocol instead:
      //   - res.write() returned true  → no backpressure, resolve now.
      //   - res.write() returned false → wait for 'drain'.
      // The per-write callback (if it ever fires) still surfaces errors,
      // and becomes a no-op for resolution once we've already settled.
      if (settled) {
        return;
      }
      if (ok) {
        finish();
      } else {
        onDrain = () => {
          onDrain = undefined;
          finish();
        };
        res.once('drain', onDrain);
      }
    });
  };

  const close = () => {
    if (closed || res.closed || res.destroyed) {
      if (shouldPropagateResponseError()) {
        return Promise.reject(responseError);
      }
      return;
    }

    return new Promise<void>((resolve, reject) => {
      let settled = false;

      const finish = () => {
        if (settled) {
          return;
        }
        settled = true;
        closed = true;
        resolve();
      };

      const fail = (error: unknown) => {
        if (settled) {
          return;
        }
        settled = true;
        closed = true;
        reject(error);
      };

      closedPromise.then(() => {
        if (shouldPropagateResponseError()) {
          fail(responseError);
          return;
        }
        finish();
      });

      // Resolve on 'finish' (data fully flushed). We do NOT pass a callback
      // to res.end because some response wrappers replace it with a 2-arg
      // signature `(chunk, encoding)` (notably `compression@1.x`) and would
      // treat the callback as a chunk, then throw inside Buffer.from(...) —
      // leaving the underlying stream un-ended and the socket hanging.
      res.once('finish', finish);

      try {
        res.end();
      } catch (error) {
        if (isClientAbortWriteError(error)) {
          finish();
          return;
        }
        fail(error);
      }
    });
  };

  return {
    write,
    close,
  };
}

export async function fromNodeHttp(
  url: URL,
  req: IncomingMessage | Http2ServerRequest,
  res: ServerResponse,
  mode: ServerRequestMode,
  getClientConn?: (req: IncomingMessage | Http2ServerRequest) => ClientConn,
  requestBodyLimit = DEFAULT_REQUEST_BODY_LIMIT
) {
  validateRequestBodyLimit(requestBodyLimit);

  const requestHeaders = new Headers();
  const nodeRequestHeaders = req.headers;

  try {
    for (const [key, value] of Object.entries(nodeRequestHeaders)) {
      if (invalidHeadersPattern.test(key)) {
        continue;
      }
      if (typeof value === 'string') {
        requestHeaders.set(key, value);
      } else if (Array.isArray(value)) {
        for (const v of value) {
          requestHeaders.append(key, v);
        }
      }
    }
  } catch (err) {
    console.error(err);
  }

  const getRequestBody = async function* () {
    let received = 0;
    for await (const chunk of req as AsyncIterable<string | Uint8Array>) {
      const byteLength = typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.byteLength;
      if (byteLength > requestBodyLimit - received) {
        req.resume();
        throw new RequestBodyLimitError(requestBodyLimit);
      }
      received += byteLength;
      yield chunk;
    }
  };

  const body = req.method === 'HEAD' || req.method === 'GET' ? undefined : getRequestBody();
  const controller = new AbortController();
  const abort = () => {
    controller.abort();
  };
  const options = {
    method: req.method,
    headers: requestHeaders,
    body: body as any,
    signal: controller.signal,
    duplex: 'half' as any,
  };
  req.on('aborted', abort);
  req.on('error', abort);
  res.on('close', abort);
  res.on('error', abort);
  const serverRequestEv: ServerRequestEvent<boolean> = {
    mode,
    url,
    request: new Request(url.href, options as any),
    env: {
      get(key) {
        return process.env[key];
      },
    },
    getWritableStream: (status, headers, cookies, resolve) => {
      res.statusCode = status;
      const sink = createNodeResponseSink(res);

      try {
        for (const [key, value] of headers) {
          if (invalidHeadersPattern.test(key)) {
            continue;
          }
          res.setHeader(key, value);
        }
        const cookieHeaders = cookies.headers();
        if (cookieHeaders.length > 0) {
          res.setHeader('Set-Cookie', cookieHeaders);
        }
      } catch (err) {
        console.error(err);
      }

      resolve(true);

      return new WritableStream<Uint8Array>({
        write(chunk) {
          return sink.write(chunk);
        },
        close() {
          return sink.close();
        },
      });
    },
    getClientConn: () => {
      return getClientConn
        ? getClientConn(req)
        : {
            ip: req.socket.remoteAddress,
          };
    },
    platform: {
      ssr: true,
      incomingMessage: req,
      node: process.versions.node,

      // Weirdly needed to make typecheck of insights happy
    } as QwikRouterPlatform,
    locale: undefined,
  };

  return serverRequestEv;
}
