"""
This file is intended to
1. test the basic behavior of SerializableDictDot, in combination with @dataclass, and
2. provides examples of best practice for working with typed objects within the Great Expectations codebase
"""  # noqa: E501 # FIXME CoP

from dataclasses import dataclass, field
from enum import Enum
from typing import List, Optional, Tuple

import pytest
from pytest import raises

from great_expectations.types import DictDot, SerializableDictDot


@dataclass
class MyClassA(SerializableDictDot):
    foo: str
    bar: int


@dataclass
class MyClassB(MyClassA):
    baz: List[str]
    qux: Optional[int] = None
    quux: int = 42

    @property
    def num_bazzes(self):
        return len(self.baz)


class MyEnum(Enum):
    VALUE_X = "x"
    VALUE_Y = "y"
    VALUE_Z = "z"


@dataclass
class MyClassC(SerializableDictDot):
    alpha_var: int
    beta_var: MyEnum
    A_list: List[MyClassA]
    B_list: List[MyClassB]
    enum_list: List[MyEnum] = field(default_factory=list)
    some_tuple: Optional[Tuple[MyClassA, MyClassB]] = None

    @property
    def num_As(self):
        return len(self.A_list)

    @property
    def num_Bs(self):
        return len(self.B_list)


@pytest.mark.unit
def test_access_using_dict_notation():
    "Keys can be accessed using dict notation"
    my_A = MyClassA(
        **{
            "foo": "a string",
            "bar": 1,
        }
    )
    assert my_A["foo"] == "a string"
    assert my_A["bar"] == 1


@pytest.mark.unit
def test_has_keys():
    "the .keys method works"
    my_A = MyClassA(
        **{
            "foo": "a string",
            "bar": 1,
        }
    )
    assert set(my_A.keys()) == {"foo", "bar"}


@pytest.mark.unit
def test_has_items():
    "the .items method works"
    my_A = MyClassA(
        **{
            "foo": "a string",
            "bar": 1,
        }
    )
    items = list(my_A.items())
    assert items == [("foo", "a string"), ("bar", 1)]


@pytest.mark.unit
def test_has_to_raw_dict():
    "the .to_raw_dict method works"
    my_A = MyClassA(
        **{
            "foo": "a string",
            "bar": 1,
        }
    )
    assert my_A.to_raw_dict() == {
        "foo": "a string",
        "bar": 1,
    }


@pytest.mark.skip(reason="Not sure what our preferred pattern for this is")
@pytest.mark.unit
def test_incorrect_type():
    "Throws an error if instantiated with an incorrect type"
    MyClassA(**{"foo": "a string", "bar": "SHOULD BE AN INT", "baz": ["a", "b", "c"]})


@pytest.mark.unit
def test_renders_to_a_useful_str():
    "Renders to a string that exposes the internals of the object"
    assert (
        MyClassA(
            foo="a string",
            bar=1,
        ).__str__()
        == """MyClassA(foo='a string', bar=1)"""
    )


@pytest.mark.skip(reason="hmmm. We should be able to make this work by default")
@pytest.mark.unit
def test_is_json_serializable():
    assert MyClassA(
        foo="a string",
        bar=1,
    ).to_json_dict() == {"foo": "a string", "bar": 1}


@pytest.mark.unit
def test_missing_keys():
    "Throws a TypeError if instantiated with missing arguments"

    with raises(TypeError):
        MyClassA()

    with raises(TypeError):
        MyClassA(
            foo="a string",
        )

    with raises(TypeError):
        MyClassA(bar=1)


@pytest.mark.unit
def test_extra_keys():
    "Throws a TypeError if instantiated with extra arguments"

    with raises(TypeError):
        MyClassA(**{"foo": "a string", "bar": 1, "baz": ["a", "b", "c"]})


@pytest.mark.unit
def test_update_after_instantiation():
    "Can be updated after instantiation, using both dot and dict notation"

    my_A = MyClassA(
        **{
            "foo": "a string",
            "bar": 1,
        }
    )
    assert my_A["foo"] == "a string"
    assert my_A["bar"] == 1

    my_A.foo = "different string"
    assert my_A["foo"] == "different string"
    assert my_A.foo == "different string"

    my_A["foo"] = "a third string"
    assert my_A["foo"] == "a third string"
    assert my_A.foo == "a third string"


