import datetime
import unittest

import mock
from dateutil import parser, tz
from freezegun import freeze_time

from posthoganalytics.client import Client
from posthoganalytics.feature_flags import (
    InconclusiveMatchError,
    match_property,
    relative_date_parse_for_feature_flag_matching,
)
from posthoganalytics.request import APIError, GetResponse
from posthoganalytics.test.test_utils import FAKE_TEST_API_KEY


class TestLocalEvaluation(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        # This ensures no real HTTP POST requests are made
        cls.capture_patch = mock.patch.object(Client, "capture")
        cls.capture_patch.start()

    @classmethod
    def tearDownClass(cls):
        cls.capture_patch.stop()

    def set_fail(self, e, batch):
        """Mark the failure handler"""
        print("FAIL", e, batch)  # noqa: T201
        self.failed = True

    def setUp(self):
        self.failed = False
        self.client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)

    @mock.patch("posthog.client.get")
    def test_flag_person_properties(self, patch_get):
        self.client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "person-flag",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "region",
                                    "operator": "exact",
                                    "value": ["USA"],
                                    "type": "person",
                                }
                            ],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            }
        ]

        feature_flag_match = self.client.get_feature_flag(
            "person-flag", "some-distinct-id", person_properties={"region": "USA"}
        )

        not_feature_flag_match = self.client.get_feature_flag(
            "person-flag", "some-distinct-2", person_properties={"region": "Canada"}
        )

        self.assertTrue(feature_flag_match)
        self.assertFalse(not_feature_flag_match)

    def test_case_insensitive_matching(self):
        self.client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "person-flag",
                "is_simple_flag": True,
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "location",
                                    "operator": "exact",
                                    "value": ["Straße"],
                                    "type": "person",
                                }
                            ],
                            "rollout_percentage": 100,
                        },
                        {
                            "properties": [
                                {
                                    "key": "star",
                                    "operator": "exact",
                                    "value": ["ſun"],
                                    "type": "person",
                                }
                            ],
                            "rollout_percentage": 100,
                        },
                    ],
                },
            }
        ]

        self.assertTrue(
            self.client.get_feature_flag(
                "person-flag",
                "some-distinct-id",
                person_properties={"location": "straße"},
            )
        )

        self.assertTrue(
            self.client.get_feature_flag(
                "person-flag",
                "some-distinct-id",
                person_properties={"location": "strasse"},
            )
        )

        self.assertTrue(
            self.client.get_feature_flag(
                "person-flag", "some-distinct-id", person_properties={"star": "ſun"}
            )
        )

        self.assertTrue(
            self.client.get_feature_flag(
                "person-flag", "some-distinct-id", person_properties={"star": "sun"}
            )
        )

    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_flag_group_properties(self, patch_get, patch_flags):
        self.client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "group-flag",
                "active": True,
                "filters": {
                    "aggregation_group_type_index": 0,
                    "groups": [
                        {
                            "properties": [
                                {
                                    "group_type_index": 0,
                                    "key": "name",
                                    "operator": "exact",
                                    "value": ["Project Name 1"],
                                    "type": "group",
                                }
                            ],
                            "rollout_percentage": 35,
                        }
                    ],
                },
            }
        ]

        self.client.group_type_mapping = {"0": "company", "1": "project"}

        # Group names not passed in
        self.assertFalse(
            self.client.get_feature_flag(
                "group-flag",
                "some-distinct-id",
                group_properties={"company": {"name": "Project Name 1"}},
            )
        )

        self.assertFalse(
            self.client.get_feature_flag(
                "group-flag",
                "some-distinct-2",
                group_properties={"company": {"name": "Project Name 2"}},
            )
        )

        # this is good
        self.assertTrue(
            self.client.get_feature_flag(
                "group-flag",
                "some-distinct-id",
                groups={"company": "amazon_without_rollout"},
                group_properties={"company": {"name": "Project Name 1"}},
            )
        )
        # rollout %
        self.assertFalse(
            self.client.get_feature_flag(
                "group-flag",
                "some-distinct-id",
                groups={"company": "amazon"},
                group_properties={"company": {"name": "Project Name 1"}},
            )
        )

        # property mismatch
        self.assertFalse(
            self.client.get_feature_flag(
                "group-flag",
                "some-distinct-2",
                groups={"company": "amazon_without_rollout"},
                group_properties={"company": {"name": "Project Name 2"}},
            )
        )
        self.assertEqual(patch_flags.call_count, 0)

        # Now group type mappings are gone, so fall back to /flags/
        patch_flags.return_value = {
            "featureFlags": {"group-flag": "decide-fallback-value"}
        }

        self.client.group_type_mapping = {}
        self.assertEqual(
            self.client.get_feature_flag(
                "group-flag",
                "some-distinct-id",
                groups={"company": "amazon"},
                group_properties={"company": {"name": "Project Name 1"}},
            ),
            "decide-fallback-value",
        )

        self.assertEqual(patch_flags.call_count, 1)

    def test_group_flag_is_inconclusive_when_group_properties_missing(self):
        feature_flag = {
            "id": 1,
            "name": "Group Flag Without Property Filters",
            "key": "group-flag-no-props",
            "active": True,
            "filters": {
                "aggregation_group_type_index": 0,
                "groups": [{"properties": [], "rollout_percentage": 100}],
            },
        }
        self.client.group_type_mapping = {"0": "company"}

        with self.assertRaises(InconclusiveMatchError):
            self.client._compute_flag_locally(
                feature_flag,
                "some-distinct-id",
                groups={"company": "acme"},
                group_properties={},
            )

    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_flag_with_complex_definition(self, patch_get, patch_flags):
        patch_flags.return_value = {
            "featureFlags": {"complex-flag": "decide-fallback-value"}
        }
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "complex-flag",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "region",
                                    "operator": "exact",
                                    "value": ["USA"],
                                    "type": "person",
                                },
                                {
                                    "key": "name",
                                    "operator": "exact",
                                    "value": ["Aloha"],
                                    "type": "person",
                                },
                            ],
                            "rollout_percentage": 100,
                        },
                        {
                            "properties": [
                                {
                                    "key": "email",
                                    "operator": "exact",
                                    "value": ["a@b.com", "b@c.com"],
                                    "type": "person",
                                },
                            ],
                            "rollout_percentage": 30,
                        },
                        {
                            "properties": [
                                {
                                    "key": "doesnt_matter",
                                    "operator": "exact",
                                    "value": ["1", "2"],
                                    "type": "person",
                                },
                            ],
                            "rollout_percentage": 0,
                        },
                    ],
                },
            }
        ]

        self.assertTrue(
            client.get_feature_flag(
                "complex-flag",
                "some-distinct-id",
                person_properties={"region": "USA", "name": "Aloha"},
            )
        )
        self.assertEqual(patch_flags.call_count, 0)

        # this distinctIDs hash is < rollout %
        self.assertTrue(
            client.get_feature_flag(
                "complex-flag",
                "some-distinct-id_within_rollout?",
                person_properties={"region": "USA", "email": "a@b.com"},
            )
        )
        self.assertEqual(patch_flags.call_count, 0)

        # will fall back on `/flags`, as all properties present for second group, but that group resolves to false
        self.assertEqual(
            client.get_feature_flag(
                "complex-flag",
                "some-distinct-id_outside_rollout?",
                person_properties={"region": "USA", "email": "a@b.com"},
            ),
            "decide-fallback-value",
        )
        self.assertEqual(patch_flags.call_count, 1)

        patch_flags.reset_mock()

        # same as above
        self.assertEqual(
            client.get_feature_flag(
                "complex-flag",
                "some-distinct-id",
                person_properties={"doesnt_matter": "1"},
            ),
            "decide-fallback-value",
        )
        self.assertEqual(patch_flags.call_count, 1)

        patch_flags.reset_mock()

        # this one will need to fall back
        self.assertEqual(
            client.get_feature_flag(
                "complex-flag", "some-distinct-id", person_properties={"region": "USA"}
            ),
            "decide-fallback-value",
        )
        self.assertEqual(patch_flags.call_count, 1)

        patch_flags.reset_mock()

        # won't need to fall back when all values are present
        self.assertFalse(
            client.get_feature_flag(
                "complex-flag",
                "some-distinct-id_outside_rollout?",
                person_properties={
                    "region": "USA",
                    "email": "a@b.com",
                    "name": "X",
                    "doesnt_matter": "1",
                },
            )
        )
        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_feature_flags_fallback_to_flags(self, patch_get, patch_flags):
        patch_flags.return_value = {
            "featureFlags": {"beta-feature": "alakazam", "beta-feature2": "alakazam2"}
        }
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "id",
                                    "value": 98,
                                    "operator": None,
                                    "type": "cohort",
                                }
                            ],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
            {
                "id": 2,
                "name": "Beta Feature",
                "key": "beta-feature2",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "region",
                                    "operator": "exact",
                                    "value": ["USA"],
                                    "type": "person",
                                }
                            ],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
        ]

        # beta-feature fallbacks to decide because property type is unknown
        feature_flag_match = client.get_feature_flag("beta-feature", "some-distinct-id")

        self.assertEqual(feature_flag_match, "alakazam")
        self.assertEqual(patch_flags.call_count, 1)

        # beta-feature2 fallbacks to decide because region property not given with call
        feature_flag_match = client.get_feature_flag(
            "beta-feature2", "some-distinct-id"
        )

        self.assertEqual(feature_flag_match, "alakazam2")
        self.assertEqual(patch_flags.call_count, 2)

    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_feature_flags_dont_fallback_to_flags_when_only_local_evaluation_is_true(
        self, patch_get, patch_flags
    ):
        patch_flags.return_value = {
            "featureFlags": {"beta-feature": "alakazam", "beta-feature2": "alakazam2"}
        }
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "id",
                                    "value": 98,
                                    "operator": None,
                                    "type": "cohort",
                                }
                            ],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
            {
                "id": 2,
                "name": "Beta Feature",
                "key": "beta-feature2",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "region",
                                    "operator": "exact",
                                    "value": ["USA"],
                                    "type": "person",
                                }
                            ],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
        ]

        # beta-feature should fallback to decide because property type is unknown,
        # but doesn't because only_evaluate_locally is true
        feature_flag_match = client.get_feature_flag(
            "beta-feature", "some-distinct-id", only_evaluate_locally=True
        )

        self.assertEqual(feature_flag_match, None)
        self.assertEqual(patch_flags.call_count, 0)

        feature_flag_match = client.feature_enabled(
            "beta-feature", "some-distinct-id", only_evaluate_locally=True
        )

        self.assertEqual(feature_flag_match, None)
        self.assertEqual(patch_flags.call_count, 0)

        # beta-feature2 should fallback to decide because region property not given with call
        # but doesn't because only_evaluate_locally is true
        feature_flag_match = client.get_feature_flag(
            "beta-feature2", "some-distinct-id", only_evaluate_locally=True
        )
        self.assertEqual(feature_flag_match, None)

        feature_flag_match = client.feature_enabled(
            "beta-feature2", "some-distinct-id", only_evaluate_locally=True
        )
        self.assertEqual(feature_flag_match, None)

        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_feature_flag_never_returns_undefined_during_regular_evaluation(
        self, patch_get, patch_flags
    ):
        patch_flags.return_value = {"featureFlags": {}}
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 0,
                        }
                    ],
                },
            },
        ]

        # beta-feature resolves to False, so no matter the default, stays False
        self.assertFalse(client.get_feature_flag("beta-feature", "some-distinct-id"))
        self.assertFalse(client.feature_enabled("beta-feature", "some-distinct-id"))

        # beta-feature2 falls back to decide, and whatever decide returns is the value
        self.assertFalse(client.get_feature_flag("beta-feature2", "some-distinct-id"))
        self.assertEqual(patch_flags.call_count, 1)

        self.assertFalse(client.feature_enabled("beta-feature2", "some-distinct-id"))
        self.assertEqual(patch_flags.call_count, 2)

    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_feature_flag_return_none_when_decide_errors_out(
        self, patch_get, patch_flags
    ):
        patch_flags.side_effect = APIError(400, "Decide error")
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
        client.feature_flags = []

        # beta-feature2 falls back to decide, which on error returns None
        self.assertIsNone(client.get_feature_flag("beta-feature2", "some-distinct-id"))
        self.assertEqual(patch_flags.call_count, 1)

        self.assertIsNone(client.feature_enabled("beta-feature2", "some-distinct-id"))
        self.assertEqual(patch_flags.call_count, 2)

    @mock.patch("posthog.client.flags")
    def test_experience_continuity_flag_not_evaluated_locally(self, patch_flags):
        patch_flags.return_value = {
            "featureFlags": {"beta-feature": "decide-fallback-value"}
        }
        client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "rollout_percentage": 100,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ]
                },
                "ensure_experience_continuity": True,
            }
        ]
        # decide called always because experience_continuity is set
        self.assertEqual(
            client.get_feature_flag("beta-feature", "distinct_id"),
            "decide-fallback-value",
        )
        self.assertEqual(patch_flags.call_count, 1)

    @mock.patch.object(Client, "capture")
    @mock.patch("posthog.client.flags")
    def test_get_all_flags_with_fallback(self, patch_flags, patch_capture):
        patch_flags.return_value = {
            "featureFlags": {
                "beta-feature": "variant-1",
                "beta-feature2": "variant-2",
                "disabled-feature": False,
            }
        }  # decide should return the same flags
        client = self.client
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "rollout_percentage": 100,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ]
                },
            },
            {
                "id": 2,
                "name": "Beta Feature",
                "key": "disabled-feature",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 0,
                        }
                    ]
                },
            },
            {
                "id": 3,
                "name": "Beta Feature",
                "key": "beta-feature2",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [{"key": "country", "value": "US"}],
                            "rollout_percentage": 0,
                        }
                    ]
                },
            },
        ]
        # beta-feature value overridden by /flags
        self.assertEqual(
            client.get_all_flags("distinct_id"),
            {
                "beta-feature": "variant-1",
                "beta-feature2": "variant-2",
                "disabled-feature": False,
            },
        )
        self.assertEqual(patch_flags.call_count, 1)
        self.assertEqual(patch_capture.call_count, 0)

    @mock.patch.object(Client, "capture")
    @mock.patch("posthog.client.flags")
    def test_get_all_flags_and_payloads_with_fallback(self, patch_flags, patch_capture):
        patch_flags.return_value = {
            "featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"},
            "featureFlagPayloads": {"beta-feature": 100, "beta-feature2": 300},
        }
        client = self.client
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "rollout_percentage": 100,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ],
                    "payloads": {
                        "true": "some-payload",
                    },
                },
            },
            {
                "id": 2,
                "name": "Beta Feature",
                "key": "disabled-feature",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 0,
                        }
                    ],
                    "payloads": {
                        "true": "another-payload",
                    },
                },
            },
            {
                "id": 3,
                "name": "Beta Feature",
                "key": "beta-feature2",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [{"key": "country", "value": "US"}],
                            "rollout_percentage": 0,
                        }
                    ],
                    "payloads": {
                        "true": "payload-3",
                    },
                },
            },
        ]
        # beta-feature value overridden by /flags
        self.assertEqual(
            client.get_all_flags_and_payloads("distinct_id")["featureFlagPayloads"],
            {
                "beta-feature": 100,
                "beta-feature2": 300,
            },
        )
        self.assertEqual(patch_flags.call_count, 1)
        self.assertEqual(patch_capture.call_count, 0)

    @mock.patch.object(Client, "capture")
    @mock.patch("posthog.client.flags")
    def test_get_all_flags_with_fallback_empty_local_flags(
        self, patch_flags, patch_capture
    ):
        patch_flags.return_value = {
            "featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}
        }
        client = self.client
        client.feature_flags = []
        # beta-feature value overridden by /flags
        self.assertEqual(
            client.get_all_flags("distinct_id"),
            {"beta-feature": "variant-1", "beta-feature2": "variant-2"},
        )
        self.assertEqual(patch_flags.call_count, 1)
        self.assertEqual(patch_capture.call_count, 0)

    @mock.patch.object(Client, "capture")
    @mock.patch("posthog.client.flags")
    def test_get_all_flags_and_payloads_with_fallback_empty_local_flags(
        self, patch_flags, patch_capture
    ):
        patch_flags.return_value = {
            "featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"},
            "featureFlagPayloads": {"beta-feature": 100, "beta-feature2": 300},
        }
        client = self.client
        client.feature_flags = []
        # beta-feature value overridden by /flags
        self.assertEqual(
            client.get_all_flags_and_payloads("distinct_id")["featureFlagPayloads"],
            {"beta-feature": 100, "beta-feature2": 300},
        )
        self.assertEqual(patch_flags.call_count, 1)
        self.assertEqual(patch_capture.call_count, 0)

    @mock.patch.object(Client, "capture")
    @mock.patch("posthog.client.flags")
    def test_get_all_flags_with_no_fallback(self, patch_flags, patch_capture):
        patch_flags.return_value = {
            "featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}
        }
        client = self.client
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "rollout_percentage": 100,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ]
                },
            },
            {
                "id": 2,
                "name": "Beta Feature",
                "key": "disabled-feature",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 0,
                        }
                    ]
                },
            },
        ]
        self.assertEqual(
            client.get_all_flags("distinct_id"),
            {"beta-feature": True, "disabled-feature": False},
        )
        # decide not called because this can be evaluated locally
        self.assertEqual(patch_flags.call_count, 0)
        self.assertEqual(patch_capture.call_count, 0)

    @mock.patch.object(Client, "capture")
    @mock.patch("posthog.client.flags")
    def test_get_all_flags_and_payloads_with_no_fallback(
        self, patch_flags, patch_capture
    ):
        client = self.client
        basic_flag = {
            "id": 1,
            "name": "Beta Feature",
            "key": "beta-feature",
            "active": True,
            "rollout_percentage": 100,
            "filters": {
                "groups": [
                    {
                        "properties": [],
                        "rollout_percentage": 100,
                    }
                ],
                "payloads": {
                    "true": "new",
                },
            },
        }
        disabled_flag = {
            "id": 2,
            "name": "Beta Feature",
            "key": "disabled-feature",
            "active": True,
            "filters": {
                "groups": [
                    {
                        "properties": [],
                        "rollout_percentage": 0,
                    }
                ],
                "payloads": {
                    "true": "some-payload",
                },
            },
        }
        client.feature_flags = [
            basic_flag,
            disabled_flag,
        ]
        self.assertEqual(
            client.get_all_flags_and_payloads("distinct_id")["featureFlagPayloads"],
            {"beta-feature": "new"},
        )
        # decide not called because this can be evaluated locally
        self.assertEqual(patch_flags.call_count, 0)
        self.assertEqual(patch_capture.call_count, 0)

    @mock.patch.object(Client, "capture")
    @mock.patch("posthog.client.flags")
    def test_get_all_flags_with_fallback_but_only_local_evaluation_set(
        self, patch_flags, patch_capture
    ):
        patch_flags.return_value = {
            "featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}
        }
        client = self.client
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "rollout_percentage": 100,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ]
                },
            },
            {
                "id": 2,
                "name": "Beta Feature",
                "key": "disabled-feature",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 0,
                        }
                    ]
                },
            },
            {
                "id": 3,
                "name": "Beta Feature",
                "key": "beta-feature2",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [{"key": "country", "value": "US"}],
                            "rollout_percentage": 0,
                        }
                    ]
                },
            },
        ]
        # beta-feature2 has no value
        self.assertEqual(
            client.get_all_flags("distinct_id", only_evaluate_locally=True),
            {"beta-feature": True, "disabled-feature": False},
        )
        self.assertEqual(patch_flags.call_count, 0)
        self.assertEqual(patch_capture.call_count, 0)

    @mock.patch.object(Client, "capture")
    @mock.patch("posthog.client.flags")
    def test_get_all_flags_and_payloads_with_fallback_but_only_local_evaluation_set(
        self, patch_flags, patch_capture
    ):
        patch_flags.return_value = {
            "featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"},
            "featureFlagPayloads": {"beta-feature": 100, "beta-feature2": 300},
        }
        client = self.client
        flag_1 = {
            "id": 1,
            "name": "Beta Feature",
            "key": "beta-feature",
            "active": True,
            "rollout_percentage": 100,
            "filters": {
                "groups": [
                    {
                        "properties": [],
                        "rollout_percentage": 100,
                    }
                ],
                "payloads": {
                    "true": "some-payload",
                },
            },
        }
        flag_2 = {
            "id": 2,
            "name": "Beta Feature",
            "key": "disabled-feature",
            "active": True,
            "filters": {
                "groups": [
                    {
                        "properties": [],
                        "rollout_percentage": 0,
                    }
                ],
                "payloads": {
                    "true": "another-payload",
                },
            },
        }
        flag_3 = {
            "id": 3,
            "name": "Beta Feature",
            "key": "beta-feature2",
            "active": True,
            "filters": {
                "groups": [
                    {
                        "properties": [{"key": "country", "value": "US"}],
                        "rollout_percentage": 0,
                    }
                ],
                "payloads": {
                    "true": "payload-3",
                },
            },
        }
        client.feature_flags = [
            flag_1,
            flag_2,
            flag_3,
        ]
        # beta-feature2 has no value
        self.assertEqual(
            client.get_all_flags_and_payloads(
                "distinct_id", only_evaluate_locally=True
            )["featureFlagPayloads"],
            {"beta-feature": "some-payload"},
        )
        self.assertEqual(patch_flags.call_count, 0)
        self.assertEqual(patch_capture.call_count, 0)

    @mock.patch.object(Client, "capture")
    @mock.patch("posthog.client.flags")
    def test_compute_inactive_flags_locally(self, patch_flags, patch_capture):
        client = self.client
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "rollout_percentage": 100,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ]
                },
            },
            {
                "id": 2,
                "name": "Beta Feature",
                "key": "disabled-feature",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 0,
                        }
                    ]
                },
            },
        ]
        self.assertEqual(
            client.get_all_flags("distinct_id"),
            {"beta-feature": True, "disabled-feature": False},
        )
        # decide not called because this can be evaluated locally
        self.assertEqual(patch_flags.call_count, 0)
        self.assertEqual(patch_capture.call_count, 0)

        # Now, after a poll interval, flag 1 is inactive, and flag 2 rollout is set to 100%.
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": False,
                "rollout_percentage": 100,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ]
                },
            },
            {
                "id": 2,
                "name": "Beta Feature",
                "key": "disabled-feature",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ]
                },
            },
        ]
        self.assertEqual(
            client.get_all_flags("distinct_id"),
            {"beta-feature": False, "disabled-feature": True},
        )
        # decide not called because this can be evaluated locally
        self.assertEqual(patch_flags.call_count, 0)
        self.assertEqual(patch_capture.call_count, 0)

    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_feature_flags_local_evaluation_None_values(self, patch_get, patch_flags):
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
        client.feature_flags = [
            {
                id: 1,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "variant": None,
                            "properties": [
                                {
                                    "key": "latestBuildVersion",
                                    "type": "person",
                                    "value": ".+",
                                    "operator": "regex",
                                },
                                {
                                    "key": "latestBuildVersionMajor",
                                    "type": "person",
                                    "value": "23",
                                    "operator": "gt",
                                },
                                {
                                    "key": "latestBuildVersionMinor",
                                    "type": "person",
                                    "value": "31",
                                    "operator": "gt",
                                },
                                {
                                    "key": "latestBuildVersionPatch",
                                    "type": "person",
                                    "value": "0",
                                    "operator": "gt",
                                },
                            ],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
        ]

        feature_flag_match = client.get_feature_flag(
            "beta-feature",
            "some-distinct-id",
            person_properties={
                "latestBuildVersion": None,
                "latestBuildVersionMajor": None,
                "latestBuildVersionMinor": None,
                "latestBuildVersionPatch": None,
            },
        )

        self.assertEqual(feature_flag_match, False)
        self.assertEqual(patch_flags.call_count, 0)
        self.assertEqual(patch_get.call_count, 0)

        feature_flag_match = client.get_feature_flag(
            "beta-feature",
            "some-distinct-id",
            person_properties={
                "latestBuildVersion": "24.32.1",
                "latestBuildVersionMajor": "24",
                "latestBuildVersionMinor": "32",
                "latestBuildVersionPatch": "1",
            },
        )

        self.assertEqual(feature_flag_match, True)

    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_feature_flags_local_evaluation_for_cohorts(self, patch_get, patch_flags):
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
        client.feature_flags = [
            {
                "id": 2,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "region",
                                    "operator": "exact",
                                    "value": ["USA"],
                                    "type": "person",
                                },
                                {
                                    "key": "id",
                                    "value": 98,
                                    "operator": None,
                                    "type": "cohort",
                                },
                            ],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
        ]
        client.cohorts = {
            "98": {
                "type": "OR",
                "values": [
                    {"key": "id", "value": 1, "type": "cohort"},
                    {
                        "key": "nation",
                        "operator": "exact",
                        "value": ["UK"],
                        "type": "person",
                    },
                ],
            },
            "1": {
                "type": "AND",
                "values": [
                    {
                        "key": "other",
                        "operator": "exact",
                        "value": ["thing"],
                        "type": "person",
                    }
                ],
            },
        }

        feature_flag_match = client.get_feature_flag(
            "beta-feature", "some-distinct-id", person_properties={"region": "UK"}
        )

        self.assertEqual(feature_flag_match, False)
        self.assertEqual(patch_flags.call_count, 0)
        self.assertEqual(patch_get.call_count, 0)

        feature_flag_match = client.get_feature_flag(
            "beta-feature",
            "some-distinct-id",
            person_properties={"region": "USA", "nation": "UK"},
        )
        # even though 'other' property is not present, the cohort should still match since it's an OR condition
        self.assertEqual(feature_flag_match, True)
        self.assertEqual(patch_flags.call_count, 0)
        self.assertEqual(patch_get.call_count, 0)

        feature_flag_match = client.get_feature_flag(
            "beta-feature",
            "some-distinct-id",
            person_properties={"region": "USA", "other": "thing"},
        )
        self.assertEqual(feature_flag_match, True)
        self.assertEqual(patch_flags.call_count, 0)
        self.assertEqual(patch_get.call_count, 0)

    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_feature_flags_local_evaluation_for_negated_cohorts(
        self, patch_get, patch_flags
    ):
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
        client.feature_flags = [
            {
                "id": 2,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "region",
                                    "operator": "exact",
                                    "value": ["USA"],
                                    "type": "person",
                                },
                                {
                                    "key": "id",
                                    "value": 98,
                                    "operator": None,
                                    "type": "cohort",
                                },
                            ],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
        ]
        client.cohorts = {
            "98": {
                "type": "OR",
                "values": [
                    {"key": "id", "value": 1, "type": "cohort"},
                    {
                        "key": "nation",
                        "operator": "exact",
                        "value": ["UK"],
                        "type": "person",
                    },
                ],
            },
            "1": {
                "type": "AND",
                "values": [
                    {
                        "key": "other",
                        "operator": "exact",
                        "value": ["thing"],
                        "type": "person",
                        "negation": True,
                    }
                ],
            },
        }

        feature_flag_match = client.get_feature_flag(
            "beta-feature", "some-distinct-id", person_properties={"region": "UK"}
        )

        self.assertEqual(feature_flag_match, False)
        self.assertEqual(patch_flags.call_count, 0)
        self.assertEqual(patch_get.call_count, 0)

        feature_flag_match = client.get_feature_flag(
            "beta-feature",
            "some-distinct-id",
            person_properties={"region": "USA", "nation": "UK"},
        )
        # even though 'other' property is not present, the cohort should still match since it's an OR condition
        self.assertEqual(feature_flag_match, True)
        self.assertEqual(patch_flags.call_count, 0)
        self.assertEqual(patch_get.call_count, 0)

        feature_flag_match = client.get_feature_flag(
            "beta-feature",
            "some-distinct-id",
            person_properties={"region": "USA", "other": "thing"},
        )
        # since 'other' is negated, we return False. Since 'nation' is not present, we can't tell whether the flag should be true or false, so go to decide
        self.assertEqual(patch_flags.call_count, 1)
        self.assertEqual(patch_get.call_count, 0)

        patch_flags.reset_mock()

        feature_flag_match = client.get_feature_flag(
            "beta-feature",
            "some-distinct-id",
            person_properties={"region": "USA", "other": "thing2"},
        )
        self.assertEqual(feature_flag_match, True)
        self.assertEqual(patch_flags.call_count, 0)
        self.assertEqual(patch_get.call_count, 0)

    @mock.patch("posthog.feature_flags.log")
    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_feature_flags_with_flag_dependencies(
        self, patch_get, patch_flags, mock_log
    ):
        # Mock remote flags call to return empty for this flag (fallback returns None)
        patch_flags.return_value = {"featureFlags": {}}
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
        client.feature_flags = [
            {
                "id": 1,
                "name": "Flag with Dependencies",
                "key": "flag-with-dependencies",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "beta-feature",
                                    "operator": "flag_evaluates_to",
                                    "value": True,
                                    "type": "flag",
                                    "dependency_chain": ["beta-feature"],
                                },
                                {
                                    "key": "email",
                                    "operator": "icontains",
                                    "value": "@example.com",
                                    "type": "person",
                                },
                            ],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            }
        ]

        # Test that flag evaluation handles flag dependencies properly
        # The flag has a dependency on "beta-feature" which doesn't exist locally
        # Since the dependency doesn't exist, local evaluation should fail and fall back to remote
        # Remote returns empty result, so final result is None
        feature_flag_match = client.get_feature_flag(
            "flag-with-dependencies",
            "test-user",
            person_properties={"email": "test@example.com"},
        )
        self.assertIsNone(feature_flag_match)
        self.assertEqual(patch_flags.call_count, 1)
        self.assertEqual(patch_get.call_count, 0)

        # Test with email that doesn't match (should also fall back to remote due to missing dependency)
        feature_flag_match = client.get_feature_flag(
            "flag-with-dependencies",
            "test-user-2",
            person_properties={"email": "test@other.com"},
        )
        self.assertIsNone(feature_flag_match)
        self.assertEqual(patch_flags.call_count, 2)  # Called twice now
        self.assertEqual(patch_get.call_count, 0)

    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_flag_dependencies_simple_chain(self, patch_get, patch_flags):
        """Test basic flag dependency: flag-b depends on flag-a"""
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
        client.feature_flags = [
            {
                "id": 1,
                "name": "Flag A",
                "key": "flag-a",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "email",
                                    "operator": "icontains",
                                    "value": "@example.com",
                                    "type": "person",
                                }
                            ],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
            {
                "id": 2,
                "name": "Flag B",
                "key": "flag-b",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "flag-a",
                                    "operator": "flag_evaluates_to",
                                    "value": True,
                                    "type": "flag",
                                    "dependency_chain": ["flag-a"],
                                }
                            ],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
        ]

        # Test when dependency is satisfied
        result = client.get_feature_flag(
            "flag-b",
            "test-user",
            person_properties={"email": "test@example.com"},
        )
        self.assertEqual(result, True)

        # Test when dependency is not satisfied
        result = client.get_feature_flag(
            "flag-b",
            "test-user-2",
            person_properties={"email": "test@other.com"},
        )
        self.assertEqual(result, False)

    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_flag_dependencies_circular_dependency(self, patch_get, patch_flags):
        """Test circular dependency handling: flag-a depends on flag-b, flag-b depends on flag-a"""
        # Mock remote flags call to return empty for these flags (fallback returns None)
        patch_flags.return_value = {"featureFlags": {}}
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
        client.feature_flags = [
            {
                "id": 1,
                "name": "Flag A",
                "key": "flag-a",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "flag-b",
                                    "operator": "flag_evaluates_to",
                                    "value": True,
                                    "type": "flag",
                                    "dependency_chain": [],  # Empty chain indicates circular dependency
                                }
                            ],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
            {
                "id": 2,
                "name": "Flag B",
                "key": "flag-b",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "flag-a",
                                    "operator": "flag_evaluates_to",
                                    "value": True,
                                    "type": "flag",
                                    "dependency_chain": [],  # Empty chain indicates circular dependency
                                }
                            ],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
        ]

        # Both flags should fall back to remote evaluation due to circular dependency
        # Since we're not mocking the remote call, both should return None
        result_a = client.get_feature_flag("flag-a", "test-user")
        self.assertIsNone(result_a)

        result_b = client.get_feature_flag("flag-b", "test-user")
        self.assertIsNone(result_b)

    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_flag_dependencies_missing_flag(self, patch_get, patch_flags):
        """Test handling of missing flag dependency"""
        # Mock remote flags call to return empty for this flag (fallback returns None)
        patch_flags.return_value = {"featureFlags": {}}
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
        client.feature_flags = [
            {
                "id": 1,
                "name": "Flag A",
                "key": "flag-a",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "non-existent-flag",
                                    "operator": "flag_evaluates_to",
                                    "value": True,
                                    "type": "flag",
                                    "dependency_chain": ["non-existent-flag"],
                                }
                            ],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            }
        ]

        # Should fall back to remote evaluation because dependency doesn't exist
        # Since we're not mocking the remote call, should return None
        result = client.get_feature_flag("flag-a", "test-user")
        self.assertIsNone(result)

    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_flag_dependencies_complex_chain(self, patch_get, patch_flags):
        """Test complex dependency chain: flag-d -> flag-c -> [flag-a, flag-b]"""
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
        client.feature_flags = [
            {
                "id": 1,
                "name": "Flag A",
                "key": "flag-a",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
            {
                "id": 2,
                "name": "Flag B",
                "key": "flag-b",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
            {
                "id": 3,
                "name": "Flag C",
                "key": "flag-c",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "flag-a",
                                    "operator": "flag_evaluates_to",
                                    "value": True,
                                    "type": "flag",
                                    "dependency_chain": ["flag-a"],
                                },
                                {
                                    "key": "flag-b",
                                    "operator": "flag_evaluates_to",
                                    "value": True,
                                    "type": "flag",
                                    "dependency_chain": ["flag-b"],
                                },
                            ],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
            {
                "id": 4,
                "name": "Flag D",
                "key": "flag-d",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "flag-c",
                                    "operator": "flag_evaluates_to",
                                    "value": True,
                                    "type": "flag",
                                    "dependency_chain": ["flag-a", "flag-b", "flag-c"],
                                }
                            ],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
        ]

        # All dependencies satisfied - should return True
        result = client.get_feature_flag("flag-d", "test-user")
        self.assertEqual(result, True)

        # Make flag-a inactive - should break the chain
        client.feature_flags[0]["active"] = False
        result = client.get_feature_flag("flag-d", "test-user")
        self.assertEqual(result, False)

    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_flag_dependencies_mixed_conditions(self, patch_get, patch_flags):
        """Test flag dependency mixed with other property conditions"""
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
        client.feature_flags = [
            {
                "id": 1,
                "name": "Base Flag",
                "key": "base-flag",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
            {
                "id": 2,
                "name": "Mixed Flag",
                "key": "mixed-flag",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "base-flag",
                                    "operator": "flag_evaluates_to",
                                    "value": True,
                                    "type": "flag",
                                    "dependency_chain": ["base-flag"],
                                },
                                {
                                    "key": "email",
                                    "operator": "icontains",
                                    "value": "@example.com",
                                    "type": "person",
                                },
                            ],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
        ]

        # Both flag dependency and email condition satisfied
        result = client.get_feature_flag(
            "mixed-flag",
            "test-user",
            person_properties={"email": "test@example.com"},
        )
        self.assertEqual(result, True)

        # Flag dependency satisfied but email condition not satisfied
        result = client.get_feature_flag(
            "mixed-flag",
            "test-user-2",
            person_properties={"email": "test@other.com"},
        )
        self.assertEqual(result, False)

        # Email condition satisfied but flag dependency not satisfied
        client.feature_flags[0]["active"] = False
        result = client.get_feature_flag(
            "mixed-flag",
            "test-user-3",
            person_properties={"email": "test@example.com"},
        )
        self.assertEqual(result, False)

    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_flag_dependencies_malformed_chain(self, patch_get, patch_flags):
        """Test handling of malformed dependency chains"""
        # Mock remote flags call to return empty for this flag (fallback returns None)
        patch_flags.return_value = {"featureFlags": {}}
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
        client.feature_flags = [
            {
                "id": 1,
                "name": "Base Flag",
                "key": "base-flag",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
            {
                "id": 2,
                "name": "Missing Chain Flag",
                "key": "missing-chain-flag",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "base-flag",
                                    "operator": "flag_evaluates_to",
                                    "value": True,
                                    "type": "flag",
                                    # No dependency_chain property - should evaluate as inconclusive
                                }
                            ],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
        ]

        # Should fall back to remote evaluation when dependency_chain is missing
        # Since we're not mocking the remote call, should return None
        result = client.get_feature_flag("missing-chain-flag", "test-user")
        self.assertIsNone(result)

    def test_flag_dependencies_without_context_raises_inconclusive(self):
        """Test that missing flags_by_key raises InconclusiveMatchError"""
        from posthoganalytics.feature_flags import (
            evaluate_flag_dependency,
            InconclusiveMatchError,
        )

        property_with_flag_dep = {
            "key": "some-flag",
            "operator": "flag_evaluates_to",
            "value": True,
            "type": "flag",
            "dependency_chain": ["some-flag"],
        }

        # Should raise InconclusiveMatchError when flags_by_key is None
        with self.assertRaises(InconclusiveMatchError) as cm:
            evaluate_flag_dependency(
                property_with_flag_dep,
                flags_by_key=None,  # This should trigger the error
                evaluation_cache={},
                distinct_id="test-user",
                properties={},
                cohort_properties={},
            )

        self.assertIn("Cannot evaluate flag dependency", str(cm.exception))
        self.assertIn("some-flag", str(cm.exception))

    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_multi_level_multivariate_dependency_chain(self, patch_get, patch_flags):
        """Test multi-level multivariate dependency chain: dependent-flag -> intermediate-flag -> leaf-flag"""
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
        client.feature_flags = [
            # Leaf flag: multivariate with "control" and "test" variants using person property overrides
            {
                "id": 1,
                "name": "Leaf Flag",
                "key": "leaf-flag",
                "active": True,
                "rollout_percentage": 100,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "email",
                                    "type": "person",
                                    "value": "control@example.com",
                                    "operator": "exact",
                                }
                            ],
                            "rollout_percentage": 100,
                            "variant": "control",
                        },
                        {
                            "properties": [
                                {
                                    "key": "email",
                                    "type": "person",
                                    "value": "test@example.com",
                                    "operator": "exact",
                                }
                            ],
                            "rollout_percentage": 100,
                            "variant": "test",
                        },
                        {
                            "rollout_percentage": 50,
                            "variant": "control",
                        },  # Default fallback
                    ],
                    "multivariate": {
                        "variants": [
                            {
                                "key": "control",
                                "name": "Control",
                                "rollout_percentage": 50,
                            },
                            {"key": "test", "name": "Test", "rollout_percentage": 50},
                        ]
                    },
                },
            },
            # Intermediate flag: multivariate with "blue" and "green" variants, depends on leaf-flag="control"
            {
                "id": 2,
                "name": "Intermediate Flag",
                "key": "intermediate-flag",
                "active": True,
                "rollout_percentage": 100,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "leaf-flag",
                                    "operator": "flag_evaluates_to",
                                    "value": "control",
                                    "type": "flag",
                                    "dependency_chain": ["leaf-flag"],
                                },
                                {
                                    "key": "variant_type",
                                    "type": "person",
                                    "value": "blue",
                                    "operator": "exact",
                                },
                            ],
                            "rollout_percentage": 100,
                            "variant": "blue",
                        },
                        {
                            "properties": [
                                {
                                    "key": "leaf-flag",
                                    "operator": "flag_evaluates_to",
                                    "value": "control",
                                    "type": "flag",
                                    "dependency_chain": ["leaf-flag"],
                                },
                                {
                                    "key": "variant_type",
                                    "type": "person",
                                    "value": "green",
                                    "operator": "exact",
                                },
                            ],
                            "rollout_percentage": 100,
                            "variant": "green",
                        },
                    ],
                    "multivariate": {
                        "variants": [
                            {"key": "blue", "name": "Blue", "rollout_percentage": 50},
                            {"key": "green", "name": "Green", "rollout_percentage": 50},
                        ]
                    },
                },
            },
            # Dependent flag: boolean flag that depends on intermediate-flag="blue"
            {
                "id": 3,
                "name": "Dependent Flag",
                "key": "dependent-flag",
                "active": True,
                "rollout_percentage": 100,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "intermediate-flag",
                                    "operator": "flag_evaluates_to",
                                    "value": "blue",
                                    "type": "flag",
                                    "dependency_chain": [
                                        "leaf-flag",
                                        "intermediate-flag",
                                    ],
                                }
                            ],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
        ]

        # Test using person properties and variant overrides to ensure predictable variants

        # Test 1: Make sure the leaf flag evaluates to the variant we expect using email overrides
        self.assertEqual(
            "control",
            client.get_feature_flag(
                "leaf-flag",
                "any-user",
                person_properties={"email": "control@example.com"},
            ),
        )
        self.assertEqual(
            "test",
            client.get_feature_flag(
                "leaf-flag",
                "any-user",
                person_properties={"email": "test@example.com"},
            ),
        )

        # Test 2: Make sure the intermediate flag evaluates to the expected variants when dependency is satisfied
        self.assertEqual(
            "blue",
            client.get_feature_flag(
                "intermediate-flag",
                "any-user",
                person_properties={
                    "email": "control@example.com",
                    "variant_type": "blue",
                },
            ),
        )

        self.assertEqual(
            "green",
            client.get_feature_flag(
                "intermediate-flag",
                "any-user",
                person_properties={
                    "email": "control@example.com",
                    "variant_type": "green",
                },
            ),
        )

        # Test 3: Make sure the intermediate flag evaluates to false when leaf dependency fails
        self.assertEqual(
            False,
            client.get_feature_flag(
                "intermediate-flag",
                "any-user",
                person_properties={
                    "email": "test@example.com",  # This makes leaf-flag="test", breaking dependency
                    "variant_type": "blue",
                },
            ),
        )

        # Test 4: When leaf-flag="control", intermediate="blue", dependent should be true
        self.assertEqual(
            True,
            client.get_feature_flag(
                "dependent-flag",
                "any-user",
                person_properties={
                    "email": "control@example.com",
                    "variant_type": "blue",
                },
            ),
        )

        # Test 5: When leaf-flag="control", intermediate="green", dependent should be false
        self.assertEqual(
            False,
            client.get_feature_flag(
                "dependent-flag",
                "any-user",
                person_properties={
                    "email": "control@example.com",
                    "variant_type": "green",
                },
            ),
        )

        # Test 6: When leaf-flag="test", intermediate is False, dependent should be false
        self.assertEqual(
            False,
            client.get_feature_flag(
                "dependent-flag",
                "any-user",
                person_properties={"email": "test@example.com", "variant_type": "blue"},
            ),
        )

    def test_matches_dependency_value(self):
        """Test the matches_dependency_value function logic"""
        from posthoganalytics.feature_flags import matches_dependency_value

        # String variant matches string exactly (case-sensitive)
        self.assertTrue(matches_dependency_value("control", "control"))
        self.assertTrue(matches_dependency_value("Control", "Control"))
        self.assertFalse(matches_dependency_value("control", "Control"))
        self.assertFalse(matches_dependency_value("Control", "CONTROL"))
        self.assertFalse(matches_dependency_value("control", "test"))

        # String variant matches boolean true (any variant is truthy)
        self.assertTrue(matches_dependency_value(True, "control"))
        self.assertTrue(matches_dependency_value(True, "test"))
        self.assertFalse(matches_dependency_value(False, "control"))

        # Boolean matches boolean exactly
        self.assertTrue(matches_dependency_value(True, True))
        self.assertTrue(matches_dependency_value(False, False))
        self.assertFalse(matches_dependency_value(False, True))
        self.assertFalse(matches_dependency_value(True, False))

        # Empty string doesn't match
        self.assertFalse(matches_dependency_value(True, ""))
        self.assertFalse(matches_dependency_value("control", ""))

        # Type mismatches
        self.assertFalse(matches_dependency_value(123, "control"))
        self.assertFalse(matches_dependency_value("control", True))

    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_production_style_multivariate_dependency_chain(
        self, patch_get, patch_flags
    ):
        """Test production-style multivariate dependency chain: multivariate-root-flag -> multivariate-intermediate-flag -> multivariate-leaf-flag"""
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
        client.feature_flags = [
            # Leaf flag: multivariate with fruit variants
            {
                "id": 451,
                "name": "Multivariate Leaf Flag (Base)",
                "key": "multivariate-leaf-flag",
                "active": True,
                "rollout_percentage": 100,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "email",
                                    "type": "person",
                                    "value": ["pineapple@example.com"],
                                    "operator": "exact",
                                }
                            ],
                            "rollout_percentage": 100,
                            "variant": "pineapple",
                        },
                        {
                            "properties": [
                                {
                                    "key": "email",
                                    "type": "person",
                                    "value": ["mango@example.com"],
                                    "operator": "exact",
                                }
                            ],
                            "rollout_percentage": 100,
                            "variant": "mango",
                        },
                        {
                            "properties": [
                                {
                                    "key": "email",
                                    "type": "person",
                                    "value": ["papaya@example.com"],
                                    "operator": "exact",
                                }
                            ],
                            "rollout_percentage": 100,
                            "variant": "papaya",
                        },
                        {
                            "properties": [
                                {
                                    "key": "email",
                                    "type": "person",
                                    "value": ["kiwi@example.com"],
                                    "operator": "exact",
                                }
                            ],
                            "rollout_percentage": 100,
                            "variant": "kiwi",
                        },
                        {
                            "properties": [],
                            "rollout_percentage": 0,  # Force default to false for unknown emails
                        },
                    ],
                    "multivariate": {
                        "variants": [
                            {"key": "pineapple", "rollout_percentage": 25},
                            {"key": "mango", "rollout_percentage": 25},
                            {"key": "papaya", "rollout_percentage": 25},
                            {"key": "kiwi", "rollout_percentage": 25},
                        ]
                    },
                },
            },
            # Intermediate flag: multivariate with color variants, depends on fruit
            {
                "id": 467,
                "name": "Multivariate Intermediate Flag (Depends on fruit)",
                "key": "multivariate-intermediate-flag",
                "active": True,
                "rollout_percentage": 100,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "multivariate-leaf-flag",
                                    "type": "flag",
                                    "value": "pineapple",
                                    "operator": "flag_evaluates_to",
                                    "dependency_chain": ["multivariate-leaf-flag"],
                                }
                            ],
                            "rollout_percentage": 100,
                            "variant": "blue",
                        },
                        {
                            "properties": [
                                {
                                    "key": "multivariate-leaf-flag",
                                    "type": "flag",
                                    "value": "mango",
                                    "operator": "flag_evaluates_to",
                                    "dependency_chain": ["multivariate-leaf-flag"],
                                }
                            ],
                            "rollout_percentage": 100,
                            "variant": "red",
                        },
                    ],
                    "multivariate": {
                        "variants": [
                            {"key": "blue", "rollout_percentage": 100},
                            {"key": "red", "rollout_percentage": 0},
                            {"key": "green", "rollout_percentage": 0},
                            {"key": "black", "rollout_percentage": 0},
                        ]
                    },
                },
            },
            # Root flag: multivariate with show variants, depends on color
            {
                "id": 468,
                "name": "Multivariate Root Flag (Depends on color)",
                "key": "multivariate-root-flag",
                "active": True,
                "rollout_percentage": 100,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "multivariate-intermediate-flag",
                                    "type": "flag",
                                    "value": "blue",
                                    "operator": "flag_evaluates_to",
                                    "dependency_chain": [
                                        "multivariate-leaf-flag",
                                        "multivariate-intermediate-flag",
                                    ],
                                }
                            ],
                            "rollout_percentage": 100,
                            "variant": "breaking-bad",
                        },
                        {
                            "properties": [
                                {
                                    "key": "multivariate-intermediate-flag",
                                    "type": "flag",
                                    "value": "red",
                                    "operator": "flag_evaluates_to",
                                    "dependency_chain": [
                                        "multivariate-leaf-flag",
                                        "multivariate-intermediate-flag",
                                    ],
                                }
                            ],
                            "rollout_percentage": 100,
                            "variant": "the-wire",
                        },
                    ],
                    "multivariate": {
                        "variants": [
                            {"key": "breaking-bad", "rollout_percentage": 100},
                            {"key": "the-wire", "rollout_percentage": 0},
                            {"key": "game-of-thrones", "rollout_percentage": 0},
                            {"key": "the-expanse", "rollout_percentage": 0},
                        ]
                    },
                },
            },
        ]

        # Test successful pineapple -> blue -> breaking-bad chain
        leaf_result = client.get_feature_flag(
            "multivariate-leaf-flag",
            "test-user",
            person_properties={"email": "pineapple@example.com"},
        )
        intermediate_result = client.get_feature_flag(
            "multivariate-intermediate-flag",
            "test-user",
            person_properties={"email": "pineapple@example.com"},
        )
        root_result = client.get_feature_flag(
            "multivariate-root-flag",
            "test-user",
            person_properties={"email": "pineapple@example.com"},
        )

        self.assertEqual(leaf_result, "pineapple")
        self.assertEqual(intermediate_result, "blue")
        self.assertEqual(root_result, "breaking-bad")

        # Test successful mango -> red -> the-wire chain
        mango_leaf_result = client.get_feature_flag(
            "multivariate-leaf-flag",
            "test-user",
            person_properties={"email": "mango@example.com"},
        )
        mango_intermediate_result = client.get_feature_flag(
            "multivariate-intermediate-flag",
            "test-user",
            person_properties={"email": "mango@example.com"},
        )
        mango_root_result = client.get_feature_flag(
            "multivariate-root-flag",
            "test-user",
            person_properties={"email": "mango@example.com"},
        )

        self.assertEqual(mango_leaf_result, "mango")
        self.assertEqual(mango_intermediate_result, "red")
        self.assertEqual(mango_root_result, "the-wire")

        # Test broken chain - user without matching email gets default/false results
        unknown_leaf_result = client.get_feature_flag(
            "multivariate-leaf-flag",
            "test-user",
            person_properties={"email": "unknown@example.com"},
        )
        unknown_intermediate_result = client.get_feature_flag(
            "multivariate-intermediate-flag",
            "test-user",
            person_properties={"email": "unknown@example.com"},
        )
        unknown_root_result = client.get_feature_flag(
            "multivariate-root-flag",
            "test-user",
            person_properties={"email": "unknown@example.com"},
        )

        self.assertEqual(
            unknown_leaf_result, False
        )  # No matching email -> null variant -> false
        self.assertEqual(unknown_intermediate_result, False)  # Dependency not satisfied
        self.assertEqual(unknown_root_result, False)  # Chain broken

    @mock.patch("posthog.client.Poller")
    @mock.patch("posthog.client.get")
    def test_load_feature_flags(self, patch_get, patch_poll):
        patch_get.return_value = GetResponse(
            data={
                "flags": [
                    {
                        "id": 1,
                        "name": "Beta Feature",
                        "key": "beta-feature",
                        "active": True,
                    },
                    {
                        "id": 2,
                        "name": "Alpha Feature",
                        "key": "alpha-feature",
                        "active": False,
                    },
                ],
                "group_type_mapping": {"0": "company"},
                "cohorts": {},
            },
            etag='"abc123"',
        )
        client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
        with freeze_time("2020-01-01T12:01:00.0000Z"):
            client.load_feature_flags()
        self.assertEqual(len(client.feature_flags), 2)
        self.assertEqual(client.feature_flags[0]["key"], "beta-feature")
        self.assertEqual(client.group_type_mapping, {"0": "company"})
        self.assertEqual(
            client._last_feature_flag_poll.isoformat(), "2020-01-01T12:01:00+00:00"
        )
        self.assertEqual(patch_poll.call_count, 1)
        # Verify ETag is stored
        self.assertEqual(client._flags_etag, '"abc123"')

    @mock.patch("posthog.client.Poller")
    @mock.patch("posthog.client.get")
    def test_load_feature_flags_sends_etag_on_subsequent_requests(
        self, patch_get, patch_poll
    ):
        """Test that the ETag is sent in If-None-Match header on subsequent requests"""
        patch_get.return_value = GetResponse(
            data={
                "flags": [{"id": 1, "key": "beta-feature", "active": True}],
                "group_type_mapping": {},
                "cohorts": {},
            },
            etag='"initial-etag"',
        )
        client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
        client.load_feature_flags()

        # First call should have no etag
        first_call_kwargs = patch_get.call_args_list[0][1]
        self.assertIsNone(first_call_kwargs.get("etag"))

        # Simulate second call
        client._load_feature_flags()

        # Second call should have the etag
        second_call_kwargs = patch_get.call_args_list[1][1]
        self.assertEqual(second_call_kwargs.get("etag"), '"initial-etag"')

    @mock.patch("posthog.client.Poller")
    @mock.patch("posthog.client.get")
    def test_load_feature_flags_304_not_modified(self, patch_get, patch_poll):
        """Test that 304 Not Modified responses skip flag processing"""
        # First response with flags
        initial_response = GetResponse(
            data={
                "flags": [{"id": 1, "key": "beta-feature", "active": True}],
                "group_type_mapping": {"0": "company"},
                "cohorts": {},
            },
            etag='"test-etag"',
        )
        # Second response is 304 Not Modified
        not_modified_response = GetResponse(
            data=None,
            etag='"test-etag"',
            not_modified=True,
        )
        patch_get.side_effect = [initial_response, not_modified_response]

        client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
        client.load_feature_flags()

        # Verify initial flags are loaded
        self.assertEqual(len(client.feature_flags), 1)
        self.assertEqual(client.feature_flags[0]["key"], "beta-feature")
        self.assertEqual(client.group_type_mapping, {"0": "company"})

        # Second call with 304
        client._load_feature_flags()

        # Flags should still be the same (not cleared)
        self.assertEqual(len(client.feature_flags), 1)
        self.assertEqual(client.feature_flags[0]["key"], "beta-feature")
        self.assertEqual(client.group_type_mapping, {"0": "company"})

    @mock.patch("posthog.client.Poller")
    @mock.patch("posthog.client.get")
    def test_load_feature_flags_etag_updated_on_new_response(
        self, patch_get, patch_poll
    ):
        """Test that ETag is updated when flags change"""
        patch_get.side_effect = [
            GetResponse(
                data={
                    "flags": [{"id": 1, "key": "flag-v1", "active": True}],
                    "group_type_mapping": {},
                    "cohorts": {},
                },
                etag='"etag-v1"',
            ),
            GetResponse(
                data={
                    "flags": [{"id": 1, "key": "flag-v2", "active": True}],
                    "group_type_mapping": {},
                    "cohorts": {},
                },
                etag='"etag-v2"',
            ),
        ]

        client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
        client.load_feature_flags()
        self.assertEqual(client._flags_etag, '"etag-v1"')

        client._load_feature_flags()
        self.assertEqual(client._flags_etag, '"etag-v2"')
        self.assertEqual(client.feature_flags[0]["key"], "flag-v2")

    @mock.patch("posthog.client.Poller")
    @mock.patch("posthog.client.get")
    def test_load_feature_flags_clears_etag_when_server_stops_sending(
        self, patch_get, patch_poll
    ):
        """Test that ETag is cleared when server stops sending it"""
        patch_get.side_effect = [
            GetResponse(
                data={
                    "flags": [{"id": 1, "key": "flag-v1", "active": True}],
                    "group_type_mapping": {},
                    "cohorts": {},
                },
                etag='"etag-v1"',
            ),
            GetResponse(
                data={
                    "flags": [{"id": 1, "key": "flag-v2", "active": True}],
                    "group_type_mapping": {},
                    "cohorts": {},
                },
                etag=None,  # Server stopped sending ETag
            ),
        ]

        client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
        client.load_feature_flags()
        self.assertEqual(client._flags_etag, '"etag-v1"')

        client._load_feature_flags()
        self.assertIsNone(client._flags_etag)
        self.assertEqual(client.feature_flags[0]["key"], "flag-v2")

    @mock.patch("posthog.client.Poller")
    @mock.patch("posthog.client.get")
    def test_load_feature_flags_wrong_key(self, patch_get, _patch_poll):
        patch_get.side_effect = APIError(401, "Unauthorized")
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)

        with self.assertLogs("posthog", level="ERROR") as logs:
            client.load_feature_flags()
            self.assertEqual(
                logs.output[0],
                "ERROR:posthog:[FEATURE FLAGS] Error loading feature flags: To use feature flags, please set a valid personal_api_key. More information: https://posthog.com/docs/api/overview",
            )
        client.debug = True
        self.assertRaises(APIError, client.load_feature_flags)

    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_feature_enabled_simple(self, patch_get, patch_flags):
        client = Client(FAKE_TEST_API_KEY)
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "rollout_percentage": 100,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ]
                },
            }
        ]
        self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_feature_enabled_simple_is_false(self, patch_get, patch_flags):
        client = Client(FAKE_TEST_API_KEY)
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "rollout_percentage": 0,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 0,
                        }
                    ]
                },
            }
        ]
        self.assertFalse(client.feature_enabled("beta-feature", "distinct_id"))
        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_feature_enabled_simple_is_true_when_rollout_is_undefined(
        self, patch_get, patch_flags
    ):
        client = Client(FAKE_TEST_API_KEY)
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "rollout_percentage": None,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": None,
                        }
                    ]
                },
            }
        ]
        self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch("posthog.client.get")
    def test_feature_enabled_simple_with_project_api_key(self, patch_get):
        client = Client(project_api_key=FAKE_TEST_API_KEY, on_error=self.set_fail)
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "rollout_percentage": 100,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ]
                },
            }
        ]
        self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))

    @mock.patch("posthog.client.flags")
    def test_feature_enabled_request_multi_variate(self, patch_flags):
        patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
        client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "rollout_percentage": 100,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ]
                },
            }
        ]
        self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
        # decide not called because this can be evaluated locally
        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch("posthog.client.get")
    def test_feature_enabled_simple_without_rollout_percentage(self, patch_get):
        client = Client(FAKE_TEST_API_KEY)
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                        }
                    ]
                },
            }
        ]
        self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))

    @mock.patch("posthog.client.flags")
    def test_get_feature_flag(self, patch_flags):
        patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
        client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "rollout_percentage": 100,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ],
                    "multivariate": {
                        "variants": [
                            {"key": "variant-1", "rollout_percentage": 50},
                            {"key": "variant-2", "rollout_percentage": 50},
                        ]
                    },
                },
            }
        ]
        self.assertEqual(
            client.get_feature_flag("beta-feature", "distinct_id"), "variant-1"
        )
        # decide not called because this can be evaluated locally
        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch("posthog.client.Poller")
    @mock.patch("posthog.client.flags")
    def test_feature_enabled_doesnt_exist(self, patch_flags, patch_poll):
        client = Client(FAKE_TEST_API_KEY)
        client.feature_flags = []

        patch_flags.return_value = {"featureFlags": {}}
        self.assertFalse(client.feature_enabled("doesnt-exist", "distinct_id"))

        patch_flags.side_effect = APIError(401, "decide error")
        self.assertIsNone(client.feature_enabled("doesnt-exist", "distinct_id"))

    @mock.patch("posthog.client.Poller")
    @mock.patch("posthog.client.flags")
    def test_personal_api_key_doesnt_exist(self, patch_flags, patch_poll):
        client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
        client.feature_flags = []

        patch_flags.return_value = {"featureFlags": {"feature-flag": True}}

        self.assertTrue(client.feature_enabled("feature-flag", "distinct_id"))

    @mock.patch("posthog.client.Poller")
    @mock.patch("posthog.client.get")
    def test_load_feature_flags_error(self, patch_get, patch_poll):
        def raise_effect():
            raise Exception("http exception")

        patch_get.return_value.raiseError.side_effect = raise_effect
        client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
        client.feature_flags = []

        self.assertFalse(client.feature_enabled("doesnt-exist", "distinct_id"))

    @mock.patch("posthog.client.flags")
    def test_get_feature_flag_with_variant_overrides(self, patch_flags):
        patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
        client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "rollout_percentage": 100,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "email",
                                    "type": "person",
                                    "value": "test@posthog.com",
                                    "operator": "exact",
                                }
                            ],
                            "rollout_percentage": 100,
                            "variant": "second-variant",
                        },
                        {"rollout_percentage": 50, "variant": "first-variant"},
                    ],
                    "multivariate": {
                        "variants": [
                            {
                                "key": "first-variant",
                                "name": "First Variant",
                                "rollout_percentage": 50,
                            },
                            {
                                "key": "second-variant",
                                "name": "Second Variant",
                                "rollout_percentage": 25,
                            },
                            {
                                "key": "third-variant",
                                "name": "Third Variant",
                                "rollout_percentage": 25,
                            },
                        ]
                    },
                },
            }
        ]
        self.assertEqual(
            client.get_feature_flag(
                "beta-feature",
                "test_id",
                person_properties={"email": "test@posthog.com"},
            ),
            "second-variant",
        )
        self.assertEqual(
            client.get_feature_flag("beta-feature", "example_id"), "first-variant"
        )
        # decide not called because this can be evaluated locally
        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch("posthog.client.flags")
    def test_flag_with_clashing_variant_overrides(self, patch_flags):
        patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
        client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "rollout_percentage": 100,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "email",
                                    "type": "person",
                                    "value": "test@posthog.com",
                                    "operator": "exact",
                                }
                            ],
                            "rollout_percentage": 100,
                            "variant": "second-variant",
                        },
                        # since second-variant comes first in the list, it will be the one that gets picked
                        {
                            "properties": [
                                {
                                    "key": "email",
                                    "type": "person",
                                    "value": "test@posthog.com",
                                    "operator": "exact",
                                }
                            ],
                            "rollout_percentage": 100,
                            "variant": "first-variant",
                        },
                        {"rollout_percentage": 50, "variant": "first-variant"},
                    ],
                    "multivariate": {
                        "variants": [
                            {
                                "key": "first-variant",
                                "name": "First Variant",
                                "rollout_percentage": 50,
                            },
                            {
                                "key": "second-variant",
                                "name": "Second Variant",
                                "rollout_percentage": 25,
                            },
                            {
                                "key": "third-variant",
                                "name": "Third Variant",
                                "rollout_percentage": 25,
                            },
                        ]
                    },
                },
            }
        ]
        self.assertEqual(
            client.get_feature_flag(
                "beta-feature",
                "test_id",
                person_properties={"email": "test@posthog.com"},
            ),
            "second-variant",
        )
        self.assertEqual(
            client.get_feature_flag(
                "beta-feature",
                "example_id",
                person_properties={"email": "test@posthog.com"},
            ),
            "second-variant",
        )
        # decide not called because this can be evaluated locally
        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch("posthog.client.flags")
    def test_flag_with_invalid_variant_overrides(self, patch_flags):
        patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
        client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "beta-feature",
                "active": True,
                "rollout_percentage": 100,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "email",
                                    "type": "person",
                                    "value": "test@posthog.com",
                                    "operator": "exact",
                                }
                            ],
                            "rollout_percentage": 100,
                            "variant": "second???",
                        },
                        {"rollout_percentage": 50, "variant": "first??"},
                    ],
                    "multivariate": {
                        "variants": [
                            {
                                "key": "first-variant",
                                "name": "First Variant",
                                "rollout_percentage": 50,
                            },
                            {
                                "key": "second-variant",
                                "name": "Second Variant",
                                "rollout_percentage": 25,
                            },
                            {
                                "key": "third-variant",
                                "name": "Third Variant",
                                "rollout_percentage": 25,
                            },
                        ]
                    },
                },
            }
        ]
        self.assertEqual(
            client.get_feature_flag(
                "beta-feature",
                "test_id",
                person_properties={"email": "test@posthog.com"},
            ),
            "third-variant",
        )
        self.assertEqual(
            client.get_feature_flag("beta-feature", "example_id"), "second-variant"
        )
        # decide not called because this can be evaluated locally
        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch("posthog.client.flags")
    def test_conditions_evaluated_in_order(self, patch_flags):
        patch_flags.return_value = {"featureFlags": {"order-test": "server-variant"}}
        client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
        client.feature_flags = [
            {
                "id": 1,
                "name": "Order Test Flag",
                "key": "order-test",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "rollout_percentage": 100,
                        },
                        {
                            "properties": [
                                {
                                    "key": "email",
                                    "type": "person",
                                    "value": "@vip.com",
                                    "operator": "icontains",
                                }
                            ],
                            "rollout_percentage": 100,
                            "variant": "vip-variant",
                        },
                    ],
                    "multivariate": {
                        "variants": [
                            {
                                "key": "control",
                                "name": "Control",
                                "rollout_percentage": 100,
                            },
                            {
                                "key": "vip-variant",
                                "name": "VIP Variant",
                                "rollout_percentage": 0,
                            },
                        ]
                    },
                },
            }
        ]

        # Even though user@vip.com would match the second condition with variant override,
        # they should match the first condition and get control
        result = client.get_feature_flag(
            "order-test",
            "user123",
            person_properties={"email": "user@vip.com"},
        )
        self.assertEqual(result, "control")

        # server not called because this can be evaluated locally
        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch("posthog.client.flags")
    def test_boolean_feature_flag_payloads_local(self, patch_flags):
        basic_flag = {
            "id": 1,
            "name": "Beta Feature",
            "key": "person-flag",
            "active": True,
            "filters": {
                "groups": [
                    {
                        "properties": [
                            {
                                "key": "region",
                                "operator": "exact",
                                "value": ["USA"],
                                "type": "person",
                            }
                        ],
                        "rollout_percentage": 100,
                    }
                ],
                "payloads": {"true": 300},
            },
        }
        self.client.feature_flags = [basic_flag]

        self.assertEqual(
            self.client.get_feature_flag_payload(
                "person-flag", "some-distinct-id", person_properties={"region": "USA"}
            ),
            300,
        )

        self.assertEqual(
            self.client.get_feature_flag_payload(
                "person-flag",
                "some-distinct-id",
                match_value=True,
                person_properties={"region": "USA"},
            ),
            300,
        )
        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch.object(Client, "capture")
    @mock.patch("posthog.client.flags")
    def test_boolean_feature_flag_payload_decide(self, patch_flags, patch_capture):
        patch_flags.return_value = {
            "featureFlags": {"person-flag": True},
            "featureFlagPayloads": {"person-flag": 300},
        }
        self.assertEqual(
            self.client.get_feature_flag_payload(
                "person-flag", "some-distinct-id", person_properties={"region": "USA"}
            ),
            300,
        )

        self.assertEqual(
            self.client.get_feature_flag_payload(
                "person-flag",
                "some-distinct-id",
                match_value=True,
                person_properties={"region": "USA"},
                send_feature_flag_events=True,
            ),
            300,
        )
        self.assertEqual(patch_flags.call_count, 2)
        self.assertEqual(patch_capture.call_count, 1)
        patch_capture.reset_mock()

    @mock.patch("posthog.client.flags")
    def test_multivariate_feature_flag_payloads(self, patch_flags):
        multivariate_flag = {
            "id": 1,
            "name": "Beta Feature",
            "key": "beta-feature",
            "active": True,
            "rollout_percentage": 100,
            "filters": {
                "groups": [
                    {
                        "properties": [
                            {
                                "key": "email",
                                "type": "person",
                                "value": "test@posthog.com",
                                "operator": "exact",
                            }
                        ],
                        "rollout_percentage": 100,
                        "variant": "second???",
                    },
                    {"rollout_percentage": 50, "variant": "first??"},
                ],
                "multivariate": {
                    "variants": [
                        {
                            "key": "first-variant",
                            "name": "First Variant",
                            "rollout_percentage": 50,
                        },
                        {
                            "key": "second-variant",
                            "name": "Second Variant",
                            "rollout_percentage": 25,
                        },
                        {
                            "key": "third-variant",
                            "name": "Third Variant",
                            "rollout_percentage": 25,
                        },
                    ]
                },
                "payloads": {
                    "first-variant": '"some-payload"',
                    "third-variant": '{"a": "json"}',
                },
            },
        }
        self.client.feature_flags = [multivariate_flag]

        self.assertEqual(
            self.client.get_feature_flag_payload(
                "beta-feature",
                "test_id",
                person_properties={"email": "test@posthog.com"},
            ),
            {"a": "json"},
        )
        self.assertEqual(
            self.client.get_feature_flag_payload(
                "beta-feature",
                "test_id",
                match_value="third-variant",
                person_properties={"email": "test@posthog.com"},
            ),
            {"a": "json"},
        )

        # Force different match value
        self.assertEqual(
            self.client.get_feature_flag_payload(
                "beta-feature",
                "test_id",
                match_value="first-variant",
                person_properties={"email": "test@posthog.com"},
            ),
            "some-payload",
        )
        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch("posthog.client.flags")
    @mock.patch("posthog.client.get")
    def test_fallback_to_api_when_flag_has_static_cohort_in_multi_condition(
        self, patch_get, patch_flags
    ):
        """
        When a flag has multiple conditions and one contains a static cohort,
        the SDK should fallback to API for the entire flag, not just skip that
        condition and evaluate the next one locally.

        This prevents returning wrong variants when later conditions could match
        locally but the user is actually in the static cohort.
        """
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)

        # Mock the local flags response - cohort 999 is NOT in cohorts map (static cohort)
        client.feature_flags = [
            {
                "id": 1,
                "key": "multi-condition-flag",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {"key": "id", "value": 999, "type": "cohort"}
                            ],
                            "rollout_percentage": 100,
                            "variant": "set-1",
                        },
                        {
                            "properties": [
                                {
                                    "key": "$geoip_country_code",
                                    "operator": "exact",
                                    "value": ["DE"],
                                    "type": "person",
                                }
                            ],
                            "rollout_percentage": 100,
                            "variant": "set-8",
                        },
                    ],
                    "multivariate": {
                        "variants": [
                            {"key": "set-1", "rollout_percentage": 50},
                            {"key": "set-8", "rollout_percentage": 50},
                        ]
                    },
                },
            }
        ]
        client.cohorts = {}  # Note: cohort 999 is NOT here - it's a static cohort

        # Mock the API response - user is in the static cohort
        patch_flags.return_value = {"featureFlags": {"multi-condition-flag": "set-1"}}

        result = client.get_feature_flag(
            "multi-condition-flag",
            "test-distinct-id",
            person_properties={"$geoip_country_code": "DE"},
        )

        # Should return the API result (set-1), not local evaluation (set-8)
        self.assertEqual(result, "set-1")

        # Verify API was called (fallback occurred)
        self.assertEqual(patch_flags.call_count, 1)

    @mock.patch("posthog.client.flags")
    def test_device_id_bucketing_uses_device_id_for_hash(self, patch_flags):
        """
        When a flag has bucketing_identifier: "device_id", the device_id should be
        used for hashing instead of distinct_id.
        """
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)

        # This flag uses device_id for bucketing
        client.feature_flags = [
            {
                "id": 1,
                "key": "device-bucketed-flag",
                "active": True,
                "filters": {
                    "bucketing_identifier": "device_id",
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            }
        ]

        # Same distinct_id with different device_ids should produce different results
        # (based on rollout percentage, we check consistency)
        result1 = client.get_feature_flag(
            "device-bucketed-flag", "user-123", device_id="device-A"
        )
        result2 = client.get_feature_flag(
            "device-bucketed-flag", "user-123", device_id="device-A"
        )

        # Same device_id should give consistent results
        self.assertEqual(result1, result2)

        # No API fallback should occur
        self.assertEqual(patch_flags.call_count, 0)

    def test_match_feature_flag_properties_without_bucketing_value_is_deprecated(
        self,
    ):
        """
        match_feature_flag_properties should preserve backward compatibility when
        bucketing_value is omitted, while warning about deprecation.
        """
        from posthoganalytics.feature_flags import match_feature_flag_properties

        flag = {
            "id": 1,
            "key": "device-bucketed-flag",
            "active": True,
            "filters": {
                "bucketing_identifier": "device_id",
                "groups": [
                    {
                        "properties": [],
                        "rollout_percentage": 100,
                    }
                ],
            },
        }

        with self.assertWarnsRegex(
            DeprecationWarning, "without bucketing_value is deprecated"
        ):
            result = match_feature_flag_properties(
                flag,
                "user-123",
                {},
                device_id="device-123",
            )

        self.assertTrue(result)

    @mock.patch("posthog.client.flags")
    def test_device_id_bucketing_same_device_different_users_same_result(
        self, patch_flags
    ):
        """
        When a flag uses device_id bucketing, different distinct_ids with the same
        device_id should get the same result.
        """
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)

        client.feature_flags = [
            {
                "id": 1,
                "key": "device-bucketed-flag",
                "active": True,
                "filters": {
                    "bucketing_identifier": "device_id",
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 50,
                        }
                    ],
                },
            }
        ]

        # Different distinct_ids with the same device_id should get the same result
        result1 = client.get_feature_flag(
            "device-bucketed-flag", "user-A", device_id="shared-device"
        )
        result2 = client.get_feature_flag(
            "device-bucketed-flag", "user-B", device_id="shared-device"
        )
        result3 = client.get_feature_flag(
            "device-bucketed-flag", "user-C", device_id="shared-device"
        )

        # All should be the same since device_id is the same
        self.assertEqual(result1, result2)
        self.assertEqual(result2, result3)

        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch("posthog.client.flags")
    def test_device_id_bucketing_fallback_when_device_id_missing(self, patch_flags):
        """
        When a flag requires device_id for bucketing but none is provided,
        it should fallback to server evaluation.
        """
        patch_flags.return_value = {"featureFlags": {"device-bucketed-flag": True}}
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)

        client.feature_flags = [
            {
                "id": 1,
                "key": "device-bucketed-flag",
                "active": True,
                "filters": {
                    "bucketing_identifier": "device_id",
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            }
        ]

        # No device_id provided - should fallback to API
        result = client.get_feature_flag("device-bucketed-flag", "user-123")

        self.assertTrue(result)
        # API should have been called
        self.assertEqual(patch_flags.call_count, 1)

    @mock.patch("posthog.client.flags")
    def test_device_id_bucketing_returns_none_when_only_evaluate_locally_and_no_device_id(
        self, patch_flags
    ):
        """
        When only_evaluate_locally=True and device_id is required but missing,
        should return None instead of falling back to API.
        """
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)

        client.feature_flags = [
            {
                "id": 1,
                "key": "device-bucketed-flag",
                "active": True,
                "filters": {
                    "bucketing_identifier": "device_id",
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            }
        ]

        # No device_id + only_evaluate_locally should return None
        result = client.get_feature_flag(
            "device-bucketed-flag", "user-123", only_evaluate_locally=True
        )

        self.assertIsNone(result)
        # API should NOT have been called
        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch("posthog.client.flags")
    def test_default_bucketing_identifier_uses_distinct_id(self, patch_flags):
        """
        When bucketing_identifier is not set or is 'distinct_id', should use
        distinct_id for hashing (default behavior).
        """
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)

        # Flag without bucketing_identifier (defaults to distinct_id)
        client.feature_flags = [
            {
                "id": 1,
                "key": "normal-flag",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 50,
                        }
                    ],
                },
            }
        ]

        # Different distinct_ids should potentially produce different results
        # but same distinct_id should produce same result
        result1 = client.get_feature_flag("normal-flag", "user-A")
        result2 = client.get_feature_flag("normal-flag", "user-A")

        self.assertEqual(result1, result2)
        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch("posthog.client.flags")
    def test_device_id_bucketing_with_multivariate_flag(self, patch_flags):
        """
        Multivariate flag variant selection should use device_id when
        bucketing_identifier is set to device_id.
        """
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)

        client.feature_flags = [
            {
                "id": 1,
                "key": "multivariate-device-flag",
                "active": True,
                "filters": {
                    "bucketing_identifier": "device_id",
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ],
                    "multivariate": {
                        "variants": [
                            {"key": "control", "rollout_percentage": 50},
                            {"key": "test", "rollout_percentage": 50},
                        ]
                    },
                },
            }
        ]

        # Same device_id should give same variant
        result1 = client.get_feature_flag(
            "multivariate-device-flag", "user-A", device_id="device-1"
        )
        result2 = client.get_feature_flag(
            "multivariate-device-flag", "user-B", device_id="device-1"
        )

        # Both should get the same variant because device_id is the same
        self.assertEqual(result1, result2)
        self.assertIn(result1, ["control", "test"])

        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch("posthog.client.flags")
    def test_device_id_bucketing_from_context(self, patch_flags):
        """
        When device_id is not passed as a parameter but is set in the context,
        it should be resolved from context.
        """
        from posthoganalytics.contexts import new_context, set_context_device_id

        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)

        client.feature_flags = [
            {
                "id": 1,
                "key": "device-bucketed-flag",
                "active": True,
                "filters": {
                    "bucketing_identifier": "device_id",
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            }
        ]

        # Set device_id in context
        with new_context():
            set_context_device_id("context-device-id")
            result = client.get_feature_flag("device-bucketed-flag", "user-123")

        # Should evaluate locally using the context device_id
        self.assertTrue(result)
        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch("posthog.client.flags")
    def test_group_flags_ignore_bucketing_identifier(self, patch_flags):
        """
        Group flags should continue to use the group identifier for hashing,
        regardless of the bucketing_identifier setting.
        """
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)

        client.feature_flags = [
            {
                "id": 1,
                "key": "group-flag",
                "active": True,
                "filters": {
                    "aggregation_group_type_index": 0,
                    "bucketing_identifier": "device_id",  # Should be ignored for group flags
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            }
        ]
        client.group_type_mapping = {"0": "company"}

        # Even with bucketing_identifier set to device_id, group flag should use group identifier
        result = client.get_feature_flag(
            "group-flag",
            "user-123",
            groups={"company": "acme-inc"},
            device_id="some-device",
        )

        self.assertTrue(result)
        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch("posthog.client.flags")
    def test_group_flag_dependency_receives_device_id(self, patch_flags):
        """
        Group flag dependency evaluation should receive device_id so dependent
        device_id-bucketed flags can be evaluated locally.
        """
        patch_flags.return_value = {"featureFlags": {"group-parent-flag": "from-api"}}
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)

        client.feature_flags = [
            {
                "id": 1,
                "key": "device-dependent-flag",
                "active": True,
                "filters": {
                    "bucketing_identifier": "device_id",
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
            {
                "id": 2,
                "key": "group-parent-flag",
                "active": True,
                "filters": {
                    "aggregation_group_type_index": 0,
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "device-dependent-flag",
                                    "operator": "flag_evaluates_to",
                                    "value": True,
                                    "type": "flag",
                                    "dependency_chain": ["device-dependent-flag"],
                                }
                            ],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
        ]
        client.group_type_mapping = {"0": "company"}

        result = client.get_feature_flag(
            "group-parent-flag",
            "user-123",
            groups={"company": "acme-inc"},
            device_id="device-123",
        )

        self.assertTrue(result)
        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch("posthog.client.flags")
    def test_group_flag_dependency_ignores_device_id_bucketing_identifier(
        self, patch_flags
    ):
        """
        Group flag dependencies should keep bucketing by group key, even when
        the dependent group flag has bucketing_identifier set to device_id.
        """
        patch_flags.return_value = {"featureFlags": {"parent-group-flag": "from-api"}}
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)

        client.feature_flags = [
            {
                "id": 1,
                "key": "child-group-flag",
                "active": True,
                "filters": {
                    "aggregation_group_type_index": 0,
                    "bucketing_identifier": "device_id",
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
            {
                "id": 2,
                "key": "parent-group-flag",
                "active": True,
                "filters": {
                    "aggregation_group_type_index": 0,
                    "groups": [
                        {
                            "properties": [
                                {
                                    "key": "child-group-flag",
                                    "operator": "flag_evaluates_to",
                                    "value": True,
                                    "type": "flag",
                                    "dependency_chain": ["child-group-flag"],
                                }
                            ],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            },
        ]
        client.group_type_mapping = {"0": "company"}

        result = client.get_feature_flag(
            "parent-group-flag",
            "user-123",
            groups={"company": "acme-inc"},
        )

        self.assertTrue(result)
        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch("posthog.client.flags")
    def test_get_all_flags_with_device_id_bucketing(self, patch_flags):
        """
        get_all_flags_and_payloads should properly handle flags with device_id bucketing.
        """
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)

        client.feature_flags = [
            {
                "id": 1,
                "key": "normal-flag",
                "active": True,
                "filters": {
                    "groups": [{"properties": [], "rollout_percentage": 100}],
                },
            },
            {
                "id": 2,
                "key": "device-flag",
                "active": True,
                "filters": {
                    "bucketing_identifier": "device_id",
                    "groups": [{"properties": [], "rollout_percentage": 100}],
                },
            },
        ]

        # With device_id provided, both flags should be evaluated locally
        result = client.get_all_flags("user-123", device_id="my-device")

        self.assertEqual(result["normal-flag"], True)
        self.assertEqual(result["device-flag"], True)
        self.assertEqual(patch_flags.call_count, 0)

    @mock.patch("posthog.client.flags")
    def test_get_all_flags_fallback_when_device_id_missing_for_some_flags(
        self, patch_flags
    ):
        """
        When some flags require device_id but it's not provided, those flags
        should trigger fallback while others can be evaluated locally.
        """
        patch_flags.return_value = {
            "featureFlags": {"normal-flag": True, "device-flag": "from-api"}
        }
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)

        client.feature_flags = [
            {
                "id": 1,
                "key": "normal-flag",
                "active": True,
                "filters": {
                    "groups": [{"properties": [], "rollout_percentage": 100}],
                },
            },
            {
                "id": 2,
                "key": "device-flag",
                "active": True,
                "filters": {
                    "bucketing_identifier": "device_id",
                    "groups": [{"properties": [], "rollout_percentage": 100}],
                },
            },
        ]

        # Without device_id, device-flag can't be evaluated locally
        client.get_all_flags("user-123")

        # Should fallback to API for all flags when any can't be evaluated locally
        self.assertEqual(patch_flags.call_count, 1)


class TestMatchProperties(unittest.TestCase):
    def property(self, key, value, operator=None):
        result = {"key": key, "value": value}
        if operator is not None:
            result.update({"operator": operator})

        return result

    def test_match_properties_exact(self):
        property_a = self.property(key="key", value="value")

        self.assertTrue(match_property(property_a, {"key": "value"}))

        self.assertFalse(match_property(property_a, {"key": "value2"}))
        self.assertFalse(match_property(property_a, {"key": ""}))
        self.assertFalse(match_property(property_a, {"key": None}))

        with self.assertRaises(InconclusiveMatchError):
            match_property(property_a, {"key2": "value"})
            match_property(property_a, {})

        property_b = self.property(key="key", value="value", operator="exact")
        self.assertTrue(match_property(property_b, {"key": "value"}))

        self.assertFalse(match_property(property_b, {"key": "value2"}))

        property_c = self.property(
            key="key", value=["value1", "value2", "value3"], operator="exact"
        )
        self.assertTrue(match_property(property_c, {"key": "value1"}))
        self.assertTrue(match_property(property_c, {"key": "value2"}))
        self.assertTrue(match_property(property_c, {"key": "value3"}))

        self.assertFalse(match_property(property_c, {"key": "value4"}))

        with self.assertRaises(InconclusiveMatchError):
            match_property(property_c, {"key2": "value"})

    def test_match_properties_not_in(self):
        property_a = self.property(key="key", value="value", operator="is_not")
        self.assertTrue(match_property(property_a, {"key": "value2"}))
        self.assertTrue(match_property(property_a, {"key": ""}))
        self.assertTrue(match_property(property_a, {"key": None}))

        property_c = self.property(
            key="key", value=["value1", "value2", "value3"], operator="is_not"
        )
        self.assertTrue(match_property(property_c, {"key": "value4"}))
        self.assertTrue(match_property(property_c, {"key": "value5"}))
        self.assertTrue(match_property(property_c, {"key": "value6"}))
        self.assertTrue(match_property(property_c, {"key": ""}))
        self.assertTrue(match_property(property_c, {"key": None}))

        self.assertFalse(match_property(property_c, {"key": "value2"}))
        self.assertFalse(match_property(property_c, {"key": "value3"}))
        self.assertFalse(match_property(property_c, {"key": "value1"}))

        with self.assertRaises(InconclusiveMatchError):
            match_property(property_a, {"key2": "value"})
            match_property(property_c, {"key2": "value1"})  # overrides don't have 'key'

    def test_match_properties_is_set(self):
        property_a = self.property(key="key", value="is_set", operator="is_set")
        self.assertTrue(match_property(property_a, {"key": "value"}))
        self.assertTrue(match_property(property_a, {"key": "value2"}))
        self.assertTrue(match_property(property_a, {"key": ""}))
        self.assertFalse(match_property(property_a, {"key": None}))

        with self.assertRaises(InconclusiveMatchError):
            match_property(property_a, {"key2": "value"})
            match_property(property_a, {})

    def test_match_properties_icontains(self):
        property_a = self.property(key="key", value="valUe", operator="icontains")
        self.assertTrue(match_property(property_a, {"key": "value"}))
        self.assertTrue(match_property(property_a, {"key": "value2"}))
        self.assertTrue(match_property(property_a, {"key": "value3"}))
        self.assertTrue(match_property(property_a, {"key": "vaLue4"}))
        self.assertTrue(match_property(property_a, {"key": "343tfvalue5"}))

        self.assertFalse(match_property(property_a, {"key": "Alakazam"}))
        self.assertFalse(match_property(property_a, {"key": 123}))

        property_b = self.property(key="key", value="3", operator="icontains")
        self.assertTrue(match_property(property_b, {"key": "3"}))
        self.assertTrue(match_property(property_b, {"key": 323}))
        self.assertTrue(match_property(property_b, {"key": "val3"}))

        self.assertFalse(match_property(property_b, {"key": "three"}))

    def test_match_properties_regex(self):
        property_a = self.property(key="key", value=r"\.com$", operator="regex")
        self.assertTrue(match_property(property_a, {"key": "value.com"}))
        self.assertTrue(match_property(property_a, {"key": "value2.com"}))
        self.assertFalse(match_property(property_a, {"key": "value2com"}))

        self.assertFalse(match_property(property_a, {"key": ".com343tfvalue5"}))
        self.assertFalse(match_property(property_a, {"key": "Alakazam"}))
        self.assertFalse(match_property(property_a, {"key": 123}))
        self.assertFalse(match_property(property_a, {"key": "valuecom"}))
        self.assertFalse(match_property(property_a, {"key": r"value\com"}))

        property_b = self.property(key="key", value="3", operator="regex")
        self.assertTrue(match_property(property_b, {"key": "3"}))
        self.assertTrue(match_property(property_b, {"key": 323}))
        self.assertTrue(match_property(property_b, {"key": "val3"}))

        self.assertFalse(match_property(property_b, {"key": "three"}))

        # invalid regex
        property_c = self.property(key="key", value="?*", operator="regex")
        self.assertFalse(match_property(property_c, {"key": "value"}))
        self.assertFalse(match_property(property_c, {"key": "value2"}))

        # non string value
        property_d = self.property(key="key", value=4, operator="regex")
        self.assertTrue(match_property(property_d, {"key": "4"}))
        self.assertTrue(match_property(property_d, {"key": 4}))

        self.assertFalse(match_property(property_d, {"key": "value"}))

    def test_match_properties_math_operators(self):
        property_a = self.property(key="key", value=1, operator="gt")
        self.assertTrue(match_property(property_a, {"key": 2}))
        self.assertTrue(match_property(property_a, {"key": 3}))

        self.assertFalse(match_property(property_a, {"key": 0}))
        self.assertFalse(match_property(property_a, {"key": -1}))
        # now we handle type mismatches so this should be true
        self.assertTrue(match_property(property_a, {"key": "23"}))

        property_b = self.property(key="key", value=1, operator="lt")
        self.assertTrue(match_property(property_b, {"key": 0}))
        self.assertTrue(match_property(property_b, {"key": -1}))
        self.assertTrue(match_property(property_b, {"key": -3}))

        self.assertFalse(match_property(property_b, {"key": 1}))
        self.assertFalse(match_property(property_b, {"key": "1"}))
        self.assertFalse(match_property(property_b, {"key": "3"}))

        property_c = self.property(key="key", value=1, operator="gte")
        self.assertTrue(match_property(property_c, {"key": 1}))
        self.assertTrue(match_property(property_c, {"key": 2}))

        self.assertFalse(match_property(property_c, {"key": 0}))
        self.assertFalse(match_property(property_c, {"key": -1}))
        # now we handle type mismatches so this should be true
        self.assertTrue(match_property(property_c, {"key": "3"}))

        property_d = self.property(key="key", value="43", operator="lte")
        self.assertTrue(match_property(property_d, {"key": "41"}))
        self.assertTrue(match_property(property_d, {"key": "42"}))
        self.assertTrue(match_property(property_d, {"key": "43"}))

        self.assertFalse(match_property(property_d, {"key": "44"}))
        self.assertFalse(match_property(property_d, {"key": 44}))
        self.assertTrue(match_property(property_d, {"key": 42}))

        property_e = self.property(key="key", value="30", operator="lt")
        self.assertTrue(match_property(property_e, {"key": "29"}))

        # depending on the type of override, we adjust type comparison
        self.assertTrue(match_property(property_e, {"key": "100"}))
        self.assertFalse(match_property(property_e, {"key": 100}))

        property_f = self.property(key="key", value="123aloha", operator="gt")
        self.assertFalse(match_property(property_f, {"key": "123"}))
        self.assertFalse(match_property(property_f, {"key": 122}))

        # this turns into a string comparison
        self.assertTrue(match_property(property_f, {"key": 129}))

    def test_match_property_date_operators(self):
        property_a = self.property(
            key="key", value="2022-05-01", operator="is_date_before"
        )
        self.assertTrue(match_property(property_a, {"key": "2022-03-01"}))
        self.assertTrue(match_property(property_a, {"key": "2022-04-30"}))
        self.assertTrue(match_property(property_a, {"key": datetime.date(2022, 4, 30)}))
        self.assertTrue(
            match_property(property_a, {"key": datetime.datetime(2022, 4, 30, 1, 2, 3)})
        )
        self.assertTrue(
            match_property(
                property_a,
                {
                    "key": datetime.datetime(
                        2022, 4, 30, 1, 2, 3, tzinfo=tz.gettz("Europe/Madrid")
                    )
                },
            )
        )
        self.assertTrue(match_property(property_a, {"key": parser.parse("2022-04-30")}))
        self.assertFalse(match_property(property_a, {"key": "2022-05-30"}))

        # Can't be a number
        with self.assertRaises(InconclusiveMatchError):
            match_property(property_a, {"key": 1})

        # can't be invalid string
        with self.assertRaises(InconclusiveMatchError):
            match_property(property_a, {"key": "abcdef"})

        property_b = self.property(
            key="key", value="2022-05-01", operator="is_date_after"
        )
        self.assertTrue(match_property(property_b, {"key": "2022-05-02"}))
        self.assertTrue(match_property(property_b, {"key": "2022-05-30"}))
        self.assertTrue(
            match_property(property_b, {"key": datetime.datetime(2022, 5, 30)})
        )
        self.assertTrue(match_property(property_b, {"key": parser.parse("2022-05-30")}))
        self.assertFalse(match_property(property_b, {"key": "2022-04-30"}))

        # can't be invalid string
        with self.assertRaises(InconclusiveMatchError):
            match_property(property_b, {"key": "abcdef"})

        # Invalid flag property
        property_c = self.property(key="key", value=1234, operator="is_date_before")

        with self.assertRaises(InconclusiveMatchError):
            match_property(property_c, {"key": 1})

        # Timezone aware property
        property_d = self.property(
            key="key", value="2022-04-05 12:34:12 +01:00", operator="is_date_before"
        )
        self.assertFalse(match_property(property_d, {"key": "2022-05-30"}))

        self.assertTrue(match_property(property_d, {"key": "2022-03-30"}))
        self.assertTrue(
            match_property(property_d, {"key": "2022-04-05 12:34:11 +01:00"})
        )
        self.assertTrue(
            match_property(property_d, {"key": "2022-04-05 12:34:11 +01:00"})
        )

        self.assertFalse(
            match_property(property_d, {"key": "2022-04-05 12:34:13 +01:00"})
        )

        self.assertTrue(
            match_property(property_d, {"key": "2022-04-05 11:34:11 +00:00"})
        )
        self.assertFalse(
            match_property(property_d, {"key": "2022-04-05 11:34:13 +00:00"})
        )

    @freeze_time("2022-05-01")
    def test_match_property_relative_date_operators(self):
        property_a = self.property(key="key", value="-6h", operator="is_date_before")
        self.assertTrue(match_property(property_a, {"key": "2022-03-01"}))
        self.assertTrue(match_property(property_a, {"key": "2022-04-30"}))
        self.assertTrue(
            match_property(property_a, {"key": datetime.datetime(2022, 4, 30, 1, 2, 3)})
        )
        # false because date comparison, instead of datetime, so reduces to same date
        self.assertFalse(
            match_property(property_a, {"key": datetime.date(2022, 4, 30)})
        )

        self.assertFalse(
            match_property(
                property_a, {"key": datetime.datetime(2022, 4, 30, 19, 2, 3)}
            )
        )
        self.assertTrue(
            match_property(
                property_a,
                {
                    "key": datetime.datetime(
                        2022, 4, 30, 1, 2, 3, tzinfo=tz.gettz("Europe/Madrid")
                    )
                },
            )
        )
        self.assertTrue(match_property(property_a, {"key": parser.parse("2022-04-30")}))
        self.assertFalse(match_property(property_a, {"key": "2022-05-30"}))

        # Can't be a number
        with self.assertRaises(InconclusiveMatchError):
            match_property(property_a, {"key": 1})

        # can't be invalid string
        with self.assertRaises(InconclusiveMatchError):
            match_property(property_a, {"key": "abcdef"})

        property_b = self.property(key="key", value="1h", operator="is_date_after")
        self.assertTrue(match_property(property_b, {"key": "2022-05-02"}))
        self.assertTrue(match_property(property_b, {"key": "2022-05-30"}))
        self.assertTrue(
            match_property(property_b, {"key": datetime.datetime(2022, 5, 30)})
        )
        self.assertTrue(match_property(property_b, {"key": parser.parse("2022-05-30")}))
        self.assertFalse(match_property(property_b, {"key": "2022-04-30"}))

        # can't be invalid string
        with self.assertRaises(InconclusiveMatchError):
            self.assertFalse(match_property(property_b, {"key": "abcdef"}))

        # Invalid flag property
        property_c = self.property(key="key", value=1234, operator="is_date_after")

        with self.assertRaises(InconclusiveMatchError):
            self.assertFalse(match_property(property_c, {"key": 1}))

        # parsed as 1234-05-01 for some reason?
        self.assertTrue(match_property(property_c, {"key": "2022-05-30"}))

        # # Timezone aware property
        property_d = self.property(key="key", value="12d", operator="is_date_before")
        self.assertFalse(match_property(property_d, {"key": "2022-05-30"}))

        self.assertTrue(match_property(property_d, {"key": "2022-03-30"}))
        self.assertTrue(
            match_property(property_d, {"key": "2022-04-05 12:34:11+01:00"})
        )
        self.assertTrue(
            match_property(property_d, {"key": "2022-04-19 01:34:11+02:00"})
        )

        self.assertFalse(
            match_property(property_d, {"key": "2022-04-19 02:00:01+02:00"})
        )

        # Try all possible relative dates
        property_e = self.property(key="key", value="1h", operator="is_date_before")
        self.assertFalse(match_property(property_e, {"key": "2022-05-01 00:00:00"}))
        self.assertTrue(match_property(property_e, {"key": "2022-04-30 22:00:00"}))

        property_f = self.property(key="key", value="-1d", operator="is_date_before")
        self.assertTrue(match_property(property_f, {"key": "2022-04-29 23:59:00"}))
        self.assertFalse(match_property(property_f, {"key": "2022-04-30 00:00:01"}))

        property_g = self.property(key="key", value="1w", operator="is_date_before")
        self.assertTrue(match_property(property_g, {"key": "2022-04-23 00:00:00"}))
        self.assertFalse(match_property(property_g, {"key": "2022-04-24 00:00:00"}))
        self.assertFalse(match_property(property_g, {"key": "2022-04-24 00:00:01"}))

        property_h = self.property(key="key", value="1m", operator="is_date_before")
        self.assertTrue(match_property(property_h, {"key": "2022-03-01 00:00:00"}))
        self.assertFalse(match_property(property_h, {"key": "2022-04-05 00:00:00"}))

        property_i = self.property(key="key", value="1y", operator="is_date_before")
        self.assertTrue(match_property(property_i, {"key": "2021-04-28 00:00:00"}))
        self.assertFalse(match_property(property_i, {"key": "2021-05-01 00:00:01"}))

        property_j = self.property(key="key", value="122h", operator="is_date_after")
        self.assertTrue(match_property(property_j, {"key": "2022-05-01 00:00:00"}))
        self.assertFalse(match_property(property_j, {"key": "2022-04-23 01:00:00"}))

        property_k = self.property(key="key", value="2d", operator="is_date_after")
        self.assertTrue(match_property(property_k, {"key": "2022-05-01 00:00:00"}))
        self.assertTrue(match_property(property_k, {"key": "2022-04-29 00:00:01"}))
        self.assertFalse(match_property(property_k, {"key": "2022-04-29 00:00:00"}))

        property_l = self.property(key="key", value="-02w", operator="is_date_after")
        self.assertTrue(match_property(property_l, {"key": "2022-05-01 00:00:00"}))
        self.assertFalse(match_property(property_l, {"key": "2022-04-16 00:00:00"}))

        property_m = self.property(key="key", value="1m", operator="is_date_after")
        self.assertTrue(match_property(property_m, {"key": "2022-04-01 00:00:01"}))
        self.assertFalse(match_property(property_m, {"key": "2022-04-01 00:00:00"}))

        property_n = self.property(key="key", value="1y", operator="is_date_after")
        self.assertTrue(match_property(property_n, {"key": "2022-05-01 00:00:00"}))
        self.assertTrue(match_property(property_n, {"key": "2021-05-01 00:00:01"}))
        self.assertFalse(match_property(property_n, {"key": "2021-05-01 00:00:00"}))
        self.assertFalse(match_property(property_n, {"key": "2021-04-30 00:00:00"}))
        self.assertFalse(match_property(property_n, {"key": "2021-03-01 12:13:00"}))

    def test_none_property_value_with_all_operators(self):
        property_a = self.property(key="key", value="none", operator="is_not")
        self.assertFalse(match_property(property_a, {"key": None}))
        self.assertTrue(match_property(property_a, {"key": "non"}))

        property_b = self.property(key="key", value=None, operator="is_set")
        self.assertFalse(match_property(property_b, {"key": None}))

        property_c = self.property(key="key", value="no", operator="icontains")
        self.assertFalse(match_property(property_c, {"key": None}))
        self.assertFalse(match_property(property_c, {"key": "smh"}))

        property_d = self.property(key="key", value="No", operator="regex")
        self.assertFalse(match_property(property_d, {"key": None}))

        property_d_lower_case = self.property(key="key", value="no", operator="regex")
        self.assertFalse(match_property(property_d_lower_case, {"key": None}))

        property_e = self.property(key="key", value=1, operator="gt")
        self.assertFalse(match_property(property_e, {"key": None}))

        property_f = self.property(key="key", value=1, operator="lt")
        self.assertFalse(match_property(property_f, {"key": None}))

        property_g = self.property(key="key", value="xyz", operator="gte")
        self.assertFalse(match_property(property_g, {"key": None}))

        property_h = self.property(key="key", value="Oo", operator="lte")
        self.assertFalse(match_property(property_h, {"key": None}))

        property_i = self.property(
            key="key", value="2022-05-01", operator="is_date_before"
        )
        self.assertFalse(match_property(property_i, {"key": None}))

        property_j = self.property(
            key="key", value="2022-05-01", operator="is_date_after"
        )
        self.assertFalse(match_property(property_j, {"key": None}))

        property_k = self.property(
            key="key", value="2022-05-01", operator="is_date_before"
        )
        with self.assertRaises(InconclusiveMatchError):
            self.assertFalse(match_property(property_k, {"key": "random"}))

    def test_match_properties_semver_eq(self):
        prop = self.property(key="version", value="1.2.3", operator="semver_eq")
        self.assertTrue(match_property(prop, {"version": "1.2.3"}))
        self.assertFalse(match_property(prop, {"version": "1.2.4"}))
        self.assertFalse(match_property(prop, {"version": "1.2.2"}))
        self.assertFalse(match_property(prop, {"version": "2.0.0"}))

        # Pre-release suffix is stripped for comparison
        self.assertTrue(match_property(prop, {"version": "1.2.3-alpha.1"}))

        # Partial versions default missing parts to 0
        prop_partial = self.property(key="version", value="1.2", operator="semver_eq")
        self.assertTrue(match_property(prop_partial, {"version": "1.2.0"}))
        self.assertFalse(match_property(prop_partial, {"version": "1.2.1"}))

    def test_match_properties_semver_neq(self):
        prop = self.property(key="version", value="1.2.3", operator="semver_neq")
        self.assertFalse(match_property(prop, {"version": "1.2.3"}))
        self.assertTrue(match_property(prop, {"version": "1.2.4"}))
        self.assertTrue(match_property(prop, {"version": "2.0.0"}))

    def test_match_properties_semver_gt(self):
        prop = self.property(key="version", value="1.2.3", operator="semver_gt")
        self.assertTrue(match_property(prop, {"version": "1.2.4"}))
        self.assertTrue(match_property(prop, {"version": "1.3.0"}))
        self.assertTrue(match_property(prop, {"version": "2.0.0"}))
        self.assertFalse(match_property(prop, {"version": "1.2.3"}))
        self.assertFalse(match_property(prop, {"version": "1.2.2"}))
        self.assertFalse(match_property(prop, {"version": "0.9.0"}))

    def test_match_properties_semver_gte(self):
        prop = self.property(key="version", value="1.2.3", operator="semver_gte")
        self.assertTrue(match_property(prop, {"version": "1.2.3"}))
        self.assertTrue(match_property(prop, {"version": "1.2.4"}))
        self.assertTrue(match_property(prop, {"version": "2.0.0"}))
        self.assertFalse(match_property(prop, {"version": "1.2.2"}))
        self.assertFalse(match_property(prop, {"version": "0.9.0"}))

    def test_match_properties_semver_lt(self):
        prop = self.property(key="version", value="1.2.3", operator="semver_lt")
        self.assertTrue(match_property(prop, {"version": "1.2.2"}))
        self.assertTrue(match_property(prop, {"version": "1.1.0"}))
        self.assertTrue(match_property(prop, {"version": "0.9.0"}))
        self.assertFalse(match_property(prop, {"version": "1.2.3"}))
        self.assertFalse(match_property(prop, {"version": "1.2.4"}))
        self.assertFalse(match_property(prop, {"version": "2.0.0"}))

    def test_match_properties_semver_lte(self):
        prop = self.property(key="version", value="1.2.3", operator="semver_lte")
        self.assertTrue(match_property(prop, {"version": "1.2.3"}))
        self.assertTrue(match_property(prop, {"version": "1.2.2"}))
        self.assertTrue(match_property(prop, {"version": "0.9.0"}))
        self.assertFalse(match_property(prop, {"version": "1.2.4"}))
        self.assertFalse(match_property(prop, {"version": "2.0.0"}))

    def test_match_properties_semver_tilde(self):
        # ~1.2.3 means >=1.2.3 <1.3.0
        prop = self.property(key="version", value="1.2.3", operator="semver_tilde")
        self.assertTrue(match_property(prop, {"version": "1.2.3"}))
        self.assertTrue(match_property(prop, {"version": "1.2.5"}))
        self.assertTrue(match_property(prop, {"version": "1.2.99"}))
        self.assertFalse(match_property(prop, {"version": "1.3.0"}))
        self.assertFalse(match_property(prop, {"version": "1.2.2"}))
        self.assertFalse(match_property(prop, {"version": "2.0.0"}))

    def test_match_properties_semver_caret(self):
        # ^1.2.3 means >=1.2.3 <2.0.0
        prop = self.property(key="version", value="1.2.3", operator="semver_caret")
        self.assertTrue(match_property(prop, {"version": "1.2.3"}))
        self.assertTrue(match_property(prop, {"version": "1.9.0"}))
        self.assertTrue(match_property(prop, {"version": "1.99.99"}))
        self.assertFalse(match_property(prop, {"version": "2.0.0"}))
        self.assertFalse(match_property(prop, {"version": "1.2.2"}))
        self.assertFalse(match_property(prop, {"version": "0.9.0"}))

        # ^0.2.3 means >=0.2.3 <0.3.0 (leftmost non-zero is minor)
        prop_zero_major = self.property(
            key="version", value="0.2.3", operator="semver_caret"
        )
        self.assertTrue(match_property(prop_zero_major, {"version": "0.2.3"}))
        self.assertTrue(match_property(prop_zero_major, {"version": "0.2.9"}))
        self.assertFalse(match_property(prop_zero_major, {"version": "0.3.0"}))
        self.assertFalse(match_property(prop_zero_major, {"version": "1.0.0"}))

        # ^0.0.3 means >=0.0.3 <0.0.4 (leftmost non-zero is patch)
        prop_zero_minor = self.property(
            key="version", value="0.0.3", operator="semver_caret"
        )
        self.assertTrue(match_property(prop_zero_minor, {"version": "0.0.3"}))
        self.assertFalse(match_property(prop_zero_minor, {"version": "0.0.4"}))
        self.assertFalse(match_property(prop_zero_minor, {"version": "0.1.0"}))

    def test_match_properties_semver_wildcard(self):
        # 1.2.* means >=1.2.0 <1.3.0
        prop = self.property(key="version", value="1.2.*", operator="semver_wildcard")
        self.assertTrue(match_property(prop, {"version": "1.2.0"}))
        self.assertTrue(match_property(prop, {"version": "1.2.5"}))
        self.assertTrue(match_property(prop, {"version": "1.2.99"}))
        self.assertFalse(match_property(prop, {"version": "1.3.0"}))
        self.assertFalse(match_property(prop, {"version": "1.1.9"}))
        self.assertFalse(match_property(prop, {"version": "2.0.0"}))

        # 1.* means >=1.0.0 <2.0.0
        prop_major = self.property(
            key="version", value="1.*", operator="semver_wildcard"
        )
        self.assertTrue(match_property(prop_major, {"version": "1.0.0"}))
        self.assertTrue(match_property(prop_major, {"version": "1.99.99"}))
        self.assertFalse(match_property(prop_major, {"version": "2.0.0"}))
        self.assertFalse(match_property(prop_major, {"version": "0.9.0"}))

    def test_match_properties_semver_with_prerelease(self):
        # Pre-release suffixes are stripped before comparison
        prop = self.property(key="version", value="1.2.3", operator="semver_gt")
        self.assertTrue(match_property(prop, {"version": "1.3.0-beta.1"}))
        self.assertFalse(match_property(prop, {"version": "1.2.2-rc.1"}))

        # Flag value can also have pre-release suffix
        prop_pre = self.property(
            key="version", value="1.2.3-alpha", operator="semver_gte"
        )
        self.assertTrue(match_property(prop_pre, {"version": "1.2.3"}))
        self.assertTrue(match_property(prop_pre, {"version": "2.0.0"}))
        self.assertFalse(match_property(prop_pre, {"version": "1.2.2"}))

    def test_match_properties_semver_edge_cases(self):
        """Test semver parsing handles v-prefix, whitespace, leading zeros, and other common formats."""
        prop = self.property(key="version", value="1.2.3", operator="semver_eq")

        # v-prefix: "v1.2.3" -> extracts "1.2.3"
        self.assertTrue(match_property(prop, {"version": "v1.2.3"}))

        # Leading space: " 1.2.3" -> extracts "1.2.3"
        self.assertTrue(match_property(prop, {"version": " 1.2.3"}))

        # Trailing space: "1.2.3 " -> extracts "1.2.3"
        self.assertTrue(match_property(prop, {"version": "1.2.3 "}))

        # Leading zeros: "01.02.03" -> int("01")=1, int("02")=2, int("03")=3
        self.assertTrue(match_property(prop, {"version": "01.02.03"}))

        # Flag value with v-prefix
        prop_v = self.property(key="version", value="v1.2.3", operator="semver_eq")
        self.assertTrue(match_property(prop_v, {"version": "1.2.3"}))

        # 0.0.0 minimal version
        prop_min = self.property(key="version", value="0.0.0", operator="semver_eq")
        self.assertTrue(match_property(prop_min, {"version": "0.0.0"}))

        prop_gt_min = self.property(key="version", value="0.0.0", operator="semver_gt")
        self.assertTrue(match_property(prop_gt_min, {"version": "0.0.1"}))
        self.assertFalse(match_property(prop_gt_min, {"version": "0.0.0"}))

        # 4-part version: regex extracts "1.2.3.4" -> takes first 3 parts
        prop_four = self.property(key="version", value="1.2.3", operator="semver_eq")
        self.assertTrue(match_property(prop_four, {"version": "1.2.3.4"}))

        # Truly invalid values raise InconclusiveMatchError
        with self.assertRaises(InconclusiveMatchError):
            match_property(prop, {"version": "abc"})

        with self.assertRaises(InconclusiveMatchError):
            match_property(prop, {"version": ""})

        # Leading dot: ".1.2.3" -> invalid, empty first component
        with self.assertRaises(InconclusiveMatchError):
            match_property(prop, {"version": ".1.2.3"})

        # Caret with v-prefix in flag value
        prop_caret_v = self.property(
            key="version", value="v1.2.3", operator="semver_caret"
        )
        self.assertTrue(match_property(prop_caret_v, {"version": "1.5.0"}))
        self.assertFalse(match_property(prop_caret_v, {"version": "2.0.0"}))

        # Wildcard with v-prefix in property value
        prop_wild = self.property(
            key="version", value="1.2.*", operator="semver_wildcard"
        )
        self.assertTrue(match_property(prop_wild, {"version": "v1.2.5"}))
        self.assertFalse(match_property(prop_wild, {"version": "v1.3.0"}))

    def test_match_properties_semver_invalid_values(self):
        prop = self.property(key="version", value="1.2.3", operator="semver_eq")

        # Invalid person property value
        with self.assertRaises(InconclusiveMatchError):
            match_property(prop, {"version": "not-a-version"})

        # Missing key
        with self.assertRaises(InconclusiveMatchError):
            match_property(prop, {"other_key": "1.2.3"})

        # None override value returns False (handled before semver logic)
        self.assertFalse(match_property(prop, {"version": None}))

        # Invalid flag value
        prop_bad = self.property(key="version", value="not-valid", operator="semver_gt")
        with self.assertRaises(InconclusiveMatchError):
            match_property(prop_bad, {"version": "1.2.3"})

    def test_unknown_operator(self):
        property_a = self.property(key="key", value="2022-05-01", operator="is_unknown")
        with self.assertRaises(InconclusiveMatchError) as exception_context:
            match_property(property_a, {"key": "random"})
        self.assertEqual(
            str(exception_context.exception), "Unknown operator is_unknown"
        )


class TestRelativeDateParsing(unittest.TestCase):
    def test_invalid_input(self):
        with freeze_time("2020-01-01T12:01:20.1340Z"):
            assert relative_date_parse_for_feature_flag_matching("1") is None
            assert relative_date_parse_for_feature_flag_matching("1x") is None
            assert relative_date_parse_for_feature_flag_matching("1.2y") is None
            assert relative_date_parse_for_feature_flag_matching("1z") is None
            assert relative_date_parse_for_feature_flag_matching("1s") is None
            assert (
                relative_date_parse_for_feature_flag_matching("123344000.134m") is None
            )
            assert relative_date_parse_for_feature_flag_matching("bazinga") is None
            assert relative_date_parse_for_feature_flag_matching("000bello") is None
            assert relative_date_parse_for_feature_flag_matching("000hello") is None

            assert relative_date_parse_for_feature_flag_matching("000h") is not None
            assert relative_date_parse_for_feature_flag_matching("1000h") is not None

    def test_overflow(self):
        assert relative_date_parse_for_feature_flag_matching("1000000h") is None
        assert (
            relative_date_parse_for_feature_flag_matching("100000000000000000y") is None
        )

    def test_hour_parsing(self):
        with freeze_time("2020-01-01T12:01:20.1340Z"):
            assert relative_date_parse_for_feature_flag_matching(
                "1h"
            ) == datetime.datetime(
                2020, 1, 1, 11, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )
            assert relative_date_parse_for_feature_flag_matching(
                "2h"
            ) == datetime.datetime(
                2020, 1, 1, 10, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )
            assert relative_date_parse_for_feature_flag_matching(
                "24h"
            ) == datetime.datetime(
                2019, 12, 31, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )
            assert relative_date_parse_for_feature_flag_matching(
                "30h"
            ) == datetime.datetime(
                2019, 12, 31, 6, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )
            assert relative_date_parse_for_feature_flag_matching(
                "48h"
            ) == datetime.datetime(
                2019, 12, 30, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )

            assert relative_date_parse_for_feature_flag_matching(
                "24h"
            ) == relative_date_parse_for_feature_flag_matching("1d")
            assert relative_date_parse_for_feature_flag_matching(
                "48h"
            ) == relative_date_parse_for_feature_flag_matching("2d")

    def test_day_parsing(self):
        with freeze_time("2020-01-01T12:01:20.1340Z"):
            assert relative_date_parse_for_feature_flag_matching(
                "1d"
            ) == datetime.datetime(
                2019, 12, 31, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )
            assert relative_date_parse_for_feature_flag_matching(
                "2d"
            ) == datetime.datetime(
                2019, 12, 30, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )
            assert relative_date_parse_for_feature_flag_matching(
                "7d"
            ) == datetime.datetime(
                2019, 12, 25, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )
            assert relative_date_parse_for_feature_flag_matching(
                "14d"
            ) == datetime.datetime(
                2019, 12, 18, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )
            assert relative_date_parse_for_feature_flag_matching(
                "30d"
            ) == datetime.datetime(
                2019, 12, 2, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )

            assert relative_date_parse_for_feature_flag_matching(
                "7d"
            ) == relative_date_parse_for_feature_flag_matching("1w")

    def test_week_parsing(self):
        with freeze_time("2020-01-01T12:01:20.1340Z"):
            assert relative_date_parse_for_feature_flag_matching(
                "1w"
            ) == datetime.datetime(
                2019, 12, 25, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )
            assert relative_date_parse_for_feature_flag_matching(
                "2w"
            ) == datetime.datetime(
                2019, 12, 18, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )
            assert relative_date_parse_for_feature_flag_matching(
                "4w"
            ) == datetime.datetime(
                2019, 12, 4, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )
            assert relative_date_parse_for_feature_flag_matching(
                "8w"
            ) == datetime.datetime(
                2019, 11, 6, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )

            assert relative_date_parse_for_feature_flag_matching(
                "1m"
            ) == datetime.datetime(
                2019, 12, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )
            assert relative_date_parse_for_feature_flag_matching(
                "4w"
            ) != relative_date_parse_for_feature_flag_matching("1m")

    def test_month_parsing(self):
        with freeze_time("2020-01-01T12:01:20.1340Z"):
            assert relative_date_parse_for_feature_flag_matching(
                "1m"
            ) == datetime.datetime(
                2019, 12, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )
            assert relative_date_parse_for_feature_flag_matching(
                "2m"
            ) == datetime.datetime(
                2019, 11, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )
            assert relative_date_parse_for_feature_flag_matching(
                "4m"
            ) == datetime.datetime(
                2019, 9, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )
            assert relative_date_parse_for_feature_flag_matching(
                "8m"
            ) == datetime.datetime(
                2019, 5, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )

            assert relative_date_parse_for_feature_flag_matching(
                "1y"
            ) == datetime.datetime(
                2019, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )
            assert relative_date_parse_for_feature_flag_matching(
                "12m"
            ) == relative_date_parse_for_feature_flag_matching("1y")

        with freeze_time("2020-04-03T00:00:00"):
            assert relative_date_parse_for_feature_flag_matching(
                "1m"
            ) == datetime.datetime(2020, 3, 3, 0, 0, 0, tzinfo=tz.gettz("UTC"))
            assert relative_date_parse_for_feature_flag_matching(
                "2m"
            ) == datetime.datetime(2020, 2, 3, 0, 0, 0, tzinfo=tz.gettz("UTC"))
            assert relative_date_parse_for_feature_flag_matching(
                "4m"
            ) == datetime.datetime(2019, 12, 3, 0, 0, 0, tzinfo=tz.gettz("UTC"))
            assert relative_date_parse_for_feature_flag_matching(
                "8m"
            ) == datetime.datetime(2019, 8, 3, 0, 0, 0, tzinfo=tz.gettz("UTC"))

            assert relative_date_parse_for_feature_flag_matching(
                "1y"
            ) == datetime.datetime(2019, 4, 3, 0, 0, 0, tzinfo=tz.gettz("UTC"))
            assert relative_date_parse_for_feature_flag_matching(
                "12m"
            ) == relative_date_parse_for_feature_flag_matching("1y")

    def test_year_parsing(self):
        with freeze_time("2020-01-01T12:01:20.1340Z"):
            assert relative_date_parse_for_feature_flag_matching(
                "1y"
            ) == datetime.datetime(
                2019, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )
            assert relative_date_parse_for_feature_flag_matching(
                "2y"
            ) == datetime.datetime(
                2018, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )
            assert relative_date_parse_for_feature_flag_matching(
                "4y"
            ) == datetime.datetime(
                2016, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )
            assert relative_date_parse_for_feature_flag_matching(
                "8y"
            ) == datetime.datetime(
                2012, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
            )


class TestCaptureCalls(unittest.TestCase):
    @mock.patch.object(Client, "capture")
    @mock.patch("posthog.client.flags")
    def test_capture_is_called(self, patch_flags, patch_capture):
        patch_flags.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "complex-flag",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [{"key": "region", "value": "USA"}],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            }
        ]

        self.assertTrue(
            client.get_feature_flag(
                "complex-flag",
                "some-distinct-id",
                person_properties={"region": "USA", "name": "Aloha"},
            )
        )
        self.assertEqual(patch_capture.call_count, 1)
        patch_capture.assert_called_with(
            "$feature_flag_called",
            distinct_id="some-distinct-id",
            properties={
                "$feature_flag": "complex-flag",
                "$feature_flag_response": True,
                "locally_evaluated": True,
                "$feature/complex-flag": True,
            },
            groups={},
            disable_geoip=None,
        )
        patch_capture.reset_mock()

        # called again for same user, shouldn't call capture again
        self.assertTrue(
            client.get_feature_flag(
                "complex-flag",
                "some-distinct-id",
                person_properties={"region": "USA", "name": "Aloha"},
            )
        )
        self.assertEqual(patch_capture.call_count, 0)
        patch_capture.reset_mock()

        # called for different user, should call capture again
        self.assertTrue(
            client.get_feature_flag(
                "complex-flag",
                "some-distinct-id2",
                person_properties={"region": "USA", "name": "Aloha"},
            )
        )
        self.assertEqual(patch_capture.call_count, 1)
        patch_capture.assert_called_with(
            "$feature_flag_called",
            distinct_id="some-distinct-id2",
            properties={
                "$feature_flag": "complex-flag",
                "$feature_flag_response": True,
                "locally_evaluated": True,
                "$feature/complex-flag": True,
            },
            groups={},
            disable_geoip=None,
        )
        patch_capture.reset_mock()

        # called for different user, but send configuration is false, so should NOT call capture again
        self.assertTrue(
            client.get_feature_flag(
                "complex-flag",
                "some-distinct-id345",
                person_properties={"region": "USA", "name": "Aloha"},
                send_feature_flag_events=False,
            )
        )
        self.assertEqual(patch_capture.call_count, 0)
        patch_capture.reset_mock()

        # called for different flag, falls back to decide, should call capture again
        self.assertEqual(
            client.get_feature_flag(
                "decide-flag",
                "some-distinct-id2",
                person_properties={"region": "USA", "name": "Aloha"},
                groups={"organization": "org1"},
            ),
            "decide-value",
        )
        self.assertEqual(patch_flags.call_count, 1)
        self.assertEqual(patch_capture.call_count, 1)
        patch_capture.assert_called_with(
            "$feature_flag_called",
            distinct_id="some-distinct-id2",
            properties={
                "$feature_flag": "decide-flag",
                "$feature_flag_response": "decide-value",
                "locally_evaluated": False,
                "$feature/decide-flag": "decide-value",
            },
            groups={"organization": "org1"},
            disable_geoip=None,
        )

    @mock.patch.object(Client, "capture")
    @mock.patch("posthog.client.flags")
    def test_capture_is_called_with_flag_details(self, patch_flags, patch_capture):
        patch_flags.return_value = {
            "flags": {
                "decide-flag": {
                    "key": "decide-flag",
                    "enabled": True,
                    "variant": "decide-variant",
                    "reason": {
                        "description": "Matched condition set 1",
                    },
                    "metadata": {
                        "id": 23,
                        "version": 42,
                    },
                },
                "false-flag": {
                    "key": "false-flag",
                    "enabled": False,
                    "variant": None,
                    "reason": {
                        "code": "no_matching_condition",
                        "description": "No matching condition",
                        "condition_index": None,
                    },
                    "metadata": {
                        "id": 1,
                        "version": 2,
                    },
                },
            },
            "requestId": "18043bf7-9cf6-44cd-b959-9662ee20d371",
            "evaluatedAt": 1234567890,
        }
        client = Client(FAKE_TEST_API_KEY)

        self.assertEqual(
            client.get_feature_flag("decide-flag", "some-distinct-id"), "decide-variant"
        )
        self.assertEqual(patch_capture.call_count, 1)
        patch_capture.assert_called_with(
            "$feature_flag_called",
            distinct_id="some-distinct-id",
            properties={
                "$feature_flag": "decide-flag",
                "$feature_flag_response": "decide-variant",
                "locally_evaluated": False,
                "$feature/decide-flag": "decide-variant",
                "$feature_flag_reason": "Matched condition set 1",
                "$feature_flag_id": 23,
                "$feature_flag_version": 42,
                "$feature_flag_request_id": "18043bf7-9cf6-44cd-b959-9662ee20d371",
                "$feature_flag_evaluated_at": 1234567890,
            },
            groups={},
            disable_geoip=None,
        )

    @mock.patch.object(Client, "capture")
    @mock.patch("posthog.client.flags")
    def test_capture_is_called_with_flag_details_and_payload(
        self, patch_flags, patch_capture
    ):
        patch_flags.return_value = {
            "flags": {
                "decide-flag-with-payload": {
                    "key": "decide-flag-with-payload",
                    "enabled": True,
                    "variant": None,
                    "reason": {
                        "code": "matched_condition",
                        "condition_index": 0,
                        "description": "Matched condition set 1",
                    },
                    "metadata": {
                        "id": 23,
                        "version": 42,
                        "payload": '{"foo": "bar"}',
                    },
                }
            },
            "requestId": "18043bf7-9cf6-44cd-b959-9662ee20d371",
        }
        client = Client(FAKE_TEST_API_KEY)

        self.assertEqual(
            client.get_feature_flag_payload(
                "decide-flag-with-payload",
                "some-distinct-id",
                send_feature_flag_events=True,
            ),
            {"foo": "bar"},
        )
        self.assertEqual(patch_capture.call_count, 1)
        patch_capture.assert_called_with(
            "$feature_flag_called",
            distinct_id="some-distinct-id",
            properties={
                "$feature_flag": "decide-flag-with-payload",
                "$feature_flag_response": True,
                "locally_evaluated": False,
                "$feature/decide-flag-with-payload": True,
                "$feature_flag_reason": "Matched condition set 1",
                "$feature_flag_id": 23,
                "$feature_flag_version": 42,
                "$feature_flag_request_id": "18043bf7-9cf6-44cd-b959-9662ee20d371",
                "$feature_flag_payload": {"foo": "bar"},
            },
            groups={},
            disable_geoip=None,
        )

    @mock.patch("posthog.client.flags")
    def test_capture_is_called_but_does_not_add_all_flags(self, patch_flags):
        patch_flags.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "complex-flag",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [{"key": "region", "value": "USA"}],
                            "rollout_percentage": 100,
                        },
                    ],
                },
            },
            {
                "id": 2,
                "name": "Gamma Feature",
                "key": "simple-flag",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        },
                    ],
                },
            },
        ]

        self.assertTrue(
            client.get_feature_flag(
                "complex-flag", "some-distinct-id", person_properties={"region": "USA"}
            )
        )

        # Grab the capture message that was just added to the queue
        msg = client.queue.get(block=False)
        assert msg["event"] == "$feature_flag_called"
        assert msg["properties"]["$feature_flag"] == "complex-flag"
        assert msg["properties"]["$feature_flag_response"] is True
        assert msg["properties"]["locally_evaluated"] is True
        assert msg["properties"]["$feature/complex-flag"] is True
        assert "$feature/simple-flag" not in msg["properties"]
        assert "$active_feature_flags" not in msg["properties"]

    @mock.patch.object(Client, "capture")
    @mock.patch("posthog.client.flags")
    def test_get_feature_flag_payload_does_not_send_feature_flag_called_events(
        self, patch_flags, patch_capture
    ):
        """Test that get_feature_flag_payload does NOT send $feature_flag_called events"""
        patch_flags.return_value = {
            "featureFlags": {"person-flag": True},
            "featureFlagPayloads": {"person-flag": 300},
        }
        client = Client(
            project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
        )

        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "person-flag",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [{"key": "region", "value": "USA"}],
                            "rollout_percentage": 100,
                        }
                    ],
                    "payloads": {"true": '"payload"'},
                },
            }
        ]

        payload = client.get_feature_flag_payload(
            key="person-flag",
            distinct_id="some-distinct-id",
            person_properties={"region": "USA", "name": "Aloha"},
        )
        self.assertIsNotNone(payload)
        self.assertEqual(patch_capture.call_count, 0)

    @mock.patch("posthog.client.flags")
    def test_fallback_to_api_in_get_feature_flag_payload_when_flag_has_static_cohort(
        self, patch_flags
    ):
        """
        Test that get_feature_flag_payload falls back to API when evaluating
        a flag with static cohorts, similar to get_feature_flag behavior.
        """
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)

        # Mock the local flags response - cohort 999 is NOT in cohorts map (static cohort)
        client.feature_flags = [
            {
                "id": 1,
                "name": "Multi-condition Flag",
                "key": "multi-condition-flag",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [
                                {"key": "id", "value": 999, "type": "cohort"}
                            ],
                            "rollout_percentage": 100,
                            "variant": "variant-1",
                        }
                    ],
                    "multivariate": {
                        "variants": [{"key": "variant-1", "rollout_percentage": 100}]
                    },
                    "payloads": {"variant-1": '{"message": "local-payload"}'},
                },
            }
        ]
        client.cohorts = {}  # Note: cohort 999 is NOT here - it's a static cohort

        # Mock the API response - user is in the static cohort
        patch_flags.return_value = {
            "featureFlags": {"multi-condition-flag": "variant-1"},
            "featureFlagPayloads": {"multi-condition-flag": '{"message": "from-api"}'},
        }

        # Call get_feature_flag_payload without match_value to trigger evaluation
        result = client.get_feature_flag_payload(
            "multi-condition-flag",
            "test-distinct-id",
        )

        # Should return the API payload, not local payload
        self.assertEqual(result, {"message": "from-api"})

        # Verify API was called (fallback occurred)
        self.assertEqual(patch_flags.call_count, 1)

    @mock.patch.object(Client, "capture")
    @mock.patch("posthog.client.flags")
    def test_disable_geoip_get_flag_capture_call(self, patch_flags, patch_capture):
        patch_flags.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
        client = Client(
            FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY, disable_geoip=True
        )
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "complex-flag",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [{"key": "region", "value": "USA"}],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            }
        ]

        client.get_feature_flag(
            "complex-flag",
            "some-distinct-id",
            person_properties={"region": "USA", "name": "Aloha"},
            disable_geoip=False,
        )

        patch_capture.assert_called_with(
            "$feature_flag_called",
            distinct_id="some-distinct-id",
            properties={
                "$feature_flag": "complex-flag",
                "$feature_flag_response": True,
                "locally_evaluated": True,
                "$feature/complex-flag": True,
            },
            groups={},
            disable_geoip=False,
        )

    @mock.patch.object(Client, "capture")
    @mock.patch("posthog.client.flags")
    def test_capture_multiple_users_doesnt_out_of_memory(
        self, patch_flags, patch_capture
    ):
        client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
        # Set on the instance to avoid relying on module-constant patching behavior
        # across Python/runtime implementations.
        client.distinct_ids_feature_flags_reported.max_size = 100
        client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "complex-flag",
                "active": True,
                "filters": {
                    "groups": [
                        {
                            "properties": [],
                            "rollout_percentage": 100,
                        }
                    ],
                },
            }
        ]

        for i in range(1000):
            distinct_id = f"some-distinct-id{i}"
            client.get_feature_flag(
                "complex-flag",
                distinct_id,
                person_properties={"region": "USA", "name": "Aloha"},
            )
            patch_capture.assert_called_with(
                "$feature_flag_called",
                distinct_id=distinct_id,
                properties={
                    "$feature_flag": "complex-flag",
                    "$feature_flag_response": True,
                    "locally_evaluated": True,
                    "$feature/complex-flag": True,
                },
                groups={},
                disable_geoip=None,
            )

            self.assertEqual(
                len(client.distinct_ids_feature_flags_reported), i % 100 + 1
            )


