"""Test executor — AI scenario generation + headless Playwright execution.

Flow: URL → page analysis → AI scenarios → Playwright execution → results.
Reuses AAT core modules (aat package must be installed).
"""

from __future__ import annotations

import asyncio
import base64
import contextlib
import logging
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

import yaml
from sqlalchemy import select

from app.config import settings
from app.database import async_session
from app.models import Test
from app.ws import WSManager

logger = logging.getLogger(__name__)

# Default model per AI provider
_DEFAULT_MODELS: dict[str, str] = {
    "claude": "claude-sonnet-4-20250514",
    "deepseek": "deepseek-chat",
    "openai": "gpt-4o-mini",
    "ollama": "codellama:7b",
}

# ---------------------------------------------------------------------------
# AI scenario generation prompt
# ---------------------------------------------------------------------------

_SCENARIO_PROMPT = """\
You are an E2E test scenario generator.

Analyze the following web page and generate user-perspective E2E test scenarios.

## Target
- URL: {url}

## Page Content (truncated)
{page_text}

## Page Elements (observed from actual page)
{page_elements}

## CRITICAL Rules
1. **Use EXACT text from the page content above** for all click targets and assertions.
   - Extract actual button labels, link text, menu item names from "Page Content"
   - NEVER use generic placeholders like "menu1", "menu2", "button1", "link1"
   - If the page has a menu with "Pricing", "Features", "About", use those exact words
2. Identify key user flows visible in the page (navigation, links, buttons, forms)
3. Generate 1-3 test scenarios covering the most important flows
4. Each scenario should have clear steps: navigate, click, type, assert
5. For click targets, use the `text` field with the exact visible label
6. For assertions, verify text that actually appears in the page content
7. Keep steps concise and actionable
8. **SELECTOR-FIRST**: Every click/type target MUST include "selector" from Page Elements.
   WRONG: {{"text": "가입"}}
   RIGHT: {{"selector": "button.MuiButtonBase-root", "text": "가입"}}
9. **FORM SUBMIT BUTTON — CRITICAL**:
   After filling form fields (find_and_type steps), the NEXT click MUST be the
   form's own submit button — look for SUBMIT[form] in Page Elements.
   - SUBMIT[form] = button INSIDE the form → USE THIS for form submission
   - SUBMIT[nav] = navigation menu link → NEVER use this after form input
   - SUBMIT[body] = button outside form/nav → only if no [form] button exists
10. **TEST INDEPENDENCE**: Each scenario MUST start with navigate to target URL.

## Output Format
Return ONLY a valid JSON array. Each object:
{{
  "id": "SC-001",
  "name": "Test name",
  "description": "Test description",
  "steps": [
    {{"step": 1, "action": "navigate", "value": "{url}", "description": "Navigate"}},
    {{"step": 2, "action": "find_and_click", "target": {{"selector": "CSS", "text": "TEXT"}}, \
"description": "Click"}},
    {{"step": 3, "action": "find_and_type", "target": {{"selector": "CSS", "text": "LABEL"}}, \
"value": "input", "description": "Type"}},
    {{"step": 4, "action": "assert", "assert_type": "text_visible", "value": "TEXT", \
"case_insensitive": true, "description": "Verify"}}
  ]
}}
Actions: navigate, find_and_click, find_and_type, assert, wait, scroll
Return ONLY valid JSON array.\
"""

