"""In-process Playwright step executor — mirrors mirabilis/service/run_test_worker.py.

Executes structured component-format steps (the same dict schema stored in the
Mirabilis database) against a live Playwright page.  Behaviour is intentionally
identical to Mirabilis so a test that passes here will pass there too.

Deliberately excluded (Mirabilis-only concerns):
  - WebSocket / Socket.IO events
  - Per-step screenshot capture
  - CDP screencast
  - DOM hash generation
  - component_data_to_update DB backfill
  - Image assertion (perform_image_assertion) — marked unsupported
  - Before/after-each API hooks
"""

import asyncio
import logging
import re

from playwright.async_api import expect

from maeris_mcp.tools.locator_engine import find_locator

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Assertion helpers
# ---------------------------------------------------------------------------

async def _perform_existence_assertion(
    page,
    assertion_type: str,
    assertion_value: str,
    timeout: int = 30000,
) -> tuple[str, str]:
    """Check for visible / invisible text on the page (assertion_nature == 'existence').

    Mirrors: perform_existence_assertion() in run_test_worker.py
    """
    try:
        if assertion_type == "to be visible":
            elements = page.get_by_text(assertion_value, exact=False)
            try:
                await elements.first.wait_for(timeout=timeout)
            except Exception:
                pass
            count = await elements.count()
            for i in range(count):
                if await elements.nth(i).is_visible():
                    return "ok", f"Text '{assertion_value}' is visible"
            return (
                "error",
                f"None of the elements with '{assertion_value}' (to be visible) text are visible.",
            )

        elif assertion_type == "not to be visible":
            elements = page.get_by_text(assertion_value, exact=False)
            try:
                await elements.first.wait_for(timeout=min(timeout, 3000))
            except Exception:
                pass
            count = await elements.count()
            any_visible = False
            for i in range(count):
                if await elements.nth(i).is_visible():
                    any_visible = True
                    break
            if any_visible:
                return (
                    "error",
                    f"At least one element with text '{assertion_value}' is visible but should not be.",
                )
            return "ok", f"Text '{assertion_value}' is not visible"

        else:
            return "error", f"Unsupported existence assertion type: '{assertion_type}'"

    except Exception as exc:
        return "error", f"Existence assertion '{assertion_type}' raised: {exc}"


