"""LLM-guided Playwright test execution and auto-fix tool."""

import asyncio
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from mcp.types import TextContent, Tool

from maeris_mcp.storage.execution_store import TestExecutionStore
from maeris_mcp.storage.fix_cache import RepoFixCache
from maeris_mcp.storage.test_store import TestGenerationStore


def _update_codemap_selector(repo_root: str | Path, old_selector: str, new_selector: str) -> None:
    """Replace *old_selector* with *new_selector* everywhere in the project's codemap.json.

    Searches elements, form fields, form submit selectors, and modal triggers.
    Non-critical — callers should wrap in try/except.
    """
    from maeris_mcp.tools.code_map_tool import codemap_path_for

    cm_path = codemap_path_for(repo_root)
    if not cm_path.exists():
        return
    data = json.loads(cm_path.read_text(encoding="utf-8"))
    nodes = data.get("nodes", {})
    changed = False

    for _route, node in nodes.items():
        # elements[].selector
        for el in node.get("elements", []):
            if el.get("selector") == old_selector:
                el["selector"] = new_selector
                changed = True
        # forms[].fields[] and forms[].submit
        for form in node.get("forms", []):
            for field in form.get("fields", []):
                if field.get("selector") == old_selector:
                    field["selector"] = new_selector
                    changed = True
            if form.get("submit") == old_selector:
                form["submit"] = new_selector
                changed = True
        # modals[].trigger_selector
        for modal in node.get("modals", []):
            if modal.get("trigger_selector") == old_selector:
                modal["trigger_selector"] = new_selector
                changed = True

    if changed:
        cm_path.write_text(json.dumps(data, indent=2), encoding="utf-8")


def _mark_proven_paths_in_codemap(repo_root: str | Path, passing_test_json: str) -> None:
    """Mark navigation_tree entries and node selectors as proven based on a passing test.

    Parses the passing test's steps to extract the navigation sequence and
    selectors used. Marks matching navigation_tree entries with proven=true
    and records the proven step sequence. Also marks used selectors as proven
    in node elements.

    Non-critical — callers should wrap in try/except.
    """
    from maeris_mcp.tools.code_map_tool import codemap_path_for

    cm_path = codemap_path_for(repo_root)
    if not cm_path.exists():
        return

    try:
        test_data = json.loads(passing_test_json)
    except Exception:
        return

    steps = test_data.get("steps", [])
    if not steps:
        return

    data = json.loads(cm_path.read_text(encoding="utf-8"))
    nav_tree = data.get("navigation_tree", {})
    nodes = data.get("nodes", {})
    changed = False

    # Extract visited URLs and used selectors from passing steps
    visited_urls: list[str] = []
    used_selectors: list[str] = []
    proven_step_sequence: list[dict] = []

    for step in steps:
        action = (step.get("action_taken") or "").lower()
        selector = (step.get("css_selector") or "").strip()
        mock_input = (step.get("mock_input") or "").strip()

        if action == "go_to" and mock_input:
            # Extract path from URL
            try:
                from urllib.parse import urlparse
                path = urlparse(mock_input).path or "/"
                visited_urls.append(path)
            except Exception:
                pass
            proven_step_sequence.append({
                "action": action,
                "mock_input": mock_input,
            })
        elif action in ("click", "send_keys", "hover", "check", "click_select") and selector:
            used_selectors.append(selector)
            proven_step_sequence.append({
                "action": action,
                "css_selector": selector,
                "description": step.get("description", ""),
            })

    # Mark navigation_tree entries as proven
    for route, entry in nav_tree.items():
        if route in visited_urls:
            if not entry.get("proven"):
                entry["proven"] = True
                entry["proven_via_steps"] = proven_step_sequence
                changed = True
            elif entry.get("proven"):
                # Already proven — increment confidence
                entry["proven_passes"] = entry.get("proven_passes", 1) + 1
                changed = True

    # Mark used selectors as proven in node elements
    for _route, node in nodes.items():
        for el in node.get("elements", []):
            sel = el.get("selector", "")
            if sel and sel in used_selectors:
                if not el.get("proven"):
                    el["proven"] = True
                    changed = True

    if changed:
        data["navigation_tree"] = nav_tree
        data["nodes"] = nodes
        cm_path.write_text(json.dumps(data, indent=2), encoding="utf-8")


def _update_codemap_learned_selectors(
    repo_root: str | Path,
    corrections: list[tuple[str, str]],
    test_name: str,
) -> None:
    """Persist selector corrections to codemap["learned_selector_fixes"].

    Unlike _update_codemap_selector (which updates node elements in-place and
    can be overwritten by a source re-scan), this writes to a dedicated
    top-level array that survives re-scans.

    Non-critical — callers should wrap in try/except.
    """
    from maeris_mcp.tools.code_map_tool import codemap_path_for

    cm_path = codemap_path_for(repo_root)
    if not cm_path.exists():
        return
    data = json.loads(cm_path.read_text(encoding="utf-8"))
    fixes = data.setdefault("learned_selector_fixes", [])

    for broken, working in corrections:
        # Deduplicate by (broken, working)
        is_dup = any(
            f.get("broken") == broken and f.get("working") == working
            for f in fixes
        )
        if not is_dup:
            fixes.append({
                "broken": broken,
                "working": working,
                "context": test_name,
                "learned_at": datetime.now(timezone.utc).isoformat(),
            })

    data["learned_selector_fixes"] = fixes
    cm_path.write_text(json.dumps(data, indent=2), encoding="utf-8")


import re as _re

_EMAIL_SELECTORS = ('[type="email"]', "[type='email']", 'input[type="email"]')
_PASSWORD_SELECTORS = ('[type="password"]', "[type='password']", 'input[type="password"]')
_EMAIL_DESC_WORDS = ("email", "username", "user name", "e-mail", "login id")
_PASSWORD_DESC_WORDS = ("password", "pass word", "passphrase", "secret")

_NEGATIVE_TEST_RE = _re.compile(
    r"invalid|wrong|incorrect|bad.cred|fail|nonexistent|non.exist|fake|bogus",
    _re.IGNORECASE,
)


def _is_negative_test_name(name: str) -> bool:
    """Return True if the test case name indicates it uses intentionally wrong credentials."""
    return bool(_NEGATIVE_TEST_RE.search(name))


def _should_inject_email(step: dict) -> bool:
    mock = step.get("mock_input") or ""
    if mock == "$MAERIS_TEST_EMAIL":
        return True
    sel = (step.get("css_selector") or "").lower()
    desc = (step.get("description") or "").lower()
    return any(s in sel for s in _EMAIL_SELECTORS) or any(w in desc for w in _EMAIL_DESC_WORDS)


def _should_inject_password(step: dict) -> bool:
    mock = step.get("mock_input") or ""
    if mock == "$MAERIS_TEST_PASSWORD":
        return True
    sel = (step.get("css_selector") or "").lower()
    desc = (step.get("description") or "").lower()
    return any(s in sel for s in _PASSWORD_SELECTORS) or any(w in desc for w in _PASSWORD_DESC_WORDS)


def _inject_base_url_into_dir(output_dir: str, old_base_url: str | None, new_base_url: str) -> int:
    """Replace the base_url and go_to URLs in all JSON step files.

    Rewrites the top-level ``base_url`` field and replaces any ``go_to``
    step whose ``mock_input`` starts with the old base URL so that
    navigation points at the user-provided URL instead.

    Returns the number of files modified.
    """
    # Strip trailing slash for consistent matching
    new_base = new_base_url.rstrip("/")
    old_base = old_base_url.rstrip("/") if old_base_url else None

    modified = 0
    for fpath in sorted(Path(output_dir).glob("*.json")):
        try:
            data = json.loads(fpath.read_text(encoding="utf-8"))
        except Exception:
            continue

        changed = False

        # Replace top-level base_url field
        if "base_url" in data and data["base_url"] != new_base:
            if old_base is None:
                old_base = data["base_url"].rstrip("/") if data.get("base_url") else None
            data["base_url"] = new_base
            changed = True

        # Replace go_to step URLs that match the old base
        for step in data.get("steps", []):
            if step.get("action_taken") != "go_to":
                continue
            mock_input = step.get("mock_input", "")
            if old_base and mock_input.startswith(old_base):
                step["mock_input"] = new_base + mock_input[len(old_base):]
                changed = True
            elif mock_input == new_base or mock_input.startswith(new_base):
                pass  # already correct
            elif old_base is None:
                # No old base known — replace the entire URL with new base
                step["mock_input"] = new_base
                changed = True

        if changed:
            try:
                fpath.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
                modified += 1
            except Exception:
                pass

    return modified


def _inject_credentials_into_dir(output_dir: str, test_email: str, test_password: str) -> int:
    """Overwrite mock_input in JSON step files for email/password send_keys steps.

    Skips files whose test name indicates they use intentionally wrong credentials
    (negative tests), so their mock values are preserved unchanged.

    Returns the number of files modified.
    """
    modified = 0
    for fpath in sorted(Path(output_dir).glob("*.json")):
        try:
            data = json.loads(fpath.read_text(encoding="utf-8"))
        except Exception:
            continue

        # Skip negative tests — they need their intentionally wrong values intact
        test_name = data.get("name") or fpath.stem
        if _is_negative_test_name(test_name):
            continue

        changed = False
        for step in data.get("steps", []):
            if step.get("action_taken") != "send_keys":
                continue
            if test_email and _should_inject_email(step):
                step["mock_input"] = test_email
                changed = True
            elif test_password and _should_inject_password(step):
                step["mock_input"] = test_password
                changed = True

        if changed:
            try:
                fpath.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
                modified += 1
            except Exception:
                pass

    return modified


