#!/usr/bin/env python3
# Note: get_combined_floor (~88 lines) 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 appStoreVersions fetch + ground-truth cross-check.

Split out of manage_marketing_version.py so the main module stays under
the 400-line cap. This module owns:

  - `fetch_versions`: paginated, platform-filtered list of ALL iOS
    appStoreVersions for an app. Logs every record to stderr so CI logs
    reflect ground truth.
  - `get_ground_truth_floor`: independent narrow query per ship-blocking
    state. Used as a self-correcting floor: if the main fetch is ever
    truncated or misfiltered, the cross-check catches it before altool
    does.

Motivation: CI run 24639633986 failed because the previous single-shot
fetch missed 1.0.4 READY_FOR_SALE and the script tried to REUSE 1.0.1.
Pagination + platform filter fix the root cause; the cross-check is
defense-in-depth so this class of bug cannot recur silently.
"""

from __future__ import annotations

import sys

import asc_build_history
from asc_common import request


# Ship-blocking states: any train that is closed to new submissions at a
# lower version. The main fetch classifies them via TERMINAL_STATES +
# BLOCKING_STATES from asc_common, but the independent cross-check uses
# this explicit list so the two paths cannot drift.
SHIP_BLOCKING_STATES = (
    "READY_FOR_SALE",
    "PENDING_APPLE_RELEASE",
    "PROCESSING_FOR_APP_STORE",
    "PENDING_DEVELOPER_RELEASE",
    "REPLACED_WITH_NEW_VERSION",
    "DEVELOPER_REMOVED_FROM_SALE",
    "REMOVED_FROM_SALE",
)


def _parse_versions(data: dict) -> list[dict]:
    """Convert an ASC appStoreVersions response payload into our record
    shape. Pure function -- no I/O, safe to call from both the paginated
    main fetch and the narrow cross-check."""
    out: list[dict] = []
    for item in data.get("data", []):
        attrs = item.get("attributes") or {}
        out.append({
            "versionString": attrs.get("versionString") or "",
            "state": attrs.get("appStoreState") or "",
            "id": item.get("id") or "",
            "createdDate": attrs.get("createdDate") or "",
            "platform": attrs.get("platform") or "",
        })
    return out


def _log_record(v: dict) -> None:
    """One line per record on stderr so CI logs reflect ground truth."""
    print(
        f"[asc-fetch] versionString={v['versionString']} "
        f"appStoreState={v['state']} id={v['id']} "
        f"platform={v.get('platform', '')} "
        f"createdDate={v['createdDate']}",
        file=sys.stderr,
    )


def fetch_versions(app_id: str, token: str) -> list[dict]:
    """Fetch ALL appStoreVersions for the app on the iOS platform.

    Follows `links.next` until exhausted (do NOT trust a single page),
    filters to platform=IOS, requests limit=200 (ASC max). Logs every
    record to stderr for CI diagnostics.
    """
    path = f"/apps/{app_id}/appStoreVersions"
    params: dict = {"filter[platform]": "IOS", "limit": 200}
    out: list[dict] = []
    pages = 0
    resp = request("GET", path, token, params=params)

    while True:
        pages += 1
        data = resp.json()
        batch = _parse_versions(data)
        for v in batch:
            _log_record(v)
        out.extend(batch)

        next_url = ((data.get("links") or {}).get("next")) or ""
        if not next_url:
            break

        # Follow the cursor URL verbatim. ASC returns fully-qualified links
        # with cursor/query already attached; passing params= would override
        # the cursor and loop forever. Extract the path after /v1 so we stay
        # on our retrying client.
        next_path = next_url.split("/v1", 1)[-1] if "/v1" in next_url else next_url
        resp = request("GET", next_path, token)

    print(
        f"[asc-fetch] done: pages={pages} records={len(out)} (platform=IOS)",
        file=sys.stderr,
    )
    return out


def _fetch_by_state(app_id: str, token: str, state: str) -> list[dict]:
    """Narrow query for the independent cross-check: one state, iOS only."""
    path = f"/apps/{app_id}/appStoreVersions"
    params = {
        "filter[platform]": "IOS",
        "filter[appStoreState]": state,
        "limit": 200,
    }
    resp = request("GET", path, token, params=params)
    return _parse_versions(resp.json())


def get_ground_truth_floor(
    app_id: str, token: str, semver_tuple
) -> str | None:
    """Independent cross-check: query each ship-blocking state directly.

    Returns the highest semver across all ship-blocking records, or None
    if none exist (first release).

    `semver_tuple` is injected to avoid circular imports; the main module
    owns the semver parser.

    Defense in depth: even if fetch_versions has a latent bug, the narrow
    cross-check catches a stale/truncated main fetch before altool does.
    """
    all_blockers: list[dict] = []
    for state in SHIP_BLOCKING_STATES:
        try:
            recs = _fetch_by_state(app_id, token, state)
        except SystemExit:
            # ASC rejects unknown states with 400 -- skip gracefully so
            # a newly-added state name in our constant does not break the
            # cross-check as a whole.
            print(
                f"[ground-truth] skip state={state} (ASC rejected filter)",
                file=sys.stderr,
            )
            continue
        for r in recs:
            print(
                f"[ground-truth] state={state} "
                f"versionString={r['versionString']} id={r['id']}",
                file=sys.stderr,
            )
        all_blockers.extend(recs)

    if not all_blockers:
        print("[ground-truth] no ship-blocking versions found", file=sys.stderr)
        return None

    floor = max(all_blockers, key=lambda v: semver_tuple(v["versionString"]))
    print(
        f"[ground-truth] floor={floor['versionString']} "
        f"(state={floor['state']}, id={floor['id']})",
        file=sys.stderr,
    )
    return floor["versionString"]


def _max_version(
    versions: list[str], semver_tuple
) -> str | None:
    """Return the lexicographic-by-semver max of a list of versionStrings,
    or None if the list is empty or contains only non-semver values."""
    parsed = [(semver_tuple(v), v) for v in versions if v]
    parsed = [p for p in parsed if p[0] != (-1, -1, -1)]
    if not parsed:
        return None
    return max(parsed)[1]


def get_combined_floor(
    app_id: str, token: str, semver_tuple,
    *,
    appstore_versions: list[dict] | None = None,
    ship_blocking_states: tuple[str, ...] = SHIP_BLOCKING_STATES,
) -> tuple[str | None, dict[str, str | None]]:
    """Combined ground-truth floor across the two authoritative ASC
    collections for marketing versions.

    Consults:
      1. appstore_versions (caller-provided or freshly fetched) filtered
         to ship-blocking states -- same pool as get_ground_truth_floor's
         main input, but we pass it in so we don't double-fetch.
      2. /v1/apps/{id}/preReleaseVersions for every TestFlight train's
         versionString. Persists across app updates.
      3. /v1/builds?filter[app]={id}&include=preReleaseVersion for every
         build's referenced preReleaseVersion.attributes.version. This is
         a second, independent path to TestFlight-train marketing versions
         that catches records hidden from the direct /preReleaseVersions
         query (CI run 24640430898: the direct query missed a record
         that still produced a 409 collision on POST). The top-level
         /v1/builds collection is used because the app-scoped
         /v1/apps/{id}/builds relationship rejects ``include`` with
         HTTP 400 (CI run 24640675162).

    /builds.attributes.version itself (the integer build number) is NOT
    consulted -- only the related preReleaseVersion's marketing version.

    Returns (floor_or_None, per_source_maxes) where per_source_maxes is
    a dict with keys 'appStoreVersions', 'preReleaseVersions',
    'buildsViaPreRelease'. The floor is the max across all three; None
    means every source was empty / non-semver (first release).

    Logs each source's max separately so a human debugging CI can see
    which endpoint contributed the binding floor:

        [combined-floor] appStoreVersions max: 1.0
        [combined-floor] preReleaseVersions max: 1.0.4
        [combined-floor] buildsViaPreRelease max: 1.0.6
        [combined-floor] combined floor: 1.0.6
    """
    asv_versions = [
        v["versionString"] for v in (appstore_versions or [])
        if v.get("state") in ship_blocking_states and v.get("versionString")
    ]
    asv_max = _max_version(asv_versions, semver_tuple)

    prerelease_versions = asc_build_history.fetch_prerelease_versions(
        app_id, token
    )
    prerelease_max = _max_version(prerelease_versions, semver_tuple)

    builds_prerelease_versions = asc_build_history.fetch_builds_prerelease_versions(
        app_id, token
    )
    builds_prerelease_max = _max_version(
        builds_prerelease_versions, semver_tuple
    )

    print(
        f"[combined-floor] appStoreVersions max: {asv_max or '<none>'}",
        file=sys.stderr,
    )
    print(
        f"[combined-floor] preReleaseVersions max: {prerelease_max or '<none>'}",
        file=sys.stderr,
    )
    print(
        f"[combined-floor] buildsViaPreRelease max: "
        f"{builds_prerelease_max or '<none>'}",
        file=sys.stderr,
    )

    candidates = [
        v for v in (asv_max, prerelease_max, builds_prerelease_max) if v
    ]
    combined = _max_version(candidates, semver_tuple)
    print(
        f"[combined-floor] combined floor: {combined or '<none>'}",
        file=sys.stderr,
    )

    per_source = {
        "appStoreVersions": asv_max,
        "preReleaseVersions": prerelease_max,
        "buildsViaPreRelease": builds_prerelease_max,
    }
    return combined, per_source
