#!/usr/bin/env python3
"""
Tests for asc_metadata_applier.py -- the AI-response -> ASC PATCH writer.

Every test mocks `asc_metadata_applier.request` so no HTTP calls are made.
Covers the double-gate, URL-field skip, char-limit validation, null-id
graceful skip, and the top-level main() fail-open semantics.
"""

from __future__ import annotations

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

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

import asc_metadata_applier as mod  # noqa: E402


TOKEN = "TEST_TOKEN"
# Every real listing the detector reads back carries the panel-set privacy
# URL; the applier appends the Terms/Privacy footer to generated descriptions
# from it, and SKIPS the description when it is empty.
PRIVACY = "https://docs.google.com/document/d/e/abc/pub"


def _state(empty_fields, localizations=None):
    return {
        "app_info_id": "ai-1",
        "version_id": "v-1",
        "locales": list(empty_fields.keys()),
        "localizations": localizations or {},
        "empty_fields": empty_fields,
    }


def _loc(app_id="app-en", ver_id="ver-en", privacy=PRIVACY):
    return {
        "app_info_localization_id": app_id,
        "version_localization_id": ver_id,
        "fields": {"privacyPolicyUrl": privacy},
    }


def _desc(body):
    return mod.with_footer(body, PRIVACY)


class ValidateFieldTests(unittest.TestCase):
    def test_returns_trimmed_value_under_limit(self):
        self.assertEqual(mod._validate_field("name", "  MyApp  "), "MyApp")

    def test_rejects_value_exceeding_char_limit(self):
        long_name = "x" * 31
        self.assertIsNone(mod._validate_field("name", long_name))

    def test_accepts_value_at_exact_char_limit(self):
        exact = "x" * 30
        self.assertEqual(mod._validate_field("name", exact), exact)

    def test_rejects_url_field_unconditionally(self):
        self.assertIsNone(mod._validate_field("marketingUrl", "https://x.y"))

    def test_rejects_non_string_value(self):
        self.assertIsNone(mod._validate_field("name", 42))
        self.assertIsNone(mod._validate_field("name", None))
        self.assertIsNone(mod._validate_field("name", ["MyApp"]))

    def test_rejects_whitespace_only_value(self):
        self.assertIsNone(mod._validate_field("name", "   \n\t "))

    def test_char_limit_measures_characters_not_bytes(self):
        # CJK chars are multi-byte but len() counts one each.
        japanese_subtitle = "あ" * 30
        self.assertEqual(
            mod._validate_field("subtitle", japanese_subtitle),
            japanese_subtitle,
        )
        over = "あ" * 31
        self.assertIsNone(mod._validate_field("subtitle", over))


class BuildWritesTests(unittest.TestCase):
    def test_includes_fields_that_pass_both_gates(self):
        writes = mod._build_writes(
            {"name": "MyApp", "subtitle": "Cool"},
            ["name", "subtitle"],
            mod.APP_LEVEL_FIELDS,
        )
        self.assertEqual(writes, {"name": "MyApp", "subtitle": "Cool"})

    def test_drops_field_not_in_empty_list(self):
        # Double-gate: subtitle is in the AI response but detector did NOT
        # report it as empty -> applier must drop it.
        writes = mod._build_writes(
            {"name": "MyApp", "subtitle": "Cool"},
            ["name"],  # only name is empty
            mod.APP_LEVEL_FIELDS,
        )
        self.assertEqual(writes, {"name": "MyApp"})

    def test_drops_url_field_even_if_in_empty_list(self):
        # Belt-and-suspenders: even if a URL field somehow leaks into
        # empty_list, SKIP_URL_FIELDS drops it.
        writes = mod._build_writes(
            {"marketingUrl": "https://x.y"},
            ["marketingUrl"],
            mod.VERSION_LEVEL_FIELDS,
        )
        self.assertEqual(writes, {})

    def test_drops_field_absent_from_ai_response(self):
        writes = mod._build_writes(
            {"name": "MyApp"},  # no subtitle
            ["name", "subtitle"],
            mod.APP_LEVEL_FIELDS,
        )
        self.assertEqual(writes, {"name": "MyApp"})

    def test_only_considers_fields_in_its_group(self):
        # description is version-level; must not appear when called with
        # APP_LEVEL_FIELDS group.
        writes = mod._build_writes(
            {"name": "MyApp", "description": "A desc"},
            ["name", "description"],
            mod.APP_LEVEL_FIELDS,
        )
        self.assertEqual(writes, {"name": "MyApp"})


