# Free3D Online — Blender Add-on
# Install via Edit > Preferences > Add-ons > Install (select free3d_online.zip).
# Requires Blender 3.6+ (built-in GLB/glTF importer).
# https://free3d.online/plugins/blender

bl_info = {
    "name": "Free3D Online",
    "author": "Free3D Online",
    "version": (0, 1, 0),
    "blender": (3, 6, 0),
    "location": "View3D > Sidebar > Free3D",
    "description": "Search, browse, and import 3D models from Free3D Online directly in Blender",
    "doc_url": "https://free3d.online/plugins/blender",
    "tracker_url": "https://free3d.online/plugins/blender",
    "category": "Import-Export",
}

import bpy
import json
import os
import tempfile
import threading
import urllib.request
import urllib.error
import urllib.parse
from bpy.props import StringProperty, IntProperty, BoolProperty, FloatProperty
from bpy.types import Panel, Operator, AddonPreferences, PropertyGroup

BASE_URL = "https://free3d.online"
VERSION = "v003"
UPDATE_FEED_URL = BASE_URL + "/assets/plugins/blender/update-feed.json"
SCRIPT_URL = BASE_URL + "/assets/plugins/blender/free3d_online.py"


# ── HTTP helpers ──────────────────────────────────────────────────────────────

def _get(url, token=None, timeout=20):
    req = urllib.request.Request(url)
    req.add_header("User-Agent", f"Free3DBlender/{VERSION}")
    if token:
        req.add_header("Authorization", f"Bearer {token}")
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return json.loads(resp.read().decode("utf-8"))


def _post(url, data, token=None, timeout=15):
    body = json.dumps(data).encode("utf-8")
    req = urllib.request.Request(url, data=body, method="POST")
    req.add_header("Content-Type", "application/json")
    req.add_header("User-Agent", f"Free3DBlender/{VERSION}")
    if token:
        req.add_header("Authorization", f"Bearer {token}")
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return json.loads(resp.read().decode("utf-8"))


def _safe_url(url):
    """Percent-encode spaces and non-ASCII characters in a URL, preserving scheme/host."""
    parsed = urllib.parse.urlsplit(url)
    safe_path = urllib.parse.quote(parsed.path, safe="/:@!$&'()*+,;=")
    safe_query = urllib.parse.quote(parsed.query, safe="=&+%")
    return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, safe_path, safe_query, parsed.fragment))


class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
    """Encode spaces in redirect target URLs before following them."""
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return urllib.request.HTTPRedirectHandler.redirect_request(
            self, req, fp, code, msg, headers, _safe_url(newurl)
        )


def _open(url, token=None, timeout=60):
    opener = urllib.request.build_opener(_SafeRedirectHandler())
    req = urllib.request.Request(_safe_url(url))
    req.add_header("User-Agent", f"Free3DBlender/{VERSION}")
    if token:
        req.add_header("Authorization", f"Bearer {token}")
    return opener.open(req, timeout=timeout)


def _download_file(url, dest_path, token=None, timeout=60):
    with _open(url, token=token, timeout=timeout) as resp:
        with open(dest_path, "wb") as f:
            f.write(resp.read())


# ── Session state (non-persistent) ───────────────────────────────────────────

class _State:
    results = []
    has_more = False
    offset = 0
    total = 0
    status = ""
    cats = []
    types = []
    auth_sid = ""
    auth_poll_url = ""
    auth_url = ""
    update_available = False
    update_version = ""
    update_url = ""
    update_script_url = ""


_st = _State()

# ── Preview image cache ───────────────────────────────────────────────────────

_pcoll = None          # bpy.utils.previews collection
_prev_loading = set()  # guids currently being fetched


def _ensure_pcoll():
    global _pcoll
    if _pcoll is None:
        _pcoll = bpy.utils.previews.new()
    return _pcoll


