#!/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.

"""Tests for the archive-expansion feature.

Covers the CLI flags and Options fields that enable expansion; VirtualFileInfo
(a file held in memory inside an archive) and ArchiveDirInfo (an expanded
archive directory); the expand_archive helper that populates an ArchiveDirInfo
from a tar or zip; its integration into RootInfo.build via subtree expansion;
and the full pipeline output with and without archive expansion.
"""

from __future__ import annotations

import io
import sys
import tarfile
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest.mock import patch

from decopy import cmdoptions
from decopy.decopy import run_pipeline
from decopy.fstree import archives, nodes, tree

_COPYRIGHT_BYTES = b"# Copyright (C) 2024 Test Author <test@example.com>\n"

_BASE = ["--no-progress", "--root", "."]


# Helpers


def _make_tar_gz(members: dict[str, bytes]) -> bytes:
    buf = io.BytesIO()
    with tarfile.open(fileobj=buf, mode="w:gz") as tar:
        for name, content in members.items():
            info = tarfile.TarInfo(name=name)
            info.size = len(content)
            tar.addfile(info, io.BytesIO(content))
    return buf.getvalue()


def _make_zip(members: dict[str, bytes]) -> bytes:
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w") as zf:
        for name, content in members.items():
            zf.writestr(name, content)
    return buf.getvalue()


def _walk_virtual_files(node: nodes.DirInfo) -> list[nodes.FileInfo]:
    """Recursively collect non-DirInfo leaves under a DirInfo node."""
    items = []
    for child in node.file_list.values():
        if isinstance(child, nodes.DirInfo):
            items.extend(_walk_virtual_files(child))
        else:
            items.append(child)
    return items


# Options fields for archive expansion


class TestArchiveOptions(unittest.TestCase):
    """CLI flags and Options fields for archive expansion."""

    def test_expand_archives_defaults_to_true(self):
        opts = cmdoptions.process_options(_BASE)
        self.assertTrue(opts.expand_archives)

    def test_no_expand_archives_flag(self):
        opts = cmdoptions.process_options([*_BASE, "--no-expand-archives"])
        self.assertFalse(opts.expand_archives)

    def test_expand_archives_flag_last_wins(self):
        opts = cmdoptions.process_options(
            [*_BASE, "--no-expand-archives", "--expand-archives"]
        )
        self.assertTrue(opts.expand_archives)
        opts = cmdoptions.process_options(
            [*_BASE, "--expand-archives", "--no-expand-archives"]
        )
        self.assertFalse(opts.expand_archives)

    def test_max_archive_depth_default(self):
        opts = cmdoptions.process_options(_BASE)
        self.assertEqual(opts.max_archive_depth, 1)

    def test_max_archive_depth_flag(self):
        opts = cmdoptions.process_options([*_BASE, "--max-archive-depth", "3"])
        self.assertEqual(opts.max_archive_depth, 3)

    def test_max_archive_member_size_default(self):
        opts = cmdoptions.process_options(_BASE)
        self.assertEqual(opts.max_archive_member_size, 10 * 1024 * 1024)

    def test_max_archive_member_size_flag(self):
        opts = cmdoptions.process_options(
            [*_BASE, "--max-archive-member-size", "1048576"]
        )
        self.assertEqual(opts.max_archive_member_size, 1024 * 1024)


# VirtualFileInfo and ArchiveDirInfo


class TestVirtualFileInfo(unittest.TestCase):
    """VirtualFileInfo holds in-memory bytes and parses them."""

    def _make_vfi(self, name: str, content: bytes) -> nodes.VirtualFileInfo:
        root = tree.RootInfo(root=".")
        parent = nodes.ArchiveDirInfo(
            parent=root, name="arch.tar.gz", archive_path="arch.tar.gz"
        )
        return nodes.VirtualFileInfo(parent=parent, name=name, content=content)

    def test_name_is_set(self):
        vfi = self._make_vfi("src.py", b"")
        self.assertEqual(vfi.name, "src.py")

    def test_fullname_includes_archive_and_member(self):
        vfi = self._make_vfi("src.py", b"")
        self.assertIn("arch.tar.gz", vfi.fullname)
        self.assertIn("src.py", vfi.fullname)

    def test_process_licenses_finds_copyright(self):
        opts = cmdoptions.process_options([*_BASE, "--text"])
        vfi = self._make_vfi("src.py", b"# Copyright 2024 Bob <bob@example.com>\n")
        result = vfi.process_licenses(root=".", options=opts)
        self.assertIsNotNone(result)
        self.assertTrue(any("Bob" in str(h) for h in vfi.copyrights))

    def test_process_licenses_returns_none_on_fullname_exclusion(self):
        opts = cmdoptions.process_options([*_BASE, "--text", "--exclude", r".*\.py$"])
        vfi = self._make_vfi("src.py", b"# Copyright 2024 X\n")
        result = vfi.process_licenses(root=".", options=opts)
        self.assertIsNone(result)


