"""VP export bundle import — POST /v1/virtual-personas/import (M3)."""

from __future__ import annotations

import json
import re
import shutil
import tempfile
import zipfile
from datetime import datetime, timezone
from io import BytesIO
from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING, Any

from niannian.virtual_persona.export_bundle import MANIFEST_SCHEMA_VERSION
from niannian.virtual_persona.pack import fresh_default_pack, read_pack, write_pack
from niannian.virtual_persona.store import VirtualPersonaValidationError, validate_profile_sections

if TYPE_CHECKING:
    from niannian.virtual_persona.store import VirtualPersonaStore

MANIFEST_NAME = "manifest.json"
_FORBIDDEN_PREFIXES = ("state/", "state/subject", "../")
_VP_ID_RE = re.compile(r"^[\w\-]{1,64}$")


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


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


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]) -> None:
    if manifest.get("schema_version") != MANIFEST_SCHEMA_VERSION:
        raise ImportBundleError(
            f"unsupported manifest schema_version: {manifest.get('schema_version')!r}"
        )
    files = manifest.get("files")
    if not isinstance(files, list) or not files:
        raise ImportBundleError("manifest files must be a non-empty list")
    for entry in files:
        if not isinstance(entry, str) or not _is_safe_bundle_path(entry):
            raise ImportBundleError(f"unsafe bundle path in manifest: {entry!r}")


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="vp-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 _slug_from_manifest(manifest: dict[str, Any]) -> str:
    raw = str(manifest.get("vp_id") or manifest.get("label") or "imported").strip()
    slug = re.sub(r"[^\w\-]+", "_", raw).strip("_")[:48]
    return slug or "imported"


def _resolve_vp_id(store: VirtualPersonaStore, slug: str) -> str:
    candidate = slug
    if _VP_ID_RE.match(candidate) and not store.vp_dir(candidate).exists():
        return candidate
    ts = int(datetime.now(timezone.utc).timestamp())
    suffix = f"-imported-{ts}"
    base_max = 64 - len(suffix)
    base = slug[:base_max] if len(slug) > base_max else slug
    base = base.rstrip("-_") or "imported"
    candidate = f"{base}{suffix}"
    if not _VP_ID_RE.match(candidate):
        raise ImportBundleError(f"cannot resolve vp_id from slug: {slug!r}")
    if store.vp_dir(candidate).exists():
        raise ImportBundleError(f"virtual persona already exists: {candidate}")
    return candidate


def _copy_bundle_files(bundle_root: Path, manifest: dict[str, Any], dest: Path) -> None:
    files = manifest.get("files") or []
    for rel in files:
        rel_str = str(rel)
        if rel_str == MANIFEST_NAME:
            continue
        src = bundle_root / rel_str
        if not src.is_file():
            raise ImportBundleError(f"bundle file missing: {rel_str}")
        target = dest / rel_str
        target.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(src, target)


def import_bundle(store: VirtualPersonaStore, data: bytes) -> dict[str, str]:
    """Import export bundle zip; create new VP at stage=customize."""
    extract_dir = _extract_zip_safe(data)
    try:
        bundle_root = _find_bundle_root(extract_dir)
        manifest = _read_manifest(bundle_root)
        _validate_manifest(manifest)

        slug = _slug_from_manifest(manifest)
        vp_id = _resolve_vp_id(store, slug)
        label = str(manifest.get("label") or slug).strip() or vp_id

        profile_path = bundle_root / "profile.md"
        if not profile_path.is_file():
            raise ImportBundleError("bundle missing profile.md")
        profile_md = profile_path.read_text(encoding="utf-8")
        validate_profile_sections(profile_md)

        row =         store.create(vp_id, label=label)
        dest = store.vp_dir(vp_id)
        (dest / "profile.md").write_text(profile_md, encoding="utf-8")
        _copy_bundle_files(bundle_root, manifest, dest)

        pack = fresh_default_pack()
        pack["stage"] = "customize"
        pack["imported_from"] = {
            "source_vp_id": manifest.get("vp_id"),
            "bundle_id": manifest.get("bundle_id"),
            "imported_at": _now_iso(),
        }
        if manifest.get("export_latest") is not None:
            pack["export_latest"] = manifest.get("export_latest")
        write_pack(store, vp_id, pack)

        return {"vp_id": vp_id, "slug": slug}
    finally:
        shutil.rmtree(extract_dir, ignore_errors=True)
