"""
agentpaas.engine.agentpack_store — install / list / uninstall AgentPacks.

The agentpaas-side half of the AgentPack feature (CR §10.1 / §10.2). The
kernel half (manifest parsing + permission model) lives in
lambdagent.agentpack; this module handles the filesystem: safe unzip,
validation via the kernel, and the on-disk layout
`<agentpacks_dir>/<id>/<version>/` (docs/AGENTPACK_SPEC.md §1/§4).

Security (SPEC §6):
  - zip-slip guard: reject any archive member that escapes the temp dir
  - manifest validated by lambdagent.load_manifest (semver, id, entrypoint
    existence + escape guard)
  - third-party packs declaring shell:true are refused unless allow_shell
  - yaml.safe_load only (inside the kernel)
"""
from __future__ import annotations

import os
import shutil
import tempfile
import zipfile
from dataclasses import dataclass
from typing import List, Optional

from lambdagent.agentpack import (
    AgentPackManifest,
    AgentPackError,
    load_manifest,
)


class AgentPackInstallError(Exception):
    """Raised when an AgentPack cannot be installed."""


@dataclass
class InstalledPack:
    id: str
    version: str
    name: str
    domain: str
    path: str
    manifest: AgentPackManifest


def _safe_extract(zf: zipfile.ZipFile, dest: str) -> None:
    """Extract a zip, rejecting path-traversal (zip-slip) members."""
    dest_abs = os.path.abspath(dest)
    for member in zf.namelist():
        # Resolve the target path and ensure it stays inside dest.
        target = os.path.abspath(os.path.join(dest, member))
        if target != dest_abs and not target.startswith(dest_abs + os.sep):
            raise AgentPackInstallError(
                f"unsafe zip member escapes extraction dir: {member!r}"
            )
    zf.extractall(dest)


def _find_pack_root(extracted_dir: str) -> str:
    """A zip may contain the pack at its root OR nested one level
    (e.g. `mypack/manifest.yml`). Return the dir that holds manifest.yml."""
    if os.path.isfile(os.path.join(extracted_dir, "manifest.yml")):
        return extracted_dir
    # Look one level down.
    entries = [
        os.path.join(extracted_dir, e) for e in os.listdir(extracted_dir)
    ]
    subdirs = [e for e in entries if os.path.isdir(e)]
    if len(subdirs) == 1 and os.path.isfile(os.path.join(subdirs[0], "manifest.yml")):
        return subdirs[0]
    raise AgentPackInstallError(
        "manifest.yml not found at zip root or single top-level dir"
    )


def peek_manifest_from_zip(zip_path: str) -> AgentPackManifest:
    """校验并返回 zip 内的 manifest，但**不安装**。

    用于「更新包」端点在落盘前确认 zip 里的包 id 与 URL 中的 pack_id
    一致（防止把别的包误传成更新）。复用与安装相同的 zip-slip 防护
    和 manifest 校验。Raises AgentPackInstallError / AgentPackError。
    """
    if not os.path.isfile(zip_path):
        raise AgentPackInstallError(f"zip not found: {zip_path}")
    if not zipfile.is_zipfile(zip_path):
        raise AgentPackInstallError(f"not a valid zip file: {zip_path}")
    tmp = tempfile.mkdtemp(prefix="agentpack_peek_")
    try:
        with zipfile.ZipFile(zip_path) as zf:
            _safe_extract(zf, tmp)
        return load_manifest(_find_pack_root(tmp))
    finally:
        shutil.rmtree(tmp, ignore_errors=True)


