#!/usr/bin/env python3
"""
Floor-check helpers for manage_marketing_version.

The combined floor is the max versionString across:
  - /appStoreVersions (caller-provided, ship-blocking states only)
  - /preReleaseVersions (TestFlight trains)
  - /builds->preReleaseVersion (hidden trains visible only via builds)

Plus an independent narrow per-state ground-truth query as defence-in-depth
against truncated/buggy main fetches.

This module owns the semver primitives, the floor-resolving wrappers
that tests patch (``fetch_versions``, ``get_ground_truth_floor``,
``get_combined_floor``), and the strict/non-strict assertion that
``decide_for_version`` runs before REUSE/CREATE branching.
"""

from __future__ import annotations

import os
import re
import sys

import asc_version_fetch
import version_utils


SEM_RE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?$")

# DESIGN DECISION: auto-bump policy is read from the env var so the action
# step can wire it from `inputs.marketing-version-auto-bump` without a
# CLI-flag re-plumb of every helper. Default 'rollover' matches the action
# input default -- patch with carry at .9 (1.0.9 -> 1.1.0) is the more
# natural semver progression for unattended CI. ``'patch'`` is preserved
# unchanged for consumers pinning the historical (unbounded) behavior.
# ``'none'`` preserves the historical fail-the-build path.
_AUTO_BUMP_ENV = "MARKETING_VERSION_AUTO_BUMP"

# DESIGN DECISION: auto-bump must only fire when the resulting bumped
# value can be committed back to the repo in the same run. The
# commit-back step in action.yml is gated on `event=push` AND
# `ref=refs/heads/<default_branch>`. Auto-bumping on any other event
# (workflow_dispatch, pull_request, push to feature branches) would
# upload an IPA at the bumped version while git stays at the old
# value, causing the next run to recompute against stale state and
# either re-bump or drift.
#
# These envs are set by the action.yml step from
# ``${{ github.event_name }}`` and ``${{ github.ref }}`` /
# ``${{ github.event.repository.default_branch }}``. Empty / unset
# values are treated as "context unknown -> refuse to auto-bump"
# (defensive: when run outside the GitHub Actions context, e.g. local
# dev or a third-party orchestrator, the safe default is to require a
# human bump).
_EVENT_ENV = "GITHUB_EVENT_NAME"
_REF_ENV = "GITHUB_REF"
_DEFAULT_BRANCH_ENV = "GITHUB_DEFAULT_BRANCH"

# These constants and helpers are intentionally public (no leading
# underscore) because manage_marketing_version.py imports them across the
# module boundary. Module-internal helpers below keep the leading
# underscore to mark them as private.
MIGRATION_HINT = (
    "See .github/actions/swift-app/MIGRATION.md for the "
    "migration procedure."
)
BUILD_SETTING_SOURCES = (
    "project.yml [xcodegen], the .xcodeproj's MARKETING_VERSION xcconfig, "
    "or Info.plist's CFBundleShortVersionString"
)


def semver_tuple(version_string: str) -> tuple[int, int, int]:
    """Parse 'M.m[.p]' -> (M, m, p). Non-semver returns (-1,-1,-1) so it
    sorts below any real version."""
    m = SEM_RE.match(version_string or "")
    if not m:
        return (-1, -1, -1)
    return (int(m.group(1)), int(m.group(2)), int(m.group(3) or "0"))


def bump_patch_for_message(floor: str) -> str:
    """Suggest a next-patch value to surface in error text. Best-effort."""
    m = SEM_RE.match(floor or "")
    if not m:
        return f"{floor} (bump above this)"
    major, minor = int(m.group(1)), int(m.group(2))
    patch = int(m.group(3) or "0")
    return f"{major}.{minor}.{patch + 1}"


def fetch_versions(app_id: str, token: str) -> list[dict]:
    return asc_version_fetch.fetch_versions(app_id, token)


def get_ground_truth_floor(app_id: str, token: str) -> str | None:
    return asc_version_fetch.get_ground_truth_floor(app_id, token, semver_tuple)


def get_combined_floor(
    app_id: str, token: str, appstore_versions: list[dict],
) -> tuple[str | None, dict[str, str | None]]:
    """Wrapper around asc_version_fetch.get_combined_floor that forwards
    both the floor AND the per-source breakdown so the cross-check error
    message can name the real source contributing the binding floor (not
    a synthesized one)."""
    return asc_version_fetch.get_combined_floor(
        app_id, token, semver_tuple, appstore_versions=appstore_versions,
    )


def _resolve_floor(app_id: str, token: str, appstore_versions: list[dict]):
    """Return (floor, per_source). floor is None when every ASC source is
    empty (true first-release case). Late-binds the seam wrappers via the
    parent ``manage_marketing_version`` module so test patches at
    ``mmv.get_combined_floor`` / ``mmv.get_ground_truth_floor`` win."""
    import manage_marketing_version as mmv
    combined, per_source = mmv.get_combined_floor(
        app_id, token, appstore_versions,
    )
    narrow = mmv.get_ground_truth_floor(app_id, token)
    candidates = [c for c in (combined, narrow) if c]
    floor = max(candidates, key=semver_tuple) if candidates else None
    return floor, per_source


def _floor_error(target: str, floor: str, per_source: dict, *, strict: bool) -> None:
    relation = "strictly greater than" if strict else "greater than or equal to"
    print(
        f"::error::MARKETING_VERSION {target} in your project's build "
        f"settings (typically " + BUILD_SETTING_SOURCES + f") must be "
        f"{relation} the App Store Connect floor {floor}. Bump it (e.g. "
        f"to {bump_patch_for_message(floor)}), commit, and rerun. "
        f"Sources contributing to floor: "
        f"appStoreVersions={per_source.get('appStoreVersions') or '<none>'}, "
        f"preReleaseVersions={per_source.get('preReleaseVersions') or '<none>'}, "
        f"buildsViaPreRelease={per_source.get('buildsViaPreRelease') or '<none>'}. "
        + MIGRATION_HINT,
        file=sys.stderr,
    )
    raise SystemExit(2)


