# SPDX-License-Identifier: GPL-2.0-or-later
"""
MorfVision — Free CC0/CC-BY 3D models, materials and HDRIs, imported straight
into Blender.

Single-file legacy add-on (Blender 3.0 – 5.x, Preferences ▸ Add-ons ▸ Install
from Disk). Searches the MorfVision / OpenMarketBase catalog, shows a thumbnail
grid, and imports the chosen asset:
  • model    → .glb imported at real-world scale, CC-BY attribution baked in
  • material → PBR material (ambientcg .zip) or base-colour (polyhaven), applied
               to the active mesh if one is selected
  • hdri     → set as the world environment

Catalog API (https://morfvision.store/api/v1):
  GET /assets/?search=&type=model|material|hdri&limit=&offset=
  GET /assets/<slug>/  -> {..., files:[{format,resolution,url}], width_cm, ...}
"""

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

import bpy
from bpy.props import (
    StringProperty,
    EnumProperty,
    IntProperty,
    FloatProperty,
    BoolProperty,
)

bl_info = {
    "name": "MorfVision — Free CC0 3D Models",
    "author": "MorfVision / Vision-AI",
    "version": (0, 9, 0),
    "blender": (3, 0, 0),
    "location": "View3D ▸ Sidebar (N) ▸ MorfVision",
    "description": "Search and import thousands of free CC0/CC-BY 3D models, "
                   "materials and HDRIs from morfvision.store.",
    "category": "Import-Export",
    "doc_url": "https://morfvision.store",
}

DEFAULT_API = "https://morfvision.store/api/v1"
SITE_BASE = "https://morfvision.store"
USER_AGENT = "MorfVision-Blender/{}.{}.{}".format(*bl_info["version"])
UTM = ("source=openmarketbase&utm_source=morfvision-blender"
       "&utm_medium=plugin&utm_campaign=morfvision-network")
# Per-type one-tap search chips.
QUICK = {
    "model": ("chair", "sofa", "table", "lamp", "rug"),
    "material": ("wood", "marble", "metal", "fabric", "concrete"),
    "hdri": ("studio", "sunset", "interior", "sky", "outdoor"),
}

# Blender's bundled Python often ships without a usable CA bundle, so verified
# HTTPS to S3 / polyhaven / ambientcg fails and thumbnails come back blank.
# These are public CC0/CC-BY assets, so an unverified context is acceptable.
_SSL = ssl._create_unverified_context()

_RESULTS = []
_PREVIEWS = {}
_THUMB_DIR = os.path.join(tempfile.gettempdir(), "morfvision_thumbs")
_LAST_COUNT = 0
_THUMB_FAILS = 0


# --------------------------------------------------------------------------- #
# HTTP helpers (stdlib only)                                                   #
# --------------------------------------------------------------------------- #
# Allowlisted CDN hosts whose media (previews, models, texture/HDRI zips) is
# routed through morfvision.store/media, so the add-on works even on networks
# that cannot reach polyhaven / AWS / ambientcg directly.
_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):
    """Wrap an allowlisted CDN url with the morfvision.store media proxy.

    API calls (morfvision.store) are not in the allowlist, so they pass through
    unchanged; only foreign-CDN media is proxied.
    """
    try:
        host = (urllib.parse.urlparse(url).hostname or "").lower()
    except Exception:  # noqa: BLE001
        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, context=_SSL)


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
            last = exc
    raise last if last else RuntimeError("request failed")


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

    Large model/HDRI files routed through the morfvision.store /media proxy can be
    truncated by the host's egress, producing a corrupt file. For big assets we
    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]
    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, context=_SSL) 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))


def _api_base(context):
    prefs = context.preferences.addons[__name__].preferences
    return (prefs.api_url or DEFAULT_API).rstrip("/")


# --------------------------------------------------------------------------- #
# Thumbnails                                                                   #
# --------------------------------------------------------------------------- #
def _clear_previews():
    pcoll = _PREVIEWS.get("main")
    if pcoll:
        pcoll.clear()


