"""LLM-guided Playwright test case generation tool for MCP server."""

import json
import os
import re
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from uuid import uuid4

from mcp.types import TextContent, Tool
from pydantic import ValidationError

from maeris_mcp.scanners.code_reader import collect_scan_files, get_file_contents
from maeris_mcp.scanners.file_summarizer import summarize_file
from maeris_mcp.storage.fix_cache import RepoFixCache
from maeris_mcp.storage.test_store import TestGenerationStore
from maeris_mcp.types.test_generation import StructuredScanNotes


def _normalize_base_url(url: str | None) -> str | None:
    if not url:
        return None
    u = url.strip()
    if not u:
        return None
    if u.startswith("//"):
        u = "https:" + u
    if not u.startswith(("http://", "https://")):
        u = "https://" + u
    return u.rstrip("/")


def _extract_app_url(app: dict) -> str | None:
    # Common keys seen in application payloads
    for key in (
        "app_url", "appUrl", "url", "base_url", "baseUrl",
        "host", "domain", "app_domain", "appDomain",
        "frontend_url", "frontendUrl", "website", "site_url", "siteUrl",
    ):
        val = app.get(key)
        if isinstance(val, str) and val.strip():
            return val
    # Fallback: nested url fields
    for key in ("app", "application", "meta", "metadata"):
        val = app.get(key)
        if isinstance(val, dict):
            nested = _extract_app_url(val)
            if nested:
                return nested
    return None


async def _resolve_base_url_from_app() -> str | None:
    try:
        from maeris_mcp.auth.token_store import TokenStore
        from maeris_mcp.maeris.client import MaerisClient

        token_store = TokenStore()
        app_id = token_store.get_app_id()

        async with MaerisClient() as client:
            apps = await client.get_applications()
    except Exception:
        return None

    if not apps:
        return None

    # Prefer matching app_id
    match = None
    if app_id:
        for app in apps:
            for key in ("appid", "app_id", "id", "_id"):
                if str(app.get(key, "")).strip() == str(app_id):
                    match = app
                    break
            if match:
                break

    # If no match, fall back to the first app with a URL
    if not match:
        for app in apps:
            if _extract_app_url(app):
                match = app
                break
    if not match:
        return None

    return _normalize_base_url(_extract_app_url(match))


async def _fetch_pinecone_selectors(flow_description: str) -> str:
    """Query Pinecone for real DOM selectors relevant to the flow.

    Returns a formatted string to inject into the LLM context, or empty string
    if Pinecone has no data for this app (graceful degradation).
    """
    try:
        from maeris_mcp.maeris.client import MaerisClient

        # Multiple targeted queries: flow description + common element types
        queries = [
            flow_description,
            "email input field login form",
            "password input field",
            "submit button form",
            "navigation menu link",
        ]
        all_results: list[dict] = []
        async with MaerisClient() as client:
            for q in queries:
                hits = await client.search_components(q, top_k=5)
                all_results.extend(hits)

        if not all_results:
            return ""

        # Deduplicate by css_selector, keep highest score per selector
        seen: set[str] = set()
        deduped: list[dict] = []
        for r in sorted(all_results, key=lambda x: x.get("score", 0), reverse=True):
            sel = (r.get("css_selector") or "").strip()
            if sel and sel not in seen:
                seen.add(sel)
                deduped.append(r)
            if len(deduped) >= 15:
                break

        if not deduped:
            return ""

        lines = [
            "=== VERIFIED DOM SELECTORS (from live app crawl via Pinecone) ===",
            "These selectors were extracted from the REAL running DOM — use them verbatim.",
            "They OVERRIDE any selectors you inferred from JSX/source code.",
            "CSS Module hashed classes in source are INVALID — use these instead.",
            "",
        ]
        for r in deduped:
            desc = (r.get("description") or "")[:70]
            sel = r.get("css_selector", "")
            score = r.get("score", 0)
            page = r.get("page_url") or ""
            page_note = f"  (page: {page})" if page else ""
            lines.append(f'  {desc}: selector="{sel}"  confidence={score:.2f}{page_note}')

        lines.append("")
        return "\n".join(lines)
    except Exception:
        return ""


async def _fetch_feedback_context(scan_root: str) -> tuple[dict, str]:
    """Read feedback cache (never runs analysis). Returns (hist_ctx, prompt_block).

    Analysis is handled by generate_code_map which writes enriched data to codemap.json.
    This function only reads the cached patterns for LLM prompt injection.
    """
    try:
        from maeris_mcp.auth.token_store import TokenStore
        from maeris_mcp.storage.feedback_cache import RepoFeedbackCache

        token_store = TokenStore()
        app_id = token_store.get_app_id()
        if not app_id or not scan_root:
            return {}, ""

        cache = RepoFeedbackCache(scan_root, app_id)
        entry = cache.load()

        # Build backward-compatible historicalContext dict
        hist_ctx: dict = {}
        if entry.proven_selectors:
            hist_ctx["passed_tests"] = [
                {
                    "name": f"proven_{s.get('page_url', 'unknown')}",
                    "selectors_used": [s.get("selector", "")],
                }
                for s in entry.proven_selectors[:20]
            ]
        if entry.flaky_selectors:
            hist_ctx["known_failures"] = [
                {
                    "test_name": f"flaky_{f.get('page_url', 'unknown')}",
                    "recurring_error": f"Flaky selector: {f.get('selector', '')}",
                    "times_failed": f.get("fail_count", 0),
                }
                for f in entry.flaky_selectors[:10]
            ]
        if hist_ctx:
            hist_ctx["instruction"] = (
                "Use passed_tests selectors as ground truth for proven patterns. "
                "Avoid any selector or flow pattern listed in known_failures."
            )

        return hist_ctx, cache.get_prompt_block()

    except Exception:
        return {}, ""  # Non-critical — degrade gracefully


def generate_tests_tool() -> Tool:
    """Return the tool definition for generate_tests."""
    return Tool(
        name="generate_tests",
        description=(
            "ALWAYS use this tool for ANY test generation request — regardless of phrasing.\n\n"
            "Trigger phrases that MUST use this tool (not file reading):\n"
            "- 'generate testcases for X'\n"
            "- 'write tests to verify Y'\n"
            "- 'create test for Z'\n"
            "- ANY phrase containing: testcase, test case, generate test, write test, create test, verify [feature]\n\n"
            "CRITICAL: Do NOT read files manually with Read/Glob/Grep and write test code directly. "
            "Do NOT write Jest, Playwright, or any other test code by hand. "
            "ALWAYS call this tool — it uses the code map to generate tests.\n\n"
            "Generates E2E test step files (Mirabilis component format) using the application code map. "
            "Discovers ALL testable flows (login, forms, CRUD, navigation, settings, etc.) "
            "and generates comprehensive test coverage.\n\n"
            "Workflow:\n"
            "  1. Call with flow_description + target_path. The tool loads (or auto-generates) the code map "
            "and returns the application structure. Provide base_url and credentials if auth is needed.\n"
            "  2. Submit test_plan with discovered_flows based on the code map.\n"
            "  3. After plan approval, submit test_cases FLOW BY FLOW (one flow per call, no is_last). "
            "Set is_last=true ONLY on the very last call.\n\n"
            "After generation completes, check the autoRun.requiredInputs. "
            "Do NOT ask 'would you like me to run' — instead, immediately ask the user ONLY for the required values: "
            "show the auto-resolved base_url (requiredInputs.base_url.current_value) and ask them to confirm or override it, "
            "then ask for test_email and test_password. "
            "Once you have all three values, call run_and_fix_tests immediately without asking for permission."
        ),
        inputSchema={
            "type": "object",
            "properties": {
                "flow_description": {
                    "type": "string",
                    "description": "Natural language description of the flow to test (e.g., 'add address flow', 'user login')",
                },
                "target_path": {
                    "type": "string",
                    "description": "Absolute path to scan (optional; defaults to server working directory)",
                },
                "session_id": {
                    "type": "string",
                    "description": "Existing session ID to continue/paginate",
                },
                "test_cases": {
                    "type": "array",
                    "items": {"type": "object"},
                    "description": (
                        "Test cases in Mirabilis component format. "
                        "Each must have: name, steps (array of action/assertion step dicts). "
                        "Optional: description, priority, tags. "
                        "Submit on the FINAL call (is_last=true) after scanning all files."
                    ),
                },
                "base_url": {
                    "type": "string",
                    "description": "Base URL for the application (e.g., 'https://light-dev.up.railway.app')",
                },
                "test_email": {
                    "type": "string",
                    "description": (
                        "Real test account email provided by the user. "
                        "Required when credentialsRequired=true is returned in plan_approved. "
                        "Pass this along with test_password before submitting any test_cases."
                    ),
                },
                "test_password": {
                    "type": "string",
                    "description": (
                        "Real test account password provided by the user. "
                        "Required when credentialsRequired=true is returned in plan_approved."
                    ),
                },
                "is_last": {
                    "type": "boolean",
                    "description": "Set true ONLY on the final call after ALL flows are submitted.",
                },
                "max_test_cases": {
                    "type": "integer",
                    "description": (
                        "Maximum number of test cases to generate (default: 50). "
                        "The tool discovers ALL testable flows in the codebase and generates "
                        "at least 2 tests per flow (1 positive + 1 negative). "
                        "Set lower only if the user explicitly asks for fewer tests."
                    ),
                    "default": 50,
                },
                "test_plan": {
                    "type": "object",
                    "description": (
                        "Test plan submission (Phase 1.5). Submit after scanning is complete but BEFORE "
                        "writing test_cases. Must include discovered_flows with planned test cases, "
                        "selectors, and assertions for each flow."
                    ),
                },
                "auth_config": {
                    "type": "object",
                    "description": (
                        "Auth configuration for the app. Submit in the final is_last=true call. "
                        "Includes login_url, email_selector, password_selector, submit_selector, "
                        "protected_routes, and protected_actions."
                    ),
                },
            },
        },
    )


def _get_project_paths(target_path: str) -> tuple[Path, str]:
    """Return base for relative paths and display name."""
    path = Path(target_path)
    project_root = path if path.is_dir() else path.parent
    base_for_rel = project_root.parent if project_root.parent != project_root else project_root
    display_name = project_root.name or str(project_root)
    return base_for_rel, display_name


# Directories inside linked repos that contain API-relevant source files.
_LINKED_INCLUDE_DIRS: frozenset[str] = frozenset({
    "routes", "route", "api", "apis",
    "controllers", "controller",
    "views", "view",
    "handlers", "handler",
    "endpoints", "endpoint",
    "models", "model",
    "schemas", "schema",
    "types", "type",
    "interfaces", "interface",
    "dtos", "dto",
    "middleware", "middlewares",
    "auth", "authentication", "authorization",
    "guards", "guard",
    "permissions", "permission",
    "services", "service",
    "repositories", "repository", "repo",
    "daos", "dao",
    "resolvers", "resolver",
    "graphql",
})

# Source file extensions to include from linked repos.
_LINKED_INCLUDE_EXTS: frozenset[str] = frozenset({
    ".py", ".ts", ".js", ".tsx", ".jsx",
    ".go", ".java", ".rb", ".php", ".rs", ".cs", ".kt",
})


def _filter_linked_repo_files(abs_files: list[str]) -> list[str]:
    """Keep only API-relevant files from a linked repo scan.

    Includes files whose parent directory matches one of the known API/schema
    directory names and whose extension is a known source file type.
    """
    result = []
    for f in abs_files:
        p = Path(f)
        if p.suffix.lower() not in _LINKED_INCLUDE_EXTS:
            continue
        # Walk the path parts (excluding the filename) looking for a match.
        dir_parts = {part.lower() for part in p.parts[:-1]}
        if dir_parts & _LINKED_INCLUDE_DIRS:
            result.append(f)
    return result


def _scan_linked_repos() -> dict[str, str]:
    """Return a linked_file_map for all valid linked repos.

    Maps absolute file path → display key, e.g.:
        "/path/to/backend/routes/auth.py" → "[backend]/routes/auth.py"

    Only includes API-relevant files (routes, models, schemas, middleware, …).
    Returns an empty dict if no repos are linked or on any error.
    """
    try:
        from maeris_mcp.auth.linked_repo_store import LinkedRepoStore
        valid_links = LinkedRepoStore().list_valid()
    except Exception:
        return {}

    linked_file_map: dict[str, str] = {}
    for link in valid_links:
        try:
            abs_files, _ = collect_scan_files(link["path"])
            filtered = _filter_linked_repo_files(abs_files)
            label = link["label"]
            link_root = Path(link["path"])
            for abs_f in filtered:
                try:
                    rel_in_link = Path(abs_f).relative_to(link_root)
                except ValueError:
                    rel_in_link = Path(Path(abs_f).name)
                display_key = f"[{label}]/{rel_in_link}"
                linked_file_map[abs_f] = display_key
        except Exception:
            continue

    return linked_file_map


# ---------------------------------------------------------------------------
# Flow-keyword file promotion helpers
# ---------------------------------------------------------------------------

# These patterns identify "structural" files (router, nav, auth) that are already
# high-priority — they stay at the front regardless of keyword matching.
_FLOW_STRUCTURAL_PATTERN = re.compile(
    r"(routes?\.(ts|tsx|js|jsx|py|rb)$|router\.(ts|tsx|js|jsx)$"
    r"|app\.(tsx|jsx|ts|js)$|_app\.|middleware\.|urls\.py$|web\.php$"
    r"|navbar|sideb?ar|header|footer|navigation|auth|login|signin"
    r"|sign[-_]?in|guard|private[-_]?route|protected[-_]?route|layout\.)",
    re.I,
)


def _extract_flow_keywords(flow_description: str) -> list[str]:
    """Extract path-matching keywords from a flow description.

    Strips stop-words and short tokens so only meaningful keywords remain
    for matching against file paths (e.g. 'address', 'profile', 'checkout').
    """
    stop = {
        "a", "an", "the", "to", "for", "of", "in", "on", "at", "by",
        "from", "with", "and", "or", "is", "it", "its", "this", "that",
        "flow", "test", "page", "form", "new", "all", "create", "add",
        "edit", "update", "delete", "view", "get", "set", "use", "run",
    }
    return [
        w.lower()
        for w in re.findall(r"\w+", flow_description)
        if len(w) > 2 and w.lower() not in stop
    ]


def _flow_similarity_ratio(current: str, cached: str) -> float:
    """Return token-overlap ratio between two flow descriptions."""
    current_tokens = set(_extract_flow_keywords(current))
    cached_tokens = set(_extract_flow_keywords(cached))
    if not current_tokens or not cached_tokens:
        return 0.0
    overlap = current_tokens & cached_tokens
    return len(overlap) / max(1, len(current_tokens))


def _extract_ui_strings(raw: str) -> list[str]:
    """Extract likely UI-visible strings from source code."""
    strings: list[str] = []
    # aria-label/title/placeholder/label attributes
    for attr in ("aria-label", "title", "placeholder", "label"):
        for m in re.finditer(rf"{attr}\s*=\s*([\"'])(.*?)\\1", raw, re.IGNORECASE):
            val = (m.group(2) or "").strip()
            if 1 < len(val) <= 80:
                strings.append(val)
    # JSX text nodes: >Text<
    for m in re.finditer(r">([^<]{2,80})<", raw):
        val = (m.group(1) or "").strip()
        if val and "\n" not in val and "\r" not in val:
            strings.append(val)
    return strings


def _extract_route_strings(raw: str) -> list[str]:
    """Extract likely route/path strings like '/settings'."""
    routes: list[str] = []
    for m in re.finditer(r"([\"'])(/[^\"'\\s]{2,120})\\1", raw):
        routes.append(m.group(2))
    return routes


def _extract_imports(raw: str) -> list[str]:
    """Extract relative import paths from source code."""
    imports: list[str] = []
    for m in re.finditer(r"\\bfrom\\s+([\"'])(\\./|\\.\\./)([^\"']+)\\1", raw):
        imports.append(m.group(2) + m.group(3))
    for m in re.finditer(r"\\brequire\\(\\s*([\"'])(\\./|\\.\\./)([^\"']+)\\1\\s*\\)", raw):
        imports.append(m.group(2) + m.group(3))
    return imports


def _build_file_fingerprint(raw: str, rel_path: str) -> dict:
    selectors = list(_extract_source_selectors(raw))
    labels = _extract_ui_strings(raw)
    routes = _extract_route_strings(raw)
    imports = _extract_imports(raw)
    tokens = set(_extract_flow_keywords(" ".join(labels + routes)))
    tokens.update(_extract_flow_keywords(rel_path))
    tokens.update(_extract_flow_keywords(" ".join(selectors)))
    return {
        "selectors": selectors[:200],
        "labels": labels[:200],
        "routes": routes[:200],
        "imports": imports[:200],
        "tokens": sorted(tokens),
    }


def _resolve_import_targets(rel_file: str, rel_import: str, file_set: set[str]) -> list[str]:
    """Resolve a relative import string to possible file paths in file_set."""
    base_dir = os.path.dirname(rel_file)
    rel = os.path.normpath(os.path.join(base_dir, rel_import))
    candidates = [
        rel,
        rel + ".ts",
        rel + ".tsx",
        rel + ".js",
        rel + ".jsx",
        rel + ".py",
        os.path.join(rel, "index.ts"),
        os.path.join(rel, "index.tsx"),
        os.path.join(rel, "index.js"),
        os.path.join(rel, "index.jsx"),
    ]
    return [c for c in candidates if c in file_set]


def _compute_relevant_files(
    flow_description: str,
    file_list: list[str],
    fingerprints: dict,
) -> tuple[set[str], set[str]]:
    """Return (relevant_files, reused_cached_files) based on cached fingerprints."""
    flow_tokens = set(_extract_flow_keywords(flow_description))
    file_set = set(file_list)
    relevant: set[str] = set()
    reused: set[str] = set()

    # First pass: structural + path keyword match + fingerprint tokens
    for rel in file_list:
        if _FLOW_STRUCTURAL_PATTERN.search(rel):
            relevant.add(rel)
            continue
        lower_path = rel.lower()
        if any(t in lower_path for t in flow_tokens):
            relevant.add(rel)
            continue
        fp = fingerprints.get(rel) or {}
        fp_tokens = set(fp.get("tokens") or [])
        if flow_tokens & fp_tokens:
            relevant.add(rel)

    # Expand via cached import graph (one hop)
    for rel in list(relevant):
        fp = fingerprints.get(rel) or {}
        for imp in fp.get("imports") or []:
            for target in _resolve_import_targets(rel, imp, file_set):
                relevant.add(target)

    # Reused = relevant files that already have cached fingerprints
    for rel in relevant:
        if rel in fingerprints:
            reused.add(rel)

    return relevant, reused


def _promote_flow_matched_files(
    files: list[str],
    keywords: list[str],
    import_distances: dict[str, int] | None = None,
) -> list[str]:
    """Re-order files so those matching the requested flow appear early.

    Keeps structural files (router, nav, auth) at the front, then promotes
    files whose path contains a flow keyword, then everything else.

    Within each bucket, files are sorted by import distance from page entry
    points (lowest distance = closest to a real page = most likely to contain
    actual rendered selectors).  This prevents a keyword-matched component
    that is never imported by any page from appearing before the component
    that is actually rendered.
    """
    if not keywords:
        return files

    _INF = 9999

    def _dist(f: str) -> int:
        if import_distances:
            return import_distances.get(f, _INF)
        return _INF

    structural: list[str] = []
    flow_matched: list[str] = []
    rest: list[str] = []

    for f in files:
        if _FLOW_STRUCTURAL_PATTERN.search(f):
            structural.append(f)
        elif any(kw in f.lower() for kw in keywords):
            flow_matched.append(f)
        else:
            rest.append(f)

    # Sort each bucket by import distance so page-proximate files come first.
    structural.sort(key=_dist)
    flow_matched.sort(key=_dist)

    return structural + flow_matched + rest


def _expected_output_schema() -> dict[str, Any]:
    """Return the JSON schema for expected test case output from LLM.

    Each test case has a `steps` array of Mirabilis-compatible component dicts.
    Assertions are interleaved with actions in the same array — no separate field.
    """
    step_schema = {
        "type": "object",
        "description": "One action or assertion step (Mirabilis component format)",
        "properties": {
            # ── Selector ──────────────────────────────────────────────────────
            "css_selector": {
                "type": "string",
                "description": (
                    "CSS selector in [attribute=\"value\"] format. "
                    "Priority: data-testid > data-cy > id > name > aria-label > placeholder > type > class. "
                    "NEVER use CSS-Module class names. Required for action steps and input/page assertion steps."
                ),
            },
            "xpath": {
                "type": "string",
                "description": (
                    "Absolute XPath e.g. /html/body/div[1]/form[1]/input[2]. "
                    "Count same-tag siblings from left, 1-indexed. Required alongside css_selector."
                ),
            },
            "nth_appearance": {
                "type": "integer",
                "default": 0,
                "description": "0-indexed occurrence when css_selector matches multiple elements. Default 0 for unique attributes.",
            },
            "tag": {
                "type": "string",
                "description": "HTML tag name: input | button | a | select | textarea | div …",
            },
            # ── Action ────────────────────────────────────────────────────────
            "action_taken": {
                "type": "string",
                "enum": ["click", "send_keys", "hover", "go_to", "set_range", "check", "click_select", "clear"],
                "description": "Action to perform. Omit for assertion-only steps. 'clear' clears an input field (mapped to send_keys with empty value).",
            },
            "mock_input": {
                "type": "string",
                "description": "Value to fill (send_keys) or full URL to navigate to (go_to).",
            },
            "validation_type": {
                "type": "string",
                "description": "file | checkbox | radio | dropdown — only when relevant.",
            },
            "options": {
                "type": "array",
                "items": {"type": "string"},
                "description": "Dropdown option texts for select elements.",
            },
            # ── Assertion ─────────────────────────────────────────────────────
            "assertion_type": {
                "type": "string",
                "enum": [
                    "to be visible", "not to be visible",
                    "to be enabled", "to be disabled", "to be editable",
                    "to be focused", "to be empty",
                    "to be checked", "to be not checked",
                    "to have value", "to contain value",
                    "to have text", "to contain text",
                    "to be password", "to be email", "to be number", "to be date",
                    "has option", "assert all options", "to have default option",
                    "to have title", "to have url", "to contain url",
                ],
                "description": "Assertion type string (exact Mirabilis value). Omit for action-only steps.",
            },
            "assertion_value": {
                "type": "string",
                "description": "Expected value — EXACT text / URL / attribute from source code. NEVER generic descriptions.",
            },
            "assertion_nature": {
                "type": "string",
                "enum": ["existence", "input", "page"],
                "description": (
                    "existence: check visible text on page (no css_selector needed). "
                    "input: check element state (needs css_selector). "
                    "page: check URL or page title."
                ),
            },
            # ── Timing ────────────────────────────────────────────────────────
            "wait_before": {"type": "integer", "default": 0, "description": "ms to wait before this step."},
            "wait_after":  {"type": "integer", "default": 0, "description": "ms to wait after this step."},
            # ── Metadata ──────────────────────────────────────────────────────
            "description": {"type": "string", "description": "Human-readable step description."},
        },
    }

    return {
        "type": "object",
        "properties": {
            "testCases": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "name": {"type": "string", "description": "Test case name"},
                        "description": {"type": "string"},
                        "priority": {
                            "type": "string",
                            "enum": ["smoke", "critical", "high", "medium", "low"],
                        },
                        "tags": {"type": "array", "items": {"type": "string"}},
                        "steps": {
                            "type": "array",
                            "description": (
                                "Ordered list of action and assertion steps in Mirabilis component format. "
                                "MUST include at least 3 action steps and 1 assertion step. "
                                "EVERY selector and assertion_value MUST come from source code you scanned."
                            ),
                            "items": step_schema,
                        },
                    },
                    "required": ["name", "steps"],
                },
            },
        },
        "required": ["testCases"],
    }


