#!/usr/bin/env python3
import argparse
import re
import shutil
from collections.abc import Iterable
from pathlib import Path
from subprocess import check_output, run

IGNORE_LIB_PREFIXES = ("/usr/lib/", "/System/", "@executable_path")
RPATH_PATTERN = re.compile(r"@rpath/(.*)")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Copies shared libraries into the macOS app bundle."
    )
    parser.add_argument("-a", "--app-name")
    parser.add_argument(
        "--copy-only",
        action="store_true",
        help="Only copy libraries, do not update links.",
    )
    parser.add_argument(
        "--links-only",
        action="store_true",
        help="Only update links, do not copy libraries.",
    )
    return parser.parse_args()


def should_ignore_lib(lib_path: str) -> bool:
    return any(lib_path.startswith(prefix) for prefix in IGNORE_LIB_PREFIXES)


def gather_libs(
    binary_path: Path, visited_paths: set[Path] | None = None
) -> Iterable[Path]:
    if visited_paths is None:
        visited_paths = set()
    visited_paths.add(binary_path)

    parent_path = binary_path.parent
    output = check_output(["otool", "-L", str(binary_path)], text=True)
    libs = [line.split()[0] for line in output.split("\n")[1:] if line]

    for lib in libs:
        match = RPATH_PATTERN.match(lib)
        lib_path = parent_path / (match.group(1) if match else lib)

        if should_ignore_lib(str(lib_path)) or lib_path == binary_path:
            continue

        yield lib_path
        if lib_path not in visited_paths:
            yield from gather_libs(lib_path, visited_paths=visited_paths)


def copy_libs(frameworks_path: Path, library_paths: set[Path]) -> None:
    frameworks_path.mkdir(parents=True, exist_ok=True)
    for lib_path in library_paths:
        target_path = frameworks_path / lib_path.name
        if not target_path.exists():
            print(f"Copying {lib_path} to {target_path}")
            shutil.copy2(lib_path, target_path)


def update_links(binary_path: Path) -> None:
    output = check_output(["otool", "-L", str(binary_path)], text=True)
    libs = [line.split()[0] for line in output.split("\n")[1:] if line]

    for lib in libs:
        if should_ignore_lib(lib):
            continue

        lib_name = Path(lib).name
        target = f"@executable_path/../Frameworks/{lib_name}"
        print(f"Updating link for {lib_name} in {binary_path}")
        run(["install_name_tool", "-change", lib, target, str(binary_path)])

        if lib_name == binary_path.name:
            print(f"Updating id for {lib_name} in {binary_path}")
            run(["install_name_tool", "-id", target, str(binary_path)])


def main() -> None:
    args = parse_args()

    app_bundle_path = Path(f"/tmp/{args.app_name}.app")
    app_binary_path = app_bundle_path / f"Contents/MacOS/TRX"
    frameworks_path = app_bundle_path / "Contents/Frameworks"

    if args.copy_only or not args.links_only:
        library_paths = set(gather_libs(app_binary_path))
        copy_libs(frameworks_path, library_paths)

    if args.links_only or not args.copy_only:
        for lib_path in frameworks_path.glob("*"):
            update_links(lib_path)

        update_links(app_binary_path)

    print(f"Libraries for {args.app_name} copied and updated.")


if __name__ == "__main__":
    main()
