"""Test management commands: create, import, run, list, and delete tests/folders."""

import asyncio
import json
import os
import re
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

import click

from maeris_mcp.auth.token_store import TokenStore

from ._core import folder_group, push_group, test_group, ui_group
from ._utils import (
    _get_testcase_id,
    _is_tty,
    _next_steps,
    _print_csv,
    _print_json,
    _print_record,
    _prompt_numbered,
    _repo_config_dir,
    _resolve_structured_output_format,
    _resolve_ai_mode,
    _run_claude_capture,
    _run_direct_api_capture,
    _spinner,
    _write_output_file,
    _json_text,
    _csv_text,
)


# ---------------------------------------------------------------------------
# create folder
# ---------------------------------------------------------------------------


@folder_group.command("create")
@click.option("--name", "-n", required=True, help="Folder name to create.")
@click.option("--parent-name", default="", help="Optional parent folder name (resolved to ID).")
def create_folder(name: str, parent_name: str) -> None:
    """Create a testcase folder (testcase group)."""
    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)

    async def _create() -> dict:
        from maeris_mcp.maeris.client import MaerisClient

        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        async with MaerisClient() as client:
            resolved_parent_id = None
            resolved_parent_name = None
            if parent_name:
                groups = await client.get_testcase_groups()
                match = next(
                    (g for g in groups if (g.get("name") or "").lower() == parent_name.lower()),
                    None,
                )
                if not match:
                    raise RuntimeError(f"Parent folder not found: {parent_name}")
                resolved_parent_name = match.get("name") or parent_name
                resolved_parent_id = (
                    match.get("testcase_group_id") or match.get("id") or match.get("_id") or None
                )
            group_id = await client.create_testcase_group(name, parent_id=resolved_parent_id)
            return {"folderId": group_id, "name": name, "parentName": resolved_parent_name}

    try:
        with _spinner("Creating folder…"):

            result = asyncio.run(_create())
    except Exception as e:
        click.secho(f"✗ Failed to create folder: {e}", fg="red")
        sys.exit(1)

    click.secho("✓ Folder created.", fg="green")
    if _is_tty():
        _print_record(result)
        _next_steps(
            "maeris test create --url <app-url>   Generate tests into this folder",
            "maeris folder list                   View all folders",
        )
    else:
        _print_json(result)


# ---------------------------------------------------------------------------
# create tests / test (AI-driven Playwright generation)
# ---------------------------------------------------------------------------


_AUTOMATION_SYSTEM_PROMPT = (
    "You are running in a fully automated non-interactive CLI session. "
    "Follow the user prompt exactly. "
    "Only call the MCP tools explicitly requested in the prompt. "
    "Do not run shell commands. Do not modify files unless the prompt explicitly instructs it. "
    "Do not push data to Maeris or delete resources unless explicitly instructed. "
    "If a tool response contains a pendingDecision, do not proceed with optional follow-up actions."
)


def _latest_session_file(prefix: str) -> "Path | None":
    config_dir = Path(os.getenv("MAERIS_CONFIG_DIR") or (Path.home() / ".maeris"))
    sessions_dir = config_dir / "sessions"
    if not sessions_dir.exists():
        return None
    files = sorted(
        sessions_dir.glob(f"{prefix}_*.json"),
        key=lambda f: f.stat().st_mtime,
        reverse=True,
    )
    return files[0] if files else None


def _latest_gen_session_id() -> str | None:
    """Return the session_id of the most recent gen session that has test_cases.

    Falls back to the most recent session (with or without test_cases) if none
    have test_cases, so callers can still surface the right error message.
    """
    config_dir = Path(os.getenv("MAERIS_CONFIG_DIR") or (Path.home() / ".maeris"))
    sessions_dir = config_dir / "sessions"
    if not sessions_dir.exists():
        return None
    files = sorted(
        sessions_dir.glob("gen_*.json"),
        key=lambda f: f.stat().st_mtime,
        reverse=True,
    )
    # Prefer the most recent session that actually has test_cases
    for f in files:
        try:
            import json as _json
            data = _json.loads(f.read_text(encoding="utf-8"))
            if data.get("test_cases"):
                return f.stem[4:]  # strip "gen_" prefix
        except Exception:
            continue
    # Fall back to most recent file (caller will get "no test cases" error)
    return files[0].stem[4:] if files else None


def _flow_matches(requested: str, actual: str) -> bool:
    """Heuristic match for flow descriptions (token overlap)."""
    if not requested or not actual:
        return False
    req_tokens = {t for t in re.findall(r"\w+", requested.lower()) if len(t) > 2}
    act_tokens = {t for t in re.findall(r"\w+", actual.lower()) if len(t) > 2}
    if not req_tokens or not act_tokens:
        return False
    overlap = req_tokens & act_tokens
    return (len(overlap) / len(req_tokens)) >= 0.5


def _find_recent_gen_session_id(flow_description: str, started_at: float) -> str | None:
    """Find the most recent gen session for this flow created after started_at."""
    config_dir = Path(os.getenv("MAERIS_CONFIG_DIR") or (Path.home() / ".maeris"))
    sessions_dir = config_dir / "sessions"
    if not sessions_dir.exists():
        return None
    candidates = []
    for f in sessions_dir.glob("gen_*.json"):
        try:
            data = json.loads(f.read_text(encoding="utf-8"))
        except Exception:
            data = {}
        flow = (data.get("flow_description") or "").strip()
        created_at = data.get("created_at")
        ts = None
        if isinstance(created_at, str) and created_at:
            try:
                dt = datetime.fromisoformat(created_at)
                if dt.tzinfo is None:
                    dt = dt.replace(tzinfo=timezone.utc)
                ts = dt.timestamp()
            except Exception:
                ts = None
        if ts is None:
            try:
                ts = f.stat().st_mtime
            except Exception:
                ts = 0
        if ts < started_at - 2:
            continue
        if _flow_matches(flow_description, flow):
            candidates.append((ts, f))
    if not candidates:
        return None
    candidates.sort(key=lambda x: x[0], reverse=True)
    f = candidates[0][1]
    return f.stem[4:]


def _latest_exec_session_id() -> str | None:
    f = _latest_session_file("exec")
    # exec_session_id encoded in filename: exec_<exec_session_id>.json
    return f.stem[5:] if f else None  # strip "exec_" prefix


from ._claude_helpers import ensure_mcp_json as _ensure_mcp_json


def _claude_run(claude_bin: str, prompt: str, model: str, cwd: str,
                allowed_tools: list[str] | None = None,
                timeout: int = 3600) -> int:
    import subprocess, sys, threading
    from rich.console import Console

    interactive = sys.stderr.isatty()

    if interactive:
        # Terminal — capture stdout, show spinner, print output after
        console = Console(stderr=True)
        cmd = [
            claude_bin, "-p", prompt,
            "--model", model,
            "--append-system-prompt", _AUTOMATION_SYSTEM_PROMPT,
        ]
        if allowed_tools:
            for tool in allowed_tools:
                cmd.extend(["--allowedTools", tool])
        process = subprocess.Popen(
            cmd,
            cwd=cwd,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            encoding="utf-8",
            errors="replace",
        )

        stdout_lines: list[str] = []
        stderr_lines: list[str] = []

        def _drain(stream, buf):
            for line in stream:
                buf.append(line)

        t_out = threading.Thread(target=_drain, args=(process.stdout, stdout_lines), daemon=True)
        t_err = threading.Thread(target=_drain, args=(process.stderr, stderr_lines), daemon=True)
        t_out.start()
        t_err.start()

        with console.status("[bold cyan]Generating tests…", spinner="dots"):
            try:
                process.wait(timeout=timeout)
            except subprocess.TimeoutExpired:
                process.kill()
                process.wait()
                click.secho(
                    f"\n✗ Test generation timed out after {timeout // 60} minutes. "
                    "The repo may be too large to scan. "
                    "Try targeting a specific sub-directory with --target.",
                    fg="red", err=True,
                )
                return 1
            t_out.join(timeout=5)
            t_err.join(timeout=5)

        output = "".join(stdout_lines).strip()
        if output:
            click.echo(output)

        return process.returncode

    # Non-interactive (listener) — use shared streaming helper
    from ._claude_helpers import run_claude_streaming
    rc, _result_text = run_claude_streaming(
        claude_bin, prompt, model, cwd,
        system_prompt=_AUTOMATION_SYSTEM_PROMPT,
    )
    return rc


def _claude_run_direct_api(prompt: str, cwd: str) -> int:
    """Drop-in replacement for _claude_run when claude_bin is None.

    Full agentic loop via Maeris AI proxy (MCP tools available).
    Returns exit code (0 = success).
    """
    import threading
    from uuid import uuid4
    from ._direct_api import execute_via_direct_api

    async def _run() -> int:
        from maeris_mcp.maeris.client import MaerisClient
        cancel_event = threading.Event()

        async with MaerisClient() as client:
            success, _result = await execute_via_direct_api(
                client=client,
                cmd_id=str(uuid4()),
                user_message=prompt,
                cwd=cwd,
                system_prompt=_AUTOMATION_SYSTEM_PROMPT,
                on_progress=lambda line: click.echo(f"  {line}", err=True),
                cancel_event=cancel_event,
            )
        return 0 if success else 1

    try:
        with _spinner("Generating tests…"):
            return asyncio.run(_run())
    except Exception as exc:
        click.secho(f"  ✗ Maeris AI error: {exc}", fg="red", err=True)
        return 1


def _run_create_tests(
    flow_description: str | None,
    target: str | None,
    folder: str | None,
    run_fix: bool,
    push: bool,
    model: str,
    auth_mode: str,
    auth_email: str | None,
    auth_password: str | None,
    base_url: str | None = None,
    timeout: int = 3600,
) -> None:
    interactive = sys.stdin.isatty()

    # Check authentication before doing anything else
    from maeris_mcp.auth.token_store import TokenStore
    token_store = TokenStore()
    if not token_store.is_authenticated():
        click.secho(
            "✗ You are not logged in. Run 'maeris auth login' to authenticate first.",
            fg="red", err=True,
        )
        sys.exit(1)

    if not flow_description:
        if not interactive:
            click.secho("✗ Please specify what you want to test (e.g. 'login flow', 'signup').", fg="red", err=True)
            sys.exit(1)
        flow_description = click.prompt("What flow do you want to test?")

    target_path = target or os.getcwd()

    try:
        claude_bin, _ai_mode = _resolve_ai_mode()
    except SystemExit:
        raise
    if claude_bin is None and _ai_mode not in ("maeris",):
        # No AI available (not configured, claude not on PATH)
        click.secho(
            "✗ No AI backend available. Install Claude Code from https://claude.ai/code "
            "or set up Maeris AI with 'maeris app init'.",
            fg="red", err=True,
        )
        sys.exit(1)

    # Ensure .mcp.json exists in target so Claude Code finds the MCP server
    mcp_was_new, mcp_original = _ensure_mcp_json(target_path)

    # -- Base URL (starting point for the test) --
    env_base_url = os.environ.get("MAERIS_TEST_BASE_URL")
    resolved_base_url: str | None = base_url or env_base_url
    if not resolved_base_url and interactive:
        resolved_base_url = click.prompt(
            "App URL (starting point for tests, e.g. http://localhost:3000)",
            default="",
        ).strip() or None

    # -- Credential handling (before subprocess: CLI cannot accept input mid-run) --
    env_email = os.environ.get("MAERIS_TEST_EMAIL")
    env_password = os.environ.get("MAERIS_TEST_PASSWORD")
    test_email: str | None = auth_email or env_email
    test_password: str | None = auth_password or env_password

    if auth_mode == "none":
        needs_auth = False
    elif auth_mode == "required":
        needs_auth = True
        if not test_email or not test_password:
            click.secho(
                "✗ Auth required but credentials missing. Provide both "
                "--auth-email and --auth-password (or set MAERIS_TEST_EMAIL / "
                "MAERIS_TEST_PASSWORD).",
                fg="red",
            )
            sys.exit(1)
    else:
        # auth_mode == "prompt"
        if test_email and test_password:
            needs_auth = True
        else:
            needs_auth = click.confirm(
                "Does this flow require login/authentication?",
                default=False,
            )
            if needs_auth:
                test_email = test_email or click.prompt("  Login email")
                test_password = test_password or click.prompt("  Login password", hide_input=True)
                if not test_password or not test_password.strip():
                    raise click.UsageError("Password cannot be empty.")

    if needs_auth:
        cred_header = (
            "IMPORTANT: The user has already provided login credentials for this flow.\n"
            "DO NOT ask the user for credentials. DO NOT wait for the user.\n"
            f"Credentials to use: email={repr(test_email)}, password={repr(test_password)}\n"
            "Include test_email and test_password in your FIRST generate_tests call (alongside flow_description).\n"
            "SECURITY: Do NOT hardcode credentials into generated test steps.\n"
            "In test steps use mock_input='$MAERIS_TEST_EMAIL' for email and '$MAERIS_TEST_PASSWORD' for password.\n\n"
        )
    else:
        cred_header = (
            "IMPORTANT: This flow does NOT require authentication.\n"
            "DO NOT add any login steps to the tests.\n"
            "DO NOT ask the user for credentials.\n\n"
        )

    # ── Scope check ──────────────────────────────────────────────────────────
    from maeris_mcp.auth.scopes import load_maeris_scope, is_maeris_tool_allowed, TOOL_SCOPES

    scope = load_maeris_scope()
    if not is_maeris_tool_allowed("generate_tests", scope):
        required = TOOL_SCOPES.get("generate_tests", "write")
        click.secho(
            f"\n✗ Your current Maeris scope is '{scope}', "
            f"but test generation requires '{required}' scope.\n",
            fg="red", err=True,
        )
        click.echo(
            f"  Run this to fix it:\n"
            f"    maeris app scope --maeris {required}\n",
            err=True,
        )
        sys.exit(1)

    # ── Phase 1: Generate ────────────────────────────────────────────────────
    click.echo(f"\nGenerating tests for: {flow_description}", err=True)
    click.echo(f"Target: {target_path}\n", err=True)
    gen_started_at = time.time()

    if run_fix:
        post_action_note = (
            "After generation the tests will be run and validated locally,"
            " then pushed to Maeris. The user has already chosen this action.\n"
        )
    elif push:
        post_action_note = (
            "After generation the tests will be pushed directly to Maeris."
            " The user has already chosen this action.\n"
        )
    else:
        post_action_note = (
            "After generation the user will decide what to do with the tests.\n"
        )

    base_url_line = (
        f"  base_url = {repr(resolved_base_url)}\n" if resolved_base_url else ""
    )
    base_url_note = (
        f"The app under test starts at: {resolved_base_url}\n"
        "Use this as the entry point (page.goto) for all generated tests.\n"
        "DO NOT ask the user for the URL — it has already been provided.\n\n"
        if resolved_base_url else
        "No base URL was provided. Infer the app entry point from the codebase "
        "(e.g. package.json scripts, next.config.js, vite.config.js, or env files).\n\n"
    )
    cred_params_line = (
        f"  test_email = {repr(test_email)}\n"
        f"  test_password = {repr(test_password)}\n"
        if needs_auth and test_email and test_password else ""
    )
    gen_prompt = (
        f"TASK: Generate Playwright tests SPECIFICALLY for the flow '{flow_description}' in '{target_path}'.\n\n"
        f"The tests you generate MUST be about: {repr(flow_description)}\n"
        f"Do NOT generate tests for unrelated flows (e.g. do not generate login-only tests unless login is the requested flow).\n\n"
        f"Call the generate_tests MCP tool RIGHT NOW with these exact parameters:\n"
        f"  flow_description = {repr(flow_description)}\n"
        f"  target_path = {repr(target_path)}\n"
        f"  include_content = true\n"
        + base_url_line
        + cred_params_line
        + "\n"
        + base_url_note
        + cred_header
        + "Do NOT ask questions. Do NOT present a menu. Do NOT wait for confirmation.\n"
        + "Do NOT ask for credentials — they have already been provided above.\n"
        + "Follow these steps immediately and in order:\n"
        + "STEP 1: Call generate_tests (flow_description, target_path, include_content=true) to start scanning.\n"
        + "STEP 2: Keep calling generate_tests with session_id + scan_notes until pagination.hasMore is false. Submit scan notes for every batch.\n"
        + "STEP 3 (MANDATORY): After hasMore=false, call generate_tests with session_id + test_plan={discovered_flows:[...]}.\n"
        + "  - Each discovered flow MUST be scoped to the requested flow description.\n"
        + "  - Do NOT include flows unrelated to: " + repr(flow_description) + "\n"
        + "  - Include entry_point, prerequisites, user_journey_steps, test_cases_planned, key_selectors, assertions_planned, confidence, risk_factors.\n"
        + "  - Wait for plan approval before writing any test code.\n"
        + "STEP 4: After plan approval, call generate_tests with session_id + test_cases=[...] + is_last=true.\n"
        + "  - Each test_case must have: name (string) and steps (array of Mirabilis component step dicts).\n"
        + "  - Do NOT submit playwright_code. Do NOT write Python code. Submit structured steps only.\n"
        + "  - action_taken MUST be one of: go_to | click | send_keys | hover | check | click_select | set_range\n"
        + "    go_to     = navigate to URL (mock_input = full URL)\n"
        + "    send_keys = type into an input (mock_input = text value)\n"
        + "    click     = click a button, link, or nav item\n"
        + "  - Assertions use assertion_nature (existence|page|input) + assertion_type + optional assertion_value — NOT action_taken.\n"
        + "  - NEVER use: navigate, fill, wait_for_selector, assert_visible, assert_url_not_contains, assert_not_visible as action_taken.\n"
        + "  - Tests must cover the requested flow, not generic login tests.\n"
        + post_action_note
        + "When done: output the following block, then STOP with no other output:\n"
        + "For each test case generated, output one line: MAERIS_TEST: <test case name>\n"
        + "Then on the last line: MAERIS_SESSION_ID=<the sessionId field from the generate_tests tool response>\n"
    )
    def _cleanup_mcp_json():
        from ._claude_helpers import cleanup_mcp_json
        cleanup_mcp_json(target_path, mcp_was_new, mcp_original)

    def _run_ai(prompt_text: str) -> int:
        if claude_bin:
            return _claude_run(claude_bin, prompt_text, model, target_path,
                               allowed_tools=["mcp__maeris__generate_tests",
                                               "mcp__maeris__run_and_fix_tests"],
                               timeout=timeout)
        return _claude_run_direct_api(prompt_text, target_path)

    try:
        rc = _run_ai(gen_prompt)
        if rc != 0:
            click.secho("✗ Test generation failed. Please try again.", fg="red", err=True)
            sys.exit(1)

        session_id = _find_recent_gen_session_id(flow_description, gen_started_at)
        if not session_id:
            session_id = _latest_gen_session_id()
        if not session_id:
            click.secho("✗ Test generation did not produce any results. Please try again.", fg="red", err=True)
            sys.exit(1)

        # Guard: ensure the session flow matches the requested flow
        try:
            config_dir = Path(os.getenv("MAERIS_CONFIG_DIR") or (Path.home() / ".maeris"))
            session_path = config_dir / "sessions" / f"gen_{session_id}.json"
            if session_path.exists():
                data = json.loads(session_path.read_text(encoding="utf-8"))
                flow = (data.get("flow_description") or "").strip()
                if not _flow_matches(flow_description, flow):
                    click.secho(
                        "✗ Generated tests do not match the requested flow. "
                        "The AI likely reused a previous session. Please re-run.",
                        fg="red", err=True,
                    )
                    sys.exit(1)
        except Exception:
            pass

        # Verify the session actually has test cases before claiming success
        try:
            import json as _json
            _config_dir = Path(os.getenv("MAERIS_CONFIG_DIR") or (Path.home() / ".maeris"))
            _session_file = _config_dir / "sessions" / f"gen_{session_id}.json"
            _session_data = _json.loads(_session_file.read_text(encoding="utf-8"))
            if not _session_data.get("test_cases"):
                click.secho(
                    "✗ Test generation completed but produced no test cases. "
                    "The MCP server may not have been connected. Please try again.",
                    fg="red", err=True,
                )
                sys.exit(1)
        except Exception:
            pass  # If we can't read the file, proceed and let push surface any error

        click.echo("✓ Tests generated successfully.", err=True)

        # ── Non-interactive: auto-run & fix, then push if folder given ────────────
        if not interactive:
            if run_fix:
                click.echo("\nRunning and auto-fixing tests…\n", err=True)
                run_prompt = (
                    f"Run and auto-fix Playwright tests. session_id={session_id}\n\n"
                    "Use the run_and_fix_tests MCP tool:\n"
                    "- Call with confirm_run=true and session_id to start.\n"
                    "- For each failing test: analyze logs and currentCode, then provide fixed_code.\n"
                    "- Keep calling with exec_session_id until status is 'completed'.\n"
                    "STOP when status is 'completed'. Do NOT call push_tests_to_maeris."
                )
                run_rc = _run_ai(run_prompt)
                if run_rc != 0:
                    click.secho("✗ Running tests failed. Please try again.", fg="red", err=True)
                    sys.exit(1)
                exec_sid = _latest_exec_session_id()
                click.echo("✓ Tests run and fixed successfully.", err=True)
                if folder:
                    _push_phase(claude_bin, session_id, exec_sid, folder, model, target_path)
            elif folder:
                _push_phase(claude_bin, session_id, None, folder, model, target_path)
            return

        # ── --no-run: skip menu, push directly ───────────────────────────────────
        if push and not run_fix:
            if not folder:
                folder = click.prompt("\nWhich Maeris test folder should tests be pushed to?")
            _push_phase(claude_bin, session_id, None, folder, model, target_path)
            return

        # ── Decision menu ─────────────────────────────────────────────────────────
        click.echo("\n" + "─" * 50, err=True)
        click.echo("What would you like to do next?", err=True)
        click.echo("  1) Run & auto-fix tests locally", err=True)
        click.echo("  2) Push tests directly to Maeris", err=True)
        choice = click.prompt("Choice", type=click.Choice(["1", "2"]), show_choices=False)

        exec_session_id: str | None = None

        if choice == "1":
            # ── Phase 2A: Run & fix ───────────────────────────────────────────────
            click.echo("\nRunning and auto-fixing tests…\n", err=True)
            run_prompt = (
                f"Run and auto-fix Playwright tests. session_id={session_id}\n\n"
                "Use the run_and_fix_tests MCP tool:\n"
                "- Call with confirm_run=true and session_id to start.\n"
                "- For each failing test: analyze logs and currentCode, then provide fixed_code.\n"
                "- Keep calling with exec_session_id until status is 'completed'.\n"
                "STOP when status is 'completed'. Do NOT call push_tests_to_maeris."
            )
            rc = _run_ai(run_prompt)
            if rc != 0:
                click.secho("✗ Running tests failed. Please try again.", fg="red", err=True)
                sys.exit(1)
            else:
                exec_session_id = _latest_exec_session_id()

            if click.confirm("\nPush tests to Maeris now?", default=True):
                if not folder:
                    folder = click.prompt("Which Maeris test folder should tests be pushed to?")
                _push_phase(claude_bin, session_id, exec_session_id, folder, model, target_path)
        else:
            # ── Phase 2B: Push directly ───────────────────────────────────────────
            if not folder:
                folder = click.prompt("\nWhich Maeris test folder should tests be pushed to?")
            _push_phase(claude_bin, session_id, None, folder, model, target_path)
    finally:
        _cleanup_mcp_json()


