"""
Drop-in Update Checker Module for Blender Add-ons

Usage:
1. Copy this file to your addon directory
2. In your addon's __init__.py:

   from . import addon_update_checker

   def register():
       # Your registration code
       addon_update_checker.register()

   def unregister():
       addon_update_checker.unregister()
       # Your unregistration code

This module will automatically:
- Read addon_id and version from your blender_manifest.toml
- Check for updates once per session
- Display a notification if an update is available

If the Project LEUC master add-on is installed and enabled, this module does
NOT contact the server. The master add-on already checks every installed
add-on in one batched request, so a second request here would ask the same
question twice. Instead the result is read out of the master's session state.

For the same reason, this module's own notifications (viewport gizmo and
in-panel banner) stay hidden while the master is present — otherwise the user
sees the same update announced twice. Both are re-enabled by a toggle in the
parent add-on's preferences.
"""

import bpy
import os
import re
import sys
import time
import uuid
import zlib
import importlib
import tomllib
import threading
import urllib.request
import urllib.error
import json
import webbrowser
import blf
import gpu
import math
from gpu_extras.batch import batch_for_shader
from bpy.types import Gizmo, GizmoGroup
from bpy.app.handlers import persistent

# ============================================================================
# Configuration
# ============================================================================

# Version data comes from the published JSON, not from a live query.
#
# This mirrors the master add-on's default "Report" mode, and the split is the
# point: the static file is edge-cached, costs nothing to serve, and is one
# canonical origin, while the report carries the analytics and community
# discovery that a static file cannot. Querying a deployment directly instead
# means a single misaligned backend answers "not found" for an add-on that is
# genuinely registered.
ADDON_JSON_URL = "https://app.projectleuc.com/addons/{addon_id}.json"
REPORT_URL = "https://kindhearted-ladybug-387.convex.site/api/report"
BUG_REPORT_URL = "https://kindhearted-ladybug-387.convex.site/api/report-bug"
BUG_STATUS_URL = "https://kindhearted-ladybug-387.convex.site/api/report-status"

USER_AGENT = "Blender-Addon-Update-Checker/1.0"
REQUEST_TIMEOUT = 30  # seconds

# Shared with the master add-on on purpose — see get_install_id().
INSTALL_ID_DIR_NAME = "BlenderUpdate"
INSTALL_ID_FILE_NAME = "install_id"

# The master add-on's extension id, as declared in its blender_manifest.toml.
LEUC_ADDON_ID = "ProjectLEUC"

# The master checks in a background thread, so its answer is not ready the
# instant we ask. Poll its state rather than racing it.
LEUC_POLL_INTERVAL = 0.5  # seconds between polls
LEUC_POLL_TIMEOUT = 30.0  # give up waiting after this long

# Blender's cap on registered class identifiers.
MAX_IDNAME_LENGTH = 64

# UI Configuration
TOAST_WIDTH = 400
TOAST_HEIGHT = 100
TOAST_MARGIN = 20
TOAST_PADDING = 15
ICON_SIZE = 48
BUTTON_HEIGHT = 28
BUTTON_WIDTH = 100
BUTTON_PADDING = 8
BUTTON_RADIUS = 6
TOAST_RADIUS = 12
DISMISS_BUTTON_SIZE = 24
DISMISS_X_SIZE = 12
FONT_SIZE_TITLE = 13
FONT_SIZE_VERSION = 11
FONT_SIZE_BUTTON = 11

# Colors (RGBA)
COLOR_BG = (0.12, 0.12, 0.12, 0.95)
COLOR_TEXT_TITLE = (1.0, 1.0, 1.0, 1.0)
COLOR_TEXT_VERSION = (0.7, 0.7, 0.7, 1.0)
COLOR_BUTTON_BG = (0.25, 0.5, 0.8, 1.0)
COLOR_BUTTON_HOVER = (0.3, 0.6, 0.9, 1.0)
COLOR_BUTTON_BG_DARK = (0.1, 0.1, 0.1, 1.0)
COLOR_BUTTON_DARK_HOVER = (0.2, 0.2, 0.2, 1.0)
COLOR_BUTTON_TEXT = (1.0, 1.0, 1.0, 1.0)
COLOR_DISMISS_BG = (0.5, 0.2, 0.2, 1.0)
COLOR_DISMISS_HOVER = (0.6, 0.25, 0.25, 1.0)

# ============================================================================
# State Management
# ============================================================================


class UpdateState:
    """Singleton to store update check state."""

    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super(UpdateState, cls).__new__(cls)
            cls._instance._initialized = False
        return cls._instance

    def __init__(self):
        if self._initialized:
            return
        self._initialized = True
        self.update_info = None
        self.checked_this_session = False
        self.addon_id = None
        self.current_version = None
        self.icon_image = None
        self.parent_addon_name = None
        # "leuc" | "api" | None — where the current answer came from, so
        # preferences can say so instead of leaving the user guessing.
        # "leuc" | "cdn" | None
        self.source = None

    def set_update_info(self, info):
        """Store update information."""
        self.update_info = info

    def get_update_info(self):
        """Get stored update information."""
        return self.update_info

    def clear_update_info(self):
        """Clear update information."""
        self.update_info = None

    def set_addon_info(self, addon_id, version):
        """Store addon identification."""
        self.addon_id = addon_id
        self.current_version = version

    def set_parent_addon(self, name):
        """Store parent addon name for preferences access."""
        self.parent_addon_name = name


# Global state instance
_state = UpdateState()

# ============================================================================
# Preferences Access
# ============================================================================


def get_preferences():
    """Get preferences for the parent addon."""
    if not _state.parent_addon_name:
        return None
    try:
        return bpy.context.preferences.addons[_state.parent_addon_name].preferences
    except:
        return None


# ============================================================================
# Project LEUC Master Add-on Integration
# ============================================================================

# Resolved package name of the master add-on, cached so the gizmo poll (which
# runs on every viewport redraw) does a dict lookup instead of a scan.
_leuc_package_cache = None

# The in-flight wait-for-master timer, so unregister can stop it.
_leuc_poll_fn = None


def _find_leuc_package():
    """Return the enabled master add-on's package name, or None.

    Reads bpy.context.preferences.addons rather than sys.modules because that
    is the list of *enabled* add-ons — a disabled extension can still linger
    in sys.modules, and deferring to it would suppress our notifications in
    favour of a master that is not running.

    The package name depends on which repository the extension was installed
    from (bl_ext.user_default.ProjectLEUC, bl_ext.vscode_development.ProjectLEUC,
    ...), so match on the trailing component instead of hardcoding a prefix.
    """
    global _leuc_package_cache

    try:
        addons = bpy.context.preferences.addons
    except Exception:
        # No context yet (early registration, background mode).
        return None

    if _leuc_package_cache is not None and _leuc_package_cache in addons:
        return _leuc_package_cache

    _leuc_package_cache = None
    for key in addons.keys():
        if key.rsplit(".", 1)[-1] == LEUC_ADDON_ID:
            _leuc_package_cache = key
            break

    return _leuc_package_cache


def is_leuc_active():
    """True when the master add-on is installed and enabled."""
    return _find_leuc_package() is not None


def _get_leuc_state():
    """Return the master add-on's state module, or None."""
    package = _find_leuc_package()
    if not package:
        return None
    try:
        return importlib.import_module(package + ".state")
    except Exception as e:
        print(f"Addon Update Checker: Could not read Project LEUC state: {e}")
        return None


def should_draw_ui():
    """Whether this module may draw its own update notifications.

    Silent by default while the master is running, since it announces the same
    update in its own UI. The preference exists for anyone who wants the
    add-on's own banner regardless.
    """
    if not is_leuc_active():
        return True

    prefs = get_preferences()
    return bool(getattr(prefs, "auc_show_ui_with_leuc", False))


def _redraw_viewports():
    """Repaint 3D viewports so a newly-found update appears without input."""
    try:
        for window in bpy.context.window_manager.windows:
            for area in window.screen.areas:
                if area.type == "VIEW_3D":
                    area.tag_redraw()
    except Exception:
        pass


