#!/usr/bin/env python3
"""
Provisioning profile lifecycle helpers for the iOS native TestFlight action.

Talks to the App Store Connect API to:

* Register a bundle ID if it doesn't exist yet (``ensure_bundle_id``).
* Delete any stale profile with the same name and create a fresh one that
  references the just-issued distribution cert (``delete_profile_by_name``,
  ``create_profile``).
* Install the resulting ``.mobileprovision`` into every Xcode profile
  directory (Xcode 15 and Xcode 16+ use different paths).

Caching
-------

``provision_all_bundles`` is now cache-aware. Callers pass the workspace
``creds_dir`` and a ``cache_hit`` flag indicating whether the cert was
itself reused from cache. The function:

  * If the manifest's cached cert_id matches the current cert_id AND the
    cert was reused, it tries to reuse each bundle's profile from disk
    (``find_reusable_profile``) before falling back to a fresh
    ``create_profile`` round-trip. Profile create on Apple's side is
    rate-limited and disturbs the team's profile-list view, so caching
    is worth it.
  * If the cert changed (cap rotation, expiry, manual revoke) it forces
    a full regen — Apple invalidates a profile the moment its leaf cert
    goes away, so reusing the on-disk file would archive an unsigned
    binary.
  * After the loop it GCs orphan ``.mobileprovision`` files whose bundle
    is no longer in ``bundle_ids`` (e.g. an extension was deleted from
    the target list) and rewrites the manifest with the current set.

Xcode pbxproj editing lives in ``pbxproj_editor.py``; this file is
ASC-facing only.
"""

from __future__ import annotations

import base64
import subprocess
from datetime import datetime
from pathlib import Path

import app_groups
import capabilities
import creds_store
from asc_common import get_json, request
from pbxproj_editor import PROFILE_PREFIX
from profile_io import decode_profile_plist, ensure_utc


# Xcode 16+ moved the canonical profile directory. Older Xcode releases
# used ~/Library/MobileDevice/Provisioning Profiles/. Write to BOTH so the
# same script works on macos-14 (Xcode 15) and macos-15 (Xcode 26).
_PROFILE_DIRS = [
    Path.home() / "Library/Developer/Xcode/UserData/Provisioning Profiles",
    Path.home() / "Library/MobileDevice/Provisioning Profiles",
]


# --------------------------------------------------------------------------- #
# ASC bundle ID registration                                                  #
# --------------------------------------------------------------------------- #

def ensure_bundle_id(token: str, identifier: str) -> str:
    """Return the ASC primary key for ``identifier``; register if missing."""
    data = get_json(
        "/bundleIds",
        token,
        params={"filter[identifier]": identifier, "limit": "5"},
    )
    for item in data.get("data", []):
        if item["attributes"]["identifier"] == identifier:
            return item["id"]
    return _register_bundle_id(token, identifier)


def _register_bundle_id(token: str, identifier: str) -> str:
    body = {
        "data": {
            "type": "bundleIds",
            "attributes": {
                "identifier": identifier,
                "name": identifier.replace(".", "-"),
                "platform": "IOS",
            },
        }
    }
    resp = request("POST", "/bundleIds", token, json_body=body)
    bundle_pk = resp.json()["data"]["id"]
    print(f"Registered new bundle id {identifier!r} (pk={bundle_pk})")
    return bundle_pk


# --------------------------------------------------------------------------- #
# Profile lifecycle                                                           #
# --------------------------------------------------------------------------- #

def delete_profile_by_name(token: str, name: str) -> None:
    """Delete every existing profile with the given name (paginated scan)."""
    deleted_any = False
    next_path: str | None = "/profiles?limit=200"
    while next_path:
        data = get_json(next_path, token)
        for p in data.get("data", []):
            if (p["attributes"].get("name") or "") == name:
                pid = p["id"]
                print(f"Deleting stale profile {pid} ({name})")
                request("DELETE", f"/profiles/{pid}", token)
                deleted_any = True
        next_link = (data.get("links") or {}).get("next")
        if not next_link:
            break
        idx = next_link.find("/v1")
        next_path = next_link[idx + len("/v1"):] if idx >= 0 else None
    if not deleted_any:
        print(f"No existing profile named {name!r}")


def create_profile(
    token: str, name: str, bundle_pk: str, cert_id: str
) -> bytes:
    body = {
        "data": {
            "type": "profiles",
            "attributes": {
                "name": name,
                "profileType": "IOS_APP_STORE",
            },
            "relationships": {
                "bundleId": {"data": {"type": "bundleIds", "id": bundle_pk}},
                "certificates": {
                    "data": [{"type": "certificates", "id": cert_id}]
                },
            },
        }
    }
    resp = request("POST", "/profiles", token, json_body=body)
    payload = resp.json()["data"]["attributes"]["profileContent"]
    return base64.b64decode(payload)