def run_and_fix_tests_tool() -> Tool:
    """Return the tool definition for run_and_fix_tests."""
    return Tool(
        name="run_and_fix_tests",
        description=(
            "LLM-guided E2E test execution and auto-fix using the Mirabilis in-process runner. "
            "Runs each generated JSON step file one by one. When a step fails, returns to "
            "the LLM with failure info so it can provide fixed_code. Each failing step gets "
            "up to step_retries (default: 3) fix attempts before the test is auto-advanced. "
            "Use mark_unfixable to explicitly skip a test. "
            "Tracks state via exec_session_id across calls. "
            "When all tests are processed, returns a summary with passing/failing results "
            "and stores the final correct step files on disk."
        ),
        inputSchema={
            "type": "object",
            "properties": {
                "session_id": {
                    "type": "string",
                    "description": (
                        "Test generation session ID from generate_tests. "
                        "Used to locate the output directory on first call."
                    ),
                },
                "output_dir": {
                    "type": "string",
                    "description": (
                        "Direct path to the generated test directory "
                        "(alternative to session_id on first call)."
                    ),
                },
                "exec_session_id": {
                    "type": "string",
                    "description": "Existing execution session ID to continue across calls.",
                },
                "fixed_code": {
                    "type": "string",
                    "description": (
                        "Full corrected JSON step file content (as a JSON string) to apply before "
                        "re-running the current failing test. Must be valid JSON with a 'steps' array."
                    ),
                },
                "mark_unfixable": {
                    "type": "boolean",
                    "description": (
                        "Mark the current failing test as unfixable and advance to the next test."
                    ),
                    "default": False,
                },
                "headless": {
                    "type": "boolean",
                    "description": (
                        "Run the browser in headless mode (default: true). "
                        "ALWAYS leave this as true unless the USER explicitly asks to watch the browser. "
                        "Do NOT set to false for debugging — use logs and DOM snapshots instead. "
                        "Only used on the first call when creating a new execution session."
                    ),
                    "default": True,
                },
                "step_retries": {
                    "type": "integer",
                    "description": "Max LLM fix attempts per failing step (default: 3). When a step fails, the LLM gets up to this many chances to provide fixed_code before the test is auto-advanced.",
                    "default": 3,
                },
                "base_url": {
                    "type": "string",
                    "description": (
                        "Base URL of the application under test (e.g. https://app.example.com). "
                        "Overrides the auto-resolved URL from the generation session. "
                        "Required on the first call when starting a new execution session."
                    ),
                },
                "test_email": {
                    "type": "string",
                    "description": (
                        "Real test account email to inject into all login/email steps before running. "
                        "Replaces placeholder email values in mock_input across all generated step files. "
                        "Only needed on the first call when starting a new execution session."
                    ),
                },
                "test_password": {
                    "type": "string",
                    "description": (
                        "Real test account password to inject into all password steps before running. "
                        "Replaces placeholder password values in mock_input across all generated step files. "
                        "Only needed on the first call when starting a new execution session."
                    ),
                },
                "skip_iframe_search": {
                    "type": "boolean",
                    "description": (
                        "Skip iframe fallback when locating elements (default: true). "
                        "Set to false only if the application under test uses iframes. "
                        "Skipping iframes speeds up failure detection significantly. "
                        "Only used on the first call when creating a new execution session."
                    ),
                    "default": True,
                },
            },
        },
    )


def _fix_instructions(timed_out: bool = False) -> str:
    timeout_warning = (
        "\n⚠️  TIMEOUT DETECTED — MANDATORY RULES:\n"
        "   - DO NOT call with mark_unfixable=true. Timeout = fixable step issue.\n"
        "   - DO NOT tell the user to run commands.\n"
        "   - Fix the step file and call again with fixed_code.\n"
        "   Common timeout fixes:\n"
        "     * Add wait_before (ms) on the failing step to wait for the page to settle\n"
        "     * Fix the css_selector — if element not found, timeout occurs\n"
        "     * Check if element is in an iframe (the runner handles iframes, but\n"
        "       the selector must still be correct)\n"
        "\n"
    ) if timed_out else ""

    return (
        timeout_warning
        + "=== HOW TO FIX A FAILING TEST (JSON STEP FORMAT) ===\n"
        "⛔ fixed_code MUST be valid JSON only — the full corrected .json step file.\n"
        "   NO Python code, NO prose, NO explanations. Just the corrected JSON.\n"
        "1. Read the 'logs' field carefully — it shows exactly what failed and why.\n"
        "2. Read 'currentCode' to see the full current step file JSON.\n"
        "3. Common failures and fixes:\n"
        "   - '[FAIL] Element not found': css_selector didn't match any element.\n"
        "     Fix: Update css_selector to a more specific/correct [attr=\"value\"] selector.\n"
        "     ⚠️  CSS MODULE SELECTOR: If css_selector looks like '.submitButton' or\n"
        "     'button.loginBtn' — it's a CSS Module class (hashed at runtime).\n"
        "     Fix immediately with stable alternatives:\n"
        "       [type=\"submit\"]         ← for submit buttons\n"
        "       [placeholder=\"foo\"]    ← for inputs with placeholder\n"
        "       [aria-label=\"Submit\"]  ← if aria-label is set\n"
        "       [data-testid=\"foo\"]    ← preferred if available\n"
        "   - '[FAIL] Expected ... to be visible/to contain url/to have value':\n"
        "     assertion_value doesn't match the actual page content.\n"
        "     Fix: Update assertion_value to match EXACT rendered text or URL.\n"
        "   - '[TIMEOUT]': Step took too long — element probably not found.\n"
        "     Fix: Add wait_before ms on the step, or fix the css_selector.\n"
        "   - 'Multiple elements matched' or '(ambiguous: N elements matched)':\n"
        "     css_selector matched more than one element on the page.\n"
        "     The runner auto-retries with alternative nth_appearance values,\n"
        "     but if all alternatives fail, you must fix the selector.\n"
        "     Fix: Use a more specific selector, or set nth_appearance to the\n"
        "     correct 0-based index for the intended element.\n"
        "4. **DOM SNAPSHOT**: If logs contain '--- DOM SNAPSHOT ---', USE IT!\n"
        "   It lists every interactive element actually on the page with their attributes.\n"
        "   Pick css_selector from the snapshot — guaranteed to match the real DOM.\n"
        "   Selector priority (best → worst):\n"
        "     1. [data-testid=\"foo\"] or [data-automation-id=\"foo\"]  ← most stable\n"
        "     2. [aria-label=\"foo\"]                                  ← stable if set\n"
        "     3. [id=\"foo\"] or [name=\"foo\"]                         ← stable if unique\n"
        "     4. [type=\"submit\"] or [placeholder=\"foo\"]             ← for inputs\n"
        "     5. button:has-text(\"exact text\")                      ← USE THIS when no attribute exists\n"
        "        Examples: button:has-text(\"Save\"), a:has-text(\"Sign in\"), li:has-text(\"light\")\n"
        "        This is ALWAYS better than a bare tag like 'button' which matches every button.\n"
        "     6. nth_appearance (integer, 0-based) to disambiguate when multiple elements match.\n"
        "   ⛔ NEVER use a bare tag name alone (button, div, a, span, li) as the css_selector.\n"
        "      A bare tag matches every element of that type — it is not a fix, it is a guess.\n"
        "      If no attribute exists and no text is visible, increase nth_appearance instead.\n"
        "5. Provide the FULL corrected JSON in 'fixed_code' (not just the changed step).\n"
        "6. Use mark_unfixable=true to explicitly skip a test before running it.\n"
        "\n"
        "=== JOURNEY-LEVEL FAILURES (test logic is wrong, not just selectors) ===\n"
        "\n"
        "7. MISSING LOGIN: If test interacts with a protected page but gets redirect:\n"
        "   - Add go_to step to login URL, then send_keys for email/password, then click submit.\n"
        "   - Use 'test@example.com' as mock_input for credentials, or env vars if specified.\n"
        "\n"
        "8. MISSING NAVIGATION: If go_to step lands on wrong page (redirect):\n"
        "   - Navigate via click steps through the UI instead of direct go_to.\n"
        "\n"
        "9. MISSING FORM FIELDS: If form submit fails due to validation:\n"
        "   - Add send_keys steps for all required fields before the click submit step.\n"
        "\n"
        "10. CONDITIONAL UI: If element doesn't exist when expected:\n"
        "    - Add the trigger action step first (e.g. click a checkbox to reveal the element).\n"
        "\n"
        "11. ASYNC CONTENT: If interacting with empty list/table:\n"
        "    - Add wait_before ms on the step (e.g. 2000) to let async data load.\n"
    )


def _extract_selector_corrections(old_json: str, new_json: str) -> list[tuple[str, str]]:
    """Extract css_selector replacements from old vs new JSON step files.

    Compares css_selector fields across steps. Returns list of
    (old_selector, new_selector) tuples for selectors that changed.
    """
    try:
        old_steps = json.loads(old_json).get("steps", [])
        new_steps = json.loads(new_json).get("steps", [])
    except Exception:
        return []

    corrections: list[tuple[str, str]] = []
    for i, (old_s, new_s) in enumerate(zip(old_steps, new_steps)):
        old_sel = (old_s.get("css_selector") or "").strip()
        new_sel = (new_s.get("css_selector") or "").strip()
        if old_sel and new_sel and old_sel != new_sel:
            corrections.append((old_sel, new_sel))
    return corrections


# Fields that should be propagated when a fix modifies an existing step.
_PROPAGATABLE_FIELDS = (
    "css_selector", "assertion_value", "assertion_type", "assertion_nature",
    "mock_input", "nth_appearance", "wait_before", "wait_after",
)


def _extract_step_field_corrections(old_json: str, new_json: str) -> list[dict]:
    """Extract all field-level changes between aligned old/new step files.

    Uses alignment to pair old steps with new steps (skipping insertions).
    For each aligned pair where any propagatable field changed, emits a
    correction record with match_key, changes, and old_values.
    """
    try:
        old_steps = json.loads(old_json).get("steps", [])
        new_steps = json.loads(new_json).get("steps", [])
    except Exception:
        return []

    corrections: list[dict] = []

    def _emit(oi: int, ni: int) -> None:
        changes: dict[str, Any] = {}
        old_values: dict[str, Any] = {}
        for field in _PROPAGATABLE_FIELDS:
            old_val = old_steps[oi].get(field)
            new_val = new_steps[ni].get(field)
            old_norm = (str(old_val).strip() if old_val is not None else "")
            new_norm = (str(new_val).strip() if new_val is not None else "")
            if old_norm != new_norm:
                changes[field] = new_val
                old_values[field] = old_val
        if changes:
            corrections.append({
                "match_key": {
                    "action_taken": (old_steps[oi].get("action_taken") or "").strip(),
                    "css_selector": (old_steps[oi].get("css_selector") or "").strip(),
                    "mock_input": (old_steps[oi].get("mock_input") or "").strip(),
                    "description": (old_steps[oi].get("description") or "").strip(),
                    "assertion_type": (old_steps[oi].get("assertion_type") or "").strip(),
                    "assertion_value": (old_steps[oi].get("assertion_value") or "").strip(),
                },
                "changes": changes,
                "old_values": old_values,
            })

    old_i = 0
    new_i = 0
    while old_i < len(old_steps) and new_i < len(new_steps):
        if _steps_match(old_steps[old_i], new_steps[new_i]):
            _emit(old_i, new_i)
            old_i += 1
            new_i += 1
        else:
            # Look ahead in new_steps for a match (insertions in between)
            found = False
            for look in range(new_i + 1, min(new_i + 6, len(new_steps))):
                if _steps_match(old_steps[old_i], new_steps[look]):
                    new_i = look
                    _emit(old_i, new_i)
                    old_i += 1
                    new_i += 1
                    found = True
                    break
            if not found:
                # No match ahead — pair by same action_taken if next old doesn't match here
                old_act = (old_steps[old_i].get("action_taken") or "").strip().lower()
                new_act = (new_steps[new_i].get("action_taken") or "").strip().lower()
                next_old_matches = (
                    old_i + 1 < len(old_steps)
                    and _steps_match(old_steps[old_i + 1], new_steps[new_i])
                )
                if old_act and old_act == new_act and not next_old_matches:
                    _emit(old_i, new_i)
                    old_i += 1
                    new_i += 1
                else:
                    new_i += 1  # skip insertion

    return corrections


