"""
FEEDBACKS — Substance Painter Plugin v2.0
Export your mesh + PBR textures to FEEDBACKS in one click.

Install:
    Copy this file to:
      • Windows: %USERPROFILE%/Documents/Adobe/Adobe Substance 3D Painter/python/plugins/
      • macOS:   ~/Documents/Adobe/Adobe Substance 3D Painter/python/plugins/

    Then in Substance Painter: Python > feedbacks_substance > Start

First time:
    Enter your FEEDBACKS email/password or paste an API token from
    your Account page to connect.

Exported texture maps (PNG):
    albedo (color), roughness, metalness, normal, emissive, opacity
    Each map is assigned to the correct FEEDBACKS material slot.
"""
__author__ = "FEEDBACKS"
__version__ = "2.0.0"

import os
import sys
import json
import string
import random
import tempfile
import ssl
import urllib.request
import urllib.parse
import urllib.error
import webbrowser
import threading
from pathlib import Path

# ---------------------------------------------------------------------------
#  Substance Painter imports (guarded for dev outside SP)
# ---------------------------------------------------------------------------
try:
    import substance_painter.ui
    import substance_painter.project
    import substance_painter.textureset
    import substance_painter.export
    _HAS_SP = True
except ImportError:
    _HAS_SP = False

_HAS_QT = False
try:
    from PySide2.QtWidgets import (  # type: ignore
        QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
        QLineEdit, QComboBox, QCheckBox, QProgressBar,
        QStackedWidget, QFrame, QSizePolicy, QScrollArea,
    )
    from PySide2.QtCore import Qt, QTimer, Signal, QObject  # type: ignore
    from PySide2.QtGui import QFont, QColor, QPalette  # type: ignore
    _HAS_QT = True
except ImportError:
    try:
        from PySide6.QtWidgets import (  # type: ignore
            QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
            QLineEdit, QComboBox, QCheckBox, QProgressBar,
            QStackedWidget, QFrame, QSizePolicy, QScrollArea,
        )
        from PySide6.QtCore import Qt, QTimer, Signal, QObject  # type: ignore
        from PySide6.QtGui import QFont, QColor, QPalette  # type: ignore
        _HAS_QT = True
    except ImportError:
        # Provide stubs so class definitions don't crash the module
        class QObject: pass  # type: ignore
        class QWidget: pass  # type: ignore
        class Signal:  # type: ignore
            def __init__(self, *a): pass
            def connect(self, *a): pass
            def emit(self, *a): pass

# ---------------------------------------------------------------------------
#  Constants
# ---------------------------------------------------------------------------
DEFAULT_SERVER = "https://zreview-production.up.railway.app"
MAX_MB = 150
MAX_BYTES = MAX_MB * 1024 * 1024
UPLOAD_TIMEOUT = 180

# Maps we export and their FEEDBACKS texture_type keys
FEEDBACKS_MAP_TYPES = {
    "color": "color",
    "roughness": "roughness",
    "metalness": "metalness",
    "normal": "normal",
    "emissive": "emissive",
    "opacity": "opacity",
}

# Labels for the UI
MAP_TYPE_LABELS = {
    "color": "Albedo / Base Color",
    "roughness": "Roughness",
    "metalness": "Metalness",
    "normal": "Normal Map",
    "emissive": "Emissive",
    "opacity": "Opacity / Alpha",
}

# Detection keywords to identify exported texture maps from filenames
_FILENAME_MAP_RULES = [
    ("basecolor", "color"), ("base_color", "color"), ("albedo", "color"),
    ("diffuse", "color"), ("_color", "color"),
    ("roughness", "roughness"), ("rough", "roughness"),
    ("metalness", "metalness"), ("metallic", "metalness"), ("metal", "metalness"),
    ("normal", "normal"), ("nrm", "normal"), ("nor", "normal"),
    ("emissive", "emissive"), ("emission", "emissive"),
    ("opacity", "opacity"), ("alpha", "opacity"), ("mask", "opacity"),
]

# Resolution options
RESOLUTION_OPTIONS = {
    "1K (1024)": 10,
    "2K (2048)": 11,
}

IS_MAC = sys.platform == "darwin"
IS_WIN = sys.platform == "win32"

if IS_MAC:
    CONFIG_DIR = Path.home() / "Library" / "Application Support" / "FEEDBACKS"
elif IS_WIN:
    CONFIG_DIR = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming"))) / "FEEDBACKS"
else:
    CONFIG_DIR = Path.home() / ".config" / "FEEDBACKS"

CONFIG_FILE = CONFIG_DIR / "substance_plugin.json"

# SSL context (some installs lack certs)
_ssl_ctx = ssl.create_default_context()
try:
    import certifi  # type: ignore
    _ssl_ctx.load_verify_locations(certifi.where())
except Exception:
    _ssl_ctx.check_hostname = False
    _ssl_ctx.verify_mode = ssl.CERT_NONE


# ---------------------------------------------------------------------------
#  Config persistence
# ---------------------------------------------------------------------------
def _load_config():
    defaults = {"server_url": DEFAULT_SERVER, "api_token": "", "open_browser": True,
                "resolution": "2K (2048)"}
    if CONFIG_FILE.exists():
        try:
            with open(CONFIG_FILE, "r", encoding="utf-8") as f:
                saved = json.load(f)
            if isinstance(saved, dict):
                defaults.update(saved)
        except Exception:
            pass
    return defaults


def _save_config(data):
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    with open(CONFIG_FILE, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)