def _persist_context_ok(policy: str) -> bool:
    """True when this run can persist a project-file bump back to the
    default branch via the action's commit-back step. The commit-back
    step is gated on ``event=push`` AND ``ref=refs/heads/<default>``;
    any other context (workflow_dispatch / pull_request / push to a
    feature branch) would upload an IPA at the bumped version while
    git stays at the old value, drifting the project from ASC.

    On refusal, emits the ``::warning::`` describing why this run
    cannot persist the bump (event/ref/default_branch values from the
    environment). The warning names the active policy (rollover /
    patch / minor) so consumers reading CI logs can see which mode
    was attempted. Side-effecting the warning here -- rather than at
    the caller -- keeps ``maybe_auto_bump`` under the per-function LOC
    cap without splitting the env-read + warning into two helpers
    (which would also push the file over the per-file functions cap)."""
    event = (os.environ.get(_EVENT_ENV) or "").strip()
    ref = (os.environ.get(_REF_ENV) or "").strip()
    default_branch = (os.environ.get(_DEFAULT_BRANCH_ENV) or "").strip()
    ok = (
        event == "push"
        and bool(default_branch)
        and ref == f"refs/heads/{default_branch}"
    )
    if ok:
        return True
    print(
        f"::warning::auto-bump={policy} is enabled, but this run "
        f"cannot persist the bump (event={event or '<unset>'}, "
        f"ref={ref or '<unset>'}, "
        f"default_branch={default_branch or '<unset>'}). The "
        f"action's commit-back step only runs on push to "
        f"refs/heads/{default_branch or '<unset>'}; auto-bumping on "
        f"any other event would upload an IPA at the bumped version "
        f"while git stays at the old value. Falling back to fail-on-"
        f"floor; either push to the default branch with auto-bump "
        f"enabled, or manually bump MARKETING_VERSION and rerun.",
        file=sys.stderr,
    )
    return False


def maybe_auto_bump(target: str, floor: str) -> str | None:
    """Return the bumped version when auto-bump is enabled, the run
    can persist the bump, and writes succeed. Returns None when policy
    is 'none', the persistence context disallows commit-back, or the
    project-file write failed -- the caller falls back to the historical
    ``_floor_error`` path.

    Public seam so ``manage_marketing_version`` can fire the auto-roll
    when target == a terminal-state floor row (READY_FOR_SALE auto-roll)
    -- not just when target < floor (the historical floor-violation
    case).

    Side effects: writes the new version into the project file (pbxproj
    / xcconfig / Info.plist / project.yml depending on resolution) and
    updates ``os.environ['MARKETING_VERSION']`` so downstream steps see
    the bumped value. ``version_utils.write_marketing_version`` ALSO
    runs ``git add -f`` on the touched path so the auto-bump is staged
    in the index before any later step (e.g. prepare_signing) mutates
    the same file."""
    policy = (os.environ.get(_AUTO_BUMP_ENV) or "rollover").strip().lower()
    if policy == "none":
        return None
    if policy not in ("rollover", "patch", "minor"):
        print(
            f"::warning::Unknown {_AUTO_BUMP_ENV}={policy!r}; "
            f"falling back to fail-on-floor behavior",
            file=sys.stderr,
        )
        return None
    from mobile_candidate_context import active

    candidate = active()
    if not candidate and not _persist_context_ok(policy):
        return None
    new_version = version_utils.compute_next_version(target, floor, policy)
    if not candidate and not version_utils.write_marketing_version(new_version):
        print(
            f"::warning::auto-bump could not locate a writable "
            f"MARKETING_VERSION source (pbxproj or Info.plist); "
            f"falling back to fail-on-floor behavior",
            file=sys.stderr,
        )
        return None
    os.environ["MARKETING_VERSION"] = new_version
    print(
        f"::notice::mmv: auto-bumped {target} -> {new_version} "
        f"(policy={policy}, ASC floor was {floor})",
        file=sys.stderr,
    )
    return new_version


def assert_target_meets_floor(
    target: str, app_id: str, token: str,
    *, appstore_versions: list[dict], strict: bool,
) -> str:
    """Reject when the floor outranks `target`. ``strict=True`` requires
    target > floor (CREATE: a new row must not collide with anything
    shipped/uploaded). ``strict=False`` allows target == floor (REUSE:
    the editable matching target IS a floor contributor, so equality is
    expected and fine).

    Returns the effective target (which may differ from the input when
    auto-bump fires on the non-strict pre-check). Callers MUST use the
    returned value for any subsequent floor-relative reasoning, otherwise
    they'd compare a stale target against the post-bump state.
    """
    floor, per_source = _resolve_floor(app_id, token, appstore_versions)
    if floor is None:
        return target
    target_t, floor_t = semver_tuple(target), semver_tuple(floor)
    ok = target_t > floor_t if strict else target_t >= floor_t
    if ok:
        relation = ">" if strict else ">="
        print(f"[decision] floor_check OK: target={target} {relation} "
              f"floor={floor}", file=sys.stderr)
        return target
    bumped = maybe_auto_bump(target, floor)
    if bumped is not None:
        return bumped
    _floor_error(target, floor, per_source, strict=strict)
    raise AssertionError("unreachable")  # _floor_error raises SystemExit