def _push_phase(
    claude_bin: "str | None",
    session_id: str,
    exec_session_id: "str | None",
    folder: str,
    model: str,
    cwd: str,
) -> None:
    click.echo("\nPushing tests to Maeris...\n", err=True)

    async def _do_push() -> list[dict]:
        from maeris_mcp.storage.test_store import TestGenerationStore
        from maeris_mcp.storage.execution_store import TestExecutionStore
        from maeris_mcp.tools.test_push import handle_push_tests_to_maeris

        test_store = TestGenerationStore()
        exec_store = TestExecutionStore()
        args = {
            "session_id": session_id,
            "test_folder": folder,
        }
        if exec_session_id:
            args["exec_session_id"] = exec_session_id
        return await handle_push_tests_to_maeris(test_store, exec_store, args)

    try:
        with _spinner("Pushing tests..."):
            results = asyncio.run(_do_push())
    except Exception as e:
        click.secho(f"Error: Pushing tests to Maeris failed: {e}", fg="red", err=True)
        sys.exit(1)

    if not results:
        click.secho("Error: Pushing tests to Maeris failed: empty response.", fg="red", err=True)
        sys.exit(1)

    text = results[0].text if hasattr(results[0], "text") else str(results[0])
    if text.strip().lower().startswith("error:"):
        click.secho(text, fg="red", err=True)
        sys.exit(1)

    # Pretty-print the push result instead of dumping raw JSON
    try:
        import json as _json
        data = _json.loads(text)
        maeris = data.get("maerisResponse", {})
        created = maeris.get("created_tests", [])
        total = data.get("testsPushed") or maeris.get("total_count") or len(created)
        parse_errors = data.get("parseErrors", 0)
        skipped = data.get("skipped", 0) + data.get("qualitySkipped", 0)

        click.echo("")
        click.secho(f"✓ {total} test case{'s' if total != 1 else ''} pushed to Maeris", fg="green")
        if parse_errors:
            click.secho(f"  ⚠ {parse_errors} test(s) skipped due to parse errors", fg="yellow")
        if skipped:
            click.secho(f"  ⚠ {skipped} test(s) skipped due to quality checks", fg="yellow")

        if created:
            click.echo("")
            click.echo("  Test cases:")
            for tc in created:
                name = tc.get("name", "(unnamed)")
                kind = tc.get("test_type", "")
                status_label = click.style("✓ created", fg="green")
                kind_label = f"  [{kind}]" if kind else ""
                click.echo(f"    {status_label}  {name}{kind_label}")

        # Surface execution results if present
        results_list = maeris.get("added_test_results", [])
        if results_list:
            click.echo("")
            click.echo("  Execution results:")
            for r in results_list:
                tc_id = r.get("testcase_id", "")
                status = r.get("status", "unknown")
                tc_name = next(
                    (tc.get("name", tc_id) for tc in created if tc.get("id") == tc_id),
                    tc_id,
                )
                if status == "pass":
                    status_str = click.style("pass", fg="green")
                elif status == "fail":
                    status_str = click.style("fail", fg="red")
                else:
                    status_str = status
                click.echo(f"    {status_str}  {tc_name}")

        click.echo("")
    except (_json.JSONDecodeError, AttributeError, TypeError):
        # Fallback: print as-is if parsing fails
        click.echo(text)


@test_group.command("generate")
@click.argument("flow_description", required=False, default=None)
@click.option("--target", "-t", default=None, help="Directory to scan (defaults to cwd).")
@click.option("--folder", "-f", default=None, help="Maeris test folder to push tests into.")
@click.option("--run-fix", "-R", "run_fix", is_flag=True, default=False,
              help="Run & validate tests locally before pushing to Maeris.")
@click.option("--push", "-P", "push", is_flag=True, default=False,
              help="Skip local run; push generated tests directly to Maeris.")
@click.option("--no-run", "-N", "no_run", is_flag=True, default=False, hidden=True,
              help="Alias for --push-only (deprecated).")
@click.option(
    "--model", "-m",
    default="claude-sonnet-4-6",
    show_default=True,
    help="AI model to use.",
)
@click.option(
    "--auth",
    "auth_mode",
    type=click.Choice(["prompt", "required", "none"]),
    default="prompt",
    show_default=True,
    help="Auth handling: prompt (ask), required (no prompt), none (no login).",
)
@click.option("--auth-email", default=None, help="Login email for auth-required flows.")
@click.option("--auth-password", default=None, help="Login password for auth-required flows.")
@click.option("--base-url", "base_url", default=None, help="Base URL of the app under test (e.g. http://localhost:3000).")
@click.option("--timeout", "timeout", default=3600, show_default=True, type=int, help="Maximum seconds to wait for test generation.")
def create_tests(
    flow_description: str | None,
    target: str | None,
    folder: str | None,
    run_fix: bool,
    push: bool,
    no_run: bool,
    model: str,
    auth_mode: str,
    auth_email: str | None,
    auth_password: str | None,
    base_url: str | None,
    timeout: int,
) -> None:
    """Generate Playwright tests using AI (requires AI CLI).

    If you don't pass an action flag, you'll be prompted after generation:
      --run-fix    Run & auto-fix tests locally, then push to Maeris.
      --push       Skip local run; push generated tests directly to Maeris.
    """
    push = push or no_run
    _run_create_tests(
        flow_description,
        target,
        folder,
        run_fix,
        push,
        model,
        auth_mode,
        auth_email,
        auth_password,
        base_url,
        timeout=timeout,
    )


# ---------------------------------------------------------------------------
# _delete_folder helper (used by delete folder/folders commands)
# ---------------------------------------------------------------------------


def _delete_folder(
    confirm: bool,
    name_filter: str = "",
    name_prefix: str = "",
    parent_name: str = "",
    parent_name_prefix: str = "",
    delete_all: bool = False,
) -> None:
    """Delete a testcase folder (testcase group)."""
    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)

    async def _fetch_groups() -> 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_testcase_groups()

    try:
        with _spinner("Fetching folders…"):

            groups = asyncio.run(_fetch_groups())
    except Exception as e:
        click.secho(f"✗ Failed to fetch folders: {e}", fg="red")
        sys.exit(1)

    if not groups:
        click.echo("No folders found.")
        return

    def _gname(g: dict) -> str:
        return g.get("testcase_group_name") or g.get("name") or ""

    def _gid(g: dict) -> str:
        return g.get("testcase_group_id") or g.get("id") or g.get("_id") or ""

    def _gparent(g: dict) -> str:
        return g.get("testcase_group_parent_id") or g.get("parent_id") or g.get("parentId") or ""

    # Non-interactive flag-driven selection
    selected_groups: list[dict] = []
    if delete_all:
        selected_groups = groups
    elif name_filter:
        needle = name_filter.strip().lower()
        selected_groups = [g for g in groups if needle in _gname(g).lower()]
        if not selected_groups:
            click.secho(f"✗ No folders found with name containing '{name_filter}'.", fg="yellow")
            sys.exit(1)
    elif name_prefix:
        needle = name_prefix.strip().lower()
        selected_groups = [g for g in groups if _gname(g).lower().startswith(needle)]
        if not selected_groups:
            click.secho(f"✗ No folders found with name starting with '{name_prefix}'.", fg="yellow")
            sys.exit(1)
    elif parent_name:
        needle = parent_name.strip().lower()
        parents = [g for g in groups if _gname(g).lower() == needle]
        if not parents:
            click.secho(f"✗ Parent folder not found: '{parent_name}'.", fg="yellow")
            sys.exit(1)
        parent_ids = {_gid(p) for p in parents}
        selected_groups = [g for g in groups if _gparent(g) in parent_ids]
        if not selected_groups:
            click.secho(f"✗ No child folders found under parent '{parent_name}'.", fg="yellow")
            sys.exit(1)
    elif parent_name_prefix:
        needle = parent_name_prefix.strip().lower()
        parents = [g for g in groups if _gname(g).lower().startswith(needle)]
        if not parents:
            click.secho(f"✗ No parent folders found with name starting with '{parent_name_prefix}'.", fg="yellow")
            sys.exit(1)
        parent_ids = {_gid(p) for p in parents}
        selected_groups = [g for g in groups if _gparent(g) in parent_ids]
        if not selected_groups:
            click.secho("✗ No child folders found under matching parents.", fg="yellow")
            sys.exit(1)
    elif _is_tty():
        FILTER_OPTIONS = [
            "By name   (search by name prefix)",
            "By list   (select one or more folders)",
            "All",
        ]
        click.echo("\nHow would you like to select folders to delete?")
        (filter_idx,) = _prompt_numbered(FILTER_OPTIONS, "Select filter", multi=False)

        if filter_idx == 0:
            try:
                needle = click.prompt("Search name (prefix)").strip().lower()
            except click.Abort:
                click.echo("\nAborted.")
                sys.exit(0)
            selected_groups = [g for g in groups if _gname(g).lower().startswith(needle)]
            if not selected_groups:
                click.secho("✗ No folders match that prefix.", fg="yellow")
                sys.exit(1)
        elif filter_idx == 1:
            idxs = _prompt_numbered(
                [_gname(g) or _gid(g) for g in groups],
                "Select",
                multi=True,
            )
            if not idxs:
                click.secho("✗ No valid selection.", fg="red")
                sys.exit(1)
            selected_groups = [groups[i] for i in idxs]
        else:
            selected_groups = groups
    else:
        selected_groups = groups
    ids_to_delete: list[str] = []
    names_to_delete: list[str] = []
    for selected in selected_groups:
        group_id = selected.get("testcase_group_id") or selected.get("id") or selected.get("_id")
        group_name = selected.get("testcase_group_name") or selected.get("name") or group_id
        if not group_id:
            click.secho("✗ Selected folder has no ID.", fg="red")
            sys.exit(1)
        ids_to_delete.append(group_id)
        names_to_delete.append(group_name or group_id)

    # Expand selection to include child folders of any selected parent folders
    parent_to_children: dict[str, list[dict]] = {}
    for g in groups:
        parent_id = _gparent(g)
        if parent_id:
            parent_to_children.setdefault(parent_id, []).append(g)

    child_ids: list[str] = []
    child_names: list[str] = []
    for pid in list(ids_to_delete):
        for child in parent_to_children.get(pid, []):
            cid = _gid(child)
            if cid and cid not in ids_to_delete and cid not in child_ids:
                child_ids.append(cid)
                child_names.append(_gname(child) or cid)

    if child_ids:
        ids_to_delete.extend(child_ids)
        names_to_delete.extend(child_names)

    if not confirm:
        click.echo(f"\nThe following {len(ids_to_delete)} folder(s) will be deleted:")
        for name in names_to_delete:
            click.echo(f"  - {name}")
        if child_ids:
            click.echo("\nNote: Deleting a parent folder also deletes its child folders.")
        click.echo()
        confirmed = click.confirm("Are you sure you want to delete these folders?", default=False)
        if not confirmed:
            click.echo("Aborted.")
            return

    async def _delete() -> dict:
        from maeris_mcp.maeris.client import MaerisClient

        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        async with MaerisClient() as client:
            results: list[dict] = []
            for gid in ids_to_delete:
                results.append(await client.delete_testcase_group(gid))
            return {"deleted": results}

    try:
        with _spinner("Deleting folder…"):

            asyncio.run(_delete())
    except Exception as e:
        click.secho(f"✗ Failed to delete folder: {e}", fg="red")
        sys.exit(1)

    click.secho(f"✓ Deleted {len(ids_to_delete)} folder(s).", fg="green")
    if _is_tty():
        for name in names_to_delete:
            click.echo(f"  - {name}")
    else:
        _print_json({"deleted": len(ids_to_delete), "names": names_to_delete})


# ---------------------------------------------------------------------------
# import tests
# ---------------------------------------------------------------------------


@test_group.command("import")
@click.argument("paths", nargs=-1, type=click.Path(exists=True, path_type=str))
@click.option(
    "--framework", "-F",
    type=click.Choice(["auto", "playwright", "cypress", "selenium"]),
    default="auto",
    show_default=True,
    help="Framework to parse (auto = try all).",
)
@click.option("--base-url", "-u", default="", help="Base URL to expand relative navigation paths.")
@click.option("--test-folder", default="", help="Target test folder/group in Maeris.")
@click.option("--label", "-l", "labels", multiple=True, help="Label to attach to imported tests (repeatable).")
@click.option("--page-name", default="", help="Optional page name override for steps.")
def import_tests(
    paths: tuple[str, ...],
    framework: str,
    base_url: str,
    test_folder: str,
    labels: tuple[str, ...],
    page_name: str,
) -> None:
    """Import Playwright/Cypress/Selenium tests as manual testcases."""
    from maeris_mcp.importers.test_importer import (
        build_manual_steps_with_verifications,
        discover_test_files,
        diagnose_no_tests,
        parse_test_file,
    )
    from maeris_mcp.importers.ai_importer import extract_with_ai
    from maeris_mcp.maeris.client import MaerisClient

    selected_paths = list(paths)
    if not selected_paths:
        directories = sorted([p for p in Path.cwd().iterdir() if p.is_dir()])
        if not directories:
            click.secho("✗ No folders available in the current directory to import.", fg="red")
            sys.exit(1)

        click.echo("Select folder(s) to import tests from:")
        for idx, folder in enumerate(directories, 1):
            click.echo(f"  {idx}) {folder.name}")

        choice = click.prompt(
            "Enter comma-separated numbers (e.g. 1,3) or 'all'",
        ).strip()

        if choice.lower() == "all":
            selected_paths = [str(folder) for folder in directories]
        else:
            indices: list[int] = []
            for part in choice.split(","):
                part = part.strip()
                if not part:
                    continue
                if not part.isdigit():
                    click.secho(f"✗ Invalid selection '{part}'.", fg="red")
                    sys.exit(1)
                indices.append(int(part) - 1)
            invalid = [i for i in indices if i < 0 or i >= len(directories)]
            if invalid:
                click.secho("✗ Selection out of range.", fg="red")
                sys.exit(1)
            selected_paths = [str(directories[i]) for i in indices]

    paths = tuple(selected_paths)

    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)

    files = discover_test_files(paths)
    if not files:
        click.secho("✗ No test files found in the provided paths.", fg="red")
        sys.exit(1)

    imported = []
    for file in files:
        cases = parse_test_file(file, framework=framework)
        if not cases:
            # Regex found nothing — try AI extraction as automatic fallback
            click.secho(f"  ⟳ Analysing {file.name} with AI...", fg="cyan")
            try:
                cases = extract_with_ai(file, base_url=base_url)
                if cases:
                    click.secho(
                        f"  ✦ AI extracted {len(cases)} test(s) from {file.name}",
                        fg="cyan",
                    )
            except Exception as exc:
                click.secho(f"  ! AI extraction failed for {file.name}: {exc}", fg="yellow")
        imported.extend(cases)

    if not imported:
        detail = diagnose_no_tests(files)
        click.secho("✗ No testcases could be parsed from the provided files.", fg="red")
        click.echo(f"  {detail}")
        sys.exit(1)

    selected_folder_id = False
    selected_folder_name: str | None = None
    if not test_folder:
        async def _fetch_groups() -> list[dict]:
            os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
            async with MaerisClient() as client:
                return await client.get_testcase_groups()

        try:
            with _spinner("Fetching folders…"):

                groups = asyncio.run(_fetch_groups())
        except Exception as e:
            click.secho(f"✗ Failed to fetch testcase folders: {e}", fg="red")
            sys.exit(1)

        if not groups:
            click.secho("✗ No testcase folders found. Please create a folder first or pass --test-folder.", fg="red")
            sys.exit(1)

        click.echo("Select a testcase folder:")
        for idx, group in enumerate(groups, 1):
            name = group.get("testcase_group_name") or group.get("name") or group.get("id") or ""
            click.echo(f"  {idx}) {name}")

        choice = click.prompt("Enter folder number").strip()
        if not choice.isdigit():
            click.secho("✗ Invalid selection.", fg="red")
            sys.exit(1)
        index = int(choice) - 1
        if index < 0 or index >= len(groups):
            click.secho("✗ Selection out of range.", fg="red")
            sys.exit(1)
        selected = groups[index]
        selected_folder_name = (
            selected.get("testcase_group_name")
            or selected.get("name")
            or selected.get("id")
            or ""
        )
        test_folder = (
            selected.get("testcase_group_id")
            or selected.get("id")
            or selected.get("_id")
            or ""
        )
        selected_folder_id = True
        if not test_folder:
            click.secho("✗ Selected folder has no ID.", fg="red")
            sys.exit(1)

    label_list = list(labels)
    page_name_override = page_name or None
    base_url_value = base_url or None

    payloads: list[dict] = []
    skipped = 0
    for tc in imported:
        steps = build_manual_steps_with_verifications(tc, base_url_value, page_name_override or tc.name)
        if not steps:
            skipped += 1
            continue
        payloads.append(
            {
                "name": tc.name,
                "description": tc.name,
                "testcase_type": "regression",
                "test_folder": test_folder,
                "labels": label_list,
                "browser": ["chrome"],
                "mark_as": "pass",
                "steps": steps,
            }
        )

    if not payloads:
        click.secho("✗ No importable steps were found in the parsed testcases.", fg="red")
        sys.exit(1)

    async def _push() -> dict:
        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        async with MaerisClient() as client:
            # Resolve or create folder
            resolved_group_id = test_folder
            resolved_group_name = selected_folder_name or test_folder
            if not selected_folder_id:
                groups = await client.get_testcase_groups()
                matched = next(
                    (g for g in groups if (g.get("name") or "").lower() == test_folder.lower()),
                    None,
                )
                if matched:
                    resolved_group_id = matched.get("testcase_group_id") or matched.get("id") or test_folder
                    resolved_group_name = matched.get("name") or test_folder
                else:
                    resolved_group_id = await client.create_testcase_group(test_folder)
                    resolved_group_name = test_folder

            for p in payloads:
                p["test_folder"] = resolved_group_id

            result = await client.create_manual_tests(payloads)
            if isinstance(result, dict):
                result["resolved_group_id"] = resolved_group_id
                result["resolved_group_name"] = resolved_group_name
            return result

    try:
        with _spinner("Pushing test to Maeris…"):

            result = asyncio.run(_push())
    except Exception as e:
        click.secho(f"✗ Failed to import testcases: {e}", fg="red")
        sys.exit(1)

    click.secho("✓ Imported testcases successfully.", fg="green")
    _next_steps(
        "maeris test list       View imported tests",
        "maeris test run        Execute test suite",
    )
    summary = {
        "filesScanned": len(files),
        "testcasesParsed": len(imported),
        "testcasesImported": len(payloads),
        "skipped": skipped,
        "testFolder": None,
    }
    if isinstance(result, dict):
        if result.get("message"):
            summary["message"] = result.get("message")
        if result.get("total_count") is not None:
            summary["totalCount"] = result.get("total_count")
        if result.get("resolved_group_name"):
            summary["testFolder"] = result.get("resolved_group_name")
        elif selected_folder_name:
            summary["testFolder"] = selected_folder_name
        else:
            summary["testFolder"] = test_folder
    if _is_tty():
        _print_record(summary)
    else:
        _print_json(summary)