# ---------------------------------------------------------------------------
#  HTTP helpers (stdlib only — no requests dependency)
# ---------------------------------------------------------------------------
def _api_request(method, url, headers=None, body=None, timeout=30):
    headers = headers or {}
    req = urllib.request.Request(url, data=body, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req, context=_ssl_ctx, timeout=timeout) as resp:
            raw = resp.read()
            try:
                return resp.status, json.loads(raw)
            except Exception:
                return resp.status, {"raw": raw.decode("utf-8", errors="replace")}
    except urllib.error.HTTPError as e:
        raw = e.read()
        try:
            return e.code, json.loads(raw)
        except Exception:
            return e.code, {"error": raw.decode("utf-8", errors="replace")}
    except Exception as e:
        return 0, {"error": str(e)}


def _api_json(method, path, body=None, token=None, server=None):
    cfg = _load_config()
    server = (server or cfg.get("server_url", DEFAULT_SERVER)).rstrip("/")
    token = token or cfg.get("api_token", "")
    url = server + path
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
    data = json.dumps(body).encode("utf-8") if body else None
    return _api_request(method, url, headers, data)


def _upload_multipart(path, filepath, token=None, field="mesh", server=None):
    cfg = _load_config()
    server = (server or cfg.get("server_url", DEFAULT_SERVER)).rstrip("/")
    token = token or cfg.get("api_token", "")
    url = server + path
    boundary = "----FeedbacksBoundary" + "".join(
        random.choices(string.ascii_letters + string.digits, k=16))
    filename = os.path.basename(filepath)
    with open(filepath, "rb") as f:
        file_data = f.read()
    body = b""
    body += f"--{boundary}\r\n".encode()
    body += f'Content-Disposition: form-data; name="{field}"; filename="{filename}"\r\n'.encode()
    body += b"Content-Type: application/octet-stream\r\n\r\n"
    body += file_data
    body += f"\r\n--{boundary}--\r\n".encode()
    headers = {
        "Content-Type": f"multipart/form-data; boundary={boundary}",
        "Authorization": f"Bearer {token}",
    }
    return _api_request("POST", url, headers, body, timeout=UPLOAD_TIMEOUT)


def _upload_texture_multipart(path, filepath, slot_key, texture_type,
                              slot_label="", slot_index=0,
                              token=None, server=None):
    """Upload a single texture map to a session's material slot.

    Field names must match what the server expects:
      texture (file), texture_type, slot_key, slot_label, slot_index
    """
    cfg = _load_config()
    server = (server or cfg.get("server_url", DEFAULT_SERVER)).rstrip("/")
    token = token or cfg.get("api_token", "")
    url = server + path
    boundary = "----FeedbacksBoundary" + "".join(
        random.choices(string.ascii_letters + string.digits, k=16))
    filename = os.path.basename(filepath)
    with open(filepath, "rb") as f:
        file_data = f.read()

    body = b""
    # file field
    body += f"--{boundary}\r\n".encode()
    body += f'Content-Disposition: form-data; name="texture"; filename="{filename}"\r\n'.encode()
    body += b"Content-Type: image/png\r\n\r\n"
    body += file_data
    body += b"\r\n"
    # texture_type field
    body += f"--{boundary}\r\n".encode()
    body += f'Content-Disposition: form-data; name="texture_type"\r\n\r\n{texture_type}\r\n'.encode()
    # slot_key field
    body += f"--{boundary}\r\n".encode()
    body += f'Content-Disposition: form-data; name="slot_key"\r\n\r\n{slot_key}\r\n'.encode()
    # slot_label field
    body += f"--{boundary}\r\n".encode()
    body += f'Content-Disposition: form-data; name="slot_label"\r\n\r\n{slot_label or slot_key}\r\n'.encode()
    # slot_index field
    body += f"--{boundary}\r\n".encode()
    body += f'Content-Disposition: form-data; name="slot_index"\r\n\r\n{slot_index}\r\n'.encode()
    body += f"--{boundary}--\r\n".encode()

    headers = {
        "Content-Type": f"multipart/form-data; boundary={boundary}",
        "Authorization": f"Bearer {token}",
    }
    return _api_request("POST", url, headers, body, timeout=UPLOAD_TIMEOUT)


# ---------------------------------------------------------------------------
#  Auth helpers
# ---------------------------------------------------------------------------
def _login(email, password):
    status, data = _api_json("POST", "/auth/login", {"email": email, "password": password})
    if status == 200:
        user = data.get("user", data)
        token = user.get("api_token") or data.get("token", "")
        if not token:
            token = data.get("api_token", "")
        return True, token, user
    return False, data.get("error", f"Login failed (HTTP {status})"), {}


def _verify_token(token=None):
    status, data = _api_json("GET", "/auth/me", token=token)
    if status == 200 and "user" in data:
        return True, data["user"]
    return False, data.get("error", f"HTTP {status}")


def _fetch_sessions(token=None):
    status, data = _api_json("GET", "/api/sessions", token=token)
    if status == 200:
        sessions = data if isinstance(data, list) else data.get("sessions", [])
        return [(s.get("id", ""), s.get("name", "Untitled")) for s in sessions]
    return []


