import json
import uuid
from typing import Self

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.utils import mount_utils
from myfasthtml.test.MyFT import MyFT

verbs = {
    'hx_get': 'GET',
    'hx_post': 'POST',
    'hx_put': 'PUT',
    'hx_delete': 'DELETE',
    'hx_patch': 'PATCH',
}


class DoNotSendCls:
  pass


DoNotSend = DoNotSendCls()


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, tag=None):
    """
    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.strip()
    elif isinstance(source, Tag):
      self.html_fragment = str(source).strip()
    elif isinstance(source, FT):
      self.html_fragment = to_xml(source).strip()
    else:
      raise ValueError(f"Invalid source '{source}' for TestableElement.")
    
    self.tag, self.element, self.my_ft = self._parse(tag, self.html_fragment)
    self.fields_mapping = {}  # link between the input label and the input name
    self.fields = {}  # Values of the fields {name: value}
    self.select_fields = {}  # list of possible options for 'select' input fields
    
    self._update_fields_mapping()
    self._update_fields()
  
  def fill(self, **kwargs):
    """
    Fill the form with the given data.

    Args:
        **kwargs: Field names and their values to fill in the form.
    """
    for name, value in kwargs.items():
      field_name = self._translate(name)
      if field_name not in self.fields:
        raise ValueError(f"Invalid field name '{name}'.")
      self.fields[self._translate(name)] = value
  
  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 _translate(self, field):
    """
    Translate a given field using a predefined mapping. If the field is not found
    in the mapping, the original field is returned unmodified.

    :param field: The field name to be translated.
    :type field: str
    :return: The translated field name if present in the mapping, or the original
        field name if no mapping exists for it.
    :rtype: str
    """
    return self.fields_mapping.get(field, field)
  
  def _support_htmx(self):
    """Check if the element supports HTMX."""
    return ('hx_get' in self.my_ft.attrs or
            'hx-get' in self.my_ft.attrs or
            'hx_post' in self.my_ft.attrs or
            'hx-post' in self.my_ft.attrs)
  
  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
    
    if data is not None:
      headers['Content-Type'] = 'application/x-www-form-urlencoded'
      bag_to_use = data
    elif json_data is not None:
      headers['Content-Type'] = 'application/json'
      bag_to_use = json_data
    else:
      # default to json_data
      headers['Content-Type'] = 'application/json'
      json_data = {}
      bag_to_use = json_data
    
    # .props contains the kwargs passed to the object (e.g., hx_post="/url")
    element_attrs = self.my_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 isinstance(value, str):
          bag_to_use |= json.loads(value)
        elif isinstance(value, dict):
          bag_to_use |= 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.my_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)
  
  def _update_fields_mapping(self):
    """
    Build a mapping between label text and input field names.

    This method finds all labels in the form and associates them with their
    corresponding input fields using the following priority order:
    1. Explicit association via 'for' attribute matching input 'id'
    2. Implicit association (label contains the input)
    3. Parent-level association with 'for'/'id'
    4. Proximity association (siblings in same parent)
    5. No label (use input name as key)

    The mapping is stored in self.fields_mapping as {label_text: input_name}.
    For inputs without a name, the id is used. If neither exists, a generic
    key like "unnamed_0" is generated.
    """
    self.fields_mapping = {}
    processed_inputs = set()
    unnamed_counter = 0
    
    # Get all inputs in the form
    all_inputs = self.element.find_all('input')
    
    # Priority 1 & 2: Explicit association (for/id) and implicit (nested)
    for label in self.element.find_all('label'):
      label_text = label.get_text(strip=True)
      
      # Check for explicit association via 'for' attribute
      label_for = label.get('for')
      if label_for:
        input_field = self.element.find(id=label_for)
        if input_field:
          input_name = self._get_input_identifier(input_field, unnamed_counter)
          if input_name.startswith('unnamed_'):
            unnamed_counter += 1
          self.fields_mapping[label_text] = input_name
          processed_inputs.add(id(input_field))
          continue
      
      # Check for implicit association (label contains input)
      input_field = label.find('input')
      if input_field:
        input_name = self._get_input_identifier(input_field, unnamed_counter)
        if input_name.startswith('unnamed_'):
          unnamed_counter += 1
        self.fields_mapping[label_text] = input_name
        processed_inputs.add(id(input_field))
        continue
    
    # Priority 3 & 4: Parent-level associations
    for label in self.element.find_all('label'):
      label_text = label.get_text(strip=True)
      
      # Skip if this label was already processed
      if label_text in self.fields_mapping:
        continue
      
      parent = label.parent
      if parent:
        input_found = False
        
        # Priority 3: Look for sibling input with matching for/id
        label_for = label.get('for')
        if label_for:
          for sibling in parent.find_all('input'):
            if sibling.get('id') == label_for and id(sibling) not in processed_inputs:
              input_name = self._get_input_identifier(sibling, unnamed_counter)
              if input_name.startswith('unnamed_'):
                unnamed_counter += 1
              self.fields_mapping[label_text] = input_name
              processed_inputs.add(id(sibling))
              input_found = True
              break
        
        # Priority 4: Fallback to proximity if no input found yet
        if not input_found:
          for sibling in parent.find_all('input'):
            if id(sibling) not in processed_inputs:
              input_name = self._get_input_identifier(sibling, unnamed_counter)
              if input_name.startswith('unnamed_'):
                unnamed_counter += 1
              self.fields_mapping[label_text] = input_name
              processed_inputs.add(id(sibling))
              break
    
    # Priority 5: Inputs without labels
    for input_field in all_inputs:
      if id(input_field) not in processed_inputs:
        input_name = self._get_input_identifier(input_field, unnamed_counter)
        if input_name.startswith('unnamed_'):
          unnamed_counter += 1
        self.fields_mapping[input_name] = input_name
  
  def _update_fields(self):
    """
    Update the fields dictionary with current form values and their proper types.

    This method processes all input and select elements in the form:
    - Determines the appropriate Python type (str, int, float, bool) based on
      the HTML input type attribute and/or the value itself
    - For select elements, populates self.select_fields with available options
    - Stores the final typed values in self.fields

    Type conversion priority:
    1. HTML type attribute (checkbox → bool, number → int/float, etc.)
    2. Value analysis fallback for ambiguous types (text/hidden/absent type)
    """
    self.fields = {}
    self.select_fields = {}
    
    # Process input fields
    for input_field in self.element.find_all('input'):
      name = input_field.get('name')
      if not name:
        continue
      
      input_type = input_field.get('type', 'text').lower()
      raw_value = input_field.get('value', '')
      
      # Type conversion based on input type
      if input_type == 'checkbox':
        # Checkbox: bool based on 'checked' attribute
        self.fields[name] = input_field.has_attr('checked')
      
      elif input_type == 'radio':
        # Radio: str value (only if checked)
        if input_field.has_attr('checked'):
          self.fields[name] = raw_value
        elif name not in self.fields:
          # If no radio is checked yet, don't set a default
          self.fields[name] = None
      
      elif input_type == 'number':
        # Number: int or float based on value
        self.fields[name] = self._convert_number(raw_value)
      
      else:
        # Other types (text, hidden, email, password, etc.): analyze value
        self.fields[name] = self._convert_value(raw_value)
    
    # Process select fields
    for select_field in self.element.find_all('select'):
      name = select_field.get('name')
      if not name:
        continue
      
      # Extract all options
      options = []
      selected_value = []
      
      for option in select_field.find_all('option'):
        option_value = option.get('value', option.get_text(strip=True))
        option_text = option.get_text(strip=True)
        
        options.append({
            'value': option_value,
            'text': option_text
        })
        
        # Track selected option
        if option.has_attr('selected'):
          selected_value.append(option_value)
      
      # Store options list
      self.select_fields[name] = options
      
      # Store selected value (or first option if none selected)
      is_multiple = select_field.has_attr('multiple')
      if is_multiple:
        self.fields[name] = selected_value
      else:
        if len(selected_value) > 0:
          self.fields[name] = selected_value[-1]
        elif options:
          self.fields[name] = options[0]['value']
    
    # Process textarea fields
    for textarea_field in self.element.find_all('textarea'):
      name = textarea_field.get('name')
      if not name:
        continue
      
      self.fields[name] = textarea_field.get_text(strip=True)
  
  @staticmethod
  def _get_input_identifier(input_field, counter):
    """
    Get the identifier for an input field.

    Args:
        input_field: The BeautifulSoup Tag object representing the input.
        counter: Current counter for unnamed inputs.

    Returns:
        The input name, id, or a generated "unnamed_X" identifier.
    """
    if input_field.get('name'):
      return input_field['name']
    elif input_field.get('id'):
      return input_field['id']
    else:
      return f"unnamed_{counter}"
  
  @staticmethod
  def _convert_number(value):
    """
    Convert a string value to int or float.

    Args:
        value: String value to convert.

    Returns:
        int, float, or empty string if conversion fails.
    """
    if not value or value.strip() == '':
      return ''
    
    try:
      # Try float first to detect decimal numbers
      if '.' in value or 'e' in value.lower():
        return float(value)
      else:
        return int(value)
    except ValueError:
      return value
  
  @staticmethod
  def _convert_value(value):
    """
    Analyze and convert a value to its appropriate type.

    Conversion priority:
    1. Boolean keywords (true/false)
    2. Float (contains decimal point)
    3. Int (numeric)
    4. Empty string
    5. String (default)

    Args:
        value: String value to convert.

    Returns:
        Converted value with appropriate type (bool, float, int, or str).
    """
    if not value or value.strip() == '':
      return ''
    
    value_lower = value.lower().strip()
    
    # Check for boolean
    if value_lower in ('true', 'false'):
      return value_lower == 'true'
    
    # Check for numeric values
    try:
      # Check for float (has decimal point or scientific notation)
      if '.' in value or 'e' in value_lower:
        return float(value)
      # Try int
      else:
        return int(value)
    except ValueError:
      pass
    
    # Default to string
    return value
  
  @staticmethod
  def _parse(tag, html_fragment: str):
    elt = BeautifulSoup(html_fragment, 'html.parser')
    
    if len(elt) == 0:
      raise ValueError(f"No HTML element found in: {html_fragment}")
    
    if len(elt) == 1:
      elt = elt.find()
      elt_tag = elt.name
      if tag is not None and tag != elt_tag:
        raise ValueError(f"Tag '{tag}' does not match with '{html_fragment}'.")
      my_ft = MyFT(elt_tag, elt.attrs)
      if elt_tag != "form":
        elt = BeautifulSoup(f"<form>{html_fragment}</form>", 'html.parser')
      return elt_tag, elt, my_ft
    
    else:
      if tag is None:
        raise ValueError(f"Multiple elements found in {html_fragment}. Please specify a tag.")
      
      elt = BeautifulSoup(f"<form>{html_fragment}</form>", 'html.parser')
      _inner = elt.find(tag)
      my_ft = MyFT(_inner.name, _inner.attrs)
      return tag, elt.find(), my_ft


class TestableForm(TestableElement):
  """
  Represents an HTML form that can be filled and submitted in tests.
  """
  
  def __init__(self, client, source):
    """
    Initialize a testable form.

    Args:
        client: The MyTestClient instance.
        source: The source HTML string containing a form.
    """
    super().__init__(client, source, "form")
    # self.form = BeautifulSoup(self.html_fragment, 'html.parser').find('form')
    # self.fields_mapping = {}  # link between the input label and the input name
    # self.fields = {}  # field name; field value
    # self.select_fields = {}  # list of possible options for 'select' input fields
    #
    # self._update_fields_mapping()
    # self.update_fields()
  
  # def update_fields(self):
  #   """
  #   Update the fields dictionary with current form values and their proper types.
  #
  #   This method processes all input and select elements in the form:
  #   - Determines the appropriate Python type (str, int, float, bool) based on
  #     the HTML input type attribute and/or the value itself
  #   - For select elements, populates self.select_fields with available options
  #   - Stores the final typed values in self.fields
  #
  #   Type conversion priority:
  #   1. HTML type attribute (checkbox → bool, number → int/float, etc.)
  #   2. Value analysis fallback for ambiguous types (text/hidden/absent type)
  #   """
  #   self.fields = {}
  #   self.select_fields = {}
  #
  #   # Process input fields
  #   for input_field in self.form.find_all('input'):
  #     name = input_field.get('name')
  #     if not name:
  #       continue
  #
  #     input_type = input_field.get('type', 'text').lower()
  #     raw_value = input_field.get('value', '')
  #
  #     # Type conversion based on input type
  #     if input_type == 'checkbox':
  #       # Checkbox: bool based on 'checked' attribute
  #       self.fields[name] = input_field.has_attr('checked')
  #
  #     elif input_type == 'radio':
  #       # Radio: str value (only if checked)
  #       if input_field.has_attr('checked'):
  #         self.fields[name] = raw_value
  #       elif name not in self.fields:
  #         # If no radio is checked yet, don't set a default
  #         pass
  #
  #     elif input_type == 'number':
  #       # Number: int or float based on value
  #       self.fields[name] = self._convert_number(raw_value)
  #
  #     else:
  #       # Other types (text, hidden, email, password, etc.): analyze value
  #       self.fields[name] = self._convert_value(raw_value)
  #
  #   # Process select fields
  #   for select_field in self.form.find_all('select'):
  #     name = select_field.get('name')
  #     if not name:
  #       continue
  #
  #     # Extract all options
  #     options = []
  #     selected_value = None
  #
  #     for option in select_field.find_all('option'):
  #       option_value = option.get('value', option.get_text(strip=True))
  #       option_text = option.get_text(strip=True)
  #
  #       options.append({
  #           'value': option_value,
  #           'text': option_text
  #       })
  #
  #       # Track selected option
  #       if option.has_attr('selected'):
  #         selected_value = option_value
  #
  #     # Store options list
  #     self.select_fields[name] = options
  #
  #     # Store selected value (or first option if none selected)
  #     if selected_value is not None:
  #       self.fields[name] = selected_value
  #     elif options:
  #       self.fields[name] = options[0]['value']
  #
  
  def submit(self):
    """
    Submit the form.

    This method handles both HTMX-enabled forms and classic HTML form submissions:
    - If the form supports HTMX (has hx_post, hx_get, etc.), uses HTMX request
    - Otherwise, simulates a classic browser form submission using the form's
      action and method attributes

    Returns:
        The response from the form submission.

    Raises:
        ValueError: If the form has no action attribute for classic submission.
    """
    # Check if the form supports HTMX
    if self._support_htmx():
      return self._send_htmx_request(data=self.fields)
    
    # Classic form submission
    action = self.element.get('action')
    if not action or action.strip() == '':
      raise ValueError(
        "The form has no 'action' attribute. "
        "Cannot submit a classic form without a target URL."
      )
    
    method = self.element.get('method', 'post').upper()
    
    # Prepare headers for classic form submission
    headers = {
        "Content-Type": "application/x-www-form-urlencoded"
    }
    
    # Send the request via the client
    return self.client.send_request(
      method=method,
      url=action,
      headers=headers,
      data=self.fields
    )


