#!/usr/bin/env python3
"""
Set the App Store "What's New" text (appStoreVersionLocalizations.whatsNew) for
the draft App Store version slot prepared by manage_marketing_version.py.

Mirrors gowalk-step's `fastlane run set_changelog` behavior against the ASC
REST API (no fastlane dependency here):

  * Uses the pre-resolved APP_STORE_VERSION_ID env var from the
    "Resolve App Store version slot" step -- no polling, no lookup by version.
  * Skips the first release (1.0 / 1.0.0 / 0.0 / 0.0.0) -- there are no prior
    release notes to announce.
  * Skips when the slot is not in a state that permits editing whatsNew.
  * PATCHes whatsNew on EVERY appStoreVersionLocalization entry with the
    same text. gowalk-step's set_changelog iterates every localization
    (not just the default one) so users in every language see the release
    notes rather than an empty "What's New" section when their locale
    isn't en-US. If no localizations exist yet (brand-new version slot),
    POST a single one for APP_STORE_LOCALE (default en-US) as a seed.

Non-fatal by design: any failure is reported as a ::warning:: and the action
continues. The TestFlight upload itself has already succeeded by this point.

Environment:
  ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH  -- App Store Connect API credentials
  MARKETING_VERSION                        -- e.g. "1.2.3"
  APP_STORE_VERSION_ID                     -- appStoreVersion id (REUSE or CREATE)
  APP_STORE_WHATS_NEW_FILE                 -- path to file holding release notes
                                              (preferred; sidesteps any YAML env
                                              interpolation that may mangle
                                              multi-line content)
  APP_STORE_WHATS_NEW                      -- release notes text (fallback when
                                              _FILE is unset / unreadable)
  APP_STORE_LOCALE                         -- locale to seed when no
                                              localizations exist; default
                                              "en-US". Ignored when
                                              localizations already exist --
                                              every existing locale is updated.
"""

from __future__ import annotations

import os
import sys
from pathlib import Path

from asc_common import get_json, make_jwt, request


# gowalk-step parity: skip whatsNew for the initial release.
SKIP_VERSIONS = {"1.0", "1.0.0", "0.0", "0.0.0"}

# Apple permits editing appStoreVersionLocalizations.whatsNew only while the
# version is in an editable state. WAITING_FOR_REVIEW / IN_REVIEW /
# READY_FOR_SALE etc. are not editable -- skip non-fatally.
WHATSNEW_EDITABLE_STATES = {
    "PREPARE_FOR_SUBMISSION",
    "REJECTED",
    "METADATA_REJECTED",
    "DEVELOPER_REJECTED",
    "INVALID_BINARY",
}


def _require_env(name: str) -> str:
    value = os.environ.get(name, "").strip()
    if not value:
        print(f"::error::{name} env var is required", file=sys.stderr)
        raise SystemExit(1)
    return value


def _warn(msg: str) -> None:
    print(f"::warning::{msg}", file=sys.stderr)


def _log(msg: str) -> None:
    print(msg, file=sys.stderr)


# Apple's 409 STATE_ERROR detail string for the known-benign case where
# the per-localization slot is locked because the parent version is
# transitioning between states. Substring match (not regex / equality) so
# we don't break if Apple appends or reformats surrounding context.
_WHATSNEW_LOCKED_DETAIL = "cannot be edited at this time"


def _patch_localization(
    token: str, localization_id: str, whats_new: str
) -> bool:
    """PATCH a single localization's whatsNew. Returns True on success,
    False when Apple reports the localization is locked (409 STATE_ERROR).

    See _handle_localization_409 for the rationale behind allow_status={409}
    and the benign-lock vs unexpected-409 routing. Other non-2xx statuses
    (auth, 5xx, malformed payloads, real ASC outages) are NOT in the
    allow-list and continue to fail loud -- asc_common.request() retries
    5xx automatically and SystemExits on everything else.
    """
    resp = request(
        "PATCH",
        f"/appStoreVersionLocalizations/{localization_id}",
        token,
        json_body={
            "data": {
                "type": "appStoreVersionLocalizations",
                "id": localization_id,
                "attributes": {"whatsNew": whats_new},
            }
        },
        allow_status={409},
    )
    if resp.status_code == 409:
        _handle_localization_409(localization_id, resp)
        return False
    return True