def _analysis_instructions(max_test_cases: int = 50, base_url: str | None = None, codemap_loaded: bool = False) -> str:
    """Return instructions for LLM-guided test generation.

    The LLM generates structured Mirabilis-compatible JSON step files — not Python code.
    """
    base_url_display = base_url or "{{BASE_URL}}"
    base_url_line = f"APPLICATION BASE URL: {base_url}\n\n" if base_url else ""

    codemap_section = ""
    if codemap_loaded:
        codemap_section = (
            "=== PRE-LOADED NAVIGATION MAP ===\n"
            "A codemap.json has been loaded. Your scan_notes already contain:\n"
            "- routes: all known application routes with auth_required flags\n"
            "- navigation_tree: how each page is reached (directly accessible vs via navigation)\n"
            "- auth: login route, email/password selectors, protected routes\n"
            "- page_elements: pre-discovered selectors, interactive elements, and page_description per page\n"
            "  (page_description explains what the page does — use it to understand purpose and plan tests)\n"
            "- nav_graph: navigation edges between pages (including conditional paths)\n"
            "\n"
            "What this means for your scan:\n"
            "- DO NOT spend batches re-discovering routes or navigation. They are already known.\n"
            "- DO verify pre-loaded selectors as you scan — if a source file contradicts them, update page_elements.\n"
            "- DO focus on: validation messages, error states, toast notifications, loading states, form rules.\n"
            "- DO add new page_elements entries for pages where you discover elements not in the codemap.\n"
            "- Your scan should be SHORTER (fewer notes per batch needed) because the structure is known.\n"
            "\n"
            "CRITICAL — EVERY test case MUST include the FULL user journey from app root:\n"
            "The test runner starts from a BLANK BROWSER with NO state. Every test must:\n"
            "1. go_to the app root or login URL (the ONLY valid go_to targets)\n"
            "2. Complete ALL login steps (send_keys email, send_keys password, click submit)\n"
            "3. Handle post-login flow (app selection, onboarding, redirects — whatever navigation_tree shows)\n"
            "   Pages marked with guard_intermediates in navigation_tree are MANDATORY detours:\n"
            "   a guard redirect fires automatically in a clean browser (no state), so you MUST\n"
            "   visit those intermediate pages before reaching the guarded page.\n"
            "4. Click through EVERY intermediate page to reach the target feature\n"
            "5. THEN perform the feature-specific interaction steps\n"
            "6. Assert the expected outcome\n"
            "\n"
            "Example: To test 'Update email in settings', your steps must be:\n"
            "  go_to /login → send_keys email → send_keys password → click login →\n"
            "  (handle post-login navigation) → click settings button → click Account tab →\n"
            "  clear email field → send_keys new_email → click save → assert success message\n"
            "\n"
            "WRONG: Starting directly at 'Open settings modal' — the browser has no session.\n"
            "RIGHT: Starting at go_to /login, authenticating, navigating to settings, then testing.\n"
            "\n"
            "The navigation_tree and nav_graph in scan_notes tell you the EXACT path from\n"
            "login to every page. Use them. Never skip any step in the chain.\n"
            "\n"
        )

    return (
        base_url_line +
        codemap_section +
        "=== YOUR ROLE ===\n"
        "You are an expert E2E test engineer generating structured test steps in Mirabilis\n"
        "component format. Each test case is a JSON object with a `steps` array.\n"
        "Do NOT write Python code. Do NOT use Playwright API calls.\n"
        "Every step is a dict with css_selector, xpath, action_taken, mock_input,\n"
        "assertion_type, assertion_value, and assertion_nature fields.\n"
        "The runner executes these steps using the same engine as Mirabilis — a test that\n"
        "passes here will pass identically when pushed to Mirabilis.\n"
        "\n"
        "=== ABSOLUTE RULES — NO EXCEPTIONS ===\n"
        "1. ZERO GUESSING: Every css_selector, xpath, assertion_value, and mock_input URL MUST\n"
        "   come from source code you scanned. If you did not see it, you CANNOT use it.\n"
        "2. ZERO INVENTED SELECTORS: Do not guess selectors like '#username' or '.error'.\n"
        "   Only use attributes that EXIST in the actual JSX/HTML you read.\n"
        "3. STEPS MUST PASS: Every css_selector must match a real DOM element.\n"
        "   Every assertion_value must match real rendered text or a real URL.\n"
        "\n"
        "=== CSS SELECTOR RULES (CRITICAL) ===\n"
        "Format: [attribute=\"value\"] — always double-quote the value.\n"
        "Priority (highest to lowest):\n"
        "  1. data-testid   →  [data-testid=\"submit-button\"]\n"
        "  2. data-cy       →  [data-cy=\"email-input\"]\n"
        "  3. id            →  [id=\"login-form\"]\n"
        "  4. name          →  [name=\"email\"]\n"
        "  5. aria-label    →  [aria-label=\"Close dialog\"]\n"
        "  6. placeholder   →  [placeholder=\"you@example.com\"]\n"
        "  7. type          →  [type=\"submit\"]\n"
        "  8. class         →  [class=\"btn-primary\"]  ← LAST RESORT ONLY\n"
        "\n"
        "NEVER use CSS Module class names (styles.xxx — they are hashed at runtime).\n"
        "NEVER guess a selector. ONLY use attributes you SAW in the source files.\n"
        "\n"
        "WRAPPER COMPONENT TRAP (CRITICAL):\n"
        "  If you see <CustomInput data-testid='foo' placeholder='bar' />, check the\n"
        "  CustomInput source to see if data-testid is forwarded to the <input>.\n"
        "  If NOT forwarded, use placeholder='bar' instead.\n"
        "  RECORD in scan_notes.prop_passthrough_issues.\n"
        "\n"
        "=== XPATH RULES ===\n"
        "Format: Absolute path from document root, 1-indexed sibling counts.\n"
        "Example: /html/body/div[1]/form[1]/input[2]\n"
        "Count only same-tag siblings from the left (1-indexed, not CSS 0-indexed).\n"
        "Provide both css_selector AND xpath for every action/input-assertion step.\n"
        "\n"
        "=== NTH APPEARANCE ===\n"
        "nth_appearance is 0-indexed. Default is 0 (first match).\n"
        "Use a higher value only when css_selector will match multiple elements on the page\n"
        "and you need the Nth one (e.g. the third submit button).\n"
        "\n"
        "=== ACTION TYPES (action_taken) ===\n"
        "  click        — click an element (button, link, checkbox label, etc.)\n"
        "  send_keys    — fill a text input or textarea (mock_input = value to type)\n"
        "  go_to        — navigate to a URL (mock_input = full URL including base)\n"
        "  hover        — hover over an element\n"
        "  check        — check a checkbox\n"
        "  click_select — open and select from a native <select> or custom dropdown\n"
        "\n"
        "For go_to steps, mock_input MUST be the full URL. Use the base URL above.\n"
        "Replace {{BASE_URL}} with the actual base URL in mock_input.\n"
        "go_to steps do NOT need css_selector or xpath.\n"
        "\n"
        "For send_keys, mock_input is the value to type (e.g. 'test@example.com').\n"
        "For click_select, options is an array of visible option texts.\n"
        "\n"
        "=== ASSERTION TYPES (assertion_type + assertion_nature) ===\n"
        "\n"
        "assertion_nature='existence' — check visible text on page (no css_selector needed):\n"
        "  assertion_type values: 'to be visible', 'not to be visible'\n"
        "  assertion_value: EXACT text string visible in the DOM (from source)\n"
        "\n"
        "assertion_nature='input' — check element state (css_selector required):\n"
        "  assertion_type values:\n"
        "    'to be visible', 'not to be visible'\n"
        "    'to be enabled', 'to be disabled', 'to be editable'\n"
        "    'to be focused', 'to be empty'\n"
        "    'to be checked', 'to be not checked'\n"
        "    'to have value', 'to contain value'\n"
        "    'to have text', 'to contain text'\n"
        "    'to be password', 'to be email', 'to be number', 'to be date'\n"
        "    'has option', 'assert all options', 'to have default option'\n"
        "  assertion_value: expected value / text on the element\n"
        "\n"
        "assertion_nature='page' — check URL or page title:\n"
        "  assertion_type values: 'to have title', 'to have url', 'to contain url'\n"
        "  assertion_value: exact title or URL substring from source code\n"
        "\n"
        "assertion_value MUST be EXACT TEXT from source code — NEVER a description:\n"
        "  CORRECT: 'Sign up for free'    ← from <h1> in source\n"
        "  WRONG:   'signup page title'   ← generic description, REJECTED\n"
        "  CORRECT: '/verify-email'       ← from router.push() in source\n"
        "  WRONG:   'user is redirected'  ← description, REJECTED\n"
        "\n"
        "=== STEP STRUCTURE ===\n"
        "Action step:\n"
        '  {"css_selector": "[type=\\"submit\\"]", "xpath": "/html/body/form[1]/button[1]",\n'
        '   "nth_appearance": 0, "tag": "button",\n'
        '   "action_taken": "click", "mock_input": null,\n'
        '   "description": "Click submit button"}\n'
        "\n"
        "Navigation step:\n"
        f'  {{"action_taken": "go_to", "mock_input": "{base_url_display}/login",\n'
        '   "description": "Navigate to login page"}}\n'
        "\n"
        "Fill step:\n"
        '  {"css_selector": "[placeholder=\\"you@example.com\\"]",\n'
        '   "xpath": "/html/body/form[1]/input[1]",\n'
        '   "nth_appearance": 0, "tag": "input",\n'
        '   "action_taken": "send_keys", "mock_input": "test@example.com",\n'
        '   "description": "Enter email address"}\n'
        "\n"
        "Assertion step (existence — visible text):\n"
        '  {"assertion_nature": "existence",\n'
        '   "assertion_type": "to be visible",\n'
        '   "assertion_value": "Sign up for free",\n'
        '   "description": "Page heading visible"}\n'
        "\n"
        "Assertion step (page — URL check):\n"
        '  {"assertion_nature": "page",\n'
        '   "assertion_type": "to contain url",\n'
        '   "assertion_value": "/verify-email",\n'
        '   "description": "Redirected to verify-email"}\n'
        "\n"
        "Assertion step (input — element state):\n"
        '  {"css_selector": "[name=\\"email\\"]",\n'
        '   "xpath": "/html/body/form[1]/input[1]",\n'
        '   "assertion_nature": "input",\n'
        '   "assertion_type": "to have value",\n'
        '   "assertion_value": "test@example.com",\n'
        '   "description": "Email field has correct value"}\n'
        "\n"
        "REQUIRED: At least 3 action steps + at least 1 assertion step per test case.\n"
        "DEPTH RULES (avoid vague tests):\n"
        "  - If a form has N required fields in scan_notes.forms, include steps for ALL N fields.\n"
        "  - If scan_notes.conditional_ui or state_dependent_ui is present, include steps to reach\n"
        "    the condition AND an assertion that verifies the conditional state.\n"
        "  - Negative tests MUST assert exact error text from scan_notes.error_messages or\n"
        "    form_validations. Do not use generic 'should show error' assertions.\n"
        "\n"
        f"=== COVERAGE — up to {max_test_cases} test cases ===\n"
        "For each flow: 1 positive (happy path) + 1 negative (validation/error).\n"
        "Negative tests: trigger validation errors, verify error messages appear.\n"
        "Use EXACT error message strings from source code for assertion_value.\n"
        "\n"
        "=== PHASE 1 — SCAN (do NOT write steps yet) ===\n"
        "Each batch provides compact structured summaries in `fileSummaries` (NOT raw source).\n"
        "Each key is a file path; the value is a summary dict with abbreviated keys:\n"
        "  t    — component type: 'page' | 'component' | 'util' | 'api_route'\n"
        "  sel  — selectors: [[attr, value], ...] e.g. [[\"data-testid\",\"submit-btn\"],[\"name\",\"email\"]]\n"
        "  txt  — visible UI text strings: button labels, headings, link text\n"
        "  rts  — route strings referenced: href, router.push targets, Link href values\n"
        "  nav  — navigation mechanisms: [{\"m\":\"route\",\"to\":\"/x\"} | {\"m\":\"modal\",\"fn\":\"toggleX\"} |\n"
        "           {\"m\":\"state\",\"fn\":\"setShowX\"} | {\"m\":\"tab\",\"dest\":\"url\"}]\n"
        "  imp  — relative imports: [\"./Login/LoginForm\", \"../common/Button\", ...]\n"
        "\n"
        "Read ALL fileSummaries carefully. Submit scan_notes with each batch.\n"
        "\n"
        "CRITICAL — During scanning, extract and record in scan_notes from the summaries:\n"
        "  - sel entries → stable selectors per page/component (prefer data-testid > id > name)\n"
        "  - txt entries → visible text, headings, button labels, error/success messages\n"
        "  - rts entries → all routes referenced (router.push targets, Link hrefs)\n"
        "  - nav entries → navigation mechanisms (route vs modal vs state vs new_tab)\n"
        "  - imp entries → import graph for tracing which components are actually rendered\n"
        "  - ALL SERVER-SIDE REDIRECTS: next.config.js redirects(), middleware redirects, rewrites\n"
        "    Record as: {\"server_redirects\": [{\"source\": \"/signup\", \"destination\": \"/\"}]}\n"
        "  - ALL CLIENT-SIDE AUTH GUARDS: components that redirect unauthenticated users\n"
        "    Record as: {\"auth_guards\": [{\"component\": \"AuthProvider\", \"redirects_to\": \"/login\"}]}\n"
        "  - BLOCKED ROUTES: any route inaccessible due to redirects or auth guards\n"
        "    Record as: {\"blocked_routes\": [\"/dashboard\"], \"accessible_routes\": [\"/login\"]}\n"
        "\n"
        "=== DEEP COMPONENT ANALYSIS (CRITICAL — prevents selector failures) ===\n"
        "When scanning wrapper/reusable components (e.g. TextInput, FormField, Modal):\n"
        "  1. TRACE PROP PASSTHROUGH: If a component's sel[] is empty but its parent passes\n"
        "     data-testid/id/name, check whether those attrs are forwarded to the DOM element.\n"
        "     If NOT forwarded, use placeholder from sel[] instead.\n"
        "     RECORD in scan_notes.prop_passthrough_issues.\n"
        "  2. DETECT CSS MODULES: If imp[] contains '*.module.css' or '*.module.scss',\n"
        "     those class names are HASHED at runtime. NEVER use them as selectors.\n"
        "     RECORD in scan_notes.css_module_selectors.\n"
        "  3. RECORD STABLE SELECTORS: For EVERY interactive element, record the best\n"
        "     stable selector from sel[] in scan_notes.stable_selectors.\n"
        "  4. TRACE FORM VALIDATION: Infer validation from input types in sel[] and txt[]\n"
        "     error message strings. RECORD in scan_notes.form_validations.\n"
        "\n"
        "=== COMPONENT RENDER VERIFICATION (CRITICAL — prevents phantom selectors) ===\n"
        "Before recording ANY selector in scan_notes, ask: is this component actually rendered?\n"
        "\n"
        "DUPLICATE COMPONENT TRAP:\n"
        "  You may find multiple components that look like they implement the same UI element\n"
        "  (e.g. two files both named 'Navigation', 'LeftPanel', 'Sidebar').\n"
        "  Only ONE is actually mounted in the running app. The others may be dead code,\n"
        "  experiments, or refactoring leftovers.\n"
        "\n"
        "  To identify which component is actually rendered:\n"
        "  1. Find the page entry file (t='page' in fileSummaries).\n"
        "  2. Follow its imp[] chain. Only components reachable via imp[] from a page\n"
        "     entry are actually rendered.\n"
        "  3. If a component is NOT in the imp[] of any page or rendered component,\n"
        "     mark it as potentially_unused and DO NOT use its selectors.\n"
        "\n"
        "  RECORD in scan_notes:\n"
        '  {"potentially_unused_components": [{"file": "components/Nav/OldNav.js",\n'
        '    "reason": "not imported by any page or rendered component"}]}\n'
        "\n"
        "=== NAVIGATION MECHANISM DETECTION (CRITICAL — prevents go_to on modals) ===\n"
        "Not all 'navigation' changes the URL. Before writing any plan step that moves\n"
        "between views, identify the EXACT mechanism the code uses:\n"
        "\n"
        "  router.push('/path')      → URL changes. Use go_to (only for root/login)\n"
        "                              or click steps that trigger the router.push.\n"
        "  dispatch(toggleXModal())  → NO URL change. A modal/panel opens as an overlay.\n"
        "                              Use click on the trigger element. NEVER go_to.\n"
        "  dispatch(setShow*())      → Same as above. State-driven overlay.\n"
        "  setState(true)            → Same as above. Local state overlay.\n"
        "  window.open(url)          → Opens in new tab. Cannot be tested with go_to.\n"
        "\n"
        "  For every clickable item in the nav / sidebar / left panel, read its nav[] entry:\n"
        "  - nav entry {\"m\":\"route\",\"to\":\"/x\"}    → router.push. navigation_mechanism: 'route'\n"
        "  - nav entry {\"m\":\"modal\",\"fn\":\"toggleX\"} → dispatch(toggleX()). mechanism: 'modal'\n"
        "  - nav entry {\"m\":\"state\",\"fn\":\"setShowX\"} → setState. mechanism: 'modal'\n"
        "  - nav entry {\"m\":\"tab\",\"dest\":\"url\"}   → window.open. mechanism: 'new_tab'\n"
        "  If no nav[] entry exists for a clickable, infer from sel[] and txt[] labels.\n"
        "\n"
        "  RECORD in scan_notes.navigation_mechanisms:\n"
        '  [{"trigger_selector": ".left-menu-settings", "mechanism": "modal",\n'
        '    "opens": "ApplicationDetailsModal", "url_changes": false},\n'
        '   {"trigger_selector": "[aria-label=\\"Go to Dashboard\\"]", "mechanism": "route",\n'
        '    "destination": "/dashboard", "url_changes": true}]\n'
        "\n"
        "  A modal mechanism means:\n"
        "  - There is NO route to go_to for this feature.\n"
        "  - The test step is: click on the trigger element.\n"
        "  - The assertion is: verify modal content is visible (text, heading).\n"
        "  - NEVER write go_to /settings if settings opens via dispatch(toggleModal).\n"
        "\n"
        "=== E2E USER JOURNEY MAPPING (CRITICAL — prevents missing steps) ===\n"
        "Before writing steps, map the COMPLETE user journey:\n"
        "  1. POST-LOGIN FLOW: What happens after login? (app selection, onboarding, redirects)\n"
        "     RECORD in scan_notes.post_login_flow.\n"
        "  2. PAGE LOAD ASYNC: What async data must load before interaction? (spinners, lists)\n"
        "  3. MODAL/DIALOG FLOWS: How to open, fill ALL required fields, submit, and verify result.\n"
        "  4. STATE-DEPENDENT UI: Elements that only appear based on app state.\n"
        "     RECORD in scan_notes.state_dependent_ui.\n"
        "\n"
        "These are the ONLY values you can use. If you didn't record it, you can't use it.\n"
        "Be thorough — read the source carefully.\n"
        "\n"
        "Do NOT submit test_cases until hasMore=false and your test_plan is approved.\n"
        "\n"
        "=== PHASE 1.5 — PLAN (MANDATORY) ===\n"
        "After hasMore=false, submit test_plan with discovered_flows before writing steps.\n"
        "Each discovered flow must include:\n"
        "  - flow_name, entry_point\n"
        "  - prerequisites: list of CONCRETE ACTIONS a real user must take before this flow,\n"
        "      NOT state descriptions. WRONG: ['User is logged in']. \n"
        "      RIGHT: ['Navigate to /login', 'Enter email', 'Enter password', 'Click Sign in',\n"
        "              'Select application from /onboarding/select-app'].\n"
        "      If login is required, list every login step explicitly here.\n"
        "      If post-login app selection is required, list that step explicitly here.\n"
        "  - user_journey_steps: ordered steps, each MUST include a 'mechanism' field:\n"
        '      WRONG: ["Click Settings", "Navigate to settings page"]\n'
        "      RIGHT (route):  {\"action\": \"click sidebar settings icon\",\n"
        '                       "selector": ".left-menu-settings",\n'
        '                       "mechanism": "modal",\n'
        '                       "opens": "ApplicationDetailsModal",\n'
        '                       "url_changes": false}\n'
        "      RIGHT (modal):  {\"action\": \"navigate to dashboard\",\n"
        '                       "mechanism": "route",\n'
        '                       "destination": "/dashboard",\n'
        '                       "url_changes": true}\n'
        "      The mechanism field is REQUIRED. It determines whether the generated step\n"
        "      uses go_to (route) or click (modal/state). If you leave it out, the step\n"
        "      will be wrong.\n"
        "  - test_cases_planned (must include positive + negative)\n"
        "  - key_selectors (from scan_notes — only from components confirmed as rendered)\n"
        "  - assertions_planned (exact text/URLs from source)\n"
        "Wait for plan_approved response before generating test_cases.\n"
        "\n"
        "=== PHASE 2 — GENERATE ===\n"
        "After plan approval, write test_cases FLOW BY FLOW — one flow per call.\n"
        "Submit test cases for each flow WITHOUT is_last=true.\n"
        "Set is_last=true ONLY on the final call (after ALL flows are submitted).\n"
        "The server accumulates test_cases across calls — previous submissions are NOT lost.\n"
        "Each call returns status='tests_received' with a running total.\n"
        "Continue until every flow is submitted, then send the last call with is_last=true.\n"
        "\n"
        "CRITICAL — HOW TO HANDLE PREREQUISITES:\n"
        "  Prerequisites are NOT assumed preconditions. They are STEPS YOU MUST WRITE.\n"
        "  If your plan listed 'Navigate to /login, Enter email, Enter password, Click Sign in'\n"
        "  as prerequisites, your steps[] array MUST start with those exact actions.\n"
        "  NEVER start a test from an assumed authenticated or navigated state.\n"
        "  ALWAYS start every test from the very first user action (go_to login page).\n"
        "\n"
        "Before writing each test, verify:\n"
        "  - First step is go_to the login URL (if auth is required)\n"
        "  - Second/third steps fill email and password\n"
        "  - Fourth step clicks sign in\n"
        "  - If post_login_flow in scan_notes mentions app selection, that step follows login\n"
        "  - Every css_selector was found in your scan_notes\n"
        "  - Every assertion_value was found in your scan_notes\n"
        "  - Every URL/path was found in your scan_notes\n"
        "  - The form flow matches source (correct field order, correct submit action)\n"
        "  - ALL required form fields are filled (check form_validations in scan_notes)\n"
        "  - Post-login conditional flows are handled (check post_login_flow)\n"
        "\n"
        "JOURNEY COMPLETENESS CHECK:\n"
        "  1. go_to login page → fill email → fill password → click sign in\n"
        "  2. Post-login step (app selection, onboarding) if post_login_flow is recorded\n"
        "  3. Navigate to target page → wait for async content (use wait_before ms on step)\n"
        "  4. Perform actions → fill ALL required fields\n"
        "  5. Submit/confirm → handle modals, toasts, redirects\n"
        "  6. Verify outcome → assertion steps with exact text/URL from source\n"
    )


# ---------------------------------------------------------------------------
# Scan-complete summary builder
# ---------------------------------------------------------------------------

