"""Package digest + ed25519 signing — byte-for-byte compatible with the app.

The desktop app (``mixlar_mini._plugin_package_digest`` /
``_verify_plugin_signature``) computes a deterministic SHA-256 over a plugin
package and verifies an ed25519 signature of that digest hex. For the app to
accept a package this SDK signs, the digest bytes must match **exactly** — so
``package_digest`` here is a faithful port of the app's algorithm (verified by
signing an example package and re-verifying with the app in tests).

Public API:
    package_digest(pkg_dir)                  -> hex str | None
    sign_package(pkg_dir, key_id, priv_b64)  -> signature b64 (writes manifest)
    verify_package(pkg_dir, publisher_keys?) -> True | False | None
    generate_keypair()                       -> (priv_b64, pub_b64)
    load_private_key_file(path) / save_private_key_file(path, priv_b64)
"""
from __future__ import annotations

import base64
import hashlib
import json
import os
from typing import Dict, Optional, Tuple

MANIFEST_NAME = "plugin.json"

# Files/dirs covered by the digest (the package's trust identity).
_PKG_DIGEST_EXTS = (".py", ".json", ".png", ".txt")
_PKG_DIGEST_DIRS = ("widgets", "screen", "icons")
# Top-level files (outside the digest dirs) also covered, so they're tamper-
# protected by the signature. Keep in sync with the app's _plugin_package_digest.
_PKG_DIGEST_TOP_FILES = ("settings.schema.json",)

#: Pinned publisher keyset {key_id: base64 raw ed25519 public key}. Mirrors the
#: app's ``_PUBLISHER_KEYS``. Only signatures verifying against a key here are
#: treated as officially signed (auto-trusted by the app).
PUBLISHER_KEYS: Dict[str, str] = {
    "mixlar-official-1": "AjxJeR5Ns4h1GdUYEzP3biW6vWoStOG1/ydMccDFWZU=",
    # Community registry key. mixlar.net signs approved community submissions
    # with this key (private half held only as a CI secret). A plugin carrying
    # a valid signature under this key is one the Mixlar registry reviewed and
    # published — see https://github.com/MixlarLabs/mixlar-plugins.
    "mixlar-registry-1": "Ynlk4wUqNL7w8kPFmpc2EynFar7jSCGq+Ir8Ec5e4cQ=",
}


def package_digest(pkg_dir: str) -> Optional[str]:
    """Deterministic SHA-256 identity of a plugin package folder.

    Covers ``plugin.json`` (with its ``sig`` field blanked, keys sorted, compact
    separators), the manifest ``entry`` file, and every allowlisted file under
    ``widgets/``, ``screen/`` and ``icons/``. Hidden files and ``__pycache__``
    are excluded. Returns the hex digest, or ``None`` when the folder isn't a
    package. **Do not "improve" the byte layout** — it must match the app.
    """
    man_path = os.path.join(pkg_dir, MANIFEST_NAME)
    if not os.path.isfile(man_path):
        return None
    files = {}   # relpath (forward slashes) -> abs path; manifest handled apart
    try:
        with open(man_path, "r", encoding="utf-8") as f:
            man = json.load(f)
        entry = man.get("entry", "") if isinstance(man, dict) else ""
    except Exception:
        man, entry = None, ""
    if entry and not any(s in entry for s in ("/", "\\", "..")):
        ep = os.path.join(pkg_dir, entry)
        if os.path.isfile(ep):
            files[entry] = ep
    for top in _PKG_DIGEST_TOP_FILES:
        tp = os.path.join(pkg_dir, top)
        if os.path.isfile(tp):
            files[top] = tp
    for sub in _PKG_DIGEST_DIRS:
        root = os.path.join(pkg_dir, sub)
        for dirpath, dirnames, filenames in os.walk(root):
            dirnames[:] = [d for d in dirnames
                           if not d.startswith(".") and d != "__pycache__"]
            for fn in filenames:
                if fn.startswith(".") or not fn.lower().endswith(_PKG_DIGEST_EXTS):
                    continue
                ap = os.path.join(dirpath, fn)
                rel = os.path.relpath(ap, pkg_dir).replace(os.sep, "/")
                files[rel] = ap
    h = hashlib.sha256()
    if isinstance(man, dict):
        man_norm = dict(man)
        man_norm["sig"] = ""
        h.update(b"plugin.json")
        h.update(json.dumps(man_norm, sort_keys=True,
                            separators=(",", ":")).encode("utf-8"))
    else:
        try:
            with open(man_path, "rb") as f:
                h.update(b"plugin.json")
                h.update(f.read())
        except OSError:
            return None
    for rel in sorted(files):
        try:
            with open(files[rel], "rb") as f:
                h.update(rel.encode("utf-8"))
                h.update(f.read())
        except OSError:
            continue
    return h.hexdigest()


# ── ed25519 keys ─────────────────────────────────────────────────────────────
def _ed25519():
    from cryptography.hazmat.primitives.asymmetric.ed25519 import (
        Ed25519PrivateKey, Ed25519PublicKey)
    return Ed25519PrivateKey, Ed25519PublicKey