class ApplyLocaleTests(unittest.TestCase):
    @mock.patch.object(mod, "request")
    def test_patches_both_resources_when_both_have_writes(self, mock_req):
        written = mod._apply_locale(
            TOKEN,
            "en-US",
            _loc("app-en", "ver-en"),
            {"name": "MyApp", "description": "Hello"},
            ["name", "description"],
        )
        self.assertEqual(written, 2)
        self.assertEqual(mock_req.call_count, 2)
        paths = [call.args[1] for call in mock_req.call_args_list]
        self.assertIn("/appInfoLocalizations/app-en", paths)
        self.assertIn("/appStoreVersionLocalizations/ver-en", paths)

    @mock.patch.object(mod, "request")
    def test_skips_app_level_when_app_id_missing(self, mock_req):
        written = mod._apply_locale(
            TOKEN,
            "ja",
            _loc(None, "ver-ja"),
            {"name": "MyApp", "description": "Hi"},
            ["name", "description"],
        )
        self.assertEqual(written, 1)  # only description PATCHed
        self.assertEqual(mock_req.call_count, 1)
        self.assertEqual(
            mock_req.call_args.args[1], "/appStoreVersionLocalizations/ver-ja"
        )

    @mock.patch.object(mod, "request")
    def test_skips_version_level_when_version_id_missing(self, mock_req):
        written = mod._apply_locale(
            TOKEN,
            "ja",
            _loc("app-ja", None),
            {"name": "MyApp", "description": "Hi"},
            ["name", "description"],
        )
        self.assertEqual(written, 1)
        self.assertEqual(mock_req.call_count, 1)
        self.assertEqual(
            mock_req.call_args.args[1], "/appInfoLocalizations/app-ja"
        )

    @mock.patch.object(mod, "request")
    def test_patch_body_contains_only_surviving_fields(self, mock_req):
        # subtitle over limit -> only name PATCHed
        long_subtitle = "x" * 40
        written = mod._apply_locale(
            TOKEN,
            "en-US",
            _loc("app-en", "ver-en"),
            {"name": "MyApp", "subtitle": long_subtitle},
            ["name", "subtitle"],
        )
        self.assertEqual(written, 1)
        self.assertEqual(mock_req.call_count, 1)
        body = mock_req.call_args.kwargs["json_body"]
        self.assertEqual(body["data"]["attributes"], {"name": "MyApp"})
        self.assertNotIn("subtitle", body["data"]["attributes"])

    @mock.patch.object(mod, "request")
    def test_request_failure_does_not_abort_other_resource(self, mock_req):
        # First PATCH (app-info) raises SystemExit; version PATCH still runs.
        mock_req.side_effect = [SystemExit("boom"), None]
        written = mod._apply_locale(
            TOKEN,
            "en-US",
            _loc("app-en", "ver-en"),
            {"name": "MyApp", "description": "Hello"},
            ["name", "description"],
        )
        self.assertEqual(written, 1)  # version succeeded, app failed
        self.assertEqual(mock_req.call_count, 2)


class ApplyTests(unittest.TestCase):
    @mock.patch.object(mod, "request")
    def test_empty_empty_fields_zero_patches(self, mock_req):
        state = _state(empty_fields={})
        ai = {"localizations": {"en-US": {"name": "MyApp"}}}
        total, locales = mod.apply(state, ai, TOKEN)
        self.assertEqual(total, 0)
        self.assertEqual(locales, 0)
        mock_req.assert_not_called()

    @mock.patch.object(mod, "request")
    def test_iterates_multiple_locales(self, mock_req):
        state = _state(
            empty_fields={"en-US": ["name"], "ja": ["name"]},
            localizations={
                "en-US": _loc("app-en", "ver-en"),
                "ja": _loc("app-ja", "ver-ja"),
            },
        )
        ai = {
            "localizations": {
                "en-US": {"name": "MyApp"},
                "ja": {"name": "マイアプリ"},
            }
        }
        total, locales = mod.apply(state, ai, TOKEN)
        self.assertEqual(total, 2)
        self.assertEqual(locales, 2)
        self.assertEqual(mock_req.call_count, 2)

    @mock.patch.object(mod, "request")
    def test_skips_locale_missing_from_ai_response(self, mock_req):
        state = _state(
            empty_fields={"en-US": ["name"], "de-DE": ["name"]},
            localizations={
                "en-US": _loc("app-en", "ver-en"),
                "de-DE": _loc("app-de", "ver-de"),
            },
        )
        ai = {"localizations": {"en-US": {"name": "MyApp"}}}
        total, locales = mod.apply(state, ai, TOKEN)
        self.assertEqual(total, 1)
        self.assertEqual(locales, 1)
        self.assertEqual(mock_req.call_count, 1)

    @mock.patch.object(mod, "request")
    def test_url_field_in_ai_response_dropped(self, mock_req):
        # Detector never reports URL fields as empty, but even if AI returns
        # one unprompted, the applier must drop it.
        state = _state(
            empty_fields={"en-US": ["description"]},
            localizations={"en-US": _loc("app-en", "ver-en")},
        )
        ai = {
            "localizations": {
                "en-US": {
                    "description": "Nice app.",
                    "marketingUrl": "https://example.com",
                    "supportUrl": "https://example.com/support",
                }
            }
        }
        total, _ = mod.apply(state, ai, TOKEN)
        self.assertEqual(total, 1)
        self.assertEqual(mock_req.call_count, 1)
        attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
        self.assertEqual(attrs, {"description": _desc("Nice app.")})

    @mock.patch.object(mod, "request")
    def test_double_gate_drops_field_not_in_empty_list(self, mock_req):
        # Detector said only 'name' was empty for en-US, but AI returned both
        # name AND subtitle. Applier must drop subtitle.
        state = _state(
            empty_fields={"en-US": ["name"]},
            localizations={"en-US": _loc("app-en", "ver-en")},
        )
        ai = {
            "localizations": {
                "en-US": {"name": "MyApp", "subtitle": "Stale"}
            }
        }
        total, _ = mod.apply(state, ai, TOKEN)
        self.assertEqual(total, 1)
        self.assertEqual(mock_req.call_count, 1)
        attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
        self.assertEqual(attrs, {"name": "MyApp"})
        self.assertNotIn("subtitle", attrs)


