#!/usr/bin/env python3
"""Reconcile an App ID's capabilities with what the app's entitlements need.

A provisioning profile only carries the capabilities enabled on its App ID.
Xcode's automatic signing hides this by enabling them for you as you edit
entitlements; the ASC API does not, so a CI-generated profile silently omits
them and `xcodebuild archive` fails with

    Provisioning profile "CI-<bundle>" doesn't include the App Attest
    capability.

This module reads the entitlement keys the project actually declares and turns
on the matching capabilities before the profile is created.

Deliberately narrow: only capabilities that are a plain on/off switch are
enabled automatically. App Groups, iCloud containers, Apple Pay merchant IDs
and friends carry settings that cannot be guessed from an entitlements file, so
they are reported and left to a human. Everything here is best-effort — a
capability we cannot enable produces a ``::warning::`` and lets the archive
deliver Apple's own (more specific) error rather than failing the build here.
"""

from __future__ import annotations

import plistlib
from pathlib import Path

from asc_common import get_json, request


# asc_common.request signals a non-retryable failure with SystemExit, which is
# a BaseException — `except Exception` sails straight past it. Spelling both
# out is what makes the "best-effort" promise below actually hold; without it
# a capability call that Apple rejects kills the whole signing step.
_API_FAILURES = (Exception, SystemExit)


# Entitlement key -> App Store Connect capabilityType, for capabilities that
# are a bare toggle. An entitlement absent from this map is not an error: most
# entitlements (keychain-access-groups, get-task-allow, ...) need no App ID
# capability at all.
CAPABILITY_BY_ENTITLEMENT = {
    "aps-environment": "PUSH_NOTIFICATIONS",
    "com.apple.developer.associated-domains": "ASSOCIATED_DOMAINS",
    "com.apple.developer.applesignin": "APPLE_ID_AUTH",
    "com.apple.developer.networking.wifi-info": "ACCESS_WIFI_INFORMATION",
    "com.apple.developer.networking.networkextension": "NETWORK_EXTENSIONS",
    "com.apple.developer.networking.vpn.api": "PERSONAL_VPN",
    "com.apple.developer.networking.multipath": "MULTIPATH",
    "com.apple.developer.networking.HotspotConfiguration": "HOT_SPOT",
    "com.apple.developer.nfc.readersession.formats": "NFC_TAG_READING",
    "com.apple.developer.homekit": "HOMEKIT",
    "com.apple.developer.healthkit": "HEALTHKIT",
    "com.apple.developer.siri": "SIRIKIT",
    "com.apple.developer.ClassKit-environment": "CLASSKIT",
    "com.apple.developer.authentication-services.autofill-credential-provider":
        "AUTOFILL_CREDENTIAL_PROVIDER",
    "com.apple.external-accessory.wireless-configuration":
        "WIRELESS_ACCESSORY_CONFIGURATION",
    "inter-app-audio": "INTER_APP_AUDIO",
}

# Capabilities whose App ID configuration carries values (container ids,
# merchant ids, protection level) that an entitlements file cannot supply
# unambiguously. Enabling these blind would create a half-configured App ID,
# so we say what is missing and stop.
APP_ATTEST_ENTITLEMENT = "com.apple.developer.devicecheck.appattest-environment"

# CarPlay capabilities are Apple-GRANTED per App ID (the CarPlay entitlement
# request at https://developer.apple.com/contact/carplay/); the ASC API's
# capabilityType enum has no CarPlay member, so a POST would 409 exactly like
# App Attest. They live in MANUAL_ENTITLEMENTS (message, no POST) and in
# PROFILE_ENTITLEMENT_KEYS (cache invalidation) — see the notes on each.
CARPLAY_ENTITLEMENTS = {
    "com.apple.developer.carplay-charging",
    "com.apple.developer.carplay-fueling",
    "com.apple.developer.carplay-parking",
    "com.apple.developer.carplay-quick-ordering",
    "com.apple.developer.carplay-maps",
    "com.apple.developer.carplay-audio",
    "com.apple.developer.carplay-communication",
}

