import argparse
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
from pathlib import Path

TEST_SLICES = {
    "iOS": "ios-arm64_x86_64-simulator",
}

DECODE_ARGS = ("UTF-8", "backslashreplace")

# The system log prefixes each line:
#   2025-01-17 16:14:29.093742+0800 iOSTestbed[23987:1fd393b4] ...
#   2025-01-17 16:14:29.093742+0800 iOSTestbed[23987:1fd393b4] ...

LOG_PREFIX_REGEX = re.compile(
    r"^\d{4}-\d{2}-\d{2}"  # YYYY-MM-DD
    r"\s+\d+:\d{2}:\d{2}\.\d+\+\d{4}"  # HH:MM:SS.ssssss+ZZZZ
    r"\s+iOSTestbed\[\d+:\w+\] "  # Process/thread ID
)


# Select a simulator device to use.
def select_simulator_device(platform):
    # List the testing simulators, in JSON format
    raw_json = subprocess.check_output(["xcrun", "simctl", "list", "-j"])
    json_data = json.loads(raw_json)

    if platform == "iOS":
        # Any iOS device will do; we'll look for "SE" devices - but the name
        # isn't consistent over time. Older Xcode versions will use "iPhone SE
        # (Nth generation)"; As of 2025, they've started using "iPhone 16e".
        #
        # When Xcode is updated after a new release, new devices will be
        # available and old ones will be dropped from the set available on the
        # latest iOS version. Select the one with the highest minimum runtime
        # version - this is an indicator of the "newest" released device, which
        # should always be supported on the "most recent" iOS version.
        se_simulators = sorted(
            (devicetype["minRuntimeVersion"], devicetype["name"])
            for devicetype in json_data["devicetypes"]
            if devicetype["productFamily"] == "iPhone"
            and (
                (
                    "iPhone " in devicetype["name"]
                    and devicetype["name"].endswith("e")
                )
                or "iPhone SE " in devicetype["name"]
            )
        )
        simulator = se_simulators[-1][1]
    else:
        raise ValueError(f"Unknown platform {platform}")

    return simulator


def xcode_test(location: Path, platform: str, simulator: str, verbose: bool):
    # Build and run the test suite on the named simulator.
    args = [
        "-project",
        str(location / f"{platform}Testbed.xcodeproj"),
        "-scheme",
        f"{platform}Testbed",
        "-destination",
        f"platform={platform} Simulator,name={simulator}",
        "-derivedDataPath",
        str(location / "DerivedData"),
    ]
    verbosity_args = [] if verbose else ["-quiet"]

    print("Building test project...")
    subprocess.run(
        ["xcodebuild", "build-for-testing"] + args + verbosity_args,
        check=True,
    )

    # Any environment variable prefixed with TEST_RUNNER_ is exposed into the
    # test runner environment. There are some variables (like those identifying
    # CI platforms) that can be useful to have access to.
    test_env = os.environ.copy()
    if "GITHUB_ACTIONS" in os.environ:
        test_env["TEST_RUNNER_GITHUB_ACTIONS"] = os.environ["GITHUB_ACTIONS"]

    print("Running test project...")
    # Test execution *can't* be run -quiet; verbose mode
    # is how we see the output of the test output.
    process = subprocess.Popen(
        ["xcodebuild", "test-without-building"] + args,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        env=test_env,
    )
    while line := (process.stdout.readline()).decode(*DECODE_ARGS):
        # Strip the timestamp/process prefix from each log line
        line = LOG_PREFIX_REGEX.sub("", line)
        sys.stdout.write(line)
        sys.stdout.flush()

    status = process.wait(timeout=5)
    exit(status)


def copy(src, tgt):
    """An all-purpose copy.

    If src is a file, it is copied. If src is a symlink, it is copied *as a
    symlink*. If src is a directory, the full tree is duplicated, with symlinks
    being preserved.
    """
    if src.is_file() or src.is_symlink():
        shutil.copyfile(src, tgt, follow_symlinks=False)
    else:
        shutil.copytree(src, tgt, symlinks=True)