async def _perform_input_assertion(
    locator,
    assertion_type: str,
    assertion_value: str | None = None,
) -> tuple[str, str]:
    """Run an assertion against a specific element (assertion_nature == 'input').

    Mirrors: perform_input_assertion() in run_test_worker.py
    """
    atype = (assertion_type or "").strip().lower()
    try:
        if atype in {"to_have_text", "to have text"}:
            await expect(locator).to_have_text(assertion_value)
        elif atype in {"to_contain_text", "to contain text"}:
            await expect(locator).to_contain_text(assertion_value)
        elif atype == "to be visible":
            await expect(locator).to_be_visible()
        elif atype == "to be hidden":
            await expect(locator).to_be_hidden()
        elif atype == "to be enabled":
            await expect(locator).to_be_enabled()
        elif atype == "to be disabled":
            await expect(locator).to_be_disabled()
        elif atype == "to be checked":
            await expect(locator).to_be_checked()
        elif atype == "to be not checked":
            await expect(locator).not_to_be_checked()
        elif atype == "to have value":
            await expect(locator).to_have_value(assertion_value)
        elif atype == "to contain value":
            value = await locator.input_value()
            assert assertion_value in value, (
                f"[to contain value] Expected '{assertion_value}' in '{value}'"
            )
        elif atype == "to be editable":
            await expect(locator).to_be_editable()
        elif atype in {"to be focused", "to be focussed"}:
            await expect(locator).to_be_focused()
        elif atype == "to be empty":
            await expect(locator).to_be_empty()
        elif atype == "to be selected":
            await expect(locator).to_be_checked()  # closest Playwright equivalent
        elif atype == "not to be selected":
            await expect(locator).not_to_be_checked()
        elif atype == "to be password":
            input_type = await locator.get_attribute("type")
            assert input_type == "password", (
                f"[to be password] Expected type='password', got: {input_type}"
            )
        elif atype == "to be email":
            value = await locator.input_value()
            input_type = await locator.get_attribute("type")
            assert re.match(r"^[\w.\-+]+@[\w.\-]+\.\w+$", value), (
                f"[to be email] Expected valid email, got: {value}"
            )
            assert input_type == "email", (
                f"[to be email] Expected type='email', got: {input_type}"
            )
        elif atype == "to be number":
            value = await locator.input_value()
            input_type = await locator.get_attribute("type")
            assert value.replace(".", "", 1).replace("-", "", 1).isdigit(), (
                f"[to be number] Expected numeric value, got: {value}"
            )
            assert input_type == "number", (
                f"[to be number] Expected type='number', got: {input_type}"
            )
        elif atype == "to be date":
            value = await locator.input_value()
            input_type = await locator.get_attribute("type")
            assert re.match(r"^\d{4}-\d{2}-\d{2}$", value), (
                f"[to be date] Expected YYYY-MM-DD, got: {value}"
            )
            assert input_type == "date", (
                f"[to be date] Expected type='date', got: {input_type}"
            )
        elif atype == "has option":
            options = locator.locator("option")
            count = await options.count()
            found = False
            for i in range(count):
                opt = options.nth(i)
                text = await opt.inner_text()
                val = await opt.get_attribute("value")
                if assertion_value in (text, val):
                    found = True
                    break
            assert found, f"[has option] Option '{assertion_value}' not found"
        elif atype == "assert all options":
            actual = await locator.locator("option").count()
            assert actual == int(assertion_value), (
                f"[assert all options] Expected {assertion_value} options, got {actual}"
            )
        elif atype in {"has default option", "to have default option"}:
            selected = locator.locator("option[selected]")
            sel_count = await selected.count()
            if sel_count == 0:
                first_opts = await locator.locator("option").count()
                current = await locator.input_value() if first_opts > 0 else ""
                first_val = await locator.locator("option").first.get_attribute("value") if first_opts > 0 else ""
                assert current == first_val and current != "", (
                    "[to have default option] No default option selected"
                )
            else:
                sel_val = await selected.first.get_attribute("value")
                current = await locator.input_value()
                assert sel_val == current, (
                    "[to have default option] Default option not currently selected"
                )
        else:
            return "error", f"Unsupported input assertion type: '{assertion_type}'"

        return "ok", f"Assertion '{assertion_type}' passed"

    except AssertionError as exc:
        return "error", str(exc)
    except Exception as exc:
        return "error", f"Input assertion '{assertion_type}' raised: {exc}"


async def _perform_page_assertion(
    page,
    assertion_type: str,
    assertion_value: str | None,
) -> tuple[str, str]:
    """Assert page-level state (assertion_nature == 'page').

    Mirrors: perform_page_assertion() in run_test_worker.py
    """
    atype = (assertion_type or "").strip().lower()
    try:
        if atype == "to have title":
            await expect(page).to_have_title(assertion_value)
        elif atype == "to have url":
            await expect(page).to_have_url(assertion_value)
        elif atype == "to contain url":
            current = page.url
            cur_stripped = re.sub(r"^https?://", "", current)
            val_stripped = re.sub(r"^https?://", "", assertion_value or "")
            assert val_stripped in cur_stripped, (
                f"[to contain url] Expected URL to contain '{assertion_value}', got: {current}"
            )
        else:
            return "error", f"Unsupported page assertion type: '{assertion_type}'"

        return "ok", f"Page assertion '{assertion_type}' passed"

    except AssertionError as exc:
        return "error", str(exc)
    except Exception as exc:
        return "error", f"Page assertion '{assertion_type}' raised: {exc}"


# ---------------------------------------------------------------------------
# Click handler (with JS fallback + post-click wait)
# ---------------------------------------------------------------------------

async def _handle_click(locator, page, page_timeout: int) -> tuple:
    """Click a locator with scroll-into-view, JS fallback, and load-state wait.

    Mirrors: handle_click_and_tab_switch() in run_test_worker.py
    Returns: (page, success: bool)
    """
    try:
        await locator.scroll_into_view_if_needed(timeout=5000)
    except Exception:
        pass

    clicked = False
    try:
        await locator.click(timeout=page_timeout)
        clicked = True
    except Exception as exc:
        logger.warning("[CLICK] Playwright click failed: %s — trying JS fallback", exc)

    if not clicked:
        try:
            await locator.evaluate("el => { el.scrollIntoView({ block: 'center' }); el.click(); }")
            clicked = True
        except Exception as exc:
            logger.error("[CLICK] JS fallback also failed: %s", exc)
            return page, False

    # Wait for navigation / DOM settle after click
    try:
        await page.wait_for_load_state("networkidle", timeout=15000)
    except Exception:
        try:
            await page.wait_for_load_state("domcontentloaded", timeout=5000)
        except Exception:
            pass  # non-navigation click — DOM already settled

    return page, True


