#!/usr/bin/env python3
"""
CREATE-path helpers for manage_marketing_version.

This module owns the wiring that turns a target versionString into either
a fresh /appStoreVersions POST or a PATCH-rename of a stale editable. The
``_no_bump`` sentinel disarms ``asc_version_create.create_or_reuse``'s
bump-retry loop because the project's MARKETING_VERSION is the single
source of truth -- CI must never invent a new versionString.

The ``_via_module`` helper provides a late-binding seam back through
``manage_marketing_version`` so test patches at ``mmv.fetch_versions`` /
``mmv.create_version`` keep winning even though the call originates here.
"""

from __future__ import annotations

import sys

from asc_common import EDITABLE_STATES, REUSABLE_STATES
import asc_build_history
import asc_version_create
from mmv_floor_check import assert_target_meets_floor, semver_tuple


def _no_bump(current: str) -> str:
    """Default bump_fn: never bump. CI must not invent a marketing
    version because the project's build setting is authoritative."""
    print(
        f"::error::POST /appStoreVersions returned 409 for {current}; "
        f"ASC reports this versionString already exists but it was not "
        f"visible in /appStoreVersions/preReleaseVersions/builds. "
        f"Inspect the app in App Store Connect manually.",
        file=sys.stderr,
    )
    raise SystemExit(4)


def _via_module(name: str):
    """Late-binding seam for tests that patch attributes on
    ``manage_marketing_version``. Returns a wrapper that defers attribute
    lookup to call time so ``mock.patch.object(mmv, name)`` keeps winning."""
    def _call(*args, **kwargs):
        import manage_marketing_version
        return getattr(manage_marketing_version, name)(*args, **kwargs)
    return _call


def create_version(app_id: str, version: str, token: str):
    return asc_version_create.create_version(app_id, version, token)


def create_or_reuse(
    app_id: str, version: str, token: str,
    *, bump_fn=None,
) -> str:
    """POST a new version at exactly `version`. Caller passes a `bump_fn`
    that disarms the bump-retry path by raising; this script never wants
    a server-side bump because the project's MARKETING_VERSION is the
    single source of truth."""
    return create_or_reuse_with_stale(
        app_id, version, token, bump_fn=bump_fn,
    )


def create_or_reuse_with_stale(
    app_id: str, version: str, token: str,
    *, bump_fn=None, stale_editable_id: str | None = None,
) -> str:
    """``create_or_reuse`` plus the optional stale-editable PATCH-rename
    seam so ``decide_for_version`` can promote a leftover editable row at
    a different versionString to the project's freshly-bumped target."""
    return asc_version_create.create_or_reuse(
        app_id, version, token,
        bump_fn=bump_fn or _no_bump,
        fetch_versions=_via_module("fetch_versions"),
        fetch_prerelease_versions=asc_build_history.fetch_prerelease_versions,
        fetch_builds_prerelease_versions=(
            asc_build_history.fetch_builds_prerelease_versions
        ),
        post_fn=_via_module("create_version"),
        stale_editable_id=stale_editable_id,
        patch_fn=asc_version_create.patch_version,
        delete_fn=asc_version_create.delete_version,
    )


def _refuse_downgrade(target: str, version_string: str, vid: str, state: str) -> None:
    """Emit a SystemExit(2) error when an editable exists at versionString >= target.

    PATCH-renaming a higher-or-equal editable would silently downgrade an
    in-progress draft. The CREATE-path floor check does NOT catch this
    because editable rows do not contribute to combined_floor."""
    print(
        f"::error::Cannot CREATE MARKETING_VERSION {target}: an "
        f"editable App Store version exists at {version_string} "
        f"(id={vid}, state={state}). Renaming a "
        f"higher or equal-version editable would downgrade an "
        f"in-progress draft. Either bump MARKETING_VERSION to "
        f">= {version_string} in your project's build settings "
        f"(project.yml [xcodegen], the .xcodeproj's "
        f"MARKETING_VERSION xcconfig, or Info.plist's "
        f"CFBundleShortVersionString), or manually delete or "
        f"rename the existing draft in App Store Connect. "
        f"See .github/actions/swift-app/MIGRATION.md "
        f"for the migration procedure.",
        file=sys.stderr,
    )
    raise SystemExit(2)


