#!/usr/bin/env python3
"""
Query App Store Connect for the highest-numbered build across every
source that can surface an in-flight upload, and print ``<highest + 1>``
to stdout.

Why multiple sources
--------------------
Apple's ``/builds`` endpoint normally lists every build regardless of
``processingState`` (PROCESSING, VALID, INVALID, EXPIRED), but there is
a window right after altool completes where a freshly-uploaded build
only shows up reliably under its ``preReleaseVersion`` relationship.
Missing that build is catastrophic: the script would return a value
altool already rejected on the previous run, causing the next upload
to fail with::

    The bundle version must be higher than the previously uploaded
    version: 'N'.

So we union build versions from BOTH ``/builds`` and
``/preReleaseVersions?include=builds`` before taking the max.

A REST build upload reserves its number the moment ``/buildUploads`` is
created. A reservation whose transfer or commit never finished (state
AWAITING_UPLOAD or PROCESSING, or one Apple refused) appears in neither
list above, yet the uploader must never reuse that number for different
IPA bytes, so ``/apps/{id}/buildUploads`` is the third source.

Environment
-----------
  ASC_KEY_ID           API key ID
  ASC_ISSUER_ID        API issuer ID
  ASC_KEY_PATH         Path to the .p8 key file
  APP_STORE_APPLE_ID   App Store numeric app id
"""

from __future__ import annotations

import json
import os
import subprocess
import sys
from pathlib import Path

from asc_common import get_json, make_jwt


def env(name: str) -> str:
    val = os.environ.get(name)
    if not val:
        if name == "APP_STORE_APPLE_ID":
            raise SystemExit(
                "::error::APP_STORE_APPLE_ID is empty. This usually means "
                "auto-detect could not resolve PRODUCT_BUNDLE_IDENTIFIER from "
                "your Xcode project (check the auto-detect: ... log lines in the "
                "'Resolve credentials + auto-detect' step above). Fix options: "
                "(1) commit your .xcodeproj or add an xcodegen project.yml; "
                "(2) pass `app-store-apple-id:` explicitly via the action's `with:` block."
            )
        raise SystemExit(f"missing env var: {name}")
    return val


def _build_version_int(build: dict) -> int | None:
    try:
        return int(build.get("attributes", {}).get("version"))
    except (TypeError, ValueError):
        return None


def _log_newest(builds: list[dict], limit: int = 3) -> None:
    """Print the newest few builds (by uploadedDate) to stderr for debugging."""
    def _uploaded(b: dict) -> str:
        return (b.get("attributes") or {}).get("uploadedDate") or ""

    newest = sorted(builds, key=_uploaded, reverse=True)[:limit]
    for b in newest:
        attrs = b.get("attributes") or {}
        print(
            f"ASC build: v={attrs.get('version')!r} "
            f"state={attrs.get('processingState')!r} "
            f"uploaded={attrs.get('uploadedDate')!r}",
            file=sys.stderr,
        )


def fetch_build_versions(app_id: str, token: str) -> set[int]:
    """Versions from GET /builds?filter[app]=... (all processingStates)."""
    data = get_json(
        "/builds",
        token,
        params={
            "filter[app]": app_id,
            "sort": "-version",
            "limit": "200",
        },
    )
    builds = data.get("data", []) or []
    print(f"ASC /builds: scanning {len(builds)} builds", file=sys.stderr)
    _log_newest(builds)
    return {n for n in map(_build_version_int, builds) if n is not None}


def fetch_prerelease_versions(app_id: str, token: str) -> set[int]:
    """Versions reached via /preReleaseVersions?include=builds.

    Fresh uploads occasionally appear in the ``included`` build records
    here before ``/builds`` catches up; harvest both the relationship
    edges and the included build resources.
    """
    data = get_json(
        "/preReleaseVersions",
        token,
        params={
            "filter[app]": app_id,
            "include": "builds",
            "limit": "200",
        },
    )
    included = data.get("included", []) or []
    builds = [r for r in included if r.get("type") == "builds"]
    print(
        f"ASC /preReleaseVersions: scanning {len(builds)} included builds",
        file=sys.stderr,
    )
    _log_newest(builds)
    return {n for n in map(_build_version_int, builds) if n is not None}