def clone_testbed(
    source: Path,
    target: Path,
    framework: Path,
    platform: str,
    apps: list[Path],
) -> None:
    if target.exists():
        print(f"{target} already exists; aborting without creating project.")
        sys.exit(10)

    if framework is None:
        if not (
            source / "Python.xcframework" / TEST_SLICES[platform] / "bin"
        ).is_dir():
            print(
                f"The testbed being cloned ({source}) does not contain "
                "a framework with slices. Re-run with --framework"
            )
            sys.exit(11)
    else:
        if not framework.is_dir():
            print(f"{framework} does not exist.")
            sys.exit(12)
        elif not (
            framework.suffix == ".xcframework"
            or (framework / "Python.framework").is_dir()
        ):
            print(
                f"{framework} is not an XCframework, "
                f"or a simulator slice of a framework build."
            )
            sys.exit(13)

    print("Cloning testbed project:")
    print(f"  Cloning {source}...", end="")
    # Only copy the files for the platform being cloned plus the files common
    # to all platforms. The XCframework will be copied later, if needed.
    target.mkdir(parents=True)

    for name in [
        "__main__.py",
        "TestbedTests",
        "Testbed.lldbinit",
        f"{platform}Testbed",
        f"{platform}Testbed.xcodeproj",
        f"{platform}Testbed.xctestplan",
    ]:
        copy(source / name, target / name)

    print(" done")

    orig_xc_framework_path = source / "Python.xcframework"
    xc_framework_path = target / "Python.xcframework"
    test_framework_path = xc_framework_path / TEST_SLICES[platform]
    if framework is not None:
        if framework.suffix == ".xcframework":
            print("  Installing XCFramework...", end="")
            xc_framework_path.symlink_to(
                framework.relative_to(xc_framework_path.parent, walk_up=True)
            )
            print(" done")
        else:
            print("  Installing simulator framework...", end="")
            # We're only installing a slice of a framework; we need
            # to do a full tree copy to make sure we don't damage
            # symlinked content.
            shutil.copytree(orig_xc_framework_path, xc_framework_path)
            if test_framework_path.is_dir():
                shutil.rmtree(test_framework_path)
            else:
                test_framework_path.unlink(missing_ok=True)
            test_framework_path.symlink_to(
                framework.relative_to(test_framework_path.parent, walk_up=True)
            )
            print(" done")
    else:
        copy(orig_xc_framework_path, xc_framework_path)

        if (
            xc_framework_path.is_symlink()
            and not xc_framework_path.readlink().is_absolute()
        ):
            # XCFramework is a relative symlink. Rewrite the symlink relative
            # to the new location.
            print("  Rewriting symlink to XCframework...", end="")
            resolved_xc_framework_path = (
                source / xc_framework_path.readlink()
            ).resolve()
            xc_framework_path.unlink()
            xc_framework_path.symlink_to(
                resolved_xc_framework_path.relative_to(
                    xc_framework_path.parent, walk_up=True
                )
            )
            print(" done")
        elif (
            test_framework_path.is_symlink()
            and not test_framework_path.readlink().is_absolute()
        ):
            print("  Rewriting symlink to simulator framework...", end="")
            # Simulator framework is a relative symlink. Rewrite the symlink
            # relative to the new location.
            orig_test_framework_path = (
                source / "Python.XCframework" / test_framework_path.readlink()
            ).resolve()
            test_framework_path.unlink()
            test_framework_path.symlink_to(
                orig_test_framework_path.relative_to(
                    test_framework_path.parent, walk_up=True
                )
            )
            print(" done")
        else:
            print("  Using pre-existing Python framework.")

    for app_src in apps:
        print(f"  Installing app {app_src.name!r}...", end="")
        app_target = target / f"Testbed/app/{app_src.name}"
        if app_target.is_dir():
            shutil.rmtree(app_target)
        shutil.copytree(app_src, app_target)
        print(" done")

    print(f"Successfully cloned testbed: {target.resolve()}")


def update_test_plan(testbed_path, platform, args):
    # Modify the test plan to use the requested test arguments.
    test_plan_path = testbed_path / f"{platform}Testbed.xctestplan"
    with test_plan_path.open("r", encoding="utf-8") as f:
        test_plan = json.load(f)

    test_plan["defaultOptions"]["commandLineArgumentEntries"] = [
        {"argument": shlex.quote(arg)} for arg in args
    ]

    with test_plan_path.open("w", encoding="utf-8") as f:
        json.dump(test_plan, f, indent=2)