def _stale_editable_id(versions: list[dict], target: str) -> str | None:
    """Return the id of a REUSABLE row at a versionString strictly LESS
    than target (a leftover lower-version draft to PATCH-rename onto the
    project's freshly-bumped MARKETING_VERSION). Returns None when no
    rename is needed.

    SystemExit(2) when an editable exists at a versionString >= target:
    PATCH-renaming it would silently downgrade an in-progress draft.
    The CREATE-path floor check does NOT catch this because editable
    rows do not contribute to combined_floor.

    Issue 13: rows currently under Apple Review (WAITING_FOR_REVIEW /
    IN_REVIEW) are EDITABLE in ASC's broad sense but mutating them would
    interfere with the review process. They are still subject to the
    >= target downgrade check (Issue 10 still applies), but they are
    NOT candidates for PATCH-rename when version < target — the CREATE
    path falls through to a plain POST instead, leaving the in-review
    row alone."""
    target_t = semver_tuple(target)
    candidates: list[str] = []
    for v in versions:
        state = v.get("state")
        if state not in EDITABLE_STATES:
            continue
        version_string = v.get("versionString") or ""
        if version_string == target:
            continue
        vid = v.get("id")
        if not vid:
            continue
        # STRICTLY greater, not >=. The skip above is a STRING compare while
        # this is a TUPLE compare, so a version that is the SAME but written
        # differently — "1.0" against a target of "1.0.0" — slipped past the
        # skip and then tripped this guard, refusing the whole deploy.
        #
        # That is precisely what App Store Connect hands a brand-new app: it
        # auto-creates version "1.0" when the record is made, while a Flutter
        # project ships MARKETING_VERSION "1.0.0". Every first release hit it.
        #
        # Renaming an editable to an EQUAL version downgrades nothing — it
        # normalises the string — so it falls through to the PATCH-rename
        # candidates below. The guard's real target is unaffected: an editable
        # genuinely AHEAD of the project (draft 1.2 vs target 1.1) still
        # refuses, because renaming that one WOULD lose an in-progress draft.
        if semver_tuple(version_string) > target_t:
            _refuse_downgrade(target, version_string, vid, state)
        # In-review rows (WAITING_FOR_REVIEW / IN_REVIEW) are editable
        # but not safe to rename — leave them alone and POST a fresh row.
        if state in REUSABLE_STATES:
            candidates.append(vid)
    return candidates[0] if candidates else None


def decide_create(
    target_version: str, versions: list[dict],
    app_id: str, token: str,
) -> dict:
    """CREATE path: enforce strict floor, PATCH-rename any stale editable,
    then POST. SystemExit(2) when target is not strictly above the floor.

    Late-binds ``mmv.create_or_reuse_with_stale`` and ``mmv._result`` so
    test patches at the parent module win."""
    import manage_marketing_version as mmv
    # The strict floor-check may also auto-bump (e.g. on a CREATE-path
    # call where the project is below the floor and the non-strict
    # pre-check did not fire because the prior caller skipped it). Use
    # the returned value for all subsequent reasoning.
    target_version = assert_target_meets_floor(
        target_version, app_id, token,
        appstore_versions=versions, strict=True,
    )
    stale_id = _stale_editable_id(versions, target_version)
    from mobile_candidate_context import active

    if active():
        # Match the shipping version decision without POSTing or renaming a store version before merge.
        return mmv._result("PREPARE", target_version, "")
    new_id = mmv.create_or_reuse_with_stale(
        app_id, target_version, token, stale_editable_id=stale_id,
    )
    if stale_id:
        msg = (f"decision=CREATE versionString={target_version} "
               f"(PATCH-rename of stale editable id={stale_id}) "
               f"-> appStoreVersion id={new_id}")
    else:
        msg = (f"decision=CREATE versionString={target_version} "
               f"-> appStoreVersion id={new_id}")
    print(f"[decision] {msg}", file=sys.stderr)
    return mmv._result("CREATE", target_version, new_id)
