#!/usr/bin/env python3
"""
Resolve the App Store Connect version slot for this run.

The project's MARKETING_VERSION build setting is the single source of truth
for the marketing version. CI does not bump it. This script finds-or-creates
the App Store Connect (ASC) row matching that exact version, and only the
build number is bumped automatically (see next_build_number.py).

Algorithm:
  1. Read MARKETING_VERSION from env (set by action.yml from
     xcodebuild -showBuildSettings or PlistBuddy).
  2. Store lock: if ANY App Store version is under Apple Review
     (WAITING_FOR_REVIEW / IN_REVIEW) or approved and awaiting the
     developer's release click (PENDING_DEVELOPER_RELEASE), no App Store
     version can be created or edited at all -- App Store Connect answers
     every POST with 409 ENTITY_ERROR.RELATIONSHIP.INVALID "You cannot
     create a new version of the App in the current state". The decision
     is TESTFLIGHT_ONLY: the build is stamped with the LOCKED version
     string (so it joins that version's TestFlight train), the build
     number still advances, and App Store metadata is skipped. Nothing
     below runs and the project file is never touched.
  3. Floor-check (non-strict): target >= combined floor across
     appStoreVersions + preReleaseVersions + builds->preReleaseVersion.
  4. If the target row already exists in /appStoreVersions:
       - any terminal match -> auto-roll or SystemExit(2); the slot is
         taken by a shipped row.
       - editable-only match -> REUSE its id (steady-state TestFlight
         iteration at unchanged MARKETING_VERSION).
  5. CREATE path: floor-check (strict, target > floor); PATCH-rename any
     stale editable to target; or POST a new row.

This module is intentionally thin: floor logic lives in
``mmv_floor_check``; CREATE wiring (POST/PATCH-rename, stale-editable
detection) lives in ``mmv_decide_create``. Both submodules re-bind back
through this one so test patches at ``mmv.<seam>`` keep winning.

Env: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH, APP_STORE_APPLE_ID,
     MARKETING_VERSION (project source of truth, set by action.yml).
Stdout: {"decision":"REUSE|CREATE|TESTFLIGHT_ONLY","versionString":"...",
         "appStoreVersionId":"..."} -- TESTFLIGHT_ONLY carries an empty id,
         the locked row's "state" and "storeVersionLocked" (== versionString).
"""

from __future__ import annotations

import json
import os
import sys

from asc_common import (
    BLOCKING_STATES,
    IN_REVIEW_STATES,
    REUSABLE_STATES,
    TERMINAL_STATES,
    make_jwt,
)

from mmv_floor_check import (
    BUILD_SETTING_SOURCES,
    MIGRATION_HINT,
    SEM_RE,
    assert_target_meets_floor,
    bump_patch_for_message,
    fetch_versions,
    get_combined_floor,
    get_ground_truth_floor,
    maybe_auto_bump,
    semver_tuple,
)
from mmv_decide_create import (
    create_or_reuse,
    create_or_reuse_with_stale,
    create_version,
    decide_create,
)

# Re-export the public seams so tests that patch ``mmv.<name>`` keep
# working. The submodules look these up through this module at call
# time, so a patch on ``mmv.fetch_versions`` propagates correctly.
__all__ = (
    "LOCKED_STATES",
    "SEM_RE",
    "create_or_reuse",
    "create_version",
    "decide_for_version",
    "env",
    "fetch_versions",
    "get_combined_floor",
    "get_ground_truth_floor",
    "main",
    "make_jwt",
    "semver_tuple",
    "store_version_locked",
)

#: App Store version states in which App Store Connect refuses to create or
#: edit ANY version of the app -- the whole App Store slot is locked, not just
#: the row itself. WAITING_FOR_REVIEW / IN_REVIEW: Apple is reviewing.
#: PENDING_DEVELOPER_RELEASE: Apple approved and the developer has not
#: released yet. A build made while one of these exists can only go to
#: TestFlight (``TESTFLIGHT_ONLY``), and it goes under the locked version
#: string so it lands in that version's TestFlight train instead of opening a
#: new marketing-version train the floor logic would then have to roll past.
LOCKED_STATES = frozenset(IN_REVIEW_STATES) | frozenset(BLOCKING_STATES)