ENV_FAKE = {"ASC_KEY_ID": "k", "ASC_ISSUER_ID": "i", "ASC_KEY_PATH": "/tmp/k.p8"}


def _run_main(state_data, ai_data, fields_filter=None):
    """Write state/ai to temp files and invoke mod.main(). Returns the code."""
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        state_path = root / "state.json"
        state_path.write_text(
            state_data if isinstance(state_data, str) else json.dumps(state_data),
            encoding="utf-8",
        )
        ai_path = root / "ai.json"
        ai_path.write_text(
            ai_data if isinstance(ai_data, str) else json.dumps(ai_data),
            encoding="utf-8",
        )
        argv = [
            "asc_metadata_applier.py",
            "--state", str(state_path),
            "--response", str(ai_path),
        ]
        if fields_filter is not None:
            argv += ["--fields-filter", fields_filter]
        with mock.patch.object(sys, "argv", argv):
            return mod.main()


class MainTests(unittest.TestCase):
    @mock.patch.object(mod, "make_jwt", return_value=TOKEN)
    @mock.patch.object(mod, "request")
    def test_empty_empty_fields_returns_early_no_jwt(self, mock_req, mock_jwt):
        rc = _run_main(_state(empty_fields={}), {"localizations": {}})
        self.assertEqual(rc, 0)
        mock_req.assert_not_called()
        mock_jwt.assert_not_called()

    @mock.patch.object(mod, "make_jwt", return_value=TOKEN)
    @mock.patch.object(mod, "request")
    def test_invalid_state_json_fails_open(self, mock_req, mock_jwt):
        rc = _run_main("{not json", {"localizations": {}})
        self.assertEqual(rc, 0)
        mock_req.assert_not_called()

    @mock.patch.dict("os.environ", ENV_FAKE)
    @mock.patch.object(mod, "make_jwt", return_value=TOKEN)
    @mock.patch.object(mod, "request")
    def test_null_app_info_loc_id_skips_app_level(self, mock_req, mock_jwt):
        state = _state(
            empty_fields={"en-US": ["name", "description"]},
            localizations={"en-US": _loc(app_id=None, ver_id="ver-en")},
        )
        ai = {"localizations": {"en-US": {"name": "MyApp", "description": "Hi"}}}
        rc = _run_main(state, ai)
        self.assertEqual(rc, 0)
        self.assertEqual(mock_req.call_count, 1)
        self.assertEqual(
            mock_req.call_args.args[1], "/appStoreVersionLocalizations/ver-en"
        )
        attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
        self.assertEqual(attrs, {"description": _desc("Hi")})

    @mock.patch.dict("os.environ", ENV_FAKE)
    @mock.patch.object(mod, "make_jwt", return_value=TOKEN)
    @mock.patch.object(mod, "request")
    def test_end_to_end_char_limit_drops_one_field(self, mock_req, mock_jwt):
        state = _state(
            empty_fields={"en-US": ["name", "subtitle"]},
            localizations={"en-US": _loc("app-en", "ver-en")},
        )
        ai = {"localizations": {"en-US": {"name": "MyApp", "subtitle": "x" * 50}}}
        rc = _run_main(state, ai)
        self.assertEqual(rc, 0)
        self.assertEqual(mock_req.call_count, 1)
        attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
        self.assertEqual(attrs, {"name": "MyApp"})

    @mock.patch.dict("os.environ", ENV_FAKE)
    @mock.patch.object(mod, "make_jwt", return_value=TOKEN)
    @mock.patch.object(mod, "request")
    def test_ai_response_with_unescaped_newlines_parses_and_normalizes(
        self, mock_req, mock_jwt,
    ):
        # Raw AI output with a literal \n inside a string literal -- strict
        # json.loads would reject this as invalid control char, collapsing
        # the applier to zero writes. With strict=False + whitespace
        # normalization the field is PATCHed with a single-space-joined
        # value that still respects the 30-char subtitle cap.
        state = _state(
            empty_fields={"en-US": ["subtitle"]},
            localizations={"en-US": _loc("app-en", "ver-en")},
        )
        raw_ai = (
            '{"localizations": {"en-US": {"subtitle": "Fast.\n'
            'Reliable VPN"}}}'
        )
        rc = _run_main(state, raw_ai)
        self.assertEqual(rc, 0)
        self.assertEqual(mock_req.call_count, 1)
        attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
        # Newline collapsed to a single space; surrounding whitespace stripped.
        self.assertEqual(attrs, {"subtitle": "Fast. Reliable VPN"})
        self.assertNotIn("\n", attrs["subtitle"])

    @mock.patch.dict("os.environ", ENV_FAKE)
    @mock.patch.object(mod, "make_jwt", return_value=TOKEN)
    @mock.patch.object(mod, "request")
    def test_null_version_loc_id_logs_explicit_skip(self, mock_req, mock_jwt):
        # Detector clears version_localization_id when the App Store version
        # is in a non-editable state. Applier must skip version-level fields
        # silently (no warning) but still PATCH app-level fields.
        state = _state(
            empty_fields={"en-US": ["name", "description"]},
            localizations={"en-US": _loc(app_id="app-en", ver_id=None)},
        )
        ai = {"localizations": {"en-US": {"name": "MyApp", "description": "Hi"}}}
        rc = _run_main(state, ai)
        self.assertEqual(rc, 0)
        # Only appInfo PATCH ran; version resource skipped.
        self.assertEqual(mock_req.call_count, 1)
        self.assertEqual(
            mock_req.call_args.args[1], "/appInfoLocalizations/app-en"
        )