# ---------------------------------------------------------------------------
#  Substance Painter mesh + texture helpers
# ---------------------------------------------------------------------------
def _resolve_mesh_path():
    """Export the mesh from the current SP project to a temp file.

    Instead of looking for the original mesh file on disk (which may
    have been moved or deleted), we use Substance Painter's built-in
    mesh export. This always works as long as a project is open.

    Falls back to locating the original file if the export API is
    unavailable (older SP versions).

    Returns (filepath, error_string_or_None).
    """
    if not _HAS_SP or not substance_painter.project.is_open():
        return None, "No Substance Painter project is open"

    # --- Strategy 1: Export mesh via SP API (reliable) ---
    try:
        tmp_dir = tempfile.mkdtemp(prefix="feedbacks_mesh_")

        # Try substance_painter.export.export_mesh first (SP 2022.1+)
        if hasattr(substance_painter.export, "export_mesh"):
            out_path = os.path.join(tmp_dir, "feedbacks_export.obj")
            result = substance_painter.export.export_mesh(out_path)
            # export_mesh returns True on success in some versions,
            # or an ExportStatus in others
            if os.path.isfile(out_path) and os.path.getsize(out_path) > 0:
                return out_path, None

        # Alternative: use the scene export with OBJ preset
        # substance_painter.export.export_project_textures can also
        # bundle the mesh when exportShaderParams is set, but a
        # simpler approach is to read the mesh from the project file.

    except Exception as exc:
        print(f"[FEEDBACKS] Mesh export API failed: {exc}")

    # --- Strategy 2: Locate original file on disk (fallback) ---
    try:
        raw_path = substance_painter.project.last_imported_mesh_path()
    except Exception as exc:
        return None, f"Could not read mesh path from project: {exc}"

    if not raw_path:
        return None, "Project has no mesh path recorded and export API unavailable"

    # Normalise file:/// URL → local path
    mesh_path = _normalise_file_url(raw_path)

    if os.path.isfile(mesh_path):
        return mesh_path, None

    # On Windows, SP sometimes records a forward-slash Unix path
    if IS_WIN:
        alt = mesh_path.replace("/", "\\")
        if os.path.isfile(alt):
            return alt, None

    # Try the project directory — the mesh is often next to the .spp file
    try:
        project_file = substance_painter.project.file_path()
        if project_file:
            project_dir = os.path.dirname(project_file)
            basename = os.path.basename(mesh_path)
            candidate = os.path.join(project_dir, basename)
            if os.path.isfile(candidate):
                return candidate, None
    except Exception:
        pass

    # --- Strategy 3: Export via scene.export (SP 2023+) ---
    try:
        import substance_painter.scene as sp_scene
        if hasattr(sp_scene, "export"):
            tmp_dir2 = tempfile.mkdtemp(prefix="feedbacks_mesh2_")
            out_path2 = os.path.join(tmp_dir2, "feedbacks_export.fbx")
            sp_scene.export(out_path2)
            if os.path.isfile(out_path2) and os.path.getsize(out_path2) > 0:
                return out_path2, None
    except Exception:
        pass

    return None, (
        f"Mesh not found on disk: {mesh_path}\n"
        "The original file may have been moved or deleted.\n\n"
        "Workaround: use File > Export Mesh in Substance Painter to save\n"
        "the mesh somewhere, then re-import it into your project so\n"
        "the path is updated."
    )


def _normalise_file_url(raw):
    """Convert file:///path or file://localhost/path to a plain OS path."""
    if not raw:
        return raw
    # Handle file:/// URLs
    if raw.startswith("file:///"):
        path = raw[len("file:///"):]
        # On Windows, file:///C:/foo → C:/foo (keep drive letter)
        # On Unix, file:///home/user → /home/user (re-add leading /)
        if IS_WIN and len(path) >= 2 and path[1] in (":", "|"):
            path = path[0] + ":" + path[2:]
        else:
            path = "/" + path
        return urllib.parse.unquote(path)
    if raw.startswith("file://"):
        # file://localhost/path or file://host/path
        rest = raw[len("file://"):]
        slash_idx = rest.find("/")
        if slash_idx >= 0:
            path = rest[slash_idx:]
            return urllib.parse.unquote(path)
    return raw


def _rename_to_project_name(mesh_path):
    """If the mesh was exported to a generic temp name, rename it to
    match the SP project name so the upload has a meaningful filename."""
    basename = os.path.basename(mesh_path)
    if not basename.startswith("feedbacks_export"):
        return mesh_path  # Already has a real name

    try:
        project_file = substance_painter.project.file_path()
        if project_file:
            project_name = Path(project_file).stem  # e.g. "MyCharacter"
        else:
            project_name = "substance_mesh"
    except Exception:
        project_name = "substance_mesh"

    ext = Path(mesh_path).suffix or ".obj"
    new_path = os.path.join(os.path.dirname(mesh_path), project_name + ext)
    try:
        os.rename(mesh_path, new_path)
        return new_path
    except Exception:
        return mesh_path  # Keep original if rename fails