def _build_scan_summary(scan_notes: dict) -> dict:
    """Build a structured summary from accumulated scan_notes.

    Called when Phase 1 scanning is complete (hasMore=false) to give the LLM
    a validated overview of the codebase before it starts writing test cases.
    Includes navigation path reachability, data quality warnings, and
    suggested flows based on available data.
    """
    if not isinstance(scan_notes, dict):
        scan_notes = {}
    routes = scan_notes.get("routes", [])
    forms = scan_notes.get("forms", [])
    auth = scan_notes.get("auth", {})
    page_elements = scan_notes.get("page_elements", [])
    page_graph = scan_notes.get("page_graph", [])
    nav_graph = scan_notes.get("nav_graph", [])
    navigation = scan_notes.get("navigation", [])

    # --- App overview ---
    pages_with_elements = {entry.get("page") for entry in page_elements if entry.get("page")}
    all_routes = {r.get("path") for r in routes if r.get("path")}
    pages_missing_elements = all_routes - pages_with_elements

    nav_components = [n.get("component", "") for n in navigation if n.get("component")]

    app_overview = {
        "total_routes": len(routes),
        "total_forms": len(forms),
        "has_auth": bool(auth.get("has_auth")),
        "nav_components": nav_components,
        "pages_with_elements_cataloged": len(pages_with_elements),
        "pages_missing_elements": sorted(pages_missing_elements) if pages_missing_elements else [],
    }

    # --- Navigation paths (adjacency list from page_graph) ---
    adjacency: dict[str, list[str]] = {}
    graph_edges = nav_graph or page_graph
    for edge in graph_edges:
        src = edge.get("from_page", "")
        dst = edge.get("to_page", "")
        if src and dst:
            adjacency.setdefault(src, []).append(dst)

    # BFS from "/" to find all reachable pages
    reachable: set[str] = set()
    if "/" in adjacency or any(e.get("from_page") == "/" for e in graph_edges):
        queue = ["/"]
        visited: set[str] = set()
        while queue:
            node = queue.pop(0)
            if node in visited:
                continue
            visited.add(node)
            reachable.add(node)
            for neighbor in adjacency.get(node, []):
                if neighbor not in visited:
                    queue.append(neighbor)

    protected_routes = set(auth.get("protected_routes", []))
    auth_protected = sorted(protected_routes & all_routes) if protected_routes else []

    unreachable = sorted(all_routes - reachable - {"/"}) if reachable else []

    # --- Suggested flows (routes that have forms or elements cataloged) ---
    suggested_flows = []
    for route in routes:
        path = route.get("path", "")
        component = route.get("component", "")
        has_form = any(
            f.get("page", "").lower() == component.lower()
            or path in (f.get("page") or "")
            for f in forms
        )
        element_count = 0
        for pe in page_elements:
            if pe.get("page") == path:
                element_count = len(pe.get("elements", []))
                break
        if has_form or element_count > 0:
            suggested_flows.append({
                "name": component or path,
                "route": path,
                "has_form": has_form,
                "elements_cataloged": element_count,
                "auth_required": route.get("auth_required", False),
            })

    # --- Warnings about data quality gaps ---
    warnings = []
    if pages_missing_elements:
        for p in sorted(pages_missing_elements):
            warnings.append(
                f"Route {p} has no elements cataloged in page_elements — "
                "selectors for this page may be missing"
            )
    if unreachable:
        for p in unreachable:
            warnings.append(
                f"No navigation path from '/' to '{p}' found in nav_graph/page_graph — "
                "tests cannot reach this page via clicks"
            )
    if auth.get("has_auth") and not auth.get("email_selector"):
        warnings.append(
            "Auth detected but no login form selectors found — "
            "login steps may be incomplete"
        )
    total_elements = sum(len(pe.get("elements", [])) for pe in page_elements)
    if len(routes) > 0 and total_elements == 0:
        warnings.append(
            "No page_elements cataloged for any page — "
            "the LLM may guess selectors. Re-scan and extract elements."
        )

    return {
        "app_overview": app_overview,
        "navigation_paths": {k: sorted(set(v)) for k, v in adjacency.items()},
        "reachable_from_home": sorted(reachable - {"/"}) if reachable else [],
        "unreachable_pages": unreachable,
        "auth_protected": auth_protected,
        "suggested_flows": suggested_flows,
        "warnings": warnings,
    }


def _validate_scan_notes(scan_notes: dict) -> list[str]:
    """Return warning-only feedback for scan_notes completeness and schema shape."""
    warnings: list[str] = []
    if not isinstance(scan_notes, dict):
        return ["WARNING: scan_notes must be an object"]

    structured: StructuredScanNotes | None = None
    try:
        structured = StructuredScanNotes.model_validate(scan_notes)
    except ValidationError as exc:
        first_issue = exc.errors()[0] if exc.errors() else {}
        loc = ".".join(str(p) for p in first_issue.get("loc", [])) or "unknown"
        msg = first_issue.get("msg", "invalid scan_notes shape")
        warnings.append(f"WARNING: scan_notes schema mismatch at '{loc}' — {msg}")

    routes = structured.routes if structured else scan_notes.get("routes", [])
    forms = structured.forms if structured else scan_notes.get("forms", [])
    nav_graph = structured.nav_graph if structured else (scan_notes.get("nav_graph") or scan_notes.get("page_graph") or [])
    auth = scan_notes.get("auth", {}) if isinstance(scan_notes.get("auth"), dict) else {}
    auth_guards = scan_notes.get("auth_guards", []) if isinstance(scan_notes.get("auth_guards"), list) else []

    if not routes:
        warnings.append(
            "WARNING: No routes found yet — look for router config, pages/ directory, or route definitions"
        )

    has_form_fields = False
    for form in forms:
        if isinstance(form, dict):
            if form.get("fields"):
                has_form_fields = True
                break
        elif getattr(form, "fields", None):
            has_form_fields = True
            break
    if not has_form_fields:
        warnings.append(
            "WARNING: No form fields captured — look for <input>, <select>, <textarea> elements"
        )

    auth_detected = bool(auth.get("has_auth")) or bool(auth_guards)
    if not auth_detected:
        for route in routes:
            if isinstance(route, dict):
                path = route.get("path", "")
                if route.get("auth_required") or "login" in path.lower() or "signin" in path.lower():
                    auth_detected = True
                    break
            else:
                path = getattr(route, "path", "") or ""
                if getattr(route, "auth_required", False) or "login" in path.lower() or "signin" in path.lower():
                    auth_detected = True
                    break

    has_login_selectors = bool(
        auth.get("email_selector") and auth.get("password_selector") and auth.get("submit_selector")
    )
    if auth_detected and not has_login_selectors:
        warnings.append("WARNING: Auth detected but no login selectors captured")

    if not nav_graph:
        warnings.append("WARNING: No navigation graph — note how pages link to each other")

    # ── Selector stability checks ────────────────────────────────────────────
    stable_selectors = scan_notes.get("stable_selectors", [])
    css_module_selectors = scan_notes.get("css_module_selectors", [])
    prop_passthrough_issues = scan_notes.get("prop_passthrough_issues", [])

    # Warn if forms found but no stable selectors recorded yet
    if has_form_fields and not stable_selectors:
        warnings.append(
            "WARNING: Forms detected but no stable_selectors recorded. "
            "For each form field, add an entry to stable_selectors with a "
            "non-class-based selector (data-testid, type, name, placeholder, aria-label)."
        )

    # Warn if CSS module imports found but not recorded
    page_elements = scan_notes.get("page_elements", [])
    has_module_import = any(
        "module.css" in str(e).lower() or "module.scss" in str(e).lower()
        for e in page_elements
    )
    if has_module_import and not css_module_selectors:
        warnings.append(
            "WARNING: CSS Module imports detected but css_module_selectors is empty. "
            "Record which selectors are from CSS Modules (they will be hashed at runtime). "
            "Use stable_selectors to provide attribute-based alternatives."
        )

    # Warn if wrapper components seen but prop forwarding not analyzed
    if has_form_fields and not prop_passthrough_issues:
        warnings.append(
            "WARNING: No prop_passthrough_issues recorded. "
            "For each custom form component (e.g. <CustomInput>, <FormField>), check whether "
            "data-testid/id/name props are actually forwarded to the underlying <input> element. "
            "Record failures in prop_passthrough_issues."
        )

    return warnings


def _normalize_route_from_entry_point(entry_point: str) -> str | None:
    """Extract a route-like path from a flow entry_point string."""
    if not entry_point:
        return None
    match = re.search(r"/[a-zA-Z0-9_./-]*", entry_point)
    if not match:
        return None
    route = match.group(0)
    if not route.startswith("/"):
        route = "/" + route
    return route.rstrip("/") or "/"


def _extract_strings(value: Any) -> list[str]:
    """Flatten nested values and return all non-empty strings."""
    found: list[str] = []
    if isinstance(value, str):
        if value.strip():
            found.append(value.strip())
        return found
    if isinstance(value, dict):
        for v in value.values():
            found.extend(_extract_strings(v))
        return found
    if isinstance(value, list):
        for v in value:
            found.extend(_extract_strings(v))
    return found


def _collect_known_selectors(scan_notes: dict) -> set[str]:
    """Collect known selectors from accumulated scan notes."""
    selectors: set[str] = set()
    if not isinstance(scan_notes, dict):
        return selectors

    forms = scan_notes.get("forms", [])
    for form in forms if isinstance(forms, list) else []:
        if not isinstance(form, dict):
            continue
        submit_selector = form.get("submit_selector")
        if isinstance(submit_selector, str) and submit_selector.strip():
            selectors.add(submit_selector.strip())
        for field in form.get("fields", []) if isinstance(form.get("fields"), list) else []:
            if isinstance(field, dict):
                sel = field.get("selector")
                if isinstance(sel, str) and sel.strip():
                    selectors.add(sel.strip())

    nav_graph = scan_notes.get("nav_graph", [])
    for edge in nav_graph if isinstance(nav_graph, list) else []:
        if isinstance(edge, dict):
            sel = edge.get("selector")
            if isinstance(sel, str) and sel.strip():
                selectors.add(sel.strip())

    conditional_ui = scan_notes.get("conditional_ui", [])
    for item in conditional_ui if isinstance(conditional_ui, list) else []:
        if isinstance(item, dict):
            sel = item.get("selector")
            if isinstance(sel, str) and sel.strip():
                selectors.add(sel.strip())

    key_selectors = scan_notes.get("key_selectors", {})
    selectors.update(_extract_strings(key_selectors))

    auth = scan_notes.get("auth", {})
    if isinstance(auth, dict):
        for key in ("email_selector", "password_selector", "submit_selector"):
            sel = auth.get(key)
            if isinstance(sel, str) and sel.strip():
                selectors.add(sel.strip())

    page_elements = scan_notes.get("page_elements", [])
    for page in page_elements if isinstance(page_elements, list) else []:
        if not isinstance(page, dict):
            continue
        for element in page.get("elements", []) if isinstance(page.get("elements"), list) else []:
            if isinstance(element, dict):
                sel = element.get("selector")
                if isinstance(sel, str) and sel.strip():
                    selectors.add(sel.strip())

    return selectors


def _collect_known_assertions(scan_notes: dict) -> tuple[set[str], set[str]]:
    """Collect known text assertions and URL/path assertions from scan notes."""
    texts: set[str] = set()
    urls: set[str] = set()
    if not isinstance(scan_notes, dict):
        return texts, urls

    routes = scan_notes.get("routes", [])
    for route in routes if isinstance(routes, list) else []:
        if isinstance(route, dict):
            path = route.get("path")
            if isinstance(path, str) and path.strip():
                urls.add(path.strip())
            redirects_to = route.get("redirects_to")
            if isinstance(redirects_to, str) and redirects_to.strip():
                urls.add(redirects_to.strip())

    for key in ("blocked_routes", "accessible_routes"):
        values = scan_notes.get(key, [])
        for route in values if isinstance(values, list) else []:
            if isinstance(route, str) and route.strip():
                urls.add(route.strip())

    server_redirects = scan_notes.get("server_redirects", [])
    for redirect in server_redirects if isinstance(server_redirects, list) else []:
        if not isinstance(redirect, dict):
            continue
        src = redirect.get("source")
        dst = redirect.get("destination")
        if isinstance(src, str) and src.strip():
            urls.add(src.strip())
        if isinstance(dst, str) and dst.strip():
            urls.add(dst.strip())

    auth = scan_notes.get("auth", {})
    if isinstance(auth, dict):
        login_url = auth.get("login_url")
        if isinstance(login_url, str) and login_url.strip():
            urls.add(login_url.strip())
        protected = auth.get("protected_routes", [])
        for route in protected if isinstance(protected, list) else []:
            if isinstance(route, str) and route.strip():
                urls.add(route.strip())

    error_messages = scan_notes.get("error_messages", {})
    for value in _extract_strings(error_messages):
        texts.add(value)

    success_indicators = scan_notes.get("success_indicators", {})
    for value in _extract_strings(success_indicators):
        if value.startswith("/"):
            urls.add(value)
        else:
            texts.add(value)

    forms = scan_notes.get("forms", [])
    for form in forms if isinstance(forms, list) else []:
        if not isinstance(form, dict):
            continue
        success = form.get("success_indicator")
        if isinstance(success, str) and success.strip():
            if success.strip().startswith("/"):
                urls.add(success.strip())
            else:
                texts.add(success.strip())
        errors = form.get("error_messages", [])
        for msg in errors if isinstance(errors, list) else []:
            if isinstance(msg, str) and msg.strip():
                texts.add(msg.strip())

    return texts, urls


def _is_negative_test_name(name: str) -> bool:
    lowered = name.lower()
    return any(
        marker in lowered
        for marker in (
            "negative", "invalid", "wrong", "empty", "missing", "error",
            "fail", "unauthorized", "forbidden", "reject", "denied",
        )
    )


# ---------------------------------------------------------------------------
# Source-grounded selector validation (Feature 3)
# ---------------------------------------------------------------------------

# Regex patterns that match real selector attribute values in source files.
# Each pattern captures the attribute value (group 1) so we can build an index.
_SELECTOR_PATTERNS = [
    re.compile(r'data-testid=["\']([^"\']+)["\']'),
    re.compile(r'data-cy=["\']([^"\']+)["\']'),
    re.compile(r'data-test=["\']([^"\']+)["\']'),
    re.compile(r'\bid=["\']([^"\']+)["\']'),
    re.compile(r'\bname=["\']([^"\']+)["\']'),
    re.compile(r'aria-label=["\']([^"\']+)["\']'),
    re.compile(r'placeholder=["\']([^"\']+)["\']'),
    re.compile(r'\btype=["\']([^"\']+)["\']'),
]


def _extract_source_selectors(content: str) -> set[str]:
    """Extract all real selector attribute values from raw source file content.

    Returns lowercased values so comparison is case-insensitive.
    Called server-side for every file batch — no extra file reads needed.
    """
    found: set[str] = set()
    for pattern in _SELECTOR_PATTERNS:
        for m in pattern.finditer(content):
            val = m.group(1).strip()
            if val:
                found.add(val.lower())
    return found


def _validate_page_element_selectors(
    page_elements: list,
    source_selectors: set[str],
) -> dict:
    """Cross-reference submitted page_elements selectors against the source index.

    For each selector in page_elements, extract the attribute value and check
    whether it was ever seen in any source file the server read.

    Returns:
        {
            "verified_count": int,
            "total_count": int,
            "suspicious": [{"page": str, "selector": str}],
            "hallucination_risk": bool,   # True if >25% selectors are unverified
        }
    """
    verified = 0
    suspicious: list[dict] = []
    total = 0

    # Extract value from e.g. [data-testid="submit-btn"] → "submit-btn"
    _val_re = re.compile(r'["\']([^"\']+)["\']')

    for page_entry in page_elements if isinstance(page_elements, list) else []:
        if not isinstance(page_entry, dict):
            continue
        # Codemap-sourced page_elements are pre-verified from source — skip re-checking.
        if page_entry.get("source") == "codemap":
            for element in page_entry.get("elements", []) if isinstance(page_entry.get("elements"), list) else []:
                if isinstance(element, dict) and element.get("selector"):
                    total += 1
                    verified += 1
            continue
        page = page_entry.get("page", "")
        for element in page_entry.get("elements", []) if isinstance(page_entry.get("elements"), list) else []:
            if not isinstance(element, dict):
                continue
            sel = (element.get("selector") or "").strip()
            if not sel:
                continue
            total += 1
            m = _val_re.search(sel)
            val = m.group(1).lower() if m else sel.lower()
            # Consider verified if the extracted value matches any known source value,
            # or if any known source value contains the extracted value (partial match).
            if val in source_selectors or any(val in s or s in val for s in source_selectors):
                verified += 1
            else:
                suspicious.append({"page": page, "selector": sel})

    hallucination_risk = total > 0 and len(suspicious) / total > 0.25
    return {
        "verified_count": verified,
        "total_count": total,
        "suspicious": suspicious,
        "hallucination_risk": hallucination_risk,
    }


def _find_files_for_keywords(
    keywords: list[str],
    all_files_abs: list[str],
    base_for_rel: "Path",
    max_files: int = 15,
) -> list[str]:
    """Return relative paths of files whose path matches any of the given keywords.

    Used by the completeness gate to suggest which files to re-read.
    """
    matched: list[str] = []
    seen: set[str] = set()
    for abs_path in all_files_abs:
        try:
            rel = os.path.relpath(abs_path, str(base_for_rel))
        except ValueError:
            rel = abs_path
        rel_lower = rel.lower().replace("\\", "/")
        if any(kw in rel_lower for kw in keywords) and rel not in seen:
            matched.append(rel)
            seen.add(rel)
        if len(matched) >= max_files:
            break
    return matched


def _check_scan_completeness(
    accumulated_notes: dict,
    changed_files: list[str],
    all_files_abs: list[str],
    base_for_rel: "Path",
    source_selectors: set[str] | None = None,
) -> dict:
    """Check whether scan_notes has enough coverage to allow plan submission.

    Validates that every route in scan_notes.routes has a corresponding
    page_elements entry (or is explicitly listed in no_elements_routes).
    Also validates every form has page_elements coverage.

    For routes whose source file changed since the last scan, cached
    page_elements are treated as stale even if present.

    When source_selectors is provided, cross-references page_elements selectors
    against real attribute values extracted from source files.  If >25% of
    selectors are unverified (hallucinated), the gate blocks with suspicious_selector
    gaps.

    Also checks that post_login_flow is populated when auth is detected.

    Returns:
        {"complete": True} if the plan gate should pass.
        {"complete": False, "gaps": [...], "rescan_files": [...]} if blocked.
    """
    if not isinstance(accumulated_notes, dict):
        return {"complete": True}

    routes = accumulated_notes.get("routes", [])
    forms = accumulated_notes.get("forms", [])
    page_elements = accumulated_notes.get("page_elements", [])
    no_elements_routes: set[str] = set(accumulated_notes.get("no_elements_routes", []))

    # Pages that have elements cataloged
    covered_pages = {
        (pe.get("page") or "").lower()
        for pe in page_elements
        if isinstance(pe, dict) and pe.get("page")
    }

    # Which files changed — used to flag stale cached entries
    changed_files_lower = {f.lower().replace("\\", "/") for f in changed_files}

    def _source_file_changed(route_path: str, component: str) -> bool:
        """Return True if any changed file likely corresponds to this route/component."""
        kw = (Path(route_path.strip("/")).name or component or "").lower()
        return bool(kw) and any(kw in f for f in changed_files_lower)

    gaps: list[dict] = []
    missing_keywords: set[str] = set()

    for route in routes if isinstance(routes, list) else []:
        if not isinstance(route, dict):
            continue
        path = (route.get("path") or "").strip()
        component = (route.get("component") or "").strip()
        if not path or path in no_elements_routes:
            continue

        path_lower = path.lower()
        component_lower = component.lower()
        # Direct match: route path or component name matches a page_elements "page" key
        in_covered = path_lower in covered_pages or component_lower in covered_pages
        # Fuzzy match: any meaningful path segment appears inside a covered page name
        # e.g. route "/app/select" → segment "select" found in "AppSelect"
        if not in_covered:
            path_segs = [s for s in path.strip("/").split("/") if len(s) > 2]
            in_covered = any(seg in pg for seg in path_segs for pg in covered_pages)
            if not in_covered and component_lower:
                # Also check if any covered page name appears in the component name
                in_covered = any(pg in component_lower for pg in covered_pages if len(pg) > 2)
        stale = in_covered and _source_file_changed(path, component)

        if not in_covered or stale:
            reason = "stale (source file changed since last scan)" if stale else "missing"
            gaps.append({
                "type": "route_missing_elements",
                "route": path,
                "component": component,
                "stale": stale,
                "message": f"Route '{path}' ({component}) page_elements are {reason}.",
            })
            kw = (Path(path.strip("/")).name or component or "").lower()
            if kw:
                missing_keywords.add(kw)

    for form in forms if isinstance(forms, list) else []:
        if not isinstance(form, dict):
            continue
        page = (form.get("page") or form.get("purpose") or "").lower().strip()
        if not page or page in no_elements_routes:
            continue
        # Fuzzy match: page keyword appears inside any covered page name or vice versa
        _form_covered = page in covered_pages or any(
            page in pg or pg in page for pg in covered_pages if len(pg) > 2
        )
        if not _form_covered:
            gaps.append({
                "type": "form_missing_elements",
                "form": form.get("form_name") or form.get("purpose"),
                "page": page,
                "message": f"Form '{form.get('form_name')}' on '{page}' has no page_elements.",
            })
            missing_keywords.add(page)

    # --- Feature 4: post_login_flow gate ---
    # When auth is detected, the LLM MUST record post_login_flow before the plan
    # can be approved. This catches the "missing app-selection step after login" bug.
    # Skip if accumulated_notes was restored from cache AND no files were re-read this
    # session (changed_files empty + no source_selectors) — the data is already valid.
    _cache_restored_session = not changed_files and not source_selectors
    auth = accumulated_notes.get("auth", {})
    has_auth = isinstance(auth, dict) and bool(
        auth.get("login_url") or auth.get("email_selector") or auth.get("submit_selector")
    )
    post_login_flow = accumulated_notes.get("post_login_flow", [])
    if has_auth and not post_login_flow and not _cache_restored_session:
        gaps.append({
            "type": "missing_post_login_flow",
            "message": (
                "Auth was detected but post_login_flow is empty. "
                "Record what happens after login: application selection screens, "
                "onboarding steps, redirects, or any conditional routing that occurs "
                "before the main app is accessible. "
                "Check middleware.ts, auth providers, post-login redirect handlers, "
                "and any component that renders conditionally based on auth state."
            ),
        })
        for kw in ("middleware", "provider", "app-select", "appselect", "onboard", "redirect", "post-login"):
            missing_keywords.add(kw)

    # --- Feature 3: hallucination detection (WARNING ONLY — does not block plan) ---
    # The source_selectors index is always partial (only files read so far), so using
    # it as a hard gate causes false positives and loops. Instead, just return warnings
    # via the caller's response — the LLM sees them but the plan is not blocked.
    # (Hallucination risk is added to the return value as "selector_warnings" below.)

    if not gaps:
        result: dict = {"complete": True}
        # Add non-blocking selector warnings if source index is available
        if source_selectors and not _cache_restored_session:
            _page_elems = accumulated_notes.get("page_elements", [])
            if _page_elems:
                _sel_result = _validate_page_element_selectors(_page_elems, source_selectors)
                if _sel_result["suspicious"]:
                    result["selectorWarnings"] = {
                        "count": len(_sel_result["suspicious"]),
                        "suspicious": _sel_result["suspicious"][:10],
                        "note": (
                            "These selectors were not found in source files read so far. "
                            "Verify them — but plan submission is not blocked by this."
                        ),
                    }
        return result

    rescan_files = _find_files_for_keywords(
        keywords=list(missing_keywords),
        all_files_abs=all_files_abs,
        base_for_rel=base_for_rel,
        max_files=15,
    )

    # If no files matched the missing keywords, the gap cannot be resolved by rescanning.
    # Return the gaps as warnings only so the plan can still proceed — otherwise the
    # session loops forever with no way out.
    if not rescan_files:
        return {
            "complete": True,
            "warnings": [
                f"{g['type']}: {g['message']}" for g in gaps
            ],
            "note": (
                "Some routes/forms have no matching source files for rescan. "
                "Plan approved with warnings — test cases for unresolved routes may be incomplete."
            ),
        }

    return {
        "complete": False,
        "gaps": gaps,
        "rescan_files": rescan_files,
        "rescan_instruction": (
            "Call generate_tests with session_id + include_content=true + "
            "rescan_files=[<listed paths>] to re-read these files. "
            "Submit updated scan_notes with page_elements for each missing route. "
            "Then resubmit test_plan."
        ),
    }


def _check_flow_coverage(test_plan: dict, scan_notes: dict, min_coverage: float = 0.8) -> dict:
    """Check that the test plan covers most of the flows suggested by scan_notes.

    Returns:
        {"ok": True, "coverage": 1.0, "missing": []} if coverage is sufficient
        {"ok": False, "coverage": <ratio>, "missing": [<flow dicts>]} otherwise
    """
    if not isinstance(test_plan, dict) or not isinstance(scan_notes, dict):
        return {"ok": True, "coverage": 1.0, "missing": []}

    summary = _build_scan_summary(scan_notes)
    suggested = summary.get("suggested_flows", []) or []
    if not suggested:
        return {"ok": True, "coverage": 1.0, "missing": []}

    discovered = test_plan.get("discovered_flows", [])
    if not isinstance(discovered, list):
        discovered = []

    def _norm(val: str) -> str:
        return (val or "").strip().lower()

    covered = 0
    missing: list[dict] = []

    for s in suggested:
        s_route = _norm(s.get("route", ""))
        s_name = _norm(s.get("name", ""))
        s_keys = {k for k in (s_route, s_name) if k}
        is_covered = False
        for flow in discovered:
            if not isinstance(flow, dict):
                continue
            flow_name = _norm(flow.get("flow_name") or flow.get("name"))
            entry_point = _norm(_normalize_route_from_entry_point(flow.get("entry_point", "")))
            if s_keys & {flow_name, entry_point}:
                is_covered = True
                break
        if is_covered:
            covered += 1
        else:
            missing.append(s)

    coverage = covered / max(1, len(suggested))
    if coverage >= min_coverage:
        return {"ok": True, "coverage": coverage, "missing": []}
    return {"ok": False, "coverage": coverage, "missing": missing}


