import copy
import pickle
import random

from collections import OrderedDict
from unittest.mock import PropertyMock, patch

import pytest

from faker import Faker
from faker.config import DEFAULT_LOCALE
from faker.generator import Generator


class TestFakerProxyClass:
    """Test Faker proxy class"""

    def test_unspecified_locale(self):
        fake = Faker()
        assert len(fake.locales) == 1
        assert len(fake.factories) == 1
        assert fake.locales[0] == DEFAULT_LOCALE

    def test_locale_as_string(self):
        locale = "en_US"
        fake = Faker(locale)
        assert len(fake.locales) == 1
        assert len(fake.factories) == 1
        assert fake.locales[0] == locale

    def test_locale_as_list(self):
        locale = ["en-US", "en_PH", "ja_JP", "de-DE"]
        expected = ["en_US", "en_PH", "ja_JP", "de_DE"]
        fake = Faker(locale)
        assert fake.locales == expected
        assert len(fake.factories) == len(expected)

        locale = ["en-US", "en_PH", "ja_JP", "de-DE", "ja-JP", "de_DE", "en-US"] * 3
        expected = ["en_US", "en_PH", "ja_JP", "de_DE"]
        fake = Faker(locale)
        assert fake.locales == expected
        assert len(fake.factories) == len(expected)

    def test_locale_as_list_invalid_value_type(self):
        locale = [1, 2]
        with pytest.raises(TypeError) as exc:
            Faker(locale)
        assert str(exc.value) == 'The locale "1" must be a string.'

    def test_locale_as_ordereddict(self):
        locale = OrderedDict(
            [
                ("de_DE", 3),
                ("en-US", 2),
                ("en-PH", 1),
                ("ja_JP", 5),
            ]
        )

        fake = Faker(locale)
        assert fake.locales == ["de_DE", "en_US", "en_PH", "ja_JP"]
        assert len(fake.factories) == 4
        assert fake.weights == [3, 2, 1, 5]

        locale = OrderedDict(
            [
                ("de_DE", 3),
                ("en-US", 2),
                ("en-PH", 1),
                ("ja_JP", 5),
                ("de-DE", 4),
                ("ja-JP", 2),
                ("en-US", 1),
            ]
        )
        fake = Faker(locale)
        assert fake.locales == ["de_DE", "en_US", "en_PH", "ja_JP"]
        assert len(fake.factories) == 4
        assert fake.weights == [4, 1, 1, 2]

    def test_invalid_locale(self):
        with pytest.raises(AttributeError):
            Faker("foo_Bar")

        with pytest.raises(AttributeError):
            Faker(["en_US", "foo_Bar"])

        with pytest.raises(AttributeError):
            Faker(
                OrderedDict(
                    [
                        ("de_DE", 3),
                        ("en-US", 2),
                        ("en-PH", 1),
                        ("foo_Bar", 5),
                    ]
                )
            )

    def test_items(self):
        locale = ["de_DE", "en-US", "en-PH", "ja_JP", "de-DE", "ja-JP", "en-US"]
        processed_locale = list({code.replace("-", "_") for code in locale})
        fake = Faker(locale)
        for locale_name, factory in fake.items():
            assert locale_name in processed_locale
            assert isinstance(factory, (Generator, Faker))

    def test_dunder_getitem(self):
        locale = ["de_DE", "en-US", "en-PH", "ja_JP"]
        fake = Faker(locale)

        for code in locale:
            assert isinstance(fake[code], (Generator, Faker))

        with pytest.raises(KeyError):
            fake["en_GB"]

    def test_seed_classmethod(self):
        fake = Faker()

        # Verify `seed()` is not callable from a class instance
        with pytest.raises(TypeError):
            fake.seed(0)

        # Verify calls to `seed()` from a class object are proxied properly
        with patch("faker.generator.Generator.seed") as mock_seed:
            mock_seed.assert_not_called()
            Faker.seed(0)
            mock_seed.assert_called_once_with(0)

    def test_seed_class_locales(self):
        Faker.seed(2043)
        count = 5
        fake = Faker(["en_GB", "fr_FR", "en_IN"])
        first_list = [fake.name() for _ in range(count)]
        # We convert the list to a set to remove duplicates and ensure
        # that we have exactly `count` unique fake values
        assert len(set(first_list)) == count

        Faker.seed(2043)
        fake = Faker(["en_GB", "fr_FR", "en_IN"])
        second_list = [fake.name() for _ in range(count)]

        assert first_list == second_list

    def test_seed_instance(self):
        locale = ["de_DE", "en-US", "en-PH", "ja_JP"]
        fake = Faker(locale)

        with patch("faker.generator.Generator.seed_instance") as mock_seed_instance:
            mock_seed_instance.assert_not_called()
            fake.seed_instance(0)

            # Verify `seed_instance(0)` was called 4 times (one for each locale)
            calls = mock_seed_instance.call_args_list
            assert len(calls) == 4
            for call in calls:
                args, kwargs = call
                assert args == (0,)
                assert kwargs == {}

    def test_seed_locale(self):
        from faker.generator import random as shared_random_instance

        locale = ["de_DE", "en-US", "en-PH", "ja_JP"]
        fake = Faker(locale)

        # Get current state of each factory's random instance
        states = {}
        for locale, factory in fake.items():
            states[locale] = factory.random.getstate()

        # Create a new random instance for en_US factory with seed value
        fake.seed_locale("en_US", 0)

        for locale, factory in fake.items():
            # en_US factory should have changed
            if locale == "en_US":
                assert factory.random != shared_random_instance
                assert factory.random.getstate() != states[locale]

            # There should be no changes for the rest
            else:
                assert factory.random == shared_random_instance
                assert factory.random.getstate() == states[locale]

    def test_single_locale_proxy_behavior(self):
        fake = Faker()
        internal_factory = fake.factories[0]

        # Test if `Generator` attributes are proxied properly
        for attr in fake.generator_attrs:
            assert getattr(fake, attr) == getattr(internal_factory, attr)

        # Test if `random` getter and setter are proxied properly
        tmp_random = fake.random
        assert internal_factory.random != 1
        fake.random = 1
        assert internal_factory.random == 1
        fake.random = tmp_random

        # Test if a valid provider method is proxied properly
        # Factory selection logic should not be triggered
        with patch("faker.proxy.Faker._select_factory") as mock_select_factory:
            mock_select_factory.assert_not_called()
            assert fake.name == internal_factory.name
            fake.name()
            mock_select_factory.assert_not_called()

    def test_multiple_locale_proxy_behavior(self):
        fake = Faker(["de-DE", "en-US", "en-PH", "ja-JP"])

        # `Generator` attributes are not implemented
        for attr in fake.generator_attrs:
            with pytest.raises(NotImplementedError):
                getattr(fake, attr)

        # The `random` getter is not implemented
        with pytest.raises(NotImplementedError):
            random = fake.random
            random.seed(0)

        # The `random` setter is not implemented
        with pytest.raises(NotImplementedError):
            fake.random = 1

    def test_multiple_locale_caching_behavior(self):
        fake = Faker(["de_DE", "en-US", "en-PH", "ja_JP"])

        with patch("faker.proxy.Faker._map_provider_method", wraps=fake._map_provider_method) as mock_map_method:
            mock_map_method.assert_not_called()
            assert not hasattr(fake, "_cached_name_mapping")

            # Test cache creation
            fake.name()
            assert hasattr(fake, "_cached_name_mapping")
            mock_map_method.assert_called_once_with("name")

            # Test subsequent cache access
            with patch.object(Faker, "_cached_name_mapping", create=True, new_callable=PropertyMock) as mock_cached_map:
                # Keep test fast by patching the cached mapping to return something simpler
                mock_cached_map.return_value = [fake["en_US"]], [1]
                for _ in range(100):
                    fake.name()

                # Python's hasattr() internally calls getattr()
                # So each call to name() accesses the cached mapping twice
                assert mock_cached_map.call_count == 200

    @patch("faker.proxy.Faker._select_factory_choice")
    @patch("faker.proxy.Faker._select_factory_distribution")
    def test_multiple_locale_factory_selection_no_weights(self, mock_factory_distribution, mock_factory_choice):
        fake = Faker(["de_DE", "en-US", "en-PH", "ja_JP"])

        # There are no distribution weights, so factory selection logic will use `random.choice`
        # if multiple factories have the specified provider method
        with patch("faker.proxy.Faker._select_factory", wraps=fake._select_factory) as mock_select_factory:
            mock_select_factory.assert_not_called()
            mock_factory_distribution.assert_not_called()
            mock_factory_choice.assert_not_called()

            # All factories for the listed locales have the `name` provider method
            fake.name()
            mock_select_factory.assert_called_once_with("name")
            mock_factory_distribution.assert_not_called()
            mock_factory_choice.assert_called_once_with(fake.factories)
            mock_select_factory.reset_mock()
            mock_factory_distribution.reset_mock()
            mock_factory_choice.reset_mock()

            # Only `en_PH` factory has provider method `luzon_province`, so there is no
            # need for `random.choice` factory selection logic to run
            fake.luzon_province()
            mock_select_factory.assert_called_with("luzon_province")
            mock_factory_distribution.assert_not_called()
            mock_factory_choice.assert_not_called()
            mock_select_factory.reset_mock()
            mock_factory_distribution.reset_mock()
            mock_factory_choice.reset_mock()

            # Both `en_US` and `ja_JP` factories have provider method `zipcode`
            fake.zipcode()
            mock_select_factory.assert_called_once_with("zipcode")
            mock_factory_distribution.assert_not_called()
            mock_factory_choice.assert_called_once_with(
                [fake["en_US"], fake["ja_JP"]],
            )

    @patch("faker.proxy.Faker._select_factory_choice")
    @patch("faker.proxy.Faker._select_factory_distribution")
    def test_multiple_locale_factory_selection_with_weights(self, mock_factory_distribution, mock_factory_choice):
        locale = OrderedDict(
            [
                ("de_DE", 3),
                ("en-US", 2),
                ("en-PH", 1),
                ("ja_JP", 5),
            ]
        )
        fake = Faker(locale)
        mock_factory_distribution.assert_not_called()
        mock_factory_choice.assert_not_called()

        # Distribution weights have been specified, so factory selection logic will use
        # `choices_distribution` if multiple factories have the specified provider method
        with patch("faker.proxy.Faker._select_factory", wraps=fake._select_factory) as mock_select_factory:
            # All factories for the listed locales have the `name` provider method
            fake.name()
            mock_select_factory.assert_called_once_with("name")
            mock_factory_distribution.assert_called_once_with(fake.factories, fake.weights)
            mock_factory_choice.assert_not_called()

    @patch("faker.proxy.Faker._select_factory_choice")
    @patch("faker.proxy.Faker._select_factory_distribution")
    def test_multiple_locale_factory_selection_single_provider(self, mock_factory_distribution, mock_factory_choice):
        locale = OrderedDict(
            [
                ("de_DE", 3),
                ("en-US", 2),
                ("en-PH", 1),
                ("ja_JP", 5),
            ]
        )
        fake = Faker(locale)

        # Distribution weights have been specified, so factory selection logic will use
        # `choices_distribution` if multiple factories have the specified provider method
        with patch("faker.proxy.Faker._select_factory", wraps=fake._select_factory) as mock_select_factory:
            # Only `en_PH` factory has provider method `luzon_province`, so there is no
            # need for `choices_distribution` factory selection logic to run
            fake.luzon_province()
            mock_select_factory.assert_called_once_with("luzon_province")
            mock_factory_distribution.assert_not_called()
            mock_factory_choice.assert_not_called()

    @patch("faker.proxy.Faker._select_factory_choice")
    @patch("faker.proxy.Faker._select_factory_distribution")
    def test_multiple_locale_factory_selection_shared_providers(self, mock_factory_distribution, mock_factory_choice):
        locale = OrderedDict(
            [
                ("de_DE", 3),
                ("en-US", 2),
                ("en-PH", 1),
                ("ja_JP", 5),
            ]
        )
        fake = Faker(locale)

        with patch("faker.proxy.Faker._select_factory", wraps=fake._select_factory) as mock_select_factory:
            # Both `en_US` and `ja_JP` factories have provider method `zipcode`
            fake.zipcode()
            mock_select_factory.assert_called_once_with("zipcode")
            mock_factory_distribution.assert_called_once_with([fake["en_US"], fake["ja_JP"]], [2, 5])
            mock_factory_choice.assert_not_called()

    def test_multiple_locale_factory_selection_unsupported_method(self):
        fake = Faker(["en_US", "en_PH"])
        with pytest.raises(AttributeError):
            fake.obviously_invalid_provider_method_a23f()

    @patch("random.Random.choice")
    @patch("random.Random.choices")
    def test_weighting_disabled_single_choice(self, mock_choices_fn, mock_choice_fn):
        fake = Faker(use_weighting=False)
        fake.first_name()
        mock_choice_fn.assert_called()
        mock_choices_fn.assert_not_called()

    @patch("random.Random.choice")
    @patch("random.Random.choices", wraps=random.Random().choices)
    def test_weighting_disabled_with_locales(self, mock_choices_fn, mock_choice_fn):
        locale = OrderedDict(
            [
                ("de_DE", 3),
                ("en-US", 2),
                ("en-PH", 1),
                ("ja_JP", 5),
            ]
        )
        fake = Faker(locale, use_weighting=False)
        fake.first_name()
        mock_choices_fn.assert_called()  # select provider
        mock_choice_fn.assert_called()  # select within provider

    @patch("random.Random.choice")
    @patch("random.Random.choices", wraps=random.Random().choices)
    def test_weighting_disabled_multiple_locales(self, mock_choices_fn, mock_choice_fn):
        locale = OrderedDict(
            [
                ("de_DE", 3),
                ("en-US", 2),
                ("en-PH", 1),
                ("ja_JP", 5),
            ]
        )
        fake = Faker(locale, use_weighting=False)
        fake.first_name()
        mock_choices_fn.assert_called()  # select provider
        mock_choice_fn.assert_called()  # select within provider

    @patch("random.Random.choice")
    @patch("random.Random.choices", wraps=random.Random().choices)
    def test_weighting_disabled_multiple_choices(self, mock_choices_fn, mock_choice_fn):
        fake = Faker(use_weighting=False)
        fake.uri_path(deep=3)

        assert mock_choices_fn.mock_calls[0][2]["k"] == 3
        assert mock_choices_fn.mock_calls[0][2]["weights"] is None
        mock_choice_fn.assert_not_called()

    @patch("random.Random.choice")
    @patch("random.Random.choices", wraps=random.Random().choices)
    def test_weighting_enabled_multiple_choices(self, mock_choices_fn, mock_choice_fn):
        fake = Faker(use_weighting=True)
        fake.uri_path(deep=3)

        assert mock_choices_fn.mock_calls[0][2]["k"] == 3
        assert mock_choices_fn.mock_calls[0][2]["weights"] is None
        mock_choice_fn.assert_not_called()

    def test_dir_include_all_providers_attribute_in_list(self):
        fake = Faker(["en_US", "en_PH"])
        expected = set(
            dir(Faker)
            + [
                "_factories",
                "_locales",
                "_factory_map",
                "_weights",
                "_unique_proxy",
                "_optional_proxy",
            ]
        )
        for factory in fake.factories:
            expected |= {attr for attr in dir(factory) if not attr.startswith("_")}
        expected = sorted(expected)
        attributes = dir(fake)
        assert attributes == expected

    def test_copy(self):
        fake = Faker("it_IT")
        fake2 = copy.deepcopy(fake)
        assert fake.locales == fake2.locales
        assert fake.locales is not fake2.locales

    def test_pickle(self):
        fake = Faker()
        pickled = pickle.dumps(fake)
        pickle.loads(pickled)