def _load_thumb(slug, url):
    global _THUMB_FAILS
    pcoll = _PREVIEWS.get("main")
    if not pcoll or not url:
        return
    os.makedirs(_THUMB_DIR, exist_ok=True)
    # Pull a small (<=512px) JPEG thumbnail through the media proxy. Large source
    # images (e.g. 2546x1167 ABO photos) choke Blender's preview thumbnail
    # generator and render blank; downscaled ones render reliably. The "_t512"
    # suffix also sidesteps any full-size thumbnails cached by older versions.
    thumb_url = "{}/media?thumb=1&url={}".format(
        SITE_BASE, urllib.parse.quote(url, safe=""))
    path = os.path.join(_THUMB_DIR, slug + "_t512.jpg")
    try:
        if not os.path.exists(path) or os.path.getsize(path) == 0:
            _download(thumb_url, path, timeout=30, retries=2)
        if slug not in pcoll:
            pcoll.load(slug, path, "IMAGE")
    except Exception:  # noqa: BLE001 — a missing thumbnail must not break search
        _THUMB_FAILS += 1


def _selected_result(context):
    slug = context.scene.morfvision_selected
    for r in _RESULTS:
        if r["slug"] == slug:
            return r
    return None


# --------------------------------------------------------------------------- #
# Operators                                                                    #
# --------------------------------------------------------------------------- #
class MORFVISION_OT_search(bpy.types.Operator):
    bl_idname = "morfvision.search"
    bl_label = "Search MorfVision"
    bl_description = "Search the MorfVision catalog and load thumbnails"
    bl_options = {"INTERNAL"}

    reset_offset: BoolProperty(default=True, options={"HIDDEN"})

    def execute(self, context):
        global _RESULTS, _LAST_COUNT, _THUMB_FAILS
        scene = context.scene
        prefs = context.preferences.addons[__name__].preferences
        if self.reset_offset:
            scene.morfvision_offset = 0

        params = {"limit": prefs.page_size, "offset": scene.morfvision_offset,
                  "type": scene.morfvision_type}
        q = scene.morfvision_query.strip()
        if q:
            params["search"] = q
        url = "{}/assets/?{}".format(_api_base(context),
                                     urllib.parse.urlencode(params))

        context.window.cursor_set("WAIT")
        try:
            data = _get_json(url, timeout=25, retries=3)
        except Exception as exc:  # noqa: BLE001
            context.window.cursor_set("DEFAULT")
            self.report({"ERROR"}, "MorfVision API error: {}".format(exc))
            return {"CANCELLED"}

        _RESULTS = data.get("results", []) or []
        _LAST_COUNT = data.get("count", len(_RESULTS))
        _THUMB_FAILS = 0
        _clear_previews()
        for r in _RESULTS:
            _load_thumb(r["slug"], r.get("preview"))
        context.window.cursor_set("DEFAULT")

        scene.morfvision_selected = _RESULTS[0]["slug"] if _RESULTS else ""
        for area in context.screen.areas:
            if area.type == "VIEW_3D":
                area.tag_redraw()
        self.report({"INFO"}, "MorfVision: {} of {} results".format(
            len(_RESULTS), _LAST_COUNT))
        return {"FINISHED"}


class MORFVISION_OT_quick(bpy.types.Operator):
    bl_idname = "morfvision.quick"
    bl_label = "Quick search"
    bl_description = "Search this popular term"
    bl_options = {"INTERNAL"}

    query: StringProperty(default="", options={"HIDDEN"})

    def execute(self, context):
        context.scene.morfvision_query = self.query
        return bpy.ops.morfvision.search(reset_offset=True)


class MORFVISION_OT_clear(bpy.types.Operator):
    bl_idname = "morfvision.clear"
    bl_label = "Clear search"
    bl_description = "Clear the search field"
    bl_options = {"INTERNAL"}

    def execute(self, context):
        context.scene.morfvision_query = ""
        return {"FINISHED"}


class MORFVISION_OT_select(bpy.types.Operator):
    bl_idname = "morfvision.select"
    bl_label = "Select asset"
    bl_description = "Select this asset"
    bl_options = {"INTERNAL"}

    slug: StringProperty(default="", options={"HIDDEN"})

    def execute(self, context):
        context.scene.morfvision_selected = self.slug
        return {"FINISHED"}