# ---------------------------------------------------------------------------
# Helpers shared across list / run / delete
# ---------------------------------------------------------------------------


def _resolve_folder_to_group_id(folder_filter: str, config_dir: Path) -> str:
    """Fetch testcase groups and return the ID of the first one whose name
    contains *folder_filter* (case-insensitive substring match).

    Exits with an error message if no match is found.
    """
    async def _fetch_groups() -> 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_testcase_groups()

    try:
        with _spinner("Fetching folders…"):

            groups = asyncio.run(_fetch_groups())
    except Exception as e:
        click.secho(f"✗ Failed to fetch folders: {e}", fg="red")
        sys.exit(1)

    needle = folder_filter.strip().lower()
    matches = [
        g for g in groups
        if needle in (g.get("testcase_group_name") or g.get("name") or "").lower()
    ]

    if not matches:
        click.secho(f"✗ No folder matching '{folder_filter}' found.", fg="yellow")
        sys.exit(1)

    if len(matches) > 1:
        names = ", ".join(
            f'"{g.get("testcase_group_name") or g.get("name") or g.get("id")}"'
            for g in matches
        )
        click.secho(f"  Multiple folders match '{folder_filter}': {names}. Using the first.", fg="yellow")

    g = matches[0]
    return g.get("testcase_group_id") or g.get("id") or g.get("_id") or ""


def _normalize_test_result_status(status: str) -> str:
    """Normalize backend test-result status values for overview summaries."""
    normalized = (status or "").strip().lower()
    if normalized in {"pass", "passed", "success"}:
        return "passed"
    if normalized in {"fail", "failed", "error"}:
        return "failed"
    return ""


def _summarize_latest_test_results(
    testcases: list[dict],
    grouped_results: dict[str, list[dict]],
) -> dict[str, int | float]:
    """Summarize pass/fail state from the latest execution per testcase."""
    passed = 0
    failed = 0
    no_result = 0

    for testcase in testcases:
        testcase_id = _get_testcase_id(testcase)
        if not testcase_id:
            continue
        results = grouped_results.get(testcase_id, [])
        if not results:
            no_result += 1
            continue

        latest_execution_id = str(results[0].get("test_execution_id") or "")
        latest_execution_results = (
            [result for result in results if str(result.get("test_execution_id") or "") == latest_execution_id]
            if latest_execution_id
            else [results[0]]
        )
        statuses = {
            _normalize_test_result_status(str(result.get("status") or ""))
            for result in latest_execution_results
        }
        statuses.discard("")

        if "failed" in statuses:
            failed += 1
        elif "passed" in statuses:
            passed += 1
        else:
            no_result += 1

    total = len([tc for tc in testcases if _get_testcase_id(tc)])
    executed = passed + failed
    pass_rate = round((passed / executed) * 100, 1) if executed else 0.0
    fail_rate = round((failed / executed) * 100, 1) if executed else 0.0

    return {
        "total": total,
        "passed": passed,
        "failed": failed,
        "no_result": no_result,
        "executed": executed,
        "pass_rate": pass_rate,
        "fail_rate": fail_rate,
    }


@ui_group.command("overview")
@click.option("--smoke", is_flag=True, help="Only include smoke tests.")
@click.option("--regression", "-r", is_flag=True, help="Only include regression tests.")
@click.option("--folder", "-f", "folder_filter", default="", help="Only include tests from the given folder.")
@click.option("--csv", "output_csv", is_flag=True, help="Output as CSV.")
@click.option("--json", "output_json", is_flag=True, help="Output raw JSON.")
@click.option("--output", "output_path", default=None, help="Write output to a .json or .csv file.")
def ui_overview_cmd(
    smoke: bool,
    regression: bool,
    folder_filter: str,
    output_csv: bool,
    output_json: bool,
    output_path: str | None,
) -> None:
    """Show a UI test overview with latest pass/fail counts and percentages."""
    output_format = _resolve_structured_output_format(output_json, output_csv, output_path)

    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)

    group_id = _resolve_folder_to_group_id(folder_filter, config_dir) if folder_filter else ""

    async def _fetch_overview_data() -> tuple[list[dict], dict[str, list[dict]]]:
        from maeris_mcp.maeris.client import APIRequestError, MaerisClient

        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        async with MaerisClient() as client:
            testcases = await client.get_testcases()
            filtered = [
                testcase
                for testcase in testcases
                if (not group_id or testcase.get("testcase_group_id") == group_id)
            ]
            if smoke or regression:
                allowed_types: set[str] = set()
                if smoke:
                    allowed_types.add("smoke")
                if regression:
                    allowed_types.add("regression")
                filtered = [
                    testcase
                    for testcase in filtered
                    if str(testcase.get("test_type") or "").lower() in allowed_types
                ]

            testcase_ids = [_get_testcase_id(testcase) for testcase in filtered if _get_testcase_id(testcase)]
            if not testcase_ids:
                return filtered, {}
            try:
                grouped_results = await client.get_test_results_for_testcases(testcase_ids)
            except APIRequestError as exc:
                if exc.status_code != 404:
                    raise
                grouped_results = {}
            return filtered, grouped_results

    try:
        with _spinner("Building UI overview…"):
            testcases, grouped_results = asyncio.run(_fetch_overview_data())
    except Exception as e:
        click.secho(f"✗ Failed to build UI overview: {e}", fg="red")
        sys.exit(1)

    summary = _summarize_latest_test_results(testcases, grouped_results)
    filter_label = "all tests"
    if smoke and regression:
        filter_label = "smoke + regression tests"
    elif smoke:
        filter_label = "smoke tests"
    elif regression:
        filter_label = "regression tests"
    if folder_filter:
        filter_label = f"{filter_label} in folder '{folder_filter}'"

    payload: dict[str, object] = {
        "scope": filter_label,
        **summary,
    }
    csv_columns = [
        "scope",
        "total",
        "passed",
        "failed",
        "no_result",
        "executed",
        "pass_rate",
        "fail_rate",
    ]

    if output_format == "csv":
        content = _csv_text([payload], csv_columns)
        if output_path:
            written = _write_output_file(content, output_path)
            click.echo(f"Wrote CSV to {written}")
        else:
            _print_csv([payload], csv_columns)
        return

    if output_format == "json" or (output_format is None and not _is_tty()):
        if output_path:
            written = _write_output_file(_json_text(payload), output_path)
            click.echo(f"Wrote JSON to {written}")
        else:
            _print_json(payload)
        return

    click.echo("")
    click.secho("  UI Test Overview", fg="cyan", bold=True)
    click.echo(f"  Scope:     {filter_label}")
    click.echo(f"  Total:     {summary['total']}")
    click.echo(f"  Passed:    {summary['passed']}")
    click.echo(f"  Failed:    {summary['failed']}")
    click.echo(f"  No result: {summary['no_result']}")
    click.echo(f"  Executed:  {summary['executed']}")
    click.echo(f"  Pass %:    {summary['pass_rate']}%")
    click.echo(f"  Fail %:    {summary['fail_rate']}%")
    click.echo("")


# ---------------------------------------------------------------------------
# _list_tests
# ---------------------------------------------------------------------------


def _list_tests(
    include_name: bool,
    include_id: bool,
    include_origin: bool,
    include_path: bool,
    filter_origin: str,
    name_filter: str,
    test_type: str,
    filter_smoke: bool = False,
    filter_regression: bool = False,
    list_all: bool = False,
    _group_id_filter: str = "",
) -> None:
    """List manual testcases for the current app/user."""
    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)

    # Resolve smoke/regression shorthands into test_type
    if (filter_smoke or filter_regression) and not test_type:
        types = []
        if filter_smoke:
            types.append("smoke")
        if filter_regression:
            types.append("regression")
        test_type = ",".join(types)

    async def _fetch() -> 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_testcases()

    try:
        with _spinner("Fetching tests…"):

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

    if not testcases:
        click.echo("No testcases found.")
        return

    has_filter = filter_origin or name_filter or test_type or _group_id_filter or list_all

    # TTY interactive menu when no filters are given and --all not set
    if not has_filter and _is_tty():
        FILTER_OPTIONS = [
            "By type   (smoke / regression / negative)",
            "By origin (manual / record / default)",
            "By name   (substring match)",
            "By group  (select a folder)",
            "All",
        ]
        click.echo("\nHow would you like to filter testcases?")
        try:
            (filter_idx,) = _prompt_numbered(FILTER_OPTIONS, "Select filter", multi=False)
        except click.Abort:
            click.echo("\nAborted.")
            sys.exit(0)

        if filter_idx == 0:
            TYPE_OPTIONS = ["smoke", "regression", "negative"]
            idxs = _prompt_numbered(TYPE_OPTIONS, "Select type(s)", multi=True)
            if not idxs:
                click.secho("✗ No selection.", fg="red")
                sys.exit(1)
            test_type = ",".join(TYPE_OPTIONS[i] for i in idxs)
        elif filter_idx == 1:
            ORIGIN_OPTIONS = ["manual", "record", "default"]
            (oidx,) = _prompt_numbered(ORIGIN_OPTIONS, "Select origin", multi=False)
            filter_origin = ORIGIN_OPTIONS[oidx]
        elif filter_idx == 2:
            try:
                name_filter = click.prompt("Name substring").strip()
            except click.Abort:
                click.echo("\nAborted.")
                sys.exit(0)
        elif filter_idx == 3:
            # Load groups for folder selection
            async def _fetch_groups() -> 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_testcase_groups()
            try:
                with _spinner("Fetching folders…"):

                    groups = asyncio.run(_fetch_groups())
            except Exception as e:
                click.secho(f"✗ Failed to fetch folders: {e}", fg="red")
                sys.exit(1)
            if not groups:
                click.secho("✗ No folders found.", fg="yellow")
                sys.exit(1)
            group_names = [g.get("testcase_group_name") or g.get("name") or g.get("id") or "" for g in groups]
            (gidx,) = _prompt_numbered(group_names, "Select folder", multi=False)
            selected_group = groups[gidx]
            _group_id_filter = (
                selected_group.get("testcase_group_id")
                or selected_group.get("id")
                or selected_group.get("_id")
                or ""
            )
        # filter_idx == 4 → All: no filter, fall through

    # Apply filters
    if filter_origin:
        testcases = [tc for tc in testcases if (tc.get("test_origin") or "").lower() == filter_origin.lower()]
    if test_type:
        chosen_types = {t.strip().lower() for t in test_type.split(",") if t.strip()}
        testcases = [tc for tc in testcases if (tc.get("test_type") or tc.get("testcase_type") or "").lower() in chosen_types]
    if name_filter:
        needle = name_filter.lower()
        testcases = [tc for tc in testcases if needle in (tc.get("name") or tc.get("testcase_name") or "").lower()]
    if _group_id_filter:
        def _tc_group_id(tc: dict) -> str:
            return (
                tc.get("testcase_group_id")
                or tc.get("group_id")
                or tc.get("group")
                or ""
            )
        testcases = [tc for tc in testcases if _tc_group_id(tc) == _group_id_filter]

    if not testcases:
        click.echo("No testcases match the provided filters.")
        return

    # Normalise each testcase into a flat dict for display
    def _tc_row(tc: dict) -> dict:
        return {
            "id":     tc.get("testcase_id") or tc.get("id") or tc.get("_id") or "-",
            "name":   tc.get("name") or tc.get("testcase_name") or "-",
            "type":   tc.get("test_type") or tc.get("testcase_type") or "-",
            "origin": tc.get("test_origin") or "-",
            "path":   tc.get("path") or tc.get("testcase_path") or tc.get("file_path") or "-",
        }

    has_include = include_id or include_name or include_origin or include_path

    if not _is_tty():
        # Non-TTY: JSON for scripting
        if has_include:
            output: list[dict] = []
            for tc in testcases:
                row = _tc_row(tc)
                item: dict = {}
                if include_id:
                    item["id"] = row["id"]
                if include_name:
                    item["name"] = row["name"]
                if include_origin:
                    item["origin"] = row["origin"]
                if include_path:
                    item["path"] = row["path"]
                output.append(item)
        else:
            output = testcases
        click.echo(json.dumps(output, indent=2))
        return

    # TTY: human-readable table
    # Decide which columns to show
    show_id     = include_id or not has_include
    show_name   = include_name or not has_include
    show_type   = not has_include           # always shown when no include flags
    show_origin = include_origin or not has_include
    show_path   = include_path              # only shown when explicitly requested

    rows = [_tc_row(tc) for tc in testcases]

    COL_DEFS = [
        ("show_name",   "Name",   "name"),
        ("show_type",   "Type",   "type"),
        ("show_origin", "Origin", "origin"),
        ("show_id",     "ID",     "id"),
        ("show_path",   "Path",   "path"),
    ]
    show_flags = {
        "show_name": show_name,
        "show_type": show_type,
        "show_origin": show_origin,
        "show_id": show_id,
        "show_path": show_path,
    }
    active = [(hdr, key) for flag, hdr, key in COL_DEFS if show_flags.get(flag)]

    # Calculate column widths
    widths = {key: max(len(hdr), *(len(r[key]) for r in rows)) for hdr, key in active}

    def _fmt_row(r: dict) -> str:
        return "  ".join(r[key].ljust(widths[key]) for _, key in active)

    header    = "  ".join(hdr.ljust(widths[key]) for hdr, key in active)
    separator = "  ".join("-" * widths[key] for _, key in active)

    click.echo()
    click.secho(f"  {len(testcases)} testcase(s) found", dim=True)
    click.echo()
    click.secho(header, bold=True)
    click.echo(separator)
    for r in rows:
        click.echo(_fmt_row(r))
    click.echo()


# ---------------------------------------------------------------------------
# _list_folders
# ---------------------------------------------------------------------------


def _list_folders(name_filter: str, include_parent_id: bool) -> None:
    """List testcase folders for the current app/user."""
    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)

    async def _fetch() -> 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_testcase_groups()

    try:
        with _spinner("Fetching folders…"):

            groups = asyncio.run(_fetch())
    except Exception as e:
        click.secho(f"✗ Failed to fetch folders: {e}", fg="red")
        sys.exit(1)

    if not groups:
        click.echo("No folders found.")
        return

    # Filter by name substring if provided
    if name_filter:
        needle = name_filter.strip().lower()
        groups = [g for g in groups if needle in (g.get("testcase_group_name") or g.get("name") or "").lower()]
        if not groups:
            click.secho(f"✗ No folders found matching '{name_filter}'.", fg="yellow")
            return

    selected_fields: list[str] = []
    # Name is always shown - it is the primary identifier
    selected_fields.append("name")
    # Show parent name to make parent-child relationships clear
    selected_fields.append("parent_name")
    if include_parent_id:
        selected_fields.append("parent_id")

    if not _is_tty():
        # Non-TTY: emit JSON for scripting
        if selected_fields:
            output: list[dict] = []
            # Build id -> name map for parent resolution
            id_to_name = {
                (g.get("testcase_group_id") or g.get("id") or g.get("_id")): (g.get("testcase_group_name") or g.get("name"))
                for g in groups
            }
            for g in groups:
                item: dict = {}
                for field in selected_fields:
                    if field == "name":
                        item["name"] = g.get("testcase_group_name") or g.get("name")
                    elif field == "parent_name":
                        pid = g.get("testcase_group_parent_id") or g.get("parent_id") or g.get("parentId")
                        item["parent_name"] = id_to_name.get(pid) or None
                    elif field == "parent_id":
                        item["parent_id"] = (
                            g.get("testcase_group_parent_id") or g.get("parent_id") or g.get("parentId")
                        )
                output.append(item)
        else:
            output = groups
        click.echo(json.dumps(output, indent=2))
        return

    # TTY: human-readable table
    col_name = "Name"
    col_parent = "Parent"
    col_parent_id = "Parent ID"

    # Build id -> name map for parent resolution
    id_to_name = {
        (g.get("testcase_group_id") or g.get("id") or g.get("_id")): (g.get("testcase_group_name") or g.get("name") or "-")
        for g in groups
    }

    rows = []
    for g in groups:
        pid = g.get("testcase_group_parent_id") or g.get("parent_id") or g.get("parentId")
        parent_name = id_to_name.get(pid) if pid else "-"
        rows.append({
            "name": g.get("testcase_group_name") or g.get("name") or "-",
            "parent_name": parent_name or "-",
            "parent_id": pid or "-",
        })

    show_name = True
    show_parent = "parent_name" in selected_fields
    show_parent_id = "parent_id" in selected_fields

    # Calculate column widths
    w_name = max(len(col_name), *(len(r["name"]) for r in rows)) if show_name else 0
    w_parent = max(len(col_parent), *(len(r["parent_name"]) for r in rows)) if show_parent else 0
    w_parent_id = max(len(col_parent_id), *(len(r["parent_id"]) for r in rows)) if show_parent_id else 0

    def _row(name: str, parent: str, parent_id: str) -> str:
        parts = []
        if show_name:
            parts.append(name.ljust(w_name))
        if show_parent:
            parts.append(parent.ljust(w_parent))
        if show_parent_id:
            parts.append(parent_id.ljust(w_parent_id))
        return "  ".join(parts)

    header = _row(col_name, col_parent, col_parent_id)
    separator = "  ".join(
        ["-" * w for w, show in [(w_name, show_name), (w_parent, show_parent), (w_parent_id, show_parent_id)] if show]
    )

    click.echo()
    click.secho(header, bold=True)
    click.echo(separator)
    for r in rows:
        click.echo(_row(r["name"], r["parent_name"], r["parent_id"]))
    click.echo()
    click.echo(f"  {len(rows)} folder(s)")


# ---------------------------------------------------------------------------
# run tests — result helpers
# ---------------------------------------------------------------------------


def _parse_run_results(
    result: dict,
    ids_requested: list[str],
) -> tuple[list[str], list[str]]:
    """Extract (passed_ids, failed_ids) from a /run_test API response.

    Tries three strategies in order:
    1. Top-level ``passed_testcase_ids`` / ``failed_testcase_ids`` fields
    2. Iterating the ``result`` / ``results`` array and reading ``.status``
    3. Treating the overall ``status`` field as applying to all requested IDs
    """
    def _extract_id(item: object) -> str:
        """Normalise a list item that may be a plain string or a dict."""
        if isinstance(item, str):
            return item
        if isinstance(item, dict):
            return (
                item.get("testcase_id")
                or item.get("id")
                or item.get("_id")
                or ""
            )
        return str(item) if item else ""

    # Strategy 1 — explicit ID lists in the response
    passed: list[str] = [
        _id for raw in (result.get("passed_testcase_ids") or [])
        if (_id := _extract_id(raw))
    ]
    failed: list[str] = [
        _id for raw in (result.get("failed_testcase_ids") or [])
        if (_id := _extract_id(raw))
    ]
    if passed or failed:
        return passed, failed

    # Strategy 2 — iterate result array
    results_list = result.get("result") or result.get("results") or []
    if isinstance(results_list, list):
        for item in results_list:
            tc_id = (
                item.get("testcase_id")
                or item.get("id")
                or item.get("_id")
                or ""
            )
            if not tc_id:
                continue
            status = (item.get("status") or "").lower()
            if status in ("fail", "failed", "error"):
                failed.append(tc_id)
            elif status in ("pass", "passed", "success"):
                passed.append(tc_id)
        if passed or failed:
            return passed, failed

    # Strategy 3 — overall status applies to all
    overall = (result.get("status") or "").lower()
    if overall in ("pass", "passed", "success"):
        return list(ids_requested), []
    if overall in ("fail", "failed", "error"):
        return [], list(ids_requested)

    return [], []