def _validate_test_plan(test_plan: dict, scan_notes: dict) -> dict[str, Any]:
    """Validate plan quality against accumulated scan notes.

    Returns errors (hard blocks) and warnings (soft guidance).
    A non-empty errors list causes plan_rejected.
    """
    errors: list[str] = []
    warnings: list[str] = []
    discovered_flows = test_plan.get("discovered_flows", [])
    if not isinstance(discovered_flows, list):
        discovered_flows = []
        warnings.append("WARNING: test_plan.discovered_flows must be a list")

    known_selectors = _collect_known_selectors(scan_notes if isinstance(scan_notes, dict) else {})
    known_texts, known_urls = _collect_known_assertions(scan_notes if isinstance(scan_notes, dict) else {})

    route_auth_map: dict[str, bool] = {}
    routes = scan_notes.get("routes", []) if isinstance(scan_notes, dict) else []
    for route in routes if isinstance(routes, list) else []:
        if not isinstance(route, dict):
            continue
        path = route.get("path")
        if isinstance(path, str) and path.strip():
            route_auth_map[path.strip()] = bool(route.get("auth_required"))

    blocked_routes = set()
    redirects = set()
    auth_protected = set()
    if isinstance(scan_notes, dict):
        blocked_routes = {
            r.strip() for r in scan_notes.get("blocked_routes", [])
            if isinstance(r, str) and r.strip()
        }
        for redirect in scan_notes.get("server_redirects", []) if isinstance(scan_notes.get("server_redirects"), list) else []:
            if isinstance(redirect, dict):
                src = redirect.get("source")
                if isinstance(src, str) and src.strip():
                    redirects.add(src.strip())
        auth = scan_notes.get("auth", {})
        if isinstance(auth, dict):
            auth_protected = {
                r.strip() for r in auth.get("protected_routes", [])
                if isinstance(r, str) and r.strip()
            }

    # --- Fix 2: Build modal-triggered view map from navigation_mechanisms ---
    # modal_triggered: {opens_name_lower -> trigger_selector}
    # Used to detect plan steps that mistakenly treat modal views as routes.
    modal_triggered: dict[str, str] = {}
    if isinstance(scan_notes, dict):
        _nav_mechs = scan_notes.get("navigation_mechanisms", []) or []
        if isinstance(_nav_mechs, list):
            for _nm in _nav_mechs:
                if not isinstance(_nm, dict):
                    continue
                if _nm.get("mechanism") == "modal":
                    _opens = (_nm.get("opens") or "").strip()
                    _trigger = (_nm.get("trigger_selector") or "").strip()
                    if _opens:
                        modal_triggered[_opens.lower()] = _trigger

    # --- Fix 3: Collect potentially unused component file paths ---
    # Used to warn when key_selectors may come from dead/unused components.
    potentially_unused_files: set[str] = set()
    if isinstance(scan_notes, dict):
        _unused = scan_notes.get("potentially_unused_components", []) or []
        if isinstance(_unused, list):
            for _uc in _unused:
                if isinstance(_uc, dict):
                    _f = (_uc.get("file") or "").strip()
                    if _f:
                        potentially_unused_files.add(_f.lower())
                elif isinstance(_uc, str) and _uc.strip():
                    potentially_unused_files.add(_uc.strip().lower())

    selector_refs_total = 0
    selector_matches = 0
    assertion_refs_total = 0

    for idx, flow in enumerate(discovered_flows, start=1):
        if not isinstance(flow, dict):
            warnings.append(f"WARNING: discovered_flows[{idx}] is not an object")
            continue

        flow_name = flow.get("flow_name") or f"Flow {idx}"
        test_cases_planned = flow.get("test_cases_planned", [])
        if not isinstance(test_cases_planned, list):
            test_cases_planned = []

        has_negative = any(isinstance(tc, str) and _is_negative_test_name(tc) for tc in test_cases_planned)
        has_positive = any(isinstance(tc, str) and not _is_negative_test_name(tc) for tc in test_cases_planned)
        if not has_positive or not has_negative:
            warnings.append(
                f"WARNING: '{flow_name}' should include at least 1 positive and 1 negative planned test case"
            )

        planned_selectors = _extract_strings(flow.get("key_selectors", {}))
        selector_refs_total += len(planned_selectors)
        missing_selectors: list[str] = []
        for selector in planned_selectors:
            if selector in known_selectors:
                selector_matches += 1
            else:
                missing_selectors.append(selector)
        if missing_selectors:
            sample = ", ".join(missing_selectors[:3])
            warnings.append(
                f"WARNING: '{flow_name}' references selectors not found in scan_notes: {sample}"
            )

        assertions = flow.get("assertions_planned", [])
        if not isinstance(assertions, list):
            assertions = []
        assertion_refs_total += len(assertions)
        missing_assertions: list[str] = []
        for assertion in assertions:
            if not isinstance(assertion, str):
                continue
            value = assertion.strip()
            if not value:
                continue
            normalized = value.lower()
            if "/" in value:
                if not any(u and (value in u or u in value) for u in known_urls):
                    missing_assertions.append(value)
            else:
                if not any(t and (normalized in t.lower() or t.lower() in normalized) for t in known_texts):
                    missing_assertions.append(value)
        if missing_assertions:
            sample = ", ".join(missing_assertions[:3])
            warnings.append(
                f"WARNING: '{flow_name}' has assertions not grounded in scan_notes text/URLs: {sample}"
            )

        entry_point = flow.get("entry_point", "")
        entry_route = _normalize_route_from_entry_point(entry_point if isinstance(entry_point, str) else "")
        if entry_route and (entry_route in blocked_routes or entry_route in redirects):
            warnings.append(
                f"WARNING: '{flow_name}' entry_point '{entry_route}' is blocked or redirected; use an accessible navigation path"
            )

        prerequisites = flow.get("prerequisites", [])
        if not isinstance(prerequisites, list):
            prerequisites = []
        journey_steps = flow.get("user_journey_steps", [])
        if not isinstance(journey_steps, list):
            journey_steps = []

        prereq_auth = any(
            isinstance(item, str) and re.search(r"auth|required|logged.?in|login|sign.?in", item, re.I)
            for item in prerequisites
        )
        entry_auth_required = bool(entry_route and (route_auth_map.get(entry_route) or entry_route in auth_protected))
        if prereq_auth or entry_auth_required:
            has_login_steps = any(
                isinstance(step, str) and re.search(r"login|sign.?in|enter email|enter password|authenticate", step, re.I)
                for step in (prerequisites + journey_steps)
            )
            if not has_login_steps:
                warnings.append(
                    f"WARNING: '{flow_name}' appears auth-protected but the plan does not include login steps"
                )

        # --- Fix 2: Modal-triggered view detection in user_journey_steps ---
        # If any journey step claims mechanism='route' for something that scan_notes
        # records as a modal-triggered view (mechanism='modal'), the generated test
        # will use go_to on a non-existent route and fail immediately.
        if modal_triggered:
            for _jstep in journey_steps:
                if not isinstance(_jstep, dict):
                    continue
                _step_mech = (_jstep.get("mechanism") or "").lower()
                _step_dest = (
                    _jstep.get("destination") or _jstep.get("opens") or ""
                ).strip().lower()
                if not _step_dest:
                    continue
                for _modal_name, _modal_trigger in modal_triggered.items():
                    if _modal_name in _step_dest or _step_dest in _modal_name:
                        if _step_mech == "route":
                            errors.append(
                                f"ERROR: '{flow_name}' journey step has mechanism='route' "
                                f"for '{_step_dest}', but scan_notes.navigation_mechanisms "
                                f"records '{_modal_name}' as a MODAL view (no URL — "
                                f"triggered by clicking '{_modal_trigger}'). "
                                f"Remove any go_to for this view. "
                                f"Use click on '{_modal_trigger}' instead. "
                                f"There is NO route for this feature."
                            )
                        elif not _step_mech:
                            # mechanism field missing — add a warning
                            warnings.append(
                                f"WARNING: '{flow_name}' journey step targeting '{_step_dest}' "
                                f"is missing a 'mechanism' field. "
                                f"scan_notes shows it is a modal triggered by '{_modal_trigger}'. "
                                f"Set mechanism='modal' and remove any go_to for this step."
                            )

        # --- Fix 3: Warn if key_selectors reference potentially-unused components ---
        if potentially_unused_files:
            _flow_selectors_raw = flow.get("key_selectors", {})
            _flow_selector_list = _extract_strings(_flow_selectors_raw)
            for _sel in _flow_selector_list:
                # Check if the selector value (as a fragment) appears in any unused file path
                _sel_frag = _sel.lower()
                for _unused_file in potentially_unused_files:
                    # Match on selector value appearing inside the file path stem,
                    # e.g. selector "navigation-items" matching "components/Nav/NavigationItems.js"
                    _file_stem = Path(_unused_file).stem.lower()
                    if _sel_frag in _file_stem or _file_stem in _sel_frag:
                        warnings.append(
                            f"WARNING: '{flow_name}' key_selector '{_sel}' may come from "
                            f"a potentially unused component '{_unused_file}'. "
                            f"Verify this selector is rendered by a page import chain "
                            f"before using it — dead components produce phantom selectors "
                            f"that don't exist in the running app."
                        )
                        break

    # --- Flow chain validation (hard error) ---
    # If scan_notes recorded a post_login_flow (e.g. app selection, onboarding),
    # every auth-protected flow that does NOT start at the login page itself MUST
    # include those post-login steps in its prerequisites or user_journey_steps.
    # This is the specific check that catches "jumped past app-selection after login".
    post_login_steps = []
    if isinstance(scan_notes, dict):
        raw_plf = scan_notes.get("post_login_flow", [])
        if isinstance(raw_plf, list):
            post_login_steps = raw_plf
        elif isinstance(raw_plf, str) and raw_plf.strip():
            post_login_steps = [raw_plf]

    if post_login_steps:
        # Build keyword set from post_login_flow descriptions
        _STOP_WORDS = {
            "this", "that", "then", "when", "with", "from", "user", "page",
            "click", "button", "after", "before", "into", "onto", "upon",
            "have", "been", "will", "they", "their", "there",
        }
        plf_keywords: set[str] = set()
        for _step in post_login_steps:
            _text = _step if isinstance(_step, str) else (
                " ".join(str(v) for v in _step.values()) if isinstance(_step, dict) else ""
            )
            for _word in re.findall(r"[a-z]{4,}", _text.lower()):
                if _word not in _STOP_WORDS:
                    plf_keywords.add(_word)

        # Auth login URL — flows that start at login are exempt
        _auth = scan_notes.get("auth", {}) if isinstance(scan_notes, dict) else {}
        _login_url = (_auth.get("login_url", "") or "").rstrip("/") if isinstance(_auth, dict) else ""

        for _idx, _flow in enumerate(discovered_flows, start=1):
            if not isinstance(_flow, dict):
                continue
            _flow_name = _flow.get("flow_name") or f"Flow {_idx}"
            _entry = _flow.get("entry_point", "")
            _entry_route = _normalize_route_from_entry_point(
                _entry if isinstance(_entry, str) else ""
            )

            # Skip the login flow itself
            _is_login_flow = bool(
                _login_url and _entry_route and (
                    _entry_route == _login_url
                    or _entry_route.rstrip("/") == _login_url
                    or _login_url.endswith(_entry_route.rstrip("/") or "/____")
                )
            )
            if _is_login_flow:
                continue

            # Only check auth-protected flows
            _prereqs = _flow.get("prerequisites", []) or []
            _journey = _flow.get("user_journey_steps", []) or []
            _prereq_auth = any(
                isinstance(s, str) and re.search(r"auth|required|logged.?in|login|sign.?in", s, re.I)
                for s in _prereqs
            )
            _entry_protected = bool(
                _entry_route and (
                    route_auth_map.get(_entry_route)
                    or _entry_route in auth_protected
                )
            )
            if not (_prereq_auth or _entry_protected):
                continue

            # Check that the flow mentions the post-login steps
            _combined = " ".join([
                *(s for s in _prereqs if isinstance(s, str)),
                *(s for s in _journey if isinstance(s, str)),
            ]).lower()
            _matched_keywords = [kw for kw in plf_keywords if kw in _combined]

            if not _matched_keywords:
                errors.append(
                    f"ERROR: '{_flow_name}' is auth-protected and starts after login, "
                    f"but its prerequisites/user_journey_steps do not include the "
                    f"post-login flow recorded in scan_notes. "
                    f"scan_notes.post_login_flow describes steps that happen between "
                    f"login and reaching the main app (e.g. application selection, "
                    f"onboarding screens, redirects). "
                    f"You MUST add those steps to this flow's prerequisites or "
                    f"as the first steps of user_journey_steps before navigating to the feature. "
                    f"Expected keywords from post_login_flow: "
                    f"{', '.join(sorted(plf_keywords)[:10])}. "
                    f"Re-read pages/onboarding/* and middleware files, update post_login_flow "
                    f"in scan_notes if needed, then fix this flow and resubmit test_plan."
                )

    return {
        "errors": errors,
        "warnings": warnings,
        "summary": {
            "flowsChecked": len(discovered_flows),
            "selectorReferences": selector_refs_total,
            "selectorsMatched": selector_matches,
            "assertionReferences": assertion_refs_total,
        },
    }



# Login/navigation injection removed — the LLM writes complete Python code
# with the full user journey (goto → login → navigate → interact → assert).
# No post-processing needed.


def _slugify(value: str) -> str:
    """Convert a string to a valid filename slug."""
    cleaned = "".join(ch.lower() if ch.isalnum() or ch.isspace() else "_" for ch in value)
    cleaned = "_".join([p for p in cleaned.split() if p])
    return cleaned or "flow"


# ---------------------------------------------------------------------------
# Step-level logical consistency validator
# ---------------------------------------------------------------------------

_NEGATIVE_WORDS = re.compile(
    r"\b(wrong|invalid|incorrect|bad|fail|error|negative|nonexistent|non.exist|"
    r"no.account|not.found|does.not.exist|fake|gibberish)\b",
    re.IGNORECASE,
)

_SUCCESS_URL_PATTERNS = re.compile(
    r"/(dashboard|notebook|home|app|workspace|overview|projects|settings|profile|main|portal)",
    re.IGNORECASE,
)

_CREDENTIAL_EMAIL_RE = re.compile(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}")


def _validate_test_steps(test_cases: list[dict]) -> list[str]:
    """Check submitted test steps for logical contradictions and selector mismatches.

    Returns a list of human-readable issue strings. Empty list means no issues.
    """
    issues: list[str] = []

    for tc in test_cases:
        if not isinstance(tc, dict):
            continue
        name = tc.get("name") or "unnamed"
        desc = (tc.get("description") or "").lower()
        steps = tc.get("steps") or []
        if not isinstance(steps, list):
            continue

        is_negative_test = bool(_NEGATIVE_WORDS.search(name) or _NEGATIVE_WORDS.search(desc))

        asserts_success_url = False
        asserts_error_msg = False
        success_url_step = ""

        for i, step in enumerate(steps):
            if not isinstance(step, dict):
                continue

            action = (step.get("action_taken") or "").lower()
            step_desc = (step.get("description") or "").lower()
            selector = step.get("css_selector") or ""
            atype = (step.get("assertion_type") or "").lower()
            avalue = step.get("assertion_value") or ""
            mock_input = step.get("mock_input") or ""

            # ── Contradiction: negative test asserts a success URL ─────────
            if atype == "to contain url" and _SUCCESS_URL_PATTERNS.search(avalue):
                asserts_success_url = True
                success_url_step = f"step {i + 1}"

            # ── Error message assertion (used later to check contradiction) ─
            if atype in ("to be visible", "to contain text") and (
                "error" in avalue.lower()
                or "invalid" in avalue.lower()
                or "not found" in avalue.lower()
                or "incorrect" in avalue.lower()
                or "could not" in avalue.lower()
            ):
                asserts_error_msg = True

            # ── Selector-type mismatch for send_keys ──────────────────────
            if action == "send_keys":
                if ("password" in step_desc) and '[type="email"]' in selector:
                    issues.append(
                        f"{name} | step {i + 1}: password field uses email selector "
                        f'({selector}). Use [type="password"] instead.'
                    )
                if ("email" in step_desc or "username" in step_desc) and '[type="password"]' in selector:
                    issues.append(
                        f"{name} | step {i + 1}: email/username field uses password selector "
                        f'({selector}). Use [type="email"] instead.'
                    )

            # ── Click action on non-clickable selector type ────────────────
            if action == "click" and selector in ('[type="email"]', '[type="password"]'):
                issues.append(
                    f"{name} | step {i + 1}: click action targets input selector "
                    f"({selector}). Use [type=\"submit\"] or a button selector."
                )

            # ── Hardcoded real email in mock_input ─────────────────────────
            if action == "send_keys" and mock_input and _CREDENTIAL_EMAIL_RE.match(mock_input.strip()):
                if not mock_input.strip().endswith("@example.com"):
                    issues.append(
                        f"{name} | step {i + 1}: mock_input contains a real email address "
                        f"({mock_input!r}). Use a placeholder like 'test@example.com' or "
                        "an env-var reference instead."
                    )

        # ── Negative test with success URL + optional error assertion ──────
        if is_negative_test and asserts_success_url:
            issues.append(
                f"{name}: negative/failure test asserts a success URL redirect "
                f"({success_url_step}) — this is logically impossible. "
                "A failed login cannot navigate to the authenticated area."
            )

        # ── Success URL assertion followed by error assertion ──────────────
        if asserts_success_url and asserts_error_msg:
            issues.append(
                f"{name}: test asserts both a success URL redirect AND an error message "
                "— these are mutually exclusive. Choose one outcome."
            )

    return issues


# ---------------------------------------------------------------------------
# Step reachability validation helpers
# ---------------------------------------------------------------------------

# Generic HTML attribute selectors that are valid without needing source-grounding.
# Matches things like [type="email"], [type="password"], [type="submit"], [type="checkbox"].
_GENERIC_TYPE_RE = re.compile(r'^\[type=["\']?\w+["\']?\]$')

# Selector patterns that use custom/specific attributes worth validating.
_CUSTOM_ATTR_RE = re.compile(
    r'data-testid=|data-cy=|\[id=|^\#[a-zA-Z]|\[name=|\[aria-label=|\[aria-labelledby='
)


def _is_custom_selector(selector: str) -> bool:
    """Return True if the selector uses a custom/specific attribute that should exist in source.

    Generic type-based selectors (e.g. [type="email"]) are valid HTML and do not need
    to be found in source_selectors.  Only custom attributes like data-testid, id, name,
    or aria-label are worth cross-checking.
    """
    if not selector or not selector.strip():
        return False
    sel = selector.strip()
    if _GENERIC_TYPE_RE.match(sel):
        return False
    return bool(_CUSTOM_ATTR_RE.search(sel))


def _extract_url_path(mock_input: str, base_url: str | None = None) -> str:
    """Extract the path component from a go_to mock_input value.

    Handles:
    - Full URLs: https://app.example.com/settings/profile → /settings/profile
    - BASE_URL placeholders: {{BASE_URL}}/login → /login
    - Relative paths: /login → /login
    - Query params and fragments are stripped.
    """
    if not mock_input:
        return ""
    url = mock_input.strip()
    # Strip {{BASE_URL}} or similar placeholders
    url = re.sub(r'\{\{[^}]+\}\}', '', url)
    # If it looks like a full URL, extract path
    if url.startswith("http://") or url.startswith("https://"):
        try:
            from urllib.parse import urlparse
            parsed = urlparse(url)
            path = parsed.path or "/"
        except Exception:
            path = url
    else:
        path = url
    # Strip query string and fragment
    path = re.split(r'[?#]', path)[0]
    # Normalize: ensure leading slash, strip trailing slash (except root)
    if not path.startswith("/"):
        path = "/" + path
    if len(path) > 1:
        path = path.rstrip("/")
    return path or "/"


def _validate_steps_against_plan(
    test_cases: list[dict],
    test_plan: dict,
) -> list[dict]:
    """Gate 2: Verify submitted test_cases faithfully implement the approved test_plan.

    For each test case, finds the matching flow in the plan and checks:
    - No user_journey_steps were dropped
    - The flow navigates to the same pages as planned
    - No flow was substituted with a different (easier) flow

    Returns a list of issue dicts. Empty list means the steps match the plan.
    """
    issues: list[dict] = []
    if not isinstance(test_plan, dict):
        return issues

    discovered_flows = test_plan.get("discovered_flows") or []
    if not isinstance(discovered_flows, list):
        return issues

    # Index plan flows by name for O(1) lookup
    plan_flows: dict[str, dict] = {}
    for flow in discovered_flows:
        if isinstance(flow, dict) and flow.get("flow_name"):
            plan_flows[flow["flow_name"].lower().strip()] = flow

    def _match_flow(tc_name: str) -> dict | None:
        """Fuzzy-match a test case name to a plan flow."""
        name_lower = tc_name.lower().strip()
        # Exact match
        if name_lower in plan_flows:
            return plan_flows[name_lower]
        # Substring match: test case name contains the flow name or vice versa
        for flow_name, flow in plan_flows.items():
            if flow_name in name_lower or name_lower in flow_name:
                return flow
        # Word-overlap match: ≥60% of flow_name words appear in tc_name
        for flow_name, flow in plan_flows.items():
            flow_words = set(flow_name.split())
            tc_words = set(name_lower.split())
            if flow_words and len(flow_words & tc_words) / len(flow_words) >= 0.6:
                return flow
        return None

    def _extract_go_to_urls(steps: list) -> list[str]:
        return [
            (s.get("mock_input") or "").strip()
            for s in steps
            if isinstance(s, dict) and s.get("action_taken") == "go_to"
        ]

    def _extract_click_targets(steps: list) -> list[str]:
        return [
            (s.get("css_selector") or "").strip()
            for s in steps
            if isinstance(s, dict) and s.get("action_taken") == "click"
        ]

    def _extract_assertion_values(steps: list) -> list[str]:
        return [
            (s.get("assertion_value") or "").strip().lower()
            for s in steps
            if isinstance(s, dict) and s.get("assertion_nature") and s.get("assertion_value")
        ]

    for tc in test_cases:
        if not isinstance(tc, dict):
            continue
        tc_name = tc.get("name") or ""
        tc_steps = tc.get("steps") or []

        matched_flow = _match_flow(tc_name)
        if not matched_flow:
            # No plan match — skip (could be a test name that diverged slightly)
            continue

        plan_journey = matched_flow.get("user_journey_steps") or []
        plan_assertions = matched_flow.get("assertions_planned") or []

        if not plan_journey and not plan_assertions:
            continue

        # Check 1: Verify planned assertions are represented in submitted steps
        tc_assertion_values = _extract_assertion_values(tc_steps)
        missing_assertions: list[str] = []
        for planned_assert in plan_assertions:
            if not isinstance(planned_assert, str):
                continue
            planned_lower = planned_assert.lower().strip()
            # Check if any assertion value in tc steps covers this planned assertion
            covered = any(
                planned_lower in av or av in planned_lower
                for av in tc_assertion_values
                if av
            )
            if not covered:
                missing_assertions.append(planned_assert)

        if missing_assertions:
            issues.append({
                "testName": tc_name,
                "issueType": "plan_assertion_dropped",
                "description": (
                    f"The following assertions from the approved plan are missing from the test: "
                    f"{missing_assertions}"
                ),
                "instruction": (
                    "Add assertion steps for each missing assertion. "
                    "The plan is a binding contract — do not omit planned assertions."
                ),
            })

        # Check 2: Verify step count is not drastically fewer than planned journey steps
        # Allow up to 30% reduction (some plan steps may be combined), but flag severe drops
        if plan_journey and len(tc_steps) < len(plan_journey) * 0.5:
            issues.append({
                "testName": tc_name,
                "issueType": "plan_steps_severely_reduced",
                "description": (
                    f"Plan specified {len(plan_journey)} journey steps but test has only "
                    f"{len(tc_steps)} steps — more than 50% were dropped."
                ),
                "instruction": (
                    "Implement all steps from user_journey_steps in your approved plan. "
                    "Each plan step must have a corresponding test step."
                ),
            })

    return issues


