import re
from textwrap import dedent

import pytest
from lxml.etree import Element, tostring as etree_tostring

import streamlink.validate as validate
from streamlink.exceptions import PluginError

# noinspection PyProtectedMember
from streamlink.validate._exception import ValidationError  # noqa: PLC2701


def assert_validationerror(exception, expected):
    assert str(exception) == dedent(expected).strip("\n")


class TestSchema:
    @pytest.fixture(scope="class")
    def schema(self):
        return validate.Schema(str, "foo")

    @pytest.fixture(scope="class")
    def schema_nested(self, schema: validate.Schema):
        return validate.Schema(schema)

    def test_validate_success(self, schema: validate.Schema):
        assert schema.validate("foo") == "foo"

    def test_validate_failure(self, schema: validate.Schema):
        with pytest.raises(PluginError) as cm:
            schema.validate("bar")
        assert_validationerror(
            cm.value,
            """
                Unable to validate result: ValidationError(equality):
                  'bar' does not equal 'foo'
            """,
        )

    def test_validate_failure_custom(self, schema: validate.Schema):
        class CustomError(PluginError):
            pass

        with pytest.raises(CustomError) as cm:
            schema.validate("bar", name="data", exception=CustomError)
        assert_validationerror(
            cm.value,
            """
                Unable to validate data: ValidationError(equality):
                  'bar' does not equal 'foo'
            """,
        )

    def test_nested_success(self, schema_nested: validate.Schema):
        assert schema_nested.validate("foo") == "foo"

    def test_nested_failure(self, schema_nested: validate.Schema):
        with pytest.raises(PluginError) as cm:
            schema_nested.validate("bar")
        assert_validationerror(
            cm.value,
            """
                Unable to validate result: ValidationError(equality):
                  'bar' does not equal 'foo'
            """,
        )


