#!/usr/bin/env python3
"""
Tests for asc_metadata_detector.py.

All HTTP is stubbed via unittest.mock; no network calls. Focus:
  * empty_fields correctly excludes URL fields (SKIP_URL_FIELDS).
  * locale union across appInfo + version resources.
  * missing resource id for a locale skips its fields without crashing.
  * no editable appInfo -> empty result, exit 0 (not an error).
  * detector runtime error -> fail-open JSON on stdout, exit 0.
"""

from __future__ import annotations

import io
import json
import sys
import unittest
from pathlib import Path
from unittest import mock

sys.path.insert(0, str(Path(__file__).resolve().parent))

import asc_metadata_detector as detector  # noqa: E402


def _app_info_payload(state: str) -> dict:
    return {"data": [{"id": "APP_INFO_1", "attributes": {"appStoreState": state}}]}


def _loc_item(loc_id: str, locale: str, **attrs) -> dict:
    return {"id": loc_id, "attributes": {"locale": locale, **attrs}}


class FetchEditableAppInfoTests(unittest.TestCase):
    def test_returns_editable_record(self):
        with mock.patch.object(
            detector, "get_json", return_value=_app_info_payload("PREPARE_FOR_SUBMISSION")
        ):
            got = detector.fetch_editable_app_info("111", "tok")
        self.assertEqual(got, {"id": "APP_INFO_1", "state": "PREPARE_FOR_SUBMISSION"})

    def test_returns_none_when_no_editable_state(self):
        with mock.patch.object(
            detector, "get_json", return_value=_app_info_payload("READY_FOR_SALE")
        ):
            got = detector.fetch_editable_app_info("111", "tok")
        self.assertIsNone(got)

    def test_returns_none_for_empty_response(self):
        with mock.patch.object(detector, "get_json", return_value={"data": []}):
            got = detector.fetch_editable_app_info("111", "tok")
        self.assertIsNone(got)


class BuildStateTests(unittest.TestCase):
    def test_unions_locales_across_resources(self):
        app_locs = {
            "en-US": {"id": "A1", "name": "MyApp", "subtitle": None,
                      "privacyPolicyUrl": None, "privacyChoicesUrl": None},
            "ja": {"id": "A2", "name": None, "subtitle": None,
                   "privacyPolicyUrl": None, "privacyChoicesUrl": None},
        }
        ver_locs = {
            "en-US": {"id": "V1", "description": "desc", "keywords": None,
                      "promotionalText": None, "whatsNew": None,
                      "supportUrl": None, "marketingUrl": None},
            "de-DE": {"id": "V2", "description": None, "keywords": None,
                      "promotionalText": None, "whatsNew": None,
                      "supportUrl": None, "marketingUrl": None},
        }
        state = detector.build_state("APP1", "VER1", app_locs, ver_locs)
        self.assertEqual(state["locales"], ["de-DE", "en-US", "ja"])
        self.assertEqual(state["app_info_id"], "APP1")
        self.assertEqual(state["version_id"], "VER1")

        self.assertEqual(
            state["localizations"]["en-US"]["app_info_localization_id"], "A1"
        )
        self.assertEqual(
            state["localizations"]["en-US"]["version_localization_id"], "V1"
        )
        # de-DE has no appInfo entry -> id is None
        self.assertIsNone(
            state["localizations"]["de-DE"]["app_info_localization_id"]
        )
        self.assertEqual(
            state["localizations"]["de-DE"]["version_localization_id"], "V2"
        )
        # ja has no version entry -> id is None
        self.assertIsNone(
            state["localizations"]["ja"]["version_localization_id"]
        )