_SCENARIO_PROMPT_WITH_DOCS = """\
You are an E2E test scenario generator.

Analyze the following web page AND the uploaded specification documents to generate \
user-perspective E2E test scenarios.

## Target
- URL: {url}

## Page Content (truncated)
{page_text}

## Page Elements (observed from actual page)
{page_elements}

## Specification Documents
{doc_text}

## CRITICAL Rules
1. **Use EXACT text from the page content above** for all click targets and assertions.
   - Extract actual button labels, link text, menu item names from "Page Content"
   - NEVER use generic placeholders like "menu1", "menu2", "button1", "link1"
   - If the page has a menu with "Pricing", "Features", "About", use those exact words
2. Use the specification documents as the PRIMARY source for identifying test scenarios
3. Cross-reference with the actual page content to verify available UI elements
4. Generate 1-5 test scenarios covering the most important flows
5. Each scenario should have clear steps: navigate, click, type, assert
6. For click targets, use the `text` field with the exact visible label
7. For assertions, verify text that actually appears in the page content
8. Keep steps concise and actionable
9. **SELECTOR-FIRST**: Every click/type target MUST include "selector" from Page Elements.
10. **FORM SUBMIT BUTTON — CRITICAL**:
   After filling form fields, click SUBMIT[form] (form's own button), NOT SUBMIT[nav].
11. **TEST INDEPENDENCE**: Each scenario MUST start with navigate to target URL.

Return the scenarios as a JSON array.\
"""


# ---------------------------------------------------------------------------
# Screenshot helper
# ---------------------------------------------------------------------------


def _screenshot_dir_for_test(test_id: int) -> Path:
    """Return and create the screenshot directory for a test."""
    base = Path(settings.screenshot_dir)
    d = base / str(test_id)
    d.mkdir(parents=True, exist_ok=True)
    return d


async def _save_screenshot(
    engine: Any,
    test_id: int,
    label: str,
    *,
    ws: WSManager | None = None,
    step: int = 0,
    timing: str = "",
) -> str | None:
    """Capture screenshot as base64 data URL and optionally stream via WS.

    Returns base64 data URL string or None.
    Ephemeral filesystems (Render) lose files on restart, so we store
    screenshots as base64 in result_json instead of on disk.
    """
    try:
        png_bytes = await engine.screenshot()
        b64 = base64.b64encode(png_bytes).decode("ascii")
        data_url = f"data:image/png;base64,{b64}"

        # Also save to disk as fallback (local dev)
        try:
            d = _screenshot_dir_for_test(test_id)
            path = d / f"{label}.png"
            path.write_bytes(png_bytes)
        except Exception:
            pass

        # Stream to frontend via WebSocket
        if ws and timing:
            await ws.broadcast(test_id, {
                "type": "screenshot",
                "step": step,
                "timing": timing,
                "image": data_url,
            })

        return data_url
    except Exception as exc:
        logger.debug("Screenshot save failed (%s): %s", label, exc)
        return None


# ---------------------------------------------------------------------------
# Quick page observation (for direct tests without full scan)
# ---------------------------------------------------------------------------