class TestArchiveDirInfo(unittest.TestCase):
    """ArchiveDirInfo marks an archive expansion root."""

    def test_is_dirinfo_subclass(self):
        self.assertTrue(issubclass(nodes.ArchiveDirInfo, nodes.DirInfo))

    def test_archive_path_stored(self):
        root = tree.RootInfo(root=".")
        adi = nodes.ArchiveDirInfo(
            parent=root, name="arch.zip", archive_path="/full/path/arch.zip"
        )
        self.assertEqual(adi.archive_path, "/full/path/arch.zip")


# FileInfo.is_excluded


class TestFileInfoIsExcluded(unittest.TestCase):
    """R7: FileInfo.is_excluded(options) -> bool extracts the shared exclusion guard."""

    def _make_fi(self, name: str) -> nodes.FileInfo:
        root = tree.RootInfo(root=".")
        return nodes.FileInfo(parent=root, name=name)

    def test_not_excluded_by_default(self):
        opts = cmdoptions.process_options(_BASE)
        fi = self._make_fi("src.py")
        self.assertFalse(fi.is_excluded(opts))

    def test_excluded_by_fullname_re(self):
        opts = cmdoptions.process_options([*_BASE, "--exclude", r".*\.py$"])
        fi = self._make_fi("src.py")
        self.assertTrue(fi.is_excluded(opts))

    def test_not_excluded_when_pattern_mismatches(self):
        opts = cmdoptions.process_options([*_BASE, "--exclude", r".*\.c$"])
        fi = self._make_fi("src.py")
        self.assertFalse(fi.is_excluded(opts))

    def test_excluded_by_special_re(self):
        opts = cmdoptions.process_options(_BASE)
        # debian/copyright is always excluded by exclude_special_re
        root = tree.RootInfo(root=".")
        debian_dir = nodes.DirInfo(parent=root, name="debian")
        root.file_list["debian"] = debian_dir
        fi = nodes.FileInfo(parent=debian_dir, name="copyright")
        self.assertTrue(fi.is_excluded(opts))


# iter_archive_members