_CARPLAY_MESSAGE = (
    "a CarPlay entitlement, which Apple grants per App ID through the CarPlay "
    "entitlement request form (developer.apple.com/contact/carplay/) — the "
    "App Store Connect API cannot enable it. Request it from Apple, wait for "
    "the grant, then re-run"
)

MANUAL_ENTITLEMENTS = {
    # App Attest is a real App ID capability in the developer portal, but it is
    # absent from the ASC API's capabilityType enum entirely — POSTing it
    # returns 409 ENTITY_ERROR.ATTRIBUTE.TYPE listing the valid values, which
    # do not include it. There is no API to turn this one on.
    APP_ATTEST_ENTITLEMENT: (
        "App Attest, which the App Store Connect API cannot enable — tick it "
        "on the App ID under Certificates, Identifiers & Profiles"
    ),
    "com.apple.security.application-groups": "App Groups",
    "com.apple.developer.icloud-container-identifiers": "iCloud",
    "com.apple.developer.icloud-services": "iCloud",
    "com.apple.developer.ubiquity-kvstore-identifier": "iCloud",
    "com.apple.developer.in-app-payments": "Apple Pay",
    "com.apple.developer.pass-type-identifiers": "Wallet",
    "com.apple.developer.default-data-protection": "Data Protection",
    **{key: _CARPLAY_MESSAGE for key in CARPLAY_ENTITLEMENTS},
}


# Entitlement keys Apple mirrors into an issued profile's Entitlements dict
# when the capability is on. Checking a cached profile against these is what
# makes the cache self-correcting: the manifest records a UUID and an expiry
# but nothing about which capabilities the profile was issued with, so a
# profile minted before a capability was added stays "valid" forever.
#
# App Groups are checked by exact identifier values in app_groups.py.
# Deliberately excludes the other settings-bearing capabilities (iCloud,
# Apple Pay, Wallet, Data Protection): those need App ID configuration this
# action does not perform, so treating them as missing would regenerate the
# profile on every single run without ever fixing anything.
#
# CarPlay IS included, deliberately: a profile cached before Apple granted
# the capability would otherwise be reinstalled until it expires, failing
# every archive with no hint. Before the grant this forces a regenerate on
# every run — which still fails, but loudly, with Apple's own message (and
# the fail-fast in profile_manager); after the grant the fresh profile
# carries the key and the check goes quiet.
PROFILE_ENTITLEMENT_KEYS = (
    set(CAPABILITY_BY_ENTITLEMENT) | {APP_ATTEST_ENTITLEMENT} | CARPLAY_ENTITLEMENTS
)


def missing_profile_entitlements(
    profile_entitlements: dict, required_keys: set[str]
) -> set[str]:
    """Required entitlement keys an already-issued profile does not carry."""
    return {
        key
        for key in required_keys & PROFILE_ENTITLEMENT_KEYS
        if key not in profile_entitlements
    }


def missing_carplay_entitlements(
    profile_entitlements: dict, required_keys: set[str]
) -> set[str]:
    """CarPlay keys the app needs that an issued profile does not carry.

    Used as a pre-archive assertion: a freshly minted profile without the
    granted CarPlay capability means Apple has not (yet) granted it, and the
    archive would die later with an opaque xcodebuild error.
    """
    return {
        key
        for key in required_keys & CARPLAY_ENTITLEMENTS
        if key not in profile_entitlements
    }


def read_entitlement_keys(path: Path) -> set[str]:
    """Top-level keys of an entitlements plist; empty when unreadable."""
    try:
        with path.open("rb") as handle:
            data = plistlib.load(handle)
    except (OSError, plistlib.InvalidFileException, ValueError) as exc:
        print(f"::warning::could not read entitlements {path}: {exc!r}")
        return set()
    return set(data) if isinstance(data, dict) else set()


