"""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")


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:
    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:
    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. For failing tests, analyze "
            "the error logs, provide fixed_code (corrected JSON file content) to retry, "
            "or mark_unfixable to skip. "
            "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,
                },
                "max_retries": {
                    "type": "integer",
                    "description": "Max fix attempts per test before auto-skipping (default: 3).",
                    "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."
                    ),
                },
            },
        },
    )


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': css_selector is too broad.\n"
        "     Fix: Use a more specific selector or increase nth_appearance.\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. Only call with mark_unfixable=true when retriesLeft=0 AND multiple fixes tried.\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


def _steps_match(a: dict, b: dict) -> bool:
    """Return True if two step dicts represent the same logical action."""
    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
    return False


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

    Returns a list of insertion descriptors::

        [{"insert_after": int, "step": dict}, ...]

    ``insert_after`` is the 0-based index in the *original* step array after
    which the new step should be inserted (-1 means prepend).
    """
    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 []  # No steps were added

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

    while new_i < len(new_steps):
        if old_i >= len(old_steps):
            # All remaining new steps are appended after the last old step
            insertions.append({"insert_after": old_i - 1, "step": new_steps[new_i]})
            new_i += 1
            continue

        if _steps_match(old_steps[old_i], new_steps[new_i]):
            old_i += 1
            new_i += 1
        else:
            # new_steps[new_i] is an insertion before old_steps[old_i]
            insertions.append({"insert_after": old_i - 1, "step": new_steps[new_i]})
            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.

    Skips any insertion whose step already exists in the target file (duplicate
    guard).  Applies insertions in reverse order to keep earlier indices stable.
    Returns the number of files modified.
    """
    if not insertions:
        return 0

    modified_count = 0
    remaining_files = test_files[current_index + 1:]

    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: list[dict] = data.get("steps", [])
        changed = False

        # Apply in reverse so earlier insert_after indices stay valid
        for ins in reversed(insertions):
            new_step = ins["step"]
            # Duplicate guard — skip if a matching step already exists
            if any(_steps_match(s, new_step) for s in steps):
                continue
            insert_pos = max(0, ins["insert_after"] + 1)
            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 _run_test(output_dir: str, test_filename: str, timeout: int = 120) -> dict[str, Any]:
    """Run a JSON step file using the in-process Mirabilis Playwright engine.

    Loads the step file, launches a browser, and executes each step via execute_step.
    Per-step results are logged. A DOM snapshot is captured on failure.
    """
    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:
        from playwright.async_api import async_playwright

        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)}")

        async with async_playwright() as pw:
            browser = await pw.chromium.launch(headless=False)
            context = await browser.new_context()
            page = await context.new_page()
            last_page = page

            try:
                all_passed = True
                for i, step in enumerate(steps):
                    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 page.wait_for_timeout(wait_before_ms)
                        status, msg, last_page = await asyncio.wait_for(
                            execute_step(page, step),
                            timeout=timeout,
                        )
                        if wait_after_ms > 0:
                            await page.wait_for_timeout(wait_after_ms)
                        log_line = f"[{status.upper()}] {step_desc}: {msg}"
                        logs_collected.append(log_line)
                        if status not in ("pass", "ok"):
                            all_passed = False
                            break
                    except asyncio.TimeoutError:
                        timed_out = True
                        logs_collected.append(f"[TIMEOUT] {step_desc}: exceeded {timeout}s")
                        all_passed = False
                        break
                    except Exception as exc:
                        logs_collected.append(f"[ERROR] {step_desc}: {type(exc).__name__}: {exc}")
                        all_passed = False
                        break

                # Capture DOM snapshot on failure to help LLM fix selectors
                if not all_passed and not timed_out:
                    try:
                        snapshot = await _capture_dom_snapshot(last_page)
                        logs_collected.append(f"\n--- DOM SNAPSHOT ---\n{snapshot}")
                    except Exception:
                        pass

                passed = all_passed
            finally:
                try:
                    await context.close()
                    await browser.close()
                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,
        }

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


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


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))
    max_retries = int(arguments.get("max_retries") or arguments.get("maxRetries") or 3)
    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")

    # ── 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 = sorted(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,
            max_retries=max_retries,
            base_url=locked_base_url,
        )
        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":
        return [TextContent(type="text", text=json.dumps(_build_final_summary(session), indent=2))]

    if session.current_index >= len(session.test_files):
        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

        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 selector corrections and step insertions to remaining tests
        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,
                )

            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 / max retries exceeded ────────────────────────────────
    auto_skipping = not mark_unfixable and session.retry_count >= session.max_retries and not fixed_code
    if mark_unfixable or auto_skipping:
        reason = (
            "marked unfixable by user"
            if mark_unfixable
            else f"max retries ({session.max_retries}) exceeded"
        )
        failure_desc = f"Test '{test_name}' ({current_file}) skipped — {reason}"
        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):
            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()

    # ── Run the test ─────────────────────────────────────────────────────────
    run_result = await _run_test(session.output_dir, current_file)

    if run_result["passed"]:
        # Gate 3: Intent validation — verify the passing test still implements its original plan.
        # Prevents "test passes by removing assertions / gutting steps" during the fix loop.
        _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  # Non-critical — proceed even if intent check fails

        if _intent_issues and session.retry_count < session.max_retries:
            # Intent was lost during fixing — re-enter fix loop with specific constraint
            return [TextContent(type="text", text=json.dumps({
                "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."
                ),
                "retriesLeft": session.max_retries - session.retry_count,
            }, indent=2))]

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

        # If the test passed after a fix was applied, record selector corrections in the fix cache.
        if fixed_code and old_json:
            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 HTML tag names (no attributes, no text filter) are not real fixes.
                # They match every element of that type and are accepted by luck, not correctness.
                _BARE_TAGS = {"button", "div", "a", "span", "li", "input", "p", "section", "form", "label"}

                _corrections = _extract_selector_corrections(old_json, fixed_code)
                for broken_sel, working_sel in _corrections:
                    # Reject bare tag selectors — do not cache them as confirmed fixes.
                    if working_sel.lower().strip() in _BARE_TAGS:
                        continue  # silently skip — the instruction update will guide LLM away from this

                    # Determine action context from fixed step
                    _action = ""
                    _context = test_name
                    try:
                        _new_steps = json.loads(fixed_code).get("steps", [])
                        for _s in _new_steps:
                            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,
                    )
                    # Also update the selector cache so future sessions start with the correct selector.
                    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  # Non-critical
                    # Update codemap.json so future test generations see corrected selectors.
                    try:
                        _update_codemap_selector(_repo_root, broken_sel, working_sel)
                    except Exception:
                        pass  # Non-critical
            except Exception:
                pass  # Non-critical — don't fail test execution

        exec_store.advance(exec_session_id)

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

        response: dict[str, Any] = {
            "execSessionId": exec_session_id,
            "completedTest": {
                "index": session.current_index - 1,
                "file": current_file,
                "name": test_name,
                "status": "passed",
            },
            "nextTest": {
                "index": session.current_index,
                "file": session.test_files[session.current_index],
                "remaining": len(session.test_files) - session.current_index,
                "total": len(session.test_files),
            },
            "logs": _truncate_logs(run_result["logs"]),
            "propagation": {
                "selectorFixesAppliedToFiles": selector_patched,
                "stepInsertionsAppliedToFiles": step_patched,
            },
            "instructions": (
                "Test PASSED. Call run_and_fix_tests with exec_session_id "
                "to run the next test."
            ),
        }
    else:
        exec_store.increment_retry(exec_session_id)
        exec_store.record_result(exec_session_id, current_file, test_name, "failed", run_result["logs"])
        test_content = test_path.read_text(encoding="utf-8") if test_path.exists() else ""

        retries_left = session.max_retries - session.retry_count
        timed_out = "[TIMEOUT]" in run_result["logs"]
        auto_skip_hint = ""
        if retries_left <= 0:
            auto_skip_hint = (
                f" Max retries ({session.max_retries}) reached — "
                "call with mark_unfixable=true ONLY if you have tried multiple different fixes."
            )

        if timed_out:
            status = "timeout"
            next_step = (
                "TIMEOUT — the subprocess was killed after 60s. This is a CODE issue, not an app issue. "
                "add wait_for_selector before interacting with dynamic elements) and 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. "
                "Only call with mark_unfixable=true when retriesLeft=0 AND you have tried multiple fixes."
            )

        response = {
            "execSessionId": exec_session_id,
            "currentTest": {
                "index": session.current_index,
                "file": current_file,
                "name": test_name,
                "retryCount": session.retry_count,
                "maxRetries": session.max_retries,
                "retriesLeft": max(0, retries_left),
                "total": len(session.test_files),
            },
            "status": status,
            "logs": _truncate_logs(run_result["logs"]),
            "currentCode": test_content,
            "fixInstructions": _fix_instructions(timed_out=timed_out) + auto_skip_hint,
            "nextStep": next_step,
        }

    return [TextContent(type="text", text=json.dumps(response, indent=2))]