def _load_preview_async(guid, url):
    """Download a preview image in a background thread, then register as icon."""
    if not guid or not url or guid in _prev_loading:
        return
    pcoll = _ensure_pcoll()
    if guid in pcoll:
        return
    _prev_loading.add(guid)

    def _worker():
        try:
            tmp = tempfile.mktemp(prefix=f"f3d_{guid[:8]}_", suffix=".jpg")
            _download_file(_safe_url(url), tmp)

            def _register():
                try:
                    if guid not in pcoll:
                        pcoll.load(guid, tmp, "IMAGE")
                    try:
                        os.remove(tmp)
                    except Exception:
                        pass
                    for window in bpy.context.window_manager.windows:
                        for area in window.screen.areas:
                            area.tag_redraw()
                except Exception:
                    pass
                return None  # don't repeat

            bpy.app.timers.register(_register, first_interval=0.0)
        except Exception:
            pass
        finally:
            _prev_loading.discard(guid)

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


def _start_preview_batch(items):
    """Trigger preview downloads for a list of result items (called after search)."""
    for item in items:
        guid = item.get("guid", "")
        url = item.get("previewSmallUrl") or item.get("previewMediumUrl") or ""
        if guid and url:
            _load_preview_async(guid, url)


def _run_update_check_async():
    """Fetch the update feed in a background thread so the UI doesn't freeze."""
    def _worker():
        try:
            feed = _get(UPDATE_FEED_URL, timeout=10)
            remote_ver = str(feed.get("version", ""))
            def _apply():
                if remote_ver and remote_ver != VERSION:
                    _st.update_available = True
                    _st.update_version = remote_ver
                    _st.update_url = str(feed.get("downloadUrl") or SCRIPT_URL)
                    _st.update_script_url = str(feed.get("scriptUrl") or SCRIPT_URL)
                    _st.status = f"Update available: {remote_ver}"
                else:
                    _st.update_available = False
                    _st.status = f"Up to date ({VERSION})"
                try:
                    for window in bpy.context.window_manager.windows:
                        for area in window.screen.areas:
                            area.tag_redraw()
                except Exception:
                    pass
                return None
            bpy.app.timers.register(_apply, first_interval=0.0)
        except Exception as exc:
            def _err():
                _st.status = f"Update check failed: {exc}"
                return None
            bpy.app.timers.register(_err, first_interval=0.0)
    threading.Thread(target=_worker, daemon=True).start()


# ── Add-on Preferences (persistent storage) ───────────────────────────────────

class Free3DPreferences(AddonPreferences):
    bl_idname = __name__

    token: StringProperty(name="API Token", default="", subtype="PASSWORD")
    balance_usd: FloatProperty(name="Balance USD", default=-1.0)
    display_name: StringProperty(name="Display Name", default="")

    def draw(self, context):
        layout = self.layout
        if self.token:
            layout.label(text=f"Signed in as: {self.display_name or 'User'}")
            layout.label(text=f"Balance: ${self.balance_usd:.2f}")
            layout.operator("free3d.sign_out", text="Sign Out", icon="PANEL_CLOSE")
        else:
            layout.label(text="Not signed in.")
            layout.operator("free3d.auth_start", text="Sign In via Browser", icon="URL")


# ── Scene properties ──────────────────────────────────────────────────────────

class Free3DSearchProps(PropertyGroup):
    query: StringProperty(name="Search", default="")
    selected_guid: StringProperty(default="")
    selected_title: StringProperty(default="")


# ── Operators ─────────────────────────────────────────────────────────────────

class F3D_OT_AuthStart(Operator):
    bl_idname = "free3d.auth_start"
    bl_label = "Sign In to Free3D"
    bl_description = "Open the browser to authorize the Blender add-on"

    def execute(self, context):
        try:
            resp = _post(f"{BASE_URL}/api/apps/auth/start", {
                "appSlug": "blender",
                "appName": "Free3D Online for Blender",
                "appVersion": VERSION,
                "pluginFamily": "blender",
                "deviceName": f"Blender {bpy.app.version_string}",
                "requestedScopes": ["download"],
            })
            if resp.get("ok"):
                _st.auth_sid = resp.get("authSessionId", "")
                _st.auth_poll_url = resp.get("pollUrl", "")
                _st.auth_url = resp.get("authUrl", "")
                bpy.ops.wm.url_open(url=_st.auth_url)
                _st.status = "Browser opened — approve access, then click Poll for Token."
                bpy.ops.free3d.auth_poll("INVOKE_DEFAULT")
            else:
                _st.status = "Auth start failed."
        except Exception as exc:
            _st.status = f"Error: {exc}"
        return {"FINISHED"}


