"""
lambdagent.agentpack — AgentPack manifest parsing, validation, permissions.

Spec: docs/AGENTPACK_SPEC.md (CR-20260607-001 rev.2 §6.3 / §7.3).

This is the kernel-side half of the AgentPack feature (CR §10.1): pure,
testable, no HTTP and no filesystem mutation beyond reading a manifest.
The install / unzip / HTTP layer lives in agentpaas.api.v1.agentpacks.

An AgentPack is a thin packaging layer over the existing agent-template
concept — `entrypoint` resolves to an ordinary lambdagent config. This
module only deals with the manifest (metadata + permissions); running the
pack is the same `from_config()` path as any other agent.
"""
from __future__ import annotations

import os
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List

from .core import LambdagentError


class AgentPackError(LambdagentError):
    """Raised on an invalid or unsafe AgentPack manifest."""


# ── Permission model (SR-003) ────────────────────────────────────────────

_FILE_WRITE_SCOPES = ("none", "workspace", "knowledge")


@dataclass
class PackPermissions:
    """Declared capabilities of a pack. Deny-by-default: any field absent
    from the manifest is the safe value (False / 'none')."""
    network: bool = False
    shell: bool = False
    file_write: str = "workspace"      # none | workspace | knowledge
    read_knowledge: bool = True
    read_filesystem: bool = False

    @classmethod
    def from_dict(cls, d: Dict[str, Any]) -> "PackPermissions":
        if not isinstance(d, dict):
            raise AgentPackError("manifest.permissions must be a mapping")
        fw = d.get("file_write", "workspace")
        if fw not in _FILE_WRITE_SCOPES:
            raise AgentPackError(
                f"permissions.file_write must be one of {_FILE_WRITE_SCOPES}, "
                f"got {fw!r}"
            )
        return cls(
            network=bool(d.get("network", False)),
            shell=bool(d.get("shell", False)),
            file_write=fw,
            read_knowledge=bool(d.get("read_knowledge", True)),
            read_filesystem=bool(d.get("read_filesystem", False)),
        )

    def to_dict(self) -> Dict[str, Any]:
        return {
            "network": self.network,
            "shell": self.shell,
            "file_write": self.file_write,
            "read_knowledge": self.read_knowledge,
            "read_filesystem": self.read_filesystem,
        }

    def summary_zh(self) -> str:
        """Plain-language permission summary for the install dialog (FR-007)."""
        parts: List[str] = []
        parts.append("读取本地知识库" if self.read_knowledge else "不读取知识库")
        fw = {
            "none": "不写入任何文件",
            "workspace": "写入 workspace",
            "knowledge": "写入 workspace 和知识库",
        }[self.file_write]
        parts.append(fw)
        parts.append("执行 shell" if self.shell else "不执行 shell")
        parts.append("访问网络" if self.network else "不访问网络")
        if self.read_filesystem:
            parts.append("读取知识库以外的文件")
        return "此智能体包将" + "，".join(parts) + "。"


# ── Manifest ──────────────────────────────────────────────────────────────

_ID_RE = re.compile(r"^[a-z0-9]([a-z0-9.\-]*[a-z0-9])?$")
_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+([.\-+][0-9A-Za-z.\-]+)?$")


