"""边打包边删除脚本：逐个打包顶层项为 tar 并删除原文件以节省空间。

扫描脚本同级目录下的 dvd 子目录顶层条目（文件和文件夹），将每个
顶层项独立打包为 .tar 归档，打包并验证完整性后删除原始文件或文件夹，
从而避免一次性占用双倍磁盘空间。已有 .tar / .zip 等归档不重复打包。

用法:
    python pack.py            # 预览 + 确认后执行
    python pack.py --yes      # 跳过确认直接执行
    python pack.py --dry-run  # 仅预览，不执行任何操作

参数:
    --yes      跳过交互确认，直接执行打包和删除
    --dry-run  仅预览将要打包和删除的条目，不实际操作
"""

from __future__ import annotations

import argparse
import logging
import os
import shutil
import sys
import tarfile
from pathlib import Path

# 目标子目录名（相对于脚本所在目录）
TARGET_DIR_NAME: str = "dvd"

# 归档后缀（仅 tar，不做 gzip 二次压缩）
ARCHIVE_SUFFIX: str = ".tar"

# 使用 GNU tar 格式：支持 >8GiB 单文件（base-256 大尺寸字段），
# 比默认 PAX 扩展头在 7-Zip/WinRAR 等 Windows 工具上兼容性更好。
# ustar 的 size 字段仅 11 位八进制，单文件上限约 8GiB-1，大文件会写坏。
TAR_FORMAT: int = tarfile.GNU_FORMAT

# 读写大文件时的拷贝缓冲（1MiB），降低超大文件打包开销
COPY_BUFSIZE: int = 1024 * 1024

# 已有归档后缀，扫描时跳过，避免把压缩包再打一层
EXISTING_ARCHIVE_SUFFIXES: tuple[str, ...] = (
    ".tar",
    ".tar.gz",
    ".tgz",
    ".zip",
)

# 排除的顶层条目名称（既不打包也不删除）
EXCLUDED_NAMES: frozenset[str] = frozenset({"temp"})


def setup_logger() -> logging.Logger:
    """配置并返回日志记录器。

    返回值:
        配置好的 Logger 实例，输出到标准错误流。
    """
    logger = logging.getLogger("pack_and_clean")
    if logger.handlers:
        return logger
    logger.setLevel(logging.INFO)
    handler = logging.StreamHandler(sys.stderr)
    handler.setFormatter(logging.Formatter("%(message)s"))
    logger.addHandler(handler)
    return logger


def is_archive(path: Path) -> bool:
    """判断路径是否为已存在的归档/压缩包。

    参数:
        path: 待判断的文件路径。

    返回值:
        True 表示该文件已经是 tar/zip 等归档，否则 False。
    """
    if not path.is_file():
        return False
    name = path.name.lower()
    return name.endswith(EXISTING_ARCHIVE_SUFFIXES)


def scan_items(root: Path) -> list[Path]:
    """扫描目标目录下的顶层待打包条目。

    遍历 root 的直接子条目，跳过已有归档（tar/zip 等）以及排除清单
    中的条目，返回所有需要打包的文件和文件夹路径。

    参数:
        root: 扫描的目标目录路径（脚本同级的 dvd）。

    返回值:
        待打包条目路径列表，按名称排序。
    """
    items: list[Path] = []

    for path in root.iterdir():
        # 跳过已有归档（.tar / .zip 等），避免重复打包
        if is_archive(path):
            continue
        # 跳过排除清单中的条目（既不打包也不删除）
        if path.name in EXCLUDED_NAMES:
            continue
        items.append(path)

    items.sort()
    return items


def iter_source_files(source: Path) -> list[tuple[str, int]]:
    """枚举源条目在归档中的预期成员名与字节大小。

    参数:
        source: 源文件或文件夹路径。

    返回值:
        (归档内成员名, 文件大小) 列表；仅包含普通文件。
    """
    results: list[tuple[str, int]] = []
    if source.is_file():
        results.append((source.name.replace("\\", "/"), source.stat().st_size))
        return results

    for path in sorted(source.rglob("*")):
        if not path.is_file():
            continue
        # tar 成员名统一使用正斜杠，与 tarfile 在 Windows 上的行为一致
        rel = path.relative_to(source).as_posix()
        arcname = f"{source.name}/{rel}"
        results.append((arcname, path.stat().st_size))
    return results


