"""Fix and recalibrate CLI commands."""

import asyncio
import json
import os
import sys
from pathlib import Path

import click

from ._core import test_group
from ._utils import (
    _get_testcase_id,
    _prompt_numbered,
    _repo_config_dir,
    _resolve_ai_mode,
    _run_claude_capture,
    _run_direct_api_capture,
    _spinner,
)


def _detect_project_traits(source_content: str, file_paths: "list[Path]") -> str:
    """Analyse source code to detect styling/framework patterns.

    Returns a short summary the AI can use to choose the right selector strategy.
    This is data-driven — no hardcoded rules about what to do, just observations
    about what the project uses.
    """
    import re
    traits: list[str] = []

    # --- Styling patterns ---
    file_names = {p.name for p in file_paths}
    file_suffixes = {p.suffix for p in file_paths}
    has_module_css = any(
        ".module." in f for f in (p.name for p in file_paths)
    )
    has_styles_import = bool(re.search(
        r'import\s+\w+\s+from\s+["\'].*\.module\.(css|scss|sass|less)', source_content
    ))
    has_styled_components = "styled(" in source_content or "styled." in source_content
    has_tailwind = "tailwind" in source_content.lower() or any(
        "tailwind" in f.lower() for f in file_names
    )
    has_css_in_js = "css`" in source_content or "css({" in source_content
    has_emotion = "@emotion" in source_content
    has_data_testid = "data-testid" in source_content
    has_data_automation = "data-automation-id" in source_content
    has_aria_labels = "aria-label=" in source_content

    if has_module_css or has_styles_import:
        traits.append(
            "STYLING: CSS Modules detected (className={styles.xxx}). "
            "Class names are hashed at build time — they will NOT match at runtime. "
            "Do not use class-based selectors for elements styled with CSS modules."
        )
    if has_styled_components:
        traits.append(
            "STYLING: Styled-components detected (styled.xxx or styled(xxx)). "
            "Generated class names are random — do not use them as selectors."
        )
    if has_tailwind:
        traits.append(
            "STYLING: Tailwind CSS detected. Utility classes (flex, mt-4, etc.) are "
            "stable and CAN be used as selectors, but are not unique. "
            "Prefer combining with text or structural selectors."
        )
    if has_css_in_js or has_emotion:
        traits.append(
            "STYLING: CSS-in-JS detected. Generated class names may be unstable — "
            "prefer attribute or text-based selectors."
        )
    if not any([has_module_css, has_styles_import, has_styled_components, has_tailwind, has_css_in_js, has_emotion]):
        traits.append(
            "STYLING: Plain CSS/SCSS with static class names. "
            "Class-based selectors should work if they exist in the source."
        )

    # --- Stable selector availability ---
    if has_data_testid:
        traits.append("SELECTORS: data-testid attributes found — prefer these.")
    if has_data_automation:
        traits.append("SELECTORS: data-automation-id attributes found — prefer these.")
    if has_aria_labels:
        traits.append("SELECTORS: aria-label attributes found — these are good selectors.")
    if not has_data_testid and not has_data_automation:
        traits.append(
            "SELECTORS: No data-testid or data-automation-id attributes found. "
            "Use text-based selectors (element:has-text(\"X\")) or structural attributes "
            "([name=...], [id=...], [placeholder=...], [type=...])."
        )

    # --- Framework ---
    if "next" in source_content.lower() and ("useRouter" in source_content or "next/navigation" in source_content):
        traits.append("FRAMEWORK: Next.js app.")
    elif "react" in source_content.lower():
        traits.append("FRAMEWORK: React app.")
    elif "angular" in source_content.lower() or ".component.ts" in str(file_suffixes):
        traits.append("FRAMEWORK: Angular app.")
    elif "vue" in source_content.lower() or ".vue" in file_suffixes:
        traits.append("FRAMEWORK: Vue app.")

    return "\n".join(traits) if traits else ""


def _extract_json_array(text: str) -> "list | None":
    """Extract the first JSON array from *text*, handling markdown fences and mixed prose."""
    import re
    # Strip outer --output-format json envelope if present
    try:
        env = json.loads(text)
        if isinstance(env, dict) and "result" in env:
            text = env["result"]
    except json.JSONDecodeError:
        pass
    # Strip markdown fences
    text = text.strip()
    if text.startswith("```"):
        m = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
        if m:
            text = m.group(1).strip()
    # Try direct parse first
    try:
        result = json.loads(text)
        if isinstance(result, list):
            return result
    except json.JSONDecodeError:
        pass
    # Scan for first '[' and attempt to parse from there
    start = text.find("[")
    if start == -1:
        return None
    for end in range(len(text), start, -1):
        if text[end - 1] == "]":
            try:
                result = json.loads(text[start:end])
                if isinstance(result, list):
                    return result
            except json.JSONDecodeError:
                continue
    return None


def _extract_component_id(component: dict) -> str:
    """Extract a component identifier from common backend response shapes."""
    for key in ("component_id", "componentId", "id", "_id", "testcase_component_id"):
        value = component.get(key)
        if value:
            return str(value)
    return ""


@test_group.command("fix")
@click.option("--name", "-n", "name_filter", default="", help="Filter by testcase name substring (case-insensitive).")
@click.option("--folder", "-f", "folder_filter", default="", help="Filter by folder/group name (case-insensitive).")
@click.option(
    "--edit", "-e",
    "edit_mode",
    is_flag=True,
    default=False,
    help="Interactively edit test steps (selector, value, URL) instead of AI auto-fix.",
)
@click.option(
    "--source",
    "-s",
    "source_dir",
    default=None,
    help="Source directory to scan for UI code (default: cwd).",
)
@click.option(
    "--validate-local/--no-validate-local",
    "validate_local",
    default=True,
    show_default=True,
    help="Run local Playwright validation after applying AI fix; auto-roll back on failure.",
)
@click.option(
    "--browser",
    "-B",
    "browser",
    default="chrome",
    show_default=True,
    help="Browser for local validation (chrome, firefox, safari).",
)
def fix_tests_cmd(
    name_filter: str,
    folder_filter: str,
    edit_mode: bool,
    source_dir: str | None,
    validate_local: bool,
    browser: str,
) -> None:
    """Fix a testcase — AI analyses steps against your source code and pushes the update.

    \b
    Default:
      Select a test. AI scans the source code, fills missing selectors,
      fixes stale ones, and pushes the corrected test to Maeris.

    \b
    With --edit:
      Select a test, inspect each step interactively, edit selectors/values/URLs,
      then replace the test in Maeris (delete + recreate).

    \b
    Examples:
      maeris test fix --name "Login"                 # Claude auto-fix (scans cwd)
      maeris test fix --name "Login" --source ./src  # Claude auto-fix (scans ./src)
      maeris test fix --name "Login" --edit          # manual step editor
      maeris test fix --folder "auth"                # choose and fix a test in folder
      maeris fix tests --name "Login"               # auto-fix (scans cwd)
      maeris fix tests --name "Login" --source ./src  # auto-fix (scans ./src)
      maeris fix tests --name "Login" --edit        # manual step editor
      maeris fix tests --folder "auth"              # auto-fix all tests in folder
    """
    from maeris_mcp.auth.token_store import TokenStore

    config_dir = _repo_config_dir()
    token_store = TokenStore(config_dir=config_dir)

    if not token_store.is_authenticated():
        click.secho("✗ Not authenticated. Run 'maeris auth login' first.", fg="red")
        sys.exit(1)
    if token_store.is_token_expired():
        click.secho("✗ Token expired. Run 'maeris auth login' to re-authenticate.", fg="red")
        sys.exit(1)

    click.echo("Fetching testcases...")

    async def _fetch() -> tuple[list[dict], list[dict]]:
        from maeris_mcp.maeris.client import MaerisClient
        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        async with MaerisClient() as client:
            tcs = await client.get_testcases()
            groups = await client.get_testcase_groups() if folder_filter else []
            return tcs, groups

    try:
        with _spinner("Fetching tests…"):
            testcases, groups = asyncio.run(_fetch())
    except Exception as e:
        click.secho(f"✗ Failed to fetch testcases: {e}", fg="red")
        sys.exit(1)

    if folder_filter:
        needle = folder_filter.strip().lower()
        matched_groups = [
            g for g in groups
            if needle in (g.get("testcase_group_name") or g.get("name") or "").lower()
        ]
        if not matched_groups:
            click.secho(f"✗ No folder matching '{folder_filter}' found.", fg="yellow")
            sys.exit(1)
        group_ids = {
            g.get("testcase_group_id") or g.get("id") or g.get("_id") for g in matched_groups
        }
        testcases = [
            tc for tc in testcases
            if (tc.get("testcase_group_id") or tc.get("group_id") or tc.get("group")) in group_ids
        ]

    if name_filter:
        needle = name_filter.strip().lower()
        testcases = [
            tc for tc in testcases
            if needle in (tc.get("name") or tc.get("testcase_name") or "").lower()
        ]

    if not testcases:
        click.secho("✗ No testcases match the provided filters.", fg="yellow")
        sys.exit(1)

    if len(testcases) == 1:
        target = testcases[0]
    else:
        labels = [
            f"{tc.get('name') or tc.get('testcase_name') or '-'}  "
            f"[{tc.get('test_type') or '-'} / {tc.get('test_origin') or '-'}]"
            for tc in testcases
        ]
        click.echo(f"\n{len(testcases)} tests match. Select one:")
        (idx,) = _prompt_numbered(labels, "Select test", multi=False)
        target = testcases[idx]

    target_name = target.get("name") or target.get("testcase_name") or "Unnamed"
    click.echo(f"Selected testcase: {target_name}")

    if edit_mode:
        _edit_testcase(target, config_dir)
    else:
        _ai_fix_testcase(
            target,
            config_dir,
            source_dir=source_dir,
            validate_local=validate_local,
            browser=browser,
        )


_BROWSER_MAP = {
    "chrome": "chromium",
    "chromium": "chromium",
    "firefox": "firefox",
    "safari": "webkit",
    "webkit": "webkit",
}


def _find_failed_step_index(error_reason: str, steps: list[dict]) -> "int | None":
    """Try to identify which step caused the Playwright error by matching its selector.

    Playwright escapes quotes in error messages (e.g. [class=\"foo\"]), so we
    normalise both sides before comparing.
    """
    if not error_reason or not steps:
        return None
    # Normalise: strip backslash-escaped quotes so selectors match as stored
    normalised = error_reason.replace('\\"', '"').replace("\\'", "'")
    for i, step in enumerate(steps):
        sel = step.get("cssSelector") or step.get("css_selector") or step.get("xpath") or ""
        if sel and sel in normalised:
            return i
    return None