@dataclass
class AgentPackManifest:
    id: str
    name: str
    version: str
    domain: str
    entrypoint: str
    permissions: PackPermissions
    audience: List[str] = field(default_factory=list)
    description: str = ""
    model_recommended: List[str] = field(default_factory=list)
    author: str = ""
    homepage: str = ""
    license: str = ""
    # Absolute path to the pack root once loaded from disk (else "").
    root_dir: str = ""

    @classmethod
    def from_dict(cls, data: Dict[str, Any], root_dir: str = "") -> "AgentPackManifest":
        if not isinstance(data, dict):
            raise AgentPackError("manifest must be a YAML mapping")

        def _req(key: str) -> Any:
            if key not in data or data[key] in (None, ""):
                raise AgentPackError(f"manifest missing required field: {key!r}")
            return data[key]

        pid = str(_req("id"))
        if not _ID_RE.match(pid):
            raise AgentPackError(
                f"manifest.id {pid!r} invalid — must match {_ID_RE.pattern}"
            )

        version = str(_req("version"))
        if not _SEMVER_RE.match(version):
            raise AgentPackError(
                f"manifest.version {version!r} is not semver MAJOR.MINOR.PATCH"
            )

        entrypoint = str(_req("entrypoint"))
        # zip-slip / escape guard: entrypoint must be relative and stay inside.
        norm = os.path.normpath(entrypoint)
        if os.path.isabs(norm) or norm.startswith(".."):
            raise AgentPackError(
                f"manifest.entrypoint {entrypoint!r} must be a relative path "
                f"inside the pack (no absolute paths, no '..')"
            )

        perms = PackPermissions.from_dict(_req("permissions"))

        model_block = data.get("model", {}) or {}
        model_recommended = []
        if isinstance(model_block, dict):
            model_recommended = list(model_block.get("recommended", []) or [])

        audience = data.get("audience", []) or []
        if not isinstance(audience, list):
            raise AgentPackError("manifest.audience must be a list")

        return cls(
            id=pid,
            name=str(_req("name")),
            version=version,
            domain=str(_req("domain")),
            entrypoint=norm,
            permissions=perms,
            audience=[str(a) for a in audience],
            description=str(data.get("description", "") or ""),
            model_recommended=[str(m) for m in model_recommended],
            author=str(data.get("author", "") or ""),
            homepage=str(data.get("homepage", "") or ""),
            license=str(data.get("license", "") or ""),
            root_dir=root_dir,
        )

    def entrypoint_path(self) -> str:
        """Absolute path to the entrypoint config (requires root_dir set)."""
        if not self.root_dir:
            raise AgentPackError("manifest has no root_dir; load from disk first")
        return os.path.join(self.root_dir, self.entrypoint)

    def to_dict(self) -> Dict[str, Any]:
        return {
            "id": self.id,
            "name": self.name,
            "version": self.version,
            "domain": self.domain,
            "entrypoint": self.entrypoint,
            "permissions": self.permissions.to_dict(),
            "audience": self.audience,
            "description": self.description,
            "model": {"recommended": self.model_recommended},
            "author": self.author,
            "homepage": self.homepage,
            "license": self.license,
        }


def load_manifest(path: str) -> AgentPackManifest:
    """Load + validate a manifest.yml from disk.

    `path` may point at a manifest.yml file OR a pack directory containing
    one. Returns a validated AgentPackManifest with root_dir set. Raises
    AgentPackError on any problem (missing file, bad YAML, schema violation,
    entrypoint escape, or — when loaded from disk — a missing entrypoint).
    """
    import yaml

    if os.path.isdir(path):
        manifest_path = os.path.join(path, "manifest.yml")
        root_dir = os.path.abspath(path)
    else:
        manifest_path = path
        root_dir = os.path.abspath(os.path.dirname(path))

    if not os.path.isfile(manifest_path):
        raise AgentPackError(f"manifest.yml not found: {manifest_path}")

    try:
        with open(manifest_path, encoding="utf-8") as f:
            data = yaml.safe_load(f)
    except yaml.YAMLError as e:
        raise AgentPackError(f"manifest.yml is not valid YAML: {e}")

    manifest = AgentPackManifest.from_dict(data or {}, root_dir=root_dir)

    # When loaded from disk, the entrypoint must actually exist + stay inside.
    ep = os.path.abspath(manifest.entrypoint_path())
    if not (ep == root_dir or ep.startswith(root_dir + os.sep)):
        raise AgentPackError(
            f"entrypoint {manifest.entrypoint!r} escapes the pack root"
        )
    if not os.path.isfile(ep):
        raise AgentPackError(
            f"entrypoint config not found: {manifest.entrypoint}"
        )
    return manifest


def validate_manifest_dict(data: Dict[str, Any]) -> AgentPackManifest:
    """Validate a manifest given as a dict (no disk access, no entrypoint
    existence check). Useful for tests + pre-publish linting."""
    return AgentPackManifest.from_dict(data)