class TestEquality:
    def test_success(self):
        assert validate.validate("foo", "foo") == "foo"

    def test_failure(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate("foo", "bar")
        assert_validationerror(
            cm.value,
            """
                ValidationError(equality):
                  'bar' does not equal 'foo'
            """,
        )


class TestType:
    def test_success(self):
        class A:
            pass

        class B(A):
            pass

        a = A()
        b = B()
        assert validate.validate(A, a) is a
        assert validate.validate(B, b) is b
        assert validate.validate(A, b) is b

    def test_failure(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(int, "1")
        assert_validationerror(
            cm.value,
            """
                ValidationError(type):
                  Type of '1' should be int, but is str
            """,
        )


class TestSequence:
    @pytest.mark.parametrize(
        ("schema", "value"),
        [
            ([3, 2, 1, 0], [1, 2]),
            ((3, 2, 1, 0), (1, 2)),
            ({3, 2, 1, 0}, {1, 2}),
            (frozenset((3, 2, 1, 0)), frozenset((1, 2))),
        ],
        ids=[
            "list",
            "tuple",
            "set",
            "frozenset",
        ],
    )
    def test_sequences(self, schema, value):
        result = validate.validate(schema, value)
        assert result == value
        assert result is not value

    def test_empty(self):
        assert validate.validate([1, 2, 3], []) == []

    def test_failure_items(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate([1, 2, 3], [3, 4, 5])
        assert_validationerror(
            cm.value,
            """
                ValidationError(AnySchema):
                  ValidationError(equality):
                    4 does not equal 1
                  ValidationError(equality):
                    4 does not equal 2
                  ValidationError(equality):
                    4 does not equal 3
            """,
        )

    def test_failure_schema(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate([1, 2, 3], {1, 2, 3})
        assert_validationerror(
            cm.value,
            """
                ValidationError(type):
                  Type of {1, 2, 3} should be list, but is set
            """,
        )


class TestDict:
    def test_simple(self):
        schema = {"foo": "FOO", "bar": str}
        value = {"foo": "FOO", "bar": "BAR", "baz": "BAZ"}
        result = validate.validate(schema, value)
        assert result == {"foo": "FOO", "bar": "BAR"}
        assert result is not value

    @pytest.mark.parametrize(
        ("value", "expected"),
        [
            ({"foo": "foo"}, {"foo": "foo"}),
            ({"bar": "bar"}, {}),
        ],
        ids=[
            "existing",
            "missing",
        ],
    )
    def test_optional(self, value, expected):
        assert validate.validate({validate.optional("foo"): "foo"}, value) == expected

    @pytest.mark.parametrize(
        ("schema", "value", "expected"),
        [
            (
                {str: {int: str}},
                {"foo": {1: "foo"}},
                {"foo": {1: "foo"}},
            ),
            (
                {validate.all(str, "foo"): str},
                {"foo": "foo"},
                {"foo": "foo"},
            ),
            (
                {validate.any(int, str): str},
                {"foo": "foo"},
                {"foo": "foo"},
            ),
            (
                {validate.transform(lambda s: s.upper()): str},
                {"foo": "foo"},
                {"FOO": "foo"},
            ),
            (
                {validate.union((str,)): str},
                {"foo": "foo"},
                {("foo",): "foo"},
            ),
        ],
        ids=[
            "type",
            "AllSchema",
            "AnySchema",
            "TransformSchema",
            "UnionSchema",
        ],
    )
    def test_keys(self, schema, value, expected):
        assert validate.validate(schema, value) == expected

    def test_failure_key(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate({str: int}, {"foo": 1, 2: 3})
        assert_validationerror(
            cm.value,
            """
                ValidationError(dict):
                  Unable to validate key
                  Context(type):
                    Type of 2 should be str, but is int
            """,
        )

    def test_failure_key_value(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate({str: int}, {"foo": "bar"})
        assert_validationerror(
            cm.value,
            """
                ValidationError(dict):
                  Unable to validate value
                  Context(type):
                    Type of 'bar' should be int, but is str
            """,
        )

    def test_failure_notfound(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate({"foo": "bar"}, {"baz": "qux"})
        assert_validationerror(
            cm.value,
            """
                ValidationError(dict):
                  Key 'foo' not found in {'baz': 'qux'}
            """,
        )

    def test_failure_value(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate({"foo": "bar"}, {"foo": 1})
        assert_validationerror(
            cm.value,
            """
                ValidationError(dict):
                  Unable to validate value of key 'foo'
                  Context(equality):
                    1 does not equal 'bar'
            """,
        )

    def test_failure_schema(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate({}, 1)
        assert_validationerror(
            cm.value,
            """
                ValidationError(type):
                  Type of 1 should be dict, but is int
            """,
        )


class TestCallable:
    @staticmethod
    def subject(v):
        return v is not None

    def test_success(self):
        value = object()
        assert validate.validate(self.subject, value) is value

    def test_failure(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(self.subject, None)
        assert_validationerror(
            cm.value,
            """
                ValidationError(Callable):
                  subject(None) is not true
            """,
        )


class TestPattern:
    @pytest.mark.parametrize(
        ("pattern", "data", "expected"),
        [
            (r"\s(?P<bar>\S+)\s", "foo bar baz", {"bar": "bar"}),
            (rb"\s(?P<bar>\S+)\s", b"foo bar baz", {"bar": b"bar"}),
        ],
    )
    def test_success(self, pattern, data, expected):
        result = validate.validate(re.compile(pattern), data)
        assert type(result) is re.Match
        assert result.groupdict() == expected

    def test_stringsubclass(self):
        assert (
            validate.validate(
                validate.all(
                    validate.xml_xpath_string(".//@bar"),
                    re.compile(r".+"),
                    validate.get(0),
                ),
                Element("foo", {"bar": "baz"}),
            )
            == "baz"
        )

    def test_failure(self):
        assert validate.validate(re.compile(r"foo"), "bar") is None

    def test_failure_type(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(re.compile(r"foo"), b"foo")
        assert_validationerror(
            cm.value,
            """
                ValidationError(Pattern):
                  cannot use a string pattern on a bytes-like object
            """,
        )

    def test_failure_schema(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(re.compile(r"foo"), 123)
        assert_validationerror(
            cm.value,
            """
                ValidationError(Pattern):
                  Type of 123 should be str or bytes, but is int
            """,
        )


class TestAllSchema:
    @pytest.fixture(scope="class")
    def schema(self):
        return validate.all(
            str,
            lambda string: string.startswith("f"),
            "foo",
        )

    def test_success(self, schema):
        assert validate.validate(schema, "foo") == "foo"

    @pytest.mark.parametrize(
        ("value", "error"),
        [
            (
                123,
                """
                    ValidationError(type):
                      Type of 123 should be str, but is int
                """,
            ),
            (
                "bar",
                """
                    ValidationError(Callable):
                      <lambda>('bar') is not true
                """,
            ),
            (
                "failure",
                """
                    ValidationError(equality):
                      'failure' does not equal 'foo'
                """,
            ),
        ],
        ids=[
            "first",
            "second",
            "third",
        ],
    )
    def test_failure(self, schema, value, error):
        with pytest.raises(ValidationError) as cm:
            validate.validate(schema, value)
        assert_validationerror(cm.value, error)


class TestAnySchema:
    @pytest.fixture(scope="class")
    def schema(self):
        return validate.any(
            "foo",
            str,
            lambda data: data is not None,
        )

    @pytest.mark.parametrize(
        "value",
        [
            "foo",
            "success",
            object(),
        ],
        ids=[
            "first",
            "second",
            "third",
        ],
    )
    def test_success(self, schema, value):
        assert validate.validate(schema, value) is value

    def test_failure(self, schema):
        with pytest.raises(ValidationError) as cm:
            validate.validate(schema, None)
        assert_validationerror(
            cm.value,
            """
                ValidationError(AnySchema):
                  ValidationError(equality):
                    None does not equal 'foo'
                  ValidationError(type):
                    Type of None should be str, but is NoneType
                  ValidationError(Callable):
                    <lambda>(None) is not true
            """,
        )


class TestNoneOrAllSchema:
    @pytest.mark.parametrize(("data", "expected"), [("foo", "FOO"), ("bar", None)])
    def test_success(self, data, expected):
        assert (
            validate.validate(
                validate.Schema(
                    re.compile(r"foo"),
                    validate.none_or_all(
                        validate.get(0),
                        validate.transform(str.upper),
                    ),
                ),
                data,
            )
            == expected
        )

    def test_failure(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.none_or_all(str, int), "foo")
        assert_validationerror(
            cm.value,
            """
                ValidationError(NoneOrAllSchema):
                  ValidationError(type):
                    Type of 'foo' should be int, but is str
            """,
        )


class TestListSchema:
    def test_success(self):
        data = [1, 3.14, "foo"]
        result = validate.validate(validate.list(int, float, "foo"), data)
        assert result is not data
        assert result == [1, 3.14, "foo"]
        assert type(result) is type(data)
        assert len(result) == len(data)

    @pytest.mark.parametrize("data", [[1, "foo"], [1.2, "foo"], [1, "bar"], [1.2, "bar"]])
    def test_success_subschemas(self, data):
        schema = validate.list(
            validate.any(int, float),
            validate.all(validate.any("foo", "bar"), validate.transform(str.upper)),
        )
        result = validate.validate(schema, data)
        assert result is not data
        assert result[0] is data[0]
        assert result[1] is not data[1]
        assert result[1].isupper()

    def test_failure(self):
        data = [1, 3.14, "foo"]
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.list("foo", int, float), data)
        assert_validationerror(
            cm.value,
            """
                ValidationError(ListSchema):
                  ValidationError(equality):
                    1 does not equal 'foo'
                  ValidationError(type):
                    Type of 3.14 should be int, but is float
                  ValidationError(type):
                    Type of 'foo' should be float, but is str
            """,
        )

    def test_failure_type(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.list(), {})
        assert_validationerror(
            cm.value,
            """
                ValidationError(ListSchema):
                  Type of {} should be list, but is dict
            """,
        )

    def test_failure_length(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.list("foo", "bar", "baz"), ["foo", "bar"])
        assert_validationerror(
            cm.value,
            """
                ValidationError(ListSchema):
                  Length of list (2) does not match expectation (3)
            """,
        )


class TestRegexSchema:
    @pytest.mark.parametrize(
        ("pattern", "data", "expected"),
        [
            (r"\s(?P<bar>\S+)\s", "foo bar baz", {"bar": "bar"}),
            (rb"\s(?P<bar>\S+)\s", b"foo bar baz", {"bar": b"bar"}),
        ],
    )
    def test_success(self, pattern, data, expected):
        result = validate.validate(validate.regex(re.compile(pattern)), data)
        assert type(result) is re.Match
        assert result.groupdict() == expected

    def test_findall(self):
        assert validate.validate(validate.regex(re.compile(r"\w+"), "findall"), "foo bar baz") == ["foo", "bar", "baz"]

    def test_split(self):
        assert validate.validate(validate.regex(re.compile(r"\s+"), "split"), "foo bar baz") == ["foo", "bar", "baz"]

    def test_failure(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.regex(re.compile(r"foo")), "bar")
        assert_validationerror(
            cm.value,
            """
                ValidationError(RegexSchema):
                  Pattern 'foo' did not match 'bar'
            """,
        )

    def test_failure_type(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.regex(re.compile(r"foo")), b"foo")
        assert_validationerror(
            cm.value,
            """
                ValidationError(RegexSchema):
                  cannot use a string pattern on a bytes-like object
            """,
        )

    def test_failure_schema(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.regex(re.compile(r"foo")), 123)
        assert_validationerror(
            cm.value,
            """
                ValidationError(RegexSchema):
                  Type of 123 should be str or bytes, but is int
            """,
        )


class TestTransformSchema:
    def test_success(self):
        def callback(string: str, *args, **kwargs):
            return string.format(*args, **kwargs)

        assert (
            validate.validate(
                validate.transform(callback, "foo", "bar", baz="qux"),
                "{0} {1} {baz}",
            )
            == "foo bar qux"
        )

    def test_failure_signature(self):
        def callback():
            pass  # pragma: no cover

        with pytest.raises(TypeError) as cm:
            validate.validate(
                validate.transform(callback),
                "foo",
            )
        assert str(cm.value).endswith("takes 0 positional arguments but 1 was given")

    def test_failure_schema(self):
        with pytest.raises(ValidationError) as cm:
            # noinspection PyTypeChecker
            validate.validate(
                validate.transform("not a callable"),
                "foo",
            )
        assert_validationerror(
            cm.value,
            """
                ValidationError(type):
                  Type of 'not a callable' should be Callable, but is str
            """,
        )


class TestGetItemSchema:
    class Container:
        def __init__(self, exception):
            self.exception = exception

        def __getitem__(self, item):
            raise self.exception

        def __repr__(self):
            return self.__class__.__name__

    @pytest.mark.parametrize(
        "obj",
        [
            {"foo": "bar"},
            Element("elem", {"foo": "bar"}),
            re.match(r"(?P<foo>.+)", "bar"),
        ],
        ids=[
            "dict",
            "lxml.etree.Element",
            "re.Match",
        ],
    )
    def test_simple(self, obj):
        assert validate.validate(validate.get("foo"), obj) == "bar"

    @pytest.mark.parametrize("exception", [KeyError, IndexError])
    def test_getitem_no_default(self, exception):
        container = self.Container(exception())
        assert validate.validate(validate.get("foo"), container) is None

    @pytest.mark.parametrize("exception", [KeyError, IndexError])
    def test_getitem_default(self, exception):
        container = self.Container(exception("failure"))
        assert validate.validate(validate.get("foo", default="default"), container) == "default"

    @pytest.mark.parametrize("exception", [TypeError, AttributeError])
    def test_getitem_error(self, exception):
        container = self.Container(exception("failure"))
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.get("foo", default="default"), container)
        assert_validationerror(
            cm.value,
            """
                ValidationError(GetItemSchema):
                  Could not get key 'foo' from object Container
                  Context:
                    failure
            """,
        )

    def test_nested(self):
        dictionary = {"foo": {"bar": {"baz": "qux"}}}
        assert validate.validate(validate.get(("foo", "bar", "baz")), dictionary) == "qux"

    def test_nested_default(self):
        dictionary = {"foo": {"bar": {"baz": "qux"}}}
        assert validate.validate(validate.get(("foo", "bar", "qux"), default="default"), dictionary) == "default"

    def test_nested_failure(self):
        dictionary = {"foo": {"bar": {"baz": "qux"}}}
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.get(("foo", "qux", "baz"), default="default"), dictionary)
        assert_validationerror(
            cm.value,
            """
                ValidationError(GetItemSchema):
                  Item 'qux' was not found in object {'bar': {'baz': 'qux'}}
            """,
        )

    def test_strict(self):
        dictionary = {
            ("foo", "bar", "baz"): "foo-bar-baz",
            "foo": {"bar": {"baz": "qux"}},
        }
        assert validate.validate(validate.get(("foo", "bar", "baz"), strict=True), dictionary) == "foo-bar-baz"


class TestAttrSchema:
    class Subject:
        foo = 1
        bar = 2

        def __repr__(self):
            return self.__class__.__name__

    @pytest.fixture()
    def obj(self):
        obj1 = self.Subject()
        obj2 = self.Subject()
        obj1.bar = obj2

        return obj1

    def test_success(self, obj):
        schema = validate.attr({"foo": validate.transform(lambda num: num + 1)})
        newobj = validate.validate(schema, obj)
        assert obj.foo == 1
        assert newobj is not obj
        assert newobj.foo == 2
        assert newobj.bar is obj.bar

    def test_failure_missing(self, obj):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.attr({"missing": int}), obj)
        assert_validationerror(
            cm.value,
            """
                ValidationError(AttrSchema):
                  Attribute 'missing' not found on object Subject
            """,
        )

    def test_failure_subschema(self, obj):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.attr({"foo": str}), obj)
        assert_validationerror(
            cm.value,
            """
                ValidationError(AttrSchema):
                  Could not validate attribute 'foo'
                  Context(type):
                    Type of 1 should be str, but is int
            """,
        )


class TestXmlElementSchema:
    upper = validate.transform(str.upper)

    @pytest.fixture()
    def element(self):
        childA = Element("childA", {"a": "1"})
        childB = Element("childB", {"b": "2"})
        childC = Element("childC")
        childA.text = "childAtext"
        childA.tail = "childAtail"
        childB.text = "childBtext"
        childB.tail = "childBtail"
        childB.append(childC)

        parent = Element("parent", {"attrkey1": "attrval1", "attrkey2": "attrval2"})
        parent.text = "parenttext"
        parent.tail = "parenttail"
        parent.append(childA)
        parent.append(childB)

        return parent

    @pytest.mark.parametrize(
        ("schema", "expected"),
        [
            (
                validate.xml_element(),
                (
                    '<parent attrkey1="attrval1" attrkey2="attrval2">'
                    + "parenttext"
                    + '<childA a="1">childAtext</childA>'
                    + "childAtail"
                    + '<childB b="2">childBtext<childC/></childB>'
                    + "childBtail"
                    + "</parent>"
                    + "parenttail"
                ),
            ),
            (
                validate.xml_element(tag=upper, attrib={upper: upper}, text=upper, tail=upper),
                (
                    '<PARENT ATTRKEY1="ATTRVAL1" ATTRKEY2="ATTRVAL2">'
                    + "PARENTTEXT"
                    + '<childA a="1">childAtext</childA>'
                    + "childAtail"
                    + '<childB b="2">childBtext<childC/></childB>'
                    + "childBtail"
                    + "</PARENT>"
                    + "PARENTTAIL"
                ),
            ),
        ],
        ids=[
            "empty",
            "subschemas",
        ],
    )
    def test_success(self, element, schema, expected):
        newelement = validate.validate(schema, element)
        assert etree_tostring(newelement).decode("utf-8") == expected
        assert newelement is not element
        assert newelement[0] is not element[0]
        assert newelement[1] is not element[1]
        assert newelement[1][0] is not element[1][0]

    @pytest.mark.parametrize(
        ("schema", "error"),
        [
            (
                validate.xml_element(tag="invalid"),
                """
                    ValidationError(XmlElementSchema):
                      Unable to validate XML tag
                      Context(equality):
                        'parent' does not equal 'invalid'
                """,
            ),
            (
                validate.xml_element(attrib={"invalid": "invalid"}),
                """
                    ValidationError(XmlElementSchema):
                      Unable to validate XML attributes
                      Context(dict):
                        Key 'invalid' not found in {'attrkey1': 'attrval1', 'attrkey2': 'attrval2'}
                """,
            ),
            (
                validate.xml_element(text="invalid"),
                """
                    ValidationError(XmlElementSchema):
                      Unable to validate XML text
                      Context(equality):
                        'parenttext' does not equal 'invalid'
                """,
            ),
            (
                validate.xml_element(tail="invalid"),
                """
                    ValidationError(XmlElementSchema):
                      Unable to validate XML tail
                      Context(equality):
                        'parenttail' does not equal 'invalid'
                """,
            ),
        ],
        ids=[
            "tag",
            "attrib",
            "text",
            "tail",
        ],
    )
    def test_failure(self, element, schema, error):
        with pytest.raises(ValidationError) as cm:
            validate.validate(schema, element)
        assert_validationerror(cm.value, error)

    def test_failure_schema(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.xml_element(), "not-an-element")
        assert_validationerror(
            cm.value,
            """
                ValidationError(Callable):
                  iselement('not-an-element') is not true
            """,
        )


class TestUnionGetSchema:
    def test_simple(self):
        assert validate.validate(
            validate.union_get("foo", "bar"),
            {"foo": 1, "bar": 2},
        ) == (1, 2)

    def test_sequence_type(self):
        assert validate.validate(
            validate.union_get("foo", "bar", seq=list),
            {"foo": 1, "bar": 2},
        ) == [1, 2]

    def test_nested(self):
        assert validate.validate(
            validate.union_get(
                ("foo", "bar"),
                ("baz", "qux"),
            ),
            {"foo": {"bar": 1}, "baz": {"qux": 2}},
        ) == (1, 2)


class TestUnionSchema:
    upper = validate.transform(str.upper)

    def test_dict_success(self):
        schema = validate.union({
            "foo": str,
            "bar": self.upper,
            validate.optional("baz"): int,
        })
        assert validate.validate(schema, "value") == {"foo": "value", "bar": "VALUE"}

    def test_dict_failure(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.union({"foo": int}), "value")
        assert_validationerror(
            cm.value,
            """
                ValidationError(UnionSchema):
                  Could not validate union
                  Context(dict):
                    Unable to validate union 'foo'
                    Context(type):
                      Type of 'value' should be int, but is str
            """,
        )

    @pytest.mark.parametrize(
        ("schema", "expected"),
        [
            (validate.union([str, upper]), ["value", "VALUE"]),
            (validate.union((str, upper)), ("value", "VALUE")),
            (validate.union({str, upper}), {"value", "VALUE"}),
            (validate.union(frozenset((str, upper))), frozenset(("value", "VALUE"))),
        ],
        ids=[
            "list",
            "tuple",
            "set",
            "frozenset",
        ],
    )
    def test_sequence(self, schema, expected):
        result = validate.validate(schema, "value")
        assert result == expected

    def test_failure_schema(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.union(None), None)
        assert_validationerror(
            cm.value,
            """
                ValidationError(UnionSchema):
                  Could not validate union
                  Context:
                    Invalid union type: NoneType
            """,
        )


class TestLengthValidator:
    @pytest.mark.parametrize(
        ("args", "value"),
        [
            ((3,), "abc"),
            ((3,), [1, 2, 3]),
            ((3,), "abcd"),
            ((3,), [1, 2, 3, 4]),
            ((3, "lt"), "ab"),
            ((3, "lt"), [1, 2]),
            ((3, "le"), "ab"),
            ((3, "le"), [1, 2]),
            ((3, "le"), "abc"),
            ((3, "le"), [1, 2, 3]),
            ((3, "eq"), "abc"),
            ((3, "eq"), [1, 2, 3]),
            ((3, "ge"), "abc"),
            ((3, "ge"), [1, 2, 3]),
            ((3, "ge"), "abcd"),
            ((3, "ge"), [1, 2, 3, 4]),
            ((3, "gt"), "abcd"),
            ((3, "gt"), [1, 2, 3, 4]),
        ],
    )
    def test_success(self, args, value):
        assert validate.validate(validate.length(*args), value) == value

    @pytest.mark.parametrize(
        ("args", "value", "error"),
        [
            ((3,), "ab", "Length must be >=3, but value is 2"),
            ((3,), [1, 2], "Length must be >=3, but value is 2"),
            ((3, "lt"), "abc", "Length must be <3, but value is 3"),
            ((3, "lt"), [1, 2, 3], "Length must be <3, but value is 3"),
            ((3, "le"), "abcd", "Length must be <=3, but value is 4"),
            ((3, "le"), [1, 2, 3, 4], "Length must be <=3, but value is 4"),
            ((3, "eq"), "ab", "Length must be ==3, but value is 2"),
            ((3, "eq"), [1, 2], "Length must be ==3, but value is 2"),
            ((3, "ge"), "ab", "Length must be >=3, but value is 2"),
            ((3, "ge"), [1, 2], "Length must be >=3, but value is 2"),
            ((3, "gt"), "abc", "Length must be >3, but value is 3"),
            ((3, "gt"), [1, 2, 3], "Length must be >3, but value is 3"),
        ],
    )
    def test_failure(self, args, value, error):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.length(*args), value)
        assert_validationerror(
            cm.value,
            f"""
                ValidationError(length):
                  {error}
            """,
        )


class TestStartsWithValidator:
    def test_success(self):
        assert validate.validate(validate.startswith("foo"), "foo bar baz")

    def test_failure(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.startswith("invalid"), "foo bar baz")
        assert_validationerror(
            cm.value,
            """
                ValidationError(startswith):
                  'foo bar baz' does not start with 'invalid'
            """,
        )

    def test_failure_schema(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.startswith("invalid"), 1)
        assert_validationerror(
            cm.value,
            """
                ValidationError(type):
                  Type of 1 should be str, but is int
            """,
        )


class TestEndsWithValidator:
    def test_success(self):
        assert validate.validate(validate.endswith("baz"), "foo bar baz")

    def test_failure(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.endswith("invalid"), "foo bar baz")
        assert_validationerror(
            cm.value,
            """
                ValidationError(endswith):
                  'foo bar baz' does not end with 'invalid'
            """,
        )

    def test_failure_schema(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.endswith("invalid"), 1)
        assert_validationerror(
            cm.value,
            """
                ValidationError(type):
                  Type of 1 should be str, but is int
            """,
        )


class TestContainsValidator:
    def test_string_success(self):
        obj = "foo bar baz"
        assert validate.validate(validate.contains("bar"), obj) is obj

    def test_string_failure(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.contains("invalid"), "foo bar baz")
        assert_validationerror(
            cm.value,
            """
                ValidationError(contains):
                  'foo bar baz' does not contain 'invalid'
            """,
        )

    def test_list_success(self):
        obj = [123, 456, 789]
        assert validate.validate(validate.contains(456), obj) is obj

    def test_list_failure(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.contains(456), [987, 654, 321])
        assert_validationerror(
            cm.value,
            """
                ValidationError(contains):
                  [987, 654, 321] does not contain 456
            """,
        )

    def test_dict_success(self):
        obj = {"abc": 123, "def": 456, "ghi": 789}
        assert validate.validate(validate.contains("def"), obj) is obj

    def test_dict_failure(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.contains("def"), {"ihg": 987, "fed": 654, "cba": 321})
        assert_validationerror(
            cm.value,
            """
                ValidationError(contains):
                  {'ihg': 987, 'fed': 654, 'cba': 321} does not contain 'def'
            """,
        )

    def test_failure_schema(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.contains("invalid"), 1)
        assert_validationerror(
            cm.value,
            """
                ValidationError(type):
                  Type of 1 should be Container, but is int
            """,
        )


class TestUrlValidator:
    url = "https://user:pass@sub.host.tld:1234/path.m3u8?query#fragment"

    @pytest.mark.parametrize(
        "params",
        [
            dict(scheme="http"),
            dict(scheme="https"),
            dict(netloc="user:pass@sub.host.tld:1234", username="user", password="pass", hostname="sub.host.tld", port=1234),
            dict(path=validate.endswith(".m3u8")),
        ],
        ids=[
            "implicit https",
            "explicit https",
            "multiple attributes",
            "subschemas",
        ],
    )
    def test_success(self, params):
        assert validate.validate(validate.url(**params), self.url)

    def test_failure_valid_url(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.url(), "foo")
        assert_validationerror(
            cm.value,
            """
                ValidationError(url):
                  'foo' is not a valid URL
            """,
        )

    def test_failure_url_attribute(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.url(invalid=str), self.url)
        assert_validationerror(
            cm.value,
            """
                ValidationError(url):
                  Invalid URL attribute 'invalid'
            """,
        )

    def test_failure_subschema(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.url(hostname="invalid"), self.url)
        assert_validationerror(
            cm.value,
            """
                ValidationError(url):
                  Unable to validate URL attribute 'hostname'
                  Context(equality):
                    'sub.host.tld' does not equal 'invalid'
            """,
        )

    def test_failure_schema(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.url(), 1)
        assert_validationerror(
            cm.value,
            """
                ValidationError(type):
                  Type of 1 should be str, but is int
            """,
        )


class TestGetAttrValidator:
    @pytest.fixture(scope="class")
    def subject(self):
        class Subject:
            foo = 1

        return Subject()

    def test_simple(self, subject):
        assert validate.validate(validate.getattr("foo"), subject) == 1

    def test_default(self, subject):
        assert validate.validate(validate.getattr("bar", 2), subject) == 2

    def test_no_default(self, subject):
        assert validate.validate(validate.getattr("bar"), subject) is None
        assert validate.validate(validate.getattr("baz"), None) is None


class TestHasAttrValidator:
    class Subject:
        foo = 1

        def __repr__(self):
            return self.__class__.__name__

    def test_success(self):
        assert validate.validate(validate.hasattr("foo"), self.Subject())

    def test_failure(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.hasattr("bar"), self.Subject())
        assert_validationerror(
            cm.value,
            """
                ValidationError(Callable):
                  getter(Subject) is not true
            """,
        )


class TestFilterValidator:
    def test_dict(self):
        schema = validate.filter(lambda k, v: k < 2 and v > 0)
        value = {0: 0, 1: 1, 2: 0, 3: 1}
        assert validate.validate(schema, value) == {1: 1}

    def test_sequence(self):
        schema = validate.filter(lambda k: k < 2)
        value = (0, 1, 2, 3)
        assert validate.validate(schema, value) == (0, 1)


class TestMapValidator:
    def test_dict(self):
        schema = validate.map(lambda k, v: (k + 1, v + 1))
        value = {0: 0, 1: 1, 2: 0, 3: 1}
        assert validate.validate(schema, value) == {1: 1, 2: 2, 3: 1, 4: 2}

    def test_sequence(self):
        schema = validate.map(lambda k: k + 1)
        value = (0, 1, 2, 3)
        assert validate.validate(schema, value) == (1, 2, 3, 4)


class TestXmlFindValidator:
    def test_success(self):
        element = Element("foo")
        assert validate.validate(validate.xml_find("."), element) is element

    def test_namespaces(self):
        root = Element("root")
        child = Element("{http://a}foo")
        root.append(child)
        assert validate.validate(validate.xml_find("./a:foo", namespaces={"a": "http://a"}), root) is child

    def test_failure_no_element(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.xml_find("*"), Element("foo"))
        assert_validationerror(
            cm.value,
            """
                ValidationError(xml_find):
                  ElementPath query '*' did not return an element
            """,
        )

    def test_failure_not_found(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.xml_find("invalid"), Element("foo"))
        assert_validationerror(
            cm.value,
            """
                ValidationError(xml_find):
                  ElementPath query 'invalid' did not return an element
            """,
        )

    def test_failure_schema(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.xml_find("."), "not-an-element")
        assert_validationerror(
            cm.value,
            """
                ValidationError(Callable):
                  iselement('not-an-element') is not true
            """,
        )

    def test_failure_syntax(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.xml_find("["), Element("foo"))
        assert_validationerror(
            cm.value,
            """
                ValidationError(xml_find):
                  ElementPath syntax error: '['
                  Context:
                    invalid path
            """,
        )


class TestXmlFindallValidator:
    @pytest.fixture(scope="class")
    def element(self):
        element = Element("root")
        for child in Element("foo"), Element("bar"), Element("baz"):
            element.append(child)

        return element

    def test_simple(self, element):
        assert validate.validate(validate.xml_findall("*"), element) == [element[0], element[1], element[2]]

    def test_empty(self, element):
        assert validate.validate(validate.xml_findall("missing"), element) == []

    def test_namespaces(self):
        root = Element("root")
        for child in Element("{http://a}foo"), Element("{http://unknown}bar"), Element("{http://a}baz"):
            root.append(child)
        assert validate.validate(validate.xml_findall("./a:*", namespaces={"a": "http://a"}), root) == [root[0], root[2]]

    def test_failure_schema(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.xml_findall("*"), "not-an-element")
        assert_validationerror(
            cm.value,
            """
                ValidationError(Callable):
                  iselement('not-an-element') is not true
            """,
        )


class TestXmlFindtextValidator:
    def test_simple(self):
        element = Element("foo")
        element.text = "bar"
        assert validate.validate(validate.xml_findtext("."), element) == "bar"

    def test_empty(self):
        element = Element("foo")
        assert validate.validate(validate.xml_findtext("."), element) is None

    def test_namespaces(self):
        root = Element("root")
        child = Element("{http://a}foo")
        child.text = "bar"
        root.append(child)
        assert validate.validate(validate.xml_findtext("./a:foo", namespaces={"a": "http://a"}), root) == "bar"

    def test_failure_schema(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.xml_findtext("."), "not-an-element")
        assert_validationerror(
            cm.value,
            """
                ValidationError(Callable):
                  iselement('not-an-element') is not true
            """,
        )


class TestXmlXpathValidator:
    @pytest.fixture(scope="class")
    def element(self):
        element = Element("root")
        for child in Element("foo"), Element("bar"), Element("baz"):
            child.text = child.tag.upper()
            element.append(child)

        return element

    def test_simple(self, element):
        assert validate.validate(validate.xml_xpath("*"), element) == [element[0], element[1], element[2]]
        assert validate.validate(validate.xml_xpath("*/text()"), element) == ["FOO", "BAR", "BAZ"]

    def test_empty(self, element):
        assert validate.validate(validate.xml_xpath("invalid"), element) is None

    def test_other(self, element):
        assert validate.validate(validate.xml_xpath("local-name(.)"), element) == "root"

    def test_namespaces(self):
        nsmap = {"a": "http://a", "b": "http://b"}
        root = Element("root", nsmap=nsmap)
        for child in Element("{http://a}child"), Element("{http://b}child"):
            root.append(child)
        assert validate.validate(validate.xml_xpath("./b:child", namespaces=nsmap), root)[0] is root[1]

    def test_extensions(self, element):
        def foo(context, a, b):
            return int(context.context_node.attrib.get("val")) + a + b

        element = Element("root", attrib={"val": "3"})
        assert validate.validate(validate.xml_xpath("foo(5, 7)", extensions={(None, "foo"): foo}), element) == 15.0

    def test_smart_strings(self, element):
        assert validate.validate(validate.xml_xpath("*/text()"), element)[0].getparent().tag == "foo"
        assert not hasattr(validate.validate(validate.xml_xpath("*/text()", smart_strings=False), element)[0], "getparent")

    def test_variables(self, element):
        assert validate.validate(validate.xml_xpath("*[local-name() = $name]/text()", name="foo"), element) == ["FOO"]

    def test_failure_schema(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.xml_xpath("."), "not-an-element")
        assert_validationerror(
            cm.value,
            """
                ValidationError(Callable):
                  iselement('not-an-element') is not true
            """,
        )

    def test_failure_evaluation(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.xml_xpath("?"), Element("root"))
        assert_validationerror(
            cm.value,
            """
                ValidationError(xml_xpath):
                  XPath evaluation error: '?'
                  Context:
                    Invalid expression
            """,
        )


class TestXmlXpathStringValidator:
    @pytest.fixture(scope="class")
    def element(self):
        element = Element("root")
        for child in Element("foo"), Element("bar"), Element("baz"):
            child.text = child.tag.upper()
            element.append(child)

        return element

    def test_simple(self, element):
        assert validate.validate(validate.xml_xpath_string("./foo/text()"), element) == "FOO"

    def test_empty(self, element):
        assert validate.validate(validate.xml_xpath_string("./text()"), element) is None

    def test_smart_strings(self, element):
        assert not hasattr(validate.validate(validate.xml_xpath_string("./foo/text()"), element)[0], "getparent")

    def test_failure_schema(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.xml_xpath_string("."), "not-an-element")
        assert_validationerror(
            cm.value,
            """
                ValidationError(Callable):
                  iselement('not-an-element') is not true
            """,
        )


class TestParseJsonValidator:
    def test_success(self):
        assert validate.validate(
            validate.parse_json(),
            """{"a": ["b", true, false, null, 1, 2.3]}""",
        ) == {"a": ["b", True, False, None, 1, 2.3]}

    def test_failure(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.parse_json(), "invalid")
        assert_validationerror(
            cm.value,
            """
                ValidationError:
                  Unable to parse JSON: Expecting value: line 1 column 1 (char 0) ('invalid')
            """,
        )


class TestParseHtmlValidator:
    def test_success(self):
        assert (
            validate.validate(
                validate.parse_html(),
                """<!DOCTYPE html><body>&quot;perfectly&quot;<a>valid<div>HTML""",
            ).tag
            == "html"
        )

    def test_failure(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.parse_html(), None)
        assert_validationerror(
            cm.value,
            """
                ValidationError:
                  Unable to parse HTML: can only parse strings (None)
            """,
        )


class TestParseXmlValidator:
    def test_success(self):
        assert (
            validate.validate(
                validate.parse_xml(),
                """<?xml version="1.0" encoding="utf-8"?><root></root>""",
            ).tag
            == "root"
        )

    def test_failure(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.parse_xml(), None)
        assert_validationerror(
            cm.value,
            """
                ValidationError:
                  Unable to parse XML: can only parse strings (None)
            """,
        )


class TestParseQsdValidator:
    def test_success(self):
        assert validate.validate(
            validate.parse_qsd(),
            "foo=bar&foo=baz&qux=quux",
        ) == {"foo": "baz", "qux": "quux"}
        assert validate.validate(
            validate.parse_qsd(),
            b"foo=bar&foo=baz&qux=quux",
        ) == {b"foo": b"baz", b"qux": b"quux"}

    def test_failure(self):
        with pytest.raises(ValidationError) as cm:
            validate.validate(validate.parse_qsd(), 123)
        assert_validationerror(
            cm.value,
            """
                ValidationError(AnySchema):
                  ValidationError(type):
                    Type of 123 should be str, but is int
                  ValidationError(type):
                    Type of 123 should be bytes, but is int
            """,
        )


class TestValidationError:
    def test_subclass(self):
        assert issubclass(ValidationError, ValueError)

    def test_empty(self):
        assert str(ValidationError()) == "ValidationError:"
        assert str(ValidationError("")) == "ValidationError:"
        assert str(ValidationError(ValidationError())) == "ValidationError:\n  ValidationError:"
        assert str(ValidationError(ValidationError(""))) == "ValidationError:\n  ValidationError:"

    def test_single(self):
        assert str(ValidationError("foo")) == "ValidationError:\n  foo"
        assert str(ValidationError(ValueError("bar"))) == "ValidationError:\n  bar"

    def test_single_nested(self):
        err = ValidationError(ValidationError("baz"))
        assert_validationerror(
            err,
            """
                ValidationError:
                  ValidationError:
                    baz
            """,
        )

    def test_multiple_nested(self):
        err = ValidationError(
            "a",
            ValidationError("b", "c"),
            "d",
            ValidationError("e"),
            "f",
        )
        assert_validationerror(
            err,
            """
                ValidationError:
                  a
                  ValidationError:
                    b
                    c
                  d
                  ValidationError:
                    e
                  f
            """,
        )

    def test_context(self):
        errA = ValidationError("a")
        errB = ValidationError("b")
        errC = ValidationError("c")
        errA.__cause__ = errB
        errB.__cause__ = errC
        assert_validationerror(
            errA,
            """
                ValidationError:
                  a
                  Context:
                    b
                    Context:
                      c
            """,
        )

    def test_multiple_nested_context(self):
        errAB = ValidationError("a", "b")
        errC = ValidationError("c")
        errDE = ValidationError("d", "e")
        errF = ValidationError("f")
        errG = ValidationError("g")
        errHI = ValidationError("h", "i")
        errCF = ValidationError(errC, errF)
        errAB.__cause__ = errCF
        errC.__cause__ = errDE
        errF.__cause__ = errG
        errCF.__cause__ = errHI
        assert_validationerror(
            errAB,
            """
                ValidationError:
                  a
                  b
                  Context:
                    ValidationError:
                      c
                      Context:
                        d
                        e
                    ValidationError:
                      f
                      Context:
                        g
                    Context:
                      h
                      i
            """,
        )

    def test_schema(self):
        err = ValidationError(
            ValidationError(
                "foo",
                schema=dict,
            ),
            ValidationError(
                "bar",
                schema="something",
            ),
            schema=validate.any,
        )
        assert_validationerror(
            err,
            """
                ValidationError(AnySchema):
                  ValidationError(dict):
                    foo
                  ValidationError(something):
                    bar
            """,
        )

    def test_recursion(self):
        err1 = ValidationError("foo")
        err2 = ValidationError("bar")
        err2.__cause__ = err1
        err1.__cause__ = err2
        assert_validationerror(
            err1,
            """
                ValidationError:
                  foo
                  Context:
                    bar
                    Context:
                      ...
            """,
        )

    def test_truncate(self):
        err = ValidationError(
            "foo {foo} bar {bar} baz",
            foo="Some really long error message that exceeds the maximum error message length",
            bar=repr("Some really long error message that exceeds the maximum error message length"),
        )
        # noinspection LongLine
        assert_validationerror(
            err,
            """
                ValidationError:
                  foo <Some really long error message that exceeds the maximum...> bar <'Some really long error message that exceeds the maximu...> baz
            """,  # noqa: E501
        )