def _extract_failure_reason(output: str) -> str:
    """Extract a concise error message from playwright subprocess output.

    For timeout errors the selector is on a 'waiting for locator(...)' line —
    we include that so the caller can identify which step failed.
    """
    msg = ""
    locator_hint = ""
    for line in output.splitlines():
        stripped = line.strip()
        if not msg and "Error:" in stripped:
            idx = stripped.find("Error:")
            msg = (stripped[idx + len("Error:"):].strip() or stripped)[:160]
        if not locator_hint and stripped.startswith("waiting for locator("):
            locator_hint = stripped[:120]
    if msg and locator_hint:
        return f"{msg} | {locator_hint}"
    return msg or ""


def _run_local_validation(
    testcase_id: str,
    testcase_name: str,
    config_dir: Path,
    browser: str,
) -> tuple[bool, str]:
    """Run one testcase locally via downloaded script and return (passed, reason)."""
    import io
    import subprocess
    import tempfile
    import zipfile

    requested = browser.strip().lower()
    if requested not in _BROWSER_MAP:
        raise RuntimeError(
            f"Invalid browser '{browser}'. Choose from: chrome, firefox, safari."
        )
    runtime_browser = _BROWSER_MAP[requested]

    py_exec = sys.executable
    check = subprocess.run([py_exec, "-c", "import playwright"], capture_output=True)
    if check.returncode != 0:
        raise RuntimeError(
            "Playwright is not installed. Install with: pip install playwright && python -m playwright install chromium"
        )

    async def _download() -> bytes:
        from maeris_mcp.maeris.client import MaerisClient

        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        async with MaerisClient() as client:
            return await client.download_testcase_scripts_zip(
                test_ids=[testcase_id],
                languages=["python"],
                browsers=[runtime_browser],
                skip_credits=True,
            )

    zip_bytes = asyncio.run(_download())

    with tempfile.TemporaryDirectory(prefix="maeris_fix_validate_") as tmpdir:
        root = Path(tmpdir)
        with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
            py_members = sorted(
                [
                    name
                    for name in zf.namelist()
                    if not name.endswith("/") and name.lower().endswith(".py")
                ]
            )
            if not py_members:
                raise RuntimeError("Downloaded archive has no Python test scripts.")
            zf.extractall(root)

        for rel_path in py_members:
            script_path = root / rel_path
            if not script_path.exists():
                continue
            click.echo(f"  Running local validation: {testcase_name} …", nl=False)
            proc = subprocess.run(
                [py_exec, str(script_path)],
                text=True,
                capture_output=True,
                cwd=str(script_path.parent),
            )
            output = (proc.stdout or "") + (proc.stderr or "")
            if proc.returncode != 0:
                click.secho(" failed", fg="red")
                return False, _extract_failure_reason(output)
            click.secho(" passed", fg="green")
    return True, ""


def _apply_step_edits(
    action_edits: list[dict],
    verification_edits: list[dict],
    config_dir: Path,
) -> None:
    """Apply step/assertion edits in-place to a testcase."""

    async def _apply() -> None:
        from maeris_mcp.maeris.client import MaerisClient

        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        async with MaerisClient() as client:
            for payload in action_edits:
                await client.edit_test_step(payload)
            for payload in verification_edits:
                await client.edit_testcase_verification(payload)

    asyncio.run(_apply())


