#!/usr/bin/env python3
"""App ID capability reconciliation.

A profile only carries the capabilities its App ID had when Apple issued it,
so an entitlement the App ID does not know about fails the archive with
"Provisioning profile ... doesn't include the App Attest capability".
"""

from __future__ import annotations

import plistlib
import tempfile
import unittest
from pathlib import Path
from unittest import mock

import capabilities


ENTITLEMENTS = {
    "aps-environment": "production",
    "com.apple.developer.associated-domains": ["applinks:gowalk.example"],
    "com.apple.developer.devicecheck.appattest-environment": "production",
    "keychain-access-groups": ["$(AppIdentifierPrefix)com.gowalk.form"],
}


class ReadEntitlementsTest(unittest.TestCase):
    def setUp(self) -> None:
        self.temp = tempfile.TemporaryDirectory()
        self.path = Path(self.temp.name) / "App.entitlements"

    def tearDown(self) -> None:
        self.temp.cleanup()

    def test_reads_top_level_keys(self) -> None:
        with self.path.open("wb") as handle:
            plistlib.dump(ENTITLEMENTS, handle)

        self.assertEqual(capabilities.read_entitlement_keys(self.path), set(ENTITLEMENTS))

    def test_unreadable_file_is_not_fatal(self) -> None:
        self.path.write_text("not a plist")

        self.assertEqual(capabilities.read_entitlement_keys(self.path), set())


class RequiredCapabilitiesTest(unittest.TestCase):
    def test_maps_only_known_toggles(self) -> None:
        # keychain-access-groups needs no App ID capability at all, and App
        # Attest is not in the ASC API's capabilityType enum, so neither can
        # produce a POST.
        self.assertEqual(
            capabilities.required_capabilities(set(ENTITLEMENTS)),
            {"PUSH_NOTIFICATIONS", "ASSOCIATED_DOMAINS"},
        )

    def test_app_attest_is_reported_not_attempted(self) -> None:
        key = "com.apple.developer.devicecheck.appattest-environment"

        self.assertNotIn(key, capabilities.CAPABILITY_BY_ENTITLEMENT)
        self.assertIn(key, capabilities.MANUAL_ENTITLEMENTS)

        with mock.patch("builtins.print") as printed:
            capabilities.warn_about_manual_entitlements({key}, "com.gowalk.form")

        messages = [c.args[0] for c in printed.call_args_list if c.args]
        self.assertTrue(any("App Attest" in m for m in messages), messages)

    def test_ignores_entitlements_with_no_capability(self) -> None:
        self.assertEqual(
            capabilities.required_capabilities({"get-task-allow", "com.apple.security.get-task-allow"}),
            set(),
        )


