#!/usr/bin/env python3
"""
resolve-symlinks.py — 并行扫描目录下所有符号链接,将指向普通文件的链接原地替换为真实文件。

替换时不会删除符号链接本身,而是将其重命名为 原名.link-bak 作为备份;
后可通过 --restore 扫描所有 .link-bak 并恢复原始符号链接。

支持目录和单个文件两种模式:
  - 传入目录 => 递归扫描其下所有符号链接并处理
  - 传入单个文件 => 仅处理该符号链接

用法:ohos-resolve-symlinks.py <目录|文件> [选项]
      ohos-resolve-symlinks.py <目录|文件> --restore

采用 ohos-sign-elf 风格的并行调度:
  - Master 遍历目录收集任务
  - Worker 池并行执行
  - 进度汇报
"""

import argparse
import os
import shutil
import signal
import sys
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
from typing import Generator, Tuple


# ---------------------------------------------------------------------------
# 工作单元
# ---------------------------------------------------------------------------

Result = Tuple[str, str]  # (path, status)


def backup_then_replace_one(link_path: str) -> Result:
    """将符号链接重命名为 .link-bak,再复制真实文件到原路径。

    安全策略:
      1. 先备份链接,再复制真实文件 —— 任何一步失败都可手动恢复。
      2. 只处理指向 *普通文件* 的符号链接(跳过指向目录、设备、不存在的目标)。
      3. 保留目标文件的 metadata(mtime、权限),owner 沿用原链接的 uid/gid。
    """
    try:
        link = Path(link_path)
        if not link.is_symlink():
            return (link_path, "skip-not-symlink")

        target = link.resolve()
        if not target.exists():
            return (link_path, f"skip-broken -> {target}")
        if not target.is_file():
            return (link_path, f"skip-not-file -> {target}")

        # 如果目标本身也是一个符号链接,再解析一层
        while target.is_symlink():
            target = target.resolve()

        # 第 1 步:备份原符号链接
        bak_path = link.parent / f"{link.name}.link-bak"
        if bak_path.exists():
            return (link_path, f"error: 备份文件已存在 {bak_path}")

        link.rename(bak_path)

        # 第 2 步:复制真实文件到原路径(link 已不存在,会创建普通文件)
        try:
            shutil.copy2(str(target), str(link), follow_symlinks=True)
            # 沿用原符号链接的 owner
            orig_stat = bak_path.lstat()
            os.chown(str(link), orig_stat.st_uid, orig_stat.st_gid, follow_symlinks=False)
        except Exception:
            # 复制失败,尽量回滚
            try:
                os.unlink(str(link))
            except OSError:
                pass
            bak_path.rename(link_path)  # 恢复符号链接
            raise

        return (link_path, f"ok ({os.path.getsize(link)} B), backup at {bak_path.name}")

    except Exception as exc:
        return (link_path, f"error: {exc}")


def backup_then_replace_dir_one(link_path: str) -> Result:
    """将指向目录的符号链接重命名为 .link-bak,再复制目标目录的全部内容到原路径。

    复制时解析所有内部符号连接到实际文件/目录(copytree(symlinks=False)),
    替换后原路径变为一个不含任何符号连接的纯普通目录树。
    """
    try:
        link = Path(link_path)
        if not link.is_symlink():
            return (link_path, "skip-not-symlink")

        target = link.resolve()
        if not target.is_dir():
            return (link_path, f"skip-not-dir -> {target}")

        bak_path = link.parent / f"{link.name}.link-bak"
        if bak_path.exists():
            return (link_path, f"error: 备份已存在 {bak_path}")

        # 第 1 步:备份目录符号链接
        link.rename(bak_path)

        # 第 2 步:复制目标目录的全部内容(不保留任何符号连接)
        try:
            shutil.copytree(
                str(target), str(link),
                symlinks=False,  # 不保留符号链接,全部解析为实际文件/目录
                ignore_dangling_symlinks=True,  # 忽略断链,不中断复制
            )
        except Exception:
            # 复制失败,回滚
            try:
                shutil.rmtree(str(link), ignore_errors=True)
            except Exception:
                pass
            bak_path.rename(link_path)  # 恢复符号链接
            raise

        return (link_path, f"ok (dir), backup at {bak_path.name}")

    except Exception as exc:
        return (link_path, f"error: {exc}")


