from copy import deepcopy
from typing import Any

import pytest

from clan_lib.errors import ClanError
from clan_lib.persist.patch_engine import (
    calc_patches,
    calculate_static_data,
    merge_objects,
)
from clan_lib.persist.path_utils import (
    delete_by_path,
    set_value_by_path,
    set_value_by_path_tuple,
)
from clan_lib.persist.write_rules import compute_write_map

# --- calculate_static_data ---


def test_calculate_static_data_basic() -> None:
    all_values = {
        "name": "example",
        "version": 1,
        "settings": {
            "optionA": True,
            "optionB": False,
            "listSetting": [1, 2, 3, 4],
        },
        "staticOnly": "staticValue",
    }
    persisted = {
        "name": "example",
        "version": 1,
        "settings": {
            "optionA": True,
            "listSetting": [2, 3],
        },
    }

    expected_static = {
        ("settings", "optionB"): False,
        ("settings", "listSetting"): [1, 4],
        ("staticOnly",): "staticValue",
    }

    static_data = calculate_static_data(all_values, persisted)
    assert static_data == expected_static


def test_calculate_static_data_no_static() -> None:
    all_values = {
        "name": "example",
        "version": 1,
        "settings": {
            "optionA": True,
            "listSetting": [1, 2, 3],
        },
    }
    persisted = {
        "name": "example",
        "version": 1,
        "settings": {
            "optionA": True,
            "listSetting": [1, 2, 3],
        },
    }

    expected_static: dict = {}

    static_data = calculate_static_data(all_values, persisted)
    assert static_data == expected_static


# See: https://git.clan.lol/clan/clan-core/issues/5231
# Uncomment this test if the issue is resolved
# def test_calculate_static_data_no_static_sets() -> None:
#     all_values = {
#         "instance": {
#             "hello": {
#                 "roles": {
#                     "default": {
#                         "machines": {
#                             "jon": {
#                                 # Default
#                                 "settings": {

#                                 }
#                             }
#                         }
#                     }
#                 }
#             }
#         }
#     }
#     persisted = {
#         "instance": {
#             "hello": {
#                 "roles": {
#                     "default": {
#                         "machines": {
#                             "jon": {

#                             }
#                         }
#                     }
#                 }
#             }
#         }
#     }

#     expected_static: dict = {}

#     static_data = calculate_static_data(all_values, persisted)
#     assert static_data == expected_static


def test_calculate_static_data_all_static() -> None:
    all_values = {
        "name": "example",
        "version": 1,
        "settings": {
            "optionA": True,
            "listSetting": [1, 2, 3],
        },
        "staticOnly": "staticValue",
    }
    persisted: dict = {}

    expected_static = {
        ("name",): "example",
        ("version",): 1,
        ("settings", "optionA"): True,
        ("settings", "listSetting"): [1, 2, 3],
        ("staticOnly",): "staticValue",
    }

    static_data = calculate_static_data(all_values, persisted)
    assert static_data == expected_static


def test_calculate_static_data_empty_all_values() -> None:
    # This should never happen in practice, but we test it for completeness.
    # Maybe this should emit a warning in the future?
    all_values: dict = {}
    persisted = {
        "name": "example",
        "version": 1,
    }

    expected_static: dict = {}

    static_data = calculate_static_data(all_values, persisted)
    assert static_data == expected_static


def test_calculate_nested_dicts() -> None:
    all_values = {
        "level1": {
            "level2": {
                "staticKey": "staticValue",
                "persistedKey": "persistedValue",
            },
            "anotherStatic": 42,
        },
        "topLevelStatic": True,
    }
    persisted = {
        "level1": {
            "level2": {
                "persistedKey": "persistedValue",
            },
        },
    }

    expected_static = {
        ("level1", "level2", "staticKey"): "staticValue",
        ("level1", "anotherStatic"): 42,
        ("topLevelStatic",): True,
    }

    static_data = calculate_static_data(all_values, persisted)
    assert static_data == expected_static


