import inspect
import pytest
from unittest.mock import MagicMock
from typedconfig.config import Config, key, section, group_key, ConfigProvider
from typedconfig.source import DictConfigSource, ConfigSource


class GrandchildConfig(Config):
    prop1 = key(section_name="grandchild", key_name="PROP1")


class ChildConfig(Config):
    prop1 = key(section_name="child", key_name="PROP1")
    grandchild_config = group_key(GrandchildConfig)


class ParentConfig(Config):
    prop1 = key(section_name="parent", key_name="PROP1")
    child_config = group_key(ChildConfig)


def test_subclass_config():
    class SampleConfig(Config):
        prop1 = key(section_name="s", key_name="prop1", cast=float)
        prop2 = key(section_name="s", key_name="prop2", cast=int)
        prop3 = key(section_name="s", key_name="prop3", cast=str)
        prop4 = key(section_name="s", key_name="prop4")

    config_source = DictConfigSource({"s": {"PROP1": "3.5", "PROP2": "2", "PROP3": "hello", "PROP4": "abc"}})
    config = SampleConfig()
    config.add_source(config_source)

    assert 3.5 == config.prop1
    assert 2 == config.prop2
    assert "hello" == config.prop3
    assert "abc" == config.prop4


def test_register_properties():
    class SampleConfig(Config):
        registerd_key1 = key(section_name="s", key_name="key1")
        registerd_key2 = key(section_name="s", key_name="key2")

        not_registered_key = 3

        def not_registered_method(self):
            pass

    config = SampleConfig()
    props = config.get_registered_properties()
    assert sorted(["registerd_key1", "registerd_key2"]) == sorted(props)


def test_register_composed_config():
    class ChildConfig1(Config):
        pass

    class ChildConfig2(Config):
        pass

    class SampleConfig(Config):
        composed_config1 = group_key(ChildConfig1)
        composed_config2 = group_key(ChildConfig2)

    config = SampleConfig()
    sub_configs = config.get_registered_composed_config()
    assert len(sub_configs) == 2
    assert isinstance(sub_configs[0], ChildConfig1)
    assert isinstance(sub_configs[1], ChildConfig2)

    # Check we're not creating a new instance of the sub configs each time we call
    sub_configs_2 = config.get_registered_composed_config()
    assert sub_configs[0] is sub_configs_2[0]
    assert sub_configs[1] is sub_configs_2[1]


def test_reuse_child_config():
    class SampleChildConfig(Config):
        pass

    class SampleConfig(Config):
        composed_1 = group_key(SampleChildConfig)
        composed_2 = group_key(SampleChildConfig)

    config = SampleConfig()
    config.read()

    sub_configs = config.get_registered_composed_config()
    assert len(sub_configs) == 2
    assert isinstance(sub_configs[0], SampleChildConfig)
    assert isinstance(sub_configs[1], SampleChildConfig)
    assert sub_configs[0] is not sub_configs[1]


@pytest.mark.parametrize(
    "config_dict, expect_error",
    [({}, True), ({"PROP1": "x"}, False), ({"PROP1": "x", "PROP2": "y"}, False)],
    ids=["ConfigMissing", "PropertiesMatch", "ExtraConfig"],
)
def test_read(config_dict, expect_error):
    class SampleConfig(Config):
        prop1 = key(section_name="s", key_name="prop1")

    config = SampleConfig()
    config.add_source(DictConfigSource({"s": config_dict}))

    if expect_error:
        with pytest.raises(KeyError):
            config.read()
    else:
        config.read()

    if "PROP1" in config_dict:
        assert config_dict["PROP1"] == config.prop1


