(function (root, factory) {
  "use strict";
  const api = factory();
  if (typeof module === "object" && module.exports) module.exports = api;
  root.SDMCollectorV2 = api;
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
  "use strict";

  const ASIN_PATTERN = /^[A-Z0-9]{10}$/;
  const IMAGE_ROLE_ORDER = Object.fromEntries([
    ["MAIN", 0],
    ...Array.from({ length: 20 }, (_, index) => [`PT${String(index + 1).padStart(2, "0")}`, index + 1])
  ]);

  const clean = value => String(value == null ? "" : value).replace(/\s+/g, " ").trim();
  const isColorDimension = name => /colou?r|\u989c\u8272|\u984f\u8272|farbe|couleur|colore|coloris|kleur/i.test(clean(name));
  const normalizeAsin = value => {
    const asin = clean(value).toUpperCase();
    return ASIN_PATTERN.test(asin) ? asin : "";
  };

  function decodeHtml(value) {
    return String(value || "")
      .replace(/&quot;|&#34;/gi, '"')
      .replace(/&apos;|&#39;/gi, "'")
      .replace(/&amp;/gi, "&")
      .replace(/&lt;/gi, "<")
      .replace(/&gt;/gi, ">")
      .replace(/\\u0026/gi, "&")
      .replace(/\\u003d/gi, "=")
      .replace(/\\u002f/gi, "/")
      .replace(/\\\//g, "/");
  }

  function normalizeAmazonImageUrl(value) {
    const url = clean(decodeHtml(value)).replace(/^['"]|['"]$/g, "");
    return /^https?:\/\/(?:[^/]+\.)?(?:media-amazon|ssl-images-amazon)\.[^/]+\/images\/I\//i.test(url) ? url : "";
  }

  function imageIdentity(value) {
    const url = normalizeAmazonImageUrl(value) || (/^https?:\/\//i.test(clean(value)) ? clean(value) : "");
    const match = url.match(/\/images\/I\/([^./?]+)(?:\._[^.]+_)?\.(?:jpe?g|png|webp)(?:[?#]|$)/i);
    return match ? match[1].toLowerCase() : url.toLowerCase();
  }

  function imageScore(record) {
    const sourceScore = { hiRes: 5000000, large: 4000000, main: 3000000, oldHires: 2500000, dynamic: 2000000, thumb: 1000000 }[record.source] || 0;
    return sourceScore + Math.max(0, Number(record.width) || 0) * Math.max(0, Number(record.height) || 0);
  }

  function dedupeImageRecords(records) {
    const best = new Map();
    for (const raw of Array.isArray(records) ? records : []) {
      const candidate = typeof raw === "string" ? raw : raw?.url;
      const url = normalizeAmazonImageUrl(candidate) || (/^https?:\/\//i.test(clean(candidate)) ? clean(candidate) : "");
      if (!url) continue;
      const record = typeof raw === "string" ? { url, role: "", width: 0, height: 0, source: "embedded" } : { ...raw, url };
      const key = imageIdentity(url);
      const current = best.get(key);
      if (!current || imageScore(record) > imageScore(current)) best.set(key, record);
    }
    return [...best.values()].sort((left, right) => {
      const role = (IMAGE_ROLE_ORDER[left.role] ?? 99) - (IMAGE_ROLE_ORDER[right.role] ?? 99);
      return role || imageScore(right) - imageScore(left);
    });
  }

  function balancedAt(source, start) {
    const opener = source[start];
    const closer = opener === "{" ? "}" : opener === "[" ? "]" : "";
    if (!closer) return "";
    let depth = 0, quote = "", escaped = false;
    for (let index = start; index < source.length; index += 1) {
      const character = source[index];
      if (quote) {
        if (escaped) escaped = false;
        else if (character === "\\") escaped = true;
        else if (character === quote) quote = "";
        continue;
      }
      if (character === '"' || character === "'") quote = character;
      else if (character === opener) depth += 1;
      else if (character === closer && --depth === 0) return source.slice(start, index + 1);
    }
    return "";
  }

  function valuesForToken(source, token) {
    const values = [];
    let cursor = 0;
    while (cursor < source.length) {
      const found = source.indexOf(token, cursor);
      if (found < 0) break;
      const colon = source.indexOf(":", found + token.length);
      if (colon < 0 || colon - found > 120) { cursor = found + token.length; continue; }
      const objectAt = source.indexOf("{", colon + 1);
      const arrayAt = source.indexOf("[", colon + 1);
      let start = -1;
      if (objectAt >= 0 && arrayAt >= 0) start = Math.min(objectAt, arrayAt);
      else start = Math.max(objectAt, arrayAt);
      if (start < 0 || start - colon > 80) { cursor = colon + 1; continue; }
      const text = balancedAt(source, start);
      if (text) values.push(text);
      cursor = start + Math.max(1, text.length);
    }
    return values;
  }

  function parseJson(text) {
    try { return JSON.parse(decodeHtml(text)); } catch { return null; }
  }

  function javascriptStringAt(source, start) {
    const quote = source[start];
    if (quote !== "'" && quote !== '"') return null;
    let value = "";
    for (let index = start + 1; index < source.length; index += 1) {
      const character = source[index];
      if (character === quote) return { value, end: index + 1 };
      if (character !== "\\") {
        value += character;
        continue;
      }
      const escaped = source[++index];
      if (escaped == null) return null;
      const simple = { n: "\n", r: "\r", t: "\t", b: "\b", f: "\f", v: "\v", 0: "\0" };
      if (Object.hasOwn(simple, escaped)) value += simple[escaped];
      else if (escaped === "x" && /^[0-9a-f]{2}$/i.test(source.slice(index + 1, index + 3))) {
        value += String.fromCharCode(parseInt(source.slice(index + 1, index + 3), 16));
        index += 2;
      } else if (escaped === "u" && /^[0-9a-f]{4}$/i.test(source.slice(index + 1, index + 5))) {
        value += String.fromCharCode(parseInt(source.slice(index + 1, index + 5), 16));
        index += 4;
      } else value += escaped;
    }
    return null;
  }

  function imageArrayAt(source, start) {
    let cursor = start;
    while (/\s/.test(source[cursor] || "")) cursor += 1;
    if (source[cursor] === "[") {
      const arrayText = balancedAt(source, cursor);
      return arrayText ? { arrayText, end: cursor + arrayText.length } : null;
    }
    const wrapper = source.slice(cursor).match(/^A\.\$\.parseJSON\s*\(\s*/);
    if (!wrapper) return null;
    const literal = javascriptStringAt(source, cursor + wrapper[0].length);
    if (!literal) return null;
    const parsed = parseJson(literal.value);
    return Array.isArray(parsed) ? { arrayText: JSON.stringify(parsed), end: literal.end } : null;
  }

  function quotedProperty(text, key) {
    const match = String(text || "").match(new RegExp(`["']${key}["']\\s*:\\s*(?:null|["']([^"']*)["'])`, "i"));
    return match?.[1] || "";
  }

  function numericProperty(text, key) {
    const match = String(text || "").match(new RegExp(`["']${key}["']\\s*:\\s*(\\d+)`, "i"));
    return match ? Number(match[1]) : 0;
  }

  function htmlAttribute(tag, name) {
    const escaped = String(name || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    const match = String(tag || "").match(new RegExp(`\\b${escaped}\\s*=\\s*(["'])([\\s\\S]*?)\\1`, "i"));
    return match ? decodeHtml(match[2]) : "";
  }

  function canonicalVariantDimensions(dimensions, values) {
    const names = Array.isArray(dimensions) ? dimensions.map(clean) : [];
    const normalizedValues = Array.isArray(values) ? values.map(clean) : [];
    const attributes = {};
    names.forEach((name, index) => { if (name && normalizedValues[index]) attributes[name] = normalizedValues[index]; });
    const colors = [];
    const secondary = [];
    normalizedValues.forEach((value, index) => {
      if (!value) return;
      if (isColorDimension(names[index])) colors.push(value);
      else secondary.push(value);
    });
    return { attributes, color: [...new Set(colors)].join(" / "), size: [...new Set(secondary)].join(" / ") };
  }

  function imageRecordsFromArray(arrayText) {
    const records = [];
    for (let index = 0; index < arrayText.length; index += 1) {
      if (arrayText[index] !== "{") continue;
      const objectText = balancedAt(arrayText, index);
      if (!objectText) continue;
      index += objectText.length - 1;
      const role = clean(quotedProperty(objectText, "variant") || quotedProperty(objectText, "role")).toUpperCase();
      if (role && !Object.hasOwn(IMAGE_ROLE_ORDER, role)) continue;
      const candidates = [
        ["hiRes", quotedProperty(objectText, "hiRes")],
        ["large", quotedProperty(objectText, "large")],
        ["main", quotedProperty(objectText, "main") || quotedProperty(objectText, "mainUrl")],
        ["thumb", quotedProperty(objectText, "thumb")]
      ];
      for (const [source, candidate] of candidates) {
        const url = normalizeAmazonImageUrl(candidate);
        if (!url) continue;
        records.push({ role, url, width: numericProperty(objectText, "width"), height: numericProperty(objectText, "height"), source });
        break;
      }
    }
    return dedupeImageRecords(records);
  }

  function colorImageSets(source, ownerAsin = "") {
    const result = {};
    const wanted = normalizeAsin(ownerAsin);
    const scripts = String(source || "").match(/<script\b[^>]*>[\s\S]*?<\/script>/gi) || [String(source || "")];
    // Amazon pages also embed recommendation widgets and sibling carousels
    // containing their own colorImages objects.  The main ImageBlockATF/BTF
    // script is page-owned even though Amazon commonly omits the ASIN from
    // that script, so accept either explicit ASIN ownership or that narrowly
    // named image-block registration.  Generic recommendation scripts remain
    // excluded.
    const isPageImageBlock = script => /["']ImageBlock(?:ATF|BTF)["']/i.test(script)
      && /["']colorImages["']/i.test(script);
    const ownedSources = wanted
      ? scripts.filter(script => script.includes(wanted) || isPageImageBlock(script))
      : scripts.filter(script => isPageImageBlock(script));
    for (const script of ownedSources) for (const objectText of valuesForToken(script, "colorImages")) {
      const propertyPattern = /["']([^"']+)["']\s*:\s*/g;
      let match;
      while ((match = propertyPattern.exec(objectText))) {
        const value = imageArrayAt(objectText, propertyPattern.lastIndex);
        if (!value) continue;
        const records = imageRecordsFromArray(value.arrayText);
        if (records.length && records.length > (result[match[1]]?.length || 0)) result[match[1]] = records;
        propertyPattern.lastIndex = value.end;
      }
    }
    return result;
  }

  function walkAsinImages(value, output, currentAsin = "") {
    if (Array.isArray(value)) {
      const records = [];
      for (const entry of value) {
        if (entry && typeof entry === "object") {
          const role = clean(entry.variant || entry.role).toUpperCase();
          const candidate = entry.hiRes || entry.large || entry.main || entry.mainUrl || entry.thumb;
          const url = normalizeAmazonImageUrl(candidate);
          if (url && (!role || Object.hasOwn(IMAGE_ROLE_ORDER, role))) records.push({ role, url, width: Number(entry.width) || 0, height: Number(entry.height) || 0, source: entry.hiRes ? "hiRes" : entry.large ? "large" : entry.thumb ? "thumb" : "main" });
        }
        walkAsinImages(entry, output, currentAsin);
      }
      if (currentAsin && records.length) output[currentAsin] = dedupeImageRecords([...(output[currentAsin] || []), ...records]);
      return;
    }
    if (!value || typeof value !== "object") return;
    const ownAsin = normalizeAsin(value.asin || value.ASIN || currentAsin);
    for (const [key, child] of Object.entries(value)) {
      walkAsinImages(child, output, normalizeAsin(key) || ownAsin);
    }
  }

  function extractEmbeddedVariationData(rawHtml, requestedAsin = "") {
    const rawSource = String(rawHtml || "");
    const html = decodeHtml(rawSource);
    const currentAsin = normalizeAsin(requestedAsin)
      || normalizeAsin(html.match(/(?:id|name)=["']ASIN["'][^>]*value=["']([A-Z0-9]{10})/i)?.[1])
      || normalizeAsin(html.match(/\/dp\/([A-Z0-9]{10})(?:[/?#"'])/i)?.[1]);
    const parentAsin = normalizeAsin(html.match(/["']parentAsin["']\s*:\s*["']([A-Z0-9]{10})/i)?.[1]);
    const dimensions = valuesForToken(html, "dimensions").map(parseJson).find(Array.isArray) || [];
    const displayValues = {};
    for (const valueText of valuesForToken(html, "dimensionValuesDisplayData")) {
      const parsed = parseJson(valueText);
      if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) Object.assign(displayValues, parsed);
    }
    const colorToAsin = {};
    for (const valueText of valuesForToken(html, "colorToAsin")) {
      const parsed = parseJson(valueText);
      if (!parsed || typeof parsed !== "object") continue;
      for (const [color, raw] of Object.entries(parsed)) {
        const asin = normalizeAsin(typeof raw === "string" ? raw : raw?.asin || raw?.ASIN);
        if (asin) colorToAsin[color] = asin;
      }
    }
    const hasColorDimension = dimensions.some(name => isColorDimension(name));
    const rows = new Map();
    for (const [rawAsin, rawValues] of Object.entries(displayValues)) {
      const asin = normalizeAsin(rawAsin);
      if (!asin) continue;
      const values = Array.isArray(rawValues) ? rawValues.map(clean) : [];
      const canonical = canonicalVariantDimensions(dimensions, values);
      rows.set(asin, { asin, attributes: canonical.attributes, options: values, color: canonical.color, size: canonical.size, images: [], imageRecords: [] });
    }
    for (const [color, asin] of Object.entries(colorToAsin)) {
      const row = rows.get(asin) || { asin, attributes: {}, options: [], color: "", size: "", images: [], imageRecords: [] };
      if (!row.color && hasColorDimension) row.color = clean(color);
      if (!Object.keys(row.attributes).some(name => /colou?r/i.test(name))) row.attributes.Color = row.color;
      rows.set(asin, row);
    }
    if (currentAsin && !rows.has(currentAsin)) rows.set(currentAsin, { asin: currentAsin, attributes: {}, options: [], color: "", size: "", images: [], imageRecords: [] });

    const sets = colorImageSets(html, currentAsin);
    for (const [name, records] of Object.entries(sets)) {
      let asin = name.toLowerCase() === "initial" ? currentAsin : "";
      // colorToAsin is an explicit page-owned mapping.  Do not guess an ASIN
      // from a repeated colour label: several sizes can share one colour and
      // the first matching row would receive another variant's gallery.
      if (!asin) asin = colorToAsin[name] || "";
      if (!asin) continue;
      const row = rows.get(asin) || { asin, attributes: {}, options: [], color: hasColorDimension ? name : "", size: "", images: [], imageRecords: [] };
      row.imageRecords = dedupeImageRecords([...(row.imageRecords || []), ...records]);
      row.images = row.imageRecords.map(record => record.url);
      if (name.toLowerCase() === "initial") row.imageGallerySource = "detail-gallery";
      else if (!row.imageGallerySource) row.imageGallerySource = "variant-main";
      rows.set(asin, row);
    }

    const mappedImages = {};
    for (const token of ["imageBlockVariations", "ImageBlockATF", "asinVariationValues"]) {
      for (const valueText of valuesForToken(html, token)) {
        const parsed = parseJson(valueText);
        if (parsed) walkAsinImages(parsed, mappedImages);
      }
    }
    for (const [asin, records] of Object.entries(mappedImages)) {
      const row = rows.get(asin) || { asin, attributes: {}, options: [], color: "", size: "", images: [], imageRecords: [] };
      row.imageRecords = dedupeImageRecords([...(row.imageRecords || []), ...records]);
      row.images = row.imageRecords.map(record => record.url);
      rows.set(asin, row);
    }

    const primaryImageTags = (rawSource.match(/<img\b[^>]*>/gi) || []).filter(tag => {
      const id = htmlAttribute(tag, "id");
      // Generic a-dynamic-image is used by recommendation cards across the
      // whole document.  Only the product image-block ids are ASIN-owned.
      return /^(?:landingImage|imgBlkFront)$/i.test(id);
    });
    const oldHires = primaryImageTags
      .map(tag => ({ role: "", url: htmlAttribute(tag, "data-old-hires"), source: "oldHires" }))
      .filter(record => record.url);
    const dynamics = [];
    for (const tag of primaryImageTags) {
      const parsed = parseJson(htmlAttribute(tag, "data-a-dynamic-image"));
      if (!parsed || typeof parsed !== "object") continue;
      for (const [url, size] of Object.entries(parsed)) dynamics.push({ role: "", url, width: Number(size?.[0]) || 0, height: Number(size?.[1]) || 0, source: "dynamic" });
    }
    if (currentAsin) {
      const row = rows.get(currentAsin);
      row.imageRecords = dedupeImageRecords([...(row.imageRecords || []), ...oldHires, ...dynamics]);
      row.images = row.imageRecords.map(record => record.url);
    }

    const variantRows = [...rows.values()].map(row => ({ ...row, selected: row.asin === currentAsin, image: row.images[0] || "" }));
    return {
      currentAsin,
      parentAsin,
      variantRows,
      diagnostics: {
        variantCount: variantRows.length,
        embeddedImageCounts: Object.fromEntries(variantRows.map(row => [row.asin, row.images.length])),
        embeddedFields: [dimensions.length ? "dimensions" : "", Object.keys(displayValues).length ? "dimensionValuesDisplayData" : "", Object.keys(sets).length ? "colorImages" : "", Object.keys(mappedImages).length ? "imageBlockVariations" : ""].filter(Boolean)
      }
    };
  }

  function mergeVariantData(product, embedded) {
    const merged = { ...(product || {}) };
    const currentAsin = normalizeAsin(embedded?.currentAsin || merged.asin);
    const existingRows = Array.isArray(merged.variantRows) ? merged.variantRows : [];
    const rows = new Map();
    for (const row of [...existingRows, ...(embedded?.variantRows || [])]) {
      const asin = normalizeAsin(row?.asin);
      if (!asin) continue;
      const previous = rows.get(asin) || {};
      const incomingIsScoped = Array.isArray(row?.imageRecords) && row.imageRecords.length > 0;
      const incomingIsDetailGallery = row.imageGallerySource === "detail-gallery";
      const previousIsDetailGallery = previous.imageGallerySource === "detail-gallery";
      // A colour/swatch mapping normally exposes only one MAIN image.  It must
      // never replace a previously confirmed full detail gallery for the same
      // ASIN.  Only an `initial` gallery is authoritative for that replacement.
      const images = dedupeImageRecords(
        incomingIsDetailGallery ? row.images
          : previousIsDetailGallery ? previous.images
            : incomingIsScoped ? row.images
              : [...(previous.images || []), ...(row.images || [])]
      ).map(record => record.url);
      rows.set(asin, {
        ...previous,
        ...row,
        asin,
        attributes: { ...(previous.attributes || {}), ...(row.attributes || {}) },
        color: row.color || previous.color || "",
        size: row.size || previous.size || "",
        imageGallerySource: incomingIsDetailGallery ? "detail-gallery" : (previous.imageGallerySource || row.imageGallerySource || ""),
        images,
        image: images[0] || row.image || previous.image || "",
        selected: asin === currentAsin,
        detailFetched: Boolean(previous.detailFetched || row.detailFetched || asin === currentAsin)
      });
    }
    if (!rows.size && merged.asin) rows.set(merged.asin, { asin: merged.asin, selected: true, images: merged.images || [], image: merged.image || merged.images?.[0] || "", attributes: {} });
    merged.asin = currentAsin || merged.asin || "";
    merged.parentAsin = embedded?.parentAsin || merged.parentAsin || "";
    merged.variantRows = [...rows.values()];
    merged.variants = merged.variantRows.map(row => ({ ...row, value: [row.color, row.size].filter(Boolean).join(" / "), dimension: "组合变体" }));
    const current = rows.get(merged.asin);
    if (current) {
      current.price = merged.price ?? current.price ?? current.originalPrice ?? "";
      current.originalPrice = merged.price ?? current.originalPrice ?? current.price ?? "";
      current.currency = merged.currency || current.currency || current.priceCurrency || "";
      current.priceCurrency = merged.currency || current.priceCurrency || current.currency || "";
      current.detailFetched = true;
    }
    if (current?.images?.length) {
      merged.images = current.images;
      merged.image = current.images[0];
    }
    merged.collectorV2Diagnostics = embedded?.diagnostics || {};
    return merged;
  }

  function variantNeedsFetch(row) {
    // Embedded Amazon variation widgets may contain several thumbnail URLs but
    // they do not contain the selected sibling's full gallery or reliable price.
    // A sibling is complete only after its own detail document was parsed.
    return Boolean(normalizeAsin(row?.asin)) && row?.detailFetched !== true && row?.detailFetchFailed !== true;
  }

  function validateCollectionResult(product) {
    const problems = [];
    if (!normalizeAsin(product?.asin)) problems.push("missing_asin");
    if (!clean(product?.title)) problems.push("missing_title");
    if (!Array.isArray(product?.images) || !product.images.length) problems.push("missing_main_images");
    for (const row of product?.variantRows || []) {
      if (!normalizeAsin(row.asin)) problems.push("invalid_variant_asin");
      if (!Array.isArray(row.images) || !row.images.length) problems.push(`missing_variant_images:${row.asin}`);
      if (row.asin !== product?.asin && row.detailFetched !== true) problems.push(`missing_variant_detail:${row.asin}`);
      if (row.asin !== product?.asin && (row.price == null || row.price === "")) problems.push(`missing_variant_price:${row.asin}`);
    }
    return { complete: problems.length === 0, problems };
  }

  return {
    clean,
    normalizeAsin,
    decodeHtml,
    normalizeAmazonImageUrl,
    imageIdentity,
    dedupeImageRecords,
    canonicalVariantDimensions,
    balancedAt,
    valuesForToken,
    extractEmbeddedVariationData,
    mergeVariantData,
    variantNeedsFetch,
    validateCollectionResult
  };
});
