"""``plugin.json`` (manifest v1) — read, validate, and build.

A faithful port of the app's ``_read_plugin_manifest`` plus a richer,
author-facing ``validate_package`` used by ``mixlar-sdk validate``. The read path
returns the same ``(manifest, error)`` contract the app uses so behaviour
matches exactly; the validate path collects *all* problems (errors + warnings)
instead of failing on the first, which is what a developer wants at the CLI.
"""
from __future__ import annotations

import json
import os
import re
from dataclasses import dataclass, field
from typing import List, Optional, Tuple

MANIFEST_NAME = "plugin.json"
WIDGET_ID_RE = re.compile(r"^[a-z0-9_-]{2,24}$")
REQUIRED_FIELDS = ("id", "name", "version", "entry")


def _semver_ok(v: str) -> bool:
    try:
        from packaging.version import Version
        Version(v)
        return True
    except Exception:
        return False


def _version_newer(a: str, b: str) -> bool:
    """True when semver ``a`` is strictly newer than ``b`` (min-app check)."""
    try:
        from packaging.version import Version
        return Version(a) > Version(b)
    except Exception:
        return False


def read_manifest(pkg_dir: str,
                  app_version: Optional[str] = None
                  ) -> Tuple[Optional[dict], Optional[str]]:
    """Parse & validate ``<pkg_dir>/plugin.json`` without executing any code.

    Returns ``(manifest_dict, None)`` on success or ``(None, "reason")`` on the
    first failure — identical semantics to the app. ``app_version`` enables the
    ``min_app_version`` gate; omit it to skip that check (author machines rarely
    know the target app version).
    """
    path = os.path.join(pkg_dir, MANIFEST_NAME)
    try:
        with open(path, "r", encoding="utf-8") as f:
            man = json.load(f)
    except (OSError, json.JSONDecodeError) as e:
        return None, f"plugin.json unreadable: {e}"
    if not isinstance(man, dict):
        return None, "plugin.json is not an object"
    if man.get("manifest") != 1:
        return None, f"unsupported manifest version: {man.get('manifest')!r}"
    for fld in REQUIRED_FIELDS:
        if not isinstance(man.get(fld), str) or not man.get(fld).strip():
            return None, f"missing required field: {fld}"
    if not _semver_ok(man["version"]):
        return None, f"invalid semver version: {man['version']!r}"
    entry = man["entry"]
    if any(s in entry for s in ("/", "\\", "..")) or not entry.endswith(".py"):
        return None, f"invalid entry filename: {entry!r}"
    if not os.path.isfile(os.path.join(pkg_dir, entry)):
        return None, f"entry file not found: {entry}"
    min_app = man.get("min_app_version", "")
    if min_app and app_version and _version_newer(min_app, app_version):
        return None, f"requires app {min_app}+ (this is {app_version})"
    widgets = man.get("widgets", [])
    if not isinstance(widgets, list):
        return None, "widgets must be a list"
    for w in widgets:
        wid = w.get("id", "") if isinstance(w, dict) else ""
        if not WIDGET_ID_RE.match(wid):
            return None, f"invalid widget id: {wid!r}"
        wdir = os.path.join(pkg_dir, "widgets", wid)
        if not os.path.isfile(os.path.join(wdir, "widget.json")):
            return None, f"declared widget bundle missing: widgets/{wid}/widget.json"
    perms = man.get("permissions", [])
    if not isinstance(perms, list) or not all(isinstance(p, str) for p in perms):
        return None, "permissions must be a list of strings"
    return man, None


@dataclass
class ValidationReport:
    """Structured result of :func:`validate_package`."""
    ok: bool = True
    errors: List[str] = field(default_factory=list)
    warnings: List[str] = field(default_factory=list)
    manifest: Optional[dict] = None

    def error(self, msg: str) -> None:
        self.errors.append(msg)
        self.ok = False

    def warn(self, msg: str) -> None:
        self.warnings.append(msg)


