const dns = require("dns");
const net = require("net");

const METADATA_HOSTS = new Set([
  "metadata.google.internal",
  "metadata.aws.internal",
  "instance-data.ec2.internal"
]);

async function evaluateUrl(value, options = {}) {
  let url;
  try { url = new URL(String(value)); }
  catch { return denied("invalid_url"); }
  if (!["http:", "https:"].includes(url.protocol)) return denied("unsafe_protocol");
  if (url.username || url.password) return denied("credentials_in_url");
  const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
  if (isLocalHostname(hostname)) return denied("local_or_metadata_host");
  if (net.isIP(hostname) && isPrivateAddress(hostname)) return denied("private_network");
  const resolveHost = options.resolveHost || defaultResolveHost;
  try {
    const addresses = await resolveHost(hostname);
    if (!addresses.length || addresses.some(isPrivateAddress)) return denied("private_network");
  } catch {
    return denied("dns_resolution_failed");
  }
  const mode = options.mode === "blacklist" ? "blacklist" : "whitelist";
  const rules = Array.isArray(options.rules) ? options.rules : [];
  const matched = rules.some((rule) => matchesRule(url, rule));
  if (mode === "whitelist" && !matched) return denied("not_whitelisted");
  if (mode === "blacklist" && matched) return denied("blacklisted");
  return { allowed: true, url: canonicalUrl(url), reason: null };
}

async function evaluateNetworkTarget(value, options = {}) {
  return evaluateUrl(value, { ...options, mode: "blacklist", rules: [] });
}

function matchesRule(url, ruleValue) {
  const raw = String(ruleValue || "").trim();
  if (!raw) return false;
  const rule = raw.replace(/^GET\s+/i, "").replace(/#.*$/, "");
  const hostname = url.hostname.toLowerCase();
  const hostPath = `${hostname}${url.pathname}${url.search}`;
  const full = canonicalUrl(url);
  if (!rule.includes("*")) {
    try {
      const hasScheme = rule.includes("://");
      const parsed = new URL(hasScheme ? rule : `https://${rule}`);
      const ruleHost = parsed.hostname.toLowerCase();
      const hostMatches = hostname === ruleHost || hostname.endsWith(`.${ruleHost}`);
      if (!hostMatches) return false;
      if (hasScheme && (url.protocol !== parsed.protocol || url.port !== parsed.port)) return false;
      const rulePath = parsed.pathname === "/" && !rule.includes("/") ? "/" : parsed.pathname;
      if (rulePath !== "/" && !url.pathname.startsWith(rulePath)) return false;
      return !parsed.search || url.search === parsed.search;
    } catch {
      return false;
    }
  }
  const expression = wildcardExpression(rule.toLowerCase());
  return expression.test(full.toLowerCase()) ||
    expression.test(hostPath.toLowerCase()) ||
    expression.test(hostname);
}

function wildcardExpression(value) {
  const escaped = value.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*");
  return new RegExp(`^${escaped}$`, "i");
}

function canonicalUrl(url) {
  const copy = new URL(url.href);
  copy.hash = "";
  return copy.href;
}

function isLocalHostname(hostname) {
  return hostname === "localhost" ||
    hostname.endsWith(".localhost") ||
    hostname.endsWith(".local") ||
    METADATA_HOSTS.has(hostname);
}

function isPrivateAddress(address) {
  const version = net.isIP(address);
  if (version === 4) {
    const [a, b, c] = address.split(".").map(Number);
    return a === 0 || a === 10 || a === 127 ||
      (a === 100 && b >= 64 && b <= 127) ||
      (a === 169 && b === 254) ||
      (a === 172 && b >= 16 && b <= 31) ||
      (a === 192 && b === 0) ||
      (a === 192 && b === 168) ||
      (a === 192 && b === 88 && c === 99) ||
      (a === 198 && (b === 18 || b === 19)) ||
      (a === 198 && b === 51 && c === 100) ||
      (a === 203 && b === 0 && c === 113) ||
      a >= 224;
  }
  if (version === 6) {
    const normalized = address.toLowerCase();
    if (normalized === "::" || normalized === "::1") return true;
    if (normalized.startsWith("fc") || normalized.startsWith("fd") ||
        /^fe[89ab]/.test(normalized) || normalized.startsWith("ff")) return true;
    if (normalized.startsWith("::ffff:")) return true;
    if (normalized.startsWith("2001:db8:")) return true;
    const first = Number.parseInt(normalized.split(":")[0], 16);
    return !Number.isFinite(first) || first < 0x2000 || first > 0x3fff;
  }
  return true;
}

async function defaultResolveHost(hostname) {
  if (net.isIP(hostname)) return [hostname];
  const rows = await dns.promises.lookup(hostname, { all: true, verbatim: true });
  return rows.map((row) => row.address);
}

function denied(reason) {
  return { allowed: false, url: null, reason };
}

module.exports = {
  canonicalUrl,
  defaultResolveHost,
  evaluateNetworkTarget,
  evaluateUrl,
  isLocalHostname,
  isPrivateAddress,
  matchesRule,
  wildcardExpression
};
