import unittest
from datetime import date
from tempfile import NamedTemporaryFile
from pytest import raises

import caosdb as db
from caosadvancedtools.models.parser import (TwiceDefinedException,
                                             YamlDefinitionError,
                                             parse_model_from_string,
                                             parse_model_from_yaml)


def to_file(string):
    f = NamedTemporaryFile(mode="w", delete=False)
    f.write(string)
    f.close()

    return f.name

# TODO: check purpose of this function... add documentation


def parse_str(string):
    parse_model_from_yaml(to_file(string))


def has_property(el, name):
    for p in el.get_properties():
        if p.name == name:
            return True

    return False


def has_parent(el, name):
    for p in el.get_parents():
        if p.name == name:
            return True

    return False


class TwiceTest(unittest.TestCase):
    def test_defined_once(self):
        string = """
RT1:
  recommended_properties:
    a:
RT2:
  recommended_properties:
    RT1:
RT3:
  recommended_properties:
    RT4:
      recommended_properties:
        a:
RT4:
"""
        model = parse_model_from_yaml(to_file(string))
        assert has_property(model["RT1"], "a")
        assert has_property(model["RT4"], "a")

    def test_defined_twice(self):
        string = """
RT1:
  recommended_properties:
    a:
RT2:
  recommended_properties:
    RT1:
      recommended_properties:
        a:
"""

        self.assertRaises(TwiceDefinedException,
                          lambda: parse_model_from_yaml(to_file(string)))

    def test_typical_case(self):
        string = """
RT1:
  recommended_properties:
    p1:
      datatype: TEXT
      description: shiet egal
  obligatory_properties:
    p2:
      datatype: TEXT
RT2:
  description: "This is awesome"
  inherit_from_suggested:
  - RT1
  - RT4
  obligatory_properties:
    RT1:
    p3:
      datatype: DATETIME
  recommended_properties:
    p4:
    RT4:
p1:
p5:
RT5:
  """
        parse_model_from_yaml(to_file(string))

    def test_wrong_kind(self):
        string = """
- RT1:
- RT2:
"""
        self.assertRaises(
            ValueError, lambda: parse_model_from_yaml(to_file(string)))

    def test_unknown_kwarg(self):
        string = """
RT1:
  datetime:
    p1:
"""
        self.assertRaises(
            ValueError, lambda: parse_model_from_yaml(to_file(string)))

    def test_definition_in_inheritance(self):
        string = """
RT2:
  description: "This is awesome"
  inherit_from_suggested:
  - RT1:
    description: "tach"
"""
        self.assertRaises(
            ValueError, lambda: parse_model_from_yaml(to_file(string)))

    def test_inheritance(self):
        string = """
RT1:
  description: "This is awesome"
  inherit_from_suggested:
  - RT2
  inherit_from_recommended:
  - RT3
  inherit_from_obligatory:
  - RT4
  - RT5
RT2:
RT3:
RT4:
RT5:
"""
        model = parse_model_from_yaml(to_file(string))
        assert has_parent(model["RT1"], "RT2")
        assert (model["RT1"].get_parent(
            "RT2")._flags["inheritance"] == db.SUGGESTED)
        assert has_parent(model["RT1"], "RT3")
        assert (model["RT1"].get_parent(
            "RT3")._flags["inheritance"] == db.RECOMMENDED)
        assert has_parent(model["RT1"], "RT4")
        assert (model["RT1"].get_parent(
            "RT4")._flags["inheritance"] == db.OBLIGATORY)
        assert has_parent(model["RT1"], "RT5")
        assert (model["RT1"].get_parent(
            "RT5")._flags["inheritance"] == db.OBLIGATORY)

    def test_properties(self):
        string = """
RT1:
  description: "This is awesome"
  recommended_properties:
    RT2:
  suggested_properties:
    RT3:
  obligatory_properties:
    RT4:
      recommended_properties:
        RT2:
    RT5:
"""
        model = parse_model_from_yaml(to_file(string))
        assert has_property(model["RT1"], "RT2")
        assert model["RT1"].get_importance("RT2") == db.RECOMMENDED
        assert has_property(model["RT1"], "RT3")
        assert model["RT1"].get_importance("RT3") == db.SUGGESTED
        assert has_property(model["RT1"], "RT4")
        assert model["RT1"].get_importance("RT4") == db.OBLIGATORY
        assert has_property(model["RT1"], "RT5")
        assert model["RT1"].get_importance("RT5") == db.OBLIGATORY
        assert has_property(model["RT4"], "RT2")
        assert model["RT4"].get_importance("RT2") == db.RECOMMENDED

    def test_datatype(self):
        string = """
p1:
  datatype: TEXT
"""
        parse_model_from_yaml(to_file(string))
        string = """
p2:
  datatype: TXT
"""
        self.assertRaises(ValueError, parse_model_from_yaml, to_file(string))