class F3D_OT_AuthPoll(Operator):
    """Modal operator that polls the auth endpoint every 2 s for up to 2 min."""
    bl_idname = "free3d.auth_poll"
    bl_label = "Poll Auth"

    _timer = None
    _attempts = 0

    def modal(self, context, event):
        if event.type != "TIMER":
            return {"PASS_THROUGH"}
        self._attempts += 1
        if self._attempts > 60:
            _st.status = "Auth timed out. Try signing in again."
            self.cancel(context)
            return {"CANCELLED"}
        try:
            poll_url = _st.auth_poll_url or f"{BASE_URL}/api/apps/auth/poll/{_st.auth_sid}"
            resp = _get(poll_url)
            if resp.get("approved"):
                prefs = context.preferences.addons[__name__].preferences
                prefs.token = resp.get("token", "")
                prefs.balance_usd = float(resp.get("balanceUsd") or resp.get("balance_usd") or 0)
                prefs.display_name = resp.get("displayName") or resp.get("display_name") or ""
                _st.status = f"Signed in. Balance: ${prefs.balance_usd:.2f}"
                _st.auth_sid = ""
                self.cancel(context)
                for area in context.screen.areas:
                    area.tag_redraw()
                return {"FINISHED"}
        except Exception:
            pass
        for area in context.screen.areas:
            area.tag_redraw()
        return {"PASS_THROUGH"}

    def invoke(self, context, event):
        self._attempts = 0
        wm = context.window_manager
        self._timer = wm.event_timer_add(2.0, window=context.window)
        wm.modal_handler_add(self)
        return {"RUNNING_MODAL"}

    def cancel(self, context):
        if self._timer:
            context.window_manager.event_timer_remove(self._timer)


class F3D_OT_SignOut(Operator):
    bl_idname = "free3d.sign_out"
    bl_label = "Sign Out"

    def execute(self, context):
        prefs = context.preferences.addons[__name__].preferences
        prefs.token = ""
        prefs.balance_usd = -1.0
        prefs.display_name = ""
        _st.status = "Signed out."
        return {"FINISHED"}


class F3D_OT_Search(Operator):
    bl_idname = "free3d.search"
    bl_label = "Search"
    bl_description = "Search the Free3D public catalog"

    reset: BoolProperty(default=True)
    browse: BoolProperty(default=False)  # True = use browse endpoint (no query required)

    def execute(self, context):
        props = context.scene.free3d
        query = props.query.strip()
        offset = 0 if self.reset else _st.offset
        try:
            if self.browse or not query:
                # Popular browse — no query required
                params = {"topK": "24", "offset": str(offset), "sort": "popular"}
                url = f"{BASE_URL}/api-embeddings/browse?" + urllib.parse.urlencode(params)
            else:
                params = {"topK": "24", "offset": str(offset), "q": query}
                url = f"{BASE_URL}/api-embeddings/?" + urllib.parse.urlencode(params)
            resp = _get(url)
            new_items = resp.get("results", [])
            if self.reset:
                _st.results = new_items
            else:
                _st.results.extend(new_items)
            _st.has_more = resp.get("hasMore", False)
            _st.offset = offset + len(new_items)
            _st.total = resp.get("total", 0)
            _st.status = f"{_st.total} found, {len(_st.results)} loaded" if _st.total else "No results."
            # Kick off preview downloads now that we have fresh results
            _start_preview_batch(_st.results[:8])
        except Exception as exc:
            _st.status = f"Search error: {exc}"
        return {"FINISHED"}


class F3D_OT_Select(Operator):
    bl_idname = "free3d.select"
    bl_label = "Select Model"

    guid: StringProperty()
    title: StringProperty()

    def execute(self, context):
        props = context.scene.free3d
        props.selected_guid = self.guid
        props.selected_title = self.title
        return {"FINISHED"}


