"""dmemfs/_archive.py — Pluggable archive extraction adapters.

v15 addition: provides ArchiveAdapter base class, ZipAdapter / TarAdapter
standard adapters, and the ``expand_archive()`` / ``expand_archive_streaming()``
public functions for extracting archives directly into a MemoryFileSystem.

References
----------
* spec_v15.md §8 (第8部)
* DetailedDesignSpec_v3.md §27
* DetailedDesignSpec_test_v3.md §23–§27
"""

from __future__ import annotations

import io
import posixpath
import tarfile
import warnings
import zipfile
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Iterator, Literal

try:
    from typing import Self
except ImportError:
    from typing_extensions import Self

if TYPE_CHECKING:
    from ._fs import MemoryFileSystem

__all__ = [
    "ArchiveAdapter",
    "ZipAdapter",
    "TarAdapter",
    "expand_archive",
    "expand_archive_streaming",
]


# ---------------------------------------------------------------------------
# Internal utility: path sanitization (Zip Slip prevention)
# ---------------------------------------------------------------------------


def _sanitize_archive_path(raw_path: str) -> str:
    """Convert an archive-internal raw path to a safe relative path.

    Never raises. Returns ``""`` for paths that reduce to nothing after
    sanitization (the caller should skip such entries).

    Processing steps
    ----------------
    1. Backslash → forward-slash normalization
    2. Strip leading ``/`` characters (absolute → relative)
    3. ``posixpath.normpath()`` to resolve ``..`` / ``.`` / ``//``
    4. Strip any remaining leading ``..`` components
    5. If nothing is left, return ``""``
    """
    p = raw_path.replace("\\", "/")
    p = p.lstrip("/")
    if not p:
        return ""
    normalized = posixpath.normpath(p)
    # Remove leading ".." components that survived normpath
    parts = [part for part in normalized.split("/") if part and part != ".."]
    if not parts:
        return ""
    return "/".join(parts)


# ---------------------------------------------------------------------------
# ArchiveAdapter base class
# ---------------------------------------------------------------------------


class ArchiveAdapter(ABC):
    """Base class for archive format adapters.

    Subclasses implement ``members()`` and ``_can_handle_impl()``.
    The public ``can_handle()`` classmethod handles BytesIO seek restoration
    and exception suppression automatically — subclasses must **not** override
    it; implement ``_can_handle_impl()`` instead.
    """

    @abstractmethod
    def members(self) -> Iterator[tuple[str, bytes]]:
        """Yield ``(raw_path_in_archive, file_bytes)`` for each file entry.

        Directory entries **must not** be yielded.
        Path normalization is the caller's responsibility.

        Notes
        -----
        This is an :func:`~abc.abstractmethod`.  Every concrete adapter subclass
        **must** implement this method.  Do not yield directory entries; only
        yield file members.
        """
        ...

    @classmethod
    def can_handle(cls, source: str | io.BytesIO) -> bool:
        """Return ``True`` if this adapter can process *source*.

        For ``BytesIO`` sources the seek position is always restored to its
        value before this call.  Any exception raised by ``_can_handle_impl``
        is caught and ``False`` is returned (fail-safe).

        Do **not** override in subclasses — implement ``_can_handle_impl``.
        """
        try:
            if isinstance(source, io.BytesIO):
                pos = source.tell()
                try:
                    return cls._can_handle_impl(source)
                finally:
                    source.seek(pos)
            return cls._can_handle_impl(source)
        except Exception:
            return False

    @classmethod
    @abstractmethod
    def _can_handle_impl(cls, source: str | io.BytesIO) -> bool:
        """Actual detection logic.

        May raise freely — ``can_handle()`` will catch any exception and
        return ``False``.  BytesIO seek restoration is guaranteed by the
        base ``can_handle()`` wrapper.
        """
        ...

    def close(self) -> None:
        """Release any held resources (default: no-op)."""

    def __enter__(self) -> Self:
        return self

    def __exit__(self, *exc: object) -> None:
        self.close()


# ---------------------------------------------------------------------------
# Standard adapters
# ---------------------------------------------------------------------------


