import json
import uuid
from dataclasses import dataclass

from bs4 import BeautifulSoup, Tag
from fastcore.xml import FT, to_xml
from fasthtml.common import FastHTML
from starlette.responses import Response
from starlette.testclient import TestClient

from myfasthtml.core.commands import mount_commands


@dataclass
class MyFT:
  tag: str
  attrs: dict


class TestableElement:
  """
  Represents an HTML element that can be interacted with in tests.

  This class will be used for future interactions like clicking elements
  or verifying element properties.
  """
  
  def __init__(self, client, source):
    """
    Initialize a testable element.

    Args:
        client: The MyTestClient instance.
        ft: The FastHTML element representation.
    """
    self.client = client
    if isinstance(source, str):
      self.html_fragment = source
      tag = BeautifulSoup(source, 'html.parser').find()
      self.ft = MyFT(tag.name, tag.attrs)
    elif isinstance(source, Tag):
      self.html_fragment = str(source)
      self.ft = MyFT(source.name, source.attrs)
    elif isinstance(source, FT):
      self.ft = source
      self.html_fragment = to_xml(source).strip()
    else:
      raise ValueError(f"Invalid source '{source}' for TestableElement.")
  
  def click(self):
    """Click the element (to be implemented)."""
    return self._send_htmx_request()
  
  def matches(self, ft):
    """Check if element matches given FastHTML element (to be implemented)."""
    pass
  
  def _send_htmx_request(self, json_data: dict | None = None, data: dict | None = None) -> Response:
    """
    Simulates an HTMX request in Python for unit testing.

    This function reads the 'hx-*' attributes from the FastHTML object
    to determine the HTTP method, URL, headers, and body of the request,
    then executes it via the TestClient.

    Args:
        data: (Optional) A dict for form data
              (sends as 'application/x-www-form-urlencoded').
        json_data: (Optional) A dict for JSON data
                   (sends as 'application/json').
                   Takes precedence over 'hx_vals'.

    Returns:
        The Response object from the simulated request.
    """
    
    # The essential header for FastHTML (and HTMX) to identify the request
    headers = {"HX-Request": "true"}
    method = "GET"  # HTMX defaults to GET if not specified
    url = None
    
    verbs = {
        'hx_get': 'GET',
        'hx_post': 'POST',
        'hx_put': 'PUT',
        'hx_delete': 'DELETE',
        'hx_patch': 'PATCH',
    }
    
    # .props contains the kwargs passed to the object (e.g., hx_post="/url")
    element_attrs = self.ft.attrs or {}
    
    # Build the attributes
    for key, value in element_attrs.items():
      
      # sanitize the key
      key = key.lower().strip()
      if key.startswith('hx-'):
        key = 'hx_' + key[3:]
      
      if key in verbs:
        # Verb attribute: defines the method and URL
        method = verbs[key]
        url = str(value)
      
      elif key == 'hx_vals':
        # hx_vals defines the JSON body, if not already provided by the test
        if json_data is None:
          if isinstance(value, str):
            json_data = json.loads(value)
          elif isinstance(value, dict):
            json_data = value
      
      elif key.startswith('hx_'):
        # Any other hx_* attribute is converted to an HTTP header
        # e.g.: 'hx_target' -> 'HX-Target'
        header_name = '-'.join(p.capitalize() for p in key.split('_'))
        headers[header_name] = str(value)
    
    # Sanity check
    if url is None:
      raise ValueError(
        f"The <{self.ft.tag}> element has no HTMX verb attribute "
        "(e.g., hx_get, hx_post) to define a URL."
      )
    
    # Send the request
    return self.client.send_request(method, url, headers=headers, data=data, json_data=json_data)