def install_profile(profile_der: bytes) -> tuple[str, str, datetime]:
    """Install the raw profile into every Xcode-visible directory.

    Returns ``(uuid, team_id, expiration)``. ``team_id`` is the team
    Apple assigned to the profile — the effective team that Xcode
    expects to find in ``DEVELOPMENT_TEAM``. ``expiration`` is the
    profile's ``ExpirationDate`` normalised to UTC; the caller persists
    it in the cache manifest.
    """
    plist = decode_profile_plist(profile_der, _PROFILE_DIRS[0])
    uuid = plist["UUID"]
    team_ids = plist.get("TeamIdentifier") or []
    team_id = team_ids[0] if team_ids else ""
    profile_name = plist.get("Name") or "<unknown>"
    expiration = ensure_utc(plist.get("ExpirationDate"))
    final_name = f"{uuid}.mobileprovision"
    for directory in _PROFILE_DIRS:
        directory.mkdir(parents=True, exist_ok=True)
        (directory / final_name).write_bytes(profile_der)
    print(
        f"  profile {profile_name!r}: uuid={uuid} team={team_id} "
        f"exp={expiration.isoformat()} dirs={[str(d) for d in _PROFILE_DIRS]}"
    )
    return uuid, team_id, expiration


# --------------------------------------------------------------------------- #
# Orchestration                                                               #
# --------------------------------------------------------------------------- #

def _try_reuse_cached(
    bid: str,
    name: str,
    cert_id: str,
    creds_dir: Path,
    manifest: dict,
    entitlement_keys: set[str] | None = None,
    required_groups: set[str] | None = None,
) -> tuple[str, str, dict] | None:
    """Return ``(uuid, team, entry)`` if a cached profile reinstalls cleanly.

    Decode failure or missing entitlement keys/group values falls through to
    fresh creation. The cache manifest's UUID and expiry alone cannot prove
    that a profile reflects the App ID's current capability assignments.
    """
    cached = creds_store.find_reusable_profile(manifest, bid, cert_id, creds_dir)
    if cached is None:
        return None
    try:
        der = creds_store.profile_path(creds_dir, cached.uuid).read_bytes()
        if entitlement_keys or required_groups:
            plist = decode_profile_plist(der, _PROFILE_DIRS[0])
            missing = capabilities.missing_profile_entitlements(
                plist.get("Entitlements") or {}, entitlement_keys or set()
            )
            if app_groups.missing(plist.get("Entitlements") or {}, required_groups or set()):
                missing.add(app_groups.ENTITLEMENT)
            if missing:
                print(
                    f"cached profile {cached.uuid} predates "
                    f"{', '.join(sorted(missing))}; regenerating"
                )
                return None
        uuid, team_id, _ = install_profile(der)
    except (OSError, subprocess.CalledProcessError, ValueError, KeyError) as exc:
        print(
            f"::warning::cached profile {cached.filename!r} unusable "
            f"({exc!r}); regenerating"
        )
        return None
    # load_profile_manifest already normalizes cached.expiration to UTC.
    entry = {
        "bundle_id": bid,
        "name": cached.name or name,
        "uuid": uuid,
        "filename": f"{uuid}.mobileprovision",
        "expiration": cached.expiration.isoformat(),
    }
    print(f"Reused cached profile {name} -> {uuid} ({bid})")
    return uuid, team_id, entry


def _provision_bundle(
    token: str,
    bid: str,
    cert_id: str,
    creds_dir: Path,
    manifest: dict,
    can_reuse: bool,
    entitlement_keys: set[str] | None = None,
    required_groups: set[str] | None = None,
) -> tuple[str, str, str, dict]:
    """Provision one bundle; return ``(name, uuid, team, manifest_entry)``."""
    name = f"{PROFILE_PREFIX}{bid}"
    # Reconcile capabilities BEFORE deciding to reuse. A profile carries only
    # the capabilities its App ID had when it was issued, so a cached profile
    # that predates an entitlement being added stays broken forever otherwise
    # — the archive keeps failing on a capability the App ID now has.
    bundle_pk = ""
    if entitlement_keys:
        bundle_pk = ensure_bundle_id(token, bid)
        if capabilities.reconcile(token, bundle_pk, bid, entitlement_keys):
            can_reuse = False
    if can_reuse:
        reused = _try_reuse_cached(
            bid, name, cert_id, creds_dir, manifest, entitlement_keys, required_groups
        )
        if reused is not None:
            uuid, team_id, entry = reused
            return name, uuid, team_id, entry

    bundle_pk = bundle_pk or ensure_bundle_id(token, bid)
    delete_profile_by_name(token, name)
    profile_der = create_profile(token, name, bundle_pk, cert_id)
    app_groups.assert_profile(profile_der, required_groups or set(), bid, _PROFILE_DIRS[0])
    if entitlement_keys:
        _assert_carplay_entitlements(profile_der, entitlement_keys, bid, name)
    uuid, team_id, expiration = install_profile(profile_der)
    creds_store.write_cached_profile(creds_dir, uuid, profile_der)
    entry = {
        "bundle_id": bid,
        "name": name,
        "uuid": uuid,
        "filename": f"{uuid}.mobileprovision",
        "expiration": expiration.isoformat(),
    }
    print(f"Installed fresh profile {name} -> {uuid} ({bid})")
    return name, uuid, team_id, entry