class TestConsistency(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        # This ensures no real HTTP POST requests are made
        cls.capture_patch = mock.patch.object(Client, "capture")
        cls.capture_patch.start()

    @classmethod
    def tearDownClass(cls):
        cls.capture_patch.stop()

    def set_fail(self, e, batch):
        """Mark the failure handler"""
        print("FAIL", e, batch)  # noqa: T201
        self.failed = True

    def setUp(self):
        self.failed = False
        self.client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)

    @mock.patch("posthog.client.get")
    def test_simple_flag_consistency(self, patch_get):
        self.client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "simple-flag",
                "active": True,
                "filters": {
                    "groups": [{"properties": [], "rollout_percentage": 45}],
                },
            }
        ]

        results = [
            False,
            True,
            True,
            False,
            True,
            False,
            False,
            True,
            False,
            True,
            False,
            True,
            True,
            False,
            True,
            False,
            False,
            False,
            True,
            True,
            False,
            True,
            False,
            False,
            True,
            False,
            True,
            True,
            False,
            False,
            False,
            True,
            True,
            True,
            True,
            False,
            False,
            False,
            False,
            False,
            False,
            True,
            True,
            False,
            True,
            True,
            False,
            False,
            False,
            True,
            True,
            False,
            False,
            False,
            False,
            True,
            False,
            True,
            False,
            True,
            False,
            True,
            True,
            False,
            True,
            False,
            True,
            False,
            True,
            True,
            False,
            False,
            True,
            False,
            False,
            True,
            False,
            True,
            False,
            False,
            True,
            False,
            False,
            False,
            True,
            True,
            False,
            True,
            True,
            False,
            True,
            True,
            True,
            True,
            True,
            False,
            True,
            True,
            False,
            False,
            True,
            True,
            True,
            True,
            False,
            False,
            True,
            False,
            True,
            True,
            True,
            False,
            False,
            False,
            False,
            False,
            True,
            False,
            False,
            True,
            True,
            True,
            False,
            False,
            True,
            False,
            True,
            False,
            False,
            True,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            True,
            True,
            False,
            False,
            True,
            False,
            False,
            True,
            True,
            False,
            False,
            True,
            False,
            True,
            False,
            True,
            True,
            True,
            False,
            False,
            False,
            True,
            False,
            False,
            False,
            False,
            True,
            True,
            False,
            True,
            True,
            False,
            True,
            False,
            True,
            True,
            False,
            True,
            False,
            True,
            True,
            True,
            False,
            True,
            False,
            False,
            True,
            True,
            False,
            True,
            False,
            True,
            True,
            False,
            False,
            True,
            True,
            True,
            True,
            False,
            True,
            True,
            False,
            False,
            True,
            False,
            True,
            False,
            False,
            True,
            True,
            False,
            True,
            False,
            True,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            True,
            False,
            True,
            True,
            False,
            False,
            True,
            False,
            True,
            False,
            False,
            False,
            True,
            False,
            True,
            False,
            False,
            False,
            True,
            False,
            False,
            True,
            False,
            True,
            True,
            False,
            False,
            False,
            False,
            True,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            True,
            True,
            False,
            True,
            False,
            True,
            True,
            False,
            True,
            False,
            True,
            False,
            False,
            False,
            True,
            True,
            True,
            True,
            False,
            False,
            False,
            False,
            False,
            True,
            True,
            True,
            False,
            False,
            True,
            True,
            False,
            False,
            False,
            False,
            False,
            True,
            False,
            True,
            True,
            True,
            True,
            False,
            True,
            True,
            True,
            False,
            False,
            True,
            False,
            True,
            False,
            False,
            True,
            True,
            True,
            False,
            True,
            False,
            False,
            False,
            True,
            True,
            False,
            True,
            False,
            True,
            False,
            True,
            True,
            True,
            True,
            True,
            False,
            False,
            True,
            False,
            True,
            False,
            True,
            True,
            True,
            False,
            True,
            False,
            True,
            True,
            False,
            True,
            True,
            True,
            True,
            True,
            False,
            False,
            False,
            False,
            False,
            True,
            False,
            True,
            False,
            False,
            True,
            True,
            False,
            False,
            False,
            True,
            False,
            True,
            True,
            True,
            True,
            False,
            False,
            False,
            False,
            True,
            True,
            False,
            False,
            True,
            True,
            False,
            True,
            True,
            True,
            True,
            False,
            True,
            True,
            True,
            False,
            False,
            True,
            True,
            False,
            False,
            True,
            False,
            False,
            True,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            True,
            True,
            False,
            False,
            True,
            False,
            False,
            True,
            False,
            True,
            False,
            False,
            True,
            False,
            False,
            False,
            False,
            False,
            False,
            True,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            True,
            True,
            True,
            False,
            False,
            False,
            True,
            False,
            True,
            False,
            False,
            False,
            True,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            True,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            True,
            False,
            True,
            False,
            True,
            True,
            True,
            False,
            False,
            False,
            True,
            True,
            True,
            False,
            True,
            False,
            True,
            True,
            False,
            False,
            False,
            True,
            False,
            False,
            False,
            False,
            True,
            False,
            True,
            False,
            True,
            True,
            False,
            True,
            False,
            False,
            False,
            True,
            False,
            False,
            True,
            True,
            False,
            True,
            False,
            False,
            False,
            False,
            False,
            False,
            True,
            True,
            False,
            False,
            True,
            False,
            False,
            True,
            True,
            True,
            False,
            False,
            False,
            True,
            False,
            False,
            False,
            False,
            True,
            False,
            True,
            False,
            False,
            False,
            True,
            False,
            True,
            True,
            False,
            True,
            False,
            True,
            False,
            True,
            False,
            False,
            True,
            False,
            False,
            True,
            False,
            True,
            False,
            True,
            False,
            True,
            False,
            False,
            True,
            True,
            True,
            True,
            False,
            True,
            False,
            False,
            False,
            False,
            False,
            True,
            False,
            False,
            True,
            False,
            False,
            True,
            True,
            False,
            False,
            False,
            False,
            True,
            True,
            True,
            False,
            False,
            True,
            False,
            False,
            True,
            True,
            True,
            True,
            False,
            False,
            False,
            True,
            False,
            False,
            False,
            True,
            False,
            False,
            True,
            True,
            True,
            True,
            False,
            False,
            True,
            True,
            False,
            True,
            False,
            True,
            False,
            False,
            True,
            True,
            False,
            True,
            True,
            True,
            True,
            False,
            False,
            True,
            False,
            False,
            True,
            True,
            False,
            True,
            False,
            True,
            False,
            False,
            True,
            False,
            False,
            False,
            False,
            True,
            True,
            True,
            False,
            True,
            False,
            False,
            True,
            False,
            False,
            True,
            False,
            False,
            False,
            False,
            True,
            False,
            True,
            False,
            True,
            True,
            False,
            False,
            True,
            False,
            True,
            True,
            True,
            False,
            False,
            False,
            False,
            True,
            True,
            False,
            True,
            False,
            False,
            False,
            True,
            False,
            False,
            False,
            False,
            True,
            True,
            True,
            False,
            False,
            False,
            True,
            True,
            True,
            True,
            False,
            True,
            True,
            False,
            True,
            True,
            True,
            False,
            True,
            False,
            False,
            True,
            False,
            True,
            True,
            True,
            True,
            False,
            True,
            False,
            True,
            False,
            True,
            False,
            False,
            True,
            True,
            False,
            False,
            True,
            False,
            True,
            False,
            False,
            False,
            False,
            True,
            False,
            True,
            False,
            False,
            False,
            True,
            True,
            True,
            False,
            False,
            False,
            True,
            False,
            True,
            True,
            False,
            False,
            False,
            False,
            False,
            True,
            False,
            True,
            False,
            False,
            True,
            True,
            False,
            True,
            True,
            True,
            True,
            False,
            False,
            True,
            False,
            False,
            True,
            False,
            True,
            False,
            True,
            True,
            False,
            False,
            False,
            True,
            False,
            True,
            True,
            False,
            False,
            False,
            True,
            False,
            True,
            False,
            True,
            True,
            False,
            True,
            False,
            False,
            True,
            False,
            False,
            False,
            True,
            True,
            True,
            False,
            False,
            False,
            False,
            False,
            True,
            False,
            False,
            True,
            True,
            True,
            True,
            True,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            True,
            True,
            True,
            False,
            False,
            True,
            True,
            False,
            True,
            True,
            False,
            True,
            False,
            True,
            False,
            False,
            False,
            True,
            False,
            False,
            True,
            False,
            False,
            True,
            True,
            True,
            True,
            False,
            False,
            True,
            False,
            True,
            True,
            False,
            False,
            True,
            False,
            False,
            True,
            True,
            False,
            True,
            False,
            False,
            True,
            True,
            True,
            False,
            False,
            False,
            False,
            False,
            True,
            False,
            True,
            False,
            False,
            False,
            False,
            False,
            True,
            True,
            False,
            True,
            True,
            True,
            False,
            False,
            False,
            False,
            True,
            True,
            True,
            True,
            False,
            True,
            True,
            False,
            True,
            False,
            True,
            False,
            True,
            False,
            False,
            False,
            False,
            True,
            True,
            True,
            True,
            False,
            False,
            True,
            False,
            True,
            True,
            False,
            False,
            False,
            False,
            False,
            False,
            True,
            False,
            True,
            False,
            True,
            True,
            False,
            False,
            True,
            True,
            True,
            True,
            False,
            False,
            True,
            False,
            True,
            True,
            False,
            False,
            True,
            True,
            True,
            False,
            True,
            False,
            False,
            True,
            True,
            False,
            False,
            False,
            True,
            False,
            False,
            True,
            False,
            False,
            False,
            True,
            True,
            True,
            True,
            False,
            True,
            False,
            True,
            False,
            True,
            False,
            True,
            False,
            False,
            True,
            False,
            False,
            True,
            False,
            True,
            True,
        ]

        for i in range(1000):
            distinctID = f"distinct_id_{i}"

            feature_flag_match = self.client.feature_enabled("simple-flag", distinctID)

            if results[i]:
                self.assertTrue(feature_flag_match)
            else:
                self.assertFalse(feature_flag_match)

    @mock.patch("posthog.client.get")
    def test_multivariate_flag_consistency(self, patch_get):
        self.client.feature_flags = [
            {
                "id": 1,
                "name": "Beta Feature",
                "key": "multivariate-flag",
                "active": True,
                "filters": {
                    "groups": [{"properties": [], "rollout_percentage": 55}],
                    "multivariate": {
                        "variants": [
                            {
                                "key": "first-variant",
                                "name": "First Variant",
                                "rollout_percentage": 50,
                            },
                            {
                                "key": "second-variant",
                                "name": "Second Variant",
                                "rollout_percentage": 20,
                            },
                            {
                                "key": "third-variant",
                                "name": "Third Variant",
                                "rollout_percentage": 20,
                            },
                            {
                                "key": "fourth-variant",
                                "name": "Fourth Variant",
                                "rollout_percentage": 5,
                            },
                            {
                                "key": "fifth-variant",
                                "name": "Fifth Variant",
                                "rollout_percentage": 5,
                            },
                        ],
                    },
                },
            }
        ]

        results = [
            "second-variant",
            "second-variant",
            "first-variant",
            False,
            False,
            "second-variant",
            "first-variant",
            False,
            False,
            False,
            "first-variant",
            "third-variant",
            False,
            "first-variant",
            "second-variant",
            "first-variant",
            False,
            False,
            "fourth-variant",
            "first-variant",
            False,
            "third-variant",
            False,
            False,
            False,
            "first-variant",
            "first-variant",
            "first-variant",
            "first-variant",
            "first-variant",
            "first-variant",
            "third-variant",
            False,
            "third-variant",
            "second-variant",
            "first-variant",
            False,
            "third-variant",
            False,
            False,
            "first-variant",
            "second-variant",
            False,
            "first-variant",
            "first-variant",
            "second-variant",
            False,
            "first-variant",
            False,
            False,
            "first-variant",
            "first-variant",
            "first-variant",
            "second-variant",
            "first-variant",
            False,
            "second-variant",
            "second-variant",
            "third-variant",
            "second-variant",
            "first-variant",
            False,
            "first-variant",
            "second-variant",
            "fourth-variant",
            False,
            "first-variant",
            "first-variant",
            "first-variant",
            False,
            "first-variant",
            "second-variant",
            False,
            "third-variant",
            False,
            False,
            False,
            False,
            False,
            False,
            "first-variant",
            "fifth-variant",
            False,
            "second-variant",
            "first-variant",
            "second-variant",
            False,
            "third-variant",
            "third-variant",
            False,
            False,
            False,
            False,
            "third-variant",
            False,
            False,
            "first-variant",
            "first-variant",
            False,
            "third-variant",
            "third-variant",
            False,
            "third-variant",
            "second-variant",
            "third-variant",
            False,
            False,
            "second-variant",
            "first-variant",
            False,
            False,
            "first-variant",
            False,
            False,
            False,
            False,
            "first-variant",
            "first-variant",
            "first-variant",
            False,
            False,
            False,
            "first-variant",
            "first-variant",
            False,
            "first-variant",
            "first-variant",
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            "first-variant",
            "first-variant",
            "first-variant",
            "first-variant",
            "second-variant",
            "first-variant",
            "first-variant",
            "first-variant",
            "second-variant",
            False,
            "second-variant",
            "first-variant",
            "second-variant",
            "first-variant",
            False,
            "second-variant",
            "second-variant",
            False,
            "first-variant",
            False,
            False,
            False,
            "third-variant",
            "first-variant",
            False,
            False,
            "first-variant",
            False,
            False,
            False,
            False,
            "first-variant",
            False,
            False,
            False,
            False,
            False,
            False,
            False,
            "first-variant",
            "first-variant",
            "third-variant",
            "first-variant",
            "first-variant",
            False,
            False,
            "first-variant",
            False,
            False,
            "fifth-variant",
            "second-variant",
            False,
            "second-variant",
            False,
            "first-variant",
            "third-variant",
            "first-variant",
            "fifth-variant",
            "third-variant",
            False,
            False,
            "fourth-variant",
            False,
            False,
            False,
            False,
            "third-variant",
            False,
            False,
            "third-variant",
            False,
            "first-variant",
            "second-variant",
            "second-variant",
            "second-variant",
            False,
            "first-variant",
            "third-variant",
            "first-variant",
            "first-variant",
            False,
            False,
            False,
            False,
            False,
            "first-variant",
            "first-variant",
            "first-variant",
            "second-variant",
            False,
            False,
            False,
            "second-variant",
            False,
            False,
            "first-variant",
            False,
            "first-variant",
            False,
            False,
            "first-variant",
            "first-variant",
            "first-variant",
            "first-variant",
            "third-variant",
            "first-variant",
            "third-variant",
            "first-variant",
            "first-variant",
            "second-variant",
            "third-variant",
            "third-variant",
            False,
            "second-variant",
            "first-variant",
            False,
            "second-variant",
            "first-variant",
            False,
            "first-variant",
            False,
            False,
            "first-variant",
            "fifth-variant",
            "first-variant",
            False,
            False,
            False,
            False,
            "first-variant",
            "first-variant",
            "second-variant",
            False,
            "second-variant",
            "third-variant",
            "third-variant",
            False,
            "first-variant",
            "third-variant",
            False,
            False,
            "first-variant",
            False,
            "third-variant",
            "first-variant",
            False,
            "third-variant",
            "first-variant",
            "first-variant",
            False,
            "first-variant",
            "second-variant",
            "second-variant",
            "first-variant",
            False,
            False,
            False,
            "second-variant",
            False,
            False,
            "first-variant",
            "first-variant",
            False,
            "third-variant",
            False,
            "first-variant",
            False,
            "third-variant",
            False,
            "third-variant",
            "second-variant",
            "first-variant",
            False,
            False,
            "first-variant",
            "third-variant",
            "first-variant",
            "second-variant",
            "fifth-variant",
            False,
            False,
            "first-variant",
            False,
            False,
            False,
            "third-variant",
            False,
            "second-variant",
            "first-variant",
            False,
            False,
            False,
            False,
            "third-variant",
            False,
            False,
            "third-variant",
            False,
            False,
            "first-variant",
            "third-variant",
            False,
            False,
            "first-variant",
            False,
            False,
            "fourth-variant",
            "fourth-variant",
            "third-variant",
            "second-variant",
            "first-variant",
            "third-variant",
            "fifth-variant",
            False,
            "first-variant",
            "fifth-variant",
            False,
            "first-variant",
            "first-variant",
            "first-variant",
            False,
            False,
            False,
            "second-variant",
            "fifth-variant",
            "second-variant",
            "first-variant",
            "first-variant",
            "second-variant",
            False,
            False,
            "third-variant",
            False,
            "second-variant",
            "fifth-variant",
            False,
            "third-variant",
            "first-variant",
            False,
            False,
            "fourth-variant",
            False,
            False,
            "second-variant",
            False,
            False,
            "first-variant",
            "fourth-variant",
            "first-variant",
            "second-variant",
            False,
            False,
            False,
            "first-variant",
            "third-variant",
            "third-variant",
            False,
            "first-variant",
            "first-variant",
            "first-variant",
            False,
            "first-variant",
            False,
            "first-variant",
            "third-variant",
            "third-variant",
            False,
            False,
            "first-variant",
            False,
            False,
            "second-variant",
            "second-variant",
            "first-variant",
            "first-variant",
            "first-variant",
            False,
            "fifth-variant",
            "first-variant",
            False,
            False,
            False,
            "second-variant",
            "third-variant",
            "first-variant",
            "fourth-variant",
            "first-variant",
            "third-variant",
            False,
            "first-variant",
            "first-variant",
            False,
            "third-variant",
            "first-variant",
            "first-variant",
            "third-variant",
            False,
            "fourth-variant",
            "fifth-variant",
            "first-variant",
            "first-variant",
            False,
            False,
            False,
            "first-variant",
            "first-variant",
            "first-variant",
            False,
            "first-variant",
            "first-variant",
            "second-variant",
            "first-variant",
            False,
            "first-variant",
            "second-variant",
            "first-variant",
            False,
            "first-variant",
            "second-variant",
            False,
            "first-variant",
            "first-variant",
            False,
            "first-variant",
            False,
            "first-variant",
            False,
            "first-variant",
            False,
            False,
            False,
            "third-variant",
            "third-variant",
            "first-variant",
            False,
            False,
            "second-variant",
            "third-variant",
            "first-variant",
            "first-variant",
            False,
            False,
            False,
            "second-variant",
            "first-variant",
            False,
            "first-variant",
            "third-variant",
            False,
            "first-variant",
            False,
            False,
            False,
            "first-variant",
            "third-variant",
            "third-variant",
            False,
            False,
            False,
            False,
            "third-variant",
            "fourth-variant",
            "fourth-variant",
            "first-variant",
            "second-variant",
            False,
            "first-variant",
            False,
            "second-variant",
            "first-variant",
            "third-variant",
            False,
            "third-variant",
            False,
            "first-variant",
            "first-variant",
            "third-variant",
            False,
            False,
            False,
            "fourth-variant",
            "second-variant",
            "first-variant",
            False,
            False,
            "first-variant",
            "fourth-variant",
            False,
            "first-variant",
            "third-variant",
            "first-variant",
            False,
            False,
            "third-variant",
            False,
            "first-variant",
            False,
            "first-variant",
            "first-variant",
            "third-variant",
            "second-variant",
            "fourth-variant",
            False,
            "first-variant",
            False,
            False,
            False,
            False,
            "second-variant",
            "first-variant",
            "second-variant",
            False,
            "first-variant",
            False,
            "first-variant",
            "first-variant",
            False,
            "first-variant",
            "first-variant",
            "second-variant",
            "third-variant",
            "first-variant",
            "first-variant",
            "first-variant",
            False,
            False,
            False,
            "third-variant",
            False,
            "first-variant",
            "first-variant",
            "first-variant",
            "third-variant",
            "first-variant",
            "first-variant",
            "second-variant",
            "first-variant",
            "fifth-variant",
            "fourth-variant",
            "first-variant",
            "second-variant",
            False,
            "fourth-variant",
            False,
            False,
            False,
            "fourth-variant",
            False,
            False,
            "third-variant",
            False,
            False,
            False,
            "first-variant",
            "third-variant",
            "third-variant",
            "second-variant",
            "first-variant",
            "second-variant",
            "first-variant",
            False,
            "first-variant",
            False,
            False,
            False,
            False,
            False,
            "first-variant",
            "first-variant",
            False,
            "second-variant",
            False,
            False,
            "first-variant",
            False,
            "second-variant",
            "first-variant",
            "first-variant",
            "first-variant",
            "third-variant",
            "second-variant",
            False,
            False,
            "fifth-variant",
            "third-variant",
            False,
            False,
            "first-variant",
            False,
            False,
            False,
            "first-variant",
            "second-variant",
            "third-variant",
            "third-variant",
            False,
            False,
            "first-variant",
            False,
            "third-variant",
            "first-variant",
            False,
            False,
            False,
            False,
            "fourth-variant",
            "first-variant",
            False,
            False,
            False,
            "third-variant",
            False,
            False,
            "second-variant",
            "first-variant",
            False,
            False,
            "second-variant",
            "third-variant",
            "first-variant",
            "first-variant",
            False,
            "first-variant",
            "first-variant",
            False,
            False,
            "second-variant",
            "third-variant",
            "second-variant",
            "third-variant",
            False,
            False,
            "first-variant",
            False,
            False,
            "first-variant",
            False,
            "second-variant",
            False,
            False,
            False,
            False,
            "first-variant",
            False,
            "third-variant",
            False,
            "first-variant",
            False,
            False,
            "second-variant",
            "third-variant",
            "second-variant",
            "fourth-variant",
            "first-variant",
            "first-variant",
            "first-variant",
            False,
            "first-variant",
            False,
            "second-variant",
            False,
            False,
            False,
            False,
            False,
            "first-variant",
            False,
            False,
            False,
            False,
            False,
            "first-variant",
            False,
            "second-variant",
            False,
            False,
            False,
            False,
            "second-variant",
            False,
            "first-variant",
            False,
            "third-variant",
            False,
            False,
            "first-variant",
            "third-variant",
            False,
            "third-variant",
            False,
            False,
            "second-variant",
            False,
            "first-variant",
            "second-variant",
            "first-variant",
            False,
            False,
            False,
            False,
            False,
            "second-variant",
            False,
            False,
            "first-variant",
            "third-variant",
            False,
            "first-variant",
            False,
            False,
            False,
            False,
            False,
            "first-variant",
            "second-variant",
            False,
            False,
            False,
            "first-variant",
            "first-variant",
            "fifth-variant",
            False,
            False,
            False,
            "first-variant",
            False,
            "third-variant",
            False,
            False,
            "second-variant",
            False,
            False,
            False,
            False,
            False,
            "fourth-variant",
            "second-variant",
            "first-variant",
            "second-variant",
            False,
            "second-variant",
            False,
            "second-variant",
            False,
            "first-variant",
            False,
            "first-variant",
            "first-variant",
            False,
            "second-variant",
            False,
            "first-variant",
            False,
            "fifth-variant",
            False,
            "first-variant",
            "first-variant",
            False,
            False,
            False,
            "first-variant",
            False,
            "first-variant",
            "third-variant",
            False,
            False,
            "first-variant",
            "first-variant",
            False,
            False,
            "fifth-variant",
            False,
            False,
            "third-variant",
            False,
            "third-variant",
            "first-variant",
            "first-variant",
            "third-variant",
            "third-variant",
            False,
            "first-variant",
            False,
            False,
            False,
            False,
            False,
            "first-variant",
            False,
            False,
            False,
            False,
            "second-variant",
            "first-variant",
            "second-variant",
            "first-variant",
            False,
            "fifth-variant",
            "first-variant",
            False,
            False,
            "fourth-variant",
            "first-variant",
            "first-variant",
            False,
            False,
            "fourth-variant",
            "first-variant",
            False,
            "second-variant",
            "third-variant",
            "third-variant",
            "first-variant",
            "first-variant",
            False,
            False,
            False,
            "first-variant",
            "first-variant",
            "first-variant",
            False,
            "third-variant",
            "third-variant",
            "third-variant",
            False,
            False,
            "first-variant",
            "first-variant",
            False,
            "second-variant",
            False,
            False,
            "second-variant",
            False,
            "third-variant",
            "first-variant",
            "second-variant",
            "fifth-variant",
            "first-variant",
            "first-variant",
            False,
            "first-variant",
            "fifth-variant",
            False,
            False,
            False,
            "third-variant",
            "first-variant",
            "first-variant",
            "second-variant",
            "fourth-variant",
            "first-variant",
            "second-variant",
            "first-variant",
            False,
            False,
            False,
            "second-variant",
            "third-variant",
            False,
            False,
            "first-variant",
            False,
            False,
            False,
            False,
            False,
            False,
            "first-variant",
            "first-variant",
            False,
            "third-variant",
            False,
            "first-variant",
            False,
            "third-variant",
            "third-variant",
            "first-variant",
            "first-variant",
            False,
            "second-variant",
            False,
            "second-variant",
            "first-variant",
            False,
            False,
            False,
            "second-variant",
            False,
            "third-variant",
            False,
            "first-variant",
            "fifth-variant",
            "first-variant",
            "first-variant",
            False,
            False,
            "first-variant",
            False,
            False,
            False,
            "first-variant",
            "fourth-variant",
            "first-variant",
            "first-variant",
            "first-variant",
            "fifth-variant",
            False,
            False,
            False,
            "second-variant",
            False,
            False,
            False,
            "first-variant",
            "first-variant",
            False,
            False,
            "first-variant",
            "first-variant",
            "second-variant",
            "first-variant",
            "first-variant",
            "first-variant",
            "first-variant",
            "first-variant",
            "third-variant",
            "first-variant",
            False,
            "second-variant",
            False,
            False,
            "third-variant",
            "second-variant",
            "third-variant",
            False,
            "first-variant",
            "third-variant",
            "second-variant",
            "first-variant",
            "third-variant",
            False,
            False,
            "first-variant",
            "first-variant",
            False,
            False,
            False,
            "first-variant",
            "third-variant",
            "second-variant",
            "first-variant",
            "first-variant",
            "first-variant",
            False,
            "third-variant",
            "second-variant",
            "third-variant",
            False,
            False,
            "third-variant",
            "first-variant",
            False,
            "first-variant",
        ]

        for i in range(1000):
            distinctID = f"distinct_id_{i}"
            feature_flag_match = self.client.get_feature_flag(
                "multivariate-flag", distinctID
            )

            if results[i]:
                self.assertEqual(feature_flag_match, results[i])
            else:
                self.assertFalse(feature_flag_match)

    @mock.patch("posthog.client.flags")
    def test_feature_flag_case_sensitive(self, mock_decide):
        mock_decide.return_value = {
            "featureFlags": {}
        }  # Ensure decide returns empty flags

        client = Client(
            project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
        )
        client.feature_flags = [
            {
                "id": 1,
                "key": "Beta-Feature",
                "active": True,
                "filters": {
                    "groups": [{"properties": [], "rollout_percentage": 100}],
                },
            }
        ]

        # Test that flag evaluation is case-sensitive
        self.assertTrue(client.feature_enabled("Beta-Feature", "user1"))
        self.assertFalse(client.feature_enabled("beta-feature", "user1"))
        self.assertFalse(client.feature_enabled("BETA-FEATURE", "user1"))

    @mock.patch("posthog.client.flags")
    def test_feature_flag_payload_case_sensitive(self, mock_decide):
        mock_decide.return_value = {
            "featureFlags": {"Beta-Feature": True},
            "featureFlagPayloads": {"Beta-Feature": {"some": "value"}},
        }

        client = Client(
            project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
        )
        client.feature_flags = [
            {
                "id": 1,
                "key": "Beta-Feature",
                "active": True,
                "filters": {
                    "groups": [{"properties": [], "rollout_percentage": 100}],
                    "payloads": {
                        "true": {"some": "value"},
                    },
                },
            }
        ]

        # Test that payload retrieval is case-sensitive
        self.assertEqual(
            client.get_feature_flag_payload("Beta-Feature", "user1"), {"some": "value"}
        )
        self.assertIsNone(client.get_feature_flag_payload("beta-feature", "user1"))
        self.assertIsNone(client.get_feature_flag_payload("BETA-FEATURE", "user1"))

    @mock.patch("posthog.client.flags")
    def test_feature_flag_case_sensitive_consistency(self, mock_decide):
        mock_decide.return_value = {
            "featureFlags": {"Beta-Feature": True},
            "featureFlagPayloads": {"Beta-Feature": {"some": "value"}},
        }

        client = Client(
            project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
        )
        client.feature_flags = [
            {
                "id": 1,
                "key": "Beta-Feature",
                "active": True,
                "filters": {
                    "groups": [{"properties": [], "rollout_percentage": 100}],
                    "payloads": {
                        "true": {"some": "value"},
                    },
                },
            }
        ]

        # Test that flag evaluation and payload retrieval are consistently case-sensitive
        # Only exact match should work
        self.assertTrue(client.feature_enabled("Beta-Feature", "user1"))
        self.assertEqual(
            client.get_feature_flag_payload("Beta-Feature", "user1"), {"some": "value"}
        )

        # Different cases should not match
        test_cases = ["beta-feature", "BETA-FEATURE", "bEtA-FeAtUrE"]
        for case in test_cases:
            self.assertFalse(client.feature_enabled(case, "user1"))

    @mock.patch("posthog.client.flags")
    def test_get_all_flags_with_flag_keys_to_evaluate(self, mock_flags):
        """Test that get_all_flags with flag_keys_to_evaluate only evaluates specified flags"""
        mock_flags.return_value = {
            "featureFlags": {
                "flag1": "value1",
                "flag2": True,
            }
        }

        client = Client(
            project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
        )

        # Call get_all_flags with flag_keys_to_evaluate
        result = client.get_all_flags(
            "user123",
            flag_keys_to_evaluate=["flag1", "flag2"],
            person_properties={"region": "USA"},
        )

        # Verify flags() was called with flag_keys_to_evaluate
        mock_flags.assert_called_once()
        call_args = mock_flags.call_args[1]
        self.assertEqual(call_args["flag_keys_to_evaluate"], ["flag1", "flag2"])
        self.assertEqual(
            call_args["person_properties"], {"distinct_id": "user123", "region": "USA"}
        )

        # Check the result
        self.assertEqual(result, {"flag1": "value1", "flag2": True})

    @mock.patch("posthog.client.flags")
    def test_get_all_flags_and_payloads_with_flag_keys_to_evaluate(self, mock_flags):
        """Test that get_all_flags_and_payloads with flag_keys_to_evaluate only evaluates specified flags"""
        mock_flags.return_value = {
            "featureFlags": {
                "flag1": "variant1",
                "flag3": True,
            },
            "featureFlagPayloads": {
                "flag1": {"data": "payload1"},
                "flag3": {"data": "payload3"},
            },
        }

        client = Client(
            project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
        )

        # Call get_all_flags_and_payloads with flag_keys_to_evaluate
        result = client.get_all_flags_and_payloads(
            "user123",
            flag_keys_to_evaluate=["flag1", "flag3"],
            person_properties={"subscription": "pro"},
        )

        # Verify flags() was called with flag_keys_to_evaluate
        mock_flags.assert_called_once()
        call_args = mock_flags.call_args[1]
        self.assertEqual(call_args["flag_keys_to_evaluate"], ["flag1", "flag3"])
        self.assertEqual(
            call_args["person_properties"],
            {"distinct_id": "user123", "subscription": "pro"},
        )

        # Check the result
        self.assertEqual(result["featureFlags"], {"flag1": "variant1", "flag3": True})
        self.assertEqual(
            result["featureFlagPayloads"],
            {"flag1": {"data": "payload1"}, "flag3": {"data": "payload3"}},
        )

    def test_get_all_flags_locally_with_flag_keys_to_evaluate(self):
        """Test that local evaluation with flag_keys_to_evaluate only evaluates specified flags"""
        client = Client(
            project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
        )

        # Set up multiple flags
        client.feature_flags = [
            {
                "id": 1,
                "key": "flag1",
                "active": True,
                "filters": {"groups": [{"properties": [], "rollout_percentage": 100}]},
            },
            {
                "id": 2,
                "key": "flag2",
                "active": True,
                "filters": {"groups": [{"properties": [], "rollout_percentage": 100}]},
            },
            {
                "id": 3,
                "key": "flag3",
                "active": True,
                "filters": {"groups": [{"properties": [], "rollout_percentage": 100}]},
            },
        ]

        # Call get_all_flags with flag_keys_to_evaluate
        result = client.get_all_flags(
            "user123",
            flag_keys_to_evaluate=["flag1", "flag3"],
            only_evaluate_locally=True,
        )

        # Should only return flag1 and flag3
        self.assertEqual(result, {"flag1": True, "flag3": True})
        self.assertNotIn("flag2", result)
