import copy
import re
from textwrap import dedent
from typing import Any, Tuple

from _pytest.python_api import RaisesContext
from pytest import mark, param, raises

from omegaconf import (
    II,
    SI,
    Container,
    DictConfig,
    IntegerNode,
    ListConfig,
    Node,
    OmegaConf,
    ValidationError,
)
from omegaconf._utils import _ensure_container
from omegaconf.errors import (
    InterpolationKeyError,
    InterpolationResolutionError,
    InterpolationValidationError,
)
from tests import MissingDict, MissingList, StructuredWithMissing, SubscriptedList, User

# file deepcode ignore CopyPasteError:
# The above comment is a statement to stop DeepCode from raising a warning on
# lines that do equality checks of the form
#       c.k == c.k
from tests.interpolation import dereference_node


@mark.parametrize(
    "cfg,key,expected",
    [
        param({"a": "${b}", "b": 10}, "a", 10, id="simple"),
        param(
            {"a": "${x}"},
            "a",
            raises(InterpolationKeyError),
            id="not_found",
        ),
        param(
            {"a": "${x.y}"},
            "a",
            raises(InterpolationKeyError),
            id="not_found",
        ),
        param({"a": "foo_${b}", "b": "bar"}, "a", "foo_bar", id="str_inter"),
        param(
            {"a": "${x}_${y}", "x": "foo", "y": "bar"},
            "a",
            "foo_bar",
            id="multi_str_inter",
        ),
        param({"a": "foo_${b.c}", "b": {"c": 10}}, "a", "foo_10", id="str_deep_inter"),
        param({"a": 10, "b": [1, "${a}"]}, "b.1", 10, id="from_list"),
        param({"a": "${b}", "b": {"c": 10}}, "a", {"c": 10}, id="dict_val"),
        param({"a": "${b}", "b": [1, 2]}, "a", [1, 2], id="list_val"),
        param({"a": "${b.1}", "b": [1, 2]}, "a", 2, id="list_index"),
        param({"a": "X_${b}", "b": [1, 2]}, "a", "X_[1, 2]", id="liststr"),
        param({"a": "X_${b}", "b": {"c": 1}}, "a", "X_{'c': 1}", id="dict_str"),
        param({"a": "${b}", "b": "${c}", "c": 10}, "a", 10, id="two_steps"),
        param({"bar": 10, "foo": ["${bar}"]}, "foo.0", 10, id="inter_in_list"),
        param({"foo": None, "bar": "${foo}"}, "bar", None, id="none"),
        param({"list": ["bar"], "foo": "${list.0}"}, "foo", "bar", id="list"),
        param(
            {"user@domain": 10, "foo": "${user@domain}"}, "foo", 10, id="user@domain"
        ),
        # relative interpolations
        param({"a": "${.b}", "b": 10}, "a", 10, id="relative"),
        param({"a": {"z": "${.b}", "b": 10}}, "a.z", 10, id="relative"),
        param({"a": {"z": "${..b}"}, "b": 10}, "a.z", 10, id="relative"),
        param({"a": {"z": "${..a.b}", "b": 10}}, "a.z", 10, id="relative"),
        param(
            {"a": "${..b}", "b": 10},
            "a",
            raises(InterpolationKeyError),
            id="relative",
        ),
    ],
)
def test_interpolation(cfg: Any, key: str, expected: Any) -> None:
    cfg = _ensure_container(cfg)
    if isinstance(expected, RaisesContext):
        with expected:
            OmegaConf.select(cfg, key)
    else:
        assert OmegaConf.select(cfg, key) == expected


def test_interpolation_with_missing() -> None:
    cfg = OmegaConf.create(
        {
            "a": "${x.missing}.txt",
            "b": "${x.missing}",
            "x": {"missing": "???"},
        }
    )
    assert OmegaConf.is_missing(cfg.x, "missing")
    assert not OmegaConf.is_missing(cfg, "a")
    assert not OmegaConf.is_missing(cfg, "b")