class TestableControl(TestableElement):
  def __init__(self, client, source, tag):
    super().__init__(client, source, tag)
    assert len(self.fields) == 1
    self._input_name = next(iter(self.fields))
  
  @property
  def name(self):
    return self._input_name
  
  @property
  def value(self):
    return self.fields[self._input_name]
  
  def _send_value(self):
    if self._input_name and self._support_htmx():
      value = {} if self.value is DoNotSend else {self._input_name: self.value}
      return self._send_htmx_request(data=value)
    return None


class TestableInput(TestableControl):
  def __init__(self, client, source):
    super().__init__(client, source, "input")
  
  def send(self, value):
    self.fields[self.name] = value
    return self._send_value()


class TestableCheckbox(TestableControl):
  def __init__(self, client, source):
    super().__init__(client, source, "input")
  
  @property
  def is_checked(self):
    return self.fields[self._input_name] == True
  
  def check(self):
    self.fields[self._input_name] = "on"
    return self._send_value()
  
  def uncheck(self):
    self.fields[self._input_name] = DoNotSend
    return self._send_value()
  
  def toggle(self):
    if self.fields[self._input_name] == "on":
      return self.uncheck()
    else:
      return self.check()


class TestableTextarea(TestableControl):
  """
  Represents a textarea element that can be interacted with in tests.

  Textareas are similar to text inputs but support multi-line text.
  """
  
  def __init__(self, client, source):
    """
    Initialize a testable textarea.

    Args:
        client: The MyTestClient instance.
        source: The source HTML or BeautifulSoup Tag.
    """
    # Parse as textarea element
    super().__init__(client, source, "textarea")
  
  def send(self, value):
    """
    Set the textarea value and trigger HTMX update if configured.

    Args:
        value: The text value to set (can be multi-line string).

    Returns:
        Response from HTMX request if applicable, None otherwise.
    """
    self.fields[self.name] = value
    return self._send_value()
  
  def append(self, text):
    """
    Append text to the current textarea value.

    Args:
        text: Text to append.

    Returns:
        Response from HTMX request if applicable, None otherwise.
    """
    current_value = self.fields.get(self.name, '')
    self.fields[self.name] = current_value + text
    return self._send_value()
  
  def clear(self):
    """
    Clear the textarea content.

    Returns:
        Response from HTMX request if applicable, None otherwise.
    """
    self.fields[self.name] = ''
    return self._send_value()


