#!/usr/bin/env python3
"""
Prepare iOS code signing on a fresh CI runner (manual-signing variant).

The action consumes a retained Apple Distribution identity from encrypted CI inputs
or an existing legacy cache. It never creates, replaces or revokes a certificate:
new private keys in this ephemeral checkout would be lost at job cleanup.

After confirming the supplied identity is usable and live on Apple, create/reuse
per-bundle provisioning profiles, patch the ephemeral Xcode project and import
that identity into the job keychain. Registry ownership and digest checks remain
mandatory when its marker is present. No signing files enter source maintenance.

Env inputs: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH (key trio), PROJECT
(.xcodeproj), TEAM_ID (optional; derived from profile plist when empty),
RUNNER_TEMP, CREDS_DIR (optional override; defaults to
``$GITHUB_WORKSPACE/creds``). Writes nothing to stdout that would leak
secrets.
"""

from __future__ import annotations

import json
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path

import app_groups
import capabilities
import creds_store
from asc_common import make_jwt
from keychain import setup_keychain
from pbxproj_editor import discover_signable_targets, patch_project_signing
from profile_manager import provision_all_bundles


def env(name: str) -> str:
    val = os.environ.get(name)
    if not val:
        raise SystemExit(f"missing env var: {name}")
    return val


def optional_env(name: str) -> str:
    return os.environ.get(name, "")


def _warn_if_renewal_near(cached: creds_store.CachedCert) -> None:
    now = datetime.now(timezone.utc)
    renew_at = now + timedelta(days=creds_store.RENEW_THRESHOLD_DAYS)
    warn_at = now + timedelta(days=creds_store.WARN_THRESHOLD_DAYS)
    if renew_at < cached.not_after <= warn_at:
        print(
            f"::warning::Cert {cached.cert_id} expires in "
            f"{(cached.not_after - now).days}d; provision its replacement before "
            f"{creds_store.RENEW_THRESHOLD_DAYS}d remaining."
        )


def _load_signing_identity(
    token: str, creds_dir: Path
) -> tuple[str, bytes, str, bool]:
    """Reuse retained signing material; CI cannot durably own newly issued keys."""
    managed = creds_store.registry_managed(creds_dir)
    cached = creds_store.load_cached_cert(creds_dir)
    expected_digest = cached.certificate_sha256 if cached and managed else None
    if cached and creds_store.verify_cert_alive(token, cached.cert_id, expected_digest):
        print(
            f"Reusing cached distribution cert {cached.cert_id} "
            f"(NotAfter {cached.not_after.isoformat()})"
        )
        _warn_if_renewal_near(cached)
        return cached.cert_id, cached.p12_bytes, cached.password, True

    if managed:
        detail = "missing, corrupt, or within the renewal window"
        if cached:
            detail = f"certificate {cached.cert_id} is not active on Apple or does not match cert.p12"
        raise SystemExit(
            "registry-managed distribution identity is unusable "
            f"({detail}); refusing certificate creation or revocation. "
            "Reconcile and replace the account identity in app-robot."
        )

    reason = "unusable" if cached else "missing_or_invalid"
    evidence = {"schema": "gowalk-cicd/apple-signing-identity-required.v1", "mode": "legacy",
                "reason": reason, "certificate_creation_attempted": False}
    print("::error title=apple_signing_identity_required::" + json.dumps(evidence, separators=(",", ":")))
    raise SystemExit(
        "A retained Apple Distribution signing identity is required; provision it through encrypted CI inputs. "
        "Refusing certificate creation because this job cannot retain a new private key durably. "
        "Existing signing files are preserved."
    )


def _resolve_creds_dir() -> Path:
    """Return the workspace creds/ root, honoring the CREDS_DIR override."""
    override = optional_env("CREDS_DIR")
    if override:
        return Path(override)
    workspace = optional_env("GITHUB_WORKSPACE") or os.getcwd()
    return Path(workspace) / "creds"


