#!/usr/bin/env python3
"""
Detect empty App Store Connect metadata fields across all localizations.

Fetches:
  * the editable appInfo record (app-level metadata resource) and its
    appInfoLocalizations (name, subtitle, privacyPolicyUrl, privacyChoicesUrl),
  * the pre-resolved appStoreVersion's appStoreVersionLocalizations
    (description, keywords, promotionalText, whatsNew, supportUrl, marketingUrl).

Computes a per-locale diff of empty fields, excluding URL fields unconditionally
(SKIP_URL_FIELDS), and emits a single JSON document to stdout used by the AI
prompt and the applier downstream.

Fail-open: any uncaught failure reports a ``::warning::`` and emits
``{"empty_fields": {}}`` so downstream steps short-circuit cleanly. Non-fatal
by design -- the TestFlight upload itself has already succeeded.

Environment:
  ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH -- App Store Connect API credentials
  APP_STORE_APPLE_ID                      -- numeric app id
  APP_STORE_VERSION_ID                    -- appStoreVersion id from
                                             manage_marketing_version.py
"""

from __future__ import annotations

import json
import sys

from asc_common import get_json, make_jwt
from metadata_constants import (
    APP_INFO_LOC_FIELDS,
    APP_LEVEL_FIELDS,
    EDITABLE_APP_INFO_STATES,
    EDITABLE_VERSION_STATES,
    SKIP_URL_FIELDS,
    VERSION_LEVEL_FIELDS,
    VERSION_LOC_FIELDS,
    log,
    require_env,
    warn,
)


def fetch_editable_app_info(app_id: str, token: str) -> dict | None:
    """Return the first appInfo record in an editable state, or None.

    Apple returns multiple appInfo records (current live + next draft). We
    want the draft. No server-side filter is 100% reliable across Apple's
    state transitions, so we fetch all and classify client-side.
    """
    data = get_json(f"/apps/{app_id}/appInfos", token)
    for item in data.get("data") or []:
        attrs = item.get("attributes") or {}
        state = attrs.get("appStoreState") or attrs.get("state") or ""
        if state in EDITABLE_APP_INFO_STATES:
            return {"id": item.get("id") or "", "state": state}
    return None


def _locs_to_map(data: list[dict], fields: tuple[str, ...]) -> dict[str, dict]:
    out: dict[str, dict] = {}
    for item in data:
        attrs = item.get("attributes") or {}
        locale = attrs.get("locale") or ""
        if not locale:
            continue
        entry: dict = {"id": item.get("id") or ""}
        entry.update({f: attrs.get(f) for f in fields})
        out[locale] = entry
    return out


def fetch_app_info_localizations(app_info_id: str, token: str) -> dict[str, dict]:
    data = get_json(
        f"/appInfos/{app_info_id}/appInfoLocalizations",
        token,
        params={"limit": 50},
    )
    return _locs_to_map(data.get("data") or [], APP_INFO_LOC_FIELDS)


def fetch_version_localizations(version_id: str, token: str) -> dict[str, dict]:
    data = get_json(
        f"/appStoreVersions/{version_id}/appStoreVersionLocalizations",
        token,
        params={"limit": 50},
    )
    return _locs_to_map(data.get("data") or [], VERSION_LOC_FIELDS)


def fetch_version_state(version_id: str, token: str) -> str:
    """Return the current appStoreState of the version, or '' on failure.

    Used to decide whether PATCHes on appStoreVersionLocalizations are safe.
    Non-editable states (e.g. IN_REVIEW, READY_FOR_SALE) cause Apple to return
    409 Conflict on version-level localization PATCHes even though the version
    record still exists.
    """
    data = get_json(f"/appStoreVersions/{version_id}", token)
    attrs = (data.get("data") or {}).get("attributes") or {}
    return attrs.get("appStoreState") or ""


def _clear_version_ids(state: dict) -> None:
    """Zero out every locale's version_localization_id in-place.

    Invoked when the app store version is in a non-editable state -- the
    applier treats None ids as "skip version-level fields for this locale",
    so the downstream PATCH is safely bypassed while app-level fields
    (name, subtitle via appInfoLocalizations) still flow through.
    """
    for loc in (state.get("localizations") or {}).values():
        loc["version_localization_id"] = None