def _adopt_leuc_result(updates):
    """Pick this add-on's entry out of the master's results, if present.

    The master only lists add-ons that *have* an update, so finding nothing
    here is a real "you are up to date", not a failure.
    """
    ours = (_state.addon_id or "").lower()

    for update in updates or []:
        if str(update.get("addon_id", "")).lower() == ours:
            # The master's dicts already use this module's key names; only the
            # availability flag has to be added, since presence in that list is
            # what "available" means on its side.
            info = dict(update)
            info["update_available"] = True
            _state.set_update_info(info)
            _state.source = "leuc"
            print(
                f"Addon Update Checker: Update for {_state.addon_id} taken from "
                f"Project LEUC (no request sent)"
            )
            _redraw_viewports()
            return

    _state.source = "leuc"
    print(
        f"Addon Update Checker: Project LEUC reports {_state.addon_id} is up to "
        f"date (no request sent)"
    )


def _wait_for_leuc():
    """Poll the master's session state until its check completes.

    Deliberately never falls back to our own request on timeout: the whole
    point of deferring is that exactly one request is made per session. If the
    master is wedged, it is also the thing that should be reporting the
    problem, and a silent second request here would undo the saving.
    """
    global _leuc_poll_fn

    deadline = time.monotonic() + LEUC_POLL_TIMEOUT

    def poll():
        # Unregistered while waiting: stop rather than act on a torn-down module.
        if _leuc_poll_fn is not poll:
            return None

        leuc_state = _get_leuc_state()

        # Master disabled mid-wait — take the check back over ourselves.
        if leuc_state is None:
            print("Addon Update Checker: Project LEUC went away, checking directly")
            _state.checked_this_session = False
            check_for_update_async()
            return None

        try:
            done = leuc_state.is_checked() and not leuc_state.is_checking()
        except Exception as e:
            print(f"Addon Update Checker: Unexpected Project LEUC state: {e}")
            return None

        if done:
            try:
                _adopt_leuc_result(leuc_state.get_updates())
            except Exception as e:
                print(f"Addon Update Checker: Could not read Project LEUC updates: {e}")
            return None

        if time.monotonic() > deadline:
            print(
                "Addon Update Checker: Project LEUC did not finish its check in "
                f"{LEUC_POLL_TIMEOUT:.0f}s; leaving the notification to it"
            )
            return None

        return LEUC_POLL_INTERVAL

    _leuc_poll_fn = poll
    bpy.app.timers.register(poll, first_interval=LEUC_POLL_INTERVAL)


# ============================================================================
# Manifest Reading
# ============================================================================


def read_addon_manifest():
    """Read addon_id and version from blender_manifest.toml.

    Searches in current directory and up to 3 parent directories to find the manifest.
    This allows the module to be placed in subfolders like 'modules/' or similar.
    """
    try:
        # Get the directory containing this module
        current_dir = os.path.dirname(os.path.abspath(__file__))

        # Search current directory and up to 3 parent directories
        search_dirs = [current_dir]
        for _ in range(3):
            parent = os.path.dirname(search_dirs[-1])
            if parent == search_dirs[-1]:  # Reached root
                break
            search_dirs.append(parent)

        manifest_path = None
        for search_dir in search_dirs:
            candidate = os.path.join(search_dir, "blender_manifest.toml")
            if os.path.exists(candidate):
                manifest_path = candidate
                print(f"Addon Update Checker: Found manifest at {manifest_path}")
                break

        if not manifest_path:
            print(
                f"Addon Update Checker: Manifest not found in {current_dir} or parent directories"
            )
            return None, None

        with open(manifest_path, "rb") as f:
            manifest = tomllib.load(f)

        addon_id = manifest.get("id")
        version = manifest.get("version")

        if not addon_id or not version:
            print("Addon Update Checker: Missing 'id' or 'version' in manifest")
            return None, None

        print(f"Addon Update Checker: Found addon {addon_id} v{version}")
        return addon_id, version

    except Exception as e:
        print(f"Addon Update Checker: Error reading manifest: {e}")
        return None, None


# ============================================================================
# Network Functions
# ============================================================================


def get_install_id():
    """Read (or create) the anonymous per-machine installation ID.

    Deliberately the same file the master add-on uses, so every add-on on a
    machine reports the same ID. Generating one per add-on would multiply a
    single user into N "unique installs" — worse than reporting none at all.

    Returns None on any failure (read-only home directory, permissions). The
    report still goes out; it just does not count toward install statistics,
    which is the right failure mode: never break an update check over
    telemetry.
    """
    try:
        if sys.platform == "win32":
            base = os.environ.get("APPDATA") or os.path.expanduser("~")
        elif sys.platform == "darwin":
            base = os.path.expanduser("~/Library/Application Support")
        else:
            base = os.environ.get("XDG_DATA_HOME") or os.path.expanduser(
                "~/.local/share"
            )

        directory = os.path.join(base, INSTALL_ID_DIR_NAME)
        path = os.path.join(directory, INSTALL_ID_FILE_NAME)

        if os.path.exists(path):
            with open(path, "r", encoding="utf-8") as handle:
                value = handle.read().strip()
            if value:
                # Validated so a corrupt file is replaced rather than reported.
                return str(uuid.UUID(value))

        new_id = str(uuid.uuid4())
        os.makedirs(directory, exist_ok=True)
        # Written via a temp file so a crash mid-write cannot leave a partial
        # ID that every later session would reject and regenerate.
        tmp = path + ".tmp"
        with open(tmp, "w", encoding="utf-8") as handle:
            handle.write(new_id)
        os.replace(tmp, path)
        return new_id

    except Exception:
        return None


def compare_versions(current, latest):
    """True when `latest` is newer than `current`.

    Server-side comparison used to answer this; reading the published JSON
    means doing it here. Numeric-component comparison, matching the master
    add-on so both agree on what counts as an update.
    """
    try:
        current_parts = [int(x) for x in str(current).split(".")]
        latest_parts = [int(x) for x in str(latest).split(".")]

        max_len = max(len(current_parts), len(latest_parts))
        current_parts += [0] * (max_len - len(current_parts))
        latest_parts += [0] * (max_len - len(latest_parts))

        return latest_parts > current_parts
    except Exception as e:
        print(f"Addon Update Checker: Error comparing {current} vs {latest}: {e}")
        return False


def get_blender_version():
    """The running Blender version as "major.minor".

    Reported so a developer can see which Blender releases their users are on.
    Nothing sent this before, so that chart read "Unknown" for every install.
    Trimmed to major.minor: patch releases would only fragment it. bpy.app
    .version is a static tuple, safe to read from the reporting thread.
    """
    try:
        return "%d.%d" % bpy.app.version[:2]
    except Exception:
        return ""


def report_version(addon_id, current_version):
    """Tell the server this add-on is installed, at this version.

    This is the half of the old single call that a static file cannot do: it
    feeds install analytics and community registry discovery. Failures are
    logged and swallowed — a telemetry write must never stop the user from
    learning about an update.
    """
    try:
        # "addon" marks this as an individual add-on reporting for itself, as
        # opposed to the master add-on reporting the whole library. Without it
        # the two are indistinguishable in analytics, and their volumes mean
        # very different things: one row per add-on per user versus one batch.
        payload = {
            "addons": [{"addon_id": addon_id, "version": current_version}],
            "client": "addon",
            # Top level: the route falls back to this for every item.
            "blender_version": get_blender_version(),
        }

        install_id = get_install_id()
        if install_id:
            payload["install_id"] = install_id

        request = urllib.request.Request(
            REPORT_URL,
            data=json.dumps(payload).encode("utf-8"),
            headers={
                "Content-Type": "application/json",
                "User-Agent": USER_AGENT,
            },
            method="POST",
        )

        with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT):
            print(f"Addon Update Checker: Reported {addon_id} v{current_version}")

    except Exception as e:
        print(f"Addon Update Checker: Warning - could not report version: {e}")


