"""
MorfVision for 3ds Max — search & import free CC0/CC-BY models from
morfvision.store directly into your scene.

Requires 3ds Max 2021+ (bundled Python 3 + PySide2/6 + qtmax). Native glTF
import requires 3ds Max 2023+; on older versions install the "Babylon / glTF"
importer plugin first.

INSTALL / RUN
  Scripting ▸ Run Script…  ▸  morfvision_3dsmax.py
  (or drag the file into a viewport). A floating "MorfVision" panel appears.

To auto-load on startup, copy this file to
  %LOCALAPPDATA%\Autodesk\3dsMax\<ver>\ENU\scripts\startup\
and add a one-line .ms there:  python.ExecuteFile "morfvision_3dsmax.py"
"""

import os
import json
import tempfile
import webbrowser
import urllib.parse
import urllib.request

# --- Qt shim: PySide6 (Max 2025+) or PySide2 (Max 2021-2024) ---------------- #
try:
    from PySide6 import QtWidgets, QtGui, QtCore
except ImportError:  # pragma: no cover
    from PySide2 import QtWidgets, QtGui, QtCore

try:
    import qtmax  # official: parents Qt windows to the Max main window
except ImportError:  # pragma: no cover
    qtmax = None

from pymxs import runtime as rt

API_DEFAULT = "https://morfvision.store/api/v1"
SITE_BASE = "https://morfvision.store"
USER_AGENT = "MorfVision-3dsMax/0.7"
UTM = ("source=openmarketbase&utm_source=morfvision-3dsmax"
       "&utm_medium=plugin&utm_campaign=morfvision-network")
PAGE_SIZE = 12
THUMB_DIR = os.path.join(tempfile.gettempdir(), "morfvision_thumbs")

_WINDOW = None  # keep a single instance alive


# --------------------------------------------------------------------------- #
# HTTP helpers (stdlib only)                                                   #
# --------------------------------------------------------------------------- #
# Allowlisted CDN hosts whose media is routed through morfvision.store/media,
# so the plugin works on networks that can't reach polyhaven / AWS / ambientcg.
_PROXY_HOSTS = {
    "cdn.polyhaven.com", "dl.polyhaven.org",
    "amazon-berkeley-objects.s3.amazonaws.com",
    "ambientcg.com", "acg-download.struffelproductions.com",
    "images.unsplash.com",
    "cosmorelax.ru", "www.cosmorelax.ru",
    "performa.vip", "www.performa.vip",
    "raw.githubusercontent.com",
}


def _proxied(url):
    """Route allowlisted CDN media through morfvision.store/media. API calls
    (morfvision.store) are not in the list, so they pass through unchanged."""
    try:
        host = (urllib.parse.urlparse(url).hostname or "").lower()
    except Exception:
        return url
    if host in _PROXY_HOSTS:
        return SITE_BASE + "/media?url=" + urllib.parse.quote(url, safe="")
    return url


def _request(url, timeout):
    req = urllib.request.Request(_proxied(url), headers={"User-Agent": USER_AGENT})
    return urllib.request.urlopen(req, timeout=timeout)


def _get_json(url, timeout=20, retries=3):
    last = None
    for _ in range(retries):
        try:
            with _request(url, timeout) as resp:
                return json.loads(resp.read().decode("utf-8"))
        except Exception as exc:  # noqa: BLE001 — flaky network, retry
            last = exc
    raise last if last else RuntimeError("request failed")


def _download(url, dest, timeout=180, retries=3, try_direct_first=False):
    """Download `url` to `dest`, verifying Content-Length when the server sends it.

    Large model files routed through the morfvision.store /media proxy can be
    truncated by the host's egress, producing a corrupt GLB that imports as 0
    nodes. For models we therefore try the DIRECT CDN url first (most networks
    reach S3/PolyHaven directly and skip that bottleneck) and fall back to the
    proxy for restricted networks; previews keep proxy-first."""
    proxied = _proxied(url)
    if proxied == url:
        targets = [url]  # not a known CDN host (e.g. our own domain)
    elif try_direct_first:
        targets = [url, proxied]
    else:
        targets = [proxied, url]

    last = None
    for target in targets:
        for _ in range(retries):
            try:
                req = urllib.request.Request(
                    target, headers={"User-Agent": USER_AGENT})
                with urllib.request.urlopen(req, timeout=timeout) as resp:
                    expected = resp.headers.get("Content-Length")
                    data = resp.read()
                with open(dest, "wb") as fh:
                    fh.write(data)
                if expected is not None and int(expected) != len(data):
                    last = "incomplete download ({}/{} bytes)".format(
                        len(data), expected)
                    continue  # truncated — retry / next target
                if os.path.getsize(dest) > 0:
                    return dest
            except Exception as exc:  # noqa: BLE001
                last = exc
    if isinstance(last, Exception):
        raise last
    raise RuntimeError(last or ("empty download: " + url))