async def _quick_observe_page(engine: Any) -> dict[str, Any]:
    """Collect page elements with context (form/nav/body) for AI prompt.

    Returns dict with 'fields', 'summary' keys.
    """
    page = engine.page
    fields = await page.evaluate("""() => {
        const fields = [];
        function getContext(el) {
            if (el.closest('form')) return 'form';
            if (el.closest('nav, header, [role="navigation"]'))
                return 'nav';
            return 'body';
        }
        // Form fields
        document.querySelectorAll('input, textarea, select').forEach(f => {
            if (f.type === 'hidden') return;
            if (f.offsetParent === null && f.offsetWidth === 0) return;
            const labelEl = f.id
                ? document.querySelector('label[for="' + f.id + '"]')
                : null;
            const parentLabel = !labelEl ? f.closest('label') : null;
            const labelNode = labelEl || parentLabel;
            const label = labelNode
                ? (labelNode.childNodes[0]?.textContent?.trim()
                   || labelNode.textContent?.trim()?.substring(0, 100))
                : '';
            let sel = null;
            if (f.id) sel = '#' + f.id;
            else if (f.name)
                sel = f.tagName.toLowerCase() + '[name="' + f.name + '"]';
            else if (f.type && f.type !== 'text')
                sel = f.tagName.toLowerCase() + '[type="' + f.type + '"]';
            fields.push({
                tag: f.tagName.toLowerCase(),
                type: f.type || 'text',
                name: f.name || '',
                placeholder: f.placeholder || '',
                label: label || '',
                selector: sel,
                context: getContext(f)
            });
        });
        // Buttons
        document.querySelectorAll('button, input[type=submit]').forEach(b => {
            if (b.offsetParent === null && b.offsetWidth === 0) return;
            const btnText = (b.textContent || b.value || '').trim();
            if (!btnText || btnText.length > 100) return;
            let sel = null;
            if (b.id) sel = '#' + b.id;
            else if (b.name)
                sel = b.tagName.toLowerCase() + '[name="' + b.name + '"]';
            else if (b.className && typeof b.className === 'string') {
                const cls = b.className.trim().split(/\\s+/)[0];
                if (cls) sel = b.tagName.toLowerCase() + '.' + cls;
            }
            fields.push({
                tag: b.tagName.toLowerCase(),
                type: 'submit_button',
                name: b.name || '',
                placeholder: '',
                label: btnText,
                selector: sel,
                context: getContext(b)
            });
        });
        // Nav links
        document.querySelectorAll(
            'nav a, header a, [role="navigation"] a'
        ).forEach(a => {
            if (a.offsetParent === null && a.offsetWidth === 0) return;
            const text = (a.textContent || '').trim();
            if (!text || text.length > 80) return;
            let sel = null;
            if (a.id) sel = '#' + a.id;
            else {
                const href = a.getAttribute('href') || '';
                if (href && href !== '#')
                    sel = 'a[href="' + href + '"]';
                else if (a.className && typeof a.className === 'string') {
                    const cls = a.className.trim().split(/\\s+/)[0];
                    if (cls) sel = 'a.' + cls;
                }
            }
            fields.push({
                tag: 'a',
                type: 'nav_link',
                name: '',
                placeholder: '',
                label: text,
                selector: sel,
                context: 'nav'
            });
        });
        return fields;
    }""")

    # Build 1-line summaries for prompt
    summary_lines: list[str] = []
    for f in fields:
        ctx = f.get("context", "")
        ctx_tag = f"[{ctx}]" if ctx else ""
        sel = f.get("selector") or "?"
        if f["type"] == "submit_button":
            summary_lines.append(
                f"SUBMIT{ctx_tag}({sel}, {f.get('label', '')!r})"
            )
        elif f["type"] == "nav_link":
            summary_lines.append(
                f"NAV_LINK({sel}, {f.get('label', '')!r})"
            )
        else:
            ph = f.get("placeholder") or f.get("label") or ""
            summary_lines.append(
                f"{f['type']}{ctx_tag}({sel}, {ph!r})"
            )

    return {
        "fields": fields,
        "summary": "\n".join(summary_lines) if summary_lines else "No elements found.",
    }


def _build_observation_from_quick(fields: list[dict]) -> list[dict]:
    """Build observation-like data from quick page fields for fix_form_submit_steps."""
    # Synthesize a single observation with all fields as navigated_page_fields
    return [{
        "element": {"text": "", "selector": "", "type": "page"},
        "observed_change": {
            "type": "page_navigation",
            "navigated_page_fields": fields,
        },
    }]


# ---------------------------------------------------------------------------
# Scenario generation (Phase 1: navigate + AI generate)
# ---------------------------------------------------------------------------


