import { lookup as dnsLookup } from "node:dns/promises";
import { request as httpRequest, type RequestOptions } from "node:http";
import { request as httpsRequest } from "node:https";
import { isIP } from "node:net";

// Original generic public-page transport. Recipe parsing happens in the separate workspace.
export class PublicPageError extends Error {
  constructor(
    message: string,
    public status = 400,
  ) {
    super(message);
  }
}
const MAX_BYTES = 2_000_000;
const BLOCKED = [
  ["0.0.0.0", 8],
  ["10.0.0.0", 8],
  ["100.64.0.0", 10],
  ["127.0.0.0", 8],
  ["169.254.0.0", 16],
  ["172.16.0.0", 12],
  ["192.0.0.0", 24],
  ["192.0.2.0", 24],
  ["192.88.99.0", 24],
  ["192.168.0.0", 16],
  ["198.18.0.0", 15],
  ["198.51.100.0", 24],
  ["203.0.113.0", 24],
  ["224.0.0.0", 4],
  ["240.0.0.0", 4],
] as const;
const ipv4Number = (address: string) =>
  address.split(".").reduce((value, part) => value * 256 + Number(part), 0);
export function isPublicIPv4(address: string) {
  if (isIP(address) !== 4) return false;
  const n = ipv4Number(address);
  return !BLOCKED.some(
    ([prefix, bits]) =>
      Math.floor(n / 2 ** (32 - bits)) ===
      Math.floor(ipv4Number(prefix) / 2 ** (32 - bits)),
  );
}
export function publicUrl(value: string) {
  let url: URL;
  try {
    url = new URL(value);
  } catch {
    throw new PublicPageError("Enter a complete public recipe page URL.");
  }
  if (
    !["http:", "https:"].includes(url.protocol) ||
    url.username ||
    url.password ||
    url.port ||
    isIP(url.hostname.replace(/^\[|\]$/g, "")) ||
    !url.hostname.includes(".") ||
    /\.(local|localhost|internal|test|invalid|example|onion)\.?$/i.test(
      url.hostname,
    )
  )
    throw new PublicPageError(
      "Use a public HTTP or HTTPS website with its standard port and no login details.",
    );
  url.hash = "";
  if (url.href.length > 2000)
    throw new PublicPageError("That URL is too long.");
  return url;
}
type Dependencies = {
  lookup: typeof dnsLookup;
  http: typeof httpRequest;
  https: typeof httpsRequest;
};
export async function fetchPublicPage(
  value: string,
  overrides: Partial<Dependencies> = {},
) {
  const deps = {
    lookup: dnsLookup,
    http: httpRequest,
    https: httpsRequest,
    ...overrides,
  };
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 12_000);
  const aborted = new Promise<never>((_, reject) =>
    controller.signal.addEventListener(
      "abort",
      () =>
        reject(
          new PublicPageError("The website took too long to respond.", 504),
        ),
      { once: true },
    ),
  );
  try {
    let url = publicUrl(value);
    const seen = new Set<string>();
    for (let redirects = 0; redirects <= 3; redirects++) {
      if (seen.has(url.href))
        throw new PublicPageError("The website returned a redirect loop.", 422);
      seen.add(url.href);
      const records = await Promise.race([
        deps.lookup(url.hostname, { all: true, family: 4, verbatim: true }),
        aborted,
      ]);
      if (
        !records.length ||
        records.some((record) => !isPublicIPv4(record.address))
      )
        throw new PublicPageError("That address is not a public website.");
      const address = records[0].address;
      const response = await Promise.race([
        new Promise<{ redirect?: string; html?: string }>((resolve, reject) => {
          const options: RequestOptions = {
            method: "GET",
            agent: false,
            signal: controller.signal,
            family: 4,
            // Pin the checked address; no second DNS resolution can change the destination.
            lookup: (_host, _options, callback) => callback(null, address, 4),
            headers: {
              "User-Agent": "Bookify-Recipe-Import/1.0",
              Accept:
                "text/html,application/ld+json;q=0.9,application/json;q=0.8",
              "Accept-Encoding": "identity",
            },
            maxHeaderSize: 16_384,
          };
          const req = (url.protocol === "https:" ? deps.https : deps.http)(
            url,
            options,
            (res) => {
              const status = res.statusCode || 0;
              if ([301, 302, 303, 307, 308].includes(status)) {
                const location = res.headers.location;
                res.destroy();
                if (!location)
                  reject(
                    new PublicPageError(
                      "The website returned an incomplete redirect.",
                      422,
                    ),
                  );
                else resolve({ redirect: location });
                return;
              }
              const type = String(
                res.headers["content-type"] || "",
              ).toLowerCase();
              if (
                status !== 200 ||
                !/^(text\/html|application\/(ld\+json|json))(;|$)/.test(type) ||
                !["", "identity"].includes(
                  String(res.headers["content-encoding"] || ""),
                )
              ) {
                res.destroy();
                reject(
                  new PublicPageError(
                    "This page could not be read. Paste its recipe JSON-LD or enter the recipe manually.",
                    422,
                  ),
                );
                return;
              }
              if (Number(res.headers["content-length"] || 0) > MAX_BYTES) {
                res.destroy();
                reject(
                  new PublicPageError(
                    "The page exceeds the 2 MB import limit.",
                    413,
                  ),
                );
                return;
              }
              const chunks: Buffer[] = [];
              let bytes = 0;
              res.on("data", (chunk) => {
                bytes += chunk.length;
                if (bytes > MAX_BYTES) {
                  res.destroy();
                  reject(
                    new PublicPageError(
                      "The page exceeds the 2 MB import limit.",
                      413,
                    ),
                  );
                } else chunks.push(Buffer.from(chunk));
              });
              res.on("end", () =>
                resolve({ html: Buffer.concat(chunks).toString("utf8") }),
              );
              res.on("error", reject);
              res.on("aborted", () =>
                reject(
                  new PublicPageError(
                    "The website stopped sending the page.",
                    422,
                  ),
                ),
              );
            },
          );
          req.on("error", reject);
          req.end();
        }),
        aborted,
      ]);
      if (response.redirect) {
        if (redirects === 3)
          throw new PublicPageError(
            "The website redirected too many times.",
            422,
          );
        url = publicUrl(new URL(response.redirect, url).href);
        continue;
      }
      return { html: response.html || "", url: url.href };
    }
    throw new PublicPageError("Unable to read this website.", 422);
  } catch (error) {
    if (error instanceof PublicPageError) throw error;
    throw new PublicPageError(
      "This page is unavailable. Paste recipe JSON-LD or enter the recipe manually.",
      422,
    );
  } finally {
    clearTimeout(timeout);
  }
}

let active = 0,
  windowStart = 0,
  requests = 0;
export async function importPublicPage(value: string) {
  const now = Date.now();
  if (now - windowStart > 60_000) {
    windowStart = now;
    requests = 0;
  }
  if (active >= 4 || requests >= 60)
    throw new PublicPageError(
      "Recipe imports are busy. Please try again in a minute.",
      429,
    );
  active++;
  requests++;
  try {
    return await fetchPublicPage(value);
  } finally {
    active--;
  }
}