def fetch_update_from_cdn(addon_id, current_version, use_community=False):
    """Look this add-on up in the published JSON and compare versions.

    Reads the same edge-cached file the master add-on reads, so the two can
    never disagree about a version the way a separate API deployment can.

    The "official" block is what the developer published. The "community" block
    is the highest version other users have been seen running, which surfaces a
    release before its registry entry is updated — but it is unverified, so it
    is only consulted when `use_community` is on, and then only if it is
    strictly newer than the official version.

    Precedence matches the master add-on, including the safety rule that a
    community version never contributes a download URL: it supplies the version
    number, while the link always stays the official one. A user-reported
    version must not be able to point anyone at a user-supplied download.

    Returns a dict in the same shape the rest of this module expects, or None
    when there is no update.
    """
    try:
        # Published under a lowercased id, matching the master add-on.
        url = ADDON_JSON_URL.format(addon_id=addon_id.lower())
        print(f"Addon Update Checker: Checking {url}")

        request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})

        with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
            data = json.loads(response.read().decode("utf-8"))

        official = data.get("official") or {}
        community = (data.get("community") or {}) if use_community else {}

        official_version = official.get("latest_version") or data.get("latest_version")
        community_version = community.get("latest_version")

        official_newer = bool(official_version) and compare_versions(
            current_version, official_version
        )
        community_newer = bool(community_version) and compare_versions(
            current_version, community_version
        )

        # Community only wins when it is ahead of official, never on a tie.
        prefer_community = community_newer and (
            not official_newer or compare_versions(official_version, community_version)
        )

        if not official_newer and not community_newer:
            known = official_version or community_version
            print(
                f"Addon Update Checker: {addon_id} v{current_version} is up to date"
                + (f" (published: v{known})" if known else "")
            )
            return None

        if prefer_community:
            latest_version = community_version
            update_type = "Discovered from community"
            # Never trusted for criticality — that is a claim only the
            # developer's own published record can make.
            is_critical = False
            addon_name = (
                community.get("addon_name") or data.get("addon_name") or addon_id
            )
        else:
            latest_version = official_version
            update_type = official.get("update_type") or ""
            is_critical = bool(official.get("is_critical"))
            addon_name = (
                official.get("addon_name") or data.get("addon_name") or addon_id
            )

        return {
            "success": True,
            "update_available": True,
            "addon_id": data.get("addon_id", addon_id),
            "addon_name": addon_name,
            "current_version": current_version,
            "latest_version": latest_version,
            # Links always come from the official record, whichever version won.
            "update_url": official.get("update_url") or "",
            "changelog_url": official.get("changelog_url") or "",
            "update_type": update_type,
            "is_critical": is_critical,
            "icon_url": official.get("icon_url") or data.get("icon_url") or "",
            "from_community": prefer_community,
        }

    except urllib.error.HTTPError as e:
        if e.code == 404:
            print(f"Addon Update Checker: {addon_id} is not registered")
        else:
            print(f"Addon Update Checker: HTTP error {e.code} checking for updates")
        return None
    except Exception as e:
        print(f"Addon Update Checker: Error checking for updates: {e}")
        return None


def check_for_update_async():
    """Resolve this add-on's update status once per session.

    Prefers the master add-on's already-batched result over a request of our
    own; only talks to the server when the master is not there to ask.
    """
    if _state.checked_this_session:
        print("Addon Update Checker: Already checked this session")
        return

    _state.checked_this_session = True

    if not _state.addon_id or not _state.current_version:
        print("Addon Update Checker: Addon info not set")
        return

    if is_leuc_active():
        # ASCII only: Blender's console on Windows is often cp1252, and a
        # non-encodable character in print() raises UnicodeEncodeError.
        print(
            "Addon Update Checker: Project LEUC is installed - deferring to its "
            "batched check instead of sending our own"
        )
        _wait_for_leuc()
        return

    # Read on the main thread: bpy.context is not safe to touch from a worker.
    prefs = get_preferences()
    use_community = bool(getattr(prefs, "auc_use_community_versions", False))

    def thread_func():
        # Report first, so the install is counted even if the version lookup
        # then fails. The two are independent by design.
        report_version(_state.addon_id, _state.current_version)

        update_info = fetch_update_from_cdn(
            _state.addon_id, _state.current_version, use_community=use_community
        )

        _state.source = "cdn"

        if update_info:
            _state.set_update_info(update_info)
            _redraw_viewports()

    thread = threading.Thread(target=thread_func, daemon=True)
    thread.start()


# ============================================================================
# Drawing Functions
# ============================================================================


def draw_rounded_box(x, y, width, height, color, radius=8, segments=8):
    """Draw a rounded rectangle."""
    vertices = []
    indices = []

    # Helper to add corner vertices
    def add_corner(cx, cy, start_angle, end_angle):
        start_idx = len(vertices)
        vertices.append((cx, cy))
        for i in range(segments + 1):
            angle = start_angle + (end_angle - start_angle) * i / segments
            vx = cx + radius * math.cos(angle)
            vy = cy + radius * math.sin(angle)
            vertices.append((vx, vy))
        for i in range(segments):
            indices.extend([start_idx, start_idx + i + 1, start_idx + i + 2])

    # Four corners
    add_corner(x + radius, y + radius, math.pi, 3 * math.pi / 2)
    add_corner(x + width - radius, y + radius, 3 * math.pi / 2, 2 * math.pi)
    add_corner(x + width - radius, y + height - radius, 0, math.pi / 2)
    add_corner(x + radius, y + height - radius, math.pi / 2, math.pi)

    # Center rectangles
    center_vertices = [
        (x + radius, y),
        (x + width - radius, y),
        (x + width - radius, y + height),
        (x + radius, y + height),
        (x, y + radius),
        (x + width, y + radius),
        (x + width, y + height - radius),
        (x, y + height - radius),
    ]

    start_idx = len(vertices)
    vertices.extend(center_vertices)

    indices.extend(
        [
            start_idx,
            start_idx + 1,
            start_idx + 2,
            start_idx,
            start_idx + 2,
            start_idx + 3,
            start_idx + 4,
            start_idx + 5,
            start_idx + 6,
            start_idx + 4,
            start_idx + 6,
            start_idx + 7,
        ]
    )

    shader = gpu.shader.from_builtin("UNIFORM_COLOR")
    batch = batch_for_shader(shader, "TRIS", {"pos": vertices}, indices=indices)
    shader.bind()
    shader.uniform_float("color", color)
    batch.draw(shader)


def draw_text(text, x, y, size, color):
    """Draw text at position."""
    font_id = 0
    blf.size(font_id, size)
    blf.position(font_id, x, y, 0)
    blf.color(font_id, *color)
    blf.draw(font_id, text)


def draw_button(x, y, width, height, text, bg_color, hover, hover_color=None, radius=6):
    """Draw a button and return its bounds."""
    if not hover_color:
        hover_color = COLOR_BUTTON_HOVER
    color = hover_color if hover and hover_color else bg_color
    draw_rounded_box(x, y, width, height, color, radius=radius)

    font_id = 0
    blf.size(font_id, FONT_SIZE_BUTTON)
    text_width, text_height = blf.dimensions(font_id, text)
    text_x = x + (width - text_width) / 2
    text_y = y + (height - text_height) / 2 + text_height / 4

    draw_text(text, text_x, text_y, FONT_SIZE_BUTTON, COLOR_BUTTON_TEXT)

    return (x, y, width, height)


def draw_dismiss_button(x, y, size, hover):
    """Draw X dismiss button."""
    color = COLOR_DISMISS_HOVER if hover else COLOR_DISMISS_BG
    draw_rounded_box(x, y, size, size, color, radius=4)

    # Draw X
    shader = gpu.shader.from_builtin("UNIFORM_COLOR")
    shader.bind()
    shader.uniform_float("color", COLOR_BUTTON_TEXT)

    center_x = x + size / 2
    center_y = y + size / 2
    offset = DISMISS_X_SIZE / 2

    vertices = (
        (center_x - offset, center_y - offset),
        (center_x + offset, center_y + offset),
        (center_x - offset, center_y + offset),
        (center_x + offset, center_y - offset),
    )

    batch = batch_for_shader(shader, "LINES", {"pos": vertices})
    batch.draw(shader)

    return (x, y, size, size)


def get_toast_position(region):
    """Calculate toast position (bottom-right corner)."""
    x = region.width - TOAST_WIDTH - TOAST_MARGIN
    y = TOAST_MARGIN
    return x, y


# ============================================================================
# Gizmo UI
# ============================================================================