def _asc_409(detail: str, code: str = "STATE_ERROR") -> SystemExit:
    """Fabricate a SystemExit mirroring what asc_common.request raises on a
    non-retryable 4xx (used by tests to simulate ASC's per-field rejection).
    """
    body = json.dumps({
        "errors": [
            {"status": "409", "code": code, "detail": detail}
        ]
    })
    return SystemExit(f"ASC PATCH /appStoreVersionLocalizations/ver-en failed: 409\n{body}")


class PerFieldPatchTests(unittest.TestCase):
    """Per-field PATCH resilience -- one field's 409 must not block others.

    Reproduces the production failure on sample1/sample2 where bundling all
    four version-level fields (description, keywords, promotionalText,
    whatsNew) into a single PATCH lost every field when ASC rejected
    whatsNew alone with 409 STATE_ERROR.
    """

    @mock.patch.object(mod, "request")
    def test_patches_each_version_field_separately(self, mock_req):
        # All four version fields present; verify 4 distinct PATCHes.
        mod._patch_group(
            TOKEN,
            mod.VERSION_RESOURCE,
            "en-US",
            "ver-en",
            {
                "description": "Desc",
                "keywords": "k1,k2",
                "promotionalText": "Promo",
                "whatsNew": "Notes",
            },
        )
        self.assertEqual(mock_req.call_count, 4)
        patched_fields = []
        for call in mock_req.call_args_list:
            attrs = call.kwargs["json_body"]["data"]["attributes"]
            self.assertEqual(len(attrs), 1, f"expected single-field PATCH, got {attrs}")
            patched_fields.extend(attrs.keys())
        self.assertEqual(
            sorted(patched_fields),
            ["description", "keywords", "promotionalText", "whatsNew"],
        )

    @mock.patch.object(mod, "request")
    def test_whatsnew_409_does_not_block_other_version_fields(self, mock_req):
        # Exact reproduction of sample1/sample2 failure: whatsNew is
        # rejected (build already attached, state transition in flight)
        # while description/keywords/promotionalText still accept edits.
        def fake_request(method, path, token, **kw):
            field = next(iter(kw["json_body"]["data"]["attributes"].keys()))
            if field == "whatsNew":
                raise _asc_409("Attribute 'whatsNew' cannot be edited at this time")
            return None

        mock_req.side_effect = fake_request
        written = mod._patch_group(
            TOKEN,
            mod.VERSION_RESOURCE,
            "en-US",
            "ver-en",
            {
                "description": "Desc",
                "keywords": "k1,k2",
                "promotionalText": "Promo",
                "whatsNew": "Notes",
            },
        )
        self.assertEqual(written, 3)
        self.assertEqual(mock_req.call_count, 4)

    @mock.patch.object(mod, "warn")
    @mock.patch.object(mod, "request")
    def test_all_version_fields_409_counts_zero_and_warns_each(self, mock_req, mock_warn):
        # ASC rejects every version-level field -- applier must attempt
        # each, warn on each, and report 0 writes. No single rejection
        # short-circuits the others (granular visibility in logs).
        mock_req.side_effect = lambda *a, **kw: (_ for _ in ()).throw(
            _asc_409("Attribute cannot be edited at this time")
        )
        written = mod._patch_group(
            TOKEN,
            mod.VERSION_RESOURCE,
            "en-US",
            "ver-en",
            {
                "description": "Desc",
                "keywords": "k1,k2",
                "promotionalText": "Promo",
                "whatsNew": "Notes",
            },
        )
        self.assertEqual(written, 0)
        self.assertEqual(mock_req.call_count, 4)
        # One warn per field, each naming the field + ASC detail.
        self.assertEqual(mock_warn.call_count, 4)
        warn_msgs = [call.args[0] for call in mock_warn.call_args_list]
        for field in ("description", "keywords", "promotionalText", "whatsNew"):
            self.assertTrue(
                any(field in msg for msg in warn_msgs),
                f"expected warn mentioning {field}; got {warn_msgs}",
            )
        for msg in warn_msgs:
            self.assertIn("cannot be edited at this time", msg)

    @mock.patch.object(mod, "request")
    def test_app_level_fields_also_split_per_field(self, mock_req):
        # Symmetry: name and subtitle must also go out as separate PATCHes
        # so a name-specific rejection doesn't nuke subtitle.
        mod._patch_group(
            TOKEN,
            mod.APP_INFO_RESOURCE,
            "en-US",
            "app-en",
            {"name": "MyApp", "subtitle": "Cool"},
        )
        self.assertEqual(mock_req.call_count, 2)
        for call in mock_req.call_args_list:
            attrs = call.kwargs["json_body"]["data"]["attributes"]
            self.assertEqual(len(attrs), 1)

    @mock.patch.object(mod, "request")
    def test_warning_includes_asc_detail(self, mock_req):
        # Warning must surface ASC's human-readable `detail`, not just
        # "409 STATE_ERROR". Makes root-causing ASC rejections faster.
        with mock.patch.object(mod, "warn") as mock_warn:
            mock_req.side_effect = _asc_409(
                "Attribute 'whatsNew' cannot be edited at this time"
            )
            mod._patch_group(
                TOKEN,
                mod.VERSION_RESOURCE,
                "en-US",
                "ver-en",
                {"whatsNew": "Notes"},
            )
            self.assertEqual(mock_warn.call_count, 1)
            msg = mock_warn.call_args.args[0]
            self.assertIn("whatsNew", msg)
            self.assertIn("Attribute 'whatsNew' cannot be edited at this time", msg)
            self.assertIn("STATE_ERROR", msg)