class F3D_OT_Import(Operator):
    bl_idname = "free3d.import_model"
    bl_label = "Import Model"
    bl_description = "Download and import the selected GLB model into the active scene"

    guid: StringProperty()
    title: StringProperty()

    def execute(self, context):
        prefs = context.preferences.addons[__name__].preferences
        token = prefs.token
        if not token:
            _st.status = "Sign in first."
            self.report({"WARNING"}, "Sign in to Free3D before importing.")
            return {"CANCELLED"}
        guid = self.guid
        title = self.title
        try:
            formats_resp = _get(f"{BASE_URL}/api/plugin/download/formats/{guid}", token=token)
            variants = formats_resp.get("glbVariants", [])
            if not variants:
                _st.status = "No GLB variants available for this model."
                return {"CANCELLED"}
            # Prefer 100k → 10k → 1k → first available
            chosen = None
            for lod_pref in ["100k", "10k", "1k"]:
                for v in variants:
                    if str(v.get("lod", "")).lower() == lod_pref:
                        chosen = v
                        break
                if chosen:
                    break
            if not chosen:
                chosen = variants[0]

            dl_req = chosen.get("downloadRequest", {})
            direct_resp = _post(
                f"{BASE_URL}/api/plugin/download/direct",
                dl_req.get("body", {}),
                token=token,
            )
            if not direct_resp.get("ok"):
                _st.status = "Download authorisation failed."
                return {"CANCELLED"}

            download_url = direct_resp.get("downloadUrl", "")
            file_name = direct_resp.get("fileName", "") or f"{guid}.glb"
            if not file_name.lower().endswith(".glb"):
                file_name += ".glb"

            tmp_dir = tempfile.mkdtemp(prefix="free3d_")
            dest = os.path.join(tmp_dir, file_name)
            _download_file(download_url, dest)

            bpy.ops.import_scene.gltf(filepath=dest, import_pack_images=True)

            remaining = float(direct_resp.get("balanceRemainingUsd") or 0)
            prefs.balance_usd = remaining
            _st.status = f"Imported: {title}. Balance: ${remaining:.2f}"

            try:
                os.remove(dest)
                os.rmdir(tmp_dir)
            except Exception:
                pass

        except Exception as exc:
            _st.status = f"Import error: {exc}"
            self.report({"ERROR"}, str(exc))
            return {"CANCELLED"}

        return {"FINISHED"}


class F3D_OT_CheckUpdate(Operator):
    bl_idname = "free3d.check_update"
    bl_label = "Check for Update"
    bl_description = "Compare local version to the published update feed"

    def execute(self, context):
        _st.status = "Checking for update..."
        _run_update_check_async()
        return {"FINISHED"}


class F3D_OT_DoUpdate(Operator):
    bl_idname = "free3d.do_update"
    bl_label = "Update Now"
    bl_description = "Download and apply the latest version, then reload the add-on"

    def execute(self, context):
        try:
            import zipfile as _zf
            import importlib
            import sys

            script_url = _safe_url(_st.update_script_url or SCRIPT_URL)
            zip_url = _safe_url(_st.update_url or "")

            current_file = os.path.abspath(__file__)
            tmp_dir = tempfile.mkdtemp(prefix="free3d_upd_")

            if zip_url.endswith(".zip"):
                # Download zip and extract __init__.py
                zip_path = os.path.join(tmp_dir, "update.zip")
                _download_file(zip_url, zip_path)
                with _zf.ZipFile(zip_path) as z:
                    names = z.namelist()
                    target = next((n for n in names if n.endswith("__init__.py")), None) or names[0]
                    data = z.read(target)
                with open(current_file, "wb") as f:
                    f.write(data)
            else:
                # Download raw .py directly
                _download_file(script_url, current_file)

            # Cleanup tmp
            try:
                for fn in os.listdir(tmp_dir):
                    os.remove(os.path.join(tmp_dir, fn))
                os.rmdir(tmp_dir)
            except Exception:
                pass

            _st.status = f"Updated to {_st.update_version} — reloading..."
            _st.update_available = False

            # Reload: disable → reimport module → enable
            mod_name = __name__
            bpy.ops.preferences.addon_disable(module=mod_name)
            if mod_name in sys.modules:
                importlib.reload(sys.modules[mod_name])
            bpy.ops.preferences.addon_enable(module=mod_name)

        except Exception as exc:
            _st.status = f"Update error: {exc}"
            self.report({"ERROR"}, str(exc))
            return {"CANCELLED"}

        return {"FINISHED"}


