#!/usr/bin/env python3
# Copyright: 2026, Maximiliano Curia <maxy@debian.org>
#
# License: ISC
#  Permission to use, copy, modify, and/or distribute this software for any
#  purpose with or without fee is hereby granted, provided that the above
#  copyright notice and this permission notice appear in all copies.
#  .
#  THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH
#  REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
#  AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT,
#  INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
#  LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
#  OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
#  PERFORMANCE OF THIS SOFTWARE.

"""Archive expansion: populate the file tree with the contents of archives.

Walks tar/zip archives (and archives nested inside them, up to a configured
depth) and materialises their members as VirtualFileInfo leaves under
ArchiveDirInfo nodes in the tree built by tree.py.
"""

from __future__ import annotations

import collections.abc as abc
import contextlib
import functools
import tarfile
import tempfile
import zipfile
from pathlib import Path
from typing import TYPE_CHECKING

import xdg.Mime

from decopy.common.log import logger
from decopy.fstree.nodes import ArchiveDirInfo, DirInfo, VirtualFileInfo
from decopy.fstree.parsers import ARCHIVE_MIMES

if TYPE_CHECKING:
    from decopy.common.core_types import Options

# Importing zipfile_zstd (if installed) monkeypatches the stdlib zipfile to
# decode zstd-compressed members. Without it those members raise
# NotImplementedError when read; see warn_unsupported_compression.
with contextlib.suppress(ImportError):
    import zipfile_zstd  # type: ignore[import-untyped]  # noqa: F401


@functools.cache
def warn_unsupported_compression() -> None:
    """Warn, once per run, that an archive uses an undecodable compression.

    Cached so a tree full of such members logs a single actionable hint
    instead of one line per member. Reset with .cache_clear() in tests.
    """
    logger.warning(
        "Some archive members use a compression method the standard library "
        "cannot decode (e.g. zstd) and were skipped; install the optional "
        "'zipfile-zstd' package to read them."
    )


def _navigate_or_create(
    start: DirInfo, dir_parts: tuple[str, ...], options: Options
) -> DirInfo | None:
    """Navigate into dir_parts from start, creating DirInfo nodes as needed.

    Returns None if any component is excluded or collides with an existing file.
    """
    parent = start
    for part in dir_parts:
        if options.exclude_directory_re.search(part):
            return None
        sub = parent.get_or_create_subdir(part)
        if sub is None:
            return None  # name collision with a file
        parent = sub
    return parent


def _add_virtual_member(
    archive_dir: ArchiveDirInfo,
    member_path: str,
    content: bytes,
    options: Options,
) -> None:
    parts = Path(member_path).parts
    if not parts:
        return
    basename = parts[-1]
    if options.exclude_file_re.search(basename):
        return
    parent = _navigate_or_create(archive_dir, parts[:-1], options)
    if parent is None:
        return
    parent.file_list[basename] = VirtualFileInfo(
        parent=parent, name=basename, content=content
    )


def _iter_tar_members(
    archive_path: str, options: Options
) -> abc.Iterator[tuple[str, bytes]]:
    with tarfile.open(archive_path, mode="r:*") as tf:
        for member in tf.getmembers():
            if not member.isfile() and not member.islnk():
                continue
            if member.size > options.max_archive_member_size:
                logger.debug(
                    "Skipping oversized member %s (%d B)", member.name, member.size
                )
                continue
            fobj = tf.extractfile(member)
            if fobj is None:
                continue
            yield member.name, fobj.read()


def _iter_zip_members(
    archive_path: str, options: Options
) -> abc.Iterator[tuple[str, bytes]]:
    with zipfile.ZipFile(archive_path, "r") as zf:
        for info in zf.infolist():
            if info.is_dir():
                continue
            if info.file_size > options.max_archive_member_size:
                logger.debug(
                    "Skipping oversized member %s (%d B)",
                    info.filename,
                    info.file_size,
                )
                continue
            try:
                content = zf.read(info.filename)
            except NotImplementedError:
                warn_unsupported_compression()
                logger.debug(
                    "Skipping member %s with unsupported compression in %s",
                    info.filename,
                    archive_path,
                )
                continue
            yield info.filename, content


