"""
Smile.One Scraper - Playwright version (full JS-rendered page)
ဂိမ်းအားလုံး ရယူရန် JavaScript စာမျက်နှာကို render လုပ်သည်
"""

import asyncio
import json
import os
import re
from decimal import Decimal, ROUND_HALF_UP
import time
from pathlib import Path
from typing import Optional
from urllib.parse import urljoin

from package_display import (
    _is_mlbb_product_url,
    collapse_merged_gem_diamond_tier_label,
    collapse_merged_pt_pack_tiers,
    collapse_merged_smile_tier_label,
    collapse_merged_tokens_tier_label,
    collapse_merged_uc_tier_label,
    is_spurious_package_label,
    strip_bonus_extra_noise,
)

from currency import parse_brl_to_cents, parse_php_amount_from_text, parse_price_to_brl_cents
from config import playwright_effective_channel

try:
    from playwright.async_api import async_playwright
except ImportError:
    async_playwright = None

BASE_URL = "https://www.smile.one"

_BRL_IN_TEXT = re.compile(r"R\$\s*[0-9\.\,]+", re.IGNORECASE)
_PHP_IN_TEXT = re.compile(r"(?:₱|\u20B1|PHP)\s*[0-9\.\,]+", re.IGNORECASE)


def _brl_text_from_number(val: float) -> str:
    """Format as Brazilian-style text so parse_brl_to_cents accepts it (e.g. R$ 19,90)."""
    cents = int(round(float(val) * 100))
    if cents <= 0:
        return ""
    whole, frac = divmod(cents, 100)
    # Thousands: 1.234,56
    w = f"{whole:,}".replace(",", ".")
    return f"R$ {w},{frac:02d}"


def _extract_packages_from_json_blob(obj, depth: int = 0) -> list[dict]:
    """
    Recursively find price-like strings in API JSON (best-effort).
    """
    out: list[dict] = []
    if depth > 25:
        return out

    price_keys = (
        "price",
        "amount",
        "valor",
        "sellprice",
        "sale_price",
        "goods_price",
        "product_price",
        "pay_amount",
        "brl",
        "real",
    )

    if isinstance(obj, dict):
        for k, v in obj.items():
            lk = str(k).lower()
            if isinstance(v, (str, int, float)) and not isinstance(v, bool):
                if isinstance(v, float) or isinstance(v, int):
                    if lk in price_keys:
                        fv = float(v)
                        if 0.01 <= fv <= 500_000:
                            pt = _brl_text_from_number(fv)
                            if pt:
                                out.append({"name": str(k)[:80], "price_text": pt})
                s = str(v).strip()
                if "R$" in s and _BRL_IN_TEXT.search(s):
                    m = _BRL_IN_TEXT.search(s)
                    if m:
                        name = s.replace(m.group(0), "").strip() or lk
                        out.append({"name": name[:120], "price_text": m.group(0).strip()})
            else:
                out.extend(_extract_packages_from_json_blob(v, depth + 1))
    elif isinstance(obj, list):
        for item in obj:
            out.extend(_extract_packages_from_json_blob(item, depth + 1))

    return out


def _merge_package_rows(rows: list[dict]) -> list[dict]:
    """Dedupe by (price_text, normalized name)."""
    seen = set()
    merged = []
    for r in rows:
        name = (r.get("name") or "").strip()
        pt = (r.get("price_text") or "").strip()
        if not pt:
            continue
        key = (pt, name[:80])
        if key in seen:
            continue
        seen.add(key)
        merged.append({
            "name": name or "",
            "price_text": pt,
            "has_buy": bool(r.get("has_buy", True)),
            "smile_li_id": r.get("smile_li_id", ""),
        })
    return merged


def _norm_package_label_key(name: str) -> str:
    """Normalize label so same product with minor spacing differences dedupes, but different SKUs at same price stay."""
    s = (name or "").strip().lower()
    s = re.sub(r"\s*×\s*", "×", s)
    s = re.sub(r"\s+", " ", s)
    return s[:120]


def _dedupe_by_brl_cents(rows: list[dict]) -> list[dict]:
    """
    One row per (BRL amount + package label). Same price in different SKUs (e.g. two R$ 4,00 packs on ML)
    must both appear — do not collapse by cents only.
    """

    def _score(row: dict) -> tuple:
        name = (row.get("name") or "").strip()
        pt = (row.get("price_text") or "").strip()
        if name in ("(=)", "=", "R$") or len(name) < 2:
            good_name = 0
        else:
            good_name = 1
        mp = _BRL_IN_TEXT.search(pt) if pt else None
        mn = _BRL_IN_TEXT.search(name) if name else None
        name_is_just_price = bool(
            mp and mn and mp.group(0).replace(" ", "").lower() == mn.group(0).replace(" ", "").lower()
        )
        return (
            bool(row.get("has_buy")),
            good_name,
            0 if name_is_just_price else 1,
            len(name),
        )

    by_key: dict[tuple[int, str], dict] = {}
    for i, r in enumerate(rows):
        pt = (r.get("price_text") or "").strip()
        cents = parse_brl_to_cents(pt)
        if cents is None:
            continue
        name = (r.get("name") or "").strip()
        lk = _norm_package_label_key(name)
        # Same BRL amount + empty/bad label used to collapse different MLBB SKUs into one row.
        if not name or len(name) < 2 or _is_price_like_label(name):
            lk = f"__row_{i}__"
        key = (cents, lk)
        cur = by_key.get(key)
        if cur is None or _score(r) > _score(cur):
            by_key[key] = r

    ordered = sorted(by_key.keys(), key=lambda k: (k[0], k[1]))
    return [by_key[k] for k in ordered]


