#!/usr/bin/env python3
"""
╔══════════════════════════════════════════════════════════╗
║         CGHEVEN Asset Library  —  v1.0.0                ║
║         DaVinci Resolve / Standalone Addon               ║
║         © 2026  Licensed Software — Not For Resale       ║
╚══════════════════════════════════════════════════════════╝
Installation:
 Windows : C:/ProgramData/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Utility/
 macOS   : ~/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Utility/
 Linux   : ~/.local/share/DaVinciResolve/Fusion/Scripts/Utility/
"""

# ═══════════════════════════════════════════════════════════════════
#  SELLER CONFIG  — Edit these before distributing
# ═══════════════════════════════════════════════════════════════════
API_BASE     = "https://api.cgheven.com/api"
LICENSE_SALT = "CGHEVEN_PRO_LIC_2025"
ADDON_NAME   = "CGHEVEN Asset Library"
ADDON_VER    = "1.0.0"
# LICENSE_SERVER = "http://localhost:1337"
# Env-overridable for local testing (CGHEVEN_API_BASE) — defaults to production.
import os as _os_for_cfg
LICENSE_SERVER = (_os_for_cfg.environ.get("CGHEVEN_API_BASE") or "https://api.cgheven.com").strip().rstrip("/")

ADDON_TYPE     = "davinci"                 
CGHEVEN_ADDON_SLUG = "davinci-asset-library"
ADDON_ACCESS_SLUG  = "davinci-resolve"  # Matches slug in Addon collection for per-addon access control
# Fallback only if logo/CGHEVEN_logo.png is missing next to this script.
APP_ICON_URL = "https://api.cgheven.com/uploads/CGHEVEN_Large_e1744786866185_6df63e9aca.png"
# Production default: keep noisy debug logs OFF for smooth UI
CGHEVEN_DEBUG_VERBOSE = False
# How many assets to fetch per API page and show before "Load more"
GRID_PAGE_SIZE = 25
# Top-level category slugs hidden from sidebar + filtered from grid/search.
# Pre-launch: VDB import to Fusion is unstable across Resolve builds — hide
# the category until uVolume integration is verified end-to-end. Add a slug
# here to suppress that category everywhere; remove to re-enable.
_HIDDEN_CATEGORY_SLUGS = frozenset()
# Resolutions available to guests (no license key required)
FREE_RESOLUTIONS = {"1K", "2K", "720p", "1080p"}
# ═══════════════════════════════════════════════════════════════════

import sys, os, json, threading, queue, urllib.request, urllib.error
import urllib.parse, webbrowser, hashlib, base64, re, time, subprocess
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

SHARED_DL_FILE = os.path.join(os.path.expanduser("~"), ".cgheven_cg_dl_v2.json")
REG_FILE       = os.path.join(os.path.expanduser("~"), ".cgheven_reg_v1.json")
PRICING_URL    = "https://cgheven.com/pricing"
DISCORD_URL    = "https://discord.gg/d2M55y9fg"   # DaVinci Resolve community server

# ═══════════════════════════════════════════════════════════════════
#  DEPENDENCY BOOTSTRAP — auto-install required Python packages
#  Thumbnails/previews need Pillow; video preview needs imageio; certifi
#  fixes SSL on machines without proper root certs. DaVinci runs the addon
#  in ITS OWN Python, so a fresh user often has none of these — which is why
#  thumbnails appear blank for them but work on a dev machine. On first
#  launch we pip-install whatever is missing INTO that interpreter so the
#  addon "just works" after install. Failures are non-fatal: a banner with
#  a one-click "Fix Now" + manual instructions is shown later if needed.
# ═══════════════════════════════════════════════════════════════════
_CGH_CACHE_ROOT  = os.path.join(os.path.expanduser("~"), ".cgheven_cache")
_CGH_DEPS_MARKER = os.path.join(_CGH_CACHE_ROOT, ".deps_attempt_v1")
# Private libs dir we own + control. DaVinci's bundled Python frequently ships with the
# user-site directory DISABLED (ENABLE_USER_SITE=False), so a `pip install --user Pillow`
# "succeeds" but never lands on sys.path → `import PIL` still fails → blank thumbnails AND
# the default Tk feather icon. Installing into our OWN dir and putting it on sys.path FIRST
# sidesteps that entirely and makes the install importable in THIS same session.
_CGH_LIBS_DIR    = os.path.join(_CGH_CACHE_ROOT, "pylibs")
# pip package name -> import (module) name. ORDER MATTERS: Pillow (thumbnails + window icon)
# and certifi (SSL) come first so they still install even if the heavier imageio/numpy
# download (video preview) is slow or fails.
_CGH_REQUIRED_DEPS = {"Pillow": "PIL", "certifi": "certifi", "imageio": "imageio"}

def _cgh_add_libs_to_path():
    """Put our private libs dir at the FRONT of sys.path so a prior (or just-completed)
    install is importable. Safe to call repeatedly."""
    try:
        os.makedirs(_CGH_LIBS_DIR, exist_ok=True)
    except Exception:
        pass
    if _CGH_LIBS_DIR not in sys.path:
        sys.path.insert(0, _CGH_LIBS_DIR)

# Make any already-installed deps importable BEFORE the first `import PIL` below.
_cgh_add_libs_to_path()

def _cgh_missing_deps():
    """Return pip names whose module can't actually be IMPORTED. We import (not just
    find_spec) so a present-but-BROKEN wheel — e.g. a Pillow built for a different Python
    ABI that was dropped into our libs dir by the installer — is treated as missing and
    reinstalled with THIS interpreter (the one DaVinci actually runs the addon with)."""
    import importlib
    missing = []
    for pip_name, import_name in _CGH_REQUIRED_DEPS.items():
        try:
            importlib.import_module(import_name)
        except Exception:
            missing.append(pip_name)
    return missing

def _cgh_pip_install(packages):
    """Best-effort pip install into our private libs dir (always importable). Returns True
    on success. Falls back to --user / plain installs, and bootstraps pip if it's absent."""
    if not packages:
        return True
    py = sys.executable or ""
    if not py:
        return False
    base = [py, "-m", "pip", "install", "--disable-pip-version-check", "--no-input"]
    # 1) --target our own dir (most reliable for DaVinci's embedded Python — no permission
    #    issues, no disabled user-site). --upgrade so a partial prior install is repaired.
    # 2) --user as a fallback (normal Pythons). 3) plain (venvs that reject --user).
    attempts = (
        [*base, "--upgrade", "--target", _CGH_LIBS_DIR, *packages],
        [*base, "--user", *packages],
        [*base, *packages],
    )
    for cmd in attempts:
        try:
            r = subprocess.run(cmd, stdout=subprocess.PIPE,
                               stderr=subprocess.STDOUT, timeout=300)
            if r.returncode == 0:
                _cgh_add_libs_to_path()
                return True
        except Exception:
            continue
    # pip itself may be absent in a stripped Python — bootstrap it once, then retry --target.
    try:
        subprocess.run([py, "-m", "ensurepip", "--upgrade"], stdout=subprocess.PIPE,
                       stderr=subprocess.STDOUT, timeout=180)
        r = subprocess.run([*base, "--upgrade", "--target", _CGH_LIBS_DIR, *packages],
                           stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=300)
        if r.returncode == 0:
            _cgh_add_libs_to_path()
            return True
    except Exception:
        pass
    return False

def _cgh_ensure_deps():
    """Auto-install missing deps on launch, INTO our libs dir so PIL imports this session.
    Backs off for 24h only after a genuinely FAILED attempt (so an offline user doesn't face
    a slow pip call every launch); a success clears the backoff immediately."""
    _cgh_add_libs_to_path()
    missing = _cgh_missing_deps()
    if not missing:
        return
    # Back off only if a PRIOR attempt failed recently (marker present = last attempt failed).
    try:
        if (os.path.exists(_CGH_DEPS_MARKER)
                and (time.time() - os.path.getmtime(_CGH_DEPS_MARKER)) < 86400):
            return
    except Exception:
        pass
    try:
        print(f"[CGHEVEN] Installing required components ({', '.join(missing)}) — "
              f"first launch only, please wait…")
    except Exception:
        pass
    # Install each missing package INDEPENDENTLY (in priority order) so a failure on one
    # — e.g. imageio pulling a big numpy wheel on a slow link — doesn't block Pillow, which
    # is what powers thumbnails + the window icon.
    for pkg in missing:
        try:
            _cgh_pip_install([pkg])
        except Exception:
            pass
    try:
        import importlib
        importlib.invalidate_caches()
    except Exception:
        pass
    # Re-check: only mark a backoff if deps are STILL missing after the attempt.
    try:
        still_missing = _cgh_missing_deps()
    except Exception:
        still_missing = missing
    try:
        os.makedirs(_CGH_CACHE_ROOT, exist_ok=True)
        if still_missing:
            with open(_CGH_DEPS_MARKER, "w") as f:
                f.write(str(int(time.time())))
        elif os.path.exists(_CGH_DEPS_MARKER):
            os.remove(_CGH_DEPS_MARKER)   # success → drop the backoff so nothing blocks re-checks
    except Exception:
        pass
    try:
        print("[CGHEVEN] Components installed — thumbnails & previews enabled."
              if not still_missing else "[CGHEVEN] Auto-install failed; will use 'Fix Now' banner.")
    except Exception:
        pass

try:
    _cgh_ensure_deps()
except Exception:
    pass

# SSL contexts for media/thumbnail fetches. Some user machines (macOS Python
# without "Install Certificates", corporate proxies) fail default verification,
# which silently blanks every thumbnail. We verify with certifi when available
# and fall back to an unverified context for these public CDN images.
import ssl as _ssl
def _cgh_make_ssl_contexts():
    verified = None
    try:
        import certifi
        verified = _ssl.create_default_context(cafile=certifi.where())
    except Exception:
        try:
            verified = _ssl.create_default_context()
        except Exception:
            verified = None
    try:
        unverified = _ssl._create_unverified_context()
    except Exception:
        unverified = None
    return verified, unverified

_CGH_SSL_VERIFIED, _CGH_SSL_UNVERIFIED = _cgh_make_ssl_contexts()

def is_registered():
    return os.path.isfile(REG_FILE)

def _verify_registration():
    """Backend control: if the saved registration was deleted server-side, remove the
    local file so the form shows again. Network/errors ignored (never lock out users)."""
    try:
        import urllib.parse, urllib.request
        if not os.path.isfile(REG_FILE):
            return
        try:
            with open(REG_FILE, "r") as f:
                reg = json.load(f)
        except Exception:
            return
        email = str(reg.get("email", "") or "").strip()
        if not email or "@" not in email:
            return
        url = f"{LICENSE_SERVER}/api/addon-registrations/check?email={urllib.parse.quote(email)}"
        req = urllib.request.Request(url, headers={
            "User-Agent": f"CGHEVENDaVinci/{ADDON_VER}",
            "Accept": "application/json",
        })
        with urllib.request.urlopen(req, timeout=8) as resp:
            data = json.loads(resp.read().decode("utf-8", errors="ignore") or "{}")
        if data.get("registered") is False:
            try: os.remove(REG_FILE)
            except Exception: pass
    except Exception:
        pass

def _fetch_trial_active():
    """Server-controlled free-trial window. Returns True while the trial is open.
    Lenient: any network/parse error → True, so users are never wrongly locked out."""
    global PRICING_URL
    try:
        import urllib.request
        url = f"{LICENSE_SERVER}/api/addon-registrations/status"
        req = urllib.request.Request(url, headers={
            "User-Agent": f"CGHEVENDaVinci/{ADDON_VER}",
            "Accept": "application/json",
        })
        with urllib.request.urlopen(req, timeout=8) as resp:
            data = json.loads(resp.read().decode("utf-8", errors="ignore") or "{}")
        pu = str(data.get("pricing_url", "") or "").strip()
        if pu:
            PRICING_URL = pu
        return data.get("trial_active", True) is not False
    except Exception:
        return True

def _has_cached_session():
    """True if a valid Strapi session is already cached (i.e. user is licensed)."""
    try:
        if not AUTH_MODULE_AVAILABLE:
            return False
        sess = load_cached_session()
        return bool(sess and sess.get("session_token"))
    except Exception:
        return False

def _shared_dl_read():
    try:
        if os.path.exists(SHARED_DL_FILE):
            with open(SHARED_DL_FILE, "r", encoding="utf-8") as f:
                data = json.load(f)
            if isinstance(data, list):
                return data
    except Exception:
        pass
    return []

def _shared_dl_write(records):
    try:
        tmp = SHARED_DL_FILE + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(records, f, ensure_ascii=True)
        os.replace(tmp, SHARED_DL_FILE)
    except Exception:
        try:
            with open(SHARED_DL_FILE, "w", encoding="utf-8") as f:
                json.dump(records, f, ensure_ascii=True)
        except Exception:
            pass

def _shared_dl_add(asset_id, fp="", fmt="", res="", title="", cat=""):
    try:
        aid = str(asset_id)
        records = _shared_dl_read()
        records = [r for r in records if not (str(r.get("id", "")) == aid and r.get("fmt", "") == fmt and r.get("res", "") == res)]
        records.insert(0, {"id": aid, "fp": fp, "fmt": fmt, "res": res, "title": title, "cat": cat, "at": int(time.time() * 1000)})
        _shared_dl_write(records[:200])
    except Exception:
        pass

def _shared_dl_get_ids():
    try:
        out = set()
        for r in _shared_dl_read():
            try:
                out.add(int(r.get("id", 0)))
            except Exception:
                pass
        return out
    except Exception:
        return set()


def _cgheven_addon_base_dir() -> Path:
    """Folder with CGHEVEN_Asset_Library.py. Fusion/Resolve may exec() without __file__."""
    try:
        return Path(__file__).resolve().parent
    except NameError:
        pass
    av0 = sys.argv[0] if sys.argv else ""
    if av0:
        p = Path(os.path.abspath(av0))
        if p.suffix.lower() == ".py" and p.is_file():
            return p.parent
    return Path(os.path.abspath(os.getcwd()))


_APP_ICON_LOCAL = _cgheven_addon_base_dir() / "logo" / "CGHEVEN_logo.png"
# Per-monitor DPI: helps Windows pick the right icon layer and avoids fuzzy bitmap scaling.
if sys.platform == "win32":
    try:
        import ctypes
        try:
            ctypes.windll.shcore.SetProcessDpiAwareness(2)
        except (AttributeError, OSError):
            ctypes.windll.user32.SetProcessDPIAware()
        # Give the process its own AppUserModelID so the Windows taskbar shows OUR
        # icon (and groups our windows) instead of the generic python.exe / Tk feather.
        try:
            ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
                "com.cgheven.assetlibrary.davinci"
            )
        except (AttributeError, OSError):
            pass
    except (AttributeError, OSError, ImportError):
        pass
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
try:
    from PIL import Image, ImageTk, ImageDraw, ImageFilter, ImageFont, ImageChops
    PIL_AVAILABLE = True
except ImportError:
    PIL_AVAILABLE = False

# Windows .ico: each size is supersampled for sharp taskbar / title; avoid single-sharp 256→16 blur.
if PIL_AVAILABLE:
    _PIL_LANCZOS = getattr(
        getattr(Image, "Resampling", Image), "LANCZOS", getattr(Image, "LANCZOS", 1)
    )
    # Shell uses 20px/24px/32px often; 512/256 help HiDPI picks. Order: largest first (ICO idiom).
    _ICO_SIZES_DESC = (512, 256, 128, 96, 64, 48, 40, 32, 24, 20, 16)

    def _cgheven_icon_fit_to_square_rgba(src):
        w0, h0 = src.size
        # Min canvas 512 so medium PNGs downscale in extra step (sharper 16–32px ICO layers).
        side = max(w0, h0, 512)
        base = Image.new("RGBA", (side, side), (0, 0, 0, 0))
        scale = min(side / max(1, w0), side / max(1, h0))
        nw, nh = max(1, int(w0 * scale)), max(1, int(h0 * scale))
        fitted = src.resize((nw, nh), _PIL_LANCZOS)
        base.paste(fitted, ((side - nw) // 2, (side - nh) // 2), fitted)
        return base

    def _cgheven_flatten_near_black_bg(square_rgba, thr=18):
        """Near-black plate → transparent so Windows taskbar/titlebar don't mush edges."""
        try:
            r, g, b, a = square_rgba.split()
            rb = r.point(lambda x: 255 if x < thr else 0)
            gb = g.point(lambda x: 255 if x < thr else 0)
            bb = b.point(lambda x: 255 if x < thr else 0)
            all_dark = ImageChops.multiply(ImageChops.multiply(rb, gb), bb)
            mask_keep = ImageChops.invert(all_dark)
            na = ImageChops.multiply(a, mask_keep)
            return Image.merge("RGBA", (r, g, b, na))
        except Exception:
            return square_rgba

    def _cgheven_prepare_icon_square(src):
        try:
            sq = _cgheven_icon_fit_to_square_rgba(src)
        except Exception:
            sq = src.convert("RGBA") if getattr(src, "mode", "") != "RGBA" else src
        return _cgheven_flatten_near_black_bg(sq)

    def _cgheven_ico_layer(square_rgba, size):
        s = max(1, int(size))
        w, h = square_rgba.size
        m = min(w, h)
        # Taskbar 16–32px: stronger supersample (up to 8×) when source is large enough.
        if s <= 32 and m >= 4 * s:
            k = 8 if s <= 20 else 6 if s <= 28 else 5
            t = min(k * s, m)
            if t < 4 * s:
                t = min(4 * s, m)
            inter = square_rgba.resize((t, t), _PIL_LANCZOS)
            out = inter.resize((s, s), _PIL_LANCZOS)
        elif m >= 2 * s and m >= 4:
            t = s * 2
            inter = square_rgba.resize((t, t), _PIL_LANCZOS)
            out = inter.resize((s, s), _PIL_LANCZOS)
        else:
            out = square_rgba.resize((s, s), _PIL_LANCZOS)
        if s <= 96:
            try:
                r, g, b, a = out.split()
                sharp = Image.merge("RGB", (r, g, b)).filter(
                    ImageFilter.UnsharpMask(radius=0.45, percent=88, threshold=0)
                )
                er, eg, eb = sharp.split()
                out = Image.merge("RGBA", (er, eg, eb, a))
            except Exception:
                try:
                    out = out.filter(
                        ImageFilter.UnsharpMask(radius=0.4, percent=70, threshold=0)
                    )
                except Exception:
                    pass
        return out

    def _cgheven_write_taskbar_ico(square_rgba, ico_path):
        try:
            layers = [_cgheven_ico_layer(square_rgba, z) for z in _ICO_SIZES_DESC]
            layers[0].save(ico_path, format="ICO", append_images=layers[1:])
        except Exception:
            fall = list(_ICO_SIZES_DESC)
            try:
                square_rgba.save(
                    ico_path, format="ICO", sizes=[(a, a) for a in reversed(fall)]
                )
            except Exception:
                raise

    def _cgheven_tk_iconphoto_chain(square_rgba, master):
        order = (16, 20, 24, 32, 40, 48, 64, 128, 256, 512)
        return [
            ImageTk.PhotoImage(_cgheven_ico_layer(square_rgba, s), master=master)
            for s in order
        ]

    def _cgheven_load_app_icon_rgba(timeout=8):
        """Prefer bundled logo/CGHEVEN_logo.png (high-res); else APP_ICON_URL."""
        import io

        try:
            if _APP_ICON_LOCAL.is_file():
                return Image.open(_APP_ICON_LOCAL).convert("RGBA")
        except Exception:
            pass
        req = urllib.request.Request(APP_ICON_URL, headers={"User-Agent": "CGHEVEN-Pro/2.0"})
        with urllib.request.urlopen(req, timeout=timeout) as r:
            data = r.read()
        return Image.open(io.BytesIO(data)).convert("RGBA")
else:
    _PIL_LANCZOS = None
    _cgheven_icon_fit_to_square_rgba = None
    _cgheven_flatten_near_black_bg = None
    _cgheven_prepare_icon_square = None
    _cgheven_ico_layer = None
    _cgheven_write_taskbar_ico = None
    _cgheven_tk_iconphoto_chain = None
    _cgheven_load_app_icon_rgba = None


# ── Shared title-bar / taskbar icon applier ─────────────────────────
# The registration + license forms appear BEFORE the main window and used to keep
# Tk's default feather icon. This applies the SAME crisp CGHEVEN icon the main
# window uses (a multi-size .ico for the Windows taskbar + an iconphoto chain for
# the title bar). Cached so the .ico is built only once. No-op without PIL.
_CGHEVEN_ICON_CACHE = {"ready": False, "ico_path": None, "square": None}

def _cgheven_apply_window_icon(win):
    if not PIL_AVAILABLE or _cgheven_load_app_icon_rgba is None:
        return
    c = _CGHEVEN_ICON_CACHE
    try:
        if not c["ready"]:
            import tempfile
            raw = _cgheven_load_app_icon_rgba(timeout=5)
            try:
                square = _cgheven_prepare_icon_square(raw)
            except Exception:
                square = raw
            ico_path = None
            if sys.platform == "win32":
                ico_path = os.path.join(tempfile.gettempdir(), "cgheven_app_icon.ico")
                try:
                    _cgheven_write_taskbar_ico(square, ico_path)
                except Exception:
                    ico_path = None
            c["square"], c["ico_path"], c["ready"] = square, ico_path, True
        if sys.platform == "win32" and c["ico_path"] and os.path.isfile(c["ico_path"]):
            try:
                win.iconbitmap(c["ico_path"])
            except Exception:
                pass
        if c["square"] is not None:
            photos = _cgheven_tk_iconphoto_chain(c["square"], win)
            win.iconphoto(True, *photos)
            try:
                win._cgheven_icon_photos = photos   # keep a ref so Tk doesn't GC them
            except Exception:
                pass
    except Exception:
        pass


try:
    import imageio
    IMAGEIO_AVAILABLE = True
except ImportError:
    IMAGEIO_AVAILABLE = False

LICENSE_FILE = os.path.join(os.path.expanduser("~"), ".cgheven_pro_v2.lic")

# ── Ensure helper modules don't have to live in Scripts/Utility ──
# Installer puts them into Fusion/Scripts/Modules/cgheven to keep Workspace>Scripts clean.
try:
    if sys.platform == "win32":
        appdata = os.getenv("APPDATA", "")
        programdata = os.getenv("PROGRAMDATA", "C:\\ProgramData")
        extra_paths = [
            os.path.join(appdata, "Blackmagic Design", "DaVinci Resolve", "Support", "Fusion", "Scripts", "Modules"),
            os.path.join(appdata, "Blackmagic Design", "DaVinci Resolve", "Support", "Fusion", "Scripts", "Modules", "cgheven"),
            os.path.join(programdata, "Blackmagic Design", "DaVinci Resolve", "Support", "Fusion", "Scripts", "Modules"),
            os.path.join(programdata, "Blackmagic Design", "DaVinci Resolve", "Support", "Fusion", "Scripts", "Modules", "cgheven"),
            os.path.join(programdata, "Blackmagic Design", "DaVinci Resolve", "Fusion", "Scripts", "Modules"),
            os.path.join(programdata, "Blackmagic Design", "DaVinci Resolve", "Fusion", "Scripts", "Modules", "cgheven"),
        ]
    elif sys.platform == "darwin":
        extra_paths = [
            os.path.expanduser("~/Library/Application Support/Blackmagic Design/DaVinci Resolve/Support/Fusion/Scripts/Modules"),
            os.path.expanduser("~/Library/Application Support/Blackmagic Design/DaVinci Resolve/Support/Fusion/Scripts/Modules/cgheven"),
            os.path.expanduser("~/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Modules"),
            os.path.expanduser("~/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Modules/cgheven"),
        ]
    else:
        extra_paths = [
            os.path.expanduser("~/.local/share/DaVinciResolve/Support/Fusion/Scripts/Modules"),
            os.path.expanduser("~/.local/share/DaVinciResolve/Support/Fusion/Scripts/Modules/cgheven"),
            os.path.expanduser("~/.local/share/DaVinciResolve/Fusion/Scripts/Modules"),
            os.path.expanduser("~/.local/share/DaVinciResolve/Fusion/Scripts/Modules/cgheven"),
        ]
    for p in extra_paths:
        if p and os.path.exists(p) and p not in sys.path:
            sys.path.insert(0, p)
except Exception:
    pass

# ── Secure Auth Module ─────────────────────────────────────────
try:
    from cgheven_auth import (
        activate_license, load_cached_session,
        do_heartbeat, proxy_get, LicenseError,
        get_machine_fingerprint, _clear_session,
        login_with_account,
    )
    AUTH_MODULE_AVAILABLE = True
    try:
        import cgheven_auth as _cgheven_auth_mod
        try:
            _cgheven_auth_mod.set_debug(bool(CGHEVEN_DEBUG_VERBOSE))
        except Exception:
            pass
        print(f"[AUTH] cgheven_auth.py loaded: {_cgheven_auth_mod.__file__}")
    except Exception:
        print("[AUTH] cgheven_auth.py loaded successfully")
except ImportError:
    AUTH_MODULE_AVAILABLE = False
    print("[AUTH] cgheven_auth.py not found — running in legacy mode")

try:
    import cgheven_auto_update as _auto_update
    UPDATE_MODULE_AVAILABLE = True
    print("[UPDATE] cgheven_auto_update.py loaded successfully")
except ImportError:
    UPDATE_MODULE_AVAILABLE = False
    _auto_update = None  # type: ignore[assignment]
    print("[UPDATE] cgheven_auto_update.py not found — auto-update disabled")
    class LicenseError(Exception):
        def __init__(self, msg="", code=0):
            super().__init__(msg)
            self.code = code
        def is_expired(self):    return self.code == 402
        def is_revoked(self):    return self.code == 403
        def is_device_limit(self): return self.code == 403
        def is_not_found(self):  return self.code in (401, 404)

# ── Analytics helper (Strapi -> PostHog forwarder) ────────────────
try:
    import cgheven_analytics_helper as _analytics
    ANALYTICS_AVAILABLE = True
    print("[ANALYTICS] cgheven_analytics_helper.py loaded successfully")
except ImportError:
    _analytics = None  # type: ignore[assignment]
    ANALYTICS_AVAILABLE = False
    print("[ANALYTICS] cgheven_analytics_helper.py not found — analytics disabled")

# Analytics context (so API client / threads can emit perf events)
_ANALYTICS_DISTINCT_ID = ""
# Global session_started guard — re-armed on account_login so session_started re-fires
# under the new (account email) identity.
_ANALYTICS_SESSION_STARTED = False
def _analytics_distinct_id() -> str:
    try:
        return _ANALYTICS_DISTINCT_ID or ""
    except Exception:
        return ""


def _analytics_cached_session() -> dict:
    """Best-effort read of the locally-cached login session (email/plan/is_premium).
    Reads the raw session file directly (no network) so analytics never blocks."""
    try:
        if AUTH_MODULE_AVAILABLE:
            from cgheven_auth import _load_session as _ls
            sess = _ls()
            if isinstance(sess, dict):
                return sess
    except Exception:
        pass
    return {}


def _analytics_identity(license_key: str = "", session_token: str = "") -> str:
    """Stable PostHog distinct_id. With the account-login (Logto) system the identity is
    the logged-in account EMAIL (stable across heartbeats). We must NEVER use the session
    token — it rotates on every heartbeat, which would shatter one user into hundreds of
    distinct_ids in PostHog.
    Order: account email → legacy license key → per-machine guest id."""
    # 1. Logged-in account email — the primary, stable identity.
    try:
        em = str(_analytics_cached_session().get("email") or "").strip().lower()
        if em:
            return em
    except Exception:
        pass
    # 2. Legacy license key (typed in UI / persisted by the old key-activation flow).
    try:
        key = str(license_key or "").strip()
        # A bare session token / placeholder is not a real key — skip it.
        if key and key not in ("ACTIVE", "ACCOUNT") and not key.startswith("eyJ"):
            return key
    except Exception:
        pass
    # 3. Per-machine guest id (stable, anonymous).
    try:
        if AUTH_MODULE_AVAILABLE:
            fp = str(get_machine_fingerprint() or "").strip()
            if fp:
                return "guest_" + fp
    except Exception:
        pass
    return ""


def _analytics_person() -> dict:
    """Plan / account properties attached to session_started + account_login events so the
    dashboard can compute plan distribution (latest plan per user) and see who upgraded."""
    out = {}
    try:
        sess = _analytics_cached_session()
        em = str(sess.get("email") or "").strip().lower()
        plan = str(sess.get("plan_label") or sess.get("plan") or "").strip()
        out["account_email"] = em
        out["plan"] = plan or "Free"
        out["is_premium"] = bool(sess.get("is_premium"))
        out["is_guest"] = not bool(em)
    except Exception:
        pass
    return out


def _analytics_emit_account_login(register: bool = False) -> None:
    """Emit an `account_login` event on browser/Logto login success. The session file has
    just been saved by login_with_account(), so the identity is now the account email and the
    plan props are fresh. ``register`` is True for a sign-up, False for a normal login."""
    try:
        if not (ANALYTICS_AVAILABLE and _analytics):
            return
        did = _analytics_identity()
        if not did:
            return
        props = {"register": bool(register)}
        props.update(_analytics_person())
        _analytics.send_event(
            license_server=LICENSE_SERVER,
            addon_slug=CGHEVEN_ADDON_SLUG,
            addon_version=ADDON_VER,
            distinct_id=did,
            event_name="account_login",
            properties=props,
        )
        # Re-arm session_started under the new (email) identity. The main app is created
        # fresh after login (so its per-instance guard is already reset), but if any global
        # guard exists we clear it here too.
        try:
            global _ANALYTICS_SESSION_STARTED
            _ANALYTICS_SESSION_STARTED = False
        except Exception:
            pass
    except Exception:
        pass

# ──────────────────────────────────────────────────────────────────
#  DAVINCI RESOLVE MODULE SETUP
# ──────────────────────────────────────────────────────────────────
def _subprocess_hide_console_kwargs():
    """Win32: ffprobe/ffmpeg are console apps — avoid a 1s black CMD flash when run from a GUI host."""
    if sys.platform == "win32" and hasattr(subprocess, "CREATE_NO_WINDOW"):
        return {"creationflags": subprocess.CREATE_NO_WINDOW}
    return {}


def _find_resolve_ffmpeg():
    """Find ffmpeg bundled with DaVinci Resolve."""
    candidates = []
    if sys.platform == "win32":
        for prog in [os.getenv("PROGRAMFILES","C:\\Program Files"),
                     os.getenv("PROGRAMFILES(X86)","")]:
            if prog:
                candidates.append(os.path.join(prog,"Blackmagic Design","DaVinci Resolve","ffmpeg.exe"))
        candidates.append("ffmpeg")
    elif sys.platform == "darwin":
        candidates += ["/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/MacOS/ffmpeg",
                       "/usr/local/bin/ffmpeg","ffmpeg"]
    else:
        candidates += ["/opt/resolve/bin/ffmpeg","/usr/bin/ffmpeg","ffmpeg"]
    for c in candidates:
        try:
            r = subprocess.run(
                [c, "-version"],
                capture_output=True,
                timeout=3,
                **_subprocess_hide_console_kwargs(),
            )
            if r.returncode == 0:
                return c
        except Exception:
            pass
    return None

RESOLVE_FFMPEG = None

# ── Pre-init hidden Tk root ────────────────────────────────────
# Some imports can accidentally trigger Tkinter's lazy root creation.
# Creating ONE withdrawn root up-front prevents a visible blank "tk" window.
try:
    if not (hasattr(tk, "_default_root") and tk._default_root):
        _pre_root = tk.Tk()
        _pre_root.withdraw()
        try:
            _pre_root.overrideredirect(True)
        except Exception:
            pass
        try:
            _pre_root.attributes("-alpha", 0.0)
        except Exception:
            pass
        try:
            _pre_root.update_idletasks()
        except Exception:
            pass
        tk._default_root = _pre_root
except Exception:
    pass

def _setup_resolve_module():
    global RESOLVE_FFMPEG
    def _disc():
        global RESOLVE_FFMPEG
        RESOLVE_FFMPEG = _find_resolve_ffmpeg()
        print(f"[FFMPEG] {'Found: '+RESOLVE_FFMPEG if RESOLVE_FFMPEG else 'Not found'}")
    threading.Thread(target=_disc, daemon=True).start()
    try:
        import DaVinciResolveScript
        return True
    except ImportError:
        pass
    pd = os.getenv("PROGRAMDATA","C:\\ProgramData")
    pf = os.getenv("PROGRAMFILES","C:\\Program Files")
    resolve_paths = []
    if sys.platform == "win32":
        resolve_paths = [
            os.path.join(pd,"Blackmagic Design","DaVinci Resolve","Support","Developer","Scripting","Modules"),
            os.path.join(pd,"Blackmagic Design","DaVinci Resolve","Support","Developer","Scripting","Examples"),
            os.path.join(pd,"Blackmagic Design","DaVinci Resolve","Support","Developer","Python"),
            os.path.join(pf,"Blackmagic Design","DaVinci Resolve"),
        ]
    elif sys.platform == "darwin":
        resolve_paths = [
            "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting/Modules",
            "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting/Examples",
        ]
    else:
        resolve_paths = ["/opt/resolve/Developer/Scripting/Modules","/opt/resolve/libs/Fusion"]
    for path in resolve_paths:
        if os.path.exists(path) and path not in sys.path:
            sys.path.insert(0, path)
    try:
        import DaVinciResolveScript
        print("[RESOLVE] DaVinciResolveScript loaded OK")
        return True
    except ImportError:
        pass
    return False

_setup_resolve_module()

# ──────────────────────────────────────────────────────────────────
#  THEME
# ──────────────────────────────────────────────────────────────────
C = {
    "bg":          "#0F0F15",   # main bg — dark navy-black (matches image)
    "sidebar":     "#0A0A0F",   # sidebar — pure very dark navy
    "card":        "#1A1A26",   # card bg — navy charcoal
    "card_h":      "#222235",   # card hover
    "search":      "#16161F",   # search input bg
    "topbar":      "#0D0D14",   # top bar bg
    "accent":      "#277070",   # brand — buttons, selected asset border, key accents
    "accent2":     "#2F8F8F",   # hover / active (lighter teal)
    "cta":         "#F45C2D",   # CTA orange→red blend (gradient #F97316→#EF4444, solid midpoint — Tkinter has no gradient)
    "cta_h":       "#E33F19",   # CTA hover blend (gradient #EA580C→#DC2626, solid midpoint)
    "accent_soft": "#1A3D3D",   # category chip overlay bg on thumbnail
    "green":       "#27AE60",
    "red":         "#E74C3C",
    "gold":        "#F0A500",
    "text":        "#FFFFFF",
    "text2":       "#8A8A9A",   # secondary text
    "dim":         "#4A4A60",   # dim text
    "border":      "#2C2C3E",   # card/widget border
    "cat_active":  "#1A4040",   # sidebar active item bg (teal-tinted)
    "preview_bg":  "#080808",
    "patreon":     "#FF424D",
    "tag_bg":      "#22223A",   # tag pill bg
    "dl_btn":      "#1E1E30",   # download button bg in card (dark outlined)
    "dl_border":   "#3C3C52",   # download button border in card
    "tag_pill_fg": "#7DD3C0",
}

TYPE_META = {
    ".mp4":  {"format": "H.264 MP4",  "color": "#4B9EFF", "icon": "▶"},
    ".mov":  {"format": "ProRes MOV", "color": "#7C5CF0", "icon": "▶"},
    ".exr":  {"format": "OpenEXR",    "color": "#F0A500", "icon": "◈"},
    ".zip":  {"format": "Archive",    "color": "#888",    "icon": "📦"},
    ".vdb":  {"format": "VDB",        "color": "#FF6B35", "icon": "⬡"},
    ".webm": {"format": "WebM",       "color": "#27AE60", "icon": "▶"},
    ".png":  {"format": "PNG",        "color": "#00D4FF", "icon": "🖼"},
    ".hdr":  {"format": "HDRI",       "color": "#F39C12", "icon": "○"},
    ".fbx":  {"format": "FBX",        "color": "#2ECC71", "icon": "⬡"},
    ".glb":  {"format": "GLB",        "color": "#27AE60", "icon": "⬡"},
    ".abc":  {"format": "Alembic",    "color": "#E74C3C", "icon": "⬡"},
}
RES_PATTERNS = [
    (r'8[Kk]|8192',  "8K"),
    (r'4[Kk]|4096|UHD', "4K"),
    (r'1[Kk]|1024',  "1K"),
    (r'2[Kk]|2048',  "2K"),
    (r'1080',        "1080p"),
    (r'720',         "720p"),
    (r'[Hh][Dd]',    "HD"),
]
FORMAT_PATTERNS = [
    (r'[Pp]rores\s*4444|[Pp]ro[Rr]es4444', "ProRes 4444"),
    (r'[Pp]rores\s*422',                   "ProRes 422"),
    (r'[Pp]rores',                          "ProRes"),
    (r'[Hh]\.264|H264|h264',              "H.264"),
    (r'[Hh]\.265|H265|[Hh][Ee][Vv][Cc]', "H.265"),
    (r'[Ee][Xx][Rr]',                      "OpenEXR"),
    (r'[Vv][Dd][Bb]',                      "VDB"),
    (r'[Pp][Nn][Gg]',                      "PNG"),
    (r'[Ww]eb[Mm]',                        "WebM"),
    (r'[Ff][Bb][Xx]',                      "FBX"),
]

# Resolve's MediaPool.ImportMedia accepts video / audio / image file
# extensions only. ZIP archives, 3D models (.blend / .fbx / .obj / .glb),
# and VDB volumes are NOT importable — passing them yields an empty
# result. We extract zips, scan for media, import only what Resolve
# handles, and reveal the rest in Explorer for the user to use externally.
RESOLVE_IMPORTABLE_EXTS = {
    # Video
    ".mov", ".mp4", ".m4v", ".mxf", ".avi", ".mkv", ".webm", ".braw",
    ".r3d", ".ari", ".cdng", ".dng",
    # Audio
    ".wav", ".aif", ".aiff", ".mp3", ".aac", ".flac", ".m4a", ".ogg",
    # Image / image sequence frames
    ".png", ".jpg", ".jpeg", ".tif", ".tiff", ".exr", ".dpx", ".bmp",
    ".tga", ".psd", ".heic", ".heif", ".hdr",
}
NON_RESOLVE_EXTS = {
    ".blend", ".fbx", ".obj", ".gltf", ".glb", ".usd", ".usdc", ".usda",
    ".usdz", ".abc", ".dae", ".vdb", ".unitypackage", ".uasset", ".spp",
    ".ztl", ".c4d", ".ma", ".mb", ".3ds", ".lwo", ".x3d", ".ply", ".stl",
}

# 3D formats that DaVinci Fusion can load natively via a comp node.
# Maps file extension → Fusion tool class name.
# Formats NOT in this map (.blend, .c4d, .ma/mb, …) still go to Explorer.
# .vdb is intentionally NOT here — it has its own multi-candidate resolver
# below (`_FUSION_VDB_TOOL_CANDIDATES`) because the actual scripting tool ID
# differs across Fusion versions and is undocumented.
# Official scripting IDs per Blackmagic/VFXPedia docs:
#   FBX + OBJ + 3DS/DAE/DXF → SurfaceFBXMesh  (UI: "FBX Mesh 3D [FBX]")
#   Alembic .abc             → SurfaceAlembicMesh (UI: "Alembic Mesh 3D [Abc]")
# GLB/GLTF are NOT supported by any Fusion node — routed to info dialog.
_FUSION_3D_TOOLS = {
    ".fbx":  "SurfaceFBXMesh",
    ".obj":  "SurfaceFBXMesh",   # OBJ uses the same FBX Mesh 3D node
    ".abc":  "SurfaceAlembicMesh",
}

# Fallback candidates in priority order (correct ID first, legacy IDs as fallback).
# File path attribute:
#   SurfaceFBXMesh / FBXMesh3D / FBXImport  →  ImportFile
#   SurfaceAlembicMesh / Alembic3D          →  Filename
_FUSION_3D_TOOL_CANDIDATES = {
    ".fbx":  ("SurfaceFBXMesh", "FBXMesh3D", "FBXImport", "uFBX"),
    ".obj":  ("SurfaceFBXMesh", "FBXMesh3D", "FBXImport"),
    ".abc":  ("SurfaceAlembicMesh", "Alembic3D", "AbcImport", "uAlembic"),
}

# IDs that use Filename (Alembic); everything else uses ImportFile.
_FUSION_3D_ALEMBIC_IDS = frozenset({"SurfaceAlembicMesh", "Alembic3D", "AbcImport", "uAlembic"})

# ── VDB volume loading (Fusion 19+ Studio) ────────────────────────
# VDB volume nodes are a Fusion 19+ feature and generally require
# DaVinci Resolve Studio (the paid tier). The exact scripting tool
# ID accepted by `comp.AddTool()` is undocumented and varies by
# Fusion build / installed Reactor fuses, so we try several
# candidate IDs in priority order. The first one that returns a
# non-None tool wins. If all fail, the user gets a VDB-specific
# fallback dialog directing them to the manual workflow
# (Fusion → uVolume node → load file from extracted folder).
#
# Priority order, best-guess based on Fusion docs / Reactor /
# community posts (no Resolve available here to verify):
#   • uVolume          — Fusion 19+ USD volume loader (most likely)
#   • VolumeFromImage  — General-purpose volume builder
#   • Volume3D         — 3D scene volume node
#   • VolumeRender3D   — Volumetric renderer
#   • VolumeFromVDBLoader — Possible Reactor / community Fuse name
#   • VolumeLoader     — Original guess (kept as last fallback)
_FUSION_VDB_TOOL_CANDIDATES = (
    "uVolume",
    "VolumeFromImage",
    "Volume3D",
    "VolumeRender3D",
    "VolumeFromVDBLoader",
    "VolumeLoader",
)

# Attribute names the various volume tools may expose for the
# VDB file path. We try each in order until one accepts a
# string assignment without raising. `Clip` is the only one that
# is indexed (Loader-style); the rest are simple scalar attrs.
_FUSION_VDB_PATH_ATTRS = (
    "Filename",
    "ImportFile",
    "Clip",        # Loader-style: tool.Clip[0] = path
    "SourceFile",
    "FilePath",
    "Asset",       # USD-style asset reference
)

# ── Thread-safe debug log file ────────────────────────────────────
_DL_DEBUG_LOG = os.path.join(os.path.expanduser("~"), "Downloads", "CGHEVEN", "_dl_debug.log")

def _dlog(msg):
    """Thread-safe debug log. Writes to file always; prints to stdout only
    when on the main thread, because Resolve's embedded Python stdout can
    raise a C-level SystemError from background threads."""
    try:
        import threading as _th
        if _th.current_thread() is _th.main_thread():
            try:
                print(msg, flush=True)
            except BaseException:
                pass
    except BaseException:
        pass
    try:
        os.makedirs(os.path.dirname(_DL_DEBUG_LOG), exist_ok=True)
        with open(_DL_DEBUG_LOG, "a") as f:
            import threading as _th, time as _t
            f.write(f"{_t.strftime('%H:%M:%S')} tid={_th.get_ident()} {msg}\n")
    except BaseException:
        pass


def _write_minimal_png(path):
    """Write a 16×16 black PNG — used as a one-frame placeholder clip
    to give Fusion a proper USD comp context for uVolume nodes."""
    import struct as _s, zlib as _z
    def _ck(tag, data):
        return _s.pack(">I", len(data)) + tag + data + _s.pack(">I", _z.crc32(tag + data) & 0xffffffff)
    w, h = 16, 16
    raw = b"".join(b"\x00" + b"\x00\x00\x00" * w for _ in range(h))
    with open(path, "wb") as _f:
        _f.write(b"\x89PNG\r\n\x1a\n"
                 + _ck(b"IHDR", _s.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
                 + _ck(b"IDAT", _z.compress(raw))
                 + _ck(b"IEND", b""))


def _discover_vdb_tool_id(fusion):
    """Enumerate Fusion's tool registry and return the best ID for VDB import.

    Tries `fusion.GetRegList()` first; falls back to the candidate list.
    Logs all volume-related tools found for diagnostic purposes.

    The category constant `CT_Tool` is undocumented across Fusion builds,
    so we probe several common values (1, 2, 4, 8, 0xff, 0). The first
    call that returns a non-empty registry wins.

    Returns a string tool ID, or None if discovery fails entirely.
    """
    candidates_priority = (
        "uVolume",
        "VolumeFromImage",
        "Volume3D",
        "VolumeRender3D",
        "VolumeFromVDBLoader",
        "VolumeLoader",
    )
    found = []
    if fusion is None:
        return None

    # Probe: try the documented attribute, then common int category codes,
    # then 0 (often "everything") as a last resort.
    cat_probes = []
    try:
        ct_tool = getattr(fusion, "CT_Tool", None)
        if ct_tool is not None:
            cat_probes.append(int(ct_tool))
    except Exception:
        pass
    for v in (1, 2, 4, 8, 0xff, 0):
        if v not in cat_probes:
            cat_probes.append(v)

    reg = None
    for cat in cat_probes:
        try:
            r = fusion.GetRegList(cat)
        except Exception as e:
            _dlog(f"[FUSION VDB] GetRegList({cat}) raised: {e!r}")
            continue
        if not r:
            continue
        # Prefer the registry that actually returns entries.
        try:
            size = len(r) if hasattr(r, "__len__") else 0
        except Exception:
            size = 0
        if size:
            reg = r
            _dlog(f"[FUSION VDB] GetRegList({cat}) returned {size} entries")
            break
        # Some bindings return non-sized iterable — keep first non-None
        if reg is None:
            reg = r

    if reg:
        try:
            if hasattr(reg, "values"):
                items = list(reg.values())
            elif hasattr(reg, "items"):
                items = [v for _, v in reg.items()]
            else:
                items = list(reg)
        except Exception as e:
            _dlog(f"[FUSION VDB] iterating GetRegList result failed: {e!r}")
            items = []

        for entry in items:
            try:
                name = ""
                if hasattr(entry, "GetID"):
                    try:
                        name = entry.GetID() or ""
                    except Exception:
                        name = ""
                if not name:
                    if isinstance(entry, dict):
                        name = entry.get("REGS_ID") or entry.get("ID") or entry.get("REGS_Name") or ""
                    elif isinstance(entry, str):
                        name = entry
                    else:
                        try:
                            name = entry["REGS_ID"]
                        except Exception:
                            try:
                                name = entry["ID"]
                            except Exception:
                                name = ""
                if isinstance(name, str) and name:
                    low = name.lower()
                    if "volume" in low or "vdb" in low or "openvdb" in low:
                        found.append(name)
            except Exception:
                continue

    if found:
        _dlog(f"[FUSION VDB] discovered volume-related tools in registry: {found}")
        for c in candidates_priority:
            if c in found:
                return c
        return found[0]

    _dlog("[FUSION VDB] registry enumeration empty/failed — falling back to candidate list")
    return None


# Never list these in the addon (Home, search, category, favorites, downloaded, prefetch cache).
EXCLUDED_CATALOG_CATEGORY_SLUGS = frozenset({"flipbooks", "shader", "shaders"})
_EXCLUDED_CAT_SLUGS_LOWER = frozenset(s.lower() for s in EXCLUDED_CATALOG_CATEGORY_SLUGS)


def _cgheven_relation_slug(rel):
    """Strapi relation or entity dict → category slug, or ''."""
    if not isinstance(rel, dict):
        return ""
    d = rel.get("data")
    if isinstance(d, dict):
        inner = d.get("attributes", d)
        if isinstance(inner, dict):
            s = inner.get("Slug", inner.get("slug", ""))
            if s:
                return str(s)
    at = rel.get("attributes")
    if isinstance(at, dict):
        s = at.get("Slug", at.get("slug", ""))
        if s:
            return str(s)
    s = rel.get("Slug", rel.get("slug", ""))
    return str(s or "")


def _cgheven_subcategories_raw_list(subs):
    if subs is None:
        return []
    if isinstance(subs, dict):
        if "data" in subs:
            subs = subs.get("data") or []
        else:
            subs = [subs]
    if isinstance(subs, list):
        return subs
    return []

# ──────────────────────────────────────────────────────────────────
#  LICENSE SYSTEM
# ──────────────────────────────────────────────────────────────────
def _lic_hash(key: str) -> str:
    raw = f"{key.upper().strip()}:{LICENSE_SALT}"
    return hashlib.sha256(raw.encode()).hexdigest()

def validate_key(key: str) -> bool:
    key = key.upper().strip()
    # Strapi / dashboard keys: CGHV-XXXXXXXX-XXXXXXXX-XXXXXXXX (3 × 8 hex)
    if re.match(r"^CGHV-[A-F0-9]{8}-[A-F0-9]{8}-[A-F0-9]{8}$", key):
        return True
    # Legacy offline keys: CGHV-XXXX-XXXX-XXXX-XXXX (last block = checksum)
    if not re.match(r"^CGHV-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}$", key):
        return False
    parts = key.split("-")
    body  = "".join(parts[1:4])
    check = parts[4]
    expected = hashlib.md5((body + LICENSE_SALT).encode()).hexdigest()[:4].upper()
    return check == expected

def save_license(key: str):
    data = base64.b64encode(_lic_hash(key).encode()).decode()
    with open(LICENSE_FILE, "w") as f:
        f.write(f"CGHEVEN_PRO\n{data}\n{key}")

def load_license() -> str | None:
    try:
        with open(LICENSE_FILE) as f:
            lines = f.read().strip().splitlines()
        if len(lines) < 3 or lines[0] != "CGHEVEN_PRO":
            return None
        stored_hash = base64.b64decode(lines[1]).decode()
        key = lines[2]
        if stored_hash == _lic_hash(key) and validate_key(key):
            return key
    except Exception:
        pass
    return None

# ──────────────────────────────────────────────────────────────────
#  FILE PARSER
# ──────────────────────────────────────────────────────────────────
_3D_ZIP_FOLDER_PATTERNS = [
    (r'/GLTF/',   "GLTF",  "#27AE60", "⬡"),
    (r'/FBX/',    "FBX",   "#2ECC71", "⬡"),
    (r'/OBJ/',    "OBJ",   "#F39C12", "⬡"),
    (r'/ABC/',    "ABC",   "#E74C3C", "⬡"),
    (r'/USDC/',   "USDC",  "#9B59B6", "⬡"),
    (r'/USD/',    "USDC",  "#9B59B6", "⬡"),
    (r'/VDB/',    "VDB",   "#FF6B35", "⬡"),
    (r'/BLEND/',  "BLEND", "#FF7043", "⬡"),
]

def parse_file_url(url: str) -> dict:
    # Backend marks premium (4K/6K/8K/16K/EXR or patreon-locked) options for free/guest users
    # with a "CGHLOCKED::" prefix instead of the real CDN URL — so the option is shown with an
    # "Upgrade" button (bypass-proof: no downloadable URL). The filename is preserved so the
    # resolution / format still parse for labelling.
    locked = url.startswith("CGHLOCKED::")
    if locked:
        url = url[len("CGHLOCKED::"):]
    fname = url.split("/")[-1].split("?")[0]
    ext   = os.path.splitext(fname)[1].lower()
    meta  = TYPE_META.get(ext, {"format": ext.lstrip(".").upper() or "Unknown",
                                 "color": C["dim"], "icon": "📄"})
    resolution = ""
    for pattern, label in RES_PATTERNS:
        if re.search(pattern, fname):
            resolution = label
            break
    fmt   = meta["format"]
    color = meta["color"]
    icon  = meta["icon"]
    # For ZIP files: detect 3D format from URL folder path OR filename (e.g. /GLTF/ or _gltf_)
    if ext == ".zip":
        url_upper  = url.replace("%2B", "+").upper()
        fname_upper = fname.upper()
        _fn_patterns = {
            "GLTF":  r'_GLTF[_.]|_GLB[_.]',
            "FBX":   r'_FBX[_.]',
            "OBJ":   r'_OBJ[_.]',
            "ABC":   r'_ABC[_.]',
            "USDC":  r'_USDC[_.]|_USD[_.]',
            "VDB":   r'_VDB[_.]',
            "BLEND": r'_BLEND[_.]',
        }
        matched = False
        for folder_pat, folder_fmt, folder_color, folder_icon in _3D_ZIP_FOLDER_PATTERNS:
            if re.search(folder_pat.upper(), url_upper):
                fmt, color, icon = folder_fmt, folder_color, folder_icon
                matched = True
                break
        if not matched:
            for folder_pat, folder_fmt, folder_color, folder_icon in _3D_ZIP_FOLDER_PATTERNS:
                fn_pat = _fn_patterns.get(folder_fmt)
                if fn_pat and re.search(fn_pat, fname_upper):
                    fmt, color, icon = folder_fmt, folder_color, folder_icon
                    break
    else:
        for pattern, label in FORMAT_PATTERNS:
            if re.search(pattern, fname):
                fmt = label
                break
    return {
        "url":        ("" if locked else url),
        "filename":   fname,
        "ext":        ext,
        "format":     fmt,
        "resolution": resolution,
        "color":      color,
        "icon":       icon,
        "size_bytes": 0,
        "size_str":   "",
        "locked":     locked,
    }

def _3d_asset_has_gltf(asset) -> bool:
    """Returns False only for 3D model assets that have no GLTF/GLB format files.
    All other asset categories always return True (not filtered).
    Used to hide coming-soon 3D assets from the grid.
    """
    try:
        a = asset.get("attributes", asset) or {}
        cat = a.get("categorie") or {}
        slug = str(cat.get("Slug", cat.get("slug", cat.get("Name", ""))) or "").lower()
        if "3d" not in slug and slug != "3d-models":
            return True
        files = a.get("files", []) or []
        if not files:
            return False
        return any(
            str(parse_file_url(str(f)).get("format", "")).upper() in ("GLTF", "GLB")
            for f in files if f
        )
    except Exception:
        return True


def fetch_file_size(url: str) -> int:
    try:
        req = urllib.request.Request(url, method="HEAD",
                                     headers={"User-Agent": "CGHEVEN-Pro/2.0"})
        with urllib.request.urlopen(req, timeout=8) as r:
            size = int(r.headers.get("Content-Length", 0) or 0)
            if size > 0:
                return size
    except Exception:
        pass
    try:
        req = urllib.request.Request(
            url,
            headers={
                "User-Agent": "CGHEVEN-Pro/2.0",
                "Range": "bytes=0-0",
            },
        )
        with urllib.request.urlopen(req, timeout=10) as r:
            content_range = str(r.headers.get("Content-Range", "") or "")
            match = re.search(r"/(\d+)$", content_range)
            if match:
                return int(match.group(1))
            return int(r.headers.get("Content-Length", 0) or 0)
    except Exception:
        return 0

def fmt_bytes(n: int) -> str:
    if n <= 0: return "N/A"
    for unit in ("B","KB","MB","GB","TB"):
        if n < 1024: return f"{n:.1f} {unit}"
        n /= 1024
    return f"{n:.1f} PB"


def _panel_parse_fraction_fps(rate: str):
    rate = str(rate or "").strip()
    if not rate or rate == "0/0":
        return None
    if "/" in rate:
        a, _, b = rate.partition("/")
        try:
            return float(a) / float(b) if float(b) else None
        except (TypeError, ValueError, ZeroDivisionError):
            return None
    try:
        return float(rate)
    except (TypeError, ValueError):
        return None


def _panel_resolution_dims(resolution: str, filename: str = "") -> tuple[int, int]:
    """Map badge resolution + filename hints to pixel width/height for size estimates."""
    fn = str(filename or "")
    m = re.search(r"(\d{3,5})\s*[x×]\s*(\d{3,5})", fn, re.I)
    if m:
        try:
            w, h = int(m.group(1)), int(m.group(2))
            if w >= 64 and h >= 64:
                return w, h
        except (TypeError, ValueError):
            pass
    r = str(resolution or "").strip().upper()
    preset = {
        "8K": (7680, 4320),
        "4K": (3840, 2160),
        "UHD": (3840, 2160),
        "2K": (2048, 1080),
        "1K": (1024, 1024),
        "1080P": (1920, 1080),
        "720P": (1280, 720),
        "HD": (1920, 1080),
    }
    if r in preset:
        return preset[r]
    if r in ("ANY", "N/A", "NA", ""):
        return 1920, 1080
    mk = re.search(r"(\d+(?:\.\d+)?)\s*K", r)
    if mk:
        try:
            k = float(mk.group(1))
            w = int(round(1920 * (k / 2.0)))
            h = int(round(1080 * (k / 2.0)))
            return max(w, 640), max(h, 360)
        except (TypeError, ValueError):
            pass
    return 1920, 1080


def _panel_guess_frame_count_int(file_info: dict | None) -> int | None:
    if not file_info:
        return None
    try:
        ext = str(file_info.get("ext", "") or "").lower()
        filename = str(file_info.get("filename", "") or "")
        lower_name = filename.lower()

        if ext in (".exr", ".png", ".jpg", ".jpeg", ".webp", ".hdr", ".tif", ".tiff", ".bmp"):
            return 1

        grid_match = re.search(r"(\d{1,2})\s*x\s*(\d{1,2})", lower_name)
        if grid_match:
            try:
                cols = int(grid_match.group(1))
                rows = int(grid_match.group(2))
                if cols > 0 and rows > 0:
                    return cols * rows
            except (TypeError, ValueError):
                pass

        for pattern in (
            r"(\d+)\s*frames?",
            r"(\d+)\s*fr(?:ames?)?\b",
            r"\bframe[_\-\s]?(\d+)\b",
            r"\b(\d+)\s*f\b",
        ):
            match = re.search(pattern, lower_name)
            if match:
                n = int(match.group(1))
                if n > 0:
                    return n
    except Exception:
        pass
    return None


def _panel_format_frame_display(count: int | None) -> str:
    if count is None or count < 1:
        return "—"
    if count == 1:
        return "1 frame"
    return f"{count} frames"


def _panel_estimate_asset_bytes(file_info: dict | None, frame_count: int | None) -> int:
    """Rough on-disk size from format, resolution label, and frame count (no API field)."""
    if not file_info:
        return 0
    ext = str(file_info.get("ext", "") or "").lower()
    if ext in (".zip", ".vdb", ".fbx", ".glb", ".abc", ".rar", ".7z", ".blend"):
        return 0
    w, h = _panel_resolution_dims(
        str(file_info.get("resolution", "") or ""),
        str(file_info.get("filename", "") or ""),
    )
    pixels = max(1, w) * max(1, h)
    fc = int(frame_count) if frame_count and int(frame_count) > 0 else 1
    ext_vid = ext in (".mp4", ".mov", ".webm", ".m4v")
    if ext_vid and (frame_count is None or int(frame_count or 0) < 2):
        fc = max(fc, 300)
    fmt_lower = str(file_info.get("format", "") or "").lower()

    if ext == ".exr" or "exr" in fmt_lower:
        bpf = pixels * 10.0
    elif ext == ".png" or "png" in fmt_lower:
        bpf = pixels * 2.5
    elif ext in (".jpg", ".jpeg"):
        bpf = pixels * 0.35
    elif ext == ".hdr" or "hdri" in fmt_lower:
        bpf = pixels * 4.0
    elif ext in (".mp4", ".m4v") or "h.264" in fmt_lower or "h264" in fmt_lower:
        bpf = pixels * 0.14
    elif "h.265" in fmt_lower or "h265" in fmt_lower or "hevc" in fmt_lower:
        bpf = pixels * 0.10
    elif ext in (".mov",) or "prores" in fmt_lower:
        bpf = pixels * 5.5
    elif ext == ".webm" or "webm" in fmt_lower:
        bpf = pixels * 0.12
    else:
        bpf = pixels * 0.45

    total = int(bpf * fc)
    return max(total, 2048)


def _panel_ffprobe_frame_count(url: str, ffprobe_exe: str) -> int | None:
    if not url or not ffprobe_exe:
        return None
    try:
        cmd = [
            ffprobe_exe, "-v", "error", "-select_streams", "v:0",
            "-show_entries", "stream=nb_frames,avg_frame_rate,r_frame_rate",
            "-show_entries", "format=duration",
            "-of", "json", url,
        ]
        proc = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=12,
            **_subprocess_hide_console_kwargs(),
        )
        if proc.returncode != 0 or not (proc.stdout or "").strip():
            return None
        payload = json.loads(proc.stdout)
        stream = ((payload.get("streams") or [{}])[0]) if isinstance(payload, dict) else {}
        fmt = payload.get("format") if isinstance(payload, dict) else {}
        nb = str(stream.get("nb_frames", "") or "").strip()
        if nb.isdigit() and int(nb) > 0:
            return int(nb)
        dur_s = str((fmt or {}).get("duration", "") or "").strip()
        duration = None
        if dur_s:
            try:
                duration = float(dur_s)
            except (TypeError, ValueError):
                duration = None
        fps = _panel_parse_fraction_fps(stream.get("avg_frame_rate", ""))
        if fps is None or fps <= 0:
            fps = _panel_parse_fraction_fps(stream.get("r_frame_rate", ""))
        if duration and fps and fps > 0:
            n = int(round(duration * fps))
            return n if n > 0 else None
    except Exception:
        pass
    return None

# ──────────────────────────────────────────────────────────────────
#  API CLIENT
# ──────────────────────────────────────────────────────────────────
class CGHEVENApi:
    def __init__(self, session_token: str = "", token: str = ""):
        self.session_token = session_token or ""
        self.token         = token or ""
        self.base          = f"{LICENSE_SERVER}/api/proxy"
        self.headers = {
            "User-Agent":    f"CGHEVEN-Pro/{ADDON_VER}",
            "Accept":        "application/json",
            "Content-Type":  "application/json",
            "Cache-Control": "no-cache",
            "Pragma":        "no-cache",
        }
        if self.session_token:
            self.headers["Authorization"] = f"Bearer {self.session_token}"

    def update_token(self, new_token: str):
        """Update the session token when a fresh JWT is received from the heartbeat."""
        self.session_token = new_token
        if new_token:
            self.headers["Authorization"] = f"Bearer {new_token}"
        else:
            self.headers.pop("Authorization", None)

    def _get(self, endpoint: str, params: dict = None) -> dict:
        _t0 = time.time()
        http_status = None
        # Guest mode: redirect /assets and /categories to public endpoints
        actual_endpoint = endpoint
        if not self.session_token:
            if "/assets" in actual_endpoint and "/public/" not in actual_endpoint:
                actual_endpoint = actual_endpoint.replace("/assets", "/public/assets")
            if "/categories" in actual_endpoint and "/public/" not in actual_endpoint:
                actual_endpoint = actual_endpoint.replace("/categories", "/public/categories")
        url = f"{self.base}/{actual_endpoint.lstrip('/')}"
        # Build request headers dynamically so guest requests don't send stale Bearer
        req_headers = dict(self.headers)
        if self.session_token:
            req_headers["Authorization"] = f"Bearer {self.session_token}"
        else:
            req_headers.pop("Authorization", None)
        p = dict(params or {})
        p["_t"] = int(time.time() * 1000)
        url += "?" + urllib.parse.urlencode(p, quote_via=urllib.parse.quote, safe="[]$")
        if CGHEVEN_DEBUG_VERBOSE:
            print(f"[API URL] {url[:300]}")
        req = urllib.request.Request(url, headers=req_headers)
        try:
            with urllib.request.urlopen(req, timeout=15) as r:
                try:
                    http_status = int(getattr(r, "status", None) or r.getcode() or 0)
                except Exception:
                    http_status = None
                response_data = r.read().decode('utf-8')
                if not response_data:
                    raise Exception(f"Empty response from API")
                # Analytics: api performance (sampled)
                try:
                    if ANALYTICS_AVAILABLE and _analytics and _analytics_distinct_id():
                        _analytics.send_api_call_performance(
                            license_server=LICENSE_SERVER,
                            addon_slug=CGHEVEN_ADDON_SLUG,
                            distinct_id=_analytics_distinct_id(),
                            endpoint=str(endpoint or ""),
                            status="success",
                            response_time_ms=int((time.time() - _t0) * 1000),
                            http_status=http_status,
                            sample_rate=1.0,
                        )
                except Exception:
                    pass
                return json.loads(response_data)
        except urllib.error.HTTPError as e:
            http_status = int(getattr(e, "code", 0) or 0)
            error_msg = f"HTTP {e.code}: {e.reason}"
            try:
                error_body = e.read().decode('utf-8')
                if error_body:
                    error_json = json.loads(error_body)
                    if 'error' in error_json:
                        error_msg += f" - {error_json['error']}"
            except:
                pass
            # Analytics: api performance (sampled)
            try:
                if ANALYTICS_AVAILABLE and _analytics and _analytics_distinct_id():
                    _analytics.send_api_call_performance(
                        license_server=LICENSE_SERVER,
                        addon_slug=CGHEVEN_ADDON_SLUG,
                        distinct_id=_analytics_distinct_id(),
                        endpoint=str(endpoint or ""),
                        status="error",
                        response_time_ms=int((time.time() - _t0) * 1000),
                        http_status=http_status,
                        error=str(error_msg or "")[:200],
                        sample_rate=1.0,
                    )
            except Exception:
                pass
            if e.code == 401:
                raise Exception("SESSION_EXPIRED")
            if e.code == 403:
                raise Exception("LICENSE_INACTIVE")
            if e.code == 429:
                raise Exception("Too many requests. Please wait a moment and try again.")
            if e.code >= 500:
                raise Exception("The server is temporarily unavailable. Please try again in a few minutes.")
            raise Exception("Unable to load content. Please check your connection and try again.")
        except urllib.error.URLError as e:
            # Analytics: api performance (sampled)
            try:
                if ANALYTICS_AVAILABLE and _analytics and _analytics_distinct_id():
                    _analytics.send_api_call_performance(
                        license_server=LICENSE_SERVER,
                        addon_slug=CGHEVEN_ADDON_SLUG,
                        distinct_id=_analytics_distinct_id(),
                        endpoint=str(endpoint or ""),
                        status="error",
                        response_time_ms=int((time.time() - _t0) * 1000),
                        http_status=None,
                        error="URLError",
                        sample_rate=1.0,
                    )
            except Exception:
                pass
            raise Exception("Unable to connect to the server. Please check your internet connection.")
        except json.JSONDecodeError:
            raise Exception("Received an unexpected response. Please try again.")
        except Exception as e:
            raise Exception(str(e))

    def fetch_all(self, endpoint: str, page_size=100) -> list:
        all_items, page = [], 1
        while True:
            params = {
                "populate":              "*",
                "pagination[page]":      page,
                "pagination[pageSize]":  page_size,
            }
            # Send addonSlug for asset requests so backend computes addon_is_free
            if "assets" in endpoint:
                params["addonSlug"] = ADDON_ACCESS_SLUG
            data = self._get(endpoint, params)
            items = data.get("data", [])
            all_items.extend(items)
            # Capture free-trial meta from first page so callers can read it
            if page == 1:
                _meta = data.get("meta", {})
                self.last_free_trial       = bool(_meta.get("free_trial", False))
                self.last_free_trial_until = _meta.get("free_trial_until", "") or ""
                self.last_banner           = _meta.get("banner") or getattr(self, "last_banner", None)
            meta  = data.get("meta", {}).get("pagination", {})
            total = meta.get("total", len(all_items))
            if page * page_size >= total or not items:
                break
            page += 1
        return all_items

    def fetch_categories(self) -> list:
        # populate=* to get parent/child relations from API
        data = self._get("/categories", {
            "pagination[pageSize]": 200,
            "populate": "*",
        })
        return data.get("data", [])

    def search_assets(self, query: str, cat_slug: str = None,
                      page=1, page_size=GRID_PAGE_SIZE) -> tuple[list, int]:
        params = {
            "populate":             "*",
            "pagination[page]":     page,
            "pagination[pageSize]": page_size,
            "sort":                 "createdAt:desc",
        }
        params["addonSlug"] = ADDON_ACCESS_SLUG
        if query:
            params["filters[Title][$containsi]"] = query
        if cat_slug and cat_slug != "all":
            params["filters[categorie][Slug][$eq]"] = cat_slug
        data = self._get("/assets", params)
        items = data.get("data", [])
        total = data.get("meta", {}).get("pagination", {}).get("total", len(items))
        return items, total

# ──────────────────────────────────────────────────────────────────
#  LICENSE ACTIVATION WINDOW
# ──────────────────────────────────────────────────────────────────
class LicenseWindow(tk.Tk):
    def __init__(self):
        # Destroy any pre-init hidden root first
        try:
            if hasattr(tk, '_default_root') and tk._default_root:
                tk._default_root.withdraw()
                tk._default_root.destroy()
                tk._default_root = None
        except Exception:
            pass
        super().__init__()
        tk._default_root = self
        
        self.title(f"{ADDON_NAME}  —  Activate")
        self.geometry("520x360")
        self.resizable(False, False)
        self.configure(bg=C["bg"])
        self.result_key = None
        self._activate_poll_id = None
        try:
            print(f"[ACTIVATE] running file: {__file__}")
        except Exception:
            pass
        self._apply_app_icon()
        self._build()

    def _apply_app_icon(self):
        """Use the same CGHEVEN icon instead of default Tk icon."""
        if not PIL_AVAILABLE:
            return
        try:
            import tempfile

            raw = _cgheven_load_app_icon_rgba(timeout=8)
            try:
                img = _cgheven_prepare_icon_square(raw)
            except Exception:
                img = raw

            if sys.platform == "win32":
                try:
                    ico_path = os.path.join(tempfile.gettempdir(), "cgheven_app_icon_activate.ico")
                    _cgheven_write_taskbar_ico(img, ico_path)
                    try:
                        self.iconbitmap(ico_path)
                    except Exception:
                        pass
                except Exception:
                    pass

            try:
                photos = _cgheven_tk_iconphoto_chain(img, self)
                self.iconphoto(True, *photos)
                self._app_icon = photos[-1]
            except Exception:
                pass
        except Exception:
            pass

    def _build(self):
        hdr = tk.Frame(self, bg=C["topbar"], height=60)
        hdr.pack(fill="x"); hdr.pack_propagate(False)
        tk.Label(hdr, text=f"  🔐  {ADDON_NAME}",
                 bg=C["topbar"], fg=C["accent"],
                 font=("Segoe UI", 14, "bold")).pack(side="left", padx=12)
        tk.Label(hdr, text=f"v{ADDON_VER}",
                 bg=C["topbar"], fg=C["dim"],
                 font=("Segoe UI", 9)).pack(side="right", padx=12)

        body = tk.Frame(self, bg=C["bg"], pady=24)
        body.pack(fill="both", expand=True, padx=32)

        # ── PRIMARY: Login + Sign up on ONE row (professional) ──
        _auth_row = tk.Frame(body, bg=C["bg"])
        _auth_row.pack(fill="x", pady=(0, 10))
        self.login_btn = tk.Button(_auth_row, text="🔐  Login",
                                    bg=C["cta"], fg="#ffffff", relief="flat",
                                    activebackground=C["cta_h"], activeforeground="white",
                                    font=("Segoe UI", 11, "bold"), cursor="hand2",
                                    pady=12, command=lambda: self._account_login(register=False))
        self.login_btn.pack(side="left", fill="x", expand=True, padx=(0, 5))
        # Sign up — same browser flow, opens the registration screen first.
        self.signup_btn = tk.Button(_auth_row, text="✨  Sign up",
                                    bg=C["card"], fg=C["text"], relief="flat",
                                    activebackground=C["card_h"], activeforeground="white",
                                    font=("Segoe UI", 11, "bold"), cursor="hand2",
                                    pady=12, command=lambda: self._account_login(register=True))
        self.signup_btn.pack(side="left", fill="x", expand=True, padx=(5, 0))

        tk.Label(body,
                 text=(
                     "Log in with your CGHEVEN account to activate the addon.\n"
                     "Your browser will open to complete a quick, secure sign-in."
                 ),
                 bg=C["bg"], fg=C["text2"],
                 font=("Segoe UI", 9), justify="left").pack(anchor="w")

        self.status_lbl = tk.Label(body, text="", bg=C["bg"],
                                    font=("Segoe UI", 9))
        self.status_lbl.pack(anchor="w", pady=8)

        tk.Button(body, text="View plans & pricing →",
                  bg=C["card"], fg=C["accent"],
                  activebackground=C["card_h"],
                  relief="flat", cursor="hand2",
                  font=("Segoe UI", 9, "bold"), padx=12, pady=8,
                  command=lambda: webbrowser.open(PRICING_URL)
                  ).pack(fill="x", pady=(4, 0))

    def _activate(self):
        key = self.key_entry.get().strip()
        if not key:
            key = self.key_var.get().strip()
        if not key:
            self._set_status("Please enter a license key.", C["red"])
            return
        prev_nudge = getattr(self, "_validating_nudge_id", None)
        if prev_nudge is not None:
            try:
                self.after_cancel(prev_nudge)
            except Exception:
                pass
            self._validating_nudge_id = None
        poll_id = getattr(self, "_activate_poll_id", None)
        if poll_id is not None:
            try:
                self.after_cancel(poll_id)
            except Exception:
                pass
            self._activate_poll_id = None
        self._set_status("⟳  Validating...", C["text2"])

        def _nudge_slow():
            try:
                cur = self.status_lbl.cget("text")
                if "Validating" in cur or cur.startswith("⟳"):
                    self._set_status(
                        "⟳  Still contacting server… (up to ~40s on a slow network)",
                        C["text2"],
                    )
            except Exception:
                pass

        self._validating_nudge_id = self.after(10000, _nudge_slow)
        self._do_validate(key)

    def _poll_activation_result(self, result_q: queue.Queue):
        """
        Worker thread se Tk update mat karo — DaVinci Resolve jaise hosts me
        ``widget.after()`` background thread se fail / delay ho sakta hai.
        Queue result yahan (main thread) poll karke UI update karo.
        """
        try:
            item = result_q.get_nowait()
        except queue.Empty:
            self._activate_poll_id = self.after(
                50, lambda rq=result_q: self._poll_activation_result(rq)
            )
            return

        self._activate_poll_id = None
        kind = item[0]

        if kind == "success":
            _, result, key_ok = item
            self._session_result = result
            self.result_key = key_ok
            self._set_status("✓  Activated successfully!", C["green"])
            # Analytics
            if ANALYTICS_AVAILABLE and _analytics:
                try:
                    _did = _analytics.anonymize_distinct_id(key_ok or "unknown")
                    _analytics.send_license_activated(
                        license_server=LICENSE_SERVER, addon_slug=CGHEVEN_ADDON_SLUG,
                        addon_version=ADDON_VERSION, distinct_id=_did)
                    _analytics.send_addon_installed(
                        license_server=LICENSE_SERVER, addon_slug=CGHEVEN_ADDON_SLUG,
                        addon_version=ADDON_VERSION, distinct_id=_did)
                except Exception:
                    pass
            self.after(900, self.destroy)
            return

        if kind == "license_error":
            e = item[1]
            detail = str(e).strip()
            if e.code == 402:
                msg = "Your license has expired. Please renew your Patreon membership to continue."
            elif e.code == 403:
                msg = "This key is already in use on another device."
            elif e.code in (401, 404):
                msg = "The license key is not valid."
            elif e.code == 429:
                msg = "Too many attempts. Please wait a few minutes and try again."
            elif e.code == 0:
                dl = detail.lower()
                net_hints = (
                    "network error",
                    "contacting license server",
                    "timed out",
                    "timeout",
                    "ssl/tls",
                    "certificate",
                    "connection",
                    "gaierror",
                    "getaddrinfo",
                )
                if detail and not any(h in dl for h in net_hints):
                    msg = detail
                else:
                    msg = "Unable to connect to the license server."
            else:
                msg = "Activation failed."
            extra = []
            try:
                extra.append(f"Code: {getattr(e, 'code', '')}")
            except Exception:
                pass
            if detail and detail not in msg:
                extra.append(f"Details: {detail}")
            if extra:
                msg = f"{msg}\n\n" + "\n".join(extra)
            self._set_status(msg, C["red"])
            return

        if kind == "other_error":
            e = item[1]
            err = f"{type(e).__name__}: {e}"
            print(f"[ACTIVATE] Unexpected error: {err}")
            self._set_status(
                "Activation failed (unexpected error).\n\n" + err,
                C["red"],
            )

    def _account_login(self, register=False):
        """BlenderKit-style browser login (background thread). On success, save the
        session and close the window exactly like a successful key activation."""
        if not AUTH_MODULE_AVAILABLE:
            self._set_status("Account login unavailable (auth module missing).", C["red"])
            return
        self._reg_flag = bool(register)
        try:
            self.login_btn.config(state="disabled", text="Opening browser…")
            self.signup_btn.config(state="disabled")
        except Exception:
            pass
        self._set_status("Opening browser… complete the login in your browser.", C["text2"])
        self._session_result = None
        result_q: queue.Queue = queue.Queue()

        def _status_cb(msg):
            result_q.put(("login_status", str(msg)))

        def _worker():
            try:
                result = login_with_account(status_cb=_status_cb, register=register)
                # session_token doubles as the "key" for the success path
                result_q.put(("success", result, result.get("session_token", "") or "ACCOUNT"))
            except LicenseError as e:
                result_q.put(("login_error", e))
            except Exception as e:
                result_q.put(("login_error", e))

        threading.Thread(target=_worker, daemon=True).start()
        self._poll_account_login(result_q)

    def _poll_account_login(self, result_q: queue.Queue):
        try:
            item = result_q.get_nowait()
        except queue.Empty:
            self.after(80, lambda rq=result_q: self._poll_account_login(rq))
            return
        kind = item[0]
        if kind == "login_status":
            self._set_status(str(item[1])[:140], C["text2"])
            self.after(80, lambda rq=result_q: self._poll_account_login(rq))
            return
        if kind == "success":
            _, result, key_ok = item
            self._session_result = result
            self.result_key = key_ok
            # Analytics: identity just switched (guest → account email). Log the login so
            # the dashboard can track accounts + plan, and re-arm session_started.
            try:
                _analytics_emit_account_login(register=getattr(self, "_reg_flag", False))
            except Exception:
                pass
            self._set_status("✓  Logged in successfully!", C["green"])
            self.after(900, self.destroy)
            return
        if kind == "login_error":
            e = item[1]
            try:
                self.login_btn.config(state="normal", text="🔐  Login")
                self.signup_btn.config(state="normal")
            except Exception:
                pass
            self._set_status(f"Login failed: {str(e)[:120]}", C["red"])
            return

    def _do_validate(self, key):
        if not AUTH_MODULE_AVAILABLE:
            if validate_key(key):
                save_license(key)
                self.result_key = key
                self._set_status("✓  Activated successfully!", C["green"])
                self.after(900, self.destroy)
            else:
                self._set_status("✗  Invalid license key. Check format and try again.", C["red"])
            return

        self._session_result = None
        result_q: queue.Queue = queue.Queue()

        def _worker():
            try:
                result = activate_license(key)
                result_q.put(("success", result, key))
            except LicenseError as e:
                result_q.put(("license_error", e))
            except Exception as e:
                result_q.put(("other_error", e))

        threading.Thread(target=_worker, daemon=True).start()
        self._poll_activation_result(result_q)

    def _set_status(self, msg, color):
        try:
            if not str(msg).lstrip().startswith("⟳"):
                vid = getattr(self, "_validating_nudge_id", None)
                if vid is not None:
                    try:
                        self.after_cancel(vid)
                    except Exception:
                        pass
                    self._validating_nudge_id = None
        except Exception:
            pass
        self.status_lbl.configure(text=msg, fg=color)

# ──────────────────────────────────────────────────────────────────
#  MAIN APPLICATION
# ──────────────────────────────────────────────────────────────────
class CGHEVENApp(tk.Tk):
    def __init__(self, license_key: str, session_token: str = "", user_plan: str = ""):
        super().__init__()

        # Hide window until fully initialized to prevent flash
        self.withdraw()

        self.license_key   = license_key
        self.session_token = session_token
        self.is_guest      = not bool(session_token)
        # Plan label of the active session ('Free Access' for free_addon keys). Free Access
        # users can preview everything but cannot download Patreon-locked assets.
        self.user_plan     = user_plan or ""
        # Display label + premium flag, updated live by the heartbeat on a plan change
        # (Free → Pro/Studio) so the UI reflects it without a re-login.
        self.user_plan_label = ""
        self.is_premium      = False
        self.free_trial    = False
        self.banner_cfg    = None  # guest banner config from backend (Banner Settings)
        # Default to Home (all assets), not the Free filter — even for guests
        self._free_filter_active = False
        # Stable PostHog distinct id: account EMAIL → legacy license key → guest_<machine>.
        # NEVER the session token (it rotates every heartbeat → fragments one user into many
        # distinct_ids). Email is sent un-hashed so it matches the account identity elsewhere.
        try:
            if ANALYTICS_AVAILABLE and _analytics:
                self.analytics_distinct_id = _analytics_identity(
                    license_key=self.license_key, session_token=self.session_token
                )
                if not self.analytics_distinct_id:
                    # Last resort — keep analytics alive with an anonymous, stable id.
                    self.analytics_distinct_id = _analytics.anonymize_distinct_id(
                        self.license_key or "unknown"
                    )
            else:
                self.analytics_distinct_id = ""
        except Exception:
            self.analytics_distinct_id = ""
        # Expose distinct id to module-level consumers (API client, threads)
        try:
            global _ANALYTICS_DISTINCT_ID
            _ANALYTICS_DISTINCT_ID = self.analytics_distinct_id or ""
        except Exception:
            pass
        self._api_error    = None
        try:
            self.api = CGHEVENApi(session_token=self.session_token)
        except Exception as e:
            self._api_error = f"Failed to initialize API: {str(e)}"
            print(f"[ERROR] {self._api_error}")

        self.resolve       = None
        self.categories    = []
        self.all_assets    = []
        self.filtered      = []
        self.thumbnails    = {}
        self.selected      = None
        self.active_cat    = "all"
        self.current_page  = 1
        self.total_assets  = 0
        self.expanded_categories = set()
        self.search_var    = tk.StringVar()
        self.sort_var      = tk.StringVar(value="Newest")
        self.filter_downloaded_only = tk.BooleanVar(value=False)
        self.filter_favourite_only = tk.BooleanVar(value=False)
        self.filter_resolution_var = tk.StringVar(value="All")
        self.filter_type_var = tk.StringVar(value="All")
        self.status_var    = tk.StringVar(value="⟳  Connecting to CGHEVEN API...")
        self.grid_cols_pref = tk.IntVar(value=4)
        self.grid_auto_mode = True
        self.active_view = "home"
        self._search_committed_query = ""
        self._preview_job  = None
        self._thumb_queue  = []
        self._thumb_flush_job = None
        self._search_after = None
        self._scroll_idle_armed = False
        self._grid_scroll_accum = 0
        self._grid_scroll_tick_job = None
        self._grid_scroll_end_job = None
        self._grid_scroll_suppress_thumbs = False
        self._grid_sidebar_repaint_hold = False
        self._grid_pan_resync_active = False
        self._grid_restore_yview_lo = None
        self._grid_pan_resync_token = 0
        self._thumb_deferred_batch = []
        self._thumb_deferred_set = []
        self._thumb_deferred_widgets = []
        self._grid_inner_configure_wh = None
        self._last_grid_scrollregion = None
        self._grid_cards_by_id = {}

        # Download tracking
        self.active_downloads    = {}
        self.completed_downloads = []
        self.download_counter    = 0
        self.pending_imports     = []
        self.category_cache      = {}

        # Disk cache (thumbnails + asset list)
        self._cache_root = os.path.join(os.path.expanduser("~"), ".cgheven_cache")
        self.cache_dir = os.path.join(self._cache_root, "thumbnails")
        self._assets_cache_path = os.path.join(self._cache_root, "assets_cache_v1.json")
        self._favorites_path = os.path.join(self._cache_root, "favorites_v1.json")
        self._downloads_index_path = os.path.join(self._cache_root, "downloads_v1.json")
        self._download_defaults_path = os.path.join(self._cache_root, "download_defaults_v1.json")
        try:
            os.makedirs(self.cache_dir, exist_ok=True)
        except Exception:
            pass
        self._load_favorites()
        self._load_download_history()
        self.download_defaults = self._load_download_defaults()
        self._custom_download_path = self._load_custom_download_path()

        # Local asset cache (for instant startup + local filtering)
        self._assets_cache_loaded = False
        self._assets_prefetch_started = False
        self._assets_prefetch_ready = False
        self._all_assets_local = []
        # favorite_asset_ids: _load_favorites() above; clearing here would wipe disk-loaded favourites
        self._file_size_cache = {}
        self._media_meta_cache = {}
        
        # Video frame cache (in-memory for fast access)
        self.video_frames_cache = {}  # url_hash -> list of PIL Images

        # Thread pool — keep it modest to avoid starving Tk mainloop (prevents "Not Responding")
        self.thumb_executor = ThreadPoolExecutor(max_workers=8, thread_name_prefix="thumb")

        self.title(ADDON_NAME)
        self.geometry("1320x800")
        self.minsize(1100, 680)
        self.configure(bg=C["bg"])
        self.downloads_view_active = False
        
        # Load icons in background only — sync load blocked event loop on Resolve
        threading.Thread(target=self._load_app_icon_sync, daemon=True).start()
        threading.Thread(target=self._load_app_icon, daemon=True).start()

        if not IMAGEIO_AVAILABLE:
            print("[WARNING] imageio not found — video preview disabled. Run: pip install imageio")

        self._connect_resolve()
        if not self.resolve:
            self.after(5000, self._check_and_import_pending)
        self._build_ui()
        if self._api_error:
            self.after(100, lambda: messagebox.showerror("API Error", self._api_error, parent=self))

        # Show window first — must happen before category load
        self.deiconify()
        self.lift()
        self.focus_force()
        self.update_idletasks()  # flush all pending UI events

        # Load categories after UI is visible and event loop is running
        self.after(300, self._load_categories)

        # Instant assets from disk cache, then background prefetch for super-fast UX
        self.after(120, self._bootstrap_assets_from_disk_cache)
        self.after(700, self._start_assets_prefetch_bg)
        # Fast free-trial probe (1 item) so cards unlock immediately without waiting for full prefetch
        self.after(150, self._start_free_trial_probe)

        # Analytics: session started (non-blocking)
        try:
            if ANALYTICS_AVAILABLE and _analytics and self.analytics_distinct_id:
                self.after(
                    800,
                    lambda: _analytics.send_session_start(
                        license_server=LICENSE_SERVER,
                        addon_slug=CGHEVEN_ADDON_SLUG,
                        addon_version=ADDON_VER,
                        distinct_id=self.analytics_distinct_id,
                        extra=_analytics_person(),
                    ),
                )
        except Exception:
            pass

        # Analytics: addon_opened each launch + update_completed when the running version
        # differs from the last launch (covers auto-update and manual reinstall).
        try:
            if ANALYTICS_AVAILABLE and _analytics and self.analytics_distinct_id:
                _lvf = os.path.join(self._cache_root, "last_version.txt")
                _last_ver = ""
                try:
                    with open(_lvf, "r", encoding="utf-8") as _f:
                        _last_ver = _f.read().strip()
                except Exception:
                    pass
                _did = self.analytics_distinct_id
                self.after(900, lambda: _analytics.send_event(
                    license_server=LICENSE_SERVER, addon_slug=CGHEVEN_ADDON_SLUG,
                    addon_version=ADDON_VER, distinct_id=_did,
                    event_name="addon_opened", properties={"version": ADDON_VER}))
                if not _last_ver:
                    # No version recorded yet → first run on this machine = a fresh install.
                    # Fires once per machine (distinct_id = guest_<machine> for logged-out users),
                    # so unique addon_installed events ≈ number of installs.
                    self.after(950, lambda: _analytics.send_event(
                        license_server=LICENSE_SERVER, addon_slug=CGHEVEN_ADDON_SLUG,
                        addon_version=ADDON_VER, distinct_id=_did,
                        event_name="addon_installed", properties={"version": ADDON_VER}))
                elif _last_ver != ADDON_VER:
                    self.after(950, lambda lv=_last_ver: _analytics.send_event(
                        license_server=LICENSE_SERVER, addon_slug=CGHEVEN_ADDON_SLUG,
                        addon_version=ADDON_VER, distinct_id=_did,
                        event_name="update_completed",
                        properties={"old_version": lv, "new_version": ADDON_VER}))
                try:
                    with open(_lvf, "w", encoding="utf-8") as _f:
                        _f.write(ADDON_VER)
                except Exception:
                    pass
        except Exception:
            pass

    # ── Asset Prefetch + Disk Cache ────────────────────────────────
    def _bootstrap_assets_from_disk_cache(self):
        """
        Fast path: show last cached assets immediately (instant open).
        Background prefetch refreshes the cache later.
        """
        def _bg():
            try:
                cached = self._load_assets_cache_disk()
                if not cached:
                    return

                def _apply():
                    try:
                        # Restore free-trial state from cache so instant-load shows correct lock state
                        if getattr(self, "_cached_free_trial", False):
                            self.free_trial       = True
                            self.free_trial_until = getattr(self, "_cached_free_trial_until", "") or ""
                        self._all_assets_local = [
                            x for x in cached
                            if self._asset_passes_catalog_exclusions(x)
                        ]
                        self._assets_cache_loaded = True

                        q = self._active_search_query()
                        if (self.active_cat or "all") == "all" and not q and not self.downloads_view_active:
                            local = self._local_filter_assets(query="", cat_slug="all", sort=self.sort_var.get())
                            self.filtered = local[:GRID_PAGE_SIZE]
                            self.total_assets = len(local)
                            try:
                                self.count_lbl.configure(text=f"{len(self.filtered)} of {self.total_assets} assets (cached)")
                            except Exception:
                                pass
                            self.status_var.set("✓  Loaded cached assets")
                            self._render_grid()
                    except Exception:
                        pass

                self.after(0, _apply)
            except Exception:
                pass

        threading.Thread(target=_bg, daemon=True).start()

    def _auth_plan_sig(self):
        """Cache-key signature = auth state + plan. The disk asset cache stores CGHLOCKED
        markers that differ per plan (guest/Free get locked grids, Pro/Studio get full
        access). Keying the cache on this signature means a plan change (Free → Pro, or a
        login) misses the cache and forces a fresh network fetch instead of serving a stale
        locked grid to a now-paid user. Mirrors the Blender addon's _ckey auth+plan prefix."""
        try:
            auth = "1" if str(getattr(self, "session_token", "") or "").strip() else "0"
        except Exception:
            auth = "0"
        plan = str(getattr(self, "user_plan", "") or "")
        return f"{auth}:{plan}"

    def _load_assets_cache_disk(self):
        try:
            p = self._assets_cache_path
            if not p or not os.path.exists(p):
                return []
            if os.path.getsize(p) > 80 * 1024 * 1024:
                return []
            with open(p, "r", encoding="utf-8") as f:
                payload = json.load(f)
            if not isinstance(payload, dict) or payload.get("version") != 1:
                return []
            # Auth + plan must match — a cache saved as guest/Free must NOT be shown to a
            # now-paid user (its assets carry stale CGHLOCKED markers). Mismatch → miss.
            if str(payload.get("auth_plan_sig", "")) != self._auth_plan_sig():
                return []
            # Restore cached free-trial state so instant-load shows correct lock state
            self._cached_free_trial       = bool(payload.get("free_trial", False))
            self._cached_free_trial_until = payload.get("free_trial_until", "") or ""
            items = payload.get("items", [])
            return items if isinstance(items, list) else []
        except Exception:
            return []

    def _save_assets_cache_disk(self, items):
        try:
            if not items or not isinstance(items, list):
                return
            try:
                os.makedirs(self._cache_root, exist_ok=True)
            except Exception:
                pass
            tmp = self._assets_cache_path + ".tmp"
            payload = {
                "version": 1, "saved_at": int(time.time()), "items": items,
                "free_trial": bool(getattr(self, "free_trial", False)),
                "free_trial_until": getattr(self, "free_trial_until", "") or "",
                # Stamp auth + plan so a later guest/Free/Pro mismatch misses this cache.
                "auth_plan_sig": self._auth_plan_sig(),
            }
            with open(tmp, "w", encoding="utf-8") as f:
                json.dump(payload, f)
            try:
                os.replace(tmp, self._assets_cache_path)
            except Exception:
                with open(self._assets_cache_path, "w", encoding="utf-8") as f:
                    json.dump(payload, f)
                try:
                    os.remove(tmp)
                except Exception:
                    pass
        except Exception:
            pass

    def _load_json_store(self, path, default):
        try:
            if not path or not os.path.exists(path):
                return default
            with open(path, "r", encoding="utf-8") as f:
                return json.load(f)
        except Exception:
            return default

    def _save_json_store(self, path, payload):
        try:
            os.makedirs(os.path.dirname(path), exist_ok=True)
            tmp = path + ".tmp"
            with open(tmp, "w", encoding="utf-8") as f:
                json.dump(payload, f, ensure_ascii=True)
            os.replace(tmp, path)
        except Exception:
            try:
                with open(path, "w", encoding="utf-8") as f:
                    json.dump(payload, f, ensure_ascii=True)
            except Exception:
                pass

    def _load_download_defaults(self) -> dict:
        _builtin = {
            "3d-models": {"format": "GLTF", "resolution": "1K"},
        }
        data = self._load_json_store(self._download_defaults_path, {"version": 1, "by_parent": {}})
        try:
            if isinstance(data, dict) and isinstance(data.get("by_parent"), dict):
                saved = dict(data.get("by_parent") or {})
                # Merge: builtin provides defaults, saved user prefs override
                merged = dict(_builtin)
                merged.update(saved)
                return merged
        except Exception:
            pass
        return dict(_builtin)

    def _save_download_defaults(self):
        self._save_json_store(
            self._download_defaults_path,
            {
                "version": 1,
                "by_parent": dict(self.download_defaults or {}),
                "download_path": getattr(self, "_custom_download_path", ""),
            },
        )

    def _load_custom_download_path(self) -> str:
        """Return the user-configured download directory, or empty string if not set."""
        data = self._load_json_store(self._download_defaults_path, {})
        if isinstance(data, dict):
            p = str(data.get("download_path", "") or "").strip()
            if p:
                return p
        return ""

    def _download_default_scope_slug(self, asset) -> str:
        """
        Map an asset to a top-level category slug (parent row in sidebar),
        so defaults can be configured per VFX / 3D / VDB / HDRI etc.
        """
        try:
            slugs = self._asset_category_slugs(asset) or set()
            if not slugs:
                return ""
            for p in (self.categories or []):
                pslug = str(p.get("slug", "") or "")
                if not pslug:
                    continue
                if pslug in slugs:
                    return pslug
                for ch in (p.get("children") or []):
                    cslug = str(ch.get("slug", "") or "")
                    if cslug and cslug in slugs:
                        return pslug
        except Exception:
            pass
        return ""

    def _pick_download_default_file(self, asset, parsed_files):
        """
        One-click download default:
        - If user configured defaults for this asset's top-level category, honor them.
        - Otherwise fall back to the existing smart ranking (1K-first, etc).
        """
        files = list(parsed_files or [])
        if not files:
            return None

        # Card default must be downloadable with NO login: prefer FREE (non-locked) files.
        # Only fall back to locked (CGHLOCKED) files when every option is premium-locked.
        _free = [f for f in files if not f.get("locked")]
        if _free:
            files = _free

        scope = self._download_default_scope_slug(asset)
        pref = {}
        try:
            if scope and isinstance(self.download_defaults, dict):
                pref = dict(self.download_defaults.get(scope) or {})
        except Exception:
            pref = {}
        if not pref:
            try:
                if isinstance(self.download_defaults, dict):
                    pref = dict(self.download_defaults.get("__all__") or {})
            except Exception:
                pref = {}

        fmt_pref = str(pref.get("format", "") or "").strip()
        res_pref = str(pref.get("resolution", "") or "").strip()

        if (not fmt_pref) or fmt_pref.lower() in ("auto", "automatic", "smart"):
            return self._pick_smart_default_file(files)
        if res_pref.lower() in ("auto", "automatic", "smart", ""):
            res_pref = ""

        def _norm(s: str) -> str:
            return re.sub(r"[^a-z0-9]+", "", str(s or "").lower())

        want_fmt = _norm(fmt_pref)

        def _fmt_matches(pf_fmt: str) -> bool:
            f = str(pf_fmt or "")
            if not f:
                return False
            if want_fmt and want_fmt in _norm(f):
                return True
            # allow choosing "MP4" while parsed format is "H.264 MP4"
            if want_fmt == "mp4" and "mp4" in _norm(f):
                return True
            if want_fmt == "mov" and "mov" in _norm(f):
                return True
            if want_fmt in ("exr", "openexr") and ("exr" in _norm(f) or "openexr" in _norm(f)):
                return True
            if want_fmt == "zip" and "zip" in _norm(f):
                return True
            if want_fmt == "vdb" and "vdb" in _norm(f):
                return True
            if want_fmt == "webm" and "webm" in _norm(f):
                return True
            if want_fmt == "png" and "png" in _norm(f):
                return True
            if want_fmt == "hdr" and "hdr" in _norm(f):
                return True
            if want_fmt in ("fbx",) and "fbx" in _norm(f):
                return True
            if want_fmt in ("glb", "gltf") and ("glb" in _norm(f) or "gltf" in _norm(f)):
                return True
            if want_fmt in ("abc",) and "abc" in _norm(f):
                return True
            if want_fmt in ("usdc", "usd") and ("usdc" in _norm(f) or "usd" in _norm(f)):
                return True
            if want_fmt == "obj" and "obj" in _norm(f):
                return True
            if want_fmt in ("h264", "h265") and ("264" in _norm(f) or "265" in _norm(f) or "h264" in _norm(f) or "h265" in _norm(f)):
                return True
            if want_fmt in ("prores",) and "prores" in _norm(f):
                return True
            return False

        candidates = [pf for pf in files if _fmt_matches(pf.get("format", ""))]
        pool = candidates if candidates else files

        if not res_pref or res_pref.lower() in ("any", "all"):
            tmp = sorted(pool, key=lambda pf: self._rp_resolution_sort_key(str(pf.get("resolution", "") or "")))
            return tmp[0] if tmp else self._pick_smart_default_file(files)

        want_res = res_pref.strip()
        for pf in pool:
            if str(pf.get("resolution", "") or "").strip() == want_res:
                return pf

        # fuzzy K match: user chose "4k" but file label is "4K"
        wr = want_res.upper().replace(" ", "")
        for pf in pool:
            pr = str(pf.get("resolution", "") or "").strip().upper().replace(" ", "")
            if pr == wr:
                return pf

        # last resort: still respect format preference, pick best res within pool
        tmp = sorted(pool, key=lambda pf: self._rp_resolution_sort_key(str(pf.get("resolution", "") or "")))
        return tmp[0] if tmp else self._pick_smart_default_file(files)

    def _open_settings_menu(self, event=None):
        """Settings entrypoint: open default download settings."""
        try:
            self._show_download_defaults_dialog()
        except Exception as e:
            print(f"[SETTINGS OPEN] {e}")

    def _show_download_defaults_dialog(self):
        """Let user pick default format + resolution for each top-level category."""
        try:
            dlg = tk.Toplevel(self)
            dlg.title("Download defaults")
            dlg.configure(bg=C["bg"])
            dlg.transient(self)
            dlg.grab_set()
            dlg.geometry("520x490")
            dlg.resizable(True, True)
            dlg.update_idletasks()
            sw, sh = dlg.winfo_screenwidth(), dlg.winfo_screenheight()
            dlg.geometry(f"520x490+{max(20, sw//2-260)}+{max(20, sh//2-245)}")

            hdr = tk.Frame(dlg, bg=C["topbar"], height=52)
            hdr.pack(fill="x")
            hdr.pack_propagate(False)
            tk.Label(hdr, text="  ⬇  Default download format & resolution",
                     bg=C["topbar"], fg=C["accent"],
                     font=("Segoe UI", 12, "bold")).pack(side="left", padx=12, pady=12)

            body = tk.Frame(dlg, bg=C["bg"])
            body.pack(fill="both", expand=True, padx=14, pady=12)

            # ── Download Location ──────────────────────────────────────
            _default_dl_path = os.path.join(os.path.expanduser("~"), "Downloads", "CGHEVEN")
            _cur_dl_path = getattr(self, "_custom_download_path", "") or _default_dl_path
            path_var = tk.StringVar(value=_cur_dl_path)

            loc_frame = tk.Frame(body, bg=C["card"], highlightbackground=C["border"], highlightthickness=1)
            loc_frame.pack(fill="x", pady=(0, 12))

            tk.Label(loc_frame, text="Download Location", bg=C["card"], fg=C["text"],
                     font=("Segoe UI", 9, "bold")).grid(
                         row=0, column=0, columnspan=2, sticky="w", padx=(10, 10), pady=(10, 4))

            path_entry = tk.Entry(
                loc_frame, textvariable=path_var,
                bg=C["search"], fg=C["text"], relief="flat", font=("Segoe UI", 9),
                readonlybackground=C["search"], state="readonly",
            )
            path_entry.grid(row=1, column=0, sticky="ew", padx=(10, 6), pady=(0, 10))

            def _browse_path():
                # Release grab so the file dialog can receive events on Windows
                dlg.grab_release()
                try:
                    chosen = filedialog.askdirectory(
                        title="Select Download Folder",
                        initialdir=path_var.get(),
                        parent=dlg,
                    )
                finally:
                    dlg.grab_set()
                if chosen:
                    path_var.set(os.path.normpath(chosen))

            def _reset_dl_path():
                path_var.set(_default_dl_path)

            btn_box = tk.Frame(loc_frame, bg=C["card"])
            btn_box.grid(row=1, column=1, padx=(0, 10), pady=(0, 10))

            tk.Button(btn_box, text="Browse", bg=C["accent"], fg="white",
                      activebackground=C["accent2"], activeforeground="white",
                      relief="flat", cursor="hand2", font=("Segoe UI", 9),
                      padx=10, pady=4, command=_browse_path).pack(side="left", padx=(0, 4))
            tk.Button(btn_box, text="Reset", bg=C["dl_btn"], fg=C["text2"],
                      activebackground=C["card_h"], activeforeground=C["text"],
                      relief="flat", cursor="hand2", font=("Segoe UI", 9),
                      padx=10, pady=4, command=_reset_dl_path).pack(side="left")

            loc_frame.columnconfigure(0, weight=1)
            # ── End Download Location ──────────────────────────────────

            tk.Label(
                body,
                text="These defaults apply to one-click card downloads and smart default selections.\n"
                     "If no file match is found, the addon will automatically fall back to the previous smart default.",
                bg=C["bg"], fg=C["text2"], justify="left", font=("Segoe UI", 9),
            ).pack(anchor="w", pady=(0, 10))

            # Keep dialog compact: no scrollbar, simple inner container
            inner = tk.Frame(body, bg=C["bg"])
            inner.pack(fill="both", expand=True)

            fmt_values = [
                "Auto",
                "H.264 MP4",
                "H.264",
                "H.265",
                "ProRes MOV",
                "ProRes",
                "OpenEXR",
                "PNG",
                "WebM",
                "VDB",
                "GLTF",
                "FBX",
                "OBJ",
                "USDC",
                "ABC",
                "GLB",
                "Alembic",
                "HDRI",
            ]
            res_values = ["Auto", "Any", "1K", "2K", "4K", "8K", "1080p", "720p", "HD"]

            rows = []
            parents = [c for c in (self.categories or []) if str(c.get("slug", "") or "")]
            if not parents:
                tk.Label(inner, text="Categories have not loaded yet. Please try again in a moment.",
                         bg=C["bg"], fg=C["text2"], font=("Segoe UI", 9)).pack(anchor="w", pady=8)
            else:
                # Compact table-like layout (Category | Format | Resolution)
                header = tk.Frame(inner, bg=C["bg"])
                header.pack(fill="x", pady=(0, 6))
                tk.Label(header, text="Category", bg=C["bg"], fg=C["dim"],
                         font=("Segoe UI", 8, "bold"), anchor="w").grid(row=0, column=0, sticky="w", padx=(8, 6))
                tk.Label(header, text="Format", bg=C["bg"], fg=C["dim"],
                         font=("Segoe UI", 8, "bold"), anchor="w").grid(row=0, column=1, sticky="w", padx=6)
                tk.Label(header, text="Resolution", bg=C["bg"], fg=C["dim"],
                         font=("Segoe UI", 8, "bold"), anchor="w").grid(row=0, column=2, sticky="w", padx=(6, 8))
                header.columnconfigure(0, weight=1)

                # Global defaults row (fallback when category has no specific defaults)
                global_row = tk.Frame(inner, bg=C["card"], highlightbackground=C["border"], highlightthickness=1)
                global_row.pack(fill="x", pady=(0, 8))
                tk.Label(global_row, text="All categories", bg=C["card"], fg=C["text"],
                         font=("Segoe UI", 9, "bold"), anchor="w").grid(row=0, column=0, sticky="w", padx=(10, 6), pady=8)
                gcur = dict((self.download_defaults or {}).get("__all__") or {})
                g_fmt_cb = ttk.Combobox(global_row, state="readonly", values=fmt_values, width=16, font=("Segoe UI", 9))
                g_fmt_cb.grid(row=0, column=1, sticky="e", padx=6, pady=8)
                g_fv = str(gcur.get("format", "") or "").strip()
                g_fmt_cb.set(g_fv if g_fv in fmt_values else "Auto")
                g_res_cb = ttk.Combobox(global_row, state="readonly", values=res_values, width=10, font=("Segoe UI", 9))
                g_res_cb.grid(row=0, column=2, sticky="e", padx=(6, 10), pady=8)
                g_rv = str(gcur.get("resolution", "") or "").strip()
                g_res_cb.set(g_rv if g_rv in res_values else "Auto")
                global_row.columnconfigure(0, weight=1)

                tk.Frame(inner, bg=C["border"], height=1).pack(fill="x", padx=6, pady=(0, 10))

                for cat in parents:
                    slug = str(cat.get("slug", "") or "")
                    name = str(cat.get("name", slug) or slug)
                    row = tk.Frame(inner, bg=C["card"], highlightbackground=C["border"], highlightthickness=1)
                    row.pack(fill="x", pady=4)

                    tk.Label(row, text=name, bg=C["card"], fg=C["text"],
                             font=("Segoe UI", 9), anchor="w").grid(row=0, column=0, sticky="w", padx=(10, 6), pady=8)

                    _cat_builtin = {"3d-models": ("GLTF", "1K")}.get(slug, ("Auto", "Auto"))
                    cur = dict((self.download_defaults or {}).get(slug) or {})
                    fmt_cb = ttk.Combobox(row, state="readonly", values=fmt_values, width=16, font=("Segoe UI", 9))
                    fmt_cb.grid(row=0, column=1, sticky="e", padx=6, pady=8)
                    fv = str(cur.get("format", "") or "").strip()
                    fmt_cb.set(fv if fv in fmt_values else _cat_builtin[0])

                    res_cb = ttk.Combobox(row, state="readonly", values=res_values, width=10, font=("Segoe UI", 9))
                    res_cb.grid(row=0, column=2, sticky="e", padx=(6, 10), pady=8)
                    rv = str(cur.get("resolution", "") or "").strip()
                    res_cb.set(rv if rv in res_values else _cat_builtin[1])

                    row.columnconfigure(0, weight=1)
                    rows.append((slug, fmt_cb, res_cb))

            bot = tk.Frame(dlg, bg=C["bg"])
            bot.pack(fill="x", padx=14, pady=(0, 12))

            def _save():
                try:
                    new_map = dict(self.download_defaults or {})
                    # Save global defaults
                    try:
                        gfmt = str(g_fmt_cb.get() or "Auto").strip()
                        gres = str(g_res_cb.get() or "Auto").strip()
                        if gfmt == "Auto" and gres == "Auto":
                            new_map.pop("__all__", None)
                        else:
                            new_map["__all__"] = {"format": gfmt, "resolution": gres}
                    except Exception:
                        pass
                    for slug, fmt_cb, res_cb in rows:
                        fmt = str(fmt_cb.get() or "Auto").strip()
                        res = str(res_cb.get() or "Auto").strip()
                        if fmt == "Auto" and res == "Auto":
                            new_map.pop(slug, None)
                        else:
                            new_map[slug] = {"format": fmt, "resolution": res}
                    self.download_defaults = new_map
                    # Save download path
                    new_dl_path = path_var.get().strip()
                    _is_default = os.path.normpath(new_dl_path) == os.path.normpath(_default_dl_path)
                    if new_dl_path and not _is_default:
                        self._custom_download_path = new_dl_path
                        try:
                            os.makedirs(new_dl_path, exist_ok=True)
                        except Exception:
                            pass
                    else:
                        self._custom_download_path = ""
                    self._save_download_defaults()
                    self.status_var.set("✓  Settings saved")
                    dlg.destroy()
                except Exception as e:
                    print(f"[DL DEFAULTS SAVE] {e}")

            tk.Button(bot, text="Cancel", bg=C["card"], fg=C["text2"],
                      relief="flat", cursor="hand2", font=("Segoe UI", 9), padx=14, pady=6,
                      command=dlg.destroy).pack(side="right")
            tk.Button(bot, text="Save", bg=C["accent"], fg="white",
                      activebackground=C["accent2"], activeforeground="white",
                      relief="flat", cursor="hand2", font=("Segoe UI", 9, "bold"), padx=18, pady=6,
                      command=_save).pack(side="right", padx=(0, 8))

            # No scrollbar: nothing to bind
        except Exception as e:
            print(f"[DL DEFAULTS UI] {e}")

    def _load_favorites(self):
        data = self._load_json_store(self._favorites_path, {"asset_ids": []})
        ids = data.get("asset_ids", []) if isinstance(data, dict) else []
        try:
            self.favorite_asset_ids = {int(x) for x in ids}
        except Exception:
            self.favorite_asset_ids = set()

    def _save_favorites(self):
        self._save_json_store(
            self._favorites_path,
            {"asset_ids": sorted(int(x) for x in (self.favorite_asset_ids or set()))},
        )

    def _is_favorite_asset(self, asset_or_id):
        try:
            aid = asset_or_id if isinstance(asset_or_id, int) else int((asset_or_id or {}).get("id", 0))
            return aid in (self.favorite_asset_ids or set())
        except Exception:
            return False

    def _toggle_favorite_asset(self, asset):
        # Favourites require login. In guest mode show a clear WARNING popup (not just a
        # quiet status line) so the user knows why nothing was saved, and offer to log in.
        if not (self.session_token or "").strip():
            try: self.status_var.set("Please login to add favourites")
            except Exception: pass
            do_login = False
            try:
                do_login = messagebox.askyesno(
                    "Login required",
                    "Please login to save favourites.\n\nDo you want to login now?",
                    parent=self,
                )
            except Exception:
                do_login = True
            if do_login:
                try: self._account_login(register=False)
                except Exception: pass
            return
        try:
            aid = int((asset or {}).get("id", 0))
        except Exception:
            aid = 0
        if aid <= 0:
            try: self.status_var.set("Could not favourite this asset")
            except Exception: pass
            return
        try:
            if aid in self.favorite_asset_ids:
                self.favorite_asset_ids.discard(aid)
                try: self.status_var.set("Removed from favourites")
                except Exception: pass
            else:
                self.favorite_asset_ids.add(aid)
                try: self.status_var.set("Added to favourites")
                except Exception: pass
                try:
                    if ANALYTICS_AVAILABLE and _analytics and getattr(self, "analytics_distinct_id", ""):
                        _analytics.send_favourite_added(
                            license_server=LICENSE_SERVER,
                            addon_slug=CGHEVEN_ADDON_SLUG,
                            distinct_id=self.analytics_distinct_id,
                            asset=asset,
                        )
                except Exception:
                    pass
            self._save_favorites()
            try:
                self._refresh_favorite_ui(asset)
            except Exception:
                pass
            if self.active_view == "favorites":
                try: self._load_assets(page=1)
                except Exception: pass
        except Exception as e:
            print(f"[FAVORITE TOGGLE ERROR] {e}")
            try: self.status_var.set("Could not save favourite")
            except Exception: pass

    def _serialize_download_info(self, dl):
        try:
            if not isinstance(dl, dict):
                return None
            return {
                "id": int(dl.get("id", 0) or 0),
                "asset": dl.get("asset") or {},
                "file_info": dict(dl.get("file_info") or {}),
                "url": dl.get("url", ""),
                "filename": dl.get("filename", ""),
                "status": dl.get("status", ""),
                "progress": float(dl.get("progress", 0) or 0),
                "total_bytes": int(dl.get("total_bytes", 0) or 0),
                "downloaded_bytes": int(dl.get("downloaded_bytes", 0) or 0),
                "start_time": float(dl.get("start_time", 0) or 0),
                "end_time": float(dl.get("end_time", 0) or 0),
                "filepath": dl.get("filepath", ""),
                "import_status": dl.get("import_status", ""),
                "import_error": dl.get("import_error", ""),
            }
        except Exception:
            return None

    def _save_download_history(self):
        try:
            items = []
            for dl in (self.completed_downloads or []):
                item = self._serialize_download_info(dl)
                if item:
                    items.append(item)
            self._save_json_store(self._downloads_index_path, {"version": 1, "items": items})
            # sync to shared file used by all CGHEVEN addons
            shared = _shared_dl_read()
            shared_ids_seen = set()
            for item in items:
                try:
                    aid = str(item.get("id", "") or "")
                    fi  = item.get("file_info") or {}
                    fmt = fi.get("fmt", "")
                    res = fi.get("res", "")
                    key = (aid, fmt, res)
                    if aid and key not in shared_ids_seen:
                        shared_ids_seen.add(key)
                        shared = [r for r in shared if not (str(r.get("id","")) == aid and r.get("fmt","") == fmt and r.get("res","") == res)]
                        asset  = item.get("asset") or {}
                        attrs  = asset.get("attributes") or {}
                        shared.insert(0, {
                            "id":             aid,
                            "fp":             item.get("filepath", ""),
                            "fmt":            fmt,
                            "res":            res,
                            "addon_is_free": attrs.get("addon_is_free", False),
                            "title":          attrs.get("Title", ""),
                            "cat":            attrs.get("ParentSlug", ""),
                            "at":             int((item.get("end_time") or time.time()) * 1000),
                        })
                except Exception:
                    pass
            _shared_dl_write(shared[:200])
        except Exception:
            pass

    def _load_download_history(self):
        data = self._load_json_store(self._downloads_index_path, {"items": []})
        items = data.get("items", []) if isinstance(data, dict) else []
        loaded = []
        max_id = 0
        local_ids = set()
        for item in items or []:
            try:
                if not isinstance(item, dict):
                    continue
                item["card"] = None
                item["pause_event"] = None
                item["thread"] = None
                loaded.append(item)
                aid = int(item.get("id", 0) or 0)
                max_id = max(max_id, aid)
                local_ids.add(aid)
            except Exception:
                pass
        # merge records from shared file that aren't already in local history
        for r in _shared_dl_read():
            try:
                aid = int(r.get("id", 0) or 0)
                if not aid or aid in local_ids:
                    continue
                local_ids.add(aid)
                loaded.append({
                    "id":               aid,
                    "asset":            {"id": aid, "attributes": {"Title": r.get("title", ""), "ParentSlug": r.get("cat", "")}},
                    "file_info":        {"fmt": r.get("fmt", ""), "res": r.get("res", "")},
                    "filepath":         r.get("fp", ""),
                    "filename":         os.path.basename(r.get("fp", "")),
                    "status":           "completed",
                    "progress":         100,
                    "total_bytes":      0,
                    "downloaded_bytes": 0,
                    "start_time":       0.0,
                    "end_time":         (r.get("at", 0) or 0) / 1000.0,
                    "url":              "",
                    "import_status":    "",
                    "import_error":     "",
                    "card":             None,
                    "pause_event":      None,
                    "thread":           None,
                })
            except Exception:
                pass
        self.completed_downloads = loaded
        self.download_counter = max(self.download_counter, max_id)

    def _start_free_trial_probe(self):
        """Fast 1-item fetch to pick up the admin banner config early. Gating no longer depends
        on free-trial state (the backend no longer sends free_trial / free_trial_until) — locking
        is driven entirely by per-file CGHLOCKED markers + is_patreon_locked + premium_locked."""
        if getattr(self, "_free_trial_probe_started", False):
            return
        self._free_trial_probe_started = True
        if not getattr(self, "api", None):
            return

        def _probe():
            try:
                data = self.api._get("/assets", {
                    "pagination[page]": 1,
                    "pagination[pageSize]": 1,
                    "addonSlug": ADDON_ACCESS_SLUG,
                })
                meta = data.get("meta", {}) if isinstance(data, dict) else {}
                self.banner_cfg = meta.get("banner") or getattr(self, "banner_cfg", None)
            except Exception:
                pass

        threading.Thread(target=_probe, daemon=True).start()

    def _start_assets_prefetch_bg(self):
        if getattr(self, "_assets_prefetch_started", False):
            return
        self._assets_prefetch_started = True
        if not getattr(self, "api", None):
            return
        threading.Thread(target=self._prefetch_assets_worker, daemon=True).start()

    def _prefetch_assets_worker(self):
        try:
            t0 = time.time()
            items = self.api.fetch_all("/assets", page_size=100)
            if not items:
                return
            # Pick up free-trial state captured by fetch_all (startup path skips _fetch_assets)
            self.free_trial       = bool(getattr(self.api, "last_free_trial", False))
            self.free_trial_until = getattr(self.api, "last_free_trial_until", "") or ""
            self.banner_cfg       = getattr(self.api, "last_banner", None) or getattr(self, "banner_cfg", None)
            self._strip_patreon_locks(items)
            items = [it for it in items if self._asset_passes_catalog_exclusions(it)]
            self._all_assets_local = items
            self._assets_prefetch_ready = True
            self._save_assets_cache_disk(items)
            dur_ms = int((time.time() - t0) * 1000)
            print(f"[PREFETCH] assets={len(items)} in {dur_ms}ms")

            def _maybe_refresh():
                try:
                    q = self._active_search_query()
                    if self.downloads_view_active:
                        return
                    # Only live-refresh if user is currently browsing/searching (not mid-download popup etc.)
                    local = self._local_filter_assets(
                        query=q,
                        cat_slug=(self.active_cat or "all"),
                        sort=self.sort_var.get(),
                    )
                    self.filtered = local[:GRID_PAGE_SIZE]
                    self.total_assets = len(local)
                    try:
                        self.count_lbl.configure(text=f"{len(self.filtered)} of {self.total_assets} assets")
                    except Exception:
                        pass
                    self.status_var.set(f"✓  {self.total_assets} assets  ·  cgheven.com")
                    self._render_grid()
                except Exception:
                    pass

            self.after(0, _maybe_refresh)
        except Exception as e:
            print(f"[PREFETCH ERROR] {e}")

    def _asset_get_attr(self, asset):
        try:
            return asset.get("attributes", asset) if isinstance(asset, dict) else {}
        except Exception:
            return {}

    def _asset_title(self, asset):
        a = self._asset_get_attr(asset)
        return str(a.get("Title", a.get("title", "")) or "")

    def _asset_created_at(self, asset):
        a = self._asset_get_attr(asset)
        v = a.get("createdAt", a.get("created_at", a.get("CreatedAt", "")))
        return str(v or "")

    def _asset_category_slugs(self, asset):
        a = self._asset_get_attr(asset)
        out = set()

        c = a.get("categorie")
        if isinstance(c, dict):
            s = _cgheven_relation_slug(c)
            if s:
                out.add(s)

        subs = a.get("subcategories") or a.get("subCategories") or []
        for sub in _cgheven_subcategories_raw_list(subs):
            if isinstance(sub, dict):
                s = _cgheven_relation_slug(sub)
                if s:
                    out.add(s)

        return out

    def _asset_passes_catalog_exclusions(self, asset):
        try:
            for s in self._asset_category_slugs(asset):
                if str(s).lower() in _EXCLUDED_CAT_SLUGS_LOWER:
                    return False
            return True
        except Exception:
            return True

    def _cat_descendant_slugs(self, slug):
        if not slug or slug == "all":
            return []
        out = set([slug])
        try:
            for p in self.categories or []:
                if p.get("slug") == slug:
                    for ch in p.get("children", []) or []:
                        cs = ch.get("slug")
                        if cs:
                            out.add(cs)
                    break
                for ch in p.get("children", []) or []:
                    if ch.get("slug") == slug:
                        return [slug]
        except Exception:
            pass
        return list(out)

    def _downloaded_asset_ids(self):
        try:
            out = set()
            for d in (self.completed_downloads or []):
                a = d.get("asset") or {}
                aid = a.get("id", None)
                if aid is not None:
                    try:
                        out.add(int(aid))
                    except Exception:
                        out.add(aid)
            out |= _shared_dl_get_ids()
            return out
        except Exception:
            return set()

    def _active_search_query(self):
        try:
            return str(getattr(self, "_search_committed_query", "") or "").strip()
        except Exception:
            return ""

    def _asset_file_entries(self, asset):
        try:
            a = self._asset_get_attr(asset)
            return [parse_file_url(f) for f in (a.get("files", []) or []) if f]
        except Exception:
            return []

    def _asset_type_label(self, asset):
        parsed = self._asset_file_entries(asset)
        exts = {str(pf.get("ext", "") or "").lower() for pf in parsed}
        if exts & {".mp4", ".mov", ".webm"}:
            return "Video"
        if exts & {".exr", ".png"}:
            return "Image"
        if exts & {".fbx", ".glb", ".abc"}:
            return "3D"
        if ".vdb" in exts:
            return "VDB"
        if ".hdr" in exts:
            return "HDRI"
        if ".zip" in exts:
            return "Archive"
        return "Asset"

    def _asset_matches_filters(self, asset):
        try:
            raw_id = asset.get("id", None)
            aid = int(raw_id) if raw_id is not None else None
            if bool(self.filter_downloaded_only.get()):
                dl = self._downloaded_asset_ids()
                if aid is not None and aid not in dl:
                    return False
                if aid is None and raw_id not in dl:
                    return False
            fav_only_var = getattr(self, "filter_favourite_only", None)
            if bool(fav_only_var.get()) if fav_only_var is not None else False:
                fav = self.favorite_asset_ids or set()
                if aid is not None and aid not in fav:
                    return False
                if aid is None and raw_id not in fav:
                    return False
            return True
        except Exception:
            return True

    def _asset_in_active_view(self, asset):
        try:
            aid = asset.get("id", None)
            view = str(getattr(self, "active_view", "home") or "home")
            if view == "favorites":
                return aid in (self.favorite_asset_ids or set())
            if view == "local":
                return aid in self._downloaded_asset_ids()
            return True
        except Exception:
            return True

    def _refresh_favorite_ui(self, asset=None):
        try:
            current = asset or getattr(self, "_rp_current_asset", None)
            btn = getattr(self, "_rp_favorite_btn", None)
            if btn and btn.winfo_exists():
                is_fav = self._is_favorite_asset(current or {})
                btn.configure(
                    text="♥" if is_fav else "♡",
                    fg="#FF6B8A" if is_fav else C["text2"],
                )
        except Exception:
            pass

    def _local_filter_assets(self, query, cat_slug, sort):
        items = list(self._all_assets_local or [])
        q = (query or "").strip().lower()

        items = [
            it for it in items
            if self._asset_in_active_view(it)
            and self._asset_matches_filters(it)
            and self._asset_passes_catalog_exclusions(it)
        ]

        # "Free" filter = assets that have at least one free (non-locked) downloadable file.
        if getattr(self, "_free_filter_active", False):
            items = [it for it in items if self._asset_has_free_file(it)]

        if cat_slug and cat_slug != "all":
            allowed = set(self._cat_descendant_slugs(cat_slug))
            if allowed:
                items = [it for it in items if self._asset_category_slugs(it) & allowed]

        if q:
            items = [it for it in items if q in self._asset_title(it).lower()]

        s = (sort or "Newest").strip()
        if s == "Oldest":
            items.sort(key=lambda it: self._asset_created_at(it))
        elif s == "Name A-Z":
            items.sort(key=lambda it: self._asset_title(it).lower())
        elif s == "Name Z-A":
            items.sort(key=lambda it: self._asset_title(it).lower(), reverse=True)
        else:  # Newest
            items.sort(key=lambda it: self._asset_created_at(it), reverse=True)
        return items

    def _apply_app_window_icon_on_main(self, ico_path, square_rgba, is_win32):
        """Set window/taskbar icon on the Tk main thread (required for clean HiDPI icons)."""
        if not PIL_AVAILABLE or square_rgba is None:
            return
        try:
            if is_win32 and ico_path and os.path.isfile(ico_path):
                self.iconbitmap(ico_path)
        except Exception as e:
            if CGHEVEN_DEBUG_VERBOSE:
                print(f"[ICON] iconbitmap: {e}")
        try:
            photos = _cgheven_tk_iconphoto_chain(square_rgba, self)
            self.iconphoto(True, *photos)
            self._app_icon = photos[-1]
        except Exception as e:
            if CGHEVEN_DEBUG_VERBOSE:
                print(f"[ICON] iconphoto: {e}")
        if is_win32 and ico_path:
            try:
                print(f"[ICON] Windows icon set: {ico_path}")
            except Exception:
                pass

    def _load_app_icon_sync(self):
        """Load icon (bundled PNG or URL), write crisp .ico, then apply on the Tk main thread."""
        if not PIL_AVAILABLE:
            return
        try:
            import tempfile, os

            raw = _cgheven_load_app_icon_rgba(timeout=5)
            try:
                img = _cgheven_prepare_icon_square(raw)
            except Exception:
                img = raw

            self._app_logo_img = img.copy()

            ico_path = None
            if sys.platform == "win32":
                ico_path = os.path.join(tempfile.gettempdir(), "cgheven_app_icon.ico")
                try:
                    _cgheven_write_taskbar_ico(img, ico_path)
                except Exception as e:
                    print(f"[ICON] ICO creation failed: {e}, using iconphoto only")
                    ico_path = None

            is_win = sys.platform == "win32"
            self.after(0, self._apply_app_window_icon_on_main, ico_path, img.copy(), is_win)
        except Exception as e:
            print(f"[ICON SYNC] {e}")

    def _load_app_icon(self):
        """Load icon in background for titlebar logo update"""
        if not PIL_AVAILABLE:
            print("[ICON] PIL not available — skipping icon load")
            return
        try:
            raw = _cgheven_load_app_icon_rgba(timeout=8)
            try:
                img = _cgheven_prepare_icon_square(raw)
            except Exception:
                img = raw
            if not hasattr(self, "_app_logo_img"):
                self._app_logo_img = img.copy()
            self.after(0, lambda orig=img: self._update_titlebar_logo(orig))
        except Exception as e:
            print(f"[ICON] {e}")

    def _update_titlebar_logo(self, original_img):
        """Update titlebar logo after icon loads"""
        if hasattr(self, "_titlebar_logo_lbl") and original_img and PIL_AVAILABLE:
            try:
                # Two-step downscale + sharpen for small titlebar glyph
                _lz = _PIL_LANCZOS
                logo_img = original_img.resize((48, 48), _lz).resize((24, 24), _lz)
                try:
                    logo_img = logo_img.filter(ImageFilter.UnsharpMask(radius=1.2, percent=160, threshold=2))
                except Exception:
                    pass
                logo_photo = ImageTk.PhotoImage(logo_img, master=self)
                self._titlebar_logo_lbl.configure(image=logo_photo, text="")
                self._titlebar_logo_lbl.image = logo_photo
            except Exception as e:
                print(f"[TITLEBAR LOGO] {e}")

    def _set_app_icon(self, photo, original_img=None):
        """Legacy method - kept for compatibility"""
        try:
            self.iconphoto(True, photo)
            self._app_icon = photo
            if original_img:
                self.after(0, lambda orig=original_img: self._update_titlebar_logo(orig))
        except Exception as e:
            print(f"[ICON SET] {e}")

    # ── DaVinci Connect ──────────────────────────────────────────
    def _connect_resolve(self):
        self.resolve = None
        self._resolve_module_available = False
        self._setup_shown = getattr(self, "_setup_shown", False)

        # ══════════════════════════════════════════════════════════
        # CONFIRMED FIX (BMD official forum):
        #
        # When Resolve runs script from Workspace > Scripts, it
        # pre-injects a variable named `resolve` (lowercase) into
        # the script's __main__ globals. This IS the Resolve object.
        #
        # CRITICAL: Do NOT call scriptapp("Resolve") — in Free version
        # it returns None and OVERWRITES the pre-injected `resolve`.
        #
        # `app` = Fusion object (NOT Resolve) — ignore it entirely.
        # ══════════════════════════════════════════════════════════

        # ── Method 1: pre-injected `resolve` global (FREE + Studio) ─
        try:
            import __main__
            r = getattr(__main__, "resolve", None)
            if r and self._valid_resolve(r):
                self.resolve = r
                self._resolve_module_available = True
                self._on_resolve_connected()
                print("[RESOLVE] OK: connected via pre-injected resolve global")
                return
        except Exception as e:
            print(f"[RESOLVE] resolve global error: {e}")

        # ── Method 2: DaVinciResolveScript (Studio / External) ────
        try:
            import DaVinciResolveScript as dvr
            self._resolve_module_available = True
            for _ in range(3):
                r = dvr.scriptapp("Resolve")
                if r and self._valid_resolve(r):
                    self.resolve = r
                    self._on_resolve_connected()
                    print("[RESOLVE] OK: connected via DaVinciResolveScript")
                    return
                time.sleep(0.5)
        except ImportError:
            pass
        except Exception as e:
            self._resolve_module_available = True
            print(f"[RESOLVE] module error: {e}")

        # ── Method 3: fusionscript via sys.path (Python 3.12+) ────
        try:
            pf   = os.getenv("PROGRAMFILES", "C:/Program Files")
            ddir = os.path.join(pf, "Blackmagic Design", "DaVinci Resolve")
            if os.path.exists(ddir) and ddir not in sys.path:
                sys.path.insert(0, ddir)
            import fusionscript as _fs
            for _ in range(3):
                r = _fs.scriptapp("Resolve")
                if r and self._valid_resolve(r):
                    self.resolve = r
                    self._resolve_module_available = True
                    self._on_resolve_connected()
                    print("[RESOLVE] OK: connected via fusionscript")
                    return
                time.sleep(0.5)
        except Exception as e:
            print(f"[RESOLVE] fusionscript error: {e}")

        # ── All failed ─────────────────────────────────────────────
        print("[RESOLVE] ERROR: not connected — retrying in background")
        if not self._setup_shown:
            self._setup_shown = True
            self.after(800, self._show_resolve_setup_guide)
        threading.Thread(target=self._connect_resolve_bg, daemon=True).start()

    def _valid_resolve(self, r):
        """Test if r is actually a working Resolve object (not Fusion app)."""
        try:
            pm = r.GetProjectManager()
            return pm is not None
        except Exception:
            return False

    def _on_resolve_connected(self):
        try:
            pm   = self.resolve.GetProjectManager()
            proj = pm.GetCurrentProject() if pm else None
            name = proj.GetName() if proj else "Unknown"
            self.after(0, lambda n=name: self.status_var.set(
                f"\U0001f7e2  DaVinci Resolve Connected  \xb7  Project: {n}"))
        except Exception:
            self.after(0, lambda: self.status_var.set("\U0001f7e2  DaVinci Resolve Connected"))
        if getattr(self, "pending_imports", None):
            self.after(500, self._process_pending_imports)

    def _show_resolve_setup_guide(self):
        try:
            guide = tk.Toplevel(self)
            guide.title("DaVinci Resolve Setup Required")
            guide.geometry("480x340")
            guide.configure(bg=C["bg"])
            guide.transient(self)
            guide.grab_set()
            guide.resizable(False, False)
            guide.update_idletasks()
            sw = guide.winfo_screenwidth()
            sh = guide.winfo_screenheight()
            guide.geometry(f"480x340+{sw//2-240}+{sh//2-170}")

            tk.Frame(guide, bg=C["accent"], height=3).pack(fill="x")
            tk.Label(guide, text="\u2699  One-Time Setup Required",
                     bg=C["bg"], fg=C["text"],
                     font=("Segoe UI", 12, "bold")).pack(pady=(18, 4))
            tk.Label(guide,
                     text="To import assets directly into Media Pool,\nenable scripting in DaVinci Resolve:",
                     bg=C["bg"], fg=C["text2"],
                     font=("Segoe UI", 9), justify="center").pack(pady=(0, 12))

            sf = tk.Frame(guide, bg=C["card"],
                          highlightbackground=C["border"], highlightthickness=1)
            sf.pack(fill="x", padx=30, pady=(0, 10))

            steps = [
                ("1", "Make sure DaVinci Resolve is OPEN with a project loaded"),
                ("2", "Inside Resolve:  Workspace  menu  Scripts"),
                ("3", "Click  CGHEVEN_Asset_Library  to run the addon"),
                ("4", "Do NOT run the .py file directly from File Explorer"),
                ("5", "Studio users: Preferences  General  External Scripting: Local"),
            ]
            for num, text in steps:
                row = tk.Frame(sf, bg=C["card"])
                row.pack(fill="x", padx=12, pady=3)
                tk.Label(row, text=num, bg=C["accent"], fg="white",
                         font=("Segoe UI", 8, "bold"),
                         width=2).pack(side="left", padx=(0, 8))
                tk.Label(row, text=text, bg=C["card"], fg=C["text"],
                         font=("Segoe UI", 8), anchor="w").pack(side="left")

            tk.Label(guide,
                     text="\U0001f4a1 Assets will still download. Auto-import will work after setup.",
                     bg=C["bg"], fg=C["dim"],
                     font=("Segoe UI", 8), justify="center").pack(pady=(4, 8))

            br = tk.Frame(guide, bg=C["bg"])
            br.pack(pady=(0, 16))
            tk.Button(br, text="OK",
                      bg=C["accent"], fg="white",
                      relief="flat", cursor="hand2",
                      font=("Segoe UI", 9, "bold"), padx=20, pady=6,
                      command=guide.destroy).pack(side="left", padx=6)
            tk.Button(br, text="Retry Connection",
                      bg=C["card_h"], fg=C["text"],
                      relief="flat", cursor="hand2",
                      font=("Segoe UI", 9), padx=14, pady=6,
                      command=lambda: [guide.destroy(), self._connect_resolve()]
                      ).pack(side="left", padx=6)
        except Exception as e:
            print(f"[SETUP GUIDE] {e}")

        try:
            script_path = os.path.abspath(__file__)
        except NameError:
            script_path = ""
        if "DaVinci Resolve" in script_path or "DaVinciResolve" in script_path:
            resolve_paths = []
            if sys.platform == "win32":
                program_data = os.getenv("PROGRAMDATA", "C:\\ProgramData")
                resolve_paths = [
                    os.path.join(program_data, "Blackmagic Design", "DaVinci Resolve", "Support", "Developer", "Python"),
                ]
            elif sys.platform == "darwin":
                resolve_paths = ["/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting/Examples/python"]
            elif sys.platform == "linux":
                resolve_paths = ["/opt/resolve/libs/Fusion"]

            for path in resolve_paths:
                if os.path.exists(path) and path not in sys.path:
                    sys.path.insert(0, path)
                    try:
                        import DaVinciResolveScript as dvr
                        self.resolve = dvr.scriptapp("Resolve")
                        if self.resolve:
                            self._resolve_module_available = True
                            self.status_var.set("🟢  DaVinci Resolve Connected")
                            return
                    except ImportError:
                        continue
                    except Exception:
                        self._resolve_module_available = True

    def _is_resolve_running(self):
        try:
            import platform
            system = platform.system()
            try:
                import psutil
                names = {"Windows": ["Resolve.exe"],
                         "Darwin":  ["DaVinci Resolve"],
                         "Linux":   ["DaVinci Resolve", "resolve"]}.get(system, [])
                for proc in psutil.process_iter(['name']):
                    for n in names:
                        if n.lower() in (proc.info['name'] or "").lower():
                            return True
                return False
            except ImportError:
                pass
            if system == "Windows":
                r = subprocess.run(
                    ["tasklist", "/FI", "IMAGENAME eq Resolve.exe"],
                    capture_output=True,
                    text=True,
                    timeout=2,
                    **_subprocess_hide_console_kwargs(),
                )
                return "Resolve.exe" in r.stdout
            else:
                r = subprocess.run(["pgrep", "-f", "DaVinci Resolve"],
                                   capture_output=True, timeout=2)
                return r.returncode == 0
        except Exception:
            return False

    def _connect_resolve_bg(self):
        import time as _t, os as _os, sys as _sys
        for attempt in range(25):
            _t.sleep(1.5)
            r = None
            # Method 1: pre-injected resolve global (FREE version)
            try:
                import __main__
                _r = getattr(__main__, "resolve", None)
                if _r and self._valid_resolve(_r):
                    r = _r
            except Exception:
                pass
            # Method 2: DaVinciResolveScript
            if not r:
                try:
                    import DaVinciResolveScript as dvr
                    _r = dvr.scriptapp("Resolve")
                    if _r and self._valid_resolve(_r):
                        r = _r
                except Exception:
                    pass
            # Method 3: fusionscript
            if not r:
                try:
                    pf2  = _os.getenv("PROGRAMFILES","C:/Program Files")
                    ddir = _os.path.join(pf2,"Blackmagic Design","DaVinci Resolve")
                    if _os.path.exists(ddir) and ddir not in _sys.path:
                        _sys.path.insert(0, ddir)
                    import fusionscript as _fs2
                    _r = _fs2.scriptapp("Resolve")
                    if _r and self._valid_resolve(_r):
                        r = _r
                except Exception:
                    pass
            if r:
                self.resolve = r
                self._resolve_module_available = True
                print(f"[RESOLVE] OK: BG connected attempt {attempt+1}")
                self.after(0, self._on_resolve_connected)
                return
        print("[RESOLVE] BG gave up")

    # ── UI Layout ─────────────────────────────────────────────────
    def _show_guest_banner(self):
        """Admin-controlled announcement banner (Banner Settings in Strapi) — news / a new
        update / an offer / a free-trial countdown at the top of the window. Visibility and
        content come entirely from the backend meta.banner payload (self.banner_cfg)."""
        grad = [(0xf9, 0x73, 0x16), (0xef, 0x44, 0x44)]  # CGHEVEN brand gradient (orange → red)
        H  = 35
        banner = tk.Frame(self, height=H)
        self._guest_banner = banner
        banner.pack(fill="x", side="top")
        banner.pack_propagate(False)

        canvas = tk.Canvas(banner, height=H, highlightthickness=0, bd=0)
        canvas.pack(fill="both", expand=True)

        def _hexc(r, g, b):
            return f"#{r:02x}{g:02x}{b:02x}"

        _last = {"w": 0}
        def _draw_gradient(w):
            canvas.delete("grad")
            C0, C1 = grad[0], grad[1]
            step = 3
            i = 0
            while i <= w:
                t = i / max(1, w)
                r = int(C0[0] + (C1[0] - C0[0]) * t)
                g = int(C0[1] + (C1[1] - C0[1]) * t)
                b = int(C0[2] + (C1[2] - C0[2]) * t)
                canvas.create_rectangle(i, 0, i + step, H, fill=_hexc(r, g, b), outline="", tags="grad")
                i += step
            canvas.tag_lower("grad")
            _last["w"] = w

        txt = canvas.create_text(12, H // 2, anchor="w", fill="#ffffff",
                                 font=("Segoe UI", 9), text="")
        btn = tk.Button(canvas, text="", bg="#ffffff", fg="#111111",
                        font=("Segoe UI", 9, "bold"), relief="flat", padx=8, pady=2,
                        cursor="hand2")
        btn_win = canvas.create_window(0, H // 2, anchor="e", window=btn)
        canvas.itemconfigure(btn_win, state="hidden")

        # Close (✕) — lets the user hide the banner. Stays hidden until the admin
        # publishes a different banner (content change), then it shows again.
        xbtn = tk.Button(canvas, text="✕", bg="#ffffff", fg="#111111",
                         font=("Segoe UI", 9, "bold"), relief="flat", padx=5, pady=1,
                         bd=0, cursor="hand2", activebackground="#f0f0f0")
        xbtn_win = canvas.create_window(0, H // 2, anchor="e", window=xbtn)
        def _dismiss_banner():
            cfg = getattr(self, "banner_cfg", None) or {}
            self._banner_dismissed_key = (str(cfg.get("title") or "").strip()
                                          + "|" + str(cfg.get("message") or "").strip())
            try: self._guest_banner.pack_forget()
            except Exception: pass
        xbtn.config(command=_dismiss_banner)

        def _on_resize(e):
            _draw_gradient(e.width)
            canvas.coords(xbtn_win, e.width - 8, H // 2)
            canvas.coords(btn_win, e.width - 34, H // 2)
            canvas.tag_raise(txt)
        canvas.bind("<Configure>", _on_resize)

        def _update():
            try:
                if not canvas.winfo_exists():
                    return
                cfg = getattr(self, "banner_cfg", None) or {}
                # Disabled (or no config) → hide the entire banner
                if cfg.get("enabled") is not True:
                    try: self._guest_banner.pack_forget()
                    except Exception: pass
                    canvas.after(1500, _update)
                    return
                # User hid it via ✕ → keep hidden until the banner content changes.
                _dk = (str(cfg.get("title") or "").strip() + "|" + str(cfg.get("message") or "").strip())
                if getattr(self, "_banner_dismissed_key", None) == _dk:
                    try: self._guest_banner.pack_forget()
                    except Exception: pass
                    canvas.after(1000, _update)
                    return
                try:
                    if not self._guest_banner.winfo_ismapped():
                        _co = getattr(self, "_content_outer", None)
                        if _co is not None and _co.winfo_exists():
                            self._guest_banner.pack(fill="x", side="top", before=_co)
                        else:
                            self._guest_banner.pack(fill="x", side="top")
                except Exception:
                    pass
                # Text = title · message · countdown
                parts = []
                title   = str(cfg.get("title") or "").strip()
                message = str(cfg.get("message") or "").strip()
                if title:   parts.append(title)
                if message: parts.append(message)
                if cfg.get("show_countdown") and cfg.get("countdown_until"):
                    try:
                        from datetime import datetime
                        cu = str(cfg.get("countdown_until")).replace("Z", "")[:19]
                        try:
                            end = datetime.strptime(cu, "%Y-%m-%dT%H:%M:%S")
                        except ValueError:
                            end = datetime.strptime(cu[:10], "%Y-%m-%d")
                        secs = (end - datetime.utcnow()).total_seconds()
                        if secs > 0:
                            d = int(secs // 86400); h = int((secs % 86400) // 3600)
                            m = int((secs % 3600) // 60); s = int(secs % 60)
                            parts.append(f"{d}d {h}h {m}m {s}s")
                    except Exception:
                        pass
                canvas.itemconfig(txt, text="   ·   ".join(parts) if parts else "")
                # Optional call-to-action button (opens the admin-provided URL)
                bl = str(cfg.get("button_label") or "").strip()
                bu = str(cfg.get("button_url") or "").strip()
                if bl and bu:
                    btn.config(text=bl, command=lambda u=bu: webbrowser.open(u))
                    canvas.itemconfigure(btn_win, state="normal")
                else:
                    canvas.itemconfigure(btn_win, state="hidden")
                canvas.after(1000, _update)
            except Exception:
                pass
        _update()

    def _is_free_plan(self):
        """True when the active license is a Free Access (free_addon) key."""
        return getattr(self, "user_plan", "") in ("Free Access", "free_addon")

    def _is_patreon_locked(self, asset):
        return (asset.get("attributes") or asset).get("is_patreon_locked", False) is True

    def _is_premium_locked(self, asset):
        """Backend sets premium_locked=True when it stripped this asset's premium formats
        (4K/6K/8K/16K + EXR). The free resolution is still downloadable, so this only drives
        an additive 'Upgrade for 4K & EXR' nudge (NOT a download block)."""
        return (asset.get("attributes") or asset).get("premium_locked", False) is True

    def _parse_asset_files(self, asset):
        """Parse all of an asset's files (CGHLOCKED-aware) into file-info dicts."""
        a = asset.get("attributes") or asset
        return [parse_file_url(str(f)) for f in (a.get("files", []) or []) if f]

    def _asset_has_free_file(self, asset):
        """True when the asset has at least one non-locked (free, real-URL) downloadable file.
        Patreon-locked assets have ALL files CGHLOCKED by the backend, so they return False."""
        if self._is_patreon_locked(asset):
            return False
        return any(not f.get("locked") for f in self._parse_asset_files(asset))

    def _locked_for_user(self, asset):
        """Fully locked (→ 'Upgrade' instead of 'Download') ONLY when NO downloadable file exists for
        THIS user. Trust the backend's per-plan CGHLOCKED markers directly — do NOT also gate on
        is_patreon_locked (via _asset_has_free_file), else a paid Pro/Studio user, who the backend
        sends real URLs for patreon-locked assets, would wrongly see Upgrade."""
        return not any(not f.get("locked") for f in self._parse_asset_files(asset))

    def _premium_nudge_for_user(self, asset):
        """True when an asset has premium formats stripped (premium_locked) but is NOT fully
        patreon-locked AND still has a free file. Drives the additive 'Upgrade for 4K & EXR'
        nudge; the free-resolution Download stays fully functional."""
        return (
            self._is_premium_locked(asset)
            and not self._is_patreon_locked(asset)
            and self._asset_has_free_file(asset)
        )

    def _open_upgrade_url(self):
        """Patreon-locked asset → open the pricing/Patreon page in the browser."""
        try:
            webbrowser.open(PRICING_URL)
        except Exception:
            pass

    def _show_upgrade_dialog(self, reason="premium"):
        """Any 'Upgrade' action goes straight to the pricing page — there is NO license-key
        system any more, so this never opens a key-entry dialog. Kept as a thin alias so any
        legacy caller still routes correctly to the buy-plan page."""
        self._open_upgrade_url()

    def _show_activation_dialog(self):
        """Open inline activation dialog so guest can enter their license key."""
        popup = tk.Toplevel(self)
        popup.title(f"{ADDON_NAME}  —  Activate License")
        popup.geometry("520x340")
        popup.resizable(False, False)
        popup.configure(bg=C["bg"])
        popup.transient(self)
        popup.grab_set()
        popup.update_idletasks()
        sw = popup.winfo_screenwidth()
        sh = popup.winfo_screenheight()
        popup.geometry(f"520x340+{sw//2-260}+{sh//2-170}")

        hdr = tk.Frame(popup, bg=C["topbar"], height=50)
        hdr.pack(fill="x"); hdr.pack_propagate(False)
        back_btn = tk.Label(hdr, text="←", bg=C["topbar"], fg=C["text2"],
                            font=("Segoe UI", 17, "bold"), cursor="hand2")
        back_btn.pack(side="left", padx=(14, 0))
        back_btn.bind("<Button-1>", lambda e: popup.destroy())
        back_btn.bind("<Enter>", lambda e: back_btn.configure(fg=C["accent"]))
        back_btn.bind("<Leave>", lambda e: back_btn.configure(fg=C["text2"]))
        tk.Label(hdr, text="  Activate License Key",
                 bg=C["topbar"], fg=C["accent"],
                 font=("Segoe UI", 12, "bold")).pack(side="left", padx=12, pady=12)

        body = tk.Frame(popup, bg=C["bg"], pady=20)
        body.pack(fill="both", expand=True, padx=24)

        tk.Label(body, text="Enter your CGHEVEN license key:",
                 bg=C["bg"], fg=C["text"], font=("Segoe UI", 10)).pack(anchor="w", pady=(0, 6))

        key_var = tk.StringVar(master=popup)
        key_entry = tk.Entry(body, textvariable=key_var, width=46,
                             bg=C["search"], fg=C["text"], insertbackground=C["text"],
                             font=("Segoe UI", 10), relief="flat", bd=6)
        key_entry.pack(fill="x", ipady=4)
        key_entry.focus_set()

        status_lbl = tk.Label(body, text="", bg=C["bg"], fg=C["red"],
                              font=("Segoe UI", 9), wraplength=460)
        status_lbl.pack(anchor="w", pady=(8, 0))

        btn_frame = tk.Frame(body, bg=C["bg"])
        btn_frame.pack(fill="x", pady=(16, 0))

        def _do_activate():
            k = (key_entry.get() or key_var.get()).strip()
            if not k:
                status_lbl.configure(text="Please enter your license key.", fg=C["red"])
                return
            activate_btn.configure(state="disabled", text="Activating…")
            popup.update_idletasks()

            def _bg():
                try:
                    if AUTH_MODULE_AVAILABLE:
                        result_data = activate_license(k)
                        new_token = result_data.get("session_token", "")
                    else:
                        new_token = ""
                        result_data = {}

                    def _ok():
                        self.session_token = new_token
                        self.license_key   = k
                        self.is_guest      = False
                        self._free_filter_active = False
                        # Carry the new plan into the app so lock state + label reflect it.
                        try:
                            if isinstance(result_data, dict):
                                self.user_plan = result_data.get("plan", "") or self.user_plan
                        except Exception:
                            pass
                        if new_token and hasattr(self, "api"):
                            self.api.update_token(new_token)
                        # Drop any guest/Free-cached (locked) asset pages so the grid reloads at
                        # the new plan's full access (BlenderKit-style _cache_invalidate on login).
                        self._invalidate_asset_caches()
                        popup.destroy()
                        try:
                            if hasattr(self, "_guest_banner") and self._guest_banner.winfo_exists():
                                self._guest_banner.destroy()
                        except Exception:
                            pass
                        messagebox.showinfo("Activated",
                                            "License activated successfully! Reloading assets…",
                                            parent=self)
                        self._load_categories()
                        self.after(300, lambda: self._load_assets_with_feedback(page=1))

                    self.after(0, _ok)
                except Exception as exc:
                    err = str(exc)
                    def _err():
                        status_lbl.configure(text=f"Activation failed: {err}", fg=C["red"])
                        activate_btn.configure(state="normal", text="Activate")
                    self.after(0, _err)

            threading.Thread(target=_bg, daemon=True).start()

        activate_btn = tk.Button(
            btn_frame, text="Activate", bg=C["accent"], fg="#ffffff",
            font=("Segoe UI", 10, "bold"), relief="flat", padx=16, pady=6,
            command=_do_activate,
        )
        activate_btn.pack(side="left")
        tk.Button(btn_frame, text="Cancel", bg=C["card"], fg=C["text2"],
                  font=("Segoe UI", 9), relief="flat", padx=12, pady=6,
                  command=popup.destroy).pack(side="left", padx=(8, 0))
        tk.Button(btn_frame, text="Buy a license", bg=C["card"], fg=C["accent"],
                  font=("Segoe UI", 9, "bold"), relief="flat", padx=12, pady=6,
                  cursor="hand2",
                  command=lambda: webbrowser.open(PRICING_URL)).pack(side="right")

        popup.bind("<Return>", lambda e: _do_activate())

    def _show_license_popup(self):
        """Compact license-key popup for guest users clicking a locked card button."""
        self._show_activation_dialog()

    def _build_ui(self):
        self.minsize(1100, 680)
        self.configure(bg=C["bg"])
        self._cat_panel_width = 188
        self._right_panel_width = 292

        # Admin-controlled announcement banner — shown for ALL users (the banner's own
        # _update loop hides itself when the admin has it disabled).
        self._show_guest_banner()

        outer = tk.Frame(self, bg=C["bg"])
        outer.pack(fill="both", expand=True)
        self._content_outer = outer   # banner always re-packs ABOVE this → stays at the TOP

        self._cat_panel_frame = tk.Frame(outer, bg=C["sidebar"], width=self._cat_panel_width)
        self._cat_panel_frame.pack(side="left", fill="y")
        self._cat_panel_frame.pack_propagate(False)
        self._cat_panel_open = True
        self._build_cat_panel_inner(self._cat_panel_frame)

        self._center_wrap = tk.Frame(outer, bg=C["bg"])
        self._center_wrap.pack(side="left", fill="both", expand=True)

        # Pillow-missing warning (thumbnails/previews disabled) with one-click fix.
        self._show_pil_warning_banner(self._center_wrap)

        self._build_search_bar(self._center_wrap)

        body = tk.Frame(self._center_wrap, bg=C["bg"])
        body.pack(fill="both", expand=True)

        self._grid_frame = tk.Frame(body, bg=C["bg"])
        self._grid_frame.pack(side="left", fill="both", expand=True)
        self._build_grid_area(self._grid_frame)

        self._right_panel = tk.Frame(body, bg="#101119", width=self._right_panel_width)
        self._right_panel.pack(side="right", fill="y")
        self._right_panel.pack_propagate(False)
        self._right_panel_open = False
        self._build_right_panel_inner(self._right_panel)
        self._right_panel.pack_forget()

        self._build_statusbar()

    # ── Dependency warning banner (Pillow missing → no thumbnails) ──────────
    def _show_pil_warning_banner(self, parent):
        """Visible only when Pillow failed to import. Offers a one-click auto-install."""
        if PIL_AVAILABLE:
            return
        try:
            bar = tk.Frame(parent, bg="#3A1D1D")
            bar.pack(fill="x", side="top")
            msg = ("⚠  Thumbnails & previews are off — the Pillow image library isn't "
                   "installed in DaVinci's Python. Click 'Fix Now' to install it automatically.")
            tk.Label(bar, text=msg, bg="#3A1D1D", fg="#FFD7D7",
                     font=("Segoe UI", 9), anchor="w", justify="left",
                     wraplength=820).pack(side="left", fill="x", expand=True, padx=12, pady=6)
            tk.Button(bar, text="✕", bg="#3A1D1D", fg="#FFD7D7", relief="flat",
                      font=("Segoe UI", 9, "bold"), cursor="hand2", bd=0,
                      command=bar.destroy).pack(side="right", padx=(0, 10))
            tk.Button(bar, text="Fix Now", bg=C["accent"], fg="white", relief="flat",
                      font=("Segoe UI", 9, "bold"), cursor="hand2", bd=0, padx=14, pady=4,
                      command=self._fix_dependencies_now).pack(side="right", padx=6, pady=4)
        except Exception:
            pass

    def _fix_dependencies_now(self):
        """User clicked 'Fix Now' — clear the 24h backoff and install in a worker thread."""
        try:
            if os.path.exists(_CGH_DEPS_MARKER):
                os.remove(_CGH_DEPS_MARKER)
        except Exception:
            pass
        try:
            messagebox.showinfo(
                "CGHEVEN",
                "Installing components… this can take up to a minute and DaVinci may "
                "look busy. You'll get a message when it's done.")
        except Exception:
            pass

        def _work():
            pkgs = _cgh_missing_deps() or list(_CGH_REQUIRED_DEPS.keys())
            ok = _cgh_pip_install(pkgs)
            try:
                self.after(0, lambda: self._deps_fix_done(ok))
            except Exception:
                pass
        threading.Thread(target=_work, daemon=True).start()

    def _deps_fix_done(self, ok):
        try:
            if ok:
                messagebox.showinfo(
                    "CGHEVEN",
                    "Components installed ✔\n\nPlease CLOSE and RE-OPEN the addon to "
                    "enable thumbnails and previews.")
            else:
                py = sys.executable or "python"
                messagebox.showwarning(
                    "CGHEVEN",
                    "Automatic install failed (no internet or restricted permissions).\n\n"
                    "Manual fix — run this in a terminal, then restart the addon:\n\n"
                    f"  \"{py}\" -m pip install --user Pillow imageio certifi")
        except Exception:
            pass

    def _build_icon_sidebar(self, parent):
        sb = tk.Frame(parent, bg="#090A0F", width=76)
        sb.pack(side="left", fill="y")
        sb.pack_propagate(False)

        tk.Frame(sb, bg="#090A0F", height=16).pack(fill="x")

        tk.Frame(sb, bg=C["border"], height=1).pack(fill="x", padx=12)

        nav_frame = tk.Frame(sb, bg="#090A0F")
        nav_frame.pack(fill="both", expand=True, pady=10)

        settings_item = tk.Frame(nav_frame, bg="#090A0F", height=64, cursor="hand2")
        settings_item.pack(fill="x", pady=(4, 0))
        settings_item.pack_propagate(False)

        stg = tk.Label(settings_item, text="⚙", bg="#090A0F", fg=C["text2"],
                       font=("Segoe UI", 16), cursor="hand2")
        stg.pack(pady=(10, 0))
        stg_txt = tk.Label(settings_item, text="Settings", bg="#090A0F", fg=C["dim"],
                           font=("Segoe UI", 7), cursor="hand2")
        stg_txt.pack(pady=(1, 0))

        def _stg_click(e):
            self._open_settings_menu(e)

        def _stg_enter(_e):
            settings_item.configure(bg=C["card"])
            stg.configure(bg=C["card"], fg=C["accent"])
            stg_txt.configure(bg=C["card"], fg=C["accent"])

        def _stg_leave(_e):
            settings_item.configure(bg="#090A0F")
            stg.configure(bg="#090A0F", fg=C["text2"])
            stg_txt.configure(bg="#090A0F", fg=C["dim"])

        for w in (settings_item, stg, stg_txt):
            w.bind("<Button-1>", _stg_click)
            w.bind("<Enter>", _stg_enter)
            w.bind("<Leave>", _stg_leave)

        # License/Profile icon just under Settings
        lic_item = tk.Frame(nav_frame, bg="#090A0F", height=64, cursor="hand2")
        lic_item.pack(fill="x", pady=(3, 0))
        lic_item.pack_propagate(False)

        lic = tk.Label(lic_item, text="👤", bg="#090A0F", fg=C["text2"],
                       font=("Segoe UI", 16), cursor="hand2")
        lic.pack(pady=(10, 0))
        lic_txt = tk.Label(lic_item, text="My Account", bg="#090A0F", fg=C["dim"],
                           font=("Segoe UI", 7), cursor="hand2")
        lic_txt.pack(pady=(1, 0))

        def _lic_click(_e):
            self._show_license_info()

        def _lic_enter(_e):
            lic_item.configure(bg=C["card"])
            lic.configure(bg=C["card"], fg=C["accent"])
            lic_txt.configure(bg=C["card"], fg=C["accent"])

        def _lic_leave(_e):
            lic_item.configure(bg="#090A0F")
            lic.configure(bg="#090A0F", fg=C["text2"])
            lic_txt.configure(bg="#090A0F", fg=C["dim"])

        for w in (lic_item, lic, lic_txt):
            w.bind("<Button-1>", _lic_click)
            w.bind("<Enter>", _lic_enter)
            w.bind("<Leave>", _lic_leave)

        # Bottom profile icon → License popup
        bottom = tk.Frame(sb, bg="#090A0F")
        bottom.pack(side="bottom", fill="x", pady=(0, 8))

        prof_item = tk.Frame(bottom, bg="#090A0F", height=64, cursor="hand2")
        prof_item.pack(fill="x")
        prof_item.pack_propagate(False)

        prof = tk.Label(prof_item, text="👤", bg="#090A0F", fg=C["text2"],
                        font=("Segoe UI", 16), cursor="hand2")
        prof.pack(pady=(10, 0))
        prof_txt = tk.Label(prof_item, text="Profile", bg="#090A0F", fg=C["dim"],
                            font=("Segoe UI", 7), cursor="hand2")
        prof_txt.pack(pady=(1, 0))

        def _prof_click(_e):
            self._show_license_info()

        def _prof_enter(_e):
            prof_item.configure(bg=C["card"])
            prof.configure(bg=C["card"], fg=C["accent"])
            prof_txt.configure(bg=C["card"], fg=C["accent"])

        def _prof_leave(_e):
            prof_item.configure(bg="#090A0F")
            prof.configure(bg="#090A0F", fg=C["text2"])
            prof_txt.configure(bg="#090A0F", fg=C["dim"])

        for w in (prof_item, prof, prof_txt):
            w.bind("<Button-1>", _prof_click)
            w.bind("<Enter>", _prof_enter)
            w.bind("<Leave>", _prof_leave)

    def _add_nav_item(self, parent, key, icon, label):
        frame = tk.Frame(parent, bg="#090A0F", height=64, cursor="hand2")
        frame.pack(fill="x", pady=3)
        frame.pack_propagate(False)

        icon_lbl = tk.Label(frame, text=icon, bg="#090A0F", fg=C["text2"],
                            font=("Segoe UI", 19))
        icon_lbl.pack(pady=(10, 0))
        txt_lbl = tk.Label(frame, text=label, bg="#090A0F", fg=C["dim"],
                           font=("Segoe UI", 7))
        txt_lbl.pack(pady=(1, 0))

        self._nav_items[key] = (frame, icon_lbl, txt_lbl)

        def click(e, k=key):
            self._on_nav_click(k)

        def on_enter(_e, ky=key, f=frame, i=icon_lbl, t=txt_lbl):
            if self._nav_active_key != ky:
                f.configure(bg=C["card"])
                i.configure(bg=C["card"])
                t.configure(bg=C["card"])

        def on_leave(_e, ky=key, f=frame, i=icon_lbl, t=txt_lbl):
            if self._nav_active_key != ky:
                f.configure(bg="#090A0F")
                i.configure(bg="#090A0F")
                t.configure(bg="#090A0F")

        for w in (frame, icon_lbl, txt_lbl):
            w.bind("<Button-1>", click)
            w.bind("<Enter>", on_enter)
            w.bind("<Leave>", on_leave)

    def _set_nav_active(self, key):
        self._nav_active_key = key
        for k, (f, i, t) in getattr(self, "_nav_items", {}).items():
            if k == key:
                f.configure(bg=C["cat_active"],
                            highlightbackground=C["accent"], highlightthickness=0)
                i.configure(bg=C["cat_active"], fg=C["accent"])
                t.configure(bg=C["cat_active"], fg=C["accent"])
            else:
                f.configure(bg="#090A0F",
                            highlightbackground="#090A0F", highlightthickness=0)
                i.configure(bg="#090A0F", fg=C["text2"])
                t.configure(bg="#090A0F", fg=C["dim"])

    def _on_nav_click(self, key):
        self._set_nav_active(key)
        if key == "home":
            self.active_view = "home"
            self._toggle_cat_panel(True)
            if self.downloads_view_active:
                self._hide_downloads_view()
            self.active_cat = "all"
            self.current_page = 1
            self._load_assets()
        elif key == "downloads":
            self.active_view = "local"
            self._toggle_cat_panel(True)
            self._show_downloads_view()

    def _toggle_cat_panel(self, show):
        self._cat_panel_open = show
        if show:
            self._cat_panel_frame.configure(width=getattr(self, "_cat_panel_width", 188))
        else:
            self._cat_panel_frame.configure(width=0)

    def _build_cat_panel_inner(self, parent):
        hdr = tk.Frame(parent, bg=C["sidebar"], pady=8)
        hdr.pack(fill="x", padx=12)
        logo_lbl = tk.Label(hdr, text="▶", bg=C["sidebar"], fg=C["accent"],
                            font=("Segoe UI", 12, "bold"))
        logo_lbl.pack(side="left", padx=(0, 6))
        self._titlebar_logo_lbl = logo_lbl
        if hasattr(self, "_app_logo_img") and PIL_AVAILABLE:
            try:
                # Use a larger, sharpened logo to look crisp on Windows DPI
                logo_img = self._app_logo_img.resize((24, 24), Image.LANCZOS)
                try:
                    logo_img = logo_img.filter(ImageFilter.UnsharpMask(radius=1.2, percent=160, threshold=2))
                except Exception:
                    pass
                logo_photo = ImageTk.PhotoImage(logo_img, master=self)
                logo_lbl.configure(image=logo_photo, text="")
                logo_lbl.image = logo_photo
            except Exception:
                pass
        tk.Label(hdr, text="CGHEVEN Asset Library",
                 bg=C["sidebar"], fg=C["dim"],
                 font=("Segoe UI", 8, "bold")).pack(side="left")
        tk.Frame(parent, bg=C["border"], height=1).pack(fill="x", padx=12)

        # ── Verify Files — fixed at sidebar bottom (packed before canvas) ──
        vfy_wrap = tk.Frame(parent, bg=C["sidebar"])
        vfy_wrap.pack(side="bottom", fill="x", pady=(0, 5), padx=8)
        tk.Frame(parent, bg=C["border"], height=1).pack(side="bottom", fill="x", padx=10)

        # ── About / Update (version + Check/Install Update buttons) — just above Verify Files ──
        self._build_about_section(parent)

        _vb = tk.Frame(vfy_wrap, bg="#1a2e1a", cursor="hand2",
                       highlightbackground="#3a6e3a", highlightthickness=1)
        _vb.pack(fill="x")
        tk.Label(_vb, text="⊕", bg="#1a2e1a", fg="#5abf5a",
                 font=("Segoe UI", 9, "bold"), padx=6, pady=6, cursor="hand2").pack(side="left")
        tk.Label(_vb, text="Verify Files", bg="#1a2e1a", fg="#5abf5a",
                 font=("Segoe UI", 8), anchor="w", pady=6, cursor="hand2").pack(side="left", fill="x", expand=True)

        # Floating tooltip using Toplevel (appears above the button)
        _tt = tk.Toplevel(self)
        _tt.withdraw()
        _tt.overrideredirect(True)
        _tt.attributes("-topmost", True)
        tk.Label(
            _tt,
            text=(
                "Verify Files\n\n"
                "Checks all your downloaded assets and confirms\n"
                "which files still exist on your disk.\n\n"
                "Missing files are automatically removed from\n"
                "your downloads list, keeping it clean and in\n"
                "sync across all CGHEVEN addons."
            ),
            bg="#1a2a1a", fg="#c8e6c8",
            font=("Segoe UI", 8),
            justify="left", padx=12, pady=10,
            relief="solid", bd=1, wraplength=220,
        ).pack()

        def _vb_enter(e):
            _vb.configure(bg="#223322", highlightbackground="#5abf5a")
            for w in _vb.winfo_children():
                w.configure(bg="#223322", fg="#7fd87f")
            try:
                _tt.update_idletasks()
                x = _vb.winfo_rootx()
                y = _vb.winfo_rooty() - _tt.winfo_reqheight() - 5
                _tt.geometry(f"+{x}+{y}")
                _tt.deiconify()
                _tt.lift()
            except Exception:
                pass

        def _vb_leave(e):
            _vb.configure(bg="#1a2e1a", highlightbackground="#3a6e3a")
            for w in _vb.winfo_children():
                w.configure(bg="#1a2e1a", fg="#5abf5a")
            try: _tt.withdraw()
            except Exception: pass

        def _vb_click(e):
            _tt.withdraw()
            _vb.configure(bg="#0f1f0f")
            for w in _vb.winfo_children():
                w.configure(bg="#0f1f0f")
            self._scan_downloads()
            _vb.after(400, lambda: (
                _vb.configure(bg="#1a2e1a"),
                [w.configure(bg="#1a2e1a", fg="#5abf5a") for w in _vb.winfo_children()]
            ) if _vb.winfo_exists() else None)

        for w in [_vb] + list(_vb.winfo_children()):
            w.bind("<Enter>", _vb_enter)
            w.bind("<Leave>", _vb_leave)
            w.bind("<Button-1>", _vb_click)

        cat_canvas = tk.Canvas(parent, bg=C["sidebar"], highlightthickness=0)
        cat_canvas.pack(side="left", fill="both", expand=True)
        self._make_custom_scrollbar(parent, cat_canvas,
                                    bg_color=C["sidebar"], thumb_color="#2A2A3A",
                                    auto_hide=True)

        self.cat_frame = tk.Frame(cat_canvas, bg=C["sidebar"])
        self._cat_window = cat_canvas.create_window((0, 0), window=self.cat_frame, anchor="nw")
        self.cat_frame.bind("<Configure>",
                            lambda e: cat_canvas.configure(scrollregion=cat_canvas.bbox("all")))
        cat_canvas.bind("<Configure>",
                        lambda e: cat_canvas.itemconfig(self._cat_window, width=e.width))

        def _mw(e, cv=cat_canvas):
            if not self._canvas_can_scroll_y(cv):
                return "break"
            cv.yview_scroll(int(-e.delta / 40), "units")
            return "break"
        cat_canvas.bind("<MouseWheel>", _mw)
        self._cat_canvas = cat_canvas
        self._cat_scroll_fn = _mw

    def _build_search_bar(self, parent):
        bar = tk.Frame(parent, bg=C["topbar"], height=52)
        bar.pack(fill="x")
        bar.pack_propagate(False)

        # Drag support (was on titlebar, now on this bar)
        bar.bind("<ButtonPress-1>",
                 lambda e: setattr(self, "_dx", e.x) or setattr(self, "_dy", e.y))
        bar.bind("<B1-Motion>",
                 lambda e: self.geometry(
                     f"+{self.winfo_x()+e.x-self._dx}+{self.winfo_y()+e.y-self._dy}"))
        self._dx = self._dy = 0

        inner = tk.Frame(bar, bg=C["topbar"])
        inner.pack(fill="both", expand=True, padx=12, pady=7)

        self._titlebar_update_badge = tk.Label(
            inner, text="  🔄 Update  ",
            bg="#FF8C00", fg="white",
            font=("Segoe UI", 7, "bold"), cursor="hand2", padx=2)
        self._titlebar_update_badge.bind(
            "<Button-1>",
            lambda e: _auto_update.show_update_notification(self)
            if UPDATE_MODULE_AVAILABLE and _auto_update else None)
        self._titlebar_update_badge.pack_forget()

        # ── Search bar (expands) ─────────────────────────────────────
        search_wrap = tk.Frame(inner, bg=C["search"],
                               highlightbackground=C["border"], highlightthickness=1)
        search_wrap.pack(side="left", fill="both", expand=True, padx=(0, 8))

        tk.Label(search_wrap, text="🔍", bg=C["search"], fg=C["dim"],
                 font=("Segoe UI", 10)).pack(side="left", padx=(10, 4))

        search_clear = tk.Label(
            search_wrap, text="✕", bg=C["search"], fg=C["dim"],
            font=("Segoe UI", 10), cursor="hand2", padx=6)
        search_clear.bind("<Button-1>", lambda e: self._clear_search())
        self._search_clear_btn = search_clear

        se = tk.Entry(search_wrap, textvariable=self.search_var,
                      bg=C["search"], fg=C["text"],
                      insertbackground=C["accent"],
                      relief="flat", font=("Segoe UI", 9))
        se.pack(side="left", fill="both", expand=True, ipady=7, padx=(0, 4))
        se.bind("<Return>", self._on_search_enter)
        se.bind("<KeyRelease>", self._on_search_typing)

        placeholder_text = "Search assets… (Enter)"
        se.insert(0, placeholder_text)
        se.configure(fg=C["dim"])

        def on_focus_in(e):
            if se.get() == placeholder_text:
                se.delete(0, tk.END)
                se.configure(fg=C["text"])
        def on_focus_out(e):
            if not se.get().strip():
                se.insert(0, placeholder_text)
                se.configure(fg=C["dim"])
        se.bind("<FocusIn>", on_focus_in)
        se.bind("<FocusOut>", on_focus_out)
        self.search_entry = se
        self._search_placeholder = placeholder_text

        # ── Count label ──────────────────────────────────────────────
        self.count_lbl = tk.Label(inner, text="", bg=C["topbar"], fg=C["dim"],
                                  font=("Segoe UI", 8), anchor="w", width=14)
        self.count_lbl.pack(side="left", padx=(0, 8))

        # ── Grid button ──────────────────────────────────────────────
        grid_wrap = tk.Frame(inner, bg=C["border"])
        grid_wrap.pack(side="left", padx=(0, 6))
        grid_inner = tk.Frame(grid_wrap, bg=C["search"], cursor="hand2")
        grid_inner.pack(padx=1, pady=1)
        # Manual grid sizes (min 3, max 6)
        self._grid_choices = (3, 4, 5, 6)
        self._grid_menu_btn = tk.Label(
            grid_inner, text="◫  Auto  ▾",
            bg=C["search"], fg=C["text"],
            font=("Segoe UI", 8, "bold"),
            padx=8, pady=5, cursor="hand2",
        )
        self._grid_menu_btn.pack(side="left")
        self._grid_menu_btn.bind("<Button-1>", lambda e: self._show_grid_menu())
        grid_inner.bind("<Button-1>", lambda e: self._show_grid_menu())

        # ── Filter + clear filters ────────────────────────────────────
        flt_row = tk.Frame(inner, bg=C["topbar"])
        flt_row.pack(side="left", padx=(0, 10))
        flt_wrap = tk.Frame(flt_row, bg=C["border"])
        flt_wrap.pack(side="left")
        flt_inner = tk.Frame(flt_wrap, bg=C["search"], cursor="hand2")
        flt_inner.pack(padx=1, pady=1)
        flt_title = tk.Label(
            flt_inner, text="⚙  Filter  ▾",
            bg=C["search"], fg=C["text"],
            font=("Segoe UI", 8, "bold"),
            padx=8, pady=5, cursor="hand2",
        )
        flt_title.pack(side="left")
        flt_title.bind("<Button-1>", lambda e: self._show_filter_menu())
        flt_inner.bind("<Button-1>", lambda e: self._show_filter_menu())
        self._filter_btn = flt_title
        self._filter_sub_lbl = None
        flt_clr = tk.Label(
            flt_row, text="✕", bg=C["topbar"], fg=C["dim"],
            font=("Segoe UI", 10), cursor="hand2", padx=4)
        flt_clr.bind("<Button-1>", lambda e: self._clear_filters())
        self._filter_clear_btn = flt_clr

        # Free is now a checkable item inside the "⚙ Filter ▾" menu (combines with the
        # currently-open category / sub-category) — no standalone button.

        # ── Refresh + Close buttons ───────────────────────────────────
        rbtn_cfg = {"bg": C["topbar"], "activebackground": C["card"],
                    "relief": "flat", "cursor": "hand2",
                    "font": ("Segoe UI", 10), "padx": 6, "pady": 2}
        tk.Button(inner, text="⟳", fg=C["text2"], command=self._refresh,
                  **rbtn_cfg).pack(side="right")

        self._set_grid_cols(self.grid_cols_pref.get())
        self._set_grid_auto(True)
        self._update_filter_summary()
        self._update_filter_button_label()
        self._update_search_clear_visibility()
        self._update_filter_clear_visibility()

    def _show_filter_menu(self):
        try:
            btn = getattr(self, "_filter_btn", None)
            if not btn or not btn.winfo_exists():
                return
            menu = tk.Menu(self, tearoff=0, bg=C["card"], fg=C["text"],
                           activebackground=C["accent"], activeforeground="white",
                           font=("Segoe UI", 9))

            def _reload():
                try:
                    self.current_page = 1
                    self._update_filter_summary()
                    self._update_filter_button_label()
                    self._load_assets_with_feedback(page=1)
                except Exception:
                    pass

            def _reload_idle():
                self.after_idle(_reload)

            def _lbl(text: str, checked: bool = False) -> str:
                # Keep all menu items aligned by reserving prefix space
                return f"{'✓' if checked else ' '}  {text}"

            menu.add_command(
                label=_lbl("Sort (reset)"),
                command=lambda: (
                    self.sort_var.set("Newest"),
                    self.filter_downloaded_only.set(False),
                    self.filter_favourite_only.set(False),
                    _reload_idle(),
                ),
            )
            menu.add_command(
                label=_lbl("Newest", checked=(str(self.sort_var.get() or "") == "Newest")),
                command=lambda: (self.sort_var.set("Newest"), _reload_idle()),
            )
            menu.add_command(
                label=_lbl("Old", checked=(str(self.sort_var.get() or "") == "Oldest")),
                command=lambda: (self.sort_var.set("Oldest"), _reload_idle()),
            )
            dl_on = bool(self.filter_downloaded_only.get())
            menu.add_command(
                label=_lbl("Downloaded", checked=dl_on),
                command=lambda: (
                    self.filter_downloaded_only.set(not dl_on),
                    _reload_idle(),
                ),
            )
            fav_on = bool(self.filter_favourite_only.get())
            menu.add_command(
                label=_lbl("Favourite", checked=fav_on),
                command=lambda: (
                    self.filter_favourite_only.set(not fav_on),
                    _reload_idle(),
                ),
            )
            menu.add_separator()
            # Free assets only — combines with the currently-open category / sub-category
            free_on = bool(getattr(self, "_free_filter_active", False))
            menu.add_command(
                label=_lbl("Free assets only", checked=free_on),
                command=lambda: (
                    setattr(self, "_free_filter_active", not free_on),
                    _reload_idle(),
                ),
            )
            menu.add_separator()
            menu.add_command(label=_lbl("Clear filters"), command=lambda: self._clear_filters())
            x = btn.winfo_rootx()
            y = btn.winfo_rooty() + btn.winfo_height() + 4
            menu.post(x, y)
        except Exception:
            pass

    def _show_grid_menu(self):
        try:
            btn = getattr(self, "_grid_menu_btn", None)
            if not btn or not btn.winfo_exists():
                return
            menu = tk.Menu(self, tearoff=0, bg=C["card"], fg=C["text"],
                           activebackground=C["accent"], activeforeground="white",
                           font=("Segoe UI", 9))
            menu.add_command(
                label=("✓ Auto layout" if bool(getattr(self, "grid_auto_mode", True)) else "Auto layout"),
                command=lambda: self._set_grid_auto(True),
            )
            menu.add_separator()
            for cols in getattr(self, "_grid_choices", (2, 3, 4, 5, 6)):
                checked = (not bool(getattr(self, "grid_auto_mode", True)) and int(self.grid_cols_pref.get() or 4) == int(cols))
                menu.add_command(
                    label=f"{'✓ ' if checked else ''}{cols} columns",
                    command=lambda c=cols: self._set_grid_cols(c),
                )
            x = btn.winfo_rootx()
            y = btn.winfo_rooty() + btn.winfo_height() + 4
            menu.post(x, y)
        except Exception:
            pass

    def _set_grid_auto(self, enabled=True):
        try:
            self.grid_auto_mode = bool(enabled)
            self._update_grid_menu_label(getattr(self, "_grid_cols", int(self.grid_cols_pref.get() or 4)))
            if hasattr(self, "grid_canvas"):
                self._maybe_reflow_grid_columns()
        except Exception:
            pass

    def _update_filter_summary(self):
        try:
            parts = []
            if (self.sort_var.get() or "Newest") != "Newest":
                parts.append(self.sort_var.get())
            if bool(self.filter_downloaded_only.get()):
                parts.append("Downloaded")
            fav_only_var = getattr(self, "filter_favourite_only", None)
            if bool(fav_only_var.get()) if fav_only_var is not None else False:
                parts.append("Favourite")
            text = ", ".join(parts) if parts else "No filters"
            if getattr(self, "_filter_sub_lbl", None) and self._filter_sub_lbl.winfo_exists():
                self._filter_sub_lbl.configure(text=text)
        except Exception:
            pass

    def _update_filter_button_label(self):
        try:
            labels = []
            sort_val = (self.sort_var.get() or "Newest")
            if sort_val == "Oldest":
                labels.append("Old")
            elif sort_val not in ("Newest", ""):
                labels.append(sort_val)
            if bool(self.filter_downloaded_only.get()):
                labels.append("Downloaded")
            fav_only_var = getattr(self, "filter_favourite_only", None)
            if bool(fav_only_var.get()) if fav_only_var is not None else False:
                labels.append("Favourite")
            if bool(getattr(self, "_free_filter_active", False)):
                labels.append("Free")
            btn = getattr(self, "_filter_btn", None)
            if btn and btn.winfo_exists():
                if labels:
                    btn.configure(text=f"⚙  {', '.join(labels)}  ▾", fg=C["accent"])
                else:
                    btn.configure(text="⚙  Filter  ▾", fg=C["text"])
            self._update_filter_clear_visibility()
        except Exception:
            pass

    def _start_loading_anim(self):
        """Display animated loading dots in the count label while loading is in progress."""
        self._loading_anim_started_at = time.time()
        self._loading_anim_running = True
        self._loading_anim_frame = 0
        self._tick_loading_anim()

    def _tick_loading_anim(self):
        if not getattr(self, "_loading_anim_running", False):
            return
        try:
            lbl = getattr(self, "count_lbl", None)
            if lbl and lbl.winfo_exists():
                dots = "." * ((self._loading_anim_frame % 3) + 1)
                lbl.configure(text=f"Loading{dots}")
            self._loading_anim_frame += 1
            self._loading_anim_job = self.after(380, self._tick_loading_anim)
        except Exception:
            pass

    def _stop_loading_anim(self):
        self._loading_anim_running = False
        job = getattr(self, "_loading_anim_job", None)
        if job:
            try:
                self.after_cancel(job)
            except Exception:
                pass
            self._loading_anim_job = None

    def _canvas_can_scroll_y(self, canvas):
        try:
            bbox = canvas.bbox("all")
            if not bbox:
                return False
            content_h = max(0, int(bbox[3] - bbox[1]))
            view_h = max(1, int(canvas.winfo_height()))
            return content_h > view_h + 2
        except Exception:
            return True

    def _load_assets_with_feedback(self, page=1, delay_ms=90):
        try:
            self.current_page = page
            self._start_loading_anim()
            self.after(delay_ms, lambda p=page: self._load_assets(page=p))
        except Exception:
            self._load_assets(page=page)

    def _update_search_clear_visibility(self):
        try:
            btn = getattr(self, "_search_clear_btn", None)
            se = getattr(self, "search_entry", None)
            if not btn or not se or not btn.winfo_exists() or not se.winfo_exists():
                return
            txt = se.get().strip()
            should_show = bool(txt and txt != getattr(self, "_search_placeholder", ""))
            if should_show and not btn.winfo_manager():
                btn.pack(side="right")
            elif not should_show and btn.winfo_manager():
                btn.pack_forget()
        except Exception:
            pass

    def _update_filter_clear_visibility(self):
        try:
            btn = getattr(self, "_filter_clear_btn", None)
            if not btn or not btn.winfo_exists():
                return
            fav_only_var = getattr(self, "filter_favourite_only", None)
            should_show = (
                (self.sort_var.get() or "Newest") != "Newest"
                or bool(self.filter_downloaded_only.get())
                or (bool(fav_only_var.get()) if fav_only_var is not None else False)
            )
            if should_show and not btn.winfo_manager():
                btn.pack(side="left")
            elif not should_show and btn.winfo_manager():
                btn.pack_forget()
        except Exception:
            pass

    def _set_grid_cols(self, cols):
        try:
            cols = int(cols)
            # Enforce allowed range even if caller passes something else
            choices = tuple(getattr(self, "_grid_choices", (3, 4, 5, 6)))
            min_c = min(choices) if choices else 3
            max_c = max(choices) if choices else 6
            cols = max(min_c, min(max_c, cols))
            self.grid_auto_mode = False
            self.grid_cols_pref.set(cols)
            for n, btn in getattr(self, "_grid_btns", {}).items():
                active = int(n) == cols
                btn.configure(
                    bg=C["accent"] if active else C["card"],
                    fg="white" if active else C["text2"],
                )
            self._update_grid_menu_label(cols)
            if hasattr(self, "grid_canvas"):
                self._maybe_reflow_grid_columns()
        except Exception:
            pass

    def _update_grid_menu_label(self, cols):
        try:
            btn = getattr(self, "_grid_menu_btn", None)
            if not btn or not btn.winfo_exists():
                return
            c = int(cols or 4)
            if bool(getattr(self, "grid_auto_mode", True)):
                btn.configure(text=f"◫  Auto ({c})  ▾")
            else:
                btn.configure(text=f"◫  {c}  ▾")
        except Exception:
            pass

    def _build_right_panel_inner(self, parent):
        footer = tk.Frame(parent, bg="#101119")
        footer.pack(side="bottom", fill="x")

        preview_outer = tk.Frame(parent, bg="#101119")
        preview_outer.pack(fill="x", padx=10, pady=0)

        preview_f = tk.Frame(preview_outer, bg="#000000", height=238)
        preview_f.pack(fill="x")
        preview_f.pack_propagate(False)

        self._rp_preview_lbl = tk.Label(
            preview_f, bg="#000000", text="Select an asset to preview",
            fg=C["dim"], font=("Segoe UI", 10), cursor="hand2",
            anchor="center", justify="center",
        )
        self._rp_preview_lbl.place(relwidth=1, relheight=1)
        self._rp_preview_lbl.bind("<Button-1>", lambda e: self._rp_play_video())

        self._rp_close_btn = tk.Button(
            preview_f,
            text="✕",
            bg="#1A1A22",
            fg=C["text2"],
            activebackground="#2A2A34",
            activeforeground=C["text"],
            relief="flat",
            bd=0,
            highlightthickness=0,
            cursor="hand2",
            font=("Segoe UI", 12, "bold"),
            width=1,
            padx=4,
            pady=0,
            command=self._close_right_panel,
        )
        self._rp_close_btn.place(relx=1.0, rely=0.0, anchor="ne", x=-2, y=2)
        self._rp_close_btn.lift()

        info_scroll = tk.Frame(parent, bg="#101119")
        info_scroll.pack(fill="both", expand=True)

        info_inner = tk.Frame(info_scroll, bg="#101119")
        info_inner.pack(fill="both", expand=True, padx=16, pady=(12, 10))

        title_row = tk.Frame(info_inner, bg="#101119")
        title_row.pack(fill="x", pady=(0, 4))

        self._rp_title_lbl = tk.Label(
            title_row, text="Select an asset",
            bg="#101119", fg=C["text"],
            font=("Segoe UI", 13, "bold"),
            wraplength=198, justify="left", anchor="w",
        )
        self._rp_title_lbl.pack(side="left", fill="x", expand=True)

        self._rp_favorite_btn = tk.Button(
            title_row, text="♡",
            bg="#101119", fg=C["text2"],
            activebackground="#101119", activeforeground="#FF6B8A",
            relief="flat", bd=0, cursor="hand2",
            font=("Segoe UI", 14, "bold"),
            command=lambda: self._toggle_favorite_asset(getattr(self, "_rp_current_asset", None)),
        )
        self._rp_favorite_btn.pack(side="right", padx=(8, 0))

        tk.Frame(info_inner, bg=C["border"], height=1).pack(fill="x", pady=(0, 10))
        tk.Label(info_inner, text="File info", bg="#101119", fg=C["text2"],
                 font=("Segoe UI", 8, "bold")).pack(anchor="w", pady=(0, 4))

        self._rp_info_frame = tk.Frame(info_inner, bg="#101119")
        self._rp_info_frame.pack(fill="x")

        for key in ("Format", "Resolution", "Frames", "Size"):
            row = tk.Frame(self._rp_info_frame, bg="#101119")
            row.pack(fill="x", pady=2)
            tk.Label(row, text=key, bg="#101119", fg=C["dim"],
                     font=("Segoe UI", 8), width=11, anchor="w").pack(side="left")
            value_key = key.lower().replace(" ", "_")
            if key in ("Format", "Resolution"):
                holder = tk.Frame(row, bg="#101119")
                holder.pack(side="left", fill="x", expand=True)
                setattr(self, f"_rp_{value_key}_badges", holder)
            else:
                lbl = tk.Label(row, text="—", bg="#101119", fg=C["text"],
                               font=("Segoe UI", 8, "bold"), anchor="w")
                lbl.pack(side="left", fill="x", expand=True)
                setattr(self, f"_rp_{value_key}_lbl", lbl)

        tk.Frame(info_inner, bg=C["border"], height=1).pack(fill="x", pady=(10, 8))
        self._rp_tags_frame = tk.Frame(info_inner, bg="#101119")
        self._rp_tags_frame.pack(fill="x", pady=(0, 8))

        tk.Frame(footer, bg=C["border"], height=1).pack(fill="x", pady=(0, 8))
        footer_inner = tk.Frame(footer, bg="#101119")
        footer_inner.pack(fill="x", padx=10, pady=(0, 10))

        tk.Label(footer_inner, text="Select format and resolution",
                 bg="#101119", fg=C["text2"],
                 font=("Segoe UI", 8, "bold")).pack(anchor="w", pady=(2, 4))

        chooser_wrap = tk.Frame(
            footer_inner, bg="#141722",
            highlightbackground=C["border"], highlightthickness=1,
        )
        chooser_wrap.pack(fill="x", pady=(0, 8))

        chooser = tk.Frame(chooser_wrap, bg="#141722")
        chooser.pack(fill="x", padx=8, pady=8)
        chooser.columnconfigure(0, weight=1, uniform="rpch")
        chooser.columnconfigure(1, weight=1, uniform="rpch")

        lf = tk.Frame(chooser, bg="#141722")
        lf.grid(row=0, column=0, sticky="nsew", padx=(0, 6))
        rf = tk.Frame(chooser, bg="#141722")
        rf.grid(row=0, column=1, sticky="nsew", padx=(6, 0))

        tk.Label(lf, text="Format", bg="#141722", fg=C["dim"],
                 font=("Segoe UI", 7, "bold")).pack(anchor="w", pady=(0, 2))
        try:
            st = ttk.Style()
            st.theme_use("clam")
            st.configure(
                "CGHV.TCombobox",
                padding=5,
                fieldbackground="#1A1E2B",
                background="#1A1E2B",
                foreground=C["text"],
                bordercolor=C["border"],
                lightcolor=C["border"],
                darkcolor=C["border"],
                arrowcolor=C["text2"],
            )
        except Exception:
            pass
        self._rp_fmt_combo = ttk.Combobox(lf, state="readonly", font=("Segoe UI", 8), style="CGHV.TCombobox")
        self._rp_fmt_combo.pack(fill="x")

        tk.Label(rf, text="Resolution", bg="#141722", fg=C["dim"],
                 font=("Segoe UI", 7, "bold")).pack(anchor="w", pady=(0, 2))
        self._rp_res_combo = ttk.Combobox(rf, state="readonly", font=("Segoe UI", 8), style="CGHV.TCombobox")
        self._rp_res_combo.pack(fill="x")

        self._rp_fmt_combo.bind("<<ComboboxSelected>>", lambda e: self._rp_on_format_combo_change())
        self._rp_res_combo.bind("<<ComboboxSelected>>", lambda e: self._rp_on_format_combo_change())

        self._rp_download_btn = tk.Button(
            footer_inner, text="Choose a file",
            bg=C["accent"], fg="white",
            activebackground=C["accent2"], activeforeground="white",
            relief="flat", cursor="hand2", bd=0,
            font=("Segoe UI", 9, "bold"), pady=7,
            command=self._rp_commit_panel_download,
        )
        self._rp_download_btn.pack(fill="x", pady=(0, 4), ipady=0)

        # Additive "Upgrade for 4K & EXR" nudge for Free Access users whose premium formats
        # were stripped (premium_locked). The free-resolution Download above still works —
        # this only opens the pricing page. Hidden by default; toggled per asset.
        self._rp_premium_nudge = tk.Label(
            footer_inner, text="🔒  Upgrade for 4K & EXR",
            bg="#101119", fg=C["cta"], cursor="hand2",
            font=("Segoe UI", 8, "bold"),
        )
        self._rp_premium_nudge.bind("<Button-1>", lambda e: self._open_upgrade_url())
        self._rp_premium_nudge.pack_forget()

        self._rp_progress_host = tk.Frame(footer_inner, bg="#101119")
        self._rp_progress_host.is_rp_proxy = True
        self._rp_progress_host.pack_forget()

        self._rp_shim_fmt = tk.Label(self._rp_progress_host, text="")
        self._rp_shim_res = tk.Label(self._rp_progress_host, text="")
        self._rp_shim_dl = tk.Label(self._rp_progress_host, text="")
        self._rp_selection_row = tk.Frame(
            self._rp_progress_host, bg=C["card"],
            highlightbackground=C["border"], highlightthickness=1,
        )
        self._rp_progress_host.selection_row = self._rp_selection_row
        self._rp_progress_host.format_btn = self._rp_shim_fmt
        self._rp_progress_host.resolution_btn = self._rp_shim_res
        self._rp_progress_host.download_btn = self._rp_shim_dl
        self._rp_progress_host.size_row = None
        self._rp_dl_proxy = self._rp_progress_host

        self._rp_current_asset = None
        self._rp_video_url = None
        self._rp_parsed_files = []
        self._rp_autoplay_job = None

    def _close_right_panel(self, _event=None):
        """Hide the right preview panel; caller may reopen via asset selection."""
        try:
            self._rp_stop_inline_video()
            self._rp_cancel_autoplay_timer()
            rp = getattr(self, "_right_panel", None)
            if rp:
                try:
                    rp.pack_forget()
                except Exception:
                    pass
            self._right_panel_open = False
            self.after_idle(self._maybe_reflow_grid_columns)
        except Exception:
            pass

    def _rp_render_badges(self, parent, badges, empty_text="—"):
        try:
            for w in list(parent.winfo_children()):
                try:
                    w.destroy()
                except Exception:
                    pass

            items = [b for b in (badges or []) if str((b or {}).get("text", "") or "").strip()]
            if not items:
                tk.Label(
                    parent, text=empty_text, bg="#101119", fg=C["text"],
                    font=("Segoe UI", 8, "bold"), anchor="w",
                ).pack(anchor="w")
                return

            max_width = int(parent.winfo_width() or 236)
            row = tk.Frame(parent, bg="#101119")
            row.pack(anchor="w", fill="x")
            used = 0

            for badge in items:
                text = str(badge.get("text", "") or "").strip()
                bg = badge.get("bg", "#1A2130")
                fg = badge.get("fg", C["text"])
                est = max(54, min(132, len(text) * 8 + 24))
                if used and used + est > max_width:
                    row = tk.Frame(parent, bg="#101119")
                    row.pack(anchor="w", fill="x")
                    used = 0
                tk.Label(
                    row, text=f"  {text}  ",
                    bg=bg, fg=fg,
                    font=("Segoe UI", 7, "bold"),
                    anchor="center",
                ).pack(side="left", padx=(0, 5), pady=(0, 4))
                used += est + 6
        except Exception:
            pass

    def _rp_resolution_sort_key(self, resolution):
        r = str(resolution or "").strip().upper()
        if not r:
            return (2, 9999, "")
        match = re.search(r"(\d+(?:\.\d+)?)\s*K", r)
        if match:
            try:
                return (0, float(match.group(1)), r)
            except Exception:
                pass
        if r in ("ANY", "N/A", "NA"):
            return (2, 9999, r)
        return (1, 9999, r)

    def _rp_update_asset_badges(self, parsed_files):
        try:
            format_badges = []
            seen_formats = set()
            resolutions = []

            for pf in (parsed_files or []):
                fmt = str(pf.get("format", "") or "").strip()
                if fmt and fmt.lower() not in seen_formats:
                    seen_formats.add(fmt.lower())
                    format_badges.append({
                        "text": fmt,
                        "bg": "#1B1E28",
                        "fg": "#C8D0E0",
                    })

                res = str(pf.get("resolution", "") or "").strip()
                if res and res.upper() not in ("ANY", "N/A", "NA") and res not in resolutions:
                    resolutions.append(res)

            resolution_badges = [
                {"text": res, "bg": "#1B1E28", "fg": "#C8D0E0"}
                for res in sorted(resolutions, key=self._rp_resolution_sort_key)
            ]

            self._rp_render_badges(self._rp_format_badges, format_badges)
            self._rp_render_badges(self._rp_resolution_badges, resolution_badges)
        except Exception:
            pass

    def _rp_guess_frames(self, file_info):
        return _panel_format_frame_display(_panel_guess_frame_count_int(file_info))

    def _show_asset_in_right_panel(self, asset):
        try:
            a = asset.get("attributes", asset)
            title = a.get("Title", a.get("title", "Unknown"))
            aid = asset.get("id", 0)

            if not self._right_panel_open:
                self._grid_sidebar_repaint_hold = True
                self._right_panel_open = True
                self._right_panel.pack(side="right", fill="y")
                self._right_panel.configure(width=getattr(self, "_right_panel_width", 320))
                self.update_idletasks()
                self._grid_sidebar_repaint_hold = False
                self._maybe_flush_deferred_grid_thumbnails()
                self.after_idle(self._maybe_reflow_grid_columns)

            self._rp_current_asset = asset
            self._rp_title_lbl.configure(text=title)
            self._refresh_favorite_ui(asset)

            files = a.get("files", []) or []
            parsed = [parse_file_url(f) for f in files if f]
            _rp_cat_slug = _cgheven_relation_slug((asset.get("attributes", asset) or {}).get("categorie") or {})
            if _rp_cat_slug == "3d-models":
                # GLTF/GLB are not supported by DaVinci Resolve Fusion — exclude them.
                # Show ZIP (auto-extracted → FBX/OBJ/ABC), FBX, OBJ, Alembic, etc.
                parsed = [pf for pf in parsed
                          if str(pf.get("format", "")).upper() not in ("GLTF", "GLB")]
            self._rp_parsed_files = parsed
            self._rp_update_asset_badges(parsed)

            for w in self._rp_tags_frame.winfo_children():
                w.destroy()
            cat_data = a.get("categorie")
            if isinstance(cat_data, dict):
                cat_name = cat_data.get("Name", cat_data.get("name", ""))
                if cat_name:
                    tk.Label(self._rp_tags_frame, text=f"  {cat_name[:14]}  ",
                             bg=C["accent_soft"], fg=C["text"],
                             font=("Segoe UI", 7, "bold")).pack(side="left", padx=(0, 4))

            default_file = self._pick_download_default_file(asset, parsed)
            default_fmt = str((default_file or {}).get("format", "") or "")
            default_res = str((default_file or {}).get("resolution", "") or "")
            unique_formats = []
            if default_fmt:
                unique_formats.append(default_fmt)
            for pf in parsed:
                fmt = str(pf.get("format", "Unknown") or "").strip()
                if fmt and fmt not in unique_formats:
                    unique_formats.append(fmt)
            # Fully locked = no free file exists (patreon-locked, or every option is a CGHLOCKED
            # premium marker) → Upgrade. Otherwise combos stay enabled; each individual locked
            # entry shows its own Upgrade via the format/resolution change handler.
            _rp_pat_locked = self._locked_for_user(asset)
            # Additive premium nudge: asset whose 4K/8K + EXR were stripped, but it is NOT fully
            # locked (so the free resolution still downloads).
            try:
                if self._premium_nudge_for_user(asset) and not _rp_pat_locked:
                    self._rp_premium_nudge.pack(fill="x", pady=(0, 4))
                else:
                    self._rp_premium_nudge.pack_forget()
            except Exception:
                pass
            if _rp_pat_locked:
                self._rp_fmt_combo.configure(values=[], state="disabled")
                self._rp_res_combo.configure(values=[], state="disabled")
                self._rp_download_btn.configure(
                    text="Upgrade", bg=C["cta"], fg="white",
                    activebackground=C["cta_h"], activeforeground="white",
                    state="normal", command=self._open_upgrade_url,
                )
                self._rp_download_btn._download_record = None
                self._rp_selected_file = None
                for attr in ("_rp_size_lbl", "_rp_frames_lbl"):
                    getattr(self, attr).configure(text="—")
            elif unique_formats:
                self._rp_fmt_combo.configure(values=unique_formats, state="readonly")
                if default_fmt and default_fmt in unique_formats:
                    self._rp_fmt_combo.set(default_fmt)
                else:
                    self._rp_fmt_combo.current(0)
                self._rp_on_format_combo_change(preferred_resolution=default_res)
            else:
                self._rp_fmt_combo.configure(values=[], state="disabled")
                self._rp_res_combo.configure(values=[], state="disabled")
                self._rp_download_btn.configure(text="No files", bg=C["dim"], state="disabled")
                self._rp_selected_file = None
                for attr in ("_rp_size_lbl", "_rp_frames_lbl"):
                    getattr(self, attr).configure(text="—")

            thumb_url = a.get("thumbnail", "")
            if thumb_url and PIL_AVAILABLE:
                def _load_rp_thumb(url=thumb_url, aid2=aid):
                    try:
                        import io
                        data = self._load_thumbnail_safe(url, timeout=10)
                        if data:
                            img = Image.open(io.BytesIO(data)).copy()
                            self.after_idle(lambda i=img: self._rp_set_preview_image(i))
                    except Exception:
                        pass
                threading.Thread(target=_load_rp_thumb, daemon=True).start()
            else:
                cached_photo = self.thumbnails.get(aid)
                if cached_photo and PIL_AVAILABLE:
                    self._rp_preview_lbl.configure(image=cached_photo, text="")
                    self._rp_preview_lbl.image = cached_photo
                else:
                    self._rp_preview_lbl.configure(text="No Preview", image="")

            raw_preview = (a.get("previews") or a.get("preview") or
                           a.get("Preview") or a.get("preview_url") or "")
            video_url = ""
            if isinstance(raw_preview, str) and raw_preview.strip():
                video_url = self._normalize_media_url(raw_preview.strip())
            elif isinstance(raw_preview, list) and raw_preview:
                video_url = self._normalize_media_url(str(raw_preview[0]).strip())

            # Keep inline preview strictly video-only (webm/mp4/mov).
            if video_url:
                vext = os.path.splitext(video_url.split("?")[0])[1].lower()
                if vext not in (".webm", ".mp4", ".mov"):
                    print(f"[RP DEBUG] aid={aid} preview URL is non-video ({vext or 'unknown'})")
                    video_url = ""
            if not video_url:
                for f in (a.get("files") or []):
                    furl = self._normalize_media_url(str(f))
                    fext = os.path.splitext(furl.split("?")[0])[1].lower()
                    if fext in (".webm", ".mp4", ".mov"):
                        video_url = furl
                        print(f"[RP DEBUG] aid={aid} using fallback video from files ({fext})")
                        break

            self._rp_video_url = video_url if video_url else None
            self._rp_preview_request_id = int(getattr(self, "_rp_preview_request_id", 0) or 0) + 1
            current_req = self._rp_preview_request_id
            print(f"[RP DEBUG] aid={aid} preview_url={'YES' if self._rp_video_url else 'NO'} raw_type={type(raw_preview).__name__} req={current_req}")
            self._rp_preview_lbl.configure(
                cursor="hand2" if self._rp_video_url else "arrow")

            # Autoplay inline in right panel
            try:
                self._rp_stop_inline_video()
            except Exception:
                pass
            if self._rp_video_url:
                self._rp_preview_lbl.configure(text="⏳ Loading preview...", fg=C["dim"])
                print(f"[RP DEBUG] Starting inline preview for aid={aid}: {self._rp_video_url[:140]} req={current_req}")
                self.after(120, lambda req=current_req: self._rp_start_inline_video(autoplay=True, request_id=req))
            else:
                print(f"[RP DEBUG] No preview URL for aid={aid}")
                self._rp_preview_lbl.configure(text="▶  No preview available", fg=C["dim"])

            try:
                if ANALYTICS_AVAILABLE and _analytics and getattr(self, "analytics_distinct_id", ""):
                    _analytics.send_asset_viewed(
                        license_server=LICENSE_SERVER,
                        addon_slug=CGHEVEN_ADDON_SLUG,
                        distinct_id=self.analytics_distinct_id,
                        asset=asset,
                        view_source="right_panel",
                    )
            except Exception:
                pass

        except Exception as e:
            print(f"[RIGHT PANEL ERROR] {e}")

    def _rp_set_preview_image(self, img):
        try:
            if not self._rp_preview_lbl.winfo_exists():
                return
            self._rp_preview_lbl.update_idletasks()
            tw = max(1, int(self._rp_preview_lbl.winfo_width() or 296))
            th = max(1, int(self._rp_preview_lbl.winfo_height() or 242))
            fitted = self._resize_image_contain(img, tw, th, bg="#000000")
            photo = ImageTk.PhotoImage(fitted, master=self)
            self._rp_preview_lbl.configure(image=photo, text="")
            self._rp_preview_lbl.image = photo
        except Exception:
            pass

    def _rp_play_video(self):
        # Toggle inline preview playback (no popup)
        try:
            lbl = getattr(self, "_rp_preview_lbl", None)
            if not lbl or not lbl.winfo_exists():
                return
            if getattr(lbl, "video_playing", False):
                self._rp_stop_inline_video()
            else:
                self._rp_start_inline_video(
                    autoplay=True,
                    request_id=getattr(self, "_rp_preview_request_id", None),
                )
        except Exception:
            pass

    def _rp_cancel_autoplay_timer(self):
        job = getattr(self, "_rp_autoplay_job", None)
        if job:
            try:
                self.after_cancel(job)
            except Exception:
                pass
            self._rp_autoplay_job = None

    def _rp_schedule_autoplay(self):
        # Legacy hook (popup autoplay removed)
        self._rp_cancel_autoplay_timer()

    def _rp_run_scheduled_autoplay(self):
        # Legacy hook (popup autoplay removed)
        self._rp_autoplay_job = None

    def _rp_stop_inline_video(self):
        try:
            lbl = getattr(self, "_rp_preview_lbl", None)
            if not lbl or not lbl.winfo_exists():
                return
            lbl.video_playing = False
            aid = getattr(lbl, "video_animation_id", None)
            if aid:
                try:
                    self.after_cancel(aid)
                except Exception:
                    pass
            lbl.video_animation_id = None
            if hasattr(lbl, "video_photos"):
                lbl.video_photos = []
        except Exception:
            pass

    def _rp_start_inline_video(self, autoplay=True, request_id=None):
        try:
            self._rp_stop_inline_video()
            if not (self._rp_video_url and self._rp_preview_lbl and self._rp_preview_lbl.winfo_exists()):
                print("[RP DEBUG] Inline preview skipped: missing video URL or preview label")
                return
            if request_id is not None and int(request_id) != int(getattr(self, "_rp_preview_request_id", -1)):
                print(f"[RP DEBUG] Inline preview skipped: stale request req={request_id}")
                return
            active_req = request_id if request_id is not None else int(getattr(self, "_rp_preview_request_id", -1))
            running_req = getattr(self, "_rp_inline_preview_running_req", None)
            if running_req is not None and int(running_req) == int(active_req):
                print(f"[RP DEBUG] Inline preview skipped: already running req={active_req}")
                return
            video_url = self._rp_video_url
            lbl = self._rp_preview_lbl
            self._rp_inline_preview_running_req = active_req
            print(f"[RP DEBUG] Inline preview thread start: {video_url[:140]} req={request_id}")

            def _bg():
                try:
                    frames = self._extract_video_frames(video_url, max_frames=40, fps=10)
                    print(f"[RP DEBUG] Extracted frames: {len(frames) if frames else 0}")
                    if request_id is not None and int(request_id) != int(getattr(self, "_rp_preview_request_id", -1)):
                        print(f"[RP DEBUG] Discarding frames from stale request req={request_id}")
                        return
                    if not frames:
                        self.after(0, lambda: lbl.configure(
                            text="▶  Preview unavailable", fg=C["dim"]) if lbl.winfo_exists() else None)
                        return
                    # Resize frames to fit the preview label
                    try:
                        lbl.update_idletasks()
                        tw = int(lbl.winfo_width() or 360)
                        th = int(lbl.winfo_height() or 242)
                        resized = []
                        for im in frames:
                            try:
                                imr = self._resize_image_contain(im, tw, th, bg="#000000")
                                resized.append(imr)
                            except Exception:
                                resized.append(im)
                        frames2 = resized
                    except Exception:
                        frames2 = frames

                    def _start():
                        try:
                            if not lbl.winfo_exists():
                                return
                            lbl.configure(text="", image="")
                            lbl.video_playing = True
                            print("[RP DEBUG] Inline preview animation started")
                            self._animate_video_preview(lbl, lbl, frames2, 0)
                        except Exception:
                            pass
                    self.after(0, _start)
                except Exception as e:
                    print(f"[RP INLINE PREVIEW ERROR] {e}")
                    self.after(0, lambda: lbl.configure(
                        text="▶  Preview error", fg=C["dim"]) if lbl.winfo_exists() else None)
                finally:
                    try:
                        running_req = getattr(self, "_rp_inline_preview_running_req", None)
                        if running_req is not None and int(running_req) == int(active_req):
                            self._rp_inline_preview_running_req = None
                    except Exception:
                        pass

            if autoplay:
                if getattr(self, "resolve", None):
                    # Resolve embedded Python is more reliable when preview extraction
                    # stays on the main thread, like popup preview already does.
                    self.after(40, _bg)
                else:
                    threading.Thread(target=_bg, daemon=True).start()
        except Exception:
            pass

    def _rp_update_file_info(self, file_info):
        try:
            if not file_info:
                for attr in ("_rp_size_lbl", "_rp_frames_lbl"):
                    getattr(self, attr).configure(text="—")
                return

            key = str(file_info.get("url", "") or "")
            cached = self._media_meta_cache.get(key)
            fc_guess = _panel_guess_frame_count_int(file_info)
            est_bytes = _panel_estimate_asset_bytes(file_info, fc_guess)
            size_text = f"~{fmt_bytes(est_bytes)} est." if est_bytes > 0 else "—"
            frames_text = _panel_format_frame_display(fc_guess)
            if cached:
                size_text = cached.get("size", size_text)
                frames_text = cached.get("frames", frames_text)
            self._rp_size_lbl.configure(text=size_text)
            self._rp_frames_lbl.configure(text=frames_text)

            def _bg():
                try:
                    url = str(file_info.get("url", "") or "")
                    if not url:
                        return
                    ext = str(file_info.get("ext", "") or "").lower()
                    ffprobe = ""
                    if RESOLVE_FFMPEG:
                        probe_name = "ffprobe.exe" if sys.platform == "win32" else "ffprobe"
                        probe_path = os.path.join(os.path.dirname(RESOLVE_FFMPEG), probe_name)
                        if os.path.exists(probe_path):
                            ffprobe = probe_path
                    if not ffprobe:
                        ffprobe = "ffprobe"

                    fc_probe = None
                    if ext in (".mp4", ".mov", ".webm", ".m4v"):
                        fc_probe = _panel_ffprobe_frame_count(url, ffprobe)

                    fc_final = fc_probe if fc_probe and fc_probe > 0 else fc_guess
                    est2 = _panel_estimate_asset_bytes(file_info, fc_final) if fc_final else est_bytes

                    size_bytes = int(file_info.get("size_bytes", 0) or 0)
                    if size_bytes <= 0:
                        size_bytes = self._file_size_cache.get(url) or 0
                    if size_bytes <= 0:
                        size_bytes = fetch_file_size(url)
                        if size_bytes > 0:
                            self._file_size_cache[url] = size_bytes

                    if size_bytes > 0:
                        size_disp = fmt_bytes(size_bytes)
                    elif est2 > 0:
                        size_disp = f"~{fmt_bytes(est2)} est."
                    else:
                        size_disp = "—"

                    if fc_probe and fc_probe > 0:
                        frames_disp = _panel_format_frame_display(fc_probe)
                    elif fc_final and fc_final > 0:
                        frames_disp = _panel_format_frame_display(fc_final)
                    else:
                        frames_disp = _panel_format_frame_display(fc_guess)

                    meta = {"size": size_disp, "frames": frames_disp}
                    self._media_meta_cache[url] = meta
                    self.after(0, lambda m=meta, u=url: self._rp_apply_async_media_meta(u, m))
                except Exception:
                    pass

            threading.Thread(target=_bg, daemon=True).start()
        except Exception:
            pass

    def _rp_apply_async_media_meta(self, url, meta):
        try:
            current = getattr(self, "_rp_selected_file", {}) or {}
            if str(current.get("url", "") or "") != str(url or ""):
                return
            self._rp_size_lbl.configure(text=meta.get("size", "—"))
            self._rp_frames_lbl.configure(text=meta.get("frames", "—"))
        except Exception:
            pass

    def _rp_update_action_button(self, file_info):
        try:
            asset = getattr(self, "_rp_current_asset", None)
            if not asset or not file_info:
                self._rp_download_btn.configure(text="Choose a file", bg=C["dim"], state="disabled",
                                                command=self._rp_commit_panel_download)
                return
            aid = asset.get("id", 0)
            res = str(file_info.get("resolution", "") or "")
            ext = str(file_info.get("ext", "") or "").lower()
            # Gating decided ONLY by the backend: this exact file is a CGHLOCKED premium marker
            # (no real URL), or the whole asset is patreon-locked → Upgrade. Free files download
            # with NO login.
            _is_locked = bool(file_info.get("locked")) or self._locked_for_user(asset)
            if _is_locked:
                self._rp_download_btn.configure(
                    text="Upgrade",
                    bg=C["cta"], fg="white",
                    activebackground=C["cta_h"], activeforeground="white",
                    state="normal",
                    command=self._open_upgrade_url,
                )
                self._rp_download_btn._download_record = None
                return
            downloaded = self._get_downloaded_file_for_asset(aid, res=res, fmt_ext=ext)
            if downloaded:
                self._rp_download_btn.configure(
                    text="Import to DaVinci",
                    bg="#172A1A", fg="#34C759",
                    activebackground="#1E3A22", activeforeground="#34C759",
                    state="normal",
                    command=self._rp_commit_panel_download,
                )
                self._rp_download_btn._download_record = downloaded
            else:
                self._rp_download_btn.configure(
                    text="Download",
                    bg=C["accent"], fg="white",
                    activebackground=C["accent2"], activeforeground="white",
                    state="normal",
                    command=self._rp_commit_panel_download,
                )
                self._rp_download_btn._download_record = None
        except Exception:
            pass

    def _rp_on_format_combo_change(self, preferred_resolution=None):
        try:
            fmt = self._rp_fmt_combo.get()
            parsed = getattr(self, "_rp_parsed_files", []) or []
            matching = [
                pf for pf in parsed
                if fmt.lower() in (pf.get("format") or "").lower()
                or (pf.get("format") or "").lower() in fmt.lower()
            ]
            resolutions = []
            for pf in matching:
                res = pf.get("resolution", "") or "Any"
                if res not in resolutions:
                    resolutions.append(res)
            resolutions = sorted(
                resolutions,
                key=lambda res: self._rp_resolution_sort_key("" if str(res or "").strip() == "Any" else res),
            )
            current_res = preferred_resolution or self._rp_res_combo.get()
            if resolutions:
                self._rp_res_combo.configure(values=resolutions, state="readonly")
                if current_res in resolutions:
                    self._rp_res_combo.set(current_res)
                else:
                    self._rp_res_combo.current(0)
            else:
                self._rp_res_combo.configure(values=["Any"], state="readonly")
                self._rp_res_combo.current(0)
            wanted_res = self._rp_res_combo.get() or "Any"
            selected = None
            for pf in matching:
                if (pf.get("resolution", "") or "Any") == wanted_res:
                    selected = pf
                    break
            if not selected and matching:
                selected = matching[0]
            self._rp_selected_file = selected
            self._rp_update_file_info(selected)
            self._rp_update_action_button(selected)
        except Exception:
            pass

    def _rp_commit_panel_download(self):
        asset = getattr(self, "_rp_current_asset", None)
        if not asset:
            return
        fmt = self._rp_fmt_combo.get()
        if not fmt:
            messagebox.showwarning("Format", "Please choose a format.", parent=self)
            return
        res = self._rp_res_combo.get() or "Any"
        proxy = self._rp_dl_proxy
        proxy.asset = asset
        proxy.parsed_files = list(getattr(self, "_rp_parsed_files", []) or [])
        proxy.selected_format = fmt
        proxy.selected_resolution = "" if res in ("Any", "") else res
        proxy.selected_file = None
        self._find_and_set_selected_file(proxy)
        if not getattr(proxy, "selected_file", None):
            messagebox.showwarning(
                "Resolution",
                "No file matched that format/resolution. Try another resolution.",
                parent=self,
            )
            return
        dl_record = getattr(self._rp_download_btn, "_download_record", None)
        if dl_record:
            self._manual_import_to_resolve(dl_record)
            return
        try:
            self._rp_fmt_combo.configure(state="disabled")
            self._rp_res_combo.configure(state="disabled")
            self._rp_download_btn.configure(state="disabled")
        except Exception:
            pass
        try:
            self._rp_download_btn.pack_forget()
            self._rp_progress_host.pack(fill="x", pady=(8, 0))
            self._rp_selection_row.pack_forget()
        except Exception:
            pass
        self._start_download(asset, proxy)

    def _rp_restore_panel_after_download(self):
        try:
            self._rp_progress_host.pack_forget()
            for w in list(self._rp_selection_row.winfo_children()):
                try:
                    w.destroy()
                except Exception:
                    pass
            self._rp_selection_row.pack_forget()
        except Exception:
            pass
        try:
            if not self._rp_download_btn.winfo_manager():
                self._rp_download_btn.pack(fill="x", pady=(0, 4), ipady=0)
            self._rp_fmt_combo.configure(state="readonly")
            self._rp_res_combo.configure(state="readonly")
            self._rp_on_format_combo_change()
        except Exception:
            pass

    def _compute_grid_columns(self):
        try:
            w = int(self.grid_canvas.winfo_width() or 0)
        except Exception:
            w = 0
        if w < 120:
            try:
                w = max(420, int(self._center_wrap.winfo_width() or 800) - 120)
            except Exception:
                w = 800
        _cached_w = getattr(self, "_grid_cols_last_w", -1)
        if w < 120 or w != _cached_w:
            try:
                self.update_idletasks()
                w = int(self.grid_canvas.winfo_width() or w)
            except Exception:
                pass
            self._grid_cols_last_w = w
        right_open = bool(
            getattr(self, "_right_panel_open", False)
            and getattr(self, "_right_panel", None)
            and self._right_panel.winfo_ismapped()
        )
        choices = tuple(getattr(self, "_grid_choices", (3, 4, 5, 6)))
        max_cols = max(choices) if choices else 6
        min_cols = min(choices) if choices else 3
        if bool(getattr(self, "grid_auto_mode", True)):
            # Column *count* from a virtual width: real grid width + preview strip, so
            # opening/closing the right panel does not change N columns (avoids full
            # grid destroy + scroll jump). Card size still uses real width below.
            w_for_cols = w
            if right_open:
                try:
                    rp = getattr(self, "_right_panel", None)
                    rw = 0
                    if rp and rp.winfo_ismapped():
                        rw = int(rp.winfo_width() or 0)
                    if rw < 1:
                        rw = int(getattr(self, "_right_panel_width", 0) or 0)
                    w_for_cols = w + max(0, rw)
                except Exception:
                    try:
                        w_for_cols = w + int(getattr(self, "_right_panel_width", 0) or 0)
                    except Exception:
                        w_for_cols = w
            # Same banding as the old "panel hidden" case (3–5 cols by width).
            cols = 5 if w_for_cols >= 1080 else (4 if w_for_cols >= 860 else 3)
        else:
            try:
                cols = int(self.grid_cols_pref.get() or 4)
            except Exception:
                cols = 4
        cols = max(min_cols, min(max_cols, cols))
        self._update_grid_menu_label(cols)
        return cols

    def _card_pixel_size(self, cols=None):
        try:
            cols = int(cols or self._compute_grid_columns())
            width = int(self.grid_canvas.winfo_width() or 980)
            gap = 16
            usable = max(320, width - (gap * (cols + 1)))
            return max(120, min(320, usable // max(cols, 1)))
        except Exception:
            return 220

    def _maybe_reflow_grid_columns(self):
        try:
            if not getattr(self, "filtered", None):
                return
            new_c = self._compute_grid_columns()
            if new_c != getattr(self, "_grid_cols", -1):
                # Reflow (e.g. preview sidebar open/close) rebuilds the grid; preserve
                # scroll and selection to avoid a jarring jump / torn card highlight.
                self._begin_grid_pan_preserve()
                self._render_grid()
                self._schedule_grid_pan_and_selection_resync()
        except Exception:
            pass

    def _begin_grid_pan_preserve(self):
        self._grid_pan_resync_active = True
        try:
            cv = getattr(self, "grid_canvas", None)
            if cv and cv.winfo_exists():
                lo, _hi = cv.yview()
                self._grid_restore_yview_lo = max(0.0, min(1.0, float(lo)))
        except Exception:
            self._grid_restore_yview_lo = None

    def _schedule_grid_pan_and_selection_resync(self):
        self._grid_pan_resync_token = int(getattr(self, "_grid_pan_resync_token", 0) + 1)
        tok = self._grid_pan_resync_token
        for ms in (20, 80, 200, 400):
            self.after(ms, lambda t=tok, m=ms: self._apply_grid_pan_and_selection_resync(t))

    def _apply_grid_pan_and_selection_resync(self, token):
        if int(token) != int(getattr(self, "_grid_pan_resync_token", -1)):
            return
        lo = getattr(self, "_grid_restore_yview_lo", None)
        if lo is not None:
            try:
                cv = getattr(self, "grid_canvas", None)
                if cv and cv.winfo_exists():
                    cv.yview_moveto(max(0.0, min(1.0, float(lo))))
            except Exception:
                pass
        self._restore_grid_card_selection()

    def _restore_grid_card_selection(self):
        """Re-highlight the asset card after a full grid rebuild (same id as right panel)."""
        a = getattr(self, "_rp_current_asset", None) or getattr(self, "selected", None)
        if not a:
            return
        try:
            aid = a.get("id", 0)
        except Exception:
            return
        if not aid:
            return
        try:
            for c in self.grid_inner.winfo_children():
                if not isinstance(c, tk.Frame):
                    continue
                if getattr(c, "asset_id", None) == aid:
                    self._set_selected_card(c)
                    return
        except Exception:
            pass

    def _rp_manual_import(self, asset):
        aid = asset.get("id", 0)
        for dl in reversed(self.completed_downloads):
            a = (dl.get("asset") or {})
            if a.get("id", 0) == aid:
                self._manual_import_to_resolve(dl)
                return

    def _card_import_asset(self, asset, card=None):
        """Import the most recently downloaded file for this asset to DaVinci Resolve."""
        try:
            aid = asset.get("id", 0)
            for dl in reversed(self.completed_downloads):
                a = dl.get("asset") or {}
                if a.get("id", 0) == aid:
                    filepath = dl.get("filepath", "")
                    if filepath and os.path.exists(filepath):
                        self._manual_import_to_resolve(dl)
                        return
                    else:
                        # File path exists in records but not on disk
                        messagebox.showwarning(
                            "File Not Found",
                            f"Downloaded file not found on disk.\n"
                            f"Check 'My Downloads' tab to re-download.",
                            parent=self)
                        return
            messagebox.showinfo(
                "Not Downloaded",
                "This asset has not been downloaded yet.\n"
                "Click Download to get it first.",
                parent=self)
        except Exception as e:
            print(f"[CARD IMPORT ERROR] {e}")

    def _get_downloaded_file_for_asset(self, aid, res=None, fmt_ext=None):
        """Return dl_info if asset (optionally with specific res/ext) was downloaded."""
        for dl in reversed(self.completed_downloads):
            a = dl.get("asset") or {}
            if a.get("id", 0) != aid:
                continue
            if res is None and fmt_ext is None:
                return dl
            fi = dl.get("file_info") or {}
            dl_res = str(fi.get("resolution", "") or "").strip().upper()
            dl_ext = str(fi.get("ext", "") or "").lower()
            if res is not None and dl_res != str(res).strip().upper():
                continue
            if fmt_ext is not None and dl_ext != str(fmt_ext).lower():
                continue
            return dl
        return None

    def _set_selected_card(self, selected_card):
        for card in self.grid_inner.winfo_children():
            if isinstance(card, tk.Frame) and hasattr(card, "asset_id"):
                if card is selected_card:
                    card.configure(highlightbackground=C["accent"], highlightthickness=1)
                    card._is_selected = True
                else:
                    card.configure(highlightbackground=C["border"], highlightthickness=1)
                    card._is_selected = False
                    try:
                        if hasattr(card, "_btn_outer"):
                            card._btn_outer.configure(bg=C["dl_border"])
                    except Exception:
                        pass

    def _build_titlebar(self):
        pass  # Merged into _build_search_bar

    # ── About / Update Section ─────────────────────────────────────
    def _build_about_section(self, parent):
        """
        Sidebar mein version info + update status card.
        Update available hone pe red/orange badge dikhata hai
        aur 'Install Update' button show karta hai.
        """
        outer = tk.Frame(parent, bg=C["sidebar"])
        outer.pack(side="bottom", fill="x", padx=8, pady=(4, 6))

        # Clickable header row
        hdr_row = tk.Frame(outer, bg=C["sidebar"], cursor="hand2")
        hdr_row.pack(fill="x")

        about_lbl = tk.Label(
            hdr_row, text="ℹ  About",
            bg=C["sidebar"], fg=C["text2"],
            font=("Segoe UI", 9, "bold"), anchor="w",
        )
        about_lbl.pack(side="left", padx=8, pady=4)

        # Toggle arrow
        self._about_open   = False
        self._about_arrow  = tk.Label(
            hdr_row, text="▸",
            bg=C["sidebar"], fg=C["dim"],
            font=("Segoe UI", 8),
        )
        self._about_arrow.pack(side="right", padx=6)

        # Collapsible card
        self._about_card = tk.Frame(
            outer, bg=C["card"],
            highlightbackground=C["border"], highlightthickness=1,
        )
        # Hidden by default — expand on click

        # Version row inside card
        ver_inner = tk.Frame(self._about_card, bg=C["card"])
        ver_inner.pack(fill="x", padx=10, pady=(8, 4))

        tk.Label(ver_inner, text="Version :", bg=C["card"],
                 fg=C["text2"], font=("Segoe UI", 8), width=10, anchor="w").pack(side="left")
        tk.Label(ver_inner, text=f"v{ADDON_VER}", bg=C["card"],
                 fg=C["text"], font=("Segoe UI", 8, "bold"), anchor="w").pack(side="left")

        # Update status label (dynamic — refreshed after check)
        upd_row = tk.Frame(self._about_card, bg=C["card"])
        upd_row.pack(fill="x", padx=10, pady=(0, 4))

        tk.Label(upd_row, text="Update :", bg=C["card"],
                 fg=C["text2"], font=("Segoe UI", 8), width=10, anchor="w").pack(side="left")
        self._upd_status_lbl = tk.Label(
            upd_row, text="Checking…",
            bg=C["card"], fg=C["dim"],
            font=("Segoe UI", 8), anchor="w",
        )
        self._upd_status_lbl.pack(side="left")

        # Install Update button — hidden until update available
        self._upd_btn = tk.Button(
            self._about_card,
            text="  🔄  Install Update  →",
            bg="#FF8C00", fg="white",
            relief="flat", cursor="hand2",
            font=("Segoe UI", 9, "bold"), pady=5,
            command=self._on_sidebar_install_update,
        )
        # Packed dynamically when update available

        # Manual check button (always available if update module loaded)
        self._chk_btn = tk.Button(
            self._about_card,
            text="  ⟳  Check Updates",
            bg=C["card"], fg=C["text2"],
            relief="flat", cursor="hand2",
            font=("Segoe UI", 9), pady=5,
            command=self._on_sidebar_check_updates,
        )
        self._chk_btn.pack(fill="x", padx=8, pady=(2, 6))

        # Separator
        tk.Frame(self._about_card, bg=C["border"], height=1).pack(
            fill="x", padx=8, pady=(4, 6)
        )

        # cgheven.com link
        link_lbl = tk.Label(
            self._about_card, text="🌐  cgheven.com",
            bg=C["card"], fg=C["dim"],
            font=("Segoe UI", 8), cursor="hand2",
        )
        link_lbl.pack(anchor="w", padx=10, pady=(0, 8))
        link_lbl.bind("<Button-1>", lambda e: webbrowser.open("https://cgheven.com"))

        # Toggle expand/collapse
        def _toggle(e=None):
            self._about_open = not self._about_open
            if self._about_open:
                self._about_card.pack(fill="x", pady=(2, 0))
                self._about_arrow.config(text="▾")
                self._refresh_update_status_label()
            else:
                self._about_card.pack_forget()
                self._about_arrow.config(text="▸")

        for w in (hdr_row, about_lbl, self._about_arrow):
            w.bind("<Button-1>", _toggle)

    def _refresh_update_status_label(self):
        """Refresh the update status label on the About card."""
        if not hasattr(self, "_upd_status_lbl"):
            return
        if not UPDATE_MODULE_AVAILABLE or _auto_update is None:
            try:
                self._upd_status_lbl.config(text="N/A", fg=C["dim"])
            except Exception:
                pass
            return
        try:
            st = _auto_update.state
            if st.update_checking:
                self._upd_status_lbl.config(text="Checking…", fg=C["dim"])
                self._upd_btn.pack_forget()
            elif st.update_available:
                self._upd_status_lbl.config(
                    text=f"v{st.update_version} available!", fg="#FFD700"
                )
                self._upd_btn.pack(fill="x", padx=8, pady=(0, 6))
            elif st.update_installed:
                self._upd_status_lbl.config(
                    text="Installed — restart Resolve", fg="#4CAF50"
                )
                self._upd_btn.pack_forget()
            else:
                self._upd_status_lbl.config(
                    text=f"v{ADDON_VER} — Up to date ✓", fg="#4CAF50"
                )
                self._upd_btn.pack_forget()
        except Exception as e:
            print(f"[UPDATE] Status label error: {e}")

    def _on_sidebar_install_update(self):
        """Sidebar 'Install Update' button press."""
        if UPDATE_MODULE_AVAILABLE and _auto_update:
            _auto_update._start_install_with_ui(self)
            # After install, refresh label
            self.after(1000, self._refresh_update_status_label)

    def _on_sidebar_check_updates(self):
        """Sidebar 'Check Updates' button press."""
        if not (UPDATE_MODULE_AVAILABLE and _auto_update):
            return
        tok = (getattr(self, "session_token", "") or "").strip()
        # Public check — guest bhi manually check kar sakta hai (auth optional).
        # Start threaded check, then refresh UI + show popup if needed
        if hasattr(self, "_upd_status_lbl"):
            try:
                self._upd_status_lbl.config(text="Checking…", fg=C["dim"])
            except Exception:
                pass
        _auto_update.run_check_threaded(
            session_token=tok,
            current_version=ADDON_VER,
            on_done=lambda: self.after(0, self._on_update_check_done),
        )

    # ── Category Tree ─────────────────────────────────────────────
    def _render_category_tree(self):
        for w in self.cat_frame.winfo_children():
            w.destroy()
        self._cat_btns = {}
        self._add_cat_row("all", "⌂  Home", 0, is_parent=False)

        all_child_slugs = set()
        for cat in self.categories:
            for child in cat.get("children", []):
                all_child_slugs.add(child.get("slug", ""))

        for cat in self.categories:
            slug     = cat["slug"]
            if slug in all_child_slugs:
                continue
            name     = cat["name"]
            count    = cat.get("count", "")
            children = cat.get("children", [])
            is_expanded = slug in self.expanded_categories

            if children:
                expand_icon = "▼" if is_expanded else "▶"
                self._add_cat_row(slug, f"{expand_icon}  {name}", 0,
                                  is_parent=True, count=count, has_children=True)
                if is_expanded:
                    for child in children:
                        self._add_cat_row(child["slug"], f"    · {child['name']}", 1,
                                          count=child.get("count", ""), is_parent=False)
            else:
                self._add_cat_row(slug, f"  {name}", 0, is_parent=False, count=count)

        tk.Frame(self.cat_frame, bg=C["border"], height=1).pack(fill="x", padx=10, pady=10)
        # Bottom actions (requested order)
        self._add_cat_row("__local__",     "⬇  Downloaded", 0, is_parent=False)
        self._add_cat_row("__favorites__", "♥  Favourite",  0, is_parent=False)
        self._add_cat_row("__license__",   "👤  My Account", 0, is_parent=False)
        self._add_cat_row("__settings__",  "⚙  Settings",   0, is_parent=False)
        self._highlight_cat(self.active_cat)

    def _add_cat_row(self, slug, text, indent, is_parent=False, count="", has_children=False):
        frame = tk.Frame(self.cat_frame, bg=C["sidebar"], cursor="hand2")
        frame.pack(fill="x", pady=0)
        disp = f"{'  ' * indent}{text}"
        if count:
            disp += f"  {count}"
        lbl = tk.Label(frame, text=disp, bg=C["sidebar"], fg=C["text2"],
                       font=("Segoe UI", 8), anchor="w", padx=8, pady=5)
        lbl.pack(fill="x")
        self._cat_btns[slug] = (frame, lbl)

        def click(e, s=slug, isp=is_parent, hc=has_children):
            if self.downloads_view_active:
                self._hide_downloads_view()
            if s == "__settings__":
                self._open_settings_menu(e)
                return
            if s == "__license__":
                self._show_license_info()
                return
            if s == "__favorites__":
                self.active_view = "favorites"
                self.active_cat = "all"
                self.current_page = 1
                self._highlight_cat(s)
                self._load_assets(page=1)
                return
            if s == "__local__":
                self.active_view = "local"
                self.active_cat = "all"
                self.current_page = 1
                self._highlight_cat(s)
                self._load_assets(page=1)
                return
            self.active_view = "home"
            if isp and hc:
                if s in self.expanded_categories:
                    self.expanded_categories.remove(s)
                else:
                    self.expanded_categories.add(s)
                self._render_category_tree()
            self.active_cat = s
            self.current_page = 1
            self._highlight_cat(s)
            # Reset free filter on category change
            self._free_filter_active = False
            try:
                self._update_filter_button_label()
            except Exception:
                pass
            # UX: show results from top
            try:
                self._scroll_grid_to_top(smooth=True)
            except Exception:
                pass
            try:
                if ANALYTICS_AVAILABLE and _analytics and getattr(self, "analytics_distinct_id", ""):
                    _analytics.send_category_event(
                        license_server=LICENSE_SERVER,
                        addon_slug=CGHEVEN_ADDON_SLUG,
                        distinct_id=self.analytics_distinct_id,
                        category_slug=s,
                        sub_category="",
                    )
            except Exception:
                pass
            self._load_assets()

        def enter(e, f=frame, l=lbl):
            if self.active_cat != slug:
                f.configure(bg=C["card"])
                l.configure(bg=C["card"])

        def leave(e, f=frame, l=lbl):
            if self.active_cat != slug:
                f.configure(bg=C["sidebar"])
                l.configure(bg=C["sidebar"])

        for w in (frame, lbl):
            w.bind("<Button-1>", click)
            w.bind("<Enter>",    enter)
            w.bind("<Leave>",    leave)
            # Bind scroll to category items
            if hasattr(self, "_cat_scroll_fn"):
                w.bind("<MouseWheel>", self._cat_scroll_fn)

    def _highlight_cat(self, slug):
        for s, (f, l) in self._cat_btns.items():
            target = slug
            if getattr(self, "active_view", "home") == "favorites":
                target = "__favorites__"
            elif getattr(self, "active_view", "home") == "local" and not self.downloads_view_active:
                target = "__local__"
            if s == target:
                f.configure(bg=C["cat_active"]); l.configure(bg=C["cat_active"], fg=C["text"])
            else:
                f.configure(bg=C["sidebar"]);    l.configure(bg=C["sidebar"],    fg=C["text2"])

    # ── Content Area (legacy — layout handled by _build_ui) ────────
    def _build_content(self, pane):
        pass

    def _build_topbar(self, parent):
        pass

    def _make_custom_scrollbar(self, parent, canvas, bg_color=None, thumb_color=None, auto_hide=False):
        """Ultra-thin custom scrollbar matching dark theme."""
        bg_c    = bg_color    or C["bg"]
        thumb_c = thumb_color or "#3A3A4A"
        bar = tk.Canvas(parent, width=6, bg=bg_c, highlightthickness=0, cursor="arrow")
        bar.pack(side="right", fill="y")

        thumb_id = bar.create_rectangle(0, 0, 6, 40, fill=thumb_c, outline="", tags="thumb")

        def _update_thumb(*args):
            try:
                lo, hi = float(args[0]), float(args[1])
                if auto_hide:
                    can_scroll = (hi - lo) < 0.999
                    if can_scroll and not bar.winfo_manager():
                        bar.pack(side="right", fill="y")
                    elif not can_scroll and bar.winfo_manager():
                        bar.pack_forget()
                bar_h = bar.winfo_height()
                if bar_h <= 0: return
                ty0 = int(lo * bar_h)
                ty1 = int(hi * bar_h)
                ty1 = max(ty0 + 16, ty1)
                bar.coords(thumb_id, 1, ty0, 5, ty1)
                bar.itemconfig(thumb_id, fill=thumb_c)
            except Exception:
                pass

        def _on_bar_click(e):
            bar_h = bar.winfo_height()
            if bar_h <= 0: return
            fraction = e.y / bar_h
            canvas.yview_moveto(fraction - 0.05)

        def _on_thumb_drag(e):
            bar_h = bar.winfo_height()
            if bar_h <= 0: return
            canvas.yview_moveto(e.y / bar_h)

        bar.bind("<Button-1>",  _on_bar_click)
        bar.bind("<B1-Motion>", _on_thumb_drag)
        bar.bind("<Enter>", lambda e: bar.itemconfig(thumb_id, fill=C["accent"]))
        bar.bind("<Leave>", lambda e: bar.itemconfig(thumb_id, fill=thumb_c))

        canvas.configure(yscrollcommand=_update_thumb)
        return bar

    def _grid_thumbs_paused(self):
        """Tk analog of Qt setUpdatesEnabled(False) for grid card thumbnails (scroll + sidebar layout)."""
        return bool(
            getattr(self, "_grid_scroll_suppress_thumbs", False)
            or getattr(self, "_grid_sidebar_repaint_hold", False)
        )

    def _is_card_in_viewport(self, aid):
        """True if the grid card for ``aid`` is in (or near) the visible canvas area."""
        try:
            card = self._grid_cards_by_id.get(aid)
            if not card or not card.winfo_exists():
                return True
            cv = getattr(self, "grid_canvas", None)
            if not cv or not cv.winfo_exists():
                return True
            cv_h = int(cv.winfo_height() or 600)
            ylo, yhi = cv.yview()
            sr = cv.cget("scrollregion")
            if not sr or sr in ("0 0 0 0", "0.0c 0.0c 0.0c 0.0c"):
                return True
            if isinstance(sr, str):
                parts = str(sr).replace("c", " ").split()
                total_h = float(parts[3]) if len(parts) >= 4 else float(cv_h)
            else:
                try:
                    total_h = float(sr[3]) if len(sr) >= 4 else float(cv_h)
                except (TypeError, ValueError, IndexError):
                    total_h = float(cv_h)
            view_top = ylo * total_h
            view_bottom = yhi * total_h
            card_y = int(card.winfo_y() or 0)
            card_h = int(card.winfo_height() or 1)
            card_bottom = card_y + card_h
            return card_bottom >= (view_top - cv_h) and card_y <= (view_bottom + cv_h)
        except Exception:
            return True

    def _deferred_thumbnails_merged(self):
        """Deduplicate deferred queues (same as full flush) before staggered or batch apply."""
        pending = list(getattr(self, "_thumb_deferred_batch", []) or [])
        self._thumb_deferred_batch = []
        by_buf = {}
        for aid, buf in pending:
            by_buf[aid] = buf
        b_items = list(by_buf.items())
        pend2 = list(getattr(self, "_thumb_deferred_set", []) or [])
        self._thumb_deferred_set = []
        by_buf_aids = set(by_buf.keys())
        by_photo = {}
        for aid, photo in pend2:
            by_photo[aid] = photo
        s_items = []
        for aid, photo in by_photo.items():
            if aid not in by_buf_aids:
                s_items.append((aid, photo))
        wlist = list(getattr(self, "_thumb_deferred_widgets", []) or [])
        self._thumb_deferred_widgets = []
        return b_items, s_items, wlist

    def _staggered_flush_deferred(self, batch_size=4, interval_ms=40):
        """Process deferred thumbnails in small batches; off-viewport work is re-queued (not lost)."""
        b_items, s_items, wlist = self._deferred_thumbnails_merged()

        def _process_chunk(b_chunk, s_chunk, w_chunk, skip_b, skip_s, skip_w):
            buf_aids_done = set()
            for aid, buf in b_chunk:
                if not self._is_card_in_viewport(aid):
                    skip_b.append((aid, buf))
                    continue
                try:
                    buf.seek(0)
                except Exception:
                    pass
                self._create_photo_from_image_batch(aid, buf)
                buf_aids_done.add(aid)
            for aid, photo in s_chunk:
                if not self._is_card_in_viewport(aid):
                    skip_s.append((aid, photo))
                    continue
                if aid in buf_aids_done:
                    continue
                self._set_thumb(aid, photo)
            for thlbl, photo in w_chunk:
                try:
                    if not thlbl.winfo_exists():
                        continue
                    th_aid = getattr(thlbl, "asset_id", None)
                    if th_aid is not None and not self._is_card_in_viewport(th_aid):
                        skip_w.append((thlbl, photo))
                        continue
                    self._display_thumb(thlbl, photo)
                except Exception:
                    pass

        def _tick(b_cur, s_cur, w_cur):
            if not b_cur and not s_cur and not w_cur:
                return
            skip_b, skip_s, skip_w = [], [], []
            b_chunk, b_cur = b_cur[:batch_size], b_cur[batch_size:]
            s_chunk, s_cur = s_cur[:batch_size], s_cur[batch_size:]
            w_chunk, w_cur = w_cur[:batch_size], w_cur[batch_size:]
            _process_chunk(b_chunk, s_chunk, w_chunk, skip_b, skip_s, skip_w)
            b_next = b_cur + skip_b
            s_next = s_cur + skip_s
            w_next = w_cur + skip_w
            if b_next or s_next or w_next:
                self.after(interval_ms, lambda: _tick(b_next, s_next, w_next))

        _tick(b_items, s_items, wlist)

    def _apply_deferred_grid_thumbnails(self):
        """Flush queued grid thumbnail work (call only when _grid_thumbs_paused() is False)."""
        b_items, s_items, wlist = self._deferred_thumbnails_merged()
        for aid, buf in b_items:
            try:
                buf.seek(0)
            except Exception:
                pass
            self._create_photo_from_image_batch(aid, buf)
        for aid, photo in s_items:
            self._set_thumb(aid, photo)
        for thlbl, photo in wlist:
            try:
                if thlbl.winfo_exists():
                    self._display_thumb(thlbl, photo)
            except Exception:
                pass

    def _maybe_flush_deferred_grid_thumbnails(self):
        if self._grid_thumbs_paused():
            return
        self._apply_deferred_grid_thumbnails()

    def _arm_grid_scroll_thumb_defer(self):
        """Pause thumbnail Label image updates while wheel is moving (reduces Win32 embed tearing)."""
        self._grid_scroll_suppress_thumbs = True
        job = getattr(self, "_grid_scroll_end_job", None)
        if job is not None:
            try:
                self.after_cancel(job)
            except Exception:
                pass
        self._grid_scroll_end_job = self.after(200, self._release_grid_scroll_thumb_defer)

    def _release_grid_scroll_thumb_defer(self):
        self._grid_scroll_end_job = None
        self._grid_scroll_suppress_thumbs = False
        self._staggered_flush_deferred(batch_size=4, interval_ms=40)
        self.after(0, self._schedule_scrollregion_update)
        try:
            tj = getattr(self, "_thumb_flush_job", None)
            if tj is not None:
                self.after_cancel(tj)
        except Exception:
            pass
        self._thumb_flush_job = None
        if getattr(self, "_thumb_queue", None):
            self._thumb_flush_job = self.after(60, self._flush_thumb_queue)

    def _grid_scroll_tick(self):
        """Coalesced yview: small steps per pass so host embed (Resolve) repaints less tear."""
        self._grid_scroll_tick_job = None
        if not hasattr(self, "grid_canvas"):
            return
        try:
            cv = self.grid_canvas
            if not cv or not cv.winfo_exists():
                return
        except Exception:
            return
        try:
            amt = int(getattr(self, "_grid_scroll_accum", 0) or 0)
        except Exception:
            amt = 0
        if not amt:
            return
        if not self._canvas_can_scroll_y(cv):
            self._grid_scroll_accum = 0
            return
        _max = 36
        if abs(amt) > _max:
            step = _max * (1 if amt > 0 else -1)
            self._grid_scroll_accum = amt - step
        else:
            step = amt
            self._grid_scroll_accum = 0
        try:
            if step:
                cv.yview_scroll(step, "units")
        except Exception:
            self._grid_scroll_accum = 0
        try:
            rem = int(getattr(self, "_grid_scroll_accum", 0) or 0)
        except Exception:
            rem = 0
        if rem:
            self._grid_scroll_tick_job = self.after(10, self._grid_scroll_tick)

    def _queue_grid_scroll(self, delta_steps):
        try:
            cv = getattr(self, "grid_canvas", None)
            if not cv or not self._canvas_can_scroll_y(cv):
                return
        except Exception:
            return
        try:
            d = int(delta_steps)
        except Exception:
            return
        if d == 0:
            return
        acc = getattr(self, "_grid_scroll_accum", 0) + d
        self._grid_scroll_accum = max(-320, min(320, acc))
        self._arm_grid_scroll_thumb_defer()
        if self._grid_scroll_tick_job is None:
            self._grid_scroll_tick_job = self.after(0, self._grid_scroll_tick)

    # ── Grid ──────────────────────────────────────────────────────
    def _build_grid_area(self, parent):
        self.grid_canvas = tk.Canvas(parent, bg=C["bg"], highlightthickness=0)
        self._grid_vscroll = self._make_custom_scrollbar(parent, self.grid_canvas, auto_hide=True)
        self.grid_canvas.pack(side="left", fill="both", expand=True)

        self.grid_inner = tk.Frame(self.grid_canvas, bg=C["bg"])
        self._grid_win  = self.grid_canvas.create_window(
            (0, 0), window=self.grid_inner, anchor="nw", tags="inner_frame")

        self.grid_inner.bind("<Configure>", self._on_grid_inner_configure)
        self.grid_canvas.bind("<Configure>", self._on_grid_resize)

        try:
            self.grid_canvas.configure(yscrollincrement=10)
        except Exception:
            pass

        def mw(e):
            if not self._canvas_can_scroll_y(self.grid_canvas):
                return "break"
            d = getattr(e, "delta", 0) or 0
            if d == 0:
                return "break"
            units = int(-d / 120.0)
            if units == 0:
                units = -1 if d > 0 else 1
            step = max(-10, min(10, units * 4))
            self._queue_grid_scroll(step)
            return "break"

        self.grid_canvas.bind("<MouseWheel>", mw)
        self.grid_inner.bind("<MouseWheel>", mw)
        def _mw4(e):
            if self._canvas_can_scroll_y(self.grid_canvas):
                self._queue_grid_scroll(-3)

        def _mw5(e):
            if self._canvas_can_scroll_y(self.grid_canvas):
                self._queue_grid_scroll(3)

        self.grid_canvas.bind("<Button-4>", _mw4)
        self.grid_canvas.bind("<Button-5>", _mw5)
        self._grid_scroll_fn = mw

        self.grid_canvas.bind("<Enter>", lambda e: self.grid_canvas.focus_set())

        # One root-level handler; avoids hundreds of <MouseWheel> bindings on every card/child.
        self.bind_all("<MouseWheel>", self._on_global_mousewheel_grid, add="+")

    def _bind_grid_mousewheel(self, widget):
        """
        Previously recursed all nested widgets; that duplicated bindings many times.
        ``bind_all`` in _build_grid_area plus canvas bindings handle scroll for this grid.
        """
        return

    def _pointer_over_grid_canvas(self, x_root, y_root):
        try:
            gc = self.grid_canvas
            if not gc.winfo_exists():
                return False
            gx, gy = gc.winfo_rootx(), gc.winfo_rooty()
            gw, gh = gc.winfo_width(), gc.winfo_height()
            return gx <= x_root < gx + max(gw, 1) and gy <= y_root < gy + max(gh, 1)
        except Exception:
            return False

    def _on_global_mousewheel_grid(self, event):
        try:
            if event.widget.winfo_toplevel() is not self:
                return
        except Exception:
            return
        if not self._pointer_over_grid_canvas(event.x_root, event.y_root):
            return
        # Avoid duplicate scroll when canvas/inner handler already processed this event
        try:
            w = event.widget
            if w in (self.grid_canvas, self.grid_inner):
                return
        except Exception:
            pass
        try:
            gc = getattr(self, "grid_canvas", None)
            if not gc or not self._canvas_can_scroll_y(gc):
                return
        except Exception:
            return
        d = getattr(event, "delta", 0) or 0
        if not d:
            return
        units = int(-d / 120.0)
        if units == 0:
            units = -1 if d > 0 else 1
        step = max(-10, min(10, units * 4))
        self._queue_grid_scroll(step)
        return "break"

    def _on_global_mousewheel_linux(self, event):
        try:
            if event.widget.winfo_toplevel() is not self:
                return
        except Exception:
            return
        if not self._pointer_over_grid_canvas(event.x_root, event.y_root):
            return
        n = getattr(event, "num", 0)
        if n == 4:
            self._queue_grid_scroll(-4)
        elif n == 5:
            self._queue_grid_scroll(4)

    def _on_grid_inner_configure(self, e=None):
        """Ignore Configure spam when width/height unchanged (common with embed + wheel)."""
        try:
            w = int(getattr(e, "width", 0) or 0)
            h = int(getattr(e, "height", 0) or 0)
            if w <= 0 or h <= 0:
                return
            wh = (w, h)
            if wh == getattr(self, "_grid_inner_configure_wh", None):
                return
            self._grid_inner_configure_wh = wh
        except Exception:
            pass
        if getattr(self, "_grid_scroll_suppress_thumbs", False):
            return
        self._schedule_scrollregion_update()

    def _schedule_scrollregion_update(self, e=None):
        """Coalesce scrollregion updates to one idle pass (reliable scroll height)."""
        if getattr(self, "_scroll_idle_armed", False):
            return
        self._scroll_idle_armed = True

        def _drain():
            self._scroll_idle_armed = False
            self._update_scrollregion()

        self.after_idle(_drain)

    def _grid_inner_tight_content_bottom(self):
        """Max bottom edge of direct children in grid_inner (avoids over-tall scroll when inner is stretched)."""
        try:
            m = 0
            for w in self.grid_inner.winfo_children():
                m = max(m, int(w.winfo_y() or 0) + int(w.winfo_height() or 0))
            return m
        except Exception:
            return 0

    def _use_tight_grid_scrollregion(self):
        av = str(getattr(self, "active_view", "home") or "home")
        if av not in ("local", "favorites"):
            return False
        if getattr(self, "downloads_view_active", False):
            return False
        return True

    def _update_scrollregion(self, e=None):
        try:
            self.grid_inner.update_idletasks()
            w = max(self.grid_inner.winfo_reqwidth(),  self.grid_inner.winfo_width())
            if self._use_tight_grid_scrollregion():
                b = int(self._grid_inner_tight_content_bottom() or 0)
                h = b if b > 0 else int(max(self.grid_inner.winfo_reqheight(), self.grid_inner.winfo_height()))
            else:
                h = max(self.grid_inner.winfo_reqheight(), self.grid_inner.winfo_height())
            if w > 0 and h > 0:
                sr = (0, 0, int(w), int(h + 10))
                if sr != getattr(self, "_last_grid_scrollregion", None):
                    self._last_grid_scrollregion = sr
                    self.grid_canvas.configure(scrollregion=sr)
                try:
                    self.grid_canvas.update_idletasks()
                    if self._use_tight_grid_scrollregion() and not self._canvas_can_scroll_y(
                        self.grid_canvas
                    ):
                        self.grid_canvas.yview_moveto(0)
                except Exception:
                    pass
        except Exception:
            pass

    def _scroll_grid_to_top(self, smooth=True):
        try:
            if not hasattr(self, "grid_canvas"):
                return
            cv = self.grid_canvas
            if not (cv and cv.winfo_exists()):
                return
            if not smooth:
                cv.yview_moveto(0)
                return
            lo, _hi = cv.yview()
            lo = float(lo or 0.0)
            if lo <= 0.001:
                return
            steps = 10
            dur_ms = 120
            for i in range(steps):
                frac = lo * (1.0 - (i + 1) / float(steps))
                self.after(int((i + 1) * (dur_ms / steps)), lambda f=frac: cv.yview_moveto(max(0.0, f)))
        except Exception:
            pass

    def _on_grid_resize(self, e):
        self.grid_canvas.itemconfig(self._grid_win, width=e.width)
        self._schedule_scrollregion_update()
        # Reflow columns when window is resized
        prev_w = getattr(self, "_grid_resize_last_w", -1)
        if abs(e.width - prev_w) > 30:
            self._grid_resize_last_w = e.width
            job = getattr(self, "_grid_resize_job", None)
            if job:
                try:
                    self.after_cancel(job)
                except Exception:
                    pass
            self._grid_resize_job = self.after(200, self._maybe_reflow_grid_columns)

    # ── Downloads View ────────────────────────────────────────────
    def _show_downloads_view(self):
        """Instant — hide grid canvas, show a dedicated overlay. No API, no grid_inner touch."""
        try:
            self.downloads_view_active = True
            self._set_nav_active("downloads")
            self.grid_canvas.pack_forget()
            parent = self.grid_canvas.master
            if not hasattr(self, "_dl_overlay") or not self._dl_overlay.winfo_exists():
                self._dl_overlay = tk.Frame(parent, bg=C["bg"])
            self._dl_overlay.pack(fill="both", expand=True)
            self._render_downloads_view()
        except Exception as e:
            print(f"[DL VIEW ERROR] {e}")

    def _hide_downloads_view(self):
        """Restore grid canvas, hide overlay."""
        try:
            self.downloads_view_active = False
            if hasattr(self, "_dl_overlay") and self._dl_overlay.winfo_exists():
                self._dl_overlay.pack_forget()
            self.grid_canvas.pack(fill="both", expand=True)
        except Exception as e:
            print(f"[HIDE DL ERROR] {e}")

    def _scan_downloads(self):
        """Verify all downloaded files still exist on disk. Remove stale records."""
        try:
            records = _shared_dl_read()
            valid   = [r for r in records if r.get("fp") and os.path.exists(r["fp"])]
            removed = len(records) - len(valid)
            _shared_dl_write(valid)
            self.completed_downloads = [
                d for d in self.completed_downloads
                if not d.get("filepath") or os.path.exists(d.get("filepath", ""))
            ]
            self._save_download_history()
            msg = f"Verify complete — {len(valid)} files OK"
            if removed:
                msg += f", {removed} missing records removed"
            self._render_downloads_view()
            messagebox.showinfo("Verify Files", msg, parent=self)
        except Exception as e:
            print(f"[SCAN ERROR] {e}")

    def _render_downloads_view(self):
        """Render downloads into the overlay frame (not grid_inner)."""
        try:
            target = getattr(self, "_dl_overlay", None)
            if not target or not target.winfo_exists():
                return
            for w in target.winfo_children():
                w.destroy()

            active_list    = [d for d in self.active_downloads.values()
                              if d.get("status") not in ("cancelled", "failed")]
            completed_list = [d for d in self.completed_downloads
                              if d.get("status") in ("completed", "failed")]

            # Header toolbar: title + scan button
            hdr = tk.Frame(target, bg=C["bg"])
            hdr.pack(fill="x", padx=20, pady=(14, 0))
            tk.Label(hdr, text="My Downloads", bg=C["bg"], fg=C["text"],
                     font=("Segoe UI", 12, "bold")).pack(side="left")
            scan_btn = tk.Button(
                hdr, text="✓  Verify Files",
                bg=C.get("card", "#1e1e1e"), fg="#6abf6a",
                activebackground="#1a3a1a", activeforeground="#7fd87f",
                relief="groove", bd=1, cursor="hand2",
                font=("Segoe UI", 8, "normal"), padx=8, pady=3,
                command=self._scan_downloads,
            )
            scan_btn.pack(side="right")
            # Tooltip on hover
            _tt_label = tk.Label(
                target, text="",
                bg="#1e2a1e", fg="#a8d8a8", font=("Segoe UI", 8),
                relief="solid", bd=1, padx=8, pady=4, wraplength=300, justify="left",
            )
            _TT_TEXT = (
                "Verify Files — Checks which downloaded files still exist on disk.\n"
                "Missing files are removed from this list, keeping your downloads\n"
                "in sync across all CGHEVEN addons."
            )
            def _tt_show(e):
                _tt_label.config(text=_TT_TEXT)
                _tt_label.place(relx=1.0, rely=1.0, anchor="ne", x=-4, y=28)
            def _tt_hide(e):
                _tt_label.place_forget()
            scan_btn.bind("<Enter>", _tt_show)
            scan_btn.bind("<Leave>", _tt_hide)

            # Scrollable canvas inside overlay
            dl_canvas = tk.Canvas(target, bg=C["bg"], highlightthickness=0)
            dl_vsb    = ttk.Scrollbar(target, orient="vertical", command=dl_canvas.yview)
            dl_canvas.configure(yscrollcommand=dl_vsb.set)
            dl_vsb.pack(side="right", fill="y")
            dl_canvas.pack(side="left", fill="both", expand=True)
            dl_inner = tk.Frame(dl_canvas, bg=C["bg"])
            dl_win   = dl_canvas.create_window((0, 0), window=dl_inner, anchor="nw")
            dl_inner.bind("<Configure>", lambda e: dl_canvas.configure(
                scrollregion=dl_canvas.bbox("all") or (0,0,1,1)))
            dl_canvas.bind("<Configure>", lambda e: dl_canvas.itemconfig(dl_win, width=e.width))
            def _mw(e): dl_canvas.yview_scroll(int(-e.delta/120), "units")
            dl_canvas.bind("<MouseWheel>", _mw)

            if active_list:
                af = tk.Frame(dl_inner, bg=C["bg"])
                af.pack(fill="x", padx=20, pady=(20, 10))
                tk.Label(af, text=f"Active Downloads ({len(active_list)})",
                         bg=C["bg"], fg=C["text"], font=("Segoe UI", 11, "bold")).pack(anchor="w")
                for d in active_list:
                    self._render_download_item(af, d, is_active=True)

            if completed_list:
                cf = tk.Frame(dl_inner, bg=C["bg"])
                cf.pack(fill="x", padx=20, pady=(20, 10))
                tk.Label(cf, text=f"Completed Downloads ({len(completed_list)})",
                         bg=C["bg"], fg=C["text"], font=("Segoe UI", 11, "bold")).pack(anchor="w")
                for d in reversed(completed_list[-20:]):
                    self._render_download_item(cf, d, is_active=False)

            if not active_list and not completed_list:
                tk.Label(dl_inner, text="No downloads yet", bg=C["bg"], fg=C["dim"],
                         font=("Segoe UI", 12)).pack(pady=80)
        except Exception as e:
            print(f"[RENDER DL ERROR] {e}")

    def _render_download_item(self, parent, download_info, is_active):
        try:
            item_frame = tk.Frame(parent, bg=C["card"],
                                  highlightbackground=C["border"], highlightthickness=1)
            item_frame.pack(fill="x", pady=4)
            asset    = download_info.get("asset", {})
            a        = asset.get("attributes", asset)
            title    = a.get("Title", a.get("title", "Unknown"))
            file_info = download_info.get("file_info", {})
            filename  = download_info.get("filename", file_info.get("filename", "file"))

            top_row = tk.Frame(item_frame, bg=C["card"])
            top_row.pack(fill="x", padx=12, pady=(8, 4))
            tk.Label(top_row, text=title[:50], bg=C["card"], fg=C["text"],
                     font=("Segoe UI", 9, "bold"), anchor="w").pack(side="left", fill="x", expand=True)

            if is_active:
                progress    = download_info.get("progress", 0)
                status_text = f"Downloading... {progress:.1f}%"
                status_color = C["accent"]
            else:
                status = download_info.get("status", "completed")
                imp    = download_info.get("import_status", "unknown")
                if status == "completed":
                    if imp == "pending":   status_text, status_color = "⏳ Pending Import",          C["accent"]
                    elif imp == "success": status_text, status_color = "✓ Completed & Imported",     C["green"]
                    elif imp == "failed":  status_text, status_color = "✓ Completed (Import Failed)", C["dim"]
                    else:                  status_text, status_color = "✓ Completed",                 C["green"]
                else:
                    status_text, status_color = "✗ Failed", C["red"]

            tk.Label(top_row, text=status_text, bg=C["card"], fg=status_color,
                     font=("Segoe UI", 8)).pack(side="right")

            mid_row = tk.Frame(item_frame, bg=C["card"])
            mid_row.pack(fill="x", padx=12, pady=(0, 4))
            fmt  = file_info.get("format", "")
            res  = file_info.get("resolution", "")
            det  = f"{filename} · {fmt}" + (f" · {res}" if res else "")
            tk.Label(mid_row, text=det, bg=C["card"], fg=C["dim"],
                     font=("Segoe UI", 8), anchor="w").pack(side="left", fill="x", expand=True)

            if is_active:
                pf = tk.Frame(item_frame, bg=C["card"])
                pf.pack(fill="x", padx=12, pady=(0, 4))
                progress = download_info.get("progress", 0)
                pb = tk.Canvas(pf, height=4, bg=C["bg"], highlightthickness=0)
                pb.pack(fill="x", pady=(0, 4))
                def _upd_pb(pb=pb, pf=pf, progress=progress):
                    try:
                        w = pf.winfo_width() or 200
                        pw = max(1, int(w * progress / 100))
                        pb.delete("all")
                        pb.create_rectangle(0, 0, pw, 4, fill=C["accent"], outline="")
                    except: pass
                pf.after_idle(_upd_pb)

                cr = tk.Frame(item_frame, bg=C["card"])
                cr.pack(fill="x", padx=12, pady=(0, 8))
                did = download_info.get("id")
                is_paused = download_info.get("status") == "paused"
                tk.Label(cr, text=f"{progress:.1f}%", bg=C["card"], fg=C["text2"],
                         font=("Segoe UI", 8)).pack(side="left", padx=(0, 12))
                tk.Button(cr, text="▶" if is_paused else "⏸",
                          bg=C["card_h"], fg=C["text"],
                          activebackground=C["accent"], activeforeground="white",
                          relief="flat", cursor="hand2", font=("Segoe UI", 7), padx=6, pady=2,
                          command=lambda d=did: self._toggle_pause_download(d)).pack(side="left", padx=(0,4))
                tk.Button(cr, text="✕",
                          bg=C["card_h"], fg=C["text2"],
                          activebackground=C["red"], activeforeground="white",
                          relief="flat", cursor="hand2", font=("Segoe UI", 7), padx=6, pady=2,
                          command=lambda d=did: self._cancel_download(d)).pack(side="left")
            else:
                filepath = download_info.get("filepath", "")
                if filepath:
                    pr = tk.Frame(item_frame, bg=C["card"])
                    pr.pack(fill="x", padx=12, pady=(0, 8))
                    tk.Label(pr, text=f"📁 {filepath}", bg=C["card"], fg=C["text2"],
                             font=("Segoe UI", 7), anchor="w").pack(side="left", fill="x", expand=True)
                    br = tk.Frame(pr, bg=C["card"])
                    br.pack(side="right")
                    imp = download_info.get("import_status", "unknown")
                    if imp == "success":
                        itext, ibg, ifg = "✓ Imported",     C["green"],  "white"
                    elif imp == "pending":
                        itext, ibg, ifg = "⏳ Queued",      C["accent"], "white"
                    else:
                        itext, ibg, ifg = "Import to Resolve", C["accent"], "white"
                    tk.Button(br, text=itext, bg=ibg, fg=ifg,
                              activebackground=C["accent"], activeforeground="white",
                              relief="flat", cursor="hand2", font=("Segoe UI", 7), padx=8, pady=2,
                              command=lambda d=download_info: self._manual_import_to_resolve(d)).pack(side="left", padx=(0,4))
                    tk.Button(br, text="Open", bg=C["card_h"], fg=C["text2"],
                              activebackground=C["accent"], activeforeground="white",
                              relief="flat", cursor="hand2", font=("Segoe UI", 7), padx=8, pady=2,
                              command=lambda p=filepath: self._open_downloaded_file(p)).pack(side="left")
        except Exception as e:
            print(f"[ERROR] render_download_item: {e}")

    def _open_downloaded_file(self, filepath):
        try:
            if not os.path.exists(filepath):
                messagebox.showerror("File Not Found", f"File not found:\n{filepath}")
                return
            import platform
            system = platform.system()
            if system == "Windows": os.startfile(filepath)
            elif system == "Darwin": subprocess.run(["open", filepath])
            else: subprocess.run(["xdg-open", filepath])
        except Exception as e:
            messagebox.showerror("Error", f"Failed to open file:\n{e}")

    # ── Pending import queue ──────────────────────────────────────
    def _check_and_import_pending(self):
        try:
            if not getattr(self, "_resolve_module_available", True):
                return
            if not self.pending_imports:
                if not self.resolve:
                    self.after(5000, self._check_and_import_pending)
                return
            if self._is_resolve_running() and not self.resolve:
                try:
                    import DaVinciResolveScript as dvr
                    self.resolve = dvr.scriptapp("Resolve")
                    if self.resolve:
                        self._process_pending_imports()
                except ImportError:
                    self._resolve_module_available = False
                    return
                except Exception:
                    pass
            elif self.resolve:
                self._process_pending_imports()
            else:
                n = len(self.pending_imports)
                self.status_var.set(f"⏳ {n} file(s) queued — waiting for DaVinci Resolve...")
            if not self.resolve and getattr(self, "_resolve_module_available", True):
                self.after(5000, self._check_and_import_pending)
        except Exception as e:
            print(f"[DEBUG] periodic check error: {e}")
            self.after(5000, self._check_and_import_pending)

    def _process_pending_imports(self):
        if not self.resolve or not self.pending_imports:
            return
        processed = []
        for item in self.pending_imports[:]:
            filepath  = item["filepath"]
            filename  = item["filename"]
            dl_info   = item["download_info"]
            if not os.path.exists(filepath):
                dl_info["import_status"] = "failed"
                processed.append(item)
                continue
            # For 3D model ZIPs: extract and find supported 3D file (FBX/OBJ/ABC/VDB)
            if filepath.lower().endswith('.zip'):
                asset = dl_info.get("asset") or {}
                cat_slug = _cgheven_relation_slug(asset.get("categorie") or {})
                if cat_slug == '3d-models':
                    _3d_path, _is_vdb = self._unzip_and_get_3d_file(filepath)
                    if _3d_path:
                        filepath = _3d_path
                        filename = os.path.basename(_3d_path)
                        item["filepath"] = _3d_path
                        item["filename"] = filename
                        dl_info["filepath"] = _3d_path
                        dl_info["filename"] = filename
                    else:
                        dl_info["import_status"] = "failed"
                        dl_info["import_error"]  = "No supported 3D file (FBX/OBJ/ABC) found in ZIP"
                        processed.append(item)
                        self.after(0, lambda fn=filename: messagebox.showerror(
                            "No Supported 3D File",
                            f"No supported 3D file found in:\n{fn}\n\n"
                            "Supported formats: FBX, OBJ, Alembic (.abc), VDB\n"
                            "GLTF/GLB is not supported by DaVinci Resolve Fusion.",
                            parent=self))
                        continue
            try:
                pm   = self.resolve.GetProjectManager()
                proj = pm.GetCurrentProject() if pm else None
                mp   = proj.GetMediaPool() if proj else None
                if mp:
                    mp.ImportMedia([os.path.abspath(filepath)])
                    dl_info["import_status"] = "success"
                    # Analytics: import success (pending auto)
                    try:
                        if ANALYTICS_AVAILABLE and _analytics and getattr(self, "analytics_distinct_id", ""):
                            fi = dl_info.get("file_info") or {}
                            _analytics.send_import_event(
                                license_server=LICENSE_SERVER,
                                addon_slug=CGHEVEN_ADDON_SLUG,
                                distinct_id=self.analytics_distinct_id,
                                asset=dl_info.get("asset") or {},
                                import_mode="pending_auto",
                                success=True,
                                fmt=str(fi.get("format", "") or ""),
                            )
                    except Exception:
                        pass
                    processed.append(item)
                    self.status_var.set(f"✓ Imported: {filename}")
            except Exception as ex:
                dl_info["import_status"] = "failed"
                dl_info["import_error"]  = str(ex)
                # Analytics: import failed (pending auto)
                try:
                    if ANALYTICS_AVAILABLE and _analytics and getattr(self, "analytics_distinct_id", ""):
                        _analytics.send_import_failed(
                            license_server=LICENSE_SERVER, addon_slug=CGHEVEN_ADDON_SLUG,
                            distinct_id=self.analytics_distinct_id,
                            asset=dl_info.get("asset") or {}, error=str(ex))
                except Exception:
                    pass
                processed.append(item)
        self._save_download_history()
        for item in processed:
            if item in self.pending_imports:
                self.pending_imports.remove(item)
        self._update_statusbar_pending()
        if self.downloads_view_active:
            self._render_downloads_view()

    def _manual_import_to_resolve(self, download_info):
        try:
            if not self.resolve:
                # Try pre-injected resolve global first
                try:
                    import __main__
                    _r = getattr(__main__, "resolve", None)
                    if _r and self._valid_resolve(_r):
                        self.resolve = _r
                except Exception:
                    pass
            if not self.resolve:
                try:
                    import DaVinciResolveScript as dvr
                    _r = dvr.scriptapp("Resolve")
                    if _r and self._valid_resolve(_r):
                        self.resolve = _r
                except Exception:
                    pass
            if not self.resolve:
                self._show_resolve_setup_guide()
                return
            filepath = download_info.get("filepath", "")
            if not filepath or not os.path.exists(filepath):
                messagebox.showerror("File Not Found", f"File not found:\n{filepath}", parent=self)
                return
            fi = download_info.get("file_info") or {}
            fmt = str(fi.get("format", "") or "").upper()
            # For ZIP files: extract based on format
            if filepath.lower().endswith('.zip'):
                asset = download_info.get("asset") or {}
                cat_slug = _cgheven_relation_slug(asset.get("categorie") or {})
                if cat_slug == '3d-models':
                    _3d_path, _is_vdb = self._unzip_and_get_3d_file(filepath)
                    if _3d_path:
                        filepath = _3d_path
                        download_info["filepath"] = _3d_path
                        download_info["filename"] = os.path.basename(_3d_path)
                    else:
                        messagebox.showerror(
                            "No Supported 3D File",
                            "No supported 3D file found in this ZIP.\n\n"
                            "Supported formats: FBX, OBJ, Alembic (.abc), VDB\n"
                            "GLTF/GLB is not supported by DaVinci Resolve Fusion.",
                            parent=self)
                        return
                elif fmt == "VDB" or self._zip_contains_vdb(filepath):
                    vdb_path, vdb_seq = self._unzip_and_get_vdb(filepath)
                    if vdb_path:
                        filepath = vdb_path
                        download_info["filepath"] = vdb_path
                        download_info["filename"] = os.path.basename(vdb_path)
                        download_info["vdb_sequence"] = vdb_seq
                    else:
                        messagebox.showerror("VDB Not Found", "VDB file not found in ZIP", parent=self)
                        return
            # VDB files: import via uVolume node (auto-switches to Fusion page)
            if filepath.lower().endswith('.vdb'):
                vdb_seq = download_info.get("vdb_sequence")
                success, msg = self._import_vdb_via_uvolume(filepath, sequence_pattern=vdb_seq)
                if success:
                    download_info["import_status"] = "success"
                    self._save_download_history()
                    self._render_downloads_view()
                    self.after(0, lambda m=msg: self.status_var.set(f"✓ {m}"))
                    messagebox.showinfo("VDB Imported", msg, parent=self)
                else:
                    messagebox.showerror("VDB Import Failed", msg, parent=self)
                return
            pm = self.resolve.GetProjectManager() if self.resolve else None
            if not pm:
                messagebox.showerror("Error",
                    "Cannot get Project Manager.\nMake sure a project is open in Resolve.",
                    parent=self)
                return
            proj = pm.GetCurrentProject()
            if not proj:
                messagebox.showerror("No Project Open",
                    "Please open a project in DaVinci Resolve first,\nthen click Import again.",
                    parent=self)
                return
            mp = proj.GetMediaPool()
            if not mp:
                messagebox.showerror("Error", "Cannot access Media Pool", parent=self)
                return
            abs_path = os.path.abspath(filepath).replace("\\", "/")
            result = mp.ImportMedia([abs_path])
            fn = os.path.basename(filepath)
            if result:
                download_info["import_status"] = "success"
                # Analytics: import success (manual)
                try:
                    if ANALYTICS_AVAILABLE and _analytics and getattr(self, "analytics_distinct_id", ""):
                        fi = download_info.get("file_info") or {}
                        _analytics.send_import_event(
                            license_server=LICENSE_SERVER,
                            addon_slug=CGHEVEN_ADDON_SLUG,
                            distinct_id=self.analytics_distinct_id,
                            asset=download_info.get("asset") or {},
                            import_mode="manual",
                            success=True,
                            fmt=str(fi.get("format", "") or ""),
                        )
                except Exception:
                    pass
                self.status_var.set(f"\u2713 Imported: {fn}")
                self._save_download_history()
            else:
                # Analytics: import failed (manual)
                try:
                    if ANALYTICS_AVAILABLE and _analytics and getattr(self, "analytics_distinct_id", ""):
                        fi = download_info.get("file_info") or {}
                        _analytics.send_import_event(
                            license_server=LICENSE_SERVER,
                            addon_slug=CGHEVEN_ADDON_SLUG,
                            distinct_id=self.analytics_distinct_id,
                            asset=download_info.get("asset") or {},
                            import_mode="manual",
                            success=False,
                            fmt=str(fi.get("format", "") or ""),
                        )
                except Exception:
                    pass
                self._save_download_history()
                messagebox.showwarning("Import Warning",
                    f"Import returned empty result for:\n{fn}\n\n"
                    f"File is at:\n{abs_path}\n\nTry dragging it manually to the timeline.",
                    parent=self)
            if self.downloads_view_active:
                self._render_downloads_view()
        except Exception as e:
            # Analytics: import failed (manual)
            try:
                if ANALYTICS_AVAILABLE and _analytics and getattr(self, "analytics_distinct_id", ""):
                    fi = download_info.get("file_info") or {}
                    _analytics.send_import_event(
                        license_server=LICENSE_SERVER,
                        addon_slug=CGHEVEN_ADDON_SLUG,
                        distinct_id=self.analytics_distinct_id,
                        asset=download_info.get("asset") or {},
                        import_mode="manual",
                        success=False,
                        fmt=str(fi.get("format", "") or ""),
                    )
            except Exception:
                pass
            self._save_download_history()
            messagebox.showerror("Import Error", f"Failed to import:\n{e}", parent=self)

    # ── Grid Render ───────────────────────────────────────────────
    def _render_grid(self):
        if not getattr(self, "_grid_pan_resync_active", False):
            self._grid_restore_yview_lo = None
        self._grid_pan_resync_active = False
        try:
            self._grid_cards_by_id.clear()
        except Exception:
            self._grid_cards_by_id = {}
        self._grid_inner_configure_wh = None
        self._last_grid_scrollregion = None
        self._thumb_deferred_batch.clear()
        self._thumb_deferred_set.clear()
        self._thumb_deferred_widgets.clear()
        for w in self.grid_inner.winfo_children():
            w.destroy()
        self.thumbnails.clear()

        if not self.filtered:
            tk.Label(self.grid_inner,
                     text="No assets found.\nTry a different search or category.",
                     bg=C["bg"], fg=C["dim"], font=("Segoe UI", 12)).pack(pady=80)
            return

        cols = self._compute_grid_columns()
        self._grid_cols = cols
        self._thumb_target_size = int(self._card_pixel_size(cols))
        self._cached_card_size = self._thumb_target_size

        display_assets = list(self.filtered)
        # "Free" filter = assets that have at least one free (non-locked) downloadable file.
        if getattr(self, "_free_filter_active", False):
            display_assets = [
                a for a in display_assets
                if self._asset_has_free_file(a)
            ]
        if not display_assets:
            tk.Label(self.grid_inner,
                     text="No assets available in this category yet.\nCheck back soon.",
                     bg=C["bg"], fg=C["dim"], font=("Segoe UI", 12)).pack(pady=80)
            return

        # Keep UI responsive: render a smaller first batch immediately,
        # then schedule the rest on the Tk main thread (Tk is not thread-safe).
        INSTANT_BATCH     = 24
        instant_assets    = display_assets[:INSTANT_BATCH]
        remaining_assets  = display_assets[INSTANT_BATCH:]

        for i, asset in enumerate(instant_assets):
            try:
                r, c = divmod(i, cols)
                card = self._make_card(self.grid_inner, asset)
                card.grid(row=r, column=c, padx=8, pady=8, sticky="n")
            except Exception as e:
                print(f"[ERROR] card {i}: {e}")

        for c in range(cols):
            self.grid_inner.columnconfigure(c, weight=1)

        self.after(10, self._schedule_scrollregion_update)

        # Thumbnail load for first batch
        if PIL_AVAILABLE and instant_assets:
            aids = [a["id"] for a in instant_assets]
            self._enqueue_thumb_load(aids)

        # NOTE: video frame pre-cache disabled to prevent startup hangs ("Not Responding").
        # Preview frames are extracted on-demand when an asset is selected.

        # Progressive load remaining (MAIN THREAD via after)
        if remaining_assets:
            try:
                self._grid_progress_token = getattr(self, "_grid_progress_token", 0) + 1
            except Exception:
                self._grid_progress_token = 1
            token = self._grid_progress_token
            self.after(18, lambda t=token, rem=list(remaining_assets), start=INSTANT_BATCH, c=cols:
                       self._render_grid_add_batch(t, rem, start, c))
        else:
            # Grid complete in one pass — re-sync selection (preview panel reflow).
            self.after(0, self._restore_grid_card_selection)

        if self.total_assets > len(self.filtered):
            self._add_pagination()

    def _render_grid_add_batch(self, token, remaining_assets, start_index, cols):
        """
        Add remaining cards in small batches on the Tk main thread.
        Prevents freezes / "Not Responding" caused by creating widgets off-thread.
        """
        try:
            if token != getattr(self, "_grid_progress_token", None):
                return
            if getattr(self, "_grid_scroll_suppress_thumbs", False):
                self.after(80, lambda t=token, rem=remaining_assets,
                           start=start_index, c=cols: self._render_grid_add_batch(t, rem, start, c))
                return
            batch_size = 12
            end = min(len(remaining_assets), batch_size)
            batch = remaining_assets[:end]
            rest = remaining_assets[end:]

            for i, asset in enumerate(batch):
                try:
                    gi = start_index + i
                    r, c = divmod(gi, cols)
                    card = self._make_card(self.grid_inner, asset)
                    card.grid(row=r, column=c, padx=8, pady=8, sticky="n")
                except Exception:
                    pass

            self._schedule_scrollregion_update()

            # Thumb load for this batch (debounced; reduces lag)
            if PIL_AVAILABLE and batch:
                try:
                    aids = [a.get("id") for a in batch if a.get("id") is not None]
                    self._enqueue_thumb_load(aids)
                except Exception:
                    pass

            if rest:
                self.after(18, lambda t=token, rem=rest, start=start_index + len(batch), c=cols:
                           self._render_grid_add_batch(t, rem, start, c))
            else:
                self.after(0, self._restore_grid_card_selection)
        except Exception:
            pass

    def _make_card(self, parent, asset):
        files = []
        try:
            a = asset.get("attributes", asset)
            title = a.get("Title", a.get("title", "Unknown"))
            cat_data = a.get("categorie")
            cat_name = ""
            if isinstance(cat_data, dict):
                cat_name = cat_data.get("Name", cat_data.get("name", ""))
            thumb_url = a.get("thumbnail", "")
            preview_url = ""
            raw_preview = (a.get("previews") or a.get("preview") or
                           a.get("Preview") or a.get("preview_url") or "")
            if isinstance(raw_preview, str) and raw_preview.strip():
                preview_url = raw_preview.strip()
            elif isinstance(raw_preview, list) and raw_preview:
                preview_url = str(raw_preview[0]).strip()
            if preview_url:
                preview_url = self._normalize_media_url(preview_url)
            files = a.get("files", []) or []
            files_summary = ""
            if files:
                file_exts = [os.path.splitext(str(f).split("?")[0])[1].lower() for f in files[:3]]
                files_summary = (
                    f"files={len(files)} (exts: {', '.join(file_exts)}"
                    f"{'...' if len(files) > 3 else ''})")
            else:
                files_summary = "files=0"
            aid = asset.get("id", 0)
            if CGHEVEN_DEBUG_VERBOSE:
                print(f"[PREVIEW DEBUG] id={aid} title='{title[:50]}' raw_preview={repr(raw_preview)[:80]} "
                      f"preview_url='{preview_url[:100]}' thumb_url={'YES' if thumb_url else 'NO'} {files_summary}")
        except Exception as e:
            print(f"[ERROR] _make_card parse: {e}")
            a = asset
            title = "Unknown"
            cat_name = ""
            thumb_url = ""
            preview_url = ""
            aid = asset.get("id", 0)
            files = []

        # Parse files ONCE here — reused by tag pills AND download button (no duplicate work)
        parsed_files = [parse_file_url(f) for f in files if f]
        cat_slug = _cgheven_relation_slug(
            (asset.get("attributes", asset) or {}).get("categorie") or {}
        )
        if cat_slug == "3d-models":
            parsed_files = [pf for pf in parsed_files
                            if str(pf.get("format","")).upper() not in ("GLTF", "GLB")]

        card_size = int(
            getattr(self, "_cached_card_size", None)
            or self._card_pixel_size(getattr(self, "_grid_cols", 4))
        )
        thumb_size = card_size
        card_height = thumb_size + 88
        card = tk.Frame(parent, bg=C["card"], cursor="hand2",
                        highlightbackground=C["border"], highlightthickness=1,
                        width=card_size, height=card_height)
        card.grid_propagate(False)
        card.pack_propagate(False)

        thumb_wrap = tk.Frame(card, bg="#080808", width=card_size, height=thumb_size)
        thumb_wrap.pack(fill="x")
        thumb_wrap.pack_propagate(False)

        thlbl = tk.Label(thumb_wrap, bg="#080808", text="", cursor="hand2")
        thlbl.place(relwidth=1, relheight=1)
        thlbl.asset_id = aid
        card.thumb_lbl = thlbl
        # Without Pillow no thumbnail can ever load — show a placeholder up front
        # (the "Fix Now" banner tells the user how to enable real thumbnails).
        if not PIL_AVAILABLE:
            self._apply_thumb_placeholder(thlbl)

        content = tk.Frame(card, bg="#12141B", height=88)
        content.pack(fill="both", expand=True)
        content.pack_propagate(False)

        chip = (cat_name[:14] if cat_name else "VFX").strip() or "VFX"
        tk.Label(thumb_wrap, text=f"  {chip}  ", bg=C["accent_soft"], fg=C["text"],
                 font=("Segoe UI", 6, "bold")).place(x=8, y=8, anchor="nw")

        # NOTE: The card intentionally shows NO premium/lock badge. A free asset that also has
        # premium (4K/EXR) files downloads its free resolution from a clean "Download" button.
        # The "Upgrade" affordance for the stripped 4K/EXR formats lives ONLY in the resolution
        # picker dropdown and the detail/right panel — never as a lock icon on the card.

        card.thumb_url = thumb_url
        card.preview_url = preview_url
        card.asset_id = aid
        card.original_thumb = None
        card.thumb_target_size = thumb_size

        # Thumbnail loaded via debounced executor queue (_enqueue_thumb_load)
        # called after each render batch — do NOT start a raw thread per card.
        if not thumb_url:
            thlbl.configure(text="No Image", fg=C["dim"], font=("Segoe UI", 7))

        title_limit = 34 if card_size >= 230 else 26
        title_one_line = str(title or "").strip().replace("\n", " ")
        if len(title_one_line) > title_limit:
            title_one_line = title_one_line[: max(0, title_limit - 3)].rstrip() + "..."
        title_lbl = tk.Label(
            content, text=title_one_line, bg="#12141B", fg=C["text"],
            font=("Segoe UI", 9, "bold"), justify="left", anchor="w", padx=10,
        )
        title_lbl.pack(fill="x", pady=(8, 4))

        # ── Detect downloaded state + compute default resolution ─────────
        _default_file = self._pick_download_default_file(asset, parsed_files)
        _res_label = ""
        if _default_file:
            _r = str(_default_file.get("resolution", "") or "").strip()
            if _r and _r.upper() not in ("", "N/A", "NA", "ANY"):
                _res_label = _r.upper()

        _is_downloaded = aid in self._downloaded_asset_ids()
        # ── Gating: Download vs Upgrade ──────────────────────────────
        # Downloadability is decided ONLY by backend CGHLOCKED markers + is_patreon_locked +
        # premium_locked. No per-addon free/paid filter and no free-trial/login gate any more.
        # Free (1K/2K + Preview) files keep real URLs and download for ANYONE with NO login;
        # "Upgrade" shows only when the asset has NO free file (patreon-locked or 4K/EXR-only).
        _is_locked = self._locked_for_user(asset)

        if _is_locked:
            dl_text    = "Upgrade"
            dl_fg      = "#ffffff"
            dl_bg_main = C["cta"]
            dl_bg_act  = C["cta_h"]
            _dl_cmd    = lambda: self._open_upgrade_url()
        elif _is_downloaded:
            dl_text    = "⬆  Import"
            dl_fg      = "#34C759"
            dl_bg_main = "#172A1A"
            dl_bg_act  = "#1E3A22"
            _dl_cmd    = lambda a=asset, c=card: self._card_import_asset(a, c)
        else:
            dl_text    = f"⬇  {_res_label}" if _res_label else "⬇  Download"
            dl_fg      = C["text"]
            dl_bg_main = C["dl_btn"]
            dl_bg_act  = "#26263E"
            _dl_cmd    = lambda a=asset, c=card: self._card_download_smart_default(a, c)

        dl_row = tk.Frame(content, bg="#12141B", height=32)
        dl_row.pack(fill="x", padx=10, pady=(0, 10))
        dl_row.pack_propagate(False)

        btn_outer = tk.Frame(dl_row, bg=C["dl_border"])
        btn_outer.pack(fill="both", expand=True)

        btn_inner = tk.Frame(btn_outer, bg=dl_bg_main)
        btn_inner.pack(fill="both", expand=True, padx=1, pady=1)

        dl_btn = tk.Button(
            btn_inner, text=dl_text,
            bg=dl_bg_main, fg=dl_fg,
            activebackground=dl_bg_act, activeforeground=dl_fg,
            relief="flat", cursor="hand2",
            font=("Segoe UI", 9, "bold"), bd=0,
            command=_dl_cmd,
        )
        dl_btn.pack(side="left", fill="both", expand=True)

        tk.Frame(btn_inner, bg=C["dl_border"], width=1).pack(side="left", fill="y", pady=4)
        menu_btn = tk.Button(
            btn_inner, text="▾",
            bg=dl_bg_main, fg=C["text2"],
            activebackground="#2E2E48", activeforeground=C["text"],
            relief="flat", cursor="hand2",
            font=("Segoe UI", 9, "bold"), bd=0, padx=10,
            command=lambda a=asset, c=card: self._show_card_download_menu(a, c),
        )
        menu_btn.pack(side="right", fill="y")

        sel_row = tk.Frame(dl_row, bg=C["dl_border"])
        sel_row.pack(fill="both", expand=True)
        sel_row.pack_forget()

        card.size_lbl = None
        card.format_btn = dl_btn
        card.selected_format = None
        card.download_id = None
        card.parsed_files = parsed_files
        card.asset = asset
        card.selection_row = None
        card.resolution_btn = None
        card.selected_resolution = None
        card.download_btn = dl_btn
        card.menu_btn = menu_btn
        card.dl_row = dl_row
        card.selected_file = None
        card.selection_row = sel_row
        card._btn_outer = btn_outer

        card.hover_timeout = None
        card.video_playing = False
        card.video_animation_id = None

        def _card_click(e, a=asset, c=card):
            self._show_asset_in_right_panel(a)
            self._set_selected_card(c)

        for widget in (card, thumb_wrap, thlbl, content, title_lbl, dl_row, btn_outer, btn_inner, dl_btn, menu_btn):
            widget.bind("<Button-1>", _card_click)
        self._bind_grid_mousewheel(card)

        def _enter(_e, c=card):
            c.configure(highlightbackground="#3A5070", highlightthickness=1)

        def _leave(_e, c=card):
            if not getattr(c, "_is_selected", False):
                c.configure(highlightbackground=C["border"], highlightthickness=1)

        card.bind("<Enter>", _enter)
        card.bind("<Leave>", _leave)

        try:
            self._grid_cards_by_id[aid] = card
        except Exception:
            pass
        return card

    def _safe_update_label(self, lbl, text):
        try:
            if lbl and lbl.winfo_exists():
                lbl.configure(text=text)
        except Exception:
            pass

    def _add_pagination(self):
        pf = tk.Frame(self.grid_inner, bg=C["bg"], pady=12)
        cols = getattr(self, "_grid_cols", 4)
        pf.grid(row=999, column=0, columnspan=cols, sticky="ew", pady=10)

        tk.Label(pf, text=f"Showing {len(self.filtered)} of {self.total_assets} assets",
                 bg=C["bg"], fg=C["text2"],
                 font=("Segoe UI", 8)).pack(side="left", padx=16)

        if len(self.filtered) < self.total_assets:
            tk.Button(pf, text="  Load More ↓  ",
                      bg=C["card"], fg=C["accent"],
                      activebackground=C["card_h"],
                      relief="flat", cursor="hand2",
                      font=("Segoe UI", 9, "bold"), padx=16, pady=6,
                      command=self._load_more).pack(side="right", padx=16)

    # ── URL Normalization ──────────────────────────────────────────
    def _normalize_media_url(self, url):
        """
        Normalize media URLs: fix relative paths and percent-encode spaces/unsafe chars.
        Returns normalized URL and logs if transformation occurred.
        """
        if not url or not isinstance(url, str):
            return url

        raw = url.strip()
        if not raw:
            return ""

        normalized = raw

        # Make scheme-relative URLs explicit
        if normalized.startswith("//"):
            normalized = "https:" + normalized

        # Check if URL is relative (starts with /file/ or similar)
        if normalized.startswith("/file/") or (normalized.startswith("/") and not normalized.startswith("//")):
            normalized = "https://cgheven.com" + normalized

        # Encode path/query safely without double-encoding:
        # decode (unquote) first, then encode (quote) once.
        # IMPORTANT: Preserve + signs or convert to %20 (not %2B) for compatibility
        try:
            split = urllib.parse.urlsplit(normalized)
            if split.scheme and split.netloc:
                # Decode first to handle already-encoded URLs
                path_decoded = urllib.parse.unquote(split.path)
                # Replace + with space, then encode (so + becomes %20, not %2B)
                path_decoded = path_decoded.replace('+', ' ')
                path = urllib.parse.quote(path_decoded, safe="/")
                
                query_decoded = urllib.parse.unquote(split.query)
                query_decoded = query_decoded.replace('+', ' ')
                query = urllib.parse.quote(query_decoded, safe="=&")
                
                fragment_decoded = urllib.parse.unquote(split.fragment)
                fragment_decoded = fragment_decoded.replace('+', ' ')
                fragment = urllib.parse.quote(fragment_decoded, safe="")
                
                normalized = urllib.parse.urlunsplit((split.scheme, split.netloc, path, query, fragment))
            else:
                # Fallback: treat as a path-like URL
                decoded = urllib.parse.unquote(normalized)
                decoded = decoded.replace('+', ' ')
                normalized = urllib.parse.quote(decoded, safe="/:")
        except Exception as e:
            if CGHEVEN_DEBUG_VERBOSE:
                print(f"[URL FIX] Error normalizing URL {repr(raw)}: {e}")
            normalized = raw

        if normalized != raw and CGHEVEN_DEBUG_VERBOSE:
            print(f"[URL FIX] url: {repr(raw)} -> {repr(normalized)}")

        return normalized

    def _resize_image_cover(self, img, target_w, target_h):
        try:
            target_w = max(1, int(target_w))
            target_h = max(1, int(target_h))
            iw, ih = img.size
            if iw <= 0 or ih <= 0:
                return img
            scale = max(target_w / float(iw), target_h / float(ih))
            nw = max(1, int(iw * scale))
            nh = max(1, int(ih * scale))
            resized = img.resize((nw, nh), Image.LANCZOS)
            left = max(0, (nw - target_w) // 2)
            top = max(0, (nh - target_h) // 2)
            return resized.crop((left, top, left + target_w, top + target_h))
        except Exception:
            return img

    def _resize_image_contain(self, img, target_w, target_h, bg="#080808"):
        try:
            target_w = max(1, int(target_w))
            target_h = max(1, int(target_h))
            iw, ih = img.size
            if iw <= 0 or ih <= 0:
                return img
            scale = min(target_w / float(iw), target_h / float(ih))
            nw = max(1, int(iw * scale))
            nh = max(1, int(ih * scale))
            resized = img.resize((nw, nh), Image.LANCZOS)
            mode = "RGBA" if ("A" in resized.mode or str(bg).startswith("#")) else "RGB"
            canvas = Image.new(mode, (target_w, target_h), bg)
            x = (target_w - nw) // 2
            y = (target_h - nh) // 2
            canvas.paste(resized, (x, y))
            return canvas
        except Exception:
            return img

    # ── Thumbnail Loading (FIX: NO disk debug writes) ─────────────
    def _get_cache_path(self, url):
        url_hash = hashlib.md5(url.encode()).hexdigest()
        return os.path.join(self.cache_dir, f"{url_hash}.png")

    def _load_thumbnail_from_cache(self, url):
        try:
            cp = self._get_cache_path(url)
            if os.path.exists(cp):
                with open(cp, 'rb') as f:
                    return f.read()
        except Exception:
            pass
        return None

    def _save_thumbnail_to_cache(self, url, data):
        try:
            with open(self._get_cache_path(url), 'wb') as f:
                f.write(data)
        except Exception:
            pass

    def _load_thumbnail_safe(self, url, timeout=10, use_cache=True):
        """Load thumbnail with caching — no blocking disk debug writes."""
        if use_cache:
            cached = self._load_thumbnail_from_cache(url)
            if cached:
                return cached
        headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
                          'AppleWebKit/537.36 (KHTML, like Gecko) '
                          'Chrome/120.0.0.0 Safari/537.36',
            'Referer': 'https://cgheven.com/',
        }
        try:
            if " " in url:
                parsed = urllib.parse.urlparse(url)
                url = urllib.parse.urlunparse((
                    parsed.scheme, parsed.netloc,
                    urllib.parse.quote(parsed.path, safe="/"),
                    parsed.params, parsed.query, parsed.fragment))
            req = urllib.request.Request(url, headers=headers)
            tmo = min(timeout, 8)
            try:
                with urllib.request.urlopen(req, timeout=tmo, context=_CGH_SSL_VERIFIED) as r:
                    data = r.read()
            except urllib.error.URLError as ue:
                # Retry once without cert verification ONLY for SSL/cert failures, so a
                # broken cert store on the user's machine doesn't blank public CDN thumbs.
                reason = getattr(ue, "reason", ue)
                txt = str(reason).lower()
                is_ssl = isinstance(reason, _ssl.SSLError) or "certificate" in txt or "ssl" in txt
                if not is_ssl or _CGH_SSL_UNVERIFIED is None:
                    raise
                with urllib.request.urlopen(req, timeout=tmo, context=_CGH_SSL_UNVERIFIED) as r:
                    data = r.read()
            if use_cache and data:
                self._save_thumbnail_to_cache(url, data)
            return data
        except Exception as e:
            if CGHEVEN_DEBUG_VERBOSE:
                print(f"[THUMB ERROR] {e}")
            return None

    def _load_single_thumb_worker(self, aid, url):
        try:
            if aid in self.thumbnails:
                return None
            data = self._load_thumbnail_safe(url, timeout=10)
            if not data:
                return None
            import io
            img = Image.open(io.BytesIO(data))
            target = int(getattr(self, "_thumb_target_size", 220) or 220)
            img = self._resize_image_contain(img, target, target, bg="#080808")
            buf = io.BytesIO()
            img.save(buf, format="PNG")
            buf.seek(0)
            return (aid, buf)
        except Exception as e:
            if CGHEVEN_DEBUG_VERBOSE:
                print(f"[THUMB WORKER ERROR] asset {aid}: {e}")
            return None

    def _load_thumbs(self, asset_ids):
        thumb_tasks = []
        for asset in self.filtered:
            aid = asset.get("id")
            if aid not in asset_ids:
                continue
            a   = asset.get("attributes", asset)
            url = a.get("thumbnail", "")
            if url and aid not in self.thumbnails:
                thumb_tasks.append((aid, url))
        if not thumb_tasks:
            return
        visible   = thumb_tasks[:40]
        remaining = thumb_tasks[40:]
        fut_to_aid = {}
        for aid, url in visible:
            f = self.thumb_executor.submit(self._load_single_thumb_worker, aid, url)
            fut_to_aid[f] = aid
        for future in as_completed(list(fut_to_aid.keys())):
            aid = fut_to_aid.get(future)
            try:
                result = future.result()
                if result:
                    r_aid, buf = result
                    self.after_idle(lambda aid=r_aid, buf=buf: self._create_photo_from_image_batch(aid, buf))
                else:
                    self.after_idle(lambda aid=aid: self._set_thumb_placeholder(aid))
            except Exception:
                self.after_idle(lambda aid=aid: self._set_thumb_placeholder(aid))
        for aid, url in remaining:
            future = self.thumb_executor.submit(self._load_single_thumb_worker, aid, url)
            def _bg(f=future, aid=aid):
                try:
                    result = f.result()
                    if result:
                        r_aid, buf = result
                        self.after_idle(lambda aid=r_aid, buf=buf: self._create_photo_from_image_batch(aid, buf))
                    else:
                        self.after_idle(lambda aid=aid: self._set_thumb_placeholder(aid))
                except Exception:
                    self.after_idle(lambda aid=aid: self._set_thumb_placeholder(aid))
            threading.Thread(target=_bg, daemon=True).start()

    def _enqueue_thumb_load(self, asset_ids):
        """Debounced thumbnail loading to reduce UI lag."""
        try:
            if not asset_ids:
                return
            cur = set(getattr(self, "_thumb_queue", []) or [])
            for aid in asset_ids:
                if aid is not None:
                    cur.add(aid)
            self._thumb_queue = list(cur)
            if getattr(self, "_thumb_flush_job", None):
                return
            self._thumb_flush_job = self.after(120, self._flush_thumb_queue)
        except Exception:
            pass

    def _flush_thumb_queue(self):
        self._thumb_flush_job = None
        try:
            ids = list(getattr(self, "_thumb_queue", []) or [])
            self._thumb_queue = []
            if not ids:
                return
            if getattr(self, "_grid_scroll_suppress_thumbs", False):
                # Don't decode in background while wheel is moving — causes card smear in Resolve.
                self._thumb_queue = ids
                self._thumb_flush_job = self.after(100, self._flush_thumb_queue)
                return
            threading.Thread(target=self._load_thumbs, args=(ids,), daemon=True).start()
        except Exception:
            pass

    def _create_photo_from_image_batch(self, aid, buf):
        try:
            if self._grid_thumbs_paused():
                self._thumb_deferred_batch.append((aid, buf))
                return
            if not (hasattr(self, "winfo_exists") and self.winfo_exists()):
                return
            import io
            photo = ImageTk.PhotoImage(Image.open(buf), master=self)
            self.thumbnails[aid] = photo
            self._set_thumb(aid, photo)
        except Exception as e:
            if CGHEVEN_DEBUG_VERBOSE:
                print(f"[PHOTO ERROR] asset {aid}: {e}")

    def _load_single_thumb_for_card(self, card, asset):
        try:
            a   = asset.get("attributes", asset)
            aid = asset.get("id", 0)
            url = a.get("thumbnail", "")
            if url and hasattr(card, "thumb_lbl"):
                self._load_single_thumb(aid, url, card.thumb_lbl)
        except Exception:
            pass

    def _load_single_thumb(self, aid, url, thlbl):
        """Load a single thumbnail — fast, no file I/O debug writes."""
        try:
            if aid in self.thumbnails:
                self.after(0, lambda: self._display_thumb(thlbl, self.thumbnails[aid]))
                return
            data = self._load_thumbnail_safe(url, timeout=10)
            if data is None:
                self.after_idle(lambda thlbl=thlbl: self._apply_thumb_placeholder(thlbl))
                return
            import io
            img = Image.open(io.BytesIO(data))
            target = int(getattr(self, "_thumb_target_size", 220) or 220)
            img = self._resize_image_contain(img, target, target, bg="#080808")
            buf = io.BytesIO()
            img.save(buf, format="PNG")
            buf.seek(0)
            self.after_idle(lambda aid=aid, buf=buf, thlbl=thlbl:
                            self._create_photo_from_image(aid, buf, thlbl))
        except Exception as e:
            if CGHEVEN_DEBUG_VERBOSE:
                print(f"[SINGLE THUMB ERROR] {e}")
            self.after_idle(lambda thlbl=thlbl: self._apply_thumb_placeholder(thlbl))

    def _create_photo_from_image(self, aid, buf, thlbl):
        try:
            if not (thlbl and hasattr(thlbl, 'winfo_exists') and thlbl.winfo_exists()):
                return
            import io
            photo = ImageTk.PhotoImage(Image.open(buf), master=self)
            self.thumbnails[aid] = photo
            self._display_thumb(thlbl, photo)
        except Exception as e:
            if CGHEVEN_DEBUG_VERBOSE:
                print(f"[CREATE PHOTO ERROR] {e}")

    def _display_thumb(self, thlbl, photo):
        try:
            if self._grid_thumbs_paused():
                self._thumb_deferred_widgets.append((thlbl, photo))
                return
            if not (thlbl and hasattr(thlbl, 'winfo_exists') and thlbl.winfo_exists()):
                return
            thlbl.configure(image=photo, text="")
            thlbl.image = photo
            # Store as original for restore on hover leave
            try:
                card = thlbl.master
                if hasattr(card, "original_thumb"):
                    card.original_thumb = photo
            except Exception:
                pass
        except Exception:
            pass

    def _set_thumb(self, aid, photo):
        try:
            if self._grid_thumbs_paused():
                self._thumb_deferred_set.append((aid, photo))
                return
            card = self._grid_cards_by_id.get(aid)
            if card and hasattr(card, "thumb_lbl"):
                self._display_thumb(card.thumb_lbl, photo)
        except Exception:
            pass

    def _apply_thumb_placeholder(self, thlbl):
        """Show a subtle 'No preview' tile instead of a blank black box when a
        thumbnail can't be fetched/decoded (or Pillow is unavailable)."""
        try:
            if thlbl and hasattr(thlbl, "winfo_exists") and thlbl.winfo_exists():
                thlbl.configure(image="", text="🖼\nNo preview",
                                fg="#4A4A57", font=("Segoe UI", 8),
                                compound="center", justify="center")
                thlbl.image = None
        except Exception:
            pass

    def _set_thumb_placeholder(self, aid):
        try:
            card = self._grid_cards_by_id.get(aid)
            if card and hasattr(card, "thumb_lbl"):
                self._apply_thumb_placeholder(card.thumb_lbl)
        except Exception:
            pass

    # ── Format / Resolution / Download ───────────────────────────
    def _show_format_menu(self, card, formats):
        try:
            menu = tk.Menu(self, tearoff=0, bg=C["card"], fg=C["text"],
                           activebackground=C["accent"], activeforeground="white",
                           font=("Segoe UI", 9))
            for fmt in formats:
                menu.add_command(label=fmt, command=lambda f=fmt, c=card: self._select_format(c, f))
            btn = card.format_btn
            menu.post(btn.winfo_rootx(), btn.winfo_rooty() + btn.winfo_height())
            try:
                card.selection_row.pack(fill="x", padx=3, pady=(1, 2))
            except Exception:
                pass
        except Exception as e:
            print(f"[FORMAT MENU ERROR] {e}")

    def _select_format(self, card, fmt):
        try:
            card.selected_format = fmt
            card.format_btn.configure(text="⬇")
            parsed_files = getattr(card, "parsed_files", [])
            matching = [pf for pf in parsed_files
                        if fmt.lower() in pf.get("format","").lower() or
                           pf.get("format","").lower() in fmt.lower()]
            resolutions = sorted(set(pf.get("resolution","") for pf in matching if pf.get("resolution")))
            if resolutions:
                card.resolution_btn.pack(side="left", padx=(0,4))
                card.resolution_btn.configure(text="RES")
            else:
                card.selected_resolution = ""
                self._find_and_set_selected_file(card)
                if getattr(card, "selected_file", None):
                    self._start_download(getattr(card, "asset", None), card)
                else:
                    self._show_download_button(card)
        except Exception as e:
            print(f"[SELECT FORMAT ERROR] {e}")

    def _show_resolution_menu(self, card):
        try:
            parsed_files = getattr(card, "parsed_files", [])
            fmt = getattr(card, "selected_format", None)
            if not fmt:
                return
            matching = [pf for pf in parsed_files
                        if fmt.lower() in pf.get("format","").lower() or
                           pf.get("format","").lower() in fmt.lower()]
            resolutions = sorted(set(pf.get("resolution","") for pf in matching if pf.get("resolution")))
            if not resolutions:
                resolutions = [""]
            menu = tk.Menu(self, tearoff=0, bg=C["card"], fg=C["text"],
                           activebackground=C["accent"], activeforeground="white",
                           font=("Segoe UI", 9))
            for res in resolutions:
                menu.add_command(label=res or "Any Resolution",
                                 command=lambda r=res, c=card: self._select_resolution(c, r))
            btn = card.resolution_btn
            menu.post(btn.winfo_rootx(), btn.winfo_rooty() + btn.winfo_height())
        except Exception as e:
            print(f"[RES MENU ERROR] {e}")

    def _select_resolution(self, card, res):
        try:
            card.selected_resolution = res
            card.resolution_btn.configure(text=f"RES: {res}" if res else "RES: Any")
            self._find_and_set_selected_file(card)
            self._show_download_button(card)
        except Exception as e:
            print(f"[SELECT RES ERROR] {e}")

    def _find_and_set_selected_file(self, card):
        try:
            fmt  = getattr(card, "selected_format", None)
            res  = getattr(card, "selected_resolution", None)
            parsed_files = getattr(card, "parsed_files", [])
            if not fmt:
                return
            match = None
            for pf in parsed_files:
                fm = fmt.lower() in pf.get("format","").lower() or pf.get("format","").lower() in fmt.lower()
                rm = not res or res == "" or res == pf.get("resolution","")
                if fm and rm:
                    match = pf; break
            if not match:
                for pf in parsed_files:
                    if fmt.lower() in pf.get("format","").lower():
                        match = pf; break
            card.selected_file = match
            if match and getattr(card, "size_lbl", None):
                fmt_str = match.get("format", "")
                res_str = match.get("resolution", "")
                card.size_lbl.configure(
                    text=f"{fmt_str}" + (f" · {res_str}" if res_str else ""))
        except Exception as e:
            print(f"[FIND FILE ERROR] {e}")

    def _show_download_button(self, card):
        try:
            card.format_btn.pack_forget()
            card.resolution_btn.pack_forget()
            card.download_btn.pack(fill="both", expand=True)
            card.download_btn.configure(text="Download", bg=C["dl_btn"], fg=C["text"])
        except Exception as e:
            print(f"[SHOW DL BTN ERROR] {e}")

    # ── Card: Smart default + Download menu ───────────────────────
    def _pick_smart_default_file(self, parsed_files):
        """
        Smart default:
        - For 3D model ZIPs: prefer GLTF → FBX → OBJ → ABC → USDC → BLEND
        - For video/image: mp4 → mov → exr → webm → vdb → fbx → glb → abc → png → hdr
        - Prefer resolution: 1K → 2K → 4K → Any/blank → everything else
        """
        files = list(parsed_files or [])
        if not files:
            return None

        # 3D format priority (by format field for ZIP-based 3D models)
        fmt_3d_rank = {"GLTF": 0, "FBX": 1, "OBJ": 2, "ABC": 3, "USDC": 4, "VDB": 5, "BLEND": 6}
        fmt_order = [".mp4", ".mov", ".exr", ".webm", ".vdb", ".fbx", ".glb", ".abc", ".png", ".hdr", ".zip"]
        fmt_rank = {ext: i for i, ext in enumerate(fmt_order)}

        def _res_rank(res):
            r = str(res or "").strip().upper()
            if r in ("1K", "2K", "4K"):
                return {"1K": 0, "2K": 1, "4K": 2}[r]
            if r in ("", "ANY", "N/A", "NA"):
                return 3
            m = re.search(r"(\d+)\s*K", r)
            if m:
                try:
                    k = int(m.group(1))
                    return 5 + k
                except Exception:
                    return 999
            return 999

        def _key(pf):
            ext = str(pf.get("ext", "") or "").lower()
            fmt = str(pf.get("format", "") or "").upper()
            # ZIP files with known 3D format — rank by 3D format priority
            if ext == ".zip" and fmt in fmt_3d_rank:
                return (fmt_3d_rank[fmt], _res_rank(pf.get("resolution", "")), str(pf.get("filename", ""))[:120].lower())
            return (
                len(fmt_3d_rank) + fmt_rank.get(ext, 50),
                _res_rank(pf.get("resolution", "")),
                str(pf.get("filename", ""))[:120].lower(),
            )

        files.sort(key=_key)
        return files[0] if files else None

    def _card_download_specific(self, asset, card, file_info):
        try:
            if not file_info:
                return
            card.selected_file = file_info
            self._start_download(asset, card)
        except Exception:
            pass

    def _card_download_smart_default(self, asset, card):
        try:
            parsed_files = getattr(card, "parsed_files", []) or []
            pick = self._pick_download_default_file(asset, parsed_files)
            if not pick:
                # Fallback: open details panel so user can choose
                self._show_asset_in_right_panel(asset)
                return
            card.selected_file = pick
            self._start_download(asset, card)
        except Exception as e:
            print(f"[CARD DL DEFAULT ERROR] {e}")

    def _asset_slug(self, asset):
        try:
            a = asset.get("attributes", asset)
            # Strapi payload uses `assets_slug` for website pages
            return str(a.get("assets_slug", a.get("Slug", a.get("slug", ""))) or "")
        except Exception:
            return ""

    def _show_card_download_menu(self, asset, card):
        try:
            parsed_files = getattr(card, "parsed_files", []) or []
            aid = asset.get("id", 0)
            cat_slug = _cgheven_relation_slug(
                (asset.get("attributes", asset) or {}).get("categorie") or {}
            )
            if not parsed_files:
                if cat_slug == "3d-models":
                    menu = tk.Menu(self, tearoff=0, bg=C["card"], fg=C["text"],
                                   font=("Segoe UI", 9))
                    menu.add_command(label="  GLTF — Coming Soon", state="disabled",
                                     font=("Segoe UI", 9))
                    btn = getattr(card, "menu_btn", None) or getattr(card, "download_btn", None)
                    if btn and btn.winfo_exists():
                        menu.post(btn.winfo_rootx(), btn.winfo_rooty() + btn.winfo_height())
                return

            menu = tk.Menu(self, tearoff=0,
                           bg=C["card"], fg=C["text"],
                           activebackground=C["accent"], activeforeground="white",
                           font=("Segoe UI", 9))

            # Group by ext (format headings)
            groups = {}
            for pf in parsed_files:
                ext = str(pf.get("ext", "") or "").lower()
                lbl = ext.lstrip(".").upper() or "FILE"
                groups.setdefault(lbl, []).append(pf)

            preferred = ["MP4","MOV","EXR","WEBM","ZIP","VDB","FBX","GLB","ABC","PNG","HDR"]
            ordered_keys = ([k for k in preferred if k in groups]
                            + sorted(k for k in groups if k not in preferred))

            for fmt_label in ordered_keys:
                # Format heading (disabled, acts as section separator)
                menu.add_command(label=f"  {fmt_label}", state="disabled",
                                 font=("Segoe UI", 8, "bold"))

                by_res = {}
                for pf in groups[fmt_label]:
                    res = str(pf.get("resolution", "") or "").strip().upper()
                    by_res.setdefault(res, []).append(pf)

                res_order = ["1K","2K","4K","8K"]
                res_keys = ([r for r in res_order if r in by_res]
                            + sorted(r for r in by_res if r not in res_order))

                for res in res_keys:
                    pf = (by_res.get(res) or [None])[0]
                    if not pf:
                        continue
                    ext_key = str(pf.get("ext", "") or "").lower()

                    # Lock state is decided ONLY by the backend: this exact entry is a CGHLOCKED
                    # premium marker (no real URL), or the whole asset is patreon-locked.
                    _is_locked = bool(pf.get("locked")) or self._locked_for_user(asset)

                    # Check if this specific format+resolution was downloaded
                    dl_rec = self._get_downloaded_file_for_asset(aid, res=res,
                                                                  fmt_ext=ext_key)
                    if dl_rec and not _is_locked:
                        row_label = f"    {res} (Import)" if res else "    Import"
                        menu.add_command(
                            label=row_label,
                            foreground="#34C759",
                            command=lambda dr=dl_rec: self._manual_import_to_resolve(dr),
                        )
                    elif _is_locked:
                        row_label = f"    \U0001f512 {res} — Upgrade" if res else "    \U0001f512 Upgrade"
                        menu.add_command(
                            label=row_label,
                            foreground=C["gold"],
                            command=lambda: self._open_upgrade_url(),
                        )
                    else:
                        row_label = f"    {res} (Download)" if res else "    Download"
                        menu.add_command(
                            label=row_label,
                            command=lambda p=pf, a=asset, c=card:
                                self._card_download_specific(a, c, p),
                        )
                menu.add_separator()

            # Favourite toggle (mirrors the After Effects dropdown). Heart fills when favourited.
            is_fav = self._is_favorite_asset(asset)
            menu.add_command(
                label=("  ♥  Remove from Favorites" if is_fav
                       else "  ♡  Add to Favorites"),
                foreground=("#FF6B8A" if is_fav else C["text"]),
                command=lambda a=asset: self._toggle_favorite_asset(a),
            )

            # View online
            slug = self._asset_slug(asset)
            if slug:
                menu.add_command(
                    label="  \U0001f310  View online",
                    command=lambda s=slug:
                        webbrowser.open(f"https://cgheven.com/assets/{s}"),
                )

            btn = getattr(card, "menu_btn", None) or getattr(card, "download_btn", None)
            if btn and btn.winfo_exists():
                menu.post(btn.winfo_rootx(), btn.winfo_rooty() + btn.winfo_height())
        except Exception as e:
            print(f"[CARD DL MENU ERROR] {e}")

    # ── Download Thread ───────────────────────────────────────────
    def _start_download(self, asset, card):
        try:
            if not getattr(card, "selected_file", None):
                messagebox.showwarning("No File Selected", "Please select format first.")
                return
            file_info = card.selected_file
            file_url  = file_info.get("url","")
            # Locked (CGHLOCKED premium marker) files have NO real URL — the backend stripped it,
            # so it can never download. Free (1K/2K + Preview) files download for ANYONE, no login.
            if file_info.get("locked") or not file_url:
                self._open_upgrade_url()
                return

            self.download_counter += 1
            did = self.download_counter
            pause_event = threading.Event()
            pause_event.set()
            dl_info = {
                "id":               did,
                "asset":            asset,
                "file_info":        file_info,
                "url":              file_url,
                "filename":         file_info.get("filename","file"),
                "status":           "downloading",
                "progress":         0,
                "total_bytes":      file_info.get("size_bytes", 0),
                "downloaded_bytes": 0,
                "start_time":       time.time(),
                "card":             card,
                "pause_event":      pause_event,
                "cancelled":        False,
                "thread":           None,
            }
            self.active_downloads[did] = dl_info
            card.download_id = did
            t = threading.Thread(target=self._download_file_thread,
                                 args=(did, file_url, file_info), daemon=True)
            dl_info["thread"] = t
            t.start()
            self._update_download_controls(card, did)
            self.status_var.set(f"Downloading: {file_info.get('filename','file')}")
            if self.downloads_view_active:
                self._render_downloads_view()
        except Exception as e:
            messagebox.showerror("Download Error", f"Failed to start download: {e}")

    def _download_file_thread(self, did, url, file_info):
        """Download with auto-retry (up to 3 attempts) and resume support."""
        max_attempts = 3
        for attempt in range(1, max_attempts + 1):
            filepath = ""
            try:
                if did not in self.active_downloads:
                    return
                dl_info = self.active_downloads[did]
                if dl_info.get("cancelled"):
                    return

                _custom_dl = getattr(self, "_custom_download_path", "") or ""
                _default_dl = os.path.join(os.path.expanduser("~"), "Downloads", "CGHEVEN")
                downloads_dir = _custom_dl if _custom_dl else _default_dl
                try:
                    os.makedirs(downloads_dir, exist_ok=True)
                except Exception:
                    downloads_dir = _default_dl
                    os.makedirs(downloads_dir, exist_ok=True)
                filename = file_info.get("filename", f"file_{did}")
                filepath = os.path.join(downloads_dir, filename)

                headers = {
                    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '                                   'AppleWebKit/537.36 (KHTML, like Gecko) '                                   'Chrome/120.0.0.0 Safari/537.36',
                    'Referer': 'https://cgheven.com/',
                    'Connection': 'keep-alive',
                }

                # Resume support: if partial file exists from previous attempt
                resume_from = 0
                if attempt > 1 and os.path.exists(filepath):
                    resume_from = os.path.getsize(filepath)
                    if resume_from > 0:
                        headers['Range'] = f'bytes={resume_from}-'
                        print(f"[DOWNLOAD] Attempt {attempt} — resuming from {resume_from} bytes")
                    else:
                        resume_from = 0
                elif attempt > 1:
                    print(f"[DOWNLOAD] Attempt {attempt} — restarting")

                parsed = urllib.parse.urlparse(url)
                encoded_url = urllib.parse.urlunparse((
                    parsed.scheme, parsed.netloc,
                    urllib.parse.quote(parsed.path, safe='/'),
                    parsed.params, parsed.query, parsed.fragment))

                req = urllib.request.Request(encoded_url, headers=headers)

                # socket.setdefaulttimeout only affects connect, not read
                # Use a larger block_size to reduce number of read calls
                import socket
                old_timeout = socket.getdefaulttimeout()
                socket.setdefaulttimeout(120)  # 2 min per read — handles slow servers

                try:
                    with urllib.request.urlopen(req, timeout=120) as response:
                        total_from_header = int(response.headers.get('Content-Length', 0))
                        total = total_from_header + resume_from if total_from_header else 0
                        downloaded = resume_from
                        block_size  = 65536  # 64KB chunks — fewer reads = fewer timeouts
                        pause_event = dl_info.get("pause_event")

                        mode = 'ab' if resume_from > 0 else 'wb'
                        with open(filepath, mode) as f:
                            while True:
                                if did not in self.active_downloads or dl_info.get("cancelled"):
                                    try: os.remove(filepath)
                                    except: pass
                                    return
                                if pause_event:
                                    pause_event.wait()
                                chunk = response.read(block_size)
                                if not chunk:
                                    break
                                f.write(chunk)
                                downloaded += len(chunk)
                                if did in self.active_downloads:
                                    progress = (downloaded / total * 100) if total > 0 else 0
                                    dl_info["downloaded_bytes"] = downloaded
                                    dl_info["progress"]         = min(progress, 99.9)
                                    dl_info["total_bytes"]      = total
                                    self.after(0, lambda: self._update_download_progress(did))
                finally:
                    socket.setdefaulttimeout(old_timeout)

                # Success
                if did in self.active_downloads:
                    dl_info = self.active_downloads.pop(did)
                    dl_info["status"]   = "completed"
                    dl_info["progress"] = 100
                    dl_info["filepath"] = filepath
                    dl_info["end_time"] = time.time()
                    # Analytics: download performance (sampled)
                    try:
                        if ANALYTICS_AVAILABLE and _analytics and _analytics_distinct_id():
                            start_t = float(dl_info.get("start_time") or 0) or 0.0
                            end_t = float(dl_info.get("end_time") or time.time())
                            dur_s = max(0.001, end_t - start_t) if start_t else 0.0
                            size_b = int(dl_info.get("downloaded_bytes") or dl_info.get("total_bytes") or 0)
                            speed_kbps = int((size_b / 1024.0) / dur_s) if dur_s > 0 else 0
                            _analytics.send_download_performance(
                                license_server=LICENSE_SERVER,
                                addon_slug=CGHEVEN_ADDON_SLUG,
                                distinct_id=_analytics_distinct_id(),
                                asset=dl_info.get("asset") or {},
                                status="success",
                                file_size_bytes=size_b,
                                download_time_ms=int(dur_s * 1000),
                                avg_speed_kbps=speed_kbps,
                                error="",
                                sample_rate=0.5,
                            )
                    except Exception:
                        pass
                    self.completed_downloads.append(dl_info)
                    self._save_download_history()
                    self.after(0, lambda: self._on_download_complete(did, dl_info))
                return  # done — exit retry loop

            except Exception as e:
                err_str = str(e)
                print(f"[DOWNLOAD] Attempt {attempt}/{max_attempts} failed: {err_str}")
                if did not in self.active_downloads:
                    return
                dl_info = self.active_downloads.get(did, {})
                if dl_info.get("cancelled"):
                    return
                if attempt < max_attempts:
                    # On 404: partial file is invalid (URL expired or file gone on CDN).
                    # Delete it so the next attempt starts from byte 0 instead of resuming
                    # from a stale offset that will keep triggering 404.
                    if "404" in err_str and filepath:
                        try:
                            if os.path.exists(filepath):
                                os.remove(filepath)
                        except Exception:
                            pass
                    # Update UI to show retrying
                    self.after(0, lambda a=attempt: self.status_var.set(
                        f"Download interrupted — retrying ({a}/{max_attempts-1})..."))
                    time.sleep(2 * attempt)  # wait 2s, 4s before retrying
                    continue
                # All attempts failed
                if did in self.active_downloads:
                    dl_info = self.active_downloads.pop(did)
                    dl_info["status"] = "failed"
                    dl_info["error"]  = f"{err_str} (failed after {max_attempts} attempts)"
                    # Analytics: download performance (sampled)
                    try:
                        if ANALYTICS_AVAILABLE and _analytics and _analytics_distinct_id():
                            start_t = float(dl_info.get("start_time") or 0) or 0.0
                            end_t = time.time()
                            dur_s = max(0.001, end_t - start_t) if start_t else 0.0
                            size_b = int(dl_info.get("downloaded_bytes") or dl_info.get("total_bytes") or 0)
                            speed_kbps = int((size_b / 1024.0) / dur_s) if dur_s > 0 else 0
                            _analytics.send_download_performance(
                                license_server=LICENSE_SERVER,
                                addon_slug=CGHEVEN_ADDON_SLUG,
                                distinct_id=_analytics_distinct_id(),
                                asset=dl_info.get("asset") or {},
                                status="failed",
                                file_size_bytes=size_b,
                                download_time_ms=int(dur_s * 1000),
                                avg_speed_kbps=speed_kbps,
                                error=str(err_str or "")[:200],
                                sample_rate=0.5,
                            )
                    except Exception:
                        pass
                    self.completed_downloads.append(dl_info)
                    self._save_download_history()
                    self.after(0, lambda: self._on_download_failed(did, dl_info))
                print(f"[DOWNLOAD ERROR] Gave up: {err_str}")

    def _update_download_progress(self, did):
        try:
            if did not in self.active_downloads:
                return
            dl_info  = self.active_downloads[did]
            progress = dl_info.get("progress", 0)
            filename = dl_info.get("filename","file")
            self.status_var.set(f"Downloading: {filename} ({progress:.1f}%)")
            card = dl_info.get("card")
            if card:
                try:
                    if card.winfo_exists():
                        self._update_download_controls(card, did)
                except: pass
            if self.downloads_view_active and not getattr(self, "_dl_refresh_pending", False):
                self._dl_refresh_pending = True
                self.after(100, self._refresh_dl_debounced)
        except Exception:
            pass

    def _refresh_dl_debounced(self):
        self._dl_refresh_pending = False
        if self.downloads_view_active:
            self._render_downloads_view()

    def _update_download_controls(self, card, did):
        try:
            if not card or not card.winfo_exists():
                return
            if did not in self.active_downloads:
                return
            dl_info  = self.active_downloads[did]
            progress = dl_info.get("progress", 0)
            is_paused = dl_info.get("status") == "paused"

            def _draw_progress_canvas(pb, prog):
                """Draw progress fill + label using the canvas' actual height (aligns with side buttons)."""
                try:
                    pb.update_idletasks()
                    w2 = int(pb.winfo_width() or 140)
                    h2 = int(pb.winfo_height() or 22)
                    w2 = max(2, w2)
                    h2 = max(10, h2)
                    pw = max(2, int(w2 * prog / 100))
                    pb.delete("all")
                    pb.create_rectangle(0, 0, w2, h2, fill="#1A1A2A", outline="")
                    pb.create_rectangle(0, 0, pw, h2, fill="#27AE60", outline="")
                    if pw > 6:
                        shine_h = max(3, min(6, h2 // 3))
                        pb.create_rectangle(0, 0, pw, shine_h, fill="#34C759", outline="")
                    pb.create_text(
                        w2 // 2, h2 // 2, text=f"{prog:.0f}%",
                        fill="white", font=("Segoe UI", 7, "bold"),
                    )
                except Exception:
                    pass

            # ── Fast path: if UI already built, just update values ────────
            pb      = getattr(card, "progress_bar_canvas", None)
            pause_b = getattr(card, "pause_btn", None)
            if pb and pause_b and pb.winfo_exists() and pause_b.winfo_exists():
                try:
                    pause_b.configure(text="▶" if is_paused else "⏸")
                    _draw_progress_canvas(pb, progress)
                except Exception:
                    pass
                return

            # ── First call: build progress UI, hide download button ───────
            sel_row = getattr(card, "selection_row", None)
            if not sel_row:
                return
            try:
                btn_outer = getattr(card, "_btn_outer", None)
                if btn_outer:
                    btn_outer.pack_forget()
                sel_row.pack(fill="both", expand=True)
            except Exception:
                pass

            for attr in ("format_btn", "resolution_btn", "download_btn", "menu_btn"):
                w = getattr(card, attr, None)
                if w:
                    try: w.pack_forget()
                    except Exception: pass
            for attr in ("pause_btn", "cancel_btn", "progress_bar_canvas", "progress_lbl"):
                w = getattr(card, attr, None)
                if w:
                    try: w.destroy()
                    except Exception: pass

            if not sel_row.winfo_exists():
                return

            # ── Progress row: same visual as download button ──────────────
            ctrl = tk.Frame(sel_row, bg=C["dl_btn"])
            ctrl.pack(fill="both", expand=True, padx=1, pady=1)

            # Use grid so cancel never gets pushed off-screen
            ctrl.columnconfigure(2, weight=1)

            # LEFT: Pause ⏸ / Resume ▶
            pause_btn = tk.Button(
                ctrl,
                text="▶" if is_paused else "⏸",
                bg=C["dl_btn"], fg="#AAAACC",
                activebackground=C["accent"], activeforeground="white",
                relief="flat", cursor="hand2", bd=0,
                font=("Segoe UI", 10),
                width=2,
                command=lambda d=did: self._toggle_pause_download(d),
            )
            pause_btn.grid(row=0, column=0, sticky="ns", padx=(2, 2))
            card.pause_btn = pause_btn

            tk.Frame(ctrl, bg=C["dl_border"], width=1).grid(
                row=0, column=1, sticky="ns", pady=3
            )

            # MIDDLE: Canvas progress bar with % text overlay
            pb_canvas = tk.Canvas(
                ctrl, height=22, bg=C["dl_btn"],
                highlightthickness=0,
            )
            pb_canvas.grid(row=0, column=2, sticky="ns", padx=6, pady=3)
            card.progress_bar_canvas = pb_canvas
            card.progress_lbl = None  # replaced by canvas text

            pb_canvas.after_idle(lambda pb=pb_canvas, prog=progress: _draw_progress_canvas(pb, prog))

            tk.Frame(ctrl, bg=C["dl_border"], width=1).grid(
                row=0, column=3, sticky="ns", pady=3
            )

            # RIGHT: Cancel ✕
            cancel_btn = tk.Button(
                ctrl,
                text="✕",
                bg=C["dl_btn"], fg="#CC4444",
                activebackground=C["red"], activeforeground="white",
                relief="flat", cursor="hand2", bd=0,
                font=("Segoe UI", 10),
                width=2,
                command=lambda d=did: self._cancel_download(d),
            )
            cancel_btn.grid(row=0, column=4, sticky="ns", padx=(2, 2))
            card.cancel_btn = cancel_btn

        except Exception:
            pass

    def _toggle_pause_download(self, did):
        try:
            if did not in self.active_downloads:
                return
            dl_info     = self.active_downloads[did]
            pause_event = dl_info.get("pause_event")
            if dl_info.get("status") == "paused":
                dl_info["status"] = "downloading"
                if pause_event: pause_event.set()
                self.status_var.set(f"Resuming: {dl_info.get('filename','file')}")
            else:
                dl_info["status"] = "paused"
                if pause_event: pause_event.clear()
                self.status_var.set(f"Paused: {dl_info.get('filename','file')}")
            card = dl_info.get("card")
            if card: self._update_download_controls(card, did)
            if self.downloads_view_active: self._render_downloads_view()
        except Exception as e:
            print(f"[PAUSE ERROR] {e}")

    def _cancel_download(self, did):
        try:
            if did not in self.active_downloads:
                return
            dl_info = self.active_downloads[did]
            dl_info["cancelled"] = True
            dl_info["status"]    = "cancelled"
            pe = dl_info.get("pause_event")
            if pe: pe.set()
            self.active_downloads.pop(did, None)
            card = dl_info.get("card")
            if card:
                card.download_id = None
                if getattr(card, "is_rp_proxy", False):
                    self._rp_restore_panel_after_download()
                else:
                    for attr in ("pause_btn", "cancel_btn", "progress_lbl", "progress_bar_canvas"):
                        btn = getattr(card, attr, None)
                        if btn:
                            try:
                                btn.destroy()
                            except Exception:
                                pass
                    sel_row = getattr(card, "selection_row", None)
                    if sel_row:
                        try:
                            sel_row.pack_forget()
                        except Exception:
                            pass
                    # Restore card action row after cancel
                    try:
                        btn_outer = getattr(card, "_btn_outer", None)
                        if btn_outer:
                            btn_outer.configure(bg=C["dl_border"])
                            btn_outer.pack(fill="both", expand=True)
                        asset_obj = getattr(card, "asset", None)
                        aid_c = asset_obj.get("id", 0) if asset_obj else 0
                        _already_dl = aid_c in self._downloaded_asset_ids()
                        dl_btn = getattr(card, "download_btn", None) or getattr(card, "format_btn", None)
                        if dl_btn and dl_btn.winfo_exists():
                            if _already_dl:
                                dl_btn.configure(text="⬆  Import", bg="#172A1A",
                                                 fg="#34C759", activebackground="#1E3A22",
                                                 command=lambda a=asset_obj, c=card:
                                                     self._card_import_asset(a, c))
                            else:
                                # Re-compute default res label
                                _pf = getattr(card, "parsed_files", [])
                                _df = self._pick_download_default_file(asset_obj, _pf) if asset_obj else self._pick_smart_default_file(_pf)
                                _rl = ""
                                if _df:
                                    _r = str(_df.get("resolution","") or "").strip()
                                    if _r.upper() not in ("","N/A","NA","ANY"):
                                        _rl = _r.upper()
                                _txt = f"⬇  {_rl}" if _rl else "⬇  Download"
                                dl_btn.configure(text=_txt, bg=C["dl_btn"],
                                                 fg=C["text"], activebackground="#26263E",
                                                 command=lambda a=asset_obj, c=card:
                                                     self._card_download_smart_default(a, c))
                            dl_btn.pack(fill="both", expand=True)
                        mb = getattr(card, "menu_btn", None)
                        if mb and mb.winfo_exists():
                            mb.pack(side="right", fill="y")
                    except Exception:
                        pass
            if self.downloads_view_active:
                self._render_downloads_view()
        except Exception as e:
            print(f"[CANCEL ERROR] {e}")

    def _on_download_complete(self, did, dl_info):
        """FIX: Immediately import to Resolve after download completes."""
        try:
            filename = dl_info.get("filename","file")
            filepath = dl_info.get("filepath","")
            self.status_var.set(f"✓ Download complete: {filename}")

            # Analytics: download completed
            try:
                if ANALYTICS_AVAILABLE and _analytics and getattr(self, "analytics_distinct_id", ""):
                    fi = dl_info.get("file_info") or {}
                    _analytics.send_download_event(
                        license_server=LICENSE_SERVER,
                        addon_slug=CGHEVEN_ADDON_SLUG,
                        addon_version=ADDON_VER,
                        distinct_id=self.analytics_distinct_id,
                        asset=dl_info.get("asset") or {},
                        file_entry={
                            "fmt": fi.get("format", "") or fi.get("fmt", ""),
                            "res": fi.get("resolution", "") or fi.get("res", ""),
                        },
                    )
            except Exception:
                pass
            # Restore card
            card = dl_info.get("card")
            if card:
                try:
                    if card.winfo_exists():
                        card.download_id = None
                        card.selected_format = None
                        card.selected_resolution = None
                        card.selected_file = None
                        if getattr(card, "is_rp_proxy", False):
                            self._rp_restore_panel_after_download()
                        else:
                            for attr in ("pause_btn", "cancel_btn", "progress_lbl", "progress_bar_canvas"):
                                btn = getattr(card, attr, None)
                                if btn:
                                    try:
                                        btn.destroy()
                                    except Exception:
                                        pass
                            sel_row = getattr(card, "selection_row", None)
                            if sel_row:
                                try:
                                    sel_row.pack_forget()
                                except Exception:
                                    pass
                            # Restore card: show "Import" + ▼ dropdown
                            try:
                                btn_outer = getattr(card, "_btn_outer", None)
                                btn_inner_f = btn_outer.winfo_children()[0] if btn_outer and btn_outer.winfo_children() else None
                                if btn_outer:
                                    btn_outer.configure(bg="#2A4A2E")
                                    btn_outer.pack(fill="both", expand=True)
                                if btn_inner_f:
                                    btn_inner_f.configure(bg="#172A1A")
                                dl_btn = getattr(card, "download_btn", None) or getattr(card, "format_btn", None)
                                if dl_btn and dl_btn.winfo_exists():
                                    dl_btn.configure(
                                        text="⬆  Import",
                                        bg="#172A1A", fg="#34C759",
                                        activebackground="#1E3A22",
                                        command=lambda a=getattr(card, "asset", None), c=card:
                                            self._card_import_asset(a, c) if a else None,
                                    )
                                    dl_btn.pack(fill="both", expand=True)
                                mb = getattr(card, "menu_btn", None)
                                if mb and mb.winfo_exists():
                                    mb.configure(bg="#172A1A")
                                    mb.pack(side="right", fill="y")
                            except Exception:
                                pass
                except Exception:
                    pass
            # Auto-import to Resolve
            self._auto_import_to_resolve(dl_info)
            try:
                current_asset = getattr(self, "_rp_current_asset", None)
                if current_asset and (current_asset.get("id", 0) == (dl_info.get("asset") or {}).get("id", -1)):
                    self._rp_on_format_combo_change()
            except Exception:
                pass
            if self.downloads_view_active:
                self._render_downloads_view()
        except Exception as e:
            print(f"[COMPLETE ERROR] {e}")

    def _unzip_and_get_gltf(self, zip_path):
        """Unzip a ZIP and return path to the first .gltf/.glb file found inside, or None."""
        try:
            import zipfile
            extract_dir = zip_path[:-4] + "_extracted"
            os.makedirs(extract_dir, exist_ok=True)
            with zipfile.ZipFile(zip_path, 'r') as z:
                z.extractall(extract_dir)
            for root, dirs, files in os.walk(extract_dir):
                for f in files:
                    if f.lower().endswith(('.gltf', '.glb')):
                        return os.path.join(root, f)
            return None
        except Exception as e:
            print(f"[UNZIP] Error: {e}")
            return None

    def _zip_contains_vdb(self, zip_path):
        """Peek inside a ZIP without extracting — returns True if any .vdb file is inside."""
        try:
            import zipfile
            with zipfile.ZipFile(zip_path, 'r') as z:
                return any(n.lower().endswith('.vdb') for n in z.namelist())
        except Exception:
            return False

    def _unzip_and_get_vdb(self, zip_path):
        """Extract VDB files from ZIP. Returns (first_vdb_path, None).
        Fusion auto-detects animated VDB sequences from the first frame path."""
        try:
            import zipfile
            extract_dir = zip_path[:-4] + "_extracted"
            os.makedirs(extract_dir, exist_ok=True)
            with zipfile.ZipFile(zip_path, 'r') as z:
                z.extractall(extract_dir)
            all_vdbs = []
            for root, dirs, files in os.walk(extract_dir):
                for f in sorted(files):
                    if f.lower().endswith('.vdb'):
                        all_vdbs.append(os.path.join(root, f))
            if not all_vdbs:
                return None, None
            return all_vdbs[0], None
        except Exception as e:
            print(f"[UNZIP VDB] Error: {e}")
            return None, None

    def _unzip_and_get_3d_file(self, zip_path):
        """Extract ZIP and return (file_path, is_vdb) for first Fusion-supported 3D file.
        Priority: VDB > FBX > ABC > OBJ > DAE/3DS. Excludes GLTF/GLB (not supported by Fusion)."""
        try:
            import zipfile
            extract_dir = os.path.splitext(zip_path)[0] + "_extracted"
            os.makedirs(extract_dir, exist_ok=True)
            with zipfile.ZipFile(zip_path, "r") as z:
                z.extractall(extract_dir)
            _SUPPORTED = (".vdb", ".fbx", ".abc", ".obj", ".dae", ".3ds", ".dxf")
            found = []
            for root, dirs, files in os.walk(extract_dir):
                for f in sorted(files):
                    ext = os.path.splitext(f)[1].lower()
                    if ext in _SUPPORTED:
                        found.append((os.path.join(root, f), ext))
            if not found:
                return None, False
            found.sort(key=lambda t: _SUPPORTED.index(t[1]) if t[1] in _SUPPORTED else 99)
            best_path, best_ext = found[0]
            return best_path, best_ext == ".vdb"
        except Exception as e:
            print(f"[UNZIP 3D] Error: {e}")
            return None, False

    def _try_extract_rar(self, rar_path, extract_dir):
        """Try to extract a RAR archive using system tools (7z/unrar/bsdtar).
        Returns True on success, False if no tool available."""
        import subprocess, shutil
        try:
            os.makedirs(extract_dir, exist_ok=True)
        except Exception:
            pass
        # Try 7z first (most common; available if 7-Zip or p7zip is installed)
        for exe_name in ("7z", "7za"):
            exe = shutil.which(exe_name)
            if exe:
                try:
                    r = subprocess.run(
                        [exe, "x", rar_path, f"-o{extract_dir}", "-y"],
                        capture_output=True, timeout=120
                    )
                    if r.returncode == 0:
                        return True
                except Exception:
                    pass
        # Try unrar (WinRAR command-line tool)
        exe = shutil.which("unrar")
        if exe:
            try:
                r = subprocess.run(
                    [exe, "x", "-y", rar_path, extract_dir + os.sep],
                    capture_output=True, timeout=120
                )
                if r.returncode == 0:
                    return True
            except Exception:
                pass
        # macOS: bsdtar handles RAR3 archives
        import platform
        if platform.system() == "Darwin":
            bsdtar = shutil.which("bsdtar") or "/usr/bin/bsdtar"
            if os.path.exists(bsdtar):
                try:
                    r = subprocess.run(
                        [bsdtar, "-xf", rar_path, "-C", extract_dir],
                        capture_output=True, timeout=120
                    )
                    if r.returncode == 0:
                        return True
                except Exception:
                    pass
        return False

    def _import_vdb_via_uvolume(self, vdb_path, sequence_pattern=None):
        """
        Import a .vdb file into DaVinci Resolve Fusion via the uVolume node.
        Auto-switches to Fusion page. Fusion auto-detects VDB sequences from
        the first frame path. Requires DaVinci Resolve 19+.
        Returns (success: bool, message: str).
        """
        try:
            # Reconnect to Resolve if needed
            if not self.resolve:
                try:
                    import DaVinciResolveScript as dvr
                    _r = dvr.scriptapp("Resolve")
                    if _r and self._valid_resolve(_r):
                        self.resolve = _r
                except Exception:
                    pass
            if not self.resolve:
                return False, "DaVinci Resolve not connected."

            # Version check — uVolume requires Resolve 19+
            try:
                ver_str = self.resolve.GetVersionString() or ""
                major = int(ver_str.split(".")[0]) if ver_str and ver_str[0].isdigit() else 99
                if major < 19:
                    return False, (
                        f"DaVinci Resolve {ver_str} detected.\n"
                        "VDB import requires DaVinci Resolve 19 or newer.\n"
                        "Please upgrade Resolve to use this feature."
                    )
            except Exception:
                pass

            # ── Auto-setup: ensure timeline clip exists for Fusion USD context ──
            # uVolume (USD pipeline) only initialises when Fusion has a proper
            # clip-based comp. An empty Fusion workspace returns a comp proxy
            # that passes GetAttrs() but rejects AddTool("uVolume"). Fix: create
            # a minimal 16×16 black PNG placeholder, import it to the media pool,
            # and append it to a new timeline so Resolve gives Fusion a real comp.
            try:
                _pm   = self.resolve.GetProjectManager()
                _proj = _pm.GetCurrentProject() if _pm else None
                _mp   = _proj.GetMediaPool() if _proj else None
                _tl   = _proj.GetCurrentTimeline() if _proj else None
                _has_clip = False
                if _tl:
                    for _ti in range(1, _tl.GetTrackCount("video") + 1):
                        if _tl.GetItemListInTrack("video", _ti):
                            _has_clip = True
                            break
                _dlog(f"[VDB SETUP] timeline={_tl!r} has_clip={_has_clip}")
                if not _has_clip and _mp:
                    if not _tl:
                        _tl = _mp.CreateEmptyTimeline("VDB Workspace")
                        _dlog(f"[VDB SETUP] CreateEmptyTimeline: {_tl!r}")
                    if _tl:
                        import tempfile as _tf
                        _png = os.path.join(_tf.gettempdir(), "cgheven_vdb_placeholder.png")
                        _write_minimal_png(_png)
                        _imported = _mp.ImportMedia([_png])
                        _dlog(f"[VDB SETUP] ImportMedia: {_imported!r}")
                        if _imported:
                            _added = _mp.AppendToTimeline(_imported)
                            _dlog(f"[VDB SETUP] AppendToTimeline: {_added!r}")
                            # Switch to Edit page so Resolve registers the new clip
                            try:
                                self.resolve.OpenPage("edit")
                                time.sleep(1.5)
                            except Exception:
                                pass
            except Exception as _se:
                _dlog(f"[VDB SETUP] auto-setup exception: {_se!r}")

            # ── Switch to Fusion page ─────────────────────────────────────────
            try:
                current_page = self.resolve.GetCurrentPage()
            except Exception:
                current_page = None

            if current_page != "fusion":
                try:
                    self.resolve.OpenPage("fusion")
                    time.sleep(3)
                except Exception:
                    pass

            fusion = self.resolve.Fusion()
            if not fusion:
                return False, (
                    "Cannot access Fusion.\n"
                    "Please place a video clip on the timeline and open it in the Fusion page, then try again."
                )

            # Retry loop — validate comp with GetAttrs() to catch nil proxy objects
            # GetCurrentComp() can return a non-None Python proxy that is internally nil (Lua)
            comp = None
            for _ in range(14):
                try:
                    c = fusion.GetCurrentComp()
                    if c:
                        c.GetAttrs()
                        comp = c
                        break
                except Exception:
                    pass
                time.sleep(0.5)

            if not comp:
                return False, (
                    "No active Fusion composition found.\n\n"
                    "To fix this:\n"
                    "1. In DaVinci Resolve, go to the Edit page (press 5)\n"
                    "2. Place any video/image clip on the timeline\n"
                    "3. Press 6 to open the Fusion page\n"
                    "4. Then try importing the VDB again."
                )

            # Fusion auto-detects VDB sequences from the first frame path
            import_path = os.path.abspath(vdb_path).replace("\\", "/")
            fname = os.path.basename(vdb_path)

            try:
                comp.Lock()
            except Exception:
                pass

            try:
                _dlog(f"[VDB DEBUG] ── starting Windows VDB import: {fname}")
                _dlog(f"[VDB DEBUG] import_path = {import_path!r}")

                # ── Step 1: uVolume node ─────────────────────────────────────
                uv = None
                uv_tool_used = ""
                for tool_name in ("uVolume", "uVolume3D", "VolumeFile"):
                    try:
                        uv = comp.AddTool(tool_name, 0, 0)
                        if uv:
                            uv_tool_used = tool_name
                            break
                    except Exception as _te:
                        _dlog(f"[VDB DEBUG] AddTool({tool_name!r}) raised: {_te!r}")
                _dlog(f"[VDB DEBUG] Step1 uVolume: tool={uv_tool_used!r} obj={uv!r}")

                if not uv:
                    try: comp.Unlock()
                    except Exception: pass
                    return False, (
                        "uVolume node could not be created in Fusion.\n\n"
                        "Possible causes:\n"
                        "• DaVinci Resolve 19 or newer is required (you have: "
                        + (self.resolve.GetVersionString() or "unknown") + ")\n"
                        "• The Fusion comp has no timeline clip — open any clip "
                        "in the Fusion page first\n\n"
                        "Quick fix:\n"
                        "1. Go to Edit page (press 5)\n"
                        "2. Drag any video/image to the timeline\n"
                        "3. Press 6 to open Fusion page\n"
                        "4. Try importing VDB again."
                    )

                # ── Step 2: Set VDB file path (Filename confirmed for Resolve 20) ──
                set_ok = False
                try:
                    uv.SetInput("Filename", import_path)
                    val = uv.GetInput("Filename")
                    _dlog(f"[VDB DEBUG] Step2 SetInput Filename readback = {val!r}")
                    if val and str(val).strip():
                        set_ok = True
                except Exception as _fe:
                    _dlog(f"[VDB DEBUG] Step2 SetInput/GetInput Filename raised: {_fe!r}")
                if not set_ok:
                    try:
                        uv.Filename = import_path
                        set_ok = True
                        _dlog(f"[VDB DEBUG] Step2 setattr Filename ok")
                    except Exception as _fa:
                        _dlog(f"[VDB DEBUG] Step2 setattr Filename raised: {_fa!r}")
                        # Dump available inputs for diagnosis
                        try:
                            il = uv.GetInputList()
                            _dlog(f"[VDB DEBUG] uVolume available inputs: {list(il.values()) if il else 'N/A'}")
                        except Exception:
                            pass
                _dlog(f"[VDB DEBUG] Step2 set_ok={set_ok}")

                # ── Step 3: uMerge node (USD pipeline merge — NOT legacy Merge3D) ──
                umerge = None
                try:
                    umerge = comp.AddTool("uMerge", 2, 0)
                except Exception as _me:
                    _dlog(f"[VDB DEBUG] Step3 AddTool(uMerge) raised: {_me!r}")
                _dlog(f"[VDB DEBUG] Step3 uMerge={umerge!r}")

                # ── Step 4: uRenderer node (USD renderer — NOT legacy Renderer3D) ──
                urenderer = None
                try:
                    urenderer = comp.AddTool("uRenderer", 4, 0)
                except Exception as _re:
                    _dlog(f"[VDB DEBUG] Step4 AddTool(uRenderer) raised: {_re!r}")
                _dlog(f"[VDB DEBUG] Step4 uRenderer={urenderer!r}")

                # ── Step 4b: Configure uRenderer defaults ────────────────────
                if urenderer:
                    for _attr, _val in (
                        ("Lighting",   2),   # 0=None, 1=Camera, 2=Scene
                        ("Complexity", 2),   # 0=Low,  1=Medium, 2=High
                        ("MaxIterations", 16),
                    ):
                        _s_ok = False
                        try:
                            urenderer.SetInput(_attr, _val)
                            _s_ok = True
                        except Exception as _se:
                            _dlog(f"[VDB DEBUG] SetInput({_attr!r},{_val}) raised: {_se!r}")
                            try:
                                setattr(urenderer, _attr, _val)
                                _s_ok = True
                            except Exception as _ae:
                                _dlog(f"[VDB DEBUG] setattr({_attr!r},{_val}) raised: {_ae!r}")
                        _dlog(f"[VDB DEBUG] Step4b set {_attr}={_val} ok={_s_ok}")

                    # Read back Lighting to confirm change was accepted
                    try:
                        l_rb = urenderer.GetInput("Lighting")
                        _dlog(f"[VDB DEBUG] Step4b uRenderer.Lighting readback={l_rb!r} (want 2=Scene)")
                    except Exception as _lre:
                        _dlog(f"[VDB DEBUG] Step4b Lighting readback FAILED: {_lre!r}")
                        try:
                            il2 = urenderer.GetInputList()
                            _dlog(f"[VDB DEBUG] uRenderer inputs: {list(il2.values()) if il2 else 'N/A'}")
                        except Exception:
                            pass

                # ── Step 5: Connect uVolume → uMerge ────────────────────────
                _conn1 = False
                if umerge and uv:
                    try:
                        umerge.SceneInput1 = uv.Output
                        _conn1 = True
                    except Exception as _c1e:
                        _dlog(f"[VDB DEBUG] Step5 umerge.SceneInput1= raised: {_c1e!r}")
                        try:
                            umerge.ConnectInput("SceneInput1", uv)
                            _conn1 = True
                        except Exception as _c1b:
                            _dlog(f"[VDB DEBUG] Step5 ConnectInput SceneInput1 raised: {_c1b!r}")
                _dlog(f"[VDB DEBUG] Step5 uVolume→uMerge ok={_conn1}")

                # ── Step 6: Connect uMerge → uRenderer ──────────────────────
                _conn2 = False
                if urenderer:
                    scene_src = umerge if umerge else uv
                    try:
                        urenderer.SceneInput = scene_src.Output
                        _conn2 = True
                    except Exception as _c2e:
                        _dlog(f"[VDB DEBUG] Step6 urenderer.SceneInput= raised: {_c2e!r}")
                        try:
                            urenderer.ConnectInput("SceneInput", scene_src)
                            _conn2 = True
                        except Exception as _c2b:
                            _dlog(f"[VDB DEBUG] Step6 ConnectInput SceneInput raised: {_c2b!r}")
                _dlog(f"[VDB DEBUG] Step6 uMerge→uRenderer ok={_conn2}")

                # ── Step 7: Connect uRenderer → MediaOut ────────────────────
                media_out_connected = False
                media_out = None
                if urenderer:
                    try:
                        media_out = comp.FindTool("MediaOut1")
                        _dlog(f"[VDB DEBUG] Step7 FindTool('MediaOut1')={media_out!r}")
                        if not media_out:
                            media_out = comp.AddTool("MediaOut", 6, 0)
                            _dlog(f"[VDB DEBUG] Step7 AddTool('MediaOut')={media_out!r}")
                        if media_out:
                            try:
                                media_out.Input = urenderer.Output
                                media_out_connected = True
                            except Exception as _c3e:
                                _dlog(f"[VDB DEBUG] Step7 media_out.Input= raised: {_c3e!r}")
                                try:
                                    media_out.ConnectInput("Input", urenderer)
                                    media_out_connected = True
                                except Exception as _c3b:
                                    _dlog(f"[VDB DEBUG] Step7 ConnectInput Input raised: {_c3b!r}")
                    except Exception as _m7e:
                        _dlog(f"[VDB DEBUG] Step7 MediaOut block raised: {_m7e!r}")
                _dlog(f"[VDB DEBUG] Step7 uRenderer→MediaOut ok={media_out_connected}")

                # ── Step 8: Dump uRenderer input IDs (for Lighting param diagnosis) ──
                if urenderer:
                    try:
                        il_all = urenderer.GetInputList()
                        if il_all:
                            ids = []
                            for _inp in il_all.values():
                                try:
                                    ids.append(getattr(_inp, "ID", str(_inp)))
                                except Exception:
                                    pass
                            _dlog(f"[VDB DEBUG] Step8 uRenderer input IDs: {ids}")
                    except Exception as _ile:
                        _dlog(f"[VDB DEBUG] Step8 GetInputList failed: {_ile!r}")

                # ── Step 9: Set comp frame range for VDB animation ───────────
                try:
                    comp.SetAttrs({
                        "COMPN_GlobalStart":  0,
                        "COMPN_GlobalEnd":    240,
                        "COMPN_RenderStart":  0,
                        "COMPN_RenderEnd":    240,
                    })
                    _dlog("[VDB DEBUG] Step9 comp frame range set to 0–240")
                except Exception as _fre:
                    _dlog(f"[VDB DEBUG] Step9 frame range failed: {_fre!r}")

                # ── Step 10: Send uRenderer output to Fusion viewer ──────────
                # SetActiveTool only SELECTS the node; ViewOn sends output to viewer.
                if urenderer:
                    try:
                        comp.SetActiveTool(urenderer)
                        _dlog("[VDB DEBUG] Step10 SetActiveTool done")
                    except Exception as _sae:
                        _dlog(f"[VDB DEBUG] Step10 SetActiveTool failed: {_sae!r}")
                    try:
                        cf = comp.CurrentFrame
                        if cf:
                            cf.ViewOn(urenderer, 1)
                            _dlog("[VDB DEBUG] Step10 ViewOn(urenderer,1) done")
                    except Exception as _voe:
                        _dlog(f"[VDB DEBUG] Step10 ViewOn failed: {_voe!r}")
                        try:
                            urenderer.ViewOn(1)
                            _dlog("[VDB DEBUG] Step10 urenderer.ViewOn(1) done (fallback)")
                        except Exception as _voe2:
                            _dlog(f"[VDB DEBUG] Step10 urenderer.ViewOn(1) failed: {_voe2!r}")

                _dlog(f"[VDB DEBUG] ── FINAL: set_ok={set_ok} urenderer={urenderer is not None} "
                      f"conn1={_conn1} conn2={_conn2} mediaout={media_out_connected}")
                _dlog("[VDB DEBUG] Full log → ~/Downloads/CGHEVEN/_dl_debug.log")

                try:
                    comp.Unlock()
                except Exception:
                    pass

                # ── Build result message ─────────────────────────────────────
                if set_ok and urenderer and media_out_connected:
                    return True, (
                        f"VDB imported & ready: {fname}\n\n"
                        "Full node chain created:\n"
                        "uVolume → uMerge → uRenderer → MediaOut\n\n"
                        "Click the uRenderer node, then press\n"
                        "Spacebar or the Play button to preview.\n\n"
                        "Tip: Add a uLight into uMerge for better lighting."
                    )
                elif set_ok and urenderer:
                    return True, (
                        f"VDB imported: {fname}\n"
                        "uVolume → uMerge → uRenderer created.\n"
                        "Connect uRenderer to MediaOut to render."
                    )
                elif set_ok:
                    return True, (
                        f"VDB imported: {fname}\n"
                        "uVolume node created with file path set.\n"
                        "Manually connect: uVolume → uMerge → uRenderer → MediaOut"
                    )
                else:
                    return True, (
                        f"uVolume node added for: {fname}\n"
                        "File path could not be set automatically.\n"
                        "Please set the file path manually in the node inspector."
                    )

            except Exception as e:
                try: comp.Unlock()
                except Exception: pass
                return False, f"Failed to build VDB node graph: {e}"

        except Exception as e:
            return False, f"VDB import error: {e}"

    def _set_vdb_path_on_tool(self, tool, norm_path):
        """Try to set the VDB file path on a freshly created Fusion volume tool.

        Different volume tools expose the path under different attribute
        names. We probe the candidates in `_FUSION_VDB_PATH_ATTRS` and
        return the name of the first attribute that accepts the
        assignment without raising. Returns "" on total failure.
        """
        base = os.path.basename(norm_path)
        for attr in _FUSION_VDB_PATH_ATTRS:
            try:
                if attr == "Clip":
                    # Loader-style indexed accessor: tool.Clip[1] = path
                    # (Fusion uses 1-based indexing for Clip lists).
                    clip = getattr(tool, "Clip", None)
                    if clip is None:
                        continue
                    try:
                        clip[1] = norm_path
                    except Exception:
                        try:
                            clip[0] = norm_path
                        except Exception:
                            continue
                    _dlog(f"[FUSION VDB] set tool.Clip = {norm_path}")
                    return "Clip"
                # Scalar attribute. We can't always introspect the
                # tool's attribute table, so just attempt the set and
                # let the failure path catch it.
                setattr(tool, attr, norm_path)
                _dlog(f"[FUSION VDB] set tool.{attr} = {norm_path}")
                return attr
            except Exception as e:
                _dlog(f"[FUSION VDB] set tool.{attr} failed for {base}: {e}")
                continue
        _dlog(f"[FUSION VDB] no path attribute accepted for {base}")
        return ""

    def _try_add_vdb_node(self, comp, vdb_path, x_pos):
        """Add a single VDB volume node to the comp. Returns the tool or None.

        Uses `_discover_vdb_tool_id()` to query Fusion's tool registry
        first, then falls back to the hardcoded `_FUSION_VDB_TOOL_CANDIDATES`
        list if discovery fails. The first ID that `AddTool` accepts
        (returns non-None) wins. After creation we log the actual
        `TOOLS_RegID` for confirmation, then try to set the file path on
        the tool via `_set_vdb_path_on_tool`. If no candidate works, we
        dump a sample of the registry to the log for diagnostic purposes
        and return None.
        """
        if not comp:
            return None
        base = os.path.basename(vdb_path)
        norm_path = vdb_path.replace("\\", "/")

        # Discover the right tool ID via the Fusion registry first.
        fusion = self.resolve.Fusion() if self.resolve else None
        discovered = _discover_vdb_tool_id(fusion) if fusion else None

        # Build ordered candidate list: discovery winner first, then
        # hardcoded fallbacks (deduped, preserving order).
        candidates = []
        if discovered:
            candidates.append(discovered)
        for c in _FUSION_VDB_TOOL_CANDIDATES:
            if c not in candidates:
                candidates.append(c)

        tool = None
        chosen = ""
        for candidate_id in candidates:
            try:
                t = comp.AddTool(candidate_id, x_pos, 0)
            except Exception as e:
                _dlog(f"[FUSION VDB] AddTool({candidate_id!r}) raised for {base}: {e!r}")
                continue
            if not t:
                _dlog(f"[FUSION VDB] AddTool({candidate_id!r}) returned None for {base}")
                continue
            # Success — log actual ID via GetAttrs for confirmation.
            try:
                actual_id = t.GetAttrs().get("TOOLS_RegID", "?")
                _dlog(f"[FUSION VDB] AddTool({candidate_id!r}) succeeded for {base}; actual TOOLS_RegID={actual_id}")
            except Exception:
                _dlog(f"[FUSION VDB] AddTool({candidate_id!r}) succeeded for {base} (RegID unknown)")
            tool = t
            chosen = candidate_id
            break

        if not tool:
            # Total failure — dump full registry for diagnostic.
            try:
                if fusion is not None:
                    all_regs = fusion.GetRegList(0)
                    if all_regs:
                        try:
                            keys = list(all_regs.keys()) if hasattr(all_regs, "keys") else list(all_regs)
                        except Exception:
                            keys = []
                        _dlog(f"[FUSION VDB] full registry sample (first 50): {keys[:50]}")
                    else:
                        _dlog("[FUSION VDB] full registry dump returned empty/None")
            except Exception as e:
                _dlog(f"[FUSION VDB] full registry dump failed: {e!r}")
            return None

        # Best-effort path assignment — it's still useful to leave the
        # node in the comp even if we can't auto-set the path.
        attr_used = self._set_vdb_path_on_tool(tool, norm_path)
        if attr_used:
            _dlog(f"[FUSION VDB] path set on tool.{attr_used} = {norm_path}")
        else:
            _dlog(f"[FUSION VDB] WARNING: no path attribute accepted on tool — user must drag-drop file in Fusion")
        try:
            short_name = os.path.splitext(base)[0][:32]
            tool.SetAttrs({"TOOLS_Name": short_name})
        except Exception:
            pass
        _dlog(f"[FUSION VDB] node ready: type={chosen} pathAttr={attr_used or '<none>'} file={base}")
        return tool

    def _try_import_3d_to_fusion(self, extract_dir, non_importable):
        """Attempt to add 3D model / VDB volume files to the active Fusion comp.

        Supports FBX, OBJ, Alembic, GLB/GLTF natively via `_FUSION_3D_TOOLS`,
        plus VDB volumes via the multi-candidate `_try_add_vdb_node`
        resolver (Fusion 19+ Studio feature; tool ID is undocumented).

        Other formats (.blend, .c4d, .ma/mb, …) are not handled here; the
        caller should fall back to _show_non_importable_info for those.

        Returns True if at least one node was added, False otherwise.
        """
        try:
            _dlog(f"[FUSION 3D] _try_import_3d_to_fusion called: extract_dir={extract_dir!r} non_importable_count={len(non_importable or [])}")
        except Exception:
            pass
        if not self.resolve:
            try:
                _dlog("[FUSION 3D] self.resolve is None → returning False")
            except Exception:
                pass
            return False

        # Gather all candidate file paths
        candidates = list(non_importable or [])
        if extract_dir and os.path.isdir(extract_dir):
            seen = set(candidates)
            for root_d, _, files in os.walk(extract_dir):
                for f in files:
                    fp = os.path.join(root_d, f)
                    if fp not in seen:
                        candidates.append(fp)
                        seen.add(fp)

        try:
            _dlog(f"[FUSION 3D] candidates={[os.path.basename(p) for p in candidates[:10]]}")
        except Exception:
            pass

        fusion_files = [
            p for p in candidates
            if os.path.splitext(p)[1].lower() in _FUSION_3D_TOOLS
        ]
        vdb_files = [
            p for p in candidates
            if os.path.splitext(p)[1].lower() == ".vdb"
        ]
        try:
            _dlog(f"[FUSION 3D] fusion_files={[os.path.basename(p) for p in fusion_files]} vdb_files={[os.path.basename(p) for p in vdb_files]}")
        except Exception:
            pass
        if not fusion_files and not vdb_files:
            try:
                _dlog("[FUSION 3D] no fusion_files and no vdb_files → returning False")
            except Exception:
                pass
            return False

        try:
            # Switch Resolve UI to Fusion page
            self.resolve.OpenPage("fusion")

            fusion = self.resolve.Fusion()
            if not fusion:
                print("[IMPORT] Fusion: could not get Fusion object from Resolve")
                _dlog("[FUSION VDB] Fusion() returned None — cannot import")
                return False

            comp = fusion.GetCurrentComp()
            if not comp:
                _dlog("[FUSION VDB] no active comp — creating new comp via fusion.NewComp()")
                try:
                    comp = fusion.NewComp()
                except Exception as e:
                    _dlog(f"[FUSION VDB] NewComp failed: {e!r}")
                    return False
                if not comp:
                    _dlog("[FUSION VDB] NewComp returned None — user must open a Fusion comp manually")
                    return False
                _dlog("[FUSION VDB] NewComp succeeded — using freshly created comp")

            imported = []
            comp.Lock()
            try:
                x_pos = 0
                for fp in fusion_files:
                    ext = os.path.splitext(fp)[1].lower()
                    tool_type = _FUSION_3D_TOOLS[ext]
                    norm_path = fp.replace("\\", "/")

                    _dlog(f"[FUSION 3D] entering loop iter for {os.path.basename(fp)} ext={ext!r} tool_type={tool_type!r}")

                    def _raw(msg):
                        try:
                            with open(_DL_DEBUG_LOG, "a") as _rf:
                                _rf.write(f"{msg}\n")
                                _rf.flush()
                        except BaseException:
                            pass

                    # Resolve 20's Fusion: `FBXMesh3D` returns None for some
                    # builds. Walk a list of candidate tool IDs and use whichever
                    # `AddTool` accepts.
                    candidates = list(_FUSION_3D_TOOL_CANDIDATES.get(ext, (tool_type,)))
                    if tool_type not in candidates:
                        candidates.insert(0, tool_type)
                    tool = None
                    chosen_id = None
                    for cand_id in candidates:
                        _raw(f"[FUSION 3D] RAW try AddTool({cand_id!r})")
                        try:
                            tool = comp.AddTool(cand_id, x_pos, 0)
                        except BaseException as _addtool_exc:
                            _raw(f"[FUSION 3D] RAW AddTool({cand_id!r}) RAISED: {type(_addtool_exc).__name__}: {_addtool_exc!r}")
                            tool = None
                            continue
                        if tool:
                            _raw(f"[FUSION 3D] RAW AddTool({cand_id!r}) → tool=OK")
                            chosen_id = cand_id
                            break
                        else:
                            _raw(f"[FUSION 3D] RAW AddTool({cand_id!r}) → tool=None")

                    if not tool:
                        # Sanity: does AddTool work at all in this comp?
                        try:
                            _bg_test = comp.AddTool("Background", x_pos + 5, 0)
                            _raw(f"[FUSION 3D] RAW sanity AddTool('Background') → {'tool=OK' if _bg_test else 'tool=None'}")
                            if _bg_test:
                                try: _bg_test.Delete()
                                except BaseException: pass
                        except BaseException as _se:
                            _raw(f"[FUSION 3D] RAW sanity AddTool('Background') RAISED: {_se!r}")

                        try:
                            reg = fusion.GetRegList(0)
                            keys = []
                            try:
                                keys = list(reg.keys()) if hasattr(reg, "keys") else []
                            except BaseException:
                                keys = []
                            _raw(f"[FUSION 3D] RAW reg type={type(reg).__name__} keys_count={len(keys)} sample_keys={keys[:5]!r}")

                            if keys:
                                fk = keys[0]
                                try:
                                    fv = reg[fk]
                                    _raw(f"[FUSION 3D] RAW first entry: key={fk!r} ({type(fk).__name__}) value={fv!r} ({type(fv).__name__})")
                                    if hasattr(fv, "GetAttrs"):
                                        try:
                                            _raw(f"[FUSION 3D] RAW first GetAttrs={fv.GetAttrs()!r}")
                                        except BaseException as _ge:
                                            _raw(f"[FUSION 3D] RAW first GetAttrs failed: {_ge!r}")
                                except BaseException as _ke:
                                    _raw(f"[FUSION 3D] RAW reg[first_key] failed: {_ke!r}")

                            names = []
                            for k in keys[:1500]:
                                try:
                                    entry = reg[k]
                                except BaseException:
                                    continue
                                nm = ""
                                try:
                                    if hasattr(entry, "GetAttrs"):
                                        a = entry.GetAttrs()
                                        for key in ("REGS_ID", "REGS_Name", "REGB_Name"):
                                            try:
                                                v = a[key] if a is not None else None
                                            except BaseException:
                                                try:
                                                    v = a.get(key) if hasattr(a, "get") else None
                                                except BaseException:
                                                    v = None
                                            if v:
                                                nm = str(v)
                                                break
                                except BaseException:
                                    nm = ""
                                if nm and isinstance(nm, str):
                                    names.append(nm)
                            for tag, terms in (
                                ("FBX",      ("fbx",)),
                                ("OBJ",      ("obj",)),
                                ("ALEMBIC",  ("alembic", "abc")),
                                ("MESH",     ("mesh",)),
                                ("SURFACE",  ("surface",)),
                                ("LOADER",   ("loader",)),
                                ("USD",      ("usd", "usdz")),
                            ):
                                hits = [n for n in names if any(t in n.lower() for t in terms)]
                                if hits:
                                    _raw(f"[FUSION 3D] RAW match {tag} ({len(hits)}): {hits}")
                            _raw(f"[FUSION 3D] RAW extracted {len(names)} total names")
                        except BaseException as _re:
                            _raw(f"[FUSION 3D] RAW GetRegList probe failed: {_re!r}")
                        continue
                    # Sync local tool_type to the candidate that actually worked
                    tool_type = chosen_id or tool_type
                    _raw(f"[FUSION 3D] RAW chosen tool_type={tool_type!r}")

                    # Set file path — Alembic uses "Filename"; FBX/OBJ use "ImportFile"
                    path_ok = False
                    _use_import_file = tool_type not in _FUSION_3D_ALEMBIC_IDS
                    _attr = "ImportFile" if _use_import_file else "Filename"
                    try:
                        tool.SetInput(_attr, norm_path)
                        path_ok = True
                        _dlog(f"[FUSION 3D] SetInput({_attr!r}) ok")
                    except Exception as _se:
                        _dlog(f"[FUSION 3D] SetInput({_attr!r}) raised: {_se!r}")
                        try:
                            setattr(tool, _attr, norm_path)
                            path_ok = True
                            _dlog(f"[FUSION 3D] setattr({_attr!r}) ok")
                        except Exception as _ae:
                            _dlog(f"[FUSION 3D] setattr({_attr!r}) raised: {_ae!r}")

                    if not path_ok:
                        try:
                            tool.Delete()
                        except Exception:
                            pass
                        _dlog(f"[FUSION 3D] skipping {os.path.basename(fp)} — path attribute rejected")
                        continue

                    short_name = os.path.splitext(os.path.basename(fp))[0][:32]
                    try:
                        tool.SetAttrs({"TOOLS_Name": short_name})
                    except Exception:
                        pass

                    # ── Build Merge3D → Renderer3D → MediaOut rendering chain ──
                    # Without this chain nothing renders in the Fusion viewer.
                    # Official pipeline: SurfaceFBXMesh → Merge3D → Renderer3D
                    merge3d    = None
                    renderer3d = None
                    try:
                        merge3d    = comp.AddTool("Merge3D",    x_pos + 1, 0)
                        renderer3d = comp.AddTool("Renderer3D", x_pos + 2, 0)
                    except Exception as _me:
                        _dlog(f"[FUSION 3D] Merge3D/Renderer3D AddTool raised: {_me!r}")

                    if merge3d:
                        try:
                            merge3d.SceneInput1 = tool.Output
                        except Exception:
                            try:
                                merge3d.ConnectInput("SceneInput1", tool)
                            except Exception:
                                pass
                    if renderer3d and merge3d:
                        try:
                            renderer3d.SceneInput = merge3d.Output
                        except Exception:
                            try:
                                renderer3d.ConnectInput("SceneInput", merge3d)
                            except Exception:
                                pass
                        # Connect Renderer3D → MediaOut
                        _mo = comp.FindTool("MediaOut1")
                        if not _mo:
                            try:
                                _mo = comp.AddTool("MediaOut", x_pos + 3, 0)
                            except Exception:
                                _mo = None
                        if _mo:
                            try:
                                _mo.Input = renderer3d.Output
                            except Exception:
                                try:
                                    _mo.ConnectInput("Input", renderer3d)
                                except Exception:
                                    pass
                        # Send Renderer3D output to Fusion viewer
                        try:
                            cf = comp.CurrentFrame
                            if cf:
                                cf.ViewOn(renderer3d, 1)
                        except Exception:
                            pass
                        _dlog(f"[FUSION 3D] chain: {short_name} → Merge3D → Renderer3D → MediaOut")
                    else:
                        # Chain build failed — still add the node, user wires manually
                        try:
                            cf = comp.CurrentFrame
                            if cf:
                                cf.ViewOn(tool, 1)
                        except Exception:
                            pass
                        _dlog(f"[FUSION 3D] chain build failed — node added standalone")

                    imported.append(fp)
                    x_pos += 4

                # VDB branch — uses dedicated multi-candidate resolver
                for fp in vdb_files:
                    tool = self._try_add_vdb_node(comp, fp, x_pos)
                    if not tool:
                        continue
                    imported.append(fp)
                    x_pos += 3
            finally:
                comp.Unlock()

            try:
                _dlog(f"[FUSION 3D] post-loop imported_count={len(imported)} imported={[os.path.basename(p) for p in imported]}")
            except Exception:
                pass
            if imported:
                n = len(imported)
                first_name = os.path.basename(imported[0])
                print(f"[IMPORT] Fusion: added {n} 3D/volume node(s) to Fusion comp")
                self.status_var.set(f"✓ Added {n} node(s) to Fusion")
                return True

            try:
                _dlog("[FUSION 3D] imported list empty → returning False")
            except Exception:
                pass
            return False

        except Exception as exc:
            try:
                import traceback as _tb
                _dlog(f"[FUSION 3D] outer except: {exc!r}")
                _dlog(f"[FUSION 3D] traceback:\n{_tb.format_exc()}")
            except Exception:
                pass
            print(f"[IMPORT] Fusion import error: {exc}")
            return False

    def _show_non_importable_info(self, asset_label, extract_dir, non_importable):
        """Themed dialog explaining why this asset can't land in Media
        Pool, with a one-click "Open in Explorer" path so the user
        still gets to the files (.blend / .fbx / .vdb / etc.).
        """
        try:
            target = extract_dir or (non_importable[0] if non_importable else "")
            if not target:
                return
            # Friendly, deduped extension list
            ext_list = sorted({
                (os.path.splitext(p)[1].lower() or "(no ext)").lstrip(".")
                for p in non_importable
            })
            ext_summary = ", ".join(f".{e}" for e in ext_list[:6]) or "unsupported"
            if len(ext_list) > 6:
                ext_summary += f", +{len(ext_list)-6} more"

            vdb_count = sum(
                1 for p in (non_importable or [])
                if os.path.splitext(p)[1].lower() == ".vdb"
            )
            is_vdb_pack = vdb_count > 0

            # RAR detection
            rar_count = sum(
                1 for p in (non_importable or [])
                if os.path.splitext(p)[1].lower() == ".rar"
            )
            is_rar_pack = rar_count > 0 and not is_vdb_pack

            # 3D-model detection. GLB/GLTF are NOT supported by Fusion natively
            # (per official Blackmagic docs) — they get a separate "convert" message.
            _3D_EXTS = (".fbx", ".obj", ".abc")
            _GLB_EXTS = (".glb", ".gltf")
            three_d_files = [
                p for p in (non_importable or [])
                if os.path.splitext(p)[1].lower() in _3D_EXTS
            ]
            glb_files = [
                p for p in (non_importable or [])
                if os.path.splitext(p)[1].lower() in _GLB_EXTS
            ]
            is_glb_pack = bool(glb_files) and not is_vdb_pack and not is_rar_pack and not three_d_files
            is_3d_pack = (
                bool(three_d_files)
                and not is_vdb_pack
                and not is_rar_pack
            )

            if is_vdb_pack:
                DLG_W, DLG_H = 480, 380
            elif is_rar_pack:
                DLG_W, DLG_H = 500, 360
            elif is_glb_pack:
                DLG_W, DLG_H = 500, 360
            elif is_3d_pack:
                DLG_W, DLG_H = 520, 400
            else:
                DLG_W, DLG_H = 460, 320
            dlg = tk.Toplevel(self)
            dlg.title(
                "VDB Volumes Detected" if is_vdb_pack
                else "RAR Archive — Manual Extract Needed" if is_rar_pack
                else "GLB/GLTF — Convert to FBX First" if is_glb_pack
                else "3D Model — Drag into Fusion" if is_3d_pack
                else "Not a Resolve format"
            )
            dlg.configure(bg=C["bg"])
            dlg.transient(self)
            dlg.grab_set()
            dlg.resizable(False, False)
            dlg.geometry(f"{DLG_W}x{DLG_H}")
            dlg.update_idletasks()
            try:
                px = self.winfo_rootx() + (self.winfo_width() - DLG_W) // 2
                py = self.winfo_rooty() + (self.winfo_height() - DLG_H) // 2
                dlg.geometry(f"{DLG_W}x{DLG_H}+{max(20, px)}+{max(20, py)}")
            except Exception:
                pass

            # Header — icon + title + filename subtitle
            hdr = tk.Frame(dlg, bg=C["bg"])
            hdr.pack(fill="x", padx=24, pady=(22, 0))
            tk.Label(
                hdr,
                text=("☁" if is_vdb_pack else "🗜" if is_rar_pack
                      else "⬡" if is_glb_pack else "▲" if is_3d_pack else "📦"),
                bg=C["bg"], fg=C["text"],
                font=("Segoe UI", 28),
            ).pack(side="left", padx=(0, 14))
            title_col = tk.Frame(hdr, bg=C["bg"])
            title_col.pack(side="left", fill="x", expand=True)
            tk.Label(
                title_col,
                text=(
                    "VDB Volumes Detected" if is_vdb_pack
                    else "RAR Archive — Manual Extract Needed" if is_rar_pack
                    else "GLB/GLTF — Convert to FBX First" if is_glb_pack
                    else "3D Model — Ready to Drop into Fusion" if is_3d_pack
                    else "Not a Resolve format"
                ),
                bg=C["bg"], fg=C["text"],
                font=("Segoe UI", 14, "bold"), anchor="w",
            ).pack(fill="x")
            # Truncate long filenames so they don't blow up the dialog.
            short_label = asset_label
            if len(short_label) > 48:
                short_label = short_label[:45] + "…"
            tk.Label(
                title_col, text=short_label,
                bg=C["bg"], fg=C["text3"] if "text3" in C else C["text2"],
                font=("Segoe UI", 10), anchor="w",
            ).pack(fill="x", pady=(2, 0))

            # Body — explain + show ext list as a chip-style block
            body = tk.Frame(dlg, bg=C["bg"])
            body.pack(fill="x", padx=24, pady=(16, 0))
            if is_vdb_pack:
                vdb_word = "VDB volume file" + ("s" if vdb_count != 1 else "")
                body_text = (
                    f"This pack contains {vdb_count} {vdb_word}.\n\n"
                    "VDB import requires DaVinci Resolve 19 or newer (free or "
                    "Studio — uVolume is available in both editions).\n\n"
                    "In Fusion: add a \"uVolume\" node (USD → uVolume) and load "
                    "the file from the extracted folder, or use Open in Explorer "
                    "to drag-drop the .vdb directly into the comp."
                )
            elif is_glb_pack:
                _glb_names = sorted({os.path.basename(p) for p in glb_files})
                _glb_list = ", ".join(_glb_names[:3]) + (
                    f"  (+{len(_glb_names) - 3} more)" if len(_glb_names) > 3 else ""
                )
                body_text = (
                    f"File(s): {_glb_list}\n\n"
                    "GLB and GLTF formats are not supported by DaVinci Resolve "
                    "Fusion (per official Blackmagic documentation).\n\n"
                    "To use this asset in Resolve:\n"
                    "1. Click \"Open in Explorer\" to open the extracted folder.\n"
                    "2. Open the .glb file in Blender (free) or any 3D app.\n"
                    "3. Export as FBX or OBJ.\n"
                    "4. Drag the .fbx into Fusion's node graph."
                )
            elif is_rar_pack:
                body_text = (
                    "This asset shipped as a RAR archive.\n\n"
                    "Auto-extraction was attempted (7-Zip / WinRAR / unrar) but "
                    "no compatible extractor was found on this system.\n\n"
                    "To extract manually:\n"
                    "1. Click \"Open in Explorer\" below.\n"
                    "2. Right-click the .rar → Extract with 7-Zip or WinRAR.\n"
                    "3. Drag the extracted .fbx/.obj/.abc into Fusion.\n\n"
                    "Tip: download the ZIP version (if available in the Format "
                    "dropdown) — ZIP imports automatically."
                )
            elif is_3d_pack:
                # Show actual 3D filenames so user knows what to drag
                _3d_names = sorted({os.path.basename(p) for p in three_d_files})
                _3d_list = ", ".join(_3d_names[:3]) + (
                    f"  (+{len(_3d_names) - 3} more)" if len(_3d_names) > 3 else ""
                )
                body_text = (
                    f"Extracted and ready: {_3d_list}\n\n"
                    "Resolve's Fusion needs you to drag the 3D file into the "
                    "node graph manually (the scripting API for FBX/OBJ/ABC "
                    "import is unreliable across Resolve versions).\n\n"
                    "1. Click \"Open Fusion Page\" below — switches Resolve to Fusion.\n"
                    "2. Click \"Open in Explorer\" — opens the extracted folder.\n"
                    "3. Drag the .fbx (or .obj/.abc) from Explorer into Fusion's "
                    "node graph. Fusion auto-creates the right import node."
                )
            else:
                body_text = (
                    "DaVinci Resolve can't import this asset directly. It contains "
                    "3D model / volumetric files that belong in Blender, Houdini, "
                    "or similar tools."
                )
            tk.Label(
                body,
                text=body_text,
                bg=C["bg"], fg=C["text2"],
                font=("Segoe UI", 10),
                wraplength=DLG_W-48, justify="left", anchor="w",
            ).pack(fill="x")
            ext_card = tk.Frame(
                body, bg=C["card"],
                highlightbackground=C["border"], highlightthickness=1,
            )
            ext_card.pack(fill="x", pady=(14, 0))
            tk.Label(
                ext_card, text="Contains",
                bg=C["card"], fg=C["text2"],
                font=("Segoe UI", 9, "bold"), anchor="w",
            ).pack(fill="x", padx=14, pady=(10, 2))
            tk.Label(
                ext_card, text=ext_summary,
                bg=C["card"], fg=C["text"],
                font=("Segoe UI", 11, "bold"), anchor="w",
            ).pack(fill="x", padx=14, pady=(0, 12))

            # Footer — themed action pills (close + reveal)
            footer = tk.Frame(dlg, bg=C["bg"])
            footer.pack(side="bottom", fill="x", padx=24, pady=20)

            def _make_pill(parent, text, on_click, *, primary=False):
                bg_main = C["accent"] if primary else C["card"]
                bg_hover = C["accent2"] if primary else C["card_h"]
                fg_main = "white" if primary else C["text"]
                wrap = tk.Frame(
                    parent,
                    bg=C["accent"] if primary else C["border"],
                    cursor="hand2",
                )
                inner_p = tk.Frame(wrap, bg=bg_main)
                inner_p.pack(padx=(0 if primary else 1), pady=(0 if primary else 1))
                lbl = tk.Label(
                    inner_p, text=text, bg=bg_main, fg=fg_main,
                    font=("Segoe UI", 10, "bold" if primary else "normal"),
                    cursor="hand2", padx=22, pady=10, anchor="center",
                )
                lbl.pack()
                for w in (wrap, inner_p, lbl):
                    w.bind("<Button-1>", lambda _e, fn=on_click: fn())
                def _on(_e):
                    inner_p.configure(bg=bg_hover); lbl.configure(bg=bg_hover)
                    if primary:
                        wrap.configure(bg=bg_hover)
                def _off(_e):
                    inner_p.configure(bg=bg_main); lbl.configure(bg=bg_main)
                    if primary:
                        wrap.configure(bg=bg_main)
                for w in (wrap, inner_p, lbl):
                    w.bind("<Enter>", _on)
                    w.bind("<Leave>", _off)
                return wrap

            def _do_reveal():
                # Windows: open Explorer and select the file/folder
                try:
                    reveal_path = os.path.abspath(target)
                    if os.path.isfile(reveal_path):
                        subprocess.Popen(["explorer", "/select,", reveal_path])
                    else:
                        subprocess.Popen(["explorer", reveal_path])
                except Exception as e:
                    print(f"[REVEAL] Explorer open failed: {e}")
                dlg.destroy()

            def _do_open_fusion():
                _dlog(f"[OPEN FUSION] click — resolve={'OK' if self.resolve else 'NONE'}")
                try:
                    if self.resolve and hasattr(self.resolve, "OpenPage"):
                        ok = self.resolve.OpenPage("fusion")
                        _dlog(f"[OPEN FUSION] OpenPage('fusion') returned: {ok!r}")
                        try:
                            fusion = self.resolve.Fusion() if hasattr(self.resolve, "Fusion") else None
                            if fusion:
                                comp = fusion.GetCurrentComp()
                                if not comp and hasattr(fusion, "NewComp"):
                                    new_comp = fusion.NewComp()
                                    _dlog(f"[OPEN FUSION] NewComp() returned: {new_comp!r}")
                                else:
                                    _dlog(f"[OPEN FUSION] existing comp present: {bool(comp)}")
                        except Exception as _ce:
                            _dlog(f"[OPEN FUSION] comp setup failed: {_ce!r}")
                    else:
                        _dlog("[OPEN FUSION] self.resolve missing or has no OpenPage")
                except Exception as e:
                    _dlog(f"[OPEN FUSION] OpenPage('fusion') failed: {e!r}")
                try:
                    dlg.destroy()
                except Exception:
                    pass

            _make_pill(footer, "Open in Explorer", _do_reveal, primary=True
                       ).pack(side="right")
            if (is_vdb_pack or is_3d_pack) and self.resolve:
                _make_pill(footer, "Open Fusion Page", _do_open_fusion
                           ).pack(side="right", padx=(0, 10))
            _make_pill(footer, "Close", dlg.destroy
                       ).pack(side="right", padx=(0, 10))

            # Esc closes; Enter triggers the primary action.
            dlg.bind("<Escape>", lambda _e: dlg.destroy())
            dlg.bind("<Return>", lambda _e: _do_reveal())
        except Exception as e:
            print(f"[IMPORT] Non-importable dialog: {e}")

    def _auto_import_to_resolve(self, dl_info):
        """FIX: Reliable auto-import with retry and proper path handling."""
        print("[IMPORT] Starting auto-import...")
        try:
            # Attempt reconnect if needed
            if not self.resolve:
                try:
                    import DaVinciResolveScript as dvr
                    self.resolve = dvr.scriptapp("Resolve")
                except Exception:
                    pass

            filepath = dl_info.get("filepath","")
            filename = os.path.basename(filepath) if filepath else dl_info.get("filename","file")

            if not self.resolve:
                # Queue for later import
                if filepath and os.path.exists(filepath):
                    self.pending_imports.append({
                        "filepath":      filepath,
                        "filename":      filename,
                        "download_info": dl_info,
                        "timestamp":     time.time(),
                    })
                    dl_info["import_status"] = "pending"
                    self._update_statusbar_pending()
                    print(f"[IMPORT] Queued: {filename}")
                    # Only show popup if not running from within Resolve
                    if not self._resolve_module_available:
                        self.after(0, lambda: messagebox.showinfo(
                            "File Downloaded",
                            f"✅ Downloaded: {filename}\n\n"
                            f"⏳ Queued for import — will import automatically\n"
                            f"when DaVinci Resolve is running with a project open.\n\n"
                            f"Or use 'Import to Resolve' in 'My Downloads'.",
                            parent=self))
                    else:
                        # Running from Resolve but not connected yet — retry immediately
                        self.after(2000, self._process_pending_imports)
                    self._save_download_history()
                return

            if not filepath or not os.path.exists(filepath):
                print(f"[IMPORT] File not found: {filepath}")
                dl_info["import_status"] = "failed"
                self._save_download_history()
                return

            # Import to Media Pool
            try:
                # Get project manager safely
                pm = self.resolve.GetProjectManager() if self.resolve else None
                if not pm:
                    raise Exception("Cannot get ProjectManager — is a project open in Resolve?")

                proj = pm.GetCurrentProject()
                if not proj:
                    raise Exception("No project open in DaVinci Resolve — please open a project first")

                mp = proj.GetMediaPool()
                if not mp:
                    raise Exception("Cannot access Media Pool")

                # For RAR files: try auto-extraction first, then route to Fusion or dialog
                if filepath.lower().endswith('.rar'):
                    self.after(0, lambda: self.status_var.set(f"⏳ Extracting RAR..."))
                    rar_extract_dir = os.path.splitext(filepath)[0] + "_extracted"
                    _rar_ok = self._try_extract_rar(filepath, rar_extract_dir)
                    if _rar_ok and os.path.isdir(rar_extract_dir):
                        _3d_exts = set(_FUSION_3D_TOOLS.keys())
                        rar_3d_files = []
                        for _rr, _dd, _ff in os.walk(rar_extract_dir):
                            for _fn in _ff:
                                if os.path.splitext(_fn)[1].lower() in _3d_exts:
                                    rar_3d_files.append(os.path.join(_rr, _fn))
                        if rar_3d_files:
                            dl_info["extract_dir"] = rar_extract_dir
                            self._save_download_history()
                            import_ok = self._try_import_3d_to_fusion(rar_extract_dir, rar_3d_files)
                            dl_info["import_status"] = "success" if import_ok else "not_supported"
                            self._save_download_history()
                            if import_ok:
                                self.after(0, lambda: self.status_var.set("✓ 3D model (from RAR) imported to Fusion"))
                            else:
                                self.after(0, lambda ed=rar_extract_dir, ei=rar_3d_files:
                                    self._show_non_importable_info(filename, ed, ei))
                            return
                    # No tool or no 3D files in RAR — show info dialog
                    dl_info["import_status"] = "not_supported"
                    self._save_download_history()
                    self.after(0, lambda fp=filepath:
                        self._show_non_importable_info(filename, None, [fp]))
                    return

                # For ZIP files: extract based on format
                if filepath.lower().endswith('.zip'):
                    asset = dl_info.get("asset") or {}
                    fi_info = dl_info.get("file_info") or {}
                    fmt_zip = str(fi_info.get("format", "") or "").upper()
                    cat_slug = _cgheven_relation_slug(asset.get("categorie") or {})
                    if cat_slug == '3d-models':
                        self.after(0, lambda: self.status_var.set(f"⏳ Unzipping {filename}..."))
                        import zipfile as _zipfile
                        extract_dir = filepath[:-4] + "_extracted"
                        os.makedirs(extract_dir, exist_ok=True)
                        try:
                            with _zipfile.ZipFile(filepath, 'r') as _z:
                                _z.extractall(extract_dir)
                        except Exception as _ze:
                            print(f"[UNZIP] Error extracting 3D ZIP: {_ze}")
                            extract_dir = None
                        # Collect all 3D files from the extract
                        _3d_exts = set(_FUSION_3D_TOOLS.keys())
                        extracted_3d = []
                        if extract_dir and os.path.isdir(extract_dir):
                            for _root, _dirs, _files in os.walk(extract_dir):
                                for _f in _files:
                                    if os.path.splitext(_f)[1].lower() in _3d_exts:
                                        extracted_3d.append(os.path.join(_root, _f))
                        if extracted_3d:
                            # Auto-import supported 3D files (FBX/OBJ/ABC) into Fusion
                            if extract_dir:
                                dl_info["extract_dir"] = extract_dir
                            self._save_download_history()
                            import_ok = self._try_import_3d_to_fusion(extract_dir, extracted_3d)
                            if import_ok:
                                dl_info["import_status"] = "success"
                                self._save_download_history()
                                self.after(0, lambda: self.status_var.set(f"✓ 3D model imported to Fusion"))
                            else:
                                dl_info["import_status"] = "not_supported"
                                self._save_download_history()
                                self.after(0, lambda ed=extract_dir, ei=extracted_3d:
                                    self._show_non_importable_info(filename, ed, ei))
                            return
                        # No FBX/OBJ/ABC found — check for unsupported formats in ZIP
                        _all_exts_in_zip = set()
                        if extract_dir and os.path.isdir(extract_dir):
                            for _r, _d, _ff in os.walk(extract_dir):
                                for _fn in _ff:
                                    _all_exts_in_zip.add(os.path.splitext(_fn)[1].lower())
                        _has_gltf = bool(_all_exts_in_zip & {".gltf", ".glb"})
                        dl_info["import_status"] = "not_supported"
                        dl_info["import_error"] = "No FBX/OBJ/ABC found in ZIP"
                        self._save_download_history()
                        if _has_gltf:
                            self.after(0, lambda: self._show_non_importable_info(
                                filename, extract_dir, []))
                        else:
                            self.after(0, lambda: messagebox.showerror(
                                "No Supported 3D File",
                                "No supported 3D format found in this ZIP.\n\n"
                                "DaVinci Resolve Fusion supports:\n"
                                "• FBX (.fbx) — SurfaceFBXMesh node\n"
                                "• OBJ (.obj) — SurfaceFBXMesh node\n"
                                "• Alembic (.abc) — SurfaceAlembicMesh node\n"
                                "• VDB (.vdb) — uVolume node (Resolve 19+)\n\n"
                                "Convert the model to FBX using Blender and re-import.",
                                parent=self))
                        return
                    elif fmt_zip == "VDB" or self._zip_contains_vdb(filepath):
                        self.after(0, lambda: self.status_var.set(f"⏳ Unzipping VDB {filename}..."))
                        vdb_path, vdb_seq = self._unzip_and_get_vdb(filepath)
                        if vdb_path:
                            filepath = vdb_path
                            filename = os.path.basename(vdb_path)
                            dl_info["filepath"] = vdb_path
                            dl_info["filename"] = filename
                            dl_info["vdb_sequence"] = vdb_seq
                        else:
                            dl_info["import_status"] = "failed"
                            dl_info["import_error"]  = "VDB file not found in ZIP"
                            self._save_download_history()
                            self.after(0, lambda: messagebox.showerror(
                                "VDB Not Found", "VDB file not found in ZIP", parent=self))
                            return

                # Non-ZIP 3D files (.fbx, .obj, .abc, .glb, .gltf): push to Fusion
                _fp_ext = os.path.splitext(filepath)[1].lower()
                if _fp_ext in _FUSION_3D_TOOLS:
                    self.after(0, lambda: self.status_var.set(f"⏳ Importing 3D to Fusion..."))
                    dl_info["import_status"] = "not_supported"
                    self._save_download_history()
                    if not self._try_import_3d_to_fusion(extract_dir=None, non_importable=[filepath]):
                        self.after(0, lambda fp=filepath:
                            self._show_non_importable_info(filename, None, [fp]))
                    return

                # VDB files: import via uVolume node (auto-switches to Fusion page)
                if filepath.lower().endswith('.vdb'):
                    self.after(0, lambda: self.status_var.set(f"⏳ Importing VDB to Fusion..."))
                    vdb_seq = dl_info.get("vdb_sequence")
                    success, msg = self._import_vdb_via_uvolume(filepath, sequence_pattern=vdb_seq)
                    dl_info["import_status"] = "success" if success else "failed"
                    if not success:
                        dl_info["import_error"] = msg
                    self._save_download_history()
                    self.after(0, lambda s=success, m=msg: (
                        self.status_var.set(f"✓ {m}" if s else f"✗ {m}"),
                        messagebox.showinfo("VDB Imported", m, parent=self) if s
                        else messagebox.showerror("VDB Import Failed", m, parent=self)
                    ))
                    return

                # Ensure path uses forward slashes (Resolve prefers this on Windows)
                abs_path = os.path.abspath(filepath).replace("\\", "/")
                print(f"[IMPORT] ImportMedia: {abs_path}")

                result = mp.ImportMedia([abs_path])
                print(f"[IMPORT] Result: {result}")

                if result:
                    dl_info["import_status"]   = "success"
                    dl_info["import_filepath"] = abs_path
                    # Analytics: import success (auto)
                    try:
                        if ANALYTICS_AVAILABLE and _analytics and getattr(self, "analytics_distinct_id", ""):
                            fi = dl_info.get("file_info") or {}
                            _analytics.send_import_event(
                                license_server=LICENSE_SERVER,
                                addon_slug=CGHEVEN_ADDON_SLUG,
                                distinct_id=self.analytics_distinct_id,
                                asset=dl_info.get("asset") or {},
                                import_mode="auto",
                                success=True,
                                fmt=str(fi.get("format", "") or ""),
                            )
                    except Exception:
                        pass
                    self.after(0, lambda fn=filename: self.status_var.set(
                        f"\u2713 Imported to Resolve: {fn}"))
                    self._save_download_history()
                else:
                    # ImportMedia returned falsy — file may already exist or wrong path
                    dl_info["import_status"] = "failed"
                    # Analytics: import failed (auto)
                    try:
                        if ANALYTICS_AVAILABLE and _analytics and getattr(self, "analytics_distinct_id", ""):
                            _analytics.send_import_failed(
                                license_server=LICENSE_SERVER, addon_slug=CGHEVEN_ADDON_SLUG,
                                distinct_id=self.analytics_distinct_id,
                                asset=dl_info.get("asset") or {},
                                error="ImportMedia returned empty result")
                    except Exception:
                        pass
                    self.after(0, lambda fn=filename: messagebox.showwarning(
                        "Import Warning",
                        f"File downloaded but import returned empty result:\n{fn}\n\n"
                        f"Try using 'Import to Resolve' button manually,\n"
                        f"or drag the file from:\n{abs_path}",
                        parent=self))

            except Exception as ex:
                _ex_msg = str(ex)
                print(f"[IMPORT] Exception: {_ex_msg}")
                dl_info["import_status"] = "failed"
                dl_info["import_error"]  = _ex_msg
                # Analytics: import failed (auto)
                try:
                    if ANALYTICS_AVAILABLE and _analytics and getattr(self, "analytics_distinct_id", ""):
                        _analytics.send_import_failed(
                            license_server=LICENSE_SERVER, addon_slug=CGHEVEN_ADDON_SLUG,
                            distinct_id=self.analytics_distinct_id,
                            asset=dl_info.get("asset") or {}, error=_ex_msg)
                except Exception:
                    pass
                # Queue for retry when project is open
                self.pending_imports.append({
                    "filepath":      filepath,
                    "filename":      filename,
                    "download_info": dl_info,
                    "timestamp":     time.time(),
                })
                self._update_statusbar_pending()
                self._save_download_history()
                self.after(0, lambda m=_ex_msg, fn=filename: messagebox.showerror(
                    "Import Error",
                    f"File downloaded to:\nC:/Users/my/Downloads/CGHEVEN/{fn}\n\n"
                    f"Could not auto-import: {m}\n\n"
                    f"Click 'Import to Resolve' in My Downloads tab to retry.",
                    parent=self))
        except Exception as e:
            print(f"[IMPORT OUTER ERROR] {e}")

    def _on_download_failed(self, did, dl_info):
        try:
            filename = dl_info.get("filename","file")
            error    = dl_info.get("error","Unknown error")
            self.status_var.set(f"✗ Download failed: {filename}")
            if ANALYTICS_AVAILABLE and _analytics and getattr(self, "analytics_distinct_id", ""):
                try:
                    http_st = int(dl_info.get("http_status") or 0)
                    _analytics.send_download_failed(
                        license_server=LICENSE_SERVER, addon_slug=CGHEVEN_ADDON_SLUG,
                        distinct_id=self.analytics_distinct_id,
                        asset=dl_info.get("asset") or {},
                        http_status=http_st, error=str(error))
                except Exception:
                    pass
            messagebox.showerror("Download Failed", f"Failed to download {filename}:\n{error}")
            card = dl_info.get("card")
            if card:
                try:
                    card.download_id = None
                    if getattr(card, "is_rp_proxy", False):
                        self._rp_restore_panel_after_download()
                    else:
                        sel_row = getattr(card, "selection_row", None)
                        if sel_row:
                            try:
                                sel_row.pack_forget()
                            except Exception:
                                pass
                        for attr in ("pause_btn", "cancel_btn", "progress_lbl", "progress_bar_canvas"):
                            btn = getattr(card, attr, None)
                            if btn:
                                try:
                                    btn.destroy()
                                except Exception:
                                    pass
                        # Restore outline-button style
                        try:
                            btn_outer = getattr(card, "_btn_outer", None)
                            if btn_outer:
                                btn_outer.pack(fill="both", expand=True)
                        except Exception:
                            pass
                        download_btn = getattr(card, "download_btn", None)
                        if download_btn:
                            try:
                                download_btn.configure(text="Download",
                                                       bg=C["dl_btn"], fg=C["text"])
                                download_btn.pack(side="left", fill="both", expand=True)
                            except Exception:
                                pass
                        mb = getattr(card, "menu_btn", None)
                        if mb:
                            try:
                                mb.pack(side="right", fill="y")
                            except Exception:
                                pass
                except Exception:
                    pass
            if self.downloads_view_active: self._render_downloads_view()
        except Exception as e:
            print(f"[FAIL HANDLER ERROR] {e}")

    # ── Hover Preview ─────────────────────────────────────────────
    def _show_hover_preview(self, card, asset):
        """
        Hover = autoplay loop using `previews` webm URL from API.
        Falls back to enlarged thumbnail if no video frames available.
        """
        if not hasattr(card, "thumb_lbl"):
            return
        thlbl = card.thumb_lbl
        if not card.original_thumb:
            img_ref = getattr(thlbl, "image", None)
            if img_ref:
                card.original_thumb = img_ref

        preview_url = getattr(card, "preview_url", "")
        if not preview_url:
            return

        ext      = os.path.splitext(preview_url.split("?")[0])[1].lower()
        is_video = ext in (".webm",".mp4",".mov",".webm")

        if not is_video:
            threading.Thread(target=self._load_hover_image,
                             args=(card, preview_url, thlbl), daemon=True).start()
            return

        # Already have frames — play immediately
        if getattr(card, "video_frames", None):
            card.video_playing = True
            self._animate_video_preview(card, thlbl, card.video_frames, 0)
            return

        # Already loading — just mark intent
        if getattr(card, "_frames_loading", False):
            card.video_playing = True
            return

        # Start loading frames in background
        card.video_playing   = True
        card._frames_loading = True

        def _bg(c=card, url=preview_url, lbl=thlbl):
            try:
                frames = self._extract_video_frames(url, max_frames=48, fps=10)
                c._frames_loading = False
                if frames:
                    c.video_frames = frames
                    # Show first frame instantly, then start loop
                    if getattr(c, "video_playing", False):
                        self.after(0, lambda c2=c, l2=lbl, f2=frames:
                            self._start_card_anim(c2, l2, f2))
                else:
                    c.video_frames  = None
                    c.video_playing = False
            except Exception as e:
                c._frames_loading = False
                c.video_playing   = False
                print(f"[HOVER BG] {e}")
        threading.Thread(target=_bg, daemon=True).start()

    def _load_hover_image(self, card, url, thlbl):
        try:
            data = self._load_thumbnail_safe(url, timeout=8)
            if not data:
                return
            import io
            img = Image.open(io.BytesIO(data)).resize((144, 90), Image.LANCZOS)
            buf = io.BytesIO()
            img.save(buf, format="PNG"); buf.seek(0)
            self.after_idle(lambda buf=buf, thlbl=thlbl: self._create_preview_photo(buf, thlbl))
        except Exception:
            pass

    def _add_play_badge(self, card, thlbl):
        """Play overlay removed — keep hook for compatibility."""
        return

    def _load_hover_video(self, card, video_url, thlbl):
        """Load video frames and start hover animation."""
        frames = self._extract_video_frames(video_url, max_frames=30, fps=10)
        if frames:
            card.video_frames       = frames
            card.video_playing      = True
            card.video_animation_id = None
            self.after_idle(lambda: self._animate_video_preview(card, thlbl, frames, 0))
        # If no frames, stay with original thumbnail (no change)

    def _extract_video_frames(self, url, max_frames=60, fps=12):
        """Extract frames from preview video. Tries imageio, then ffmpeg. Uses cache for speed."""
        import io, tempfile
        tmp_path = None
        fdir     = None
        
        # Check cache first (fast path)
        cache_key = hashlib.md5(f"{url}_{max_frames}_{fps}".encode()).hexdigest()
        if cache_key in self.video_frames_cache:
            cached_frames = self.video_frames_cache[cache_key]
            print(f"[FRAMES] CACHE HIT: {len(cached_frames)} frames from cache for url='{url[:80]}...'")
            return cached_frames.copy()  # Return copy to avoid mutation
        
        # Debug logging at start
        ext = os.path.splitext(url.split("?")[0])[1].lower() or ".webm"
        print(f"[FRAMES] Starting extraction: url='{url[:120]}' ext='{ext}' max_frames={max_frames} fps={fps}")
        if ext not in (".webm", ".mp4", ".mov"):
            print(f"[FRAMES] Skipping non-video preview ext='{ext}'")
            return []
        
        try:
            # Use correct extension so decoders recognize the format
            tmp      = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
            tmp_path = tmp.name

            req = urllib.request.Request(url, headers={
                "User-Agent": "Mozilla/5.0",
                "Referer": "https://cgheven.com/",
            })
            # Keep preview download capped to stay responsive.
            MAX_PREVIEW_BYTES = 16 * 1024 * 1024
            with urllib.request.urlopen(req, timeout=30) as r:
                chunk = r.read(131072)  # 128KB chunks
                total_size = 0
                while chunk and total_size < MAX_PREVIEW_BYTES:
                    tmp.write(chunk)
                    total_size += len(chunk)
                    chunk = r.read(131072)
            tmp.close()
            file_size_kb = os.path.getsize(tmp_path) // 1024
            print(f"[FRAMES] Downloaded {file_size_kb}KB → {tmp_path}")

            frames = []

            # ── Method A: imageio (pip install imageio[ffmpeg]) ──
            if IMAGEIO_AVAILABLE:
                print(f"[FRAMES] Trying imageio backend...")
                try:
                    import imageio
                    # Use ffmpeg plugin explicitly — handles webm properly
                    try:
                        reader = imageio.get_reader(tmp_path, plugin="ffmpeg")
                        print(f"[FRAMES] imageio using ffmpeg plugin")
                    except Exception:
                        reader = imageio.get_reader(tmp_path)
                        print(f"[FRAMES] imageio using default plugin")
                    meta    = reader.get_meta_data()
                    src_fps = float(meta.get("fps", fps) or fps)
                    step    = max(1, int(src_fps / fps))
                    print(f"[FRAMES] imageio metadata: fps={src_fps} step={step}")
                    for i, frame in enumerate(reader):
                        if i % step == 0:
                            frames.append(Image.fromarray(frame))
                            if len(frames) >= max_frames:
                                break
                    reader.close()
                    if frames:
                        print(f"[FRAMES] imageio SUCCESS: {len(frames)} frames extracted")
                        # Cache frames for future use
                        self.video_frames_cache[cache_key] = frames
                        # Limit cache size to prevent memory issues (keep last 20 entries)
                        if len(self.video_frames_cache) > 20:
                            oldest_key = next(iter(self.video_frames_cache))
                            del self.video_frames_cache[oldest_key]
                        return frames
                    else:
                        print(f"[FRAMES] imageio produced 0 frames")
                except Exception as e:
                    print(f"[FRAMES] imageio FAILED: {e}")
            else:
                print(f"[FRAMES] imageio not available (IMAGEIO_AVAILABLE=False)")

            # ── Method B: system/Resolve ffmpeg ─────────────────
            global RESOLVE_FFMPEG
            if not RESOLVE_FFMPEG:
                RESOLVE_FFMPEG = _find_resolve_ffmpeg()
            if RESOLVE_FFMPEG:
                print(f"[FRAMES] Trying ffmpeg backend: {RESOLVE_FFMPEG}")
                try:
                    fdir = tempfile.mkdtemp(prefix="cgh_f_")
                    opat = os.path.join(fdir, "f%04d.jpg")
                    result = subprocess.run(
                        [
                            RESOLVE_FFMPEG, "-y", "-loglevel", "warning",
                            "-i", tmp_path,
                            "-vf", f"fps={fps},scale=240:-2",
                            "-frames:v", str(max_frames),
                            "-q:v", "4", opat
                        ],
                        capture_output=True,
                        timeout=30,
                        **_subprocess_hide_console_kwargs(),
                    )
                    stderr_msg = result.stderr.decode()[:200] if result.stderr else ""
                    if stderr_msg:
                        print(f"[FRAMES] ffmpeg stderr: {stderr_msg}")
                    jpgs = sorted([f for f in os.listdir(fdir) if f.endswith(".jpg")])
                    print(f"[FRAMES] ffmpeg extracted {len(jpgs)} JPG files")
                    for jp in jpgs[:max_frames]:
                        try:
                            frames.append(Image.open(os.path.join(fdir, jp)).copy())
                        except Exception:
                            pass
                    if frames:
                        print(f"[FRAMES] ffmpeg SUCCESS: {len(frames)} frames extracted")
                        # Cache frames for future use
                        self.video_frames_cache[cache_key] = frames
                        # Limit cache size to prevent memory issues (keep last 20 entries)
                        if len(self.video_frames_cache) > 20:
                            oldest_key = next(iter(self.video_frames_cache))
                            del self.video_frames_cache[oldest_key]
                        return frames
                    else:
                        print(f"[FRAMES] ffmpeg FAILED: produced 0 frames")
                        # Some webm files decode better directly from URL than partial temp files.
                        try:
                            print("[FRAMES] ffmpeg retry via direct URL input")
                            for jp in os.listdir(fdir):
                                if jp.endswith(".jpg"):
                                    try:
                                        os.unlink(os.path.join(fdir, jp))
                                    except Exception:
                                        pass
                            subprocess.run(
                                [
                                    RESOLVE_FFMPEG, "-y", "-loglevel", "warning",
                                    "-i", url,
                                    "-vf", f"fps={fps},scale=240:-2",
                                    "-frames:v", str(max_frames),
                                    "-q:v", "4", opat
                                ],
                                capture_output=True,
                                timeout=35,
                                **_subprocess_hide_console_kwargs(),
                            )
                            jpgs2 = sorted([f for f in os.listdir(fdir) if f.endswith(".jpg")])
                            print(f"[FRAMES] ffmpeg direct-url extracted {len(jpgs2)} JPG files")
                            for jp in jpgs2[:max_frames]:
                                try:
                                    frames.append(Image.open(os.path.join(fdir, jp)).copy())
                                except Exception:
                                    pass
                            if frames:
                                print(f"[FRAMES] ffmpeg direct-url SUCCESS: {len(frames)} frames extracted")
                                self.video_frames_cache[cache_key] = frames
                                if len(self.video_frames_cache) > 20:
                                    oldest_key = next(iter(self.video_frames_cache))
                                    del self.video_frames_cache[oldest_key]
                                return frames
                        except Exception as e2:
                            print(f"[FRAMES] ffmpeg direct-url FAILED: {e2}")
                except Exception as e:
                    print(f"[FRAMES] ffmpeg FAILED: {e}")
            else:
                print(f"[FRAMES] ffmpeg not available (RESOLVE_FFMPEG=None)")

            print("[FRAMES] FAILED: No preview method available or all methods failed")
            return []
        except Exception as e:
            # Enhanced error logging with URL details
            error_msg = str(e)
            url_repr = repr(url)[:150] if url else "None"
            print(f"[FRAMES OUTER] ERROR extracting frames from url={url_repr}")
            print(f"[FRAMES OUTER] Exception type: {type(e).__name__}, message: {error_msg}")
            return []
        finally:
            if tmp_path:
                try: os.unlink(tmp_path)
                except: pass
            if fdir:
                try:
                    import shutil
                    shutil.rmtree(fdir, ignore_errors=True)
                except: pass

    def _animate_video_preview(self, card, thlbl, frames, frame_index=0):
        """Animate video frames on card thumbnail — loops."""
        try:
            if not thlbl.winfo_exists():
                return
            if not getattr(card, "video_playing", False):
                return
            if frame_index < len(frames):
                photo = ImageTk.PhotoImage(frames[frame_index], master=self)
                if not hasattr(card, "video_photos"):
                    card.video_photos = []
                card.video_photos.append(photo)
                if len(card.video_photos) > 5:
                    card.video_photos.pop(0)
                thlbl.configure(image=photo, text="")
                thlbl.image = photo
            nxt = (frame_index + 1) % len(frames)
            card.video_animation_id = self.after(
                100, lambda: self._animate_video_preview(card, thlbl, frames, nxt))
        except Exception as e:
            print(f"[ANIM ERROR] {e}")

    def _create_preview_photo(self, buf, thlbl):
        try:
            import io
            photo = ImageTk.PhotoImage(Image.open(buf), master=self)
            self._display_thumb(thlbl, photo)
        except Exception:
            pass

    def _start_card_anim(self, card, thlbl, frames):
        """Show first frame immediately, then start loop animation."""
        try:
            if not getattr(card, "video_playing", False):
                return
            if not thlbl.winfo_exists():
                return
            # Show frame 0 immediately
            img = frames[0]
            w, h = thlbl.winfo_width() or 180, thlbl.winfo_height() or 130
            ratio = min(w/max(img.width,1), h/max(img.height,1))
            img_r = img.resize((max(1,int(img.width*ratio)),
                                max(1,int(img.height*ratio))), Image.LANCZOS)
            photo = ImageTk.PhotoImage(img_r, master=self)
            if not hasattr(card, "video_photos"):
                card.video_photos = []
            card.video_photos = [photo]
            thlbl.configure(image=photo, text="")
            thlbl.image = photo
            # Start animation loop
            self._animate_video_preview(card, thlbl, frames, 1)
        except Exception as e:
            print(f"[START ANIM] {e}")

    def _restore_thumbnail(self, card):
        """Restore original thumbnail, stop video animation."""
        card.video_playing = False
        if getattr(card, "video_animation_id", None):
            try: self.after_cancel(card.video_animation_id)
            except: pass
            card.video_animation_id = None
        if hasattr(card, "video_photos"):
            card.video_photos = []
        if not hasattr(card, "thumb_lbl"):
            return
        thlbl = card.thumb_lbl
        if getattr(card, "original_thumb", None):
            self._display_thumb(thlbl, card.original_thumb)
            try: thlbl.configure(text="")
            except: pass

    # ── Popup Preview ─────────────────────────────────────────────
    def _show_popup_preview(self, asset):
        """
        INSTANT popup: shows cached thumbnail immediately.
        No video frame extraction — just show thumb + play button.
        """
        try:
            a        = asset.get("attributes", asset)
            title    = a.get("Title", "Unknown")
            thumb_url = a.get("thumbnail", "")
            aid      = asset.get("id", 0)

            # Analytics: asset viewed (click/open)
            try:
                if ANALYTICS_AVAILABLE and _analytics and getattr(self, "analytics_distinct_id", ""):
                    _analytics.send_asset_viewed(
                        license_server=LICENSE_SERVER,
                        addon_slug=CGHEVEN_ADDON_SLUG,
                        distinct_id=self.analytics_distinct_id,
                        asset=asset,
                        view_source="popup",
                    )
            except Exception:
                pass

            if thumb_url:
                thumb_url = self._normalize_media_url(thumb_url)

            # Get video URL
            raw_preview = (a.get("previews") or a.get("preview") or
                           a.get("Preview") or a.get("preview_url") or "")
            preview_url = ""
            if isinstance(raw_preview, str) and raw_preview.strip():
                preview_url = raw_preview.strip()
            elif isinstance(raw_preview, list) and raw_preview:
                preview_url = str(raw_preview[0]).strip()
            if preview_url:
                preview_url = self._normalize_media_url(preview_url)

            files     = a.get("files", []) or []
            video_url = ""
            if preview_url:
                ext = os.path.splitext(preview_url.split("?")[0])[1].lower()
                if ext in (".webm", ".mp4", ".mov"):
                    video_url = preview_url
            if not video_url:
                for f in files:
                    ext = os.path.splitext(str(f).split("?")[0])[1].lower()
                    if ext in (".mp4", ".mov", ".webm"):
                        video_url = f; break
            if video_url:
                video_url = self._normalize_media_url(video_url)

            # ── Build popup ──
            popup = tk.Toplevel(self)
            popup.title(f"Preview: {title}")
            popup.geometry("600x480")
            popup.configure(bg="#0A0A0E")
            popup.transient(self)
            popup.grab_set()

            def close_popup():
                popup.destroy()
            popup.protocol("WM_DELETE_WINDOW", close_popup)

            popup.update_idletasks()
            sw, sh = popup.winfo_screenwidth(), popup.winfo_screenheight()
            popup.geometry(f"600x480+{sw//2-300}+{sh//2-240}")

            # Title bar
            tk.Label(popup, text=title, bg="#0A0A0E", fg="white",
                     font=("Segoe UI", 12, "bold"),
                     wraplength=560, justify="center").pack(pady=(12, 6))
            tk.Frame(popup, bg=C["border"], height=1).pack(fill="x", padx=20)

            # Preview image area
            img_frame = tk.Frame(popup, bg="#111111", height=320)
            img_frame.pack(fill="x", padx=20, pady=(10, 0))
            img_frame.pack_propagate(False)

            img_lbl = tk.Label(img_frame, bg="#111111", text="", fg="#555",
                               font=("Segoe UI", 11))
            img_lbl.place(relwidth=1, relheight=1)
            popup.preview_label = img_lbl

            # STEP 1: Show cached thumbnail INSTANTLY
            cached_photo = self.thumbnails.get(aid)
            if cached_photo and PIL_AVAILABLE:
                # Use cached small photo immediately (0ms)
                img_lbl.configure(image=cached_photo, text="")
                img_lbl.image = cached_photo
                # Then load larger version in background
                if thumb_url:
                    def _load_large():
                        try:
                            import io
                            data = self._load_thumbnail_from_cache(thumb_url)
                            if data:
                                img = Image.open(io.BytesIO(data))
                                w, h = img.size
                                ratio = min(560/w, 310/h, 1.0)
                                img = img.resize((max(1,int(w*ratio)), max(1,int(h*ratio))),
                                                 Image.LANCZOS)
                                self.after_idle(lambda i=img: self._display_popup_image(popup, i))
                        except Exception: pass
                    threading.Thread(target=_load_large, daemon=True).start()
            elif thumb_url and PIL_AVAILABLE:
                threading.Thread(target=self._load_popup_image,
                                 args=(popup, thumb_url), daemon=True).start()
            else:
                img_lbl.configure(text="No preview", fg="#555")

            # ── Video section (inline preview + status) ──
            if video_url:
                vid_bar = tk.Frame(popup, bg="#0D0D12")
                vid_bar.pack(fill="x", padx=20, pady=(4, 0))

                # HIDDEN: vid_status label (Playing frames text)
                # vid_status = tk.Label(vid_bar, text="⟳ Loading video...",
                #                       bg="#0D0D12", fg=C["accent"],
                #                       font=("Segoe UI", 8))
                # vid_status.pack(side="left", padx=4)
                # popup.vid_status = vid_status
                popup.vid_status = None  # Keep reference for compatibility

                # Stop/play toggle button
                popup.video_playing = True

                def _stop_video():
                    popup.video_playing = False
                    # vid_status.configure(text="⏸ Paused", fg=C["text2"])

                def _start_video():
                    popup.video_playing = True
                    frs = getattr(popup, "video_frames", None)
                    if frs:
                        # vid_status.configure(text=f"▶ Playing ({len(frs)} frames)", fg=C["accent"])
                        self._animate_popup_video(popup, frs, 0)

                # HIDDEN: Play/Pause button
                # toggle_btn = tk.Button(vid_bar, text="⏸ Pause",
                #                        bg="#0D0D12", fg=C["text2"],
                #                        activebackground=C["card_h"],
                #                        relief="flat", cursor="hand2",
                #                        font=("Segoe UI", 8), padx=8, pady=2,
                #                        command=_stop_video)
                # toggle_btn.pack(side="left", padx=4)
                # popup.toggle_btn = toggle_btn
                popup.toggle_btn = None  # Keep reference for compatibility

                # HIDDEN: Open in Player button
                # open_btn = tk.Button(vid_bar, text="⎋ Open in Player",
                #                      bg="#0D0D12", fg=C["text2"],
                #                      activebackground=C["card_h"],
                #                      relief="flat", cursor="hand2",
                #                      font=("Segoe UI", 8), padx=8, pady=2,
                #                      command=lambda u=video_url: self._play_preview(u))
                # open_btn.pack(side="right", padx=4)

                # Load video frames — Resolve: main thread; else: background thread
                def _do_load_video():
                    self._load_popup_video_inline(popup, video_url, thumb_url)

                if getattr(self, "resolve", None):
                    # Resolve: small delay so popup renders first, then load in main thread
                    popup.after(200, _do_load_video)
                else:
                    threading.Thread(target=_do_load_video, daemon=True).start()

            # Bottom bar
            bot = tk.Frame(popup, bg="#0A0A0E")
            bot.pack(fill="x", padx=20, pady=(10, 12))

            # Category badge
            cat_data = a.get("categorie")
            cat_name = ""
            if isinstance(cat_data, dict):
                cat_name = cat_data.get("Name", cat_data.get("name", ""))
            if cat_name:
                tk.Label(bot, text=f"  {cat_name}  ",
                         bg=C["accent2"], fg="white",
                         font=("Segoe UI", 8)).pack(side="left", padx=(0, 8))

            fc = len(files)
            if fc:
                tk.Label(bot, text=f"{fc} file{'s' if fc!=1 else ''}",
                         bg="#0A0A0E", fg=C["text2"],
                         font=("Segoe UI", 8)).pack(side="left")

            tk.Button(bot, text="✕ Close",
                      bg=C["card_h"], fg=C["text2"],
                      activebackground=C["red"], activeforeground="white",
                      relief="flat", cursor="hand2",
                      font=("Segoe UI", 8), padx=10, pady=4,
                      command=close_popup).pack(side="right")

        except Exception as e:
            print(f"[POPUP ERROR] {e}")

    def _load_popup_video_inline(self, popup, video_url, thumb_url):
        """Load video frames — works in both normal and Resolve (main-thread) modes."""
        in_resolve = bool(getattr(self, "resolve", None))

        def _set_status(msg, color="#888"):
            try:
                lbl = getattr(popup, "vid_status", None)
                if not lbl:
                    return
                def _do():
                    try:
                        if lbl.winfo_exists():
                            lbl.configure(text=msg, fg=color)
                    except Exception:
                        pass
                if in_resolve:
                    _do()   # already in main thread
                else:
                    self.after_idle(_do)
            except Exception:
                pass

        def _start_anim(frames):
            """Schedule animation in main thread."""
            def _do():
                try:
                    if not popup.winfo_exists():
                        return
                    popup.video_frames  = frames
                    popup.video_playing = True
                    btn = getattr(popup, "toggle_btn", None)
                    if btn and btn.winfo_exists():
                        btn.configure(text="⏸ Pause",
                                      command=lambda: _stop_helper())
                    # HIDDEN: Status text update
                    # _set_status(f"▶ Playing ({len(frames)} frames)", C["accent"])
                    self._animate_popup_video(popup, frames, 0)
                except Exception as e:
                    print(f"[VIDEO ANIM] {e}")

            def _stop_helper():
                popup.video_playing = False
                # HIDDEN: Status text update
                # _set_status("⏸ Paused", C["text2"])
                btn = getattr(popup, "toggle_btn", None)
                if btn and btn.winfo_exists():
                    btn.configure(text="▶ Resume",
                                  command=lambda: _resume_helper())

            def _resume_helper():
                frs = getattr(popup, "video_frames", None)
                if frs:
                    popup.video_playing = True
                    # HIDDEN: Status text update
                    # _set_status(f"▶ Playing ({len(frs)} frames)", C["accent"])
                    btn = getattr(popup, "toggle_btn", None)
                    if btn and btn.winfo_exists():
                        btn.configure(text="⏸ Pause",
                                      command=lambda: _stop_helper())
                    self._animate_popup_video(popup, frs, 0)

            if in_resolve:
                _do()
            else:
                self.after_idle(_do)

        # Check cache first
        import hashlib as _hl
        cache_key = _hl.md5(f"{video_url}_40_10".encode()).hexdigest()
        if cache_key in self.video_frames_cache:
            # HIDDEN: Status text update
            # _set_status("▶ Cached — loading...", C["green"])
            frames = self.video_frames_cache[cache_key]
            _start_anim(frames)
            return

        # HIDDEN: Status text update
        # _set_status("⟳ Downloading preview...", C["accent"])
        frames = self._extract_video_frames(video_url, max_frames=40, fps=10)

        if frames:
            _start_anim(frames)
        else:
            popup.video_playing = False
            # HIDDEN: Status text update
            # _set_status("⎋ No video — use Open in Player", C["text2"])

    def _load_popup_preview(self, popup, preview_url, thumb_url):
        """Legacy stub — now handled by _show_popup_preview."""
        pass

    def _safe_popup_label(self, popup, text, color="#666"):
        try:
            if hasattr(popup, "preview_label") and popup.preview_label.winfo_exists():
                popup.preview_label.configure(text=text, fg=color)
        except Exception:
            pass

    def _add_popup_install_hint(self, popup):
        try:
            hint = tk.Label(popup,
                            text="💡 Install imageio for video playback:\n  pip install imageio",
                            bg="#000000", fg="#4A9EFF", font=("Segoe UI", 9), justify="center")
            hint.pack(pady=(0, 8))
        except Exception:
            pass

    def _load_popup_image(self, popup, image_url):
        try:
            import io
            data = self._load_thumbnail_safe(image_url, timeout=12, use_cache=True)
            if not data:
                raise Exception("No data")
            img = Image.open(io.BytesIO(data))
            w, h  = img.size
            ratio = min(520/w, 360/h, 1.0)
            img   = img.resize((int(w*ratio), int(h*ratio)), Image.LANCZOS)
            self.after_idle(lambda img=img, popup=popup: self._display_popup_image(popup, img))
        except Exception as e:
            print(f"[POPUP IMG ERROR] {e}")
            self.after_idle(lambda: self._safe_popup_label(popup, "Failed to load preview", "#f00"))

    def _display_popup_image(self, popup, img):
        try:
            if not (hasattr(popup, "preview_label") and popup.preview_label.winfo_exists()):
                return
            photo = ImageTk.PhotoImage(img, master=popup)
            popup.preview_label.configure(image=photo, text="")
            popup.preview_label.image = photo
        except Exception as e:
            print(f"[POPUP DISPLAY ERROR] {e}")

    def _animate_popup_video(self, popup, frames, frame_index=0):
        """FIX: Animate video in popup — loops continuously."""
        try:
            if not hasattr(popup, "preview_label") or not popup.preview_label.winfo_exists():
                return
            if not getattr(popup, "video_playing", False):
                return
            if frame_index < len(frames):
                img = frames[frame_index]
                w, h  = img.size
                ratio = min(520/w, 360/h, 1.0)
                img   = img.resize((int(w*ratio), int(h*ratio)), Image.LANCZOS)
                photo = ImageTk.PhotoImage(img, master=popup)
                if not hasattr(popup, "video_photos"):
                    popup.video_photos = []
                popup.video_photos.append(photo)
                if len(popup.video_photos) > 5:
                    popup.video_photos.pop(0)
                popup.preview_label.configure(image=photo, text="")
                popup.preview_label.image = photo
            nxt = (frame_index + 1) % len(frames)
            popup.video_animation_id = self.after(
                66, lambda: self._animate_popup_video(popup, frames, nxt))
        except Exception as e:
            print(f"[POPUP ANIM ERROR] {e}")

    # ── Preview Panel (kept for completeness) ────────────────────
    def _show_preview_empty(self):
        pass

    def _show_preview(self, asset):
        pass

    def _play_preview(self, url):
        # Normalize URL before opening
        normalized_url = self._normalize_media_url(url)
        print(f"[PLAY PREVIEW] Opening video: original='{url[:100]}' normalized='{normalized_url[:100]}'")
        
        ext = os.path.splitext(normalized_url.split("?")[0])[1].lower()
        if ext in (".webm",".mp4",".mov"):
            try:
                if sys.platform == "win32":
                    os.startfile(normalized_url); return
                elif sys.platform == "darwin":
                    subprocess.Popen(["open", normalized_url]); return
                else:
                    subprocess.Popen(["xdg-open", normalized_url]); return
            except Exception as e:
                print(f"[PLAY PREVIEW] Failed to open with system player: {e}")
                pass
        webbrowser.open(normalized_url)

    # ── Status Bar ────────────────────────────────────────────────
    def _build_statusbar(self):
        bar = tk.Frame(self, bg=C["topbar"], height=28)
        bar.pack(fill="x", side="bottom")
        bar.pack_propagate(False)

        tk.Label(bar, textvariable=self.status_var,
                 bg=C["topbar"], fg=C["dim"],
                 font=("Segoe UI", 8)).pack(side="left", padx=12)

        self.pending_label = tk.Label(bar, text="", bg=C["topbar"], fg=C["accent"],
                                      font=("Segoe UI", 8))
        self.pending_label.pack(side="right", padx=8)

        self.check_resolve_btn = tk.Button(bar, text="Check Resolve",
                                             bg=C["card"], fg=C["text2"],
                                             activebackground=C["accent"], activeforeground="white",
                                             relief="flat", cursor="hand2",
                                             font=("Segoe UI", 7), padx=8, pady=2,
                                             command=self._manual_check_resolve)
        self.check_resolve_btn.pack(side="right", padx=(0, 6))

        self._update_statusbar_pending()

        rv = "🟢  Resolve Connected" if self.resolve else "⚫  Standalone Mode"
        fc = C["green"] if self.resolve else C["dim"]
        tk.Label(bar, text=rv, bg=C["topbar"], fg=fc,
                 font=("Segoe UI", 8)).pack(side="right", padx=12)

        tk.Label(bar, text=f"{ADDON_NAME}  ·  v{ADDON_VER}",
                 bg=C["topbar"], fg=C["dim"],
                 font=("Segoe UI", 7)).pack(side="right", padx=12)

    def _update_statusbar_pending(self):
        try:
            n = len(self.pending_imports)
            if n > 0:
                self.pending_label.configure(text=f"⏳ {n} queued")
                self.check_resolve_btn.pack(side="right", padx=(0,10))
            else:
                self.pending_label.configure(text="")
                self.check_resolve_btn.pack_forget()
        except Exception:
            pass

    def _manual_check_resolve(self):
        self.status_var.set("🔍 Checking for DaVinci Resolve...")
        self._check_and_import_pending()

    # ── API Calls ─────────────────────────────────────────────────
    def _load_categories(self):
        if getattr(self, "_categories_loading", False):
            return  # prevent concurrent loads
        self._categories_loading = True
        self.status_var.set("⟳  Loading categories...")

        # Resolve env: threads can't make network calls (SSL hangs silently).
        # Detect Resolve and run fetch directly in main thread via after_idle.
        resolve_env = bool(getattr(self, "resolve", None))
        if resolve_env:
            print("[CATS] Resolve env detected — fetching in main thread")
            self.after_idle(self._fetch_categories_main_thread)
        else:
            threading.Thread(target=self._fetch_categories, daemon=True).start()

    def _fetch_categories_main_thread(self):
        """Fetch categories directly in main thread (for Resolve environment).
        Uses after() to keep UI responsive — fetches in small steps."""
        self.update_idletasks()
        try:
            print("[CATS] Main-thread fetch starting...")
            raw = self.api.fetch_categories()
            print(f"[CATS] Main-thread fetch got {len(raw)} raw items")
            # Reuse same build logic
            self._build_category_tree(raw)
        except Exception as e:
            _emsg = str(e)
            print(f"[CATEGORIES ERROR] {_emsg}")
            self.status_var.set(f"⚠  API Error: {_emsg[:80]}")
        finally:
            self._categories_loading = False
            self._render_category_tree()
            if self.categories:
                self.after(100, self._load_assets)

    def _build_category_tree(self, raw):
        """Build category tree from raw API response. Works in any thread context."""
        by_id   = {}
        by_slug = {}
        for item in raw:
            a    = item.get("attributes", item)
            name = a.get("Name", a.get("name","Unknown"))
            slug = a.get("Slug", a.get("slug",""))
            cid  = item.get("id")
            doc_id = (item.get("documentId") or a.get("documentId") or "")
            cat  = {"id": cid, "doc_id": doc_id,
                    "name": name, "slug": slug,
                    "children": [], "parent_id": None}
            parent_data = a.get("parent_category") or a.get("parent") or {}
            if isinstance(parent_data, dict):
                pid = (parent_data.get("data", {}) or {})
                if isinstance(pid, dict):
                    cat["parent_id"] = pid.get("id")
                elif isinstance(pid, int):
                    cat["parent_id"] = pid
            by_id[cid]    = cat
            by_slug[slug] = cat

        PARENT_MAP = {
            "furniture": "3d-models", "props": "3d-models", "weapons": "3d-models",
            "atmospheric-fx-vdbs": "vdbs", "congestus-clouds": "vdbs",
            "cumulus-cloud": "vdbs", "explosions": "vdbs", "fire-vdbs": "vdbs",
            "magic-effects-vdbs": "vdbs", "tornado-fx-vdbs": "vdbs",
            "atmospheric-fx-vfx": "vfx", "cannon-sparks": "vfx",
            "explosions-vfx": "vfx", "fire-vfx": "vfx", "lightning-fx": "vfx",
            "magic-effects-vfx": "vfx", "muzzle-flashes-vfx": "vfx",
            "tornado-fx-vfx": "vfx", "space-hdr": "hdri",
        }
        TOP_SLUGS = {"vfx", "3d-models", "hdri", "vdbs"}

        for cat in by_slug.values():
            s = cat["slug"]
            if s in TOP_SLUGS:
                continue
            if s in PARENT_MAP:
                cat["parent_slug"] = PARENT_MAP[s]

        roots = []
        for cat in by_slug.values():
            ps = cat.get("parent_slug")
            if ps and ps in by_slug:
                parent = by_slug[ps]
                if cat not in parent["children"]:
                    parent["children"].append(cat)
            elif cat.get("parent_id") and cat["parent_id"] in by_id:
                parent = by_id[cat["parent_id"]]
                if cat not in parent["children"]:
                    parent["children"].append(cat)
            else:
                if cat["slug"] in TOP_SLUGS:
                    roots.append(cat)

        roots = [cat for cat in roots if cat.get("slug") in TOP_SLUGS]
        for root in roots:
            root["children"] = [ch for ch in (root.get("children") or []) if (ch.get("parent_slug") or root.get("slug")) in TOP_SLUGS]

        order = ["3d-models", "vdbs", "vfx", "hdri"]
        roots.sort(key=lambda c: order.index(c["slug"]) if c["slug"] in order else 99)
        self.categories = roots if roots else list(by_slug.values())
        print(f"[CATS] {len(self.categories)} parents, "
              f"{sum(len(c['children']) for c in self.categories)} children")

    def _fetch_categories(self):
        """Background thread fetch (non-Resolve env)."""
        try:
            raw = self.api.fetch_categories()
            self._build_category_tree(raw)
        except Exception as e:
            _emsg = str(e)
            print(f"[CATEGORIES ERROR] {_emsg}")
            if "SESSION_EXPIRED" in _emsg:
                self.after_idle(lambda: self.status_var.set(
                    "⚠  Session expired — please restart the addon"))
                self.after_idle(lambda: _show_session_expired_dialog(self))
            elif "LICENSE_INACTIVE" in _emsg:
                self.after_idle(lambda: self.status_var.set(
                    "⚠  License inactive — please renew your membership"))
                self.after_idle(lambda: _show_license_expired(self))
            else:
                self.after_idle(lambda: self.status_var.set(
                    "⚠  Connection issue — check internet and try again"))
                self.after_idle(lambda: _show_connection_error(self))
        finally:
            self._categories_loading = False
            self.after_idle(self._render_category_tree)
            if self.categories:
                self.after_idle(self._load_assets)

    def _debug_asset_schema_once(self):
        """One-time debug: fetch 1 asset, print all field keys to find category field."""
        if not CGHEVEN_DEBUG_VERBOSE:
            return
        if getattr(self, "_schema_debugged", False):
            return
        self._schema_debugged = True
        def _do():
            try:
                data = self.api._get("/assets", {
                    "populate": "*",
                    "pagination[page]": 1,
                    "pagination[pageSize]": 1,
                })
                items = data.get("data", [])
                if items:
                    item = items[0]
                    a = item.get("attributes", item)
                    print("=" * 60)
                    print("[SCHEMA DEBUG] Asset top-level keys:", list(item.keys()))
                    print("[SCHEMA DEBUG] Asset attribute keys:", list(a.keys()))
                    # Print category-related fields
                    for k, v in a.items():
                        if any(x in k.lower() for x in ["cat", "tag", "type", "group"]):
                            print(f"[SCHEMA DEBUG] Field '{k}': {repr(v)[:300]}")
                    # Also print ALL fields for full picture
                    print("[SCHEMA DEBUG] ALL fields:")
                    for k, v in a.items():
                        print(f"  '{k}': {repr(v)[:150]}")
                    print("=" * 60)
            except Exception as e:
                print(f"[SCHEMA DEBUG ERROR] {e}")
        threading.Thread(target=_do, daemon=True).start()

    def _load_assets(self, page=1):
        self.current_page = page
        # Optional schema debug (disabled in production via CGHEVEN_DEBUG_VERBOSE)
        if CGHEVEN_DEBUG_VERBOSE:
            self.after(500, self._debug_asset_schema_once)
        # Performance: measure load time from request start → UI update (page 1 only)
        try:
            self._assets_load_t0 = time.time()
            self._assets_load_source = "search" if self._active_search_query() else "browse"
        except Exception:
            self._assets_load_t0 = None
            self._assets_load_source = ""
        cat_slug = self.active_cat
        query = self._active_search_query()
        sort = self.sort_var.get()

        # UX: whenever starting a new browse/search (page 1), scroll to top + start anim
        if int(page) == 1:
            try:
                self.after_idle(lambda: self._scroll_grid_to_top(smooth=True))
            except Exception:
                pass
            self._start_loading_anim()

        # Super-fast local mode (prefetch/disk cache)
        if (getattr(self, "_all_assets_local", None)
                and (getattr(self, "_assets_prefetch_ready", False) or getattr(self, "_assets_cache_loaded", False))):
            try:
                local = self._local_filter_assets(query=query, cat_slug=cat_slug, sort=sort)
                # "Free" filter = assets that have at least one free (non-locked) downloadable file.
                if getattr(self, "_free_filter_active", False):
                    local = [a for a in local if self._asset_has_free_file(a)]
                page_size = GRID_PAGE_SIZE
                start = max(0, (int(page) - 1) * page_size)
                end = start + page_size
                batch = local[start:end]
                if page == 1:
                    self.filtered = batch
                else:
                    self.filtered.extend(batch)
                self.total_assets = len(local)
                self._stop_loading_anim()
                try:
                    self.count_lbl.configure(text=f"{len(self.filtered)} of {self.total_assets} assets")
                except Exception:
                    pass
                self.status_var.set(f"✓  {self.total_assets} assets  ·  cgheven.com")
                self._render_grid()
                return
            except Exception:
                pass

        # Instant from cache (must not bypass client-side filters / sort)
        if (cat_slug and cat_slug != "all" and
                cat_slug in self.category_cache and page == 1 and not query
                and not self._category_cache_must_bypass()):
            cached = [
                it for it in self.category_cache[cat_slug]
                if self._asset_passes_catalog_exclusions(it)
            ]
            # "Free" filter = assets that have at least one free (non-locked) downloadable file.
            if getattr(self, "_free_filter_active", False):
                cached = [a for a in cached if self._asset_has_free_file(a)]
            self.filtered     = cached[:GRID_PAGE_SIZE]
            self.total_assets = len(cached)
            self._stop_loading_anim()
            self.count_lbl.configure(text=f"{len(self.filtered)} assets (cached)")
            self.status_var.set("✓  Loading from cache...")
            self._render_grid()
            return

        self.status_var.set("⟳  Loading assets...")
        # Resolve env: run in main thread to avoid SSL/network hang in threads
        if getattr(self, "resolve", None):
            self.after_idle(lambda q=query, c=cat_slug, s=sort: self._fetch_assets(q, c, s))
        else:
            threading.Thread(target=self._fetch_assets,
                             args=(query, cat_slug, sort), daemon=True).start()

    def _load_more(self):
        self._load_assets(self.current_page + 1)

    @staticmethod
    def _strip_patreon_locks(items):
        """Treat every asset as unlocked in the UI (no Patreon gate)."""
        for it in items or []:
            a = it.get("attributes")
            if isinstance(a, dict):
                a["is_patreon_locked"] = False

    def _fetch_assets(self, query, cat_slug, sort):
        try:
            sort_map = {
                "Newest":   "createdAt:desc",
                "Oldest":   "createdAt:asc",
                "Name A-Z": "Title:asc",
                "Name Z-A": "Title:desc",
            }
            params = {
                "populate":             "*",
                "pagination[page]":     self.current_page,
                "pagination[pageSize]": GRID_PAGE_SIZE,
                "sort":                 sort_map.get(sort, "createdAt:desc"),
            }
            if query:
                params["filters[Title][$containsi]"] = query
            # Always send addonSlug so backend can compute addon_is_free per asset
            params["addonSlug"] = ADDON_ACCESS_SLUG
            if getattr(self, "_free_filter_active", False):
                params["freeOnly"] = "true"
            if cat_slug and cat_slug != "all":
                # Find parent or child in tree
                parent_cat = None
                child_cat  = None
                for p in self.categories:
                    if p["slug"] == cat_slug:
                        parent_cat = p; break
                    for ch in p.get("children", []):
                        if ch["slug"] == cat_slug:
                            child_cat = ch; break
                    if child_cat:
                        break

                # Build list of cats to include
                if parent_cat is not None:
                    cats_needed = [parent_cat] + parent_cat.get("children", [])
                    print(f"[FETCH] PARENT '{cat_slug}' → {len(cats_needed)} cats")
                else:
                    cats_needed = [child_cat] if child_cat else []
                    print(f"[FETCH] CHILD '{cat_slug}'")

                # ── Strapi v5: use documentId for reliable relation filtering ──
                doc_ids = [c["doc_id"] for c in cats_needed if c.get("doc_id")]
                int_ids = [str(c["id"]) for c in cats_needed if c.get("id")]
                print(f"[FETCH] doc_ids={doc_ids} int_ids={int_ids}")

                if doc_ids:
                    # Primary: filter by documentId (stable in Strapi v5)
                    if len(doc_ids) == 1:
                        params["filters[categorie][documentId][$eq]"] = doc_ids[0]
                    else:
                        for i, did in enumerate(doc_ids):
                            params[f"filters[categorie][documentId][$in][{i}]"] = did
                elif int_ids:
                    # Fallback: integer id
                    if len(int_ids) == 1:
                        params["filters[categorie][id][$eq]"] = int_ids[0]
                    else:
                        for i, iid in enumerate(int_ids):
                            params[f"filters[categorie][id][$in][{i}]"] = iid

            data  = self.api._get("/assets", params)
            items = data.get("data", [])
            total = data.get("meta",{}).get("pagination",{}).get("total", len(items))
            self.free_trial       = bool(data.get("meta", {}).get("free_trial", False))
            self.free_trial_until = data.get("meta", {}).get("free_trial_until", "") or ""
            self.banner_cfg       = data.get("meta", {}).get("banner") or getattr(self, "banner_cfg", None)
            print(f"[FETCH categorie] free_trial={self.free_trial} until={self.free_trial_until} returned {len(items)} items")

            # Analytics: search event (only on first page, only if query non-empty)
            try:
                if (
                    ANALYTICS_AVAILABLE
                    and _analytics
                    and getattr(self, "analytics_distinct_id", "")
                    and self.current_page == 1
                    and (query or "").strip()
                ):
                    _analytics.send_search_event(
                        license_server=LICENSE_SERVER,
                        addon_slug=CGHEVEN_ADDON_SLUG,
                        distinct_id=self.analytics_distinct_id,
                        query=query,
                        results_count=int(total or 0) if items else 0,
                        category=str(cat_slug or ""),
                        result_asset_names=[str((i.get("attributes", i) or {}).get("Title", (i.get("attributes", i) or {}).get("title", "")) or "") for i in (items or [])],
                        result_asset_ids=[str(i.get("id", "") or "") for i in (items or [])],
                    )
            except Exception:
                pass

            # ── If 0 results, also try subcategories relation ──────────────
            if len(items) == 0 and cat_slug and cat_slug != "all":
                sub_params = {
                    "populate":             "*",
                    "pagination[page]":     self.current_page,
                    "pagination[pageSize]": GRID_PAGE_SIZE,
                    "sort":                 params.get("sort", "createdAt:desc"),
                    "addonSlug":            ADDON_ACCESS_SLUG,
                }
                if getattr(self, "_free_filter_active", False):
                    sub_params["freeOnly"] = "true"
                if query:
                    sub_params["filters[Title][$containsi]"] = query

                # Try subcategories with documentId
                if doc_ids:
                    if len(doc_ids) == 1:
                        sub_params["filters[subcategories][documentId][$eq]"] = doc_ids[0]
                    else:
                        for i, did in enumerate(doc_ids):
                            sub_params[f"filters[subcategories][documentId][$in][{i}]"] = did
                    print(f"[FETCH subcategories] trying doc_ids={doc_ids}")
                elif int_ids:
                    if len(int_ids) == 1:
                        sub_params["filters[subcategories][id][$eq]"] = int_ids[0]
                    else:
                        for i, iid in enumerate(int_ids):
                            sub_params[f"filters[subcategories][id][$in][{i}]"] = iid
                    print(f"[FETCH subcategories] trying int_ids={int_ids}")
                else:
                    sub_params = None

                if sub_params:
                    try:
                        data2  = self.api._get("/assets", sub_params)
                        items2 = data2.get("data", [])
                        total2 = data2.get("meta",{}).get("pagination",{}).get("total", len(items2))
                        print(f"[FETCH subcategories] returned {len(items2)} items")
                        if items2:
                            seen = {x["id"] for x in items}
                            for x in items2:
                                if x["id"] not in seen:
                                    items.append(x)
                                    seen.add(x["id"])
                            total = max(total, total2)
                    except Exception as e2:
                        print(f"[FETCH subcategories ERROR] {e2}")

            self._strip_patreon_locks(items)

            try:
                items = [
                    it for it in (items or [])
                    if self._asset_in_active_view(it)
                    and self._asset_matches_filters(it)
                    and self._asset_passes_catalog_exclusions(it)
                ]
                total = len(items)
            except Exception:
                pass

            # ── If still 0, print all keys of first asset for schema debug ──
            if len(items) == 0 and cat_slug and cat_slug != "all":
                print(f"[FETCH] 0 results for '{cat_slug}' — running schema debug")
                try:
                    dbg = self.api._get("/assets", {"populate": "*",
                        "pagination[page]": 1, "pagination[pageSize]": 1})
                    dbg_items = dbg.get("data", [])
                    if dbg_items:
                        a0 = dbg_items[0].get("attributes", dbg_items[0])
                        print(f"[SCHEMA] keys: {list(a0.keys())}")
                        for k, v in a0.items():
                            if any(x in k.lower() for x in ["cat","sub","tag","group","type"]):
                                print(f"[SCHEMA] '{k}': {repr(v)[:200]}")
                except Exception:
                    pass

            if self.current_page == 1:
                self.filtered = items
            else:
                self.filtered.extend(items)
            self.total_assets = total

            if cat_slug and cat_slug != "all" and self.current_page == 1 and not query:
                self.category_cache[cat_slug] = items.copy()

            fc = len(self.filtered)
            def update_ui():
                try:
                    self._stop_loading_anim()
                    self.count_lbl.configure(text=f"{fc} of {total} assets")
                    self.status_var.set(f"✓  {total} assets  ·  cgheven.com")
                    self._render_grid()
                    # Analytics: asset load performance (sampled)
                    try:
                        t0 = getattr(self, "_assets_load_t0", None)
                        if t0 and ANALYTICS_AVAILABLE and _analytics and getattr(self, "analytics_distinct_id", ""):
                            _analytics.send_asset_load_performance(
                                license_server=LICENSE_SERVER,
                                addon_slug=CGHEVEN_ADDON_SLUG,
                                distinct_id=self.analytics_distinct_id,
                                source=str(getattr(self, "_assets_load_source", "") or ""),
                                load_time_ms=int((time.time() - float(t0)) * 1000),
                                asset_count=int(total or 0),
                                sample_rate=1.0,
                            )
                    except Exception:
                        pass
                except Exception:
                    pass
            # If in Resolve main-thread mode, call directly; else schedule
            if getattr(self, "resolve", None):
                update_ui()
            else:
                self.after_idle(update_ui)
        except Exception as e:
            _emsg = str(e)
            print(f"[FETCH ASSETS ERROR] {_emsg}")
            if "SESSION_EXPIRED" in _emsg:
                self.after_idle(lambda: self.status_var.set(
                    "⚠  Session expired — please restart the addon"))
                if not self.filtered:
                    self.after(0, lambda: _show_session_expired_dialog(self))
            elif "LICENSE_INACTIVE" in _emsg:
                self.after_idle(lambda: self.status_var.set(
                    "⚠  License inactive — please renew your membership"))
                if not self.filtered:
                    self.after(0, lambda: _show_license_expired(self))
            else:
                self.after_idle(lambda: self.status_var.set(
                    "⚠  Connection issue — check internet and try again"))
                if not self.filtered:
                    self.after(0, lambda: _show_connection_error(self))
            self.filtered = []
            self.after(0, self._stop_loading_anim)
            self.after(0, self._render_grid)

    def _category_cache_must_bypass(self):
        """Category disk cache ignores filters — skip it when user changed sort/filters."""
        try:
            if (self.sort_var.get() or "Newest") != "Newest":
                return True
            if bool(self.filter_downloaded_only.get()):
                return True
            fav = getattr(self, "filter_favourite_only", None)
            if fav and bool(fav.get()):
                return True
        except Exception:
            return True
        return False

    def _on_search_typing(self, e):
        try:
            if hasattr(self, "search_entry"):
                current = self.search_entry.get()
                placeholder = getattr(self, "_search_placeholder", "")
                if current != placeholder:
                    self.search_entry.configure(fg=C["text"])
            self._update_search_clear_visibility()
        except Exception:
            pass
        return None

    def _on_search_enter(self, e):
        self._commit_search()
        return None

    def _commit_search(self):
        try:
            se = getattr(self, "search_entry", None)
            placeholder = getattr(self, "_search_placeholder", "")
            raw = se.get() if se else self.search_var.get()
            query = raw.strip() if raw != placeholder else ""
            self._search_committed_query = query
            self.current_page = 1
            self._update_search_clear_visibility()
            self._load_assets_with_feedback(page=1)
        except Exception:
            pass

    def _clear_search(self):
        try:
            se = getattr(self, "search_entry", None)
            ph = getattr(self, "_search_placeholder", "Search assets… (Enter)")
            if se and se.winfo_exists():
                se.delete(0, tk.END)
                se.insert(0, ph)
                se.configure(fg=C["dim"])
            self.search_var.set("")
            self._search_committed_query = ""
            self.current_page = 1
            self._update_search_clear_visibility()
            self._load_assets_with_feedback(page=1)
        except Exception:
            pass

    def _clear_filters(self):
        try:
            self.sort_var.set("Newest")
            self.filter_downloaded_only.set(False)
            self.filter_favourite_only.set(False)
            self.filter_resolution_var.set("All")
            self.filter_type_var.set("All")
            self._update_filter_summary()
            self._update_filter_button_label()
            self.current_page = 1
            self._update_filter_clear_visibility()
            self._load_assets_with_feedback(page=1)
        except Exception:
            pass

    def _toggle_free_filter(self):
        self._free_filter_active = not self._free_filter_active
        if hasattr(self, "_free_btn"):
            try:
                self._free_btn.configure(
                    bg="#059669" if self._free_filter_active else "#10b981",
                    relief="sunken" if self._free_filter_active else "flat",
                )
            except Exception:
                pass
        self._load_assets(page=1)

    def _invalidate_asset_caches(self):
        """Drop every client-side asset cache (in-memory prefetch, per-category cache, and
        the on-disk assets cache) so the NEXT fetch goes to the network at the current auth +
        plan. Needed because the proxy strips/keeps CGHLOCKED markers per plan — a guest/Free
        cached (locked) grid must not be served to a now-paid user.

        DaVinci-specific note: this addon has no per-query in-memory dict cache (like the
        Blender ``S._query_cache``). Instead it caches: ``_all_assets_local`` (prefetch list),
        ``category_cache`` (per-category page-1), and a disk file ``assets_cache_v1.json``. We
        clear all three + reset the prefetch flags so a re-fetch is forced."""
        try:
            self._all_assets_local = None
            self._assets_prefetch_ready = False
            self._assets_prefetch_started = False
            self._assets_cache_loaded = False
        except Exception:
            pass
        try:
            self.category_cache.clear()
        except Exception:
            pass
        # Remove the disk cache file so a relaunch can't resurrect the stale (locked) grid.
        try:
            p = getattr(self, "_assets_cache_path", "")
            if p and os.path.exists(p):
                os.remove(p)
        except Exception:
            pass

    def _on_plan_changed(self, new_plan="", plan_label="", is_premium=None):
        """Called when the heartbeat detects a plan change (Free → Pro/Studio bought after
        login, or an expiry). Update the cached plan label, drop the stale asset caches and
        re-fetch the CURRENT view so access (locked vs full) updates live without re-login."""
        try:
            if new_plan:
                self.user_plan = new_plan
            if is_premium is not None:
                self.is_premium = bool(is_premium)
            if plan_label:
                self.user_plan_label = plan_label
        except Exception:
            pass
        self._invalidate_asset_caches()
        try:
            self._load_assets(page=self.current_page or 1)
        except Exception:
            try:
                self._load_assets(page=1)
            except Exception:
                pass

    def _account_login_inapp(self, register=False):
        """In-app browser login for a guest. Opens the browser; on success the app adopts the
        session, leaves guest mode and reloads the library at the new plan — no restart needed."""
        if not AUTH_MODULE_AVAILABLE:
            try:
                messagebox.showinfo("Login", "Account login unavailable (auth module missing).", parent=self)
            except Exception:
                pass
            return
        self._reg_flag = bool(register)
        result_q = queue.Queue()

        def _worker():
            try:
                result = login_with_account(status_cb=lambda m: result_q.put(("status", str(m))), register=register)
                result_q.put(("success", result))
            except Exception as e:
                result_q.put(("error", e))

        threading.Thread(target=_worker, daemon=True).start()
        self._poll_inapp_login(result_q)

    def _poll_inapp_login(self, result_q):
        try:
            item = result_q.get_nowait()
        except queue.Empty:
            self.after(120, lambda: self._poll_inapp_login(result_q))
            return
        kind = item[0]
        if kind == "status":
            self.after(120, lambda: self._poll_inapp_login(result_q))
            return
        if kind == "error":
            try:
                messagebox.showerror("Login failed", str(item[1])[:160], parent=self)
            except Exception:
                pass
            return
        # success → adopt the new session and reload the library at the new plan.
        result = item[1] or {}
        try:
            sess_token = result.get("session_token", "") or ""
            self.session_token = sess_token
            self.is_guest = not bool(sess_token)
            if sess_token and hasattr(self, "api"):
                self.api.update_token(sess_token)
            try:
                _analytics_emit_account_login(register=getattr(self, "_reg_flag", False))
            except Exception:
                pass
            try:
                _start_heartbeat(self)
            except Exception:
                pass
            self._on_plan_changed(
                result.get("plan", "") or "",
                result.get("plan_label", "") or "",
                result.get("is_premium"),
            )
            try:
                messagebox.showinfo("Logged in", "✓  Logged in — your plan is now active.", parent=self)
            except Exception:
                pass
        except Exception as e:
            print(f"[INAPP LOGIN] post-login error: {e}")

    def _refresh(self):
        self.thumbnails.clear()
        self.category_cache.clear()
        self._load_categories()

    def _select_asset(self, asset):
        self.selected = asset
        # Popup preview removed — use right panel preview instead
        try:
            self._show_asset_in_right_panel(asset)
            self._set_selected_card(next((c for c in self.grid_inner.winfo_children()
                                          if isinstance(c, tk.Frame) and getattr(c, "asset_id", None) == asset.get("id", None)), None))
        except Exception:
            pass

    def _import_to_resolve(self, asset):
        path = filedialog.askopenfilename(
            title="Select downloaded file to import",
            filetypes=[("All Media","*.*"),("VDB","*.vdb"),("EXR","*.exr"),
                       ("FBX","*.fbx"),("MP4","*.mp4"),("MOV","*.mov")])
        if path and self.resolve:
            try:
                _pm2   = self.resolve.GetProjectManager() if self.resolve else None
                _proj2 = _pm2.GetCurrentProject() if _pm2 else None
                _mp2   = _proj2.GetMediaPool() if _proj2 else None
                if not _mp2:
                    raise Exception("No project open")
                _mp2.ImportMedia([path.replace("\\", "/")])
                self.status_var.set(f"✓  Imported: {os.path.basename(path)}")
                messagebox.showinfo("Success", f"✓  Imported:\n{os.path.basename(path)}")
            except Exception as ex:
                messagebox.showerror("Import Error", str(ex))

    # ── Update Badge / Status Helpers ────────────────────────────
    def _on_update_check_done(self):
        """
        Background update check khatam hone ke baad main thread mein call hota hai.
        - Titlebar badge show/hide karta hai
        - Sidebar label refresh karta hai
        - Update available hone pe popup dikhata hai
        """
        try:
            if UPDATE_MODULE_AVAILABLE and _auto_update:
                st = _auto_update.state
                if st.update_available:
                    # Analytics: update_prompt_shown once when an update is first found
                    try:
                        if (ANALYTICS_AVAILABLE and _analytics
                                and getattr(self, "analytics_distinct_id", "")
                                and not getattr(self, "_update_prompt_tracked", False)):
                            self._update_prompt_tracked = True
                            _analytics.send_event(
                                license_server=LICENSE_SERVER,
                                addon_slug=CGHEVEN_ADDON_SLUG,
                                addon_version=ADDON_VER,
                                distinct_id=self.analytics_distinct_id,
                                event_name="update_prompt_shown",
                                properties={"from_version": ADDON_VER, "to_version": st.update_version or ""},
                            )
                    except Exception:
                        pass
                    # Titlebar badge show karo
                    try:
                        self._titlebar_update_badge.pack(side="left", padx=(0, 4))
                    except Exception:
                        pass
                    # Sidebar label refresh karo (agar already open hai)
                    self._refresh_update_status_label()
                    # Popup dikhao
                    _auto_update.show_update_notification(self)
                else:
                    # Up to date — badge chhupao
                    try:
                        self._titlebar_update_badge.pack_forget()
                    except Exception:
                        pass
                    self._refresh_update_status_label()
        except Exception as e:
            print(f"[UPDATE] on_done callback error: {e}")

    # ── License Info Popup ───────────────────────────────────────
    def _show_license_info(self):
        """License details popup — status, plan, expiry, deactivate."""
        try:
            sess = None
            if AUTH_MODULE_AVAILABLE:
                try:
                    from cgheven_auth import _load_session
                    sess = _load_session()
                except Exception:
                    pass

            popup = tk.Toplevel(self)
            popup.title("My Account")
            popup.geometry("420x320")
            popup.configure(bg=C["bg"])
            popup.resizable(False, False)
            popup.transient(self)
            popup.grab_set()
            popup.update_idletasks()
            sw = popup.winfo_screenwidth()
            sh = popup.winfo_screenheight()
            popup.geometry(f"420x410+{sw//2-210}+{sh//2-205}")

            # Header
            hdr = tk.Frame(popup, bg=C["topbar"], height=50)
            hdr.pack(fill="x"); hdr.pack_propagate(False)
            tk.Label(hdr, text="  👤  My Account",
                     bg=C["topbar"], fg=C["accent"],
                     font=("Segoe UI", 12, "bold")).pack(side="left", padx=12, pady=12)
            tk.Label(hdr, text=f"v{ADDON_VER}",
                     bg=C["topbar"], fg=C["dim"],
                     font=("Segoe UI", 9)).pack(side="right", padx=12)

            # Body
            body = tk.Frame(popup, bg=C["bg"], pady=16)
            body.pack(fill="both", expand=True, padx=24)

            # Community support — Discord (blurple, docked at the bottom so it shows in
            # BOTH the activated and not-activated states; a stuck user needs help most).
            disc_wrap = tk.Frame(body, bg=C["bg"])
            disc_wrap.pack(side="bottom", fill="x", pady=(12, 0))
            tk.Label(disc_wrap,
                     text="Questions or issues? Get help from our team & community.",
                     bg=C["bg"], fg=C["dim"], font=("Segoe UI", 8)).pack(pady=(6, 4))
            disc_btn = tk.Button(
                disc_wrap, text="💬   Join CGHEVEN Discord",
                bg="#5865F2", fg="white", activebackground="#4752C4",
                activeforeground="white", relief="flat", cursor="hand2",
                font=("Segoe UI", 10, "bold"), pady=9, bd=0,
                command=lambda: webbrowser.open(DISCORD_URL),
            )
            disc_btn.pack(fill="x")
            disc_btn.bind("<Enter>", lambda e: disc_btn.config(bg="#4752C4"))
            disc_btn.bind("<Leave>", lambda e: disc_btn.config(bg="#5865F2"))

            def info_row(label, value, val_color=None):
                row = tk.Frame(body, bg=C["bg"])
                row.pack(fill="x", pady=3)
                tk.Label(row, text=label, bg=C["bg"], fg=C["text2"],
                         font=("Segoe UI", 9), width=14, anchor="w").pack(side="left")
                tk.Label(row, text=value, bg=C["bg"],
                         fg=val_color or C["text"],
                         font=("Segoe UI", 9, "bold"), anchor="w").pack(side="left")

            if sess:
                # Friendly plan name (Free / Pro / Studio / Studio Team …) — same as the Blender
                # addon. Prefer the server's plan_label; only fall back to the raw plan code
                # (e.g. "Yearly") if the label is missing.
                _rawp     = str(sess.get("plan_label") or sess.get("plan") or "Unknown").strip()
                if _rawp.lower() in ("free_addon", "free"):
                    plan = "Free"
                elif " " in _rawp or any(c.isupper() for c in _rawp):
                    plan = _rawp
                else:
                    plan = _rawp.capitalize()
                expires   = sess.get("expires_at", "")
                if expires and len(str(expires)) >= 10:
                    expires = str(expires)[:10]
                grace_ts  = sess.get("offline_grace_until", 0)
                grace_hrs = max(0, (grace_ts - time.time()) / 3600)
                # Account details (like the Blender addon's "My Details").
                _name  = str(sess.get("name") or sess.get("user_name") or "").strip()
                _email = str(sess.get("email") or sess.get("user_email") or "").strip()
                _is_premium = bool(sess.get("is_premium"))
                # Free / non-premium = no real expiry → "Lifetime" (matches Blender).
                _exp_display = expires if (_is_premium and expires and expires != "N/A") else "Lifetime"

                info_row("Status :",   "Active", C["green"])
                if _name:
                    info_row("Name :",  _name)
                if _email:
                    info_row("Email :", _email)
                info_row("Plan :",     plan)
                info_row("Expires :",  _exp_display,
                         C["green"] if _exp_display == "Lifetime" else None)
                info_row("Addon :",    "DaVinci Resolve Asset Library")
                if grace_hrs < 24:
                    info_row("Offline :",
                        f"Limited — please connect to internet soon",
                        C["gold"])
                else:
                    info_row("Offline :",  f"Available ({grace_hrs:.0f}h)", C["green"])

                # Free user → orange "Upgrade for full access →" TEXT LINK after the plan.
                if not _is_premium:
                    _up_link = tk.Label(body, text="Upgrade for full access  →",
                                        bg=C["bg"], fg=C["cta"], cursor="hand2",
                                        font=("Segoe UI", 10, "bold"), anchor="w")
                    _up_link.pack(fill="x", pady=(10, 0))
                    _up_link.bind("<Button-1>", lambda _e: webbrowser.open(PRICING_URL))

                tk.Frame(body, bg=C["border"], height=1).pack(fill="x", pady=10)

                btn_row = tk.Frame(body, bg=C["bg"])
                btn_row.pack(fill="x")

                def _do_logout():
                    try:
                        ok = messagebox.askyesno(
                            "Logout",
                            "Are you sure you want to logout on this device?\n\n"
                            "You will need to log in with your CGHEVEN account again.",
                            parent=popup,
                        )
                    except Exception:
                        ok = True
                    if not ok:
                        return

                    # Clear secure session (preferred) + legacy local key (fallback)
                    try:
                        if AUTH_MODULE_AVAILABLE:
                            try:
                                _clear_session()
                            except Exception:
                                pass
                        try:
                            if os.path.exists(LICENSE_FILE):
                                os.remove(LICENSE_FILE)
                        except Exception:
                            pass
                    finally:
                        try:
                            popup.destroy()
                        except Exception:
                            pass
                        try:
                            messagebox.showinfo(
                                "Logged Out",
                                "✓  Logged out successfully.\n\nPlease reopen the addon to login again.",
                                parent=self,
                            )
                        except Exception:
                            pass
                        try:
                            self.destroy()
                        except Exception:
                            pass

                tk.Button(
                    btn_row,
                    text="Logout",
                    bg=C["card_h"],
                    fg=C["text2"],
                    relief="flat",
                    cursor="hand2",
                    font=("Segoe UI", 9),
                    padx=14,
                    pady=6,
                    command=_do_logout,
                ).pack(side="left")
                tk.Button(btn_row, text="View plans & pricing →",
                          bg=C["card"], fg=C["text2"],
                          relief="flat", cursor="hand2",
                          font=("Segoe UI", 9), padx=14, pady=6,
                          command=lambda: webbrowser.open(PRICING_URL)
                          ).pack(side="right")
                tk.Button(btn_row, text="Visit Dashboard →",
                          bg=C["card"], fg=C["text2"],
                          relief="flat", cursor="hand2",
                          font=("Segoe UI", 9), padx=14, pady=6,
                          command=lambda: webbrowser.open(
                              "https://cgheven.com/dashboard")
                          ).pack(side="left")
            else:
                # Not logged in = the FREE tier (no "Guest" wording — show it as a Free plan with a
                # lifetime expiry, like the Blender addon). Logging in unlocks the paid plan's premium.
                info_row("Status :",  "Active",   C["green"])
                info_row("Plan :",    "Free",     C["green"])
                info_row("Expires :", "Lifetime", C["green"])
                info_row("Addon :",   "DaVinci Resolve Asset Library")
                tk.Label(body,
                         text="Free (1K/2K) downloads are open. Log in to unlock\n"
                              "your plan's premium assets (4K/EXR).",
                         bg=C["bg"], fg=C["text2"],
                         font=("Segoe UI", 9), justify="left").pack(pady=8)
                # Login + Sign up on ONE row (professional).
                _auth_row2 = tk.Frame(body, bg=C["bg"])
                _auth_row2.pack(fill="x")
                tk.Button(_auth_row2, text="🔐  Login",
                          bg=C["cta"], fg="white", relief="flat", cursor="hand2",
                          activebackground=C["cta_h"], activeforeground="white",
                          font=("Segoe UI", 10, "bold"), pady=9,
                          command=lambda: (popup.destroy(), self._account_login_inapp(register=False))
                          ).pack(side="left", fill="x", expand=True, padx=(0, 5))
                tk.Button(_auth_row2, text="✨  Sign up",
                          bg=C["card"], fg=C["text"], relief="flat", cursor="hand2",
                          font=("Segoe UI", 10, "bold"), pady=9,
                          command=lambda: (popup.destroy(), self._account_login_inapp(register=True))
                          ).pack(side="left", fill="x", expand=True, padx=(5, 0))
                # Orange "Upgrade for full access →" TEXT LINK BELOW the login / sign-up buttons (centered).
                _up_link = tk.Label(body, text="Upgrade for full access  →",
                                    bg=C["bg"], fg=C["cta"], cursor="hand2",
                                    font=("Segoe UI", 10, "bold"), anchor="center")
                _up_link.pack(fill="x", pady=(12, 0))
                _up_link.bind("<Button-1>", lambda _e: webbrowser.open(PRICING_URL))

            if is_registered():
                tk.Button(popup, text="✎  My Details",
                          bg=C["card"], fg=C["text"],
                          relief="flat", cursor="hand2",
                          font=("Segoe UI", 9), padx=16, pady=5,
                          command=lambda: (popup.destroy(), self._show_my_details())
                          ).pack(pady=(0, 6))

            tk.Button(popup, text="Close",
                      bg=C["card_h"], fg=C["text2"],
                      relief="flat", cursor="hand2",
                      font=("Segoe UI", 9), padx=16, pady=5,
                      command=popup.destroy).pack(pady=(0, 12))

        except Exception as e:
            print(f"[LICENSE INFO ERROR] {e}")

    def _show_my_details(self):
        """View registered details as text; click Change to edit (updates backend + local file)."""
        import queue as _queue
        _FONT = "Segoe UI"
        reg = {}
        try:
            if os.path.isfile(REG_FILE):
                with open(REG_FILE, "r") as f:
                    reg = json.load(f)
        except Exception:
            reg = {}

        popup = tk.Toplevel(self)
        popup.title("My Details")
        popup.configure(bg=C["bg"])
        popup.minsize(420, 380)
        popup.resizable(True, True)
        popup.transient(self); popup.grab_set()
        popup.update_idletasks()
        sw = popup.winfo_screenwidth(); sh = popup.winfo_screenheight()
        popup.geometry(f"460x540+{sw//2-230}+{sh//2-270}")

        tk.Label(popup, text="  ✎  My Details", bg=C["topbar"], fg=C["accent"],
                 font=(_FONT, 12, "bold")).pack(fill="x", ipady=12)

        body = tk.Frame(popup, bg=C["bg"], pady=10)
        body.pack(fill="both", expand=True, padx=28)

        cur = {"name": reg.get("name"), "email": reg.get("email"),
               "phone": reg.get("phone"), "location": reg.get("location")}
        oe = {"v": str(reg.get("email", "") or "")}

        view = tk.Frame(body, bg=C["bg"])
        edit = tk.Frame(body, bg=C["bg"])

        # ── VIEW MODE (read-only text) ──
        val_lbls = {}
        def _vrow(title, key):
            r = tk.Frame(view, bg=C["bg"]); r.pack(fill="x", pady=(10, 0))
            tk.Label(r, text=title, bg=C["bg"], fg=C["text2"],
                     font=(_FONT, 9)).pack(anchor="w")
            v = tk.Label(r, text=(cur.get(key) or "—"), bg=C["bg"], fg=C["text"],
                         font=(_FONT, 11), wraplength=390, justify="left")
            v.pack(anchor="w"); val_lbls[key] = v
        _vrow("Full Name", "name")
        _vrow("Email", "email")
        _vrow("Phone / WhatsApp", "phone")
        _vrow("Country", "location")
        change_btn = tk.Button(view, text="Change", bg=C["accent"], fg="#ffffff",
                               relief="flat", font=(_FONT, 10, "bold"), cursor="hand2", pady=8)
        change_btn.pack(fill="x", pady=(20, 0))

        # ── EDIT MODE (input fields) ──
        def _lbl(t): tk.Label(edit, text=t, bg=C["bg"], fg=C["text2"],
                               font=(_FONT, 9)).pack(anchor="w", pady=(10, 2))
        def _inp(val):
            e = tk.Entry(edit, bg=C["search"], fg=C["text"], insertbackground=C["text"],
                         font=(_FONT, 10), relief="flat", bd=6)
            e.pack(fill="x", ipady=4); e.insert(0, str(val or "")); return e
        _lbl("Full Name");                   name_e  = _inp(cur.get("name"))
        _lbl("Email *");                     email_e = _inp(cur.get("email"))
        _lbl("Phone / WhatsApp (Optional)"); phone_e = _inp(cur.get("phone"))
        _lbl("Country");              loc_e   = _inp(cur.get("location"))

        status_lbl = tk.Label(edit, text="", bg=C["bg"], fg=C["red"],
                               font=(_FONT, 9), wraplength=390)
        status_lbl.pack(anchor="w", pady=(8, 0))

        btn_row = tk.Frame(edit, bg=C["bg"]); btn_row.pack(fill="x", pady=(12, 0))
        save_btn = tk.Button(btn_row, text="Save", bg=C["accent"], fg="#ffffff",
                             relief="flat", font=(_FONT, 10, "bold"), cursor="hand2", pady=8)
        save_btn.pack(side="left", fill="x", expand=True)
        cancel_btn = tk.Button(btn_row, text="Cancel", bg=C["card"], fg=C["text"],
                               relief="flat", font=(_FONT, 10), cursor="hand2", pady=8)
        cancel_btn.pack(side="left", fill="x", expand=True, padx=(8, 0))

        def _show_view(): edit.pack_forget(); view.pack(fill="both", expand=True)
        def _show_edit():
            view.pack_forget(); edit.pack(fill="both", expand=True); status_lbl.config(text="")
        change_btn.config(command=_show_edit)
        cancel_btn.config(command=_show_view)

        _q = _queue.Queue()
        def _poll():
            try:
                while True:
                    action, val = _q.get_nowait()
                    if action == "ok":
                        for k, lab in val_lbls.items():
                            lab.config(text=(cur.get(k) or "—"))
                        save_btn.config(state="normal", text="Save")
                        status_lbl.config(text="✓ Details updated.", fg=C["green"])
                        popup.after(800, _show_view)
                    else:
                        status_lbl.config(text=val[:120], fg=C["red"])
                        save_btn.config(state="normal", text="Save")
            except _queue.Empty:
                pass
            if popup.winfo_exists():
                popup.after(150, _poll)
        popup.after(150, _poll)

        def _submit():
            email = email_e.get().strip()
            if not email or "@" not in email:
                status_lbl.config(text="Please enter a valid email address.", fg=C["red"]); return
            save_btn.config(state="disabled", text="Saving…")
            status_lbl.config(text="")
            name = name_e.get().strip(); phone = phone_e.get().strip(); loc = loc_e.get().strip()
            def _post():
                try:
                    payload = json.dumps({
                        "name": name, "email": email, "phone": phone, "location": loc,
                        "addon_slug": CGHEVEN_ADDON_SLUG, "original_email": oe["v"],
                    }).encode("utf-8")
                    req = urllib.request.Request(
                        f"{LICENSE_SERVER}/api/addon-registrations/register",
                        data=payload,
                        headers={"Content-Type": "application/json",
                                 "Accept": "application/json",
                                 "User-Agent": f"CGHEVENDaVinci/{ADDON_VER}"},
                        method="POST")
                    with urllib.request.urlopen(req, timeout=15): pass
                    with open(REG_FILE, "w") as f:
                        json.dump({"name": name, "email": email, "phone": phone,
                                   "location": loc, "ts": time.time()}, f)
                    cur.update({"name": name, "email": email, "phone": phone, "location": loc})
                    oe["v"] = email
                    _q.put(("ok", ""))
                except urllib.error.HTTPError as e:
                    try:
                        b = e.read().decode("utf-8", errors="ignore")
                        msg = (json.loads(b).get("error") or {}).get("message") or f"Server error ({e.code})"
                    except Exception:
                        msg = f"Server error ({e.code})."
                    _q.put(("err", msg))
                except (urllib.error.URLError, OSError):
                    _q.put(("err", "Cannot connect. Check your internet."))
                except Exception as e:
                    _q.put(("err", str(e)[:100]))
            threading.Thread(target=_post, daemon=True).start()
        save_btn.config(command=_submit)

        _show_view()

    # ── Helpers ───────────────────────────────────────────────────
    def _descendants(self, w):
        out = [w]
        for c in w.winfo_children():
            out.extend(self._descendants(c))
        return out


# ═══════════════════════════════════════════════════════════════════
#  ENTRY POINTS
# ═══════════════════════════════════════════════════════════════════
def _apply_ttk_theme(master=None):
    """
    IMPORTANT: `ttk.Style()` can create a hidden default root if no Tk exists yet,
    which shows up as a blank "tk" window on Windows. Always pass a real Tk/Toplevel
    as `master` once your UI root exists.
    """
    style = ttk.Style(master)
    try: style.theme_use("clam")
    except: pass
    # Combobox input
    style.configure("TCombobox",
                    fieldbackground=C.get("card_h", C["card"]),
                    background=C.get("card_h", C["card"]),
                    foreground="black", selectbackground=C["accent"],
                    selectforeground="white", arrowcolor=C["text2"])
    try:
        style.map(
            "TCombobox",
            foreground=[("readonly", "black"), ("!disabled", "black")],
        )
    except Exception:
        pass
    # Combobox dropdown list colors (Windows Tk uses a Listbox under the hood)
    try:
        if master is not None:
            for pat in (
                "*TCombobox*Listbox.background",
                "*Combobox*Listbox.background",
            ):
                master.tk.call("option", "add", pat, C["card"])
            for pat in (
                "*TCombobox*Listbox.foreground",
                "*Combobox*Listbox.foreground",
            ):
                master.tk.call("option", "add", pat, C["text"])
            for pat in (
                "*TCombobox*Listbox.selectBackground",
                "*Combobox*Listbox.selectBackground",
            ):
                master.tk.call("option", "add", pat, C["accent"])
            for pat in (
                "*TCombobox*Listbox.selectForeground",
                "*Combobox*Listbox.selectForeground",
            ):
                master.tk.call("option", "add", pat, "white")
    except Exception:
        pass
    style.configure("TScrollbar",
                    background=C["card"], troughcolor=C["bg"],
                    arrowcolor=C["dim"], relief="flat")
    style.configure("Sash", sashrelief="flat", background=C["border"])


def _show_connection_error(parent=None):
    """
    User-friendly connection error dialog with clear steps.
    Shows what went wrong and exactly how to fix it.
    """
    import tkinter as _tk_err
    from tkinter import messagebox as _mb_err

    steps = (
        "Unable to load content from CGHEVEN servers.\n\n"
        "Please try the following steps:\n\n"
        "  1.  Make sure you are connected to the internet\n"
        "  2.  Close and reopen the addon\n"
        "  3.  If the issue continues, your session may have\n"
        "       expired — re-enter your license key\n\n"
        "Need help? Visit:  cgheven.com/support"
    )

    try:
        if parent and hasattr(parent, 'winfo_exists') and parent.winfo_exists():
            _mb_err.showerror("Connection Issue", steps, parent=parent)
        else:
            _mb_err.showerror("Connection Issue", steps)
    except Exception as e:
        print(f"[ERROR DIALOG] {e}")


def _show_session_expired_dialog(parent=None):
    """
    Clear dialog when session/license expires.
    Tells user exactly what happened and how to fix it.
    """
    from tkinter import messagebox as _mb_s

    msg = (
        "Your CGHEVEN session has expired.\n\n"
        "This happens when:\n"
        "  •  Your Patreon membership has ended\n"
        "  •  You have been inactive for more than 72 hours\n"
        "  •  Your license was renewed on another device\n\n"
        "To continue:\n"
        "  1.  Make sure your Patreon membership is active\n"
        "  2.  Close and reopen the addon\n"
        "  3.  Enter your license key again\n\n"
        "Visit cgheven.com/dashboard to get your license key."
    )
    try:
        if parent and hasattr(parent, 'winfo_exists') and parent.winfo_exists():
            _mb_s.showerror("Session Expired", msg, parent=parent)
        else:
            _mb_s.showerror("Session Expired", msg)
    except Exception as e:
        print(f"[SESSION DIALOG] {e}")


def _run_license_form(trial_active=False):
    """Blocking license-activation form shown before the app launches. During the trial it
    asks for the key we just emailed; after the trial it gates a purchased license.
    Returns True if activated, "reregister" if the user wants to register again (trial only),
    or False if cancelled."""
    import tkinter as _tk
    import queue as _queue

    activated = {"ok": False, "reregister": False}

    root = _tk.Tk()
    root.withdraw()

    popup = _tk.Toplevel(root)
    popup.title(f"{ADDON_NAME}  —  Activate License")
    popup.geometry("480x430")
    popup.resizable(False, False)
    popup.configure(bg=C["bg"])
    popup.protocol("WM_DELETE_WINDOW", lambda: (root.destroy(), sys.exit(0)))
    root.update_idletasks()
    sw = popup.winfo_screenwidth(); sh = popup.winfo_screenheight()
    popup.geometry(f"480x430+{sw//2-240}+{sh//2-215}")
    popup.lift(); popup.focus_force()

    # ── Header with logo ──────────────────────────────────────────
    header = _tk.Frame(popup, bg=C["topbar"])
    header.pack(fill="x")
    # Back/close button (top-left) — closes the popup, same as the window's X.
    _back = _tk.Label(header, text="←", bg=C["topbar"], fg=C["text2"],
                      font=("Segoe UI", 16, "bold"), cursor="hand2")
    _back.pack(side="left", padx=(12, 2))
    _back.bind("<Button-1>", lambda e: (root.destroy(), sys.exit(0)))
    try:
        import urllib.request as _ur
        import base64 as _b64
        logo_data = _ur.urlopen(APP_ICON_URL, timeout=5).read()
        logo_img = _tk.PhotoImage(data=_b64.b64encode(logo_data).decode())
        logo_img = logo_img.subsample(max(1, logo_img.width() // 36))
        lbl_logo = _tk.Label(header, image=logo_img, bg=C["topbar"])
        lbl_logo.image = logo_img
        lbl_logo.pack(side="left", padx=(14, 6), pady=10)
    except Exception:
        pass
    _tk.Label(header, text=f"{ADDON_NAME}  —  Activate License",
              bg=C["topbar"], fg=C["accent"],
              font=("Segoe UI", 13, "bold")).pack(side="left", pady=14)
    # CGHEVEN logo in the title bar + Windows taskbar (replaces the default Tk feather)
    _cgheven_apply_window_icon(popup)

    # ── Body ─────────────────────────────────────────────────────
    body = _tk.Frame(popup, bg=C["bg"], pady=10)
    body.pack(fill="both", expand=True, padx=28)

    # ── PRIMARY: Login + Sign up on ONE row (professional) ──
    _auth_row = _tk.Frame(body, bg=C["bg"])
    _auth_row.pack(fill="x", pady=(8, 6))
    login_btn = _tk.Button(_auth_row, text="🔐  Login",
                           bg=C["cta"], fg="#ffffff", relief="flat",
                           activebackground=C["cta_h"], activeforeground="white",
                           font=("Segoe UI", 11, "bold"), cursor="hand2",
                           pady=12)
    login_btn.pack(side="left", fill="x", expand=True, padx=(0, 5))
    # Sign up — same browser flow, opens the registration screen first.
    signup_btn = _tk.Button(_auth_row, text="✨  Sign up",
                            bg=C["card"], fg=C["text"], relief="flat",
                            font=("Segoe UI", 11, "bold"), cursor="hand2",
                            pady=12)
    signup_btn.pack(side="left", fill="x", expand=True, padx=(5, 0))

    _reg_flag = [False]

    def _do_account_login(register=False):
        if not AUTH_MODULE_AVAILABLE:
            status_lbl.config(text="Account login unavailable (auth module missing).", fg=C["red"])
            return
        _reg_flag[0] = bool(register)
        login_btn.config(state="disabled", text="Opening browser…")
        try: signup_btn.config(state="disabled")
        except Exception: pass
        status_lbl.config(text="Opening browser… complete the login in your browser.", fg=C["text2"])

        def _status_cb(msg):
            _q.put(("login_status", str(msg)))

        def _bg():
            try:
                login_with_account(status_cb=_status_cb, register=register)
                _q.put(("success", ""))
            except Exception as e:
                _q.put(("login_error", f"Login failed: {str(e)[:120]}"))

        threading.Thread(target=_bg, daemon=True).start()

    login_btn.config(command=lambda: _do_account_login(False))
    signup_btn.config(command=lambda: _do_account_login(True))

    _tk.Label(body,
              text=("Log in with your CGHEVEN account to continue.\n"
                    "Your browser will open to complete a quick, secure sign-in."),
              bg=C["bg"], fg=C["text"], font=("Segoe UI", 9),
              wraplength=420, justify="left").pack(anchor="w", pady=(4, 4))

    status_lbl = _tk.Label(body, text="", bg=C["bg"], fg=C["red"],
                           font=("Segoe UI", 9), wraplength=420)
    status_lbl.pack(anchor="w", pady=(8, 0))

    _q = _queue.Queue()

    def _poll():
        try:
            while True:
                action, val = _q.get_nowait()
                if action == "success":
                    activated["ok"] = True
                    # Analytics: identity just switched (guest → account email). Log the
                    # login (plan props attached) and re-arm session_started.
                    try:
                        _analytics_emit_account_login(register=_reg_flag[0])
                    except Exception:
                        pass
                    popup.destroy(); return
                elif action == "login_status":
                    status_lbl.config(text=val[:140], fg=C["text2"])
                elif action == "login_error":
                    status_lbl.config(text=val[:140], fg=C["red"])
                    login_btn.config(state="normal", text="🔐  Login")
                    try: signup_btn.config(state="normal")
                    except Exception: pass
        except _queue.Empty:
            pass
        root.after(100, _poll)

    root.after(100, _poll)

    _tk.Button(body, text="View plans & pricing →",
               bg=C["card"], fg=C["accent"], relief="flat",
               font=("Segoe UI", 10, "bold"), cursor="hand2",
               pady=11, command=lambda: webbrowser.open(PRICING_URL)).pack(fill="x", pady=(16, 0))

    root.wait_window(popup)
    try: root.destroy()
    except Exception: pass
    if activated["reregister"]:
        return "reregister"
    return activated["ok"]


def _run_registration_form(post_activation=False):
    """Show registration form as blocking Toplevel. Returns True if registered.
    post_activation=True means the user already activated a paid license (after-trial); the form
    is then a data-capture step only (the backend will NOT generate a key after the trial)."""
    import tkinter as _tk
    import queue as _queue

    root = _tk.Tk()
    root.withdraw()

    popup = _tk.Toplevel(root)
    popup.title(f"{ADDON_NAME}  —  Get Free Access")
    popup.geometry("480x480")
    popup.resizable(True, True)
    popup.minsize(440, 480)
    popup.configure(bg=C["bg"])
    popup.protocol("WM_DELETE_WINDOW", lambda: (root.destroy(), sys.exit(0)))
    root.update_idletasks()
    sw = popup.winfo_screenwidth(); sh = popup.winfo_screenheight()
    popup.geometry(f"480x480+{sw//2-240}+{sh//2-240}")
    popup.lift(); popup.focus_force()

    # ── Header with logo ──────────────────────────────────────────
    header = _tk.Frame(popup, bg=C["topbar"])
    header.pack(fill="x")
    # Back/close button (top-left) — closes the popup, same as the window's X.
    _back = _tk.Label(header, text="←", bg=C["topbar"], fg=C["text2"],
                      font=("Segoe UI", 16, "bold"), cursor="hand2")
    _back.pack(side="left", padx=(12, 2))
    _back.bind("<Button-1>", lambda e: (root.destroy(), sys.exit(0)))
    try:
        import urllib.request as _ur
        from io import BytesIO
        logo_data = _ur.urlopen(APP_ICON_URL, timeout=5).read()
        import base64 as _b64
        logo_b64 = _b64.b64encode(logo_data).decode()
        logo_img = _tk.PhotoImage(data=logo_b64)
        logo_img = logo_img.subsample(max(1, logo_img.width() // 36))
        lbl_logo = _tk.Label(header, image=logo_img, bg=C["topbar"])
        lbl_logo.image = logo_img
        lbl_logo.pack(side="left", padx=(14, 6), pady=10)
    except Exception:
        pass
    _tk.Label(header, text=f"{ADDON_NAME}  —  Get Free Access",
              bg=C["topbar"], fg=C["accent"],
              font=("Segoe UI", 13, "bold")).pack(side="left", pady=14)
    # CGHEVEN logo in the title bar + Windows taskbar (replaces the default Tk feather)
    _cgheven_apply_window_icon(popup)

    # ── Body ─────────────────────────────────────────────────────
    body = _tk.Frame(popup, bg=C["bg"], pady=10)
    body.pack(fill="both", expand=True, padx=28)

    _reg_intro = ("Almost done — please complete your registration."
                  if post_activation else
                  "Register once and we'll email your free access key.")
    _tk.Label(body, text=_reg_intro,
              bg=C["bg"], fg=C["text"], font=("Segoe UI", 10),
              wraplength=420).pack(anchor="w", pady=(8, 4))

    def _lbl(t):
        _tk.Label(body, text=t, bg=C["bg"], fg=C["text2"],
                  font=("Segoe UI", 9)).pack(anchor="w", pady=(12, 2))

    def _inp():
        e = _tk.Entry(body, bg=C["search"], fg=C["text"],
                      insertbackground=C["text"], font=("Segoe UI", 10),
                      relief="flat", bd=6)
        e.pack(fill="x", ipady=5)
        return e

    _lbl("Full Name");                                           name_e  = _inp()
    _lbl("Email *");                                             email_e = _inp()
    _lbl("Phone / WhatsApp (Optional) — e.g. +92 300 1234567"); phone_e = _inp()
    _lbl("Country");                                      loc_e   = _inp()

    status_lbl = _tk.Label(body, text="", bg=C["bg"], fg=C["red"],
                            font=("Segoe UI", 9), wraplength=420)
    status_lbl.pack(anchor="w", pady=(8, 0))

    # Thread-safe queue for background → UI communication
    _q = _queue.Queue()

    def _poll():
        try:
            while True:
                action, val = _q.get_nowait()
                if action == "success":
                    popup.destroy(); return
                elif action == "error":
                    status_lbl.config(text=val[:120], fg=C["red"])
                    submit_btn.config(state="normal", text="Get Free Access →")
        except _queue.Empty:
            pass
        root.after(100, _poll)

    root.after(100, _poll)

    def _submit():
        email = email_e.get().strip()
        if not email or "@" not in email:
            status_lbl.config(text="Please enter a valid email address.", fg=C["red"])
            return
        # Capture ALL field values on the MAIN thread — Tk widgets must never be read from a
        # background thread (doing so raises "main thread is not in main loop").
        name = name_e.get().strip()
        phone = phone_e.get().strip()
        loc = loc_e.get().strip()
        submit_btn.config(state="disabled", text="Registering…")
        status_lbl.config(text="", fg=C["red"])

        def _post():
            try:
                payload = json.dumps({
                    "name": name, "email": email,
                    "phone": phone, "location": loc,
                    "addon_slug": CGHEVEN_ADDON_SLUG
                }).encode("utf-8")
                req = urllib.request.Request(
                    f"{LICENSE_SERVER}/api/addon-registrations/register",
                    data=payload,
                    headers={
                        "Content-Type": "application/json",
                        "Accept": "application/json",
                        "User-Agent": f"CGHEVENDaVinci/{ADDON_VER}",
                    },
                    method="POST"
                )
                with urllib.request.urlopen(req, timeout=15): pass
                with open(REG_FILE, "w") as f:
                    json.dump({"name": name, "email": email,
                               "phone": phone, "location": loc,
                               "ts": time.time()}, f)
                _q.put(("success", ""))
            except urllib.error.HTTPError as e:
                try:
                    b = e.read().decode("utf-8", errors="ignore")
                    msg = (json.loads(b).get("error") or {}).get("message") or f"Server error ({e.code})"
                except Exception:
                    msg = f"Server error ({e.code}). Please try again."
                _q.put(("error", msg))
            except (urllib.error.URLError, OSError):
                _q.put(("error", "Cannot connect. Check your internet."))
            except Exception as e:
                _q.put(("error", str(e)[:100]))

        threading.Thread(target=_post, daemon=True).start()

    submit_btn = _tk.Button(body, text="Get Free Access →",
                             bg=C["accent"], fg="#ffffff", relief="flat",
                             font=("Segoe UI", 11, "bold"), cursor="hand2",
                             pady=14, command=_submit)
    submit_btn.pack(fill="x", pady=(16, 0))
    email_e.bind("<Return>", lambda e: _submit())
    name_e.focus_set()

    root.wait_window(popup)
    try: root.destroy()
    except Exception: pass
    return is_registered()


def main():
    try:
        # GUEST-FIRST — the library is ALWAYS open (BlenderKit / Blender-addon style). Login is
        # OPTIONAL and lives inside the app ("Login with CGHEVEN account"); it is only needed to
        # unlock premium (4K/EXR / patreon-locked) downloads. Guests browse + download the free
        # (1K/2K) tier with no login. No blocking pre-launch activation/registration gate.

        key          = None
        session_data = None
        sess_token   = ""

        # ── Step 1: Try secure Strapi session ──────────────────
        if AUTH_MODULE_AVAILABLE:
            try:
                session_data = load_cached_session()
                if session_data:
                    sess_token = session_data.get("session_token", "")
                    key        = sess_token or "ACTIVE"
                    print(f"[AUTH] Session loaded — plan: {session_data.get('plan','?')}")
            except LicenseError as e:
                print(f"[AUTH] Session invalid: {e}")
                detail = str(e).strip().lower()
                if "machine" in detail and "mismatch" in detail:
                    print(
                        "[AUTH] Hint: Session / license is bound to another PC or an old bind. "
                        "Use Logout (License screen) or delete .cgheven_session_v3.json, "
                        "then activate again on this machine."
                    )
                session_data = None
                key          = None
                sess_token   = ""
            except Exception as e:
                print(f"[AUTH] Session load error: {e}")

        # ── Step 2: Legacy local license — SKIP if auth module available
        # Legacy .lic file has no session token — proxy will reject it
        if not key and not AUTH_MODULE_AVAILABLE:
            key = load_license()
            if key:
                print("[AUTH] Legacy local license loaded (fallback mode)")

        # ── Step 3: No session → launch as GUEST ────────────────
        # No blocking gate any more: a guest opens the library (public proxy) and can download
        # the free tier; premium options show "Upgrade". Login happens inside the app.
        if not key:
            key = ""
            sess_token = ""
            print("[AUTH] No active session — launching as guest (library open, login optional).")

        # ── Step 4: Launch main app ─────────────────────────────
        app = CGHEVENApp(key, session_token=sess_token, user_plan=(session_data or {}).get("plan", ""))
        # Seed display plan_label + is_premium from the cached session (heartbeat keeps these
        # fresh) so the UI starts at the right plan without waiting for the first re-fetch.
        try:
            if session_data:
                app.user_plan_label = session_data.get("plan_label", "") or app.user_plan_label
                if session_data.get("is_premium") is not None:
                    app.is_premium = bool(session_data.get("is_premium"))
        except Exception:
            pass
        _apply_ttk_theme(app)

        # Update API token
        if sess_token and hasattr(app, "api"):
            app.api.update_token(sess_token)
            print(f"[AUTH] Session token set on API")
            # Start background heartbeat
            _start_heartbeat(app)

        # ── Step 5: Background update check ─────────────────────
        # Public check — guests (no session token) bhi update popup paayenge.
        if UPDATE_MODULE_AVAILABLE and _auto_update:
            _auto_update.run_check_threaded(
                session_token   = sess_token,
                current_version = ADDON_VER,
                on_done         = lambda: app.after(0, app._on_update_check_done),
            )
            print("[UPDATE] Background update check started")

        app.mainloop()

    except Exception as e:
        import traceback
        print(f"[FATAL] {e}\n{traceback.format_exc()}")
        sys.exit(1)


def _start_heartbeat(app):
    """Background thread — re-checks the license every 5 minutes. Refreshes the JWT token AND
    picks up plan changes (Free → Pro/Studio bought after login, or an expiry) so the addon
    updates live without a re-login."""
    if not AUTH_MODULE_AVAILABLE:
        return

    def _beat():
        while True:
            time.sleep(300)  # 5 minutes (was 30 min — too slow for a live plan upgrade)
            try:
                try:
                    if not app.winfo_exists():
                        break
                except Exception:
                    break
                machine   = get_machine_fingerprint()
                old_plan  = str(getattr(app, "user_plan", "") or "")
                hb        = do_heartbeat(app.session_token, machine)
                if hb:
                    new_tok = hb.get("token", "") if isinstance(hb, dict) else hb
                    if new_tok:
                        app.session_token = new_tok
                        if hasattr(app, "api"):
                            app.api.update_token(new_tok)
                    new_plan = str((hb.get("plan") if isinstance(hb, dict) else "") or "")
                    plan_lbl = (hb.get("plan_label") if isinstance(hb, dict) else "") or ""
                    is_prem  = hb.get("is_premium") if isinstance(hb, dict) else None
                    print("[AUTH] Heartbeat — token refreshed"
                          + (f" · plan={new_plan}" if new_plan else ""))
                    # Plan changed → drop stale (guest/old-plan) asset caches + re-fetch the
                    # current view so locked/full access updates live (UI work on main thread).
                    if new_plan and new_plan != old_plan:
                        print(f"[AUTH] Plan changed: {old_plan or '∅'} → {new_plan} — reloading assets")
                        app.after(0, lambda np=new_plan, pl=plan_lbl, ip=is_prem:
                                  app._on_plan_changed(new_plan=np, plan_label=pl, is_premium=ip))
                else:
                    print("[AUTH] Heartbeat — license invalid")
                    app.after(0, lambda: _show_license_expired(app))
                    break
            except Exception as e:
                print(f"[AUTH] Heartbeat error: {e}")

    threading.Thread(target=_beat, daemon=True).start()


def _show_license_expired(app):
    """License expire hone pe user ko batao."""
    try:
        if ANALYTICS_AVAILABLE and _analytics:
            try:
                _did = getattr(app, "analytics_distinct_id", "") or "unknown"
                _analytics.send_license_expired(
                    license_server=LICENSE_SERVER, addon_slug=CGHEVEN_ADDON_SLUG,
                    distinct_id=_did)
            except Exception:
                pass
        messagebox.showerror(
            "License Expired",
            "Your CGHEVEN license is no longer active.\n\n"
            "This may happen if your Patreon membership has expired "
            "or been cancelled.\n\n"
            "To continue using the addon, please renew your membership at:\n"
            "cgheven.com/dashboard",
            parent=app
        )
        app.destroy()
    except Exception as e:
        print(f"[EXPIRED] {e}")


def run_from_resolve():
    main()


if __name__ == "__main__":
    main()