def restore_one(bak_path_str: str) -> Result:
    """将 .link-bak 符号链接恢复回原名,并删除之前替换出来的普通文件。"""
    try:
        bak_path = Path(bak_path_str)
        if not bak_path.name.endswith('.link-bak') or not bak_path.is_symlink():
            return (bak_path_str, "skip-not-link-bak")

        # 推断原始路径
        original_path_str = bak_path_str[:-len('.link-bak')]
        original_path = Path(original_path_str)

        # 如果原始路径存在且是目录,跳过(非预期状态)
        if original_path.is_dir():
            return (bak_path_str, "skip-original-is-dir")

        # 删除之前替换出来的普通文件(或残留的符号链接)
        orig_exists = original_path.exists() or original_path.is_symlink()
        if orig_exists:
            original_path.unlink()

        # 将 .link-bak 符号链接重命名回原名
        bak_path.rename(original_path_str)

        if orig_exists:
            return (bak_path_str, f"restored (deleted replaced file)")
        else:
            return (bak_path_str, f"restored (original path was already absent)")

    except Exception as exc:
        return (bak_path_str, f"error: {exc}")


def restore_dir_one(bak_path_str: str) -> Result:
    """删除整个复制的真实目录树,再将 .link-bak 符号链接恢复回原名。"""
    try:
        bak_path = Path(bak_path_str)
        if not bak_path.name.endswith('.link-bak') or not bak_path.is_symlink():
            return (bak_path_str, "skip-not-link-bak")

        original_path_str = bak_path_str[:-len('.link-bak')]
        original_path = Path(original_path_str)

        # 原始路径必须是真实目录(之前替换出来的)
        if not original_path.is_dir():
            return (bak_path_str, "skip-not-real-dir")

        # 删除整个复制出来的目录树
        shutil.rmtree(str(original_path))

        # 恢复符号链接
        bak_path.rename(original_path_str)

        return (bak_path_str, "restored (deleted copied directory)")

    except Exception as exc:
        return (bak_path_str, f"error: {exc}")


# ---------------------------------------------------------------------------
# Master:扫描收集任务
# ---------------------------------------------------------------------------

def collect_symlinks(root: str) -> Generator[str, None, None]:
    """递归枚举 root 下所有符号链接(排除 .link-bak 文件),避免无限递归跟随。"""
    try:
        with os.scandir(root) as it:
            for entry in it:
                if entry.is_symlink():
                    # 排除已经是我们自己产生的备份
                    if not entry.name.endswith('.link-bak'):
                        yield entry.path
                if entry.is_dir() and not entry.is_symlink():
                    yield from collect_symlinks(entry.path)
    except PermissionError as exc:
        print(f"[warn] 权限不足,跳过目录: {exc}", file=sys.stderr)
    except OSError as exc:
        print(f"[warn] 扫描失败: {exc}", file=sys.stderr)


def collect_link_bak(root: str) -> Generator[str, None, None]:
    """递归枚举 root 下所有 .link-bak 符号链接(还原时使用)。"""
    try:
        with os.scandir(root) as it:
            for entry in it:
                if entry.is_symlink() and entry.name.endswith('.link-bak'):
                    yield entry.path
                if entry.is_dir() and not entry.is_symlink():
                    yield from collect_link_bak(entry.path)
    except PermissionError as exc:
        print(f"[warn] 权限不足,跳过目录: {exc}", file=sys.stderr)
    except OSError as exc:
        print(f"[warn] 扫描失败: {exc}", file=sys.stderr)