@pytest.mark.parametrize(
    "prop_val, args, expected_value_or_error",
    [
        ("3", dict(), "3"),
        (None, dict(), KeyError),
        ("3", dict(required=True), "3"),
        (None, dict(required=True), KeyError),
        ("3", dict(required=False), "3"),
        (None, dict(required=False), None),
        ("3", dict(cast=None), "3"),
        ("3", dict(cast=int), 3),
        ("a", dict(cast=int), ValueError),
        ("3", dict(default=None), "3"),
        ("3", dict(default="5"), "3"),
        (None, dict(default="5"), KeyError),
        (None, dict(required=False, default=3), 3),
        ("3", dict(required=False, default=3), "3"),
        (None, dict(required=False, default=3, cast=int), 3),
        ("3", dict(required=False, default=3, cast=int), 3),
    ],
)
def test_key_getter(prop_val, args, expected_value_or_error):
    class SampleConfig(Config):
        prop = key(section_name="s", key_name="prop1", **args)

    config = SampleConfig()
    if prop_val is None:
        source_dict = {}
    else:
        source_dict = {"s": {"PROP1": prop_val}}
    config.add_source(DictConfigSource(source_dict))

    if inspect.isclass(expected_value_or_error) and issubclass(expected_value_or_error, BaseException):
        with pytest.raises(expected_value_or_error):
            _ = config.prop
    else:
        v = config.prop
        assert v == expected_value_or_error


def test_get_key():
    class SampleConfig(Config):
        prop = key(section_name="s", key_name="prop1")

    config = SampleConfig()
    config.add_source(DictConfigSource({"s": {"PROP1": "propval"}}))
    v = config.get_key("s", "prop1")
    assert v == "propval"


def test_caching():
    class SampleConfig(Config):
        prop1 = key(section_name="SampleConfig", key_name="PROP1")

    mock_source: ConfigSource = MagicMock(spec=ConfigSource)
    mock_source.get_config_value = MagicMock()
    s = SampleConfig()
    s.add_source(mock_source)

    mock_source.get_config_value.assert_not_called()
    a = s.prop1
    mock_source.get_config_value.assert_called_once_with("SampleConfig", "PROP1")
    b = s.prop1
    mock_source.get_config_value.assert_called_once()
    s.clear_cache()
    c = s.prop1
    assert 2 == mock_source.get_config_value.call_count


def test_compose_configs():
    config = ParentConfig()
    config.add_source(
        DictConfigSource(
            {
                "child": {"PROP1": "1"},
                "parent": {"PROP1": "2"},
            }
        )
    )

    # Check that we are actually trying to read the grandchild which has a missing key
    with pytest.raises(KeyError):
        config.read()
    config.add_source(DictConfigSource({"grandchild": {"PROP1": "3"}}))
    config.read()
    assert "2" == config.prop1
    assert isinstance(config.child_config, ChildConfig)
    assert "1" == config.child_config.prop1
    assert isinstance(config.child_config.grandchild_config, GrandchildConfig)
    assert "3" == config.child_config.grandchild_config.prop1


def test_add_source():
    c = Config()
    with pytest.raises(TypeError):
        bad_type: ConfigSource = 3
        c.add_source(bad_type)

    assert len(c.config_sources) == 0
    new_source = DictConfigSource({})
    c.add_source(new_source)
    assert len(c.config_sources) == 1
    assert c.config_sources[-1] is new_source


def test_init_with_sources():
    c = Config(sources=[DictConfigSource({}), DictConfigSource({})])
    assert 2 == len(c.config_sources)


def test_section_decorator():
    @section("my_section")
    class SampleConfig(Config):
        prop1 = key(key_name="prop1")

    c = SampleConfig()
    c.add_source(DictConfigSource({"my_section": dict(PROP1="abc")}))
    assert "abc" == c.prop1


def test_key_name_inference():
    class SampleConfig(Config):
        prop1 = key(section_name="s")
        prop2 = key(section_name="s")

    c = SampleConfig()

    c.add_source(
        DictConfigSource(
            {
                "s": dict(
                    PROP1="abc",
                    PROP2="def",
                )
            }
        )
    )
    assert "abc" == c.prop1
    assert "def" == c.prop2