class DiffEmptyFieldsTests(unittest.TestCase):
    def _state(self, fields_en: dict, **kwargs) -> dict:
        defaults = {
            "app_info_localization_id": "A1",
            "version_localization_id": "V1",
        }
        defaults.update(kwargs)
        return {
            "localizations": {
                "en-US": {**defaults, "fields": fields_en},
            }
        }

    def test_empty_string_and_whitespace_treated_as_empty(self):
        state = self._state({
            "name": "", "subtitle": "   ",
            "description": None, "keywords": "kw",
            "promotionalText": None, "whatsNew": "notes",
        })
        self.assertEqual(
            sorted(detector.diff_empty_fields(state)["en-US"]),
            ["description", "name", "promotionalText", "subtitle"],
        )

    def test_url_fields_never_reported_empty(self):
        # supportUrl/marketingUrl/privacyPolicyUrl even when None must NOT
        # appear in the diff -- they are in SKIP_URL_FIELDS.
        state = self._state({
            "name": "X", "subtitle": "Y",
            "description": "d", "keywords": "k",
            "promotionalText": "p", "whatsNew": "w",
            "supportUrl": None, "marketingUrl": "",
            "privacyPolicyUrl": None,
        })
        self.assertEqual(detector.diff_empty_fields(state), {})

    def test_skips_app_level_when_app_info_localization_id_missing(self):
        state = self._state(
            {"name": None, "subtitle": None,
             "description": None, "keywords": None,
             "promotionalText": None, "whatsNew": None},
            app_info_localization_id=None,
        )
        # name/subtitle must be skipped because app_info_localization_id is None
        self.assertEqual(
            sorted(detector.diff_empty_fields(state)["en-US"]),
            ["description", "keywords", "promotionalText", "whatsNew"],
        )

    def test_skips_version_level_when_version_localization_id_missing(self):
        state = self._state(
            {"name": None, "subtitle": None,
             "description": None, "keywords": None,
             "promotionalText": None, "whatsNew": None},
            version_localization_id=None,
        )
        self.assertEqual(
            sorted(detector.diff_empty_fields(state)["en-US"]),
            ["name", "subtitle"],
        )

    def test_fully_populated_returns_empty_dict(self):
        state = self._state({
            "name": "X", "subtitle": "Y",
            "description": "d", "keywords": "k",
            "promotionalText": "p", "whatsNew": "w",
        })
        self.assertEqual(detector.diff_empty_fields(state), {})


class ExtractExistingFieldsTests(unittest.TestCase):
    """Phase-2 orchestration reads ``existing_fields`` as authoritative ASC
    context (description, etc.) -- must be populated for every locale with
    all 6 non-URL fields, regardless of which are empty."""

    NON_URL = ("name", "subtitle", "description", "keywords",
               "promotionalText", "whatsNew")

    def _state(self, fields_en: dict, fields_de: dict | None = None) -> dict:
        localizations = {
            "en-US": {
                "app_info_localization_id": "A1",
                "version_localization_id": "V1",
                "fields": fields_en,
            },
        }
        if fields_de is not None:
            localizations["de-DE"] = {
                "app_info_localization_id": "A2",
                "version_localization_id": "V2",
                "fields": fields_de,
            }
        return {"localizations": localizations}

    def test_contains_every_non_url_field_per_locale(self):
        state = self._state({
            "name": "MyApp", "subtitle": "Cool",
            "description": "A desc", "keywords": "k1,k2",
            "promotionalText": "promo", "whatsNew": "news",
        })
        existing = detector.extract_existing_fields(state)
        self.assertEqual(set(existing), {"en-US"})
        self.assertEqual(set(existing["en-US"].keys()), set(self.NON_URL))

    def test_preserves_populated_asc_values(self):
        state = self._state({
            "name": "MyApp", "subtitle": "Cool",
            "description": "Great app!", "keywords": "k1,k2",
            "promotionalText": "promo text", "whatsNew": "v1 notes",
        })
        existing = detector.extract_existing_fields(state)["en-US"]
        self.assertEqual(existing["name"], "MyApp")
        self.assertEqual(existing["subtitle"], "Cool")
        self.assertEqual(existing["description"], "Great app!")
        self.assertEqual(existing["keywords"], "k1,k2")
        self.assertEqual(existing["promotionalText"], "promo text")
        self.assertEqual(existing["whatsNew"], "v1 notes")

    def test_normalizes_missing_or_none_to_empty_string(self):
        state = self._state({
            "name": None, "subtitle": "",
            "description": None, "keywords": "",
            # promotionalText omitted entirely
            "whatsNew": None,
        })
        existing = detector.extract_existing_fields(state)["en-US"]
        self.assertEqual(existing["name"], "")
        self.assertEqual(existing["subtitle"], "")
        self.assertEqual(existing["description"], "")
        self.assertEqual(existing["keywords"], "")
        self.assertEqual(existing["promotionalText"], "")
        self.assertEqual(existing["whatsNew"], "")

    def test_excludes_url_fields(self):
        state = self._state({
            "name": "MyApp", "subtitle": "Cool",
            "description": "d", "keywords": "k",
            "promotionalText": "p", "whatsNew": "w",
            "privacyPolicyUrl": "https://x", "privacyChoicesUrl": "https://y",
            "supportUrl": "https://s", "marketingUrl": "https://m",
        })
        existing = detector.extract_existing_fields(state)["en-US"]
        self.assertNotIn("privacyPolicyUrl", existing)
        self.assertNotIn("privacyChoicesUrl", existing)
        self.assertNotIn("supportUrl", existing)
        self.assertNotIn("marketingUrl", existing)

    def test_populates_all_locales(self):
        state = self._state(
            {"name": "EN", "subtitle": "S", "description": "D",
             "keywords": "K", "promotionalText": "P", "whatsNew": "W"},
            {"name": "DE", "subtitle": "", "description": "DE desc",
             "keywords": "", "promotionalText": "", "whatsNew": ""},
        )
        existing = detector.extract_existing_fields(state)
        self.assertEqual(set(existing), {"en-US", "de-DE"})
        self.assertEqual(existing["de-DE"]["name"], "DE")
        self.assertEqual(existing["de-DE"]["description"], "DE desc")
        self.assertEqual(existing["de-DE"]["subtitle"], "")