# ---------------------------------------------------------------------------
# 主流程
# ---------------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(
        description="并行将目录下的符号链接替换为实际文件(可还原)。"
    )
    parser.add_argument(
        "root",
        help="扫描路径(目录或单个文件)",
    )
    parser.add_argument(
        "--restore",
        action="store_true",
        help="还原模式:扫描 .link-bak 备份文件并恢复原始符号链接",
    )
    parser.add_argument(
        "-j", "--jobs",
        type=int,
        default=os.cpu_count() or 4,
        help="并行 Worker 数(默认 = CPU 核数)",
    )
    parser.add_argument(
        "--skip-names",
        default="brew",
        help="替换时要跳过的文件名黑名单,逗号分隔,不区分路径(默认 brew)",
    )
    parser.add_argument(
        "--include-dir",
        action="store_true",
        help="也替换/还原指向目录的符号链接(默认仅处理文件符号链接)",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="仅列出待处理的条目,不实际执行操作",
    )
    args = parser.parse_args()

    target = os.path.abspath(os.path.expanduser(args.root))

    if not os.path.lexists(target):
        print(f"错误:路径不存在: {target}", file=sys.stderr)
        sys.exit(1)

    # 判断是目录还是单个文件:符号链接即使是目录链接也视为单个文件
    is_dir = os.path.isdir(target) and not os.path.islink(target)

    # -----------------------------------------------------------------------
    # 还原模式
    # -----------------------------------------------------------------------
    if args.restore:
        if is_dir:
            # 如果是真实目录,先检查同级是否有 .link-bak 备份(可能是之前替换的目录链接)
            bak_candidate = target + '.link-bak'
            if os.path.islink(bak_candidate):
                bak_links = [bak_candidate]
                is_dir = False
                print(f"还原单个目录备份: {target}")
            else:
                print(f"还原模式 - 扫描目录: {target}")
                bak_links = list(collect_link_bak(target))
        else:
            # 单个文件:检查是否为 .link-bak 符号链接
            if target.endswith('.link-bak') and os.path.islink(target):
                bak_links = [target]
            else:
                # 尝试补全 .link-bak:如果给出的是原始文件名,自动补上后缀
                bak_candidate = target + '.link-bak'
                if os.path.islink(bak_candidate):
                    bak_links = [bak_candidate]
                else:
                    print(f"错误:指定的文件不是符号链接也不是 .link-bak: {target}", file=sys.stderr)
                    sys.exit(1)

        total = len(bak_links)
        print(f"找到 {total} 个 .link-bak 备份")

        if total == 0:
            return

        # 分类:文件备份 vs 目录备份
        file_baks = []
        dir_baks = []
        for p in bak_links:
            original = p[:-len('.link-bak')]
            if os.path.isdir(original):
                dir_baks.append(p)
            else:
                file_baks.append(p)

        # --include-dir 未启用时跳过目录备份
        skipped_dirs = 0
        if not args.include_dir and dir_baks:
            skipped_dirs = len(dir_baks)
            print(f"跳过 {skipped_dirs} 个目录备份(使用 --include-dir 可处理目录)")
            dir_baks = []

        if args.dry_run:
            for p in bak_links:
                bak_target = os.readlink(p)
                original = p[:-len('.link-bak')] if p.endswith('.link-bak') else p
                kind = "  [目录]" if p in dir_baks else ""
                print(f"  将恢复: {original}")
                print(f"    备份: {p}  ->  {bak_target}{kind}")
            return

        # 本地辅助函数:跑一个并行还原阶段
        def _run_restore(items, worker_fn, label):
            if not items:
                print(f"  (无 {label} 需要处理)")
                return (0, 0, 0)
            n = len(items)
            print(f"  {label}: {n} 个, {args.jobs if n > 1 else '单任务'} workers")
            restored_cnt = err_cnt = skip_cnt = 0
            done = 0
            with ProcessPoolExecutor(max_workers=args.jobs if n > 1 else 1) as pool:
                fut_map = {pool.submit(worker_fn, p): p for p in items}
                try:
                    for fut in as_completed(fut_map):
                        done += 1
                        path, status = fut.result()
                        pct = done * 100 // n

                        if status.startswith("restored"):
                            restored_cnt += 1
                        elif status.startswith("skip"):
                            skip_cnt += 1
                        else:
                            err_cnt += 1

                        print(f"  [{done}/{n} {pct:3d}%] {path}")
                        if not status.startswith("restored"):
                            print(f"           {status}")
                except KeyboardInterrupt:
                    print("\n收到中断,等待正在进行的 worker 完成…", file=sys.stderr)
                    for fut in fut_map:
                        fut.cancel()
                    pool.shutdown(wait=False)
                    print(f"已取消。已处理 {done}/{n},剩余已跳过。", file=sys.stderr)
                    sys.exit(130)
            return (restored_cnt, skip_cnt, err_cnt)

        signal.signal(signal.SIGINT, signal.SIG_IGN)

        print()
        print(f"阶段 1/2: 恢复文件备份")
        r_files, s_files, e_files = _run_restore(file_baks, restore_one, "文件")
        print()
        print(f"阶段 2/2: 恢复目录备份")
        r_dirs, s_dirs, e_dirs = _run_restore(dir_baks, restore_dir_one, "目录")

        print()
        r_total = r_files + r_dirs
        s_total = s_files + s_dirs
        e_total = e_files + e_dirs
        print(f"还原完成: 已处理 {total},恢复 {r_total},跳过 {s_total},错误 {e_total}"
              f"{',目录备份跳过 ' + str(skipped_dirs) if skipped_dirs else ''}")
        return

    # -----------------------------------------------------------------------
    # 替换模式(默认)
    # -----------------------------------------------------------------------
    skip_set = {n.strip() for n in args.skip_names.split(",")} if args.skip_names else set()

    if is_dir:
        print(f"扫描阶段: {target}")
        links = list(collect_symlinks(target))
    else:
        links = [target]

    total = len(links)
    print(f"找到 {total} 条符号链接")

    if total == 0:
        return

    # 按黑名单过滤
    to_replace = []
    skipped_blacklist = 0
    for p in links:
        name = os.path.basename(p)
        if name in skip_set:
            skipped_blacklist += 1
        else:
            to_replace.append(p)

    if skipped_blacklist:
        print(f"黑名单过滤: 跳过 {skipped_blacklist} 条(位于 {args.skip_names} 黑名单)")

    # 分类:文件符号链接 vs 目录符号链接
    file_links = []
    dir_links = []
    for p in to_replace:
        if os.path.islink(p) and os.path.isdir(p):
            dir_links.append(p)
        else:
            file_links.append(p)

    # --include-dir 未启用时,目录符号链接归入跳过
    skipped_dirs = 0
    if not args.include_dir and dir_links:
        skipped_dirs = len(dir_links)
        print(f"跳过 {skipped_dirs} 条目录符号链接(使用 --include-dir 可处理目录)")
        dir_links = []

    # --dry-run
    if args.dry_run:
        for p in links:
            if os.path.islink(p):
                link_target = os.readlink(p)
            else:
                link_target = "(not a symlink)"
            name = os.path.basename(p)
            tag = "  [blacklisted]" if name in skip_set else ""
            kind = "  [目录]" if p in dir_links else "  [目录](已跳过)" if (os.path.islink(p) and os.path.isdir(p)) else ""
            print(f"  {p}  ->  {link_target}{tag}{kind}")
        print(f"  其中: {len(file_links)} 条文件链接, {len(dir_links)} 条目录链接"
              f"{'(跳过 ' + str(skipped_dirs) + ')' if skipped_dirs else ''}")
        return

    # 本地辅助函数:跑一个并行替换阶段
    def _run_replace(items, worker_fn, label):
        if not items:
            print(f"  (无 {label} 需要处理)")
            return (0, 0, 0)
        n = len(items)
        print(f"  {label}: {n} 个, {args.jobs if n > 1 else '单任务'} workers(备份为 .link-bak)")
        ok_cnt = err_cnt = skip_cnt = 0
        done = 0
        with ProcessPoolExecutor(max_workers=args.jobs if n > 1 else 1) as pool:
            fut_map = {pool.submit(worker_fn, p): p for p in items}
            try:
                for fut in as_completed(fut_map):
                    done += 1
                    path, status = fut.result()
                    pct = done * 100 // n

                    if status.startswith("ok"):
                        ok_cnt += 1
                    elif status.startswith("skip"):
                        skip_cnt += 1
                    else:
                        err_cnt += 1

                    print(f"  [{done}/{n} {pct:3d}%] {path}")
                    if not status.startswith("ok"):
                        print(f"           {status}")
            except KeyboardInterrupt:
                print("\n收到中断,等待正在进行的 worker 完成…", file=sys.stderr)
                for fut in fut_map:
                    fut.cancel()
                pool.shutdown(wait=False)
                print(f"已取消。已处理 {done}/{n},剩余已跳过。", file=sys.stderr)
                sys.exit(130)
        return (ok_cnt, skip_cnt, err_cnt)

    # 没有实际要处理的任务时提前退出
    if not file_links and not dir_links:
        print("所有符号链接均被跳过,无操作。")
        return

    signal.signal(signal.SIGINT, signal.SIG_IGN)

    print()
    phase_file = "1/1" if not dir_links else "1/2"
    print(f"阶段 {phase_file}: 替换文件符号链接")
    ok_files, skip_files, err_files = _run_replace(file_links, backup_then_replace_one, "文件符号链接")

    ok_dirs = skip_dirs = err_dirs = 0
    if dir_links:
        print()
        print(f"阶段 2/2: 替换目录符号链接")
        ok_dirs, skip_dirs, err_dirs = _run_replace(dir_links, backup_then_replace_dir_one, "目录符号链接")

    # 汇总
    print()
    ok_total = ok_files + ok_dirs
    skip_total = skip_files + skip_dirs
    err_total = err_files + err_dirs
    extras = []
    if skipped_blacklist:
        extras.append(f"黑名单跳过 {skipped_blacklist}")
    if skipped_dirs:
        extras.append(f"目录跳过 {skipped_dirs}")
    extras_str = f",{','.join(extras)}" if extras else ""
    print(f"完成: 总 {len(links)},替换 {ok_total},跳过(条件){skip_total},错误 {err_total}{extras_str}")


if __name__ == "__main__":
    main()