def test_key_name_inference_multi_level():
    class SampleConfigBase(Config):
        prop1 = key(section_name="s")

    class SampleConfig(SampleConfigBase):
        prop2 = key(section_name="s")

    c = SampleConfig()

    c.add_source(
        DictConfigSource(
            {
                "s": dict(
                    PROP1="abc",
                    PROP2="def",
                )
            }
        )
    )
    assert "abc" == c.prop1
    assert "def" == c.prop2


def test_least_verbose_config():
    @section("X")
    class SampleConfig(Config):
        prop1 = key()
        prop2 = key(cast=int)

    c = SampleConfig()
    c.add_source(DictConfigSource({"X": dict(PROP1="abc", PROP2="44")}))

    assert "abc" == c.prop1
    assert 44 == c.prop2


def test_section_decorator_precedence():
    @section("decorator")
    class SampleConfig(Config):
        decorator_section = key(cast=str)
        key_specific_section = key(section_name="key_specific", cast=str)

    c = SampleConfig(
        sources=[
            DictConfigSource({"decorator": dict(decorator_section="a"), "key_specific": dict(key_specific_section="b")})
        ]
    )
    c.read()


def test_no_section_provided():
    class SampleConfig(Config):
        k = key(cast=str)

    c = SampleConfig()
    with pytest.raises(ValueError):
        c.read()


def test_multiple_group_keys_with_section_decorators():
    @section("a")
    class Child1(Config):
        k1 = key(cast=str)

    @section("b")
    class Child2(Config):
        k2 = key(cast=str)

    class ParentConfig(Config):
        c1 = group_key(Child1)
        c2 = group_key(Child2)

    c1 = Child1()
    c2 = Child2()

    assert c1._section_name == "a"
    assert c2._section_name == "b"

    p = ParentConfig()
    p.add_source(DictConfigSource({"a": {"k1": "v1"}, "b": {"k2": "v2"}}))
    assert p.c1._section_name == "a"
    assert p.c2._section_name == "b"

    assert "v1" == p.c1.k1
    assert "v2" == p.c2.k2


def test_cast_with_default():
    @section("s")
    class SampleConfig(Config):
        nullable_key = key(cast=str, required=False, default=None)
        bool_key = key(cast=bool, required=False, default=False)

    s = SampleConfig()
    s.add_source(DictConfigSource({}))
    assert s.nullable_key is None
    assert s.bool_key is False


def test_provider_property_read():
    provider = ConfigProvider()
    config = ParentConfig(provider=provider)
    p = config.provider
    # Check same object is returned
    assert p is provider


def test_provider_property_is_readonly():
    config = ParentConfig()
    with pytest.raises(Exception):
        config.provider = ConfigProvider()


def test_construct_config_without_provider():
    sources = [DictConfigSource({})]
    config = ParentConfig(sources=sources)
    assert isinstance(config.provider, ConfigProvider)
    assert config.provider.config_sources == sources


def test_construct_config_bad_provider_type():
    with pytest.raises(TypeError):
        config = ParentConfig(provider=3)


def test_construct_config_with_provider():
    sources = [DictConfigSource({})]
    provider = ConfigProvider(sources)
    config = ParentConfig(sources=sources, provider=provider)
    assert config.provider is provider
    assert config.config_sources == sources


def test_replace_source():
    sources = [DictConfigSource({}), DictConfigSource({})]
    config = Config(sources=sources)

    assert config.config_sources == sources
    assert config.config_sources[0] is sources[0]
    assert config.config_sources[1] is sources[1]

    replacement_source = DictConfigSource({})
    config.replace_source(sources[0], replacement_source)

    assert config.config_sources == [replacement_source, sources[1]]
    assert config.config_sources[0] is replacement_source
    assert config.config_sources[1] is sources[1]


def test_replace_source_not_found():
    source_a = DictConfigSource({})
    source_b = DictConfigSource({})
    config = Config()
    config.add_source(source_a)
    with pytest.raises(ValueError):
        config.replace_source(source_b, source_a)


def test_replace_source_bad_type():
    source = DictConfigSource({})
    config = Config(sources=[source])
    with pytest.raises(TypeError):
        bad_type: ConfigSource = 3
        config.replace_source(source, bad_type)


