import re
from datetime import datetime
from decimal import Decimal
from ipaddress import IPv4Address, IPv4Interface, IPv4Network, IPv6Address, IPv6Interface, IPv6Network

import botocore.exceptions
import pytest

from dyntastic import A, Attr

from .conftest import MyNestedModel


def test_must_provide_actions(item):
    with pytest.raises(ValueError, match="Must provide at least one action to update"):
        item.update()


def test_set_new_attribute(item_no_my_str):
    item_no_my_str.update(A.my_str.set("bar"))
    assert item_no_my_str.my_str == "bar"


def test_set_existing_attribute(item):
    new_data = {
        "my_str": "new_value",
        "my_datetime": datetime(2022, 2, 13, 0, 0, 0),
        "my_bytes": b"new_bytes",
        "my_int": 6,
        "my_decimal": Decimal("2.5"),
        "my_str_set": {"d", "e"},
        "my_bytes_set": {b"e", b"f"},
        "my_int_set": {3, 4},
        "my_str_list": ["e", "f"],
        "my_int_list": [40, 50],
        "my_dict": {"d": 4, "e": 5},
        "my_nested_data": {"foo": ["bar", "baz", "bat"]},
        "my_nested_model": MyNestedModel(sample_field="updated"),
        "my_ipv4_address": IPv4Address("99.66.0.1"),
        "my_ipv4_interface": IPv4Interface("99.66.0.1"),
        "my_ipv4_network": IPv4Network("99.66.0.1/32"),
        "my_ipv6_address": IPv6Address("991:db8::"),
        "my_ipv6_interface": IPv6Interface("991:db8::"),
        "my_ipv6_network": IPv6Network("9901:db8::1000/124"),
    }

    updates = [getattr(A, key).set(value) for key, value in new_data.items()]
    item.update(*updates)
    item.refresh()
    refreshed_data = dict(item)
    refreshed_data.pop("id")
    refreshed_data.pop("timestamp", None)
    assert refreshed_data == new_data


def test_refresh_alias_item(alias_item):
    alias_item.update(A.my_str.set("bar"))
    assert alias_item.my_str == "bar"


def test_no_refresh(item):
    item.update(A.my_str.set("bar"), refresh=False)
    with pytest.raises(
        ValueError,
        match=re.escape(
            "Dyntastic instance was not refreshed after update. "
            "Call refresh(), or use ignore_unrefreshed() to ignore safety checks"
        ),
    ):
        item.my_str

    # make sure these attributes always are accessible
    assert item.ConditionException
    assert item.refresh
    assert item.ignore_unrefreshed

    item.refresh()
    assert item.my_str == "bar"


def test_ignore_refresh(item):
    item.update(A.my_str.set("bar"), refresh=False)
    with pytest.raises(
        ValueError,
        match=re.escape(
            "Dyntastic instance was not refreshed after update. "
            "Call refresh(), or use ignore_unrefreshed() to ignore safety checks"
        ),
    ):
        item.my_str

    # make sure these attributes always are accessible
    assert item.ConditionException
    assert item.refresh
    assert item.ignore_unrefreshed

    item.ignore_unrefreshed()
    assert item.my_str == "foo"

    item.refresh()
    assert item.my_str == "bar"


def test_update_with_Attr(item):
    item.update(Attr.my_str.set("bar"))
    assert item.my_str == "bar"


def test_update_nested_string(item):
    item.update(A("my_nested_model.sample_field").set("new_value"))
    assert item.my_nested_model.sample_field == "new_value"


def test_set_and_remove_multiple_attributes(item):
    item.update(A.my_str.set("new_value"), A.my_int.set(500), A.my_decimal.remove(), A.my_dict.remove())
    assert item.my_str == "new_value"
    assert item.my_int == 500
    assert item.my_decimal is None
    assert item.my_dict is None


def test_set_if_not_exist_exists(item):
    item.update(A.my_str.set(A.my_str.if_not_exists("bar")))
    assert item.my_str == "foo"


def test_set_if_not_exist_does_not_exists(item_no_my_str):
    item_no_my_str.update(A.my_str.set(A.my_str.if_not_exists("bar")))
    assert item_no_my_str.my_str == "bar"


def test_set_default_exists(item):
    item.update(A.my_str.set_default("bar"))
    assert item.my_str == "foo"


def test_set_default_does_not_exist(item_no_my_str):
    item_no_my_str.update(A.my_str.set_default("bar"))
    assert item_no_my_str.my_str == "bar"