def test_assign_to_interpolation() -> None:
    cfg = OmegaConf.create(
        {"foo": 10, "bar": "${foo}", "typed_bar": IntegerNode("${foo}")}
    )
    assert OmegaConf.is_interpolation(cfg, "bar")
    assert cfg.bar == 10
    assert cfg.typed_bar == 10

    # assign regular field
    cfg.bar = 20
    assert not OmegaConf.is_interpolation(cfg, "bar")

    with raises(ValidationError):
        cfg.typed_bar = "nope"
    cfg.typed_bar = 30

    assert cfg.foo == 10
    assert cfg.bar == 20
    assert cfg.typed_bar == 30


def test_merge_with_interpolation() -> None:
    cfg = OmegaConf.create(
        {
            "foo": 10,
            "bar": "${foo}",
            "typed_bar": IntegerNode("${foo}"),
        }
    )

    assert OmegaConf.merge(cfg, {"bar": 20}) == {"foo": 10, "bar": 20, "typed_bar": 10}
    assert OmegaConf.merge(cfg, {"typed_bar": 30}) == {
        "foo": 10,
        "bar": 10,
        "typed_bar": 30,
    }

    with raises(ValidationError):
        OmegaConf.merge(cfg, {"typed_bar": "nope"})


def test_non_container_interpolation() -> None:
    cfg = OmegaConf.create({"foo": 0, "bar": "${foo.baz}"})
    with raises(InterpolationKeyError):
        cfg.bar


def test_indirect_interpolation() -> None:
    d = {
        "a": {"aa": 10},
        "b": "${a}",
        "c": "${b.aa}",
    }

    cfg = OmegaConf.create(d)
    assert cfg.c == 10
    assert OmegaConf.to_container(cfg, resolve=True) == {
        "a": {"aa": 10},
        "b": {"aa": 10},
        "c": 10,
    }


def test_indirect_interpolation2() -> None:
    d = {
        "a": {"aa": 10},
        "b": "${a.aa}",
        "c": "${b}",
    }

    cfg = OmegaConf.create(d)
    assert cfg.c == 10

    assert OmegaConf.to_container(cfg, resolve=True) == {
        "a": {"aa": 10},
        "b": 10,
        "c": 10,
    }


@mark.parametrize(
    "cfg",
    [
        param({"a": "${b}", "b": "string", "s": "foo_${b}"}, id="str"),
        param({"a": "${b}", "b": True, "s": "foo_${b}"}, id="bool"),
        param({"a": "${b}", "b": 10, "s": "foo_${b}"}, id="int"),
        param({"a": "${b}", "b": 3.14, "s": "foo_${b}"}, id="float"),
    ],
)
def test_type_inherit_type(cfg: Any) -> None:
    cfg = _ensure_container(cfg)
    assert isinstance(cfg.a, type(cfg.b))
    assert type(cfg.s) == str  # check that string interpolations are always strings


def test_interpolation_in_list_key_error() -> None:
    # Test that a KeyError is thrown if an str_interpolation key is not available
    c = OmegaConf.create(["${10}"])

    with raises(InterpolationKeyError):
        c[0]


def test_unsupported_interpolation_type() -> None:
    c = OmegaConf.create({"foo": "${wrong_type:ref}"})
    with raises(ValueError):
        c.foo


def test_incremental_dict_with_interpolation() -> None:
    conf = OmegaConf.create()
    conf.a = 1
    conf.b = OmegaConf.create()
    conf.b.c = "${a}"
    assert conf.b.c == conf.a  # type:ignore


@mark.parametrize(
    "cfg,node_key,key,expected",
    [
        param({"a": 10}, "", "", ({"a": 10}, "")),
        param({"a": 10}, "", ".", ({"a": 10}, "")),
        param({"a": 10}, "", "a", ({"a": 10}, "a")),
        param({"a": 10}, "", ".a", ({"a": 10}, "a")),
        param({"a": {"b": 10}}, "a", ".", ({"b": 10}, "")),
        param({"a": {"b": 10}}, "a", ".b", ({"b": 10}, "b")),
        param({"a": {"b": 10}}, "a", "..", ({"a": {"b": 10}}, "")),
        param({"a": {"b": 10}}, "a", "..a", ({"a": {"b": 10}}, "a")),
        param({"a": {"b": {"c": 10}}}, "a.b", ".", ({"c": 10}, "")),
        param({"a": {"b": {"c": 10}}}, "a.b", "..", ({"b": {"c": 10}}, "")),
        param({"a": {"b": {"c": 10}}}, "a.b", "...", ({"a": {"b": {"c": 10}}}, "")),
    ],
)
def test_resolve_key_and_root(
    cfg: Any, node_key: str, key: str, expected: Tuple[Node, str]
) -> None:
    cfg = _ensure_container(cfg)
    node: Container = OmegaConf.select(cfg, node_key)
    assert node._resolve_key_and_root(key) == expected