async def generate_scenarios_for_test(
    test_id: int, ws: WSManager | None = None
) -> dict[str, Any]:
    """Navigate to URL, capture page, generate scenarios via AI, save YAML.

    Returns dict: {scenario_yaml, steps_total, error?}
    """
    async with async_session() as db:
        test = (await db.execute(select(Test).where(Test.id == test_id))).scalar_one()
        target_url = test.target_url
        doc_text = test.doc_text

    try:
        from aat.adapters import ADAPTER_REGISTRY
        from aat.core.models import AIConfig, EngineConfig
        from aat.engine.web import WebEngine
    except ImportError as exc:
        msg = f"AAT core not installed: {exc}. Run 'pip install -e .' from project root."
        logger.error(msg)
        return {"error": msg}

    engine_config = EngineConfig(
        type="web",
        headless=settings.playwright_headless,
        viewport_width=1920,
        viewport_height=1080,
    )
    engine = WebEngine(engine_config)
    engine._launch_args = [
        "--no-sandbox", "--disable-setuid-sandbox",
        "--dns-over-https-url=https://dns.google/dns-query",
    ]

    try:
        await engine.start()
        # Skip ngrok free-tier interstitial warning page
        await engine.page.set_extra_http_headers(
            {"ngrok-skip-browser-warning": "true"}
        )
        await engine.navigate(target_url)
        page_text = await engine.get_page_text()
        screenshot = await engine.screenshot()

        # Quick observation — collect page elements with form/nav context
        observed = await _quick_observe_page(engine)

        # Save initial screenshot + stream to frontend
        await _save_screenshot(
            engine, test_id, "initial", ws=ws, step=0, timing="initial"
        )

        # Generate scenarios via AI
        ai_config = AIConfig(
            provider=settings.ai_provider,
            api_key=settings.ai_api_key,
            model=settings.ai_model or _DEFAULT_MODELS.get(settings.ai_provider, ""),
        )

        adapter_cls = ADAPTER_REGISTRY.get(ai_config.provider)
        if adapter_cls is None:
            return {"error": f"Unknown AI provider: {ai_config.provider}"}

        adapter = adapter_cls(ai_config)

        # Use document-enhanced prompt if doc_text is available
        if doc_text:
            prompt = _SCENARIO_PROMPT_WITH_DOCS.format(
                url=target_url,
                page_text=page_text[:8000],
                page_elements=observed["summary"][:4000],
                doc_text=doc_text[:16000],
            )
        else:
            prompt = _SCENARIO_PROMPT.format(
                url=target_url,
                page_text=page_text[:8000],
                page_elements=observed["summary"][:4000],
            )

        scenarios = await adapter.generate_scenarios(prompt, images=[screenshot])

        # Apply field target fix + form-submit fix + post-submit assert
        if scenarios:
            from app.scenario_utils import (
                ensure_post_submit_assert,
                fix_field_targets,
                fix_form_submit_steps,
            )

            fake_obs = _build_observation_from_quick(observed["fields"])
            scenarios = fix_field_targets(scenarios, fake_obs)
            scenarios = fix_form_submit_steps(scenarios, fake_obs)
            scenarios = ensure_post_submit_assert(scenarios)

        if not scenarios:
            return {"error": "AI generated no scenarios"}

        # Serialize to YAML
        scenario_dicts = []
        for s in scenarios:
            sd = s.model_dump(mode="json", exclude_none=True)
            scenario_dicts.append(sd)
        scenario_yaml = yaml.safe_dump(
            scenario_dicts, default_flow_style=False, allow_unicode=True
        )

        total_steps = sum(len(s.steps) for s in scenarios)

        # Update DB
        async with async_session() as db:
            test = (await db.execute(select(Test).where(Test.id == test_id))).scalar_one()
            test.scenario_yaml = scenario_yaml
            test.steps_total = total_steps
            await db.commit()

        # Notify frontend
        if ws:
            await ws.broadcast(test_id, {
                "type": "scenarios_ready",
                "scenario_yaml": scenario_yaml,
                "steps_total": total_steps,
            })

        return {"scenario_yaml": scenario_yaml, "steps_total": total_steps}

    except Exception as exc:
        logger.exception("Scenario generation failed for test %d", test_id)
        return {"error": str(exc)}
    finally:
        with contextlib.suppress(Exception):
            await engine.stop()


# ---------------------------------------------------------------------------
# Main executor
# ---------------------------------------------------------------------------