class TestableSelect(TestableControl):
  """
  Represents a select dropdown element that can be interacted with in tests.

  Supports both single and multiple selection modes.
  """
  
  def __init__(self, client, source):
    """
    Initialize a testable select element.

    Args:
        client: The MyTestClient instance.
        source: The source HTML or BeautifulSoup Tag.
    """
    # Parse as select element
    super().__init__(client, source, "select")
    self._is_multiple = self.my_ft.attrs.get('multiple') is not None
  
  @property
  def is_multiple(self):
    """Check if this is a multiple selection dropdown."""
    return self._is_multiple
  
  @property
  def options(self):
    """
    Get all available options for this select.

    Returns:
        List of dicts with 'value' and 'text' keys.
    """
    return self.select_fields.get(self.name, [])
  
  def select(self, value):
    """
    Select an option by value.

    Args:
        value: The value of the option to select (not the text).

    Returns:
        Response from HTMX request if applicable, None otherwise.

    Raises:
        ValueError: If the value is not in the available options.
    """
    # Validate the value exists in options
    available_values = [opt['value'] for opt in self.options]
    if value not in available_values:
      raise ValueError(
        f"Value '{value}' not found in select options. "
        f"Available values: {available_values}"
      )
    
    if self.is_multiple:
      # For multiple select, value should be a list
      current = self.fields.get(self.name, [])
      if not isinstance(current, list):
        current = [current] if current else []
      if value not in current:
        current.append(value)
      self.fields[self.name] = current[0] if len(current) == 1 else current  # it's not a list when only one is selected
    else:
      # For single select, just set the value
      self.fields[self.name] = value
    
    return self._send_value()
  
  def select_by_text(self, text):
    """
    Select an option by its visible text.

    Args:
        text: The visible text of the option to select.

    Returns:
        Response from HTMX request if applicable, None otherwise.

    Raises:
        ValueError: If the text is not found in options.
    """
    # Find the value corresponding to this text
    for option in self.options:
      if option['text'] == text:
        return self.select(option['value'])
    
    raise ValueError(
      f"Option with text '{text}' not found. "
      f"Available texts: {[opt['text'] for opt in self.options]}"
    )
  
  def deselect(self, value):
    """
    Deselect an option (only for multiple selects).

    Args:
        value: The value of the option to deselect.

    Returns:
        Response from HTMX request if applicable, None otherwise.

    Raises:
        ValueError: If called on a non-multiple select.
    """
    if not self.is_multiple:
      raise ValueError("Cannot deselect on a single-select dropdown")
    
    current = self.fields.get(self.name, [])
    if not isinstance(current, list):
      current = [current] if current else []
    
    if value in current:
      current.remove(value)
      self.fields[self.name] = current[0] if len(current) == 1 else current
      return self._send_value()
    
    return None