class MainTests(unittest.TestCase):
    def _env(self) -> dict:
        return {
            "ASC_KEY_ID": "k",
            "ASC_ISSUER_ID": "i",
            "ASC_KEY_PATH": "/tmp/key.p8",
            "APP_STORE_APPLE_ID": "111",
            "APP_STORE_VERSION_ID": "222",
        }

    def test_no_editable_app_info_emits_empty_fields(self):
        stdout = io.StringIO()
        with mock.patch.dict("os.environ", self._env(), clear=True), \
             mock.patch.object(detector, "make_jwt", return_value="tok"), \
             mock.patch.object(
                 detector, "get_json",
                 return_value=_app_info_payload("READY_FOR_SALE"),
             ), \
             mock.patch.object(sys, "stdout", stdout):
            rc = detector.main()
        self.assertEqual(rc, 0)
        payload = json.loads(stdout.getvalue())
        self.assertEqual(payload, {"empty_fields": {}})

    def test_runtime_error_is_non_fatal(self):
        stdout = io.StringIO()
        with mock.patch.dict("os.environ", self._env(), clear=True), \
             mock.patch.object(detector, "make_jwt", side_effect=RuntimeError("boom")), \
             mock.patch.object(sys, "stdout", stdout):
            rc = detector.main()
        self.assertEqual(rc, 0)
        payload = json.loads(stdout.getvalue())
        self.assertEqual(payload, {"empty_fields": {}})

    def test_happy_path_emits_full_state(self):
        env = self._env()

        def fake_get_json(path, token, params=None):
            if path.endswith("/appInfos"):
                return _app_info_payload("PREPARE_FOR_SUBMISSION")
            if "/appInfoLocalizations" in path:
                return {"data": [
                    _loc_item("A1", "en-US", name="MyApp", subtitle=None,
                              privacyPolicyUrl=None, privacyChoicesUrl=None),
                    _loc_item("A2", "ja", name=None, subtitle=None,
                              privacyPolicyUrl=None, privacyChoicesUrl=None),
                ]}
            if "/appStoreVersionLocalizations" in path:
                return {"data": [
                    _loc_item("V1", "en-US", description="desc",
                              keywords=None, promotionalText=None,
                              whatsNew=None, supportUrl=None, marketingUrl=None),
                    _loc_item("V2", "ja", description=None,
                              keywords=None, promotionalText=None,
                              whatsNew=None, supportUrl=None, marketingUrl=None),
                ]}
            if path.startswith("/appStoreVersions/") and "Localizations" not in path:
                return {"data": {"id": "222", "attributes":
                                 {"appStoreState": "PREPARE_FOR_SUBMISSION"}}}
            raise AssertionError(f"unexpected path {path}")

        stdout = io.StringIO()
        with mock.patch.dict("os.environ", env, clear=True), \
             mock.patch.object(detector, "make_jwt", return_value="tok"), \
             mock.patch.object(detector, "get_json", side_effect=fake_get_json), \
             mock.patch.object(sys, "stdout", stdout):
            rc = detector.main()
        self.assertEqual(rc, 0)
        payload = json.loads(stdout.getvalue())
        self.assertEqual(sorted(payload["locales"]), ["en-US", "ja"])
        # en-US still missing subtitle, keywords, promotionalText, whatsNew
        self.assertEqual(
            sorted(payload["empty_fields"]["en-US"]),
            ["keywords", "promotionalText", "subtitle", "whatsNew"],
        )
        # ja missing everything non-URL
        self.assertEqual(
            sorted(payload["empty_fields"]["ja"]),
            ["description", "keywords", "name", "promotionalText",
             "subtitle", "whatsNew"],
        )
        # app_store_state surfaced for downstream debugging.
        self.assertEqual(payload["app_store_state"], "PREPARE_FOR_SUBMISSION")
        # existing_fields surfaces authoritative ASC values for phase-2
        # prompting -- must be present, keyed by every locale, with all 6
        # non-URL fields (None/missing normalized to "").
        self.assertIn("existing_fields", payload)
        self.assertEqual(set(payload["existing_fields"]), {"en-US", "ja"})
        self.assertEqual(
            set(payload["existing_fields"]["en-US"]),
            {"name", "subtitle", "description", "keywords",
             "promotionalText", "whatsNew"},
        )
        self.assertEqual(payload["existing_fields"]["en-US"]["name"], "MyApp")
        self.assertEqual(payload["existing_fields"]["en-US"]["description"], "desc")
        self.assertEqual(payload["existing_fields"]["en-US"]["subtitle"], "")
        self.assertEqual(payload["existing_fields"]["ja"]["description"], "")


