const dns = require("dns").promises;
const net = require("net");
const { obsBrowserSourceCss } = require("./obs-browser-defaults");

const MAX_DOCUMENT_BYTES = 5 * 1024 * 1024;
const MAX_REDIRECTS = 4;

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 = String(address).toLowerCase();
    return normalized === "::" || normalized === "::1" || normalized.startsWith("fc") ||
      normalized.startsWith("fd") || /^fe[89ab]/.test(normalized) || normalized.startsWith("ff") ||
      normalized.startsWith("::ffff:") || normalized.startsWith("2001:db8:");
  }
  return true;
}

async function isPrivateTarget(value, { resolveHost } = {}) {
  const parsed = new URL(value);
  if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password) return true;
  const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
  if (hostname === "localhost" || hostname.endsWith(".localhost") || hostname.endsWith(".local")) return true;
  if (net.isIP(hostname)) return isPrivateAddress(hostname);
  const addresses = resolveHost
    ? await resolveHost(hostname)
    : (await dns.lookup(hostname, { all: true, verbatim: true })).map((entry) => entry.address);
  return !addresses.length || addresses.some(isPrivateAddress);
}

async function fetchPublicOverlay(url, signal, redirects = 0, options = {}) {
  if (redirects > MAX_REDIRECTS) throw new Error("Too many redirects.");
  if (await isPrivateTarget(url, options)) return null;
  const fetchImpl = options.fetchImpl || fetch;
  const response = await fetchImpl(url, {
    method: "GET",
    redirect: "manual",
    signal,
    headers: {
      Accept: "text/html,application/xhtml+xml;q=0.9,*/*;q=0.2",
      "Accept-Language": String(options.acceptLanguage || "en").slice(0, 200),
      "User-Agent": String(options.userAgent || "Lumi-Overlay/1.0").slice(0, 500)
    }
  });
  if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
    await response.body?.cancel?.().catch(() => {});
    const nextUrl = new URL(response.headers.get("location"), url).toString();
    return fetchPublicOverlay(nextUrl, signal, redirects + 1, options);
  }
  return response;
}

async function boundedText(response, maxBytes = MAX_DOCUMENT_BYTES) {
  const declared = Number(response.headers.get("content-length"));
  if (Number.isFinite(declared) && declared > maxBytes) throw new Error("The website document is too large.");
  if (!response.body?.getReader) {
    const text = await response.text();
    if (Buffer.byteLength(text, "utf8") > maxBytes) throw new Error("The website document is too large.");
    return text;
  }
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let bytes = 0;
  let output = "";
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    bytes += value.byteLength;
    if (bytes > maxBytes) {
      await reader.cancel().catch(() => {});
      throw new Error("The website document is too large.");
    }
    output += decoder.decode(value, { stream: true });
  }
  return output + decoder.decode();
}

function htmlAttribute(value) {
  return String(value || "")
    .replaceAll("&", "&amp;")
    .replaceAll('"', "&quot;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;");
}

const TRANSPARENT_WEBSITE_GUARD = "html, body, #root, #app, [data-reactroot] { background-color: rgba(0, 0, 0, 0) !important; }";

function websiteCss(value, forceTransparentBackground = true) {
  const css = obsBrowserSourceCss(value);
  return forceTransparentBackground ? `${TRANSPARENT_WEBSITE_GUARD}\n${css}` : css;
}

function safeStyleText(value, forceTransparentBackground) {
  return websiteCss(value, forceTransparentBackground).replace(/<\/style/gi, "<\\/style");
}