def test_set_int_plus(item):
    item.update(A.my_int.set(A.my_int + 100))
    assert item.my_int == 105


def test_set_int_rplus(item):
    item.update(A.my_int.set(100 + A.my_int))
    assert item.my_int == 105


def test_set_int_minus(item):
    item.update(A.my_int.set(A.my_int - 100))
    assert item.my_int == -95


def test_set_int_rminus(item):
    item.update(A.my_int.set(100 - A.my_int))
    assert item.my_int == 95


def test_list_append(item):
    item.update(A.my_str_list.set(A.my_str_list.list_append("z")))
    assert item.my_str_list == ["a", "b", "c", "d", "z"]


def test_list_append_if_not_exists(item_no_my_str_list):
    item = item_no_my_str_list

    default_list = A.my_str_list.if_not_exists(["y"])
    # TODO: fix type hinting around this pattern
    appended_list = A.list_append(default_list, "z")

    item.update(A.my_str_list.set(appended_list))
    assert item.my_str_list == ["y", "z"]


def test_append(item):
    item.update(A.my_str_list.append("z"))
    assert item.my_str_list == ["a", "b", "c", "d", "z"]


def test_remove_attribute(item):
    item.update(A.my_str.remove())
    assert item.my_str is None
    item.update(A.my_str_list.remove())
    assert item.my_str_list is None


def test_remove_list_element(item):
    item.update(A.my_str_list.remove(0))
    assert item.my_str_list == ["b", "c", "d"]


def test_add_single_string_to_set(item):
    item.update(A.my_str_set.add("z"))
    assert item.my_str_set == {"a", "b", "c", "z"}


@pytest.mark.parametrize("collection_type", [list, tuple, set])
def test_add_multiple_strings_to_set(collection_type, item):
    item.update(A.my_str_set.add(collection_type(["y", "z"])))
    assert item.my_str_set == {"a", "b", "c", "y", "z"}


def test_add_single_string_to_new_set(item_no_my_str_set):
    item = item_no_my_str_set
    item.update(A.my_str_set.add("z"))
    assert item.my_str_set == {"z"}


@pytest.mark.parametrize("collection_type", [list, tuple, set])
def test_add_multiple_strings_to_new_set(collection_type, item_no_my_str_set):
    item = item_no_my_str_set
    item.update(A.my_str_set.add(collection_type(["y", "z"])))
    assert item.my_str_set == {"y", "z"}


def test_cannot_add_int_to_set(item):
    with pytest.raises(
        botocore.exceptions.ClientError, match="An operand in the update expression has an incorrect data type"
    ):
        item.update(A.my_int_set.add(5))


def test_add_int(item):
    item.update(A.my_int.add(100))
    assert item.my_int == 105


def test_add_new_int(item_no_my_int):
    item = item_no_my_int
    item.update(A.my_int.add(100))
    assert item.my_int == 100


def test_add_decimal(item):
    item.update(A.my_decimal.add(Decimal("3.25")))
    assert item.my_decimal == Decimal("4.75")


def test_add_new_decimal(item_no_my_decimal):
    item = item_no_my_decimal
    item.update(A.my_decimal.add(Decimal("100.5")))
    assert item.my_decimal == Decimal("100.5")


def test_delete_single_string_from_set(item):
    item.update(A.my_str_set.delete("a"))
    assert item.my_str_set == {"b", "c"}


@pytest.mark.parametrize("collection_type", [list, tuple, set])
def test_delete_multiple_strings(collection_type, item):
    item.update(A.my_str_set.delete(collection_type(["a", "b"])))
    assert item.my_str_set == {"c"}


def test_delete_all_strings(item):
    item.update(A.my_str_set.delete(item.my_str_set))
    assert item.my_str_set is None


@pytest.fixture(params=["success", "failure", "raise_failure"])
def condition_tester(request):
    action = A.my_str.set("new_value")

    def tester(success_case, failure_case, item):
        if request.param == "success":
            item.update(action, condition=success_case, require_condition=True)
            assert item.my_str == "new_value"
        elif request.param == "failure":
            item.update(action, condition=failure_case)
            assert item.my_str == "foo"
        else:
            with pytest.raises(item.ConditionException(), match="The conditional request failed"):
                item.update(action, condition=failure_case, require_condition=True)

            item.refresh()
            assert item.my_str == "foo"

    return tester