_APP_ID_MISSING_HINT = (
    "::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."
)

_MARKETING_VERSION_MISSING_HINT = (
    "::error::MARKETING_VERSION env var is missing or invalid. The "
    "action.yml step that reads it from xcodebuild -showBuildSettings "
    "(or PlistBuddy fallback) must set it before this script runs. "
    + MIGRATION_HINT
)


def _log(msg: str) -> None:
    print(f"[decision] {msg}", file=sys.stderr)


def _result(decision: str, version: str, vid: str, state: str = "",
            **extra: str) -> dict:
    out = {
        "decision": decision,
        "versionString": version,
        "appStoreVersionId": vid,
    }
    if state:
        out["state"] = state
    out.update(extra)
    return out


def env(name: str) -> str:
    val = os.environ.get(name)
    if not val:
        if name == "APP_STORE_APPLE_ID":
            msg = _APP_ID_MISSING_HINT
        elif name == "MARKETING_VERSION":
            msg = _MARKETING_VERSION_MISSING_HINT
        else:
            msg = f"::error::Missing required env var: {name}"
        print(msg, file=sys.stderr)
        raise SystemExit(1)
    return val


def _validate_target(target_version: str) -> None:
    """SystemExit(1) when target is not a parseable semver."""
    if SEM_RE.match(target_version or ""):
        return
    print(
        f"::error::MARKETING_VERSION {target_version!r} is not a "
        f"valid semantic version (expected MAJOR.MINOR[.PATCH]). "
        f"Set MARKETING_VERSION in " + BUILD_SETTING_SOURCES + ". "
        + MIGRATION_HINT,
        file=sys.stderr,
    )
    raise SystemExit(1)


def _classify_match_at_target(
    versions: list[dict], target: str,
) -> tuple[str, dict | None, bool]:
    """Classify rows at ``target`` -> ``(status, match, also_editable)``.

    Status values: ``"none"`` (no match -> CREATE), ``"reuse"`` (editable
    -> REUSE its id), ``"terminal"`` (settled, slot locked -- caller may
    auto-roll), ``"in_review"`` (under Apple Review or awaiting dev
    release click -- caller MUST reject), ``"unknown"`` (matched but
    state is in NO allowlist -- caller MUST reject; round 12 fail-closed
    so an unfamiliar ASC state can't silently 409 on CREATE). The state
    taxonomy lives in ``asc_common.py`` next to the constant set
    definitions; positive allowlists (round 11) replaced the historical
    ``not in EDITABLE_STATES`` test that misclassified
    PENDING_DEVELOPER_RELEASE as terminal."""
    matches = [v for v in versions if v.get("versionString") == target]
    if not matches:
        return "none", None, False
    in_review = [
        v for v in matches
        if v.get("state") in IN_REVIEW_STATES
        or v.get("state") in BLOCKING_STATES
    ]
    if in_review:
        return "in_review", in_review[0], False
    terminal = [v for v in matches if v.get("state") in TERMINAL_STATES]
    if terminal:
        return "terminal", terminal[0], len(matches) > 1
    reusable = [v for v in matches if v.get("state") in REUSABLE_STATES]
    if reusable:
        return "reuse", reusable[0], False
    # Round 12: matches exist but no state is recognized -- fail closed.
    return "unknown", matches[0], len(matches) > 1