def _step_matches_key(step: dict, match_key: dict) -> bool:
    """Return True if *step* matches a correction's identification key."""
    if (step.get("action_taken") or "").strip() != match_key.get("action_taken", ""):
        return False
    for field in ("css_selector", "mock_input", "description"):
        step_val = (step.get(field) or "").strip()
        key_val = (match_key.get(field) or "").strip()
        if step_val and key_val and step_val == key_val:
            return True
    # For assertion steps, match on assertion_type AND assertion_value together
    key_atype = (match_key.get("assertion_type") or "").strip()
    key_aval = (match_key.get("assertion_value") or "").strip()
    if key_atype and key_aval:
        if (step.get("assertion_type") or "").strip() == key_atype and \
           (step.get("assertion_value") or "").strip() == key_aval:
            return True
    return False


def _propagate_field_corrections(
    output_dir: str,
    field_corrections: list[dict],
    test_files: list[str],
    current_index: int,
) -> int:
    """Apply field-level corrections to matching steps in remaining test files.

    Only modifies a field if the step still has the OLD value.
    Returns the number of files modified.
    """
    if not field_corrections:
        return 0

    modified_count = 0
    for filename in test_files[current_index + 1:]:
        filepath = Path(output_dir) / filename
        if not filepath.exists():
            continue
        try:
            data = json.loads(filepath.read_text(encoding="utf-8"))
        except Exception:
            continue

        steps = data.get("steps", [])
        changed = False
        for step in steps:
            for correction in field_corrections:
                if not _step_matches_key(step, correction["match_key"]):
                    continue
                for field, new_val in correction["changes"].items():
                    old_val = correction["old_values"].get(field)
                    step_norm = (str(step.get(field)).strip() if step.get(field) is not None else "")
                    old_norm = (str(old_val).strip() if old_val is not None else "")
                    if step_norm == old_norm:
                        step[field] = new_val
                        changed = True

        if changed:
            data["steps"] = steps
            try:
                filepath.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
                modified_count += 1
            except Exception:
                pass

    return modified_count


def _steps_match(a: dict, b: dict, *, strict_assertion: bool = False) -> bool:
    """Return True if two step dicts represent the same logical action.

    When *strict_assertion* is True, assertion steps must also have the same
    ``assertion_value`` to match.  Use strict=True for duplicate guards and
    strict=False for alignment (pairing old/new steps that may have modified values).
    """
    if a.get("action_taken") != b.get("action_taken"):
        return False
    # Match on description (case-insensitive)
    a_desc = (a.get("description") or "").lower().strip()
    b_desc = (b.get("description") or "").lower().strip()
    if a_desc and b_desc and a_desc == b_desc:
        return True
    # Match on css_selector
    a_sel = (a.get("css_selector") or "").strip()
    b_sel = (b.get("css_selector") or "").strip()
    if a_sel and a_sel == b_sel:
        return True
    # Match on mock_input (covers go_to / navigate steps)
    a_inp = (a.get("mock_input") or "").strip()
    b_inp = (b.get("mock_input") or "").strip()
    if a_inp and a_inp == b_inp:
        return True
    # For go_to steps, also match if URLs share the same origin (domain)
    # so a URL change like "example.com" → "example.com/login" still aligns.
    if (a.get("action_taken") or "").strip().lower() == "go_to" and a_inp and b_inp:
        try:
            from urllib.parse import urlparse
            a_origin = urlparse(a_inp).netloc
            b_origin = urlparse(b_inp).netloc
            if a_origin and a_origin == b_origin:
                return True
        except Exception:
            pass
    # Match on assertion fields (covers page/existence/input assertions)
    a_nature = (a.get("assertion_nature") or "").strip().lower()
    b_nature = (b.get("assertion_nature") or "").strip().lower()
    a_atype = (a.get("assertion_type") or "").strip().lower()
    b_atype = (b.get("assertion_type") or "").strip().lower()
    if a_nature and a_nature == b_nature and a_atype and a_atype == b_atype:
        a_aval = (a.get("assertion_value") or "").strip()
        b_aval = (b.get("assertion_value") or "").strip()
        if strict_assertion:
            if a_aval and b_aval and a_aval == b_aval:
                return True
        else:
            if a_aval and b_aval:
                return True
    return False


def _is_assertion_step(step: dict) -> bool:
    """Return True if the step is a verification/assertion (not a journey action)."""
    return bool((step.get("assertion_nature") or "").strip())


def _nearest_journey_step(steps: list[dict], idx: int, direction: int = -1) -> dict | None:
    """Find the nearest non-assertion step at or before/after *idx*.

    *direction* -1 searches backward (for anchor_before), +1 searches forward (for anchor_after).
    """
    i = idx
    while 0 <= i < len(steps):
        if not _is_assertion_step(steps[i]):
            return steps[i]
        i += direction
    return None


def _extract_step_insertions(old_json: str, new_json: str) -> list[dict]:
    """Detect steps that were inserted during a fix using a linear alignment.

    Anchors prefer journey (non-assertion) steps so that propagation works
    across tests with the same journey but different assertions.
    """
    try:
        old_steps = json.loads(old_json).get("steps", [])
        new_steps = json.loads(new_json).get("steps", [])
    except Exception:
        return []

    if len(new_steps) <= len(old_steps):
        return []

    insertions: list[dict] = []
    old_i = 0
    new_i = 0

    while new_i < len(new_steps):
        if old_i >= len(old_steps):
            insertions.append({
                "insert_after": old_i - 1, "step": new_steps[new_i],
                # Prefer journey-step anchors; fall back to any step
                "anchor_before_old": _nearest_journey_step(old_steps, old_i - 1, -1) or (old_steps[old_i - 1] if old_i > 0 else None),
                "anchor_before_new": _nearest_journey_step(new_steps, new_i - 1, -1) or (new_steps[new_i - 1] if new_i > 0 else None),
                "anchor_after": None,
            })
            new_i += 1
            continue

        if _steps_match(old_steps[old_i], new_steps[new_i]):
            old_i += 1
            new_i += 1
        else:
            insertions.append({
                "insert_after": old_i - 1, "step": new_steps[new_i],
                "anchor_before_old": _nearest_journey_step(old_steps, old_i - 1, -1) or (old_steps[old_i - 1] if old_i > 0 else None),
                "anchor_before_new": _nearest_journey_step(new_steps, new_i - 1, -1) or (new_steps[new_i - 1] if new_i > 0 else None),
                "anchor_after": _nearest_journey_step(old_steps, old_i, +1) or (old_steps[old_i] if old_i < len(old_steps) else None),
            })
            new_i += 1

    return insertions


def _propagate_step_insertions(
    output_dir: str,
    insertions: list[dict],
    test_files: list[str],
    current_index: int,
) -> int:
    """Insert new steps into remaining untested JSON step files.

    Uses anchor context to verify the target file has the same flow structure
    and to find the correct insertion position. Skips files with different flows.
    """
    if not insertions:
        return 0

    modified_count = 0
    for filename in test_files[current_index + 1:]:
        filepath = Path(output_dir) / filename
        if not filepath.exists():
            continue
        try:
            data = json.loads(filepath.read_text(encoding="utf-8"))
        except Exception:
            continue

        steps: list[dict] = data.get("steps", [])
        changed = False

        for ins in insertions:
            new_step = ins["step"]
            is_journey_insert = not _is_assertion_step(new_step)

            # Context guard — find anchor in target file.
            # Anchors are journey steps, so search only among journey steps
            # to match tests with same journey but different assertions.
            anchor_old = ins.get("anchor_before_old")
            anchor_new = ins.get("anchor_before_new")
            anchor_after = ins.get("anchor_after")
            has_context = False
            anchor_pos = -1

            for anchor in (anchor_new, anchor_old):
                if not anchor:
                    continue
                for si in range(len(steps) - 1, -1, -1):
                    if _steps_match(steps[si], anchor, strict_assertion=True):
                        has_context = True
                        anchor_pos = si
                        break
                if has_context:
                    break

            if not has_context and anchor_after:
                for si in range(len(steps)):
                    if _steps_match(steps[si], anchor_after, strict_assertion=True):
                        has_context = True
                        anchor_pos = si - 1
                        break

            if not has_context and (anchor_old or anchor_new or anchor_after):
                continue  # different flow structure

            # For journey-step insertions, skip past any assertion steps
            # that immediately follow the anchor so assertions stay grouped
            # with the step they verify and the new journey step goes after them.
            insert_pos = anchor_pos + 1 if anchor_pos >= 0 else max(0, ins["insert_after"] + 1)
            if is_journey_insert:
                while insert_pos < len(steps) and _is_assertion_step(steps[insert_pos]):
                    insert_pos += 1

            # Duplicate guard — skip if identical step near insertion point
            dup_found = False
            for si, s in enumerate(steps):
                if _steps_match(s, new_step, strict_assertion=True) and abs(si - insert_pos) <= 2:
                    dup_found = True
                    break
            if dup_found:
                continue

            steps.insert(insert_pos, new_step)
            changed = True

        if changed:
            data["steps"] = steps
            try:
                filepath.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
                modified_count += 1
            except Exception:
                pass

    return modified_count


def _propagate_selector_fixes(
    output_dir: str,
    current_file: str,
    corrections: list[tuple[str, str]],
    test_files: list[str],
    current_index: int,
) -> int:
    """Apply css_selector corrections to remaining untested JSON step files.

    Only modifies files that haven't been tested yet (index > current_index).
    Returns the number of files modified.
    """
    if not corrections:
        return 0

    modified_count = 0
    remaining_files = test_files[current_index + 1:]
    correction_map = dict(corrections)

    for filename in remaining_files:
        filepath = Path(output_dir) / filename
        if not filepath.exists():
            continue
        try:
            data = json.loads(filepath.read_text(encoding="utf-8"))
        except Exception:
            continue

        steps = data.get("steps", [])
        changed = False
        for step in steps:
            sel = (step.get("css_selector") or "").strip()
            if sel in correction_map:
                step["css_selector"] = correction_map[sel]
                changed = True

        if changed:
            try:
                filepath.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
                modified_count += 1
            except Exception:
                pass

    return modified_count