def _upload_version_int(upload: dict) -> int | None:
    try:
        return int((upload.get("attributes") or {}).get("cfBundleVersion"))
    except (TypeError, ValueError):
        return None


def fetch_build_upload_versions(app_id: str, token: str) -> set[int]:
    """Versions reserved by REST build uploads (``/apps/{id}/buildUploads``), any state.

    Every reservation counts, finished or not: a fresh number is always safe,
    while reusing one that an unfinished or refused upload still holds makes
    the next run collide with that upload identity.
    """
    data = get_json(
        f"/apps/{app_id}/buildUploads",
        token,
        params={"filter[platform]": "IOS", "limit": "200"},
    )
    uploads = data.get("data", []) or []
    print(f"ASC /buildUploads: scanning {len(uploads)} upload reservations", file=sys.stderr)
    for upload in uploads[:3]:
        attrs = upload.get("attributes") or {}
        state = (attrs.get("state") or {}).get("state") if isinstance(attrs.get("state"), dict) else attrs.get("state")
        print(f"ASC upload reservation: v={attrs.get('cfBundleVersion')!r} state={state!r}", file=sys.stderr)
    return {n for n in map(_upload_version_int, uploads) if n is not None}


def _read_pbxproj_build_number() -> int | None:
    """Best-effort local fallback: read CURRENT_PROJECT_VERSION from pbxproj.

    Returns the maximum integer CURRENT_PROJECT_VERSION found across all
    XCBuildConfiguration blocks of the first discovered .xcodeproj at the
    current working directory. Returns None if nothing usable is found
    (no project, parse failure, all values are non-numeric / interpolated).
    """
    try:
        projects = sorted(Path(".").glob("*.xcodeproj"))
        if not projects:
            return None
        pbx = projects[0] / "project.pbxproj"
        if not pbx.exists():
            return None
        out = subprocess.check_output(
            ["plutil", "-convert", "json", "-o", "-", str(pbx)]
        )
        objects = (json.loads(out).get("objects") or {})
    except (subprocess.CalledProcessError, json.JSONDecodeError, OSError):
        return None

    values: set[int] = set()
    for obj in objects.values():
        if not isinstance(obj, dict):
            continue
        settings = obj.get("buildSettings") or {}
        raw = settings.get("CURRENT_PROJECT_VERSION")
        if raw is None:
            continue
        try:
            values.add(int(str(raw).strip()))
        except ValueError:
            continue
    return max(values) if values else None


def resolve_next_build_number(app_id: str, token: str) -> int:
    """Aggregate every known source of uploaded build versions."""
    versions = fetch_build_versions(app_id, token)
    versions |= fetch_prerelease_versions(app_id, token)
    versions |= fetch_build_upload_versions(app_id, token)
    highest = max(versions, default=0)
    print(f"ASC highest build version seen: {highest}", file=sys.stderr)
    return highest + 1


def main() -> None:
    app_id = env("APP_STORE_APPLE_ID")
    try:
        token = make_jwt(env("ASC_KEY_ID"), env("ASC_ISSUER_ID"), env("ASC_KEY_PATH"))
        print(resolve_next_build_number(app_id, token))
        return
    except SystemExit:
        raise
    except Exception as exc:  # noqa: BLE001 - last-resort safety net
        print(
            f"::warning::ASC build lookup failed ({exc!r}); "
            "falling back to pbxproj CURRENT_PROJECT_VERSION + 1",
            file=sys.stderr,
        )

    local = _read_pbxproj_build_number()
    if local is None:
        raise SystemExit(
            "::error::could not resolve next build number from ASC or pbxproj; "
            "fix ASC credentials or commit a CURRENT_PROJECT_VERSION in the Xcode project"
        )
    print(local + 1)


if __name__ == "__main__":
    main()
