#!/usr/bin/env python3
# Note: ~68-line helper exceeds the 50-line function cap. Pre-existing
# in the source-of-truth; refactor tracked separately to avoid bundling
# unrelated edits into round-13 trims.
"""
App Store Connect stale-editable appStoreVersion reuse.

Split out of asc_version_create.py to keep each module under the 400-line
cap. Owns the "PATCH then DELETE+POST fallback" flow that recovers from
ASC's "You cannot create a new version of the App in the current state."
409 (CI run 24640907316).

Why: ASC allows only ONE editable appStoreVersion at a time. When a
stale editable is sitting below our target marketing floor, a straight
POST of the new version returns 409 ENTITY_ERROR.RELATIONSHIP.INVALID
with pointer /data/relationships/app. Bumping the version cannot fix
this -- the editable itself must be renamed (PATCH) or removed (DELETE)
first.

Exit code:
  5 -- PATCH failed AND DELETE failed (e.g. version locked by review);
       manual intervention required in ASC.
"""

from __future__ import annotations

import json as _json
import sys

from asc_common import request


# Substrings in error.detail that indicate "a stale editable exists and
# blocks creating a new version". ASC returns this as 409 with
# code=ENTITY_ERROR.RELATIONSHIP.INVALID and pointer=/data/relationships/app
# (CI run 24640907316). Case-insensitive match.
_EDITABLE_EXISTS_DETAIL_HINTS = (
    "create a new version",
    "cannot create a new version",
    "current state",
)


def _log_body(body: dict, tag: str) -> None:
    """Pretty-print a request body to stderr before the call."""
    try:
        pretty = _json.dumps(body, indent=2, sort_keys=True)
    except (TypeError, ValueError):
        pretty = repr(body)
    print(f"[{tag}] request body: {pretty}", file=sys.stderr)


def patch_version(app_id: str, existing_id: str, target_version: str, token: str):
    """PATCH /appStoreVersions/{id} to rename versionString. Preserves
    metadata and review attachments. 4xx statuses allowed so the caller
    can fall back to DELETE+POST.

    ASC PATCH shape per JSON:API: data.type=appStoreVersions, data.id
    must match the URL id, attributes carries the rename."""
    body = {
        "data": {
            "type": "appStoreVersions",
            "id": str(existing_id),
            "attributes": {"versionString": target_version},
        }
    }
    _log_body(body, "patch-version")
    return request(
        "PATCH", f"/appStoreVersions/{existing_id}", token,
        json_body=body, allow_status={400, 403, 404, 409, 422},
    )


def delete_version(app_id: str, existing_id: str, token: str):
    """DELETE /appStoreVersions/{id}. 4xx statuses allowed so the caller
    can surface a clear error when the record is locked by review."""
    return request(
        "DELETE", f"/appStoreVersions/{existing_id}", token,
        allow_status={400, 403, 404, 409, 422},
    )


def _is_2xx(resp) -> bool:
    return 200 <= getattr(resp, "status_code", 0) < 300


def _body_text(resp) -> str:
    t = getattr(resp, "text", None)
    return t if isinstance(t, str) else ""


def reuse_stale_editable(
    *,
    app_id: str,
    existing_id: str,
    target_version: str,
    token: str,
    patch_fn=None,
    delete_fn=None,
    post_fn=None,
) -> str:
    """Reuse a stale editable appStoreVersion that blocks creating a new
    version. Tries PATCH (rename) first; on failure falls back to
    DELETE + POST. SystemExit(5) when DELETE also fails.

    All three ASC calls are injected so tests can drive the sequence
    without hitting the network. ``post_fn`` must accept
    ``(app_id, version, token)`` -- same shape as ``create_version``.
    """
    patch = patch_fn if patch_fn is not None else patch_version
    delete = delete_fn if delete_fn is not None else delete_version
    if post_fn is None:
        # Lazy import to avoid a circular dep with asc_version_create.
        from asc_version_create import create_version as _default_post
        post = _default_post
    else:
        post = post_fn

    print(
        f"[reuse-stale] PATCH /appStoreVersions/{existing_id} "
        f"versionString -> {target_version}",
        file=sys.stderr,
    )
    resp = patch(app_id, existing_id, target_version, token)
    if _is_2xx(resp):
        print("[reuse-stale] PATCH ok", file=sys.stderr)
        return existing_id

    print(
        f"[reuse-stale] PATCH failed status={resp.status_code} "
        f"body={_body_text(resp)}",
        file=sys.stderr,
    )

    print(
        f"[reuse-stale] DELETE /appStoreVersions/{existing_id}",
        file=sys.stderr,
    )
    del_resp = delete(app_id, existing_id, token)
    if not _is_2xx(del_resp):
        print(
            f"::error::stale editable appStoreVersion id={existing_id} "
            f"cannot be renamed or deleted -- manually intervene in ASC. "
            f"DELETE status={del_resp.status_code} body={_body_text(del_resp)}",
            file=sys.stderr,
        )
        raise SystemExit(5)

    print("[reuse-stale] DELETE ok, retrying POST", file=sys.stderr)
    post_resp = post(app_id, target_version, token)
    if not _is_2xx(post_resp):
        print(
            f"::error::stale editable {existing_id} was deleted but POST "
            f"for {target_version} still failed "
            f"status={post_resp.status_code} body={_body_text(post_resp)}",
            file=sys.stderr,
        )
        raise SystemExit(5)
    return post_resp.json()["data"]["id"]


def is_editable_exists_409(errors: list[dict]) -> bool:
    """True iff the 409 is attributable to a pre-existing editable
    appStoreVersion (ASC allows only one editable at a time). Matches
    the classifier in the CI run 24640907316 spec: code is
    ENTITY_ERROR.RELATIONSHIP.INVALID AND detail contains "create a
    new version" (or similar hint)."""
    for e in errors:
        if not isinstance(e, dict):
            continue
        code = (e.get("code") or "").strip()
        detail = (e.get("detail") or "").strip().lower()
        if code == "ENTITY_ERROR.RELATIONSHIP.INVALID" and any(
            hint in detail for hint in _EDITABLE_EXISTS_DETAIL_HINTS
        ):
            return True
    return False