class ZipAdapter(ArchiveAdapter):
    """Adapter for ZIP archives (uses stdlib ``zipfile``)."""

    def __init__(self, source: str | io.BytesIO) -> None:
        self._source = source

    def members(self) -> Iterator[tuple[str, bytes]]:
        with zipfile.ZipFile(self._source) as zf:
            for info in zf.infolist():
                if info.is_dir():
                    continue
                yield info.filename, zf.read(info.filename)

    @classmethod
    def _can_handle_impl(cls, source: str | io.BytesIO) -> bool:
        return zipfile.is_zipfile(source)


class TarAdapter(ArchiveAdapter):
    """Adapter for TAR archives (uses stdlib ``tarfile``).

    Supports ``.tar``, ``.tar.gz``, ``.tar.bz2``, ``.tar.xz``.
    Uses sequential ``for member in tf:`` iteration to avoid loading all
    member metadata into memory at once (lower peak memory than getmembers()).
    """

    def __init__(self, source: str | io.BytesIO) -> None:
        self._source = source

    def members(self) -> Iterator[tuple[str, bytes]]:
        kwargs: dict = (
            {"fileobj": self._source}
            if isinstance(self._source, io.BytesIO)
            else {"name": self._source}
        )
        with tarfile.open(**kwargs) as tf:
            for member in tf:
                if not member.isfile():
                    continue
                f = tf.extractfile(member)
                if f is not None:
                    yield member.name, f.read()

    @classmethod
    def _can_handle_impl(cls, source: str | io.BytesIO) -> bool:
        return tarfile.is_tarfile(source)


# ---------------------------------------------------------------------------
# Default adapter registry
# ---------------------------------------------------------------------------

_DEFAULT_ADAPTERS: list[type[ArchiveAdapter]] = [ZipAdapter, TarAdapter]


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------


def _detect(
    source: str | io.BytesIO,
    adapters: list[type[ArchiveAdapter]] | None,
) -> ArchiveAdapter:
    """Auto-detect and instantiate a suitable adapter for *source*.

    Parameters
    ----------
    source:
        File path or ``BytesIO`` containing the archive data.
    adapters:
        List of adapter **classes** to try (in order).  ``None`` falls back
        to ``_DEFAULT_ADAPTERS``.

    Raises
    ------
    ValueError
        If no registered adapter can handle *source*.
    """
    _adapters = adapters if adapters is not None else _DEFAULT_ADAPTERS
    for cls in _adapters:
        if cls.can_handle(source):
            return cls(source)
    names = [c.__name__ for c in _adapters]
    raise ValueError(f"No registered adapter can handle the given source. Tried adapters: {names}")


def _check_dest_collision(
    mfs: "MemoryFileSystem",
    tree: dict[str, bytes],
    on_conflict: Literal["raise", "overwrite"],
) -> None:
    """Pre-scan *tree* paths for collisions with existing MFS nodes.

    Called by ``expand_archive()`` before delegating to ``import_tree()``.
    Raises immediately on directory collision regardless of *on_conflict*.

    Parameters
    ----------
    mfs:
        Target ``MemoryFileSystem`` instance.
    tree:
        Dict of ``{dest_path: data}`` about to be imported.
    on_conflict:
        ``"raise"`` → raise ``FileExistsError`` on file collision.
        ``"overwrite"`` → emit ``UserWarning`` and continue.
    """
    for dest_path in tree:
        if mfs.is_dir(dest_path):
            raise IsADirectoryError(f"Cannot extract file over existing directory: {dest_path!r}")
        # exists() and not is_dir() ≡ is_file()
        if mfs.is_file(dest_path):
            if on_conflict == "raise":
                raise FileExistsError(f"File already exists in MFS: {dest_path!r}")
            warnings.warn(
                f"Existing MFS file {dest_path!r} will be overwritten.",
                UserWarning,
                stacklevel=3,  # warn() → _check_dest_collision() → expand_archive() → user
            )


# ---------------------------------------------------------------------------
# Public API: expand_archive() — atomic extraction
# ---------------------------------------------------------------------------