class TestIterArchiveMembers(unittest.TestCase):
    """R6: iter_archive_members yields (path, bytes) for tar and zip."""

    def _iter_bytes(
        self,
        data: bytes,
        suffix: str,
        extra_flags: list[str] | None = None,
    ) -> list[tuple[str, bytes]]:
        opts = cmdoptions.process_options([*_BASE, *(extra_flags or [])])
        with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
            f.write(data)
            fpath = f.name
        try:
            return list(archives.iter_archive_members(fpath, opts))
        finally:
            Path(fpath).unlink()

    def test_tar_gz_yields_path_and_bytes(self):
        members = self._iter_bytes(
            _make_tar_gz({"src/main.py": _COPYRIGHT_BYTES}), ".tar.gz"
        )
        paths = [p for p, _ in members]
        self.assertIn("src/main.py", paths)
        content = next(c for p, c in members if p == "src/main.py")
        self.assertEqual(content, _COPYRIGHT_BYTES)

    def test_zip_yields_path_and_bytes(self):
        members = self._iter_bytes(_make_zip({"README": _COPYRIGHT_BYTES}), ".zip")
        paths = [p for p, _ in members]
        self.assertIn("README", paths)

    def test_tar_symlink_not_yielded(self):
        buf = io.BytesIO()
        with tarfile.open(fileobj=buf, mode="w:gz") as tar:
            link = tarfile.TarInfo(name="link")
            link.type = tarfile.SYMTYPE
            link.linkname = "target"
            tar.addfile(link)
            real = tarfile.TarInfo(name="real.py")
            real.size = len(_COPYRIGHT_BYTES)
            tar.addfile(real, io.BytesIO(_COPYRIGHT_BYTES))
        members = self._iter_bytes(buf.getvalue(), ".tar.gz")
        paths = [p for p, _ in members]
        self.assertNotIn("link", paths)
        self.assertIn("real.py", paths)

    def test_tar_oversized_not_yielded(self):
        big = b"x" * 1000
        members = self._iter_bytes(
            _make_tar_gz({"big.txt": big, "small.py": _COPYRIGHT_BYTES}),
            ".tar.gz",
            ["--max-archive-member-size", "100"],
        )
        paths = [p for p, _ in members]
        self.assertNotIn("big.txt", paths)
        self.assertIn("small.py", paths)

    def test_zip_oversized_not_yielded(self):
        big = b"x" * 1000
        members = self._iter_bytes(
            _make_zip({"big.txt": big, "small.py": _COPYRIGHT_BYTES}),
            ".zip",
            ["--max-archive-member-size", "100"],
        )
        paths = [p for p, _ in members]
        self.assertNotIn("big.txt", paths)
        self.assertIn("small.py", paths)

    def test_zip_yields_correct_content(self):
        members = self._iter_bytes(_make_zip({"README": _COPYRIGHT_BYTES}), ".zip")
        content = next(c for p, c in members if p == "README")
        self.assertEqual(content, _COPYRIGHT_BYTES)

    def test_zip_directory_entry_not_yielded(self):
        buf = io.BytesIO()
        with zipfile.ZipFile(buf, "w") as zf:
            zf.writestr("pkg/", b"")
            zf.writestr("pkg/mod.py", _COPYRIGHT_BYTES)
        members = self._iter_bytes(buf.getvalue(), ".zip")
        paths = [p for p, _ in members]
        self.assertNotIn("pkg/", paths)
        self.assertIn("pkg/mod.py", paths)

    def test_tar_directory_not_yielded(self):
        buf = io.BytesIO()
        with tarfile.open(fileobj=buf, mode="w:gz") as tar:
            directory = tarfile.TarInfo(name="pkg")
            directory.type = tarfile.DIRTYPE
            tar.addfile(directory)
            real = tarfile.TarInfo(name="pkg/mod.py")
            real.size = len(_COPYRIGHT_BYTES)
            tar.addfile(real, io.BytesIO(_COPYRIGHT_BYTES))
        members = self._iter_bytes(buf.getvalue(), ".tar.gz")
        paths = [p for p, _ in members]
        self.assertNotIn("pkg", paths)
        self.assertIn("pkg/mod.py", paths)

    def test_tar_hardlink_yielded(self):
        buf = io.BytesIO()
        with tarfile.open(fileobj=buf, mode="w:gz") as tar:
            real = tarfile.TarInfo(name="a.py")
            real.size = len(_COPYRIGHT_BYTES)
            tar.addfile(real, io.BytesIO(_COPYRIGHT_BYTES))
            link = tarfile.TarInfo(name="b.py")
            link.type = tarfile.LNKTYPE
            link.linkname = "a.py"
            tar.addfile(link)
        members = self._iter_bytes(buf.getvalue(), ".tar.gz")
        self.assertIn("b.py", [p for p, _ in members])
        content = next(c for p, c in members if p == "b.py")
        self.assertEqual(content, _COPYRIGHT_BYTES)

    def test_plain_uncompressed_tar_yields_path_and_bytes(self):
        buf = io.BytesIO()
        with tarfile.open(fileobj=buf, mode="w") as tar:
            info = tarfile.TarInfo(name="x.py")
            info.size = len(_COPYRIGHT_BYTES)
            tar.addfile(info, io.BytesIO(_COPYRIGHT_BYTES))
        members = self._iter_bytes(buf.getvalue(), ".tar")
        self.assertIn("x.py", [p for p, _ in members])

    def test_corrupt_archive_raises(self):
        with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as f:
            f.write(b"not an archive")
            fpath = f.name
        try:
            with self.assertRaises((tarfile.TarError, zipfile.BadZipFile, OSError)):
                opts = cmdoptions.process_options(_BASE)
                list(archives.iter_archive_members(fpath, opts))
        finally:
            Path(fpath).unlink()


# Unsupported compression (e.g. zstd) handling


