import re
from dataclasses import dataclass

from fastcore.basics import NotStr

from myfasthtml.core.utils import quoted_str
from myfasthtml.test.testclient import MyFT


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 if self.value is not None else ''})"
  
  def __repr__(self):
    return f"{self.__class__.__name__}({self.value if self.value is not None else ''})"
  
  def __eq__(self, other):
    if type(self) is not type(other):
      return False
    return self.value == other.value
  
  def __hash__(self):
    return hash(self.value)


class AttrPredicate(Predicate):
  """
  Predicate that validates an attribute value.
  It's given as a value of an attribute.
  """
  pass


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


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


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


class AnyValue(AttrPredicate):
  """
  True is the attribute is present and the value is not None.
  """
  
  def __init__(self):
    super().__init__(None)
  
  def validate(self, actual):
    return actual is not None


class ChildrenPredicate(Predicate):
  """
  Predicate given as a child of an element.
  """
  
  def to_debug(self, element):
    return element


class Empty(ChildrenPredicate):
  def __init__(self):
    super().__init__(None)
  
  def validate(self, actual):
    return len(actual.children) == 0 and len(actual.attrs) == 0


class AttributeForbidden(ChildrenPredicate):
  """
  To validate that an attribute is not present in an element.
  """
  
  def __init__(self, value):
    super().__init__(value)
  
  def validate(self, actual):
    return self.value not in actual.attrs or actual.attrs[self.value] is None
  
  def to_debug(self, element):
    element.attrs[self.value] = "** NOT ALLOWED **"
    return element


@dataclass
class DoNotCheck:
  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):
    return f"ErrorOutput({self.output})"
  
  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 = self._str_element(MyFT(elt_name, {attr_name: attr_value}), keep_open=True)
      self._add_to_output(f"{path_str}")
      self.indent += "  "
    
    # then render the element
    if hasattr(self.expected, "tag") and hasattr(self.element, "tag"):
      # display the tag and its attributes
      tag_str = self._str_element(self.element, self.expected)
      self._add_to_output(tag_str)
      
      # Try to show where the differences are
      error_str = self._detect_error(self.element, self.expected)
      if error_str:
        self._add_to_output(error_str)
      
      # render the children
      expected_children = [c for c in self.expected.children if not isinstance(c, ChildrenPredicate)]
      if len(expected_children) > 0:
        self.indent += "  "
        element_index = 0
        for expected_child in expected_children:
          if element_index >= len(self.element.children):
            # When there are fewer children than expected, we display a placeholder
            child_str = "! ** MISSING ** !"
            self._add_to_output(child_str)
            element_index += 1
            continue
          
          # display the child
          element_child = self.element.children[element_index]
          child_str = self._str_element(element_child, expected_child, keep_open=False)
          self._add_to_output(child_str)
          
          # manage errors (only when the expected is a FT element
          if hasattr(expected_child, "tag"):
            child_error_str = self._detect_error(element_child, expected_child)
            if child_error_str:
              self._add_to_output(child_error_str)
          
          # continue
          element_index += 1
        
        self.indent = self.indent[:-2]
        self._add_to_output(")")
    else:
      self._add_to_output(str(self.element))
      # Try to show where the differences are
      error_str = self._detect_error(self.element, self.expected)
      if error_str:
        self._add_to_output(error_str)
  
  def _add_to_output(self, msg):
    self.output.append(f"{self.indent}{msg}")
  
  @staticmethod
  def _str_element(element, expected=None, keep_open=None):
    # compare to itself if no expected element is provided
    if expected is None:
      expected = element
    
    if hasattr(element, "tag"):
      # the attributes are compared to the expected element
      elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in
                   [attr_name for attr_name in expected.attrs if attr_name is not None]}
      
      elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items())
      tag_str = f"({element.tag} {elt_attrs_str}"
      
      # manage the closing tag
      if keep_open is False:
        tag_str += " ...)" if len(element.children) > 0 else ")"
      elif keep_open is True:
        tag_str += "..." if elt_attrs_str == "" else " ..."
      else:
        # close the tag if there are no children
        not_special_children = [c for c in element.children if not isinstance(c, Predicate)]
        if len(not_special_children) == 0:  tag_str += ")"
      return tag_str
    
    else:
      return quoted_str(element)
  
  def _detect_error(self, element, expected):
    if hasattr(expected, "tag") and hasattr(element, "tag"):
      tag_str = len(element.tag) * (" " if element.tag == expected.tag else "^")
      elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in expected.attrs}
      attrs_in_error = [attr_name for attr_name, attr_value in elt_attrs.items() if
                        not self._matches(attr_value, 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" {tag_str} {elt_attrs_error}"
        return error_str
    else:
      if not self._matches(element, expected):
        return len(str(element)) * "^"
    
    return None
  
  @staticmethod
  def _matches(element, expected):
    if element == expected:
      return True
    elif isinstance(expected, Predicate):
      return expected.validate(element)
    else:
      return element == expected


class ErrorComparisonOutput:
  def __init__(self, actual_error_output, expected_error_output):
    self.actual_error_output = actual_error_output
    self.expected_error_output = expected_error_output
  
  @staticmethod
  def adjust(to_adjust, reference):
    for index, ref_line in enumerate(reference):
      if "^^" in ref_line:
        # insert an empty line in to_adjust
        to_adjust.insert(index, "")
    
    return to_adjust
  
  def render(self):
    # init if needed
    if not self.actual_error_output.output:
      self.actual_error_output.compute()
    if not self.expected_error_output.output:
      self.expected_error_output.compute()
    
    actual = self.actual_error_output.output
    expected = self.expected_error_output.output
    
    # actual = self.adjust(actual, expected) # does not seem to be needed
    expected = self.adjust(expected, actual)
    
    actual_max_length = len(max(actual, key=len))
    # expected_max_length = len(max(expected, key=len))
    
    output = []
    for a, e in zip(actual, expected):
      line = f"{a:<{actual_max_length}} | {e}".rstrip()
      output.append(line)
    
    return "\n".join(output)


def matches(actual, expected, path=""):
  def print_path(p):
    return f"Path  : '{p}'\n" if p else ""
  
  def _type(x):
    return type(x)
  
  def _debug(elt):
    return str(elt) if elt else "None"
  
  def _debug_compare(a, b):
    actual_out = ErrorOutput(path, a, b)
    expected_out = ErrorOutput(path, b, b)
    
    comparison_out = ErrorComparisonOutput(actual_out, expected_out)
    return comparison_out.render()
  
  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)}Error : {msg}\n{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 conditions
    for predicate in [c for c in expected.children if isinstance(c, ChildrenPredicate)]:
      assert predicate.validate(actual), \
        _error_msg(f"The condition '{predicate}' is not satisfied.",
                   _actual=actual,
                   _expected=predicate.to_debug(expected))
    
    # 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,
                                                       _expected=expected)
      
      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,
                     _expected=expected)
      
      else:
        assert actual.attrs[expected_attr] == expected.attrs[expected_attr], \
          _error_msg(f"The values are different for '{expected_attr}': ",
                     _actual=actual,
                     _expected=expected)
    
    # compare the children
    expected_children = [c for c in expected.children if not isinstance(c, Predicate)]
    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