@pytest.mark.unit
def test_can_be_subclassed():
    my_B = MyClassB(
        foo="a string",
        bar=1,
        baz=["a", "b", "c"],
        qux=-100,
        quux=43,
    )

    assert my_B["foo"] == "a string"
    assert my_B["bar"] == 1
    assert my_B["baz"] == ["a", "b", "c"]
    assert my_B["qux"] == -100
    assert my_B["quux"] == 43

    assert my_B.foo == "a string"
    assert my_B.bar == 1
    assert my_B.baz == ["a", "b", "c"]
    assert my_B.qux == -100
    assert my_B.quux == 43

    my_B = MyClassB(
        **{
            "foo": "a string",
            "bar": 1,
            "baz": ["a", "b", "c"],
            "qux": -100,
            "quux": 43,
        }
    )

    assert my_B["foo"] == "a string"
    assert my_B["bar"] == 1
    assert my_B["baz"] == ["a", "b", "c"]
    assert my_B["qux"] == -100
    assert my_B["quux"] == 43

    assert my_B.foo == "a string"
    assert my_B.bar == 1
    assert my_B.baz == ["a", "b", "c"]
    assert my_B.qux == -100
    assert my_B.quux == 43


@pytest.mark.unit
def test_can_have_derived_properties():
    "Can have derived properties"
    my_B = MyClassB(
        **{
            "foo": "a string",
            "bar": 1,
            "baz": ["a", "b", "c"],
            "qux": -100,
        }
    )
    assert my_B.num_bazzes == 3


@pytest.mark.unit
def test_can_have_optional_arguments():
    "Can have optional arguments"
    my_B = MyClassB(
        **{
            "foo": "a string",
            "bar": 1,
            "baz": ["a", "b", "c"],
            # "qux": -100, #Optional property
        }
    )
    assert my_B.qux is None


@pytest.mark.unit
def test_can_have_default_values():
    "Can have default values"
    my_B = MyClassB(
        **{
            "foo": "a string",
            "bar": 1,
            "baz": ["a", "b", "c"],
            "qux": -100,  # Optional property
        }
    )
    assert my_B.quux == 42


@pytest.mark.unit
def test_can_use_positional_arguments():
    "Can use a normal mix of positional and keyword arguments"
    my_B = MyClassB(
        "a string",
        1,
        baz=["a", "b", "c"],
        qux=-100,
    )
    assert my_B.foo == "a string"
    assert my_B.bar == 1
    assert my_B.baz == ["a", "b", "c"]
    assert my_B.qux == -100

    my_B = MyClassB(
        qux=-100,
        baz=["a", "b", "c"],
        bar=1,
        foo="a string",
    )
    assert my_B.foo == "a string"
    assert my_B.bar == 1
    assert my_B.baz == ["a", "b", "c"]
    assert my_B.qux == -100


@pytest.mark.unit
def test_can_be_nested():
    "Objects of this type can be nested"

    my_C = MyClassC(
        alpha_var=20,
        beta_var=MyEnum("x"),
        A_list=[
            MyClassA(
                foo="A-1",
                bar=101,
            ),
            MyClassA(
                **{
                    "foo": "A-2",
                    "bar": 102,
                }
            ),
        ],
        B_list=[
            MyClassB(
                **{
                    "foo": "B-1",
                    "bar": 201,
                    "baz": ["a", "b", "c"],
                    "qux": -100,
                    "quux": 43,
                }
            )
        ],
    )

    # Demonstrate that we can access sub-objects using a mix of dict and dot notation:
    assert my_C.A_list[0].foo == "A-1"
    assert my_C["A_list"][1].bar == 102
    assert my_C["B_list"][0]["quux"] == 43

    # Note: we don't currently support dot notation access within lists: `assert my_C["A_list"].1.bar == 102`  # noqa: E501 # FIXME CoP

    # Demonstrate that we can access Enum sub-objects
    assert my_C["beta_var"] == MyEnum("x")