# ── Sidebar Panel ─────────────────────────────────────────────────────────────

class F3D_PT_Main(Panel):
    bl_label = "Free3D Online"
    bl_idname = "F3D_PT_Main"
    bl_space_type = "VIEW_3D"
    bl_region_type = "UI"
    bl_category = "Free3D"

    def draw(self, context):
        layout = self.layout
        prefs = context.preferences.addons[__name__].preferences
        props = context.scene.free3d

        # ── Update banner (always visible at top) ──
        if _st.update_available:
            box = layout.box()
            row = box.row(align=True)
            row.label(text=f"Update: {_st.update_version}", icon="FILE_REFRESH")
            row.operator("free3d.do_update", text="Update Now", icon="IMPORT")
        else:
            layout.operator("free3d.check_update", text="Check for Update", icon="FILE_REFRESH")

        layout.separator()

        # ── Auth state ──
        if prefs.token:
            box = layout.box()
            row = box.row()
            row.label(text=prefs.display_name or "Signed in", icon="USER")
            row.label(text=f"${prefs.balance_usd:.2f}")
            box.operator("free3d.sign_out", text="Sign Out", icon="PANEL_CLOSE")
        else:
            layout.operator("free3d.auth_start", text="Sign In via Browser", icon="URL")

        layout.separator()

        # ── Search bar ──
        row = layout.row(align=True)
        row.prop(props, "query", text="")
        op = row.operator("free3d.search", text="", icon="VIEWZOOM")
        op.reset = True
        op.browse = False

        bop = layout.operator("free3d.search", text="Browse Popular", icon="WORLD")
        bop.reset = True
        bop.browse = True

        # ── Status line ──
        if _st.status:
            layout.label(text=_st.status[:60], icon="INFO")

        # ── Results list ──
        if _st.results:
            pcoll = _ensure_pcoll()
            col = layout.column(align=True)
            for item in _st.results[:8]:
                guid = item.get("guid", "")
                title = item.get("title") or guid[:16] or "model"
                label = title[:36]
                is_sel = props.selected_guid == guid

                row = col.row(align=True)
                # Show preview thumbnail if ready
                if guid in pcoll:
                    row.template_icon(icon_value=pcoll[guid].icon_id, scale=2.0)
                sel_op = row.operator(
                    "free3d.select",
                    text=label,
                    depress=is_sel,
                )
                sel_op.guid = guid
                sel_op.title = title
                if is_sel:
                    imp = row.operator("free3d.import_model", text="", icon="IMPORT")
                    imp.guid = guid
                    imp.title = title

            if props.selected_guid and not any(
                props.selected_guid == item.get("guid") for item in _st.results[:8]
            ):
                layout.separator()
                op = layout.operator(
                    "free3d.import_model",
                    text=f"Import: {props.selected_title[:28]}",
                    icon="IMPORT",
                )
                op.guid = props.selected_guid
                op.title = props.selected_title

            if _st.has_more:
                more = layout.operator("free3d.search", text="Load More")
                more.reset = False
                more.browse = False


# ── Registration ──────────────────────────────────────────────────────────────

_CLASSES = (
    Free3DPreferences,
    Free3DSearchProps,
    F3D_OT_AuthStart,
    F3D_OT_AuthPoll,
    F3D_OT_SignOut,
    F3D_OT_Search,
    F3D_OT_Select,
    F3D_OT_Import,
    F3D_OT_CheckUpdate,
    F3D_OT_DoUpdate,
    F3D_PT_Main,
)


def _deferred_update_check():
    _run_update_check_async()
    return None  # don't repeat


def register():
    for cls in _CLASSES:
        bpy.utils.register_class(cls)
    bpy.types.Scene.free3d = bpy.props.PointerProperty(type=Free3DSearchProps)
    bpy.app.timers.register(_deferred_update_check, first_interval=2.0)


def unregister():
    global _pcoll
    if _pcoll is not None:
        bpy.utils.previews.remove(_pcoll)
        _pcoll = None
    del bpy.types.Scene.free3d
    for cls in reversed(_CLASSES):
        bpy.utils.unregister_class(cls)


if __name__ == "__main__":
    register()