def generate_keypair() -> Tuple[str, str]:
    """Create a fresh ed25519 keypair. Returns ``(private_b64, public_b64)``.

    ``private_b64`` is the base64 of the 32 raw private bytes (the format
    ``load_private_key_file`` expects). ``public_b64`` is what a maintainer adds
    to :data:`PUBLISHER_KEYS` / the app's ``_PUBLISHER_KEYS`` to pin the signer.
    """
    from cryptography.hazmat.primitives import serialization
    Ed25519PrivateKey, _ = _ed25519()
    priv = Ed25519PrivateKey.generate()
    raw_priv = priv.private_bytes(
        encoding=serialization.Encoding.Raw,
        format=serialization.PrivateFormat.Raw,
        encryption_algorithm=serialization.NoEncryption(),
    )
    raw_pub = priv.public_key().public_bytes(
        encoding=serialization.Encoding.Raw,
        format=serialization.PublicFormat.Raw,
    )
    return (base64.b64encode(raw_priv).decode("ascii"),
            base64.b64encode(raw_pub).decode("ascii"))


def public_from_private(priv_b64: str) -> str:
    """Derive the base64 public key from a base64 private key."""
    from cryptography.hazmat.primitives import serialization
    Ed25519PrivateKey, _ = _ed25519()
    priv = Ed25519PrivateKey.from_private_bytes(base64.b64decode(priv_b64))
    raw_pub = priv.public_key().public_bytes(
        encoding=serialization.Encoding.Raw,
        format=serialization.PublicFormat.Raw,
    )
    return base64.b64encode(raw_pub).decode("ascii")


def default_key_file(key_id: str = "mixlar-official-1") -> str:
    """The conventional private-key path the app's signer uses on Windows."""
    return os.path.join(os.path.expandvars(r"%APPDATA%\Mixlar\keys"),
                        f"{key_id}.ed25519.key")


def load_private_key_file(path: str) -> str:
    """Read a base64 private key file → base64 string."""
    with open(path, "r", encoding="utf-8") as f:
        return f.read().strip()


def save_private_key_file(path: str, priv_b64: str) -> None:
    """Write a base64 private key file, creating the directory."""
    os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
    with open(path, "w", encoding="utf-8") as f:
        f.write(priv_b64.strip())


# ── Sign / verify ────────────────────────────────────────────────────────────
def sign_package(pkg_dir: str, key_id: str, priv_b64: str) -> str:
    """Sign the package in place. Writes ``publisher``/``sig`` into plugin.json.

    Mirrors ``research_tools/sign_plugin.sign_one``: set ``publisher`` and blank
    ``sig`` first (both are inside the digest via the blank-sig rule), persist,
    compute the digest, sign the hex, write the base64 signature back. Returns
    the base64 signature. Raises on missing manifest or key problems.
    """
    man_path = os.path.join(pkg_dir, MANIFEST_NAME)
    if not os.path.isfile(man_path):
        raise FileNotFoundError(f"no {MANIFEST_NAME} in {pkg_dir}")
    with open(man_path, "r", encoding="utf-8") as f:
        man = json.load(f)
    man["publisher"] = key_id
    man["sig"] = ""
    with open(man_path, "w", encoding="utf-8") as f:
        json.dump(man, f, indent=2)

    digest = package_digest(pkg_dir)
    if not digest:
        raise RuntimeError("could not compute package digest")

    Ed25519PrivateKey, _ = _ed25519()
    priv = Ed25519PrivateKey.from_private_bytes(base64.b64decode(priv_b64))
    sig = priv.sign(digest.encode("ascii"))
    sig_b64 = base64.b64encode(sig).decode("ascii")
    man["sig"] = sig_b64
    with open(man_path, "w", encoding="utf-8") as f:
        json.dump(man, f, indent=2)
    return sig_b64


def verify_signature(manifest: dict, digest: Optional[str],
                     publisher_keys: Optional[Dict[str, str]] = None):
    """Faithful port of the app's ``_verify_plugin_signature``.

    Returns ``None`` (unsigned), ``True`` (valid against a pinned key), or
    ``False`` (present but invalid: unknown publisher / bad base64 / mismatch).
    """
    keys = PUBLISHER_KEYS if publisher_keys is None else publisher_keys
    sig = (manifest or {}).get("sig", "")
    if not sig:
        return None
    pub_id = (manifest or {}).get("publisher", "")
    pub_b64 = keys.get(pub_id)
    if not pub_b64:
        return False
    if not digest:
        return False
    try:
        _, Ed25519PublicKey = _ed25519()
        pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(pub_b64))
        pub.verify(base64.b64decode(sig), digest.encode("ascii"))
        return True
    except Exception:
        return False


def verify_package(pkg_dir: str,
                   publisher_keys: Optional[Dict[str, str]] = None):
    """Convenience: read the manifest, digest the folder, verify. Tri-state."""
    man_path = os.path.join(pkg_dir, MANIFEST_NAME)
    try:
        with open(man_path, "r", encoding="utf-8") as f:
            man = json.load(f)
    except (OSError, json.JSONDecodeError):
        return False
    return verify_signature(man, package_digest(pkg_dir), publisher_keys)


__all__ = [
    "MANIFEST_NAME", "PUBLISHER_KEYS", "package_digest",
    "generate_keypair", "public_from_private", "default_key_file",
    "load_private_key_file", "save_private_key_file",
    "sign_package", "verify_signature", "verify_package",
]