def _export_textures(output_dir, size_log2=11):
    """Export PBR channel maps from the current SP project.

    Only exports maps used in FEEDBACKS: albedo/color, roughness,
    metalness, normal, emissive, opacity.

    Returns:
        list of (slot_name, feedbacks_type, filepath) on success
        ([], error_message) on failure
    """
    if not _HAS_SP or not substance_painter.project.is_open():
        return [], "No project open"

    results = []

    try:
        # Use a simple channel-per-file export configuration.
        # We build a JSON export config that outputs individual maps.
        all_sets = substance_painter.textureset.all_texture_sets()
        if not all_sets:
            return [], "No texture sets found in project"

        # Build per-channel output map definitions
        output_maps = []
        for channel_tag, fb_type in FEEDBACKS_MAP_TYPES.items():
            output_maps.append({
                "fileName": "$mesh_$textureSet_" + channel_tag,
                "parameters": {
                    "fileFormat": "png",
                    "bitDepth": "8",
                    "sizeLog2": size_log2,
                    "paddingAlgorithm": "diffusion",
                    "dilationDistance": 16,
                },
                "channels": [_channel_spec(channel_tag)],
            })

        export_list = []
        for ts in all_sets:
            export_list.append({
                "rootPath": ts.name(),
                "exportPreset": "",
                "maps": output_maps,
            })

        config = {
            "exportShaderParams": False,
            "exportPath": output_dir,
            "exportList": export_list,
        }

        export_result = substance_painter.export.export_project_textures(config)

        if export_result.status != substance_painter.export.ExportStatus.Success:
            return [], f"Export failed: {export_result.message}"

        # Collect exported files and map them to FEEDBACKS types
        for ts_name, file_list in (export_result.textures or {}).items():
            slot_name = ts_name.replace(" ", "_").lower()
            for fpath in file_list:
                fb_type = _detect_map_type(Path(fpath).stem)
                if fb_type and fb_type in FEEDBACKS_MAP_TYPES:
                    results.append((slot_name, fb_type, fpath))

    except Exception as e:
        # Fallback: try the simple preset-based export
        return _export_textures_preset_fallback(output_dir, size_log2)

    if not results:
        # If channel export produced nothing, try preset fallback
        return _export_textures_preset_fallback(output_dir, size_log2)

    return results, None


def _export_textures_preset_fallback(output_dir, size_log2=11):
    """Fallback: export using the PBR Metallic Roughness preset."""
    try:
        export_preset = substance_painter.resource.ResourceID(
            context="starter_assets", name="PBR Metallic Roughness")

        all_sets = substance_painter.textureset.all_texture_sets()
        export_list = []
        for ts in all_sets:
            export_list.append({"rootPath": ts.name()})

        config = {
            "exportShaderParams": False,
            "exportPath": output_dir,
            "exportList": export_list,
            "defaultExportPreset": export_preset.url(),
            "exportParameters": [{
                "parameters": {
                    "fileFormat": "png",
                    "sizeLog2": size_log2,
                    "paddingAlgorithm": "diffusion",
                },
            }],
        }

        export_result = substance_painter.export.export_project_textures(config)
        if export_result.status != substance_painter.export.ExportStatus.Success:
            return [], f"Export failed: {export_result.message}"

        results = []
        for ts_name, file_list in (export_result.textures or {}).items():
            slot_name = ts_name.replace(" ", "_").lower()
            for fpath in file_list:
                fb_type = _detect_map_type(Path(fpath).stem)
                if fb_type and fb_type in FEEDBACKS_MAP_TYPES:
                    results.append((slot_name, fb_type, fpath))

        return results, None
    except Exception as e:
        return [], str(e)


def _channel_spec(channel_tag):
    """Map our channel tag to the SP channel source spec."""
    channel_map = {
        "color": {"destChannel": "RGB", "srcChannel": "RGB",
                  "srcMapType": "documentMap", "srcMapName": "baseColor"},
        "roughness": {"destChannel": "L", "srcChannel": "L",
                      "srcMapType": "documentMap", "srcMapName": "roughness"},
        "metalness": {"destChannel": "L", "srcChannel": "L",
                      "srcMapType": "documentMap", "srcMapName": "metallic"},
        "normal": {"destChannel": "RGB", "srcChannel": "RGB",
                   "srcMapType": "documentMap", "srcMapName": "normal"},
        "emissive": {"destChannel": "RGB", "srcChannel": "RGB",
                     "srcMapType": "documentMap", "srcMapName": "emissive"},
        "opacity": {"destChannel": "L", "srcChannel": "L",
                    "srcMapType": "documentMap", "srcMapName": "opacity"},
    }
    return channel_map.get(channel_tag, {
        "destChannel": "RGB", "srcChannel": "RGB",
        "srcMapType": "documentMap", "srcMapName": channel_tag,
    })


def _detect_map_type(filename_stem):
    """Detect FEEDBACKS texture map type from an exported filename."""
    name = filename_stem.lower()
    for keyword, map_type in _FILENAME_MAP_RULES:
        if keyword in name:
            return map_type
    return None


# ---------------------------------------------------------------------------
#  Qt Signals bridge (thread-safe UI updates)
# ---------------------------------------------------------------------------
class _SignalBridge(QObject):
    status_changed = Signal(str)
    progress_changed = Signal(int)
    login_result = Signal(bool, str)
    upload_done = Signal(bool, str)
    sessions_loaded = Signal(list)


_bridge = None


# ---------------------------------------------------------------------------
#  Plugin UI — QDockWidget panel
# ---------------------------------------------------------------------------
_plugin_widget = None