def _build_nav_adjacency(scan_notes: dict) -> dict[str, list[str]]:
    """Build from_page → [to_page, ...] adjacency from nav_graph / page_graph."""
    adjacency: dict[str, list[str]] = {}
    graph_edges = scan_notes.get("nav_graph") or scan_notes.get("page_graph") or []
    for edge in graph_edges:
        if not isinstance(edge, dict):
            continue
        src = edge.get("from_page", "")
        dst = edge.get("to_page", "")
        if src and dst:
            adjacency.setdefault(src, [])
            if dst not in adjacency[src]:
                adjacency[src].append(dst)
    return adjacency


def _get_known_routes(scan_notes: dict) -> set[str]:
    """Return the set of route paths from scan_notes.routes."""
    routes = scan_notes.get("routes", [])
    if not isinstance(routes, list):
        return set()
    return {r.get("path", "") for r in routes if isinstance(r, dict) and r.get("path")}


def _validate_step_reachability(
    test_cases: list[dict],
    scan_notes: dict,
    source_selectors: list[str],
) -> list[dict]:
    """Static step-by-step validation of test cases against repository source.

    Simulates hypothetical execution of each test case step by step, tracking which
    page the user is on after each action.  For each step validates:

    1. go_to URLs must exist in scan_notes.routes
       (hard error: url_not_found)
    2. Custom css_selectors must appear in source_selectors or page_elements
       (hard error: selector_not_in_source)
    3. Custom css_selectors that exist in source but are NOT present on the
       current page's page_elements catalogue
       (warning: selector_wrong_page)
    4. go_to that bypasses nav_graph edges from the current page
       (warning: navigation_skip)

    Page state is tracked across all step types:
    - go_to  → sets current_page to the target path
    - click  → if the selector matches a nav_graph edge from current_page,
                current_page advances to the destination; this makes the NEXT
                step's selector/URL validation happen against the correct page.

    Returns a list of issue dicts.  Empty list means no issues detected.
    Skips URL/selector validation when scan_notes lacks routes AND source_selectors
    is empty (insufficient data — avoids false positives on sparse scans).
    """
    if not isinstance(scan_notes, dict):
        scan_notes = {}
    if not isinstance(source_selectors, list):
        source_selectors = []

    known_routes = _get_known_routes(scan_notes)
    nav_adj = _build_nav_adjacency(scan_notes)

    # Fix 2: Build modal-triggered view map: opens_name_lower → trigger_selector
    # A go_to targeting a modal-triggered view is a hard error (modal_triggered_view).
    _step_modal_map: dict[str, str] = {}
    _nav_mechs_raw = scan_notes.get("navigation_mechanisms", []) or []
    if isinstance(_nav_mechs_raw, list):
        for _nm in _nav_mechs_raw:
            if not isinstance(_nm, dict):
                continue
            if _nm.get("mechanism") == "modal":
                _opens = (_nm.get("opens") or "").strip()
                _trigger = (_nm.get("trigger_selector") or "").strip()
                if _opens:
                    _step_modal_map[_opens.lower()] = _trigger

    # Build per-page selector sets from page_elements
    page_elems_raw = scan_notes.get("page_elements", [])
    page_elem_selectors: dict[str, set[str]] = {}
    if isinstance(page_elems_raw, list):
        for pe in page_elems_raw:
            if not isinstance(pe, dict):
                continue
            page = pe.get("page", "")
            elems = pe.get("elements", [])
            if not page or not isinstance(elems, list):
                continue
            sels: set[str] = set()
            for el in elems:
                if isinstance(el, dict):
                    sel = el.get("selector") or el.get("css_selector") or ""
                    if sel:
                        sels.add(sel.strip())
                elif isinstance(el, str) and el.strip():
                    sels.add(el.strip())
            page_elem_selectors[page] = sels

    all_page_selectors: set[str] = set()
    for sels in page_elem_selectors.values():
        all_page_selectors |= sels

    source_sel_set = {s.strip() for s in source_selectors if isinstance(s, str) and s.strip()}
    all_known_selectors = source_sel_set | all_page_selectors

    # Build click-navigation map: (from_page, selector) -> to_page
    # When a click step uses a selector that matches a nav_graph edge, we advance the page.
    click_nav_map: dict[tuple[str, str], str] = {}
    for edge in (scan_notes.get("nav_graph") or scan_notes.get("page_graph") or []):
        if not isinstance(edge, dict):
            continue
        fp = edge.get("from_page", "")
        tp = edge.get("to_page", "")
        sel = (edge.get("selector") or "").strip()
        if fp and tp and sel:
            click_nav_map[(fp, sel)] = tp

    # Determine login URL — always allowed as a direct go_to entry point
    auth = scan_notes.get("auth", {}) if isinstance(scan_notes.get("auth"), dict) else {}
    raw_login_url = auth.get("login_url") or "/login"
    login_path = _extract_url_path(raw_login_url)

    # Build auth-protected route set from routes + auth.protected_routes + navigation_tree
    _route_auth_map: dict[str, bool] = {}
    for r in (scan_notes.get("routes") or []):
        if isinstance(r, dict) and r.get("path"):
            _route_auth_map[r["path"].strip()] = bool(r.get("auth_required"))
    _auth_protected_paths: set[str] = {
        p.strip() for p in (auth.get("protected_routes") or [])
        if isinstance(p, str) and p.strip()
    }
    _nav_tree = scan_notes.get("navigation_tree") or {}
    if isinstance(_nav_tree, dict):
        for _path, _info in _nav_tree.items():
            if isinstance(_info, dict) and _info.get("auth_required"):
                _auth_protected_paths.add(_path.strip())
    # Merge: any route flagged auth_required in route_auth_map also goes in the set
    for _p, _req in _route_auth_map.items():
        if _req:
            _auth_protected_paths.add(_p)

    has_route_data = bool(known_routes)
    has_selector_data = bool(all_known_selectors)

    issues: list[dict] = []

    for tc in test_cases:
        if not isinstance(tc, dict):
            continue
        name = tc.get("name") or "unnamed"
        steps = tc.get("steps") or []
        if not isinstance(steps, list):
            continue

        # current_page: the page the hypothetical user is on after each step.
        # Starts as None (no page loaded yet).
        current_page: str | None = None
        visited_pages: set[str] = set()

        for i, step in enumerate(steps):
            if not isinstance(step, dict):
                continue

            step_num = i + 1
            _prev_page = current_page
            action = (step.get("action_taken") or "").lower().strip()
            selector = (step.get("css_selector") or "").strip()
            mock_input = (step.get("mock_input") or "").strip()
            step_desc = (step.get("description") or "").lower()

            # ── go_to: URL existence + navigation skip check ─────────────
            if action == "go_to":
                new_path = _extract_url_path(mock_input)

                # Fix 2 — Hard error: go_to on a modal-triggered view (no URL exists).
                # Check BEFORE url_not_found so we give a precise error message.
                if new_path and new_path not in ("/", login_path) and _step_modal_map:
                    _path_lower = new_path.lower().strip("/")
                    _modal_match: str | None = None
                    _modal_trigger: str = ""
                    for _mname, _mtrig in _step_modal_map.items():
                        # Match path fragment against modal name
                        # e.g. "/settings" ↔ "ApplicationDetailsModal" (settings keyword)
                        # e.g. "/settings" ↔ "settings" (exact substring)
                        _mname_clean = _mname.lower().replace("modal", "").replace("dialog", "").strip()
                        if _mname_clean and (_mname_clean in _path_lower or _path_lower in _mname_clean):
                            _modal_match = _mname
                            _modal_trigger = _mtrig
                            break
                    if _modal_match:
                        issues.append({
                            "testName": name,
                            "stepNumber": step_num,
                            "issueType": "modal_triggered_view",
                            "description": (
                                f"go_to '{new_path}' — this view is NOT a route. "
                                f"scan_notes.navigation_mechanisms records '{_modal_match}' as "
                                f"a modal/overlay triggered by a UI action, not a URL change. "
                                f"There is no navigable path for this feature."
                            ),
                            "suggestion": (
                                f"Remove this go_to entirely. "
                                f"Instead, click the trigger element: '{_modal_trigger}'. "
                                f"The test step should be: action_taken='click', "
                                f"css_selector='{_modal_trigger}'. "
                                f"Then assert that the modal content is visible."
                            ),
                        })

                # Hard error: URL path not in known routes
                if has_route_data and new_path and new_path not in known_routes:
                    if new_path not in ("/", login_path):
                        avail = sorted(known_routes)[:20]
                        # Check if this missing path might be a modal-triggered view
                        _path_lower = new_path.lower().strip("/")
                        _modal_hint = ""
                        for _mname, _mtrig in _step_modal_map.items():
                            _mname_clean = _mname.lower().replace("modal", "").replace("dialog", "").strip()
                            if _mname_clean and (_mname_clean in _path_lower or _path_lower in _mname_clean):
                                _modal_hint = (
                                    f" NOTE: '{_mname}' is a modal view with no URL — "
                                    f"click '{_mtrig}' instead of using go_to."
                                )
                                break
                        issues.append({
                            "testName": name,
                            "stepNumber": step_num,
                            "issueType": "url_not_found",
                            "description": (
                                f"go_to '{new_path}' — this path is not defined in any known route."
                            ),
                            "suggestion": (
                                f"Known routes: {avail}. "
                                "If this page is only reachable via UI navigation, "
                                f"replace the go_to with click steps from "
                                f"'{current_page or login_path}'."
                                + _modal_hint
                            ),
                        })

                # Navigation skip check: go_to jumps past required navigation steps.
                # Auth-protected routes → hard error (auth_route_direct_nav).
                # Other skips → soft warning (navigation_skip).
                if new_path and new_path not in ("/", login_path):
                    _is_auth_protected = new_path in _auth_protected_paths
                    _has_nav_skip = (
                        current_page is not None
                        and new_path != current_page
                        and bool(nav_adj.get(current_page))
                        and new_path not in set(nav_adj.get(current_page, []))
                    )
                    if _is_auth_protected:
                        # Hard error: model must never directly navigate to an auth-protected
                        # route — it must log in first and click through
                        _nav_tree_entry = _nav_tree.get(new_path, {}) if isinstance(_nav_tree, dict) else {}
                        _reachable_via = (
                            _nav_tree_entry.get("reachable_via")
                            if isinstance(_nav_tree_entry, dict) else None
                        )
                        _hint = (
                            f"Navigate to '{_reachable_via}' first and click through."
                            if _reachable_via
                            else f"Start from '{login_path}', complete login, then click to '{new_path}'."
                        )
                        issues.append({
                            "testName": name,
                            "stepNumber": step_num,
                            "issueType": "auth_route_direct_nav",
                            "description": (
                                f"go_to '{new_path}' — this route requires authentication. "
                                "A real user cannot navigate here directly without logging in first."
                            ),
                            "suggestion": (
                                f"Remove this go_to. {_hint} "
                                "go_to is only valid for the app root URL or the login page."
                            ),
                        })
                    elif _has_nav_skip:
                        reachable_from = set(nav_adj.get(current_page, []))
                        issues.append({
                            "testName": name,
                            "stepNumber": step_num,
                            "issueType": "navigation_skip",
                            "description": (
                                f"go_to '{new_path}' from '{current_page}' but no nav_graph "
                                f"edge connects these pages."
                            ),
                            "suggestion": (
                                f"Pages reachable from '{current_page}' via UI: "
                                f"{sorted(reachable_from)}. "
                                "Replace the direct go_to with click steps that follow the "
                                "real user navigation path."
                            ),
                        })

                # Advance hypothetical page state
                current_page = new_path

            # ── Interaction steps: validate selector + advance page state ─
            elif action in ("click", "send_keys", "hover", "check", "click_select",
                            "clear", "set_range"):

                if selector and _is_custom_selector(selector) and has_selector_data:
                    page_sels = page_elem_selectors.get(current_page or "", set())
                    has_page_catalogue = bool(page_sels)

                    if has_page_catalogue:
                        # We have page-specific element data — check against it.
                        if selector not in page_sels:
                            if selector in all_known_selectors:
                                # Selector exists in source but on a DIFFERENT page.
                                # This means the test step would fail because the
                                # element isn't present on the current page.
                                wrong_pages = [
                                    pg for pg, sels in page_elem_selectors.items()
                                    if selector in sels
                                ]
                                issues.append({
                                    "testName": name,
                                    "stepNumber": step_num,
                                    "issueType": "selector_wrong_page",
                                    "description": (
                                        f"css_selector '{selector}' exists in source but "
                                        f"is NOT present on the current page '{current_page}'. "
                                        f"It was found on: {wrong_pages}."
                                    ),
                                    "suggestion": (
                                        f"Either navigate to one of {wrong_pages} first, "
                                        f"or use a selector that belongs to '{current_page}': "
                                        f"{sorted(page_sels)[:10]}."
                                    ),
                                })
                            else:
                                # Not found anywhere in source.
                                issues.append({
                                    "testName": name,
                                    "stepNumber": step_num,
                                    "issueType": "selector_not_in_source",
                                    "description": (
                                        f"css_selector '{selector}' was not found in any "
                                        f"scanned source file or page_elements."
                                    ),
                                    "suggestion": (
                                        f"Available selectors on '{current_page}': "
                                        f"{sorted(page_sels)[:10]}. "
                                        "Use a selector that exists in the source."
                                    ),
                                })
                    else:
                        # No page-specific catalogue — fall back to global check.
                        if selector not in all_known_selectors:
                            issues.append({
                                "testName": name,
                                "stepNumber": step_num,
                                "issueType": "selector_not_in_source",
                                "description": (
                                    f"css_selector '{selector}' was not found in any "
                                    f"scanned source file or page_elements."
                                ),
                                "suggestion": (
                                    "No page_elements cataloged for this page. "
                                    "Use a selector that exists in the scanned source. "
                                    f"All known selectors (sample): "
                                    f"{sorted(all_known_selectors)[:10]}."
                                ),
                            })

                # ── Advance page state after a click that triggers navigation ──
                # If the click selector matches a nav_graph edge from current_page,
                # the hypothetical user is now on the destination page.
                # This makes the NEXT step's validation happen against the correct page.
                if action == "click" and current_page and selector:
                    dest = click_nav_map.get((current_page, selector))
                    if dest:
                        current_page = dest
                    else:
                        # Try partial/substring match on the nav selector
                        # (nav_graph selector might be longer than the step's selector)
                        for (fp, nav_sel), tp in click_nav_map.items():
                            if fp == current_page and (
                                selector in nav_sel or nav_sel in selector
                            ):
                                current_page = tp
                                break

            # ── Check guard intermediates when page changed ──────────────
            # If we've advanced to a new page that has guard_intermediates
            # (mandatory detour pages like app-selection or onboarding),
            # verify that all intermediates were visited before this page.
            if current_page and current_page != _prev_page:
                visited_pages.add(current_page)
                _gi_entry = _nav_tree.get(current_page, {}) if isinstance(_nav_tree, dict) else {}
                _guard_ints = _gi_entry.get("guard_intermediates", []) if isinstance(_gi_entry, dict) else []
                for _gi in _guard_ints:
                    if isinstance(_gi, str) and _gi not in visited_pages:
                        _gi_tree = _nav_tree.get(_gi, {}) if isinstance(_nav_tree, dict) else {}
                        _gi_action = _gi_tree.get("action", "guard redirect") if isinstance(_gi_tree, dict) else "guard redirect"
                        issues.append({
                            "testName": name,
                            "stepNumber": step_num,
                            "issueType": "guard_intermediate_skipped",
                            "description": (
                                f"Navigated to '{current_page}' but mandatory intermediate "
                                f"page '{_gi}' was not visited first. In a clean browser "
                                f"(no session/state), a guard redirect sends users to "
                                f"'{_gi}' before they can access '{current_page}' "
                                f"(e.g. app selection, onboarding, email verification)."
                            ),
                            "suggestion": (
                                f"Add navigation steps to visit '{_gi}' and complete "
                                f"its flow before reaching '{current_page}'. "
                                f"The guard action is: '{_gi_action}'. "
                                f"Check journeyPaths or navigation_tree for the "
                                f"correct step sequence."
                            ),
                        })

    return issues


# ---------------------------------------------------------------------------
# Token estimation helpers
# ---------------------------------------------------------------------------

_CHARS_PER_TOKEN = 3.5  # conservative estimate for mixed English + JSON


def _est(chars: int) -> int:
    """Estimate token count from character count."""
    return max(0, round(chars / _CHARS_PER_TOKEN))


def _build_token_stats(
    phase: str,
    call_number: int,
    components: dict[str, int],
    response_chars: int,
    cumulative_total: int,
) -> dict:
    """Build the tokenStats dict included in every generate_tests response.

    ``components`` maps component name -> char count for items going INTO the LLM
    (i.e. things we return that will be in context next call).
    ``response_chars`` is the total size of the JSON response we're sending now.
    ``cumulative_total`` is the running total of estimated tokens for this session.
    """
    input_chars = sum(components.values())
    input_tokens = _est(input_chars)
    output_tokens = _est(response_chars)
    this_call_total = input_tokens + output_tokens

    breakdown = {k: {"chars": v, "estimatedTokens": _est(v)} for k, v in components.items()}

    return {
        "phase": phase,
        "callNumber": call_number,
        "thisCall": {
            "inputEstimatedTokens": input_tokens,
            "outputEstimatedTokens": output_tokens,
            "totalEstimatedTokens": this_call_total,
        },
        "breakdown": breakdown,
        "sessionCumulativeTokens": cumulative_total + this_call_total,
        "note": (
            "Token counts are estimates (~3.5 chars/token). "
            "Breakdown shows what was returned to the LLM this call — "
            "use it to identify which component to shrink to cut costs."
        ),
    }


_CLAUDE_MD_CONTENT = """\
# Maeris MCP — Production Playwright E2E Standard

Generate production-grade tests that are executable, source-grounded, robust, low-flake, and useful.

## Primary Rule
- Always use MCP tools (`generate_tests`, `run_and_fix_tests`, `push_tests_to_maeris`).
- Do not return plain-text test ideas as a substitute for tool output.
- Never invent selectors, URLs, validations, credentials, or hidden business rules.

## generate_tests Workflow

### Step 1 — Scan
- Call `generate_tests` with `flow_description`, `target_path`, `include_content=true`.
- Analyze every file batch and submit `scan_notes` on every scan call.
- Discover meaningful, source-supported user journeys only.
- Do NOT submit `test_cases` until `hasMore=false`.

### Step 1.5 — Plan (Mandatory)
- After scan complete, call `generate_tests` with `session_id` + `test_plan`.
- Each discovered flow must include:
  - `flow_name`, `entry_point`, `prerequisites`, `user_journey_steps`
  - `test_cases_planned`, `key_selectors`, `assertions_planned`
  - `confidence` (`high`/`medium`/`low`) and `risk_factors`
- If evidence is insufficient, exclude the flow or mark low confidence with explicit blockers.
- Do not generate code until plan approval.

### Step 2 — Generate Steps
- After plan approval, call `generate_tests` with `session_id`, `test_cases`, `is_last=true`.
- Each test case must have a `steps` array of Mirabilis component dicts (no Python code).
- Use complete journey: go_to → login if needed → navigate → interact → assertion steps.
- Each step must have `css_selector` ([attr="val"] format), `xpath`, `action_taken` or `assertion_*` fields.

### Step 3 — Collect Execution Inputs
- After generation completes, the response includes `autoRun.requiredInputs`.
- Do NOT ask "would you like me to run the tests?" — skip the yes/no question entirely.
- Instead, immediately tell the user what was generated (number of tests, flows covered), then ask them directly for the required values:
  - Show the auto-resolved `base_url` (from `requiredInputs.base_url.current_value`) and ask the user to confirm or override it.
  - Ask for `test_email` and `test_password`.
- Once you have all three values, call `run_and_fix_tests` with the `autoRun.arguments` plus the user-provided values — no further confirmation needed.

## Coverage Rules
- Prioritize auth, onboarding, core business flows, critical CRUD/forms, conversion paths, settings/permissions.
- For each meaningful flow: at least one positive test.
- Add negative/failure tests only when source clearly supports validations/error states.
- Favor quality and reliability over raw test count.

## Robustness Rules
- Selector priority: `data-testid` -> `id` -> stable `name` -> role/name -> label -> static text -> CSS/XPath last.
- Never guess selectors. Avoid brittle positional selectors unless no stable alternative exists.
- Use condition-based waits; avoid arbitrary sleep.
- Assert business outcomes (URL, heading, success/error, modal state, record presence, validation feedback).

## Data Rules
- Never invent credentials, OTPs, tokens, payment data, or hidden setup.
- Use only source-supported deterministic values.
- If required data is missing, report as risk/blocker in plan.

## Tool Error Handling
- Report tool errors verbatim and continue via tools only (no manual fallback workflow).
- If session expires:
  "The session expired because the MCP server restarted. I cannot recover or reconstruct the previous tests — please run `generate_tests` again to rescan the repo."
- If `status: "test_cases_reachability_failed"`:
  Fix every entry in `reachabilityIssues` before resubmitting:
  - `url_not_found`: Replace the go_to URL with a route from `knownRoutes`. If only reachable via UI, replace with click steps following `navigationMap`.
  - `auth_route_direct_nav`: Removed go_to for an auth-protected route. Start from login page, authenticate, then click to the target.
  - `modal_triggered_view`: go_to used for a view that has NO URL. Remove the go_to entirely. Click the trigger element shown in `suggestion` instead, then assert modal content is visible.
  - `selector_not_in_source`: Selector not found anywhere in source. Use one from `suggestion`.
  - `selector_wrong_page`: Selector found in source but on a different page than the test is currently on — the element won't be present, step would fail. Navigate to the correct page first or use a selector from `suggestion` for the current page.
  - `guard_intermediate_skipped`: Navigated to a page without visiting a mandatory intermediate page first. The guarded page has a guard redirect (e.g. app selection, onboarding) that fires in a clean browser. Add steps to visit and complete the intermediate page before navigating to the guarded page. Check journeyPaths for the correct sequence.
  Also review `navigationWarnings` and replace direct go_to steps with UI click navigation where possible.
  Do NOT ignore issues or resubmit unchanged test cases.

## run_and_fix_tests Strict Loop
- Continue until all tests pass or retries are exhausted.
- On failure: inspect `logs` + `currentCode`, submit corrected `fixed_code`, continue.
- Treat timeouts as test-code issues first (selectors/waits/readiness) and fix iteratively.
- If app is unavailable: tell user to start app and wait; do not abandon session.
- Use `mark_unfixable=true` only when retries are 0 and multiple materially different fixes were attempted.\n"""


def _write_project_claude_md(scan_root: str) -> None:
    """Write CLAUDE.md to the scanned project so Claude Code reads the loop rules."""
    try:
        claude_md_path = Path(scan_root) / "CLAUDE.md"
        if claude_md_path.exists():
            existing = claude_md_path.read_text(encoding="utf-8")
            # Only append if the maeris section is not already there
            if "run_and_fix_tests" not in existing:
                claude_md_path.write_text(
                    existing.rstrip() + "\n\n" + _CLAUDE_MD_CONTENT,
                    encoding="utf-8",
                )
        else:
            claude_md_path.write_text(_CLAUDE_MD_CONTENT, encoding="utf-8")
    except Exception:
        pass  # Non-critical — don't fail test generation if this write fails