def create_archive(source: Path, archive: Path, logger: logging.Logger) -> bool:
    """将源条目一次性打包为 tar 归档（不经 gzip）。

    使用 GNU 格式以正确写入超过 8GiB 的单文件尺寸字段。

    参数:
        source: 待打包的源文件或文件夹路径。
        archive: 输出的归档路径。
        logger: 日志记录器。

    返回值:
        True 表示打包成功，False 表示失败。

    抛出异常:
        捕获所有 OSError 并记录日志，不向上抛出。
    """
    try:
        # GNU 格式 + 显式 copybufsize，避免 ustar 8GiB 上限与大文件慢拷贝
        with tarfile.open(
            archive,
            mode="w",
            format=TAR_FORMAT,
            copybufsize=COPY_BUFSIZE,
        ) as tar:
            tar.add(source, arcname=source.name)
            # 确保缓冲数据落盘，降低进程异常退出时产生残缺归档的概率
            if tar.fileobj is not None:
                tar.fileobj.flush()
                try:
                    os.fsync(tar.fileobj.fileno())
                except (OSError, AttributeError, ValueError):
                    pass
        return True
    except (OSError, tarfile.TarError) as exc:
        logger.error("打包失败 %s: %s", source, exc)
        # 清理可能产生的残缺归档
        if archive.exists():
            try:
                archive.unlink()
            except OSError:
                pass
        return False


def verify_archive(
    archive: Path, source: Path, logger: logging.Logger
) -> bool:
    """验证 tar 归档的完整性（结构 + 成员尺寸 + 可读数据区）。

    不仅读取成员列表，还会：
    1. 与源文件树交叉比对每个普通文件的大小；
    2. 对每个文件成员定位到数据区末尾，确认可读长度与 size 一致。

    这样可发现 ustar 大文件尺寸截断、写入中断等导致的“能列目录但解压失败”。

    参数:
        archive: 待验证的归档路径。
        source: 对应的源文件或文件夹路径。
        logger: 日志记录器。

    返回值:
        True 表示归档完整有效，False 表示已损坏。

    抛出异常:
        捕获所有 OSError 和 TarError 并记录日志，不向上抛出。
    """
    try:
        expected = dict(iter_source_files(source))
        with tarfile.open(archive, mode="r:", copybufsize=COPY_BUFSIZE) as tar:
            members = tar.getmembers()
            if not members:
                logger.error("归档为空或损坏: %s", archive)
                return False

            file_members = [m for m in members if m.isfile()]
            member_map = {m.name.replace("\\", "/"): m for m in file_members}

            if set(member_map) != set(expected):
                missing = sorted(set(expected) - set(member_map))
                extra = sorted(set(member_map) - set(expected))
                if missing:
                    logger.error(
                        "归档缺少文件 (%d): %s",
                        len(missing),
                        ", ".join(missing[:5])
                        + ("..." if len(missing) > 5 else ""),
                    )
                if extra:
                    logger.error(
                        "归档含多余文件 (%d): %s",
                        len(extra),
                        ", ".join(extra[:5])
                        + ("..." if len(extra) > 5 else ""),
                    )
                return False

            for name, src_size in expected.items():
                member = member_map[name]
                # 关键检测：ustar 截断后 member.size 会与真实源大小不一致
                if member.size != src_size:
                    logger.error(
                        "成员大小不匹配 %s: 归档=%d 源=%d",
                        name,
                        member.size,
                        src_size,
                    )
                    return False

                extracted = tar.extractfile(member)
                if extracted is None:
                    logger.error("无法读取成员数据: %s", name)
                    return False
                with extracted:
                    # 定位到数据区末尾，确认归档内实际可读长度完整
                    # （比全量重读 9GB+ 数据快得多，仍能发现截断）
                    extracted.seek(0, 2)
                    readable = extracted.tell()
                    if readable != src_size:
                        logger.error(
                            "成员数据区不完整 %s: 可读=%d 期望=%d",
                            name,
                            readable,
                            src_size,
                        )
                        return False

        return True
    except (OSError, tarfile.TarError) as exc:
        logger.error("归档验证失败 %s: %s", archive, exc)
        return False


def delete_source(source: Path, logger: logging.Logger) -> bool:
    """删除原始文件或文件夹。

    若源为文件夹则递归删除整个目录树，若为文件则直接删除。

    参数:
        source: 待删除的源路径。
        logger: 日志记录器。

    返回值:
        True 表示删除成功，False 表示失败。

    抛出异常:
        捕获 OSError 并记录日志，不向上抛出。
    """
    try:
        if source.is_dir():
            shutil.rmtree(source)
        else:
            source.unlink()
        return True
    except OSError as exc:
        logger.error("删除原文件失败 %s: %s", source, exc)
        return False