def _handle_localization_409(localization_id: str, resp) -> None:
    """Route an ASC 409 STATE_ERROR on a localization PATCH to the right
    log channel based on the response detail.

    Apple returns 409 STATE_ERROR ("Attribute 'whatsNew' cannot be edited
    at this time") on individual localizations even when the parent
    appStoreVersion's appStoreState is in the editable allow-list checked
    upstream. This happens when the per-localization state is locked
    independently (e.g. submitted, in-review at the localization level,
    or transitioning) -- a race the version-level state check at the top
    of main() cannot see.

    The benign-lock detail is logged plain -- it's the expected,
    non-actionable case and emitting `::warning::` every clean run is
    noise. Any OTHER 409 detail (genuinely unexpected) is surfaced as
    `::warning::`.

    ASC error envelope: ``{"errors": [{"status": "409", "code":
    "STATE_ERROR", "detail": "Attribute 'whatsNew' cannot be edited at
    this time"}]}``. We collapse every error.detail into one string so
    the benign-lock substring match survives multi-error responses.
    Falls back to raw response text if JSON parsing fails (network
    layer occasionally returns HTML on infrastructure faults).
    """
    try:
        body = resp.json()
    except ValueError:
        body = None
    if isinstance(body, dict):
        errors = body.get("errors") or []
        parts = [e.get("detail") or e.get("title") or ""
                 for e in errors if isinstance(e, dict)]
        detail = " | ".join(p for p in parts if p)
    else:
        detail = resp.text or ""

    if _WHATSNEW_LOCKED_DETAIL in detail:
        _log(
            f"[whatsNew] localization {localization_id} is currently "
            f"locked, skipping (detail: {detail!r})"
        )
        return
    _warn(
        f"appStoreVersionLocalization {localization_id} returned 409 "
        f"with unexpected detail {detail!r}; whatsNew not patched for "
        f"this localization. Other localizations and the rest of the "
        f"run continue."
    )


def _create_localization(
    token: str, version_id: str, locale: str, whats_new: str
) -> str:
    resp = request(
        "POST",
        "/appStoreVersionLocalizations",
        token,
        json_body={
            "data": {
                "type": "appStoreVersionLocalizations",
                "attributes": {"locale": locale, "whatsNew": whats_new},
                "relationships": {
                    "appStoreVersion": {
                        "data": {"type": "appStoreVersions", "id": version_id}
                    }
                },
            }
        },
    )
    return resp.json()["data"]["id"]


def _read_whats_new() -> tuple[str, str]:
    """Return (content, source). Prefer a file path (no env interpolation
    risk). Fall back to APP_STORE_WHATS_NEW env var. Returns ("", "<empty>")
    when neither is populated."""
    path = os.environ.get("APP_STORE_WHATS_NEW_FILE", "").strip()
    if path:
        try:
            content = Path(path).read_text()
            return content, f"file:{path}"
        except OSError as exc:
            _warn(
                f"APP_STORE_WHATS_NEW_FILE={path} unreadable ({exc!r}); "
                f"falling back to APP_STORE_WHATS_NEW env var."
            )
    content = os.environ.get("APP_STORE_WHATS_NEW", "")
    if content:
        return content, "env:APP_STORE_WHATS_NEW"
    return "", "<empty>"


def _locale_release_notes(locale: str) -> str:
    """Per-locale override: fastlane/metadata/ios/<locale>/release_notes.txt
    under GITHUB_WORKSPACE (fastlane's set_changelog layout, which the README
    documents). Empty string when the file is absent or unreadable."""
    root = os.environ.get("GITHUB_WORKSPACE", ".")
    path = Path(root) / "fastlane" / "metadata" / "ios" / locale / "release_notes.txt"
    try:
        return path.read_text().strip()
    except OSError:
        return ""