def _assert_carplay_entitlements(
    profile_der: bytes, entitlement_keys: set[str], bid: str, name: str
) -> None:
    """Fail fast when a fresh profile lacks a required CarPlay capability.

    CarPlay capabilities are Apple-granted per App ID and cannot be enabled
    through the ASC API. Without this check the run dies much later inside
    ``xcodebuild archive`` with an opaque "doesn't include the ... capability"
    — here it dies immediately, saying exactly what to do.
    """
    try:
        plist = decode_profile_plist(profile_der, _PROFILE_DIRS[0])
    except (OSError, subprocess.CalledProcessError, ValueError) as exc:
        print(f"::warning::could not inspect fresh profile for {bid}: {exc!r}")
        return
    missing = capabilities.missing_carplay_entitlements(
        plist.get("Entitlements") or {}, entitlement_keys
    )
    if not missing:
        return
    keys = ", ".join(sorted(missing))
    raise SystemExit(
        f"::error::Profile {name} for {bid} was issued WITHOUT {keys}. "
        f"CarPlay capabilities are granted by Apple per App ID via the "
        f"CarPlay entitlement request (developer.apple.com/contact/carplay/) "
        f"and cannot be enabled through the App Store Connect API. Until the "
        f"grant lands, keep the CarPlay key out of the entitlements file "
        f"used by this configuration; once Apple confirms it on the App ID, "
        f"re-run — the profile is regenerated automatically."
    )


def _gc_orphan_profiles(
    creds_dir: Path, old_manifest: dict, new_entries: list[dict]
) -> None:
    """Remove cached .mobileprovision files for bundles no longer present."""
    new_uuids = {e["uuid"] for e in new_entries}
    for raw in old_manifest.get("profiles", []):
        uuid = raw.get("uuid")
        if not uuid or uuid in new_uuids:
            continue
        path = creds_store.profile_path(creds_dir, uuid)
        if path.exists():
            path.unlink(missing_ok=True)
            print(f"GC'd orphan profile {uuid} ({raw.get('bundle_id')!r})")


def provision_all_bundles(
    token: str,
    bundle_ids: list[str],
    cert_id: str,
    *,
    creds_dir: Path,
    cache_hit: bool,
    entitlements_by_bundle: dict[str, set[str]] | None = None,
    app_groups_by_bundle: dict[str, set[str]] | None = None,
) -> tuple[list[tuple[str, str, str]], str]:
    """Create + install a CI profile for each bundle id, with caching.

    Returns ``(mappings, team_id)`` with ``(bundle_id, profile_name, uuid)``
    tuples and the team Apple assigned to the profiles.
    ``cache_hit`` says whether the cert came from cache. ``creds_dir``
    is the workspace ``creds/`` root. When the manifest's cert_id
    differs from ``cert_id`` we force a full regen — Apple invalidates
    every profile bound to the prior cert the moment that cert is
    revoked, so reusing on-disk profiles would archive unsigned bits.
    """
    entitlements_by_bundle = entitlements_by_bundle or {}
    app_groups_by_bundle = app_groups_by_bundle or {}
    app_groups.validate_requirements(entitlements_by_bundle, app_groups_by_bundle)
    manifest = creds_store.load_profile_manifest(creds_dir)
    can_reuse = cache_hit and manifest.get("cert_id") == cert_id
    if cache_hit and not can_reuse:
        print(
            f"Manifest cert_id {manifest.get('cert_id')!r} differs from "
            f"current {cert_id!r}; regenerating all profiles"
        )

    results: list[tuple[str, str, str]] = []
    new_entries: list[dict] = []
    effective_team = ""
    for bid in bundle_ids:
        name, uuid, team_id, entry = _provision_bundle(
            token, bid, cert_id, creds_dir, manifest, can_reuse,
            entitlements_by_bundle.get(bid),
            app_groups_by_bundle.get(bid),
        )
        if team_id:
            effective_team = team_id
        results.append((bid, name, uuid))
        new_entries.append(entry)

    _gc_orphan_profiles(creds_dir, manifest, new_entries)
    creds_store.write_profile_manifest(
        creds_dir, {"cert_id": cert_id, "profiles": new_entries}
    )
    return results, effective_team