@mark.parametrize("copy_func", [copy.copy, copy.deepcopy])
@mark.parametrize(
    "data,key",
    [
        param({"a": 10, "b": "${a}"}, "b", id="dict"),
        param([10, "${0}"], 1, id="list"),
    ],
)
def test_interpolation_after_copy(copy_func: Any, data: Any, key: Any) -> None:
    dict_cfg = OmegaConf.create(data)
    assert copy_func(dict_cfg._get_node(key))._dereference_node() == 10


def test_resolve_interpolation_without_parent() -> None:
    with raises(
        InterpolationResolutionError,
        match=re.escape("Cannot resolve interpolation for a node without a parent"),
    ):
        DictConfig(content="${foo}")._dereference_node()


def test_resolve_interpolation_without_parent_no_throw() -> None:
    cfg = DictConfig(content="${foo}")
    assert cfg._dereference_node(throw_on_resolution_failure=False) is None


def test_optional_after_interpolation() -> None:
    cfg = OmegaConf.structured(StructuredWithMissing(opt_num=II("num")))
    # Ensure that we can set an optional field to `None` even when it currently
    # points to a non-optional field.
    cfg.opt_num = None


@mark.parametrize("ref", ["missing", "invalid"])
def test_invalid_intermediate_result_when_not_throwing(
    ref: str, restore_resolvers: Any
) -> None:
    """
    Test the handling of missing / resolution failures in nested interpolations.

    The main goal of this test is to make sure that the resolution of an interpolation
    is stopped immediately when a missing / resolution failure occurs, even if
    `throw_on_resolution_failure` is set to False.
    When this happens while dereferencing a node, the result should be `None`.
    """

    def fail_if_called(x: Any) -> None:
        assert False

    OmegaConf.register_new_resolver("fail_if_called", fail_if_called)
    cfg = OmegaConf.create(
        {
            "x": "${fail_if_called:${%s}}" % ref,
            "missing": "???",
        }
    )
    x_node = cfg._get_node("x")
    assert isinstance(x_node, Node)
    assert x_node._dereference_node(throw_on_resolution_failure=False) is None


def test_none_value_in_quoted_string(restore_resolvers: Any) -> None:
    OmegaConf.register_new_resolver("test", lambda x: x)
    cfg = OmegaConf.create({"x": "${test:'${missing}'}", "missing": None})
    assert cfg.x == "None"


@mark.parametrize(
    ("cfg", "key", "expected_value", "expected_node_type"),
    [
        param(
            User(name="Bond", age=SI("${cast:int,'7'}")),
            "age",
            7,
            IntegerNode,
            id="expected_type",
        ),
        param(
            # This example specifically test the case where intermediate resolver results
            # cannot be cast to the same type as the key.
            User(name="Bond", age=SI("${cast:int,${drop_last:${drop_last:7xx}}}")),
            "age",
            7,
            IntegerNode,
            id="intermediate_type_mismatch_ok",
        ),
        param(
            # This example relies on the automatic casting of a string to int when
            # assigned to an IntegerNode.
            User(name="Bond", age=SI("${cast:str,'7'}")),
            "age",
            7,
            IntegerNode,
            id="convert_str_to_int",
        ),
        param(
            MissingList(list=SI("${identity:[a, b, c]}")),
            "list",
            ["a", "b", "c"],
            ListConfig,
            id="list_str",
        ),
        param(
            MissingList(list=SI("${identity:[0, 1, 2]}")),
            "list",
            ["0", "1", "2"],
            ListConfig,
            id="list_int_to_str",
        ),
        param(
            MissingDict(dict=SI("${identity:{key1: val1, key2: val2}}")),
            "dict",
            {"key1": "val1", "key2": "val2"},
            DictConfig,
            id="dict_str",
        ),
        param(
            MissingDict(dict=SI("${identity:{a: 0, b: 1}}")),
            "dict",
            {"a": "0", "b": "1"},
            DictConfig,
            id="dict_int_to_str",
        ),
    ],
)
def test_interpolation_type_validated_ok(
    cfg: Any,
    key: str,
    expected_value: Any,
    expected_node_type: Any,
    common_resolvers: Any,
) -> Any:
    def drop_last(s: str) -> str:
        return s[0:-1]  # drop last character from string `s`

    OmegaConf.register_new_resolver("drop_last", drop_last)

    cfg = OmegaConf.structured(cfg)

    val = cfg[key]
    assert val == expected_value

    node = cfg._get_node(key)
    assert isinstance(node, Node)
    assert isinstance(node._dereference_node(), expected_node_type)


