import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

export const DEFAULT_USER_AGENT =
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";

const TEXT_TYPES = new Set([
  "application/javascript",
  "application/json",
  "application/ld+json",
  "application/xml",
  "image/svg+xml",
  "text/css",
  "text/html",
  "text/javascript",
  "text/plain",
  "text/xml"
]);

const MIME_BY_EXT = new Map([
  [".apng", "image/apng"],
  [".avif", "image/avif"],
  [".css", "text/css"],
  [".gif", "image/gif"],
  [".html", "text/html"],
  [".ico", "image/x-icon"],
  [".jpeg", "image/jpeg"],
  [".jpg", "image/jpeg"],
  [".js", "text/javascript"],
  [".json", "application/json"],
  [".m4a", "audio/mp4"],
  [".mp3", "audio/mpeg"],
  [".mp4", "video/mp4"],
  [".otf", "font/otf"],
  [".png", "image/png"],
  [".svg", "image/svg+xml"],
  [".ttf", "font/ttf"],
  [".webm", "video/webm"],
  [".webp", "image/webp"],
  [".woff", "font/woff"],
  [".woff2", "font/woff2"],
  [".xml", "application/xml"]
]);

const TRANSPARENT_IMAGE_DATA_URI =
  "data:image/gif;base64,R0lGODlhAQABAAAAACwAAAAAAQABAAA=";

export function defaultArchivePath() {
  return process.env.ARCHIVE_PATH || path.join(process.env.TMPDIR || "/tmp", "local-page-archives");
}

export function isHttpUrl(value) {
  return /^https?:\/\//i.test(value);
}

export function isFileUrl(value) {
  return /^file:\/\//i.test(value);
}

export function inputToUrl(input) {
  if (/^[a-z][a-z0-9+.-]*:/i.test(input)) {
    return input;
  }
  return pathToFileURL(path.resolve(input)).href;
}

export function slugForUrl(inputUrl) {
  const url = new URL(inputUrl);
  const stem =
    `${url.hostname}${url.pathname}`
      .replace(/\/+$/, "")
      .replace(/[^a-z0-9]+/gi, "-")
      .replace(/^-+|-+$/g, "")
      .slice(0, 90) || "archive";
  return `${stem}-${new Date().toISOString().replace(/[:.]/g, "-")}`;
}