def required_capabilities(entitlement_keys: set[str]) -> set[str]:
    return {
        CAPABILITY_BY_ENTITLEMENT[key]
        for key in entitlement_keys
        if key in CAPABILITY_BY_ENTITLEMENT
    }


def warn_about_manual_entitlements(entitlement_keys: set[str], bundle_id: str) -> None:
    for key in sorted(entitlement_keys & set(MANUAL_ENTITLEMENTS)):
        if key == "com.apple.security.application-groups":
            print(f"App Groups for {bundle_id}: issued profiles must cover every declared group identifier")
            continue
        print(
            f"::warning::{bundle_id} declares {key}, which needs "
            f"{MANUAL_ENTITLEMENTS[key]} configured on the App ID with values "
            f"this action cannot infer. Enable it in the Apple Developer "
            f"portal if the archive complains."
        )


def enabled_capabilities(token: str, bundle_pk: str) -> set[str]:
    try:
        # No `limit` here: this relationship rejects it outright with
        # PARAMETER_ERROR.ILLEGAL. Apple returns the full capability set.
        data = get_json(f"/bundleIds/{bundle_pk}/bundleIdCapabilities", token)
    except _API_FAILURES as exc:
        print(f"::warning::could not list capabilities for {bundle_pk}: {exc!r}")
        return set()
    found = set()
    for item in data.get("data", []):
        capability = (item.get("attributes") or {}).get("capabilityType")
        if capability:
            found.add(capability)
    print(f"App ID {bundle_pk} capabilities: {', '.join(sorted(found)) or '(none)'}")
    return found


def enable_capability(token: str, bundle_pk: str, capability: str) -> bool:
    body = {
        "data": {
            "type": "bundleIdCapabilities",
            "attributes": {"capabilityType": capability},
            "relationships": {
                "bundleId": {"data": {"type": "bundleIds", "id": bundle_pk}}
            },
        }
    }
    try:
        response = request(
            "POST", "/bundleIdCapabilities", token,
            json_body=body, allow_status={200, 201, 409},
        )
    except _API_FAILURES as exc:
        print(f"::warning::could not enable {capability}: {exc!r}")
        return False
    if response.status_code == 409:
        # Could be "already on" — or Apple refusing outright, which reads
        # identically from the status code alone. Surface the body: without it
        # the run just reports a profile missing a capability we claimed to
        # have enabled, and there is nothing in the log to explain why.
        # Untruncated enough to show Apple's full list of valid capabilityType
        # values, which is the only way to learn that a name is simply not
        # API-manageable (see App Attest in MANUAL_ENTITLEMENTS).
        print(
            f"::warning::App Store Connect returned 409 for {capability}: "
            f"{response.text[:2000]}"
        )
        return False
    print(f"Enabled {capability} on the App ID")
    return True


def reconcile(token: str, bundle_pk: str, bundle_id: str, entitlement_keys: set[str]) -> bool:
    """Enable every simple capability the entitlements imply.

    Returns True when the caller must regenerate the profile — which is
    whenever the App ID was missing something, NOT merely when a POST
    succeeded. A cached profile was issued while the App ID lacked these, so
    reusing it reproduces the same archive failure either way; regenerating at
    least picks up the App ID's current state, and if the capability is still
    absent the error comes from Apple describing the real problem rather than
    from a stale cache.
    """
    warn_about_manual_entitlements(entitlement_keys, bundle_id)
    wanted = required_capabilities(entitlement_keys)
    if not wanted:
        return False
    missing = wanted - enabled_capabilities(token, bundle_pk)
    if not missing:
        return False
    print(
        f"{bundle_id}: entitlements require {', '.join(sorted(missing))} "
        f"but the App ID does not have them; enabling"
    )
    for capability in sorted(missing):
        enable_capability(token, bundle_pk, capability)
    return True
