"""Extract common archive formats to a destination directory."""
from __future__ import annotations

import os
import subprocess
import tarfile
import zipfile
from pathlib import Path


def extract_archive(archive_path: str, dest_dir: str) -> None:
    """Extract zip / tar.gz / git bundle into dest_dir."""
    name = os.path.basename(archive_path).lower()

    if name.endswith(".zip"):
        with zipfile.ZipFile(archive_path, "r") as zf:
            zf.extractall(dest_dir)
        return

    if name.endswith((".tar.gz", ".tgz", ".tar.bz2", ".tar.xz", ".tar")):
        with tarfile.open(archive_path, "r:*") as tf:
            tf.extractall(dest_dir)
        return

    if name.endswith(".bundle"):
        result = subprocess.run(
            ["git", "clone", archive_path, dest_dir],
            capture_output=True,
        )
        if result.returncode != 0:
            raise RuntimeError(
                f"git bundle extraction failed: {result.stderr.decode(errors='replace')}"
            )
        return

    raise RuntimeError(
        f"Unsupported archive format '{os.path.basename(archive_path)}'. "
        "Accepted: .zip, .tar.gz, .tgz, .bundle"
    )


def safe_extract_zip_entries(
    zip_path: str,
    *,
    max_entries: int,
    max_entry_bytes: int,
    max_total_bytes: int,
) -> list[tuple[str, bytes]]:
    """Read every file entry out of a zip, guarding against untrusted input.

    Unlike extract_archive() above (extractall(), no limits -- fine for the
    more controlled inputs it currently handles), this is for archives an
    external agent uploaded: entry count and declared total size are checked
    against the central directory *before* reading any bytes, each entry's
    path is checked for traversal, and each entry's declared size is capped
    individually. Never calls extractall() or .extract() (which can recreate
    symlinks from a crafted archive); each entry is read as plain bytes via
    zf.open() and returned to the caller to persist as an ordinary file at a
    path the caller controls.

    Mirrors the path-safety check in
    src/domain/devices/bundle_service.py::_extract_repo_bundle, the one
    existing safe zip-reading precedent in this codebase.

    Raises ValueError on any violation (bad path, oversized entry/archive,
    too many entries) -- the caller decides whether that fails the whole
    upload or is reported per-entry; this function itself never skips
    silently.
    """
    with zipfile.ZipFile(zip_path) as zf:
        infos = [info for info in zf.infolist() if not info.is_dir()]
        if len(infos) > max_entries:
            raise ValueError(f"Archive has more than {max_entries} files")
        if sum(info.file_size for info in infos) > max_total_bytes:
            raise ValueError(
                f"Archive's uncompressed contents exceed {max_total_bytes // (1024 * 1024)} MiB"
            )
        entries: list[tuple[str, bytes]] = []
        for info in infos:
            normalized = info.filename.replace("\\", "/")
            # Check the leading slash / drive letter *before* stripping it --
            # rel_name.strip("/") below would otherwise turn "/etc/passwd"
            # into "etc/passwd", which looks relative and would slip past an
            # is_absolute() check performed only on the stripped name (a real
            # gap in this codebase's other zip-path check, bundle_service.py's
            # _extract_repo_bundle, which strips before checking).
            if normalized.startswith("/") or (len(normalized) > 1 and normalized[1] == ":"):
                raise ValueError(f"Archive entry has an unsafe path: {info.filename!r}")
            rel_name = normalized.strip("/")
            if not rel_name:
                continue
            parts = Path(rel_name).parts
            if any(part in ("..", "") for part in parts) or Path(rel_name).is_absolute():
                raise ValueError(f"Archive entry has an unsafe path: {info.filename!r}")
            if info.file_size > max_entry_bytes:
                raise ValueError(
                    f"Archive entry {rel_name!r} exceeds {max_entry_bytes // (1024 * 1024)} MiB"
                )
            with zf.open(info, "r") as src:
                entries.append((rel_name, src.read()))
        return entries


def is_archive_filename(filename: str, content_type: str | None = None) -> bool:
    name = (filename or "").lower()
    ct = (content_type or "").lower()
    return (
        name.endswith((".zip", ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz", ".tar", ".bundle"))
        or "zip" in ct
        or "gzip" in ct
    )