def test_dot_in_keys() -> None:
    all_values = {
        "key.foo": "staticValue",
        "key": {
            "foo": "anotherStaticValue",
        },
    }
    persisted: dict = {}

    expected_static = {
        ("key.foo",): "staticValue",
        ("key", "foo"): "anotherStaticValue",
    }

    static_data = calculate_static_data(all_values, persisted)

    assert static_data == expected_static


# --------- calc_patches ---------


def test_update_simple() -> None:
    prios = {
        "foo": {
            "__prio": 100,  # <- writeable: "foo"
            "bar": {"__prio": 1000},  # <- writeable: mkDefault "foo.bar"
            "nix": {"__prio": 100},  # <- non writeable: "foo.bar" (defined in nix)
        },
    }

    data_eval = {"foo": {"bar": "baz", "nix": "this is set in nix"}}

    data_disk: dict = {}

    writeables = compute_write_map(prios, data_eval, data_disk)

    assert writeables == {
        "writeable": {("foo",), ("foo", "bar")},
        "non_writeable": {("foo", "nix")},
    }
    update = {
        "foo": {
            "bar": "new value",  # <- user sets this value
            # If the user would have set this value, it would trigger an error
            "nix": "this is set in nix",  # <- user didnt touch this value
        },
    }
    patchset, delete_set = calc_patches(
        data_disk,
        update,
        all_values=data_eval,
        writeables=writeables,
    )

    assert patchset == {("foo", "bar"): "new value"}
    assert delete_set == set()


def test_update_add_empty_dict() -> None:
    prios = {
        "foo": {
            "__prio": 100,  # <- writeable: "foo"
            "nix": {"__prio": 100},  # <- non writeable: "foo.nix" (defined in nix)
        },
    }

    data_eval: dict = {"foo": {"nix": {}}}

    data_disk: dict = {}

    writeables = compute_write_map(prios, data_eval, data_disk)

    update = deepcopy(data_eval)

    set_value_by_path_tuple(update, ("foo", "mimi"), {})

    patchset, delete_set = calc_patches(
        data_disk,
        update,
        all_values=data_eval,
        writeables=writeables,
    )

    assert patchset == {("foo", "mimi"): {}}  # this is what gets persisted
    assert delete_set == set()


def test_update_many() -> None:
    prios = {
        "foo": {
            "__prio": 100,  # <- writeable: "foo"
            "bar": {"__prio": 100},  # <-
            "nix": {"__prio": 100},  # <- non writeable: "foo.nix" (defined in nix)
            "nested": {
                "__prio": 100,
                "x": {"__prio": 100},  # <- writeable: "foo.nested.x"
                "y": {"__prio": 100},  # <- non-writeable: "foo.nested.y"
            },
        },
    }

    data_eval = {
        "foo": {
            "bar": "baz",
            "nix": "this is set in nix",
            "nested": {"x": "x", "y": "y"},
        },
    }

    data_disk = {"foo": {"bar": "baz", "nested": {"x": "x"}}}

    writeables = compute_write_map(prios, data_eval, data_disk)

    assert writeables == {
        "writeable": {
            ("foo",),
            ("foo", "bar"),
            ("foo", "nested"),
            ("foo", "nested", "x"),
        },
        "non_writeable": {("foo", "nix"), ("foo", "nested", "y")},
    }

    update = {
        "foo": {
            "bar": "new value for bar",  # <- user sets this value
            "nix": "this is set in nix",  # <- user cannot set this value
            "nested": {
                "x": "new value for x",  # <- user sets this value
                "y": "y",  # <- user cannot set this value
            },
        },
    }
    patchset, delete_set = calc_patches(
        data_disk,
        update,
        all_values=data_eval,
        writeables=writeables,
    )

    assert patchset == {
        ("foo", "bar"): "new value for bar",
        ("foo", "nested", "x"): "new value for x",
    }
    assert delete_set == set()