def _ai_fix_testcase(
    tc: dict,
    config_dir: Path,
    source_dir: str | None = None,
    validate_local: bool = True,
    browser: str = "chrome",
) -> None:
    """Use Claude Code CLI to analyse a testcase's steps against the source code,
    fix missing/broken selectors, and push the updated test to Maeris."""
    import io
    import re
    import subprocess
    import tempfile
    import zipfile

    MAX_RETRIES = 5

    tc_name = tc.get("name") or tc.get("testcase_name") or "Unnamed"
    tc_id = _get_testcase_id(tc)
    if not tc_id:
        click.secho(f"✗ Test '{tc_name}' has no testcase ID.", fg="red")
        sys.exit(1)

    claude_bin, _ai_mode = _resolve_ai_mode()

    # --- Read source code once ---
    # Prioritize component/page files over utility/config files to stay within
    # the token budget.  Sort by relevance: files in "components", "pages", "views"
    # directories come first, then everything else alphabetically.
    scan_root = Path(source_dir) if source_dir else Path.cwd()
    from maeris_mcp.tidy import SUPPORTED_EXTENSIONS, SKIP_DIRS

    click.echo(f"Scanning source files in: {scan_root}")

    _priority_dirs = {"components", "pages", "views", "screens", "sections", "layouts", "app"}

    all_files: list[Path] = []
    for path in sorted(scan_root.rglob("*")):
        if not path.is_file() or path.suffix not in SUPPORTED_EXTENSIONS:
            continue
        if any(part in SKIP_DIRS for part in path.parts):
            continue
        all_files.append(path)

    # Sort: UI component files first, then the rest
    def _priority(p: Path) -> int:
        return 0 if any(part in _priority_dirs for part in p.parts) else 1

    all_files.sort(key=lambda p: (_priority(p), str(p)))

    source_chunks: list[str] = []
    total_chars = 0
    MAX_CHARS = 80_000

    for path in all_files:
        if total_chars >= MAX_CHARS:
            break
        try:
            content = path.read_text(encoding="utf-8", errors="ignore")
        except OSError:
            continue
        if not content.strip():
            continue
        rel = str(path.relative_to(scan_root))
        chunk = f"=== FILE: {rel} ===\n{content}\n"
        source_chunks.append(chunk)
        total_chars += len(chunk)

    if not source_chunks:
        click.secho(
            f"✗ No source files found under '{scan_root}'. "
            "Use --source to point at your UI source directory.",
            fg="yellow",
        )
        sys.exit(1)

    # Detect project characteristics so the AI knows what selector strategies work
    _project_hints = _detect_project_traits("".join(source_chunks), all_files)

    # --- Fetch steps ---
    steps: list[dict] = _fetch_testcase_steps_rich(tc, config_dir)
    if not steps:
        click.secho(f"✗ Test '{tc_name}' has no steps.", fg="yellow")
        sys.exit(1)

    # --- Step 1: Get the most recent test result ---
    from rich.console import Console
    from rich.status import Status
    _console = Console()

    async def _fetch_results() -> list[dict]:
        from maeris_mcp.maeris.client import MaerisClient
        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        async with MaerisClient() as client:
            return await client.get_test_results_for_testcase(tc_id)

    try:
        with _console.status(f"[bold cyan]Fetching latest test result for '{tc_name}'…", spinner="dots"):
            results = asyncio.run(_fetch_results())
    except Exception as e:
        click.secho(f"✗ Failed to fetch test results: {e}", fg="red")
        sys.exit(1)

    if not results:
        click.secho(
            "✗ No test results found for this testcase. "
            "Run the test first with 'maeris test run'.",
            fg="yellow",
        )
        sys.exit(1)

    # Check the most recent result — if it passed, nothing to fix
    latest = results[0]
    status = (latest.get("status") or "").lower()

    if status == "pass":
        click.secho("✓ Last test run passed. Nothing to fix.", fg="green")
        return

    failed_flu_id = latest.get("failed_flu_id") or ""
    error_message = latest.get("error_message") or ""

    if not failed_flu_id:
        click.secho(
            "✗ Test failed but no specific failed step was recorded.\n"
            f"  Error: {error_message[:200] if error_message else '(none)'}",
            fg="yellow",
        )
        sys.exit(1)

    _panel_error = error_message[:200] if error_message else ""

    # --- Step 2: Find the failed step ---
    # A verification step and action step can share the same flu_id.
    # Check the error message for verification-like patterns to pick the
    # right one.  Verification errors mention "visible", "text", "assert", etc.
    _verification_hints = ["visible", "to be visible", "text are", "assert", "expected", "not found"]
    _error_looks_like_verification = any(
        h in (error_message or "").lower() for h in _verification_hints
    )

    # Collect all matching steps
    matching_steps = [s for s in steps if s.get("_flu_id") == failed_flu_id]
    if not matching_steps:
        click.secho(
            "✗ Could not match the failed step to any known test step. "
            "The test data may be out of sync — try running the test again on Maeris.",
            fg="yellow",
        )
        sys.exit(1)

    # Prefer verification step if the error looks like a verification failure
    if _error_looks_like_verification:
        failed_step = next(
            (s for s in matching_steps if s.get("type") == "verification"),
            matching_steps[0],
        )
    else:
        # Prefer action step
        failed_step = next(
            (s for s in matching_steps if s.get("type") != "verification"),
            matching_steps[0],
        )

    step_name = failed_step.get("name") or "(unnamed)"

    from rich.panel import Panel
    from rich.text import Text
    panel_text = Text()
    panel_text.append("  Status:  ", style="bold")
    panel_text.append("FAILED\n", style="bold red")
    panel_text.append("  Step:    ", style="bold")
    panel_text.append(f"{step_name}\n", style="white")
    if _panel_error:
        panel_text.append("  Error:   ", style="bold")
        panel_text.append(f"{_panel_error}", style="dim")
    _console.print(Panel(panel_text, title=f"[bold]{tc_name}[/bold]", border_style="red", expand=False))

    # --- Step 3: Check if verification ---
    if failed_step.get("type") == "verification":
        _handle_verification_failure(failed_step, tc_id, config_dir)
        return

    # --- Step 4–7: Fix loop ---
    # Track accumulated edits across iterations (for different steps)
    accumulated_edits: list[tuple[dict, dict]] = []  # (original, fixed) pairs
    retries_for_step = 0  # retries spent on the current step
    previous_attempts: list[str] = []  # selectors already tried for current step
    steps_fixed_count = 0  # for progress display
    _all_source = "".join(source_chunks)  # pre-join for selector validation

    for attempt in range(1, MAX_RETRIES * 3 + 1):  # generous upper bound
        retries_for_step += 1
        click.echo(f"\n{'─' * 60}")
        progress = f" │ {steps_fixed_count} fixed" if steps_fixed_count else ""
        _console.print(
            f"  [bold]Fixing[/bold] [cyan]'{step_name}'[/cyan] "
            f"[dim](attempt {retries_for_step}/{MAX_RETRIES}{progress})[/dim]"
        )

        # --- AI fixes only the failed step ---
        with _console.status("[bold cyan]🔍 Analyzing failure…", spinner="dots"):
            fixed_step = _claude_fix_single_step(
                claude_bin, failed_step, error_message, source_chunks, tc_name,
                previous_attempts=previous_attempts,
                project_hints=_project_hints,
            )

        if fixed_step is None:
            click.secho("  ✗ Could not produce a fix.", fg="red")
            break

        # --- Check if AI wants to ADD a new step ---
        if fixed_step.get("_action") == "add_step":
            new_step_name = fixed_step.get("name") or "(new step)"
            new_step_css = fixed_step.get("cssSelector") or ""
            new_step_action = fixed_step.get("actionType") or "click"
            click.secho(
                f"  Adding a missing step before '{step_name}':",
                fg="cyan",
            )
            click.echo(f"    Name:     {new_step_name}")
            click.echo(f"    Selector: {new_step_css}")
            click.echo(f"    Action:   {new_step_action}")
            if fixed_step.get("mockInput"):
                click.echo(f"    Input:    {fixed_step['mockInput']}")

            # Source matching is advisory only here; runtime validation is authoritative.
            if new_step_css and not _selector_in_source(new_step_css, _all_source):
                click.secho(
                    f"  ! Selector '{new_step_css}' not found literally in source; testing locally anyway.",
                    fg="yellow",
                )

            # Find the index of the failed step in the steps list
            failed_step_idx = next(
                (i for i, s in enumerate(steps) if s.get("_flu_id") == failed_flu_id),
                len(steps),
            )

            # Insert the new step into our local steps list for testing
            new_step_entry = {
                "name": new_step_name,
                "cssSelector": new_step_css,
                "actionType": new_step_action,
                "page_url": failed_step.get("page_url") or "",
                "_flu_id": f"_new_{attempt}",  # temp ID
                "_is_new": True,
                "_insert_index": failed_step_idx,
            }
            if fixed_step.get("mockInput"):
                new_step_entry["mockInput"] = fixed_step["mockInput"]

            # Test locally with the new step inserted
            test_steps = list(steps)
            test_steps.insert(failed_step_idx, new_step_entry)

            with _console.status("[bold cyan]🧪 Running test locally with new step…", spinner="dots"):
                passed, run_output = _run_locally_generated(
                    test_steps, accumulated_edits
                )

            if passed:
                click.secho("\n  ✓ All steps passed locally with new step!", fg="green")
                push_ok = True
                # Push accumulated edits first
                if accumulated_edits:
                    with _console.status("[bold cyan]📤 Pushing fixes to Maeris…", spinner="dots"):
                        if not _push_step_edits(tc_id, accumulated_edits, config_dir):
                            push_ok = False
                # Push the new step
                with _console.status("[bold cyan]📤 Adding new step to Maeris…", spinner="dots"):
                    if not _push_new_step(tc_id, failed_step_idx, fixed_step, config_dir):
                        push_ok = False
                if push_ok:
                    click.secho(
                        f"  ✓ New step '{new_step_name}' added and "
                        f"{len(accumulated_edits)} edit(s) pushed to Maeris.",
                        fg="green",
                    )
                else:
                    click.secho(
                        "  ✗ Push to Maeris failed. Check auth with 'maeris auth login'.",
                        fg="red",
                    )
                return

            click.secho(f"\n  ✗ Test failed locally with new step.", fg="red")
            # Check if app is not running
            if any(e in run_output for e in ["ERR_CONNECTION_REFUSED", "ERR_CONNECTION_RESET", "ECONNREFUSED"]):
                click.secho("    Could not connect to the app. Is it running?", fg="red")
                click.secho("    Start your app and run 'maeris test fix' again.", fg="yellow")
                break
            # Track the failed selector so AI doesn't reuse it
            if new_step_css:
                previous_attempts.append(new_step_css)
            error_message = run_output
            if retries_for_step >= MAX_RETRIES:
                click.secho(
                    f"\n  ✗ Could not fix '{step_name}' after {MAX_RETRIES} attempts.",
                    fg="red",
                )
                break
            continue

        fixed_step = _merge_step_patch(failed_step, fixed_step)

        # Show diff
        changes = _show_single_step_diff(failed_step, fixed_step)
        if not changes:
            click.secho("  No changes needed for this step.", fg="yellow")
            break

        # Track what was tried
        tried_css = fixed_step.get("cssSelector") or fixed_step.get("css_selector") or ""
        if tried_css:
            previous_attempts.append(tried_css)

        # Source matching is advisory only here; runtime validation is authoritative.
        if tried_css and not _selector_in_source(tried_css, _all_source):
            click.secho(
                f"  ! Selector '{tried_css}' not found literally in source; testing locally anyway.",
                fg="yellow",
            )

        # --- Generate script from steps and run locally ---
        with _console.status("[bold cyan]🧪 Running test locally…", spinner="dots"):
            passed, run_output = _run_locally_generated(
                steps, accumulated_edits + [(failed_step, fixed_step)]
            )

        if passed:
            accumulated_edits.append((failed_step, fixed_step))
            with _console.status("[bold cyan]📤 Pushing fixes to Maeris…", spinner="dots"):
                push_ok = _push_step_edits(tc_id, accumulated_edits, config_dir)
            if push_ok:
                success_text = Text()
                success_text.append(f"  ✓ All steps passed locally\n", style="green")
                success_text.append(f"  ✓ {len(accumulated_edits)} step(s) pushed to Maeris", style="green")
                _console.print(Panel(success_text, title=f"[bold green]{tc_name}[/bold green]", border_style="green", expand=False))
            else:
                click.secho(
                    "\n  ✗ Push to Maeris failed. Check auth with 'maeris auth login'.",
                    fg="red",
                )
            return

        # --- Failed locally: identify which step failed ---
        # Check if the app itself is not running
        _conn_errors = ["ERR_CONNECTION_REFUSED", "ERR_CONNECTION_RESET", "NS_ERROR_CONNECTION_REFUSED", "ECONNREFUSED"]
        if any(e in run_output for e in _conn_errors):
            click.secho("\n  ✗ Could not connect to the app. Is it running?", fg="red")
            click.secho("    Start your app and run 'maeris test fix' again.", fg="yellow")
            break

        click.secho(f"\n  ✗ Test failed locally.", fg="red")

        fixed_css = (
            fixed_step.get("cssSelector") or fixed_step.get("css_selector") or ""
        ) if fixed_step else ""
        new_failed_flu, new_error = _identify_failure_from_output(
            run_output, steps, failed_flu_id, fixed_css=fixed_css
        )

        if new_failed_flu and new_failed_flu != failed_flu_id:
            # ── Different step failed — current step is fixed ──
            accumulated_edits.append((failed_step, fixed_step))
            steps_fixed_count += 1
            _console.print(f"  [green]✓[/green] [bold]'{step_name}'[/bold] fixed [dim]({steps_fixed_count} total)[/dim]")
            _console.print(f"  [dim]Moving to next failing step…[/dim]")

            # If we couldn't identify the exact step, push what we have
            if new_failed_flu == "UNKNOWN_NEW_STEP":
                with _console.status("[bold cyan]📤 Pushing fixes to Maeris…", spinner="dots"):
                    _push_step_edits(tc_id, accumulated_edits, config_dir)
                click.secho(
                    f"  ✓ {len(accumulated_edits)} step(s) pushed to Maeris.",
                    fg="green",
                )
                click.secho(
                    "  A step is failing but couldn't match it to a known step.\n"
                    "  Run 'maeris test fix' again after running on Maeris.",
                    fg="yellow",
                )
                return

            # Find the new failed step
            new_failed_step = next(
                (s for s in steps if s.get("_flu_id") == new_failed_flu), None
            )
            if not new_failed_step:
                new_failed_step = _match_step_by_selector(new_error, steps)

            if new_failed_step and new_failed_step.get("type") == "verification":
                # Push fixes so far, then handle the verification step
                with _console.status("[bold cyan]📤 Pushing fixes to Maeris…", spinner="dots"):
                    _push_step_edits(tc_id, accumulated_edits, config_dir)
                click.secho(
                    f"  ✓ {len(accumulated_edits)} step(s) pushed to Maeris.",
                    fg="green",
                )
                _handle_verification_failure(new_failed_step, tc_id, config_dir)
                return

            if new_failed_step:
                # Move to fixing the new step
                failed_step = new_failed_step
                failed_flu_id = new_failed_step.get("_flu_id") or ""
                step_name = new_failed_step.get("name") or "(unnamed)"
                error_message = new_error
                retries_for_step = 0  # reset retries for the new step
                previous_attempts = []  # reset attempts for new step
            else:
                # Can't find step — push what we have
                with _console.status("[bold cyan]📤 Pushing fixes to Maeris…", spinner="dots"):
                    _push_step_edits(tc_id, accumulated_edits, config_dir)
                click.secho(
                    f"  ✓ {len(accumulated_edits)} step(s) pushed to Maeris.",
                    fg="green",
                )
                click.secho(
                    "  Run 'maeris test fix' again if more steps fail on Maeris.",
                    fg="yellow",
                )
                return
        else:
            # ── Same step still failing — retry ──
            error_message = new_error or error_message
            if retries_for_step >= MAX_RETRIES:
                click.secho(
                    f"\n  ✗ Could not fix '{step_name}' after {MAX_RETRIES} attempts.",
                    fg="red",
                )
                break

    # Loop ended without full success — offer to push partial fixes
    if accumulated_edits:
        click.echo(f"  {len(accumulated_edits)} earlier step(s) were fixed.")
        try:
            if click.confirm("  Push partial fixes to Maeris?", default=True):
                _push_step_edits(tc_id, accumulated_edits, config_dir)
                click.secho("  ✓ Partial fixes pushed.", fg="green")
        except click.Abort:
            pass