class MyTestClient:
  """
  A test client helper for FastHTML applications that provides
  a more user-friendly API for testing HTML responses.

  This class wraps Starlette's TestClient and provides methods
  to verify page content in a way similar to NiceGui's test fixtures.
  """
  
  def __init__(self, app: FastHTML, parent_levels: int = 1):
    """
    Initialize the test client.

    Args:
        app: The FastHTML application to test.
        parent_levels: Number of parent levels to show in error messages (default: 1).
    """
    self.app = app
    self.client = TestClient(app)
    self._content = None
    self._soup = None
    self._session = str(uuid.uuid4())
    self.parent_levels = parent_levels
    
    # make sure that the commands are mounted
    mount_commands(self.app)
  
  def open(self, path: str):
    """
    Open a page and store its content for subsequent assertions.

    Args:
        path: The URL path to request (e.g., '/home', '/api/users').

    Returns:
        self: Returns the client instance for method chaining.

    Raises:
        AssertionError: If the response status code is not 200.
    """
    
    res = self.client.get(path)
    assert res.status_code == 200, (
        f"Failed to open '{path}'. "
        f"status code={res.status_code} : reason='{res.text}'"
    )
    
    self.set_content(res.text)
    return self
  
  def send_request(self, method: str, url: str, headers: dict = None, data=None, json_data=None):
    json_data['session'] = self._session
    res = self.client.request(
      method,
      url,
      headers=headers,
      data=data,  # For form data
      json=json_data  # For JSON bodies (e.g., from hx_vals)
    )
    
    assert res.status_code == 200, (
        f"Failed to send request '{method=}', {url=}. "
        f"status code={res.status_code} : reason='{res.text}'"
    )
    
    self.set_content(res.text)
    return self
  
  def should_see(self, text: str):
    """
    Assert that the given text is present in the visible page content.

    This method parses the HTML and searches only in the visible text,
    ignoring HTML tags and attributes.

    Args:
        text: The text string to search for (case-sensitive).

    Returns:
        self: Returns the client instance for method chaining.

    Raises:
        AssertionError: If the text is not found in the page content.
        ValueError: If no page has been opened yet.
    """
    if self._content is None:
      raise ValueError(
        "No page content available. Call open() before should_see()."
      )
    
    visible_text = self._soup.get_text()
    
    if text not in visible_text:
      # Provide a snippet of the actual content for debugging
      snippet_length = 200
      content_snippet = (
          visible_text[:snippet_length] + "..."
          if len(visible_text) > snippet_length
          else visible_text
      )
      raise AssertionError(
        f"Expected to see '{text}' in page content but it was not found.\n"
        f"Visible content (first {snippet_length} chars): {content_snippet}"
      )
    
    return self
  
  def should_not_see(self, text: str):
    """
    Assert that the given text is NOT present in the visible page content.

    This method parses the HTML and searches only in the visible text,
    ignoring HTML tags and attributes.

    Args:
        text: The text string that should not be present (case-sensitive).

    Returns:
        self: Returns the client instance for method chaining.

    Raises:
        AssertionError: If the text is found in the page content.
        ValueError: If no page has been opened yet.
    """
    if self._content is None:
      raise ValueError(
        "No page content available. Call open() before should_not_see()."
      )
    
    visible_text = self._soup.get_text()
    
    if text in visible_text:
      element = self._find_visible_text_element(self._soup, text)
      
      if element:
        context = self._format_element_with_context(element, self.parent_levels)
        error_msg = (
            f"Expected NOT to see '{text}' in page content but it was found.\n"
            f"Found in:\n{context}"
        )
      else:
        error_msg = (
            f"Expected NOT to see '{text}' in page content but it was found.\n"
            f"Unable to locate the element containing this text."
        )
      
      raise AssertionError(error_msg)
    
    return self
  
  def find_element(self, selector: str):
    """
    Find a single HTML element using a CSS selector.

    This method searches for elements matching the given CSS selector.
    It expects to find exactly one matching element.

    Args:
        selector: A CSS selector string (e.g., '#my-id', '.my-class', 'button.primary').

    Returns:
        TestableElement: A testable element wrapping the HTML fragment.

    Raises:
        ValueError: If no page has been opened yet.
        AssertionError: If no element or multiple elements match the selector.

    Examples:
        element = client.open('/').find_element('#login-button')
        element = client.find_element('button.primary')
    """
    if self._content is None:
      raise ValueError(
        "No page content available. Call open() before find_element()."
      )
    
    results = self._soup.select(selector)
    
    if len(results) == 0:
      raise AssertionError(
        f"No element found matching selector '{selector}'."
      )
    elif len(results) == 1:
      return TestableElement(self, results[0])
    else:
      raise AssertionError(
        f"Found {len(results)} elements matching selector '{selector}'. Expected exactly 1."
      )
  
  def get_content(self) -> str:
    """
    Get the raw HTML content of the last opened page.

    Returns:
        The HTML content as a string, or None if no page has been opened.
    """
    return self._content
  
  def set_content(self, content: str):
    """
    Set the HTML content and parse it with BeautifulSoup.

    Args:
        content: The HTML content string to set.
    """
    self._content = content
    self._soup = BeautifulSoup(content, 'html.parser')
  
  @staticmethod
  def _find_visible_text_element(soup, text: str):
    """
    Find the first element containing the visible text.

    This method traverses the BeautifulSoup tree to find the first element
    whose visible text content (including descendants) contains the search text.

    Args:
        soup: BeautifulSoup object representing the parsed HTML.
        text: The text to search for.

    Returns:
        BeautifulSoup element containing the text, or None if not found.
    """
    # Traverse all elements in the document
    for element in soup.descendants:
      # Skip NavigableString nodes, we want Tag elements
      if not isinstance(element, Tag):
        continue
      
      # Get visible text of this element and its descendants
      element_text = element.get_text()
      
      # Check if our search text is in this element's visible text
      if text in element_text:
        # Found it! But we want the smallest element containing the text
        # So let's check if any of its children also contain the text
        found_in_child = False
        
        for child in element.children:
          if isinstance(child, Tag) and text in child.get_text():
            found_in_child = True
            break
        
        # If no child contains the text, this is our target element
        if not found_in_child:
          return element
    
    return None
  
  @staticmethod
  def _indent_html(html_str: str, indent: int = 2):
    """
    Add indentation to HTML string.

    Args:
        html_str: HTML string to indent.
        indent: Number of spaces for indentation.

    Returns:
        str: Indented HTML string.
    """
    lines = html_str.split('\n')
    indented_lines = [' ' * indent + line for line in lines if line.strip()]
    return '\n'.join(indented_lines)
  
  def _format_element_with_context(self, element, parent_levels: int):
    """
    Format an element with its parent context for display.

    Args:
        element: BeautifulSoup element to format.
        parent_levels: Number of parent levels to include.

    Returns:
        str: Formatted HTML string with indentation.
    """
    # Collect the element and its parents
    elements_to_show = [element]
    current = element
    
    for _ in range(parent_levels):
      if current.parent and current.parent.name:  # Skip NavigableString parents
        elements_to_show.insert(0, current.parent)
        current = current.parent
      else:
        break
    
    # Format the top-level element with proper indentation
    if len(elements_to_show) == 1:
      return self._indent_html(str(element), indent=2)
    
    # Build the nested structure
    result = self._build_nested_context(elements_to_show, element)
    return self._indent_html(result, indent=2)
  
  def _build_nested_context(self, elements_chain, target_element):
    """
    Build nested HTML context showing parents and target element.

    Args:
        elements_chain: List of elements from outermost parent to target.
        target_element: The element that contains the searched text.

    Returns:
        str: Nested HTML structure.
    """
    if len(elements_chain) == 1:
      return str(target_element)
    
    # Get the outermost element
    outer = elements_chain[0]
    
    # Start with opening tag
    result = f"<{outer.name}"
    if outer.attrs:
      attrs = ' '.join(f'{k}="{v}"' if not isinstance(v, list) else f'{k}="{" ".join(v)}"'
                       for k, v in outer.attrs.items())
      result += f" {attrs}"
    result += ">\n"
    
    # Add nested content
    if len(elements_chain) == 2:
      # This is the target element
      result += self._indent_html(str(target_element), indent=2) + "\n"
    else:
      # Recursive call for deeper nesting
      nested = self._build_nested_context(elements_chain[1:], target_element)
      result += self._indent_html(nested, indent=2) + "\n"
    
    # Closing tag
    result += f"</{outer.name}>"
    
    return result
