"""Playwright locator resolution engine — mirrors mirabilis/src/utils/playwright_utils.py.

Provides _find_locator() with the same CSS+XPath parallel search, nth-appearance
handling, iframe fallback, and selector sanitisation logic used by Mirabilis at
runtime.  Tests generated here and run against this engine will behave identically
when pushed to Mirabilis.
"""

import asyncio
import logging
import re

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Selector sanitisation
# ---------------------------------------------------------------------------

def escape_css_selector(selector: str) -> tuple[str, int | None]:
    """Sanitise a CSS selector string and extract any embedded nth-appearance.

    Handles:
    - :nth-appearance(N) pseudo-selector — strips it and returns N as selector_nth
    - Single-quoted attribute values  [attr='val'] → [attr="val"]
    - Backslash-escaped single quotes  [\\'val\\'] → ["val"]

    Returns:
        (escaped_selector, selector_nth | None)
    """
    if not selector:
        return selector, None

    selector = selector.strip()
    selector_nth: int | None = None

    # Extract and strip :nth-appearance(N) pseudo-selector
    nth_match = re.search(r":nth-appearance\(\s*(\d+)\s*\)", selector)
    if nth_match:
        selector_nth = int(nth_match.group(1))
        selector = selector.replace(nth_match.group(0), "")

    # Normalise escaped single quotes then convert [attr='val'] → [attr="val"]
    if "\\'" in selector:
        selector = selector.replace("\\'", "'")
        selector = re.sub(r"\[([^=]+)='([^']+)'\]", r'[\1="\2"]', selector)
    elif "'" in selector and "[" in selector and "]" in selector:
        selector = re.sub(r"\[([^=]+)='([^']+)'\]", r'[\1="\2"]', selector)

    return selector, selector_nth


def fix_and_validate_xpath(xpath: str) -> str | None:
    """Clean up common XPath corruption from storage/serialisation and validate.

    Handles:
    - Repeated backslashes  \\\\' → '
    - Escaped parentheses / brackets
    - Surrounding quotes
    - Leading dash artifact (-//input…)

    Returns cleaned XPath string, or None if it cannot be made valid.
    """
    if not xpath:
        return None

    original = xpath
    xpath = xpath.strip()

    # Remove leading dash artifact
    if xpath.startswith("-") and xpath[1:].startswith("//"):
        xpath = xpath[1:]

    # Strip surrounding quotes
    if (xpath.startswith("'") and xpath.endswith("'")) or \
       (xpath.startswith('"') and xpath.endswith('"')):
        xpath = xpath[1:-1]

    # Collapse repeated backslashes then unescape quotes
    xpath = re.sub(r"\\+", r"\\", xpath)
    xpath = xpath.replace("\\'", "'")
    xpath = xpath.replace('\\"', '"')

    # Remove accidental escaping before parens and brackets
    xpath = xpath.replace("\\(", "(").replace("\\)", ")")
    xpath = xpath.replace("\\[", "[").replace("\\]", "]")

    # Fix broken contains() patterns
    xpath = re.sub(r"text\(\),\s*\\'", "text(), '", xpath)

    # Validate with lxml if available; fall back to basic check
    try:
        from lxml import etree
        etree.XPath(xpath)
        logger.debug("Valid XPath: %s", xpath)
        return xpath
    except ImportError:
        # lxml not installed — return as-is if it starts with / or //
        if xpath.startswith("/") or xpath.startswith("("):
            return xpath
        logger.warning("lxml not available; XPath not validated: %s", xpath)
        return xpath
    except Exception:
        logger.error("Invalid XPath after fixing: %s → %s", original, xpath)
        return None


# ---------------------------------------------------------------------------
# Low-level locator helpers
# ---------------------------------------------------------------------------