class TestUnsupportedCompression(unittest.TestCase):
    """A member decopy cannot decompress is skipped with a one-time hint."""

    def setUp(self) -> None:
        # The "warn once" guard is process-global; reset it per test.
        archives.warn_unsupported_compression.cache_clear()

    def _iter_with_unreadable(
        self, unreadable: set[str], members: dict[str, bytes]
    ) -> tuple[list[tuple[str, bytes]], list[str]]:
        """Iterate a zip, forcing the named members to fail decompression."""
        opts = cmdoptions.process_options(_BASE)
        real_read = zipfile.ZipFile.read

        def fake_read(self, name, pwd=None):  # type: ignore[no-untyped-def]
            if name in unreadable:
                msg = "That compression method is not supported"
                raise NotImplementedError(msg)
            return real_read(self, name, pwd)

        with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as f:
            f.write(_make_zip(members))
            fpath = f.name
        try:
            with (
                patch.object(zipfile.ZipFile, "read", fake_read),
                self.assertLogs("decopy", level="WARNING") as cm,
            ):
                result = list(archives.iter_archive_members(fpath, opts))
            return result, cm.output
        finally:
            Path(fpath).unlink()

    def test_unreadable_member_skipped_others_yielded(self):
        result, _ = self._iter_with_unreadable(
            {"a.py"}, {"a.py": _COPYRIGHT_BYTES, "b.py": _COPYRIGHT_BYTES}
        )
        paths = [p for p, _ in result]
        self.assertNotIn("a.py", paths)
        self.assertIn("b.py", paths)

    def test_unreadable_member_does_not_raise(self):
        # Regression: NotImplementedError previously escaped as a raw traceback.
        result, _ = self._iter_with_unreadable({"a.py"}, {"a.py": _COPYRIGHT_BYTES})
        self.assertEqual(result, [])

    def test_warns_about_zipfile_zstd_dependency(self):
        _, logs = self._iter_with_unreadable({"a.py"}, {"a.py": _COPYRIGHT_BYTES})
        self.assertTrue(any("zipfile-zstd" in line for line in logs))

    def test_warns_only_once(self):
        _, logs = self._iter_with_unreadable(
            {"a.py", "b.py"},
            {"a.py": _COPYRIGHT_BYTES, "b.py": _COPYRIGHT_BYTES},
        )
        hint_lines = [line for line in logs if "zipfile-zstd" in line]
        self.assertEqual(len(hint_lines), 1)

    def test_zstd_member_read_when_dependency_present(self):
        if not hasattr(zipfile, "ZIP_ZSTANDARD"):
            self.skipTest("zipfile-zstd not installed")
        buf = io.BytesIO()
        with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_ZSTANDARD) as zf:
            zf.writestr("README", _COPYRIGHT_BYTES)
        opts = cmdoptions.process_options(_BASE)
        with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as f:
            f.write(buf.getvalue())
            fpath = f.name
        try:
            members = list(archives.iter_archive_members(fpath, opts))
        finally:
            Path(fpath).unlink()
        content = next(c for p, c in members if p == "README")
        self.assertEqual(content, _COPYRIGHT_BYTES)


# expand_archive


class TestExpandArchive(unittest.TestCase):
    """expand_archive populates an ArchiveDirInfo from tar or zip."""

    def _make_archive_dir(self, name: str) -> nodes.ArchiveDirInfo:
        root = tree.RootInfo(root=".")
        return nodes.ArchiveDirInfo(parent=root, name=name, archive_path="")

    def _expand_bytes(
        self,
        data: bytes,
        suffix: str,
        extra_flags: list[str] | None = None,
    ) -> nodes.ArchiveDirInfo:
        if extra_flags is None:
            extra_flags = []
        opts = cmdoptions.process_options([*_BASE, *extra_flags])
        archive_dir = self._make_archive_dir(f"arch{suffix}")
        with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
            f.write(data)
            fpath = f.name
        try:
            archives.expand_archive(fpath, archive_dir, opts, depth=0)
        finally:
            Path(fpath).unlink()
        return archive_dir

    def test_tar_gz_member_becomes_virtual_file(self):
        data = _make_tar_gz({"src/main.py": _COPYRIGHT_BYTES})
        archive_dir = self._expand_bytes(data, ".tar.gz")
        names = [f.name for f in _walk_virtual_files(archive_dir)]
        self.assertIn("main.py", names)

    def test_zip_member_becomes_virtual_file(self):
        data = _make_zip({"README": _COPYRIGHT_BYTES})
        archive_dir = self._expand_bytes(data, ".zip")
        names = [f.name for f in _walk_virtual_files(archive_dir)]
        self.assertIn("README", names)

    def test_tar_symlink_is_skipped(self):
        buf = io.BytesIO()
        with tarfile.open(fileobj=buf, mode="w:gz") as tar:
            link = tarfile.TarInfo(name="link")
            link.type = tarfile.SYMTYPE
            link.linkname = "target"
            tar.addfile(link)
            real = tarfile.TarInfo(name="real.py")
            real.size = len(_COPYRIGHT_BYTES)
            tar.addfile(real, io.BytesIO(_COPYRIGHT_BYTES))
        archive_dir = self._expand_bytes(buf.getvalue(), ".tar.gz")
        names = [f.name for f in _walk_virtual_files(archive_dir)]
        self.assertNotIn("link", names)
        self.assertIn("real.py", names)

    def test_oversized_member_is_skipped(self):
        big = b"x" * 1000  # much larger than _COPYRIGHT_BYTES (52 bytes)
        data = _make_tar_gz({"big.txt": big, "small.py": _COPYRIGHT_BYTES})
        archive_dir = self._expand_bytes(
            data, ".tar.gz", ["--max-archive-member-size", "100"]
        )
        names = [f.name for f in _walk_virtual_files(archive_dir)]
        self.assertNotIn("big.txt", names)
        self.assertIn("small.py", names)

    def test_corrupt_archive_raises(self):
        archive_dir = self._make_archive_dir("bad.tar.gz")
        opts = cmdoptions.process_options(_BASE)
        with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as f:
            f.write(b"not a tar file")
            fpath = f.name
        try:
            with self.assertRaises((tarfile.TarError, zipfile.BadZipFile, OSError)):
                archives.expand_archive(fpath, archive_dir, opts, depth=0)
        finally:
            Path(fpath).unlink()

    def test_virtual_file_content_is_parseable(self):
        data = _make_tar_gz({"src/main.py": _COPYRIGHT_BYTES})
        archive_dir = self._expand_bytes(data, ".tar.gz", ["--text"])
        opts = cmdoptions.process_options([*_BASE, "--text"])
        for vfi in _walk_virtual_files(archive_dir):
            if vfi.name == "main.py":
                vfi.process_licenses(".", opts)
                self.assertTrue(any("Test Author" in str(h) for h in vfi.copyrights))
                break
        else:
            self.fail("main.py not found in expanded archive")


