#!/usr/bin/python3

import datetime
import glob
import json
import os
import sys
import time
import traceback

# Ordered the same as we order platforms in the generated Xcode project, except
# macOS is last
_DEVICE_PLATFORMS = {
    "iphoneos": None,
    "appletvos": None,
    "watchos": None,
    "macosx": None,
}
_SIMULATOR_PLATFORMS = {
    "iphonesimulator": None,
    "appletvsimulator": None,
    "watchsimulator": None,
    "macosx": None,
}


def _wait_for_value(calculate_value, value_name):
    wait_counter = 0
    while True:
        value = calculate_value()
        if value:
            break
        if wait_counter == 0:
            now = datetime.datetime.now().strftime('%H:%M:%S')
            print(
                f"note: ({now}) {value_name} not updated yet, waiting...",
                file = sys.stderr,
                flush = True,
            )
        if wait_counter == 10:
            now = datetime.datetime.now().strftime('%H:%M:%S')
            print(
                f"""\
warning: ({now}) {value_name} still not updated after 10 seconds. If happens \
frequently, or the cache is never created, please file a bug report here: \
https://github.com/MobileNativeFoundation/rules_xcodeproj/issues/new?template=bug.md""",
                file = sys.stderr,
                flush = True,
            )
        time.sleep(1)
        wait_counter += 1
    if wait_counter > 0:
        now = datetime.datetime.now().strftime('%H:%M:%S')
        print(
                f"""\
note: ({now}) {value_name} updated after {wait_counter} seconds.""",
                file = sys.stderr,
                flush = True,
        )
    return value


def _get_build_request(
        xcode_version,
        objroot,
        build_request_min_ctime):
    if xcode_version < 1430:
        # Before Xcode 14.3
        def wait_for_description():
            build_description_cache = max(
                glob.iglob(
                    f"{objroot}/XCBuildData/BuildDescriptionCacheIndex-*",
                ),
                key = os.path.getctime,
            )
            if (os.path.getctime(build_description_cache) >=
                build_request_min_ctime):
                return build_description_cache
            return None

        build_description_cache = (
            _wait_for_value(wait_for_description, "BuildDescriptionCacheIndex")
        )
        with open(build_description_cache, 'rb') as f:
            f.seek(-32, os.SEEK_END)
            build_request_id = f.read().decode('ASCII')

        build_request_file = (
            f"{objroot}/XCBuildData/{build_request_id}-buildRequest.json"
        )
        def wait_for_build_request_file():
            if os.path.exists(build_request_file):
                with open(build_request_file, encoding = "utf-8") as f:
                    # Parse the build-request.json file
                    try:
                        return json.load(f)
                    except Exception as error:
                        print(
                            f"""\
error: Failed to parse '{build_request_file}':
{type(error).__name__}: {error}.

Please file a bug report here: \
https://github.com/MobileNativeFoundation/rules_xcodeproj/issues/new?template=bug.md""",
                            file = sys.stderr,
                        )
                        exit(1)
            return None

        return _wait_for_value(
            wait_for_build_request_file,
            f"\"{build_request_file}\"",
        )

    def wait_for_build_request():
        try:
            xcbuilddata = max(
                glob.iglob(f"{objroot}/XCBuildData/*.xcbuilddata"),
                key = os.path.getctime,
            )
            if os.path.getctime(xcbuilddata) < build_request_min_ctime:
                return None
        except (ValueError, FileNotFoundError, OSError):
            # ValueError: no .xcbuilddata files yet (empty glob)
            # FileNotFoundError/OSError: TOCTOU race - file deleted between glob and getctime
            return None
        build_request_file = f"{xcbuilddata}/build-request.json"
        if os.path.exists(build_request_file):
            with open(build_request_file, encoding = "utf-8") as f:
                # Parse the build-request.json file
                try:
                    return json.load(f)
                except Exception as error:
                    print(
                        f"""\
error: Failed to parse '{build_request_file}':
{type(error).__name__}: {error}.

Please file a bug report here: \
https://github.com/MobileNativeFoundation/rules_xcodeproj/issues/new?template=bug.md""",
                        file = sys.stderr,
                    )
                    sys.exit(1)
        return None

    return _wait_for_value(
        wait_for_build_request,
        "newest 'buildRequest.json' file",
    )


