#!/usr/bin/env python3
"""
Xcode ``project.pbxproj`` helpers.

Parses native targets and patches their XCBuildConfiguration buildSettings
blocks with manual signing values, while keeping the file's original
OpenStep format (comments, ordering, the !$*UTF8*$! header) intact. SwiftPM
and other non-native targets are ignored so their build settings stay on
whatever defaults Xcode / SwiftPM picked.
"""

from __future__ import annotations

import json
import re
import subprocess
from pathlib import Path


PROFILE_PREFIX = "CI-"

# Product types that must be signed with a provisioning profile. App
# extensions share the common ``com.apple.product-type.app-extension``
# prefix (Message Filter, Widgets, NetworkExtension, WatchKit, etc).
_SIGNABLE_PRODUCT_TYPES = {
    "com.apple.product-type.application",
    "com.apple.product-type.application.on-demand-install-capable",
    "com.apple.product-type.application.watchapp2",
    "com.apple.product-type.watchkit2-extension",
}
_SIGNABLE_PRODUCT_PREFIXES = (
    "com.apple.product-type.app-extension",
)

_SIGNING_KEYS = {
    "CODE_SIGN_STYLE": "Manual",
    "CODE_SIGN_IDENTITY": '"Apple Distribution"',
    "CODE_SIGNING_ALLOWED": "YES",
    "CODE_SIGNING_REQUIRED": "YES",
}
_STRIP_KEYS = ("PROVISIONING_PROFILE",)

_SETTING_LINE = re.compile(r"^(\s*)([A-Z_][A-Z0-9_]*)\s*=\s*(.+);\s*$")


# --------------------------------------------------------------------------- #
# Parsing                                                                     #
# --------------------------------------------------------------------------- #

def _load_pbxproj(project_path: str) -> dict:
    pbx = Path(project_path) / "project.pbxproj"
    out = subprocess.check_output(
        ["plutil", "-convert", "json", "-o", "-", str(pbx)]
    )
    return json.loads(out)


def _is_signable_product_type(product_type: str) -> bool:
    if product_type in _SIGNABLE_PRODUCT_TYPES:
        return True
    return any(product_type.startswith(p) for p in _SIGNABLE_PRODUCT_PREFIXES)


def _bundle_id_from_configs(objects: dict, config_ids: list[str]) -> str:
    """Return the first non-empty bundle id across the given configs."""
    for cid in config_ids:
        cfg = objects.get(cid) or {}
        settings = cfg.get("buildSettings") or {}
        bid = (settings.get("PRODUCT_BUNDLE_IDENTIFIER") or "").strip()
        if bid and "$(" not in bid and "${" not in bid:
            return bid
    return ""


def _expand_entitlements_refs(value: str, target_name: str) -> str:
    """Expand the build-setting references we can resolve without xcodebuild.

    ``$(TARGET_NAME)`` is the idiom every xcodegen target template uses for a
    per-target entitlements path, and ``$(SRCROOT)``/``$(PROJECT_DIR)`` both
    name the directory the path is already relative to. Those three are
    unambiguous from the pbxproj alone; anything else is left in place so the
    caller can reject the value.
    """
    for reference in (f"$(TARGET_NAME)", "${TARGET_NAME}"):
        value = value.replace(reference, target_name)
    for name in ("SRCROOT", "PROJECT_DIR"):
        value = value.replace(f"$({name})/", "").replace(f"${{{name}}}/", "")
    return value


def _entitlements_from_configs(
    objects: dict, config_ids: list[str], target_name: str = "", base: Path | None = None
) -> str:
    """First CODE_SIGN_ENTITLEMENTS path across the given configs.

    Needed to reconcile the App ID's capabilities with what the app declares —
    a profile only carries capabilities enabled on its App ID.

    A literal path is returned as-is (the caller warns when it does not exist).
    A path built from build settings is expanded only for the references we can
    resolve exactly, and then only accepted when the resulting file is really
    there — a missed entitlements file costs a clearer error, a wrong one costs
    a wrong App ID edit.

    Only the ARCHIVE configuration counts: CI archives Release, so its
    CODE_SIGN_ENTITLEMENTS (falling back to Profile, then to every config
    only when neither exists) decides provisioning. An entitlement wired
    into Debug alone — the standard way to simulator-test an Apple-granted
    capability (CarPlay) before the grant lands — must NOT force that
    capability onto the App ID for the archive.
    """
    by_name = {
        ((objects.get(cid) or {}).get("name") or "").lower(): cid
        for cid in config_ids
    }
    archive_cid = by_name.get("release") or by_name.get("profile")
    candidates = [archive_cid] if archive_cid else config_ids

    for cid in candidates:
        cfg = objects.get(cid) or {}
        settings = cfg.get("buildSettings") or {}
        value = (settings.get("CODE_SIGN_ENTITLEMENTS") or "").strip().strip('"')
        if not value:
            continue
        if "$(" not in value and "${" not in value:
            return value
        expanded = _expand_entitlements_refs(value, target_name)
        if "$(" in expanded or "${" in expanded:
            continue
        if base is None or (base / expanded).is_file():
            return expanded
    return ""


