import re
from dataclasses import dataclass

from fastcore.basics import NotStr


class Predicate:
  def __init__(self, value):
    self.value = value
  
  def validate(self, actual):
    raise NotImplementedError
  
  def __str__(self):
    return f"{self.__class__.__name__}({self.value})"


class StartsWith(Predicate):
  def __init__(self, value):
    super().__init__(value)
  
  def validate(self, actual):
    return actual.startswith(self.value)


class Contains(Predicate):
  def __init__(self, value):
    super().__init__(value)
  
  def validate(self, actual):
    return self.value in actual


class DoesNotContain(Predicate):
  def __init__(self, value):
    super().__init__(value)
  
  def validate(self, actual):
    return self.value not in actual


@dataclass
class DoNotCheck:
  desc: str = None


@dataclass
class Empty:
  desc: str = None


class ErrorOutput:
  def __init__(self, path, element, expected):
    self.path = path
    self.element = element
    self.expected = expected
    self.output = []
    self.indent = ""
  
  @staticmethod
  def _unconstruct_path_item(item):
    if "#" in item:
      elt_name, elt_id = item.split("#")
      return elt_name, "id", elt_id
    elif match := re.match(r'(\w+)\[(class|name)=([^]]+)]', item):
      return match.groups()
    return item, None, None
  
  def __str__(self):
    self.compute()
  
  def compute(self):
    # first render the path hierarchy
    for p in self.path.split(".")[:-1]:
      elt_name, attr_name, attr_value = self._unconstruct_path_item(p)
      path_str = f'({elt_name} "{attr_name}"="{attr_value}"' if attr_name else f"({elt_name}"
      self._add_to_output(f"{path_str} ...")
      self.indent += "  "
    
    # then render the element
    if hasattr(self.expected, "tag") and hasattr(self.element, "tag"):
      # render the attributes
      elt_attrs = {attr_name: self.element.attrs.get(attr_name, "** MISSING **") for attr_name in self.expected.attrs}
      elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items())
      tag_str = f"({self.element.tag} {elt_attrs_str}"
      if len(self.expected.children) == 0:  tag_str += ")"
      self._add_to_output(tag_str)
      
      # display the error in the attributes
      attrs_in_error = [attr_name for attr_name, attr_value in elt_attrs.items() if
                        attr_value != self.expected.attrs[attr_name]]
      if attrs_in_error:
        elt_attrs_error = " ".join(len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
                                   for name, value in elt_attrs.items())
        error_str = f" {len(self.element.tag) * " "} {elt_attrs_error}"
        self._add_to_output(error_str)
      
      # render the children
      if len(self.expected.children) > 0:
        self.indent += "  "
        element_index = 0
        for child in self.expected.children:
          if hasattr(child, "tag"):
            child_attr_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in child.attrs.items())
            sub_children_indicator = " ..." if len(child.children) > 0 else ""
            child_str = f"({child.tag} {child_attr_str}{sub_children_indicator})"
            self._add_to_output(child_str)
          else:
            self._add_to_output(child)
        
        self.indent = self.indent[:-2]
        self._add_to_output(")")
  
  def _add_to_output(self, msg):
    self.output.append(f"{self.indent}{msg}")


def matches(actual, expected, path=""):
  def print_path(p):
    return f"Path: '{p}'\n\t" if p else ""
  
  def _type(x):
    return type(x)
  
  def _debug(elt):
    return str(elt) if elt else "None"
  
  def _debug_compare(a, b):
    return f"actual={_debug(a)}, expected={_debug(b)}"
  
  def _error_msg(msg, _actual=None, _expected=None):
    if _actual is None and _expected is None:
      debug_info = ""
    elif _actual is None:
      debug_info = _debug(_expected)
    elif _expected is None:
      debug_info = _debug(_actual)
    else:
      debug_info = _debug_compare(_actual, _expected)
    
    return f"{print_path(path)}{msg} : {debug_info}"
  
  def _assert_error(msg, _actual=None, _expected=None):
    assert False, _error_msg(msg, _actual=_actual, _expected=_expected)
  
  def _get_current_path(elt):
    if hasattr(elt, "tag"):
      res = f"{elt.tag}"
      if "id" in elt.attrs:
        res += f"#{elt.attrs['id']}"
      elif "name" in elt.attrs:
        res += f"[name={elt.attrs['name']}]"
      elif "class" in elt.attrs:
        res += f"[class={elt.attrs['class']}]"
      return res
    else:
      return elt.__class__.__name__
  
  if actual is not None and expected is None:
    _assert_error("Actual is not None while expected is None", _actual=actual)
  
  if isinstance(expected, DoNotCheck):
    return True
  
  if actual is None and expected is not None:
    _assert_error("Actual is None while expected is ", _expected=expected)
  
  # set the path
  path += "." + _get_current_path(actual) if path else _get_current_path(actual)
  
  assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \
    _error_msg("The types are different: ", _actual=actual, _expected=expected)
  
  if isinstance(expected, (list, tuple)):
    if len(actual) < len(expected):
      _assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
    if len(actual) > len(expected):
      _assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
    
    for actual_child, expected_child in zip(actual, expected):
      assert matches(actual_child, expected_child, path=path)
  
  elif isinstance(expected, NotStr):
    to_compare = actual.s.lstrip('\n').lstrip()
    assert to_compare.startswith(expected.s), _error_msg("Notstr values are different: ",
                                                         _actual=to_compare,
                                                         _expected=expected.s)
  
  elif hasattr(expected, "tag"):
    # validate the tags names
    assert actual.tag == expected.tag, _error_msg("The elements are different: ",
                                                  _actual=actual.tag,
                                                  _expected=expected.tag)
    
    # special case when the expected element is empty
    if len(expected.children) > 0 and isinstance(expected.children[0], Empty):
      assert len(actual.children) == 0, _error_msg("Actual is not empty:", _actual=actual)
      assert len(actual.attrs) == 0, _error_msg("Actual is not empty:", _actual=actual)
      return True
    
    # compare the attributes
    for expected_attr, expected_value in expected.attrs.items():
      assert expected_attr in actual.attrs, _error_msg(f"'{expected_attr}' is not found in Actual:",
                                                       _actual=actual.attrs)
      
      if isinstance(expected_value, Predicate):
        assert expected_value.validate(actual.attrs[expected_attr]), \
          _error_msg(f"The condition '{expected_value}' is not satisfied: ",
                     _actual=actual.attrs[expected_attr],
                     _expected=expected_value)
      
      else:
        assert actual.attrs[expected_attr] == expected.attrs[expected_attr], \
          _error_msg(f"The values are different for '{expected_attr}': ",
                     _actual=actual.attrs[expected_attr],
                     _expected=expected.attrs[expected_attr])
    
    # compare the children
    if len(actual.children) < len(expected.children):
      _assert_error("Actual is lesser than expected: ", _actual=actual, _expected=expected)
    
    for actual_child, expected_child in zip(actual.children, expected.children):
      assert matches(actual_child, expected_child, path=path)
  
  else:
    assert actual == expected, _error_msg("The values are different: ",
                                          _actual=actual,
                                          _expected=expected)
  
  return True