# archives.expand_subtree and recompute_total


class TestExpandArchivesInSubtree(unittest.TestCase):
    """archives.expand_subtree integrates with RootInfo.build."""

    def test_archive_file_replaced_by_archive_dir_info(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            archive_data = _make_tar_gz({"src/main.py": _COPYRIGHT_BYTES})
            (Path(tmpdir) / "vendor.tar.gz").write_bytes(archive_data)
            opts = cmdoptions.process_options(["--no-progress", "--root", tmpdir])
            filetree = tree.RootInfo.build(opts)
            self.assertIsInstance(filetree["vendor.tar.gz"], nodes.ArchiveDirInfo)

    def test_corrupt_archive_restores_original_file_info(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            (Path(tmpdir) / "bad.tar.gz").write_bytes(b"not a tar")
            opts = cmdoptions.process_options(["--no-progress", "--root", tmpdir])
            filetree = tree.RootInfo.build(opts)
            item = filetree["bad.tar.gz"]
            self.assertIsInstance(item, nodes.FileInfo)
            self.assertNotIsInstance(item, nodes.ArchiveDirInfo)

    def test_no_expand_archives_leaves_file_info(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            archive_data = _make_tar_gz({"src/main.py": _COPYRIGHT_BYTES})
            (Path(tmpdir) / "vendor.tar.gz").write_bytes(archive_data)
            opts = cmdoptions.process_options(
                ["--no-progress", "--root", tmpdir, "--no-expand-archives"]
            )
            filetree = tree.RootInfo.build(opts)
            self.assertNotIsInstance(filetree["vendor.tar.gz"], nodes.ArchiveDirInfo)

    def test_recompute_total_reflects_virtual_files(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            archive_data = _make_tar_gz(
                {
                    "a.py": _COPYRIGHT_BYTES,
                    "b.py": _COPYRIGHT_BYTES,
                }
            )
            (Path(tmpdir) / "vendor.tar.gz").write_bytes(archive_data)
            opts = cmdoptions.process_options(["--no-progress", "--root", tmpdir])
            filetree = tree.RootInfo.build(opts)
            # 1 archive expands to 2 virtual files; total must be > 1
            self.assertGreater(filetree.total, 1)


# max_archive_depth - nested archive expansion control


def _make_nested_tar() -> bytes:
    """outer.tar.gz containing inner.tar.gz containing inner.py."""
    inner = _make_tar_gz({"inner.py": _COPYRIGHT_BYTES})
    return _make_tar_gz({"inner.tar.gz": inner})


class TestMaxArchiveDepth(unittest.TestCase):
    """max_archive_depth controls nested archive expansion."""

    def test_default_depth_leaves_nested_archive_as_virtual_file(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            (Path(tmpdir) / "outer.tar.gz").write_bytes(_make_nested_tar())
            opts = cmdoptions.process_options(["--no-progress", "--root", tmpdir])
            filetree = tree.RootInfo.build(opts)
            outer = filetree["outer.tar.gz"]
            self.assertIsInstance(outer, nodes.ArchiveDirInfo)
            assert isinstance(outer, nodes.DirInfo)
            inner = outer.file_list.get("inner.tar.gz")
            self.assertIsInstance(inner, nodes.VirtualFileInfo)
            self.assertNotIsInstance(inner, nodes.ArchiveDirInfo)

    def test_depth_2_expands_nested_archive(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            (Path(tmpdir) / "outer.tar.gz").write_bytes(_make_nested_tar())
            opts = cmdoptions.process_options(
                ["--no-progress", "--root", tmpdir, "--max-archive-depth", "2"]
            )
            filetree = tree.RootInfo.build(opts)
            outer = filetree["outer.tar.gz"]
            self.assertIsInstance(outer, nodes.ArchiveDirInfo)
            assert isinstance(outer, nodes.DirInfo)
            inner = outer.file_list.get("inner.tar.gz")
            self.assertIsInstance(inner, nodes.ArchiveDirInfo)

    def test_depth_2_nested_content_is_accessible(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            (Path(tmpdir) / "outer.tar.gz").write_bytes(_make_nested_tar())
            opts = cmdoptions.process_options(
                ["--no-progress", "--root", tmpdir, "--max-archive-depth", "2"]
            )
            filetree = tree.RootInfo.build(opts)
            outer = filetree["outer.tar.gz"]
            assert isinstance(outer, nodes.DirInfo)
            inner_archive = outer.file_list.get("inner.tar.gz")
            assert isinstance(inner_archive, nodes.ArchiveDirInfo)
            inner_file = inner_archive.file_list.get("inner.py")
            self.assertIsInstance(inner_file, nodes.VirtualFileInfo)

    def test_corrupt_nested_archive_falls_back_to_virtual_file(self):
        # inner member is corrupt; should fall back to VirtualFileInfo, not crash
        corrupt_inner = b"not an archive"
        outer = _make_tar_gz({"bad.tar.gz": corrupt_inner})
        with tempfile.TemporaryDirectory() as tmpdir:
            (Path(tmpdir) / "outer.tar.gz").write_bytes(outer)
            opts = cmdoptions.process_options(
                ["--no-progress", "--root", tmpdir, "--max-archive-depth", "2"]
            )
            filetree = tree.RootInfo.build(opts)
            outer_dir = filetree["outer.tar.gz"]
            self.assertIsInstance(outer_dir, nodes.ArchiveDirInfo)
            assert isinstance(outer_dir, nodes.DirInfo)
            inner = outer_dir.file_list.get("bad.tar.gz")
            self.assertIsInstance(inner, nodes.VirtualFileInfo)
            self.assertNotIsInstance(inner, nodes.ArchiveDirInfo)


# End-to-end integration


class TestArchiveExpansionEndToEnd(unittest.TestCase):
    """Full pipeline output with/without archive expansion."""

    def _run(self, root: str, extra_flags: list[str] | None = None) -> str:
        if extra_flags is None:
            extra_flags = []
        opts = cmdoptions.process_options(
            ["--no-progress", "--no-glob", "--root", root, *extra_flags]
        )
        buf = io.StringIO()
        with patch.object(sys, "stdout", buf):
            run_pipeline(opts)
        return buf.getvalue()

    def test_default_expands_archives_virtual_paths_in_output(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            archive_data = _make_tar_gz({"src/main.py": _COPYRIGHT_BYTES})
            (Path(tmpdir) / "vendor.tar.gz").write_bytes(archive_data)
            out = self._run(tmpdir)
            # virtual path vendor.tar.gz/... must appear in the Files: stanza
            self.assertIn("vendor.tar.gz/", out)
            self.assertIn("Test Author", out)

    def test_no_expand_archives_treats_archive_as_single_file(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            archive_data = _make_tar_gz({"src/main.py": _COPYRIGHT_BYTES})
            (Path(tmpdir) / "vendor.tar.gz").write_bytes(archive_data)
            out = self._run(tmpdir, ["--no-expand-archives"])
            # The sub-path separator must NOT appear
            self.assertNotIn("vendor.tar.gz/", out)


if __name__ == "__main__":
    unittest.main()