async def _capture_dom_snapshot(page) -> str:
    """Capture interactive elements from the live page for LLM fix context."""
    try:
        elements = await page.evaluate("""
            () => {
                const sel = 'input, button, a, select, textarea, [role="button"], [data-testid]';
                const els = [...document.querySelectorAll(sel)].slice(0, 60);
                return els.map(el => {
                    const attrs = {};
                    for (const a of el.attributes) {
                        if (a.value) attrs[a.name] = a.value;
                    }
                    return {
                        tag: el.tagName.toLowerCase(),
                        text: (el.innerText || el.value || '').slice(0, 80).trim(),
                        attrs
                    };
                });
            }
        """)
        lines = []
        for e in (elements or []):
            attrs_str = " ".join(
                f'{k}="{v}"' for k, v in (e.get("attrs") or {}).items()
                if v and k not in ("style", "class") or k in ("id", "name", "type",
                    "placeholder", "aria-label", "data-testid", "data-cy", "href")
            )
            text = e.get("text", "")
            tag = e.get("tag", "?")
            lines.append(f"<{tag} {attrs_str}>{text}</{tag}>")
        return "\n".join(lines) if lines else "(no interactive elements found)"
    except Exception as exc:
        return f"(snapshot failed: {exc})"


def _validate_intent(
    current_steps: list[dict],
    original_plan_flow: dict | None,
    test_name: str,
) -> list[dict]:
    """Gate 3: Verify a passing test still implements its original plan intent.

    Checks that fixing failures didn't silently:
    - Drop assertions to make the test pass trivially
    - Remove steps that were part of the original scenario
    - Substitute a different (easier) flow

    Returns a list of intent violations. Empty list = intent preserved.
    """
    if not original_plan_flow or not isinstance(original_plan_flow, dict):
        return []

    issues: list[dict] = []

    # Check 1: Planned assertions are still present in current steps
    planned_assertions = original_plan_flow.get("assertions_planned") or []
    current_assertion_values = [
        (s.get("assertion_value") or "").strip().lower()
        for s in current_steps
        if isinstance(s, dict) and s.get("assertion_nature") and s.get("assertion_value")
    ]

    missing_assertions: list[str] = []
    for planned in planned_assertions:
        if not isinstance(planned, str):
            continue
        planned_lower = planned.lower().strip()
        covered = any(
            planned_lower in av or av in planned_lower
            for av in current_assertion_values if av
        )
        if not covered:
            missing_assertions.append(planned)

    if missing_assertions:
        issues.append({
            "issueType": "intent_assertion_dropped",
            "testName": test_name,
            "description": (
                f"Test passes but planned assertions are no longer present: {missing_assertions}. "
                "These were dropped during fixing, making the test pass trivially."
            ),
            "instruction": (
                "Restore the missing assertions. The test must verify the outcome, not just run steps. "
                "Add assertion steps for each item listed above."
            ),
        })

    # Check 2: Minimum step count vs planned journey
    planned_journey = original_plan_flow.get("user_journey_steps") or []
    if planned_journey and len(current_steps) < len(planned_journey) * 0.4:
        issues.append({
            "issueType": "intent_steps_gutted",
            "testName": test_name,
            "description": (
                f"Test passes but has only {len(current_steps)} steps "
                f"vs {len(planned_journey)} planned — more than 60% were removed during fixing."
            ),
            "instruction": (
                "Restore the removed steps. The fix should correct individual failing steps, "
                "not gut the test to make it trivially pass."
            ),
        })

    return issues


async def _dismiss_overlays(page) -> None:
    """Silently dismiss cookie banners, GDPR consent dialogs, and blocking modals.

    Uses a single JS evaluation for speed — no per-selector Python awaits.
    Called after page navigation steps so overlays don't block subsequent interactions.
    Non-fatal: all exceptions are swallowed.
    """
    try:
        dismissed = await page.evaluate("""
            () => {
                // Priority 1: Known cookie/consent IDs and data attributes
                const consentSelectors = [
                    '#onetrust-accept-btn-handler',
                    '#accept-cookies', '#cookie-accept', '#acceptCookies',
                    '#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll',
                    '[data-testid="cookie-accept"]', '[data-testid="accept-cookies"]',
                    '[aria-label="Accept cookies"]', '[aria-label="Accept all cookies"]',
                    '.cookie-accept', '.cc-accept', '.js-accept-cookies',
                ];
                for (const sel of consentSelectors) {
                    const el = document.querySelector(sel);
                    if (el && el.offsetParent !== null) { el.click(); return 'consent'; }
                }

                // Priority 2: Buttons with exact cookie-accept text inside overlay containers
                const cookieTexts = [
                    'Accept All Cookies', 'Accept All', 'Accept Cookies',
                    'Allow All Cookies', 'Allow All', 'I Accept', 'Agree', 'Got it',
                ];
                const overlayRoots = document.querySelectorAll(
                    '[role="dialog"], [role="alertdialog"], ' +
                    '[class*="cookie" i], [class*="consent" i], [class*="gdpr" i], ' +
                    '[class*="banner" i], [class*="overlay" i], [class*="popup" i]'
                );
                for (const root of overlayRoots) {
                    const btns = root.querySelectorAll('button, [role="button"], a');
                    for (const btn of btns) {
                        const text = (btn.textContent || '').trim();
                        if (cookieTexts.includes(text) && btn.offsetParent !== null) {
                            btn.click(); return 'overlay-text';
                        }
                    }
                }

                // Priority 3: Close button inside dialog/modal (non-cookie modals)
                const closeSelectors = [
                    '[role="dialog"] button[aria-label="Close"]',
                    '[role="dialog"] button[aria-label="close"]',
                    '[role="dialog"] button[aria-label="Dismiss"]',
                    '[role="alertdialog"] button[aria-label="Close"]',
                    '.modal button[aria-label="Close"]', '.modal .close',
                    '.modal-close', '.popup-close', '.dialog-close',
                    '[class*="Modal"] [class*="close" i]',
                    '[class*="Overlay"] [class*="close" i]',
                    '[class*="Dialog"] [class*="close" i]',
                ];
                for (const sel of closeSelectors) {
                    const el = document.querySelector(sel);
                    if (el && el.offsetParent !== null) { el.click(); return 'close'; }
                }

                return null;
            }
        """)
        if dismissed:
            await page.wait_for_timeout(400)  # allow overlay animation to complete
    except Exception:
        pass


# ---------------------------------------------------------------------------
# Browser pool — reuse a single browser process across all tests in a session.
# Each test run gets a fresh BrowserContext (clean cookies / localStorage / state).
# ---------------------------------------------------------------------------

import threading as _pool_threading

_browser_pool: dict[str, Any] = {}  # exec_session_id → Browser instance
_playwright_pool: dict[str, Any] = {}  # exec_session_id → Playwright context manager
_pool_lock = _pool_threading.Lock()


async def _get_or_launch_browser(exec_session_id: str, headless: bool = True):
    """Return a shared Browser for *exec_session_id*, launching one if needed.

    The browser is cached in ``_browser_pool`` and reused for every test run
    within the same execution session.  Only the first call pays the startup cost.
    """
    with _pool_lock:
        if exec_session_id in _browser_pool:
            browser = _browser_pool[exec_session_id]
            if browser.is_connected():
                return browser
            # Browser died — clean up stale entry and re-launch below
            _browser_pool.pop(exec_session_id, None)
            _playwright_pool.pop(exec_session_id, None)

    from playwright.async_api import async_playwright

    pw_cm = async_playwright()
    pw = await pw_cm.start()
    browser = await pw.chromium.launch(headless=headless)

    with _pool_lock:
        _playwright_pool[exec_session_id] = (pw_cm, pw)
        _browser_pool[exec_session_id] = browser

    return browser


async def _close_browser_pool(exec_session_id: str) -> None:
    """Shut down the pooled browser for *exec_session_id* and free resources."""
    with _pool_lock:
        browser = _browser_pool.pop(exec_session_id, None)
        pw_tuple = _playwright_pool.pop(exec_session_id, None)

    if browser:
        try:
            await browser.close()
        except Exception:
            pass
    if pw_tuple:
        pw_cm, pw = pw_tuple
        try:
            await pw_cm.__aexit__(None, None, None)
        except Exception:
            pass