def _bfs_build_nav_tree(
    cm_nodes: dict,
    cm_edges: list[dict],
    cm_auth: dict,
    cm_raw_nav_tree: dict,
) -> dict:
    """Build a navigation_tree using BFS from entry points through page-to-page edges.

    The codemap's raw navigation_tree picks reachable_via from the first incoming
    edge — which may be a component, not a real user navigation step. This function
    runs BFS from the login/root entry so each page's reachable_via is its parent
    in the shortest user-journey path (e.g. /login → /select-app → /notebook).

    Conditional paths from the raw tree are preserved.
    """
    nav_tree: dict[str, dict] = {}

    # Initialize all page nodes
    for route, node in cm_nodes.items():
        if route.startswith("component:") or route.startswith("modal:"):
            continue
        if not route.startswith("/"):
            continue
        nav_tree[route] = {
            "auth_required": node.get("auth_required", False),
        }

    # Build page-to-page adjacency from UNCONDITIONAL edges only.
    # Conditional edges (guards, success/error redirects) represent alternate
    # paths, not the normal user click journey. Including them would make BFS
    # shortcut through redirects (e.g. login→notebook via "if result.success")
    # instead of following the actual click path (login→select-app→notebook).
    adjacency: dict[str, list[dict]] = {}
    for edge in cm_edges:
        src = edge.get("from", "")
        dst = edge.get("to", "")
        if (
            src.startswith("/")
            and dst.startswith("/")
            and src in nav_tree
            and dst in nav_tree
            and not edge.get("condition")  # unconditional edges only
        ):
            adjacency.setdefault(src, []).append(edge)

    # Determine entry points — login page is the primary entry for test flows.
    # The codemap auth may report a component path as login_route (e.g.
    # "component:components/Login/...") — fall back to known login URL patterns.
    login_route = cm_auth.get("login_route") or ""
    if not login_route.startswith("/"):
        # Auth detector picked a component, not a page route — find the real login page
        for candidate in ("/login", "/signin", "/sign-in", "/auth/login", "/auth"):
            if candidate in nav_tree:
                login_route = candidate
                break
        else:
            login_route = ""

    entry_points: list[str] = []
    if login_route and login_route in nav_tree:
        entry_points.append(login_route)
    # Only add "/" as entry if no login route exists (non-auth apps).
    # For auth apps, the real test journey starts at /login — adding "/"
    # would shortcut BFS through the root's redirect edges.
    if not entry_points and "/" in nav_tree:
        entry_points.append("/")

    # BFS
    visited: set[str] = set()
    queue: list[str] = []
    for ep in entry_points:
        nav_tree[ep]["directly_accessible"] = True
        visited.add(ep)
        queue.append(ep)

    while queue:
        current = queue.pop(0)
        for edge in adjacency.get(current, []):
            dest = edge["to"]
            if dest in visited:
                continue
            visited.add(dest)
            nav_tree[dest]["reachable_via"] = current
            # Derive a human-readable action description
            trigger = edge.get("trigger", "")
            sel = edge.get("selector_hint")
            mech = edge.get("mechanism", "route")
            if trigger == "on_submit" and sel:
                nav_tree[dest]["action"] = f"submit form ({sel})"
            elif trigger == "on_click" and sel:
                nav_tree[dest]["action"] = f"click {sel}"
            elif mech == "redirect":
                nav_tree[dest]["action"] = "guard redirect"
            else:
                nav_tree[dest]["action"] = "navigate"
            nav_tree[dest].pop("directly_accessible", None)
            queue.append(dest)

    # --- Second pass: inject guard-redirect intermediates ---
    # Guard-redirect edges are conditional edges that fire AUTOMATICALLY when
    # a user lands on a page (trigger: on_mount/always, mechanism: redirect/
    # conditional_route). They represent mandatory detours like app selection,
    # onboarding, or email verification that block access to the guarded page.
    #
    # In test context (clean browser, no stored state), these guards WILL fire.
    # E.g. ProtectedRoute redirects to /onboarding/select-app if no applicationId.
    #
    # We insert guard destinations BEFORE the guarded page in the reachable_via
    # chain so journeyPaths and reachability validation include them.
    _GUARD_TRIGGERS = {"on_mount", "always", ""}

    # Strategy 1: page-to-page guard redirect edges from cm_edges
    for edge in cm_edges:
        src = edge.get("from", "")
        dst = edge.get("to", "")
        condition = edge.get("condition")
        trigger = (edge.get("trigger") or "").lower().strip()
        mechanism = (edge.get("mechanism") or "").lower()
        if (
            condition
            and src.startswith("/") and dst.startswith("/")
            and src in nav_tree and dst in nav_tree
            and src != dst
            and mechanism in ("redirect", "conditional_route")
            and trigger in _GUARD_TRIGGERS
            and src in visited
            and dst not in visited
        ):
            # Guard on src redirects to dst. Insert dst before src.
            parent = nav_tree.get(src, {}).get("reachable_via")
            visited.add(dst)
            nav_tree[dst]["reachable_via"] = parent if parent else src
            nav_tree[dst]["action"] = "guard redirect"
            nav_tree[dst].pop("directly_accessible", None)
            nav_tree[src]["reachable_via"] = dst
            nav_tree[src].setdefault("guard_intermediates", []).append(dst)

    # Strategy 2: unreachable pages with guard-redirect conditional_paths
    # from components (e.g. ProtectedRoute → /select-app). The FROM field
    # is a component, not a page route, so Strategy 1 misses these.
    # We find the guarded page by looking at outgoing edges from the
    # unreachable page back to a reachable page.
    for dest_route, raw_entry in cm_raw_nav_tree.items():
        if not isinstance(raw_entry, dict) or not dest_route.startswith("/"):
            continue
        if dest_route in visited or dest_route not in nav_tree:
            continue

        conds = raw_entry.get("conditional_paths", [])
        if not isinstance(conds, list) or not conds:
            continue

        # Check if any conditional path is a guard redirect
        has_guard = False
        for cp in conds:
            if not isinstance(cp, dict):
                continue
            trigger = (cp.get("trigger") or "").lower().strip()
            condition = cp.get("condition", "")
            if condition and trigger in _GUARD_TRIGGERS:
                has_guard = True
                break

        if not has_guard:
            continue

        # Find the guarded page: check outgoing edges from dest_route to
        # a reachable page. After completing the intermediate flow (e.g.
        # selecting an app), the user is sent to the guarded page.
        guarded_page = None
        for edge in cm_edges:
            edge_src = edge.get("from", "")
            edge_dst = edge.get("to", "")
            if (
                edge_src == dest_route
                and edge_dst.startswith("/")
                and edge_dst in visited
            ):
                guarded_page = edge_dst
                break

        if not guarded_page:
            continue

        # Insert dest_route before guarded_page in the reachable_via chain
        parent = nav_tree.get(guarded_page, {}).get("reachable_via")
        visited.add(dest_route)
        nav_tree[dest_route]["reachable_via"] = parent if parent else guarded_page
        nav_tree[dest_route]["action"] = "guard redirect"
        nav_tree[dest_route].pop("directly_accessible", None)
        nav_tree[guarded_page]["reachable_via"] = dest_route
        nav_tree[guarded_page].setdefault("guard_intermediates", []).append(dest_route)

    # Run BFS from newly discovered guard intermediates to find pages
    # only reachable through them (e.g. sub-pages of onboarding flow).
    _guard_queue = [r for r in visited if nav_tree.get(r, {}).get("action") == "guard redirect"]
    while _guard_queue:
        current = _guard_queue.pop(0)
        for edge in adjacency.get(current, []):
            dest = edge["to"]
            if dest in visited:
                continue
            visited.add(dest)
            nav_tree[dest]["reachable_via"] = current
            trigger = edge.get("trigger", "")
            sel = edge.get("selector_hint")
            mech = edge.get("mechanism", "route")
            if trigger == "on_submit" and sel:
                nav_tree[dest]["action"] = f"submit form ({sel})"
            elif trigger == "on_click" and sel:
                nav_tree[dest]["action"] = f"click {sel}"
            elif mech == "redirect":
                nav_tree[dest]["action"] = "guard redirect"
            else:
                nav_tree[dest]["action"] = "navigate"
            nav_tree[dest].pop("directly_accessible", None)
            _guard_queue.append(dest)

    # Preserve conditional paths from the raw codemap nav_tree
    for route, raw_entry in cm_raw_nav_tree.items():
        if route in nav_tree and isinstance(raw_entry, dict):
            conds = raw_entry.get("conditional_paths")
            if conds:
                nav_tree[route]["conditional_paths"] = conds

    # Ensure login page is always marked as entry
    if login_route and login_route in nav_tree:
        nav_tree[login_route]["directly_accessible"] = True
        nav_tree[login_route]["auth_required"] = False

    return nav_tree