class TestableRange(TestableControl):
  """
  Represents a range input (slider) that can be interacted with in tests.
  """
  
  def __init__(self, client, source):
    """
    Initialize a testable range input.

    Args:
        client: The MyTestClient instance.
        source: The source HTML or BeautifulSoup Tag.
    """
    super().__init__(client, source, "input")
    
    # Extract min, max, step from attributes
    self._min = float(self.my_ft.attrs.get('min', 0))
    self._max = float(self.my_ft.attrs.get('max', 100))
    self._step = float(self.my_ft.attrs.get('step', 1))
  
  @property
  def min_value(self):
    """Get the minimum value of the range."""
    return self._min
  
  @property
  def max_value(self):
    """Get the maximum value of the range."""
    return self._max
  
  @property
  def step(self):
    """Get the step increment of the range."""
    return self._step
  
  def set(self, value):
    """
    Set the range value.

    Args:
        value: Numeric value to set (will be clamped to min/max).

    Returns:
        Response from HTMX request if applicable, None otherwise.
    """
    # Clamp value to valid range
    value = max(self._min, min(self._max, float(value)))
    
    # Round to nearest step
    value = round((value - self._min) / self._step) * self._step + self._min
    
    self.fields[self.name] = value
    return self._send_value()
  
  def increase(self):
    """
    Increase the range value by one step.

    Returns:
        Response from HTMX request if applicable, None otherwise.
    """
    current = float(self.fields.get(self.name, self._min))
    return self.set(current + self._step)
  
  def decrease(self):
    """
    Decrease the range value by one step.

    Returns:
        Response from HTMX request if applicable, None otherwise.
    """
    current = float(self.fields.get(self.name, self._min))
    return self.set(current - self._step)