nested_str = A("my_nested_model.sample_field")


@pytest.mark.parametrize(
    "success,failure",
    [
        # equality (all data types to check serialization)
        (A.my_str == "foo", A.my_str == "non-matching"),
        (A.my_str.eq("foo"), A.my_str.eq("non-matching")),
        (A.my_datetime == datetime(2022, 2, 12, 12, 26, 35), A.my_datetime == datetime(2022, 3, 1)),
        (A.my_bytes == b"foobar", A.my_bytes == b"not this"),
        (A.my_int == 5, A.my_int == -1000),
        (A.my_decimal == Decimal("1.5"), A.my_decimal == Decimal("100.5")),
        (A.my_str_set == {"a", "b", "c"}, A.my_str_set == {"x", "y"}),
        (A.my_bytes_set == {b"a", b"b", b"c"}, A.my_bytes_set == {b"x", b"y"}),
        (A.my_int_set == {1, 2}, A.my_int_set == {5, 6}),
        (A.my_str_list == ["a", "b", "c", "d"], A.my_str_list == ["x", "y", "z"]),
        (A.my_int_list == [10, 20, 30], A.my_int_list == [500, 600]),
        (A.my_dict == {"a": 1, "b": 2, "c": 3}, A.my_dict == {"x": 100, "y": 200}),
        (
            A.my_nested_data == [{"a": [{"foo": "bar"}], "b": "test"}, "some_string"],
            A.my_nested_data == ["not", {"this": "data"}],
        ),
        (nested_str == "hello", nested_str == "non-matching"),
        # inequality
        (A.my_str != "non-matching", A.my_str != "foo"),
        (A.my_str.ne("non-matching"), A.my_str.ne("foo")),
        (A.my_int != 5000, A.my_int != 5),
        (nested_str != "non-matching", nested_str != "hello"),
        (nested_str.ne("non-matching"), nested_str.ne("hello")),
        # </<=/>/>=
        (A.my_int < 10, A.my_int < 0),
        (A.my_int <= 5, A.my_int <= 4),
        (A.my_str < "zzz", A.my_str < "abc"),
        (A.my_str_set < {"a", "b", "c", "d"}, A.my_str_set < {"z"}),
        (A.my_int > 0, A.my_int > 10),
        (A.my_int >= 5, A.my_int >= 6),
        (A.my_str > "fff", A.my_str > "zzz"),
        (nested_str > "he", nested_str > "hello"),
        (nested_str >= "hello", nested_str >= "hello2"),
        (nested_str < "hello2", nested_str < "hello"),
        (nested_str <= "hello", nested_str <= "he"),
        # string operators
        (A.my_str.begins_with("f"), A.my_str.begins_with("z")),
        (A.my_str.between("f", "food"), A.my_str.between("z", "zebra")),
        (A.my_str.is_in({"foo", "bar", "baz"}), A.my_str.is_in({"d", "e"})),
        (A.my_str.contains("fo"), A.my_str.contains("blah")),
        (A.my_str_set.contains("a"), A.my_str_set.contains(A.my_str)),
        (nested_str.begins_with("h"), nested_str.begins_with("z")),
        (nested_str.between("h", "hello2"), nested_str.between("h", "he")),
        (nested_str.is_in({"a", "hello", "b"}), nested_str.is_in({"a", "b"})),
        (nested_str.contains("hel"), nested_str.contains("foo")),
        (A.my_str_set.contains("a"), A.my_str_set.contains(A.my_str)),
        # attribute_type/size/existence functions
        (A.my_str.attribute_type("S"), A.my_str.attribute_type("N")),
        (A.my_str.size < 15, A.my_str.size > 20),
        (A.my_str.size == 3, ~(A.my_str.size == 3)),
        (A.my_str.exists(), A.my_str.not_exists()),
        (nested_str.attribute_type("S"), nested_str.attribute_type("N")),
        (nested_str.size < 15, nested_str.size > 20),
        (nested_str.size == 5, ~(nested_str.size == 5)),
        (nested_str.exists(), nested_str.not_exists()),
        # logical operators
        (~(A.my_str == "bar"), ~(A.my_str == "foo")),
        ((A.my_str == "foo") | (A.my_str == "bar"), (A.my_str == "foo") & (A.my_int == 10)),
    ],
)
def test_update_condition(success, failure, condition_tester, item):
    condition_tester(success, failure, item)