async def handle_generate_tests(
    test_store: TestGenerationStore,
    arguments: dict,
) -> list[TextContent]:
    """Handle the generate_tests tool call."""
    flow_description = arguments.get("flow_description") or arguments.get("flowDescription")
    target_path = arguments.get("target_path") or arguments.get("targetPath") or os.getcwd()
    session_id = arguments.get("session_id") or arguments.get("sessionId")
    include_content = arguments.get("include_content") or arguments.get("includeContent", False)
    offset = arguments.get("offset", 0)
    limit = min(arguments.get("limit", 10), 20)
    base_url = arguments.get("base_url") or arguments.get("baseUrl")
    _call_start_time = datetime.now(timezone.utc)
    is_last = bool(arguments.get("is_last") or arguments.get("isLast"))
    max_test_cases = int(arguments.get("max_test_cases") or arguments.get("maxTestCases") or 50)
    raw_test_plan = arguments.get("test_plan") or arguments.get("testPlan")
    rescan_files: list[str] = arguments.get("rescan_files") or arguments.get("rescanFiles") or []

    # Feedback context — initialized early, populated during codemap load if new session
    _fb_hist: dict = {}
    _fb_prompt: str = ""

    if not flow_description and not session_id:
        return [TextContent(
            type="text",
            text="Error: flow_description is required when starting a new session",
        )]

    # Resolve base_url FIRST — before session creation — so the application URL
    # is stored on the session from the very beginning and included in the first response.
    # The LLM needs to know where the app lives before it starts reading files.
    auto_base_url: str | None = None
    if not base_url:
        auto_base_url = await _resolve_base_url_from_app()
        if auto_base_url:
            base_url = auto_base_url

    # Handle session retrieval or creation
    _codemap_loaded = False
    _codemap: dict | None = None

    if session_id:
        session = test_store.get_session(session_id)
        if not session:
            return [TextContent(type="text", text=f"Error: session not found: {session_id}")]
        file_list = session.file_list
        base_for_rel = Path(session.base_for_rel)
        display_target = session.target_path
        flow_desc = session.flow_description
        status = session.status
        scan_root = session.scan_root
    else:
        # Create new session — codemap-first flow (no file scanning)
        if not flow_description:
            return [TextContent(type="text", text="Error: flow_description is required")]

        scan_root = os.path.abspath(target_path)
        if not os.path.exists(scan_root):
            return [TextContent(type="text", text=f"Error: path does not exist: {target_path}")]

        base_for_rel, display_target = _get_project_paths(scan_root)

        # --- Load or auto-generate codemap (with staleness check) ---
        from maeris_mcp.tools.code_map_tool import codemap_path_for, handle_generate_code_map
        _codemap_path = codemap_path_for(scan_root)

        # generate_code_map handles its own staleness logic:
        # - If codemap doesn't exist → full scan + enrich
        # - If source >3 days old → full re-scan + enrich
        # - If feedback stale (new tests/results) → enrich only (no re-scan)
        # - If everything fresh → returns cached
        # So we always call it (without force_refresh) and let it decide.
        try:
            await handle_generate_code_map({
                "target_path": scan_root,
                "base_url": base_url,
            })
        except Exception as _cm_err:
            if not _codemap_path.exists():
                return [TextContent(type="text", text=json.dumps({
                    "status": "error",
                    "message": f"Failed to generate code map: {_cm_err}",
                }))]

        if not _codemap_path.exists():
            return [TextContent(type="text", text=json.dumps({
                "status": "error",
                "message": "Could not generate codemap for this repository. No scannable source files found.",
            }))]

        _codemap = json.loads(_codemap_path.read_text(encoding="utf-8"))
        _codemap_loaded = True

        # Codemap on disk is already enriched with feedback patterns by generate_code_map.
        # Read feedback prompt block for LLM injection.
        try:
            _fb_hist, _fb_prompt = await _fetch_feedback_context(scan_root)
        except Exception:
            _fb_hist, _fb_prompt = {}, ""

        # Create session with empty file_list (no scanning needed)
        file_list: list[str] = []
        session_id = test_store.create_session(
            flow_description=flow_description,
            target_path=display_target,
            scan_root=scan_root,
            base_for_rel=str(base_for_rel),
            file_list=file_list,
            base_url=base_url,
            linked_file_map={},
        )

        # --- Pre-populate ALL scan_notes from codemap ---
        _cm_nodes = _codemap.get("nodes", {})
        _cm_nav_tree = _codemap.get("navigation_tree", {})
        _cm_auth = _codemap.get("auth", {})
        _cm_edges = _codemap.get("edges", [])

        # Routes
        _cm_routes = [
            {"path": p, "auth_required": n.get("auth_required", False)}
            for p, n in _cm_nodes.items()
            if n.get("type") == "page" and p.startswith("/")
        ]
        test_store.update_scan_notes(session_id, {"routes": _cm_routes})

        # Build navigation_tree via BFS from entry points through page-to-page
        # edges so reachable_via reflects the shortest real user journey chain
        # (e.g. /login → /onboarding/select-app → /notebook) instead of picking
        # an arbitrary incoming edge.
        _bfs_nav_tree = _bfs_build_nav_tree(
            _cm_nodes, _cm_edges, _cm_auth, _cm_nav_tree,
        )
        test_store.update_scan_notes(session_id, {"navigation_tree": _bfs_nav_tree})

        if _cm_auth and _cm_auth.get("has_auth"):
            test_store.update_scan_notes(session_id, {"auth": _cm_auth})

        # Page elements
        _cm_page_elements = []
        for _rp, _nd in _cm_nodes.items():
            if _nd.get("type") != "page" or not _rp.startswith("/"):
                continue
            _els = _nd.get("elements", [])
            _forms = _nd.get("forms", [])
            if _els or _forms:
                _cm_page_elements.append({
                    "page": _rp,
                    "elements": [
                        {"selector": el.get("selector", ""), "description": el.get("label", ""), "type": el.get("kind", "element")}
                        for el in _els
                    ],
                    "forms": _forms,
                    "source": "codemap",
                })
        if _cm_page_elements:
            test_store.update_scan_notes(session_id, {"page_elements": _cm_page_elements})

        # Nav graph
        _cm_nav_graph = []
        for _edge in _cm_edges:
            _from = _edge.get("from", "")
            _to = _edge.get("to", "")
            if _from.startswith("/") and _to.startswith("/"):
                _cm_nav_graph.append({
                    "from_page": _from, "to_page": _to,
                    "mechanism": _edge.get("mechanism", "route"),
                    "trigger": _edge.get("trigger"),
                    "selector": _edge.get("selector_hint"),
                    "condition": _edge.get("condition"),
                })
        if _cm_nav_graph:
            test_store.update_scan_notes(session_id, {"nav_graph": _cm_nav_graph})

        # Source selectors
        _cm_selectors: list[str] = []
        for _rp, _nd in _cm_nodes.items():
            for _el in _nd.get("elements", []):
                if _el.get("selector"):
                    _cm_selectors.append(_el["selector"])

        session = test_store.get_session(session_id)
        if session and _cm_selectors:
            session.source_selectors = list(set(_cm_selectors))

        # Mark scan as complete immediately (no scanning needed)
        test_store.set_scan_complete(session_id, True)

        flow_desc = flow_description
        status = "analyzing"

    if not session:
        return [TextContent(type="text", text=f"Error: session not found: {session_id}")]

    # Token tracking helpers — computed once per call, used at every return path.
    _call_number = len(session.token_log) + 1
    _cumulative_tokens = sum(
        e.get("thisCall", {}).get("totalEstimatedTokens", 0) for e in session.token_log
    )
    # Mutable cell: populated in the include_content block so _finalize_token_stats
    # can write the file list to the log entry and token_logs.txt.
    _files_read_this_call: list[str] = []

    def _finalize_token_stats(phase: str, response_dict: dict, components: dict[str, int]) -> None:
        """Inject tokenStats into response_dict and persist the log entry."""
        resp_chars = len(json.dumps(response_dict))
        stats = _build_token_stats(phase, _call_number, components, resp_chars, _cumulative_tokens)
        response_dict["tokenStats"] = stats
        duration_ms = round(
            (datetime.now(timezone.utc) - _call_start_time).total_seconds() * 1000
        )
        log_entry = {
            "call": _call_number,
            "phase": phase,
            "timestamp": _call_start_time.isoformat(),
            "durationMs": duration_ms,
            **stats,
        }
        if _files_read_this_call:
            log_entry["filesReadThisCall"] = list(_files_read_this_call)
        if session_id:
            test_store.append_token_log(session_id, log_entry)
        # Write file-level detail to token_logs.txt for every call.
        try:
            from maeris_mcp.token_logger import log_files_read as _log_files_read
            _log_files_read(
                session_id=session_id or "",
                call_number=_call_number,
                phase=phase,
                files_read=list(_files_read_this_call),
                token_stats=stats,
                duration_ms=duration_ms,
            )
        except Exception:
            pass

    # Update base_url on the session (covers both new sessions and subsequent calls
    # where the caller re-submits a base_url or we re-resolved it above).
    if base_url and session_id:
        test_store.update_base_url(session_id, base_url)
        refreshed = test_store.get_session(session_id)
        if refreshed:
            session = refreshed

    # Store auth_config if the LLM submitted it
    raw_auth_config = arguments.get("auth_config") or arguments.get("authConfig")
    if raw_auth_config and isinstance(raw_auth_config, dict) and session_id:
        test_store.set_auth_config(session_id, raw_auth_config)

    # Process and accumulate scan_notes (persistent cheat sheet across batches)
    raw_scan_notes = arguments.get("scan_notes") or arguments.get("scanNotes")
    accumulated_notes: dict = {}
    scan_note_warnings: list[str] = []
    if raw_scan_notes and isinstance(raw_scan_notes, dict) and session_id:
        accumulated_notes = test_store.update_scan_notes(session_id, raw_scan_notes)
        # Auto-populate auth_config from scan_notes as a fallback — ensures login
        # injection works even if the LLM forgets to submit auth_config in the final call.
        auth_from_notes = accumulated_notes.get("auth")
        if auth_from_notes and isinstance(auth_from_notes, dict) and auth_from_notes.get("has_auth"):
            if not raw_auth_config:  # Don't overwrite explicit auth_config
                test_store.set_auth_config(session_id, {
                    "login_url": auth_from_notes.get("login_url", ""),
                    "email_selector": auth_from_notes.get("email_selector", ""),
                    "password_selector": auth_from_notes.get("password_selector", ""),
                    "submit_selector": auth_from_notes.get("submit_selector", ""),
                    "protected_routes": auth_from_notes.get("protected_routes", []),
                    "protected_actions": auth_from_notes.get("protected_actions", []),
                })
        scan_note_warnings = _validate_scan_notes(accumulated_notes)
    elif session_id:
        # Even if no new notes submitted, fetch existing accumulated notes
        s = test_store.get_session(session_id)
        if s:
            accumulated_notes = s.scan_notes or {}
            session = s

    # Phase 1.5 submission path: approve/stash test plan before any code generation.
    if raw_test_plan is not None:
        if not session_id:
            return [TextContent(
                type="text",
                text="Error: session_id is required when submitting test_plan",
            )]
        if not isinstance(raw_test_plan, dict):
            return [TextContent(
                type="text",
                text="Error: test_plan must be an object",
            )]
        if is_last:
            return [TextContent(
                type="text",
                text=(
                    "Error: Submit test_plan in a dedicated call first. "
                    "After plan approval, submit test_cases with is_last=true."
                ),
            )]
        # Require scan completion before accepting a plan.
        _gate_session = test_store.get_session(session_id)
        if _gate_session and not _gate_session.scan_complete:
            return [TextContent(
                type="text",
                text=(
                    "Error: Scan is not complete yet. Continue calling generate_tests with "
                    "include_content=true until hasMore=false, then submit test_plan."
                ),
            )]
        # --- Completeness gate ---
        # Block plan submission if any route/form is missing page_elements.
        # Hard limit: after 2 rejections the gate approves with warnings so the
        # session never loops forever (the LLM has tried; continuing to block is
        # counterproductive and burns tokens).
        _prior_rejections = (_gate_session.gate_rejections or 0) if _gate_session else 0
        _MAX_GATE_REJECTIONS = 2

        if _prior_rejections < _MAX_GATE_REJECTIONS:
            _gate_changed = (_gate_session.changed_files if _gate_session else [])
            # Source selectors are only reliable when we read enough of the codebase.
            # Pass an empty set when the index is small to avoid false hallucination
            # positives from partially-read repos.
            _raw_source_selectors = set(_gate_session.source_selectors) if _gate_session else set()
            _gate_source_selectors = _raw_source_selectors if len(_raw_source_selectors) >= 100 else set()
            _gate_all_files = (
                list((session.linked_file_map or {}).keys()) +
                [str(Path(session.base_for_rel) / p) for p in (session.file_list or [])]
            ) if session else []
            _completeness = _check_scan_completeness(
                accumulated_notes,
                _gate_changed,
                _gate_all_files,
                Path(session.base_for_rel) if session else Path("."),
                source_selectors=_gate_source_selectors,
            )
            if not _completeness["complete"]:
                _rejection_num = test_store.increment_gate_rejections(session_id)
                _gate_response: dict[str, Any] = {
                    "sessionId": session_id,
                    "status": "rescan_required",
                    "gateRejectionCount": _rejection_num,
                    "gateRejectionsRemaining": max(0, _MAX_GATE_REJECTIONS - _rejection_num),
                    "message": (
                        f"Plan blocked ({_rejection_num}/{_MAX_GATE_REJECTIONS}): "
                        f"{len(_completeness['gaps'])} route(s)/form(s) have "
                        "missing or stale page_elements. Re-read the listed files, "
                        "submit updated scan_notes with page_elements for each gap, "
                        "then resubmit test_plan. "
                        f"After {_MAX_GATE_REJECTIONS} rejections the plan will be approved automatically."
                    ),
                    "gaps": _completeness["gaps"],
                    "rescanFiles": _completeness.get("rescan_files", []),
                    "instruction": _completeness.get("rescan_instruction", ""),
                }
                _finalize_token_stats("plan_gate_blocked", _gate_response, {
                    "accumulated_notes": len(json.dumps(accumulated_notes)),
                })
                return [TextContent(type="text", text=json.dumps(_gate_response, indent=2))]
        # Gate passed (or max rejections reached — approve with whatever we have)
        # --- End completeness gate ---

        # --- Flow coverage gate ---
        # Ensure most suggested flows from scan_notes are represented in the plan.
        _MAX_COVERAGE_REJECTIONS = 1
        _coverage = _check_flow_coverage(raw_test_plan, accumulated_notes)
        if not _coverage["ok"]:
            _prior_cov_rejections = (_gate_session.coverage_rejections or 0) if _gate_session else 0
            if _prior_cov_rejections < _MAX_COVERAGE_REJECTIONS:
                _rejection_num = test_store.increment_coverage_rejections(session_id)
                missing = _coverage.get("missing", [])
                _coverage_response: dict[str, Any] = {
                    "sessionId": session_id,
                    "status": "plan_rejected",
                    "reason": "flow_coverage",
                    "coverage": round(float(_coverage.get("coverage", 0.0)) * 100, 1),
                    "coverageRejectionCount": _rejection_num,
                    "coverageRejectionsRemaining": max(0, _MAX_COVERAGE_REJECTIONS - _rejection_num),
                    "missingFlows": [
                        {
                            "name": m.get("name"),
                            "route": m.get("route"),
                            "has_form": m.get("has_form"),
                            "auth_required": m.get("auth_required"),
                        }
                        for m in (missing or [])
                    ][:20],
                    "instruction": (
                        "Your plan is missing flows that were detected in scan_notes. "
                        "Add these flows to discovered_flows (or explain why they are out of scope), "
                        "then resubmit test_plan. "
                        "After one rejection, the plan will proceed with warnings to avoid loops."
                    ),
                }
                _finalize_token_stats("plan_rejected", _coverage_response, {
                    "accumulated_notes": len(json.dumps(accumulated_notes)),
                })
                return [TextContent(type="text", text=json.dumps(_coverage_response, indent=2))]
        # --- End flow coverage gate ---

        if not test_store.set_test_plan(session_id, raw_test_plan):
            return [TextContent(
                type="text",
                text=f"Error: session not found: {session_id}",
            )]
        refreshed = test_store.get_session(session_id)
        if refreshed:
            session = refreshed
            accumulated_notes = session.scan_notes or accumulated_notes

        discovered_flows = raw_test_plan.get("discovered_flows", [])
        if not isinstance(discovered_flows, list):
            discovered_flows = []
        validation = _validate_test_plan(raw_test_plan, accumulated_notes)
        # If coverage was insufficient but max rejections reached, warn instead of blocking.
        if not _coverage["ok"]:
            validation.setdefault("warnings", []).append(
                f"WARNING: Plan covers only {round(float(_coverage.get('coverage', 0.0)) * 100, 1)}% "
                f"of suggested flows. Consider adding missing flows for better coverage."
            )

        # Hard block: flow chain errors (e.g. missing post-login steps)
        # Cap at 2 rejections to prevent infinite loops — after that, demote to warnings.
        _MAX_VALIDATION_REJECTIONS = 2
        _prior_validation_rejections = (_gate_session.validation_rejections or 0) if _gate_session else 0
        if validation.get("errors") and _prior_validation_rejections < _MAX_VALIDATION_REJECTIONS:
            _val_rejection_num = test_store.increment_validation_rejections(session_id)
            _rejection: dict[str, Any] = {
                "sessionId": session_id,
                "status": "plan_rejected",
                "validationRejectionCount": _val_rejection_num,
                "validationRejectionsRemaining": max(0, _MAX_VALIDATION_REJECTIONS - _val_rejection_num),
                "errors": validation["errors"],
                "warnings": validation.get("warnings", []),
                "instruction": (
                    "Your test plan has flow chain errors that must be fixed before generation. "
                    "For each ERROR listed: update the affected flow's prerequisites or "
                    "user_journey_steps to include the missing post-login steps, then "
                    "resubmit test_plan with the corrected discovered_flows. "
                    f"After {_MAX_VALIDATION_REJECTIONS} rejections the plan will be approved automatically."
                ),
            }
            _finalize_token_stats("plan_rejected", _rejection, {
                "accumulated_notes": len(json.dumps(accumulated_notes)),
            })
            return [TextContent(type="text", text=json.dumps(_rejection, indent=2))]
        # Max validation rejections reached — demote errors to warnings and proceed
        if validation.get("errors") and _prior_validation_rejections >= _MAX_VALIDATION_REJECTIONS:
            validation.setdefault("warnings", []).extend([
                f"WARNING (auto-approved after {_MAX_VALIDATION_REJECTIONS} rejections): " + err
                for err in validation["errors"]
            ])

        response = {
            "sessionId": session_id,
            "status": "plan_approved",
            "planValidation": validation,
            "discoveredFlows": len(discovered_flows),
            "totalTestCasesPlanned": sum(
                len(flow.get("test_cases_planned", []))
                for flow in discovered_flows
                if isinstance(flow, dict) and isinstance(flow.get("test_cases_planned", []), list)
            ),
            "instruction": (
                "PHASE 2 — GENERATE. Your plan is approved. Now write the steps[] array for each "
                "planned test case in Mirabilis component format. Follow your plan exactly — "
                "use the selectors and assertions you listed. "
                "Submit test cases FLOW BY FLOW — one flow per call, no is_last=true yet.\n"
                "Set is_last=true ONLY on the very last call (when ALL flows are done).\n"
                "Each call returns status='tests_received' with running total.\n"
                "FOR EACH TEST — MANDATORY STEP ORDER:\n"
                "1. PREREQUISITES FIRST: Your plan listed prerequisites as concrete actions.\n"
                "   Write every prerequisite as actual steps[] entries. They are NOT assumed.\n"
                "   If prerequisites include login → your first steps must be:\n"
                "     {go_to: login_url} → {send_keys: email} → {send_keys: password} → {click: sign_in}\n"
                "   If prerequisites include app selection → add that step immediately after login.\n"
                "   NEVER skip prerequisites. NEVER start from an assumed logged-in state.\n"
                "2. JOURNEY STEPS: After all prerequisites, write the user_journey_steps.\n"
                "3. ASSERTIONS: End with assertion steps verifying the outcome.\n\n"
                "FOR EACH TEST, also verify:\n"
                "- What selectors did you plan? Use css_selector=[attr=\"value\"] format.\n"
                "- What assertions did you plan? Use assertion_type/assertion_value/assertion_nature.\n\n"
                "MANDATORY CONSISTENCY RULES — submission is rejected if violated:\n"
                "1. SELECTOR TYPE MATCH: password send_keys → [type=\"password\"] only. "
                "Email send_keys → [type=\"email\"] only. Never swap these.\n"
                "2. CLICK TARGETS: click steps must target buttons/links, NEVER input selectors "
                "([type=\"email\"] or [type=\"password\"]).\n"
                "3. NEGATIVE TESTS — ONE OUTCOME: Wrong/invalid credential tests MUST assert an "
                "error message. MUST NOT assert a success URL (/dashboard, /notebook, /home, etc). "
                "A failed login cannot reach the authenticated area.\n"
                "4. NO REAL CREDENTIALS: Do NOT use real email addresses from source code. "
                "Use 'test@example.com' as placeholder.\n"
                "5. ONE OUTCOME PER TEST: Never assert both a success URL and an error message "
                "in the same test — they are mutually exclusive.\n\n"
                "=== STEP FORMAT REFERENCE (use EXACTLY these action_taken values) ===\n"
                "VALID action_taken values: go_to | click | send_keys | hover | check | click_select | set_range\n"
                "NEVER USE: navigate, fill, type, assert, assert_visible, wait_for_selector, verify\n\n"
                "Navigation:  {\"action_taken\": \"go_to\", \"mock_input\": \"https://example.com/login\"}\n"
                "Fill input:  {\"action_taken\": \"send_keys\", \"css_selector\": \"[type=\\\"email\\\"]\", \"mock_input\": \"$MAERIS_TEST_EMAIL\"}\n"
                "Click:       {\"action_taken\": \"click\", \"css_selector\": \"[type=\\\"submit\\\"]\"}\n"
                "URL assert:  {\"assertion_nature\": \"page\", \"assertion_type\": \"to contain url\", \"assertion_value\": \"/dashboard\"}\n"
                "Text assert: {\"assertion_nature\": \"existence\", \"assertion_type\": \"to be visible\", \"assertion_value\": \"Welcome\"}\n"
                "Elem assert: {\"assertion_nature\": \"input\", \"assertion_type\": \"to be visible\", \"css_selector\": \"[data-testid=\\\"submit\\\"]\"}\n"
                "Assertions do NOT use action_taken — they use assertion_nature + assertion_type + assertion_value.\n"
            ),
            "approvedPlan": raw_test_plan,
        }
        if scan_note_warnings:
            response["scanNoteWarnings"] = scan_note_warnings

        # Credential prompt at plan-approval time (before any test steps are written).
        _plan_auth = accumulated_notes.get("auth", {}) if accumulated_notes else {}
        _plan_has_auth = isinstance(_plan_auth, dict) and bool(
            _plan_auth.get("login_url") or _plan_auth.get("email_selector")
            or _plan_auth.get("submit_selector") or _plan_auth.get("has_auth")
        )
        _cred_session = test_store.get_session(session_id)
        _session_has_creds = bool(_cred_session and _cred_session.test_credentials)
        if _plan_has_auth and not _session_has_creds:
            response["credentialsRequired"] = True
            response["credentialPrompt"] = (
                "AUTH DETECTED. Credentials were provided in your context when this session started. "
                "Call generate_tests IMMEDIATELY (do NOT ask the user) with: "
                "session_id + test_email=<email from context> + test_password=<password from context>. "
                "Then call generate_tests again with the test_cases. "
                "DO NOT start writing test_cases until credentials are stored. "
                "DO NOT use placeholder values like test@example.com."
            )

        # Multi-flow enforcement reminder.
        _flow_count = len(discovered_flows)
        _total_planned = sum(
            len(f.get("test_cases_planned", []))
            for f in discovered_flows if isinstance(f, dict)
        )
        if _flow_count > 1:
            response["phase2BatchingRequired"] = {
                "flowCount": _flow_count,
                "totalTestsPlanned": _total_planned,
                "instruction": (
                    f"You have {_flow_count} flows with {_total_planned} total test cases. "
                    f"Make {_flow_count} separate calls WITHOUT is_last=true (one per flow). "
                    "Set is_last=true ONLY on the very last call. "
                    "DO NOT submit all tests in one call."
                ),
            }

        _finalize_token_stats("plan_approval", response, {
            "test_plan_submitted": len(json.dumps(raw_test_plan)),
            "accumulated_notes": len(json.dumps(accumulated_notes)),
        })
        return [TextContent(type="text", text=json.dumps(response, indent=2))]

    # Store credentials if provided by the user after plan approval
    _cred_email = arguments.get("test_email") or arguments.get("testEmail") or ""
    _cred_password = arguments.get("test_password") or arguments.get("testPassword") or ""
    if session_id and (_cred_email or _cred_password):
        _cred_sess = test_store.get_session(session_id)
        if _cred_sess:
            _cred_sess.test_credentials = {"email": _cred_email, "password": _cred_password}
            test_store._save(_cred_sess)

    # Process submitted test cases
    raw_test_cases = arguments.get("test_cases") or arguments.get("testCases") or []
    if isinstance(raw_test_cases, dict):
        raw_test_cases = [raw_test_cases]
    test_case_stats: dict[str, int] = {}

    # Enforce Phase 1.5 before final code submission.
    if is_last and not session.test_plan:
        return [TextContent(
            type="text",
            text=(
                "Error: test_plan required before submitting test_cases. "
                "Submit a test_plan first (see Phase 1.5 instructions)."
            ),
        )]

    if raw_test_cases and isinstance(raw_test_cases, list):
        test_case_stats = test_store.add_test_cases(session_id, raw_test_cases)
        refreshed = test_store.get_session(session_id)
        if refreshed:
            session = refreshed

        # Validate logical consistency of submitted steps.
        # If critical issues are found, reject the submission so Claude can fix them.
        step_issues = _validate_test_steps(raw_test_cases)
        if step_issues:
            # Remove the just-added cases so Claude must resubmit corrected ones
            test_store.remove_last_test_cases(session_id, len(raw_test_cases))
            return [TextContent(type="text", text=json.dumps({
                "status": "test_cases_rejected",
                "reason": "Logical consistency issues found in submitted test steps.",
                "issues": step_issues,
                "instruction": (
                    "Your submitted test_cases contain logical contradictions or selector mismatches. "
                    "Fix ALL of the issues listed above and resubmit.\n\n"
                    "RULES:\n"
                    "1. A negative test (wrong/invalid credentials) MUST assert an error message — "
                    "NEVER a success URL like /notebook or /dashboard.\n"
                    "2. Password send_keys steps MUST use [type=\"password\"] selector, "
                    "NOT [type=\"email\"].\n"
                    "3. Email send_keys steps MUST use [type=\"email\"] selector, "
                    "NOT [type=\"password\"].\n"
                    "4. Click steps MUST target a button/submit selector, not an input selector.\n"
                    "5. Do NOT hardcode real email addresses from source code. "
                    "Use 'test@example.com' or env-var placeholders.\n"
                    "6. Each test must represent exactly ONE outcome: success OR failure, never both."
                ),
            }, indent=2))]

        # Validate step reachability against repository source (routes + selectors + nav graph).
        # Hard errors (url_not_found, selector_not_in_source, selector_wrong_page) → reject.
        # Soft warnings (navigation_skip) → included in response but do not block.
        _MAX_REACHABILITY_REJECTIONS = 2
        _SOFT_ISSUE_TYPES = {"navigation_skip"}
        reach_issues = _validate_step_reachability(
            raw_test_cases,
            accumulated_notes or {},
            session.source_selectors or [] if session else [],
        )
        hard_reach = [i for i in reach_issues if i.get("issueType") not in _SOFT_ISSUE_TYPES]
        soft_reach = [i for i in reach_issues if i.get("issueType") in _SOFT_ISSUE_TYPES]

        _reach_rejections = session.reachability_rejections if session else 0
        if hard_reach and _reach_rejections < _MAX_REACHABILITY_REJECTIONS:
            # Increment rejection counter and persist
            if session:
                session.reachability_rejections += 1
                test_store._save(session)
            test_store.remove_last_test_cases(session_id, len(raw_test_cases))
            _nav_map = _build_nav_adjacency(accumulated_notes or {})
            # Enrich knownRoutes with traversal metadata so the model knows
            # which routes are auth-protected and how to navigate to them.
            _nav_tree_data = (accumulated_notes or {}).get("navigation_tree") or {}
            _raw_routes = (accumulated_notes or {}).get("routes") or []
            _known_rts = []
            for _r in _raw_routes if isinstance(_raw_routes, list) else []:
                if not isinstance(_r, dict) or not _r.get("path"):
                    continue
                _rpath = _r["path"].strip()
                _tree_entry = _nav_tree_data.get(_rpath, {}) if isinstance(_nav_tree_data, dict) else {}
                _known_rts.append({
                    "path": _rpath,
                    "auth_required": bool(_r.get("auth_required") or (isinstance(_tree_entry, dict) and _tree_entry.get("auth_required"))),
                    "directly_accessible": bool(not _r.get("auth_required") and isinstance(_tree_entry, dict) and _tree_entry.get("directly_accessible")),
                    "navigable_via": _tree_entry.get("reachable_via") if isinstance(_tree_entry, dict) else None,
                })
            if not _known_rts:
                # Fallback: plain list when no route metadata available
                _known_rts = sorted(_get_known_routes(accumulated_notes or {}))  # type: ignore[assignment]

            # Enrich knownRoutes with codemap element previews (Change 7)
            # so the LLM can fix selectors without needing another scan batch.
            try:
                _reach_scan_root = session.scan_root if session else None
                if _reach_scan_root:
                    from maeris_mcp.tools.code_map_tool import codemap_path_for as _reach_cm_path_fn
                    _reach_cm_path = _reach_cm_path_fn(_reach_scan_root)
                    if _reach_cm_path.exists():
                        _reach_cm = json.loads(_reach_cm_path.read_text(encoding="utf-8"))
                        _reach_cm_nodes = _reach_cm.get("nodes", {})
                        for _rt_entry in _known_rts:
                            if isinstance(_rt_entry, dict):
                                _rt_node = _reach_cm_nodes.get(_rt_entry.get("path", ""), {})
                                if _rt_node:
                                    _rt_entry["has_forms"] = bool(_rt_node.get("forms"))
                                    _rt_entry["has_modals"] = bool(_rt_node.get("modals"))
                                    _rt_entry["elements_preview"] = [
                                        _el["selector"] for _el in _rt_node.get("elements", [])[:5]
                                    ]
            except Exception:
                pass  # Non-critical enrichment

            return [TextContent(type="text", text=json.dumps({
                "status": "test_cases_reachability_failed",
                "reason": "Steps reference URLs or selectors inconsistent with the repository source.",
                "reachabilityIssues": hard_reach,
                "navigationWarnings": soft_reach,
                "navigationMap": _nav_map,
                "knownRoutes": _known_rts,
                "instruction": (
                    "Your submitted test_cases contain steps that are inconsistent with the "
                    "scanned repository. Fix ALL issues listed in 'reachabilityIssues' and resubmit.\n\n"
                    "REACHABILITY RULES:\n"
                    "1. URL_NOT_FOUND: Every go_to URL must match a known route. "
                    "Use only paths from 'knownRoutes' above. "
                    "If the page is only reachable via UI navigation (clicks/menus), "
                    "replace the go_to with actual click steps following 'navigationMap'.\n"
                    "2. AUTH_ROUTE_DIRECT_NAV: go_to was used for an auth-protected route. "
                    "go_to is ONLY valid for the app root URL or the login page. "
                    "Start from the login page, complete authentication, then click to the target. "
                    "Check 'navigable_via' in knownRoutes to find the correct parent page.\n"
                    "3. MODAL_TRIGGERED_VIEW: go_to was used for a view that has NO URL. "
                    "scan_notes.navigation_mechanisms records this view as a modal/overlay "
                    "opened by a UI click, not a route change. "
                    "Remove the go_to entirely. Click the trigger element shown in 'suggestion' "
                    "instead, then assert the modal content is visible.\n"
                    "4. SELECTOR_NOT_IN_SOURCE: The css_selector does not exist anywhere in the "
                    "scanned source. Use a selector from the 'suggestion' field.\n"
                    "5. SELECTOR_WRONG_PAGE: The css_selector exists in source but is catalogued "
                    "on a DIFFERENT page than the one the test is currently on. This step would "
                    "fail because the element is not present on the current page. Either navigate "
                    "to the correct page first, or use a selector that belongs to the current page "
                    "(see 'suggestion' for available selectors on the current page).\n"
                    "6. NAVIGATION_SKIP (warnings in 'navigationWarnings', not blocking): "
                    "Avoid direct go_to for pages reachable only through UI clicks. "
                    "Prefer click navigation that mirrors the real user journey."
                ),
            }, indent=2))]

        # Gate 2: Steps vs Plan — verify submitted steps faithfully implement the approved plan.
        # This catches: dropped assertions, severely reduced step counts, substituted flows.
        # Allowed up to 2 rejections (same pattern as reachability) before proceeding.
        _MAX_PLAN_REJECTIONS = 2
        _plan_rejections = getattr(session, "plan_mismatch_rejections", 0) if session else 0
        if session and session.test_plan and _plan_rejections < _MAX_PLAN_REJECTIONS:
            _plan_issues = _validate_steps_against_plan(raw_test_cases, session.test_plan)
            if _plan_issues:
                # Increment rejection counter
                if hasattr(session, "plan_mismatch_rejections"):
                    session.plan_mismatch_rejections = _plan_rejections + 1  # type: ignore[assignment]
                    test_store._save(session)
                test_store.remove_last_test_cases(session_id, len(raw_test_cases))
                return [TextContent(type="text", text=json.dumps({
                    "status": "test_cases_plan_mismatch",
                    "reason": (
                        "Submitted test steps do not faithfully implement the approved plan. "
                        "The plan is a binding contract."
                    ),
                    "planMismatchIssues": _plan_issues,
                    "instruction": (
                        "Fix ALL issues listed in 'planMismatchIssues' and resubmit.\n\n"
                        "RULES:\n"
                        "1. Every assertion listed in 'assertions_planned' in your approved plan "
                        "MUST have a corresponding assertion step in the test.\n"
                        "2. Every step in 'user_journey_steps' must be implemented — do not drop "
                        "steps to make the test simpler or shorter.\n"
                        "3. Do not substitute a different flow that is easier to automate — "
                        "test the exact scenario from the plan."
                    ),
                    "sessionId": session_id,
                }, indent=2))]

        # Enforce multi-call: reject is_last=true when not all planned tests are submitted.
        if raw_test_cases and is_last and session and session.test_plan:
            _ep = session.test_plan if isinstance(session.test_plan, dict) else {}
            _all_flows = _ep.get("discovered_flows", [])
            _all_planned = sum(
                len(f.get("test_cases_planned", []))
                for f in _all_flows if isinstance(f, dict)
            )
            _stored_before = len(session.test_cases) - len(raw_test_cases)
            # If this is the ONLY call (nothing stored before) and submitted < planned
            # AND there are multiple flows, reject the premature is_last.
            if len(_all_flows) > 1 and _stored_before == 0 and len(session.test_cases) < _all_planned:
                test_store.remove_last_test_cases(session_id, len(raw_test_cases))
                return [TextContent(type="text", text=json.dumps({
                    "sessionId": session_id,
                    "status": "submission_incomplete",
                    "testCasesReceived": len(raw_test_cases),
                    "testCasesPlanned": _all_planned,
                    "flowsPlanned": len(_all_flows),
                    "instruction": (
                        f"Received {len(raw_test_cases)} tests with is_last=true but "
                        f"your plan requires {_all_planned} across {len(_all_flows)} flows. "
                        "Remove is_last=true. Submit one flow per call without is_last=true. "
                        "Only set is_last=true on the final call after all flows are done."
                    ),
                }, indent=2))]

        # Intermediate Phase 2 batch — test cases received but not finalized yet.
        # Return a clear "keep going" response so the LLM doesn't fall through to the
        # scan-phase response and get confused.
        if not is_last:
            _total_so_far = len(session.test_cases) if session else len(raw_test_cases)
            _planned_total = 0
            if session and session.test_plan:
                _plan_dict = session.test_plan if isinstance(session.test_plan, dict) else {}
                _planned_total = sum(
                    len(f.get("test_cases_planned", []))
                    for f in _plan_dict.get("discovered_flows", [])
                    if isinstance(f, dict)
                )
            _remaining = max(0, _planned_total - _total_so_far)
            _intermediate: dict = {
                "sessionId": session_id,
                "status": "tests_received",
                "testCasesStoredSoFar": _total_so_far,
                "testCasesPlanned": _planned_total or "unknown",
                "testCasesRemaining": _remaining if _planned_total else "unknown",
                "instruction": (
                    f"Received {len(raw_test_cases)} test case(s). "
                    f"Running total: {_total_so_far} stored."
                    + (f" {_remaining} more to go based on your plan." if _planned_total else "")
                    + " Continue submitting the next flow's test_cases in another call. "
                    "When ALL flows from your plan are submitted, send the final call with is_last=true."
                ),
            }
            _finalize_token_stats("tests_batch", _intermediate, {
                "test_cases_submitted": len(json.dumps(raw_test_cases)),
            })
            return [TextContent(type="text", text=json.dumps(_intermediate, indent=2))]

    # Handle finalization
    if is_last and session:
        # Auto-fetch base_url from /application API if not already set
        if not session.base_url:
            try:
                auto_base_url = await _resolve_base_url_from_app()
                if auto_base_url:
                    test_store.update_base_url(session_id, auto_base_url)
                    session = test_store.get_session(session_id)
            except Exception:
                pass

        if not session.base_url:
            return [TextContent(
                type="text",
                text=(
                    "Error: Could not determine base_url. No URL is configured for this application in Maeris."
                ),
            )]

        if not session.test_cases:
            return [TextContent(
                type="text",
                text="Error: No test cases submitted. Submit test_cases with steps arrays.",
            )]

        # Generate JSON step files (Mirabilis component format)
        # Use temp dir so we never write into the user's repo
        flow_slug = _slugify(flow_desc)
        output_path = os.path.join(
            tempfile.gettempdir(), "maeris_tests", session_id, flow_slug
        )

        try:
            from maeris_mcp.generators.playwright_generator import generate_step_files

            result = generate_step_files(
                test_cases=session.test_cases,
                flow_name=flow_desc,
                base_url=session.base_url,
                output_dir=output_path,
                auth_config=(accumulated_notes or {}).get("auth") or {},
                post_login_flow=(accumulated_notes or {}).get("post_login_flow") or [],
                credentials=session.test_credentials or {},
            )

            # Finalize session
            test_store.finalize_session(session_id, output_path)

            # Detect if generated tests require authentication
            # Check steps for login-related actions/mock_inputs
            _auth_required = False
            import re as _auth_re
            _login_patterns = _auth_re.compile(
                r"login|sign.?in|password|TEST_EMAIL|TEST_PASSWORD|auth|credential",
                _auth_re.IGNORECASE,
            )
            for tc in session.test_cases:
                steps = tc.get("steps", []) if isinstance(tc, dict) else getattr(tc, "steps", [])
                for step in (steps or []):
                    step_text = json.dumps(step) if isinstance(step, dict) else str(step)
                    if _login_patterns.search(step_text):
                        _auth_required = True
                        break
                if _auth_required:
                    break
            if not _auth_required:
                # Also check scan_notes for auth guards
                _notes = accumulated_notes or {}
                _auth_info = _notes.get("auth") or {}
                _guards = _notes.get("auth_guards") or []
                if (_auth_info.get("has_auth") or _guards):
                    _auth_required = True

            # Build per-test-case summary with step details for user review
            _tc_summary = []
            for _tc_i, _tc in enumerate(session.test_cases):
                _tc_steps = _tc.get("steps", []) if isinstance(_tc, dict) else []
                _tc_summary.append({
                    "name": _tc.get("name", f"Test {_tc_i + 1}") if isinstance(_tc, dict) else f"Test {_tc_i + 1}",
                    "steps_count": len(_tc_steps),
                    "steps": [
                        {
                            "step": _si + 1,
                            "action": _s.get("action_taken", "") if isinstance(_s, dict) else "",
                            "selector": _s.get("css_selector", "") if isinstance(_s, dict) else "",
                            "input": (_s.get("mock_input", "") or "")[:60] if isinstance(_s, dict) else "",
                            "assertion": _s.get("assertion_type", "") if isinstance(_s, dict) else "",
                            "assertion_value": (_s.get("assertion_value", "") or "")[:60] if isinstance(_s, dict) else "",
                        }
                        for _si, _s in enumerate(_tc_steps)
                    ],
                })

            response = {
                "sessionId": session_id,
                "status": "completed",
                "flowDescription": flow_desc,
                "testCasesGenerated": len(session.test_cases),
                "outputDirectory": output_path,
                "filesGenerated": result["files_generated"],
                "summary": (
                    f"Generated {result['test_files_count']} test file(s) with "
                    f"{result['total_tests']} test case(s) in {output_path}"
                ),
                "testCaseSummary": _tc_summary,
                "autoRun": {
                    "tool": "run_and_fix_tests",
                    "arguments": {
                        "session_id": session_id,
                    },
                    "requiredInputs": {
                        "base_url": {
                            "description": "Base URL of the application to test (e.g. https://app.example.com)",
                            "current_value": session.base_url or None,
                        },
                        "test_email": {
                            "description": "Email/username for the test account",
                        },
                        "test_password": {
                            "description": "Password for the test account",
                        },
                    },
                },
            }
            _finalize_token_stats("finalize", response, {
                "test_cases_submitted": len(json.dumps(session.test_cases)),
                "accumulated_notes": len(json.dumps(accumulated_notes)),
            })
            return [TextContent(type="text", text=json.dumps(response, indent=2))]
        except Exception as e:
            return [TextContent(type="text", text=f"Error generating scripts: {str(e)}")]

    # Build response for non-finalized calls (codemap-first flow)
    is_first_call = not bool(arguments.get("session_id") or arguments.get("sessionId"))

    response: dict[str, Any] = {
        "sessionId": session_id,
        "flowDescription": flow_desc,
        "targetPath": display_target,
        "status": status,
    }
    if scan_note_warnings:
        response["scanNoteWarnings"] = scan_note_warnings
    if False:  # linked repos removed — codemap-first flow
        linked_file_map = {}
        linked_abs = []
        response["linkedReposContext"] = {
            "count": len(linked_abs),
            "repos": list({v.split("/")[0] for v in linked_file_map.values()}),
            "note": (
                "Files from linked repositories are included at the start of the file list. "
                "They provide API routes, schemas, and models from sibling repos — "
                "use them to write accurate request payloads and understand backend behaviour."
            ),
        }

    # On first call with codemap, build the codemap summary response.
    if is_first_call and _codemap_loaded and _codemap:
        if False:  # OLD BLOCK DISABLED — scan_notes already populated in session creation
            _scan_root_path = Path(scan_root) if scan_root else None
            if not _scan_root_path and session:
                _scan_root_path = Path(session.scan_root) if session.scan_root else None
            if _scan_root_path:
                from maeris_mcp.tools.code_map_tool import codemap_path_for as _cm_path_fn
                _codemap_path = _cm_path_fn(_scan_root_path)
                if _codemap_path.exists():
                    _codemap = json.loads(_codemap_path.read_text(encoding="utf-8"))
                    _codemap_loaded = True
                    _cm_nav_tree = _codemap.get("navigation_tree", {})
                    _cm_auth = _codemap.get("auth", {})
                    _cm_nodes = _codemap.get("nodes", {})

                    # --- Routes ---
                    _cm_routes = [
                        {
                            "path": p,
                            "auth_required": n.get("auth_required", False),
                            "description": n.get("description", ""),
                            "file": n.get("file", ""),
                        }
                        for p, n in _cm_nodes.items()
                        if n.get("type") == "page" and p.startswith("/")
                    ]
                    if _cm_nav_tree:
                        test_store.update_scan_notes(session.session_id, {"navigation_tree": _cm_nav_tree})
                    if _cm_auth and _cm_auth.get("has_auth"):
                        test_store.update_scan_notes(session.session_id, {"auth": _cm_auth})
                    if _cm_routes:
                        test_store.update_scan_notes(session.session_id, {"routes": _cm_routes})

                    # --- Page elements (Change 1): pre-populate selectors per page ---
                    _cm_page_elements = []
                    for _route_path, _node in _cm_nodes.items():
                        if _node.get("type") != "page" or not _route_path.startswith("/"):
                            continue
                        _elements = _node.get("elements", [])
                        _forms = _node.get("forms", [])
                        if _elements or _forms:
                            _page_entry: dict = {
                                "page": _route_path,
                                "elements": [
                                    {
                                        "selector": el.get("selector", ""),
                                        "description": el.get("label", ""),
                                        "type": el.get("kind", "element"),
                                    }
                                    for el in _elements
                                ],
                                "forms": _forms,
                                "source": "codemap",
                            }
                            # Include page description/purpose from codemap if available
                            if _node.get("description"):
                                _page_entry["page_description"] = _node["description"]
                            _cm_page_elements.append(_page_entry)
                    if _cm_page_elements:
                        test_store.update_scan_notes(session.session_id, {"page_elements": _cm_page_elements})

                    # --- Nav graph (Change 2): pre-populate navigation edges ---
                    _cm_nav_graph = []
                    for _edge in _codemap.get("edges", []):
                        _from = _edge.get("from", "")
                        _to = _edge.get("to", "")
                        if _from.startswith("/") and _to.startswith("/"):
                            _cm_nav_graph.append({
                                "from_page": _from,
                                "to_page": _to,
                                "mechanism": _edge.get("mechanism", "route"),
                                "trigger": _edge.get("trigger"),
                                "selector": _edge.get("selector_hint"),
                                "condition": _edge.get("condition"),
                            })
                    if _cm_nav_graph:
                        test_store.update_scan_notes(session.session_id, {"nav_graph": _cm_nav_graph})

                    # --- Source selectors (Change 3): register codemap selectors as verified ---
                    _cm_selectors: list[str] = []
                    for _route_path, _node in _cm_nodes.items():
                        for _el in _node.get("elements", []):
                            _sel = _el.get("selector", "")
                            if _sel:
                                _cm_selectors.append(_sel)
                    if _cm_selectors and session:
                        _existing_sel = list(session.source_selectors or [])
                        _existing_sel.extend(_cm_selectors)
                        session.source_selectors = list(set(_existing_sel))

                    # --- Response (Change 4): enriched prebuiltCodeMap with per-page summaries ---
                    _cm_conditional = [e for e in _codemap.get("edges", []) if e.get("condition")]
                    response["prebuiltCodeMap"] = {
                        "status": "loaded",
                        "generatedAt": _codemap.get("meta", {}).get("generated_at"),
                        "framework": _codemap.get("meta", {}).get("framework"),
                        "totalPages": _codemap.get("meta", {}).get("total_pages", 0),
                        "totalConditionalFlows": len(_cm_conditional),
                        "navigationTree": _cm_nav_tree,
                        "auth": _cm_auth,
                        "conditionalFlows": _cm_conditional[:20],
                        "pages": {
                            _rp: {
                                "description": _nd.get("description", ""),
                                "file": _nd.get("file", ""),
                                "child_components": _nd.get("child_components", []),
                                "elements_count": len(_nd.get("elements", [])),
                                "has_forms": bool(_nd.get("forms")),
                                "has_modals": bool(_nd.get("modals")),
                                "selectors": [_e["selector"] for _e in _nd.get("elements", [])[:10]],
                            }
                            for _rp, _nd in _cm_nodes.items()
                            if _nd.get("type") == "page" and _rp.startswith("/")
                        },
                        "message": (
                            "Navigation map pre-loaded from codemap.json. "
                            "page_elements, nav_graph, routes, auth, and navigation_tree "
                            "are all pre-populated in your scan_notes. "
                            "During scanning, focus ONLY on discovering: "
                            "(1) validation messages and error states, "
                            "(2) dynamic/async content not visible in static analysis, "
                            "(3) form field validation rules, "
                            "(4) loading/transition states, "
                            "(5) verifying and correcting pre-loaded selectors against actual rendered output. "
                            "Do NOT re-discover routes or navigation — they are already known. "
                            "CRITICAL: Every test case MUST start from the app root URL (go_to), "
                            "complete login if auth is required, then navigate via clicks to the "
                            "target page — never skip the journey preamble. Use the navigation_tree "
                            "to determine the exact path from login to each feature."
                        ),
                    }
        # --- NEW: Build codemap-first response ---
        _cm_nodes_resp = _codemap.get("nodes", {})
        _cm_auth_resp = _codemap.get("auth", {})
        _cm_nav_tree_resp = _codemap.get("navigation_tree", {})
        _cm_edges_resp = _codemap.get("edges", [])
        _cm_conditional_resp = [e for e in _cm_edges_resp if e.get("condition")]
        _needs_base_url = not base_url
        _needs_credentials = bool(_cm_auth_resp and _cm_auth_resp.get("has_auth"))

        response["status"] = "codemap_loaded"
        response["scanComplete"] = True
        response["readyForPlan"] = True
        response["framework"] = _codemap.get("meta", {}).get("framework")
        response["totalPages"] = _codemap.get("meta", {}).get("total_pages", 0)
        response["totalEdges"] = _codemap.get("meta", {}).get("total_edges", 0)
        response["navigationTree"] = _cm_nav_tree_resp
        response["auth"] = _cm_auth_resp
        response["conditionalFlows"] = _cm_conditional_resp[:20]
        response["pages"] = {
            _rp: {
                "description": _nd.get("description", ""),
                "elements_count": len(_nd.get("elements", [])),
                "has_forms": bool(_nd.get("forms")),
                "has_modals": bool(_nd.get("modals")),
                "selectors": [_e["selector"] for _e in _nd.get("elements", [])[:10]],
            }
            for _rp, _nd in _cm_nodes_resp.items()
            if _nd.get("type") == "page" and _rp.startswith("/")
        }

        # Pre-compute the full journey path for each page by tracing reachable_via
        # chains. This tells the LLM the EXACT navigation sequence from login to
        # every page so it doesn't have to figure it out from the raw nav tree.
        _bfs_tree = session.scan_notes.get("navigation_tree", {}) if session else {}
        _journey_paths: dict[str, list[str]] = {}
        for _jp_route in response["pages"]:
            _chain: list[str] = []
            _current = _jp_route
            _seen: set[str] = set()
            while _current and _current not in _seen:
                _seen.add(_current)
                _chain.append(_current)
                _tree_entry = _bfs_tree.get(_current, {})
                _current = _tree_entry.get("reachable_via") if isinstance(_tree_entry, dict) else None
            _chain.reverse()  # entry → ... → target
            if len(_chain) > 1:
                _journey_paths[_jp_route] = _chain
        if _journey_paths:
            response["journeyPaths"] = _journey_paths

        if _needs_base_url:
            response["baseUrlRequired"] = True
            response["baseUrlPrompt"] = "What is the local development URL? (e.g. http://localhost:3000)"
        if _needs_credentials:
            response["credentialsRequired"] = True
            response["credentialPrompt"] = (
                "This application requires authentication. "
                "Please provide test credentials (email and password)."
            )

        # Build instruction with explicit journey example from the computed paths
        _journey_example = ""
        if _journey_paths:
            # Pick the most common target (e.g. /notebook) as the example
            _example_target = max(_journey_paths, key=lambda k: len(_journey_paths[k]))
            _example_chain = _journey_paths[_example_target]
            _journey_example = (
                "\n\nEXAMPLE JOURNEY (from journeyPaths):\n"
                f"  To reach {_example_target}, your test steps MUST navigate through:\n"
                f"  {' → '.join(_example_chain)}\n"
                "  Each arrow is a click/interaction — never skip intermediate pages.\n"
                "  Check journeyPaths for the exact chain to every page.\n"
            )

        response["instruction"] = (
            "The application code map has been loaded. All routes, elements, navigation flows, "
            "and auth configuration are known.\n\n"
            + ("Please provide the base_url.\n" if _needs_base_url else "")
            + ("Please provide test_email and test_password for authentication.\n" if _needs_credentials else "")
            + "\nIMPORTANT: The 'journeyPaths' field shows the EXACT navigation chain from "
            "login to every page. Every test case MUST follow this chain — go_to the entry page, "
            "then click through each intermediate page in order. NEVER skip a page in the chain."
            + _journey_example
            + "\nSubmit your test_plan with discovered_flows based on the code map data above."
        )
        response["analysisInstructions"] = _analysis_instructions(
            max_test_cases, base_url=base_url, codemap_loaded=True
        )
        response["expectedOutputSchema"] = _expected_output_schema()

    # Inject fix cache prompt block (verified selector fixes from previous test runs).
    # Load for both first-call (scan phase) and when no pagination (generation/plan phases).
    try:
        _fix_cache_root = scan_root if scan_root else (session.scan_root if session else None)
        if _fix_cache_root:
            _fix_prompt = RepoFixCache(_fix_cache_root).get_prompt_block()
            if _fix_prompt:
                response["verifiedSelectorFixes"] = _fix_prompt
    except Exception:
        pass  # Non-critical

    # Inject feedback context on first call only.
    # _fb_hist / _fb_prompt are already populated from codemap load (new session path).
    # For existing sessions, fetch now.
    if is_first_call:
        if not _fb_hist and not _fb_prompt:
            try:
                _fb_hist, _fb_prompt = await _fetch_feedback_context(
                    scan_root or (session.scan_root if session else "")
                )
            except Exception:
                _fb_hist, _fb_prompt = {}, ""
        if _fb_hist:
            response["historicalContext"] = _fb_hist
        if _fb_prompt:
            response["provenPatterns"] = _fb_prompt

    # Send full instructions only on the first call to save context window space.
    # Subsequent calls get a compact reminder — the LLM already has the instructions.
    if is_first_call:
        instructions = _analysis_instructions(max_test_cases, base_url=base_url)
        # Append domain-specific flow hints if domain was detected
        domain_info = (session.scan_notes if isinstance(session.scan_notes, dict) else {}).get("app_domain_info") if session else None
        if not domain_info:
            domain_info = accumulated_notes.get("app_domain_info")
        if domain_info and domain_info.get("domain"):
            from maeris_mcp.scanners.code_reader import get_domain_flow_hints
            hints = get_domain_flow_hints(domain_info["domain"])
            if hints:
                instructions += (
                    "\n\n=== DOMAIN-SPECIFIC GUIDANCE ===\n" + hints +
                    f"(Detected with {domain_info.get('confidence', 'unknown')} confidence "
                    f"from signals: {', '.join(domain_info.get('signals', []))})\n"
                )
        response["analysisInstructions"] = instructions
        response["expectedOutputSchema"] = _expected_output_schema()
    else:
        response["reminder"] = (
            "Submit test_plan with discovered_flows, or test_cases if plan is already approved."
        )

    # Return accumulated scan_notes for continuation calls
    if accumulated_notes and not is_first_call:
        response["accumulatedScanNotes"] = accumulated_notes

    if auto_base_url:
        response["autoDetectedBaseUrl"] = auto_base_url

    if test_case_stats:
        response["testCasesSubmitted"] = test_case_stats

    if False:  # File scanning removed — codemap-first flow
        if rescan_files:
            # Targeted rescan: return EXACTLY the files the gate requested.
            # Do NOT skip based on cache — if the gate said to re-read a file,
            # the LLM must get the content so it can submit fresh page_elements.
            # Skipping here creates a permanent loop: gate blocks → rescan skipped
            # → gate blocks again.
            paginated_abs = []
            for rel in rescan_files:
                _matched_abs: str | None = None
                for abs_path in all_files_abs:
                    try:
                        if os.path.relpath(abs_path, str(base_for_rel)) == rel:
                            _matched_abs = abs_path
                            break
                    except ValueError:
                        if abs_path == rel:
                            _matched_abs = abs_path
                            break
                if _matched_abs is not None:
                    paginated_abs.append(_matched_abs)
            has_more = False
            response["pagination"] = {
                "offset": "targeted",
                "returned": len(paginated_abs),
                "total": len(paginated_abs),
                "hasMore": False,
                "nextOffset": None,
                "scanPhase": (
                    "TARGETED RESCAN — read these files, extract ALL interactive elements "
                    "(inputs, buttons, links, forms), and submit scan_notes with "
                    "page_elements for every route/form the gate flagged as missing. "
                    "Then resubmit test_plan."
                ),
            }
        else:
            # Adaptive batch: add files until the file-count cap OR
            # cumulative content size budget is hit, whichever comes first.
            # Keeps batches fast for small files and prevents context
            # blow-up when individual files are large (e.g. 80-100 KB).
            _BATCH_CHAR_BUDGET = 120_000  # ~30k tokens per batch
            paginated_abs = []
            _batch_chars = 0
            _next_offset = offset
            for _i in range(offset, len(all_files_abs)):
                _fp = all_files_abs[_i]
                try:
                    _fsize = os.path.getsize(_fp)
                except OSError:
                    _fsize = 0
                # Always include at least one file even if it alone exceeds budget
                if paginated_abs and _batch_chars + _fsize > _BATCH_CHAR_BUDGET:
                    break
                paginated_abs.append(_fp)
                _batch_chars += _fsize
                _next_offset = _i + 1
                if len(paginated_abs) >= limit:
                    break
            has_more = _next_offset < total_files
            response["pagination"] = {
                "offset": offset,
                "limit": limit,
                "returned": len(paginated_abs),
                "total": total_files,
                "hasMore": has_more,
                "nextOffset": _next_offset if has_more else None,
                "scanPhase": (
                    f"SCANNING — {len(paginated_abs)} files in this batch "
                    f"({offset + 1}–{_next_offset} of {total_files}). "
                    f"NEXT CALL: pass offset={_next_offset} (this exact integer) to continue. "
                    "Also submit scan_notes={...} with your observations from this batch. "
                    "Do NOT submit test_cases until hasMore=false."
                    if has_more else
                    "SCAN COMPLETE — PHASE 1 is finished. PHASE 1.5 planning is now mandatory: "
                    "submit test_plan before writing any test_cases."
                ),
            }
        contents = get_file_contents(paginated_abs)

        file_summaries_out: dict[str, dict] = {}
        for abs_path in paginated_abs:
            raw = contents.get(abs_path, "")
            display_key = linked_file_map.get(abs_path)
            if display_key:
                file_key = display_key
            else:
                try:
                    file_key = os.path.relpath(abs_path, str(base_for_rel))
                except ValueError:
                    file_key = abs_path
            file_summaries_out[file_key] = summarize_file(file_key, raw)

        response["fileSummaries"] = file_summaries_out

        # Persist per-file fingerprints for smarter reuse in future sessions.
        try:
            if _scan_cache:
                _fp_updates: dict = {}
                for abs_path in paginated_abs:
                    raw = contents.get(abs_path, "")
                    if not raw:
                        continue
                    try:
                        rel_path = os.path.relpath(abs_path, str(base_for_rel))
                    except ValueError:
                        rel_path = abs_path
                    _fp_updates[abs_path] = _build_file_fingerprint(raw, rel_path)
                if _fp_updates:
                    _scan_cache.update_file_fingerprints(_fp_updates)
        except Exception:
            pass

        # Populate the cell so _finalize_token_stats writes the file list to token_logs.txt.
        _files_read_this_call.clear()
        _files_read_this_call.extend(file_summaries_out.keys())

        # --- Feature 3: Server-side selector extraction ---
        # Extract real selector attribute values from the raw source content of every
        # file in this batch and accumulate them on the session.  These are later
        # cross-referenced against page_elements to catch hallucinated selectors.
        try:
            _batch_selectors: set[str] = set()
            for abs_path in paginated_abs:
                _raw_content = contents.get(abs_path, "")
                if _raw_content:
                    _batch_selectors.update(_extract_source_selectors(_raw_content))
            if _batch_selectors:
                test_store.update_source_selectors(session_id, _batch_selectors)
        except Exception:
            pass  # Non-critical

        # Emit real-time selectorWarnings if accumulated page_elements contain
        # selectors that were never seen in any source file read so far.
        try:
            _current_session = test_store.get_session(session_id)
            _source_sel_set = set(_current_session.source_selectors) if _current_session else set()
            _page_elems = accumulated_notes.get("page_elements", [])
            if _source_sel_set and _page_elems:
                _sel_check = _validate_page_element_selectors(_page_elems, _source_sel_set)
                if _sel_check["suspicious"]:
                    response["selectorWarnings"] = {
                        "verifiedCount": _sel_check["verified_count"],
                        "totalCount": _sel_check["total_count"],
                        "suspiciousCount": len(_sel_check["suspicious"]),
                        "suspicious": _sel_check["suspicious"][:10],
                        "instruction": (
                            "WARNING: The selectors listed above were NOT found in any source file "
                            "that has been read so far. Do NOT use selectors you have not seen in "
                            "actual source code. Either correct them to match real attributes or "
                            "remove them from page_elements entirely."
                        ),
                    }
        except Exception:
            pass  # Non-critical

        # On SCAN COMPLETE, include a structured summary with validation
        if not has_more:
            if accumulated_notes:
                scan_summary = _build_scan_summary(accumulated_notes)
                response["scanCompleteSummary"] = scan_summary

                # Persist condensed notes and selector cache so future sessions can reuse them.
                try:
                    _active_cache = _scan_cache or RepoScanCache(scan_root)
                    _active_cache.set_condensed_notes(json.dumps(accumulated_notes))
                    _active_cache.set_last_flow_description(flow_desc)
                    # Save extracted page_elements so future sessions start with known selectors.
                    _final_page_elements = accumulated_notes.get("page_elements", [])
                    if _final_page_elements:
                        _active_cache.set_selector_cache(_final_page_elements)
                except Exception:
                    pass  # Non-critical

            # Mark scan complete for this session (only for full scan batches).
            if session_id and not rescan_files:
                try:
                    test_store.set_scan_complete(session_id, True)
                except Exception:
                    pass

            # Fetch real DOM selectors from Pinecone (graceful — empty string if not crawled)
            pinecone_selectors = await _fetch_pinecone_selectors(flow_desc)
            if pinecone_selectors:
                response["verifiedDOMSelectors"] = pinecone_selectors

            response["CODEBASE_SUMMARY"] = (
                "SCAN COMPLETE. Do not write steps yet. Submit a test_plan first, wait for approval, "
                "then generate test_cases with steps arrays."
            )
            response["requiredNextStep"] = (
                "PHASE 1.5 — TEST PLANNING (MANDATORY before writing steps)\n\n"
                "Before writing any test_cases, you MUST submit a test_plan by calling "
                "generate_tests again with:\n"
                "  session_id + test_plan={...}\n\n"
                "Your test_plan must include discovered_flows with:\n"
                "  - flow_name\n"
                "  - entry_point\n"
                "  - prerequisites\n"
                "  - user_journey_steps\n"
                "  - test_cases_planned\n"
                "  - key_selectors (from scan_notes)\n"
                "  - assertions_planned (exact text/URLs from source)\n\n"
                "DO NOT submit test_cases or is_last=true until you have submitted and "
                "received confirmation of your test_plan."
            )
    else:
        base_url_note = (
            f"Application base URL: {base_url} — every test starts with go_to to this URL (the home page). "
            "Navigate to other pages by CLICKING links/buttons found in the NavBar/menu source — "
            "do NOT use go_to for deep URLs like /login or /profile. "
        ) if base_url else (
            "The base_url is fetched automatically from the Maeris application — do NOT ask the user for it. "
        )
        response["nextStep"] = (
            "PHASE 1 — SCAN ONLY. Call again with include_content=true, offset=0 to begin reading files. "
            f"{base_url_note}"
            "Read router/route files first to map every page URL, then NavBar for navigation selectors. "
            "DO NOT submit test_cases during scanning — you have not yet seen the full codebase. "
            "Submit scan_notes={...} with EVERY batch call to build your persistent cheat sheet. "
            "Continue calling with increasing offsets until hasMore=false. "
            "After hasMore=false, submit test_plan={...} for Phase 1.5 approval. "
            "Only after plan approval: submit test_cases FLOW BY FLOW — one flow per call (no is_last). "
            "When ALL flows are submitted, call one final time with is_last=true. "
            "If the flow requires authentication, credentials are already in your context — "
            "pass test_email and test_password directly to generate_tests (do NOT ask the user)."
        )

    # Measure token cost components
    _instructions_chars = len(json.dumps(response.get("analysisInstructions", "")))
    _notes_chars = len(json.dumps(accumulated_notes))
    _finalize_token_stats("init" if is_first_call else "continuation", response, {
        "analysis_instructions": _instructions_chars,
        "accumulated_notes": _notes_chars,
    })

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