def _display_run_results(
    result: dict,
    ids_requested: list[str],
    testcases: list[dict],
    elapsed_s: float,
) -> tuple[list[str], list[str]]:
    """Print a per-test pass/fail table and return (passed_ids, failed_ids)."""
    passed_ids, failed_ids = _parse_run_results(result, ids_requested)

    name_by_id: dict[str, str] = {
        _get_testcase_id(tc): (tc.get("name") or _get_testcase_id(tc) or "")
        for tc in testcases
        if _get_testcase_id(tc)
    }

    reported = set(passed_ids) | set(failed_ids)
    click.echo(f"\n  Results ({round(elapsed_s, 1)}s):\n")
    for tc_id in ids_requested:
        name = name_by_id.get(tc_id, tc_id)
        if tc_id in failed_ids:
            click.echo(f"    {click.style('✗', fg='red', bold=True)}  {name}")
        elif tc_id in passed_ids:
            click.echo(f"    {click.style('✓', fg='green', bold=True)}  {name}")
        else:
            click.echo(f"    {click.style('?', fg='yellow')}  {name}  (no result received)")

    unknown = len(ids_requested) - len(reported & set(ids_requested))
    click.echo()
    parts = []
    if passed_ids:
        parts.append(click.style(f"{len(passed_ids)} passed", fg="green", bold=True))
    if failed_ids:
        parts.append(click.style(f"{len(failed_ids)} failed", fg="red", bold=True))
    if unknown > 0:
        parts.append(click.style(f"{unknown} unknown", fg="yellow"))
    click.echo("  " + "  |  ".join(parts) if parts else "  No results received.")
    click.echo()

    return passed_ids, failed_ids


# ---------------------------------------------------------------------------
# run tests
# ---------------------------------------------------------------------------


def _derive_areas_from_paths(changed_files: list[str]) -> list[str]:
    """Derive testable area names from a list of changed file paths.

    Skips infrastructure directories (node_modules, dist, utils, …) and generic
    file stems (index, main, app, …).  Returns Title-cased area strings suitable
    for fuzzy-matching against testcase_functionality values.
    """
    import re as _re
    from pathlib import Path as _Path

    # Dirs whose entire subtree should be ignored (generated / vendor code)
    _SKIP_TREES = frozenset({
        "node_modules", "dist", "build", "__pycache__", ".git",
        ".next", ".nuxt", "coverage", "vendor",
    })
    # Dir names that are generic containers — skip the name itself as an area label,
    # but still descend into them to pick up meaningful subdirectory/file names.
    _SKIP_LABEL_DIRS = frozenset({
        "src", "lib", "utils", "helpers", "common", "shared",
        "components", "pages", "views", "api", "services",
        "static", "assets", "styles", "css", "scss",
        "config", "scripts", "migrations", "seeds",
        "tests", "test", "spec", "__tests__", "fixtures",
        "frontend", "backend",
    })
    _SKIP_STEMS = frozenset({
        "index", "main", "app", "router", "routes", "types", "constants",
        "utils", "helpers", "config", "setup", "settings", "base",
        "__init__", "__main__",
    })

    def _to_words(s: str) -> str:
        """Split CamelCase / kebab / snake into space-separated Title Case words."""
        s = _re.sub(r"(?<=[a-z])(?=[A-Z])", " ", s)  # camelCase → camel Case
        s = s.replace("-", " ").replace("_", " ")
        return " ".join(w.capitalize() for w in s.split() if w)

    areas: set[str] = set()
    for file_path in changed_files:
        p = _Path(file_path)
        parts = p.parts

        # Skip files that live inside a generated/vendor tree
        if any(part.lower() in _SKIP_TREES for part in parts[:-1]):
            continue

        # Meaningful subdirectory names (skip generic container names)
        for part in parts[:-1]:
            pl = part.lower()
            if pl not in _SKIP_LABEL_DIRS and pl not in _SKIP_TREES and not part.startswith("."):
                areas.add(_to_words(part))

        # File stem (skip generic stems)
        if p.stem.lower() not in _SKIP_STEMS:
            areas.add(_to_words(p.stem))

    return sorted(areas)


def _match_testcases_by_areas(testcases: list[dict], areas: list[str]) -> list[dict]:
    """Return testcases that match any of the given areas.

    Matches against the testcase name, functionality tag, and description —
    all checked in parallel so testcases without a functionality tag still match
    correctly based on their name alone.
    """
    import difflib

    if not areas:
        return []

    area_lowers = [a.lower() for a in areas]

    def _matches(tc: dict) -> bool:
        # Collect every text field worth matching against
        candidates: list[str] = []
        for field in (
            "name", "testcase_name",
            "testcase_functionality", "test_case_functionality",
            "description",
        ):
            val = (tc.get(field) or "").strip()
            if val:
                candidates.append(val.lower())

        if not candidates:
            return False

        for area_lower in area_lowers:
            for candidate in candidates:
                # Substring: "environment" inside "verify create, edit and delete environment"
                if area_lower in candidate or candidate in area_lower:
                    return True
                # Word-level: any word from the area appears in the candidate
                for word in area_lower.split():
                    if len(word) >= 4 and word in candidate:
                        return True
                # Fuzzy similarity
                if difflib.SequenceMatcher(None, area_lower, candidate).ratio() >= 0.6:
                    return True
        return False

    matched: list[dict] = []
    seen: set[str] = set()
    for tc in testcases:
        tc_id = _get_testcase_id(tc)
        if tc_id and tc_id not in seen and _matches(tc):
            matched.append(tc)
            seen.add(tc_id)

    return matched


def _extract_tokens_from_files(changed_files: list[str]) -> set[str]:
    """Read changed source files and extract CSS classes, IDs, and identifiers.

    These tokens are used to match against testcase component selectors and
    attributes, giving a much more precise picture of which testcases are
    actually affected by the file changes.
    """
    import re
    from pathlib import Path

    _NOISE = frozenset({
        # Common English words
        "the", "and", "for", "this", "that", "with", "from", "are", "has",
        "not", "its", "can", "was", "but", "you", "all", "new", "get", "set",
        "let", "var", "const", "true", "false", "null", "undefined",
        # Generic HTML form field names — appear in virtually every form
        "email", "phone", "password", "username", "confirm", "token",
        "first", "last", "middle",
        # Generic UI / component terms — too common to be discriminating
        "btn", "form", "input", "button", "submit", "cancel", "reset",
        "label", "field", "text", "value", "data", "item", "list",
        "container", "wrapper", "content", "section", "header", "footer",
        "modal", "dialog", "popup", "panel", "card", "row", "col", "box",
        "icon", "img", "image", "link", "nav", "menu", "tab", "page",
        "title", "body", "main", "side", "top", "bottom", "left", "right",
    })

    def _split(s: str) -> list[str]:
        """Split camelCase / kebab / snake into lowercase words ≥ 3 chars."""
        s = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", s)
        return [p.lower() for p in re.split(r"[\s\-_./]+", s) if len(p) >= 3]

    # Patterns for JS/JSX/TS/TSX/CSS/HTML source.
    # We deliberately EXCLUDE name= and placeholder= because their values
    # (e.g. name="email", placeholder="Enter your email") are standardised
    # HTML conventions that appear in every form — they are not unique
    # identifiers and cause false-positive matches across unrelated pages.
    _PATTERNS = [
        re.compile(r'className\s*=\s*["\']([^"\']+)["\']'),         # className="foo bar"
        re.compile(r'\bid\s*=\s*["\']([^"\']+)["\']'),              # id="foo"
        re.compile(r'aria-label\s*=\s*["\']([^"\']{3,})["\']'),     # aria-label="..."
        re.compile(r'data-testid\s*=\s*["\']([^"\']+)["\']'),       # data-testid="foo"
        re.compile(r'data-automation-id\s*=\s*["\']([^"\']+)["\']'),# data-automation-id="foo"
        re.compile(r'data-cy\s*=\s*["\']([^"\']+)["\']'),           # data-cy="foo"
        re.compile(r'\.([\w][\w-]{2,})\s*[{,]'),                    # .css-class {
        re.compile(r'#([\w][\w-]{2,})\s*[{,]'),                     # #css-id {
    ]

    tokens: set[str] = set()

    for file_path in changed_files:
        # Tokens from the filename itself
        p = Path(file_path)
        for tok in _split(p.stem):
            tokens.add(tok)
        tokens.add(p.stem.lower())

        try:
            with open(file_path, "r", encoding="utf-8", errors="ignore") as fh:
                content = fh.read()
        except (OSError, IOError):
            continue

        for pattern in _PATTERNS:
            for m in pattern.finditer(content):
                val = m.group(1)
                tokens.add(val.lower())
                tokens.update(_split(val))

    return tokens - _NOISE


def _extract_component_tokens(comp: dict) -> set[str]:
    """Extract searchable tokens from a single component dict.

    Reads css_selector, xpath, HTML attributes, visible text, page URL / name
    and returns a set of lowercase tokens (≥ 3 chars each).  Used both for
    testcase matching and for post-run failure classification.
    """
    import re
    from urllib.parse import urlparse

    toks: set[str] = set()

    def _add(val: str) -> None:
        if not val:
            return
        val = val.lower()
        toks.add(val)
        toks.update(p for p in re.split(r"[\s\-_./:#\[\]>+~()\"'=@]+", val) if len(p) >= 3)

    # css_selector — richest single field
    css = comp.get("css_selector") or ""
    _add(css)
    for m in re.finditer(r"\.([\w-]{3,})", css):
        _add(m.group(1))
    for m in re.finditer(r"#([\w-]{3,})", css):
        _add(m.group(1))

    # xpath attribute values
    xpath = comp.get("xpath") or ""
    for m in re.finditer(r'@(?:id|class|name)\s*=\s*["\']([^"\']+)["\']', xpath):
        _add(m.group(1))

    # HTML attributes JSON
    attrs = comp.get("attributes") or {}
    if isinstance(attrs, dict):
        for key in ("class", "id", "name", "placeholder", "aria-label",
                    "data-testid", "data-cy", "value", "type"):
            _add(str(attrs.get(key) or ""))

    # Visible text, label, placeholder
    for field in ("text", "component_label", "placeholder"):
        val = comp.get(field) or ""
        toks.update(w.lower() for w in val.split() if len(w) >= 3)

    # page_url path segments
    page_url = comp.get("page_url") or ""
    if page_url:
        path = urlparse(page_url).path
        toks.update(seg.lower() for seg in path.split("/") if len(seg) >= 3)

    # page_name words
    page_name = comp.get("page_name") or ""
    toks.update(w.lower() for w in page_name.split() if len(w) >= 3)

    return toks


def _extract_failed_flu_id(run_result: dict, tc_id: str) -> str | None:
    """Return the failed_flu_id for a specific testcase from the run result.

    Searches ``run_result["results"]`` (or ``"result"``) for the entry whose
    testcase_id matches ``tc_id`` and returns its ``failed_flu_id`` field.
    """
    results_list = run_result.get("results") or run_result.get("result") or []
    if not isinstance(results_list, list):
        return None
    for r in results_list:
        rid = r.get("testcase_id") or r.get("id") or r.get("_id") or ""
        if rid == tc_id:
            flu = r.get("failed_flu_id")
            if flu:
                return str(flu)
    return None


def _classify_failure(
    tc_id: str,
    run_result: dict,
    component_data: dict,
    file_tokens: set[str],
) -> tuple[str, str | None, str | None]:
    """Classify a failed testcase as 'related', 'unrelated', or 'unknown'.

    Returns ``(classification, failed_step_label, failed_page_name)``.

    - 'related'   — the test failed at a step whose tokens overlap with the
                    user's changed files → likely caused by the code change.
    - 'unrelated' — the test failed at a step unrelated to the changed files
                    (e.g. a login step that was already broken).
    - 'unknown'   — not enough data to classify (no flu_id in response or
                    the component could not be found).
    """
    failed_flu_id = _extract_failed_flu_id(run_result, tc_id)
    if not failed_flu_id:
        return "unknown", None, None

    unique_comps = component_data.get("unique_components", [])
    failed_comp = next(
        (c for c in unique_comps if c.get("flu_id") == failed_flu_id
         or c.get("component_id") == failed_flu_id),
        None,
    )
    if not failed_comp:
        return "unknown", None, None

    failed_step_label = (
        failed_comp.get("component_label")
        or failed_comp.get("text")
        or failed_comp.get("action_taken")
        or None
    )
    failed_page_name = failed_comp.get("page_name") or None

    comp_tokens = _extract_component_tokens(failed_comp)
    if comp_tokens & file_tokens:
        return "related", failed_step_label, failed_page_name
    return "unrelated", failed_step_label, failed_page_name


def _get_custom_value_for_component(
    custom_data_by_flu: "dict[str, dict]",
    comp: dict,
) -> "str | None":
    """Return the custom input value for a component, mirroring mirabilis lookup logic.

    Looks up by component_id first, then by label (placeholder, name, id, text).
    ``custom_data_by_flu`` maps flu_id → parsed input_json dict.
    """
    flu_id = comp.get("flu_id") or comp.get("component_id") or ""
    input_json = custom_data_by_flu.get(flu_id)
    if not input_json:
        return None

    # Primary: match by component_id
    component_id = comp.get("component_id") or ""
    if component_id and component_id in input_json:
        val = input_json[component_id]
        return None if val == [None] else str(val)

    # Fallback: match by label (placeholder > name > component_id > text > label)
    label = ""
    for src in [
        comp.get("placeholder"),
        comp.get("name"),
        comp.get("component_id"),
        comp.get("text"),
        comp.get("component_label"),
    ]:
        if src:
            label = str(src).strip().lower()
            break
    if not label:
        return None

    for key, val in input_json.items():
        if str(key).strip().lower() == label:
            return None if val == [None] else str(val)

    return None


def _parse_custom_data(custom_data: list[dict]) -> "dict[str, dict]":
    """Parse the raw custom_data list into {flu_id: input_json_dict}."""
    result: dict[str, dict] = {}
    for entry in custom_data:
        flu_id = entry.get("flu_id") or ""
        if not flu_id:
            continue
        raw = entry.get("input_json")
        if not raw:
            continue
        if isinstance(raw, (bytes, bytearray)):
            raw = raw.decode("utf-8", errors="replace")
        if isinstance(raw, str):
            try:
                raw = json.loads(raw)
            except (json.JSONDecodeError, ValueError):
                continue
        if isinstance(raw, dict):
            result[flu_id] = raw
    return result


def _build_playwright_testcase_dict(
    tc: dict,
    ordered_components: list[dict],
    verifications: list[dict],
    custom_data: "list[dict] | None" = None,
) -> dict:
    """Convert Maeris component + verification data into a playwright test_case dict."""
    action_map = {
        "click": "click",
        "go_to": "go_to",
        "navigate": "go_to",
        "send_keys": "send_keys",
        "hover": "hover",
        "fill": "send_keys",
        "type": "send_keys",
    }
    custom_by_flu = _parse_custom_data(custom_data) if custom_data else {}

    steps = []
    for comp in ordered_components:
        action = action_map.get((comp.get("action_taken") or "click").lower(), "click")
        step: dict = {
            "action": action,
            "description": comp.get("component_label") or comp.get("text") or action,
            "selector": comp.get("css_selector") or "",
            "selector_type": "css",
        }
        # Custom data overrides mock_input for input actions
        if action in ("send_keys", "fill", "select"):
            custom_val = _get_custom_value_for_component(custom_by_flu, comp)
            value = custom_val if custom_val is not None else comp.get("mock_input")
            if value is not None:
                step["value"] = str(value)
        elif comp.get("mock_input"):
            step["value"] = comp["mock_input"]
        if action == "go_to" and comp.get("page_url"):
            step["url"] = comp["page_url"]
        steps.append(step)

    assertions = []
    for ver in verifications:
        assertions.append({
            "assertion_type": ver.get("assertion_type") or "visible",
            "description": ver.get("description") or "Verify",
            "selector": ver.get("css_selector") or "",
            "expected": ver.get("assertion_value") or "",
        })

    return {
        "name": tc.get("name") or "testcase",
        "description": tc.get("description") or "",
        "steps": steps,
        "assertions": assertions,
    }


def _extract_failed_step_name(logs: str) -> str | None:
    """Return the human-readable step name from a Maeris test-runner log.

    Looks for lines like:
      ``... - ERROR -   Failed at  -> Navigate to login page``
    and returns ``"Navigate to login page"``.  Returns None if not found.
    """
    import re as _re_sn
    for ln in logs.splitlines():
        m = _re_sn.search(r"Failed at\s*->\s*(.+)", ln)
        if m:
            return m.group(1).strip()
    return None