class MORFVISION_OT_page(bpy.types.Operator):
    bl_idname = "morfvision.page"
    bl_label = "MorfVision Page"
    bl_options = {"INTERNAL"}

    direction: IntProperty(default=1, options={"HIDDEN"})

    def execute(self, context):
        scene = context.scene
        prefs = context.preferences.addons[__name__].preferences
        new = scene.morfvision_offset + self.direction * prefs.page_size
        scene.morfvision_offset = max(0, new)
        return bpy.ops.morfvision.search(reset_offset=False)


class MORFVISION_OT_import(bpy.types.Operator):
    bl_idname = "morfvision.import_asset"
    bl_label = "Import asset"
    bl_description = "Import the selected asset into the scene"

    def execute(self, context):
        prefs = context.preferences.addons[__name__].preferences
        result = _selected_result(context)
        if not result:
            self.report({"ERROR"}, "Select an asset first")
            return {"CANCELLED"}
        slug = result["slug"]

        context.window.cursor_set("WAIT")
        try:
            detail = _get_json("{}/assets/{}/".format(_api_base(context), slug),
                               timeout=25, retries=3)
        except Exception as exc:  # noqa: BLE001
            context.window.cursor_set("DEFAULT")
            self.report({"ERROR"}, "Detail fetch failed: {}".format(exc))
            return {"CANCELLED"}

        kind = detail.get("type") or result.get("type") or "model"
        try:
            if kind == "hdri":
                msg = _import_hdri(detail)
            elif kind == "material":
                msg = _import_material(detail, prefs, context)
            else:
                msg = _import_model(detail, prefs)
        except Exception as exc:  # noqa: BLE001
            context.window.cursor_set("DEFAULT")
            self.report({"ERROR"}, "Import failed: {}".format(exc))
            return {"CANCELLED"}

        context.window.cursor_set("DEFAULT")
        self.report({"INFO"}, msg)
        return {"FINISHED"}


class MORFVISION_OT_open_editor(bpy.types.Operator):
    bl_idname = "morfvision.open_editor"
    bl_label = "Open in Vision-AI"
    bl_description = "Open this asset's page on morfvision.store"

    def execute(self, context):
        result = _selected_result(context)
        if not result:
            self.report({"ERROR"}, "Select an asset first")
            return {"CANCELLED"}
        webbrowser.open("{}/en/assets/{}/?{}".format(
            SITE_BASE, result["slug"], UTM))
        return {"FINISHED"}


class MORFVISION_OT_open_site(bpy.types.Operator):
    bl_idname = "morfvision.open_site"
    bl_label = "Browse morfvision.store"

    def execute(self, context):
        webbrowser.open("{}/?{}".format(SITE_BASE, UTM))
        return {"FINISHED"}


# --------------------------------------------------------------------------- #
# Importers                                                                    #
# --------------------------------------------------------------------------- #
def _world_bbox_size(objs):
    import mathutils  # noqa: WPS433
    pts = []
    for o in objs:
        for corner in o.bound_box:
            pts.append(o.matrix_world @ mathutils.Vector(corner))
    if not pts:
        return None
    mins = [min(p[i] for p in pts) for i in range(3)]
    maxs = [max(p[i] for p in pts) for i in range(3)]
    return (maxs[0] - mins[0], maxs[1] - mins[1], maxs[2] - mins[2])


def _fit_to_dimensions(objs, detail):
    roots = [o for o in objs if o.parent not in objs]
    if not roots:
        return
    size = _world_bbox_size(roots)
    if not size or max(size) <= 0:
        return
    cm = [detail.get("width_cm") or 0, detail.get("height_cm") or 0,
          detail.get("depth_cm") or 0]
    target_m = max(cm) / 100.0
    if target_m <= 0:
        return
    factor = target_m / max(size)
    if abs(factor - 1.0) < 0.02:
        return
    for o in roots:
        o.scale = [s * factor for s in o.scale]


def _auto_scale_units(objs):
    """No catalog dims: 3ds Max OBJ/FBX exports are often in mm or cm, so a
    furniture model lands 100–1000× too big. Pick the smallest unit divisor
    that brings the largest dimension into a plausible (≤8 m) range."""
    roots = [o for o in objs if o.parent not in objs]
    if not roots:
        return
    size = _world_bbox_size(roots)
    if not size:
        return
    maxd = max(size)
    if maxd <= 8.0:
        return
    for div in (10.0, 100.0, 1000.0):
        if maxd / div <= 8.0:
            for o in roots:
                o.scale = [s / div for s in o.scale]
            return