def test_update_parent_non_writeable() -> None:
    prios = {
        "foo": {
            "__prio": 50,  # <- non-writeable: "foo"
            "bar": {"__prio": 1000},  # <- writeable: mkDefault "foo.bar"
        },
    }

    data_eval = {
        "foo": {
            "bar": "baz",
        },
    }

    data_disk = {
        "foo": {
            "bar": "baz",
        },
    }

    writeables = compute_write_map(prios, data_eval, data_disk)

    assert writeables == {
        "writeable": set(),
        "non_writeable": {("foo",), ("foo", "bar")},
    }

    update = {
        "foo": {
            "bar": "new value",  # <- user sets this value
        },
    }
    with pytest.raises(ClanError) as error:
        calc_patches(data_disk, update, all_values=data_eval, writeables=writeables)

    assert "Path 'foo.bar' is readonly." in str(error.value)


# TODO: Resolve the issue https://git.clan.lol/clan/clan-core/issues/5231
# def test_remove_non_writable_attrs() -> None:
#     prios = {
#         "foo": {
#             "__prio": 100,  # <- writeable: "foo"
#         },
#     }

#     data_eval: dict = {"foo": {"bar": {}, "baz": {}}}

#     data_disk: dict = {}

#     writeables = compute_write_map(prios, data_eval, data_disk)

#     update: dict = {
#         "foo": {
#             "bar": {},  # <- user leaves this value
#             # User removed "baz"
#         },
#     }

#     with pytest.raises(ClanError) as error:
#         calc_patches(data_disk, update, all_values=data_eval, writeables=writeables)

#     assert "Cannot delete path 'foo.baz'" in str(error.value)


def test_update_list() -> None:
    prios = {
        "foo": {
            "__prio": 100,  # <- writeable: "foo"
        },
    }

    data_eval = {
        #  [ "A" ]  is defined in nix.
        "foo": ["A", "B"],
    }

    data_disk = {"foo": ["B"]}

    writeables = compute_write_map(prios, data_eval, data_disk)

    assert writeables == {"writeable": {("foo",)}, "non_writeable": set()}

    # Add "C" to the list
    update = {"foo": ["A", "B", "C"]}  # User wants to add "C"

    patchset, _ = calc_patches(
        data_disk,
        update,
        all_values=data_eval,
        writeables=writeables,
    )

    assert patchset == {("foo",): ["B", "C"]}

    # "foo": ["A", "B"]
    # Remove "B" from the list
    # Expected is [ ] because ["A"] is defined in nix
    update = {"foo": ["A"]}  # User wants to remove "B"

    patchset, _ = calc_patches(
        data_disk,
        update,
        all_values=data_eval,
        writeables=writeables,
    )

    assert patchset == {("foo",): []}


def test_update_list_duplicates() -> None:
    prios = {
        "foo": {
            "__prio": 100,  # <- writeable: "foo"
        },
    }

    data_eval = {
        #  [ "A" ]  is defined in nix.
        "foo": ["A", "B"],
    }

    data_disk = {"foo": ["B"]}

    writeables = compute_write_map(prios, data_eval, data_disk)

    assert writeables == {"writeable": {("foo",)}, "non_writeable": set()}

    # Add "A" to the list
    update = {"foo": ["A", "B", "A"]}  # User wants to add duplicate "A"

    with pytest.raises(ClanError) as error:
        calc_patches(data_disk, update, all_values=data_eval, writeables=writeables)

    assert "Path 'foo' contains list duplicates: ['A']" in str(error.value)


def test_dont_persist_defaults() -> None:
    """Default values should not be persisted to disk if not explicitly requested by the user."""
    prios = {
        "enabled": {"__prio": 1500},
        "config": {"__prio": 100},
    }
    data_eval = {
        "enabled": True,
        "config": {"foo": "bar"},
    }
    data_disk: dict[str, Any] = {}
    writeables = compute_write_map(prios, data_eval, data_disk)
    assert writeables == {
        "writeable": {("config",), ("enabled",)},
        "non_writeable": set(),
    }

    update = deepcopy(data_eval)
    set_value_by_path(update, "config.foo", "foo")

    patchset, delete_set = calc_patches(
        data_disk,
        update,
        all_values=data_eval,
        writeables=writeables,
    )
    assert patchset == {("config", "foo"): "foo"}
    assert delete_set == set()


