#!/usr/bin/env python3
"""
Shared helpers for App Store Connect API calls.

Provides JWT generation, a retrying HTTP client, and state-classification
constants used by multiple scripts in this action.
"""

from __future__ import annotations

import os
import sys
import time
from typing import Any

import jwt
import requests


ASC_BASE = "https://api.appstoreconnect.apple.com/v1"

# ----------------------------------------------------------------------
# ASC version-state taxonomy (consumed by manage_marketing_version's
# ``_classify_match_at_target``):
#
#   terminal  -- READY_FOR_SALE / PROCESSING_FOR_APP_STORE /
#                PENDING_APPLE_RELEASE / archived (REPLACED_WITH_NEW_VERSION,
#                REMOVED_FROM_SALE, NOT_APPLICABLE). Slot is locked; auto-roll
#                may advance the project's MARKETING_VERSION to the next
#                patch instead of rejecting.
#   in_review -- WAITING_FOR_REVIEW / IN_REVIEW (Apple is actively reviewing)
#                OR PENDING_DEVELOPER_RELEASE (Apple approved; awaiting the
#                developer's manual release click). Auto-roll is forbidden:
#                advancing past either state would race the review process or
#                bypass the developer's release decision.
#   editable  -- PREPARE_FOR_SUBMISSION / REJECTED / METADATA_REJECTED /
#                DEVELOPER_REJECTED / INVALID_BINARY. REUSE the row's id
#                (developer-actionable; mutating is safe).
#   unknown   -- any state in NONE of the above. Round-12 fail-closed: the
#                classifier rejects rather than guessing, so a future ASC
#                state cannot silently 409 on CREATE or interfere with
#                whatever Apple is doing with the row.
#
# Round-11 design decision: the classifier uses POSITIVE allowlists below
# rather than ``not in EDITABLE_STATES``. The negative test misclassified
# PENDING_DEVELOPER_RELEASE as terminal -- it's NOT editable, but it's also
# NOT auto-rollable (the developer has an approved build awaiting their
# manual release). Positive allowlists make the taxonomy explicit and
# prevent future ASC states from silently falling into the auto-roll
# bucket.
# ----------------------------------------------------------------------

# App Store version states that allow editing the current draft.
EDITABLE_STATES = {
    "PREPARE_FOR_SUBMISSION",
    "WAITING_FOR_REVIEW",
    "IN_REVIEW",
    "REJECTED",
    "METADATA_REJECTED",
    "INVALID_BINARY",
    "DEVELOPER_REJECTED",
}

# Subset of EDITABLE_STATES safe to mutate (attach a build, rename via PATCH)
# without going through Apple Review. WAITING_FOR_REVIEW and IN_REVIEW are
# editable in the broad ASC sense (developer can withdraw), but Apple is
# actively reviewing them: attaching a TestFlight build or PATCH-renaming
# such a row would silently interfere with App Review. REJECTED /
# METADATA_REJECTED / INVALID_BINARY come back from Apple Review and are
# explicitly developer-actionable, so mutating them is safe.
REUSABLE_STATES = {
    "PREPARE_FOR_SUBMISSION",
    "DEVELOPER_REJECTED",
    "REJECTED",
    "METADATA_REJECTED",
    "INVALID_BINARY",
}

# Editable states that are currently under Apple Review. Mutating these
# would interfere with the review process.
IN_REVIEW_STATES = {
    "WAITING_FOR_REVIEW",
    "IN_REVIEW",
}

# Terminal states: the version is locked and a new one must be created.
TERMINAL_STATES = {
    "READY_FOR_SALE",
    "PROCESSING_FOR_APP_STORE",
    "PENDING_APPLE_RELEASE",
    "REPLACED_WITH_NEW_VERSION",
    "REMOVED_FROM_SALE",
    "NOT_APPLICABLE",
}

# Blocking states: the version is approved but awaiting developer action.
BLOCKING_STATES = {"PENDING_DEVELOPER_RELEASE"}


# Retry policy for App Store Connect API calls.
#
# Apple's API intermittently returns 5xx under load and uses 429 for throttling.
# These are retryable; non-listed 4xx responses are not (retrying just produces
# the same failure).
_RETRY_STATUSES = {429, 500, 502, 503, 504}
_RETRY_BACKOFFS = (2, 8, 30)  # seconds

# Per-request timeouts (seconds). Splitting connect vs read so a slow TLS
# handshake can't masquerade as a slow API response (and vice versa).
# Without these, ``requests.request(...)`` would block the CI runner
# indefinitely on a hung TCP socket -- the runner only kills the job at
# its 6h hard limit, by which point the on-call has already paged.
_DEFAULT_TIMEOUT_CONNECT_SEC = 10.0
_DEFAULT_TIMEOUT_READ_SEC = 30.0
# Env overrides for slow networks / unusually large ASC responses.
_TIMEOUT_CONNECT_ENV = "ASC_REQUEST_TIMEOUT_CONNECT_SEC"
_TIMEOUT_READ_ENV = "ASC_REQUEST_TIMEOUT_READ_SEC"