def discover_signable_targets(project_path: str) -> list[dict]:
    """Return one entry per signable native target.

    Each entry:
    ``{"name": str, "bundle_id": str, "config_ids": [str,...],
    "entitlements": str}`` where ``config_ids`` is the list of
    XCBuildConfiguration UUIDs whose ``buildSettings`` dict we need to patch
    (typically Debug + Release) and ``entitlements`` is the target's
    CODE_SIGN_ENTITLEMENTS path relative to the project directory ("" when the
    target declares none).
    """
    pbx = _load_pbxproj(project_path)
    objects = pbx["objects"]
    base = Path(project_path).parent
    targets: list[dict] = []
    for obj in objects.values():
        if obj.get("isa") != "PBXNativeTarget":
            continue
        if not _is_signable_product_type(obj.get("productType") or ""):
            continue
        name = obj.get("name") or "<unknown>"
        config_list = objects.get(obj.get("buildConfigurationList")) or {}
        config_ids = list(config_list.get("buildConfigurations") or [])
        bundle_id = _bundle_id_from_configs(objects, config_ids)
        if not bundle_id:
            print(f"skip target {name!r}: no PRODUCT_BUNDLE_IDENTIFIER")
            continue
        targets.append(
            {
                "name": name,
                "bundle_id": bundle_id,
                "config_ids": config_ids,
                "entitlements": _entitlements_from_configs(
                    objects, config_ids, target_name=name, base=base
                ),
            }
        )
    if not targets:
        raise SystemExit(
            f"discover_signable_targets: no signable targets found in "
            f"{project_path}"
        )
    return targets


# --------------------------------------------------------------------------- #
# Mutation                                                                    #
# --------------------------------------------------------------------------- #

def patch_project_signing(
    project_path: str,
    targets: list[dict],
    team_id: str,
) -> None:
    """Set manual signing + per-target profile name for every target.

    Edits the pbxproj as text so formatting (comments, ordering, header)
    survives. Scope is strictly the XCBuildConfiguration blocks referenced
    by the ``targets`` list. SwiftPM / resource-bundle configs stay put.
    """
    pbx_path = Path(project_path) / "project.pbxproj"
    text = pbx_path.read_text(encoding="utf-8")
    for target in targets:
        profile_name = f"{PROFILE_PREFIX}{target['bundle_id']}"
        for cid in target["config_ids"]:
            text = _apply_signing_to_config(text, cid, team_id, profile_name)
        print(
            f"Patched {target['name']!r} -> {profile_name} "
            f"(configs={len(target['config_ids'])})"
        )
    pbx_path.write_text(text, encoding="utf-8")
    subprocess.check_call(["plutil", "-lint", str(pbx_path)])


def _apply_signing_to_config(
    text: str, config_id: str, team_id: str, profile_name: str
) -> str:
    start = text.find(f"\t\t{config_id} ")
    if start < 0:
        start = text.find(f"\t\t{config_id}\t")
    if start < 0:
        start = text.find(f"{config_id} = {{")
    if start < 0:
        raise SystemExit(
            f"patch_project_signing: config {config_id} not found"
        )
    key = "buildSettings = {"
    s_idx = text.find(key, start)
    if s_idx < 0:
        raise SystemExit(
            f"patch_project_signing: buildSettings not found for {config_id}"
        )
    end = _match_brace(text, s_idx + len(key) - 1)
    if end < 0:
        raise SystemExit(
            f"patch_project_signing: unbalanced buildSettings for {config_id}"
        )
    inner = text[s_idx + len(key): end]
    patched = _patch_inner_settings(inner, team_id, profile_name)
    return text[: s_idx + len(key)] + patched + text[end:]


def _match_brace(text: str, open_idx: int) -> int:
    """Return the index of the ``}`` that closes the ``{`` at ``open_idx``."""
    depth = 0
    i = open_idx
    while i < len(text):
        ch = text[i]
        if ch == "{":
            depth += 1
        elif ch == "}":
            depth -= 1
            if depth == 0:
                return i
        i += 1
    return -1


def _patch_inner_settings(
    inner: str, team_id: str, profile_name: str
) -> str:
    """Rewrite build-setting lines inside one buildSettings block."""
    values = dict(_SIGNING_KEYS)
    values["DEVELOPMENT_TEAM"] = team_id
    values["PROVISIONING_PROFILE_SPECIFIER"] = f'"{profile_name}"'

    lines = inner.splitlines(keepends=True)
    emitted: set[str] = set()
    out: list[str] = []
    for line in lines:
        m = _SETTING_LINE.match(line)
        if not m:
            out.append(line)
            continue
        indent, key_name = m.group(1), m.group(2)
        if key_name in _STRIP_KEYS:
            continue
        if key_name in values:
            out.append(f"{indent}{key_name} = {values[key_name]};\n")
            emitted.add(key_name)
            continue
        out.append(line)

    missing = [k for k in values if k not in emitted]
    if missing:
        indent = "\t\t\t\t"
        for line in lines:
            m = _SETTING_LINE.match(line)
            if m:
                indent = m.group(1)
                break
        tail = out[-1] if out else ""
        new_lines = [f"{indent}{k} = {values[k]};\n" for k in missing]
        if tail.strip() == "":
            out = out[:-1] + new_lines + [tail]
        else:
            out = out + new_lines
    return "".join(out)