def _handle_verification_failure(step: dict, tc_id: str, config_dir: Path) -> None:
    """Show verification failure details and ask user what to do."""
    assertion_nature = step.get("assertion_nature") or "?"
    assertion_type = step.get("assertion_type") or "?"
    assertion_value = step.get("assertion_value") or ""
    selector = step.get("cssSelector") or step.get("css_selector") or ""

    click.echo()
    click.secho("  Test is failing at verification:", fg="yellow", bold=True)
    click.echo(f"    Nature:    {assertion_nature}")
    click.echo(f"    Type:      {assertion_type}")
    click.echo(f"    Expected:  {assertion_value or '(empty)'}")
    click.echo(f"    Selector:  {selector or '(empty)'}")
    click.echo()

    try:
        if not click.confirm("  Is this verification correct?", default=True):
            new_value = click.prompt(
                "  Enter the correct assertion value", default=assertion_value
            ).strip()
            if new_value and new_value != assertion_value:
                verification_id = step.get("_verification_id") or ""
                if not verification_id:
                    click.secho("✗ Missing verification ID — cannot update.", fg="red")
                    return

                async def _update():
                    from maeris_mcp.maeris.client import MaerisClient
                    os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
                    async with MaerisClient() as client:
                        await client.edit_testcase_verification({
                            "testcases_verification_id": verification_id,
                            "assertion_value": new_value,
                        })

                try:
                    with _spinner("Updating verification…"):
                        asyncio.run(_update())
                    click.secho(
                        f"  ✓ Verification updated: '{assertion_value}' → '{new_value}'",
                        fg="green",
                    )
                except Exception as e:
                    click.secho(f"✗ Failed to update verification: {e}", fg="red")
            else:
                click.echo("  No changes made.")
        else:
            click.echo(
                "  Verification is correct — the app behaviour may have changed. "
                "No edits made."
            )
    except click.Abort:
        click.echo("\n  Aborted.")


def _claude_fix_single_step(
    claude_bin: str,
    step: dict,
    error_message: str,
    source_chunks: list[str],
    tc_name: str,
    previous_attempts: list[str] | None = None,
    project_hints: str = "",
) -> "dict | None":
    """Send only the failed step + error + source to AI for analysis. Returns fixed step dict."""
    stdin_content = (
        "=== FAILED STEP (JSON) ===\n"
        + json.dumps(step, indent=2)
        + "\n\n=== ERROR MESSAGE ===\n"
        + (error_message or "(no error message)")
    )
    if project_hints:
        stdin_content += "\n\n=== PROJECT ANALYSIS (auto-detected) ===\n" + project_hints

    stdin_content += (
        "\n\n=== SOURCE CODE ===\n"
        + "".join(source_chunks)
    )

    if previous_attempts:
        stdin_content += (
            "\n\n=== PREVIOUSLY TRIED SELECTORS (all failed — do NOT reuse) ===\n"
            + "\n".join(f"  - {s}" for s in previous_attempts)
        )

    instruction = (
        "OUTPUT REQUIREMENT: Your entire response must be a single valid JSON object — "
        "no prose, no markdown fences, no explanation. "
        "Start with { and end with }.\n\n"
        f'Task: fix the SINGLE failed test step for "{tc_name}".\n\n'
        "You are given:\n"
        "  1. The step that failed (JSON)\n"
        "  2. The error message from the test runner\n"
        "  3. Auto-detected project analysis (styling system, available selector types)\n"
        "  4. The UI source code\n"
        "  5. Previously tried selectors (if any) — you MUST NOT reuse these\n\n"
        "DIAGNOSIS — decide which type of fix is needed:\n\n"
        "A) SELECTOR FIX — the step exists but the selector is wrong/outdated.\n"
        "   Return the fixed step object (same structure as input).\n\n"
        "B) MISSING STEP — the test fails because a step is missing BEFORE this one.\n"
        "   For example: a popup/modal appeared, a consent dialog, a navigation change,\n"
        "   or a required interaction that didn't exist when the test was recorded.\n"
        "   Signs: timeout on an element that should be visible but isn't because\n"
        "   the page hasn't reached the expected state yet.\n"
        '   Return: {"_action": "add_step", "name": "...", "cssSelector": "...", '
        '"actionType": "click|fill|select", "mockInput": "", "page_url": "..."}\n'
        "   Only include mockInput if the action needs input (fill/type).\n\n"
        "SELECTOR STRATEGY:\n"
        "1. READ THE PROJECT ANALYSIS section — it tells you what styling system the\n"
        "   app uses and what selector types are available. Follow its guidance.\n"
        "2. Selector priority (best to worst):\n"
        "   a) data-testid, data-automation-id, [id=...], [name=...], [aria-label=...]\n"
        "   b) Text selectors SCOPED to element type: button:has-text(\"X\"), "
        "a:has-text(\"X\"), span:text-is(\"X\")\n"
        "   c) Attribute selectors: [placeholder=...], [type=...], [role=...]\n"
        "   d) Static class names (ONLY if the project analysis says classes are stable)\n"
        "   e) Structural: nth-child, xpath (last resort)\n"
        "3. STRICT MODE: Playwright strict mode — selector MUST match exactly ONE element.\n"
        "   - WRONG: div:has-text(\"X\") — matches the div AND all its ancestors\n"
        "   - RIGHT: button:has-text(\"X\") — leaf element, matches one\n"
        "   - RIGHT: span:text-is(\"X\") — exact text match\n"
        "4. FIND THE ELEMENT IN SOURCE CODE:\n"
        "   - Locate the exact JSX/HTML element you need to interact with.\n"
        "   - Read what text it renders. If it uses a variable like {item.name},\n"
        "     trace back to where item.name is defined to find the actual text.\n"
        "   - Check its attributes: id, name, aria-label, data-testid, role.\n"
        "   - Use what EXISTS in the source. Never invent attributes or class names.\n"
        "5. Fix cssSelector, page_url, or actionType as needed.\n"
        "6. NEVER change mockInput — keep the original input value exactly as-is.\n"
        "7. Keep every other field unchanged.\n\n"
        "Return a single JSON object."
    )

    system_prompt = (
        "You output ONLY raw JSON — no prose, no markdown, no explanation. "
        "Your response must be parseable by json.loads(). "
        "Start immediately with { and end with }. Nothing else."
    )

    if claude_bin:
        stdout_data, rc = _run_claude_capture(
            claude_bin,
            instruction,
            model="claude-sonnet-4-6",
            label="fixing step",
            extra_args=["--append-system-prompt", system_prompt],
            stdin_text=stdin_content,
        )
    else:
        stdout_data, rc = _run_direct_api_capture(
            instruction,
            "claude-sonnet-4-6",
            system_prompt=system_prompt,
            label="fixing step",
            stdin_text=stdin_content,
        )

    if rc != 0:
        return None

    # Extract JSON object from output
    text = stdout_data.strip()
    import re as _re
    # Strip --output-format json envelope
    try:
        env = json.loads(text)
        if isinstance(env, dict) and "result" in env:
            text = env["result"]
    except json.JSONDecodeError:
        pass
    # Strip markdown fences
    if text.startswith("```"):
        m = _re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
        if m:
            text = m.group(1).strip()
    try:
        result = json.loads(text)
        if isinstance(result, dict):
            return result
    except json.JSONDecodeError:
        pass
    # Scan for first '{'
    start = text.find("{")
    if start == -1:
        return None
    for end in range(len(text), start, -1):
        if text[end - 1] == "}":
            try:
                result = json.loads(text[start:end])
                if isinstance(result, dict):
                    return result
            except json.JSONDecodeError:
                continue
    return None


def _generate_playwright_script(steps: list[dict], edits: "list[tuple[dict, dict]]") -> str:
    """Generate a Playwright Python script from test steps.

    Applies any selector edits (original→fixed) before generating.
    No download needed — built entirely from step data.
    """
    # Build a map of flu_id → fixed selector from edits
    edit_map: dict[str, str] = {}
    for orig, fixed in edits:
        flu = orig.get("_flu_id") or ""
        new_css = fixed.get("cssSelector") or fixed.get("css_selector") or ""
        if flu and new_css:
            edit_map[flu] = new_css

    # Find the first page_url as the starting URL
    start_url = ""
    for s in steps:
        url = s.get("page_url") or ""
        if url:
            start_url = url
            break

    lines = [
        "import asyncio",
        "import re",
        "from playwright.async_api import async_playwright, expect",
        "",
        "async def run():",
        "    async with async_playwright() as p:",
        "        browser = await p.chromium.launch(headless=False)",
        "        context = await browser.new_context()",
        "        page = await context.new_page()",
        "",
    ]

    if start_url:
        lines.append(f'        await page.goto("{start_url}", timeout=30000)')
        lines.append('        await page.wait_for_load_state("domcontentloaded", timeout=15000)')
        lines.append("")

    current_url = start_url
    action_count = 0

    for step in steps:
        flu_id = step.get("_flu_id") or ""
        name = step.get("name") or "(unnamed)"
        page_url = step.get("page_url") or ""

        if step.get("type") == "verification":
            # Generate assertion code instead of skipping
            css = edit_map.get(flu_id) or step.get("cssSelector") or step.get("css_selector") or ""
            nature = (step.get("assertion_nature") or "").lower()
            value = (step.get("assertion_value") or "").replace('"', '\\"')

            lines.append(f"        # Assertion: {name}")

            if page_url and page_url != current_url:
                lines.append(f'        await page.goto("{page_url}", timeout=30000)')
                lines.append('        await page.wait_for_load_state("domcontentloaded", timeout=15000)')
                current_url = page_url

            if "url" in nature or "navigate" in nature:
                if value:
                    lines.append(f'        await expect(page).to_have_url(re.compile(r"{value}"), timeout=8000)')
                else:
                    lines.append('        await expect(page).to_have_url(re.compile(r".*"), timeout=8000)')
            elif "title" in nature:
                if value:
                    lines.append(f'        await expect(page).to_have_title(re.compile(r"{value}"), timeout=8000)')
            elif css and ("visible" in nature or "appear" in nature or "show" in nature or not nature):
                css_e = css.replace('"', '\\"')
                lines.append(f'        await expect(page.locator("{css_e}")).to_be_visible(timeout=8000)')
            elif css and ("hidden" in nature or "disappear" in nature or "not visible" in nature):
                css_e = css.replace('"', '\\"')
                lines.append(f'        await expect(page.locator("{css_e}")).to_be_hidden(timeout=8000)')
            elif css and ("text" in nature or "contain" in nature):
                css_e = css.replace('"', '\\"')
                if value:
                    lines.append(f'        await expect(page.locator("{css_e}")).to_contain_text("{value}", timeout=8000)')
                else:
                    lines.append(f'        await expect(page.locator("{css_e}")).to_be_visible(timeout=8000)')
            elif css and "value" in nature:
                css_e = css.replace('"', '\\"')
                if value:
                    lines.append(f'        await expect(page.locator("{css_e}")).to_have_value("{value}", timeout=8000)')
            elif css and ("enable" in nature or "disable" in nature):
                css_e = css.replace('"', '\\"')
                if "disable" in nature:
                    lines.append(f'        await expect(page.locator("{css_e}")).to_be_disabled(timeout=8000)')
                else:
                    lines.append(f'        await expect(page.locator("{css_e}")).to_be_enabled(timeout=8000)')
            elif css:
                css_e = css.replace('"', '\\"')
                lines.append(f'        await expect(page.locator("{css_e}")).to_be_visible(timeout=8000)')
            else:
                # No selector — skip silently (page-level assertion without specific element)
                lines.append("        pass  # page-level assertion — no selector available")

            action_count += 1
            lines.append("")
            continue

        css = edit_map.get(flu_id) or step.get("cssSelector") or step.get("css_selector") or ""
        action = (
            step.get("actionType")
            or step.get("action_type")
            or step.get("action_taken")
            or "click"
        ).lower()
        mock_input = step.get("mockInput") or step.get("mock_input") or ""
        nav_target = mock_input or page_url or (
            css if isinstance(css, str) and css.startswith(("http://", "https://")) else ""
        )

        if action in {"go_to", "goto", "navigate", "open"}:
            if not nav_target:
                continue
            action_count += 1
            lines.append(f"        # Step: {name}")
            if nav_target != current_url:
                lines.append(f'        await page.goto("{nav_target}", timeout=30000)')
                lines.append('        await page.wait_for_load_state("domcontentloaded", timeout=15000)')
                current_url = nav_target
            lines.append("        await page.wait_for_timeout(500)")
            lines.append("")
            continue

        if not css:
            continue

        action_count += 1

        lines.append(f"        # Step: {name}")

        # Navigate if page_url changed
        if page_url and page_url != current_url:
            lines.append(f'        await page.goto("{page_url}", timeout=30000)')
            lines.append('        await page.wait_for_load_state("domcontentloaded", timeout=15000)')
            current_url = page_url

        # Escape quotes in selector for Python string
        css_escaped = css.replace('"', '\\"')

        if action == "click":
            lines.append(f'        await page.locator("{css_escaped}").click(timeout=10000)')
        elif action in ("type", "input", "fill"):
            mock_escaped = mock_input.replace('"', '\\"')
            lines.append(f'        await page.locator("{css_escaped}").fill("{mock_escaped}", timeout=10000)')
        elif action == "select":
            mock_escaped = mock_input.replace('"', '\\"')
            lines.append(f'        await page.locator("{css_escaped}").select_option("{mock_escaped}", timeout=10000)')
        elif action == "hover":
            lines.append(f'        await page.locator("{css_escaped}").hover(timeout=10000)')
        elif action == "check":
            lines.append(f'        await page.locator("{css_escaped}").check(timeout=10000)')
        else:
            lines.append(f'        await page.locator("{css_escaped}").click(timeout=10000)')

        # Small wait between steps for page to settle
        lines.append("        await page.wait_for_timeout(500)")
        lines.append("")

    if action_count == 0:
        # No actionable steps — script would pass vacuously
        lines.append('        raise RuntimeError("No actionable steps with selectors found")')
        lines.append("")

    lines.append("        await browser.close()")
    lines.append("")
    lines.append("asyncio.run(run())")

    return "\n".join(lines)