class AUC_GT_UpdateNotification(Gizmo):
    """Gizmo for displaying update notification."""

    bl_idname = "AUC_GT_update_notification"

    def setup(self):
        """Initialize gizmo."""
        self.hover_button = None
        self.button_bounds = []

    def draw(self, context):
        """Draw the notification toast."""
        if not hasattr(self, "hover_button"):
            self.hover_button = None
        if not hasattr(self, "button_bounds"):
            self.button_bounds = []

        update = _state.get_update_info()
        if not update:
            return

        region = context.region
        if not region:
            return

        x, y = get_toast_position(region)

        # Enable blending
        gpu.state.blend_set("ALPHA")

        # Draw background
        draw_rounded_box(x, y, TOAST_WIDTH, TOAST_HEIGHT, COLOR_BG, radius=TOAST_RADIUS)

        # Draw dismiss button
        dismiss_x = x + TOAST_WIDTH - DISMISS_BUTTON_SIZE - TOAST_PADDING
        dismiss_y = y + TOAST_HEIGHT - DISMISS_BUTTON_SIZE - TOAST_PADDING
        hover_dismiss = self.hover_button == "dismiss"
        dismiss_bounds = draw_dismiss_button(
            dismiss_x, dismiss_y, DISMISS_BUTTON_SIZE, hover_dismiss
        )

        # Draw content
        content_x = x + TOAST_PADDING
        content_y = y + TOAST_HEIGHT - TOAST_PADDING

        # Title
        draw_text(
            "Update Available",
            content_x,
            content_y - 15,
            FONT_SIZE_TITLE,
            COLOR_TEXT_TITLE,
        )

        # Addon name
        addon_name = update.get("addon_name", update.get("addon_id", "Unknown"))
        draw_text(
            addon_name, content_x, content_y - 35, FONT_SIZE_TITLE, COLOR_TEXT_TITLE
        )

        # Version info
        current = update.get("current_version", "?")
        latest = update.get("latest_version", "?")
        version_text = f"v{current} → v{latest}"
        draw_text(
            version_text,
            content_x,
            content_y - 55,
            FONT_SIZE_VERSION,
            COLOR_TEXT_VERSION,
        )

        # Buttons
        button_y = y + TOAST_PADDING
        button_x = content_x

        self.button_bounds = [("dismiss", dismiss_bounds)]

        # Download button
        if update.get("update_url"):
            hover = self.hover_button == "download"
            bounds = draw_button(
                button_x,
                button_y,
                BUTTON_WIDTH,
                BUTTON_HEIGHT,
                "Download",
                COLOR_BUTTON_BG,
                hover,
                radius=BUTTON_RADIUS,
            )
            self.button_bounds.append(("download", bounds))
            button_x += BUTTON_WIDTH + BUTTON_PADDING

        # Changelog button
        if update.get("changelog_url"):
            hover = self.hover_button == "changelog"
            bounds = draw_button(
                button_x,
                button_y,
                BUTTON_WIDTH,
                BUTTON_HEIGHT,
                "Changelog",
                COLOR_BUTTON_BG_DARK,
                hover,
                hover_color=COLOR_BUTTON_DARK_HOVER,
                radius=BUTTON_RADIUS,
            )
            self.button_bounds.append(("changelog", bounds))

        gpu.state.blend_set("NONE")

    def test_select(self, context, location):
        """Test if mouse is over notification."""
        update = _state.get_update_info()
        if not update:
            return -1

        region = context.region
        if not region:
            return -1

        mx = int(location[0])
        my = int(location[1])
        x, y = get_toast_position(region)

        if x <= mx <= x + TOAST_WIDTH and y <= my <= y + TOAST_HEIGHT:
            prev_hover = self.hover_button if hasattr(self, "hover_button") else None
            self.hover_button = None

            if hasattr(self, "button_bounds"):
                for button_id, (bx, by, bwidth, bheight) in self.button_bounds:
                    if bx <= mx <= bx + bwidth and by <= my <= by + bheight:
                        self.hover_button = button_id
                        break

            if prev_hover != self.hover_button:
                context.area.tag_redraw()

            return 0

        return -1

    def modal(self, context, event, tweak):
        """Handle mouse movement."""
        if event.type == "MOUSEMOVE":
            if hasattr(self, "button_bounds"):
                mx = event.mouse_region_x
                my = event.mouse_region_y

                prev_hover = (
                    self.hover_button if hasattr(self, "hover_button") else None
                )
                self.hover_button = None

                for button_id, (bx, by, bwidth, bheight) in self.button_bounds:
                    if bx <= mx <= bx + bwidth and by <= my <= by + bheight:
                        self.hover_button = button_id
                        break

                if prev_hover != self.hover_button:
                    context.area.tag_redraw()

        return {"RUNNING_MODAL"}

    def invoke(self, context, event):
        """Handle button clicks."""
        update = _state.get_update_info()
        if not update:
            return {"CANCELLED"}

        mx = event.mouse_region_x
        my = event.mouse_region_y

        clicked_button = None
        if hasattr(self, "button_bounds"):
            for button_id, (bx, by, bwidth, bheight) in self.button_bounds:
                if bx <= mx <= bx + bwidth and by <= my <= by + bheight:
                    clicked_button = button_id
                    break

        if clicked_button:
            self._handle_button_click(clicked_button, update, context)
            return {"FINISHED"}

        return {"RUNNING_MODAL"}

    def _handle_button_click(self, button_id, update, context):
        """Handle button click actions."""
        if button_id == "dismiss":
            _state.clear_update_info()
            print("Addon Update Checker: Dismissed notification")

        elif button_id == "download":
            url = update.get("update_url")
            if url:
                webbrowser.open(url)
                print(f"Addon Update Checker: Opening download URL: {url}")

        elif button_id == "changelog":
            url = update.get("changelog_url")
            if url:
                webbrowser.open(url)
                print(f"Addon Update Checker: Opening changelog URL: {url}")

        context.area.tag_redraw()


class AUC_GGT_UpdateNotificationGroup(GizmoGroup):
    """Gizmo group for update notifications."""

    bl_idname = "AUC_GGT_update_notification_group"
    bl_label = "Addon Update Notification"
    bl_space_type = "VIEW_3D"
    bl_region_type = "WINDOW"
    bl_options = {"PERSISTENT", "SHOW_MODAL_ALL"}

    @classmethod
    def poll(cls, context):
        """Only show when an update is available and this module owns the UI."""
        if not should_draw_ui():
            return False

        prefs = get_preferences()
        use_gizmos = (
            prefs.auc_use_gizmo_notifications
            if prefs and hasattr(prefs, "auc_use_gizmo_notifications")
            else False
        )
        return (
            use_gizmos
            and bool(_state.get_update_info())
            and context.area.type == "VIEW_3D"
        )

    def setup(self, context):
        """Setup gizmo group."""
        gz = self.gizmos.new(AUC_GT_UpdateNotification.bl_idname)
        gz.use_draw_modal = True
        gz.use_draw_value = False
        gz.use_event_handle_all = True
        gz.select_bias = 150.0
        self.notification_gizmo = gz


# ============================================================================
# Handlers
# ============================================================================


@persistent
def on_load_post(dummy):
    """Check for updates on file load (once per session)."""
    check_for_update_async()


# ============================================================================
# UI Drawing Functions for Preferences and Panels
# ============================================================================


# ============================================================================
# Bug Reporting
# ============================================================================
#
# Duplicated from the master add-on's bugreport.py rather than imported,
# because this file is dropped whole into someone else's add-on and has no
# siblings to import from. The redaction rules are the same ones the server
# applies on arrival (convex/scrub.js); if these drift, the preview shown to
# the reporter stops matching what actually gets stored, which is the only
# thing that preview is for.
#
# Reports here are always about THIS add-on -- its id comes from the manifest
# this module already read -- so unlike the master's form there is nothing to
# type and nothing to get wrong.

_BUG_HOME_PATTERNS = [
    re.compile(r"([A-Za-z]:[\\/]+Users[\\/]+)([^\\/\s\"']+)"),
    re.compile(r"(/home/)([^/\s\"']+)"),
    re.compile(r"(/Users/)([^/\s\"']+)"),
]
# "<user>" is in here so re-scrubbing reports nothing removed instead of
# counting its own placeholder as a fresh redaction.
_BUG_SYSTEM_ACCOUNTS = re.compile(
    r"^(Public|Default|Default User|All Users|<user>)$", re.I
)
_BUG_EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
_BUG_IPV4 = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
_BUG_TOKEN = re.compile(r"\b(?:leuc_pat_|sk-|ghp_|gho_|github_pat_)[A-Za-z0-9_-]{8,}")
_BUG_UNC = re.compile(r"(\\\\)([A-Za-z0-9-]+)(\\)")