class ExtractAscDetailTests(unittest.TestCase):
    def test_parses_standard_errors_envelope(self):
        exc = SystemExit(
            'ASC PATCH /x failed: 409\n'
            '{"errors":[{"status":"409","code":"STATE_ERROR",'
            '"detail":"Attribute cannot be edited at this time"}]}'
        )
        result = mod._extract_asc_detail(exc)
        self.assertIn("Attribute cannot be edited at this time", result)
        self.assertIn("STATE_ERROR", result)

    def test_falls_back_to_raw_when_body_not_json(self):
        exc = SystemExit("ASC PATCH /x failed: 500\n<html>gateway timeout</html>")
        result = mod._extract_asc_detail(exc)
        # Raw fallback -- we didn't crash and returned *something* useful.
        self.assertIn("500", result)

    def test_handles_missing_body(self):
        exc = SystemExit("ASC PATCH /x network error")
        result = mod._extract_asc_detail(exc)
        self.assertEqual(result, "ASC PATCH /x network error")

    def test_handles_empty_errors_array(self):
        exc = SystemExit('ASC PATCH /x failed: 400\n{"errors":[]}')
        result = mod._extract_asc_detail(exc)
        # Empty errors array -> fall back to raw; no crash.
        self.assertIn("400", result)


class ApplyPerFieldResilienceTests(unittest.TestCase):
    @mock.patch.object(mod, "request")
    def test_apply_counts_only_successful_patches_across_locales(self, mock_req):
        # Three version fields across en-US: whatsNew rejected, others succeed.
        # Final total_written must be 2 (description, keywords), locales_touched=1.
        def fake_request(method, path, token, **kw):
            field = next(iter(kw["json_body"]["data"]["attributes"].keys()))
            if field == "whatsNew":
                raise _asc_409("cannot be edited")
            return None

        mock_req.side_effect = fake_request
        state = _state(
            empty_fields={"en-US": ["description", "keywords", "whatsNew"]},
            localizations={"en-US": _loc("app-en", "ver-en")},
        )
        ai = {
            "localizations": {
                "en-US": {
                    "description": "Desc",
                    "keywords": "k1,k2",
                    "whatsNew": "Notes",
                }
            }
        }
        total, locales = mod.apply(state, ai, TOKEN)
        self.assertEqual(total, 2)
        self.assertEqual(locales, 1)
        self.assertEqual(mock_req.call_count, 3)


