"""``.mixplugin`` packaging — zip a plugin package folder for distribution.

A ``.mixplugin`` is a plain zip of the package folder (the folder containing
``plugin.json``), with the folder itself as the top-level entry — so unzipping
next to the destination reproduces the package unchanged. This is the unit
``mixlar-sdk pack`` builds, ``mixlar-sdk link``/install consumes, and the marketplace
distributes. No compression trickery, no code execution: extraction is a pure
file copy with path-traversal guards.
"""
from __future__ import annotations

import os
import zipfile
from typing import Optional, Tuple

from . import manifest as _manifest

#: Names/patterns excluded from packages (build artefacts, VCS, OS cruft).
_EXCLUDE_DIRS = {".git", "__pycache__"}
_EXCLUDE_NAMES = {".DS_Store"}


def _default_plugins_dir() -> str:
    """The app's default plugin install directory (Windows ``%APPDATA%``)."""
    return os.path.expandvars(r"%APPDATA%\Mixlar\config\plugins")


def _should_exclude(rel_parts: Tuple[str, ...]) -> bool:
    """True if any path segment is a hidden file/dir, VCS, or build cruft."""
    for part in rel_parts:
        if part in _EXCLUDE_DIRS or part in _EXCLUDE_NAMES:
            return True
        if part.startswith(".") or part.endswith(".pyc"):
            return True
    return False


def _iter_package_files(pkg_dir: str):
    """Yield ``(abs_path, arcname)`` pairs for every file to include, sorted."""
    pkg_name = os.path.basename(os.path.normpath(pkg_dir))
    entries = []
    for dirpath, dirnames, filenames in os.walk(pkg_dir):
        rel_dir = os.path.relpath(dirpath, pkg_dir)
        dirnames[:] = [d for d in dirnames
                       if not _should_exclude((d,))]
        for fn in filenames:
            if _should_exclude((fn,)):
                continue
            abs_path = os.path.join(dirpath, fn)
            rel = fn if rel_dir == "." else os.path.join(rel_dir, fn)
            arcname = "/".join((pkg_name, rel.replace(os.sep, "/")))
            entries.append((abs_path, arcname))
    entries.sort(key=lambda pair: pair[1])
    return entries


def pack(pkg_dir: str, out_path: Optional[str] = None,
         sign_with: Optional[Tuple[str, str]] = None) -> str:
    """Zip a validated plugin package folder into a ``.mixplugin``.

    Validates ``plugin.json`` first (raises :class:`ValueError` on failure).
    If ``sign_with`` is ``(key_id, priv_b64)``, signs the package in place
    before zipping. Writes deterministically (sorted entries, fixed
    timestamps) so identical inputs produce byte-identical output. Returns
    the output path, defaulting to ``<name>-<version>.mixplugin`` next to
    ``pkg_dir``.
    """
    pkg_dir = os.path.abspath(pkg_dir)
    man, err = _manifest.read_manifest(pkg_dir)
    if man is None:
        raise ValueError(f"invalid plugin package: {err}")

    if sign_with is not None:
        from . import signing
        key_id, priv_b64 = sign_with
        signing.sign_package(pkg_dir, key_id, priv_b64)
        # Re-read: sign_package rewrites plugin.json (publisher/sig fields).
        man, err = _manifest.read_manifest(pkg_dir)
        if man is None:
            raise ValueError(f"package invalid after signing: {err}")

    if out_path is None:
        fname = f"{man['id']}-{man['version']}.mixplugin"
        out_path = os.path.join(os.path.dirname(pkg_dir), fname)

    entries = _iter_package_files(pkg_dir)
    with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zf:
        for abs_path, arcname in entries:
            info = zipfile.ZipInfo(arcname, date_time=(1980, 1, 1, 0, 0, 0))
            info.compress_type = zipfile.ZIP_DEFLATED
            with open(abs_path, "rb") as f:
                zf.writestr(info, f.read())
    return out_path


def manifest_of(mixplugin_path: str) -> dict:
    """Read ``plugin.json`` out of a ``.mixplugin`` without extracting it."""
    import json
    with zipfile.ZipFile(mixplugin_path, "r") as zf:
        name = _manifest_arcname(zf)
        with zf.open(name) as f:
            return json.load(f)


def _manifest_arcname(zf: zipfile.ZipFile) -> str:
    """Find the single top-level-folder ``plugin.json`` entry in ``zf``."""
    candidates = [n for n in zf.namelist()
                  if n.endswith(f"/{_manifest.MANIFEST_NAME}")
                  and n.count("/") == 1]
    if not candidates:
        raise ValueError(f"no {_manifest.MANIFEST_NAME} found in package")
    return candidates[0]


def _safe_extract_root(zf: zipfile.ZipFile) -> str:
    """Return the shared top-level folder name, guarding against traversal."""
    root: Optional[str] = None
    for name in zf.namelist():
        if os.path.isabs(name) or ".." in name.replace("\\", "/").split("/"):
            raise ValueError(f"unsafe path in package: {name!r}")
        top = name.replace("\\", "/").split("/", 1)[0]
        if not top:
            raise ValueError(f"unsafe path in package: {name!r}")
        if root is None:
            root = top
        elif root != top:
            raise ValueError("package has multiple top-level folders")
    if root is None:
        raise ValueError("empty package")
    return root


def unpack(mixplugin_path: str, dest_dir: str) -> str:
    """Safely extract a ``.mixplugin`` into ``dest_dir``.

    Rejects absolute paths and ``..`` segments before touching disk. Returns
    the path to the extracted package folder (``dest_dir/<pkg_name>``).
    """
    os.makedirs(dest_dir, exist_ok=True)
    with zipfile.ZipFile(mixplugin_path, "r") as zf:
        root = _safe_extract_root(zf)
        dest_abs = os.path.abspath(dest_dir)
        for name in zf.namelist():
            target = os.path.abspath(os.path.join(dest_dir, name))
            if not (target == dest_abs or
                    target.startswith(dest_abs + os.sep)):
                raise ValueError(f"unsafe path in package: {name!r}")
        zf.extractall(dest_dir)
    return os.path.join(dest_dir, root)


def install(mixplugin_path: str, plugins_dir: Optional[str] = None) -> str:
    """Unpack a ``.mixplugin`` into the app's plugins directory.

    Defaults to ``%APPDATA%/Mixlar/config/plugins``. This is what ``mixlar
    link``/install targets so a packed plugin is immediately loadable by the
    running app. Returns the installed package path.
    """
    target = plugins_dir if plugins_dir is not None else _default_plugins_dir()
    return unpack(mixplugin_path, target)


def contents(mixplugin_path: str) -> list[str]:
    """List every entry (files and folders) inside a ``.mixplugin``."""
    with zipfile.ZipFile(mixplugin_path, "r") as zf:
        return zf.namelist()


__all__ = ["pack", "manifest_of", "unpack", "install", "contents"]