def _calculate_label_and_target_ids(
        build_request,
        guid_labels,
        guid_target_ids):
    # Xcode gets "stuck" in the `buildFiles` or `build` command for
    # top-level targets, so we can't reliably change commands here. Leaving
    # the code in place in case this is fixed in the future, or we want to
    # do something similar in an XCBBuildService proxy.
    #
    # command = (
    #     build_request.get("_buildCommand2", {}).get("command", "build")
    # )
    command = "build"
    parameters = build_request["parameters"]
    platform = (
        parameters["activeRunDestination"]["platform"]
    )
    configuration_name = parameters["configurationName"]

    labels_and_target_ids = []
    for target in build_request["configuredTargets"]:
        label = guid_labels.get(target["guid"])
        if not label:
            # `BazelDependency` and the like
            continue
        full_target_target_ids = guid_target_ids[target["guid"]]
        target_target_ids = (
            full_target_target_ids.get(command) or
            # Will only be `null` if `command == "buildFiles"` and there
            # isn't a different compile target id
            full_target_target_ids["build"]
        )
        target_ids = _select_target_ids(
            target_target_ids[configuration_name],
            platform,
        )
        for target_id in target_ids:
            labels_and_target_ids.append((label, target_id))

    if not labels_and_target_ids:
        print(
            """\
error: Failed to determine labels and targets. Note, currently `.xcworkspace`s \
aren't supported. Please make sure you are opening the generated \
`.xcodeproj` file bundle directly. If you are, try using the "Clean Build \
Folder" command instead (⇧ ⌘ K). If you still get this error after that, then \
please file a bug report here: \
https://github.com/MobileNativeFoundation/rules_xcodeproj/issues/new?template=bug.md""",
            file = sys.stderr,
        )
        exit(1)

    return labels_and_target_ids


def _calculate_guid_labels_and_target_ids(base_objroot):
    pif_cache = f"{base_objroot}/XCBuildData/PIFCache"
    project_cache = f"{pif_cache}/project"
    target_cache = f"{pif_cache}/target"

    # The PIF cache will only be created before the `SetSessionUserInfo`
    # command, which normally happens when a project is opened. If Derived Data
    # is cleared  while the project is open
    if not (os.path.exists(project_cache) and os.path.exists(target_cache)):
        print(
            f"""\
error: PIFCache ({pif_cache}) doesn't exist. If you manually cleared Derived \
Data, you need to close and re-open the project for the PIFCache to be created \
again. Using the "Clean Build Folder" command instead (⇧ ⌘ K) won't trigger \
this error. If this error still happens after re-opening the project, please \
file a bug report here: \
https://github.com/MobileNativeFoundation/rules_xcodeproj/issues/new?template=bug.md""",
            file = sys.stderr,
        )
        sys.exit(1)

    project_pif = max(
        glob.iglob(f"{project_cache}/*"),
        key = os.path.getctime,
    )

    guid_payload_parent = f"{base_objroot}/guid_payload"
    guid_payload_path = f"""\
{guid_payload_parent}/{os.path.basename(project_pif)}_v2.json"""

    if os.path.exists(guid_payload_path):
        with open(guid_payload_path, encoding = "utf-8") as f:
            payload = json.load(f)
            return payload["labels"], payload["targetIds"]

    with open(project_pif, encoding = "utf-8") as f:
        project_pif = json.load(f)

    targets = project_pif["targets"]

    guid_labels = {}
    guid_target_ids = {}
    for target_name in targets:
        target_file = f"{target_cache}/{target_name}-json"
        with open(target_file, encoding = "utf-8") as f:
            target_pif = json.load(f)

        label = None
        build_target_ids = {}
        compile_target_ids = {}
        for configuration in target_pif["buildConfigurations"]:
            config_build_target_ids = {"key": "BAZEL_TARGET_ID"}
            config_compile_target_ids = {"key": "BAZEL_COMPILE_TARGET_IDS"}
            for key, value in configuration["buildSettings"].items():
                if key.startswith("BAZEL_TARGET_ID"):
                    # This uses a list in the case where the value isn't meant to
                    # be inherited. Otherwise, `$(BAZEL_TARGET_ID)` is used later
                    # in this processing to pull from an inherited setting.
                    if value == "$(BAZEL_TARGET_ID)":
                        target_ids = value
                    else:
                        # This is only a single value but the later parsing of
                        # these target ids assumes a list.
                        target_ids = [value]

                    config_build_target_ids[_platform_from_build_key(key)] = (
                        target_ids
                    )
                elif key.startswith("BAZEL_COMPILE_TARGET_IDS"):
                    # This uses a list in the case where the value isn't meant to
                    # be inherited. Otherwise, `$(BAZEL_COMPILE_TARGET_IDS)` is
                    # used later in this processing to pull from an inherited
                    # setting.
                    if value == "$(BAZEL_COMPILE_TARGET_IDS)":
                        target_ids = "$(BAZEL_COMPILE_TARGET_IDS)"
                    else:
                        # Target identifiers contain a space but are space separated.
                        # Split on all spaces then rejoin across the identifier pairs.
                        target_ids = value.split(" ")
                        target_ids = [
                            " ".join(target_ids[i:i+2])
                            for i in range(0, len(target_ids), 2)
                        ]

                    config_compile_target_ids[_platform_from_compile_key(key)] = (
                        target_ids
                    )
                elif key == "BAZEL_LABEL":
                    label = value
            configuration_name = configuration["name"]
            build_target_ids[configuration_name] = config_build_target_ids
            compile_target_ids[configuration_name] = config_compile_target_ids

        if not label:
            # `BazelDependency` and the like
            continue

        target_ids = {
            "build": build_target_ids,
        }
        if len(compile_target_ids) > 1:
            target_ids["buildFiles"] = compile_target_ids

        guid = target_pif["guid"]
        guid_labels[guid] = label
        guid_target_ids[guid] = target_ids

    os.makedirs(guid_payload_parent, exist_ok = True)
    with open(guid_payload_path, "w", encoding = "utf-8") as f:
        payload = {
            "labels": guid_labels,
            "targetIds": guid_target_ids,
        }
        json.dump(payload, f)

    return guid_labels, guid_target_ids