def _merge_step_patch(original: dict, patch: dict) -> dict:
    """Merge an AI-produced partial step patch onto the original step.

    The fixer may return only the fields it wants to change. Preserve all
    existing fields. For existing steps, never let the fixer alter the input
    value; selector repair must not rewrite test data.
    """
    merged = dict(original)
    for key, value in patch.items():
        if key in {"mockInput", "mock_input"}:
            continue
        if value is not None:
            merged[key] = value

    # Always preserve input aliases from the original step.
    if "mockInput" in original:
        merged["mockInput"] = original.get("mockInput")
    if "mock_input" in original:
        merged["mock_input"] = original.get("mock_input")

    return merged


def _run_locally_generated(
    steps: list[dict],
    edits: "list[tuple[dict, dict]]",
) -> "tuple[bool, str]":
    """Generate a Playwright script from steps, apply edits, and run locally."""
    import subprocess
    import tempfile

    # Verify playwright is installed before generating/running
    py_exec = sys.executable
    check = subprocess.run(
        [py_exec, "-c", "import playwright"],
        capture_output=True,
    )
    if check.returncode != 0:
        return False, (
            "Playwright not installed. Install with:\n"
            "  pip install playwright && python -m playwright install chromium"
        )

    script_content = _generate_playwright_script(steps, edits)

    with tempfile.TemporaryDirectory(prefix="maeris_fix_") as tmpdir:
        script_path = Path(tmpdir) / "test_fix.py"
        script_path.write_text(script_content, encoding="utf-8")

        try:
            proc = subprocess.run(
                [py_exec, str(script_path)],
                text=True,
                capture_output=True,
                cwd=tmpdir,
                timeout=120,
            )
        except subprocess.TimeoutExpired:
            return False, "[TIMEOUT] Test exceeded 120s limit"

        output = (proc.stdout or "") + "\n" + (proc.stderr or "")
        passed = proc.returncode == 0
        if not passed and proc.stderr:
            # Show only the key error line(s) — skip the full traceback
            import re as _re
            key_lines = []
            for line in proc.stderr.strip().splitlines():
                stripped = line.strip()
                if any(kw in stripped for kw in [
                    "Error:", "TimeoutError:", "strict mode violation",
                    "resolved to", "waiting for locator",
                ]):
                    key_lines.append(stripped)
            if key_lines:
                click.echo()
                for line in key_lines[:5]:
                    click.echo(f"  {line}")
        return passed, output


def _show_single_step_diff(original: dict, fixed: dict) -> int:
    """Show diff for a single step. Returns number of changed fields."""
    changed = 0
    for key in sorted(set(list(original.keys()) + list(fixed.keys()))):
        if key.startswith("_"):
            continue
        old_val = original.get(key)
        new_val = fixed.get(key)
        if old_val != new_val:
            changed += 1
            click.echo(
                f"    {key}: "
                + click.style(str(old_val or "(empty)"), fg="red")
                + "  →  "
                + click.style(str(new_val or "(empty)"), fg="green")
            )
    return changed


def _run_locally_with_fix(
    tc_id: str,
    edits: "list[tuple[dict, dict]]",
    config_dir: Path,
) -> "tuple[bool, str]":
    """Download Playwright script, apply selector edits, run locally.

    Args:
        edits: list of (original_step, fixed_step) pairs to apply.

    Returns:
        (passed, output_text)
    """
    import io
    import subprocess
    import tempfile
    import zipfile

    async def _download() -> bytes:
        from maeris_mcp.maeris.client import MaerisClient
        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        async with MaerisClient() as client:
            return await client.download_testcase_scripts_zip(
                test_ids=[tc_id],
                languages=["python"],
                browsers=["chromium"],
                skip_credits=True,
            )

    try:
        zip_bytes = asyncio.run(_download())
    except Exception as e:
        return False, f"Failed to download test script: {e}"

    with tempfile.TemporaryDirectory(prefix="maeris_fix_") as tmpdir:
        root = Path(tmpdir)
        try:
            with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
                py_members = [
                    n for n in zf.namelist()
                    if not n.endswith("/") and n.lower().endswith(".py")
                ]
                if not py_members:
                    return False, "Downloaded archive has no Python test scripts."
                zf.extractall(root)
        except zipfile.BadZipFile:
            return False, "Downloaded file is not a valid ZIP archive."

        script_path = root / py_members[0]
        if not script_path.exists():
            return False, f"Script not found: {py_members[0]}"

        # Apply edits: replace old selectors with new ones
        content = script_path.read_text(encoding="utf-8", errors="ignore")
        for orig, fixed in edits:
            old_css = orig.get("cssSelector") or orig.get("css_selector") or ""
            new_css = fixed.get("cssSelector") or fixed.get("css_selector") or ""
            if old_css and new_css and old_css != new_css:
                # Try multiple quote/escape variants the script may use
                content = content.replace(old_css, new_css)
                # Escaped double quotes: \"...\")
                content = content.replace(
                    old_css.replace('"', '\\"'), new_css.replace('"', '\\"')
                )
                # Also try replacing just the distinguishing substring
                # (e.g. "Account Settings2" → "Account Settings")
                if '"' in old_css and '"' in new_css:
                    import re as _re
                    old_inner = _re.findall(r'"([^"]+)"', old_css)
                    new_inner = _re.findall(r'"([^"]+)"', new_css)
                    for old_i, new_i in zip(old_inner, new_inner):
                        if old_i != new_i:
                            content = content.replace(old_i, new_i)

            # Never replace mockInput — keep original input values intact

        script_path.write_text(content, encoding="utf-8")

        # Verify playwright is installed
        py_exec = sys.executable
        check = subprocess.run(
            [py_exec, "-c", "import playwright"],
            capture_output=True,
        )
        if check.returncode != 0:
            click.secho(
                "  ✗ Playwright not installed. Install with:\n"
                "      pip install playwright && python -m playwright install chromium",
                fg="red",
            )
            return False, "Playwright not installed"

        click.echo(f"  Running: {py_members[0]} …")
        proc = subprocess.run(
            [py_exec, str(script_path)],
            text=True,
            capture_output=True,
            cwd=str(script_path.parent),
            timeout=120,
        )
        combined = (proc.stdout or "") + (proc.stderr or "")

        if proc.returncode == 0:
            return True, combined
        else:
            # Show a snippet of the error
            lines = combined.strip().splitlines()
            tail = "\n".join(lines[-15:]) if len(lines) > 15 else combined.strip()
            click.echo(f"\n  Error output:\n{tail}")
            return False, combined