def _extract_failure_step_key(logs: str) -> str:
    """Extract a short signature that identifies WHICH step failed.

    Used to detect when the fix loop has moved on to a new failing step so the
    per-step retry counter can reset.  Returns a normalised string that is
    stable across re-runs of the same failing step.
    """
    import re as _re_step

    lines = [ln.strip() for ln in logs.splitlines() if ln.strip()]

    # Walk tracebacks to find the actual exception line (most specific).
    in_tb = False
    last_exc = ""
    for ln in lines:
        if ln.startswith("Traceback (most recent call last)"):
            in_tb = True
            continue
        if in_tb:
            if not ln.startswith(" ") and ":" in ln:
                last_exc = ln
                in_tb = False
    if last_exc:
        return last_exc[:120]

    # Fall back: first line that names a locator or error.
    for ln in lines:
        if any(kw in ln for kw in ("locator(", "Error:", "TimeoutError", "AssertionError", "FAILED")):
            # Strip timestamps so the key is stable
            ln = _re_step.sub(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", "", ln).strip()
            return ln[:120]

    return lines[-1][:120] if lines else "unknown"


def _run_playwright_with_autofix(
    output_dir: str,
    script_filename: str,
    tc_name: str,
    config_dir: "Path",
    max_retries: int = 3,
) -> tuple[bool, str]:
    """Run a playwright script through _run_test() with a Claude fix loop.

    Retries are tracked **per failing step**: when a fix moves the test past a
    previously-broken step and a new step starts failing, the retry counter
    resets to ``max_retries`` for that new step.  This way each individual step
    gets ``max_retries`` fix attempts rather than the entire test sharing a
    single budget.

    Returns (passed, final_code).
    """
    from maeris_mcp.tools.test_execution import _run_test

    try:
        claude_bin, _ai_mode = _resolve_ai_mode()
    except SystemExit:
        claude_bin = None
    script_path = Path(output_dir) / script_filename

    # Per-step retry tracking
    step_retry_counts: dict[str, int] = {}   # step_key -> times we've tried to fix it
    current_step_key: str | None = None
    overall_cap = max_retries * 20            # safety valve against infinite loops

    for _overall in range(overall_cap + 1):
        is_first_run = _overall == 0
        if not is_first_run:
            # fixes_done = how many fix attempts have already been applied to
            # this step; the run we're about to do is attempt (fixes_done + 1).
            fixes_done = step_retry_counts.get(current_step_key or "", 0)
            attempt_n = fixes_done + 1        # 2nd run = attempt 2, etc.
            total_attempts = max_retries + 1  # e.g. 4 total when max_retries=3
            label = (
                f"step attempt {attempt_n}/{total_attempts}"
                if attempt_n < total_attempts
                else "final step attempt"
            )
        else:
            label = "attempt 1"
        click.echo(f"    ▶ Running playwright script ({label})…")
        result = asyncio.run(_run_test(output_dir, script_filename, timeout=120))
        if result["passed"]:
            click.secho("    ✓ Playwright test passed.", fg="green")
            return True, script_path.read_text(encoding="utf-8")

        # ── Show a useful failure summary ─────────────────────────────────
        log_lines = [ln for ln in result["logs"].splitlines() if ln.strip()]

        exception_line = None
        in_traceback = False
        for ln in log_lines:
            stripped = ln.strip()
            if stripped.startswith("Traceback (most recent call last)"):
                in_traceback = True
            elif in_traceback:
                if not stripped.startswith(" ") and ":" in stripped:
                    exception_line = stripped
                    in_traceback = False
        if not exception_line:
            for ln in log_lines:
                stripped = ln.strip()
                if any(kw in stripped for kw in ("Error", "FAILED", "assert", "Timeout", "locator", "RESULT")):
                    exception_line = stripped
                    break

        failed_step_name = _extract_failed_step_name(result["logs"])
        if failed_step_name:
            click.secho(f"      ✗ Failed step: {failed_step_name}", fg="yellow")
        if exception_line:
            click.secho(f"        {exception_line[:200]}", fg="yellow")
        if exception_line and "unexpected exception" in exception_line and log_lines:
            relevant = [ln.strip() for ln in log_lines[-15:] if ln.strip() and ln.strip() not in (exception_line, f"Failed at  -> {failed_step_name}")]
            if relevant:
                click.secho("        Details from logs:", fg="yellow", dim=True)
                for detail in relevant:
                    click.secho(f"          {detail[:200]}", fg="yellow", dim=True)

        # ── Per-step retry accounting ─────────────────────────────────────
        new_step_key = _extract_failure_step_key(result["logs"])
        if new_step_key != current_step_key:
            # The fix moved us past the previous step — a new step is failing.
            current_step_key = new_step_key
            # Don't pre-populate; first failure of a step is counted below.

        step_retry_counts[current_step_key] = step_retry_counts.get(current_step_key, 0) + 1
        retries_used = step_retry_counts[current_step_key]

        if retries_used > max_retries:
            break

        # ── Ask Claude to fix the code ────────────────────────────────────
        current_code = script_path.read_text(encoding="utf-8")
        raw_logs = result["logs"]
        log_tail = raw_logs[-2500:] if len(raw_logs) > 2500 else raw_logs
        if len(raw_logs) > 2500:
            log_tail = "...[truncated — showing last 2500 chars]...\n" + log_tail
        stdin_content = (
            f"=== TEST CODE ===\n{current_code}\n\n"
            f"=== FAILURE LOGS ===\n{log_tail}"
        )
        instruction = (
            f"You are fixing a Playwright test for '{tc_name}'. "
            "Fix the CSS selectors or logic so the test passes. "
            "Return ONLY valid executable Python code. No prose, no markdown fences."
        )
        system_prompt = (
            "Output ONLY executable Python code. "
            "No explanation, no markdown, no comments about the fix."
        )
        step_label = f"'{failed_step_name}'" if failed_step_name else "step"
        if claude_bin:
            fixed_code, rc = _run_claude_capture(
                claude_bin,
                instruction,
                "claude-sonnet-4-6",
                label=f"fixing {step_label} in '{tc_name}' (attempt {retries_used}/{max_retries})",
                extra_args=[
                    "--output-format", "text",
                    "--no-session-persistence",
                    "--system-prompt", system_prompt,
                ],
                stdin_text=stdin_content,
            )
        else:
            fixed_code, rc = _run_direct_api_capture(
                instruction,
                "claude-sonnet-4-6",
                system_prompt=system_prompt,
                label=f"fixing {step_label} in '{tc_name}' (attempt {retries_used}/{max_retries})",
                stdin_text=stdin_content,
            )
        if rc != 0 or not fixed_code.strip():
            break
        code = fixed_code.strip()
        if code.startswith("```"):
            parts = code.split("```", 2)
            inner = parts[1] if len(parts) > 1 else code
            if inner.startswith("python"):
                inner = inner[6:]
            code = inner.rsplit("```", 1)[0].strip()
        script_path.write_text(code, encoding="utf-8")
        step_suffix = f" for '{failed_step_name}'" if failed_step_name else ""
        click.echo(f"    ✎ Fix applied{step_suffix} — re-running…")

    return False, script_path.read_text(encoding="utf-8")


def _extract_ordered_playwright_steps(code: str) -> list[dict]:
    """Extract steps from a playwright script in execution order.

    Each entry is either:
      {"type": "locator", "selector": str, "fill_value": str | None}
      {"type": "goto",    "url": str}
    """
    import re as _re_local
    steps = []
    for line in code.splitlines():
        stripped = line.strip()
        goto_m = _re_local.search(r'page\.goto\(\s*["\']([^"\']+)["\']\s*\)', stripped)
        if goto_m:
            steps.append({"type": "goto", "url": goto_m.group(1)})
            continue
        sel_m = _re_local.search(r'page\.locator\(\s*["\']([^"\']+)["\']\s*\)', stripped)
        if sel_m:
            selector = sel_m.group(1)
            fill_m = _re_local.search(r'\.fill\(\s*["\']([^"\']*)["\']', stripped)
            steps.append({
                "type": "locator",
                "selector": selector,
                "fill_value": fill_m.group(1) if fill_m else None,
            })
    return steps


def _build_maeris_edit_steps(
    original_components: list[dict],
    original_code: str,
    fixed_code: str,
) -> "tuple[list[dict], bool]":
    """Build edit_manual_testcase steps by positionally matching fixed playwright
    locators to original components.

    Returns (steps, replace_all_steps).

    - When step counts match: positional 1-to-1 mapping, replace_all=False.
    - When counts differ (Claude added/deleted steps): selector-based matching
      with positional fallback, replace_all=True.  Unmatched fixed steps become
      new steps (no step_id); original components with no match are dropped.
    """
    fixed_pl = _extract_ordered_playwright_steps(fixed_code)
    fixed_locators = [s for s in fixed_pl if s["type"] == "locator"]
    fixed_gotos = [s for s in fixed_pl if s["type"] == "goto"]

    orig_nav = [(i, c) for i, c in enumerate(original_components)
                if (c.get("action_taken") or "").lower() in ("go_to", "navigate")]
    orig_non_nav = [(i, c) for i, c in enumerate(original_components)
                    if (c.get("action_taken") or "").lower() not in ("go_to", "navigate")]

    counts_match = (
        len(fixed_locators) == len(orig_non_nav)
        and len(fixed_gotos) == len(orig_nav)
    )

    def _comp_step(comp: dict, selector: str, fill_value: "str | None",
                   page_url: "str | None" = None) -> dict:
        step: dict = {}
        flu_id = comp.get("flu_id") or comp.get("component_id") or ""
        if flu_id:
            step["step_id"] = flu_id
        for src, dst in [
            ("xpath", "xpathSelector"),
            ("action_taken", "actionType"),
            ("component_label", "name"),
        ]:
            val = comp.get(src)
            if val is not None:
                step[dst] = val
        step["cssSelector"] = selector
        if page_url is not None:
            step["page_url"] = page_url
        elif comp.get("page_url"):
            step["page_url"] = comp["page_url"]
        if fill_value is not None:
            step["mockInput"] = fill_value
        elif comp.get("mock_input") is not None:
            step["mockInput"] = comp["mock_input"]
        return step

    def _new_step(pl: dict) -> dict:
        """Build a step payload for a fixed-script entry with no matching component."""
        if pl["type"] == "goto":
            return {"cssSelector": "", "actionType": "go_to",
                    "page_url": pl["url"], "name": f"Navigate to {pl['url']}"}
        selector = pl["selector"]
        fill_value = pl.get("fill_value")
        if fill_value is not None:
            return {"cssSelector": selector, "actionType": "send_keys",
                    "mockInput": fill_value, "name": f"Fill {selector}"}
        return {"cssSelector": selector, "actionType": "click",
                "name": f"Click {selector}"}

    # ── Case 1: counts match → simple positional mapping ───────────────────
    if counts_match:
        steps: list[dict] = []
        locator_idx = 0
        goto_idx = 0
        for comp in original_components:
            action = (comp.get("action_taken") or "").lower()
            if action in ("go_to", "navigate"):
                if goto_idx < len(fixed_gotos):
                    url = fixed_gotos[goto_idx]["url"]
                    goto_idx += 1
                else:
                    url = comp.get("page_url") or ""
                steps.append(_comp_step(comp, "", None, page_url=url))
            else:
                if locator_idx < len(fixed_locators):
                    pl = fixed_locators[locator_idx]
                    steps.append(_comp_step(comp, pl["selector"], pl.get("fill_value")))
                    locator_idx += 1
                else:
                    steps.append(_comp_step(comp, comp.get("css_selector") or "", None))
        return steps, False

    # ── Case 2: counts differ → selector-based matching, replace_all=True ──
    # Build lookup: original css_selector → (index, component)
    orig_sel_map: dict[str, tuple[int, dict]] = {}
    for i, c in orig_non_nav:
        sel = c.get("css_selector") or ""
        if sel and sel not in orig_sel_map:
            orig_sel_map[sel] = (i, c)

    orig_nav_iter = iter(orig_nav)
    used: set[int] = set()

    # We rebuild steps in the ORDER they appear in the fixed script so that
    # the testcase execution matches the fixed playwright flow exactly.
    steps = []
    non_nav_positional = list(orig_non_nav)  # for positional fallback
    non_nav_pos_ptr = 0

    for pl in fixed_pl:
        if pl["type"] == "goto":
            pair = next(orig_nav_iter, None)
            if pair:
                _, comp = pair
                used.add(pair[0])
                steps.append(_comp_step(comp, "", None, page_url=pl["url"]))
            else:
                steps.append(_new_step(pl))
        else:
            sel = pl["selector"]
            matched: "tuple[int, dict] | None" = None

            # Try exact selector match first
            if sel in orig_sel_map:
                idx, comp = orig_sel_map[sel]
                if idx not in used:
                    matched = (idx, comp)

            # Positional fallback
            if matched is None:
                while non_nav_pos_ptr < len(non_nav_positional):
                    idx, comp = non_nav_positional[non_nav_pos_ptr]
                    non_nav_pos_ptr += 1
                    if idx not in used:
                        matched = (idx, comp)
                        break

            if matched:
                idx, comp = matched
                used.add(idx)
                steps.append(_comp_step(comp, sel, pl.get("fill_value")))
            else:
                steps.append(_new_step(pl))

    return steps, True


def _playwright_fix_testcase(
    tc: dict,
    config_dir: "Path",
    max_retries: int = 3,
) -> tuple[bool, int]:
    """Fix a failing testcase by generating a Playwright script, running the auto-fix
    loop locally, then updating Maeris with the corrected steps.

    Returns (success, steps_updated).
    """
    import shutil as _shutil
    import tempfile

    tc_id = _get_testcase_id(tc)
    tc_name = tc.get("name") or tc_id or "testcase"
    if not tc_id:
        return False, 0

    # Parse FLU IDs from testcase_path (format: "flu_id1->flu_id2->...")
    path = tc.get("testcase_path") or tc.get("path") or ""
    flu_ids = [fid.strip() for fid in path.split("->") if fid.strip()]
    if not flu_ids:
        click.secho(f"    ⚠ No testcase_path FLU IDs for '{tc_name}', skipping.", fg="yellow")
        return False, 0

    # Fetch ordered components, verifications, custom input data, and app base URL
    async def _fetch_data() -> tuple[list[dict], list[dict], list[dict], str]:
        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        from maeris_mcp.maeris.client import MaerisClient
        async with MaerisClient() as client:
            components = await client.get_components_by_flu_ids(flu_ids)
            verifications = await client.get_verifications_by_testcase_id(tc_id)
            try:
                custom_data = await client.get_custom_data_for_testcase(tc_id)
            except Exception:
                custom_data = []
            apps = await client.get_applications()
            base_url = ""
            if apps:
                app = apps[0] if isinstance(apps, list) else apps
                base_url = (
                    app.get("app_url") or app.get("url") or app.get("frontend_url") or ""
                )
            return components, verifications, custom_data, base_url

    try:
        components, verifications, custom_data, base_url = asyncio.run(_fetch_data())
    except Exception as exc:
        click.secho(f"    ✗ Failed to fetch testcase data: {exc}", fg="yellow")
        return False, 0

    if not components:
        click.secho(f"    ⚠ No components found for '{tc_name}', skipping.", fg="yellow")
        return False, 0

    # Order components by their position in flu_ids list
    flu_order = {fid: i for i, fid in enumerate(flu_ids)}
    ordered_components = sorted(
        components,
        key=lambda c: flu_order.get(
            c.get("flu_id") or c.get("component_id") or "", 999
        ),
    )

    # Build playwright test case dict and generate script to temp dir
    tc_dict = _build_playwright_testcase_dict(
        tc, ordered_components, verifications, custom_data=custom_data
    )

    temp_dir = tempfile.mkdtemp(prefix="maeris_playwright_fix_")
    try:
        from maeris_mcp.generators.playwright_generator import generate_step_files

        gen_result = generate_step_files(
            test_cases=[tc_dict],
            flow_name=tc_name,
            base_url=base_url,
            output_dir=temp_dir,
        )
        if not gen_result.get("files_generated"):
            click.secho(f"    ✗ Script generation failed for '{tc_name}'", fg="yellow")
            return False, 0

        script_filename = next(
            (f for f in gen_result["files_generated"] if f.endswith(".json")), None
        )
        if not script_filename:
            click.secho(f"    ✗ No .json script generated for '{tc_name}'", fg="yellow")
            return False, 0

        script_path = Path(temp_dir) / script_filename
        original_code = script_path.read_text(encoding="utf-8")

        # Run + auto-fix loop
        passed, fixed_code = _run_playwright_with_autofix(
            temp_dir, script_filename, tc_name, config_dir, max_retries=max_retries
        )

        if not passed:
            click.secho(
                f"    ⚠ Playwright test still failing after retries — "
                f"saving any selector improvements to Maeris.",
                fg="yellow",
            )

        # Build Maeris edit payload — handles added/deleted steps via replace_all
        steps, replace_all = _build_maeris_edit_steps(
            ordered_components, original_code, fixed_code
        )

        # Structural changes (replace_all) are only safe to save when the test
        # actually passed — a failing script with a different step count likely
        # means Claude produced a truncated/broken script, not a genuine fix.
        if replace_all and not passed:
            click.secho(
                f"    ⚠ Step count changed ({len(ordered_components)} → {len(steps)}) "
                f"but test still fails — skipping Maeris update to avoid data loss.",
                fg="yellow",
            )
            return False, 0

        # Count meaningful selector / fill-value changes
        if replace_all:
            changed = len(steps)
        else:
            changed = sum(
                1 for orig, step in zip(ordered_components, steps)
                if (
                    step.get("cssSelector") != (orig.get("css_selector") or "")
                    or step.get("mockInput") != orig.get("mock_input")
                )
            )

        if changed == 0:
            if not passed:
                click.secho(
                    f"    ✗ No selector/value changes detected for '{tc_name}'",
                    fg="yellow",
                )
            return passed, 0

        if replace_all:
            click.echo(
                f"    ℹ Step count changed ({len(ordered_components)} → {len(steps)}) "
                f"— replacing all steps in Maeris."
            )

        async def _apply_edits() -> None:
            os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
            from maeris_mcp.maeris.client import MaerisClient
            async with MaerisClient() as client:
                await client.edit_manual_testcase(
                    tc_id,
                    {"steps": steps, "replace_all_steps": replace_all},
                )

        try:
            asyncio.run(_apply_edits())
        except Exception as exc:
            click.secho(f"    ✗ Failed to update testcase in Maeris: {exc}", fg="yellow")
            return False, 0

        return passed, changed

    finally:
        _shutil.rmtree(temp_dir, ignore_errors=True)


def _auto_fix_testcase(
    tc: dict,
    failed_flu_id: str | None,
    changed_files: list[str],
    config_dir: "Path",
) -> tuple[bool, int]:
    """Auto-fix a failing testcase using Claude CLI + changed source files.

    Reads only the changed files (not full codebase), asks Claude to fix the
    failing step selectors, and applies changes via PUT /teststeps.

    Returns ``(success, steps_changed)`` — ``(False, 0)`` on any failure.
    """
    try:
        claude_bin, _ai_mode = _resolve_ai_mode()
    except SystemExit:
        return False, 0

    from maeris_mcp.cli.fix_cmds import _fetch_testcase_steps_rich

    steps = _fetch_testcase_steps_rich(tc, config_dir)
    if not steps:
        return False, 0

    # Read changed file contents (capped at 60 000 chars total)
    file_sections: list[str] = []
    total_chars = 0
    for fpath in changed_files:
        try:
            content = Path(fpath).read_text(encoding="utf-8", errors="replace")
        except OSError:
            continue
        if not content.strip():
            continue
        section = f"--- {fpath} ---\n{content}"
        if total_chars + len(section) > 60_000:
            # Truncate the last file to fit
            remaining = 60_000 - total_chars
            if remaining > 200:
                file_sections.append(section[:remaining] + "\n[TRUNCATED]")
            break
        file_sections.append(section)
        total_chars += len(section)

    if not file_sections:
        return False, 0

    stdin_content = (
        f"=== FAILED STEP FLU ID ===\n{failed_flu_id or '(unknown)'}\n\n"
        f"=== TEST STEPS (JSON) ===\n{json.dumps(steps, indent=2)}\n\n"
        f"=== CHANGED SOURCE FILES ===\n" + "\n\n".join(file_sections)
    )

    tc_name = tc.get("name") or _get_testcase_id(tc) or "testcase"
    instruction = (
        f"You are fixing a failing Maeris testcase named '{tc_name}'. "
        f"The test failed at the step with flu_id '{failed_flu_id}'. "
        "The source files shown are the files that were recently changed. "
        "Fix the failing step's CSS selector, XPath, or mock input — and any other "
        "steps that are now stale due to the code changes shown. "
        "Prefer data-automation-id selectors when they exist. "
        "Fall back to: [name=...], [id=...], [aria-label=...], [type=...], or a specific stable class. "
        "Never invent selectors that do not appear in the provided source code. "
        "Return ONLY a valid JSON array of the complete fixed steps with the same "
        "structure as the input. No prose, no markdown fences, no explanations."
    )

    system_prompt = (
        "You are a pure JSON fixer. "
        "Output ONLY a valid JSON array of step objects. "
        "No prose, no markdown, no explanation. "
        "If nothing needs fixing, return the original array unchanged."
    )

    if claude_bin:
        stdout_data, rc = _run_claude_capture(
            claude_bin,
            instruction,
            "claude-sonnet-4-6",
            label=f"fixing '{tc_name}'",
            extra_args=[
                "--output-format", "json",
                "--tools", "",
                "--no-session-persistence",
                "--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=f"fixing '{tc_name}'",
            stdin_text=stdin_content,
        )

    if rc != 0:
        click.secho(f"    ✗ AI exited with code {rc}", fg="yellow", err=True)
        return False, 0

    # Parse Claude's response (may be wrapped in an envelope or raw JSON)
    try:
        envelope = json.loads(stdout_data)
        raw = envelope.get("result", "").strip() if isinstance(envelope, dict) else stdout_data.strip()
    except json.JSONDecodeError:
        raw = stdout_data.strip()

    if raw.startswith("```"):
        parts = raw.split("```", 2)
        inner = parts[1] if len(parts) > 1 else raw
        if inner.startswith("json"):
            inner = inner[4:]
        raw = inner.rsplit("```", 1)[0].strip()

    try:
        fixed_steps: list[dict] = json.loads(raw)
    except json.JSONDecodeError:
        return False, 0

    if not isinstance(fixed_steps, list):
        return False, 0

    # Build edit payloads for steps that actually changed
    tc_id = _get_testcase_id(tc)
    field_map = {
        "cssSelector": "css_selector",
        "css_selector": "css_selector",
        "mockInput": "mock_input",
        "mock_input": "mock_input",
        "actionType": "action_taken",
        "action_taken": "action_taken",
        "xpath": "xpath",
        "name": "name",
        "page_url": "page_url",
    }

    edits_to_apply: list[tuple[dict, dict, dict]] = []  # (orig, fixed, payload)
    for orig, fixed in zip(steps, fixed_steps):
        if orig == fixed:
            continue
        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: dict = {"testcase_id": tc_id}
        if flu_id:
            edit_payload["flu_id"] = flu_id
        if component_id:
            edit_payload["component_id"] = component_id
        for cli_field, server_field in field_map.items():
            new_val = fixed.get(cli_field)
            old_val = orig.get(cli_field)
            if new_val is not None and new_val != old_val:
                edit_payload[server_field] = new_val
        edits_to_apply.append((orig, fixed, edit_payload))

    if not edits_to_apply:
        return True, 0

    # ------------------------------------------------------------------
    # Playwright verification: run the fixed steps locally (headless)
    # before committing anything to Maeris.
    # ------------------------------------------------------------------
    import subprocess
    import tempfile

    py_exec = sys.executable
    pw_check = subprocess.run([py_exec, "-c", "import playwright"], capture_output=True)
    if pw_check.returncode != 0:
        click.secho(
            "       ⚠ Playwright not installed — cannot verify fixes locally.\n"
            "         Install: pip install playwright && python -m playwright install chromium\n"
            "         Skipping auto-fix to avoid applying unverified changes.",
            fg="yellow",
        )
        return False, 0

    # Per-action cap: 10 s; navigation cap: 30 s.
    # Subprocess timeout = (steps × 10 s) + 60 s buffer, min 90 s.
    action_ms = 10_000
    nav_ms = 30_000
    proc_timeout = max(90, len(fixed_steps) * (action_ms // 1000) + 60)

    click.echo(
        f"       Running Playwright verification with fixed steps "
        f"(timeout {proc_timeout}s)…"
    )
    script_code = _generate_local_test_script(
        tc_name, fixed_steps, slow_mo=500, browser="chromium", headless=False,
        action_timeout_ms=action_ms, nav_timeout_ms=nav_ms,
    )
    try:
        with tempfile.NamedTemporaryFile(
            suffix=".py", mode="w", delete=False, prefix="maeris_verify_", encoding="utf-8"
        ) as f:
            f.write(script_code)
            script_path = f.name

        proc = subprocess.run(
            [py_exec, script_path],
            capture_output=True,
            text=True,
            timeout=proc_timeout,
        )
    except Exception as exc:
        click.secho(f"       - Playwright run error: {exc}", fg="yellow")
        return False, 0
    finally:
        try:
            Path(script_path).unlink(missing_ok=True)
        except Exception:
            pass

    if proc.returncode != 0:
        click.secho("       ✗ Playwright verification failed — not applying fixes.", fg="yellow")
        output = (proc.stdout or "") + (proc.stderr or "")
        for line in output.strip().splitlines()[-10:]:
            click.echo(f"         {line}")
        return False, 0

    click.secho("       ✓ Playwright verification passed.", fg="green")

    # Show per-step diff
    for i, (orig, fixed, _) in enumerate(edits_to_apply, 1):
        step_name = orig.get("name") or f"step {i}"
        for key in sorted(set(list(orig.keys()) + list(fixed.keys()))):
            if key.startswith("_"):
                continue
            old_val = orig.get(key)
            new_val = fixed.get(key)
            if old_val != new_val and new_val is not None:
                click.echo(
                    f"       {step_name}  {key}: "
                    + click.style(str(old_val or "(empty)"), fg="red")
                    + "  →  "
                    + click.style(str(new_val or "(empty)"), fg="green")
                )

    # Apply via PUT /tests/manual-edit/<testcase_id> with the full fixed steps array.
    # Map internal step fields to the format expected by the manual-edit API:
    #   step_id      ← _flu_id  (identifies the existing step to update)
    #   cssSelector  ← cssSelector
    #   xpathSelector← xpath
    #   actionType   ← actionType
    #   mockInput    ← mockInput
    #   name         ← name
    api_steps: list[dict] = []
    for step in fixed_steps:
        s: dict = {}
        flu_id = step.get("_flu_id") or step.get("flu_id") or ""
        if flu_id:
            s["step_id"] = flu_id
        for src_key, dst_key in (
            ("name", "name"),
            ("cssSelector", "cssSelector"),
            ("xpath", "xpathSelector"),
            ("actionType", "actionType"),
            ("mockInput", "mockInput"),
            ("page_url", "page_url"),
        ):
            val = step.get(src_key)
            if val is not None:
                s[dst_key] = val
        api_steps.append(s)

    async def _apply_edits() -> None:
        from maeris_mcp.maeris.client import MaerisClient
        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        async with MaerisClient() as client:
            await client.edit_manual_testcase(
                tc_id,
                {"steps": api_steps, "replace_all_steps": False},
            )

    try:
        asyncio.run(_apply_edits())
    except Exception as exc:
        click.secho(f"       - Failed to apply step edits: {exc}", fg="yellow")
        return False, 0

    return True, len(edits_to_apply)


def _match_testcases_by_components(
    testcases: list[dict],
    component_data: dict,
    file_tokens: set[str],
) -> list[dict]:
    """Match testcases by comparing their step components against file tokens.

    For each testcase step (component), this checks css_selector, xpath,
    HTML attributes, page_url, text, label and placeholder — giving a precise,
    selector-level answer to 'does this testcase touch the changed UI?'.
    """
    tc_map = {
        m["testcase_id"]: m["component_ids"]
        for m in component_data.get("testcase_component_maps", [])
    }
    comp_map = {
        c["component_id"]: c
        for c in component_data.get("unique_components", [])
    }

    matched: list[dict] = []
    seen: set[str] = set()

    for tc in testcases:
        tc_id = _get_testcase_id(tc)
        if not tc_id or tc_id in seen:
            continue

        component_ids = tc_map.get(tc_id, [])
        # Count how many distinct components in this testcase share tokens with
        # the changed files.  Requiring ≥ 2 components to match prevents a
        # single generic token coincidence from pulling in unrelated testcases.
        matching_components = 0
        for comp_id in component_ids:
            if not comp_id:
                continue
            comp = comp_map.get(comp_id)
            if comp:
                overlap = _extract_component_tokens(comp) & file_tokens
                if len(overlap) >= 2:
                    # At least 2 specific tokens match → strong signal
                    matching_components += 1
                elif overlap:
                    # Only 1 token — count it but require 2+ such components
                    matching_components += 0.5

        if matching_components >= 1:
            matched.append(tc)
            seen.add(tc_id)

    return matched


# ---------------------------------------------------------------------------
# Local Playwright runner helpers
# ---------------------------------------------------------------------------


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


def _extract_failure_reason(output: str) -> str:
    """Extract a concise error message from playwright subprocess output."""
    # Look for the first playwright Error line
    for line in output.splitlines():
        stripped = line.strip()
        if "Error:" in stripped and stripped:
            # Strip module prefix (e.g. "playwright._impl._errors.Error: ...")
            if "Error:" in stripped:
                idx = stripped.find("Error:")
                msg = stripped[idx + len("Error:"):].strip()
                return msg[:120] if msg else stripped[:120]
    return ""
def _generate_local_test_script(
    tc_name: str,
    steps: list[dict],
    slow_mo: int,
    browser: str,
    headless: bool = False,
    action_timeout_ms: int = 10_000,
    nav_timeout_ms: int = 30_000,
) -> str:
    """Generate a standalone Playwright Python script from testcase steps."""
    n = len(steps)
    lines = [
        "from playwright.sync_api import sync_playwright, expect",
        "import sys",
        "",
        f"TC_NAME = {tc_name!r}",
        f"SLOW_MO = {slow_mo}",
        f"BROWSER = {browser!r}",
        "",
        "",
        "def run():",
        "    passed_steps = []",
        "    failed_steps = []",
        "    with sync_playwright() as p:",
        f"        browser_inst = getattr(p, BROWSER).launch(headless={headless!r}, slow_mo=SLOW_MO)",
        "        page = browser_inst.new_page()",
        f"        page.set_default_timeout({action_timeout_ms})",
        f"        page.set_default_navigation_timeout({nav_timeout_ms})",
        "        print(f'\\n  Running: {TC_NAME}')",
        f"        print(f'  {n} step(s)\\n')",
    ]

    for i, step in enumerate(steps, 1):
        action = (step.get("actionType") or step.get("action_type") or "click").lower()
        selector = step.get("cssSelector") or step.get("css_selector") or ""
        xpath = step.get("xpath") or ""
        page_url = step.get("page_url") or ""
        mock_input = step.get("mockInput") or step.get("mock_input") or ""
        step_name = (step.get("name") or f"step {i}").replace("'", "\\'")
        stype = (step.get("type") or "action").lower()
        assertion_nature = (step.get("assertion_nature") or "").lower()
        assertion_value = step.get("assertion_value") or ""

        # Playwright locator expression
        if selector:
            loc_expr = f"page.locator({selector!r})"
        elif xpath:
            loc_expr = f"page.locator({xpath!r})"
        else:
            loc_expr = None

        lines.append(f"        # Step {i}: {step_name}")
        lines.append(f"        try:")

        if action in ("go_to", "navigate", "goto", "open", "url"):
            if page_url:
                lines.append(f"            page.goto({page_url!r})")
            else:
                lines.append(f"            pass  # navigate — no URL provided")
        elif action in ("click", "tap"):
            if loc_expr:
                lines.append(f"            {loc_expr}.click()")
            elif page_url:
                lines.append(f"            page.goto({page_url!r})")
            else:
                lines.append(f"            pass  # click — no selector or URL")
        elif action in ("send_keys", "fill", "type", "input", "enter", "sendkeys"):
            if loc_expr:
                lines.append(f"            {loc_expr}.fill({mock_input!r})")
            else:
                lines.append(f"            pass  # fill — no selector")
        elif action == "check":
            if loc_expr:
                lines.append(f"            {loc_expr}.check()")
            else:
                lines.append(f"            pass  # check — no selector")
        elif action == "uncheck":
            if loc_expr:
                lines.append(f"            {loc_expr}.uncheck()")
            else:
                lines.append(f"            pass  # uncheck — no selector")
        elif action == "hover":
            if loc_expr:
                lines.append(f"            {loc_expr}.hover()")
            else:
                lines.append(f"            pass  # hover — no selector")
        elif action in ("select", "selectoption"):
            if loc_expr:
                lines.append(f"            {loc_expr}.select_option({mock_input!r})")
            else:
                lines.append(f"            pass  # select — no selector")
        elif action in ("scroll", "scroll_down", "scrolldown"):
            lines.append("            page.evaluate('window.scrollBy(0, 300)')")
        elif action in ("scroll_up", "scrollup"):
            lines.append("            page.evaluate('window.scrollBy(0, -300)')")
        elif action == "screenshot":
            lines.append(f"            page.screenshot(path='screenshot_step_{i}.png')")
        elif stype == "verification" or action in ("verify", "assert", "check_text"):
            if loc_expr:
                if "not_visible" in assertion_nature or "hidden" in assertion_nature:
                    lines.append(f"            expect({loc_expr}).not_to_be_visible()")
                elif "visible" in assertion_nature:
                    lines.append(f"            expect({loc_expr}).to_be_visible()")
                elif "text" in assertion_nature or "contain" in assertion_nature:
                    lines.append(f"            expect({loc_expr}).to_contain_text({assertion_value!r})")
                elif "value" in assertion_nature:
                    lines.append(f"            expect({loc_expr}).to_have_value({assertion_value!r})")
                elif "disabled" in assertion_nature:
                    lines.append(f"            expect({loc_expr}).to_be_disabled()")
                elif "enabled" in assertion_nature:
                    lines.append(f"            expect({loc_expr}).to_be_enabled()")
                elif "checked" in assertion_nature:
                    lines.append(f"            expect({loc_expr}).to_be_checked()")
                else:
                    lines.append(f"            expect({loc_expr}).to_be_visible()")
            elif assertion_value:
                lines.append(f"            expect(page.get_by_text({assertion_value!r})).to_be_visible()")
            else:
                lines.append(f"            pass  # verification — no selector or expected value")
        else:
            lines.append(f"            pass  # unhandled action: {action!r}")

        lines.append(f"            passed_steps.append({step_name!r})")
        lines.append(f"            print(f'  PASS Step {i}/{n}: {step_name}')")
        lines.append(f"        except Exception as _e:")
        lines.append(f"            failed_steps.append({step_name!r})")
        lines.append(f"            print(f'  FAIL Step {i}/{n}: {step_name}  -- {{_e}}')")
        lines.append("")

    lines += [
        f"        print('\\n  ' + '-' * 50)",
        f"        print(f'  {{len(passed_steps)}}/{n} steps passed  |  {{TC_NAME}}')",
        "        if failed_steps:",
        "            for s in failed_steps:",
        "                print(f'    FAIL {s}')",
        "        browser_inst.close()",
        "        return len(failed_steps) == 0",
        "",
        "",
        "if __name__ == '__main__':",
        "    ok = run()",
        "    sys.exit(0 if ok else 1)",
    ]

    return "\n".join(lines)


def _run_local_playwright(
    ids_to_run: list[str],
    config_dir: "Path",
    slow_mo: int,
    browser: str,
    testcases_to_run: list[dict] | None = None,
) -> None:
    """Execute tests locally. Tries server-generated scripts first; falls back to
    locally-generated scripts from step data when the download fails (e.g., no credits)."""
    import io
    import subprocess
    import tempfile
    import zipfile

    import re

    raw_browsers = [b.strip().lower() for b in browser.split(",") if b.strip()]
    invalid = [b for b in raw_browsers if b not in _BROWSER_MAP]
    if invalid:
        click.secho(
            f"✗ Invalid browser(s): {', '.join(invalid)}. Choose from: chrome, firefox, safari.",
            fg="red",
        )
        sys.exit(1)
    browsers = [_BROWSER_MAP[b] for b in raw_browsers]

    # 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 is not installed.\n"
            "  Install it with:\n"
            "    pip install playwright\n"
            "    python -m playwright install chromium",
            fg="red",
        )
        sys.exit(1)

    # Build id→tc map
    tc_meta: dict[str, dict] = {}
    if testcases_to_run:
        for tc in testcases_to_run:
            tid = _get_testcase_id(tc)
            if tid:
                tc_meta[tid] = tc

    async def _prepare_download_data() -> bytes:
        from maeris_mcp.maeris.client import MaerisClient
        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        async with MaerisClient() as client:
            if not tc_meta:
                all_tcs = await client.get_testcases()
                for tc in all_tcs:
                    tc_id = _get_testcase_id(tc)
                    if tc_id:
                        tc_meta[tc_id] = tc
            return await client.download_testcase_scripts_zip(
                test_ids=ids_to_run,
                languages=["python"],
                browsers=browsers,
                skip_credits=True,
            )

    def _inject_slow_mo(script_text: str) -> str:
        if slow_mo is None:
            return script_text

        pattern = re.compile(r"(\.launch\()(.*?)(\))", re.DOTALL)

        def _repl(match: re.Match) -> str:
            args = match.group(2)
            if "slow_mo" in args:
                return match.group(0)
            args_str = args.strip()
            if not args_str:
                new_args = f"slow_mo={slow_mo}"
            elif args_str.endswith(","):
                new_args = f"{args_str} slow_mo={slow_mo}"
            else:
                new_args = f"{args_str}, slow_mo={slow_mo}"
            return f"{match.group(1)}{new_args}{match.group(3)}"

        return pattern.sub(_repl, script_text)

    def _run_script(script_path: "Path", tc_name: str, tc_type: str) -> dict:
        try:
            original = script_path.read_text(encoding="utf-8")
            updated = _inject_slow_mo(original)
            if updated != original:
                script_path.write_text(updated, encoding="utf-8")
        except Exception:
            pass

        proc = subprocess.run(
            [py_exec, str(script_path)],
            text=True,
            capture_output=True,
            cwd=str(script_path.parent),
        )
        combined_output = (proc.stdout or "") + (proc.stderr or "")
        if proc.returncode == 0:
            click.secho("  passed", fg="green")
            return {"name": tc_name, "type": tc_type, "status": "passed", "reason": ""}
        else:
            click.secho("  failed", fg="red")
            reason = _extract_failure_reason(combined_output)
            return {"name": tc_name, "type": tc_type, "status": "failed", "reason": reason}

    started_at = time.perf_counter()

    # --- Attempt 1: download server-generated scripts ---
    zip_bytes: bytes | None = None
    try:
        with _spinner("Downloading server-generated scripts…"):
            zip_bytes = asyncio.run(_prepare_download_data())
    except Exception as dl_err:
        click.secho(
            f"  ⚠ Server script download failed ({dl_err}); generating locally from step data…",
            fg="yellow",
        )

    if zip_bytes is not None:
        results: list[dict] = []
        with tempfile.TemporaryDirectory(prefix="maeris_local_dl_") as tmpdir:
            root = Path(tmpdir)
            with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
                py_members = sorted(
                    n for n in zf.namelist()
                    if not n.endswith("/") and n.lower().endswith(".py")
                )
                if not py_members:
                    click.secho(
                        "  ⚠ Downloaded archive has no Python scripts; generating locally…",
                        fg="yellow",
                    )
                    zip_bytes = None  # fall through to local generation
                else:
                    zf.extractall(root)
                    for rel_path in py_members:
                        script_path = root / rel_path
                        if not script_path.exists():
                            continue
                        stem = script_path.stem
                        tc_id = stem.rsplit("_", 1)[0] if "_" in stem else stem
                        meta = tc_meta.get(tc_id, {})
                        tc_name = meta.get("name") or meta.get("testcase_name") or tc_id
                        tc_type = meta.get("test_type") or "-"
                        click.echo(f"  Running: {tc_name} …", nl=False)
                        results.append(_run_script(script_path, tc_name, tc_type))

        if zip_bytes is not None:
            elapsed_s = time.perf_counter() - started_at
            _print_local_run_table(results, elapsed_s)
            if any(r["status"] == "failed" for r in results):
                sys.exit(1)
            return

    # --- Fallback: generate Playwright scripts locally from step data ---
    from maeris_mcp.cli.fix_cmds import (
        _fetch_testcase_steps_rich,
    )

    if not tc_meta:
        async def _fetch_all():
            from maeris_mcp.maeris.client import MaerisClient
            os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
            async with MaerisClient() as client:
                return await client.get_testcases()

        try:
            with _spinner("Fetching test cases…"):
                all_tcs = asyncio.run(_fetch_all())
            for tc in all_tcs:
                tc_id = _get_testcase_id(tc)
                if tc_id:
                    tc_meta[tc_id] = tc
        except Exception as e:
            click.secho(f"✗ Failed to fetch test cases: {e}", fg="red")
            sys.exit(1)

    results = []
    if len(browsers) > 1:
        click.secho(
            f"Warning: Multiple browsers requested ({', '.join(raw_browsers)}); "
            f"local generation will use the first: {raw_browsers[0]}",
            fg="yellow",
        )
    local_browser = browsers[0] if browsers else "chromium"
    for tc_id in ids_to_run:
        tc = tc_meta.get(tc_id, {"testcase_id": tc_id})
        tc_name = tc.get("name") or tc.get("testcase_name") or tc_id
        tc_type = tc.get("test_type") or "-"

        steps = _fetch_testcase_steps_rich(tc, config_dir)
        if not steps:
            click.secho(f"  ⚠ {tc_name}: no steps found — skipped", fg="yellow")
            results.append({"name": tc_name, "type": tc_type, "status": "failed", "reason": "No steps found"})
            continue

        script_content = _generate_local_test_script(
            tc_name,
            steps,
            slow_mo=slow_mo,
            browser=local_browser,
            headless=False,
        )
        click.echo(f"  Running: {tc_name} …", nl=False)
        with tempfile.TemporaryDirectory(prefix="maeris_local_") as tmpdir:
            script_path = Path(tmpdir) / "test_local.py"
            script_path.write_text(script_content, encoding="utf-8")
            results.append(_run_script(script_path, tc_name, tc_type))

    elapsed_s = time.perf_counter() - started_at
    _print_local_run_table(results, elapsed_s)
    if any(r["status"] == "failed" for r in results):
        sys.exit(1)


def _print_local_run_table(results: list[dict], elapsed_s: float) -> None:
    """Print a per-test result table for local Playwright runs."""
    passed = sum(1 for r in results if r["status"] == "passed")
    failed = len(results) - passed

    name_w = max(len("Name"), max((len(r["name"]) for r in results), default=0))
    type_w = max(len("Type"), max((len(r["type"]) for r in results), default=0))
    reason_w = max(len("Reason"), max((len(r["reason"]) for r in results), default=0))
    sep = "─" * (name_w + type_w + reason_w + 22)

    click.echo(f"\n  {'Name':<{name_w}}  {'Type':<{type_w}}  {'Result':<12}  Reason")
    click.echo(f"  {sep}")
    for r in results:
        if r["status"] == "passed":
            result_str = click.style("✓  passed", fg="green")
        else:
            result_str = click.style("✗  failed", fg="red")
        reason = r["reason"][:80] if r["reason"] else ""
        click.echo(f"  {r['name']:<{name_w}}  {r['type']:<{type_w}}  {result_str:<12}  {reason}")

    click.echo()
    overall_fg = "green" if failed == 0 else "red"
    click.secho(
        f"  Local run: {passed} passed  ·  {failed} failed  ·  {len(results)} total  ·  {elapsed_s:.2f}s",
        fg=overall_fg,
    )


def _print_run_table(
    testcases_to_run: list[dict],
    result: dict,
    elapsed_s: float,
    type_of_test_run: str,
) -> None:
    """Print a table showing individual test results after a cloud run."""
    overall_status = (result.get("status") or "").lower()
    exec_id = result.get("test_execution_id") or result.get("execution_id") or ""
    message = result.get("message") or ""

    # We only know per-test pass/fail when the overall status is "success"
    # (all passed). On "failed" we don't know which individual tests failed —
    # show ? and direct the user to the portal for details.
    if overall_status == "success":
        row_result = click.style("✓  passed", fg="green")
    elif overall_status == "failed":
        row_result = click.style("?  see portal", fg="yellow")
    else:
        row_result = click.style("?  unknown", fg="yellow")

    rows = [
        (tc.get("name") or tc.get("id") or "-", tc.get("test_type") or "-")
        for tc in testcases_to_run
    ]
    name_w = max(len("Name"), max((len(r[0]) for r in rows), default=0))
    type_w = max(len("Type"), max((len(r[1]) for r in rows), default=0))
    sep = "─" * (name_w + type_w + 16)

    click.echo(f"\n  {'Name':<{name_w}}  {'Type':<{type_w}}  Result")
    click.echo(f"  {sep}")
    for name, tc_type in rows:
        click.echo(f"  {name:<{name_w}}  {tc_type:<{type_w}}  {row_result}")

    click.echo()
    if exec_id:
        click.echo(f"  Execution ID: {exec_id}")
    if message:
        click.echo(f"  Message: {message}")
    status_fg = "green" if overall_status == "success" else ("red" if overall_status == "failed" else "yellow")
    click.secho(
        f"  Status: {overall_status or '?'}  ·  {len(testcases_to_run)} test(s)  ·  {elapsed_s:.2f}s",
        fg=status_fg,
    )


@test_group.command("run")
@click.option("--type", "type_of_test_run", default="manual", show_default=True, help="Type of test run.")
@click.option("--smoke", "filter_smoke", is_flag=True, default=False, help="Run all smoke testcases.")
@click.option("--regression", "-r", "filter_regression", is_flag=True, default=False, help="Run all regression testcases.")
@click.option("--all", "-a", "run_all", is_flag=True, default=False, help="Run every testcase.")
@click.option("--name", "-n", "name_filter", default="", help="Filter by testcase name substring (case-insensitive).")
@click.option("--folder", "-f", "folder_filter", default="", help="Run all testcases in a folder/group (case-insensitive name match).")
@click.option(
    "--affected", "-A",
    "affected",
    is_flag=True,
    default=False,
    help="Run only testcases whose functionality is affected by changes on the current branch.",
)
@click.option(
    "--base", "-b",
    "base_branch",
    default="master",
    show_default=True,
    help="Base branch to diff against when using --affected.",
)
@click.option(
    "--local", "-l",
    "local",
    is_flag=True,
    default=False,
    help="Execute steps locally in a headed Playwright browser instead of running on Maeris.",
)
@click.option(
    "--slow-mo",
    "slow_mo",
    default=800,
    show_default=True,
    help="Milliseconds between each Playwright step (visual pacing). Only used with --local.",
)
@click.option(
    "--browser", "-B",
    "browser",
    default="chrome",
    show_default=True,
    help="Browser(s) to use for local Playwright execution. Comma-separated list of: chrome, firefox, safari. Only used with --local.",
)
def run_tests_cmd(
    type_of_test_run: str,
    filter_smoke: bool,
    filter_regression: bool,
    run_all: bool,
    name_filter: str,
    folder_filter: str,
    affected: bool,
    base_branch: str,
    local: bool,
    slow_mo: int,
    browser: str,
) -> None:
    """Run testcases interactively - filter by type, origin, name, group, or pick manually."""
    config_dir = _repo_config_dir()
    token_store = TokenStore(config_dir=config_dir)
    # Initialised here so it's always in scope at the results section below,
    # regardless of which filter path populated it.
    testcases: list[dict] = []

    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)

    async def _fetch_testcases() -> 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_testcases()

    async def _fetch_groups() -> 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_testcase_groups()

    async def _fetch_components(testcase_ids: list[str]) -> 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_components_for_testcases(testcase_ids)

    def _confirm_run(count: int, label: str) -> None:
        """Ask for confirmation when running more than 5 testcases."""
        if count <= 5:
            return
        try:
            ok = click.confirm(
                f"About to run {count} testcase(s) for {label}. Continue?",
                default=False,
            )
        except click.Abort:
            click.echo("\nAborted.")
            sys.exit(0)
        if not ok:
            click.echo("Cancelled.")
            sys.exit(0)

    # Determine filter_idx from flags; otherwise show interactive menu.
    if filter_smoke or filter_regression:
        filter_idx = 0   # by type - flags already tell us which types
    elif name_filter:
        filter_idx = 2   # by name
    elif folder_filter:
        filter_idx = 3   # by group
    elif run_all:
        filter_idx = 4   # browse all - select all automatically
    elif affected:
        filter_idx = 5   # by branch diff
    else:
        # --- Step 1: pick filter strategy ---
        FILTER_OPTIONS = [
            "By type   (smoke / regression)",
            "By origin (manual / record / default)",
            "By name   (search by name)",
            "By group  (select a test group)",
            "By tests  (browse and pick from all testcases)",
        ]
        click.echo("\nHow would you like to filter tests?")
        (filter_idx,) = _prompt_numbered(FILTER_OPTIONS, "Select filter", multi=False)

    ids_to_run: list[str] = []
    testcases_to_run: list[dict] = []

    # --- Path 0: By type ---
    if filter_idx == 0:
        try:
            with _spinner("Fetching tests…"):

                testcases = asyncio.run(_fetch_testcases())
        except Exception as e:
            click.secho(f"- Failed to fetch testcases: {e}", fg="red")
            sys.exit(1)
        smoke_count = sum(1 for tc in testcases if tc.get("test_type") == "smoke")
        regression_count = sum(1 for tc in testcases if tc.get("test_type") == "regression")
        if filter_smoke and filter_regression:
            chosen_types = {"smoke", "regression"}
        elif filter_smoke:
            chosen_types = {"smoke"}
        elif filter_regression:
            chosen_types = {"regression"}
        else:
            type_opts = [
                f"smoke ({smoke_count})",
                f"regression ({regression_count})",
            ]
            (tidx,) = _prompt_numbered(type_opts, "Select type", multi=False)
            chosen_types = {"smoke"} if tidx == 0 else {"regression"}
        chosen_type = " / ".join(sorted(chosen_types))
        ids_to_run = [
            _get_testcase_id(tc)
            for tc in testcases
            if tc.get("test_type") in chosen_types
        ]
        testcases_to_run = [tc for tc in testcases if tc.get("test_type") in chosen_types]
        if not ids_to_run:
            click.secho(f"- No {chosen_type} testcases found.", fg="yellow")
            sys.exit(1)
        click.echo(f"Running {len(ids_to_run)} {chosen_type} testcase(s)...")
        _confirm_run(len(ids_to_run), chosen_type)

    # --- Path 1: By origin ---
    elif filter_idx == 1:
        try:
            with _spinner("Fetching tests…"):

                testcases = asyncio.run(_fetch_testcases())
        except Exception as e:
            click.secho(f"- Failed to fetch testcases: {e}", fg="red")
            sys.exit(1)
        manual_count = sum(1 for tc in testcases if tc.get("test_origin") == "manual")
        record_count = sum(1 for tc in testcases if tc.get("test_origin") == "record")
        default_count = sum(1 for tc in testcases if tc.get("test_origin") == "default")
        origin_opts = [
            f"manual ({manual_count})",
            f"record ({record_count})",
            f"default ({default_count})",
        ]
        (oidx,) = _prompt_numbered(origin_opts, "Select origin", multi=False)
        chosen_origin = ["manual", "record", "default"][oidx]
        ids_to_run = [_get_testcase_id(tc) for tc in testcases if tc.get("test_origin") == chosen_origin]
        testcases_to_run = [tc for tc in testcases if tc.get("test_origin") == chosen_origin]
        if not ids_to_run:
            click.secho(f"- No {chosen_origin} testcases found.", fg="yellow")
            sys.exit(1)
        click.echo(f"Running {len(ids_to_run)} {chosen_origin} testcase(s)...")
        _confirm_run(len(ids_to_run), chosen_origin)

    # --- Path 2: By name ---
    elif filter_idx == 2:
        try:
            with _spinner("Fetching tests…"):

                testcases = asyncio.run(_fetch_testcases())
        except Exception as e:
            click.secho(f"- Failed to fetch testcases: {e}", fg="red")
            sys.exit(1)
        if name_filter:
            needle = name_filter.strip().lower()
        else:
            try:
                needle = click.prompt("Search name").strip().lower()
            except click.Abort:
                click.echo("\nAborted.")
                sys.exit(0)
        pool = [tc for tc in testcases if needle in (tc.get("name") or "").lower()]
        if not pool:
            click.secho("- No testcases match that name.", fg="yellow")
            sys.exit(1)
        labels = [f"{tc.get('name')}  [{tc.get('test_type') or '-'}]" for tc in pool]
        if name_filter:
            idxs = list(range(len(pool)))
        else:
            idxs = _prompt_numbered(labels, "Select", multi=True)
        if not idxs:
            click.secho("- No valid selection.", fg="red")
            sys.exit(1)
        ids_to_run = [_get_testcase_id(pool[i]) for i in idxs]
        testcases_to_run = [pool[i] for i in idxs]
        click.echo(f"Running {len(ids_to_run)} testcase(s)...")
        _confirm_run(len(ids_to_run), "name filter")

    # --- Path 3: By group ---
    elif filter_idx == 3:
        click.echo("Fetching test groups...")
        try:
            with _spinner("Fetching folders…"):

                groups = asyncio.run(_fetch_groups())
            with _spinner("Fetching tests…"):

                testcases = asyncio.run(_fetch_testcases())
        except Exception as e:
            click.secho(f"- Failed to fetch data: {e}", fg="red")
            sys.exit(1)
        if not groups:
            click.secho("- No test groups found.", fg="yellow")
            sys.exit(1)

        def _group_name(g: dict) -> str:
            return g.get("testcase_group_name") or g.get("name") or g.get("id") or ""

        def _group_id(g: dict) -> str:
            return g.get("testcase_group_id") or g.get("id") or g.get("_id") or ""

        # Count testcases per group for display
        group_counts: dict[str, int] = {}
        for tc in testcases:
            tc_group = (
                tc.get("testcase_group_id")
                or tc.get("group_id")
                or tc.get("group")
                or ""
            )
            if tc_group:
                group_counts[tc_group] = group_counts.get(tc_group, 0) + 1
        if folder_filter:
            needle = folder_filter.strip().lower()
            selected_groups = [g for g in groups if needle in _group_name(g).lower()]
            if not selected_groups:
                click.secho(f"- No folder matching '{folder_filter}' found.", fg="yellow")
                sys.exit(1)
        else:
            labels = [
                f"{_group_name(g)} ({group_counts.get(_group_id(g), 0)})"
                for g in groups
            ]
            idxs = _prompt_numbered(labels, "Select group", multi=True)
            if not idxs:
                click.secho("- No valid selection.", fg="red")
                sys.exit(1)
            selected_groups = [groups[i] for i in idxs]
        selected_group_ids = {_group_id(g) for g in selected_groups}
        selected_names = ", ".join(_group_name(g) for g in selected_groups)

        # Filter testcases by group field
        for tc in testcases:
            tc_group = (
                tc.get("testcase_group_id")
                or tc.get("group_id")
                or tc.get("group")
                or ""
            )
            if tc_group in selected_group_ids:
                ids_to_run.append(_get_testcase_id(tc))
                testcases_to_run.append(tc)

        # Fallback: groups may carry their own testcase list
        if not ids_to_run:
            for g in selected_groups:
                for tc in g.get("testcases") or g.get("tests") or []:
                    tc_id = tc if isinstance(tc, str) else _get_testcase_id(tc)
                    if tc_id:
                        ids_to_run.append(tc_id)
                        testcases_to_run.append(
                            tc if isinstance(tc, dict) else {"name": tc_id, "test_type": "-"}
                        )

        if not ids_to_run:
            click.secho(f"- No testcases found in group(s): {selected_names}.", fg="yellow")
            sys.exit(1)
        click.echo(f'Running {len(ids_to_run)} testcase(s) from "{selected_names}"...')
        _confirm_run(len(ids_to_run), f"group(s): {selected_names}")

    # --- Path 5: By branch diff ---
    elif filter_idx == 5:
        import subprocess

        click.echo(f"\nComputing git diff vs {base_branch!r}…")
        proc = subprocess.run(
            ["git", "diff", f"{base_branch}...HEAD", "--name-only"],
            capture_output=True,
            text=True,
        )
        if proc.returncode != 0:
            # Fallback: try two-dot diff (useful when base is not an ancestor)
            proc = subprocess.run(
                ["git", "diff", base_branch, "HEAD", "--name-only"],
                capture_output=True,
                text=True,
            )
        if proc.returncode != 0:
            click.secho(
                f"✗ git diff failed: {proc.stderr.strip() or 'not a git repo or branch not found'}",
                fg="red",
            )
            sys.exit(1)

        committed_files = set(f.strip() for f in proc.stdout.splitlines() if f.strip())

        # Also include uncommitted changes (staged + unstaged) so that files
        # modified in the working tree are detected even before they are committed.
        staged_proc = subprocess.run(
            ["git", "diff", "--cached", "--name-only"],
            capture_output=True,
            text=True,
        )
        unstaged_proc = subprocess.run(
            ["git", "diff", "--name-only"],
            capture_output=True,
            text=True,
        )
        staged_files = set(f.strip() for f in staged_proc.stdout.splitlines() if f.strip()) if staged_proc.returncode == 0 else set()
        unstaged_files = set(f.strip() for f in unstaged_proc.stdout.splitlines() if f.strip()) if unstaged_proc.returncode == 0 else set()

        changed_files = sorted(committed_files | staged_files | unstaged_files)
        if not changed_files:
            click.secho(
                f"✗ No files changed between {base_branch!r} and HEAD.",
                fg="yellow",
            )
            sys.exit(0)

        click.echo(f"  {len(changed_files)} file(s) changed.")

        areas = _derive_areas_from_paths(changed_files)
        if not areas:
            click.secho("✗ Could not derive testable area names from the changed files.", fg="yellow")
            sys.exit(1)

        click.echo(f"  Detected {len(areas)} area(s): {', '.join(areas[:8])}"
                   + (" …" if len(areas) > 8 else ""))

        try:
            with _spinner("Fetching tests…"):

                testcases = asyncio.run(_fetch_testcases())
        except Exception as e:
            click.secho(f"✗ Failed to fetch testcases: {e}", fg="red")
            sys.exit(1)

        # --- Component-based matching (precise) ---
        # Extract identifiers from the actual file content (CSS classes, IDs, etc.)
        # then compare against each testcase's recorded step components.
        # component_data and file_tokens are kept in scope for post-run
        # failure classification (see below).
        file_tokens = _extract_tokens_from_files(changed_files)
        all_tc_ids = [_get_testcase_id(tc) for tc in testcases if _get_testcase_id(tc)]

        matched = []
        used_component_matching = False
        component_data: dict = {}  # populated below if component matching succeeds
        if all_tc_ids and file_tokens:
            try:
                click.echo("  Fetching testcase component data…")
                component_data = asyncio.run(_fetch_components(all_tc_ids))
                matched = _match_testcases_by_components(testcases, component_data, file_tokens)
                used_component_matching = True
            except Exception as e:
                click.secho(
                    f"  ⚠ Component matching unavailable ({e}); falling back to name matching.",
                    fg="yellow",
                )

        # --- Fallback: area/name-based matching ---
        if not used_component_matching:
            areas = _derive_areas_from_paths(changed_files)
            if not areas:
                click.secho("✗ Could not derive testable area names from the changed files.", fg="yellow")
                sys.exit(1)
            click.echo(f"  Detected {len(areas)} area(s): {', '.join(areas[:8])}"
                       + (" …" if len(areas) > 8 else ""))
            matched = _match_testcases_by_areas(testcases, areas)

        if not matched:
            click.secho(
                "✗ No testcases matched the affected files.\n"
                "  Tip: make sure your testcases have been recorded against the running app,\n"
                "  or use --folder / --name to select tests manually.",
                fg="yellow",
            )
            sys.exit(1)

        match_method = "components" if used_component_matching else "areas"
        click.echo(f"\n  {len(matched)} testcase(s) matched by {match_method}:\n")
        for tc in matched:
            func = tc.get("test_case_functionality") or tc.get("testcase_functionality") or ""
            click.echo(
                f"    {click.style('•', fg='cyan')}  "
                f"{tc.get('name') or '-'}"
                + (f"  [{func}]" if func else "")
            )
        click.echo()

        ids_to_run = [_get_testcase_id(tc) for tc in matched if _get_testcase_id(tc)]
        testcases_to_run = [tc for tc in matched if _get_testcase_id(tc)]
        _confirm_run(len(ids_to_run), f"affected areas ({base_branch}…HEAD)")

    # --- Path 4: By tests (browse all) ---
    else:
        try:
            with _spinner("Fetching tests…"):

                testcases = asyncio.run(_fetch_testcases())
        except Exception as e:
            click.secho(f"- Failed to fetch testcases: {e}", fg="red")
            sys.exit(1)
        if not testcases:
            click.secho("- No testcases found.", fg="yellow")
            sys.exit(1)
        if run_all:
            idxs = list(range(len(testcases)))
        else:
            labels = [
                f"{tc.get('name')}  [{tc.get('test_type') or '-'} / {tc.get('test_origin') or '-'}]"
                for tc in testcases
            ]
            idxs = _prompt_numbered(labels, "Select", multi=True)
        if not idxs:
            click.secho("- No valid selection.", fg="red")
            sys.exit(1)
        ids_to_run = [_get_testcase_id(testcases[i]) for i in idxs]
        testcases_to_run = [testcases[i] for i in idxs]
        click.echo(f"Running {len(ids_to_run)} testcase(s)...")
        _confirm_run(len(ids_to_run), "selected tests")

    # --- Local Playwright run ---
    if local:
        _run_local_playwright(ids_to_run, config_dir, slow_mo, browser, testcases_to_run)
        return

    # --- Run (cloud) ---
    async def _run_ids(ids: list[str], exec_id: str | None = None) -> dict:
        from maeris_mcp.maeris.client import MaerisClient

        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        async with MaerisClient() as client:
            return await client.run_testcases(
                test_ids=ids,
                test_execution_id=exec_id,
                type_of_test_run=type_of_test_run,
            )

    click.echo(f"\n  Sending {len(ids_to_run)} testcase(s) to Maeris…")
    try:
        started_at = time.perf_counter()
        with _spinner("Running tests on Maeris…"):

            result = asyncio.run(_run_ids(ids_to_run))
        elapsed_s = time.perf_counter() - started_at
    except Exception as e:
        click.secho(f"- Failed to run testcases: {e}", fg="red")
        sys.exit(1)

    click.echo(f"  Tests completed in {round(elapsed_s, 1)}s.")

    # -----------------------------------------------------------------------
    # --affected: detailed pass/fail display + "was this intended?" follow-up
    # -----------------------------------------------------------------------
    if affected and _is_tty() and isinstance(result, dict):
        passed_ids, failed_ids = _display_run_results(result, ids_to_run, testcases, elapsed_s)

        if not failed_ids:
            click.echo(
                f"  {click.style('✓ All tests passed!', fg='green', bold=True)}"
            )
        else:
            # ------------------------------------------------------------------
            # Classify failures as related/unrelated to the user's code changes.
            # Only possible when we have component_data (primary matching path).
            # ------------------------------------------------------------------
            name_by_id: dict[str, str] = {
                _get_testcase_id(tc): (tc.get("name") or _get_testcase_id(tc) or "")
                for tc in testcases
                if _get_testcase_id(tc)
            }

            related_failures: list[dict] = []
            unrelated_failures: list[dict] = []
            unknown_failures: list[dict] = []

            can_classify = bool(component_data and file_tokens)
            for tc_id in failed_ids:
                tc_name = name_by_id.get(tc_id, tc_id)
                if can_classify:
                    classification, step_label, page_name = _classify_failure(
                        tc_id, result, component_data, file_tokens
                    )
                else:
                    classification, step_label, page_name = "unknown", None, None

                entry = {
                    "id": tc_id,
                    "name": tc_name,
                    "step_label": step_label,
                    "page_name": page_name,
                }
                if classification == "related":
                    related_failures.append(entry)
                elif classification == "unrelated":
                    unrelated_failures.append(entry)
                else:
                    unknown_failures.append(entry)

            # Show differentiated failure sections when classification is available
            if can_classify and (related_failures or unrelated_failures):
                if unrelated_failures:
                    click.echo(
                        f"  {click.style('⚠', fg='yellow', bold=True)}  "
                        f"{len(unrelated_failures)} test(s) failed at step(s) "
                        f"{click.style('NOT related', fg='yellow', bold=True)} to your changes:\n"
                    )
                    for f in unrelated_failures:
                        page_info = f"  on '{f['page_name']}'" if f.get("page_name") else ""
                        click.echo(
                            f"    {click.style('⚠', fg='yellow')}  {f['name']}"
                        )
                        if f.get("step_label"):
                            click.echo(
                                f"       Failed at: "
                                f"{click.style(repr(f['step_label']), fg='yellow')}"
                                f"{page_info}"
                            )
                    click.echo()

                if related_failures:
                    click.echo(
                        f"  {len(related_failures)} test(s) failed at step(s) "
                        f"{click.style('related to your changes', fg='red', bold=True)}:\n"
                    )
                    for f in related_failures:
                        page_info = f"  on '{f['page_name']}'" if f.get("page_name") else ""
                        click.echo(
                            f"    {click.style('✗', fg='red', bold=True)}  {f['name']}"
                        )
                        if f.get("step_label"):
                            click.echo(
                                f"       Failed at: "
                                f"{click.style(repr(f['step_label']), fg='red')}"
                                f"{page_info}"
                            )
                    click.echo()

                if unknown_failures:
                    click.echo(
                        f"  {len(unknown_failures)} test(s) with unclassified failures:\n"
                    )
                    for f in unknown_failures:
                        click.echo(f"    {click.style('?', fg='yellow')}  {f['name']}")
                    click.echo()

            # Ask whether the failures were expected
            try:
                intended = click.confirm(
                    f"  {len(failed_ids)} test(s) failed — were these failures expected?",
                    default=False,
                )
            except click.Abort:
                click.echo("\nAborted.")
                sys.exit(0)

            if intended:
                click.echo("  OK — marking as known failures and exiting.")
            else:
                # Build ids_to_fix: always include related + unknown; optionally unrelated
                ids_to_fix = (
                    [f["id"] for f in related_failures]
                    + [f["id"] for f in unknown_failures]
                )

                if unrelated_failures:
                    try:
                        fix_unrelated = click.confirm(
                            f"  {len(unrelated_failures)} test(s) failed before reaching your "
                            f"changed code — likely a pre-existing issue.\n"
                            f"  Do you want to fix those too?",
                            default=False,
                        )
                    except click.Abort:
                        click.echo("\nAborted.")
                        sys.exit(0)
                    if fix_unrelated:
                        ids_to_fix += [f["id"] for f in unrelated_failures]

                if not ids_to_fix:
                    # Nothing left to fix (user declined all)
                    click.echo("  No tests selected for fixing.")
                else:
                    # ----------------------------------------------------------
                    # Auto-fix: use Claude CLI + changed source files to repair
                    # failing testcase step selectors, then re-run immediately.
                    # ----------------------------------------------------------
                    from maeris_mcp.cli.fix_cmds import _ai_fix_testcase

                    tc_by_id: dict[str, dict] = {
                        _get_testcase_id(tc): tc for tc in testcases if _get_testcase_id(tc)
                    }

                    click.echo(f"\n  Fixing {len(ids_to_fix)} testcase(s)…")
                    for tc_id in ids_to_fix:
                        tc_name = name_by_id.get(tc_id, tc_id)
                        tc_obj = tc_by_id.get(tc_id)
                        click.echo(
                            f"\n  {click.style('—', fg='cyan')} Fixing "
                            f"{click.style(repr(tc_name), bold=True)}…"
                        )
                        if tc_obj is None:
                            click.secho(
                                f"    ✗  '{tc_name}' — testcase metadata not found, skipping.",
                                fg="yellow",
                            )
                            continue
                        _ai_fix_testcase(tc_obj, config_dir)

                    # Re-run immediately — no user prompt needed
                    click.echo(f"\n  Re-running {len(ids_to_fix)} testcase(s)…")
                    try:
                        rerun_start = time.perf_counter()
                        rerun_result = asyncio.run(_run_ids(ids_to_fix))
                        rerun_elapsed = time.perf_counter() - rerun_start
                    except Exception as e:
                        click.secho(f"  ✗ Re-run failed: {e}", fg="red")
                        sys.exit(1)

                    rerun_passed, rerun_failed = _display_run_results(
                        rerun_result, ids_to_fix, testcases, rerun_elapsed
                    )
                    if not rerun_failed:
                        click.echo(
                            f"  {click.style('✓ All re-run tests passed!', fg='green', bold=True)}"
                        )
                    else:
                        click.secho(
                            f"  {len(rerun_failed)} test(s) still failing after auto-fix.",
                            fg="red",
                        )
                        click.echo(
                            "  Run 'maeris fix tests' for manual step-by-step inspection."
                        )

    # -----------------------------------------------------------------------
    # All other filter paths: keep original compact summary
    # -----------------------------------------------------------------------
    elif _is_tty() and isinstance(result, dict):
        summary: dict = {
            "testsRequested": len(ids_to_run),
            "type": type_of_test_run,
            "durationSec": round(elapsed_s, 2),
        }
        for key in ("test_execution_id", "execution_id", "status", "message", "tests_count"):
            if key in result and result.get(key) is not None:
                summary[key] = result.get(key)
        _print_record(summary)
    else:
        _print_json(result)
        click.echo(f"Run duration: {elapsed_s:.2f}s")


# ---------------------------------------------------------------------------
# list tests / test / folder / folders
# ---------------------------------------------------------------------------


@test_group.command("list")
@click.option("--include-name", "include_name", is_flag=True, help="Include testcase name in output.")
@click.option("--include-id", "include_id", is_flag=True, help="Include testcase id in output.")
@click.option("--include-origin", "include_origin", is_flag=True, help="Include testcase origin in output.")
@click.option("--include-path", "include_path", is_flag=True, help="Include testcase path in output.")
@click.option("--origin", "-o", "filter_origin", default="", help="Filter by origin (manual / record / default).")
@click.option("--name", "-n", "name_filter", default="", help="Filter by name substring (case-insensitive).")
@click.option("--search", "-q", "search", default="", help="Alias for --name.")
@click.option("--test-type", "test_type", default="", help="Filter by type (smoke, regression, negative — comma-separated).")
@click.option("--smoke", "filter_smoke", is_flag=True, help="Shorthand for --test-type smoke.")
@click.option("--regression", "-r", "filter_regression", is_flag=True, help="Shorthand for --test-type regression.")
@click.option("--folder", "-f", "folder_filter", default="", help="Filter by folder/group name (case-insensitive substring match).")
@click.option("--all", "-a", "list_all", is_flag=True, help="List all testcases without interactive filter menu.")
def list_tests_cmd(
    include_name: bool,
    include_id: bool,
    include_origin: bool,
    include_path: bool,
    filter_origin: str,
    name_filter: str,
    search: str,
    test_type: str,
    filter_smoke: bool,
    filter_regression: bool,
    folder_filter: str,
    list_all: bool,
) -> None:
    """List testcases for the current app/user."""
    group_id = ""
    if folder_filter:
        group_id = _resolve_folder_to_group_id(folder_filter, _repo_config_dir())
    _list_tests(
        include_name, include_id, include_origin, include_path,
        filter_origin, name_filter or search, test_type,
        filter_smoke, filter_regression, list_all,
        _group_id_filter=group_id,
    )


@folder_group.command("list")
@click.option("--name", "-n", "name_filter", default="", help="Filter folders by name (substring match, case-insensitive).")
@click.option("--parent-id", "include_parent_id", is_flag=True, help="Include parent folder id in output.")
def list_folders_cmd(name_filter: str, include_parent_id: bool) -> None:
    """List testcase folders for the current app/user."""
    _list_folders(name_filter, include_parent_id)


# ---------------------------------------------------------------------------
# _delete_tests helper
# ---------------------------------------------------------------------------


def _delete_tests(
    filter_smoke: bool,
    filter_regression: bool,
    filter_origin: str,
    name_filter: str,
    delete_all: bool,
) -> None:
    """Core logic for 'maeris delete tests'."""
    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)

    async def _fetch_testcases() -> 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_testcases()

    async def _fetch_groups() -> 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_testcase_groups()

    # Determine filter path from flags.
    if filter_smoke or filter_regression:
        filter_idx = 0   # by type
    elif filter_origin:
        filter_idx = 1   # by origin
    elif name_filter:
        filter_idx = 2   # by name
    elif delete_all:
        filter_idx = 5   # all
    else:
        FILTER_OPTIONS = [
            "By type   (smoke / regression)",
            "By origin (manual / record / default)",
            "By name   (search by name)",
            "By group  (select a test group)",
            "By tests  (browse and pick from all testcases)",
            "All",
        ]
        click.echo("\nHow would you like to select tests to delete?")
        (filter_idx,) = _prompt_numbered(FILTER_OPTIONS, "Select filter", multi=False)

    ids_to_delete: list[str] = []

    # --- Path 0: By type ---
    if filter_idx == 0:
        try:
            with _spinner("Fetching tests…"):

                testcases = asyncio.run(_fetch_testcases())
        except Exception as e:
            click.secho(f"✗ Failed to fetch testcases: {e}", fg="red")
            sys.exit(1)
        smoke_count = sum(1 for tc in testcases if tc.get("test_type") == "smoke")
        regression_count = sum(1 for tc in testcases if tc.get("test_type") == "regression")
        if filter_smoke and filter_regression:
            chosen_types: set[str] = {"smoke", "regression"}
        elif filter_smoke:
            chosen_types = {"smoke"}
        elif filter_regression:
            chosen_types = {"regression"}
        else:
            type_opts = [
                f"smoke ({smoke_count})",
                f"regression ({regression_count})",
            ]
            (tidx,) = _prompt_numbered(type_opts, "Select type", multi=False)
            chosen_types = {"smoke"} if tidx == 0 else {"regression"}
        chosen_type = " / ".join(sorted(chosen_types))
        ids_to_delete = [
            _get_testcase_id(tc)
            for tc in testcases
            if tc.get("test_type") in chosen_types and _get_testcase_id(tc)
        ]
        if not ids_to_delete:
            click.secho(f"✗ No {chosen_type} testcases found.", fg="yellow")
            sys.exit(1)
        click.echo(f"Deleting {len(ids_to_delete)} {chosen_type} testcase(s)...")

    # --- Path 1: By origin ---
    elif filter_idx == 1:
        try:
            with _spinner("Fetching tests…"):

                testcases = asyncio.run(_fetch_testcases())
        except Exception as e:
            click.secho(f"✗ Failed to fetch testcases: {e}", fg="red")
            sys.exit(1)
        if filter_origin:
            chosen_origin = filter_origin.strip().lower()
        else:
            manual_count = sum(1 for tc in testcases if tc.get("test_origin") == "manual")
            record_count = sum(1 for tc in testcases if tc.get("test_origin") == "record")
            default_count = sum(1 for tc in testcases if tc.get("test_origin") == "default")
            origin_opts = [
                f"manual ({manual_count})",
                f"record ({record_count})",
                f"default ({default_count})",
            ]
            (oidx,) = _prompt_numbered(origin_opts, "Select origin", multi=False)
            chosen_origin = ["manual", "record", "default"][oidx]
        ids_to_delete = [
            _get_testcase_id(tc)
            for tc in testcases
            if tc.get("test_origin") == chosen_origin and _get_testcase_id(tc)
        ]
        if not ids_to_delete:
            click.secho(f"✗ No {chosen_origin} testcases found.", fg="yellow")
            sys.exit(1)
        click.echo(f"Deleting {len(ids_to_delete)} {chosen_origin} testcase(s)...")

    # --- Path 2: By name ---
    elif filter_idx == 2:
        try:
            with _spinner("Fetching tests…"):

                testcases = asyncio.run(_fetch_testcases())
        except Exception as e:
            click.secho(f"✗ Failed to fetch testcases: {e}", fg="red")
            sys.exit(1)
        if name_filter:
            needle = name_filter.strip().lower()
        else:
            try:
                needle = click.prompt("Search name").strip().lower()
            except click.Abort:
                click.echo("\nAborted.")
                sys.exit(0)
        pool = [tc for tc in testcases if needle in (tc.get("name") or "").lower()]
        if not pool:
            click.secho("✗ No testcases match that name.", fg="yellow")
            sys.exit(1)
        labels = [f"{tc.get('name')}  [{tc.get('test_type') or '-'}]" for tc in pool]
        if name_filter:
            idxs = list(range(len(pool)))
        else:
            idxs = _prompt_numbered(labels, "Select", multi=True)
        if not idxs:
            click.secho("✗ No valid selection.", fg="red")
            sys.exit(1)
        ids_to_delete = [_get_testcase_id(pool[i]) for i in idxs if _get_testcase_id(pool[i])]
        click.echo(f"Deleting {len(ids_to_delete)} testcase(s)...")

    # --- Path 3: By group ---
    elif filter_idx == 3:
        try:
            with _spinner("Fetching folders…"):

                groups = asyncio.run(_fetch_groups())
            with _spinner("Fetching tests…"):

                testcases = asyncio.run(_fetch_testcases())
        except Exception as e:
            click.secho(f"✗ Failed to fetch data: {e}", fg="red")
            sys.exit(1)
        if not groups:
            click.secho("✗ No test groups found.", fg="yellow")
            sys.exit(1)

        def _group_name(g: dict) -> str:
            return g.get("testcase_group_name") or g.get("name") or g.get("id") or ""

        def _group_id(g: dict) -> str:
            return g.get("testcase_group_id") or g.get("id") or g.get("_id") or ""

        group_counts: dict[str, int] = {}
        for tc in testcases:
            tc_group = tc.get("testcase_group_id") or tc.get("group_id") or tc.get("group") or ""
            if tc_group:
                group_counts[tc_group] = group_counts.get(tc_group, 0) + 1
        labels = [
            f"{_group_name(g)} ({group_counts.get(_group_id(g), 0)})"
            for g in groups
        ]
        idxs = _prompt_numbered(labels, "Select group", multi=True)
        if not idxs:
            click.secho("✗ No valid selection.", fg="red")
            sys.exit(1)
        selected_groups = [groups[i] for i in idxs]
        selected_group_ids = {_group_id(g) for g in selected_groups}
        selected_names = ", ".join(_group_name(g) for g in selected_groups)
        for tc in testcases:
            tc_group = tc.get("testcase_group_id") or tc.get("group_id") or tc.get("group") or ""
            if tc_group in selected_group_ids:
                tc_id = _get_testcase_id(tc)
                if tc_id:
                    ids_to_delete.append(tc_id)
        if not ids_to_delete:
            for g in selected_groups:
                for tc in g.get("testcases") or g.get("tests") or []:
                    tc_id = tc if isinstance(tc, str) else _get_testcase_id(tc)
                    if tc_id:
                        ids_to_delete.append(tc_id)
        if not ids_to_delete:
            click.secho(f"✗ No testcases found in group(s): {selected_names}.", fg="yellow")
            sys.exit(1)
        click.echo(f'Deleting {len(ids_to_delete)} testcase(s) from "{selected_names}"...')

    # --- Path 4: By tests (browse all) ---
    elif filter_idx == 4:
        try:
            with _spinner("Fetching tests…"):

                testcases = asyncio.run(_fetch_testcases())
        except Exception as e:
            click.secho(f"✗ Failed to fetch testcases: {e}", fg="red")
            sys.exit(1)
        if not testcases:
            click.secho("✗ No testcases found.", fg="yellow")
            sys.exit(1)
        labels = [
            f"{tc.get('name')}  [{tc.get('test_type') or '-'} / {tc.get('test_origin') or '-'}]"
            for tc in testcases
        ]
        idxs = _prompt_numbered(labels, "Select", multi=True)
        if not idxs:
            click.secho("✗ No valid selection.", fg="red")
            sys.exit(1)
        ids_to_delete = [_get_testcase_id(testcases[i]) for i in idxs if _get_testcase_id(testcases[i])]
        click.echo(f"Deleting {len(ids_to_delete)} testcase(s)...")

    # --- Path 5: All ---
    else:
        try:
            with _spinner("Fetching tests…"):

                testcases = asyncio.run(_fetch_testcases())
        except Exception as e:
            click.secho(f"✗ Failed to fetch testcases: {e}", fg="red")
            sys.exit(1)
        if not testcases:
            click.secho("✗ No testcases found.", fg="yellow")
            sys.exit(1)
        ids_to_delete = [_get_testcase_id(tc) for tc in testcases if _get_testcase_id(tc)]
        click.echo(f"Deleting all {len(ids_to_delete)} testcase(s)...")


    if not ids_to_delete:
        click.secho("No testcases to delete.", fg="yellow")
        sys.exit(1)

    # Build a friendly list of testcase names for confirmation (when available)
    names: list[str] = []
    try:
        if "testcases" in locals():
            id_to_name = {
                _get_testcase_id(tc): (tc.get("name") or tc.get("testcase_name") or _get_testcase_id(tc))
                for tc in (testcases or [])
                if _get_testcase_id(tc)
            }
            names = [id_to_name.get(tid) for tid in ids_to_delete if id_to_name.get(tid)]
    except Exception:
        names = []

    try:
        if names:
            preview = names[:10]
            more = len(names) - len(preview)
            click.echo("Testcases to delete:")
            for n in preview:
                click.echo(f"  - {n}")
            if more > 0:
                click.echo(f"  ...and {more} more")
        confirmed = click.confirm(
            f"Are you sure you want to delete {len(ids_to_delete)} testcase(s)?",
            default=False,
        )
    except click.Abort:
        click.echo("\nAborted.")
        sys.exit(0)
    if not confirmed:
        click.echo("Cancelled.")
        return

    async def _do_delete() -> dict:
        from maeris_mcp.maeris.client import MaerisClient

        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        async with MaerisClient() as client:
            return await client.delete_testcases(ids_to_delete)

    try:
        with _spinner("Deleting tests…"):

            result = asyncio.run(_do_delete())
    except Exception as e:
        click.secho(f"✗ Failed to delete testcases: {e}", fg="red")
        sys.exit(1)

    click.secho(f"✓ Deleted {len(ids_to_delete)} testcase(s).", fg="green")
    if _is_tty():
        _next_steps(
            "maeris test list                     View remaining tests",
            "maeris test create --url <app-url>   Regenerate tests",
        )
    else:
        _print_json({"deleted": len(ids_to_delete)})


# ---------------------------------------------------------------------------
# delete tests / folder / folders commands
# ---------------------------------------------------------------------------


@test_group.command("delete")
@click.option("--smoke", "filter_smoke", is_flag=True, help="Delete only smoke testcases.")
@click.option("--regression", "-r", "filter_regression", is_flag=True, help="Delete only regression testcases.")
@click.option("--origin", "-o", "filter_origin", default="", help="Delete testcases with this origin (manual / record / default).")
@click.option("--name", "-n", "name_filter", default="", help="Delete testcases whose name contains this string (case-insensitive).")
@click.option("--all", "-a", "delete_all", is_flag=True, help="Delete all testcases without prompting a filter menu.")
def delete_tests_main(
    filter_smoke: bool,
    filter_regression: bool,
    filter_origin: str,
    name_filter: str,
    delete_all: bool,
) -> None:
    """Delete testcases — use flags for quick filtering or run without flags for the interactive menu."""
    _delete_tests(filter_smoke, filter_regression, filter_origin, name_filter, delete_all)


@folder_group.command("delete")
@click.option("--confirm", "-y", is_flag=True, help="Skip confirmation prompt.")
@click.option("--name", "-n", "name_filter", default="", help="Delete folder(s) whose name contains this string (case-insensitive).")
@click.option("--name-prefix", "name_prefix", default="", help="Delete folder(s) whose name starts with this prefix.")
@click.option("--parent-name", "parent_name", default="", help="Delete all child folders under the parent with this exact name.")
@click.option("--parent-name-prefix", "parent_name_prefix", default="", help="Delete all child folders under parents whose name starts with this prefix.")
@click.option("--all", "-a", "delete_all", is_flag=True, help="Delete all folders without prompting a filter menu.")
def delete_folder_main(confirm: bool, name_filter: str, name_prefix: str, parent_name: str, parent_name_prefix: str, delete_all: bool) -> None:
    """Delete a testcase folder (testcase group)."""
    _delete_folder(confirm, name_filter, name_prefix, parent_name, parent_name_prefix, delete_all)

# push tests — push previously generated tests to Maeris portal
# ---------------------------------------------------------------------------


@push_group.command("tests")
@click.option("--folder", "-f", required=True, help="Maeris test folder to push into.")
@click.option("--target", "-t", default=None, help="Directory where tests were generated (defaults to cwd).")
@click.option(
    "--model", "-m",
    default="claude-sonnet-4-6",
    show_default=True,
    help="Claude model to use.",
)
def push_tests(folder: str, target: str | None, model: str) -> None:
    """Push previously generated Playwright tests to the Maeris portal."""
    target_path = target or os.getcwd()

    session_id = _latest_gen_session_id()
    if not session_id:
        click.secho(
            "✗ No recent test generation session found. Run 'maeris create tests' first.",
            fg="red", err=True,
        )
        sys.exit(1)

    claude_bin, _ai_mode = _resolve_ai_mode()

    mcp_was_new, mcp_original = _ensure_mcp_json(target_path)

    def _cleanup():
        from ._claude_helpers import cleanup_mcp_json
        cleanup_mcp_json(target_path, mcp_was_new, mcp_original)

    try:
        _push_phase(claude_bin, session_id, None, folder, model, target_path)
    finally:
        _cleanup()