class VersionStateGateTests(unittest.TestCase):
    """Exercise the editable-version-state gate added to stop 409s on
    /appStoreVersionLocalizations PATCHes when the version is locked."""

    def _env(self) -> dict:
        return {
            "ASC_KEY_ID": "k",
            "ASC_ISSUER_ID": "i",
            "ASC_KEY_PATH": "/tmp/key.p8",
            "APP_STORE_APPLE_ID": "111",
            "APP_STORE_VERSION_ID": "222",
        }

    def _make_fake_get_json(self, version_state: str):
        def fake_get_json(path, token, params=None):
            if path.endswith("/appInfos"):
                return _app_info_payload("PREPARE_FOR_SUBMISSION")
            if "/appInfoLocalizations" in path:
                return {"data": [
                    _loc_item("A1", "en-US", name=None, subtitle=None,
                              privacyPolicyUrl=None, privacyChoicesUrl=None),
                ]}
            if "/appStoreVersionLocalizations" in path:
                return {"data": [
                    _loc_item("V1", "en-US", description=None,
                              keywords=None, promotionalText=None,
                              whatsNew=None, supportUrl=None, marketingUrl=None),
                ]}
            if path.startswith("/appStoreVersions/") and "Localizations" not in path:
                return {"data": {"id": "222", "attributes":
                                 {"appStoreState": version_state}}}
            raise AssertionError(f"unexpected path {path}")
        return fake_get_json

    def _run(self, version_state: str) -> dict:
        stdout = io.StringIO()
        with mock.patch.dict("os.environ", self._env(), clear=True), \
             mock.patch.object(detector, "make_jwt", return_value="tok"), \
             mock.patch.object(
                 detector, "get_json",
                 side_effect=self._make_fake_get_json(version_state),
             ), \
             mock.patch.object(sys, "stdout", stdout):
            rc = detector.main()
        self.assertEqual(rc, 0)
        return json.loads(stdout.getvalue())

    def test_in_review_clears_version_ids_and_omits_version_fields(self):
        # IN_REVIEW: version record exists but accepts no localization
        # PATCHes -- Apple returns 409. Detector must zero the id so the
        # applier only touches app-level fields (name, subtitle via
        # appInfoLocalizations, which uses a separate editability gate).
        payload = self._run("IN_REVIEW")
        self.assertIsNone(
            payload["localizations"]["en-US"]["version_localization_id"]
        )
        self.assertEqual(payload["empty_fields"]["en-US"], ["name", "subtitle"])
        for f in ("description", "keywords", "promotionalText", "whatsNew"):
            self.assertNotIn(f, payload["empty_fields"]["en-US"])
        self.assertEqual(payload["app_store_state"], "IN_REVIEW")

    def test_waiting_for_review_is_editable_and_preserves_version_fields(self):
        # WAITING_FOR_REVIEW is in EDITABLE_VERSION_STATES per Apple's docs;
        # the version id must NOT be cleared even though the app is queued.
        payload = self._run("WAITING_FOR_REVIEW")
        self.assertEqual(
            payload["localizations"]["en-US"]["version_localization_id"], "V1"
        )
        self.assertIn("description", payload["empty_fields"]["en-US"])

    def test_prepare_for_submission_preserves_version_level_fields(self):
        payload = self._run("PREPARE_FOR_SUBMISSION")
        self.assertEqual(
            payload["localizations"]["en-US"]["version_localization_id"], "V1"
        )
        self.assertIn("description", payload["empty_fields"]["en-US"])
        self.assertIn("whatsNew", payload["empty_fields"]["en-US"])
        self.assertEqual(payload["app_store_state"], "PREPARE_FOR_SUBMISSION")

    def test_ready_for_sale_clears_version_ids(self):
        # READY_FOR_SALE is a live, locked state -- PATCHes would 409.
        payload = self._run("READY_FOR_SALE")
        self.assertIsNone(
            payload["localizations"]["en-US"]["version_localization_id"]
        )
        self.assertNotIn("description", payload["empty_fields"].get("en-US", []))


if __name__ == "__main__":
    unittest.main()