def _tag_attribution(objs, detail, write_text):
    slug = detail.get("slug", "")
    license_ = detail.get("license", "")
    author = detail.get("author", "")
    source = detail.get("source_url", "")
    for o in objs:
        o["morfvision_slug"] = slug
        o["morfvision_license"] = license_
        o["morfvision_author"] = author
        o["morfvision_source"] = source
    if write_text and ("CC-BY" in license_ or "CC BY" in license_):
        name = "MorfVision_Credits.txt"
        txt = bpy.data.texts.get(name) or bpy.data.texts.new(name)
        if txt.as_string() == "":
            txt.write("Credits for assets imported via MorfVision "
                      "(morfvision.store)\n"
                      "==============================================\n")
        txt.write("- {} — {} ({}). Source: {}\n".format(
            detail.get("name", slug), author, license_, source))


def _model_file(detail):
    """Pick an importable model file: prefer GLB, fall back to FBX."""
    files = detail.get("files", [])
    for fmt in ("glb", "fbx"):
        url = next((f.get("url") for f in files
                    if f.get("format") == fmt and f.get("url")), None)
        if url:
            return fmt, url
    return None, None


def _do_import(path, fmt):
    if fmt in ("glb", "gltf"):
        bpy.ops.import_scene.gltf(filepath=path)
    elif fmt == "fbx":
        bpy.ops.import_scene.fbx(filepath=path)
    elif fmt == "obj":
        if hasattr(bpy.ops.wm, "obj_import"):      # Blender 4.0+
            bpy.ops.wm.obj_import(filepath=path)
        else:                                      # Blender 3.x
            bpy.ops.import_scene.obj(filepath=path)


def _import_zip_model(detail, zurl):
    """cosmorelax/vendor zips bundle an .obj (+ .mtl + maps) next to a .max.
    Extract and import the best DCC-neutral file inside."""
    import zipfile  # noqa: WPS433
    import shutil  # noqa: WPS433
    os.makedirs(_THUMB_DIR, exist_ok=True)
    zpath = os.path.join(_THUMB_DIR, detail["slug"] + "_m.zip")
    _download(zurl, zpath, timeout=300, retries=3)
    exdir = os.path.join(_THUMB_DIR, detail["slug"] + "_m")
    if os.path.isdir(exdir):
        shutil.rmtree(exdir, ignore_errors=True)
    os.makedirs(exdir, exist_ok=True)
    with zipfile.ZipFile(zpath) as z:
        z.extractall(exdir)

    cands = {}
    for root, _dirs, names in os.walk(exdir):
        for n in names:
            ext = os.path.splitext(n)[1].lower().lstrip(".")
            if ext in ("glb", "gltf", "fbx", "obj") and ext not in cands:
                cands[ext] = os.path.join(root, n)
    for ext in ("glb", "gltf", "fbx", "obj"):
        if ext in cands:
            _do_import(cands[ext], ext)
            return ext, exdir
    raise RuntimeError("archive has only native 3ds Max (.max) — open on "
                       "morfvision.store or use the 3ds Max plugin")


def _find_map(exdir, keywords):
    """Best matching texture in the extracted folder (largest = highest-res)."""
    best = None
    for root, _dirs, names in os.walk(exdir):
        for n in names:
            ln = n.lower()
            if ln.endswith((".jpg", ".jpeg", ".png")) \
                    and any(k in ln for k in keywords):
                p = os.path.join(root, n)
                if best is None or os.path.getsize(p) > os.path.getsize(best):
                    best = p
    return best