class TestableRadio(TestableControl):
  """
  Represents a radio button input that can be interacted with in tests.

  Note: Radio buttons with the same name form a group where only one
  can be selected at a time.
  """
  
  def __init__(self, client, source):
    """
    Initialize a testable radio button.

    Args:
        client: The MyTestClient instance.
        source: The source HTML or BeautifulSoup Tag.
    """
    super().__init__(client, source, "input")
    nb_radio_buttons = len(self.element.find_all("input", type="radio"))
    assert nb_radio_buttons > 0, "No radio buttons found."
    assert nb_radio_buttons < 2, "Only one radio button per name is supported."
    self._radio_value = self.my_ft.attrs.get('value', '')
  
  @property
  def radio_value(self):
    """Get the value attribute of this radio button."""
    return self._radio_value
  
  @property
  def is_checked(self):
    """Check if this radio button is currently selected."""
    return self.fields.get(self.name) == self._radio_value
  
  def select(self):
    """
    Select this radio button.

    Returns:
        Response from HTMX request if applicable, None otherwise.
    """
    self.fields[self.name] = self._radio_value
    return self._send_value()


class TestableButton(TestableElement):
  """
  Represents a button element that can be clicked in tests.

  Buttons can trigger HTMX requests or form submissions.
  """
  
  def __init__(self, client, source):
    """
    Initialize a testable button.

    Args:
        client: The MyTestClient instance.
        source: The source HTML or BeautifulSoup Tag.
    """
    super().__init__(client, source, "button")
  
  @property
  def text(self):
    """Get the visible text of the button."""
    return self.element.get_text(strip=True)
  
  def click(self):
    """
    Click the button and trigger any associated HTMX request.

    Returns:
        Response from HTMX request if applicable, None otherwise.
    """
    if self._support_htmx():
      return self._send_htmx_request()
    return None