def test_set_sources():
    old_sources = [DictConfigSource({})]
    new_sources = [DictConfigSource({}), DictConfigSource({})]
    config = Config(sources=old_sources)
    config.set_sources(new_sources)
    assert len(config.config_sources) == 2
    assert config.config_sources[0] is new_sources[0]
    assert config.config_sources[1] is new_sources[1]


def test_property_is_read_only():
    config = GrandchildConfig()
    with pytest.raises(AttributeError):
        config.prop1 = "a"


def test_post_read_hook():
    @section("s")
    class SampleConfig(Config):
        prop1 = key(cast=str)
        prop2 = key(cast=str, required=False)

        def post_read_hook(self) -> dict:
            return dict(prop2="x" + self.prop1)

    config_source = DictConfigSource(
        {
            "s": {
                "prop1": "a",
            }
        }
    )
    config = SampleConfig(sources=[config_source])
    config.read()

    assert config.prop1 == "a"
    assert config.prop2 == "xa"


def test_post_read_hook_different_key_name():
    @section("s")
    class SampleConfig(Config):
        prop1 = key(section_name="s", key_name="key1", cast=str)
        prop2 = key(section_name="s", key_name="key2", cast=str, required=False)

        def post_read_hook(self) -> dict:
            return dict(prop2="x" + self.prop1)

    config_source = DictConfigSource(
        {
            "s": {
                "key1": "a",
            }
        }
    )
    config = SampleConfig(sources=[config_source])
    config.read()

    assert config.prop1 == "a"
    assert config.prop2 == "xa"


def test_post_read_hook_modify_child():
    class SampleChildConfig(Config):
        prop3 = key(section_name="s", key_name="key3", cast=str)

    class SampleConfig(Config):
        prop3 = group_key(SampleChildConfig)

        def post_read_hook(self) -> dict:
            return dict(prop3=dict(prop3="new_value"))

    config_source = DictConfigSource(
        {
            "s": {
                "key3": "b",
            }
        }
    )
    config = SampleConfig(sources=[config_source])
    config.read()

    assert config.prop3.prop3 == "new_value"


def test_post_read_hook_child_takes_priority():
    class SampleChildConfig(Config):
        prop3 = key(section_name="s", key_name="key3", cast=str)

        def post_read_hook(self) -> dict:
            return dict(prop3="child_new_value")

    class SampleConfig(Config):
        prop3 = group_key(SampleChildConfig)

        def post_read_hook(self) -> dict:
            return dict(prop3=dict(prop3="new_value"))

    config_source = DictConfigSource(
        {
            "s": {
                "key3": "b",
            }
        }
    )
    config = SampleConfig(sources=[config_source])
    config.read()

    assert config.prop3.prop3 == "child_new_value"


@pytest.mark.parametrize(
    "post_read_hook_return_value",
    [
        dict(wrong_prop="c"),
        dict(prop1=dict(a=4)),
        dict(prop2="d"),
    ],
    ids=["KeyNotExist", "DictNotValue", "ValueNotDict"],
)
def test_post_read_hook_invalid_attributes(post_read_hook_return_value: dict):
    class SampleChildConfig(Config):
        pass

    @section("s")
    class SampleConfig(Config):
        prop1 = key(cast=str)
        prop2 = group_key(SampleChildConfig)

        def post_read_hook(self) -> dict:
            return post_read_hook_return_value

    config_source = DictConfigSource({"s": {"prop1": "a"}})
    config = SampleConfig(sources=[config_source])
    with pytest.raises(KeyError):
        config.read()


def test_config_repr():
    class SampleChildConfig(Config):
        a = key(section_name="test", cast=str, required=False, default="A")

    @section("test")
    class SampleConfig(Config):
        b = key(cast=str, required=False, default="B")
        child = group_key(SampleChildConfig)

    config = SampleConfig()
    assert repr(config) == "SampleConfig(b='B', child=SampleChildConfig(a='A'))"