# --------------------------------------------------------------------------- #
# 3ds Max scene helpers                                                        #
# --------------------------------------------------------------------------- #
def _node_handles():
    """Stable, hashable identity for every scene node."""
    return {rt.getHandleByAnim(o) for o in rt.objects}


def _import_file(path):
    """Import a model file (.glb/.fbx) and return the newly-created nodes.

    3ds Max's importFile auto-detects the format from the extension and uses
    the matching importer (native glTF on 2023+, native FBX everywhere)."""
    before = _node_handles()
    rt.importFile(path, rt.Name("noPrompt"))
    new = []
    for o in rt.objects:
        if rt.getHandleByAnim(o) not in before:
            new.append(o)
    return new


def _tag_attribution(nodes, detail):
    slug = detail.get("slug", "")
    lic = detail.get("license", "")
    author = detail.get("author", "")
    source = detail.get("source_url", "")
    for n in nodes:
        rt.setUserProp(n, "morfvision_slug", slug)
        rt.setUserProp(n, "morfvision_license", lic)
        rt.setUserProp(n, "morfvision_author", author)
        rt.setUserProp(n, "morfvision_source", source)


# --------------------------------------------------------------------------- #
# UI                                                                           #
# --------------------------------------------------------------------------- #
class MorfVisionPanel(QtWidgets.QDialog):
    def __init__(self, parent=None):
        super(MorfVisionPanel, self).__init__(parent)
        self.setWindowTitle("MorfVision — Free CC0 3D Models")
        self.setMinimumWidth(360)
        self.setWindowFlags(self.windowFlags()
                            | QtCore.Qt.WindowStaysOnTopHint)
        self.api = API_DEFAULT
        self.results = []
        self.offset = 0
        self._build_ui()

    def _build_ui(self):
        lay = QtWidgets.QVBoxLayout(self)

        top = QtWidgets.QHBoxLayout()
        self.search_edit = QtWidgets.QLineEdit()
        self.search_edit.setPlaceholderText("Search models — chair, lamp, rug…")
        self.search_edit.returnPressed.connect(self.on_search)
        btn = QtWidgets.QPushButton("Search")
        btn.clicked.connect(self.on_search)
        top.addWidget(self.search_edit)
        top.addWidget(btn)
        lay.addLayout(top)

        self.models_only = QtWidgets.QCheckBox("Models only")
        self.models_only.setChecked(True)
        lay.addWidget(self.models_only)

        self.list = QtWidgets.QListWidget()
        self.list.setIconSize(QtCore.QSize(160, 120))
        self.list.setGridSize(QtCore.QSize(184, 168))
        self.list.setViewMode(QtWidgets.QListView.IconMode)
        self.list.setResizeMode(QtWidgets.QListView.Adjust)
        self.list.setWordWrap(True)
        self.list.setMovement(QtWidgets.QListView.Static)
        self.list.setSpacing(8)
        self.list.itemDoubleClicked.connect(lambda *_: self.on_import())
        self.list.currentItemChanged.connect(self._on_select)
        lay.addWidget(self.list, 1)

        nav = QtWidgets.QHBoxLayout()
        self.prev_btn = QtWidgets.QPushButton("◀ Prev")
        self.prev_btn.clicked.connect(lambda: self.on_page(-1))
        self.next_btn = QtWidgets.QPushButton("Next ▶")
        self.next_btn.clicked.connect(lambda: self.on_page(1))
        nav.addWidget(self.prev_btn)
        nav.addWidget(self.next_btn)
        lay.addLayout(nav)

        actions = QtWidgets.QHBoxLayout()
        self.import_btn = QtWidgets.QPushButton("Import into Scene")
        self.import_btn.clicked.connect(self.on_import)
        self.editor_btn = QtWidgets.QPushButton("Open in Vision-AI")
        self.editor_btn.clicked.connect(self.on_open_editor)
        actions.addWidget(self.import_btn)
        actions.addWidget(self.editor_btn)
        lay.addLayout(actions)

        self.status = QtWidgets.QLabel("Free CC0 / CC-BY models — morfvision.store")
        self.status.setWordWrap(True)
        lay.addWidget(self.status)

    # -- networking-backed slots -------------------------------------------- #
    def _busy(self, on):
        if on:
            QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor)
        else:
            QtWidgets.QApplication.restoreOverrideCursor()
        QtWidgets.QApplication.processEvents()

    def on_search(self, reset=True):
        if reset:
            self.offset = 0
        params = {"limit": PAGE_SIZE, "offset": self.offset}
        q = self.search_edit.text().strip()
        if q:
            params["search"] = q
        if self.models_only.isChecked():
            params["type"] = "model"
        url = "{}/assets/?{}".format(self.api, urllib.parse.urlencode(params))

        self._busy(True)
        try:
            data = _get_json(url, timeout=25, retries=3)
        except Exception as exc:  # noqa: BLE001
            self._busy(False)
            self.status.setText("API error: {}".format(exc))
            return
        self.results = data.get("results", []) or []
        count = data.get("count", len(self.results))
        self._populate()
        self._busy(False)
        self.status.setText("{} of {} models (page {})".format(
            len(self.results), count, self.offset // PAGE_SIZE + 1))

    def on_page(self, direction):
        self.offset = max(0, self.offset + direction * PAGE_SIZE)
        self.on_search(reset=False)

    @staticmethod
    def _importable(result):
        # GLB (native 2023+ or our server FBX), FBX and OBJ import directly.
        # Vendor .zip we can extract a mesh from server-side counts too
        # (cosmorelax = OBJ inside); performa .zip = .max only -> web only.
        fmts = result.get("formats") or []
        if any(f in fmts for f in ("glb", "fbx", "obj")):
            return True
        return result.get("source") == "cosmorelax"

    def _make_icon(self, path, importable):
        """Render the preview with a '3ds Max ✓' / 'Web only' badge (web-only
        dimmed), mirroring the SketchUp grid so importability is obvious."""
        W, H = 320, 240
        canvas = QtGui.QPixmap(W, H)
        canvas.fill(QtGui.QColor(0x1d, 0x1d, 0x22))
        pr = QtGui.QPainter(canvas)
        pr.setRenderHint(QtGui.QPainter.Antialiasing, True)
        pr.setRenderHint(QtGui.QPainter.SmoothPixmapTransform, True)
        base = QtGui.QPixmap(path) if (path and os.path.exists(path)) else QtGui.QPixmap()
        if not base.isNull():
            sc = base.scaled(W, H, QtCore.Qt.KeepAspectRatioByExpanding,
                             QtCore.Qt.SmoothTransformation)
            pr.drawPixmap(0, 0, sc, max(0, (sc.width() - W) // 2),
                          max(0, (sc.height() - H) // 2), W, H)
        if not importable:
            pr.fillRect(0, 0, W, H, QtGui.QColor(0, 0, 0, 115))
        text = u"3ds Max ✓" if importable else u"Web only"
        f = QtGui.QFont()
        f.setBold(True)
        f.setPixelSize(22)
        pr.setFont(f)
        fm = QtGui.QFontMetrics(f)
        tw = fm.horizontalAdvance(text) if hasattr(fm, "horizontalAdvance") else fm.width(text)
        padx, pady, bx, by = 14, 7, 12, 12
        bw, bh = tw + 2 * padx, fm.height() + 2 * pady
        pr.setPen(QtCore.Qt.NoPen)
        pr.setBrush(QtGui.QColor(0x1f, 0x7a, 0x4d) if importable
                    else QtGui.QColor(0x3a, 0x3a, 0x42))
        pr.drawRoundedRect(bx, by, bw, bh, bh / 2.0, bh / 2.0)
        pr.setPen(QtGui.QColor(0xdf, 0xfb, 0xe9) if importable
                  else QtGui.QColor(0xc9, 0xc9, 0xd2))
        pr.drawText(bx + padx, by + pady + fm.ascent(), text)
        pr.end()
        return QtGui.QIcon(canvas)

    def _on_select(self, current, _previous=None):
        if not current:
            return
        ok = bool(current.data(QtCore.Qt.UserRole + 1))
        self.status.setText(
            "Ready to import." if ok
            else "Web only — Import will open this model on morfvision.store.")

    def _populate(self):
        self.list.clear()
        os.makedirs(THUMB_DIR, exist_ok=True)
        for r in self.results:
            importable = self._importable(r)
            item = QtWidgets.QListWidgetItem(r.get("name", r["slug"])[:40])
            item.setData(QtCore.Qt.UserRole, r["slug"])
            item.setData(QtCore.Qt.UserRole + 1, importable)
            tp = None
            preview = r.get("preview")
            if preview:
                try:
                    ext = os.path.splitext(
                        urllib.parse.urlparse(preview).path)[1] or ".jpg"
                    tp = os.path.join(THUMB_DIR, r["slug"] + ext)
                    if not os.path.exists(tp):
                        _download(preview, tp, timeout=30, retries=2)
                except Exception:  # noqa: BLE001
                    tp = None
            try:
                item.setIcon(self._make_icon(tp, importable))
            except Exception:  # noqa: BLE001 — fall back to a plain thumbnail
                if tp:
                    item.setIcon(QtGui.QIcon(tp))
            if not importable:
                item.setForeground(QtGui.QColor(0x9a, 0x9a, 0xaa))
            self.list.addItem(item)

    def _selected_slug(self):
        it = self.list.currentItem()
        return it.data(QtCore.Qt.UserRole) if it else None

    def on_import(self):
        slug = self._selected_slug()
        if not slug:
            self.status.setText("Select a model first.")
            return
        self._busy(True)
        try:
            detail = _get_json("{}/assets/{}/".format(self.api, slug),
                               timeout=25, retries=3)
            files = detail.get("files", []) or []

            def pick(fmt):
                return next((f.get("url") for f in files
                             if f.get("format") == fmt and f.get("url")), None)

            # Try the formats 3ds Max can import, best first. GLB carries PBR but
            # needs the glTF importer (built in to 3ds Max 2023+); FBX/OBJ import
            # natively on every version, so they're the fallback (e.g. all of
            # PolyHaven, which ships FBX and no GLB). If a format imports 0 nodes
            # (no glTF importer, or a Draco/KTX2-compressed GLB) we move on.
            candidates = []
            for fmt, ext in (("glb", ".glb"), ("fbx", ".fbx"), ("obj", ".obj")):
                u = pick(fmt)
                if u:
                    candidates.append((fmt, u, ext))

            nodes = []
            used = None
            for fmt, url, ext in candidates:
                try:
                    dest = os.path.join(THUMB_DIR, slug + ext)
                    _download(url, dest, timeout=180, retries=3,
                              try_direct_first=True)
                    got = _import_file(dest)
                except Exception:  # noqa: BLE001 — try the next format
                    continue
                if got:
                    nodes, used = got, fmt
                    break

            # Server-side fallback: nothing imported locally — ask the convert
            # service for an assimp FBX and import that. FBX imports natively even
            # when this 3ds Max has no glTF importer (rescues ABO GLB) and when the
            # asset only ships a vendor .zip (rescues cosmorelax → OBJ → FBX). A
            # non-convertible asset (performa .max) 404s here -> graceful web open.
            if not nodes:
                try:
                    self.status.setText("Converting on server…")
                    man = _get_json(
                        "{}/convert?slug={}&to=fbx".format(
                            SITE_BASE, urllib.parse.quote(slug)),
                        timeout=120, retries=2)
                    main = man.get("fbx") or man.get("main")
                    if main:
                        dest = os.path.join(THUMB_DIR, slug + "_srv.fbx")
                        _download(man["base"] + main, dest, timeout=300, retries=3)
                        got = _import_file(dest)
                        if got:
                            nodes, used = got, "fbx"
                except Exception:  # noqa: BLE001 — fall through to web
                    pass

            if not nodes:
                # Every attempt imported 0 objects — report it honestly and
                # offer the web page instead of a misleading "Imported … 0 nodes".
                self._busy(False)
                self.status.setText(
                    "Couldn't import ‘{}’; opening it on morfvision.store.".format(
                        detail.get("name", slug)))
                webbrowser.open("{}/en/assets/{}/?{}".format(SITE_BASE, slug, UTM))
                return

            _tag_attribution(nodes, detail)
        except Exception as exc:  # noqa: BLE001
            self._busy(False)
            self.status.setText("Import failed: {}".format(exc))
            return
        self._busy(False)
        note = " (FBX/OBJ: geometry only, textures may need reassigning)" \
            if used in ("fbx", "obj") else ""
        self.status.setText("Imported '{}' — {} nodes, {}.{}".format(
            detail.get("name", slug), len(nodes),
            detail.get("license", ""), note))

    def on_open_editor(self):
        slug = self._selected_slug()
        if not slug:
            self.status.setText("Select a model first.")
            return
        webbrowser.open("{}/en/assets/{}/?{}".format(SITE_BASE, slug, UTM))


def show_panel():
    global _WINDOW
    parent = qtmax.GetQMaxMainWindow() if qtmax else None
    if _WINDOW is None:
        _WINDOW = MorfVisionPanel(parent)
    _WINDOW.show()
    _WINDOW.raise_()
    return _WINDOW


if __name__ == "__main__":
    show_panel()