def _identify_failure_from_output(
    output: str, steps: list[dict], current_flu_id: str,
    fixed_css: str = "",
) -> "tuple[str | None, str]":
    """Try to identify which step failed from Playwright output.

    Returns (failed_flu_id_or_None, error_message).
    If the failing selector doesn't match the current step, we know the
    current step was fixed and a *different* step is now failing.

    ``fixed_css`` is the selector we just patched into the script for the
    current step.  If the error mentions *that* selector, it's still the
    same step failing (the fix didn't fully work).
    """
    import re

    # Extract selector from common Playwright error patterns.
    # Playwright error output uses escaped quotes inside locator(), e.g.:
    #   locator("div:has-text(\"Manage Address\")")
    # So we need patterns that handle both escaped and unescaped quotes.
    selector_patterns = [
        r'locator\("((?:[^"\\]|\\.)*)"\)',       # handles escaped quotes inside
        r'selector\s*"((?:[^"\\]|\\.)*)"\s*',
        r'waiting for\s+locator\("((?:[^"\\]|\\.)*)"\)',
        r'waiting for\s+"((?:[^"\\]|\\.)*)"\s*',
    ]

    found_selector = None
    for pattern in selector_patterns:
        m = re.search(pattern, output)
        if m:
            found_selector = m.group(1).replace('\\"', '"')
            break

    if found_selector:
        # If the error selector matches the fixed CSS we just applied,
        # it's still the same step failing (fix didn't fully resolve it).
        if fixed_css and (
            found_selector == fixed_css
            or found_selector in fixed_css
            or fixed_css in found_selector
        ):
            return None, output  # same step

        # Try to match against step selectors.
        # When multiple steps share the same original selector (e.g. checkboxes),
        # and we already fixed the current step to use a different selector,
        # the error must be from one of the OTHER steps with the original selector.
        matched_current = False
        for step in steps:
            step_css = step.get("cssSelector") or step.get("css_selector") or ""
            if step_css and found_selector in step_css:
                flu = step.get("_flu_id") or ""
                if flu and flu != current_flu_id:
                    return flu, output
                else:
                    matched_current = True

        if matched_current:
            # The error selector matches the current step's ORIGINAL selector.
            # If we applied a fix (fixed_css differs from original), then our
            # step now uses a different selector in the script.  The error must
            # be from a different step that shares the same original selector.
            current_step = next(
                (s for s in steps if s.get("_flu_id") == current_flu_id), None
            )
            original_css = ""
            if current_step:
                original_css = (
                    current_step.get("cssSelector")
                    or current_step.get("css_selector")
                    or ""
                )
            if fixed_css and fixed_css != original_css:
                # Our step uses fixed_css now — error is from a different step.
                # Find the NEXT step after the current one with the same selector.
                found_current = False
                for step in steps:
                    flu = step.get("_flu_id") or ""
                    if flu == current_flu_id:
                        found_current = True
                        continue
                    if found_current:
                        s_css = step.get("cssSelector") or step.get("css_selector") or ""
                        if s_css and found_selector in s_css and flu:
                            return flu, output
                # Couldn't find the exact next step
                return "UNKNOWN_NEW_STEP", output
            return None, output  # genuinely the same step

        # The failing selector doesn't match ANY known step selector.
        # Check if it also doesn't match the CURRENT step — that means
        # the current step was fixed and a new (unknown) step is failing.
        current_step = next(
            (s for s in steps if s.get("_flu_id") == current_flu_id), None
        )
        if current_step:
            current_css = (
                current_step.get("cssSelector")
                or current_step.get("css_selector")
                or ""
            )
            # If the error selector is clearly different from the current step's
            # selector, mark it as a different (unknown) step failure
            if current_css and found_selector not in current_css and current_css not in found_selector:
                # Try to find by matching inner text in step names
                for step in steps:
                    name = (step.get("name") or "").lower()
                    # Extract text from selector like div:has-text("Manage Address")
                    text_m = re.search(r'has-text\(["\']([^"\']+)["\']\)', found_selector)
                    if text_m and text_m.group(1).lower() in name:
                        flu = step.get("_flu_id") or ""
                        if flu and flu != current_flu_id:
                            return flu, output

                # Can't identify which step, but we know it's NOT the current one
                # Return a sentinel so the caller knows the current fix worked
                return "UNKNOWN_NEW_STEP", output

    # Extract a concise error message
    error_lines = []
    for line in output.splitlines():
        lower = line.lower()
        if any(kw in lower for kw in ["error", "timeout", "failed", "assert"]):
            error_lines.append(line.strip())

    return None, "\n".join(error_lines[-5:]) if error_lines else output[-500:]


def _match_step_by_selector(error_output: str, steps: list[dict]) -> "dict | None":
    """Try to find a step whose selector appears in the error output."""
    import re

    selector_patterns = [
        r'locator\("((?:[^"\\]|\\.)*)"\)',
        r'page\.locator\("((?:[^"\\]|\\.)*)"\)',
    ]

    for pattern in selector_patterns:
        m = re.search(pattern, error_output)
        if m:
            found = m.group(1).replace('\\"', '"')
            for step in steps:
                css = step.get("cssSelector") or step.get("css_selector") or ""
                if css and found in css:
                    return step
    return None


def _push_new_step(
    tc_id: str,
    step_index: int,
    step_data: dict,
    config_dir: Path,
) -> bool:
    """Push a new step to Maeris via the add_test_step API. Returns True on success."""
    component = {
        "flu_name": step_data.get("name") or "New Step",
        "cname": step_data.get("name") or "New Step",
        "component_label": step_data.get("name") or "New Step",
        "css_selector": step_data.get("cssSelector") or "",
        "xpath": step_data.get("xpath") or "",
        "action_taken": step_data.get("actionType") or "click",
        "mock_input": step_data.get("mockInput") or "",
    }

    async def _add():
        from maeris_mcp.maeris.client import MaerisClient
        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        async with MaerisClient() as client:
            await client.add_test_step(tc_id, step_index, component)

    try:
        asyncio.run(_add())
        return True
    except Exception as e:
        click.secho(f"  ✗ Failed to add new step: {e}", fg="red")
        return False


def _push_step_edits(
    tc_id: str,
    edits: "list[tuple[dict, dict]]",
    config_dir: Path,
) -> bool:
    """Push step edits to Maeris via the edit API. Returns True on success."""
    action_edits: list[dict] = []
    verification_edits: list[dict] = []

    for orig, fixed in edits:
        is_verification = orig.get("type") == "verification"
        if is_verification:
            verification_id = orig.get("_verification_id") or ""
            if not verification_id:
                continue
            has_changes = any(
                (fixed.get(field) != orig.get(field))
                for field in ("cssSelector", "css_selector", "xpath", "assertion_type", "assertion_value")
            )
            if has_changes:
                edit_payload: dict = {
                    "testcases_verification_id": verification_id,
                    "css_selector": fixed.get("cssSelector") or fixed.get("css_selector") or "",
                    "xpath": fixed.get("xpath") or "",
                    "assertion_type": fixed.get("assertion_type") or "",
                    "assertion_value": fixed.get("assertion_value") or "",
                }
                verification_edits.append(edit_payload)
        else:
            flu_id = orig.get("_flu_id") or orig.get("flu_id") or ""
            component_id = orig.get("_component_id") or orig.get("component_id") or ""
            if not flu_id:
                continue
            edit_payload = {"testcase_id": tc_id, "flu_id": flu_id}
            if component_id:
                edit_payload["component_id"] = component_id
            has_changes = any(
                (fixed.get(field) != orig.get(field))
                for field in ("cssSelector", "css_selector", "actionType", "action_taken", "xpath", "name", "page_url")
            )
            if has_changes:
                edit_payload["css_selector"] = fixed.get("cssSelector") or fixed.get("css_selector") or ""
                edit_payload["action_taken"] = fixed.get("actionType") or fixed.get("action_taken") or ""
                edit_payload["xpath"] = fixed.get("xpath") or ""
                edit_payload["name"] = fixed.get("name") or ""
                edit_payload["page_url"] = fixed.get("page_url") or ""
                # PUT /teststeps behaves like a replace-style update; preserve input explicitly.
                if "mockInput" in fixed or "mock_input" in fixed or "mockInput" in orig or "mock_input" in orig:
                    edit_payload["mock_input"] = (
                        fixed.get("mockInput")
                        if fixed.get("mockInput") is not None
                        else fixed.get("mock_input")
                    )
                    if edit_payload["mock_input"] is None:
                        edit_payload["mock_input"] = orig.get("mockInput")
                    if edit_payload["mock_input"] is None:
                        edit_payload["mock_input"] = orig.get("mock_input")
                action_edits.append(edit_payload)

    if not action_edits and not verification_edits:
        return True

    async def _apply() -> None:
        from maeris_mcp.maeris.client import MaerisClient
        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        async with MaerisClient() as client:
            for payload in action_edits:
                await client.edit_test_step(payload)
            for payload in verification_edits:
                await client.edit_testcase_verification(payload)

    try:
        asyncio.run(_apply())
        return True
    except Exception as e:
        click.secho(f"  ✗ Failed to push step edits: {e}", fg="red")
        return False


def _fetch_testcase_steps(tc: dict, config_dir: Path) -> list[dict]:
    """Return the steps for a testcase.

    First checks if the testcase object already carries a ``steps`` list (some
    API responses include it).  If not, reconstructs the steps from the
    ``testcase_path`` / ``path`` field by fetching the matching components and
    FLU names from the server.
    """
    existing = list(tc.get("steps") or [])
    if existing:
        return existing

    path = tc.get("path") or tc.get("testcase_path") or ""
    flu_ids = [fid.strip() for fid in path.split("->") if fid.strip()]
    if not flu_ids:
        return []

    async def _fetch():
        from maeris_mcp.maeris.client import MaerisClient
        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        async with MaerisClient() as client:
            components = await client.get_components_by_flu_ids(flu_ids)
            flus = await client.get_flus()
            return components, flus

    try:
        with _spinner("Fetching step details…"):
            components, flus = asyncio.run(_fetch())
    except Exception as exc:
        click.secho(f"  ✗ Could not fetch step details: {exc}", fg="yellow")
        return []

    flu_name_map: dict[str, str] = {}
    for f in flus:
        fid = f.get("flu_id") or f.get("id") or ""
        if fid:
            flu_name_map[fid] = f.get("flu_name") or f.get("name") or fid

    comp_map: dict[str, dict] = {}
    for comp in components:
        fid = comp.get("flu_id") or ""
        if fid:
            comp_map[fid] = comp

    steps: list[dict] = []
    for flu_id in flu_ids:
        comp = comp_map.get(flu_id, {})
        step: dict = {
            "name": flu_name_map.get(flu_id, flu_id),
            "actionType": comp.get("action_taken") or "click",
            "cssSelector": comp.get("css_selector") or "",
            "page_url": comp.get("page_url") or "",
            "page_name": comp.get("page_name") or "",
        }
        if comp.get("mock_input"):
            step["mockInput"] = comp["mock_input"]
        if comp.get("xpath"):
            step["xpath"] = comp["xpath"]
        steps.append(step)

    return steps


def _step_display(step: dict, idx: int) -> str:
    """Format a single step for display in the editor."""
    stype = step.get("type") or "action"
    if stype == "verification":
        action = f"verify/{step.get('assertion_nature', '?')}"
        selector = step.get("css_selector") or step.get("cssSelector") or ""
        value = step.get("assertion_value") or step.get("assertion_type") or ""
    else:
        action = step.get("actionType") or step.get("action_type") or "?"
        selector = step.get("cssSelector") or step.get("css_selector") or ""
        value = step.get("mockInput") or step.get("mock_input") or step.get("page_url") or ""
    name = step.get("name") or step.get("step_name") or ""
    sel_str = f"  css: {selector}" if selector else ""
    val_str = f"  → {value[:60]}" if value else ""
    return f"  {idx:>2}.  {action:<16}{name[:40]:<42}{sel_str:<35}{val_str}"