async def _try_css_locator(
    page, escaped_selector: str, timeout: int
) -> tuple:
    """Attempt to find elements using a CSS selector.

    Returns: (locator | None, count: int, error: str | None)
    """
    try:
        css_loc = page.locator(escaped_selector)
        await css_loc.first.wait_for(state="attached", timeout=timeout)
        css_count = await css_loc.count()
        return css_loc, css_count, None
    except Exception as exc:
        return None, 0, str(exc)[:120]


async def _try_xpath_locator(
    page, fixed_xpath: str, timeout: int
) -> tuple:
    """Attempt to find elements using an XPath expression.

    Returns: (locator | None, count: int, error: str | None)
    """
    try:
        xpath_loc = page.locator(f"xpath={fixed_xpath}")
        await xpath_loc.first.wait_for(state="attached", timeout=timeout)
        xpath_count = await xpath_loc.count()
        return xpath_loc, xpath_count, None
    except Exception as exc:
        return None, 0, str(exc)[:120]


# ---------------------------------------------------------------------------
# Iframe / frame fallback
# ---------------------------------------------------------------------------

async def _search_frames_recursive(
    frame_or_page,
    selector: str | None,
    xpath: str | None,
    nth: int,
    timeout: int,
    depth: int = 0,
    max_depth: int = 2,
):
    """Recursively search nested iframes for an element.

    Returns the first matching locator or None.
    """
    if depth > max_depth:
        return None

    if hasattr(frame_or_page, "frames"):
        frames = frame_or_page.frames
    else:
        frames = [frame_or_page]

    for i, frame in enumerate(frames):
        try:
            locator = None
            if selector:
                escaped, nth_from_sel = escape_css_selector(selector)
                effective_nth = nth_from_sel if nth_from_sel is not None else nth
                frame_loc = frame.locator(escaped)
                count = await frame_loc.count()
                if count > 0:
                    locator = frame_loc.nth(effective_nth) if effective_nth < count else frame_loc.nth(0)
                    await locator.wait_for(state="attached", timeout=timeout)
                    logger.info("[FRAME] Found via CSS in frame %d depth %d", i, depth)
                    return locator
            if xpath and locator is None:
                frame_loc = frame.locator(f"xpath={xpath}")
                count = await frame_loc.count()
                if count > 0:
                    locator = frame_loc.nth(nth) if nth < count else frame_loc.nth(0)
                    await locator.wait_for(state="attached", timeout=timeout)
                    logger.info("[FRAME] Found via XPath in frame %d depth %d", i, depth)
                    return locator

            # Recurse into child frames
            result = await _search_frames_recursive(
                frame, selector, xpath, nth, timeout, depth + 1, max_depth
            )
            if result:
                return result
        except Exception as exc:
            if "timeout" in str(exc).lower() or "detached" in str(exc).lower():
                logger.debug("[FRAME] frame %d depth %d: %s", i, depth, str(exc)[:80])
            else:
                logger.warning("[FRAME] frame %d depth %d error: %s", i, depth, str(exc)[:80])

    return None


# ---------------------------------------------------------------------------
# Primary entry point
# ---------------------------------------------------------------------------