_BUG_MAX_MESSAGE = 20000

# The composing report. Module-level rather than a preferences property: a
# traceback is multi-line and a StringProperty would flatten it.
_bug_draft = {"text": "", "scrubbed": False, "redactions": {}}


def scrub_error_text(text):
    """Redact personal details. Returns (text, {category: count})."""
    if not isinstance(text, str) or not text:
        return "", {}

    counts = {}

    def bump(kind, n):
        if n:
            counts[kind] = counts.get(kind, 0) + n

    out = text

    for pattern in _BUG_HOME_PATTERNS:
        hits = [0]

        def replace_user(match):
            if _BUG_SYSTEM_ACCOUNTS.match(match.group(2)):
                return match.group(0)
            hits[0] += 1
            return match.group(1) + "<user>"

        out = pattern.sub(replace_user, out)
        bump("username", hits[0])

    hits = [0]

    def replace_host(match):
        if match.group(2) == "<host>":
            return match.group(0)
        hits[0] += 1
        return match.group(1) + "<host>" + match.group(3)

    out = _BUG_UNC.sub(replace_host, out)
    bump("hostname", hits[0])

    out, n = _BUG_EMAIL.subn("<email>", out)
    bump("email", n)

    out, n = _BUG_TOKEN.subn("<token>", out)
    bump("token", n)

    hits = [0]

    def replace_ip(match):
        if match.group(0) in ("127.0.0.1", "0.0.0.0"):
            return match.group(0)
        hits[0] += 1
        return "<ip>"

    out = _BUG_IPV4.sub(replace_ip, out)
    bump("ip", hits[0])

    return out, counts


def describe_redactions(counts):
    if not counts:
        return ""
    return ", ".join(
        "%d %s%s" % (n, kind, "s" if n > 1 else "")
        for kind, n in sorted(counts.items())
    )


def _bug_storage_path():
    """Tickets live beside the shared install ID.

    Not in the parent add-on's preferences: someone who resets their
    preferences should not lose the only handle they have on an open report.
    """
    try:
        if sys.platform == "win32":
            base = os.environ.get("APPDATA") or os.path.expanduser("~")
        elif sys.platform == "darwin":
            base = os.path.expanduser("~/Library/Application Support")
        else:
            base = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share")
        directory = os.path.join(base, INSTALL_ID_DIR_NAME)
        os.makedirs(directory, exist_ok=True)
        return os.path.join(directory, "bug_reports.json")
    except Exception:
        return None


def load_bug_tickets():
    """Only this add-on's tickets. The file is shared with every other add-on
    running this module, so it is keyed by add-on id."""
    path = _bug_storage_path()
    if not path or not os.path.exists(path):
        return []
    try:
        with open(path, "r", encoding="utf-8") as handle:
            data = json.load(handle)
        return data.get(_state.addon_id or "", [])
    except Exception:
        return []


def _save_bug_tickets(tickets):
    path = _bug_storage_path()
    if not path:
        return
    try:
        data = {}
        if os.path.exists(path):
            with open(path, "r", encoding="utf-8") as handle:
                data = json.load(handle)
        data[_state.addon_id or ""] = tickets[:25]
        tmp = path + ".tmp"
        with open(tmp, "w", encoding="utf-8") as handle:
            json.dump(data, handle, indent=2)
        os.replace(tmp, path)
    except Exception as exc:
        print("Addon Update Checker: could not save ticket: %s" % exc)


def _bug_error_text(exc):
    """Prefer the server's own sentence over "HTTP Error 404"."""
    if isinstance(exc, urllib.error.HTTPError):
        try:
            body = json.loads(exc.read().decode("utf-8"))
            if body.get("error"):
                return body["error"]
        except Exception:
            pass
        if exc.code == 429:
            return "Too many reports from this network. Try again later."
        return "Server returned %d." % exc.code
    if isinstance(exc, urllib.error.URLError):
        return "Could not reach the server. Check your connection."
    return str(exc)


def submit_bug_async(message, callback, name="", email="", discord_tag=""):
    payload = {
        "addon_id": _state.addon_id,
        "message": message[:_BUG_MAX_MESSAGE],
        "blender_version": get_blender_version(),
    }
    if _state.current_version:
        payload["addon_version"] = _state.current_version
    if name:
        payload["name"] = name
    if email:
        payload["email"] = email
    if discord_tag:
        payload["discord_tag"] = discord_tag

    def worker():
        try:
            request = urllib.request.Request(
                BUG_REPORT_URL,
                data=json.dumps(payload).encode("utf-8"),
                headers={"Content-Type": "application/json", "User-Agent": USER_AGENT},
                method="POST",
            )
            with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
                result = json.loads(response.read().decode("utf-8"))
            if result.get("success"):
                callback(True, result)
            else:
                callback(False, result.get("error", "Could not file that report."))
        except Exception as exc:  # noqa: BLE001 - surfaced to the user verbatim
            callback(False, _bug_error_text(exc))

    threading.Thread(target=worker, daemon=True).start()


def refresh_bug_statuses_async(callback):
    tickets = load_bug_tickets()

    def worker():
        updated = 0
        for entry in tickets:
            ticket = entry.get("ticket")
            if not ticket:
                continue
            try:
                request = urllib.request.Request(
                    "%s?ticket=%s" % (BUG_STATUS_URL, ticket),
                    headers={"User-Agent": USER_AGENT},
                )
                with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
                    result = json.loads(response.read().decode("utf-8"))
            except urllib.error.HTTPError as exc:
                if exc.code == 404:
                    # Deleted by the developer. Say so rather than showing a
                    # status that will never change again.
                    entry["status"] = "deleted"
                    updated += 1
                continue
            except Exception:
                continue

            if not result.get("success"):
                continue

            if entry.get("status") != result.get("status") or entry.get(
                "developer_note"
            ) != (result.get("developer_note") or ""):
                updated += 1
            entry["status"] = result.get("status", entry.get("status"))
            entry["developer_note"] = result.get("developer_note") or ""
            entry["merged"] = bool(result.get("merged"))

        _save_bug_tickets(tickets)
        callback(updated)

    threading.Thread(target=worker, daemon=True).start()


def _wrap_text(text, width):
    """Blender labels do not wrap, so a developer's note is split before it is
    drawn. Over-long words are left whole -- a clipped URL beats a broken one."""
    lines = []
    for paragraph in (text or "").split("\n"):
        current = ""
        for word in paragraph.split():
            if current and len(current) + 1 + len(word) > width:
                lines.append(current)
                current = word
            else:
                current = (current + " " + word) if current else word
        lines.append(current)
    return [line for line in lines if line] or [""]