def build_state(
    app_info_id: str,
    version_id: str,
    app_info_locs: dict[str, dict],
    version_locs: dict[str, dict],
) -> dict:
    """Union locales across the two resources and merge their field dicts."""
    locales = sorted(set(app_info_locs) | set(version_locs))
    localizations: dict[str, dict] = {}
    for locale in locales:
        app_loc = app_info_locs.get(locale) or {}
        ver_loc = version_locs.get(locale) or {}
        fields = {f: app_loc.get(f) for f in APP_INFO_LOC_FIELDS}
        fields.update({f: ver_loc.get(f) for f in VERSION_LOC_FIELDS})
        localizations[locale] = {
            "app_info_localization_id": app_loc.get("id") or None,
            "version_localization_id": ver_loc.get("id") or None,
            "fields": fields,
        }
    return {
        "app_info_id": app_info_id,
        "version_id": version_id,
        "locales": locales,
        "localizations": localizations,
    }


def _is_empty(value) -> bool:
    return value is None or (isinstance(value, str) and not value.strip())


def _empty_in_group(
    fields: dict, group: tuple[str, ...], resource_id: str | None
) -> list[str]:
    """Return editable (non-URL) group fields that are empty, or [] if the
    owning resource id is missing for this locale."""
    if not resource_id:
        return []
    return [
        f for f in group
        if f not in SKIP_URL_FIELDS and _is_empty(fields.get(f))
    ]


def diff_empty_fields(state: dict) -> dict[str, list[str]]:
    """List empty, non-URL fields per locale, skipping those whose owning
    resource id is missing for that locale."""
    out: dict[str, list[str]] = {}
    for locale, loc in (state.get("localizations") or {}).items():
        fields = loc.get("fields") or {}
        empty = (
            _empty_in_group(fields, APP_LEVEL_FIELDS, loc.get("app_info_localization_id"))
            + _empty_in_group(fields, VERSION_LEVEL_FIELDS, loc.get("version_localization_id"))
        )
        if empty:
            out[locale] = empty
    return out


def extract_existing_fields(state: dict) -> dict[str, dict[str, str]]:
    """Current ASC value for every non-URL field, per locale.

    Always returns all 6 editable fields (APP_LEVEL_FIELDS + VERSION_LEVEL_FIELDS)
    per locale, normalizing missing/None values to "". Phase-2 orchestration
    reads this as authoritative "what ASC currently holds" context for the AI
    prompt -- especially ``description``, which seeds localization prompts.
    URL fields are excluded (SKIP_URL_FIELDS, never AI-generated).
    """
    all_fields = APP_LEVEL_FIELDS + VERSION_LEVEL_FIELDS
    out: dict[str, dict[str, str]] = {}
    for locale, loc in (state.get("localizations") or {}).items():
        fields = loc.get("fields") or {}
        out[locale] = {f: (fields.get(f) or "") for f in all_fields}
    return out


def run() -> dict:
    app_id = require_env("APP_STORE_APPLE_ID")
    version_id = require_env("APP_STORE_VERSION_ID")
    token = make_jwt(
        require_env("ASC_KEY_ID"),
        require_env("ASC_ISSUER_ID"),
        require_env("ASC_KEY_PATH"),
    )

    app_info = fetch_editable_app_info(app_id, token)
    if app_info is None:
        log(
            "no editable appInfo state; AI metadata step skipped "
            "(expected for freshly-launched app)"
        )
        return {"empty_fields": {}}

    app_info_locs = fetch_app_info_localizations(app_info["id"], token)
    version_locs = fetch_version_localizations(version_id, token)
    state = build_state(app_info["id"], version_id, app_info_locs, version_locs)

    version_state = fetch_version_state(version_id, token)
    state["app_store_state"] = version_state
    if version_state and version_state not in EDITABLE_VERSION_STATES:
        warn(
            f"appStoreVersion in non-editable state={version_state}; "
            f"skipping version-level metadata "
            f"(will only update app-level fields)"
        )
        _clear_version_ids(state)

    state["empty_fields"] = diff_empty_fields(state)
    state["existing_fields"] = extract_existing_fields(state)
    log(
        f"detector: app_info_state={app_info['state']} "
        f"app_store_state={version_state or '?'} "
        f"locales={len(state['locales'])} "
        f"empty_locales={len(state['empty_fields'])}"
    )
    return state


def main() -> int:
    try:
        result = run()
    except SystemExit:
        raise
    except Exception as exc:
        warn(f"metadata detector failed (non-fatal): {exc!r}")
        print(json.dumps({"empty_fields": {}}))
        return 0
    print(json.dumps(result))
    return 0


if __name__ == "__main__":
    sys.exit(main())
