from __future__ import annotations

import os
import shutil
import uuid
import zipfile
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator

from liverylab_core import package_security


MANIFEST_NAME = "manifest.json"
WINDOWS_LEGACY_MAX_PATH = 259
WINDOWS_EXTENDED_MAX_PATH = 32700


def is_zip_package(path: str | Path) -> bool:
    return Path(path).suffix.lower() == ".zip"


def export_zip_path(out_dir: str | Path, package_name: str) -> Path:
    target = Path(out_dir).expanduser()
    if target.suffix.lower() == ".zip":
        return target
    return target / f"{package_name}.zip"


def _display_path(path: str | Path) -> str:
    try:
        return str(Path(path).resolve(strict=False))
    except Exception:
        return str(path)


def check_windows_path_length(path: str | Path, context: str = "using path") -> None:
    if os.name != "nt":
        return
    text = _display_path(path)
    if text.startswith("\\\\?\\"):
        return
    if len(text) <= WINDOWS_EXTENDED_MAX_PATH:
        return
    raise OSError(
        f"Windows path too long while {context}: {len(text)} characters "
        f"(extended limit {WINDOWS_EXTENDED_MAX_PATH}). Move LiveryLab closer to a drive root "
        "or use a shorter export/import folder.\n"
        f"Path: {text}"
    )


def native_path(path: str | Path) -> str:
    text = _display_path(path)
    if os.name != "nt" or text.startswith("\\\\?\\"):
        return text
    if len(text) <= WINDOWS_LEGACY_MAX_PATH:
        return text
    if text.startswith("\\\\"):
        return "\\\\?\\UNC\\" + text.lstrip("\\")
    return "\\\\?\\" + text


def ensure_parent(path: str | Path) -> None:
    parent = Path(path).parent
    os.makedirs(native_path(parent), exist_ok=True)


def copy_file(source: str | Path, target: str | Path) -> None:
    check_windows_path_length(target, "copying file")
    ensure_parent(target)
    shutil.copy2(native_path(source), native_path(target))


def zip_directory(source_root: str | Path, zip_path: str | Path) -> Path:
    source = Path(source_root)
    target = Path(zip_path)
    if not source.exists() or not source.is_dir():
        raise FileNotFoundError(f"Package staging folder not found: {source}")
    check_windows_path_length(target, "writing ZIP package")
    ensure_parent(target)
    if target.exists():
        os.unlink(native_path(target))
    with zipfile.ZipFile(native_path(target), "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
        for path in sorted(source.rglob("*"), key=lambda item: str(item).lower()):
            if not path.is_file():
                continue
            archive.write(native_path(path), path.relative_to(source).as_posix())
    return target


def _safe_member_path(root: Path, member_name: str) -> Path:
    raw = str(member_name or "").replace("\\", "/")
    if not raw or raw.endswith("/"):
        return root
    rel = Path(raw)
    if rel.is_absolute() or ".." in rel.parts:
        raise ValueError(f"Unsafe ZIP member path: {member_name}")
    target = (root / rel).resolve()
    try:
        target.relative_to(root.resolve())
    except ValueError as exc:
        raise ValueError(f"ZIP member escapes package root: {member_name}") from exc
    return target


def extract_zip(
    zip_path: str | Path,
    temp_parent: str | Path,
    required_manifest: str | None = MANIFEST_NAME,
    *,
    max_file_count: int | None = None,
    max_total_uncompressed_bytes: int | None = None,
    max_member_uncompressed_bytes: int | None = None,
    max_member_name_length: int | None = None,
    max_compression_ratio: int | None = None,
) -> Path:
    source = Path(zip_path).expanduser().resolve()
    if source.suffix.lower() != ".zip":
        raise ValueError(f"Package must be a .zip file: {source}")
    if not source.exists() or not source.is_file():
        raise FileNotFoundError(f"Package ZIP not found: {source}")
    temp_root = Path(temp_parent)
    temp_root.mkdir(parents=True, exist_ok=True)
    for _ in range(16):
        target_root = temp_root / f"p_{uuid.uuid4().hex[:8]}"
        try:
            target_root.mkdir(parents=True, exist_ok=False)
            break
        except FileExistsError:
            continue
    else:
        raise FileExistsError(f"Could not create unique package temp folder under: {temp_root}")
    try:
        with zipfile.ZipFile(source, "r") as archive:
            validate_options = {
                "required_manifest": required_manifest,
            }
            if max_file_count is not None:
                validate_options["max_file_count"] = max_file_count
            if max_total_uncompressed_bytes is not None:
                validate_options["max_total_uncompressed_bytes"] = max_total_uncompressed_bytes
            if max_member_uncompressed_bytes is not None:
                validate_options["max_member_uncompressed_bytes"] = max_member_uncompressed_bytes
            if max_member_name_length is not None:
                validate_options["max_member_name_length"] = max_member_name_length
            if max_compression_ratio is not None:
                validate_options["max_compression_ratio"] = max_compression_ratio
            package_security.validate_zip_archive(archive, **validate_options)
            for info in archive.infolist():
                if info.flag_bits & 0x1:
                    raise ValueError("Encrypted ZIP packages are not supported")
                target = _safe_member_path(target_root, info.filename)
                check_windows_path_length(target, f"extracting ZIP member {info.filename}")
            for info in archive.infolist():
                if info.is_dir():
                    continue
                target = _safe_member_path(target_root, info.filename)
                check_windows_path_length(target, f"extracting ZIP member {info.filename}")
                ensure_parent(target)
                with archive.open(info, "r") as src, open(native_path(target), "wb") as dst:
                    shutil.copyfileobj(src, dst)
    except Exception:
        shutil.rmtree(target_root, ignore_errors=True)
        raise
    if required_manifest and not (target_root / required_manifest).exists():
        shutil.rmtree(target_root, ignore_errors=True)
        raise FileNotFoundError(f"{required_manifest} not found in package ZIP: {source}")
    return target_root


@contextmanager
def package_root(
    path: str | Path,
    temp_parent: str | Path,
    allow_folder: bool = True,
    required_manifest: str | None = MANIFEST_NAME,
    *,
    max_file_count: int | None = None,
    max_total_uncompressed_bytes: int | None = None,
    max_member_uncompressed_bytes: int | None = None,
    max_member_name_length: int | None = None,
    max_compression_ratio: int | None = None,
) -> Iterator[Path]:
    source = Path(path).expanduser()
    if source.suffix.lower() == ".zip":
        extracted = extract_zip(
            source,
            temp_parent,
            required_manifest=required_manifest,
            max_file_count=max_file_count,
            max_total_uncompressed_bytes=max_total_uncompressed_bytes,
            max_member_uncompressed_bytes=max_member_uncompressed_bytes,
            max_member_name_length=max_member_name_length,
            max_compression_ratio=max_compression_ratio,
        )
        try:
            yield extracted
        finally:
            shutil.rmtree(extracted, ignore_errors=True)
        return
    if allow_folder and source.exists() and source.is_dir():
        yield source.resolve()
        return
    raise ValueError(f"Package must be a .zip file: {source}")