def _resolve_pbx_team(effective_team: str, configured_team: str) -> str:
    """Pick the team Xcode must see and warn on mismatch with config.

    The ASC API key is bound to a single developer team. Every profile
    it issues lives in THAT team, so Xcode's ``DEVELOPMENT_TEAM`` must
    match it exactly — otherwise the profile can't be matched to the
    target at archive time. If the ci.config.yaml team differs, warn
    and use the profile's team as the source of truth.
    """
    pbx_team = effective_team or configured_team
    if not pbx_team:
        raise SystemExit(
            "Unable to determine Apple developer team. The installed "
            "provisioning profile did not expose a TeamIdentifier and no "
            "TEAM_ID was provided. Set `app.team_id` in ci.config.yaml. "
            "Find your team id at https://developer.apple.com/account -> "
            "Membership (look for 'Team ID')."
        )
    if effective_team and configured_team and effective_team != configured_team:
        print(
            f"::warning::Config team {configured_team} differs from ASC API "
            f"key's team {effective_team}; patching pbxproj with "
            f"{effective_team} (the team that actually issued the "
            "provisioning profiles)."
        )
    return pbx_team


def _write_signing_map(
    runner_temp: str, pbx_team: str, mappings: list
) -> None:
    """Persist mapping for the downstream Export IPA step.

    ``ExportOptions.plist`` needs a ``provisioningProfiles`` dict and
    the effective team that issued the profiles.
    """
    map_path = Path(runner_temp) / "signing_map.json"
    map_path.write_text(
        json.dumps(
            {
                "team_id": pbx_team,
                "profiles": {bid: pname for bid, pname, _ in mappings},
            },
            indent=2,
        )
    )
    print(f"Wrote signing map to {map_path}")


def _resolve_project_path() -> str:
    """Return the .xcodeproj path; reject WORKSPACE-only mode loudly."""
    project = optional_env("PROJECT")
    if optional_env("WORKSPACE") and not project:
        raise SystemExit(
            "prepare_signing: WORKSPACE-only mode is not supported; the "
            "underlying .xcodeproj must be passed via PROJECT so we can "
            "patch its signing settings."
        )
    if not project:
        raise SystemExit("prepare_signing: PROJECT env var is required")
    return project


def _entitlement_keys_by_bundle(project: str, targets: list[dict]) -> dict[str, set[str]]:
    """bundle_id -> entitlement keys declared by the targets that ship it.

    CODE_SIGN_ENTITLEMENTS is relative to the project directory. Targets
    sharing a bundle id (an app and its test host, say) contribute a union —
    a capability any of them needs must be on the App ID.
    """
    base = Path(project).parent
    by_bundle: dict[str, set[str]] = {}
    for target in targets:
        relative = target.get("entitlements") or ""
        if not relative:
            continue
        path = base / relative
        if not path.is_file():
            print(f"::warning::{target['name']}: entitlements not found at {path}")
            continue
        keys = capabilities.read_entitlement_keys(path)
        if keys:
            by_bundle.setdefault(target["bundle_id"], set()).update(keys)
    return by_bundle


def main() -> None:
    runner_temp = env("RUNNER_TEMP")
    # TEAM_ID is optional — provision_all_bundles returns the effective
    # team from the installed profile's plist; see _resolve_pbx_team.
    team_id = optional_env("TEAM_ID")
    project = _resolve_project_path()
    token = make_jwt(env("ASC_KEY_ID"), env("ASC_ISSUER_ID"), env("ASC_KEY_PATH"))
    creds_dir = _resolve_creds_dir()

    targets = discover_signable_targets(project)
    bundle_ids = sorted({t["bundle_id"] for t in targets})
    print(f"Signable targets ({len(targets)}):")
    for t in targets:
        print(
            f"  - {t['name']} -> {t['bundle_id']} "
            f"({len(t['config_ids'])} configs)"
        )

    groups_by_bundle = app_groups.declared_by_bundle(project, targets)
    cert_id, p12_bytes, p12_pass, cache_hit = _load_signing_identity(
        token, creds_dir
    )
    mappings, effective_team = provision_all_bundles(
        token, bundle_ids, cert_id, creds_dir=creds_dir, cache_hit=cache_hit,
        entitlements_by_bundle=_entitlement_keys_by_bundle(project, targets),
        app_groups_by_bundle=groups_by_bundle,
    )
    print("Profile map:")
    for bid, pname, uuid in mappings:
        print(f"  {bid} -> {pname} ({uuid})")

    pbx_team = _resolve_pbx_team(effective_team, team_id)
    patch_project_signing(project, targets, pbx_team)
    _write_signing_map(runner_temp, pbx_team, mappings)

    # Stage p12 in $RUNNER_TEMP for the keychain importer (the cache copy
    # under creds/cert.p12 stays untouched as the source of truth).
    p12_path = Path(runner_temp) / "cert.p12"
    p12_path.write_bytes(p12_bytes)
    setup_keychain(p12_path, p12_pass, runner_temp)


if __name__ == "__main__":
    main()