def draw_bug_report_section(layout, context=None):
    """Compose a report about this add-on, and follow the ones already sent.

    Called from draw_update_section_for_prefs, so an add-on that already
    displays that section gets this for free.
    """
    prefs = get_preferences()
    if not prefs or not hasattr(prefs, "auc_show_bug_report"):
        return
    if not _state.addon_id or not _bug_idnames:
        return

    box = layout.box()
    row = box.row()
    row.prop(
        prefs,
        "auc_show_bug_report",
        text="",
        icon="TRIA_DOWN" if prefs.auc_show_bug_report else "TRIA_RIGHT",
        emboss=False,
    )
    row.label(text="Report a Bug", icon="ERROR")

    tickets = load_bug_tickets()
    open_states = ("new", "acknowledged", "needs_info")
    open_count = len([t for t in tickets if t.get("status") in open_states])
    if open_count and not prefs.auc_show_bug_report:
        row.label(text="%d open" % open_count)

    if not prefs.auc_show_bug_report:
        return

    compose = box.box()
    compose.label(text="Report an error in %s" % (_state.addon_id or "this add-on"), icon="TEXT")

    row = compose.row(align=True)
    row.operator(_bug_op("bug_paste"), text="Paste Error from Clipboard", icon="PASTEDOWN")
    if _bug_draft["text"]:
        row.operator(_bug_op("bug_clear"), text="", icon="X")

    if _bug_draft["text"]:
        preview = compose.box()
        lines = _bug_draft["text"].split("\n")
        for line in lines[:6]:
            preview.label(text=line[:110] if line.strip() else " ")
        if len(lines) > 6:
            preview.label(text="... %d more lines" % (len(lines) - 6))

        col = compose.column(align=True)
        if _bug_draft["scrubbed"]:
            removed = describe_redactions(_bug_draft["redactions"])
            col.label(
                text=("Redacted: %s" % removed) if removed else "Nothing personal found",
                icon="CHECKMARK",
            )
        else:
            col.label(
                text="Not redacted yet - your username is likely in these paths.",
                icon="ERROR",
            )

        row = compose.row(align=True)
        row.scale_y = 1.2
        row.operator(_bug_op("bug_scrub"), text="Remove Personal Info", icon="FAKE_USER_OFF")

    col = compose.column(align=True)
    col.label(text="Optional - only if you want a direct reply:")
    col.prop(prefs, "auc_bug_name")
    col.prop(prefs, "auc_bug_email")
    col.prop(prefs, "auc_bug_discord")

    row = compose.row(align=True)
    row.scale_y = 1.4
    row.enabled = bool(_bug_draft["text"].strip())
    row.operator(_bug_op("bug_send"), text="Send Report", icon="EXPORT")

    col = compose.column(align=True)
    col.label(text="Sent: your error text, this add-on's version,")
    col.label(text="and your Blender version. Nothing else.")

    if not tickets:
        return

    sent = box.box()
    row = sent.row()
    row.label(text="Your reports (%d)" % len(tickets), icon="PRESET")
    row.operator(_bug_op("bug_refresh"), text="", icon="FILE_REFRESH")

    labels = {
        "new": ("Sent", "EXPORT"),
        "acknowledged": ("Acknowledged", "CHECKMARK"),
        "needs_info": ("Developer needs more info", "QUESTION"),
        "resolved": ("Resolved", "CHECKMARK"),
        "rejected": ("Closed", "CANCEL"),
        "deleted": ("Deleted by the developer", "TRASH"),
    }

    for entry in tickets:
        label, icon = labels.get(entry.get("status", "new"), (entry.get("status", "?"), "DOT"))
        entry_box = sent.box()
        row = entry_box.row()
        row.label(text=entry.get("ticket", ""), icon=icon)
        row.label(text=label)
        op = row.operator(_bug_op("bug_forget"), text="", icon="X")
        op.ticket = entry.get("ticket", "")

        if entry.get("merged"):
            # Otherwise the note reads as a reply to someone else, because it
            # is one -- the developer answered whoever reported it first.
            entry_box.label(
                text="Merged with an earlier report of the same bug.", icon="COMMUNITY"
            )

        note = entry.get("developer_note")
        if note:
            note_box = entry_box.box()
            note_box.label(text="From the developer:", icon="INFO")
            for line in _wrap_text(note, 58):
                note_box.label(text=line)


class AUC_OT_BugPaste(bpy.types.Operator):
    """Paste an error message from the clipboard"""

    bl_idname = "auc.bug_paste"
    bl_label = "Paste Error from Clipboard"
    bl_description = (
        "Read the error text from your clipboard. Copy the traceback from "
        "Blender's console or the Info editor first"
    )
    bl_options = {"REGISTER", "INTERNAL"}

    def execute(self, context):
        # window_manager.clipboard rather than a StringProperty: a property
        # flattens a pasted traceback onto one line, and the line structure is
        # most of what makes a traceback readable.
        text = context.window_manager.clipboard or ""
        if not text.strip():
            self.report({"WARNING"}, "Clipboard is empty")
            return {"CANCELLED"}
        _bug_draft["text"] = text[:_BUG_MAX_MESSAGE]
        _bug_draft["scrubbed"] = False
        _bug_draft["redactions"] = {}
        self.report({"INFO"}, "Pasted %d characters" % len(_bug_draft["text"]))
        return {"FINISHED"}


class AUC_OT_BugScrub(bpy.types.Operator):
    """Remove personal information from the pasted error"""

    bl_idname = "auc.bug_scrub"
    bl_label = "Remove Personal Info"
    bl_description = (
        "Replace your Windows or macOS account name, email addresses, IP "
        "addresses and any API keys in the pasted text with placeholders"
    )
    bl_options = {"REGISTER", "INTERNAL"}

    def execute(self, context):
        text, counts = scrub_error_text(_bug_draft["text"])
        _bug_draft["text"] = text
        _bug_draft["scrubbed"] = True
        _bug_draft["redactions"] = counts
        removed = describe_redactions(counts)
        self.report({"INFO"}, "Removed %s" % removed if removed else "Nothing personal found")
        return {"FINISHED"}


class AUC_OT_BugClear(bpy.types.Operator):
    """Discard the pasted error"""

    bl_idname = "auc.bug_clear"
    bl_label = "Clear"
    bl_options = {"REGISTER", "INTERNAL"}

    def execute(self, context):
        _bug_draft["text"] = ""
        _bug_draft["scrubbed"] = False
        _bug_draft["redactions"] = {}
        return {"FINISHED"}


class AUC_OT_BugSend(bpy.types.Operator):
    """Send the report to this add-on's developer"""

    bl_idname = "auc.bug_send"
    bl_label = "Send Report"
    bl_description = (
        "Send this error to the developer. You get a ticket back so you can "
        "see their reply here, without giving them any way to identify you"
    )
    bl_options = {"REGISTER", "INTERNAL"}

    def invoke(self, context, event):
        if not _bug_draft["scrubbed"]:
            # One confirmation, only in the case that warrants it. The server
            # redacts anyway, but the reporter deserves the chance to look at
            # their own paths before they leave the machine.
            return context.window_manager.invoke_confirm(self, event)
        return self.execute(context)

    def execute(self, context):
        prefs = get_preferences()
        if not _bug_draft["text"].strip():
            self.report({"ERROR"}, "Paste an error message first")
            return {"CANCELLED"}

        def on_result(ok, payload):
            # Runs on the worker thread; anything touching Blender data hops
            # back to the main thread through a one-shot timer.
            def apply():
                if ok:
                    tickets = load_bug_tickets()
                    tickets.insert(
                        0,
                        {
                            "ticket": payload.get("ticket", ""),
                            "status": "new",
                            "developer_note": "",
                            "merged": False,
                        },
                    )
                    _save_bug_tickets(tickets)
                    _bug_draft["text"] = ""
                    _bug_draft["scrubbed"] = False
                    _bug_draft["redactions"] = {}
                    print("Addon Update Checker: report filed, ticket %s" % payload.get("ticket"))
                else:
                    print("Addon Update Checker: report failed: %s" % payload)
                _redraw_preferences()
                return None

            bpy.app.timers.register(apply, first_interval=0.0)

        submit_bug_async(
            _bug_draft["text"],
            on_result,
            name=getattr(prefs, "auc_bug_name", "").strip() if prefs else "",
            email=getattr(prefs, "auc_bug_email", "").strip() if prefs else "",
            discord_tag=getattr(prefs, "auc_bug_discord", "").strip() if prefs else "",
        )
        self.report({"INFO"}, "Sending report...")
        return {"FINISHED"}


class AUC_OT_BugRefresh(bpy.types.Operator):
    """Check whether the developer has replied"""

    bl_idname = "auc.bug_refresh"
    bl_label = "Refresh Report Status"
    bl_options = {"REGISTER", "INTERNAL"}

    def execute(self, context):
        def on_done(updated):
            def apply():
                if updated:
                    print("Addon Update Checker: %d report(s) updated" % updated)
                _redraw_preferences()
                return None

            bpy.app.timers.register(apply, first_interval=0.0)

        refresh_bug_statuses_async(on_done)
        self.report({"INFO"}, "Checking for replies...")
        return {"FINISHED"}


class AUC_OT_BugForget(bpy.types.Operator):
    """Stop tracking this report"""

    bl_idname = "auc.bug_forget"
    bl_label = "Forget Report"
    bl_description = (
        "Remove this ticket from your list. The report itself stays with the "
        "developer -- this only means you stop seeing their replies"
    )
    bl_options = {"REGISTER", "INTERNAL"}

    ticket: bpy.props.StringProperty()

    def execute(self, context):
        _save_bug_tickets(
            [t for t in load_bug_tickets() if t.get("ticket") != self.ticket]
        )
        return {"FINISHED"}