async def _run_test(output_dir: str, test_filename: str, timeout: int = 120, headless: bool = True, exec_session_id: str | None = None, skip_iframe_search: bool = True) -> dict[str, Any]:
    """Run a JSON step file using the in-process Mirabilis Playwright engine.

    Loads the step file, creates a *fresh* browser context (clean cookies,
    localStorage, sessionStorage) on a shared browser instance, and executes
    each step via execute_step.  Per-step results are logged.  A DOM snapshot
    is captured on failure.

    When *exec_session_id* is provided the browser is reused across calls via
    ``_browser_pool``, avoiding the ~5 s launch overhead on every run/retry.
    """
    from maeris_mcp.tools.step_executor import execute_step

    test_path = Path(output_dir) / test_filename
    log_file = Path(output_dir) / "logs.txt"

    # Write run header and record byte offset so we can isolate this run's output
    timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
    separator = "=" * 60
    header = f"\n{separator}\n=== TEST: {test_filename} | {timestamp} ===\n{separator}\n"
    try:
        with open(log_file, "a", encoding="utf-8") as lf:
            lf.write(header)
            lf.flush()
        output_start = log_file.stat().st_size
    except Exception:
        output_start = 0

    logs_collected: list[str] = []
    timed_out = False
    passed = False

    try:
        # ── Obtain browser (pooled when exec_session_id is set) ──────────
        if exec_session_id:
            browser = await _get_or_launch_browser(exec_session_id, headless=headless)
            owns_browser = False  # pool owns the lifecycle
        else:
            # Fallback for standalone calls without a session
            from playwright.async_api import async_playwright
            _pw_cm = async_playwright()
            _pw = await _pw_cm.start()
            browser = await _pw.chromium.launch(headless=headless)
            owns_browser = True

        data = json.loads(test_path.read_text(encoding="utf-8"))
        steps = data.get("steps", [])
        base_url = data.get("base_url", "")
        test_name_str = data.get("name", test_filename)
        logs_collected.append(f"Running: {test_name_str}")
        logs_collected.append(f"Steps: {len(steps)}")

        # ── Helper: run all steps in a fresh context, return result ────────
        async def _execute_all_steps(
            step_list: list[dict],
        ) -> tuple[bool, bool, int, list[str], list[dict], object]:
            """Run steps in a fresh browser context.

            Returns: (all_passed, timed_out, failed_step_index,
                      logs, ambiguous_steps_info, last_page)
            ambiguous_steps_info: list of dicts with keys
                {step_index, match_count, css_selector, nth_used}
            """
            ctx = await browser.new_context()
            pg = await ctx.new_page()
            last_pg = pg
            _logs: list[str] = []
            _ambiguous: list[dict] = []
            _failed_idx = -1
            _all_ok = True
            _timed_out = False

            try:
                for i, step in enumerate(step_list):
                    step_desc = step.get("description") or f"Step {i + 1}"
                    wait_before_ms = int(step.get("wait_before") or 0)
                    wait_after_ms = int(step.get("wait_after") or 0)
                    try:
                        if wait_before_ms > 0:
                            await pg.wait_for_timeout(wait_before_ms)
                        status, msg, last_pg, step_match_count = await asyncio.wait_for(
                            execute_step(pg, step, skip_iframe_search=skip_iframe_search),
                            timeout=timeout,
                        )
                        if wait_after_ms > 0:
                            await pg.wait_for_timeout(wait_after_ms)
                        # Track ambiguous selectors for retry logic
                        if step_match_count > 1:
                            _ambiguous.append({
                                "step_index": i,
                                "match_count": step_match_count,
                                "css_selector": step.get("css_selector") or "",
                                "nth_used": int(step.get("nth_appearance") or 0),
                            })
                        # Dismiss cookie banners / modals after navigation steps
                        step_action = (step.get("action_taken") or "").lower()
                        if step_action in ("go_to", "click"):
                            await _dismiss_overlays(last_pg)
                        log_line = f"[{status.upper()}] {step_desc}: {msg}"
                        _logs.append(log_line)
                        if status not in ("pass", "ok"):
                            _all_ok = False
                            _failed_idx = i
                            break
                    except asyncio.TimeoutError:
                        _timed_out = True
                        _logs.append(f"[TIMEOUT] {step_desc}: exceeded {timeout}s")
                        _all_ok = False
                        _failed_idx = i
                        break
                    except Exception as exc:
                        _logs.append(f"[ERROR] {step_desc}: {type(exc).__name__}: {exc}")
                        _all_ok = False
                        _failed_idx = i
                        break

                # DOM snapshot on failure
                if not _all_ok and not _timed_out:
                    try:
                        snapshot = await _capture_dom_snapshot(last_pg)
                        _logs.append(f"\n--- DOM SNAPSHOT ---\n{snapshot}")
                    except Exception:
                        pass
            finally:
                try:
                    await ctx.close()
                except Exception:
                    pass

            return _all_ok, _timed_out, _failed_idx, _logs, _ambiguous, last_pg

        # ── Primary run ───────────────────────────────────────────────────────
        failed_step_index = -1
        ambiguous_info: list[dict] = []
        try:
            all_passed, timed_out, failed_step_index, run_logs, ambiguous_info, last_page = (
                await _execute_all_steps(steps)
            )
            logs_collected.extend(run_logs)

            # ── Ambiguous-selector retry ──────────────────────────────────────
            # When a step fails and a *prior* step had multiple selector
            # matches, the failure may be caused by clicking the wrong
            # element.  Instead of blindly trying all nth values, we first
            # probe which candidates are visible+enabled and only retry those.
            if not all_passed and not timed_out and ambiguous_info:
                # Find the last ambiguous step that is *before* the failed step
                retry_candidates = [
                    a for a in ambiguous_info if a["step_index"] < failed_step_index
                ]
                if retry_candidates:
                    amb = retry_candidates[-1]
                    amb_idx = amb["step_index"]
                    amb_count = amb["match_count"]
                    amb_nth_used = amb["nth_used"]
                    amb_selector = amb["css_selector"]

                    logs_collected.append(
                        f"\n[AMBIGUOUS-RETRY] Step {amb_idx + 1} matched {amb_count} elements "
                        f"(selector: {amb_selector!r}, used nth={amb_nth_used}). "
                        f"Probing for visible+enabled alternatives..."
                    )

                    # ── Probe: replay up to the ambiguous step, then check
                    # which nth candidates are visible+enabled on the live page.
                    viable_nths: list[int] = []
                    try:
                        probe_ctx = await browser.new_context()
                        probe_pg = await probe_ctx.new_page()
                        try:
                            # Replay steps before the ambiguous one to reach the right page state
                            for pi in range(amb_idx):
                                p_step = steps[pi]
                                p_wait = int(p_step.get("wait_before") or 0)
                                if p_wait > 0:
                                    await probe_pg.wait_for_timeout(p_wait)
                                _s, _m, probe_pg, _mc = await asyncio.wait_for(
                                    execute_step(probe_pg, p_step, skip_iframe_search=skip_iframe_search),
                                    timeout=timeout,
                                )
                                p_action = (p_step.get("action_taken") or "").lower()
                                if p_action in ("go_to", "click"):
                                    await _dismiss_overlays(probe_pg)

                            # Now probe each nth candidate on the live page
                            from maeris_mcp.tools.locator_engine import escape_css_selector, _try_css_locator
                            escaped_sel, _sel_nth = escape_css_selector(amb_selector)
                            if escaped_sel:
                                probe_loc, probe_count, _err = await _try_css_locator(
                                    probe_pg, escaped_sel, 5000
                                )
                                if probe_loc and probe_count > 1:
                                    for ci in range(probe_count):
                                        candidate = probe_loc.nth(ci)
                                        try:
                                            is_vis = await candidate.is_visible()
                                            if not is_vis:
                                                continue
                                            try:
                                                is_en = await candidate.is_enabled()
                                            except Exception:
                                                is_en = True
                                            if is_en and ci != amb_nth_used:
                                                viable_nths.append(ci)
                                        except Exception:
                                            continue
                        finally:
                            try:
                                await probe_ctx.close()
                            except Exception:
                                pass
                    except Exception as probe_exc:
                        logger.warning("[AMBIGUOUS-RETRY] Probe failed: %s", probe_exc)

                    if not viable_nths:
                        logs_collected.append(
                            "[AMBIGUOUS-RETRY] No visible+enabled alternatives found. "
                            "Skipping retry."
                        )
                    else:
                        logs_collected.append(
                            f"[AMBIGUOUS-RETRY] Found {len(viable_nths)} viable alternative(s): "
                            f"nth={viable_nths}. Retrying..."
                        )

                        for try_nth in viable_nths:
                            # Build a modified step list with the new nth_appearance
                            modified_steps = []
                            for si, s in enumerate(steps):
                                if si == amb_idx:
                                    s_copy = dict(s)
                                    s_copy["nth_appearance"] = try_nth
                                    modified_steps.append(s_copy)
                                else:
                                    modified_steps.append(s)

                            logs_collected.append(
                                f"[AMBIGUOUS-RETRY] Trying nth={try_nth} for step {amb_idx + 1}..."
                            )

                            (
                                retry_passed, retry_timed_out, retry_failed_idx,
                                retry_logs, _retry_amb, last_page,
                            ) = await _execute_all_steps(modified_steps)

                            if retry_passed:
                                logs_collected.append(
                                    f"[AMBIGUOUS-RETRY] nth={try_nth} PASSED — "
                                    f"updating step {amb_idx + 1} nth_appearance to {try_nth}"
                                )
                                logs_collected.extend(retry_logs)
                                # Persist the fix: update the step in-place and on disk
                                steps[amb_idx]["nth_appearance"] = try_nth
                                try:
                                    data["steps"] = steps
                                    test_path.write_text(
                                        json.dumps(data, indent=2, ensure_ascii=False),
                                        encoding="utf-8",
                                    )
                                except Exception:
                                    pass
                                all_passed = True
                                timed_out = False
                                failed_step_index = -1
                                break
                            else:
                                logs_collected.append(
                                    f"[AMBIGUOUS-RETRY] nth={try_nth} failed at step {retry_failed_idx + 1}"
                                )

                        if not all_passed:
                            logs_collected.append(
                                "[AMBIGUOUS-RETRY] All visible alternatives failed. "
                                "Reporting original failure."
                            )

            passed = all_passed
        finally:
            # Only close the browser when we launched it ourselves (no pool)
            if owns_browser:
                try:
                    await browser.close()
                    await _pw_cm.__aexit__(None, None, None)
                except Exception:
                    pass

    except asyncio.TimeoutError:
        timed_out = True
        logs_collected.append(f"[TIMEOUT] Test exceeded {timeout}s limit")
    except Exception as exc:
        logs_collected.append(f"RESULT: ERROR\n  Exception -> {type(exc).__name__}: {exc}")

    captured = "\n".join(logs_collected)

    # Write captured output to log file
    try:
        with open(log_file, "a", encoding="utf-8") as lf:
            lf.write(captured + "\n")
    except Exception:
        pass

    current_logs = _read_from(log_file, output_start)
    _append_footer(log_file, test_filename, timed_out=timed_out)

    if timed_out:
        return {
            "passed": False,
            "logs": f"[TIMEOUT] Test exceeded {timeout}s limit\n{current_logs}".strip(),
            "return_code": -1,
            "failed_step_index": failed_step_index,
            "ambiguous_steps": ambiguous_info,
        }

    return {
        "passed": passed,
        "logs": current_logs.strip(),
        "return_code": 0 if passed else 1,
        "failed_step_index": failed_step_index,
        "ambiguous_steps": ambiguous_info,
    }


def _read_from(log_file: Path, byte_offset: int) -> str:
    """Read log_file content starting from byte_offset."""
    try:
        with open(log_file, "r", encoding="utf-8", errors="replace") as f:
            f.seek(byte_offset)
            return f.read()
    except Exception:
        return ""


def _append_footer(log_file: Path, test_filename: str, timed_out: bool = False) -> None:
    """Append an end marker to logs.txt after a test run."""
    try:
        suffix = " [TIMED OUT]" if timed_out else ""
        separator = "=" * 60
        footer = f"\n{separator}\n=== END: {test_filename}{suffix} ===\n{separator}\n\n"
        with open(log_file, "a", encoding="utf-8") as lf:
            lf.write(footer)
    except Exception:
        pass


import re as _re_crud

# CRUD-aware ordering: tests that create/add/register/sign-up run first,
# then view/read/list/verify, then edit/update/modify, then delete/remove last.
# Within each tier, alphabetical order is preserved.
_CRUD_TIERS: list[tuple[int, _re_crud.Pattern]] = [
    (0, _re_crud.compile(r"create|add|new|register|sign.?up|setup|onboard", _re_crud.IGNORECASE)),
    (1, _re_crud.compile(r"view|read|list|get|fetch|load|display|show|verify|check|search|filter|browse", _re_crud.IGNORECASE)),
    (2, _re_crud.compile(r"edit|update|modify|change|rename|toggle|enable|disable|activate|deactivate|switch", _re_crud.IGNORECASE)),
    (3, _re_crud.compile(r"delete|remove|discard|archive|cancel|revoke|logout|sign.?out", _re_crud.IGNORECASE)),
]