def test_set_null() -> None:
    data_eval: dict = {
        "foo": {},
        "bar": {},
    }
    data_disk = data_eval

    # User set Foo to null
    # User deleted bar
    update = {"foo": None}

    patchset, delete_set = calc_patches(
        data_disk,
        update,
        all_values=data_eval,
        writeables=compute_write_map(
            {"__prio": 100, "foo": {"__prio": 100}},
            data_eval,
            data_disk,
        ),
    )
    assert patchset == {("foo",): None}
    assert delete_set == {("bar",)}


def test_machine_delete() -> None:
    prios = {
        "machines": {"__prio": 100},
    }
    data_eval = {
        "machines": {
            "foo": {"name": "foo"},
            "bar": {"name": "bar"},
            "naz": {"name": "naz"},
        },
    }
    data_disk = data_eval

    writeables = compute_write_map(prios, data_eval, data_disk)
    assert writeables == {"writeable": {("machines",)}, "non_writeable": set()}

    # Delete machine "bar"  from the inventory
    update = deepcopy(data_eval)
    delete_by_path(update, "machines.bar")

    patchset, delete_set = calc_patches(
        data_disk,
        update,
        all_values=data_eval,
        writeables=writeables,
    )

    assert patchset == {}
    assert delete_set == {("machines", "bar")}


def test_update_mismatching_update_type() -> None:
    prios = {
        "foo": {
            "__prio": 100,  # <- writeable: "foo"
        },
    }

    data_eval = {"foo": ["A", "B"]}

    data_disk: dict = {}

    writeables = compute_write_map(prios, data_eval, data_disk)

    assert writeables == {"writeable": {("foo",)}, "non_writeable": set()}

    # set foo to an int but it is a list
    update: dict = {"foo": 1}

    with pytest.raises(ClanError) as error:
        calc_patches(data_disk, update, all_values=data_eval, writeables=writeables)

    assert (
        "Type mismatch for path 'foo'. Cannot update <class 'list'> with <class 'int'>"
        in str(error.value)
    )


def test_delete_key() -> None:
    prios = {
        "foo": {
            "__prio": 100,  # <- writeable: "foo"
        },
    }

    data_eval = {"foo": {"bar": "baz"}}

    data_disk = data_eval

    writeables = compute_write_map(prios, data_eval, data_disk)

    assert writeables == {"writeable": {("foo",)}, "non_writeable": set()}

    # remove all keys from foo
    update: dict = {"foo": {}}

    patchset, delete_set = calc_patches(
        data_disk,
        update,
        all_values=data_eval,
        writeables=writeables,
    )

    assert patchset == {("foo",): {}}
    assert delete_set == {("foo", "bar")}


def test_delete_key_intermediate() -> None:
    prios = {
        "foo": {
            "__prio": 100,
        },
    }

    data_eval = {
        "foo": {
            # Remove the key "bar"
            "bar": {"name": "bar", "info": "info", "other": ["a", "b"]},
            # Leave the key "other"
            "other": {"name": "other", "info": "info", "other": ["a", "b"]},
        },
    }
    update: dict = {
        "foo": {"other": {"name": "other", "info": "info", "other": ["a", "b"]}},
    }

    data_disk = data_eval

    writeables = compute_write_map(prios, data_eval, data_disk)

    assert writeables == {"writeable": {("foo",)}, "non_writeable": set()}

    # remove all keys from foo

    patchset, delete_set = calc_patches(
        data_disk,
        update,
        all_values=data_eval,
        writeables=writeables,
    )

    assert patchset == {}
    assert delete_set == {("foo", "bar")}


def test_delete_key_non_writeable() -> None:
    prios = {
        "foo": {
            "__prio": 50,
        },
    }

    data_eval = {
        "foo": {
            # Remove the key "bar"
            "bar": {"name": "bar", "info": "info", "other": ["a", "b"]},
        },
    }
    update: dict = {"foo": {}}

    data_disk = data_eval

    writeables = compute_write_map(prios, data_eval, data_disk)

    assert writeables == {"writeable": set(), "non_writeable": {("foo",)}}

    # remove all keys from foo
    with pytest.raises(ClanError) as error:
        calc_patches(data_disk, update, all_values=data_eval, writeables=writeables)

    assert "Path 'foo' is readonly." in str(error.value)