@pytest.mark.unit
def test_to_raw_dict_works_recursively():
    "the .to_raw_dict method works recursively on both DotDicts and Enums"

    my_C = MyClassC(
        alpha_var=20,
        beta_var=MyEnum("x"),
        A_list=[
            MyClassA(
                foo="A-1",
                bar=101,
            ),
            MyClassA(
                **{
                    "foo": "A-2",
                    "bar": 102,
                }
            ),
        ],
        B_list=[
            MyClassB(
                **{
                    "foo": "B-1",
                    "bar": 201,
                    "baz": ["a", "b", "c"],
                    "qux": -100,
                    "quux": 43,
                }
            )
        ],
        enum_list=[
            MyEnum("x"),
            MyEnum("y"),
            MyEnum("z"),
        ],
        some_tuple=(
            MyClassA(
                foo="A-1",
                bar=101,
            ),
            MyClassB(
                foo="B-1",
                bar=201,
                baz=["a", "b", "c"],
                qux=-100,
                quux=43,
            ),
        ),
    )

    C_dict = my_C.to_raw_dict()

    # Make sure it's a dictionary, not a DictDot
    assert type(C_dict) == dict  # noqa: E721 # FIXME CoP
    assert isinstance(C_dict, DictDot) is False
    # Dictionaries don't support dot notation.
    with raises(AttributeError):
        C_dict.A_list  # noqa: B018 # FIXME CoP

    assert type(C_dict["A_list"][0]) == dict  # noqa: E721 # FIXME CoP
    assert type(C_dict["B_list"][0]) == dict  # noqa: E721 # FIXME CoP

    assert C_dict == {
        "alpha_var": 20,
        "beta_var": "x",
        "A_list": [
            {
                "foo": "A-1",
                "bar": 101,
            },
            {
                "foo": "A-2",
                "bar": 102,
            },
        ],
        "B_list": [
            {
                "foo": "B-1",
                "bar": 201,
                "baz": ["a", "b", "c"],
                "qux": -100,
                "quux": 43,
            }
        ],
        "enum_list": [
            "x",
            "y",
            "z",
        ],
        "some_tuple": [
            {
                "foo": "A-1",
                "bar": 101,
            },
            {
                "foo": "B-1",
                "bar": 201,
                "baz": ["a", "b", "c"],
                "qux": -100,
                "quux": 43,
            },
        ],
    }


@pytest.mark.unit
def test_instantiation_with_a_from_legacy_dict_method():
    """Can be instantiated from a legacy dictionary.

    Note: This pattern is helpful for cases where we're migrating from dictionary-based objects to typed objects.
    One especially thorny example is when the dictionary contains keys that are reserved words in python.

    For example, test cases use the reserved word: "in" as one of their required fields.
    """  # noqa: E501 # FIXME CoP

    import inspect
    import logging

    @dataclass
    class MyClassE(SerializableDictDot):
        foo: str
        bar: int
        input: int

        @classmethod
        def from_legacy_dict(cls, dict):
            """This method is an adapter to allow typing of legacy my_class_e dictionary objects, without needing to immediately clean up every object."""  # noqa: E501 # FIXME CoP
            temp_dict = {}
            for k, v in dict.items():
                # Ignore parameters that don't match the type definition
                if k in inspect.signature(cls).parameters:
                    temp_dict[k] = v
                else:  # noqa: PLR5501 # FIXME CoP
                    if k == "in":
                        temp_dict["input"] = v
                    else:
                        logging.warning(
                            f"WARNING: Got extra parameter: {k} while instantiating MyClassE."
                            "This parameter will be ignored."
                            "You probably need to clean up a library_metadata object."
                        )

            return cls(**temp_dict)

    my_E = MyClassE.from_legacy_dict(
        {
            "foo": "a string",
            "bar": 1,
            "in": 10,
        }
    )

    assert my_E["foo"] == "a string"
    assert my_E["bar"] == 1
    assert my_E["input"] == 10
    assert my_E.input == 10

    # Note that after instantiation, the class does NOT have an "in" property
    with raises(AttributeError):
        my_E["in"] == 10  # noqa: B015 # FIXME CoP

    # Because `in` is a reserved word, this will raise a SyntaxError:
    # my_F.in == 100

    # Because `in` is a reserved word, this will also raise a SyntaxError:
    # my_F.in = 100