def _update_all_localizations(
    token: str, version_id: str, version: str, whats_new: str, seed_locale: str,
    only_if_empty: bool = False,
) -> int:
    """PATCH whatsNew on every existing localization. If none exist, POST a
    single seed localization in `seed_locale`. Returns the count of
    localizations written.

    When ``only_if_empty`` is set the text is a fallback backstop: any
    localization that already has a non-blank whatsNew (e.g. AI-generated
    release notes, or notes a human entered) is left untouched, and only
    empty ones are filled. This lets the step run unconditionally after the
    AI-metadata phase without clobbering good content, while still
    guaranteeing no locale ships with an empty "What's New" (which App Store
    Connect rejects as "This field is required")."""
    locs = get_json(
        f"/appStoreVersions/{version_id}/appStoreVersionLocalizations", token
    )
    entries = locs.get("data") or []
    if not entries:
        seed_text = _locale_release_notes(seed_locale) or whats_new
        new_id = _create_localization(token, version_id, seed_locale, seed_text)
        _log(
            f"CREATEd appStoreVersionLocalization {new_id} whatsNew for "
            f"{version} ({seed_locale}) -- no prior localizations"
        )
        return 1
    count = 0
    skipped = 0
    already = 0
    for item in entries:
        loc_id = item.get("id") or ""
        loc = (item.get("attributes") or {}).get("locale") or "?"
        if not loc_id:
            _warn(f"skipping localization without id: {item!r}")
            continue
        if only_if_empty:
            existing = ((item.get("attributes") or {}).get("whatsNew") or "").strip()
            if existing:
                already += 1
                continue
        # A committed per-locale release_notes.txt beats the single shared
        # text, so localized notes ship instead of one language everywhere.
        text = _locale_release_notes(loc) or whats_new
        if _patch_localization(token, loc_id, text):
            _log(
                f"PATCHed appStoreVersionLocalization {loc_id} whatsNew "
                f"for {version} ({loc})"
            )
            count += 1
        else:
            skipped += 1
    if already:
        _log(f"whatsNew already present on {already} localization(s); left as-is")
    if skipped:
        _log(f"whatsNew skipped for {skipped} locked localization(s)")
    return count


def main() -> int:
    whats_new, source = _read_whats_new()
    _log(
        f"whatsNew source={source} length={len(whats_new)} "
        f"newlines={whats_new.count(chr(10))}"
    )
    if not whats_new.strip():
        _log("whatsNew empty; skipping.")
        return 0

    version = _require_env("MARKETING_VERSION")
    version_id = _require_env("APP_STORE_VERSION_ID")
    seed_locale = os.environ.get("APP_STORE_LOCALE", "").strip() or "en-US"
    only_if_empty = os.environ.get("WHATS_NEW_ONLY_IF_EMPTY", "").strip().lower() in {
        "1", "true", "yes",
    }

    if version in SKIP_VERSIONS:
        _log(f"Skipping whatsNew for first release {version}.")
        return 0

    token = make_jwt(
        _require_env("ASC_KEY_ID"),
        _require_env("ASC_ISSUER_ID"),
        _require_env("ASC_KEY_PATH"),
    )

    ver = get_json(f"/appStoreVersions/{version_id}", token)
    state = (ver.get("data") or {}).get("attributes", {}).get("appStoreState", "")
    if state not in WHATSNEW_EDITABLE_STATES:
        _warn(
            f"appStoreVersion {version} is {state}; whatsNew not editable in "
            f"this state, skipping."
        )
        return 0

    count = _update_all_localizations(
        token, version_id, version, whats_new, seed_locale,
        only_if_empty=only_if_empty,
    )
    _log(f"whatsNew set for {count} localizations on {version}")
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except SystemExit:
        raise
    except Exception as exc:  # non-fatal per gowalk-step's set_changelog parity
        _warn(f"whatsNew setter failed (non-fatal): {exc!r}")
        sys.exit(0)
