"""Install and load proprietary managed tools owned by a hub.

Written once for every DCC (docs/plans/three-blocks-cinema4d-addon.md §5): the
install lifecycle is download → sha256 → zip-slip-guarded staged extract →
atomic swap → import + `register()` → installed.json, with rollback to the
previous version if anything in that chain raises.

Managed tools are plain Python packages the hub imports, never DCC plugins — the
reason install/update/remove stays restart-free even in Cinema 4D, where real
plugins can only register at application startup. The module lifecycle contract
is `register()` / `unregister()` in every host; anything host-shaped a tool
exposes on top (a Blender panel, a C4D dialog factory) is read by the host's own
UI layer, not from here.
"""

from __future__ import annotations

import hashlib
import importlib
import json
import os
import re
import shutil
import stat
import sys
import time
import uuid
import zipfile
from contextlib import contextmanager
from pathlib import Path, PurePosixPath

from . import config
from .catalog_core import version_tuple
from .context_core import HUB_API

# tb_addon.json's legacy per-host minimum key and display name; retire when the
# manifests migrate to the `hosts: {<key>: {min}}` shape (plan §6).
_HOST_META = {"blender": ("minBlender", "Blender"), "c4d": ("minC4D", "Cinema 4D")}


def _sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as source:
        for chunk in iter(lambda: source.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def _validate_entry(entry: dict) -> tuple[str, str, str]:
    module = entry.get("module")
    version = entry.get("version")
    expected = entry.get("sha256")
    if not isinstance(module, str) or not re.fullmatch(r"[A-Za-z_]\w*", module):
        raise RuntimeError("The catalog returned an invalid add-on module name.")
    if not isinstance(version, str) or not version:
        raise RuntimeError("The catalog returned an invalid add-on version.")
    if not isinstance(expected, str) or not re.fullmatch(r"[0-9a-fA-F]{64}", expected):
        raise RuntimeError("The catalog did not provide a valid add-on checksum.")
    return module, version, expected.lower()


class AddonManager:
    """The managed-tool lifecycle for one host.

    Tools live in `~/.three-blocks/<HOST_KEY>/addons/<module>/` beside an
    `installed.json` ledger — the same shape in every host, so a headless
    `three-blocks <host> install <tool>` can provision a render farm with the
    DCC closed.
    """

    def __init__(self, host):
        self._host = host
        self._state = None
        self._state_file = None
        self._active: list[str] = []

    # --- locations -----------------------------------------------------------

    def root(self) -> Path:
        return config.config_dir() / self._host.HOST_KEY

    def addons_root(self) -> Path:
        return self.root() / "addons"

    def state_path(self) -> Path:
        return self.root() / "installed.json"

    # --- the installed.json ledger -------------------------------------------

    def _read_state(self) -> dict:
        path = self.state_path()
        if self._state is None or self._state_file != path:
            try:
                value = json.loads(path.read_text(encoding="utf-8"))
                self._state = value if isinstance(value, dict) else {}
            except (OSError, ValueError):
                self._state = {}
            self._state_file = path
        return self._state

    def _write_state(self, value: dict) -> None:
        path = self.state_path()
        path.parent.mkdir(parents=True, exist_ok=True)
        temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
        temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
        os.replace(temporary, path)
        self._state = value
        self._state_file = path

    def _reload_state(self) -> None:
        self._state = None
        self._state_file = None

    @contextmanager
    def _lock(self):
        """Serialize live swaps across DCC instances using stdlib locks."""
        path = self.root() / ".manager.lock"
        path.parent.mkdir(parents=True, exist_ok=True)
        with path.open("a+b") as lock:
            if os.name == "nt":
                import msvcrt

                if path.stat().st_size == 0:
                    lock.write(b"\0")
                    lock.flush()
                lock.seek(0)
                msvcrt.locking(lock.fileno(), msvcrt.LK_LOCK, 1)
                try:
                    yield
                finally:
                    lock.seek(0)
                    msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1)
            else:
                import fcntl

                fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
                try:
                    yield
                finally:
                    fcntl.flock(lock.fileno(), fcntl.LOCK_UN)

    # --- queries -------------------------------------------------------------

    def installed(self, module: str) -> dict | None:
        value = self._read_state().get(module)
        return value if isinstance(value, dict) and (self.addons_root() / module).is_dir() else None

    def installed_version(self, module: str | None) -> str | None:
        value = self.installed(module) if module else None
        return str(value.get("version")) if value and value.get("version") else None

    def installed_modules(self) -> list[str]:
        """Every module the ledger claims AND that still exists on disk."""
        return [module for module in self._read_state() if self.installed(module)]

    # --- download → staging --------------------------------------------------

    def prepare_archive(self, entry: dict, archive: str | Path) -> Path:
        """Verify and safely extract an archive into same-filesystem staging."""
        module, version, expected = _validate_entry(entry)
        archive = Path(archive)
        if _sha256(archive) != expected:
            raise RuntimeError(f"{module} download failed its SHA-256 integrity check.")

        staging = self.addons_root() / ".staging" / f"{module}-{uuid.uuid4().hex}"
        package = staging / module
        package.mkdir(parents=True, exist_ok=False)
        try:
            with zipfile.ZipFile(archive) as bundle:
                for info in bundle.infolist():
                    raw = info.filename
                    parts = PurePosixPath(raw).parts
                    if (
                        not parts
                        or raw.startswith("/")
                        or "\\" in raw
                        or parts[0] != module
                        or any(part in {"", ".", ".."} for part in parts)
                    ):
                        raise RuntimeError(f"Unsafe add-on archive entry: {raw}")
                    mode = info.external_attr >> 16
                    if mode and stat.S_ISLNK(mode):
                        raise RuntimeError(f"Links are not allowed in add-on archives: {raw}")
                    relative = Path(*parts[1:])
                    if not relative.parts:
                        continue
                    target = (package / relative).resolve()
                    if os.path.commonpath((str(package.resolve()), str(target))) != str(package.resolve()):
                        raise RuntimeError(f"Unsafe add-on archive entry: {raw}")
                    if info.is_dir():
                        target.mkdir(parents=True, exist_ok=True)
                        continue
                    target.parent.mkdir(parents=True, exist_ok=True)
                    with bundle.open(info) as source, target.open("wb") as output:
                        shutil.copyfileobj(source, output)

            metadata = json.loads((package / "tb_addon.json").read_text(encoding="utf-8"))
            if metadata.get("name") != module or metadata.get("version") != version:
                raise RuntimeError(f"{module} metadata does not match the signed catalog.")
            if int(metadata.get("hubApi") or 0) > HUB_API:
                raise RuntimeError(f"{module} requires a newer Three Blocks Hub.")
            self._check_host_minimum(metadata, module)
            return package
        except Exception:
            shutil.rmtree(staging, ignore_errors=True)
            raise

    def _check_host_minimum(self, metadata: dict, module: str) -> None:
        """Refuse a tool that declares a newer host than the one running —
        without this, a 4.2-only tool installs into 3.6 and dies at import."""
        host_version = getattr(self._host, "host_version", None)
        if not callable(host_version):
            return
        key = self._host.HOST_KEY
        legacy_key, display = _HOST_META.get(key, (None, key))
        hosts = metadata.get("hosts")
        entry = hosts.get(key) if isinstance(hosts, dict) else None
        minimum = entry.get("min") if isinstance(entry, dict) else None
        if not minimum and legacy_key:
            minimum = metadata.get(legacy_key)
        current = str(host_version())
        if minimum and version_tuple(str(minimum)) > version_tuple(current):
            raise RuntimeError(f"{module} needs {display} {minimum}+ (this is {display} {current}).")

    def discard_staged(self, staged_package: str | Path) -> None:
        shutil.rmtree(Path(staged_package).parent, ignore_errors=True)

    # --- import lifecycle ----------------------------------------------------

    def _ensure_import_path(self) -> None:
        root = str(self.addons_root())
        if root not in sys.path:
            sys.path.insert(0, root)

    def _purge(self, module: str) -> None:
        for name in [name for name in sys.modules if name == module or name.startswith(module + ".")]:
            del sys.modules[name]
        importlib.invalidate_caches()

    def _deactivate(self, module: str) -> None:
        loaded = sys.modules.get(module)
        if loaded and callable(getattr(loaded, "unregister", None)):
            loaded.unregister()
        self._purge(module)
        if module in self._active:
            self._active.remove(module)

    def _activate_module(self, module: str) -> None:
        self._ensure_import_path()
        loaded = importlib.import_module(module)
        register = getattr(loaded, "register", None)
        if not callable(register):
            self._purge(module)
            raise RuntimeError(f"{module} does not expose register().")
        register()
        if module not in self._active:
            self._active.append(module)

    # --- install / update / remove -------------------------------------------

    def _activate_unlocked(self, entry: dict, staged_package: str | Path) -> None:
        """Atomically swap and register a prepared add-on, rolling back on error."""
        module, version, expected = _validate_entry(entry)
        staged = Path(staged_package)
        target = self.addons_root() / module
        backup = self.addons_root() / f".{module}.backup-{uuid.uuid4().hex}"
        previous = dict(self._read_state())
        had_target = target.is_dir()
        was_loaded = module in sys.modules
        backed_up = False
        installed_new = False
        self.addons_root().mkdir(parents=True, exist_ok=True)
        try:
            self._deactivate(module)
            if had_target:
                os.replace(target, backup)
                backed_up = True
            os.replace(staged, target)
            installed_new = True
            self._activate_module(module)
            updated = dict(previous)
            updated[module] = {
                "version": version,
                "artifact": entry.get("artifact"),
                "sha256": expected,
                "installedAt": int(time.time() * 1000),
            }
            self._write_state(updated)
            shutil.rmtree(backup, ignore_errors=True)
        except Exception as error:
            if installed_new:
                try:
                    self._deactivate(module)
                except Exception:
                    self._purge(module)
                shutil.rmtree(target, ignore_errors=True)
            if backed_up and backup.exists():
                os.replace(backup, target)
                try:
                    self._activate_module(module)
                except Exception as rollback_error:
                    raise RuntimeError(
                        f"{module} update failed ({error}); rollback also failed ({rollback_error})."
                    ) from error
            elif was_loaded and had_target and target.is_dir() and module not in sys.modules:
                self._activate_module(module)
            raise
        finally:
            self.discard_staged(staged)

    def activate(self, entry: dict, staged_package: str | Path) -> None:
        with self._lock():
            self._reload_state()
            self._activate_unlocked(entry, staged_package)

    def remove(self, module: str) -> None:
        with self._lock():
            self._reload_state()
            self._deactivate(module)
            shutil.rmtree(self.addons_root() / module, ignore_errors=True)
            updated = dict(self._read_state())
            updated.pop(module, None)
            self._write_state(updated)

    # --- hub startup / shutdown ---------------------------------------------

    def cleanup_staging(self) -> None:
        shutil.rmtree(self.addons_root() / ".staging", ignore_errors=True)

    def register_installed(self) -> None:
        self._ensure_import_path()
        for module in self._read_state():
            if module in self._active:
                continue
            if not (self.addons_root() / module).is_dir():
                continue
            try:
                self._activate_module(module)
            except Exception as error:
                print(f"three_blocks: could not load managed add-on {module}: {error}")

    def unregister_all(self) -> None:
        for module in list(reversed(self._active)):
            try:
                self._deactivate(module)
            except Exception as error:
                print(f"three_blocks: could not unload managed add-on {module}: {error}")
        root = str(self.addons_root())
        if root in sys.path:
            sys.path.remove(root)