class ParseFieldsFilterTests(unittest.TestCase):
    def test_none_returns_none(self):
        self.assertIsNone(mod._parse_fields_filter(None))

    def test_empty_string_returns_none(self):
        self.assertIsNone(mod._parse_fields_filter(""))

    def test_single_field(self):
        self.assertEqual(mod._parse_fields_filter("description"), {"description"})

    def test_comma_separated(self):
        self.assertEqual(
            mod._parse_fields_filter("name,subtitle,keywords"),
            {"name", "subtitle", "keywords"},
        )

    def test_trims_whitespace(self):
        self.assertEqual(
            mod._parse_fields_filter("  name , subtitle "),
            {"name", "subtitle"},
        )

    def test_drops_empty_tokens(self):
        self.assertEqual(
            mod._parse_fields_filter("name,,subtitle,"),
            {"name", "subtitle"},
        )


class ApplyWithFieldsFilterTests(unittest.TestCase):
    """Two-phase orchestration: phase-1 PATCHes description first, phase-2
    PATCHes the remaining fields using description as prompt context."""

    @mock.patch.object(mod, "request")
    def test_filter_description_only_patches_description(self, mock_req):
        state = _state(
            empty_fields={"en-US": ["description", "keywords", "whatsNew"]},
            localizations={"en-US": _loc("app-en", "ver-en")},
        )
        ai = {
            "localizations": {
                "en-US": {
                    "description": "Desc",
                    "keywords": "k1,k2",
                    "whatsNew": "Notes",
                }
            }
        }
        total, locales = mod.apply(state, ai, TOKEN, fields_filter={"description"})
        self.assertEqual(total, 1)
        self.assertEqual(locales, 1)
        self.assertEqual(mock_req.call_count, 1)
        attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
        self.assertEqual(attrs, {"description": _desc("Desc")})

    @mock.patch.object(mod, "request")
    def test_filter_excludes_description(self, mock_req):
        # Phase-2: filter out description, PATCH the rest.
        state = _state(
            empty_fields={"en-US": ["description", "keywords", "whatsNew"]},
            localizations={"en-US": _loc("app-en", "ver-en")},
        )
        ai = {
            "localizations": {
                "en-US": {
                    "description": "Desc (stale)",
                    "keywords": "k1,k2",
                    "whatsNew": "Notes",
                }
            }
        }
        total, locales = mod.apply(
            state, ai, TOKEN, fields_filter={"keywords", "whatsNew"}
        )
        self.assertEqual(total, 2)
        self.assertEqual(locales, 1)
        self.assertEqual(mock_req.call_count, 2)
        patched = []
        for call in mock_req.call_args_list:
            attrs = call.kwargs["json_body"]["data"]["attributes"]
            patched.extend(attrs.keys())
        self.assertEqual(sorted(patched), ["keywords", "whatsNew"])

    @mock.patch.object(mod, "request")
    def test_filter_preserves_double_gate(self, mock_req):
        # Field in filter but NOT in empty_fields -> still skipped.
        # Detector said only 'description' was empty; phase-2 tries to PATCH
        # keywords + whatsNew; must write 0.
        state = _state(
            empty_fields={"en-US": ["description"]},
            localizations={"en-US": _loc("app-en", "ver-en")},
        )
        ai = {
            "localizations": {
                "en-US": {
                    "description": "Desc",
                    "keywords": "stale",
                    "whatsNew": "stale",
                }
            }
        }
        total, locales = mod.apply(
            state, ai, TOKEN, fields_filter={"keywords", "whatsNew"}
        )
        self.assertEqual(total, 0)
        self.assertEqual(locales, 0)
        mock_req.assert_not_called()

    @mock.patch.object(mod, "request")
    def test_filter_none_is_current_behavior(self, mock_req):
        # Regression: fields_filter omitted/None PATCHes every empty field.
        state = _state(
            empty_fields={"en-US": ["name", "description"]},
            localizations={"en-US": _loc("app-en", "ver-en")},
        )
        ai = {"localizations": {"en-US": {"name": "MyApp", "description": "D"}}}
        total, _ = mod.apply(state, ai, TOKEN)
        self.assertEqual(total, 2)
        self.assertEqual(mock_req.call_count, 2)

    @mock.patch.object(mod, "request")
    def test_empty_filter_set_writes_nothing(self, mock_req):
        # Defensive: an explicitly empty filter set (no fields allowed)
        # must skip every locale without touching the API.
        state = _state(
            empty_fields={"en-US": ["name", "description"]},
            localizations={"en-US": _loc("app-en", "ver-en")},
        )
        ai = {"localizations": {"en-US": {"name": "MyApp", "description": "D"}}}
        total, locales = mod.apply(state, ai, TOKEN, fields_filter=set())
        self.assertEqual(total, 0)
        self.assertEqual(locales, 0)
        mock_req.assert_not_called()