def _apply_guessed_material(objs, exdir):
    """Vendor OBJ exports ship maps but a bare .mtl, so the mesh comes in
    untextured. Best-effort: hang the most diffuse-looking map (+normal) as a
    Principled material. A guess — one material for the whole object."""
    meshes = [o for o in objs if o.type == "MESH"]
    if not meshes:
        return False
    textured = any(
        ms.material and ms.material.use_nodes and any(
            n.type == "TEX_IMAGE" and n.image
            for n in ms.material.node_tree.nodes)
        for o in meshes for ms in o.material_slots)
    if textured:
        return False
    diff = _find_map(exdir, ("difuse", "diffuse", "albedo", "_diff",
                             "basecolor", "_col", "color"))
    if not diff:
        return False
    mat = bpy.data.materials.new("MorfVision_guessed")
    mat.use_nodes = True
    nt = mat.node_tree
    bsdf = nt.nodes.get("Principled BSDF")
    base = nt.nodes.new("ShaderNodeTexImage")
    base.image = bpy.data.images.load(diff, check_existing=True)
    base.location = (-600, 300)
    nt.links.new(base.outputs["Color"], bsdf.inputs["Base Color"])
    nrm = _find_map(exdir, ("nrm", "_nor", "normal", "_n."))
    if nrm and nrm != diff:
        ni = bpy.data.images.load(nrm, check_existing=True)
        ni.colorspace_settings.name = "Non-Color"
        nt_node = nt.nodes.new("ShaderNodeTexImage")
        nt_node.image = ni
        nt_node.location = (-600, -100)
        nmap = nt.nodes.new("ShaderNodeNormalMap")
        nmap.location = (-300, -100)
        nt.links.new(nt_node.outputs["Color"], nmap.inputs["Color"])
        nt.links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"])
    for o in meshes:
        o.data.materials.clear()
        o.data.materials.append(mat)
    return True


def _import_model(detail, prefs):
    before = set(bpy.data.objects)
    exdir = None
    fmt, url = _model_file(detail)
    if url:
        dest = os.path.join(_THUMB_DIR, detail["slug"] + "." + fmt)
        os.makedirs(_THUMB_DIR, exist_ok=True)
        _download(url, dest, timeout=180, retries=3, try_direct_first=True)
        _do_import(dest, fmt)
        used = fmt
    else:
        zurl = next((f["url"] for f in detail.get("files", [])
                     if (f.get("url") or "").lower().endswith(".zip")), None)
        if not zurl:
            raise RuntimeError("no Blender-importable file — open on "
                               "morfvision.store")
        used, exdir = _import_zip_model(detail, zurl)

    new_objs = [o for o in bpy.data.objects if o not in before]
    has_dims = any(detail.get(k) for k in ("width_cm", "height_cm", "depth_cm"))
    if has_dims and (prefs.auto_scale or used != "glb"):
        _fit_to_dimensions(new_objs, detail)       # exact, from catalog dims
    elif used != "glb" and not has_dims:
        _auto_scale_units(new_objs)                # guess mm/cm → metres

    guessed = False
    if exdir and getattr(prefs, "guess_archive_textures", True):
        guessed = _apply_guessed_material(new_objs, exdir)

    _tag_attribution(new_objs, detail, prefs.write_attribution)
    if used == "obj":
        note = " (OBJ + guessed texture)" if guessed else " (OBJ geometry)"
    else:
        note = {"glb": "", "fbx": " (FBX)", "gltf": " (glTF)"}.get(used, "")
    return "Imported '{}' ({} objects{})".format(
        detail.get("name", detail["slug"]), len(new_objs), note)


def _pick_file(files, fmt, want_res=("2k", "2",)):
    """Pick a file of format `fmt`, preferring a resolution in want_res."""
    cands = [f for f in files if f.get("format") == fmt and f.get("url")]
    if not cands:
        return None
    for res in want_res:
        for f in cands:
            if res in (f.get("resolution") or "").lower():
                return f
    return cands[0]


def _import_hdri(detail):
    files = detail.get("files", [])
    f = _pick_file(files, "hdr", ("2k", "4k", "1k")) \
        or _pick_file(files, "exr", ("2k", "4k", "1k"))
    if not f:
        raise RuntimeError("no HDRI file found")
    dest = os.path.join(_THUMB_DIR, detail["slug"] + "." + f["format"])
    os.makedirs(_THUMB_DIR, exist_ok=True)
    _download(f["url"], dest, timeout=180, retries=3, try_direct_first=True)

    img = bpy.data.images.load(dest, check_existing=True)
    scene = bpy.context.scene
    world = scene.world or bpy.data.worlds.new("World")
    scene.world = world
    world.use_nodes = True
    nt = world.node_tree
    nt.nodes.clear()
    out = nt.nodes.new("ShaderNodeOutputWorld")
    bg = nt.nodes.new("ShaderNodeBackground")
    env = nt.nodes.new("ShaderNodeTexEnvironment")
    env.image = img
    env.location = (-300, 0)
    bg.location = (0, 0)
    out.location = (200, 0)
    nt.links.new(env.outputs["Color"], bg.inputs["Color"])
    nt.links.new(bg.outputs["Background"], out.inputs["Surface"])
    return "Set world HDRI '{}' ({})".format(
        detail.get("name", detail["slug"]), f.get("resolution", ""))


