#!/usr/bin/env python3
# Note: fetch_prerelease_versions (~58 lines) and
# fetch_builds_prerelease_versions (~59 lines) exceed 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 pre-release-version history fetcher.

Split out of asc_version_fetch.py so each module stays focused and under
the 400-line cap. This module owns:

  - `fetch_prerelease_versions`: paginated list of every TestFlight train
    versionString (/v1/apps/{id}/preReleaseVersions -> attributes.version).
    TestFlight trains persist across app updates and feed ASC's upload
    validator's "previously approved version" floor.

Why NOT /v1/apps/{id}/builds? Two reasons, both learned the hard way:

  1. The app-scoped /builds relationship rejects
     ``filter[preReleaseVersion.platform]`` with HTTP 400 ("parameter is
     not permitted on this endpoint"). That filter is only valid on the
     top-level /v1/builds collection, not the relationship view.

  2. Even if the filter worked, /builds.attributes.version is the INTEGER
     build number (CFBundleVersion), NOT the marketing versionString.
     Empirical proof from CI run 24640182161:
         ASC /builds: scanning 6 builds -> v='3','2','1'
     Those '3','2','1' are build numbers. Marketing versions on the same
     app looked like 1.0, 1.0.1, 1.0.4.

So /v1/apps/{id}/builds is unfit for marketing-version discovery.
/v1/apps/{id}/preReleaseVersions is the authoritative source:
``attributes.version`` there IS the marketing versionString.

Why no ``filter[platform]`` on the request? CI run 24640348572 proved
the app-scoped ``/apps/{id}/preReleaseVersions`` relationship endpoint
rejects ``filter[platform]`` with HTTP 400 ("parameter is not permitted
on this endpoint"). The filter is only valid on the top-level
``/preReleaseVersions`` collection. So we fetch unfiltered and filter
client-side.

Defensive-inclusion rule (CI run 24640430898): records with an EXPLICIT
non-iOS platform (``MAC_OS``, ``TV_OS``, ``VISION_OS``) are excluded;
records with iOS OR null/missing platform are INCLUDED. Rationale: the
409 failure proved ASC can hide preReleaseVersion records that still
collide with a POST to /appStoreVersions. Better to overbump one patch
than to miss a hidden collision.

Also in this module (CI run 24640430898): ``fetch_builds_prerelease_versions``
queries ``/v1/builds?filter[app]={id}&include=preReleaseVersion``,
cross-references the ``included`` array, and extracts each build's
preReleaseVersion.attributes.version (the marketing version, not the
build number). This reveals preReleaseVersions that the direct
/preReleaseVersions query might miss (pagination truncation or state
filtering). The build's own ``attributes.version`` is the INTEGER build
number (CFBundleVersion) and is NOT used.

Why the TOP-LEVEL ``/v1/builds`` collection (not ``/v1/apps/{id}/builds``)?
CI run 24640675162 proved the app-scoped relationship endpoint rejects
``include=preReleaseVersion`` with HTTP 400 PARAMETER_ERROR.ILLEGAL
(`"The parameter 'include' can not be used with this request"`). The
top-level ``/v1/builds`` collection DOES accept both ``filter[app]``
and ``include``, so we query that instead and scope by ``filter[app]``.

(``next_build_number.py`` still uses /builds -- but it needs build
numbers, not marketing versions, so that usage is correct.)
"""

from __future__ import annotations

import sys

from asc_common import request


def _iter_pages(path: str, token: str, params: dict):
    """Generator over `.data` lists across paginated ASC responses.

    Follows `links.next` verbatim (stripping the `/v1` prefix so we stay
    on the retrying client). Yields each record; callers decide what to
    extract and how to log.
    """
    resp = request("GET", path, token, params=params)
    while True:
        data = resp.json()
        for item in data.get("data", []):
            yield item
        next_url = ((data.get("links") or {}).get("next")) or ""
        if not next_url:
            return
        next_path = next_url.split("/v1", 1)[-1] if "/v1" in next_url else next_url
        resp = request("GET", next_path, token)


_EXPLICIT_NON_IOS_PLATFORMS = {"MAC_OS", "TV_OS", "VISION_OS"}


def _platform_is_counted(platform: str | None) -> bool:
    """Defensive inclusion rule: include iOS and null/missing platform;
    exclude only explicit known-wrong platforms. Keeps hidden records
    from slipping past the floor (CI run 24640430898)."""
    if platform in _EXPLICIT_NON_IOS_PLATFORMS:
        return False
    return True


def fetch_prerelease_versions(app_id: str, token: str) -> list[str]:
    """Return every TestFlight train's versionString for the app.

    Paginates through links.next so mature apps with >200 trains are not
    truncated. Filters client-side -- the app-scoped
    ``/apps/{id}/preReleaseVersions`` relationship endpoint rejects
    ``filter[platform]`` with HTTP 400 (CI run 24640348572), so we drop
    the server-side filter and evaluate ``attributes.platform`` on each
    returned record instead.

    Defensive inclusion (CI run 24640430898): iOS records AND records
    with null/missing platform are INCLUDED; only explicit MAC_OS /
    TV_OS / VISION_OS are excluded. Records with missing/null version
    are skipped regardless.
    """
    path = f"/apps/{app_id}/preReleaseVersions"
    params = {"limit": 200}
    out: list[str] = []
    skipped = 0
    for item in _iter_pages(path, token, params):
        attrs = item.get("attributes") or {}
        version = attrs.get("version")
        platform = attrs.get("platform")
        record_id = item.get("id", "")
        if not version:
            print(
                f"[prerelease-history] skip id={record_id} "
                f"platform={platform or '<null>'} reason=missing-version",
                file=sys.stderr,
            )
            skipped += 1
            continue
        if not _platform_is_counted(platform):
            print(
                f"[prerelease-history] skip id={record_id} version={version} "
                f"platform={platform or '<null>'} reason=not-ios",
                file=sys.stderr,
            )
            skipped += 1
            continue
        out.append(version)
        if platform == "IOS":
            log_msg = (
                f"[prerelease-history] version={version} "
                f"platform=IOS id={record_id}"
            )
        else:
            log_msg = (
                f"[prerelease-history] version={version} "
                f"platform=<null> id={record_id} "
                f"reason=included-defensively"
            )
        print(log_msg, file=sys.stderr)
    print(
        f"[prerelease-history] done: trains={len(out)} skipped={skipped}",
        file=sys.stderr,
    )
    return out


def fetch_builds_prerelease_versions(app_id: str, token: str) -> list[str]:
    """Return every build's referenced preReleaseVersion marketing version.

    Queries the TOP-LEVEL
    ``/v1/builds?filter[app]={id}&include=preReleaseVersion`` collection
    and cross-references the ``included`` array to resolve each build's
    ``relationships.preReleaseVersion.data.id`` -> the corresponding
    ``included`` record's ``attributes.version`` (marketing version,
    NOT the build number).

    The app-scoped relationship endpoint ``/v1/apps/{id}/builds`` rejects
    the ``include`` parameter with HTTP 400 PARAMETER_ERROR.ILLEGAL
    (`"The parameter 'include' can not be used with this request"`,
    CI run 24640675162). The top-level collection accepts both
    ``filter[app]`` and ``include``, so we query that and scope by app.

    Paginates through links.next. Same defensive platform rule as
    fetch_prerelease_versions: iOS / null included, explicit non-iOS
    excluded. Each cross-reference is logged.

    Rationale: CI run 24640430898 exposed a hidden preReleaseVersion
    that the direct /preReleaseVersions query did not surface, yet a
    POST to /appStoreVersions for that version returned 409. The
    builds->preReleaseVersion path is a second, independent view of
    those same records and catches ones missed by the direct query.
    """
    path = "/builds"
    params = {
        "filter[app]": app_id,
        "include": "preReleaseVersion",
        "limit": 200,
    }
    out: list[str] = []
    counters = {"builds_scanned": 0, "skipped": 0}

    resp = request("GET", path, token, params=params)
    while True:
        data = resp.json()
        included_by_id = _index_included(data.get("included") or [])
        for build in data.get("data", []):
            counters["builds_scanned"] += 1
            version = _extract_build_prerelease_version(
                build, included_by_id, counters,
            )
            if version is not None:
                out.append(version)

        next_url = ((data.get("links") or {}).get("next")) or ""
        if not next_url:
            break
        next_path = next_url.split("/v1", 1)[-1] if "/v1" in next_url else next_url
        resp = request("GET", next_path, token)

    print(
        f"[builds-prerelease] done: builds={counters['builds_scanned']} "
        f"versions={len(out)} skipped={counters['skipped']}",
        file=sys.stderr,
    )
    return out


def _extract_build_prerelease_version(
    build: dict, included_by_id: dict[str, dict], counters: dict,
) -> str | None:
    """Return the build's referenced preReleaseVersion marketing version
    if it passes the defensive platform filter; else None. ``counters``
    is mutated to track skips for the done-line summary."""
    rel_id = _prerelease_ref_id(build)
    if not rel_id:
        return None
    ref = included_by_id.get(rel_id)
    if ref is None:
        return None
    attrs = ref.get("attributes") or {}
    version = attrs.get("version")
    platform = attrs.get("platform")
    if not version:
        counters["skipped"] += 1
        return None
    if not _platform_is_counted(platform):
        print(
            f"[builds-prerelease] skip build={build.get('id', '')} "
            f"prerelease_id={rel_id} version={version} "
            f"platform={platform or '<null>'} reason=not-ios",
            file=sys.stderr,
        )
        counters["skipped"] += 1
        return None
    print(
        f"[builds-prerelease] build={build.get('id', '')} "
        f"prerelease_id={rel_id} version={version} "
        f"platform={platform or '<null>'}",
        file=sys.stderr,
    )
    return version


def _index_included(included: list[dict]) -> dict[str, dict]:
    """Build {id: record} map restricted to preReleaseVersions for O(1)
    cross-reference from a build's relationships.preReleaseVersion.data.id."""
    out: dict[str, dict] = {}
    for rec in included:
        if rec.get("type") != "preReleaseVersions":
            continue
        rid = rec.get("id")
        if rid:
            out[rid] = rec
    return out


def _prerelease_ref_id(build: dict) -> str | None:
    """Return the preReleaseVersion id referenced by a build, or None
    if the relationship is absent/empty."""
    rels = build.get("relationships") or {}
    pre = rels.get("preReleaseVersion") or {}
    data = pre.get("data") or {}
    rid = data.get("id")
    return rid if rid else None
