"""Skills bundle import — POST /v1/skills/import (SK24-M2)."""

from __future__ import annotations

import json
import shutil
import tempfile
import zipfile
from io import BytesIO
from pathlib import Path, PurePosixPath
from typing import Any

from niannian.skills.loader import SkillLoader
from niannian.skills.registry import parse_skill_file

MANIFEST_NAME = "manifest.json"
_FORBIDDEN_PREFIXES = ("default/", "state/", "state/subject", "export/", "custom/", "../")


class ImportBundleError(ValueError):
    """Invalid or unsafe skills import bundle."""


def _is_safe_bundle_path(rel_path: str) -> bool:
    """Reject path traversal, absolute paths, and forbidden prefixes."""
    raw = str(rel_path or "").strip().replace("\\", "/")
    if not raw or raw.startswith("/") or raw.startswith("../") or "/../" in raw:
        return False
    parts = PurePosixPath(raw).parts
    if ".." in parts or raw.startswith(".."):
        return False
    normalized = PurePosixPath(raw).as_posix()
    for prefix in _FORBIDDEN_PREFIXES:
        if normalized == prefix.rstrip("/") or normalized.startswith(prefix):
            return False
    return True


def _validate_manifest(manifest: dict[str, Any]) -> list[str]:
    files = manifest.get("files")
    if isinstance(files, list) and files:
        rel_paths = [str(entry) for entry in files]
    else:
        skills = manifest.get("skills")
        if not isinstance(skills, list) or not skills:
            raise ImportBundleError("manifest must include files or skills")
        rel_paths = []
        for entry in skills:
            if not isinstance(entry, dict):
                raise ImportBundleError("manifest skills entries must be objects")
            path = str(entry.get("path") or "").replace("\\", "/")
            if path.startswith("export/"):
                path = path[len("export/") :]
            if path:
                rel_paths.append(path)
        if not rel_paths:
            raise ImportBundleError("manifest skills must list at least one path")
    for entry in rel_paths:
        if not _is_safe_bundle_path(entry):
            raise ImportBundleError(f"unsafe bundle path in manifest: {entry!r}")
    return rel_paths


def _read_manifest(bundle_root: Path) -> dict[str, Any]:
    path = bundle_root / MANIFEST_NAME
    if not path.is_file():
        raise ImportBundleError("manifest.json missing from bundle")
    try:
        with path.open(encoding="utf-8") as f:
            data = json.load(f)
    except (OSError, json.JSONDecodeError) as exc:
        raise ImportBundleError("manifest.json unreadable or invalid JSON") from exc
    if not isinstance(data, dict):
        raise ImportBundleError("manifest.json must be an object")
    return data


def _extract_zip_safe(data: bytes) -> Path:
    if not data:
        raise ImportBundleError("empty bundle upload")
    tmp = tempfile.mkdtemp(prefix="skills-import-")
    root = Path(tmp)
    try:
        with zipfile.ZipFile(BytesIO(data), "r") as zf:
            for info in zf.infolist():
                name = str(info.filename or "").replace("\\", "/").lstrip("/")
                if not name or name.endswith("/"):
                    continue
                if not _is_safe_bundle_path(name):
                    raise ImportBundleError(f"unsafe zip entry path: {info.filename!r}")
                dest = root / name
                dest.parent.mkdir(parents=True, exist_ok=True)
                with zf.open(info) as src, dest.open("wb") as out:
                    shutil.copyfileobj(src, out)
    except zipfile.BadZipFile as exc:
        shutil.rmtree(root, ignore_errors=True)
        raise ImportBundleError("bundle is not a valid zip archive") from exc
    except ImportBundleError:
        shutil.rmtree(root, ignore_errors=True)
        raise
    except OSError as exc:
        shutil.rmtree(root, ignore_errors=True)
        raise ImportBundleError(f"failed to extract bundle: {exc}") from exc
    return root


def _find_bundle_root(extract_dir: Path) -> Path:
    direct = extract_dir / MANIFEST_NAME
    if direct.is_file():
        return extract_dir
    candidates: list[Path] = []
    for child in extract_dir.iterdir():
        if child.is_dir() and (child / MANIFEST_NAME).is_file():
            candidates.append(child)
    if len(candidates) == 1:
        return candidates[0]
    if not candidates:
        raise ImportBundleError("manifest.json not found in bundle")
    raise ImportBundleError("bundle contains multiple manifest roots")


def _skill_name_from_rel(rel_path: str) -> str:
    parts = PurePosixPath(rel_path).parts
    if len(parts) != 2 or parts[1] != "SKILL.md":
        raise ImportBundleError(f"unexpected bundle path (expected {{name}}/SKILL.md): {rel_path!r}")
    return parts[0]


def import_bundle(loader: SkillLoader, data: bytes) -> dict[str, list[dict[str, str]]]:
    """Import export bundle zip; write skills under skills/custom/."""
    extract_dir = _extract_zip_safe(data)
    try:
        bundle_root = _find_bundle_root(extract_dir)
        manifest = _read_manifest(bundle_root)
        rel_paths = _validate_manifest(manifest)

        imported: list[dict[str, str]] = []
        seen: set[str] = set()
        for rel_str in rel_paths:
            if rel_str == MANIFEST_NAME:
                continue
            if not rel_str.endswith("/SKILL.md"):
                continue
            skill_name = _skill_name_from_rel(rel_str)
            src = bundle_root / rel_str
            if not src.is_file():
                raise ImportBundleError(f"bundle file missing: {rel_str}")
            dest = loader.root / "custom" / skill_name / "SKILL.md"
            dest.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(src, dest)
            rec = parse_skill_file(dest, readonly=False)
            if rec is None:
                raise ImportBundleError(f"invalid SKILL.md in bundle: {rel_str}")
            if skill_name not in seen:
                seen.add(skill_name)
                imported.append({"name": rec.name})

        if not imported:
            raise ImportBundleError("no skills found in bundle")
        loader.reload()
        return {"imported": imported}
    finally:
        shutil.rmtree(extract_dir, ignore_errors=True)