def _normalize_rows_to_php(rows: list[dict]) -> list[dict]:
    """Keep ₱ prices as normalized ₱ text (no BRL conversion)."""
    out: list[dict] = []
    for r in rows:
        pt = (r.get("price_text") or "").strip()
        php_amt = parse_php_amount_from_text(pt)
        if php_amt is None:
            continue
        q = php_amt.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
        price_text = f"₱ {q}"
        nm = (r.get("name") or "").strip()
        if _is_price_like_label(nm):
            nm = ""
        out.append(
            {
                "name": nm,
                "price_text": price_text,
                "has_buy": bool(r.get("has_buy", True)),
                "smile_li_id": r.get("smile_li_id", ""),
            }
        )
    return out


def _dedupe_by_php_cents(rows: list[dict]) -> list[dict]:
    """One row per (PHP minor units + package label). Preserve first-seen DOM order (critical for /ph/ + li index)."""

    def _score(row: dict) -> tuple:
        name = (row.get("name") or "").strip()
        pt = (row.get("price_text") or "").strip()
        if name in ("(=)", "=", "₱") or len(name) < 2:
            good_name = 0
        else:
            good_name = 1
        mp = _PHP_IN_TEXT.search(pt) if pt else None
        mn = _PHP_IN_TEXT.search(name) if name else None
        name_is_just_price = bool(
            mp and mn and mp.group(0).replace(" ", "").lower() == mn.group(0).replace(" ", "").lower()
        )
        return (
            bool(row.get("has_buy")),
            good_name,
            0 if name_is_just_price else 1,
            len(name),
        )

    def _key_for_row(i: int, r: dict) -> tuple[int, str] | None:
        pt = (r.get("price_text") or "").strip()
        php_amt = parse_php_amount_from_text(pt)
        if php_amt is None:
            return None
        cents = int((php_amt * Decimal(100)).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
        name = (r.get("name") or "").strip()
        lk = _norm_package_label_key(name)
        if not name or len(name) < 2 or _is_price_like_label(name):
            lk = f"__row_{i}__"
        return (cents, lk)

    by_key: dict[tuple[int, str], dict] = {}
    for i, r in enumerate(rows):
        key = _key_for_row(i, r)
        if key is None:
            continue
        cur = by_key.get(key)
        if cur is None or _score(r) > _score(cur):
            by_key[key] = r

    out: list[dict] = []
    seen_k: set[tuple[int, str]] = set()
    for i, r in enumerate(rows):
        key = _key_for_row(i, r)
        if key is None or key in seen_k:
            continue
        seen_k.add(key)
        out.append(by_key[key])
    return out


_GENERIC_JSON_KEYS = frozenset(
    {
        "price",
        "amount",
        "valor",
        "sellprice",
        "sale_price",
        "goods_price",
        "product_price",
        "pay_amount",
        "brl",
        "real",
    }
)


def _drop_placeholder_json_names(rows: list[dict]) -> list[dict]:
    """Remove rows whose name is only a generic API key (no product title)."""
    out = []
    for r in rows:
        nm = (r.get("name") or "").strip().lower()
        if nm in _GENERIC_JSON_KEYS:
            continue
        out.append(r)
    return out


def _filter_spurious_labels(rows: list[dict]) -> list[dict]:
    return [r for r in rows if not is_spurious_package_label(r.get("name") or "")]


def _is_price_like_label(s: str) -> bool:
    """True if the string is only a price token (not a product title)."""
    t = (s or "").strip()
    if not t:
        return True
    if re.fullmatch(r"R\$\s*[\d\.\,]+", t, flags=re.IGNORECASE):
        return True
    if re.fullmatch(r"(?:US\$|USD)\s*[\d\.\,]+", t, flags=re.IGNORECASE):
        return True
    if re.fullmatch(r"[\u20b1₱]\s*[\d\.\,]+", t, flags=re.IGNORECASE):
        return True
    if re.fullmatch(r"PHP\s*[\d\.\,]+", t, flags=re.IGNORECASE):
        return True
    return False


def _normalize_rows_to_brl(rows: list[dict]) -> list[dict]:
    """Ensure price_text is R$ so order matching + MMK display work; drops rows we cannot parse."""
    out: list[dict] = []
    for r in rows:
        pt = (r.get("price_text") or "").strip()
        cents = parse_price_to_brl_cents(pt)
        if cents is None:
            continue
        brl = cents / 100.0
        nm = (r.get("name") or "").strip()
        if _is_price_like_label(nm):
            nm = ""
        out.append(
            {
                "name": nm,
                "price_text": _brl_text_from_number(brl),
                "has_buy": bool(r.get("has_buy", True)),
                "smile_li_id": r.get("smile_li_id", ""),
            }
        )
    return out


def _ensure_display_names(
    rows: list[dict],
    *,
    default_title: str | None = None,
    merchant_url: str | None = None,
) -> list[dict]:
    """
    Fill missing names; prefer h1 page title or URL slug — not generic 'Package N'.
    'Package N' appeared when the DOM had no label and the scraper used R$ as the title.
    """
    slug = _name_from_url(merchant_url or "").strip() if merchant_url else ""
    base = (default_title or "").strip()
    if len(base) > 54:
        base = base[:54].strip()
    if not base:
        base = slug or "Pack"
    out: list[dict] = []
    for i, r in enumerate(rows):
        nm = (r.get("name") or "").strip()
        if not nm or _is_price_like_label(nm):
            nm = f"{base} · {i + 1}"
        out.append({**r, "name": nm})
    return out


def _finalize_dom_package_data(data: dict, captured_json: list, merchant_url: str) -> tuple[list[dict], str]:
    """Merge DOM + captured JSON rows, normalize price (BRL or ₱), dedupe, filter spurious labels."""
    page_title = strip_bonus_extra_noise((data.get("title") or "").strip())

    dom_pkgs = data.get("packages") or []
    dom_has_labels = sum(1 for p in dom_pkgs if len((p.get("name") or "").strip()) >= 3) >= 2

    # When the native smile.one LI extraction already found real labelled packages,
    # skip the captured JSON blobs — they often contain search/recommendation data.
    if dom_has_labels:
        merged = _merge_package_rows(dom_pkgs)
    else:
        net_rows: list[dict] = []
        for blob in captured_json:
            net_rows.extend(_extract_packages_from_json_blob(blob))
        if _is_mlbb_product_url(merchant_url):
            merged = _merge_package_rows(dom_pkgs)
            if not merged and net_rows:
                merged = _merge_package_rows(dom_pkgs + net_rows)
        else:
            merged = _merge_package_rows(dom_pkgs + net_rows)
    for r in merged:
        nm = strip_bonus_extra_noise((r.get("name") or "").strip())
        r["name"] = collapse_merged_gem_diamond_tier_label(
            collapse_merged_pt_pack_tiers(
                collapse_merged_tokens_tier_label(
                    collapse_merged_uc_tier_label(collapse_merged_smile_tier_label(nm))
                )
            ),
            product_url=merchant_url,
        )
    is_ph = "/ph/" in (merchant_url or "").lower()
    if is_ph:
        merged = _normalize_rows_to_php(merged)
    else:
        merged = _normalize_rows_to_brl(merged)
    merged = _drop_placeholder_json_names(merged)
    if is_ph:
        merged = _dedupe_by_php_cents(merged)
    else:
        merged = _dedupe_by_brl_cents(merged)
    merged = _ensure_display_names(
        merged,
        default_title=page_title,
        merchant_url=merchant_url,
    )
    merged = _filter_spurious_labels(merged)
    if not is_ph:
        merged.sort(key=lambda x: (not x.get("has_buy", True), x.get("price_text", "")))

    return merged, page_title


async def scrape_all_products() -> list[dict]:
    """Playwright ဖြင့် စာမျက်နှာအားလုံး load ပြီး products အားလုံး ထုတ်ယူသည်"""
    if not async_playwright:
        return []

    products = []
    seen_urls = {}

    launch_kw: dict = {"headless": True}
    ch = playwright_effective_channel()
    if ch:
        launch_kw["channel"] = ch

    async with async_playwright() as p:
        browser = await p.chromium.launch(**launch_kw)
        context = await browser.new_context(
            user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36",
            locale="pt-BR",
        )
        page = await context.new_page()
        page.set_default_navigation_timeout(90000)

        try:
            try:
                from config import (
                    HOME_AFTER_SCROLL_WAIT_MS,
                    HOME_POST_LOAD_WAIT_MS,
                    HOME_SCROLL_MAX_ROUNDS,
                    HOME_SCROLL_WAIT_MS,
                )
            except Exception:
                HOME_POST_LOAD_WAIT_MS = 1100
                HOME_SCROLL_MAX_ROUNDS = 18
                HOME_SCROLL_WAIT_MS = 280
                HOME_AFTER_SCROLL_WAIT_MS = 350

            await page.goto(f"{BASE_URL}/br/", wait_until="domcontentloaded", timeout=90000)
            await page.wait_for_timeout(HOME_POST_LOAD_WAIT_MS)
            # Lazy-loaded grids: scroll until height stabilizes so more /merchant/ links appear.
            prev_h = 0
            stable = 0
            for _ in range(HOME_SCROLL_MAX_ROUNDS):
                await page.evaluate("() => window.scrollTo(0, document.body.scrollHeight)")
                await page.wait_for_timeout(HOME_SCROLL_WAIT_MS)
                h = await page.evaluate("() => document.body.scrollHeight")
                if h == prev_h:
                    stable += 1
                    if stable >= 3:
                        break
                else:
                    stable = 0
                prev_h = h
            await page.wait_for_timeout(HOME_AFTER_SCROLL_WAIT_MS)

            # Lazy-loaded tiles: ensure img src is set before we read data-src (helps slow VPS / cold cache).
            try:
                await page.evaluate(
                    """() => {
                      document.querySelectorAll('img[data-src]').forEach((img) => {
                        const ds = img.getAttribute('data-src');
                        if (ds && (!img.getAttribute('src') || img.src.indexOf('data:') === 0)) {
                          img.setAttribute('src', ds);
                        }
                      });
                    }"""
                )
                await page.wait_for_timeout(900)
            except Exception:
                pass

            data = await page.evaluate("""
                () => {
                    const sels = [
                      'a[href*="/merchant/"]',
                      'a[href*="/entertainment/pay/"]',
                      'a[href*="/br/merchant/"]',
                    ];
                    const seen = new Set();
                    const out = [];
                    sels.forEach(sel => {
                      document.querySelectorAll(sel).forEach(a => {
                        const href = a.getAttribute('href') || '';
                        if (!href || seen.has(href)) return;
                        seen.add(href);
                        const img = a.querySelector('img');
                        let imgSrc = '';
                        if (img) {
                          imgSrc = img.getAttribute('data-src') || img.getAttribute('src') || '';
                        }
                        out.push({
                          href,
                          text: (a.textContent || '').trim(),
                          image: imgSrc
                        });
                      });
                    });
                    return out;
                }
            """)

            seen_images = {}
            for item in data:
                href = item["href"]
                text = item["text"]
                full_url = urljoin(BASE_URL, href).split("?")[0]
                img = item.get("image", "")
                if img and not img.startswith("http"):
                    img = urljoin(BASE_URL, img)

                name = ""
                if "adicionar aos favoritos" in text.lower() or "remover" in text.lower():
                    name = text.replace("Adicionar aos favoritos", "").replace("Remover", "").strip()
                elif "comprar agora" in text.lower():
                    name = _name_from_url(full_url)
                else:
                    name = text if len(text) > 3 else _name_from_url(full_url)

                if len(name) < 3:
                    name = _name_from_url(full_url)
                if len(name) < 3 or name.lower() in ("ver tudo", "comprar agora"):
                    continue

                if full_url not in seen_urls or len(name) > len(seen_urls.get(full_url, "")):
                    seen_urls[full_url] = name
                if img and (full_url not in seen_images or not seen_images[full_url]):
                    seen_images[full_url] = img

            for url_key, name in seen_urls.items():
                products.append({
                    "name": name,
                    "url": url_key,
                    "image": seen_images.get(url_key, ""),
                })

        finally:
            await browser.close()

    return products


async def scrape_game_packages(merchant_url: str, max_scrolls: Optional[int] = None) -> dict:
    """
    Extract package options from a smile.one game/merchant page (e.g. /br with R$, /ph with ₱).

    JSON + DOM are merged (DOM rows first on duplicate keys). BRL, PHP, and USD price strings are
    normalized to canonical R$ text for BRL→MMK display.

    Returns:
      {
        "url": merchant_url,
        "title": "...",
        "packages": [{"name": "...", "price_text": "R$ ..."}]
      }
    """
    if not async_playwright:
        return {"url": merchant_url, "title": None, "packages": []}

    try:
        from config import (
            PACKAGE_AFTER_SCROLL_WAIT_MS,
            PACKAGE_POST_NAV_WAIT_MS,
            PACKAGE_SCROLL_ROUNDS,
            PACKAGE_SCROLL_STEP_WAIT_MS,
        )
    except Exception:
        PACKAGE_SCROLL_ROUNDS = 3
        PACKAGE_SCROLL_STEP_WAIT_MS = 350
        PACKAGE_POST_NAV_WAIT_MS = 800
        PACKAGE_AFTER_SCROLL_WAIT_MS = 220

    n_scrolls = PACKAGE_SCROLL_ROUNDS if max_scrolls is None else max_scrolls

    captured_json: list = []

    _SKIP_JSON_URL_PATS = re.compile(
        r"(?i)search|suggest|recommend|popular|trending|hot-game|notification|login|auth|session|cart|track|analytic|log",
    )

    async def _on_response(response) -> None:
        if response.status != 200:
            return
        try:
            ct = (response.headers.get("content-type") or "").lower()
            if "json" not in ct:
                return
            u = response.url or ""
            if "smile.one" not in u:
                return
            if _SKIP_JSON_URL_PATS.search(u):
                return
            txt = await response.text()
            if len(txt) > 2_000_000:
                return
            t = txt.strip()
            if not (t.startswith("{") or t.startswith("[")):
                return
            captured_json.append(json.loads(t))
        except Exception:
            return

    launch_kw: dict = {"headless": True}
    ch = playwright_effective_channel()
    if ch:
        launch_kw["channel"] = ch

    _loc = (merchant_url or "").lower()
    _locale = "en-PH" if "/ph/" in _loc else "pt-BR"

    async with async_playwright() as p:
        browser = await p.chromium.launch(**launch_kw)
        context = await browser.new_context(
            user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36",
            locale=_locale,
        )
        page = await context.new_page()
        page.set_default_navigation_timeout(90000)
        page.set_default_timeout(60000)
        page.on("response", _on_response)

        try:
            # "load" waits for JS bundles; "domcontentloaded" often yields empty package grids.
            await page.goto(merchant_url, wait_until="load", timeout=90000)
            await page.wait_for_timeout(PACKAGE_POST_NAV_WAIT_MS)

            for _ in range(n_scrolls):
                await page.evaluate("() => window.scrollTo(0, document.body.scrollHeight)")
                await page.wait_for_timeout(PACKAGE_SCROLL_STEP_WAIT_MS)

            await page.wait_for_timeout(PACKAGE_AFTER_SCROLL_WAIT_MS)

            try:
                if "/ph/" in _loc:
                    await page.get_by_text("\u20b1", exact=False).first.wait_for(timeout=20000)
                else:
                    await page.get_by_text("R$", exact=False).first.wait_for(timeout=20000)
            except Exception:
                pass

            _PACKAGE_PAGE_EVAL_JS = """
                (merchantUrl) => {
                  const titleEl = document.querySelector('h1');
                  const title = (titleEl && titleEl.innerText ? titleEl.innerText.trim() : (document.title || '')).trim();

                  const isJunkLabel = (s) => {
                    if (!s || s.length < 2) return true;
                    if (/^[=\\s().,0-9]+$/i.test(s)) return true;
                    if (/\\d[0-9.,]*\\s*\\(\\s*=\\s*\\)\\s*\\d/.test(s)) return true;
                    return false;
                  };

                  function isExcludedRegion(el) {
                    if (!el || !el.closest) return false;
                    const bad = el.closest(
                      'aside, footer, header, nav, '
                    + '[class*="footer"], [class*="Footer"], '
                    + '[class*="header"], [class*="Header"], '
                    + '[class*="recommend"], [class*="Recommend"], '
                    + '[class*="related"], [class*="Related"], '
                    + '[class*="suggest"], [class*="Suggest"], '
                    + '[class*="hot"], '
                    + '[class*="payment"], [class*="Payment"], '
                    + '[class*="pay-method"], [class*="PayMethod"], '
                    + '[class*="checkout"], [class*="Checkout"], '
                    + '[class*="payment-method"], [class*="pay-list"], [class*="select-pay"], '
                    + '[class*="search-box"], [class*="SearchBox"], [class*="search-result"], '
                    + '[class*="side-menu"], [class*="SideMenu"], '
                    + '[class*="Buscados"], [class*="buscados"], '
                    + '[class*="country-list"]'
                    );
                    return !!bad;
                  }

                  function isPaymentMethodLabel(s) {
                    const t = (s || '').trim().toLowerCase();
                    return t === 'picpay' || t === 'pix' || t === 'boleto' || t === 'lotérica' || t === 'loterica' || t === 'mercado pago';
                  }

                  function isPhpPriceLine(ln) {
                    const s = (ln || '').trim();
                    if (/^PHP\\s*[\\d]/i.test(s)) return true;
                    if (s.length < 1) return false;
                    return s.charCodeAt(0) === 0x20B1 && /^\\s*[\\d\\.,]+$/.test(s.slice(1));
                  }

                  function firstNonPriceLines(raw, priceText) {
                    const parts = (raw || '').split(/\\r?\\n|•|\\||·|‹|›/g).map(s => s.replace(/\\s+/g, ' ').trim()).filter(Boolean);
                    for (const ln of parts) {
                      if (priceText && ln.indexOf(priceText) >= 0) continue;
                      if (/^[Rr]\\$\\s*[\\d\\.\\,]+$/.test(ln)) continue;
                      if (isPhpPriceLine(ln)) continue;
                      if (ln.length < 2 || ln.length > 220) continue;
                      if (isJunkLabel(ln)) continue;
                      return ln.slice(0, 160);
                    }
                    return '';
                  }

                  function titleLikeLabel(cardEl) {
                    const sels = ['h2','h3','h4','h5','[class*="title"]','[class*="Title"]','[class*="name"]','[class*="Name"]','[class*="goods-name"]','[class*="sku-name"]','[class*="Label"]','[class*="label"]','[class*="goods-title"]','[class*="GoodsTitle"]','p[class*="text"]'];
                    for (const s of sels) {
                      try {
                        const t = cardEl.querySelector(s);
                        if (t) {
                          const txt = (t.innerText || '').replace(/\\s+/g, ' ').trim();
                          if (txt && txt.length >= 2 && !isJunkLabel(txt) && !isPaymentMethodLabel(txt) && txt.length < 200) return txt.slice(0, 160);
                        }
                      } catch (e) {}
                    }
                    return '';
                  }

                  const scopeSelectors = [
                    'main',
                    '[class*="goods"]',
                    '[class*="Goods"]',
                    '[class*="product-list"]',
                    '[class*="sku"]',
                    '[class*="recharge"]',
                    '[class*="package"]',
                    '[class*="GoodsList"]',
                    '[class*="goods-list"]',
                  ];
                  const roots = [];
                  scopeSelectors.forEach(sel => {
                    try {
                      document.querySelectorAll(sel).forEach(el => roots.push(el));
                    } catch (e) {}
                  });
                  try { roots.push(document.body); } catch (e) {}
                  const scopeRoots = roots.length ? roots : [document.body];

                  function pickPriceText(brlMatches) {
                    if (!brlMatches || brlMatches.length < 1) return null;
                    // Cards often show "De R$ … Por R$ …" — use the last BRL (current price).
                    return brlMatches[brlMatches.length - 1][0];
                  }

                  function labelFromRaw(raw, priceText) {
                    const idx = raw.indexOf(priceText);
                    if (idx < 0) return '';
                    const before = raw.slice(0, idx).replace(/\\s+/g, ' ').trim();
                    const after = raw.slice(idx + priceText.length).replace(/\\s+/g, ' ').trim();
                    const pick = (s) => {
                      if (!s || s.length < 2) return '';
                      if (isJunkLabel(s)) return '';
                      return s;
                    };
                    let lb = pick(before) || pick(after);
                    if (!lb && (before || after)) {
                      const merged = (before + ' ' + after).trim();
                      if (merged.length >= 2 && !isJunkLabel(merged)) lb = merged;
                    }
                    if (!lb) return '';
                    const low = lb.toLowerCase();
                    const cutAt = low.indexOf('comprar');
                    if (cutAt > 8) lb = lb.slice(0, cutAt).trim();
                    return lb.slice(0, 160);
                  }

                  function extract(scopeRestricted) {
                    const out = [];
                    const seen = new Set();
                    const inScope = (el) => {
                      if (!scopeRestricted) return true;
                      return scopeRoots.some(r => r.contains(el));
                    };

                    const nodes = document.querySelectorAll(
                      'span, div, p, li, td, a, button, label, h2, h3, h4, strong, article'
                    );

                    for (const el of nodes) {
                      if (!inScope(el)) continue;
                      if (isExcludedRegion(el)) continue;

                      const raw = (el.innerText || '').replace(/\\s+/g, ' ').trim();
                      if (!raw || raw.length > 320) continue;

                      const brlMatches = [...raw.matchAll(/[Rr]\\$\\s*[0-9\\.\\,]+/gi)];
                      let priceText = null;
                      let label = '';

                      if (brlMatches.length >= 1) {
                        priceText = pickPriceText(brlMatches);
                        label = labelFromRaw(raw, priceText);
                      } else {
                        const phpMatches = [...raw.matchAll(/\\u20B1\\s*[0-9][0-9\\.,]*/g)];
                        if (phpMatches.length >= 1) {
                          priceText = phpMatches[phpMatches.length - 1][0];
                          label = labelFromRaw(raw, priceText);
                        } else {
                          const usd = raw.match(/(?:US\\$|USD)\\s*([0-9]+(?:[\\.,][0-9]{1,2})?)/i);
                          if (usd) {
                            priceText = usd[0].trim();
                            label = labelFromRaw(raw, priceText);
                            if (!label) label = raw.replace(usd[0], '').replace(/\\s+/g, ' ').trim();
                          }
                        }
                      }
                      if (!priceText) continue;

                      if (!label || label.length < 2) {
                        const par = el.closest('[class*="product"], [class*="card"], [class*="item"], [class*="sku"], [class*="goods"], li, article, tr, a');
                        if (par) {
                          const tln = titleLikeLabel(par);
                          if (tln) label = tln;
                          if (!label || label.length < 2) {
                            const ptxt = par.innerText.replace(/\\s+/g, ' ').trim().slice(0, 400);
                            const m2 = [...ptxt.matchAll(/[Rr]\\$\\s*[0-9\\.\\,]+/gi)];
                            const mPhp = [...ptxt.matchAll(/\\u20B1\\s*[0-9][0-9\\.,]*/g)];
                            let pt = null;
                            if (m2.length >= 1) pt = pickPriceText(m2);
                            else if (mPhp.length >= 1) pt = mPhp[mPhp.length - 1][0];
                            if (pt) {
                              label = labelFromRaw(ptxt, pt);
                              if (!label || label.length < 2) {
                                label = ptxt.replace(pt, '').replace(/\\s+/g, ' ').trim().slice(0, 160);
                              }
                            }
                          }
                        }
                      }

                      if (!label || label.length < 2) {
                        label = raw.replace(priceText, '').replace(/\\s+/g, ' ').trim();
                      }
                      if (!label || label.length < 2) {
                        label = firstNonPriceLines(raw, priceText);
                      }
                      if (!label || label.length < 2) {
                        const aria = (el.getAttribute && (el.getAttribute('aria-label') || el.getAttribute('title'))) || '';
                        const al = aria.replace(/\\s+/g, ' ').trim();
                        if (al && (!priceText || al.indexOf(priceText) < 0) && !isJunkLabel(al)) label = al.slice(0, 160);
                      }
                      if (!label || label.length < 2) {
                        let p = el;
                        for (let d = 0; d < 5 && p; d++, p = p.parentElement) {
                          if (!p || !p.getAttribute) continue;
                          const ds = p.getAttribute('data-name') || p.getAttribute('data-title') || p.getAttribute('data-goods-name') || p.getAttribute('data-product-name');
                          if (ds && ds.trim()) { label = ds.trim().slice(0, 160); break; }
                        }
                      }
                      if (label && isJunkLabel(label)) continue;
                      if (!label || label.length < 2) {
                        /* Do not use price as title — server fills from page title. */
                        label = '';
                      }
                      if (isPaymentMethodLabel(label)) continue;

                      const hasBuy = Array.from((el.closest('article, li, section, div') || el).querySelectorAll('button, a, input[type="submit"]')).some(b => {
                        const t = ((b.innerText || '') + ' ' + (b.value || '')).toLowerCase();
                        return t.includes('comprar') || t.includes('buy') || t.includes('pagar') || t.includes('adicionar') || t.includes('order');
                      });

                      const key = (label && label.length > 0)
                        ? priceText + '|' + label.slice(0, 100)
                        : priceText + '|__empty__' + out.length;
                      if (seen.has(key)) continue;
                      seen.add(key);

                      let liIdNear = '';
                      const liAnc = el.closest && el.closest('li[id]');
                      if (liAnc && liAnc.id) liIdNear = String(liAnc.id);

                      out.push({
                        name: label.slice(0, 160),
                        price_text: priceText,
                        has_buy: hasBuy,
                        smile_li_id: liIdNear
                      });
                    }

                    out.sort((a, b) => (b.has_buy === a.has_buy ? 0 : (b.has_buy ? 1 : -1)));
                    return out;
                  }

                  function extractCards() {
                    const out = [];
                    const seen = new Set();
                    const selectors = [
                      'article',
                      '[class*="product-item"]',
                      '[class*="goods-item"]',
                      '[class*="goods-card"]',
                      '[class*="good-card"]',
                      '[class*="GoodCard"]',
                      '[class*="recharge-item"]',
                      '[class*="package-item"]',
                      '[class*="list-item"]',
                      '[class*="sku"]',
                      '[class*="goods-list"] > *',
                      '[class*="GoodsList"] > *',
                      '[class*="goods"] [class*="item"]',
                      '[class*="grid"] > div',
                    ];
                    const roots = [];
                    selectors.forEach(sel => {
                      try {
                        document.querySelectorAll(sel).forEach(el => roots.push(el));
                      } catch (e) {}
                    });
                    for (const el of roots) {
                      if (isExcludedRegion(el)) continue;
                      const raw = (el.innerText || '').replace(/\\s+/g, ' ').trim();
                      if (!raw || raw.length > 1800) continue;
                      const brlMatches = [...raw.matchAll(/[Rr]\\$\\s*[0-9\\.\\,]+/gi)];
                      const phpMatches = [...raw.matchAll(/\\u20B1\\s*[0-9][0-9\\.,]*/g)];
                      let priceText = null;
                      if (brlMatches.length >= 1) priceText = pickPriceText(brlMatches);
                      else if (phpMatches.length >= 1) priceText = phpMatches[phpMatches.length - 1][0];
                      if (!priceText) continue;
                      let label = titleLikeLabel(el);
                      if (!label) label = labelFromRaw(raw, priceText);
                      if (!label || label.length < 2) {
                        label = raw.replace(priceText, '').replace(/\\s+/g, ' ').trim().slice(0, 160);
                      }
                      if (!label || label.length < 2) {
                        label = firstNonPriceLines(raw, priceText);
                      }
                      if (!label || label.length < 2) {
                        const aria = (el.getAttribute && (el.getAttribute('aria-label') || el.getAttribute('title'))) || '';
                        const al = aria.replace(/\\s+/g, ' ').trim();
                        if (al && (!priceText || al.indexOf(priceText) < 0) && !isJunkLabel(al)) label = al.slice(0, 160);
                      }
                      if (!label || label.length < 2) {
                        let p = el;
                        for (let d = 0; d < 5 && p; d++, p = p.parentElement) {
                          if (!p || !p.getAttribute) continue;
                          const ds = p.getAttribute('data-name') || p.getAttribute('data-title') || p.getAttribute('data-goods-name') || p.getAttribute('data-product-name');
                          if (ds && ds.trim()) { label = ds.trim().slice(0, 160); break; }
                        }
                      }
                      if (label && isJunkLabel(label)) continue;
                      if (!label || label.length < 2) {
                        label = '';
                      }
                      if (isPaymentMethodLabel(label)) continue;
                      const hasBuy = Array.from(el.querySelectorAll('button, a, input[type="submit"]')).some(b => {
                        const t = ((b.innerText || '') + ' ' + (b.value || '')).toLowerCase();
                        return t.includes('comprar') || t.includes('buy') || t.includes('pagar') || t.includes('adicionar') || t.includes('order');
                      });
                      const key = (label && label.length > 0)
                        ? priceText + '|' + label.slice(0, 100)
                        : priceText + '|__empty__' + out.length;
                      if (seen.has(key)) continue;
                      seen.add(key);
                      let liIdNear = '';
                      const liAnc = el.closest && el.closest('li[id]');
                      if (liAnc && liAnc.id) liIdNear = String(liAnc.id);
                      else if (el.id) liIdNear = String(el.id);
                      out.push({ name: label.slice(0, 160), price_text: priceText, has_buy: hasBuy, smile_li_id: liIdNear });
                    }
                    return out;
                  }

                  function extractSmileCards() {
                    const out = [];
                    const seen = new Set();
                    const uls = document.querySelectorAll(
                      'ul.PcDiamant-ul, ul.commonDiamant-ul, .product-list-container ul'
                    );
                    for (const ul of uls) {
                      if (isExcludedRegion(ul)) continue;
                      for (const li of ul.querySelectorAll(':scope > li')) {
                        const raw = (li.innerText || '').replace(/\\s+/g, ' ').trim();
                        if (!raw || raw.length > 400) continue;
                        const brl = [...raw.matchAll(/[Rr]\\$\\s*[0-9.,]+/gi)];
                        const phpM = [...raw.matchAll(/\\u20B1\\s*[0-9][0-9.,]*/g)];
                        let priceText = null;
                        if (brl.length >= 1) priceText = pickPriceText(brl);
                        else if (phpM.length >= 1) priceText = phpM[phpM.length - 1][0];
                        if (!priceText) continue;
                        let label = '';
                        // smile.one: <li> has <span> with prices + <p> or text node with SKU name
                        const pEl = li.querySelector('p');
                        if (pEl) {
                          const pt = (pEl.innerText || '').replace(/\\s+/g, ' ').trim();
                          if (pt && pt.length >= 2 && !isJunkLabel(pt) && !/^[Rr]\\$/.test(pt) && pt.trim().charCodeAt(0) !== 0x20B1) label = pt;
                        }
                        if (!label) {
                          label = labelFromRaw(raw, priceText);
                        }
                        if (!label || label.length < 2) {
                          label = raw;
                          for (const m of brl) { label = label.replace(m[0], ''); }
                          for (const m of phpM) { label = label.replace(m[0], ''); }
                          label = label.replace(/\\s+/g, ' ').trim();
                        }
                        if (!label || label.length < 1) continue;
                        if (isJunkLabel(label)) continue;
                        if (isPaymentMethodLabel(label)) continue;
                        const key = priceText + '|' + label.slice(0, 100);
                        if (seen.has(key)) continue;
                        seen.add(key);
                        out.push({name: label.slice(0, 160), price_text: priceText, has_buy: true, smile_li_id: li.id || ''});
                      }
                    }
                    return out;
                  }

                  // Detect 404 / "page not found" before extraction
                  const bodyText = (document.body.innerText || '').slice(0, 1000).toLowerCase();
                  if (/p[aá]gina\\s+n[aã]o\\s+encontrada|page\\s+not\\s+found|404/.test(bodyText) && bodyText.length < 500) {
                    return { title: title, packages: [] };
                  }

                  const u = (merchantUrl || '').toLowerCase();
                  const isMlbb = u.includes('mobilelegends') || u.includes('mobile-legends') || u.includes('/mlbb') || u.includes('m-lbb') || u.includes('mlbb');
                  // Prefer smile.one native card extraction; fall back to generic heuristic
                  const smileNative = extractSmileCards();
                  let packages;
                  if (smileNative.length >= 2) {
                    packages = smileNative;
                  } else {
                    const loose = isMlbb ? [] : extract(false);
                    packages = extractCards().concat(extract(true)).concat(loose);
                  }
                  const deduped = [];
                  const seenK = new Set();
                  let emptyRow = 0;
                  for (const p of packages) {
                    const nm = (p.name || '').trim();
                    const k = nm.length > 0
                      ? (p.price_text + '|' + nm.slice(0, 100))
                      : (p.price_text + '|__empty__' + (emptyRow++));
                    if (seenK.has(k)) continue;
                    seenK.add(k);
                    deduped.push(p);
                  }
                  const isPhStore = /\\/ph\\//i.test(String(merchantUrl || ''));
                  if (!isPhStore) {
                    deduped.sort((a, b) => (b.has_buy === a.has_buy ? 0 : (b.has_buy ? 1 : -1)));
                  }
                  return { title, packages: deduped.slice(0, 120) };
                }
                """

            merged: list[dict] = []
            title_clean = ""
            data: dict = {}
            for attempt in range(2):
                if attempt == 1:
                    await page.wait_for_timeout(2000)
                    await page.evaluate("() => window.scrollTo(0, document.body.scrollHeight)")
                    await page.wait_for_timeout(2000)
                raw_eval = await page.evaluate(_PACKAGE_PAGE_EVAL_JS, merchant_url)
                data = raw_eval if isinstance(raw_eval, dict) else {}
                merged, title_clean = _finalize_dom_package_data(data, captured_json, merchant_url)
                if merged:
                    break

            return {
                "url": merchant_url,
                "title": title_clean,
                "packages": merged[:120],
            }
        finally:
            await browser.close()


def run_game_packages(merchant_url: str) -> dict:
    """Sync wrapper."""
    return asyncio.run(scrape_game_packages(merchant_url))


_PACKAGE_CACHE: dict[str, tuple[float, dict]] = {}
_PACKAGE_CACHE_FILE = Path(__file__).resolve().parent / "package_cache.json"
_MAX_DISK_CACHE_ENTRIES = 800


def _load_disk_package_cache() -> dict:
    p = _PACKAGE_CACHE_FILE
    if not p.is_file():
        return {}
    try:
        with open(p, "r", encoding="utf-8") as f:
            raw = json.load(f)
        return raw if isinstance(raw, dict) else {}
    except Exception:
        return {}


def _persist_package_cache_entry(key: str, data: dict) -> None:
    """Append one URL result; cap size by dropping oldest entries."""
    try:
        d = _load_disk_package_cache()
        d[key] = {"ts": time.time(), "data": data}
        if len(d) > _MAX_DISK_CACHE_ENTRIES:
            items = sorted(
                d.items(),
                key=lambda x: float((x[1] or {}).get("ts", 0)),
                reverse=True,
            )[:_MAX_DISK_CACHE_ENTRIES]
            d = dict(items)
        tmp = _PACKAGE_CACHE_FILE.with_suffix(".json.tmp")
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(d, f, ensure_ascii=False)
        os.replace(str(tmp), str(_PACKAGE_CACHE_FILE))
    except Exception:
        try:
            t = _PACKAGE_CACHE_FILE.with_suffix(".json.tmp")
            if t.is_file():
                t.unlink()
        except Exception:
            pass


def run_game_packages_cached(merchant_url: str, *, bypass_cache: bool = False) -> dict:
    """
    Same as run_game_packages but returns a recent cached result for the same URL
    to avoid long Playwright runs when navigating back to a game.

    Uses a short in-memory TTL while the process runs, and a longer on-disk JSON file
    (PACKAGE_SCRAPE_DISK_CACHE_TTL_SEC) so repeat visits stay fast after restart.
    Use product URL ?refresh=1 to bypass both and scrape fresh.
    """
    try:
        from config import PACKAGE_SCRAPE_CACHE_TTL_SEC, PACKAGE_SCRAPE_DISK_CACHE_TTL_SEC

        mem_ttl = float(PACKAGE_SCRAPE_CACHE_TTL_SEC)
        disk_ttl = float(PACKAGE_SCRAPE_DISK_CACHE_TTL_SEC)
    except Exception:
        mem_ttl = 300.0
        disk_ttl = 604800.0

    key = (merchant_url or "").strip().split("?")[0]
    now = time.time()

    if not bypass_cache and key in _PACKAGE_CACHE:
        ts, data = _PACKAGE_CACHE[key]
        if now - ts < mem_ttl and data.get("packages"):
            return data

    if not bypass_cache:
        disk = _load_disk_package_cache()
        ent = disk.get(key)
        if isinstance(ent, dict):
            ts = float(ent.get("ts", 0))
            data = ent.get("data")
            if (
                now - ts < disk_ttl
                and isinstance(data, dict)
                and data.get("packages")
            ):
                _PACKAGE_CACHE[key] = (now, data)
                return data

    data = run_game_packages(merchant_url)
    if data.get("packages"):
        _PACKAGE_CACHE[key] = (time.time(), data)
        _persist_package_cache_entry(key, data)
    return data


def _name_from_url(url: str) -> str:
    parts = url.rstrip("/").split("/")
    for p in reversed(parts):
        if p and p not in ("merchant", "game", "pay", "entertainment", "br"):
            return p.replace("-", " ").replace("_", " ").title()
    return "Product"


def run_scrape() -> list[dict]:
    """Sync wrapper"""
    return asyncio.run(scrape_all_products())