class ListTest(unittest.TestCase):
    def test_list(self):
        string = """
RT1:
  recommended_properties:
    a:
      datatype: LIST(RT2)
    b:
      datatype: LIST(TEXT)
    c:
      datatype: LIST<TEXT>
RT2:
"""
        model = parse_model_from_yaml(to_file(string))

        self.assertTrue(isinstance(model['b'], db.Property))
        self.assertEqual(model['b'].datatype, db.LIST(db.TEXT))
        self.assertTrue(isinstance(model['c'], db.Property))
        self.assertEqual(model['c'].datatype, db.LIST(db.TEXT))

        # This failed for an older version of caosdb-models
        string_list = """
A:
  obligatory_properties:
    B:
      datatype: LIST(B)
B:
  obligatory_properties:
    c:
      datatype: INTEGER
"""
        model = parse_model_from_yaml(to_file(string_list))
        self.assertTrue(isinstance(model['A'], db.RecordType))
        self.assertEqual(model['A'].properties[0].datatype, db.LIST("B"))


class ParserTest(unittest.TestCase):
    """Generic tests for good and bad syntax."""

    def test_empty_property_list(self):
        """Emtpy property lists are allowed now."""
        empty = """
A:
  obligatory_properties:
"""
        parse_str(empty)

    def test_non_string_name(self):
        """Test for when the name does not look like a string to YAML."""
        name_int = """1:
  recommended_properties:
    1.2:
    Null:
    0x0:
    010:
"""
        model = parse_model_from_string(name_int)
        self.assertEqual(len(model), 5)
        for key in model.keys():
            self.assertIsInstance(key, str)

    def test_unexpected_keyword(self):
        """Test for when keywords happen at places where they should not be."""
        yaml = """A:
  obligatory_properties:
    recommended_properties:
"""
        with self.assertRaises(YamlDefinitionError) as yde:
            parse_model_from_string(yaml)
        self.assertIn("line 3", yde.exception.args[0])
        self.assertIn("recommended_properties", yde.exception.args[0])

    def test_parents_list(self):
        """Parents must be a list."""
        yaml = """A:
  inherit_from_obligatory:
    A:
"""
        with self.assertRaises(YamlDefinitionError) as yde:
            parse_model_from_string(yaml)
        self.assertIn("line 3", yde.exception.args[0])

    def test_reference_property(self):
        """Test correct creation of reference property using an RT."""
        modeldef = """A:
  recommended_properties:
    ref:
      datatype: LIST<A>
"""
        model = parse_model_from_string(modeldef)
        self.assertEqual(len(model), 2)
        for key in model.keys():
            if key == "A":
                self.assertTrue(isinstance(model[key], db.RecordType))
            elif key == "ref":
                self.assertTrue(isinstance(model[key], db.Property))
                self.assertEqual(model[key].datatype, "LIST<A>")


class ExternTest(unittest.TestCase):
    """TODO Testing the "extern" keyword in the YAML."""
    @unittest.expectedFailure
    def test_extern(self):
        raise NotImplementedError("Extern testing is not implemented yet.")