def _platform_from_build_key(key):
    if key.startswith("BAZEL_TARGET_ID[sdk="):
        return key[20:-2]
    return ""


def _platform_from_compile_key(key):
    if key.startswith("BAZEL_COMPILE_TARGET_IDS[sdk="):
        return key[29:-2]
    return ""


def _select_target_ids(target_ids, platform):
    key = target_ids["key"]

    platforms = {platform: None}

    # We need to try other similar platforms (i.e. other simulator platforms if
    # `platform`` is for a simulator). This is to support schemes with targets
    # of multiple platforms in them. Because `dict` is insertion ordered,
    # `platform` will be checked first.
    platforms.update(_similar_platforms(platform))

    for platform in platforms:
        platform_target_ids = target_ids.get(platform)
        if platform_target_ids:
            if platform_target_ids == f"$({key})":
                return target_ids[""]
            return platform_target_ids
    return target_ids[""]


def _similar_platforms(platform):
    if platform == "macosx" or "simulator" in platform:
        return _SIMULATOR_PLATFORMS
    return _DEVICE_PLATFORMS


def _main(
        xcode_version,
        objroot,
        base_objroot,
        marker_file,
        prefixes_str):
    if not os.path.exists(marker_file):
        return

    build_request_min_ctime = os.path.getctime(marker_file)

    try:
        xcode_version = int(xcode_version)
    except ValueError:
        print(
            """
warning: xcode_version was not an integer. Please file a bug report here: \
https://github.com/MobileNativeFoundation/rules_xcodeproj/issues/new?template=bug.md
""",
            file = sys.stderr,
        )
        xcode_version = 9999

    prefixes = prefixes_str.split(",")

    try:
        build_request = _get_build_request(
            xcode_version,
            objroot,
            build_request_min_ctime,
        )
        guid_labels, guid_target_ids = (
            _calculate_guid_labels_and_target_ids(base_objroot)
        )
    except Exception:
        print(
            f"""\
error: Failed to calculate labels and target ids from PIFCache:
{traceback.format_exc()}
Please file a bug report here: \
https://github.com/MobileNativeFoundation/rules_xcodeproj/issues/new?template=bug.md""",
            file = sys.stderr,
        )
        sys.exit(1)

    labels_and_target_ids = _calculate_label_and_target_ids(
        build_request,
        guid_labels,
        guid_target_ids,
    )

    print("\n".join(
        [
            f"{label}\n{prefix} {id}"
            for label, id in labels_and_target_ids
            for prefix in prefixes
        ],
    ))


if __name__ == "__main__":
    _main(
        # XCODE_VERSION_ACTUAL
        sys.argv[1],
        # non_preview_objroot
        sys.argv[2],
        # base_objroot
        sys.argv[3],
        # build_marker_file
        sys.argv[4],
        # output_group_prefixes
        sys.argv[5],
    )