def _sort_tests_by_crud(filenames) -> list[str]:
    """Sort test filenames by CRUD lifecycle order so dependent tests run in sequence.

    Create/add tests run first, then read/view, then edit/update, then delete/remove.
    Files that don't match any pattern get tier 1.5 (between read and edit).
    """
    def _tier(name: str) -> tuple[int, str]:
        lower = name.lower()
        for tier, pattern in _CRUD_TIERS:
            if pattern.search(lower):
                return (tier, name)
        # Default: between read and edit
        return (1, name)

    return sorted(filenames, key=_tier)


def _truncate_logs(logs: str, max_chars: int = 6000) -> str:
    """Return truncated logs with both beginning and ending context."""
    if len(logs) <= max_chars:
        return logs

    marker = "\n[... truncated ...]\n"
    if max_chars <= len(marker) + 2:
        return logs[-max_chars:]

    # Keep early setup/navigation context and failure tail.
    head_chars = min(1500, max(200, (max_chars - len(marker)) // 2))
    tail_chars = max_chars - len(marker) - head_chars
    if tail_chars < 200:
        tail_chars = 200
        head_chars = max_chars - len(marker) - tail_chars

    return logs[:head_chars] + marker + logs[-tail_chars:]


def _write_execution_report(session) -> str:
    """Write a markdown execution report to disk and return its path."""
    passed = [r for r in session.results if r.status == "passed"]
    failed = [r for r in session.results if r.status in ("failed", "error", "unfixable")]
    timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")

    lines = [
        "# Test Execution Report",
        f"",
        f"**Generated:** {timestamp}  ",
        f"**Output directory:** `{session.output_dir}`  ",
        f"",
        "## Summary",
        f"",
        f"| Total | Passed | Failed/Unfixable |",
        f"|-------|--------|-----------------|",
        f"| {len(session.test_files)} | {len(passed)} | {len(failed)} |",
        f"",
        "## Results",
        f"",
    ]

    for r in session.results:
        icon = "✅" if r.status == "passed" else "❌"
        lines.append(f"### {icon} {r.test_name} (`{r.test_file}`)")
        lines.append(f"")
        lines.append(f"- **Status:** {r.status.upper()}")
        lines.append(f"- **Attempts:** {r.attempts}")
        if r.status != "passed" and r.logs:
            lines.append(f"")
            lines.append(f"**Last error log:**")
            lines.append(f"```")
            lines.append(_truncate_logs(r.logs, max_chars=2000))
            lines.append(f"```")
        lines.append(f"")

    if session.failed_descriptions:
        lines.append("## Unfixable Test Descriptions")
        lines.append("")
        for desc in session.failed_descriptions:
            lines.append(f"- {desc}")
        lines.append("")

    report_content = "\n".join(lines)
    report_path = Path(session.output_dir) / "test_execution_report.md"
    try:
        report_path.parent.mkdir(parents=True, exist_ok=True)
        report_path.write_text(report_content, encoding="utf-8")
    except Exception:
        pass  # Don't fail the whole operation if report writing fails

    return str(report_path)


def _build_final_summary(session) -> dict[str, Any]:
    """Build the final execution summary and write the execution report."""
    passed = [r for r in session.results if r.status == "passed"]
    failed = [r for r in session.results if r.status in ("failed", "error", "unfixable")]
    report_path = _write_execution_report(session)
    response: dict[str, Any] = {
        "execSessionId": session.exec_session_id,
        "status": "completed",
        "outputDir": session.output_dir,
        "summary": {
            "total": len(session.test_files),
            "passed": len(passed),
            "failed": len(failed),
        },
        "passedTests": [
            {"file": r.test_file, "name": r.test_name, "attempts": r.attempts}
            for r in passed
        ],
        "failedTests": [
            {
                "file": r.test_file,
                "name": r.test_name,
                "attempts": r.attempts,
                "finalLogs": _truncate_logs(r.logs),
            }
            for r in failed
        ],
        "failedDescriptions": session.failed_descriptions,
        "correctScriptsStoredAt": session.output_dir,
        "logsFile": str(Path(session.output_dir) / "logs.txt"),
        "executionReportPath": report_path,
        "note": (
            "Passing test scripts are stored in the output directory. "
            "Cumulative execution logs are in logs.txt. "
            "Full execution report is in test_execution_report.md."
        ),
    }
    if session.gen_session_id:
        passed_count = len(passed)
        failed_count = len(failed)
        total_count = len(session.results)
        response["pendingDecision"] = {
            "question": (
                f"Execution complete: {passed_count} passed, {failed_count} failed out of {total_count}. "
                "Which tests do you want to push to Maeris?"
            ),
            "options": {
                "1_push_passed_only": {
                    "label": "Push only passed tests",
                    "description": (
                        f"Push only the {passed_count} passing test(s) to Maeris."
                    ),
                    "tool": "push_tests_to_maeris",
                    "arguments": {
                        "session_id": session.gen_session_id,
                        "exec_session_id": session.exec_session_id,
                    },
                },
                "2_push_all": {
                    "label": "Push all tests",
                    "description": (
                        f"Push all {total_count} test(s) (including {failed_count} failed) to Maeris."
                    ),
                    "tool": "push_tests_to_maeris",
                    "arguments": {
                        "session_id": session.gen_session_id,
                    },
                },
                "3_skip_push": {
                    "label": "Skip for now",
                    "description": "Do not push tests to Maeris",
                    "tool": None,
                    "arguments": {},
                },
            },
        }
    else:
        response["nextStep"] = (
            "Execution completed. If you want to push tests to Maeris, "
            "call push_tests_to_maeris with the generation session_id."
        )
    return response




async def handle_run_and_fix_tests(
    exec_store: TestExecutionStore,
    test_store: TestGenerationStore,
    arguments: dict,
) -> list[TextContent]:
    """Handle the run_and_fix_tests tool call."""
    exec_session_id = arguments.get("exec_session_id") or arguments.get("execSessionId")
    session_id = arguments.get("session_id") or arguments.get("sessionId")
    output_dir = arguments.get("output_dir") or arguments.get("outputDir")
    fixed_code: str | None = arguments.get("fixed_code") or arguments.get("fixedCode")
    mark_unfixable = bool(arguments.get("mark_unfixable") or arguments.get("markUnfixable", False))
    base_url_override: str | None = arguments.get("base_url") or arguments.get("baseUrl")
    test_email: str | None = arguments.get("test_email") or arguments.get("testEmail")
    test_password: str | None = arguments.get("test_password") or arguments.get("testPassword")
    headless: bool = arguments.get("headless", True) if "headless" in arguments else True
    skip_iframe_search: bool = arguments.get("skip_iframe_search", True) if "skip_iframe_search" in arguments else True
    step_retries = int(arguments.get("step_retries") or arguments.get("stepRetries") or 3)

    # ── Resolve / create session ─────────────────────────────────────────────
    if exec_session_id:
        session = exec_store.get_session(exec_session_id)
        if not session:
            return [TextContent(type="text", text=f"Error: execution session not found: {exec_session_id}")]
    else:
        # First call — resolve output_dir
        gen_session = None
        if session_id and not output_dir:
            gen_session = test_store.get_session(session_id)
            if not gen_session:
                return [TextContent(type="text", text=f"Error: generation session not found: {session_id}")]
            if not gen_session.output_dir:
                return [TextContent(
                    type="text",
                    text="Error: generation session has no output directory. Has generate_tests been finalized (is_last=true)?",
                )]
            output_dir = gen_session.output_dir

        if not output_dir:
            return [TextContent(
                type="text",
                text="Error: provide session_id (from generate_tests) or output_dir to start execution.",
            )]

        if not Path(output_dir).exists():
            return [TextContent(type="text", text=f"Error: directory does not exist: {output_dir}")]

        tests_dir = Path(output_dir)
        test_files = _sort_tests_by_crud(f.name for f in tests_dir.glob("*.json"))
        if not test_files:
            return [TextContent(type="text", text=f"Error: no test .json files found in {tests_dir}")]

        # Prefer user-provided base_url; fall back to auto-resolved from generation session
        locked_base_url = base_url_override or (gen_session.base_url if gen_session else None)

        exec_session_id = exec_store.create_session(
            output_dir=output_dir,
            test_files=test_files,
            gen_session_id=session_id,
            base_url=locked_base_url,
            headless=headless,
            skip_iframe_search=skip_iframe_search,
            step_retries=step_retries,
        )
        session = exec_store.get_session(exec_session_id)

        # Inject user-provided base_url into step files before any test runs.
        if base_url_override:
            _inject_base_url_into_dir(output_dir, gen_session.base_url if gen_session else None, base_url_override)

        # Inject real credentials into step files before any test runs.
        # Also persist them on the generation session for use during push.
        if test_email or test_password:
            _inject_credentials_into_dir(output_dir, test_email or "", test_password or "")
            if session_id:
                creds = {}
                if test_email:
                    creds["email"] = test_email
                if test_password:
                    creds["password"] = test_password
                test_store.set_credentials(session_id, creds)

    # ── Playwright pre-flight check ───────────────────────────────────────────
    # Only run on first call (current_index == 0, no fixed_code) to avoid
    # slowing down every iteration.
    if session.current_index == 0 and not fixed_code:
        try:
            import subprocess as _sp
            _pw_check = _sp.run(
                ["python", "-m", "playwright", "--version"],
                capture_output=True,
                timeout=10,
            )
            if _pw_check.returncode != 0:
                return [TextContent(type="text", text=json.dumps({
                    "status": "playwright_not_installed",
                    "message": (
                        "Playwright is not installed. Please run the following commands and "
                        "then call run_and_fix_tests again:\n\n"
                        "  pip install playwright\n"
                        "  playwright install chromium\n"
                    ),
                }, indent=2))]
        except Exception:
            pass  # If check itself fails, let the test run fail naturally

    # ── Already finished? ────────────────────────────────────────────────────
    if session.status == "completed":
        await _close_browser_pool(exec_session_id)
        return [TextContent(type="text", text=json.dumps(_build_final_summary(session), indent=2))]

    if session.current_index >= len(session.test_files):
        await _close_browser_pool(exec_session_id)
        exec_store.finalize(exec_session_id)
        return [TextContent(type="text", text=json.dumps(_build_final_summary(session), indent=2))]

    current_file = session.test_files[session.current_index]
    test_path = Path(session.output_dir) / current_file
    test_name = current_file.removesuffix(".json").replace("_", " ").title()

    # Tracks how many remaining test files were pre-patched from the latest fix
    selector_patched: int = 0
    step_patched: int = 0

    # ── Apply fixed code ─────────────────────────────────────────────────────
    if fixed_code and not mark_unfixable:
        # Validate that fixed_code is valid JSON with a steps array
        try:
            fixed_data = json.loads(fixed_code)
            if not isinstance(fixed_data.get("steps"), list):
                raise ValueError("'steps' array is missing")
        except Exception as parse_err:
            return [TextContent(type="text", text=json.dumps({
                "execSessionId": exec_session_id,
                "error": "fixed_code is not valid JSON — rejected without writing to disk.",
                "parseError": str(parse_err),
                "nextStep": (
                    "The fixed_code you provided is not valid JSON or is missing the 'steps' array. "
                    "Fix the JSON and call again with corrected fixed_code."
                ),
            }, indent=2))]

        # Guard the base URL — don't let fixes silently switch domains
        from urllib.parse import urlparse

        locked_host = ""
        old_json = ""
        if session.base_url:
            locked_host = urlparse(session.base_url).netloc

        try:
            old_json = test_path.read_text(encoding="utf-8")
        except Exception:
            pass

        if not locked_host and old_json:
            try:
                old_data = json.loads(old_json)
                for step in old_data.get("steps", []):
                    if step.get("action_taken") == "go_to" and step.get("mock_input"):
                        locked_host = urlparse(step["mock_input"]).netloc
                        if locked_host:
                            break
            except Exception:
                pass

        if locked_host:
            for step in fixed_data.get("steps", []):
                if step.get("action_taken") == "go_to" and step.get("mock_input"):
                    new_host = urlparse(step["mock_input"]).netloc
                    if new_host and new_host != locked_host:
                        return [TextContent(type="text", text=json.dumps({
                            "execSessionId": exec_session_id,
                            "error": f"fixed_code changed the base URL from '{locked_host}' to '{new_host}' — rejected.",
                            "originalHost": locked_host,
                            "attemptedHost": new_host,
                            "nextStep": (
                                f"Your fix changed the domain from '{locked_host}' to '{new_host}'. "
                                f"Keep the original base URL and only fix the selectors/assertions."
                            ),
                        }, indent=2))]
                    break

        # Guard assertions — don't let fixes silently strip verification steps
        new_assertion_count = sum(
            1 for s in fixed_data.get("steps", [])
            if isinstance(s, dict) and s.get("assertion_nature")
        )
        old_assertion_count = 0
        if old_json:
            try:
                old_steps = json.loads(old_json).get("steps", [])
                old_assertion_count = sum(
                    1 for s in old_steps
                    if isinstance(s, dict) and s.get("assertion_nature")
                )
            except Exception:
                pass

        if new_assertion_count == 0 and old_assertion_count > 0:
            return [TextContent(type="text", text=json.dumps({
                "execSessionId": exec_session_id,
                "error": (
                    f"fixed_code removed all {old_assertion_count} assertion step(s) — rejected. "
                    "Every test must have at least 1 verification step."
                ),
                "oldAssertionCount": old_assertion_count,
                "newAssertionCount": 0,
                "nextStep": (
                    "Your fix removed all assertion steps (steps with assertion_nature). "
                    "Do NOT remove assertions to make a test pass. Instead, fix the root cause: "
                    "use the correct selector, wait for the right element, or update the "
                    "assertion_value to match what the page actually shows."
                ),
            }, indent=2))]

        if new_assertion_count < old_assertion_count:
            return [TextContent(type="text", text=json.dumps({
                "execSessionId": exec_session_id,
                "error": (
                    f"fixed_code reduced assertion steps from {old_assertion_count} to "
                    f"{new_assertion_count} — rejected."
                ),
                "oldAssertionCount": old_assertion_count,
                "newAssertionCount": new_assertion_count,
                "nextStep": (
                    f"Your fix dropped {old_assertion_count - new_assertion_count} assertion step(s). "
                    "Do NOT remove assertions to make a test pass. Fix failing assertions by "
                    "correcting the selector, adjusting assertion_value to match actual page content, "
                    "or adding wait_before to let the page settle."
                ),
            }, indent=2))]

        # Guard assertion step correctness — reject malformed verification steps
        _VALID_NATURES = {"existence", "page", "input", "image"}
        _VALID_TYPES_BY_NATURE: dict[str, frozenset[str]] = {
            "existence": frozenset({
                "to be visible", "not to be visible",
            }),
            "page": frozenset({
                "to have title", "to have url", "to contain url",
            }),
            "input": frozenset({
                "to be visible", "to be hidden",
                "to be enabled", "to be disabled",
                "to be checked", "to be not checked",
                "to have text", "to contain text",
                "to have value", "to contain value",
                "to be editable", "to be focused", "to be focussed",
                "to be empty", "to be selected", "not to be selected",
                "to be password", "to be email", "to be number", "to be date",
                "has option", "assert all options",
                "has default option", "to have default option",
            }),
        }
        _BARE_TAGS = frozenset({
            "button", "div", "a", "span", "li", "input", "p", "section",
            "form", "label", "select", "textarea", "img", "h1", "h2", "h3",
            "h4", "h5", "h6", "table", "tr", "td", "th", "ul", "ol", "nav",
            "header", "footer", "main", "aside", "article",
        })
        assertion_issues: list[str] = []
        for si, s in enumerate(fixed_data.get("steps", [])):
            if not isinstance(s, dict):
                continue
            a_nature = (s.get("assertion_nature") or "").strip().lower()
            if not a_nature:
                continue

            if a_nature not in _VALID_NATURES:
                assertion_issues.append(
                    f"Step {si + 1}: invalid assertion_nature '{a_nature}' "
                    f"— must be one of {sorted(_VALID_NATURES)}"
                )
                continue

            a_type = (s.get("assertion_type") or "").strip().lower()
            valid_types = _VALID_TYPES_BY_NATURE.get(a_nature)
            if valid_types and a_type and a_type not in valid_types:
                assertion_issues.append(
                    f"Step {si + 1}: invalid assertion_type '{a_type}' for "
                    f"assertion_nature '{a_nature}' — must be one of {sorted(valid_types)}"
                )

            css_sel = (s.get("css_selector") or "").strip()

            # input assertions require a css_selector to locate the element
            if a_nature == "input" and not css_sel:
                assertion_issues.append(
                    f"Step {si + 1}: input assertion '{a_type}' has no css_selector "
                    "— input assertions require a selector to locate the element"
                )

            # Reject bare tag selectors on any assertion — they match every element
            if css_sel and css_sel.lower() in _BARE_TAGS:
                assertion_issues.append(
                    f"Step {si + 1}: css_selector '{css_sel}' is a bare tag name — "
                    "use a specific selector like [data-testid=\"...\"], [id=\"...\"], "
                    "or [aria-label=\"...\"] instead"
                )

        if assertion_issues:
            return [TextContent(type="text", text=json.dumps({
                "execSessionId": exec_session_id,
                "error": "fixed_code contains invalid assertion steps — rejected.",
                "assertionIssues": assertion_issues,
                "nextStep": (
                    "Fix the assertion steps listed above and call again with corrected fixed_code. "
                    "Valid assertion_nature values: existence, page, input. "
                    "input assertions MUST have a specific css_selector (not a bare tag). "
                    "existence assertions verify text on the page (no selector needed). "
                    "page assertions verify the URL or title (no selector needed)."
                ),
            }, indent=2))]

        try:
            test_path.write_text(
                json.dumps(fixed_data, indent=2, ensure_ascii=False),
                encoding="utf-8",
            )
        except Exception as e:
            return [TextContent(type="text", text=f"Error: could not write fixed code: {e}")]

        # Propagate ALL fixes to remaining tests.
        # Order: selectors → field corrections → insertions.
        # Field corrections before insertions so inserted steps keep their values.
        # Insertions use dual anchors (old+new) to find position after field changes.
        if old_json:
            corrections = _extract_selector_corrections(old_json, fixed_code)
            if corrections:
                selector_patched = _propagate_selector_fixes(
                    session.output_dir,
                    current_file,
                    corrections,
                    session.test_files,
                    session.current_index,
                )

            field_corrections = _extract_step_field_corrections(old_json, fixed_code)
            if field_corrections:
                _propagate_field_corrections(
                    session.output_dir,
                    field_corrections,
                    session.test_files,
                    session.current_index,
                )

            insertions = _extract_step_insertions(old_json, fixed_code)
            if insertions:
                step_patched = _propagate_step_insertions(
                    session.output_dir,
                    insertions,
                    session.test_files,
                    session.current_index,
                )

    # ── Mark unfixable ──────────────────────────────────────────────────────
    if mark_unfixable:
        failure_desc = f"Test '{test_name}' ({current_file}) skipped — marked unfixable by user"
        exec_store.add_failed_description(exec_session_id, failure_desc)
        exec_store.record_result(
            exec_session_id,
            current_file,
            test_name,
            "unfixable",
            logs=failure_desc,
        )
        exec_store.advance(exec_session_id)

        if session.current_index >= len(session.test_files):
            await _close_browser_pool(exec_session_id)
            exec_store.finalize(exec_session_id)
            return [TextContent(type="text", text=json.dumps(_build_final_summary(session), indent=2))]

        # Update to next test after advance
        current_file = session.test_files[session.current_index]
        test_path = Path(session.output_dir) / current_file
        test_name = current_file.removesuffix(".json").replace("_", " ").title()

    # ── Batch-run loop ─────────────────────────────────────────────────────
    # Run tests sequentially.  Passing tests are batched — we keep running
    # the next test without returning to the LLM.  The loop only breaks
    # (returns to Claude) when a test fails, an intent issue is found, or
    # all tests are done.
    batched_passes: list[dict[str, Any]] = []

    while True:
        # Snapshot pre-run JSON so we can diff after (for auto-fix/ambiguous-retry propagation)
        _pre_run_json = ""
        try:
            _pre_run_json = (Path(session.output_dir) / current_file).read_text(encoding="utf-8")
        except Exception:
            pass

        run_result = await _run_test(
            session.output_dir, current_file,
            headless=session.headless,
            exec_session_id=exec_session_id,
            skip_iframe_search=session.skip_iframe_search,
        )

        if run_result["passed"]:
            # Propagate runtime changes (auto-fix selectors, ambiguous-retry nth, etc.)
            if _pre_run_json:
                try:
                    _post_run_json = (Path(session.output_dir) / current_file).read_text(encoding="utf-8")
                except Exception:
                    _post_run_json = ""
                if _post_run_json and _post_run_json != _pre_run_json:
                    _rt_field = _extract_step_field_corrections(_pre_run_json, _post_run_json)
                    if _rt_field:
                        _propagate_field_corrections(
                            session.output_dir, _rt_field, session.test_files, session.current_index,
                        )
                    _rt_ins = _extract_step_insertions(_pre_run_json, _post_run_json)
                    if _rt_ins:
                        _propagate_step_insertions(
                            session.output_dir, _rt_ins, session.test_files, session.current_index,
                        )

            # Gate 3: Intent validation
            _intent_issues: list[dict] = []
            try:
                _original_plan_flow: dict | None = None
                if session.gen_session_id:
                    _gen = test_store.get_session(session.gen_session_id)
                    if _gen and _gen.test_plan and isinstance(_gen.test_plan, dict):
                        _tc_name_lower = test_name.lower().strip()
                        for _flow in (_gen.test_plan.get("discovered_flows") or []):
                            if not isinstance(_flow, dict):
                                continue
                            _fname = (_flow.get("flow_name") or "").lower().strip()
                            if _fname and (_fname in _tc_name_lower or _tc_name_lower in _fname):
                                _original_plan_flow = _flow
                                break

                if _original_plan_flow:
                    _current_step_data = json.loads(
                        (Path(session.output_dir) / current_file).read_text(encoding="utf-8")
                    )
                    _current_steps = _current_step_data.get("steps") or []
                    _intent_issues = _validate_intent(_current_steps, _original_plan_flow, test_name)
            except Exception:
                pass

            if _intent_issues:
                # Intent lost — must return to Claude for a fix
                response: dict[str, Any] = {
                    "execSessionId": exec_session_id,
                    "status": "intent_validation_failed",
                    "testName": test_name,
                    "intentIssues": _intent_issues,
                    "logs": run_result.get("logs", ""),
                    "currentCode": (Path(session.output_dir) / current_file).read_text(encoding="utf-8"),
                    "instruction": (
                        "The test passed but its intent was lost during fixing. "
                        "Provide fixed_code that restores the missing assertions and steps "
                        "while keeping the test passing.\n\n"
                        "CRITICAL: Do NOT remove assertions to make a test pass. "
                        "Fix the root cause instead — use the correct selector, wait for the "
                        "right element, or adjust the assertion value to match what the page shows."
                    ),
                }
                if batched_passes:
                    response["batchedPasses"] = batched_passes
                return [TextContent(type="text", text=json.dumps(response, indent=2))]

            exec_store.record_result(exec_session_id, current_file, test_name, "passed", run_result["logs"])

            # Mark proven paths in codemap for EVERY passing test
            try:
                _repo_root_proven = None
                if session.gen_session_id:
                    _gen_proven = test_store.get_session(session.gen_session_id)
                    if _gen_proven:
                        _repo_root_proven = _gen_proven.scan_root
                if not _repo_root_proven:
                    _repo_root_proven = session.output_dir
                _passing_json = (Path(session.output_dir) / current_file).read_text(encoding="utf-8")
                _mark_proven_paths_in_codemap(_repo_root_proven, _passing_json)
            except Exception:
                pass

            # Record selector corrections in fix cache + codemap (from ALL sources)
            _all_sel_corrections: list[tuple[str, str]] = []
            # Source 1: LLM fixed_code (alignment-aware)
            if fixed_code and old_json:
                for fc in _extract_step_field_corrections(old_json, fixed_code):
                    o_sel = (fc["old_values"].get("css_selector") or "").strip()
                    n_sel = (fc["changes"].get("css_selector") or "").strip()
                    if o_sel and n_sel and o_sel != n_sel and (o_sel, n_sel) not in _all_sel_corrections:
                        _all_sel_corrections.append((o_sel, n_sel))
                for o_sel, n_sel in _extract_selector_corrections(old_json, fixed_code):
                    if (o_sel, n_sel) not in _all_sel_corrections:
                        _all_sel_corrections.append((o_sel, n_sel))
            # Source 2: Runtime auto-fix / ambiguous-retry
            if _pre_run_json:
                try:
                    _post_p_json = (Path(session.output_dir) / current_file).read_text(encoding="utf-8")
                except Exception:
                    _post_p_json = ""
                if _post_p_json and _post_p_json != _pre_run_json:
                    for fc in _extract_step_field_corrections(_pre_run_json, _post_p_json):
                        o_sel = (fc["old_values"].get("css_selector") or "").strip()
                        n_sel = (fc["changes"].get("css_selector") or "").strip()
                        if o_sel and n_sel and o_sel != n_sel and (o_sel, n_sel) not in _all_sel_corrections:
                            _all_sel_corrections.append((o_sel, n_sel))

            if _all_sel_corrections:
                try:
                    _repo_root = None
                    if session.gen_session_id:
                        _gen = test_store.get_session(session.gen_session_id)
                        if _gen:
                            _repo_root = _gen.scan_root
                    if not _repo_root:
                        _repo_root = session.output_dir
                    _fix_cache = RepoFixCache(_repo_root)
                    _BARE_TAGS = {"button", "div", "a", "span", "li", "input", "p", "section", "form", "label"}

                    for broken_sel, working_sel in _all_sel_corrections:
                        if working_sel.lower().strip() in _BARE_TAGS:
                            continue
                        _action = ""
                        _context = test_name
                        try:
                            _ps = json.loads(
                                (Path(session.output_dir) / current_file).read_text(encoding="utf-8")
                            ).get("steps", [])
                            for _s in _ps:
                                if (_s.get("css_selector") or "").strip() == working_sel:
                                    _action = _s.get("action_taken", "")
                                    _context = _s.get("description") or test_name
                                    break
                        except Exception:
                            pass
                        _fix_cache.record_fix(
                            broken=broken_sel, working=working_sel,
                            action=_action, context=_context,
                        )
                        try:
                            from maeris_mcp.storage.scan_cache import RepoScanCache
                            RepoScanCache(_repo_root).update_selector(
                                old_selector=broken_sel, new_selector=working_sel,
                            )
                        except Exception:
                            pass
                        try:
                            _update_codemap_selector(_repo_root, broken_sel, working_sel)
                        except Exception:
                            pass
                    try:
                        _update_codemap_learned_selectors(_repo_root, _all_sel_corrections, test_name)
                    except Exception:
                        pass
                except Exception:
                    pass

            batched_passes.append({
                "index": session.current_index,
                "file": current_file,
                "name": test_name,
            })

            exec_store.advance(exec_session_id)

            # All tests done?
            if session.current_index >= len(session.test_files):
                await _close_browser_pool(exec_session_id)
                exec_store.finalize(exec_session_id)
                return [TextContent(type="text", text=json.dumps(_build_final_summary(session), indent=2))]

            # Advance to next test and continue the loop (no return to Claude)
            current_file = session.test_files[session.current_index]
            test_path = Path(session.output_dir) / current_file
            test_name = current_file.removesuffix(".json").replace("_", " ").title()
            # Clear fix context — subsequent batched tests are fresh runs
            fixed_code = None
            old_json = ""
            continue  # ← run next test immediately

        else:
            # ── Propagate partial auto-fixes from failed run ──────────────
            if _pre_run_json:
                try:
                    _post_fail_json = (Path(session.output_dir) / current_file).read_text(encoding="utf-8")
                except Exception:
                    _post_fail_json = ""
                if _post_fail_json and _post_fail_json != _pre_run_json:
                    _pf_sel = _extract_selector_corrections(_pre_run_json, _post_fail_json)
                    if _pf_sel:
                        _propagate_selector_fixes(
                            session.output_dir, current_file, _pf_sel,
                            session.test_files, session.current_index,
                        )
                    _pf_field = _extract_step_field_corrections(_pre_run_json, _post_fail_json)
                    if _pf_field:
                        _propagate_field_corrections(
                            session.output_dir, _pf_field,
                            session.test_files, session.current_index,
                        )

            # ── Test failed — check per-step fix budget ───────────────────
            failed_step_idx = run_result.get("failed_step_index", -1)
            timed_out = "[TIMEOUT]" in run_result["logs"]
            test_content = test_path.read_text(encoding="utf-8") if test_path.exists() else ""

            # Track how many times LLM has tried to fix this specific step
            fix_count = exec_store.increment_step_fix(exec_session_id, failed_step_idx) if failed_step_idx >= 0 else 0

            if fix_count > session.step_retries:
                # Exhausted fix attempts for this step — advance to next test
                exec_store.record_result(exec_session_id, current_file, test_name, "failed", run_result["logs"])
                failure_desc = (
                    f"Test '{test_name}' ({current_file}) failed — "
                    f"step {failed_step_idx + 1} exhausted {session.step_retries} fix attempts"
                )
                exec_store.add_failed_description(exec_session_id, failure_desc)
                exec_store.advance(exec_session_id)

                batched_passes.append({
                    "index": session.current_index - 1,
                    "file": current_file,
                    "name": test_name,
                    "status": "failed",
                })

                # All tests done?
                if session.current_index >= len(session.test_files):
                    await _close_browser_pool(exec_session_id)
                    exec_store.finalize(exec_session_id)
                    return [TextContent(type="text", text=json.dumps(_build_final_summary(session), indent=2))]

                # Advance to next test and continue the loop
                current_file = session.test_files[session.current_index]
                test_path = Path(session.output_dir) / current_file
                test_name = current_file.removesuffix(".json").replace("_", " ").title()
                fixed_code = None
                old_json = ""
                continue

            # Still have fix attempts — return to LLM for fixing
            exec_store.record_result(exec_session_id, current_file, test_name, "failed", run_result["logs"])
            fixes_left = session.step_retries - fix_count

            if timed_out:
                status = "timeout"
                next_step = (
                    "TIMEOUT — the subprocess was killed after 60s. This is a CODE issue, not an app issue. "
                    "Fix the css_selector or add wait_before on the failing step, then call with fixed_code. "
                    "DO NOT call with mark_unfixable=true. DO NOT give the user shell commands."
                )
            else:
                status = "failed"
                next_step = (
                    "Analyze the logs and currentCode above. "
                    "Call run_and_fix_tests with exec_session_id + fixed_code "
                    "to apply your fix and re-run."
                )

            response = {
                "execSessionId": exec_session_id,
                "currentTest": {
                    "index": session.current_index,
                    "file": current_file,
                    "name": test_name,
                    "total": len(session.test_files),
                },
                "failedStep": {
                    "index": failed_step_idx,
                    "fixAttempt": fix_count,
                    "maxFixAttempts": session.step_retries,
                    "fixesLeft": fixes_left,
                },
                "status": status,
                "logs": _truncate_logs(run_result["logs"]),
                "currentCode": test_content,
                "fixInstructions": _fix_instructions(timed_out=timed_out),
                "nextStep": next_step,
            }
            if batched_passes:
                response["batchedPasses"] = batched_passes
            return [TextContent(type="text", text=json.dumps(response, indent=2))]