def _redraw_preferences():
    for window in bpy.context.window_manager.windows:
        for area in window.screen.areas:
            if area.type == "PREFERENCES":
                area.tag_redraw()


# Templates. Never registered directly -- see create_unique_bug_classes.
_BUG_OPERATOR_TEMPLATES = (
    ("BugPaste", "bug_paste", AUC_OT_BugPaste),
    ("BugScrub", "bug_scrub", AUC_OT_BugScrub),
    ("BugClear", "bug_clear", AUC_OT_BugClear),
    ("BugSend", "bug_send", AUC_OT_BugSend),
    ("BugRefresh", "bug_refresh", AUC_OT_BugRefresh),
    ("BugForget", "bug_forget", AUC_OT_BugForget),
)

# Filled in by create_unique_bug_classes, read by the drawing code so the
# buttons call THIS add-on's copy of each operator.
_bug_idnames = {}


def create_unique_bug_classes(addon_name):
    """Per-add-on copies of the bug report operators.

    Two add-ons bundling this file would otherwise both register
    "auc.bug_paste". Blender allows the second registration and lets it
    replace the first, so disabling either add-on leaves the other with dead
    buttons -- and worse, the surviving operator would drive whichever module
    instance registered last, editing the wrong add-on's draft.

    Same fix as create_unique_gizmo_classes, and the same suffix, so the two
    sets of generated names stay recognisably from the same add-on.
    """
    safe_name = _unique_suffix(addon_name)
    created = []
    _bug_idnames.clear()

    for suffix, verb, template in _BUG_OPERATOR_TEMPLATES:
        class_name = f"AUC_OT_{suffix}_{safe_name}"
        idname = f"auc.{verb}_{safe_name.lower()}"

        # Blender raises on registration if this is too long, which would abort
        # the parent add-on's register() entirely. Fail here, naming the
        # identifier, rather than inside bpy.utils.register_class.
        if len(class_name) > MAX_IDNAME_LENGTH:
            raise ValueError(
                f"Addon Update Checker: generated identifier '{class_name}' is "
                f"{len(class_name)} characters, over Blender's "
                f"{MAX_IDNAME_LENGTH} limit"
            )

        attrs = {
            "bl_idname": idname,
            "bl_label": template.bl_label,
            "bl_options": template.bl_options,
            "execute": template.execute,
            "__doc__": template.__doc__,
            "__module__": __name__,
        }
        if hasattr(template, "bl_description"):
            attrs["bl_description"] = template.bl_description
        if hasattr(template, "invoke"):
            attrs["invoke"] = template.invoke
        if template is AUC_OT_BugForget:
            attrs["__annotations__"] = {"ticket": bpy.props.StringProperty()}

        created.append(type(class_name, (bpy.types.Operator,), attrs))
        _bug_idnames[verb] = idname

    return created


def _bug_op(verb):
    """The idname for one of this add-on's bug operators, or None before
    register() has run."""
    return _bug_idnames.get(verb)


def draw_update_section_for_prefs(layout, context=None):
    """Draw update information in addon preferences."""
    update = _state.get_update_info()
    prefs = get_preferences()

    box = layout.box()
    row = box.row()
    row.label(text="Update Information:", icon="INFO")

    leuc_active = is_leuc_active()

    # When the master add-on is present it owns both the request and the
    # notification. Say so, rather than showing controls that do nothing.
    if leuc_active:
        leuc_box = box.box()
        col = leuc_box.column()
        col.label(text="Project LEUC is installed", icon="CHECKMARK")
        col.label(text="Updates are checked once for all add-ons, not per add-on.")

        if prefs and hasattr(prefs, "auc_show_ui_with_leuc"):
            col.prop(
                prefs,
                "auc_show_ui_with_leuc",
                text="Show this add-on's own notifications anyway",
            )
            if prefs.auc_show_ui_with_leuc:
                col.label(
                    text="Updates will be announced twice: here and by Project LEUC.",
                    icon="ERROR",
                )

    # Only meaningful when this module does its own checking; with the master
    # installed, its own community preference is the one in effect.
    if not leuc_active and prefs and hasattr(prefs, "auc_use_community_versions"):
        community_box = box.box()
        col = community_box.column()
        col.prop(prefs, "auc_use_community_versions")
        col.label(
            text="Versions reported by other users, shown before the official listing.",
        )
        if prefs.auc_use_community_versions:
            col.label(text="Unverified — download links stay official.", icon="COMMUNITY")

    # Only meaningful while this module is the one drawing notifications.
    if should_draw_ui() and prefs and hasattr(prefs, "auc_use_gizmo_notifications"):
        warning_box = box.box()
        warning_box.alert = True
        col = warning_box.column()
        col.prop(
            prefs,
            "auc_use_gizmo_notifications",
            text="Enable Viewport Notifications (Not Recommended)",
        )
        if prefs.auc_use_gizmo_notifications:
            col.label(text="⚠ This may clutter your viewport!", icon="ERROR")
            col.label(text="Using panel notifications is recommended instead.")

    box.separator()

    if update and update.get("update_available"):
        # Update available
        update_box = box.box()
        update_box.label(
            text=f"Update Available: v{update.get('latest_version', '?')}",
            icon="IMPORT",
        )

        current = update.get("current_version", "?")
        latest = update.get("latest_version", "?")
        update_box.label(text=f"Current: v{current} → Latest: v{latest}")

        # Addon name
        if update.get("addon_name"):
            update_box.label(text=f"Addon: {update.get('addon_name')}")

        # Where the version number came from. A community-reported version is
        # a claim by other users, not by the developer, and saying so is the
        # difference between informing and misleading.
        if update.get("from_community"):
            update_box.label(text="Discovered from community", icon="COMMUNITY")

        # Buttons
        row = update_box.row(align=True)
        if update.get("update_url"):
            op = row.operator("wm.url_open", text="Download", icon="EXPORT")
            op.url = update["update_url"]

        if update.get("changelog_url"):
            op = row.operator("wm.url_open", text="View Changelog", icon="TEXT")
            op.url = update["changelog_url"]
    else:
        # Up to date
        box.label(text="Addon is up to date!", icon="CHECKMARK")
        if _state.current_version:
            box.label(text=f"Version: {_state.current_version}")

    # Outside the update box: reporting a bug has nothing to do with whether
    # an update is available, and burying it inside "Update Information" is
    # where nobody would look for it.
    layout.separator()
    draw_bug_report_section(layout, context)


def draw_update_section_for_panel(layout, context=None):
    """Draw update information in addon panels (only if update available)."""
    # Project LEUC already puts this same update in front of the user; drawing
    # it here too would announce it twice in one screen.
    if not should_draw_ui():
        return

    update = _state.get_update_info()

    if not update or not update.get("update_available"):
        return  # Don't show anything if no update

    box = layout.box()
    box.alert = True

    # Header
    row = box.row()
    row.label(text=f"Update Available!", icon="IMPORT")

    # Version info
    current = update.get("current_version", "?")
    latest = update.get("latest_version", "?")
    box.label(text=f"v{current} → v{latest}")

    if update.get("from_community"):
        box.label(text="Discovered from community", icon="COMMUNITY")

    # Buttons
    row = box.row(align=True)
    if update.get("update_url"):
        op = row.operator("wm.url_open", text="Download", icon="EXPORT")
        op.url = update["update_url"]

    if update.get("changelog_url"):
        op = row.operator("wm.url_open", text="Changelog", icon="TEXT")
        op.url = update["changelog_url"]


# ============================================================================
# Preferences Properties Class
# ============================================================================