def _classify_map(filename):
    n = filename.lower()
    if any(k in n for k in ("color", "albedo", "_diff", "basecolor", "_col")):
        return "base"
    if "normalgl" in n or "normal" in n or "_nor" in n or "_nrm" in n:
        return "normal"
    if "rough" in n:
        return "rough"
    if "metal" in n:
        return "metal"
    if "ambientocclusion" in n or "_ao" in n:
        return "ao"
    if "displacement" in n or "_disp" in n or "height" in n:
        return "disp"
    return None


def _collect_maps(detail):
    """Return {map_type: filepath} for a material, handling both ambientcg
    (.zip bundles) and polyhaven (individual maps, usually diffuse only)."""
    import zipfile  # noqa: WPS433
    files = detail.get("files", [])
    slug = detail["slug"]
    os.makedirs(_THUMB_DIR, exist_ok=True)
    maps = {}

    zip_f = _pick_file([f for f in files if (f.get("url") or "").endswith(".zip")],
                       "jpg", ("2k", "1k")) \
        or next((f for f in files if (f.get("url") or "").endswith(".zip")), None)
    if zip_f:
        zpath = os.path.join(_THUMB_DIR, slug + ".zip")
        _download(zip_f["url"], zpath, timeout=240, retries=3)
        exdir = os.path.join(_THUMB_DIR, slug + "_mat")
        os.makedirs(exdir, exist_ok=True)
        with zipfile.ZipFile(zpath) as z:
            z.extractall(exdir)
        for root, _dirs, names in os.walk(exdir):
            for fn in names:
                if fn.lower().endswith((".jpg", ".jpeg", ".png", ".exr")):
                    kind = _classify_map(fn)
                    if kind and kind not in maps:
                        maps[kind] = os.path.join(root, fn)
        return maps

    # polyhaven-style: individual image maps (jpg/png), commonly only diffuse
    for kind, fmt in (("base", "jpg"),):
        f = _pick_file(files, fmt, ("2k", "1k")) or _pick_file(files, "png", ("2k", "1k"))
        if f:
            dest = os.path.join(_THUMB_DIR, slug + "_" + kind + ".jpg")
            _download(f["url"], dest, timeout=180, retries=3)
            maps[kind] = dest
    return maps


def _import_material(detail, prefs, context):
    maps = _collect_maps(detail)
    if not maps:
        raise RuntimeError("no usable texture maps found")

    mat = bpy.data.materials.new(detail.get("name", detail["slug"]))
    mat.use_nodes = True
    nt = mat.node_tree
    bsdf = nt.nodes.get("Principled BSDF")
    y = 300

    def tex(path, non_color):
        nonlocal y
        img = bpy.data.images.load(path, check_existing=True)
        if non_color:
            img.colorspace_settings.name = "Non-Color"
        node = nt.nodes.new("ShaderNodeTexImage")
        node.image = img
        node.location = (-600, y)
        y -= 300
        return node

    if "base" in maps:
        nt.links.new(tex(maps["base"], False).outputs["Color"],
                     bsdf.inputs["Base Color"])
    if "rough" in maps:
        nt.links.new(tex(maps["rough"], True).outputs["Color"],
                     bsdf.inputs["Roughness"])
    if "metal" in maps:
        nt.links.new(tex(maps["metal"], True).outputs["Color"],
                     bsdf.inputs["Metallic"])
    if "normal" in maps:
        nmap = nt.nodes.new("ShaderNodeNormalMap")
        nmap.location = (-300, -300)
        nt.links.new(tex(maps["normal"], True).outputs["Color"],
                     nmap.inputs["Color"])
        nt.links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"])

    mat["morfvision_slug"] = detail.get("slug", "")
    mat["morfvision_license"] = detail.get("license", "")

    obj = context.active_object
    applied = ""
    if obj and obj.type == "MESH":
        if obj.data.materials:
            obj.data.materials[0] = mat
        else:
            obj.data.materials.append(mat)
        applied = " → applied to '{}'".format(obj.name)
    return "Material '{}' ({} maps){}".format(
        detail.get("name", detail["slug"]), len(maps), applied)