export function findEffectiveBase(html, fallbackBaseUrl) {
  const match = html.match(/<base\b[^>]*\bhref=(["']?)([^"'\s>]+)\1/i);
  if (!match) {
    return fallbackBaseUrl;
  }
  return resolveUrl(match[2], fallbackBaseUrl) || fallbackBaseUrl;
}

export function resolveUrl(rawUrl, baseUrl) {
  if (!rawUrl) {
    return null;
  }
  const trimmed = htmlDecode(rawUrl.trim());
  if (
    !trimmed ||
    /^(?:undefined|null)$/i.test(trimmed) ||
    trimmed.startsWith("#") ||
    /^%23/i.test(trimmed) ||
    /^(?:about|blob|data|javascript|mailto|tel):/i.test(trimmed)
  ) {
    return trimmed;
  }
  try {
    if (trimmed.startsWith("//") && (!baseUrl || /^file:/i.test(baseUrl))) {
      return `https:${trimmed}`;
    }
    return new URL(trimmed, baseUrl).href;
  } catch {
    return null;
  }
}

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

export function htmlDecode(value) {
  return String(value)
    .replaceAll("&amp;", "&")
    .replaceAll("&quot;", '"')
    .replaceAll("&#39;", "'")
    .replaceAll("&apos;", "'")
    .replaceAll("&lt;", "<")
    .replaceAll("&gt;", ">");
}

export async function replaceAsync(input, regex, replacer) {
  const parts = [];
  let lastIndex = 0;
  for (const match of input.matchAll(regex)) {
    parts.push(input.slice(lastIndex, match.index));
    parts.push(await replacer(match));
    lastIndex = match.index + match[0].length;
  }
  parts.push(input.slice(lastIndex));
  return parts.join("");
}

export class AssetInliner {
  constructor(options = {}) {
    this.userAgent = options.userAgent || DEFAULT_USER_AGENT;
    this.referer = options.referer;
    this.maxAssetBytes = options.maxAssetBytes || 30 * 1024 * 1024;
    this.maxInlineStyleBytes = options.maxInlineStyleBytes || 128 * 1024;
    this.shouldBlockAsset = options.shouldBlockAsset || null;
    this.cache = new Map();
    this.warnings = [];
  }

  async inlineHtml(html, baseUrl, options = {}) {
    const depth = options.depth || 0;
    const effectiveBase = findEffectiveBase(html, baseUrl);
    let output = html;

    output = output.replace(/<base\b[^>]*>/gi, "");
    output = output.replace(/<script\b[\s\S]*?<\/script>/gi, "");
    output = output.replace(/<noscript\b[\s\S]*?<\/noscript>/gi, "");
    output = output.replace(/<link\b[^>]*\brel=(["']?)(?:preconnect|dns-prefetch|modulepreload)\1[^>]*>/gi, "");
    output = output.replace(
      /<link\b(?=[^>]*\brel=(["']?)preload\1)(?=[^>]*\bas=(["']?)script\2)[^>]*>/gi,
      ""
    );

    output = await replaceAsync(output, /<style\b([^>]*)>([\s\S]*?)<\/style>/gi, async (match) => {
      const attrs = match[1] || "";
      const css = await this.inlineCss(match[2] || "", effectiveBase);
      return `<style${attrs}>${css}</style>`;
    });

    output = await replaceAsync(output, /\sstyle=(["'])([\s\S]*?)\1/gi, async (match) => {
      const decoded = htmlDecode(match[2]);
      if (!hasExternalCssReference(decoded)) {
        return match[0];
      }
      if (decoded.length > this.maxInlineStyleBytes) {
        this.warnings.push(`Skipped inline style asset rewrite: ${decoded.length} bytes exceeds ${this.maxInlineStyleBytes}`);
        return match[0];
      }
      const css = await this.inlineCss(decoded, effectiveBase);
      return ` style=${match[1]}${htmlEscape(css)}${match[1]}`;
    });

    output = await replaceAsync(output, /<link\b[^>]*>/gi, async (match) => {
      return this.rewriteLinkTag(match[0], effectiveBase);
    });

    output = await replaceAsync(output, /<iframe\b[^>]*>[\s\S]*?<\/iframe>|<iframe\b[^>]*\/?>/gi, async (match) => {
      if (depth >= 1) {
        return this.rewriteMediaAttributes(match[0], effectiveBase);
      }
      return this.rewriteIframeTag(match[0], effectiveBase, depth);
    });

    output = await replaceAsync(
      output,
      /<(?:img|source|audio|video|track|embed|object|input)\b[^>]*>/gi,
      async (match) => this.rewriteMediaAttributes(match[0], effectiveBase)
    );

    return output;
  }

  async rewriteLinkTag(tag, baseUrl) {
    const rel = getAttribute(tag, "rel") || "";
    const href = getAttribute(tag, "href");
    const asValue = getAttribute(tag, "as") || "";
    if (!href) {
      return tag;
    }

    if (/\bstylesheet\b/i.test(rel)) {
      const absolute = resolveUrl(href, baseUrl);
      if (!absolute || absolute.startsWith("data:")) {
        return "";
      }
      const css = await this.fetchText(absolute, baseUrl, "stylesheet");
      if (css == null) {
        return "";
      }
      const inlinedCss = await this.inlineCss(css, absolute);
      return `<style>${inlinedCss}</style>`;
    }

    const isInlineableLink =
      /\b(?:icon|apple-touch-icon|image_src)\b/i.test(rel) ||
      (/\bpreload\b/i.test(rel) && /^(?:font|image|style)$/i.test(asValue));
    if (!isInlineableLink) {
      if (/\bpreload\b/i.test(rel) && /^script$/i.test(asValue)) {
        return "";
      }
      return tag;
    }
    if (/\bpreload\b/i.test(rel) && /^style$/i.test(asValue)) {
      return "";
    }
    let output = tag;
    if (/\bpreload\b/i.test(rel) && /^image$/i.test(asValue)) {
      const imageSrcset = getAttribute(output, "imagesrcset");
      if (imageSrcset) {
        output = setAttribute(output, "imagesrcset", await this.inlineSrcset(imageSrcset, baseUrl));
      }
    }

    const dataUri = await this.toDataUri(href, baseUrl, linkResourceType(asValue));
    if (!dataUri) {
      return "";
    }
    return setAttribute(output, "href", dataUri);
  }

  async rewriteMediaAttributes(tag, baseUrl) {
    let output = tag;
    const tagName = getTagName(output);
    for (const attr of ["src", "poster", "data"]) {
      const value = getAttribute(output, attr);
      if (!value) {
        continue;
      }
      const dataUri = await this.toDataUri(value, baseUrl, mediaResourceType(tagName, attr));
      if (dataUri) {
        output = setAttribute(output, attr, dataUri);
      } else {
        output = replaceMissingMediaAttribute(output, attr);
      }
    }
    const srcset = getAttribute(output, "srcset");
    if (srcset) {
      const rewritten = await this.inlineSrcset(srcset, baseUrl);
      output = setAttribute(output, "srcset", rewritten);
    }
    return output;
  }

  async rewriteIframeTag(tag, baseUrl, depth) {
    const srcdoc = getAttribute(tag, "srcdoc");
    if (srcdoc) {
      let rewritten = removeAttribute(tag, "src");
      if (depth >= 2) {
        return rewritten;
      }
      const inlined = await this.inlineHtml(srcdoc, baseUrl, { depth: depth + 1 });
      rewritten = setAttribute(rewritten, "srcdoc", inlined);
      return rewritten;
    }

    const src = getAttribute(tag, "src");
    if (!src) {
      return this.rewriteMediaAttributes(tag, baseUrl);
    }
    const absolute = resolveUrl(src, baseUrl);
    if (!absolute || absolute.startsWith("data:")) {
      return tag;
    }
    const text = await this.fetchText(absolute, baseUrl, "subdocument");
    if (text != null) {
      const inlined = await this.inlineHtml(text, absolute, { depth: depth + 1 });
      let rewritten = removeAttribute(tag, "src");
      rewritten = setAttribute(rewritten, "srcdoc", inlined);
      return rewritten;
    }
    return this.rewriteMediaAttributes(tag, baseUrl);
  }

  async inlineSrcset(value, baseUrl) {
    const candidates = splitSrcset(value)
      .map((part) => part.trim())
      .filter(Boolean);
    const rewritten = [];
    for (const candidate of candidates) {
      const [urlPart, ...descriptor] = candidate.split(/\s+/);
      const dataUri = await this.toDataUri(urlPart, baseUrl, "image");
      rewritten.push([dataUri || TRANSPARENT_IMAGE_DATA_URI, ...descriptor].join(" "));
    }
    return rewritten.join(", ");
  }

  async inlineCss(css, baseUrl) {
    let output = await replaceAsync(
      css,
      /@import\s+(?:url\()?["']?([^"')\s;]+)["']?\)?[^;]*;/gi,
      async (match) => {
        const absolute = resolveUrl(match[1], baseUrl);
        if (!absolute || absolute.startsWith("data:")) {
          return "";
        }
        const imported = await this.fetchText(absolute, baseUrl, "stylesheet");
        if (imported == null) {
          return "";
        }
        return this.inlineCss(imported, absolute);
      }
    );

    output = await replaceAsync(output, /url\(\s*(["']?)([^"')]+)\1\s*\)/gi, async (match) => {
      const raw = cleanCssUrl(match[2]);
      if (!raw || raw.startsWith("#") || /^%23/i.test(raw) || /^(?:data|blob|about|javascript):/i.test(raw)) {
        return match[0];
      }
      const dataUri = await this.toDataUri(raw, baseUrl, cssResourceType(raw, baseUrl));
      return dataUri ? `url("${dataUri}")` : "url(about:blank)";
    });

    return output;
  }

  async toDataUri(rawUrl, baseUrl, resourceType = "other") {
    const absolute = resolveUrl(rawUrl, baseUrl);
    if (!absolute || absolute.startsWith("data:")) {
      return absolute;
    }
    const cacheKey = `${resourceType}:${absolute}`;
    if (this.cache.has(cacheKey)) {
      return this.cache.get(cacheKey);
    }
    const asset = await this.fetchAsset(absolute, baseUrl, resourceType);
    if (!asset) {
      this.cache.set(cacheKey, null);
      return null;
    }
    const dataUri = `data:${asset.contentType};base64,${asset.bytes.toString("base64")}`;
    this.cache.set(cacheKey, dataUri);
    return dataUri;
  }

  async fetchText(rawUrl, baseUrl, resourceType = "other") {
    const asset = await this.fetchAsset(rawUrl, baseUrl, resourceType);
    if (!asset) {
      return null;
    }
    const contentType = asset.contentType.split(";")[0].toLowerCase();
    if (!TEXT_TYPES.has(contentType) && !contentType.endsWith("+xml")) {
      return null;
    }
    return asset.bytes.toString("utf8");
  }

  async fetchAsset(rawUrl, baseUrl, resourceType = "other") {
    const absolute = resolveUrl(rawUrl, baseUrl);
    if (!absolute || absolute.startsWith("data:")) {
      return null;
    }
    try {
      if (this.shouldBlockAsset?.(absolute, resourceType)) {
        return null;
      }
      if (isFileUrl(absolute)) {
        const filePath = fileURLToPath(absolute);
        const bytes = await fs.readFile(filePath);
        return {
          bytes,
          contentType: mimeFromUrl(absolute)
        };
      }
      if (!isHttpUrl(absolute)) {
        return null;
      }
      for (let attempt = 1; attempt <= 2; attempt += 1) {
        const controller = new AbortController();
        const timeout = setTimeout(() => controller.abort(), 30000);
        try {
          const response = await fetch(absolute, {
            headers: {
              "user-agent": this.userAgent,
              accept: "*/*",
              ...(this.referer ? { referer: this.referer } : {})
            },
            redirect: "follow",
            signal: controller.signal
          });
          clearTimeout(timeout);
          if (!response.ok) {
            this.warnings.push(`Failed to fetch ${absolute}: HTTP ${response.status}`);
            return null;
          }
          const arrayBuffer = await response.arrayBuffer();
          if (arrayBuffer.byteLength > this.maxAssetBytes) {
            this.warnings.push(`Skipped ${absolute}: ${arrayBuffer.byteLength} bytes exceeds ${this.maxAssetBytes}`);
            return null;
          }
          return {
            bytes: Buffer.from(arrayBuffer),
            contentType: response.headers.get("content-type")?.split(";")[0] || mimeFromUrl(absolute)
          };
        } catch (error) {
          clearTimeout(timeout);
          if (attempt < 2) {
            continue;
          }
          this.warnings.push(`Failed to fetch ${absolute}: ${error.message}`);
          return null;
        }
      }
    } catch (error) {
      this.warnings.push(`Failed to fetch ${absolute}: ${error.message}`);
      return null;
    }
  }
}

export function splitSrcset(value) {
  const candidates = [];
  let current = "";
  let quote = "";
  let parenDepth = 0;

  for (let index = 0; index < value.length; index += 1) {
    const ch = value[index];

    if (quote) {
      current += ch;
      if (ch === quote) {
        quote = "";
      }
      continue;
    }

    if (ch === '"' || ch === "'") {
      quote = ch;
      current += ch;
      continue;
    }

    if (ch === "(") {
      parenDepth += 1;
      current += ch;
      continue;
    }

    if (ch === ")" && parenDepth > 0) {
      parenDepth -= 1;
      current += ch;
      continue;
    }

    if (ch === "," && parenDepth === 0 && isSrcsetCandidateSeparator(value, index, current)) {
      if (current.trim()) {
        candidates.push(current.trim());
      }
      current = "";
      while (index + 1 < value.length && /\s/.test(value[index + 1])) {
        index += 1;
      }
      continue;
    }

    current += ch;
  }

  if (current.trim()) {
    candidates.push(current.trim());
  }
  return candidates;
}

function isSrcsetCandidateSeparator(value, commaIndex, currentCandidate) {
  const after = value.slice(commaIndex + 1);
  if (!after.trim()) {
    return true;
  }
  if (/^\s/.test(after)) {
    return true;
  }

  const tokens = currentCandidate.trim().split(/\s+/);
  const descriptor = tokens.at(-1) || "";
  return /^(?:\d+(?:\.\d+)?[wxh]|\d+(?:\.\d+)?x)$/i.test(descriptor);
}

function mimeFromUrl(rawUrl) {
  let pathname = rawUrl;
  try {
    pathname = new URL(rawUrl).pathname;
  } catch {
    // Keep raw string.
  }
  return MIME_BY_EXT.get(path.extname(pathname).toLowerCase()) || "application/octet-stream";
}

function linkResourceType(asValue) {
  const normalized = String(asValue || "").toLowerCase();
  if (normalized === "font") return "font";
  if (normalized === "script") return "script";
  if (normalized === "style") return "stylesheet";
  if (normalized === "document") return "subdocument";
  if (normalized === "audio" || normalized === "video") return "media";
  return "image";
}

function mediaResourceType(tagName, attr) {
  if (tagName === "iframe") return "subdocument";
  if (tagName === "object" || tagName === "embed") return "object";
  if (tagName === "audio" || tagName === "video") return "media";
  if (attr === "poster") return "image";
  if (tagName === "track") return "other";
  return "image";
}

function cssResourceType(rawUrl, baseUrl) {
  const absolute = resolveUrl(rawUrl, baseUrl) || rawUrl;
  let pathname = absolute;
  try {
    pathname = new URL(absolute).pathname;
  } catch {
    // Keep raw string.
  }
  const ext = path.extname(pathname).toLowerCase();
  if ([".woff", ".woff2", ".ttf", ".otf"].includes(ext)) return "font";
  if ([".mp4", ".webm", ".mp3", ".m4a"].includes(ext)) return "media";
  return "image";
}

function getTagName(markup) {
  return markup.match(/^<([a-z0-9:-]+)/i)?.[1]?.toLowerCase() || "";
}

function getAttribute(tag, attr) {
  const openingTag = getOpeningTag(tag);
  if (!openingTag) {
    return null;
  }
  const match = findAttribute(openingTag, attr);
  return match ? htmlDecode(match.value) : null;
}

function setAttribute(tag, attr, value) {
  const escaped = htmlEscape(value);
  return replaceOpeningTag(tag, (openingTag) => {
    const match = findAttribute(openingTag, attr);
    if (match) {
      return `${openingTag.slice(0, match.start)}${attr}="${escaped}"${openingTag.slice(match.end)}`;
    }

    const selfClosing = /\/\s*>$/.test(openingTag);
    const closeIndex = openingTag.lastIndexOf(">");
    const beforeClose = openingTag.slice(0, closeIndex).replace(/\s*\/\s*$/, "");
    return `${beforeClose} ${attr}="${escaped}"${selfClosing ? " /" : ""}>`;
  });
}

function removeAttribute(tag, attr) {
  return replaceOpeningTag(tag, (openingTag) => {
    const match = findAttribute(openingTag, attr);
    if (!match) {
      return openingTag;
    }

    let start = match.start;
    while (start > 0 && /\s/.test(openingTag[start - 1])) {
      start -= 1;
    }
    return `${openingTag.slice(0, start)}${openingTag.slice(match.end)}`;
  });
}

function replaceMissingMediaAttribute(tag, attr) {
  const tagName = tag.match(/^<([a-z0-9:-]+)/i)?.[1]?.toLowerCase() || "";
  if (attr === "src" && (tagName === "img" || tagName === "input")) {
    return setAttribute(tag, attr, TRANSPARENT_IMAGE_DATA_URI);
  }
  return removeAttribute(tag, attr);
}

function hasExternalCssReference(css) {
  if (/@import\b/i.test(css)) {
    return true;
  }
  for (const match of css.matchAll(/url\(\s*(["']?)([^"')]+)\1\s*\)/gi)) {
    const raw = cleanCssUrl(match[2]);
    if (raw && !raw.startsWith("#") && !/^%23/i.test(raw) && !/^(?:data|blob|about|javascript):/i.test(raw)) {
      return true;
    }
  }
  return false;
}

function cleanCssUrl(value) {
  const decoded = htmlDecode(String(value).trim());
  const quote = decoded[0];
  if ((quote === '"' || quote === "'") && decoded.at(-1) === quote) {
    return decoded.slice(1, -1).trim();
  }
  return decoded;
}

function getOpeningTag(markup) {
  const end = openingTagEndIndex(markup);
  return end >= 0 ? markup.slice(0, end + 1) : null;
}

function replaceOpeningTag(markup, replacer) {
  const end = openingTagEndIndex(markup);
  if (end < 0) {
    return markup;
  }
  return `${replacer(markup.slice(0, end + 1))}${markup.slice(end + 1)}`;
}

function openingTagEndIndex(markup) {
  let quote = "";
  for (let i = 0; i < markup.length; i += 1) {
    const ch = markup[i];
    if (quote) {
      if (ch === quote) {
        quote = "";
      }
      continue;
    }
    if (ch === '"' || ch === "'") {
      quote = ch;
      continue;
    }
    if (ch === ">") {
      return i;
    }
  }
  return -1;
}

function findAttribute(openingTag, attr) {
  const attrLower = attr.toLowerCase();
  const nameMatch = openingTag.match(/^<[^\s/>]+/);
  let index = nameMatch ? nameMatch[0].length : 1;

  while (index < openingTag.length) {
    while (index < openingTag.length && /\s/.test(openingTag[index])) {
      index += 1;
    }
    if (index >= openingTag.length || openingTag[index] === ">" || openingTag[index] === "/") {
      return null;
    }

    const start = index;
    while (index < openingTag.length && !/[\s=/>]/.test(openingTag[index])) {
      index += 1;
    }
    const name = openingTag.slice(start, index);

    while (index < openingTag.length && /\s/.test(openingTag[index])) {
      index += 1;
    }

    let value = "";
    if (openingTag[index] === "=") {
      index += 1;
      while (index < openingTag.length && /\s/.test(openingTag[index])) {
        index += 1;
      }

      const quote = openingTag[index];
      if (quote === '"' || quote === "'") {
        index += 1;
        const valueStart = index;
        while (index < openingTag.length && openingTag[index] !== quote) {
          index += 1;
        }
        value = openingTag.slice(valueStart, index);
        if (openingTag[index] === quote) {
          index += 1;
        }
      } else {
        const valueStart = index;
        while (index < openingTag.length && !/[\s>]/.test(openingTag[index])) {
          index += 1;
        }
        value = openingTag.slice(valueStart, index);
      }
    }

    if (name.toLowerCase() === attrLower) {
      return { start, end: index, value };
    }
  }

  return null;
}