# --- test merge_objects ---


def test_merge_objects_empty() -> None:
    obj1: dict[str, int] = {}  # type: ignore[var-annotated]
    obj2: dict[str, int] = {}  # type: ignore[var-annotated]

    merged = merge_objects(obj1, obj2)

    assert merged == {}


def test_merge_objects_basic() -> None:
    obj1 = {"a": 1, "b": 2}
    obj2 = {"b": 3, "c": 4}

    merged = merge_objects(obj1, obj2)

    assert merged == {"a": 1, "b": 3, "c": 4}


def test_merge_objects_simple() -> None:
    obj1 = {"a": 1}
    obj2 = {"a": None}

    # merge_objects should update obj2 with obj1
    # Set a value to None
    merged_order = merge_objects(obj1, obj2)

    assert merged_order == {"a": None}

    # Test reverse merge
    # Set a value from None to 1
    merged_reverse = merge_objects(obj2, obj1)

    assert merged_reverse == {"a": 1}


def test_merge_none_to_value() -> None:
    obj1 = {
        "a": None,
    }
    obj2 = {"a": {"b": 1}}

    merged_obj = merge_objects(obj1, obj2)
    assert merged_obj == {"a": {"b": 1}}

    obj3 = {"a": [1, 2, 3]}
    merged_list = merge_objects(obj1, obj3)
    assert merged_list == {"a": [1, 2, 3]}

    obj4 = {"a": 1}
    merged_int = merge_objects(obj1, obj4)
    assert merged_int == {"a": 1}

    obj5 = {"a": "test"}
    merged_str = merge_objects(obj1, obj5)
    assert merged_str == {"a": "test"}

    obj6 = {"a": True}
    merged_bool = merge_objects(obj1, obj6)
    assert merged_bool == {"a": True}


def test_merge_objects_value_to_none() -> None:
    obj1 = {"a": {"b": 1}}
    obj2 = {"a": None}

    merged_obj = merge_objects(obj1, obj2)
    assert merged_obj == {"a": None}

    obj3 = {"a": [1, 2, 3]}
    merged_list = merge_objects(obj3, obj2)
    assert merged_list == {"a": None}

    obj4 = {"a": 1}
    merged_int = merge_objects(obj4, obj2)
    assert merged_int == {"a": None}

    obj5 = {"a": "test"}
    merged_str = merge_objects(obj5, obj2)
    assert merged_str == {"a": None}

    obj6 = {"a": True}
    merged_bool = merge_objects(obj6, obj2)
    assert merged_bool == {"a": None}


def test_merge_objects_nested() -> None:
    obj1 = {"a": {"b": 1, "c": 2}, "d": 3}
    obj2 = {"a": {"b": 4}, "e": 5}

    merged = merge_objects(obj1, obj2)

    assert merged == {"a": {"b": 4, "c": 2}, "d": 3, "e": 5}


def test_merge_objects_lists() -> None:
    obj1 = {"a": [1, 2], "b": {"c": [3, 4]}}
    obj2 = {"a": [2, 3], "b": {"c": [4, 5]}}

    merged = merge_objects(obj1, obj2)

    # Lists get merged and deduplicated
    # Lists (get sorted, but that is not important)
    # Maybe we shouldn't sort them?
    assert merged == {"a": [1, 2, 3], "b": {"c": [3, 4, 5]}}


def test_merge_objects_unset_list_elements() -> None:
    obj1 = {"a": [1, 2], "b": {"c": [3, 4]}}
    obj2 = {"a": [], "b": {"c": [5]}}

    merged = merge_objects(obj1, obj2, merge_lists=False)

    # Lists get merged and deduplicated
    # None values are not removed
    assert merged == {"a": [], "b": {"c": [5]}}


def test_merge_objects_with_mismatching_nesting() -> None:
    obj1 = {"a": {"b": 1}, "c": 2}
    obj2 = {"a": 3}

    # Merging should raise an error because obj1 and obj2 have different nesting for 'a'
    with pytest.raises(ClanError) as excinfo:
        merge_objects(obj1, obj2)

    assert "Type mismatch for key 'a'" in str(excinfo.value)