class ReconcileTest(unittest.TestCase):
    def _response(self, status: int):
        response = mock.MagicMock()
        response.status_code = status
        return response

    def test_enables_only_the_missing_capability(self) -> None:
        posted: list[str] = []

        def fake_request(method, path, token, **kw):
            posted.append(kw["json_body"]["data"]["attributes"]["capabilityType"])
            return self._response(201)

        listed = {"data": [{"attributes": {"capabilityType": "PUSH_NOTIFICATIONS"}}]}
        with mock.patch.object(capabilities, "get_json", return_value=listed), \
                mock.patch.object(capabilities, "request", side_effect=fake_request):
            changed = capabilities.reconcile("tok", "PK", "com.gowalk.form", set(ENTITLEMENTS))

        self.assertTrue(changed)
        self.assertEqual(posted, ["ASSOCIATED_DOMAINS"])

    def test_no_op_when_the_app_id_already_has_everything(self) -> None:
        listed = {
            "data": [
                {"attributes": {"capabilityType": "PUSH_NOTIFICATIONS"}},
                {"attributes": {"capabilityType": "ASSOCIATED_DOMAINS"}},
            ]
        }
        with mock.patch.object(capabilities, "get_json", return_value=listed), \
                mock.patch.object(capabilities, "request") as post:
            changed = capabilities.reconcile("tok", "PK", "com.gowalk.form", set(ENTITLEMENTS))

        self.assertFalse(changed)
        post.assert_not_called()

    def test_attempts_every_missing_capability(self) -> None:
        # `any()` over a generator would stop at the first success and leave
        # the rest of the capabilities off the App ID.
        posted: list[str] = []

        def fake_request(method, path, token, **kw):
            posted.append(kw["json_body"]["data"]["attributes"]["capabilityType"])
            return self._response(201)

        with mock.patch.object(capabilities, "get_json", return_value={"data": []}), \
                mock.patch.object(capabilities, "request", side_effect=fake_request):
            capabilities.reconcile("tok", "PK", "com.gowalk.form", set(ENTITLEMENTS))

        self.assertEqual(sorted(posted), ["ASSOCIATED_DOMAINS", "PUSH_NOTIFICATIONS"])

    def test_regenerates_even_when_the_capability_could_not_be_enabled(self) -> None:
        # A 409 (or any refusal) still means the cached profile was issued
        # while the App ID lacked the capability. Reusing it reproduces the
        # archive failure and hides Apple's real error behind a stale cache.
        response = self._response(409)
        response.text = '{"errors":[{"detail":"nope"}]}'
        with mock.patch.object(capabilities, "get_json", return_value={"data": []}), \
                mock.patch.object(capabilities, "request", return_value=response):
            changed = capabilities.reconcile(
                "tok", "PK", "com.gowalk.form",
                {"com.apple.developer.associated-domains"},
            )

        self.assertTrue(changed)

    def test_surfaces_the_409_body(self) -> None:
        response = self._response(409)
        response.text = '{"errors":[{"detail":"capability is not modifiable"}]}'
        with mock.patch.object(capabilities, "get_json", return_value={"data": []}), \
                mock.patch.object(capabilities, "request", return_value=response), \
                mock.patch("builtins.print") as printed:
            capabilities.reconcile(
                "tok", "PK", "com.gowalk.form",
                {"com.apple.developer.associated-domains"},
            )

        messages = [c.args[0] for c in printed.call_args_list if c.args]
        self.assertTrue(
            any("capability is not modifiable" in m for m in messages), messages
        )

    def test_api_failure_does_not_raise(self) -> None:
        with mock.patch.object(capabilities, "get_json", return_value={"data": []}), \
                mock.patch.object(capabilities, "request", side_effect=RuntimeError("boom")):
            changed = capabilities.reconcile(
                "tok", "PK", "com.gowalk.form",
                {"com.apple.developer.associated-domains"},
            )

        self.assertTrue(changed)

    def test_systemexit_from_asc_does_not_kill_signing(self) -> None:
        # asc_common.request raises SystemExit, not Exception, so a bare
        # `except Exception` would let an Apple 4xx abort the whole run.
        # An unreadable App ID is also unknown state, so the profile is
        # regenerated rather than trusted from cache.
        with mock.patch.object(capabilities, "get_json", side_effect=SystemExit("400")), \
                mock.patch.object(capabilities, "request", side_effect=SystemExit("400")):
            changed = capabilities.reconcile(
                "tok", "PK", "com.gowalk.form",
                {"com.apple.developer.associated-domains"},
            )

        self.assertTrue(changed)

    def test_capability_listing_sends_no_limit_parameter(self) -> None:
        # /bundleIds/{id}/bundleIdCapabilities rejects `limit` with
        # PARAMETER_ERROR.ILLEGAL rather than ignoring it.
        with mock.patch.object(capabilities, "get_json", return_value={"data": []}) as get:
            capabilities.enabled_capabilities("tok", "PK")

        get.assert_called_once_with("/bundleIds/PK/bundleIdCapabilities", "tok")

    def test_warns_about_capabilities_it_will_not_guess(self) -> None:
        with mock.patch.object(capabilities, "get_json", return_value={"data": []}), \
                mock.patch("builtins.print") as printed:
            capabilities.reconcile(
                "tok", "PK", "com.gowalk.form",
                {"com.apple.security.application-groups"},
            )

        warnings = [c.args[0] for c in printed.call_args_list if c.args]
        self.assertTrue(any("App Groups" in w for w in warnings), warnings)


class CarPlayEntitlementsTest(unittest.TestCase):
    CHARGING = "com.apple.developer.carplay-charging"

    def test_every_carplay_key_is_manual_with_a_request_form_message(self) -> None:
        for key in capabilities.CARPLAY_ENTITLEMENTS:
            self.assertIn(key, capabilities.MANUAL_ENTITLEMENTS, key)
            self.assertIn("carplay", capabilities.MANUAL_ENTITLEMENTS[key].lower())

    def test_carplay_never_maps_to_an_asc_capability_post(self) -> None:
        # The ASC capabilityType enum has no CarPlay member; a POST would 409.
        self.assertEqual(
            capabilities.required_capabilities(capabilities.CARPLAY_ENTITLEMENTS),
            set(),
        )

    def test_cached_profile_without_carplay_is_detected_as_stale(self) -> None:
        # The self-correcting-cache check: a profile minted pre-grant must be
        # regenerated once the entitlements file declares CarPlay.
        self.assertEqual(
            capabilities.missing_profile_entitlements(
                {"application-identifier": "T.com.gowalk.ev"}, {self.CHARGING}
            ),
            {self.CHARGING},
        )

    def test_profile_carrying_carplay_passes_both_checks(self) -> None:
        entitlements = {self.CHARGING: True}
        self.assertEqual(
            capabilities.missing_profile_entitlements(entitlements, {self.CHARGING}),
            set(),
        )
        self.assertEqual(
            capabilities.missing_carplay_entitlements(entitlements, {self.CHARGING}),
            set(),
        )

    def test_missing_carplay_ignores_non_carplay_keys(self) -> None:
        self.assertEqual(
            capabilities.missing_carplay_entitlements(
                {}, {"aps-environment", "keychain-access-groups"}
            ),
            set(),
        )

    def test_reconcile_warns_but_does_not_post_for_carplay(self) -> None:
        with mock.patch.object(capabilities, "get_json", return_value={"data": []}), \
                mock.patch.object(capabilities, "request") as posted, \
                mock.patch("builtins.print") as printed:
            regenerate = capabilities.reconcile(
                "tok", "PK", "com.gowalk.ev", {self.CHARGING}
            )

        posted.assert_not_called()
        self.assertFalse(regenerate)
        warnings = [c.args[0] for c in printed.call_args_list if c.args]
        self.assertTrue(any("carplay" in w.lower() for w in warnings), warnings)


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