class ErrorMessageTest(unittest.TestCase):
    """Tests for understandable error messages."""

    # Note: This was changed with implementation of role keyword
    @unittest.expectedFailure
    def test_non_dict(self):
        """When a value is given, where a list or mapping is expected."""
        recordtype_value = """
A: "some class"
"""
        recommended_value = """
A:
  recommended_properties: 23
"""
        property_value = """
prop:
  datatype: DOUBLE
A:
  recommended_properties:
  - prop: 3.14
"""
        # Failing strings and the lines where they fail
        failing = {
            recordtype_value: 2,
            recommended_value: 3,
            property_value: 6
        }
        for string, line in failing.items():
            # parse_str(string)
            with self.assertRaises(YamlDefinitionError) as yde:
                parse_str(string)
            assert("line {}".format(line) in yde.exception.args[0])


def test_define_role():
    model = """
A:
  role: Record
"""
    entities = parse_model_from_string(model)
    assert "A" in entities
    assert isinstance(entities["A"], db.Record)
    assert entities["A"].role == "Record"

    model = """
A:
  role: Record
  inherit_from_obligatory:
  - C
  obligatory_properties:
    b:
b:
  datatype: INTEGER
C:
  obligatory_properties:
    b:
D:
  role: RecordType
"""
    entities = parse_model_from_string(model)
    for l, ent in (("A", "Record"), ("b", "Property"),
                   ("C", "RecordType"), ("D", "RecordType")):
        assert l in entities
        assert isinstance(entities[l], getattr(db, ent))
        assert entities[l].role == ent

    assert entities["A"].parents[0].name == "C"
    assert entities["A"].name == "A"

    assert entities["A"].properties[0].name == "b"
    assert entities["A"].properties[0].value is None

    assert entities["C"].properties[0].name == "b"
    assert entities["C"].properties[0].value is None

    model = """
A:
  role: Record
  obligatory_properties:
    b: 42
b:
  datatype: INTEGER
"""

    entities = parse_model_from_string(model)
    assert entities["A"].get_property("b").value == 42
    assert entities["b"].value is None

    model = """
b:
  datatype: INTEGER
  value: 18
"""
    entities = parse_model_from_string(model)
    assert entities["b"].value == 18


def test_issue_72():
    """Tests for
    https://gitlab.indiscale.com/caosdb/src/caosdb-advanced-user-tools/-/issues/72

    In some cases, faulty values would be read in for properties without a
    specified value.

    """
    model = """
Experiment:
  obligatory_properties:
    date:
      datatype: DATETIME
      description: 'date of the experiment'
    identifier:
      datatype: TEXT
      description: 'identifier of the experiment'
    temperature:
      datatype: DOUBLE
      description: 'temp'
TestExperiment:
  role: Record
  inherit_from_obligatory:
    - Experiment
  obligatory_properties:
    date: 2022-03-02
    identifier: Test
    temperature: 23
  recommended_properties:
    additional_prop:
      datatype: INTEGER
      value: 7
"""
    entities = parse_model_from_string(model)
    assert "Experiment" in entities
    assert "date" in entities
    assert "identifier" in entities
    assert "temperature" in entities
    assert "TestExperiment" in entities
    assert "additional_prop" in entities
    assert isinstance(entities["Experiment"], db.RecordType)

    assert entities["Experiment"].get_property("date") is not None
    # No value is set, so this has to be None
    assert entities["Experiment"].get_property("date").value is None

    assert entities["Experiment"].get_property("identifier") is not None
    assert entities["Experiment"].get_property("identifier").value is None

    assert entities["Experiment"].get_property("temperature") is not None
    assert entities["Experiment"].get_property("temperature").value is None

    test_rec = entities["TestExperiment"]
    assert isinstance(test_rec, db.Record)
    assert test_rec.get_property("date").value == date(2022, 3, 2)
    assert test_rec.get_property("identifier").value == "Test"
    assert test_rec.get_property("temperature").value == 23
    assert test_rec.get_property("additional_prop").value == 7


def test_file_role():
    """Not implemented for now, see
    https://gitlab.indiscale.com/caosdb/src/caosdb-advanced-user-tools/-/issues/74.

    """
    model = """
F:
  role: File
"""
    with raises(NotImplementedError):
        entities = parse_model_from_string(model)