def _reject_match(
    target: str, match: dict, *,
    also_editable: bool = False, unknown: bool = False,
) -> None:
    """Reject a non-reusable match at ``target``. When ``unknown`` is
    True the matched row's ASC state is in none of our allowlists --
    bail rather than guess (round 12: silently CREATEing would 409;
    silently REUSEing could interfere with whatever Apple is doing
    with the row). Otherwise this is the historical terminal-collision
    rejection path."""
    state, vid = match.get("state", ""), match.get("id", "")
    if unknown:
        print(
            f"::error::MARKETING_VERSION {target} exists in App Store "
            f"Connect in unrecognized state {state!r} (id={vid}); "
            f"bailing rather than guessing whether to REUSE or CREATE. "
            f"Update mmv classifier (asc_common.TERMINAL_STATES / "
            f"IN_REVIEW_STATES / BLOCKING_STATES / REUSABLE_STATES) to "
            f"cover {state!r}, or bump MARKETING_VERSION manually to a "
            f"fresh value in your project's build settings ("
            + BUILD_SETTING_SOURCES + "). " + MIGRATION_HINT,
            file=sys.stderr,
        )
        raise SystemExit(2)
    suffix = (
        " (an editable row at the same versionString also exists)"
        if also_editable else ""
    )
    print(
        f"::error::MARKETING_VERSION {target} already exists in App "
        f"Store Connect in state {state} (id={vid}){suffix}. REUSE/CREATE "
        f"both require a fresh marketing version. Bump MARKETING_VERSION "
        f"in your project's build settings (" + BUILD_SETTING_SOURCES +
        "). " + MIGRATION_HINT,
        file=sys.stderr,
    )
    raise SystemExit(2)


def _reject_in_review_match(target: str, match: dict) -> None:
    """Issue 13: REUSE/CREATE must NOT touch a row Apple is reviewing
    OR a row awaiting the developer's manual release click
    (PENDING_DEVELOPER_RELEASE). Both states require human action;
    auto-rolling past them would either race App Review or bypass the
    developer's release decision."""
    state, vid = match.get("state", ""), match.get("id", "")
    if state == "PENDING_DEVELOPER_RELEASE":
        situation = (
            "is approved by App Review and awaiting your manual release. "
            "Either release the existing build via App Store Connect, or"
        )
    else:
        situation = (
            "is currently in App Review and cannot be modified. Either "
            "wait for review to complete, or"
        )
    print(
        f"::error::MARKETING_VERSION {target} exists in App Store Connect "
        f"in state {state} (id={vid}). The version {situation} "
        f"bump MARKETING_VERSION to a fresh value in your project's "
        f"build settings (" + BUILD_SETTING_SOURCES + "). "
        + MIGRATION_HINT,
        file=sys.stderr,
    )
    raise SystemExit(2)


def _auto_roll_past_locked(
    target_version: str, versions: list[dict],
) -> tuple[str, str, dict | None, bool]:
    """Try to auto-roll past a TERMINAL row at target.

    Classifies the row(s) at target. When the match is terminal
    (READY_FOR_SALE / PROCESSING_FOR_APP_STORE / PENDING_APPLE_RELEASE
    etc.), fires ``maybe_auto_bump`` to advance the project's
    MARKETING_VERSION to the next patch and re-classifies against the
    rolled value. When auto-bump is disabled (policy='none') or the
    write target is unreachable, returns the original locked
    classification so the caller can surface the historical
    SystemExit(2) rejection.

    DESIGN DECISION: in-review collisions (WAITING_FOR_REVIEW /
    IN_REVIEW / PENDING_DEVELOPER_RELEASE) are deliberately NOT
    auto-rolled. Apple is either actively reviewing the row at
    ``target`` or has approved it and is waiting on the developer's
    manual release click; advancing the project to a new version would
    either submit a competing build while review is in flight or
    bypass the developer's release decision -- both states require
    human action. The historical SystemExit(2) rejection at these
    states surfaces the conflict loudly so a human can resolve it.

    Returns ``(effective_target, status, match, also_editable)``."""
    status, match, also_editable = _classify_match_at_target(
        versions, target_version,
    )
    if status != "terminal":
        return target_version, status, match, also_editable
    rolled = maybe_auto_bump(target_version, target_version)
    if rolled is None or rolled == target_version:
        return target_version, status, match, also_editable
    _log(f"auto-roll: {target_version} is {match.get('state', '')}, "
         f"advancing project to {rolled}")
    new_status, new_match, new_also = _classify_match_at_target(
        versions, rolled,
    )
    return rolled, new_status, new_match, new_also