# ---------------------------------------------------------------------------
# Select / dropdown handler
# ---------------------------------------------------------------------------

async def _handle_select(locator, page, component: dict, page_timeout: int) -> tuple[str, str]:
    """Handle both native <select> and custom dropdown components.

    Mirrors the select handling block in interact_with_component().
    """
    options: list = component.get("options") or []
    mock_input: str | None = component.get("mock_input")

    try:
        tag_name = await locator.evaluate("el => el.tagName.toLowerCase()")
        is_native = tag_name == "select"
    except Exception:
        is_native = False

    if is_native:
        try:
            if mock_input and options and mock_input in options:
                idx = options.index(mock_input)
                await locator.select_option(index=idx, timeout=page_timeout)
            else:
                await locator.select_option(index=0, timeout=page_timeout)
            return "ok", "Option selected"
        except Exception as exc:
            logger.warning("[SELECT] select_option failed: %s — trying click", exc)
            try:
                await locator.click(timeout=page_timeout)
                return "ok", "Clicked native select"
            except Exception as exc2:
                return "error", f"Native select failed: {exc2}"
    else:
        # Custom dropdown — click to open, then click matching option
        try:
            await locator.click(timeout=page_timeout)
            await page.wait_for_timeout(150)
        except Exception as exc:
            return "error", f"Custom dropdown open click failed: {exc}"

        if options and mock_input:
            try:
                if options and isinstance(options[0], dict):
                    for opt in options:
                        if opt.get("text") == mock_input or opt.get("value") == mock_input:
                            sel = opt.get("css_selector", "")
                            nth = opt.get("nth_appearance", 0)
                            if sel:
                                await page.locator(sel).nth(nth).click(timeout=page_timeout)
                                return "ok", f"Custom dropdown option '{mock_input}' selected"
                else:
                    opt_locator = page.locator(f'text="{mock_input}"').first
                    await opt_locator.click(timeout=page_timeout)
                    return "ok", f"Custom dropdown option '{mock_input}' selected"
            except Exception as exc:
                logger.warning("[SELECT] Custom option click failed: %s", exc)

        return "ok", "Custom dropdown opened"


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