def expand_archive(
    mfs: "MemoryFileSystem",
    source: str | io.BytesIO,
    dest: str = "/",
    *,
    on_conflict: Literal["raise", "overwrite"] = "raise",
    adapter: ArchiveAdapter | None = None,
    adapters: list[type[ArchiveAdapter]] | None = None,
) -> None:
    """Extract an archive into *mfs* atomically (All-or-Nothing).

    The extraction delegates to :meth:`MemoryFileSystem.import_tree`, which
    provides full rollback on quota exceeded or any other error.

    Parameters
    ----------
    mfs:
        Target ``MemoryFileSystem`` instance.
    source:
        File path (``str``) or ``io.BytesIO`` containing the archive.
    dest:
        Destination prefix inside MFS.  Defaults to root ``"/"``
        (archive paths are appended directly).
    on_conflict:
        How to handle collisions:

        * ``"raise"`` *(default)* — raise ``FileExistsError`` / ``IsADirectoryError``
          and leave MFS unchanged.
        * ``"overwrite"`` — overwrite existing files (emit ``UserWarning``).
          Directory collisions always raise ``IsADirectoryError``.

        Note: ``"skip"`` is only available in :func:`expand_archive_streaming`.
    adapter:
        Provide a pre-instantiated :class:`ArchiveAdapter` directly.
        Bypasses automatic format detection.  Takes priority over *adapters*.
    adapters:
        List of adapter **classes** for auto-detection.  ``None`` uses the
        default ``[ZipAdapter, TarAdapter]``.

    Raises
    ------
    ValueError
        If *on_conflict* is not ``"raise"`` or ``"overwrite"``, or if no
        adapter can handle *source*.
    FileExistsError
        ``on_conflict="raise"`` and a file already exists at the destination.
    IsADirectoryError
        A directory already exists at the destination path.
    MFSQuotaExceededError
        Combined archive data exceeds MFS quota.  MFS is rolled back.
    """
    _VALID_CONFLICTS = {"raise", "overwrite"}
    if on_conflict not in _VALID_CONFLICTS:
        raise ValueError(
            f"Invalid on_conflict={on_conflict!r}. "
            f"expand_archive() accepts: {sorted(_VALID_CONFLICTS)}"
        )

    # Adapter selection: explicit instance > adapters list > defaults
    _adapter: ArchiveAdapter
    if adapter is not None:
        _adapter = adapter
    else:
        _adapter = _detect(source, adapters)

    # Normalize dest prefix
    dest_norm = dest.rstrip("/")

    # Build the import dict from archive members
    tree: dict[str, bytes] = {}
    with _adapter:
        for raw_path, data in _adapter.members():
            safe = _sanitize_archive_path(raw_path)
            if not safe:
                continue  # skip empty / dangerous paths

            dest_path = f"{dest_norm}/{safe}" if dest_norm else f"/{safe}"

            # Archive-internal duplicate detection
            if dest_path in tree:
                if on_conflict == "raise":
                    raise FileExistsError(
                        f"Duplicate path in archive (after sanitization): {dest_path!r}"
                    )
                warnings.warn(
                    f"Duplicate archive entry {dest_path!r}; "
                    "later entry will overwrite earlier one.",
                    UserWarning,
                    stacklevel=2,
                )
            tree[dest_path] = data

    # MFS existing-node collision scan
    _check_dest_collision(mfs, tree, on_conflict)

    # Atomic import (All-or-Nothing via import_tree)
    mfs.import_tree(tree)


# ---------------------------------------------------------------------------
# Public API: expand_archive_streaming() — streaming extraction
# ---------------------------------------------------------------------------