class MainFieldsFilterTests(unittest.TestCase):
    """CLI surface: --fields-filter is parsed + threaded through apply()."""

    @mock.patch.dict("os.environ", ENV_FAKE)
    @mock.patch.object(mod, "make_jwt", return_value=TOKEN)
    @mock.patch.object(mod, "request")
    def test_cli_filter_description_only(self, mock_req, mock_jwt):
        state = _state(
            empty_fields={"en-US": ["description", "keywords"]},
            localizations={"en-US": _loc("app-en", "ver-en")},
        )
        ai = {
            "localizations": {
                "en-US": {"description": "Desc", "keywords": "k1,k2"}
            }
        }
        rc = _run_main(state, ai, fields_filter="description")
        self.assertEqual(rc, 0)
        self.assertEqual(mock_req.call_count, 1)
        attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
        self.assertEqual(attrs, {"description": _desc("Desc")})

    @mock.patch.dict("os.environ", ENV_FAKE)
    @mock.patch.object(mod, "make_jwt", return_value=TOKEN)
    @mock.patch.object(mod, "request")
    def test_cli_filter_phase2_subset(self, mock_req, mock_jwt):
        state = _state(
            empty_fields={"en-US": [
                "name", "subtitle", "description",
                "keywords", "promotionalText", "whatsNew",
            ]},
            localizations={"en-US": _loc("app-en", "ver-en")},
        )
        ai = {
            "localizations": {
                "en-US": {
                    "name": "MyApp",
                    "subtitle": "Cool",
                    "description": "stale",
                    "keywords": "k",
                    "promotionalText": "p",
                    "whatsNew": "w",
                }
            }
        }
        rc = _run_main(
            state, ai,
            fields_filter="name,subtitle,keywords,promotionalText,whatsNew",
        )
        self.assertEqual(rc, 0)
        patched = []
        for call in mock_req.call_args_list:
            attrs = call.kwargs["json_body"]["data"]["attributes"]
            patched.extend(attrs.keys())
        self.assertEqual(
            sorted(patched),
            ["keywords", "name", "promotionalText", "subtitle", "whatsNew"],
        )
        self.assertNotIn("description", patched)

    @mock.patch.dict("os.environ", ENV_FAKE)
    @mock.patch.object(mod, "make_jwt", return_value=TOKEN)
    @mock.patch.object(mod, "request")
    def test_cli_filter_omitted_unchanged(self, mock_req, mock_jwt):
        state = _state(
            empty_fields={"en-US": ["name", "description"]},
            localizations={"en-US": _loc("app-en", "ver-en")},
        )
        ai = {"localizations": {"en-US": {"name": "MyApp", "description": "D"}}}
        rc = _run_main(state, ai)
        self.assertEqual(rc, 0)
        self.assertEqual(mock_req.call_count, 2)