# --------------------------------------------------------------------------- #
# UI                                                                           #
# --------------------------------------------------------------------------- #
class MORFVISION_PT_panel(bpy.types.Panel):
    bl_label = "MorfVision"
    bl_idname = "MORFVISION_PT_panel"
    bl_space_type = "VIEW_3D"
    bl_region_type = "UI"
    bl_category = "MorfVision"

    def draw_header(self, context):
        self.layout.label(text="", icon="MESH_CUBE")

    def draw(self, context):
        layout = self.layout
        scene = context.scene
        prefs = context.preferences.addons[__name__].preferences
        pcoll = _PREVIEWS.get("main")

        # --- 1. choose what to browse ---
        layout.label(text="1.  What are you looking for?")
        layout.prop(scene, "morfvision_type", expand=True)

        # --- 2. search ---
        layout.label(text="2.  Search the catalog")
        row = layout.row(align=True)
        row.scale_y = 1.2
        row.prop(scene, "morfvision_query", text="", icon="VIEWZOOM")
        if scene.morfvision_query:
            row.operator("morfvision.clear", text="", icon="PANEL_CLOSE")
        row.operator("morfvision.search", text="Search")
        chips = layout.row(align=True)
        chips.label(text="Try:")
        for term in QUICK.get(scene.morfvision_type, ()):
            chips.operator("morfvision.quick", text=term).query = term

        layout.separator()

        if _RESULTS:
            total = _LAST_COUNT or len(_RESULTS)
            kinds = {"model": "models", "material": "materials", "hdri": "HDRIs"}
            layout.label(text="3.  Pick a result — {} {}".format(
                total, kinds.get(scene.morfvision_type, "assets")))

            box = layout.box()
            cols = 2 if prefs.icon_scale >= 6 else 3
            tg = box.grid_flow(row_major=True, columns=cols,
                               even_columns=True, align=True)
            for r in _RESULTS:
                icon_id = pcoll[r["slug"]].icon_id \
                    if (pcoll and r["slug"] in pcoll) else 0
                cell = tg.column(align=True)
                cell.template_icon(icon_value=icon_id, scale=prefs.icon_scale)
                op = cell.operator(
                    "morfvision.select", text=r.get("name", r["slug"])[:16],
                    depress=(r["slug"] == scene.morfvision_selected))
                op.slug = r["slug"]
            if _THUMB_FAILS:
                box.label(text="{} previews failed (network)".format(
                    _THUMB_FAILS), icon="ERROR")

            psize = max(1, prefs.page_size)
            pages = max(1, (total + psize - 1) // psize)
            page = scene.morfvision_offset // psize + 1
            nav = box.row(align=True)
            nav.operator("morfvision.page", text="",
                         icon="TRIA_LEFT").direction = -1
            nav.label(text="Page {} / {}".format(page, pages))
            nav.operator("morfvision.page", text="",
                         icon="TRIA_RIGHT").direction = 1

            # --- 4. selected asset + action ---
            sel = _selected_result(context)
            if sel:
                kind = sel.get("type", "model")
                fmts = sel.get("formats") or []
                layout.label(text="4.  Add to your scene")
                card = layout.box()
                card.label(text=sel.get("name", sel["slug"]), icon="OBJECT_DATA")
                meta = card.row(align=True)
                meta.label(text=(sel.get("category") or "—").title(),
                           icon="FILE_3D")
                meta.label(text=sel.get("license") or "—", icon="FUND")
                meta.label(text=(fmts[0].upper() if fmts else "—"), icon="FILE")
                tags = sel.get("tags") or []
                if tags:
                    card.label(text=", ".join(tags[:4]), icon="PRESET")

                primary = card.row(align=True)
                primary.scale_y = 1.6
                show_editor = True
                if kind == "hdri":
                    primary.operator("morfvision.import_asset",
                                     text="Set as world HDRI", icon="WORLD")
                elif kind == "material":
                    primary.operator("morfvision.import_asset",
                                     text="Create material", icon="MATERIAL")
                    obj = context.active_object
                    if not (obj and obj.type == "MESH"):
                        card.label(text="Tip: select a mesh first to auto-apply",
                                   icon="INFO")
                elif set(fmts) & {"glb", "fbx", "zip"}:
                    primary.operator("morfvision.import_asset",
                                     text="Import into Scene", icon="IMPORT")
                else:
                    primary.operator("morfvision.open_editor",
                                     text="Open on morfvision.store", icon="URL")
                    card.label(text="No Blender file ({}) — view on site".format(
                        "/".join(fmts) or "?"), icon="INFO")
                    show_editor = False
                if show_editor:
                    card.operator("morfvision.open_editor",
                                  text="Open in Vision-AI", icon="URL")
        else:
            box = layout.box()
            col = box.column(align=True)
            col.label(text="Thousands of free assets", icon="WORLD")
            col.label(text="Models · Materials · HDRIs")
            col.label(text="CC0 / CC-BY · no signup")
            col.label(text="Pick a type and search above.")

        layout.separator()
        layout.operator("morfvision.open_site",
                        text="Browse morfvision.store", icon="URL",
                        emboss=False)


class MorfVisionPrefs(bpy.types.AddonPreferences):
    bl_idname = __name__

    api_url: StringProperty(
        name="Catalog API",
        default=DEFAULT_API,
        description="Base URL of the MorfVision / OpenMarketBase API")
    page_size: IntProperty(
        name="Results per page", default=12, min=4, max=48)
    icon_scale: FloatProperty(
        name="Thumbnail size", default=5.0, min=3.0, max=10.0,
        description="Size of the preview thumbnails shown in the panel")
    auto_scale: BoolProperty(
        name="Fit models to catalog dimensions",
        default=False,
        description="Uniformly rescale model imports to the catalog's "
                    "real-world size. glTF is metric by spec, so leave off "
                    "unless a model imports at the wrong scale")
    guess_archive_textures: BoolProperty(
        name="Guess textures for archive models",
        default=True,
        description="For vendor .zip models whose OBJ ships no material info, "
                    "hang the most diffuse-looking map from the archive as a "
                    "best-effort material (a guess — one material per object)")
    write_attribution: BoolProperty(
        name="Write CC-BY credits text block",
        default=True,
        description="Append attribution for CC-BY assets to a "
                    "'MorfVision_Credits.txt' text datablock")

    def draw(self, context):
        col = self.layout.column()
        col.prop(self, "api_url")
        col.prop(self, "page_size")
        col.prop(self, "icon_scale")
        col.prop(self, "auto_scale")
        col.prop(self, "guess_archive_textures")
        col.prop(self, "write_attribution")


# --------------------------------------------------------------------------- #
# Registration                                                                 #
# --------------------------------------------------------------------------- #
_CLASSES = (
    MorfVisionPrefs,
    MORFVISION_OT_search,
    MORFVISION_OT_quick,
    MORFVISION_OT_clear,
    MORFVISION_OT_select,
    MORFVISION_OT_page,
    MORFVISION_OT_import,
    MORFVISION_OT_open_editor,
    MORFVISION_OT_open_site,
    MORFVISION_PT_panel,
)


def register():
    import bpy.utils.previews
    for cls in _CLASSES:
        bpy.utils.register_class(cls)

    bpy.types.Scene.morfvision_query = StringProperty(
        name="Search", description="Search the MorfVision catalog")
    bpy.types.Scene.morfvision_type = EnumProperty(
        name="Type",
        items=[("model", "Models", "3D models"),
               ("material", "Materials", "PBR materials / textures"),
               ("hdri", "HDRIs", "Environment HDRIs")],
        default="model")
    bpy.types.Scene.morfvision_offset = IntProperty(default=0, min=0)
    bpy.types.Scene.morfvision_selected = StringProperty(default="")

    _PREVIEWS["main"] = bpy.utils.previews.new()


def unregister():
    for pcoll in _PREVIEWS.values():
        bpy.utils.previews.remove(pcoll)
    _PREVIEWS.clear()

    del bpy.types.Scene.morfvision_query
    del bpy.types.Scene.morfvision_type
    del bpy.types.Scene.morfvision_offset
    del bpy.types.Scene.morfvision_selected

    for cls in reversed(_CLASSES):
        bpy.utils.unregister_class(cls)


if __name__ == "__main__":
    register()