class TestableDatalist(TestableControl):
  """
  Represents an input with datalist (autocomplete/combobox) that can be
  interacted with in tests.

  This is essentially an input that can show suggestions from a datalist.
  """
  
  def __init__(self, client, source):
    """
    Initialize a testable input with datalist.

    Args:
        client: The MyTestClient instance.
        source: The source HTML or BeautifulSoup Tag.
    """
    super().__init__(client, source, "input")
    
    # Find associated datalist
    list_id = self.my_ft.attrs.get('list')
    self._datalist_options = []
    
    if list_id:
      # Parse the full HTML to find the datalist
      soup = BeautifulSoup(self.html_fragment, 'html.parser')
      datalist = soup.find('datalist', id=list_id)
      
      if datalist:
        for option in datalist.find_all('option'):
          option_value = option.get('value', option.get_text(strip=True))
          self._datalist_options.append(option_value)
  
  @property
  def suggestions(self):
    """
    Get all available suggestions from the datalist.

    Returns:
        List of suggestion values.
    """
    return self._datalist_options
  
  def send(self, value):
    """
    Set the input value (can be any value, not restricted to suggestions).

    Args:
        value: The value to set.

    Returns:
        Response from HTMX request if applicable, None otherwise.
    """
    self.fields[self.name] = value
    return self._send_value()
  
  def select_suggestion(self, value):
    """
    Select a value from the datalist suggestions.

    Args:
        value: The suggestion value to select.

    Returns:
        Response from HTMX request if applicable, None otherwise.

    Raises:
        ValueError: If the value is not in the suggestions.
    """
    if value not in self._datalist_options:
      raise ValueError(
        f"Value '{value}' not found in datalist suggestions. "
        f"Available: {self._datalist_options}"
      )
    
    return self.send(value)