def expand_archive_streaming(
    mfs: "MemoryFileSystem",
    source: str | io.BytesIO,
    dest: str = "/",
    *,
    on_conflict: Literal["raise", "overwrite", "skip"] = "raise",
    adapter: ArchiveAdapter | None = None,
    adapters: list[type[ArchiveAdapter]] | None = None,
) -> int:
    """Extract an archive into *mfs* entry-by-entry (streaming).

    Unlike :func:`expand_archive`, this function does **not** provide
    All-or-Nothing atomicity.  If an error occurs mid-extraction, already
    extracted files remain in MFS.

    Parameters
    ----------
    mfs:
        Target ``MemoryFileSystem`` instance.
    source:
        File path (``str``) or ``io.BytesIO`` containing the archive.
    dest:
        Destination prefix inside MFS.
    on_conflict:
        How to handle collisions:

        * ``"raise"`` *(default)* — raise on first conflict, leaving partial
          extraction in MFS.
        * ``"overwrite"`` — overwrite existing files (emit ``UserWarning``).
        * ``"skip"`` — silently skip both archive-duplicate and MFS-existing
          entries.  Directory collisions always raise ``IsADirectoryError``.
    adapter:
        Pre-instantiated adapter; bypasses auto-detection (takes priority).
    adapters:
        Adapter class list for auto-detection.

    Returns
    -------
    int
        Number of successful **write operations** performed.  When *on_conflict*
        is ``"overwrite"`` and a duplicate archive entry is encountered, each
        write is counted individually (so the return value may exceed the
        number of unique destination paths).  Skipped entries are not counted.

    Raises
    ------
    ValueError
        Invalid *on_conflict* value or no adapter found for *source*.
    FileExistsError
        ``on_conflict="raise"`` and a collision is detected.
    IsADirectoryError
        A directory already exists at the destination path
        (regardless of *on_conflict*).
    MFSQuotaExceededError
        Quota exceeded during write.  Partial extraction may remain in MFS.
    """
    _VALID_CONFLICTS = {"raise", "overwrite", "skip"}
    if on_conflict not in _VALID_CONFLICTS:
        raise ValueError(
            f"Invalid on_conflict={on_conflict!r}. "
            f"expand_archive_streaming() accepts: {sorted(_VALID_CONFLICTS)}"
        )

    # Adapter selection
    _adapter: ArchiveAdapter
    if adapter is not None:
        _adapter = adapter
    else:
        _adapter = _detect(source, adapters)

    dest_norm = dest.rstrip("/")

    write_count = 0
    # Track paths seen in this archive run (for duplicate detection)
    seen: set[str] = set()

    with _adapter:
        for raw_path, data in _adapter.members():
            safe = _sanitize_archive_path(raw_path)
            if not safe:
                continue

            dest_path = f"{dest_norm}/{safe}" if dest_norm else f"/{safe}"

            # --- Conflict resolution (priority: archive-dup > MFS-existing) ---

            is_archive_dup = dest_path in seen

            if is_archive_dup:
                # Archive-internal duplicate
                if on_conflict == "raise":
                    raise FileExistsError(
                        f"Duplicate path in archive (after sanitization): {dest_path!r}"
                    )
                if on_conflict == "skip":
                    # First entry wins; skip subsequent duplicates
                    continue
                # on_conflict == "overwrite": fall through and overwrite
                warnings.warn(
                    f"Duplicate archive entry {dest_path!r}; "
                    "later entry will overwrite earlier one.",
                    UserWarning,
                    stacklevel=2,
                )
            else:
                # First occurrence: check MFS existing nodes
                seen.add(dest_path)

                if mfs.is_dir(dest_path):
                    # Directory collision is always an error
                    raise IsADirectoryError(
                        f"Cannot extract file over existing directory: {dest_path!r}"
                    )
                if mfs.is_file(dest_path):
                    # MFS existing file collision
                    if on_conflict == "raise":
                        raise FileExistsError(f"File already exists in MFS: {dest_path!r}")
                    if on_conflict == "skip":
                        continue
                    # on_conflict == "overwrite"
                    warnings.warn(
                        f"Existing MFS file {dest_path!r} will be overwritten.",
                        UserWarning,
                        stacklevel=2,
                    )

            # Ensure parent directories exist
            parent = posixpath.dirname(dest_path) or "/"
            mfs.mkdir(parent, exist_ok=True)

            with mfs.open(dest_path, "wb") as fh:
                fh.write(data)
            write_count += 1

    return write_count