function loadEndCssScript(value, forceTransparentBackground) {
  const encoded = JSON.stringify(websiteCss(value, forceTransparentBackground)).replaceAll("<", "\\u003c");
  const force = forceTransparentBackground !== false ? "true" : "false";
  return `<script id="lumi-obs-browser-css-loader">(()=>{const css=${encoded},force=${force};let queued=false;const apply=()=>{queued=false;let style=document.getElementById("lumi-obs-browser-css");if(!style){style=document.createElement("style");style.id="lumi-obs-browser-css";style.appendChild(document.createTextNode(css))}const target=document.head||document.documentElement;if(style.parentNode!==target||style!==target.lastElementChild)target.appendChild(style);if(force)for(const node of document.querySelectorAll("html,body,#root,#app,[data-reactroot]"))if(node.style.getPropertyValue("background-color")!=="rgba(0, 0, 0, 0)"||node.style.getPropertyPriority("background-color")!=="important")node.style.setProperty("background-color","rgba(0, 0, 0, 0)","important")};const schedule=()=>{if(!queued){queued=true;requestAnimationFrame(apply)}};document.readyState==="complete"?apply():window.addEventListener("load",apply,{once:true});new MutationObserver(schedule).observe(document.documentElement,{subtree:true,childList:true,attributes:true,attributeFilter:["class","style"]})})()</script>`;
}

function injectDocumentCss(html, upstreamUrl, customCss, options = {}) {
  let document = String(html || "");
  const existingBase = document.match(/<base\b[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/i)?.[1];
  const documentBase = existingBase ? new URL(existingBase, upstreamUrl).toString() : upstreamUrl;
  document = document
    .replace(/<base\b[^>]*>/gi, "")
    .replace(/<meta\b[^>]*http-equiv\s*=\s*["']?content-security-policy["']?[^>]*>/gi, "");
  const headStart = `<base href="${htmlAttribute(documentBase)}"><meta name="referrer" content="no-referrer">`;
  const forceTransparentBackground = options.forceTransparentBackground !== false;
  const style = `<style id="lumi-obs-browser-css">${safeStyleText(customCss, forceTransparentBackground)}</style>`;
  const loadEndScript = loadEndCssScript(customCss, forceTransparentBackground);
  if (/<head\b[^>]*>/i.test(document)) {
    document = document.replace(/<head\b[^>]*>/i, (match) => `${match}${headStart}`);
    document = /<\/head\s*>/i.test(document)
      ? document.replace(/<\/head\s*>/i, `${style}</head>`)
      : `${document}${style}`;
  } else {
    document = `<head>${headStart}${style}</head>${document}`;
  }
  return /<\/body\s*>/i.test(document)
    ? document.replace(/<\/body\s*>/i, `${loadEndScript}</body>`)
    : `${document}${loadEndScript}`;
}

async function loadInjectedOverlayDocument(url, customCss, options = {}) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), Number(options.timeoutMs) || 12000);
  try {
    const response = await fetchPublicOverlay(url, controller.signal, 0, options);
    if (!response) {
      const error = new Error("Private and local website addresses cannot use page CSS injection. Use direct compatibility mode for this source.");
      error.code = "PRIVATE_TARGET";
      throw error;
    }
    if (!response.ok) {
      await response.body?.cancel?.().catch(() => {});
      throw new Error(`The website returned HTTP ${response.status}.`);
    }
    const contentType = String(response.headers.get("content-type") || "").toLowerCase();
    if (contentType && !/(?:text\/html|application\/xhtml\+xml)/i.test(contentType)) {
      await response.body?.cancel?.().catch(() => {});
      throw new Error("The website did not return an HTML document.");
    }
    const html = await boundedText(response, Number(options.maxBytes) || MAX_DOCUMENT_BYTES);
    if (!html.trim()) throw new Error("The website returned an empty document.");
    return {
      html: injectDocumentCss(html, response.url || url, customCss, options),
      upstreamUrl: response.url || url
    };
  } finally {
    clearTimeout(timeout);
  }
}

module.exports = {
  fetchPublicOverlay,
  injectDocumentCss,
  isPrivateAddress,
  isPrivateTarget,
  loadInjectedOverlayDocument,
  TRANSPARENT_WEBSITE_GUARD
};