class AddonUpdateCheckerProperties:
    """Mixin class for addon preferences to add update checker properties."""

    auc_use_gizmo_notifications: bpy.props.BoolProperty(
        name="Use Gizmo Notifications",
        description="Show update notifications as viewport overlays (not recommended - may clutter viewport)",
        default=False,
    )

    auc_use_community_versions: bpy.props.BoolProperty(
        name="Use Community Versions",
        description=(
            "Also consider versions reported by other users, which can surface "
            "a release before the official registry entry is updated. These are "
            "unverified, so this is off by default. Download links always come "
            "from the official record"
        ),
        default=False,
    )

    auc_show_bug_report: bpy.props.BoolProperty(
        name="Report a Bug",
        description="Send an error report to this add-on's developer",
        default=False,
    )

    # All optional. The ticket is what lets someone follow their report, so
    # none of this is needed to file one -- it is only worth filling in if they
    # want the developer to be able to reach them directly.
    auc_bug_name: bpy.props.StringProperty(
        name="Name",
        description="Optional. What the developer should call you",
        default="",
    )

    auc_bug_email: bpy.props.StringProperty(
        name="Email",
        description="Optional. Only if you want a direct reply",
        default="",
    )

    auc_bug_discord: bpy.props.StringProperty(
        name="Discord",
        description="Optional. Your Discord tag, if the developer runs a server",
        default="",
    )

    auc_show_ui_with_leuc: bpy.props.BoolProperty(
        name="Show Notifications Alongside Project LEUC",
        description=(
            "Draw this add-on's own update notifications even when the Project "
            "LEUC master add-on is installed. Off by default, because Project "
            "LEUC already announces the same update"
        ),
        default=False,
    )


# ============================================================================
# Registration
# ============================================================================

_classes = (
    AUC_GT_UpdateNotification,
    AUC_GGT_UpdateNotificationGroup,
)

_registered_classes = []  # Store dynamically created classes


def _unique_suffix(addon_name):
    """Build a short, stable, unique token for generated class identifiers.

    Blender rejects registered class identifiers longer than 64 characters, and
    an extension's package name is always "bl_ext.<repo>.<id>". Pasted whole
    onto a 32-character prefix that overflows for most real add-ons
    ("bl_ext.vscode_development.CleanPanels" alone is 37), so the add-on id
    carries the readability and a CRC of the full package carries the
    uniqueness — two add-ons sharing an id across different repositories still
    get different classes.

    CRC32 rather than a hash from hashlib: it is stable across sessions (unlike
    hash()), and unavailable-under-FIPS concerns do not apply.
    """
    tail = addon_name.rsplit(".", 1)[-1]
    safe = "".join(c if c.isalnum() else "_" for c in tail)[:20]
    digest = format(zlib.crc32(addon_name.encode("utf-8")) & 0xFFFFFFFF, "08x")
    return f"{safe}_{digest}"


def create_unique_gizmo_classes(addon_name):
    """Create unique gizmo classes for this addon to avoid naming conflicts.

    Multiple addons can use this module simultaneously without conflicts.
    """
    # Create unique identifiers based on addon name
    safe_name = _unique_suffix(addon_name)

    # Create unique Gizmo class
    class_name = f"AUC_GT_UpdateNotification_{safe_name}"
    gizmo_idname = f"AUC_GT_update_notification_{safe_name}"

    # Blender raises on registration if any of these exceed the limit, which
    # aborts the parent add-on's register() entirely. Fail here, with the name
    # that is too long, rather than inside bpy.utils.register_class.
    for identifier in (
        class_name,
        gizmo_idname,
        f"AUC_GGT_UpdateNotificationGroup_{safe_name}",
        f"AUC_GGT_update_notification_group_{safe_name}",
    ):
        if len(identifier) > MAX_IDNAME_LENGTH:
            raise ValueError(
                f"Addon Update Checker: generated identifier '{identifier}' is "
                f"{len(identifier)} characters, over Blender's "
                f"{MAX_IDNAME_LENGTH} limit"
            )

    UniqueGizmo = type(
        class_name,
        (Gizmo,),
        {
            "bl_idname": gizmo_idname,
            "setup": AUC_GT_UpdateNotification.setup,
            "draw": AUC_GT_UpdateNotification.draw,
            "test_select": AUC_GT_UpdateNotification.test_select,
            "modal": AUC_GT_UpdateNotification.modal,
            "invoke": AUC_GT_UpdateNotification.invoke,
            "_handle_button_click": AUC_GT_UpdateNotification._handle_button_click,
            "__module__": __name__,
        },
    )

    # Create unique GizmoGroup class
    group_class_name = f"AUC_GGT_UpdateNotificationGroup_{safe_name}"
    group_idname = f"AUC_GGT_update_notification_group_{safe_name}"

    def setup_method(self, context):
        gz = self.gizmos.new(gizmo_idname)
        gz.use_draw_modal = True
        gz.use_draw_value = False
        gz.use_event_handle_all = True
        gz.select_bias = 150.0
        self.notification_gizmo = gz

    UniqueGizmoGroup = type(
        group_class_name,
        (GizmoGroup,),
        {
            "bl_idname": group_idname,
            "bl_label": "Addon Update Notification",
            "bl_space_type": "VIEW_3D",
            "bl_region_type": "WINDOW",
            "bl_options": {"PERSISTENT", "SHOW_MODAL_ALL"},
            "poll": classmethod(AUC_GGT_UpdateNotificationGroup.poll.__func__),
            "setup": setup_method,
            "__module__": __name__,
        },
    )

    return [UniqueGizmo, UniqueGizmoGroup]


def register(parent_addon_name=None, addon_id=None):
    """Register the update checker.

    Args:
        parent_addon_name: Name of the parent addon (for preferences access).
                          If None, will be auto-detected from __package__.
        addon_id: Optional addon ID to override the one from blender_manifest.toml.
                 Useful if you want to manually specify the ID for API calls.

    There is no endpoint override: version data now comes from one published
    CDN origin rather than a per-deployment query, which is what made an
    override necessary in the first place. Self-hosters edit ADDON_JSON_URL
    and REPORT_URL at the top of this file.
    """
    global _registered_classes

    # Store parent addon name
    if not parent_addon_name:
        # Try to auto-detect from __package__
        parent_addon_name = (
            __package__.rsplit(".", 1)[0] if "." in __package__ else __package__
        )

    _state.set_parent_addon(parent_addon_name)

    # Read addon info from manifest
    manifest_id, version = read_addon_manifest()

    # Use override ID if provided, otherwise use manifest ID
    final_addon_id = addon_id if addon_id else manifest_id

    if not final_addon_id or not version:
        print(
            "Addon Update Checker: Could not determine addon info, update checking disabled"
        )
        return

    _state.set_addon_info(final_addon_id, version)

    # Create unique gizmo classes for this addon
    _registered_classes = create_unique_gizmo_classes(parent_addon_name)

    # Bug report operators get the same per-add-on treatment, for the same
    # reason: two add-ons bundling this file must not fight over one idname.
    _registered_classes += create_unique_bug_classes(parent_addon_name)

    # Register unique classes (gizmos only show if enabled in preferences)
    for cls in _registered_classes:
        bpy.utils.register_class(cls)

    # Register handler
    bpy.app.handlers.load_post.append(on_load_post)

    print(f"Addon Update Checker: Registered for {final_addon_id} v{version}")
    if addon_id and addon_id != manifest_id:
        print(
            f"Addon Update Checker: Using override ID '{addon_id}' (manifest has '{manifest_id}')"
        )
    leuc_package = _find_leuc_package()
    if leuc_package:
        print(
            f"Addon Update Checker: Project LEUC found ({leuc_package}) - will read "
            f"its result instead of sending a request, and stay quiet unless the "
            f"'show notifications anyway' preference is on"
        )
    else:
        print(
            f"Addon Update Checker: Gizmo notifications are {'enabled' if get_preferences() and getattr(get_preferences(), 'auc_use_gizmo_notifications', False) else 'disabled'} (controlled in preferences)"
        )


def unregister():
    """Unregister the update checker."""
    global _registered_classes, _leuc_package_cache, _leuc_poll_fn

    # The add-on may be disabled while still waiting on Project LEUC. Drop the
    # timer, and clear the reference the closure checks in case Blender has
    # already queued one more call.
    if _leuc_poll_fn is not None:
        try:
            if bpy.app.timers.is_registered(_leuc_poll_fn):
                bpy.app.timers.unregister(_leuc_poll_fn)
        except Exception:
            pass
        _leuc_poll_fn = None

    _leuc_package_cache = None

    # Remove handler
    if on_load_post in bpy.app.handlers.load_post:
        bpy.app.handlers.load_post.remove(on_load_post)

    # Unregister dynamically created classes
    for cls in reversed(_registered_classes):
        try:
            bpy.utils.unregister_class(cls)
        except:
            pass

    _registered_classes = []
    _bug_idnames.clear()

    print("Addon Update Checker: Unregistered")