async def find_locator(
    page,
    selector: str | None = None,
    xpath: str | None = None,
    nth: int = 0,
    timeout: int = 30000,
):
    """Resolve a Playwright locator using the same strategy as Mirabilis.

    Strategy (in order):
    1. Sanitise selector (escape_css_selector) and XPath (fix_and_validate_xpath).
    2. If both are available, search in parallel:
       - Both find exactly 1 → prefer CSS.
       - Only one finds exactly 1 → use that.
       - Both find multiple → use whichever finds FEWER (more specific).
       - nth safety: if nth >= count, fall back to nth(0) silently.
    3. If only one is available, use it with nth.
    4. If nothing found on the main page, search iframes recursively.
    5. Final wait_for(attached) before returning.

    Returns the resolved Locator, or None if the element cannot be found.
    """
    try:
        # Normalise nth
        try:
            effective_nth = int(nth) if nth is not None else 0
        except (ValueError, TypeError):
            effective_nth = 0

        escaped_selector: str | None = None
        fixed_xpath: str | None = None

        if selector:
            escaped_selector, selector_nth = escape_css_selector(selector)
            if selector_nth is not None:
                effective_nth = selector_nth

        if xpath:
            fixed_xpath = fix_and_validate_xpath(xpath)

        final_loc = None

        # ── Both selector and xpath available ────────────────────────────────
        if escaped_selector and fixed_xpath:
            css_result, xpath_result = await asyncio.gather(
                _try_css_locator(page, escaped_selector, timeout),
                _try_xpath_locator(page, fixed_xpath, timeout),
                return_exceptions=False,
            )
            css_loc, css_count, css_err = css_result
            xpath_loc, xpath_count, xpath_err = xpath_result

            if css_err:
                logger.warning("[LOCATOR] CSS failed: %s", css_err)
            if xpath_err:
                logger.warning("[LOCATOR] XPath failed: %s", xpath_err)

            if css_count == 1 and xpath_count == 1:
                final_loc = css_loc          # both exact — prefer CSS
                logger.debug("[LOCATOR] Both single match — using CSS")
            elif css_count == 1:
                final_loc = css_loc
            elif xpath_count == 1:
                final_loc = xpath_loc
            elif css_loc and xpath_loc:
                # Use whichever is more specific (fewer matches)
                if xpath_count < css_count:
                    final_loc = xpath_loc.nth(effective_nth) if effective_nth < xpath_count else xpath_loc.nth(0)
                    logger.debug("[LOCATOR] XPath more specific (%d vs %d)", xpath_count, css_count)
                else:
                    final_loc = css_loc.nth(effective_nth) if effective_nth < css_count else css_loc.nth(0)
                    logger.debug("[LOCATOR] CSS more specific or equal (%d vs %d)", css_count, xpath_count)
            elif css_loc:
                final_loc = css_loc.nth(effective_nth) if effective_nth < css_count else css_loc.nth(0)
            elif xpath_loc:
                final_loc = xpath_loc.nth(effective_nth) if effective_nth < xpath_count else xpath_loc.nth(0)

        # ── Only CSS ─────────────────────────────────────────────────────────
        elif escaped_selector:
            css_loc, css_count, css_err = await _try_css_locator(page, escaped_selector, timeout)
            if css_loc:
                if css_count == 1:
                    final_loc = css_loc
                else:
                    final_loc = css_loc.nth(effective_nth) if effective_nth < css_count else css_loc.nth(0)
            else:
                logger.warning("[LOCATOR] CSS failed: %s", css_err)

        # ── Only XPath ───────────────────────────────────────────────────────
        elif fixed_xpath:
            xpath_loc, xpath_count, xpath_err = await _try_xpath_locator(page, fixed_xpath, timeout)
            if xpath_loc:
                if xpath_count == 1:
                    final_loc = xpath_loc
                else:
                    final_loc = xpath_loc.nth(effective_nth) if effective_nth < xpath_count else xpath_loc.nth(0)
            else:
                logger.warning("[LOCATOR] XPath failed: %s", xpath_err)

        # ── Iframe fallback ───────────────────────────────────────────────────
        if final_loc is None and (escaped_selector or fixed_xpath):
            logger.info("[LOCATOR] Main page failed — searching iframes")
            final_loc = await _search_frames_recursive(
                page, escaped_selector, fixed_xpath, effective_nth, timeout
            )
            if final_loc:
                logger.info("[LOCATOR] Found in iframe")

        if final_loc is None:
            logger.warning(
                "[LOCATOR] Element not found — CSS: %r  XPath: %r", selector, xpath
            )
            return None

        await final_loc.wait_for(state="attached", timeout=timeout)
        logger.debug("[LOCATOR] Resolved successfully")
        return final_loc

    except Exception as exc:
        logger.error("[LOCATOR] Exception: %s", exc)
        return None