def _edit_testcase(tc: dict, config_dir: Path) -> None:
    """Interactive step editor: show steps, let user edit fields, delete+recreate."""
    tc_name = tc.get("name") or tc.get("testcase_name") or "Unnamed"
    tc_id = _get_testcase_id(tc)
    raw_steps: list[dict] = _fetch_testcase_steps(tc, config_dir)

    if not raw_steps:
        click.secho(f"✗ Test '{tc_name}' has no steps to edit (or step data could not be fetched).", fg="yellow")
        sys.exit(1)

    steps = [dict(s) for s in raw_steps]

    click.echo(f"\nEditing: {click.style(tc_name, bold=True)}  ({len(steps)} steps)\n")

    while True:
        click.echo(f"  {'#':>3}  {'action':<16}{'name':<42}{'selector':<35}{'value/url'}")
        click.echo("  " + "─" * 120)
        for i, step in enumerate(steps, 1):
            click.echo(_step_display(step, i))
        click.echo()

        try:
            raw = click.prompt(
                "  Edit step # (Enter to finish, q to cancel)", default="", show_default=False
            ).strip()
        except click.Abort:
            click.echo("\nAborted.")
            sys.exit(0)

        if raw == "":
            break
        if raw.lower() == "q":
            click.echo("Cancelled — no changes saved.")
            sys.exit(0)

        if not raw.isdigit() or not (1 <= int(raw) <= len(steps)):
            click.secho(f"  Invalid step number '{raw}'.", fg="yellow")
            continue

        step = steps[int(raw) - 1]
        stype = step.get("type") or "action"
        click.echo(f"\n  Step {raw} — {step.get('actionType') or step.get('action_type') or stype}")

        if stype == "verification":
            editable = [
                ("css_selector", "CSS selector",     step.get("css_selector") or step.get("cssSelector") or ""),
                ("assertion_value", "Expected value", step.get("assertion_value") or ""),
                ("page_url",       "Page URL",        step.get("page_url") or ""),
            ]
        else:
            editable = [
                ("cssSelector", "CSS selector", step.get("cssSelector") or step.get("css_selector") or ""),
                ("mockInput",   "Mock input",   step.get("mockInput") or step.get("mock_input") or ""),
                ("page_url",    "Page URL",     step.get("page_url") or ""),
                ("name",        "Step name",    step.get("name") or step.get("step_name") or ""),
            ]

        for field, label, current in editable:
            display = current or click.style("(empty)", fg="bright_black")
            try:
                new_val = click.prompt(
                    f"    {label} [{display}]", default=current, show_default=False
                ).strip()
            except click.Abort:
                click.echo("\nAborted.")
                sys.exit(0)
            if new_val != current:
                step[field] = new_val or None
                if field == "cssSelector":
                    step["css_selector"] = new_val or None
                elif field == "css_selector":
                    step["cssSelector"] = new_val or None
                elif field == "mockInput":
                    step["mock_input"] = new_val or None
        click.echo()

    click.echo(f"\nApply changes to '{tc_name}'? (will delete + recreate the test)")
    try:
        if not click.confirm("  Confirm", default=False):
            click.echo("Cancelled — no changes saved.")
            sys.exit(0)
    except click.Abort:
        click.echo("\nAborted.")
        sys.exit(0)

    payload = {
        "name": tc_name,
        "description": tc.get("description") or tc_name,
        "testcase_type": tc.get("test_type") or tc.get("testcase_type") or "regression",
        "test_folder": tc.get("testcase_group_id") or tc.get("group_id") or tc.get("group") or "",
        "labels": tc.get("labels") or [],
        "browser": tc.get("browser") or ["chrome"],
        "mark_as": tc.get("mark_as") or "pass",
        "steps": steps,
    }

    async def _apply() -> dict:
        from maeris_mcp.maeris.client import MaerisClient
        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        async with MaerisClient() as client:
            if tc_id:
                await client.delete_testcases([tc_id])
            return await client.create_manual_tests([payload])

    try:
        asyncio.run(_apply())
    except Exception as e:
        click.secho(f"✗ Failed to update testcase: {e}", fg="red")
        sys.exit(1)

    click.secho(f"\n  ✓ '{tc_name}' updated successfully.", fg="green")


def _selector_in_source(selector: str, source_content: str) -> bool:
    """Return True if the selector is plausibly valid given the source code.

    This is intentionally permissive. It is used as a confidence signal, not a
    hard proof that a selector will work at runtime.
    """
    import re
    if not selector:
        return True

    # xpath and URLs are validated at runtime, not statically.
    stripped = selector.strip()
    if (
        stripped.startswith("xpath=")
        or stripped.startswith("//")
        or stripped.startswith(("http://", "https://"))
    ):
        return True

    checks_run = 0
    matches_found = 0

    # Text-based selectors: allow through if any referenced text is present.
    text_patterns = [
        r':has-text\(["\'](.+?)["\']\)',
        r':text-is\(["\'](.+?)["\']\)',
        r'\btext=["\'](.+?)["\']',
        r'\btext=([^\s\]]+)',
    ]
    for pat in text_patterns:
        for text_value in re.findall(pat, selector):
            text_value = text_value.strip()
            if not text_value:
                continue
            checks_run += 1
            if text_value in source_content:
                matches_found += 1

    # Attribute selectors: check every explicit attribute value, not just the first one.
    for attr_value in re.findall(r'\[[\w-]+=["\'](.*?)["\']\]', selector):
        attr_value = attr_value.strip()
        if not attr_value:
            continue
        checks_run += 1
        if attr_value in source_content:
            matches_found += 1

    # Strip Playwright pseudo-selectors to isolate the core selector.
    cleaned = re.sub(r':has-text\(["\'].*?["\']\)', '', selector)
    cleaned = re.sub(r':text-is\(["\'].*?["\']\)', '', cleaned)
    cleaned = re.sub(r':has\(.*?\)', '', cleaned)
    cleaned = re.sub(r':nth\(\d+\)', '', cleaned)
    cleaned = re.sub(r':(?:first|last)-child', '', cleaned)
    cleaned = re.sub(r'\s*>>.*', '', cleaned)
    cleaned = re.sub(r':(visible|hidden|enabled|disabled|focus)\b', '', cleaned)
    cleaned = cleaned.strip()

    if not cleaned:
        return True

    # Class/ID selectors: accept if any identifier is present in source.
    identifiers = re.findall(r'[.#]([\w-]+)', cleaned)
    if identifiers:
        for ident in identifiers:
            checks_run += 1
            if ident in source_content:
                matches_found += 1

    # Pure tag/structural selectors cannot be validated statically.
    if checks_run == 0:
        return True

    return matches_found > 0


def _fetch_testcase_steps_rich(tc: dict, config_dir: Path) -> list[dict]:
    """Like _fetch_testcase_steps but includes flu_id / component_id metadata
    needed for in-place edits via PUT /teststeps. Also appends verification
    (assertion) steps tagged with type='verification' and _verification_id."""
    tc_id = _get_testcase_id(tc)
    existing = list(tc.get("steps") or [])
    if existing and not any("_flu_id" in s for s in existing):
        # Steps came in pre-built but lack metadata — fall through to fetch
        existing = []

    path = tc.get("path") or tc.get("testcase_path") or ""
    flu_ids = [fid.strip() for fid in path.split("->") if fid.strip()]

    async def _fetch():
        from maeris_mcp.maeris.client import MaerisClient
        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        async with MaerisClient() as client:
            components = await client.get_components_by_flu_ids(flu_ids) if flu_ids else []
            flus = await client.get_flus() if flu_ids else []
            verifications = await client.get_all_testcase_verifications()
            return components, flus, verifications

    try:
        with _spinner("Fetching step details…"):
            components, flus, all_verifications = asyncio.run(_fetch())
    except Exception as exc:
        click.secho(f"  ✗ Could not fetch step details: {exc}", fg="yellow")
        return []

    flu_name_map: dict[str, str] = {}
    for f in flus:
        fid = f.get("flu_id") or f.get("id") or ""
        if fid:
            flu_name_map[fid] = f.get("flu_name") or f.get("name") or fid

    comp_map: dict[str, dict] = {}
    for comp in components:
        fid = comp.get("flu_id") or ""
        if fid:
            comp_map[fid] = comp

    steps: list[dict] = []
    for flu_id in flu_ids:
        comp = comp_map.get(flu_id, {})
        step: dict = {
            "name": flu_name_map.get(flu_id, flu_id),
            "actionType": comp.get("action_taken") or "click",
            "cssSelector": comp.get("css_selector") or "",
            "page_url": comp.get("page_url") or "",
            "page_name": comp.get("page_name") or "",
            "_flu_id": flu_id,
            "_component_id": _extract_component_id(comp),
        }
        if comp.get("mock_input"):
            step["mockInput"] = comp["mock_input"]
        if comp.get("xpath"):
            step["xpath"] = comp["xpath"]
        steps.append(step)

    # Append verification (assertion) steps for this testcase
    tc_verifications = [
        v for v in all_verifications
        if v.get("testcase_id") == tc_id and not v.get("deleted_at")
    ]
    for v in tc_verifications:
        vstep: dict = {
            "type": "verification",
            "name": v.get("assertion_type") or "assertion",
            "assertion_nature": v.get("assertion_nature") or "",
            "assertion_type": v.get("assertion_type") or "",
            "assertion_value": v.get("assertion_value") or "",
            "cssSelector": v.get("css_selector") or "",
            "xpath": v.get("xpath") or "",
            "_verification_id": v.get("testcases_verification_id") or "",
            "_flu_id": v.get("flu_id") or "",
            "_component_id": v.get("component_id") or "",
        }
        steps.append(vstep)

    return steps