def store_version_locked(versions: list[dict]) -> dict | None:
    """The ``TESTFLIGHT_ONLY`` decision when any App Store version locks the
    slot (:data:`LOCKED_STATES`), else None.

    Decided BEFORE the floor check and before any row at the target is
    classified, because the lock is a property of the APP, not of the row at
    the target: CI run 33743284641 (GoVolt) had 1.6.3 WAITING_FOR_REVIEW and a
    project at 1.6.4, the target classified as "none", CREATE was attempted,
    and App Store Connect answered 409 RELATIONSHIP.INVALID -- exit 4, every
    push to main, until a human noticed. A target that itself matches the
    in-review row used to exit 2 asking a human to wait or bump; that was a
    person in the loop for a state the API describes precisely.

    Several locked rows (Apple allows one per platform, but fail-safe): the
    highest semver wins, because that is the train a new build belongs to.
    Nothing here reads or writes the project file: the locked string is
    exported for THIS build only and the project's MARKETING_VERSION stays the
    source of truth for the next release.
    """
    locked = [v for v in versions if v.get("state") in LOCKED_STATES
              and v.get("versionString")]
    if not locked:
        return None
    row = max(locked, key=lambda v: semver_tuple(v["versionString"]))
    version, state, vid = row["versionString"], row.get("state", ""), row.get("id", "")
    _log(f"decision=TESTFLIGHT_ONLY versionString={version} (App Store version "
         f"{version} is {state} (id={vid}); App Store Connect refuses to create "
         f"or edit any version while one is in that state, so this build is "
         f"stamped {version}, uploaded to TestFlight only, and App Store "
         f"metadata is skipped)")
    print(
        f"::notice::App Store version {version} is {state}: uploading this build "
        f"to TestFlight under {version} and skipping App Store metadata until "
        f"Apple's review concludes.",
        file=sys.stderr,
    )
    return _result("TESTFLIGHT_ONLY", version, "", state,
                   storeVersionLocked=version)


def decide_for_version(
    target_version: str, versions: list[dict],
    app_id: str, token: str,
) -> dict:
    """Find-or-create the ASC row at exactly ``target_version``.

    Validates semver, answers ``TESTFLIGHT_ONLY`` while any App Store
    version locks the slot (:func:`store_version_locked` -- no floor check,
    no auto-bump, no CREATE), then runs the non-strict floor check
    (auto-bump rewrites MARKETING_VERSION when below the ASC floor; the
    returned effective target is what downstream uses), then classifies any
    row(s) at target. Terminal collisions auto-roll to the next patch
    (or reject when policy='none'); unknown-state collisions reject loudly
    (see ``_classify_match_at_target`` for the full taxonomy). No match ->
    CREATE; reusable editable -> REUSE."""
    _validate_target(target_version)
    locked = store_version_locked(versions)
    if locked is not None:
        return locked
    target_version = assert_target_meets_floor(
        target_version, app_id, token,
        appstore_versions=versions, strict=False,
    )
    target_version, status, match, also_editable = _auto_roll_past_locked(
        target_version, versions,
    )
    if status == "terminal":
        _reject_match(target_version, match, also_editable=also_editable)
    if status == "in_review":
        _reject_in_review_match(target_version, match)
    if status == "unknown":
        _reject_match(target_version, match, unknown=True)
    if status == "none":
        return decide_create(target_version, versions, app_id, token)
    vid = match.get("id", "")
    state = match.get("state", "")
    _log(f"decision=REUSE versionString={target_version} "
         f"(matches MARKETING_VERSION; state={state}, id={vid})")
    return _result("REUSE", target_version, vid, state)


def main() -> None:
    key_id = env("ASC_KEY_ID")
    issuer_id = env("ASC_ISSUER_ID")
    key_path = env("ASC_KEY_PATH")
    app_id = env("APP_STORE_APPLE_ID")
    target_version = env("MARKETING_VERSION")

    token = make_jwt(key_id, issuer_id, key_path)
    versions = fetch_versions(app_id, token)
    result = decide_for_version(target_version, versions, app_id, token)
    print(json.dumps(result))


if __name__ == "__main__":
    main()