class FeedbacksPanel(QWidget):
    """Main dock widget for the FEEDBACKS Substance Painter plugin."""

    def __init__(self, parent=None):
        super().__init__(parent)
        self.setObjectName("FeedbacksPluginPanel")
        self.setWindowTitle("FEEDBACKS")
        self.setMinimumWidth(280)

        self._token = ""
        self._user = None
        self._sessions = []
        self._timer = QTimer(self)
        self._timer.timeout.connect(self._poll_check)

        global _bridge
        _bridge = _SignalBridge()
        _bridge.status_changed.connect(self._on_status)
        _bridge.progress_changed.connect(self._on_progress)
        _bridge.login_result.connect(self._on_login_result)
        _bridge.upload_done.connect(self._on_upload_done)
        _bridge.sessions_loaded.connect(self._on_sessions_loaded)

        self._build_ui()
        self._try_auto_login()

    # ── UI build ──
    def _build_ui(self):
        root = QVBoxLayout(self)
        root.setContentsMargins(12, 12, 12, 12)
        root.setSpacing(0)

        # Header
        header = QLabel("FEEDBACKS")
        header.setFont(self._mono(11, bold=True))
        header.setStyleSheet("letter-spacing:3px; color:#ff4d1c; margin-bottom:12px;")
        root.addWidget(header)

        # Stacked pages: 0=login, 1=main
        self._stack = QStackedWidget()
        root.addWidget(self._stack)

        self._stack.addWidget(self._build_login_page())
        self._stack.addWidget(self._build_main_page())

        # Status bar
        self._status = QLabel("")
        self._status.setFont(self._mono(9))
        self._status.setStyleSheet("color:#706b63; margin-top:10px;")
        self._status.setWordWrap(True)
        root.addWidget(self._status)

        # Progress
        self._progress = QProgressBar()
        self._progress.setRange(0, 100)
        self._progress.setValue(0)
        self._progress.setVisible(False)
        self._progress.setFixedHeight(4)
        self._progress.setStyleSheet("""
            QProgressBar { background:#1e1e21; border:none; border-radius:2px; }
            QProgressBar::chunk { background:#ff4d1c; border-radius:2px; }
        """)
        root.addWidget(self._progress)
        root.addStretch()

    def _build_login_page(self):
        page = QWidget()
        lay = QVBoxLayout(page)
        lay.setContentsMargins(0, 0, 0, 0)
        lay.setSpacing(8)

        lay.addWidget(self._section_label("CONNECT YOUR ACCOUNT"))

        # Email
        self._email_input = QLineEdit()
        self._email_input.setPlaceholderText("Email")
        self._style_input(self._email_input)
        lay.addWidget(self._email_input)

        # Password
        self._pw_input = QLineEdit()
        self._pw_input.setPlaceholderText("Password")
        self._pw_input.setEchoMode(QLineEdit.Password)
        self._style_input(self._pw_input)
        lay.addWidget(self._pw_input)

        # Login button
        self._login_btn = QPushButton("LOG IN")
        self._style_btn_primary(self._login_btn)
        self._login_btn.clicked.connect(self._do_login)
        lay.addWidget(self._login_btn)

        lay.addWidget(self._sep())

        lay.addWidget(self._section_label("OR PASTE API TOKEN"))

        self._token_input = QLineEdit()
        self._token_input.setPlaceholderText("Paste token from Account page")
        self._style_input(self._token_input)
        lay.addWidget(self._token_input)

        self._token_btn = QPushButton("CONNECT WITH TOKEN")
        self._style_btn(self._token_btn)
        self._token_btn.clicked.connect(self._do_token_login)
        lay.addWidget(self._token_btn)

        lay.addStretch()
        return page

    def _build_main_page(self):
        page = QWidget()
        lay = QVBoxLayout(page)
        lay.setContentsMargins(0, 0, 0, 0)
        lay.setSpacing(8)

        # User info
        self._user_label = QLabel("—")
        self._user_label.setFont(self._mono(10))
        self._user_label.setStyleSheet("color:#f3efe8;")
        lay.addWidget(self._user_label)

        lay.addWidget(self._sep())
        lay.addWidget(self._section_label("SESSION"))

        # Session selector
        self._session_combo = QComboBox()
        self._session_combo.setStyleSheet("""
            QComboBox {
                background: rgba(255,255,255,0.05);
                border: 1px solid rgba(255,255,255,0.10);
                border-radius: 8px;
                padding: 8px 10px;
                color: #f3efe8;
                font-family: 'DM Mono', monospace;
                font-size: 10px;
            }
            QComboBox::drop-down { border: none; }
            QComboBox QAbstractItemView {
                background: #1e1e21;
                color: #f3efe8;
                selection-background-color: rgba(255,77,28,0.2);
            }
        """)
        lay.addWidget(self._session_combo)

        # New session button
        row = QHBoxLayout()
        self._refresh_btn = QPushButton("REFRESH")
        self._style_btn(self._refresh_btn)
        self._refresh_btn.clicked.connect(self._refresh_sessions)
        row.addWidget(self._refresh_btn)

        self._new_session_btn = QPushButton("+ NEW SESSION")
        self._style_btn(self._new_session_btn)
        self._new_session_btn.clicked.connect(self._create_session)
        row.addWidget(self._new_session_btn)
        lay.addLayout(row)

        lay.addWidget(self._sep())
        lay.addWidget(self._section_label("EXPORT OPTIONS"))

        # Texture resolution selector
        res_row = QHBoxLayout()
        res_label = QLabel("Texture size:")
        res_label.setFont(self._mono(9))
        res_label.setStyleSheet("color:#d6d1c8;")
        res_row.addWidget(res_label)

        self._resolution_combo = QComboBox()
        for label in RESOLUTION_OPTIONS:
            self._resolution_combo.addItem(label)
        # Default to 2K
        self._resolution_combo.setCurrentIndex(1)
        self._resolution_combo.setStyleSheet("""
            QComboBox {
                background: rgba(255,255,255,0.05);
                border: 1px solid rgba(255,255,255,0.10);
                border-radius: 8px;
                padding: 6px 10px;
                color: #f3efe8;
                font-family: 'DM Mono', monospace;
                font-size: 10px;
            }
            QComboBox::drop-down { border: none; }
            QComboBox QAbstractItemView {
                background: #1e1e21;
                color: #f3efe8;
                selection-background-color: rgba(255,77,28,0.2);
            }
        """)
        res_row.addWidget(self._resolution_combo)
        lay.addLayout(res_row)

        # Texture maps info
        maps_label = QLabel("Maps: albedo, roughness, metalness,\n"
                            "normal, emissive, opacity")
        maps_label.setFont(self._mono(8))
        maps_label.setStyleSheet("color:#706b63; margin:4px 0 0 0;")
        maps_label.setWordWrap(True)
        lay.addWidget(maps_label)

        # Export textures checkbox
        self._export_textures_cb = QCheckBox("Include PBR textures")
        self._export_textures_cb.setChecked(True)
        self._export_textures_cb.setFont(self._mono(9))
        self._export_textures_cb.setStyleSheet("color:#d6d1c8; spacing:6px; margin-top:6px;")
        lay.addWidget(self._export_textures_cb)

        # Open browser checkbox
        self._open_browser_cb = QCheckBox("Open in browser after upload")
        self._open_browser_cb.setChecked(True)
        self._open_browser_cb.setFont(self._mono(9))
        self._open_browser_cb.setStyleSheet("color:#d6d1c8; spacing:6px;")
        lay.addWidget(self._open_browser_cb)

        lay.addWidget(self._sep())

        # Upload button
        self._upload_btn = QPushButton("EXPORT TO FEEDBACKS")
        self._style_btn_primary(self._upload_btn)
        self._upload_btn.clicked.connect(self._do_upload)
        lay.addWidget(self._upload_btn)

        # Logout
        self._logout_btn = QPushButton("LOG OUT")
        self._style_btn(self._logout_btn)
        self._logout_btn.setStyleSheet(
            self._logout_btn.styleSheet() + "color:#706b63; border-color:rgba(255,255,255,0.06);")
        self._logout_btn.clicked.connect(self._do_logout)
        lay.addWidget(self._logout_btn)

        lay.addStretch()
        return page

    # ── Styling helpers ──
    def _mono(self, size, bold=False):
        f = QFont("DM Mono", size)
        if bold:
            f.setBold(True)
        return f

    def _section_label(self, text):
        lbl = QLabel(text)
        lbl.setFont(self._mono(8))
        lbl.setStyleSheet("color:#706b63; letter-spacing:2px; margin-bottom:4px; margin-top:4px;")
        return lbl

    def _sep(self):
        line = QFrame()
        line.setFrameShape(QFrame.HLine)
        line.setFixedHeight(1)
        line.setStyleSheet("background:rgba(255,255,255,0.08); border:none; margin:8px 0;")
        return line

    def _style_input(self, widget):
        widget.setFont(self._mono(10))
        widget.setStyleSheet("""
            QLineEdit {
                background: rgba(255,255,255,0.05);
                border: 1px solid rgba(255,255,255,0.10);
                border-radius: 10px;
                padding: 10px 12px;
                color: #f3efe8;
            }
            QLineEdit:focus {
                border-color: rgba(255,77,28,0.5);
            }
        """)

    def _style_btn(self, btn):
        btn.setFont(self._mono(9, bold=True))
        btn.setCursor(Qt.PointingHandCursor)
        btn.setFixedHeight(34)
        btn.setStyleSheet("""
            QPushButton {
                background: rgba(255,255,255,0.04);
                border: 1px solid rgba(255,255,255,0.10);
                border-radius: 999px;
                color: #d6d1c8;
                letter-spacing: 1px;
                padding: 0 14px;
            }
            QPushButton:hover {
                border-color: rgba(255,77,28,0.4);
                color: #f3efe8;
            }
            QPushButton:pressed { background: rgba(255,77,28,0.1); }
            QPushButton:disabled { opacity: 0.4; }
        """)

    def _style_btn_primary(self, btn):
        btn.setFont(self._mono(9, bold=True))
        btn.setCursor(Qt.PointingHandCursor)
        btn.setFixedHeight(38)
        btn.setStyleSheet("""
            QPushButton {
                background: #ff4d1c;
                border: none;
                border-radius: 999px;
                color: #ffffff;
                letter-spacing: 1px;
                padding: 0 18px;
            }
            QPushButton:hover { background: #ff6b42; }
            QPushButton:pressed { background: #e0431a; }
            QPushButton:disabled { background: #4a2a1e; color: #7a5a4a; }
        """)

    # ── Status / Progress ──
    def _on_status(self, msg):
        self._status.setText(msg)

    def _on_progress(self, val):
        self._progress.setVisible(val > 0 and val < 100)
        self._progress.setValue(val)

    # ── Auto-login from saved config ──
    def _try_auto_login(self):
        cfg = _load_config()
        token = cfg.get("api_token", "")
        if token:
            self._set_status("Verifying saved token...")
            threading.Thread(target=self._verify_thread, args=(token,), daemon=True).start()

    def _verify_thread(self, token):
        ok, result = _verify_token(token)
        if ok:
            self._token = token
            self._user = result
            _bridge.login_result.emit(True, result.get("name") or result.get("email", ""))
        else:
            _bridge.login_result.emit(False, "")

    # ── Login ──
    def _do_login(self):
        email = self._email_input.text().strip()
        pw = self._pw_input.text().strip()
        if not email or not pw:
            self._set_status("Enter email and password.")
            return
        self._login_btn.setEnabled(False)
        self._set_status("Logging in...")
        threading.Thread(target=self._login_thread, args=(email, pw), daemon=True).start()

    def _login_thread(self, email, pw):
        ok, token_or_err, user = _login(email, pw)
        if ok and token_or_err:
            self._token = token_or_err
            self._user = user
            cfg = _load_config()
            cfg["api_token"] = token_or_err
            _save_config(cfg)
            _bridge.login_result.emit(True, user.get("name") or user.get("email", ""))
        elif ok and not token_or_err:
            _bridge.login_result.emit(False, "Login OK but no API token. Generate one in Account page.")
        else:
            _bridge.login_result.emit(False, str(token_or_err))

    def _do_token_login(self):
        token = self._token_input.text().strip()
        if not token:
            self._set_status("Paste a token first.")
            return
        self._token_btn.setEnabled(False)
        self._set_status("Verifying token...")
        threading.Thread(target=self._token_verify_thread, args=(token,), daemon=True).start()

    def _token_verify_thread(self, token):
        ok, result = _verify_token(token)
        if ok:
            self._token = token
            self._user = result
            cfg = _load_config()
            cfg["api_token"] = token
            _save_config(cfg)
            _bridge.login_result.emit(True, result.get("name") or result.get("email", ""))
        else:
            _bridge.login_result.emit(False, f"Invalid token: {result}")

    def _on_login_result(self, ok, info):
        self._login_btn.setEnabled(True)
        self._token_btn.setEnabled(True)
        if ok:
            self._user_label.setText(info)
            self._stack.setCurrentIndex(1)
            self._set_status("Connected.")
            self._refresh_sessions()
        else:
            if info:
                self._set_status(info)
            else:
                self._set_status("Not connected. Please log in.")

    # ── Logout ──
    def _do_logout(self):
        self._token = ""
        self._user = None
        cfg = _load_config()
        cfg["api_token"] = ""
        _save_config(cfg)
        self._stack.setCurrentIndex(0)
        self._set_status("Logged out.")

    # ── Sessions ──
    def _refresh_sessions(self):
        self._set_status("Loading sessions...")
        threading.Thread(target=self._sessions_thread, daemon=True).start()

    def _sessions_thread(self):
        sessions = _fetch_sessions(self._token)
        _bridge.sessions_loaded.emit(sessions)

    def _on_sessions_loaded(self, sessions):
        self._sessions = sessions
        self._session_combo.clear()
        for sid, name in sessions:
            label = name if name else sid[:8]
            self._session_combo.addItem(label, sid)
        self._set_status(f"{len(sessions)} session(s) loaded.")

    def _create_session(self):
        self._set_status("Creating session...")
        threading.Thread(target=self._create_session_thread, daemon=True).start()

    def _create_session_thread(self):
        # Derive name from current project if possible
        name = "Substance Painter Export"
        if _HAS_SP and substance_painter.project.is_open():
            try:
                ppath = substance_painter.project.file_path()
                if ppath:
                    name = Path(ppath).stem
            except Exception:
                pass
        status, data = _api_json("POST", "/api/sessions", {"name": name}, token=self._token)
        if status in (200, 201):
            _bridge.status_changed.emit(f"Session created: {name}")
            sessions = _fetch_sessions(self._token)
            _bridge.sessions_loaded.emit(sessions)
        else:
            _bridge.status_changed.emit(f"Create failed: {data.get('error', 'unknown')}")

    # ── Upload ──
    def _do_upload(self):
        idx = self._session_combo.currentIndex()
        if idx < 0 or not self._sessions:
            self._set_status("Select a session first.")
            return
        sid = self._session_combo.currentData()
        if not sid:
            self._set_status("No session selected.")
            return
        if not _HAS_SP or not substance_painter.project.is_open():
            self._set_status("No Substance Painter project open.")
            return

        self._upload_btn.setEnabled(False)
        include_textures = self._export_textures_cb.isChecked()
        open_browser = self._open_browser_cb.isChecked()

        # Get selected resolution
        res_label = self._resolution_combo.currentText()
        size_log2 = RESOLUTION_OPTIONS.get(res_label, 11)

        self._set_status("Preparing export...")
        _bridge.progress_changed.emit(10)

        # ── IMPORTANT: Export mesh AND textures on the MAIN thread ──
        # Substance Painter's Python API is NOT thread-safe.
        # All SP API calls must happen here, before spawning the upload thread.
        mesh_path = None
        mesh_err = None
        exported_textures = []
        tex_err = None

        # Export mesh (main thread)
        self._set_status("Exporting mesh from project...")
        try:
            mesh_path, mesh_err = _resolve_mesh_path()
            if mesh_path:
                mesh_path = _rename_to_project_name(mesh_path)
        except Exception as exc:
            mesh_err = str(exc)

        if mesh_err or not mesh_path:
            self._set_status(mesh_err or "Could not export mesh from project")
            self._upload_btn.setEnabled(True)
            _bridge.progress_changed.emit(0)
            return

        # Export textures (main thread)
        tmp_dir = None
        if include_textures:
            res_text = "1K" if size_log2 == 10 else "2K"
            self._set_status(f"Exporting {res_text} textures...")
            _bridge.progress_changed.emit(15)
            try:
                tmp_dir = tempfile.mkdtemp(prefix="feedbacks_sp_")
                exported_textures, tex_err = _export_textures(tmp_dir, size_log2)
            except Exception as exc:
                tex_err = str(exc)

        _bridge.progress_changed.emit(20)

        # Now spawn the upload thread with pre-exported files
        threading.Thread(
            target=self._upload_thread,
            args=(sid, mesh_path, exported_textures, tex_err,
                  tmp_dir, open_browser),
            daemon=True,
        ).start()

    def _upload_thread(self, session_id, mesh_path, exported_textures,
                       tex_err, tmp_dir, open_browser):
        try:
            # 1. Check mesh size
            fsize = os.path.getsize(mesh_path)
            if fsize > MAX_BYTES:
                _bridge.upload_done.emit(False, f"Mesh too large ({fsize // (1024*1024)}MB > {MAX_MB}MB)")
                return

            # 2. Upload mesh
            _bridge.status_changed.emit(f"Uploading mesh: {os.path.basename(mesh_path)}...")
            _bridge.progress_changed.emit(30)
            status, data = _upload_multipart(
                f"/api/sessions/{session_id}/mesh", mesh_path, self._token)
            if status not in (200, 201):
                _bridge.upload_done.emit(False, f"Mesh upload failed: {data.get('error', f'HTTP {status}')}")
                return

            # 3. Upload pre-exported PBR textures
            tex_warnings = []
            if tex_err:
                tex_warnings.append(f"Texture export: {tex_err}")
            elif exported_textures:
                total = len(exported_textures)
                for i, (slot_name, fb_type, fpath) in enumerate(exported_textures):
                    pct = 40 + int((i / max(1, total)) * 45)
                    _bridge.status_changed.emit(
                        f"Uploading {fb_type} ({i + 1}/{total})...")
                    _bridge.progress_changed.emit(pct)

                    try:
                        t_status, t_data = _upload_texture_multipart(
                            f"/api/sessions/{session_id}/material-textures",
                            fpath,
                            slot_key=slot_name,
                            texture_type=fb_type,
                            slot_label=slot_name.replace("_", " ").title(),
                            slot_index=0,
                            token=self._token,
                        )
                        if t_status not in (200, 201):
                            err_msg = t_data.get("error", f"HTTP {t_status}") if isinstance(t_data, dict) else f"HTTP {t_status}"
                            tex_warnings.append(f"{fb_type}: {err_msg}")
                    except Exception as exc:
                        tex_warnings.append(f"{fb_type}: {exc}")
            else:
                if not tex_err:
                    _bridge.status_changed.emit("No PBR maps found in project.")

            # Cleanup temp dir
            if tmp_dir:
                try:
                    import shutil
                    shutil.rmtree(tmp_dir, ignore_errors=True)
                except Exception:
                    pass

            _bridge.progress_changed.emit(95)

            # 4. Open browser (with auth handoff so user isn't a guest)
            if open_browser:
                cfg = _load_config()
                server = cfg.get("server_url", DEFAULT_SERVER).rstrip("/")
                # Get a short-lived browser token to set the JWT cookie
                try:
                    bstatus, bdata = _api_json(
                        "POST", "/auth/browser-token", {},
                        token=self._token)
                    handoff_token = (
                        bdata.get("token", "") if bstatus == 200 else ""
                    )
                except Exception:
                    handoff_token = ""

                if handoff_token:
                    redirect_target = f"/app?s={session_id}&external=1"
                    url = (
                        f"{server}/auth/external-login?"
                        + urllib.parse.urlencode({
                            "token": handoff_token,
                            "redirect": redirect_target,
                        })
                    )
                else:
                    # Fallback: open without auth (user will be guest)
                    url = f"{server}/app?s={session_id}"
                webbrowser.open(url)

            # Build success message
            if tex_warnings:
                msg = f"Mesh uploaded. {len(tex_warnings)} texture(s) failed: " + "; ".join(tex_warnings[:3])
            elif exported_textures:
                msg = f"Mesh + {len(exported_textures)} textures uploaded!"
            else:
                msg = "Mesh uploaded!"
            _bridge.upload_done.emit(True, msg)

        except Exception as e:
            _bridge.upload_done.emit(False, str(e))

    def _on_upload_done(self, ok, msg):
        self._upload_btn.setEnabled(True)
        _bridge.progress_changed.emit(100 if ok else 0)
        self._set_status(msg)
        if ok:
            QTimer.singleShot(3000, lambda: _bridge.progress_changed.emit(0))

    # ── Misc ──
    def _set_status(self, msg):
        self._status.setText(msg)

    def _poll_check(self):
        pass


# ---------------------------------------------------------------------------
#  Plugin entry points (Substance Painter API)
# ---------------------------------------------------------------------------
def start_plugin():
    global _plugin_widget
    if not _HAS_QT:
        print("[FEEDBACKS] ERROR: PySide2/PySide6 not available — cannot create UI.")
        return
    try:
        _plugin_widget = FeedbacksPanel()
        if _HAS_SP:
            substance_painter.ui.add_dock_widget(_plugin_widget)
        print("[FEEDBACKS] Plugin started.")
    except Exception as exc:
        print(f"[FEEDBACKS] ERROR starting plugin: {exc}")
        import traceback
        traceback.print_exc()


def close_plugin():
    global _plugin_widget
    if _plugin_widget:
        try:
            if _HAS_SP:
                substance_painter.ui.delete_ui_element(_plugin_widget)
        except Exception:
            pass
        _plugin_widget = None
    print("[FEEDBACKS] Plugin closed.")