@mark.parametrize(
    ("cfg", "key", "expected_error"),
    [
        param(
            User(name="Bond", age=SI("${cast:str,seven}")),
            "age",
            raises(
                InterpolationValidationError,
                match=re.escape(
                    dedent(
                        """\
                        Value 'seven' could not be converted to Integer
                            full_key: age
                        """
                    )
                ),
            ),
            id="type_mismatch_resolver",
        ),
        param(
            User(name="Bond", age=SI("${name}")),
            "age",
            raises(
                InterpolationValidationError,
                match=re.escape("'Bond' could not be converted to Integer"),
            ),
            id="type_mismatch_node_interpolation",
        ),
        param(
            StructuredWithMissing(opt_num=None, num=II("opt_num")),
            "num",
            raises(
                InterpolationValidationError,
                match=re.escape("Non optional field cannot be assigned None"),
            ),
            id="non_optional_node_interpolation",
        ),
        param(
            SubscriptedList(list=SI("${identity:[a, b]}")),
            "list",
            raises(
                InterpolationValidationError,
                match=re.escape("Value 'a' could not be converted to Integer"),
            ),
            id="list_type_mismatch",
        ),
        param(
            MissingDict(dict=SI("${identity:{0: b, 1: d}}")),
            "dict",
            raises(
                InterpolationValidationError,
                match=re.escape("Key 0 (int) is incompatible with (str)"),
            ),
            id="dict_key_type_mismatch",
        ),
    ],
)
def test_interpolation_type_validated_error(
    cfg: Any,
    key: str,
    expected_error: Any,
    common_resolvers: Any,
) -> None:
    cfg = OmegaConf.structured(cfg)

    with expected_error:
        cfg[key]

    assert OmegaConf.select(cfg, key, throw_on_resolution_failure=False) is None


@mark.parametrize(
    ("cfg", "key"),
    [
        param({"dict": "${identity:{a: 0, b: 1}}"}, "dict.a", id="dict"),
        param(
            {"dict": "${identity:{a: 0, b: {c: 1}}}"},
            "dict.b.c",
            id="dict_nested",
        ),
        param({"list": "${identity:[0, 1]}"}, "list.0", id="list"),
        param({"list": "${identity:[0, [1, 2]]}"}, "list.1.1", id="list_nested"),
    ],
)
def test_interpolation_readonly_resolver_output(
    common_resolvers: Any, cfg: Any, key: str
) -> None:
    cfg = OmegaConf.create(cfg)
    sub_key: Any
    parent_key, sub_key = key.rsplit(".", 1)
    try:
        sub_key = int(sub_key)  # convert list index to integer
    except ValueError:
        pass
    parent_node = OmegaConf.select(cfg, parent_key)
    assert parent_node._get_flag("readonly")


def test_interpolation_readonly_node() -> None:
    cfg = OmegaConf.structured(User(name="7", age=II("name")))
    resolved = dereference_node(cfg, "age")
    assert resolved == 7
    # The `resolved` node must be read-only because `age` is an integer, so the
    # interpolation cannot return directly the `name` node.
    assert resolved._get_flag("readonly")


def test_type_validation_error_no_throw() -> None:
    cfg = OmegaConf.structured(User(name="Bond", age=SI("${name}")))
    bad_node = cfg._get_node("age")
    assert bad_node._dereference_node(throw_on_resolution_failure=False) is None