type _Opener = abc.Callable[[str, Options], abc.Iterator[tuple[str, bytes]]]

# Ordered container handlers: (exception meaning "not this format", opener).
# A new container (rar, 7z, ...) is added as another entry here rather than a
# new except arm in iter_archive_members.
_ARCHIVE_OPENERS: tuple[tuple[type[Exception], _Opener], ...] = (
    (tarfile.TarError, _iter_tar_members),
    (zipfile.BadZipFile, _iter_zip_members),
)


def iter_archive_members(
    archive_path: str, options: Options
) -> abc.Iterator[tuple[str, bytes]]:
    """Yield (member_path, content) for each regular file in a supported archive.

    Tries each opener in _ARCHIVE_OPENERS in order and uses the first whose
    format matches. Raises the last opener's error (e.g. zipfile.BadZipFile, or
    OSError from any opener) when no handler recognises the file.
    """
    last_error: Exception | None = None
    for not_this_format, opener in _ARCHIVE_OPENERS:
        try:
            yield from opener(archive_path, options)
        except not_this_format as error:
            last_error = error
        else:
            return
    if last_error is not None:
        raise last_error


def _expand_nested_member(
    archive_dir: ArchiveDirInfo,
    member_path: str,
    content: bytes,
    options: Options,
    depth: int,
) -> None:
    """Expand an archive member that is itself an archive, at the given depth."""
    parts = Path(member_path).parts
    if not parts:
        return
    basename = parts[-1]
    if options.exclude_file_re.search(basename):
        return
    parent = _navigate_or_create(archive_dir, parts[:-1], options)
    if parent is None:
        return
    nested_dir = ArchiveDirInfo(parent=parent, name=basename, archive_path=member_path)
    suffix = Path(basename).suffix
    tmp_path: str | None = None
    try:
        with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
            tmp.write(content)
            tmp_path = tmp.name
        expand_archive(tmp_path, nested_dir, options, depth)
        parent.file_list[basename] = nested_dir
    except (tarfile.TarError, zipfile.BadZipFile, OSError) as e:
        logger.warning("Failed to expand nested archive %s: %s", member_path, e)
        parent.file_list[basename] = VirtualFileInfo(
            parent=parent, name=basename, content=content
        )
    finally:
        if tmp_path is not None:
            with contextlib.suppress(OSError):
                Path(tmp_path).unlink()


def expand_archive(
    archive_path: str,
    archive_dir: ArchiveDirInfo,
    options: Options,
    depth: int,
) -> None:
    """Populate archive_dir with VirtualFileInfo leaves from the archive.

    Raises tarfile.TarError, zipfile.BadZipFile, or OSError on corrupt input.
    """
    for member_path, content in iter_archive_members(archive_path, options):
        if (
            str(xdg.Mime.get_type(member_path)) in ARCHIVE_MIMES
            and depth + 1 < options.max_archive_depth
        ):
            _expand_nested_member(archive_dir, member_path, content, options, depth + 1)
        else:
            _add_virtual_member(archive_dir, member_path, content, options)


def expand_subtree(
    dirinfo: DirInfo, fs_root: str, options: Options, depth: int
) -> None:
    """Replace every archive file under dirinfo with its expanded ArchiveDirInfo."""
    for name in list(dirinfo.file_list.keys()):
        subdir = dirinfo.get_subdir(name)
        if subdir is not None:
            expand_subtree(subdir, fs_root, options, depth)
            continue
        item = dirinfo.file_list[name]
        full_path = str(Path(fs_root) / item.fullname)
        if str(xdg.Mime.get_type(full_path)) not in ARCHIVE_MIMES:
            continue
        archive_dir = ArchiveDirInfo(parent=dirinfo, name=name, archive_path=full_path)
        try:
            expand_archive(full_path, archive_dir, options, depth)
            dirinfo.file_list[name] = archive_dir
        except (tarfile.TarError, zipfile.BadZipFile, OSError) as e:
            logger.warning("Failed to expand archive %s: %s", full_path, e)