def make_jwt(key_id: str, issuer_id: str, key_path: str) -> str:
    """Return an ES256 JWT valid for 20 minutes, audience appstoreconnect-v1."""
    with open(key_path, "r") as f:
        key = f.read()
    now = int(time.time())
    payload = {
        "iss": issuer_id,
        "iat": now,
        "exp": now + 1200,
        "aud": "appstoreconnect-v1",
    }
    return jwt.encode(
        payload, key, algorithm="ES256", headers={"kid": key_id, "typ": "JWT"}
    )


def _request_timeouts() -> tuple[float, float]:
    """Return ``(connect, read)`` timeouts in seconds. Both env-overridable
    for slow networks (``ASC_REQUEST_TIMEOUT_CONNECT_SEC`` /
    ``ASC_REQUEST_TIMEOUT_READ_SEC``); invalid or non-positive values
    fall back to the defaults so a typo can't disable the timeout."""
    def read(env_name: str, default: float) -> float:
        raw = (os.environ.get(env_name) or "").strip()
        if not raw:
            return default
        try:
            value = float(raw)
        except ValueError:
            return default
        return value if value > 0 else default
    return (
        read(_TIMEOUT_CONNECT_ENV, _DEFAULT_TIMEOUT_CONNECT_SEC),
        read(_TIMEOUT_READ_ENV, _DEFAULT_TIMEOUT_READ_SEC),
    )


def _network_error_category(exc: BaseException) -> str:
    """Closed diagnostic labels only; exception text can contain private URLs."""
    for error_type, category in (
        (requests.exceptions.SSLError, "tls"),
        (requests.exceptions.ConnectTimeout, "connect_timeout"),
        (requests.exceptions.ReadTimeout, "read_timeout"),
        (requests.exceptions.Timeout, "timeout"),
        (requests.exceptions.ConnectionError, "connection"),
    ):
        if isinstance(exc, error_type):
            return category
    return "request"


def _retry_network_error(
    method: str, path: str, attempt: int, total: int,
    backoffs: tuple[int, ...], exc: BaseException,
) -> None:
    """Network error path: sleep and return when retries remain, else
    raise SystemExit (terminal). Caller ``continue``s after return."""
    category = _network_error_category(exc)
    if attempt >= total - 1:
        raise SystemExit(
            f"ASC {method} {path} network error after {total} "
            f"attempts (category={category})"
        )
    delay = backoffs[attempt]
    print(
        f"ASC {method} {path} network error; "
        f"retrying in {delay}s ({attempt + 1}/{total - 1}; category={category})",
        file=sys.stderr,
    )
    time.sleep(delay)


def _retry_status(
    method: str, path: str, attempt: int, total: int,
    backoffs: tuple[int, ...], status: int,
) -> bool:
    """Retryable status code: sleep and return True when retries remain,
    else return False so the caller breaks to the final SystemExit."""
    if attempt >= total - 1:
        return False
    delay = backoffs[attempt]
    print(
        f"ASC {method} {path} returned {status}; "
        f"retrying in {delay}s ({attempt + 1}/{total - 1})",
        file=sys.stderr,
    )
    time.sleep(delay)
    return True


def request(
    method: str,
    path: str,
    token: str,
    *,
    json_body: Any = None,
    params: dict | None = None,
    allow_status: set[int] | None = None,
    max_attempts: int = 3,
) -> requests.Response:
    """HTTP request with retry on 429/5xx and explicit connect/read timeouts.

    `path` is the portion after `/v1` (e.g. "/apps/123/appStoreVersions").
    `allow_status` lists non-2xx statuses the caller wants returned without
    raising (useful for 409 conflict handling). Per-request timeouts come
    from ``_request_timeouts()`` (env-overridable); without them a hung
    TCP socket would block the CI runner indefinitely.
    Raises SystemExit on non-retryable failure with a clear stderr message.
    """
    url = f"{ASC_BASE}{path}"
    headers = {"Authorization": f"Bearer {token}"}
    if json_body is not None:
        headers["Content-Type"] = "application/json"
    backoffs = _RETRY_BACKOFFS[: max(0, max_attempts - 1)]
    total = len(backoffs) + 1
    timeout = _request_timeouts()
    resp: requests.Response | None = None
    for attempt in range(total):
        try:
            resp = requests.request(
                method, url, headers=headers, params=params,
                json=json_body, timeout=timeout,
            )
        except requests.RequestException as exc:
            _retry_network_error(method, path, attempt, total, backoffs, exc)
            continue
        if resp.status_code < 400:
            return resp
        if allow_status and resp.status_code in allow_status:
            return resp
        if resp.status_code in _RETRY_STATUSES and _retry_status(
                method, path, attempt, total, backoffs, resp.status_code):
            continue
        break
    assert resp is not None
    raise SystemExit(
        f"ASC {method} {path} failed: {resp.status_code}\n{resp.text[:2000]}"
    )


def get_json(path: str, token: str, *, params: dict | None = None) -> dict:
    """GET `path` and return the decoded JSON body."""
    return request("GET", path, token, params=params).json()