async def execute_test(test_id: int, ws: WSManager | None = None) -> dict[str, Any]:
    """Execute a single test end-to-end.

    If scenario_yaml already exists in DB (review mode), parse and execute it.
    Otherwise, generate scenarios first, then execute (auto mode).

    Returns dict: {passed, scenarios, duration_ms, error?}
    """
    # -- Fetch test record --
    async with async_session() as db:
        test = (await db.execute(select(Test).where(Test.id == test_id))).scalar_one()
        target_url = test.target_url
        existing_yaml = test.scenario_yaml

    start = time.monotonic()

    # -- Import AAT core (lazy to give clear error if missing) --
    try:
        from aat.adapters import ADAPTER_REGISTRY
        from aat.core.models import (
            ActionType,
            AIConfig,
            EngineConfig,
            HumanizerConfig,
            MatchingConfig,
            Scenario,
        )
        from aat.engine.comparator import Comparator
        from aat.engine.executor import StepExecutor
        from aat.engine.humanizer import Humanizer
        from aat.engine.waiter import Waiter
        from aat.engine.web import WebEngine
        from aat.matchers import MATCHER_REGISTRY
        from aat.matchers.hybrid import HybridMatcher
    except ImportError as exc:
        msg = f"AAT core not installed: {exc}. Run 'pip install -e .' from project root."
        logger.error(msg)
        return {"passed": False, "error": msg, "duration_ms": _elapsed(start)}

    # -- Start headless browser (same viewport as scan crawler) --
    engine_config = EngineConfig(
        type="web",
        headless=settings.playwright_headless,
        viewport_width=1920,
        viewport_height=1080,
    )
    engine = WebEngine(engine_config)
    engine._launch_args = [
        "--no-sandbox", "--disable-setuid-sandbox",
        "--dns-over-https-url=https://dns.google/dns-query",
    ]

    # Console log collector
    console_logs: list[dict[str, str]] = []
    _console_ctx: dict[str, str] = {"url": target_url, "step": "", "step_num": "0"}

    def _on_console(msg: Any) -> None:
        """Collect browser console messages."""
        level = str(getattr(msg, "type", "log"))  # log, warning, error, info
        # Only collect warnings and errors (skip verbose logs)
        if level in ("error", "warning", "warn"):
            text = str(getattr(msg, "text", ""))
            if text:
                console_logs.append({
                    "level": "warning" if level == "warn" else level,
                    "text": text[:500],  # truncate long messages
                    "url": _console_ctx["url"],
                    "step": _console_ctx["step"],
                    "step_num": _console_ctx["step_num"],
                    "timestamp": datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
                })

    def _on_page_error(error: Any) -> None:
        """Collect uncaught page errors."""
        console_logs.append({
            "level": "error",
            "text": f"Uncaught: {str(error)[:500]}",
            "url": _console_ctx["url"],
            "step": _console_ctx["step"],
            "step_num": _console_ctx["step_num"],
            "timestamp": datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
        })

    try:
        await engine.start()

        # Skip ngrok free-tier interstitial warning page
        await engine.page.set_extra_http_headers(
            {"ngrok-skip-browser-warning": "true"}
        )

        # Attach console/error listeners to Playwright page
        try:
            page = engine.page
            page.on("console", _on_console)
            page.on("pageerror", _on_page_error)
        except Exception:
            logger.debug("Could not attach console listeners")

        await engine.navigate(target_url)

        # Save initial screenshot + stream to frontend
        init_ss = await _save_screenshot(
            engine, test_id, "initial", ws=ws, step=0, timing="initial"
        )

        # -- Load or generate scenarios --
        if existing_yaml:
            # Review mode: parse pre-existing YAML
            raw = yaml.safe_load(existing_yaml)
            if isinstance(raw, dict):
                raw = [raw]
            scenarios = [Scenario.model_validate(item) for item in raw]
        else:
            # Auto mode: quick observation + AI generation (same pipeline as scan)
            page_text = await engine.get_page_text()
            screenshot = await engine.screenshot()
            observed = await _quick_observe_page(engine)

            ai_config = AIConfig(
                provider=settings.ai_provider,
                api_key=settings.ai_api_key,
                model=settings.ai_model or _DEFAULT_MODELS.get(settings.ai_provider, ""),
            )
            adapter_cls = ADAPTER_REGISTRY.get(ai_config.provider)
            if adapter_cls is None:
                return {
                    "passed": False,
                    "error": f"Unknown AI provider: {ai_config.provider}",
                    "duration_ms": _elapsed(start),
                }
            adapter = adapter_cls(ai_config)
            prompt = _SCENARIO_PROMPT.format(
                url=target_url,
                page_text=page_text[:8000],
                page_elements=observed["summary"][:4000],
            )
            scenarios = await adapter.generate_scenarios(prompt, images=[screenshot])

            # Apply field target fix + form-submit fix + post-submit assert
            if scenarios:
                from app.scenario_utils import (
                    ensure_post_submit_assert,
                    fix_field_targets,
                    fix_form_submit_steps,
                )

                fake_obs = _build_observation_from_quick(observed["fields"])
                scenarios = fix_field_targets(scenarios, fake_obs)
                scenarios = fix_form_submit_steps(scenarios, fake_obs)
                scenarios = ensure_post_submit_assert(scenarios)

        if not scenarios:
            return {
                "passed": False,
                "error": "No scenarios to execute",
                "duration_ms": _elapsed(start),
            }

        # -- Save scenario info to DB --
        total_steps = sum(len(s.steps) for s in scenarios)
        async with async_session() as db:
            test = (await db.execute(select(Test).where(Test.id == test_id))).scalar_one()
            if not existing_yaml:
                # Auto mode: save generated YAML
                scenario_dicts = [s.model_dump(mode="json", exclude_none=True) for s in scenarios]
                test.scenario_yaml = yaml.safe_dump(
                    scenario_dicts, default_flow_style=False, allow_unicode=True
                )
            # Always sync steps_total with actual scenario steps
            test.steps_total = total_steps
            test.steps_completed = 0
            await db.commit()

        if ws:
            await ws.broadcast(test_id, {
                "type": "scenarios_generated",
                "count": len(scenarios),
                "steps_total": total_steps,
            })

        # -- Build matcher + executor (3-tier hybrid matching) --
        matching_config = MatchingConfig()
        matchers = []
        for name in ["template", "ocr"]:
            matcher_cls = MATCHER_REGISTRY.get(name)
            if matcher_cls:
                try:
                    matchers.append(matcher_cls(matching_config))
                except Exception:
                    logger.debug("Matcher %s not available, skipping", name)

        # Tier 3: Vision AI matcher (Claude Vision API fallback)
        try:
            from aat.matchers.vision_ai import VisionAIMatcher
            matchers.append(VisionAIMatcher(
                ai_config=ai_config,
                matching_config=matching_config,
            ))
        except Exception:
            logger.debug("VisionAIMatcher not available, skipping")

        hybrid = HybridMatcher(matchers, matching_config) if matchers else None

        # screenshot_dir for StepExecutor (per-test isolation)
        ss_dir = _screenshot_dir_for_test(test_id)

        step_executor = StepExecutor(
            engine=engine,
            matcher=hybrid or matchers[0] if matchers else _NoopMatcher(),
            humanizer=Humanizer(HumanizerConfig(enabled=False)),
            waiter=Waiter(),
            comparator=Comparator(),
            screenshot_dir=ss_dir,
        )

        # -- Execute scenarios --
        all_results: list[dict[str, Any]] = []
        overall_passed = True
        completed = 0

        for scenario in scenarios:
            # Clear browser state for session isolation
            try:
                context = engine.page.context
                await context.clear_cookies()
                await engine.page.evaluate(
                    "try { localStorage.clear(); sessionStorage.clear(); } catch(e) {}"
                )
            except Exception:
                pass
            # Re-navigate for each scenario
            await engine.navigate(target_url)

            step_results: list[dict[str, Any]] = []
            scenario_passed = True

            for i, step in enumerate(scenario.steps):
                step_num = completed + 1

                # Update console context for this step
                _console_ctx["step"] = step.description or str(step.action)
                _console_ctx["step_num"] = str(step_num)
                with contextlib.suppress(Exception):
                    _console_ctx["url"] = await engine.get_url() or target_url

                if ws:
                    await ws.broadcast(test_id, {
                        "type": "step_start",
                        "step": step_num,
                        "total": total_steps,
                        "description": step.description or str(step.action),
                    })

                # Before-screenshot
                ss_before = await _save_screenshot(
                    engine, test_id, f"step_{step_num}_before",
                    ws=ws, step=step_num, timing="before",
                )

                nav_failed = False
                try:
                    result = await asyncio.wait_for(
                        step_executor.execute_step(step),
                        timeout=30.0,
                    )
                    status = result.status.value

                    # Navigate: verify actual page state on failure
                    # Playwright may throw timeout but page still loads
                    if step.action == ActionType.NAVIGATE and status == "failed":
                        try:
                            from urllib.parse import urlparse
                            current_url = await engine.get_url()
                            nav_target = step.value or target_url
                            target_host = urlparse(nav_target).netloc
                            current_host = urlparse(current_url or "").netloc
                            # Only override if we actually reached the target domain
                            if (
                                current_url
                                and not current_url.startswith("about:")
                                and not current_url.startswith("chrome-error:")
                                and target_host
                                and current_host == target_host
                            ):
                                logger.info(
                                    "Step %d navigate: page at %s, overriding to passed",
                                    step_num, current_url,
                                )
                                status = "passed"
                        except Exception:
                            pass

                    # After-screenshot
                    ss_after = await _save_screenshot(
                        engine, test_id, f"step_{step_num}_after",
                        ws=ws, step=step_num, timing="after",
                    )

                    elapsed = getattr(result, "elapsed_ms", 0)
                    error_msg = (
                        getattr(result, "error_message", None) if status == "failed" else None
                    )

                    step_results.append({
                        "step": i + 1,
                        "action": (
                            result.action if hasattr(result, "action") else str(step.action)
                        ),
                        "description": step.description or None,
                        "target": (
                            step.target.selector or step.target.text if step.target else None
                        ),
                        "value": step.value if step.action == ActionType.NAVIGATE else None,
                        "status": status,
                        "elapsed_ms": elapsed,
                        "error": error_msg if status == "failed" else None,
                        "screenshot_before": ss_before,
                        "screenshot_after": ss_after,
                    })

                    if status == "failed":
                        scenario_passed = False
                        overall_passed = False
                        # Navigate failure → skip remaining steps
                        if step.action == ActionType.NAVIGATE:
                            nav_failed = True

                    if ws:
                        if status == "passed":
                            await ws.broadcast(test_id, {
                                "type": "step_done",
                                "step": step_num,
                                "status": status,
                                "elapsed_ms": elapsed,
                            })
                        else:
                            await ws.broadcast(test_id, {
                                "type": "step_fail",
                                "step": step_num,
                                "status": status,
                                "error": error_msg,
                                "description": step.description or str(step.action),
                            })

                except TimeoutError:
                    err_msg = (
                        f"Timeout after 30s: "
                        f"{step.description or str(step.action)}"
                    )
                    logger.warning("Step %d timed out", step_num)
                    nav_failed = step.action == ActionType.NAVIGATE

                    ss_error = await _save_screenshot(
                        engine, test_id, f"step_{step_num}_error",
                        ws=ws, step=step_num, timing="error",
                    )

                    step_results.append({
                        "step": i + 1,
                        "action": str(step.action),
                        "description": step.description or None,
                        "target": step.target.text if step.target and step.target.text else None,
                        "value": step.value if step.action == ActionType.NAVIGATE else None,
                        "status": "error",
                        "error": err_msg,
                        "screenshot_before": ss_before,
                        "screenshot_error": ss_error,
                    })
                    scenario_passed = False
                    overall_passed = False

                    if ws:
                        await ws.broadcast(test_id, {
                            "type": "step_fail",
                            "step": step_num,
                            "error": err_msg,
                            "description": step.description or str(step.action),
                        })

                except Exception as exc:
                    err_msg = str(exc)
                    logger.warning("Step %d failed: %s", step_num, exc)
                    nav_failed = step.action == ActionType.NAVIGATE

                    ss_error = await _save_screenshot(
                        engine, test_id, f"step_{step_num}_error",
                        ws=ws, step=step_num, timing="error",
                    )

                    step_results.append({
                        "step": i + 1,
                        "action": str(step.action),
                        "description": step.description or None,
                        "target": step.target.text if step.target and step.target.text else None,
                        "value": step.value if step.action == ActionType.NAVIGATE else None,
                        "status": "error",
                        "error": err_msg,
                        "screenshot_before": ss_before,
                        "screenshot_error": ss_error,
                    })
                    scenario_passed = False
                    overall_passed = False

                    if ws:
                        await ws.broadcast(test_id, {
                            "type": "step_fail",
                            "step": step_num,
                            "error": err_msg,
                            "description": step.description or str(step.action),
                        })

                completed += 1

                # Navigate failure → skip remaining steps in this scenario
                if nav_failed:
                    skip_reason = "Skipped: navigation failed"
                    for j in range(i + 1, len(scenario.steps)):
                        remaining = scenario.steps[j]
                        completed += 1
                        step_results.append({
                            "step": j + 1,
                            "action": str(remaining.action),
                            "description": remaining.description or None,
                            "status": "skipped",
                            "error": skip_reason,
                        })
                        if ws:
                            await ws.broadcast(test_id, {
                                "type": "step_fail",
                                "step": completed,
                                "status": "skipped",
                                "error": skip_reason,
                                "description": remaining.description or str(remaining.action),
                            })
                    logger.info(
                        "Navigate failed at step %d — skipping %d remaining steps",
                        step_num, len(scenario.steps) - i - 1,
                    )
                    break

                # Update progress in DB (+ heartbeat to prevent stuck-timeout)
                # Clamp to total_steps to prevent overflow (e.g. 17/15)
                async with async_session() as db:
                    t = (await db.execute(select(Test).where(Test.id == test_id))).scalar_one()
                    t.steps_completed = min(completed, total_steps)
                    if completed > total_steps:
                        t.steps_total = completed
                        total_steps = completed
                    t.updated_at = datetime.now(UTC)
                    await db.commit()

            all_results.append({
                "scenario_id": scenario.id,
                "scenario_name": scenario.name,
                "passed": scenario_passed,
                "steps": step_results,
            })

        return {
            "passed": overall_passed,
            "scenarios": all_results,
            "screenshots_dir": str(_screenshot_dir_for_test(test_id)),
            "initial_screenshot": init_ss,
            "duration_ms": _elapsed(start),
            "console_logs": console_logs[:100],  # cap at 100 entries
            "execution_mode": "standard",
        }

    except Exception as exc:
        logger.exception("Test %d execution error", test_id)
        return {
            "passed": False,
            "error": str(exc),
            "duration_ms": _elapsed(start),
        }
    finally:
        with contextlib.suppress(Exception):
            await engine.stop()


def _elapsed(start: float) -> float:
    return round((time.monotonic() - start) * 1000, 1)


class _NoopMatcher:
    """Fallback matcher when no real matchers are available."""

    @property
    def name(self) -> str:
        return "noop"

    async def find(self, target: Any, screenshot: bytes) -> None:
        return None

    def can_handle(self, target: Any) -> bool:
        return False