def validate_package(pkg_dir: str,
                     app_version: Optional[str] = None) -> ValidationReport:
    """Collect *all* manifest problems (not just the first) for the CLI.

    Widget-body linting lives in :mod:`mixlar.validator`; this covers the
    manifest, entry, icons, and the manifest↔widget.json version cross-check.
    """
    rep = ValidationReport()
    man, err = read_manifest(pkg_dir, app_version)
    if man is None:
        rep.error(err or "invalid manifest")
        return rep
    rep.manifest = man

    if man["id"] != man["id"].strip():
        rep.warn("id has surrounding whitespace")
    icons_dir = man.get("icons_dir", "")
    if icons_dir and not os.path.isdir(os.path.join(pkg_dir, icons_dir)):
        rep.warn(f"icons_dir '{icons_dir}' does not exist")
    if not man.get("author"):
        rep.warn("no 'author' — shown on the plugin card")
    if not man.get("description"):
        rep.warn("no 'description' — shown on the plugin card")

    # Manifest widget version is authoritative; warn when widget.json disagrees.
    for w in man.get("widgets", []):
        wid = w.get("id", "")
        mver = w.get("version", "")
        wj = os.path.join(pkg_dir, "widgets", wid, "widget.json")
        try:
            with open(wj, "r", encoding="utf-8") as f:
                jver = json.load(f).get("version", "")
            if mver and jver and mver != jver:
                rep.warn(f"widget '{wid}': manifest v{mver} != widget.json "
                         f"v{jver} (manifest wins)")
        except Exception as e:
            rep.warn(f"widget '{wid}': widget.json unreadable ({e})")

    # Permissions vs. actual capability use (least-privilege check).
    from . import permissions as _perms
    from . import scan as _scan
    for bad in _perms.unknown_entries(man):
        rep.warn(f"permission '{bad}' is not a known capability "
                 f"({', '.join(sorted(_perms.CAPABILITIES))})")
    try:
        report = _scan.scan_package(pkg_dir)
    except Exception as e:  # scanning must never break validation
        report = None
        rep.warn(f"security scan skipped: {e}")
    if report is not None:
        declared = _perms.declared_caps(man)
        undeclared, unused = _perms.diff(declared, report.capabilities)
        if undeclared:
            rep.warn("code uses capabilities not declared in 'permissions' "
                     f"(the app will block these): {', '.join(sorted(undeclared))}")
        if unused:
            rep.warn("'permissions' declares capabilities the code never uses "
                     f"(tighten): {', '.join(sorted(unused))}")
        for f in report.high:
            rep.warn(f"high-risk: {f.file}:{f.line} — {f.message}")

    # Signature status (informational).
    if man.get("sig"):
        from .signing import verify_package
        res = verify_package(pkg_dir)
        if res is True:
            pass
        elif res is False:
            rep.error("signature present but INVALID (unknown publisher or "
                      "tampered package)")
    return rep


def build_manifest(plugin_id: str, name: str, version: str, entry: str,
                   author: str = "", description: str = "",
                   icon: str = "fa5s.puzzle-piece", icon_color: str = "",
                   widgets: Optional[list] = None,
                   permissions: Optional[list] = None) -> dict:
    """Assemble a manifest dict for scaffolding (used by ``mixlar-sdk create``)."""
    man = {
        "manifest": 1,
        "id": plugin_id,
        "name": name,
        "version": version,
        "author": author,
        "description": description,
        "entry": entry,
        "icon": icon,
    }
    if icon_color:
        man["icon_color"] = icon_color
    man["widgets"] = widgets or []
    # Least-privilege capability declaration (network/subprocess/filesystem/
    # native/input/gui). Empty by default — the app blocks any undeclared use.
    man["permissions"] = permissions or []
    man["publisher"] = ""
    man["sig"] = ""
    return man


__all__ = [
    "MANIFEST_NAME", "WIDGET_ID_RE", "REQUIRED_FIELDS",
    "read_manifest", "ValidationReport", "validate_package", "build_manifest",
]