async def execute_step(
    page,
    component: dict,
    page_timeout: int = 30000,
) -> tuple[str, str, object]:
    """Execute a single Mirabilis-format component step.

    Mirrors: interact_with_component() in run_test_worker.py

    Component dict fields (all optional unless noted):
        css_selector      str   — [attr="value"] CSS selector
        xpath             str   — absolute XPath
        nth_appearance    int   — 0-indexed (default 0)
        action_taken      str   — "click"|"send_keys"|"hover"|"go_to"|"set_range"|"check"
        mock_input        str   — value to type or URL to navigate to
        tag               str   — "input"|"button"|"a"|"select" etc.
        validation_type   str   — "file"|"checkbox"|"radio"|"dropdown"
        options           list  — dropdown option texts
        assertion_type    str   — Mirabilis assertion string
        assertion_value   str   — expected value
        assertion_nature  str   — "existence"|"input"|"page"
        wait_before       int   — ms (applied by caller before execute_step)
        wait_after        int   — ms (applied by caller after execute_step)

    Returns: (status: "ok"|"error", message: str, page)
    """
    css_selector: str | None = component.get("css_selector") or component.get("selector")
    xpath: str | None = component.get("xpath")
    nth: int = int(component.get("nth_appearance") or 0)
    action: str = (component.get("action_taken") or "").strip().lower()
    tag: str = (component.get("tag") or component.get("tag_name") or "").lower()
    mock_input: str | None = component.get("mock_input")
    validation_type: str = (component.get("validation_type") or "").lower()
    attributes: dict = component.get("attributes") or {}
    assertion_type: str | None = component.get("assertion_type")
    assertion_value: str | None = component.get("assertion_value")
    assertion_nature: str | None = (component.get("assertion_nature") or "").lower()

    # ── Skip body-level components ────────────────────────────────────────────
    if tag == "body":
        return "ok", "Body element skipped", page

    # ── Assertion dispatch ────────────────────────────────────────────────────
    if assertion_type:
        if assertion_nature == "existence":
            status, msg = await _perform_existence_assertion(
                page, assertion_type, assertion_value or "", page_timeout
            )
            return status, msg, page

        elif assertion_nature == "page":
            status, msg = await _perform_page_assertion(page, assertion_type, assertion_value)
            return status, msg, page

        elif assertion_nature == "input":
            # input assertions need a locator
            locator = await find_locator(page, css_selector, xpath, nth, page_timeout)
            if locator is None:
                return (
                    "error",
                    f"Element not found for input assertion '{assertion_type}': "
                    f"css={css_selector!r} xpath={xpath!r}",
                    page,
                )
            status, msg = await _perform_input_assertion(locator, assertion_type, assertion_value)
            return status, msg, page

        elif assertion_nature == "image":
            # Image comparison requires stored reference images — not supported in maeris-mcp runner
            logger.warning("[STEP] Image assertion skipped (not supported in local runner)")
            return "ok", "Image assertion skipped (not supported in local runner)", page

        else:
            # Unknown nature — try existence as fallback
            status, msg = await _perform_existence_assertion(
                page, assertion_type, assertion_value or "", page_timeout
            )
            return status, msg, page

    # ── go_to (navigation) — no locator needed ───────────────────────────────
    if action == "go_to":
        if not mock_input:
            return "error", "go_to action requires mock_input (URL)", page
        try:
            await page.goto(mock_input, wait_until="domcontentloaded", timeout=page_timeout)
            # Best-effort networkidle wait — gives slow/SPA pages time to finish loading
            try:
                await page.wait_for_load_state("networkidle", timeout=10000)
            except Exception:
                pass
            return "ok", f"Navigated to {mock_input}", page
        except Exception as exc:
            return "error", f"Navigation to '{mock_input}' failed: {exc}", page

    # ── skip fill actions with no input ──────────────────────────────────────
    if action in {"send_keys", "to_fill", "set_range"} and mock_input is None:
        return "ok", "No mock_input provided — step skipped", page

    # ── resolve locator ───────────────────────────────────────────────────────
    logger.info(
        "[STEP] Locating element: css=%r xpath=%r nth=%d action=%r",
        css_selector, xpath, nth, action,
    )
    locator = await find_locator(page, css_selector, xpath, nth, page_timeout)

    if locator is None:
        return (
            "error",
            f"Element not found: css={css_selector!r} xpath={xpath!r} nth={nth}",
            page,
        )

    # ── extract tag if missing ────────────────────────────────────────────────
    if not tag:
        try:
            tag = await locator.evaluate("el => el.tagName.toLowerCase()")
        except Exception:
            pass

    # ── select / dropdown ─────────────────────────────────────────────────────
    if tag == "select" or action == "click_select":
        status, msg = await _handle_select(locator, page, component, page_timeout)
        return status, msg, page

    # ── click ─────────────────────────────────────────────────────────────────
    if action == "click":
        page, success = await _handle_click(locator, page, page_timeout)
        if not success:
            return "error", "Click failed — element could not be clicked", page
        return "ok", "Clicked", page

    # ── hover ─────────────────────────────────────────────────────────────────
    if action == "hover":
        try:
            await locator.hover(timeout=page_timeout)
            return "ok", "Hovered", page
        except Exception as exc:
            return "error", f"Hover failed: {exc}", page

    # ── check (checkbox / radio) ──────────────────────────────────────────────
    if action == "check" or validation_type in {"checkbox", "radio"}:
        try:
            await locator.check(timeout=page_timeout)
            return "ok", "Checked", page
        except Exception as exc:
            return "error", f"Check failed: {exc}", page

    # ── fill (send_keys / set_range / to_fill) ────────────────────────────────
    if action in {"send_keys", "to_fill", "set_range"}:
        input_type = attributes.get("type", "").lower() if attributes else ""
        is_file = validation_type == "file" or input_type == "file"

        if is_file:
            if mock_input and mock_input.strip():
                try:
                    await locator.set_input_files(mock_input, timeout=page_timeout)
                    return "ok", f"File input set to '{mock_input}'", page
                except Exception as exc:
                    return "error", f"set_input_files failed: {exc}", page
            else:
                return "ok", "File input skipped (no path provided)", page

        try:
            await locator.fill(mock_input or "", timeout=page_timeout)
            await page.wait_for_timeout(100)
            return "ok", f"Filled with '{mock_input}'", page
        except Exception as exc:
            return "error", f"Fill failed: {exc}", page

    # ── unrecognised action ───────────────────────────────────────────────────
    return (
        "error",
        f"Unrecognised action '{action}' for tag '{tag or 'unknown'}' — "
        f"css={css_selector!r}",
        page,
    )