def preview(items: list[Path], logger: logging.Logger) -> None:
    """打印待打包条目的预览清单。

    参数:
        items: 待打包条目路径列表。
        logger: 日志记录器。
    """
    logger.info("=" * 60)
    logger.info("预览：将打包并删除 %d 个顶层条目", len(items))
    logger.info("-" * 60)
    for path in items:
        kind = "文件夹" if path.is_dir() else "文件"
        archive_name = path.name + ARCHIVE_SUFFIX
        logger.info("  [%s] %s -> %s", kind, path.name, archive_name)
    logger.info("-" * 60)
    logger.info(
        "格式: tar/GNU（支持>8GiB，无 gzip）| 验证: 尺寸交叉比对+数据区可读后再删除"
    )
    if EXCLUDED_NAMES:
        logger.info("排除: %s（不打包不删除）", ", ".join(sorted(EXCLUDED_NAMES)))
    logger.info("=" * 60)


def process_item(
    source: Path, root: Path, logger: logging.Logger
) -> bool:
    """处理单个条目：打包、验证、删除原文件。

    参数:
        source: 待打包的源路径。
        root: 根目录，压缩包输出到此目录。
        logger: 日志记录器。

    返回值:
        True 表示该条目处理成功，False 表示失败。
    """
    archive = root / (source.name + ARCHIVE_SUFFIX)

    # 若同名归档已存在，跳过避免覆盖
    if archive.exists():
        logger.error("归档已存在，跳过: %s", archive)
        return False

    logger.info("正在打包: %s", source.name)
    if not create_archive(source, archive, logger):
        return False

    logger.info("正在验证: %s", archive.name)
    if not verify_archive(archive, source, logger):
        logger.error("验证失败，保留原文件并删除坏归档: %s", source)
        try:
            if archive.exists():
                archive.unlink()
        except OSError as exc:
            logger.error("删除坏归档失败 %s: %s", archive, exc)
        return False

    logger.info("正在删除原文件: %s", source.name)
    if not delete_source(source, logger):
        logger.error("删除原文件失败，但归档已生成: %s", archive)
        return False

    logger.info("完成: %s -> %s", source.name, archive.name)
    return True


def parse_args(argv: list[str]) -> argparse.Namespace:
    """解析命令行参数。

    参数:
        argv: 命令行参数列表。

    返回值:
        解析后的参数命名空间。
    """
    parser = argparse.ArgumentParser(
        description=(
            "逐个打包顶层项为 tar 并删除原文件，"
            "边打包边删除以节省磁盘空间。"
        )
    )
    parser.add_argument(
        "--yes",
        action="store_true",
        help="跳过交互确认，直接执行。",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="仅预览，不执行任何打包或删除操作。",
    )
    return parser.parse_args(argv)


def main(argv: list[str] | None = None) -> int:
    """脚本主入口。

    执行扫描、预览、确认、逐个打包删除的完整流程。

    参数:
        argv: 可选的命令行参数列表，默认从 sys.argv 读取。

    返回值:
        0 表示全部成功，1 表示用户取消或存在失败项。
    """
    args = parse_args(argv if argv is not None else sys.argv[1:])
    logger = setup_logger()

    script_path = Path(__file__).resolve()
    root = script_path.parent / TARGET_DIR_NAME

    logger.info("工作目录: %s", root)
    logger.info("")

    if not root.is_dir():
        logger.error("目标目录不存在: %s", root)
        return 1

    items = scan_items(root)

    if not items:
        logger.info("无需打包：%s 下没有待处理的顶层条目。", root)
        return 0

    preview(items, logger)

    if args.dry_run:
        logger.info("已启用 --dry-run，不执行任何操作。")
        return 0

    if not args.yes:
        logger.info("")
        try:
            answer = input(
                "确认执行打包并删除原文件？此操作不可逆 [y/N]: "
            ).strip().lower()
        except (EOFError, KeyboardInterrupt):
            logger.info("已取消。")
            return 1
        if answer not in ("y", "yes"):
            logger.info("已取消，未做任何修改。")
            return 1

    logger.info("")
    success_count = 0
    fail_count = 0

    for source in items:
        if process_item(source, root, logger):
            success_count += 1
        else:
            fail_count += 1
        logger.info("")

    logger.info(
        "完成：成功 %d 项，失败 %d 项。", success_count, fail_count
    )
    return 0 if fail_count == 0 else 1


if __name__ == "__main__":
    sys.exit(main())