# Update the TestableElement factory method
# This should be added to the MyTestClient._testable_element_factory method

def _testable_element_factory_extended(client, elt):
  """
  Extended factory method for creating appropriate Testable* instances.

  This should replace or extend the existing _testable_element_factory method
  in MyTestClient.

  Args:
      client: The MyTestClient instance.
      elt: BeautifulSoup Tag element.

  Returns:
      Appropriate Testable* instance based on element type.
  """
  if elt.name == "input":
    input_type = elt.get("type", "text").lower()
    
    if input_type == "checkbox":
      return TestableCheckbox(client, elt)
    elif input_type == "radio":
      return TestableRadio(client, elt)
    elif input_type == "range":
      return TestableRange(client, elt)
    elif elt.get("list"):  # Input with datalist
      return TestableDatalist(client, elt)
    else:
      return TestableInput(client, elt)
  
  elif elt.name == "textarea":
    return TestableTextarea(client, elt)
  
  elif elt.name == "select":
    return TestableSelect(client, elt)
  
  elif elt.name == "button":
    return TestableButton(client, elt)
  
  else:
    return TestableElement(client, elt, elt.name)


#   def get_value(tag):
#     """Return the current user-facing value of an HTML input-like element."""
#     if tag.name == 'input':
#       t = tag.get('type', 'text').lower()
#       if t in ('checkbox', 'radio'):
#         # For checkbox/radio: return True/False if checked, else value if defined
#         return tag.has_attr('checked')
#       return tag.get('value', '')
#
#     elif tag.name == 'textarea':
#       # Textarea content is its text, not an attribute
#       return tag.text or ''
#
#     elif tag.name == 'select':
#       # For <select>, get selected option value (or text if no value attr)
#       selected = tag.find('option', selected=True)
#       if selected:
#         return selected.get('value', selected.text)
#       first = tag.find('option')
#       return first.get('value', first.text) if first else ''
#
#     else:
#       raise TypeError(f"Unsupported tag: <{tag.name}>")
#
#
# def _update_value(tag, new_value):
#   """Simulate user input by updating the value of <input>, <textarea>, or <select>."""
#   if tag.name == 'input':
#     t = tag.get('type', 'text').lower()
#     if t in ('checkbox', 'radio'):
#       # For checkbox/radio: treat True/False as checked/unchecked
#       if isinstance(new_value, bool):
#         if new_value:
#           tag['checked'] = ''
#         elif 'checked' in tag.attrs:
#           del tag.attrs['checked']
#       else:
#         tag['value'] = str(new_value)
#     else:
#       tag['value'] = str(new_value)
#
#   elif tag.name == 'textarea':
#     tag.string = str(new_value)
#
#   elif tag.name == 'select':
#     # Deselect all options
#     for option in tag.find_all('option'):
#       option.attrs.pop('selected', None)
#     # Select matching one by value or text
#     matched = tag.find('option', value=str(new_value))
#     if matched:
#       matched['selected'] = ''
#     else:
#       matched = tag.find('option', string=str(new_value))
#       if matched:
#         matched['selected'] = ''
#
#   else:
#     raise TypeError(f"Unsupported tag: <{tag.name}>")

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_utils(self.app)
  
  def open(self, path: str) -> Self:
    """
    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):
    if json_data is not None:
      json_data['session'] = self._session
    if data is not None:
      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) -> Self:
    """
    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.
    """
    
    def clean_text(txt):
      return "\n".join(line for line in txt.splitlines() if line.strip())
    
    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 = clean_text(
        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) -> Self:
    """
    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) -> TestableElement:
    """
    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 self._testable_element_factory(results[0])
    else:
      raise AssertionError(
        f"Found {len(results)} elements matching selector '{selector}'. Expected exactly 1."
      )
  
  def find_form(self, fields: list = None) -> TestableForm:
    """
    Find a form element in the page content.
    Can provide title of the fields to ease the search
    :param fields:
    :return:
    """
    if self._content is None:
      raise ValueError(
        "No page content available. Call open() before find_form()."
      )
    
    results = self._soup.select("form")
    if len(results) == 0:
      raise AssertionError(
        f"No form found."
      )
    
    if fields is None:
      remaining = [TestableForm(self, form) for form in results]
    else:
      remaining = []
      for form in results:
        testable_form = TestableForm(self, form)
        if all(testable_form._translate(field) in testable_form.fields for field in fields):
          remaining.append(testable_form)
    
    if len(remaining) == 1:
      return remaining[0]
    else:
      raise AssertionError(
        f"Found {len(remaining)} forms (with the specified fields). Expected exactly 1."
      )
  
  def find_input(self, identifier: str) -> TestableInput:
    pass
  
  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) -> Self:
    """
    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')
    return self
  
  def _testable_element_factory(self, elt):
    """
    Factory method for creating appropriate Testable* instances.

    Args:
        elt: BeautifulSoup Tag element.

    Returns:
        Appropriate Testable* instance based on element type.
    """
    if elt.name == "input":
      input_type = elt.get("type", "text").lower()
      
      if input_type == "checkbox":
        return TestableCheckbox(self, elt)
      elif input_type == "radio":
        return TestableRadio(self, elt)
      elif input_type == "range":
        return TestableRange(self, elt)
      elif elt.get("list"):  # Input with datalist
        return TestableDatalist(self, elt)
      else:
        return TestableInput(self, elt)
    
    elif elt.name == "textarea":
      return TestableTextarea(self, elt)
    
    elif elt.name == "select":
      return TestableSelect(self, elt)
    
    elif elt.name == "button":
      return TestableButton(self, elt)
    
    else:
      return TestableElement(self, elt, elt.name)
  
  @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