@test_group.command("recalibrate")
@click.option("--source", "-s", default=None, help="UI source directory (default: cwd).")
@click.option("--name", "-n", "name_filter", default="", help="Only recalibrate tests whose name contains this string.")
@click.option("--folder", "-f", "folder_filter", default="", help="Only recalibrate tests in this folder.")
@click.option("--dry-run", "-d", is_flag=True, default=False, help="Show what would change without writing anything.")
def recalibrate_cmd(
    source: str | None,
    name_filter: str,
    folder_filter: str,
    dry_run: bool,
) -> None:
    """Scan UI source and update test steps to match the current codebase.

    \b
    Detects stale selectors, missing elements, and renamed IDs, then uses
    AI to produce in-place edits via PUT /teststeps (no delete/recreate).

    \b
    Examples:
      maeris recalibrate                          # all tests, scan cwd
      maeris recalibrate --source ../autoe-light  # point at a different repo
      maeris recalibrate --name "Login"           # single test
      maeris recalibrate --folder "auth"          # whole folder
      maeris recalibrate --dry-run                # preview only
    """
    from maeris_mcp.auth.token_store import TokenStore

    config_dir = _repo_config_dir()
    token_store = TokenStore(config_dir=config_dir)

    if not token_store.is_authenticated():
        click.secho("✗ Not authenticated. Run 'maeris auth login' first.", fg="red")
        sys.exit(1)
    if token_store.is_token_expired():
        click.secho("✗ Token expired. Run 'maeris auth login' to re-authenticate.", fg="red")
        sys.exit(1)

    claude_bin, _ai_mode = _resolve_ai_mode()

    async def _fetch_tcs():
        from maeris_mcp.maeris.client import MaerisClient
        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        async with MaerisClient() as client:
            tcs = await client.get_testcases()
            groups = await client.get_testcase_groups() if folder_filter else []
            return tcs, groups

    click.echo("Fetching testcases…")
    try:
        with _spinner("Fetching tests…"):
            testcases, groups = asyncio.run(_fetch_tcs())
    except Exception as e:
        click.secho(f"✗ Failed to fetch testcases: {e}", fg="red")
        sys.exit(1)

    if folder_filter:
        needle = folder_filter.strip().lower()
        matched = [g for g in groups if needle in (g.get("testcase_group_name") or g.get("name") or "").lower()]
        if not matched:
            click.secho(f"✗ No folder matching '{folder_filter}'.", fg="yellow")
            sys.exit(1)
        gids = {g.get("testcase_group_id") or g.get("id") for g in matched}
        testcases = [tc for tc in testcases if (tc.get("testcase_group_id") or tc.get("group_id")) in gids]

    if name_filter:
        needle = name_filter.strip().lower()
        testcases = [tc for tc in testcases if needle in (tc.get("name") or "").lower()]

    if not testcases:
        click.secho("✗ No testcases match the provided filters.", fg="yellow")
        sys.exit(1)

    scan_root = Path(source) if source else Path.cwd()
    from maeris_mcp.tidy import SUPPORTED_EXTENSIONS, SKIP_DIRS

    source_files: dict[str, str] = {}
    total_chars = 0
    MAX_CHARS = 120_000

    for path in sorted(scan_root.rglob("*")):
        if total_chars >= MAX_CHARS:
            break
        if not path.is_file() or path.suffix not in SUPPORTED_EXTENSIONS:
            continue
        if any(part in SKIP_DIRS for part in path.parts):
            continue
        try:
            content = path.read_text(encoding="utf-8", errors="ignore")
        except OSError:
            continue
        rel = str(path.relative_to(scan_root))
        source_files[rel] = content
        total_chars += len(content)

    if not source_files:
        click.secho(f"✗ No source files found under '{scan_root}'. Use --source to point at your UI repo.", fg="yellow")
        sys.exit(1)

    combined_source = "\n".join(f"=== FILE: {rel} ===\n{content}" for rel, content in source_files.items())
    click.echo(f"  {len(source_files)} source file(s) loaded from {scan_root}")

    # Detect project traits for the AI prompt
    _all_paths = [scan_root / rel for rel in source_files]
    _recal_project_hints = _detect_project_traits(combined_source, _all_paths)

    click.echo("\nChecking selectors…")
    stale_tests: list[tuple[dict, list[dict]]] = []

    for tc in testcases:
        steps = _fetch_testcase_steps_rich(tc, config_dir)
        if not steps:
            continue
        has_stale = any(
            not _selector_in_source(s.get("cssSelector") or s.get("xpath") or "", combined_source)
            for s in steps
            if s.get("cssSelector") or s.get("xpath")
        )
        if has_stale:
            stale_tests.append((tc, steps))

    if not stale_tests:
        click.secho("\n✓ All selectors look up-to-date. Nothing to recalibrate.", fg="green")
        return

    click.echo(f"\n  Found {len(stale_tests)} test(s) with potentially stale selectors:\n")
    for tc, steps in stale_tests:
        stale_steps = [
            s for s in steps
            if (s.get("cssSelector") or s.get("xpath"))
            and not _selector_in_source(s.get("cssSelector") or s.get("xpath") or "", combined_source)
        ]
        click.echo(
            f"    {click.style('•', fg='yellow')} {tc.get('name') or '-'}"
            f"  ({len(stale_steps)} stale step(s))"
        )

    click.echo(f"\n  AI will analyse each test ({len(stale_tests)} call(s)).")

    if dry_run:
        click.secho("\n  [dry-run] No changes applied.", fg="yellow")
        return

    try:
        if not click.confirm("\n  Proceed?", default=True):
            click.echo("Cancelled.")
            return
    except click.Abort:
        click.echo("\nAborted.")
        return

    instruction_tmpl = (
        "You are a test step recalibrator for a web application.\n"
        "You will receive:\n"
        "  1. The CURRENT steps of a test (JSON)\n"
        "  2. Auto-detected project analysis (styling system, available selectors)\n"
        "  3. The full UI source code\n\n"
        "Your job:\n"
        "1. Read the PROJECT ANALYSIS to understand what selector strategies work.\n"
        "2. For every step whose cssSelector or xpath no longer exists in the source: "
        "find the correct replacement selector.\n"
        "3. Selector priority: data-testid > [id=...] > [name=...] > [aria-label=...] > "
        "element:has-text(\"X\") > [placeholder=...] > stable class > structural.\n"
        "4. STRICT MODE: selectors must match exactly ONE element. Scope text selectors "
        "to element type (button:has-text, NOT div:has-text).\n"
        "5. Never invent attributes or class names not in the source.\n"
        "6. If a step's selector is still valid, leave it unchanged.\n"
        "7. You MAY change 'name' if the label has clearly changed in the source.\n\n"
        "Return ONLY a JSON array of step objects that need updating, each containing:\n"
        "  { \"_flu_id\": \"<same as input>\", \"_component_id\": \"<same as input>\","
        " \"cssSelector\": \"<new>\", \"name\": \"<unchanged or updated>\" }\n"
        "If nothing needs changing, return an empty array [].\n"
        "No prose, no markdown fences."
    )

    system_prompt = (
        "You output ONLY raw JSON — no prose, no markdown, no explanation. "
        "Your response must be parseable by json.loads(). "
        "Start immediately with [ and end with ]. Nothing else."
    )

    applied = 0
    errors = 0

    with click.progressbar(
        stale_tests,
        label="  Recalibrating",
        item_show_func=lambda tc: f"  {(tc[0].get('name') or '')[:30]}" if tc else "",
    ) as bar:
        for tc, steps in bar:
            tc_name = tc.get("name") or "-"
            tc_id = _get_testcase_id(tc)

            stdin_content = (
                "=== CURRENT STEPS (JSON) ===\n"
                + json.dumps(steps, indent=2)
            )
            if _recal_project_hints:
                stdin_content += (
                    "\n\n=== PROJECT ANALYSIS (auto-detected) ===\n"
                    + _recal_project_hints
                )
            stdin_content += "\n\n=== SOURCE CODE ===\n" + combined_source

            try:
                click.echo(f"\n  Recalibrating '{tc_name}'…", err=True)
                if claude_bin:
                    stdout_data, rc = _run_claude_capture(
                        claude_bin,
                        instruction_tmpl,
                        model="claude-sonnet-4-6",
                        label=tc_name,
                        extra_args=["--append-system-prompt", system_prompt],
                        stdin_text=stdin_content,
                    )
                else:
                    stdout_data, rc = _run_direct_api_capture(
                        instruction_tmpl,
                        "claude-sonnet-4-6",
                        system_prompt=system_prompt,
                        label=tc_name,
                        stdin_text=stdin_content,
                    )
            except Exception:
                click.secho(f"\n  ✗ Failed on '{tc_name}'", fg="red")
                errors += 1
                continue

            if rc != 0:
                click.secho(f"\n  ✗ Analysis error on '{tc_name}'", fg="red")
                errors += 1
                continue

            edits = _extract_json_array(stdout_data)
            if not isinstance(edits, list) or not edits:
                continue

            # Validate: reject any AI-suggested selectors not found in source
            validated_edits = []
            for edit in edits:
                new_css = edit.get("cssSelector") or edit.get("css_selector") or ""
                if new_css and not _selector_in_source(new_css, combined_source):
                    step_name = edit.get("name") or edit.get("_flu_id") or "?"
                    click.secho(
                        f"\n  ⚠ Skipping '{step_name}': selector '{new_css}' not found in source.",
                        fg="yellow",
                    )
                    continue
                validated_edits.append(edit)
            edits = validated_edits
            if not edits:
                continue

            async def _apply_edits(tc_id: str, edits: list[dict]) -> None:
                from maeris_mcp.maeris.client import MaerisClient
                os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
                async with MaerisClient() as client:
                    for edit in edits:
                        flu_id = edit.get("_flu_id") or edit.get("flu_id") or ""
                        component_id = edit.get("_component_id") or edit.get("component_id") or ""
                        if not flu_id:
                            continue
                        comp = comp_map.get(flu_id, {})
                        edit_payload: dict = {"testcase_id": tc_id}
                        if flu_id:
                            edit_payload["flu_id"] = flu_id
                        if component_id:
                            edit_payload["component_id"] = component_id
                        for field in ("cssSelector", "css_selector", "xpath", "name",
                                      "action_taken"):
                            val = edit.get(field)
                            if val is not None:
                                server_key = "css_selector" if field == "cssSelector" else field
                                edit_payload[server_key] = val
                        if comp.get("mock_input") is not None:
                            edit_payload["mock_input"] = comp.get("mock_input")
                        await client.edit_test_step(edit_payload)

            try:
                with _spinner("Pushing fixes to Maeris…"):
                    asyncio.run(_apply_edits(tc_id, edits))
                applied += 1
            except Exception as exc:
                click.secho(f"\n  ✗ Failed to apply edits for '{tc_name}': {exc}", fg="red")
                errors += 1

    click.echo()
    if applied:
        click.secho(f"✓ Recalibrated {applied} test(s).", fg="green")
    if errors:
        click.secho(f"  {errors} test(s) had errors.", fg="yellow")