def install_from_zip(
    zip_path: str,
    agentpacks_dir: str,
    *,
    allow_shell: bool = False,
    trusted: bool = False,
) -> InstalledPack:
    """Install an AgentPack from a local .zip file.

    Args:
        zip_path: path to the .zip
        agentpacks_dir: install root (config.agentpacks_dir)
        allow_shell: permit a pack that declares shell:true (SR-003 — third
            party packs are refused shell unless explicitly allowed)
        trusted: first-party / built-in pack (bypasses the shell refusal)

    Returns the InstalledPack. Raises AgentPackInstallError / AgentPackError.
    """
    if not os.path.isfile(zip_path):
        raise AgentPackInstallError(f"zip not found: {zip_path}")
    if not zipfile.is_zipfile(zip_path):
        raise AgentPackInstallError(f"not a valid zip file: {zip_path}")

    tmp = tempfile.mkdtemp(prefix="agentpack_")
    try:
        with zipfile.ZipFile(zip_path) as zf:
            _safe_extract(zf, tmp)

        pack_root = _find_pack_root(tmp)
        manifest = load_manifest(pack_root)  # validates everything (SPEC §2)

        # SR-003: refuse third-party shell unless allowed.
        if manifest.permissions.shell and not (allow_shell or trusted):
            raise AgentPackInstallError(
                f"pack {manifest.id!r} requests shell access; refusing a "
                f"third-party pack with shell:true. Re-install with "
                f"allow_shell=True if you trust it."
            )

        dest = os.path.join(agentpacks_dir, manifest.id, manifest.version)
        if os.path.exists(dest):
            shutil.rmtree(dest)  # reinstall same version → overwrite
        os.makedirs(os.path.dirname(dest), exist_ok=True)
        shutil.move(pack_root, dest)

        # Reload from the final location so root_dir points at the install.
        final = load_manifest(dest)
        return InstalledPack(
            id=final.id, version=final.version, name=final.name,
            domain=final.domain, path=dest, manifest=final,
        )
    finally:
        shutil.rmtree(tmp, ignore_errors=True)


def list_installed(agentpacks_dir: str) -> List[InstalledPack]:
    """Enumerate installed packs (one InstalledPack per id/version).
    Skips dirs whose manifest fails to load (logs nothing — caller may)."""
    out: List[InstalledPack] = []
    if not os.path.isdir(agentpacks_dir):
        return out
    for pack_id in sorted(os.listdir(agentpacks_dir)):
        id_dir = os.path.join(agentpacks_dir, pack_id)
        if not os.path.isdir(id_dir):
            continue
        for version in sorted(os.listdir(id_dir)):
            vdir = os.path.join(id_dir, version)
            if not os.path.isdir(vdir):
                continue
            try:
                m = load_manifest(vdir)
            except (AgentPackError, OSError):
                continue
            out.append(InstalledPack(
                id=m.id, version=m.version, name=m.name,
                domain=m.domain, path=vdir, manifest=m,
            ))
    return out


def get_installed(agentpacks_dir: str, pack_id: str,
                  version: Optional[str] = None) -> Optional[InstalledPack]:
    """Return one installed pack by id (+ optional version; newest if None)."""
    matches = [p for p in list_installed(agentpacks_dir) if p.id == pack_id]
    if not matches:
        return None
    if version:
        for p in matches:
            if p.version == version:
                return p
        return None
    # Newest by version string sort (semver-ish; good enough for v0.1).
    return sorted(matches, key=lambda p: p.version)[-1]


def uninstall(agentpacks_dir: str, pack_id: str,
              version: Optional[str] = None) -> int:
    """Remove a pack (one version, or all versions if version is None).
    Returns the number of version dirs removed."""
    id_dir = os.path.join(agentpacks_dir, pack_id)
    if not os.path.isdir(id_dir):
        return 0
    removed = 0
    if version:
        vdir = os.path.join(id_dir, version)
        if os.path.isdir(vdir):
            shutil.rmtree(vdir)
            removed = 1
        # Clean up the now-empty id dir.
        if os.path.isdir(id_dir) and not os.listdir(id_dir):
            shutil.rmtree(id_dir)
    else:
        removed = len([
            v for v in os.listdir(id_dir)
            if os.path.isdir(os.path.join(id_dir, v))
        ])
        shutil.rmtree(id_dir)
    return removed