def run_testbed(
    platform: str,
    simulator: str | None,
    args: list[str],
    verbose: bool = False,
):
    location = Path(__file__).parent
    print("Updating test plan...", end="")
    update_test_plan(location, platform, args)
    print(" done.")

    if simulator is None:
        simulator = select_simulator_device(platform)
    print(f"Running test on {simulator}")

    xcode_test(
        location,
        platform=platform,
        simulator=simulator,
        verbose=verbose,
    )


def main():
    # Look for directories like `iOSTestbed` as an indicator of the platforms
    # that the testbed folder supports. The original source testbed can support
    # many platforms, but when cloned, only one platform is preserved.
    available_platforms = [
        platform
        for platform in ["iOS"]
        if (Path(__file__).parent / f"{platform}Testbed").is_dir()
    ]

    parser = argparse.ArgumentParser(
        description=(
            "Manages the process of testing an Apple Python project "
            "through Xcode."
        ),
    )

    subcommands = parser.add_subparsers(dest="subcommand")
    clone = subcommands.add_parser(
        "clone",
        description=(
            "Clone the testbed project, copying in a Python framework and"
            "any specified application code."
        ),
        help="Clone a testbed project to a new location.",
    )
    clone.add_argument(
        "--framework",
        help=(
            "The location of the XCFramework (or simulator-only slice of an "
            "XCFramework) to use when running the testbed"
        ),
    )
    clone.add_argument(
        "--platform",
        dest="platform",
        choices=available_platforms,
        default=available_platforms[0],
        help=f"The platform to target (default: {available_platforms[0]})",
    )
    clone.add_argument(
        "--app",
        dest="apps",
        action="append",
        default=[],
        help="The location of any code to include in the testbed project",
    )
    clone.add_argument(
        "location",
        help="The path where the testbed will be cloned.",
    )

    run = subcommands.add_parser(
        "run",
        usage=(
            "%(prog)s [-h] [--simulator SIMULATOR] -- "
            "<test arg> [<test arg> ...]"
        ),
        description=(
            "Run a testbed project. The arguments provided after `--` will be "
            "passed to the running iOS process as if they were arguments to "
            "`python -m`."
        ),
        help="Run a testbed project",
    )
    run.add_argument(
        "--platform",
        dest="platform",
        choices=available_platforms,
        default=available_platforms[0],
        help=f"The platform to target (default: {available_platforms[0]})",
    )
    run.add_argument(
        "--simulator",
        help=(
            "The name of the simulator to use (eg: 'iPhone 16e'). Defaults to "
            "the most recently released 'entry level' iPhone device. Device "
            "architecture and OS version can also be specified; e.g., "
            "`--simulator 'iPhone 16 Pro,arch=arm64,OS=26.0'` would run on "
            "an ARM64 iPhone 16 Pro simulator running iOS 26.0."
        ),
    )
    run.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Enable verbose output",
    )

    try:
        pos = sys.argv.index("--")
        testbed_args = sys.argv[1:pos]
        test_args = sys.argv[pos + 1 :]
    except ValueError:
        testbed_args = sys.argv[1:]
        test_args = []

    context = parser.parse_args(testbed_args)

    if context.subcommand == "clone":
        clone_testbed(
            source=Path(__file__).parent.resolve(),
            target=Path(context.location).resolve(),
            framework=Path(context.framework).resolve()
            if context.framework
            else None,
            platform=context.platform,
            apps=[Path(app) for app in context.apps],
        )
    elif context.subcommand == "run":
        if test_args:
            if not (
                Path(__file__).parent
                / "Python.xcframework"
                / TEST_SLICES[context.platform]
                / "bin"
            ).is_dir():
                print(
                    "Testbed does not contain a compiled Python framework. "
                    f"Use `python {sys.argv[0]} clone ...` to create a "
                    "runnable clone of this testbed."
                )
                sys.exit(20)

            run_testbed(
                platform=context.platform,
                simulator=context.simulator,
                verbose=context.verbose,
                args=test_args,
            )
        else:
            print(
                "Must specify test arguments "
                f"(e.g., {sys.argv[0]} run -- test)"
            )
            print()
            parser.print_help(sys.stderr)
            sys.exit(21)
    else:
        parser.print_help(sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    # Under the buildbot, stdout is not a TTY, but we must still flush after
    # every line to make sure our output appears in the correct order relative
    # to the output of our subprocesses.
    for stream in [sys.stdout, sys.stderr]:
        stream.reconfigure(line_buffering=True)
    main()