class DescriptionFooterTests(unittest.TestCase):
    """Every generated description ends with the two-line Terms/Privacy footer
    taken from the locale's existing privacyPolicyUrl; no URL, no description."""

    def test_footer_text_is_exact(self):
        self.assertEqual(
            mod.footer_for("https://p.example/privacy"),
            "Terms of Use: https://www.apple.com/legal/internet-services/itunes/dev/stdeula/"
            "\nPrivacy Policy: https://p.example/privacy",
        )

    def test_footer_is_appended_verbatim_as_the_last_two_lines(self):
        out = mod.with_footer("Great app.\n\n- fast\n- small", PRIVACY)
        lines = out.split("\n")
        self.assertEqual(lines[-3], "")  # one blank line separates body and footer
        self.assertEqual(lines[-2], f"Terms of Use: {mod.TERMS_URL}")
        self.assertEqual(lines[-1], f"Privacy Policy: {PRIVACY}")
        self.assertTrue(out.startswith("Great app.\n\n- fast\n- small"))

    def test_short_body_is_not_shortened(self):
        self.assertEqual(mod.with_footer("Hello", PRIVACY),
                         "Hello\n\n" + mod.footer_for(PRIVACY))

    def test_long_body_is_shortened_on_a_whitespace_boundary(self):
        body = " ".join(["word"] * 1200)  # 5999 chars, spaces every 5th char
        out = mod.with_footer(body, PRIVACY)
        footer = mod.footer_for(PRIVACY)
        self.assertLessEqual(len(out), mod.DESCRIPTION_LIMIT)
        self.assertTrue(out.endswith("\n\n" + footer))
        kept = out[: -len("\n\n" + footer)]
        self.assertTrue(kept.endswith("word"))       # cut between words, not inside
        self.assertFalse(kept.endswith(" "))          # trailing whitespace stripped
        # the budget is used, not wasted: at most one word's width short
        self.assertGreater(len(out), mod.DESCRIPTION_LIMIT - 6)

    def test_body_exactly_at_budget_keeps_every_character(self):
        footer = mod.footer_for(PRIVACY)
        budget = mod.DESCRIPTION_LIMIT - len("\n\n" + footer)
        body = ("ab " * budget)[:budget].rstrip()
        out = mod.with_footer(body, PRIVACY)
        self.assertEqual(out, body + "\n\n" + footer)
        self.assertLessEqual(len(out), mod.DESCRIPTION_LIMIT)

    def test_body_without_whitespace_is_hard_cut(self):
        out = mod.with_footer("x" * 5000, PRIVACY)
        self.assertEqual(len(out), mod.DESCRIPTION_LIMIT)
        self.assertTrue(out.endswith("\n\n" + mod.footer_for(PRIVACY)))

    @mock.patch.object(mod, "request")
    def test_locale_with_privacy_url_patches_description_with_footer(self, mock_req):
        written = mod._apply_locale(
            TOKEN, "en-US", _loc("app-en", "ver-en"),
            {"description": "Hello"}, ["description"],
        )
        self.assertEqual(written, 1)
        attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
        self.assertEqual(attrs, {"description": "Hello\n\n" + mod.footer_for(PRIVACY)})

    @mock.patch.object(mod, "warn")
    @mock.patch.object(mod, "request")
    def test_empty_privacy_url_skips_description_but_not_other_fields(
            self, mock_req, mock_warn):
        written = mod._apply_locale(
            TOKEN, "en-US", _loc("app-en", "ver-en", privacy=""),
            {"name": "MyApp", "description": "Hello", "keywords": "k1,k2"},
            ["name", "description", "keywords"],
        )
        self.assertEqual(written, 2)
        patched = [next(iter(c.kwargs["json_body"]["data"]["attributes"]))
                   for c in mock_req.call_args_list]
        self.assertEqual(sorted(patched), ["keywords", "name"])
        self.assertTrue(any("privacyPolicyUrl is empty" in c.args[0]
                            for c in mock_warn.call_args_list))
        for call in mock_req.call_args_list:
            self.assertNotIn("Terms of Use", json.dumps(call.kwargs["json_body"]))

    @mock.patch.object(mod, "warn")
    @mock.patch.object(mod, "request")
    def test_missing_fields_block_skips_description(self, mock_req, mock_warn):
        loc = {"app_info_localization_id": "app-en", "version_localization_id": "ver-en"}
        written = mod._apply_locale(
            TOKEN, "en-US", loc, {"description": "Hello"}, ["description"])
        self.assertEqual(written, 0)
        mock_req.assert_not_called()
        self.assertEqual(mock_warn.call_count, 1)

    @mock.patch.object(mod, "request")
    def test_whitespace_only_privacy_url_counts_as_empty(self, mock_req):
        written = mod._apply_locale(
            TOKEN, "en-US", _loc("app-en", "ver-en", privacy="   "),
            {"description": "Hello"}, ["description"])
        self.assertEqual(written, 0)
        mock_req.assert_not_called()

    @mock.patch.object(mod, "request")
    def test_run_without_description_is_unaffected(self, mock_req):
        written = mod._apply_locale(
            TOKEN, "en-US", _loc("app-en", "ver-en", privacy=""),
            {"name": "MyApp", "keywords": "k1,k2"}, ["name", "keywords"])
        self.assertEqual(written, 2)
        self.assertEqual(mock_req.call_count, 2)

    @mock.patch.object(mod, "request")
    def test_generated_description_over_budget_ships_inside_the_limit(self, mock_req):
        body = " ".join(["benefit"] * 480)  # under 4000 on its own, over with footer
        self.assertLessEqual(len(body), mod.DESCRIPTION_LIMIT)
        written = mod._apply_locale(
            TOKEN, "en-US", _loc("app-en", "ver-en"),
            {"description": body}, ["description"])
        self.assertEqual(written, 1)
        value = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]["description"]
        self.assertLessEqual(len(value), mod.DESCRIPTION_LIMIT)
        self.assertTrue(value.endswith(mod.footer_for(PRIVACY)))

    @mock.patch.object(mod, "request")
    def test_apply_end_to_end_carries_the_footer_per_locale(self, mock_req):
        state = _state(
            empty_fields={"en-US": ["description"], "ja": ["description"]},
            localizations={
                "en-US": _loc("app-en", "ver-en", privacy="https://p.example/en"),
                "ja": _loc("app-ja", "ver-ja", privacy=""),
            },
        )
        ai = {"localizations": {"en-US": {"description": "Hi"},
                                "ja": {"description": "こんにちは"}}}
        total, locales = mod.apply(state, ai, TOKEN, fields_filter={"description"})
        self.assertEqual((total, locales), (1, 1))
        attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
        self.assertEqual(attrs["description"],
                         "Hi\n\n" + mod.footer_for("https://p.example/en"))


class NormalizeWhitespaceTests(unittest.TestCase):
    def test_collapses_embedded_newlines(self):
        self.assertEqual(mod._normalize_whitespace("a\nb"), "a b")

    def test_collapses_mixed_whitespace_runs(self):
        self.assertEqual(mod._normalize_whitespace("a \n\t  b"), "a b")

    def test_strips_leading_trailing_whitespace(self):
        self.assertEqual(mod._normalize_whitespace("  hi\n"), "hi")

    def test_non_string_passthrough(self):
        self.assertEqual(mod._normalize_whitespace(None), None)
        self.assertEqual(mod._normalize_whitespace(42), 42)


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