"""generate_code_map MCP tool.

Scans the user's application codebase and produces a structured JSON navigation graph
covering all pages, routes, navigation flows (including conditional/role-based), clickable
elements, forms, modals, and auth boundaries.

Output is saved to a temp directory and automatically injected into generate_tests
sessions to provide pre-built navigation context.
"""

import hashlib
import json
import re
import tempfile
from datetime import datetime, timezone
from pathlib import Path


def codemap_path_for(target_path: str | Path) -> Path:
    """Return the temp-directory path for a project's codemap.json.

    Uses a hash of the resolved target path to avoid collisions between
    different projects while never writing into the user's repository.
    """
    resolved = str(Path(target_path).resolve())
    path_hash = hashlib.sha256(resolved.encode()).hexdigest()[:12]
    return Path(tempfile.gettempdir()) / "maeris_codemap" / path_hash / "codemap.json"


def codemap_content_hash(codemap_path: Path) -> str:
    """Return SHA256 hash of the codemap.json content for change detection.

    Returns empty string if the file does not exist or cannot be read.
    """
    try:
        if codemap_path.exists():
            content = codemap_path.read_bytes()
            return hashlib.sha256(content).hexdigest()[:16]
    except Exception:
        pass
    return ""


from mcp.types import TextContent, Tool

from maeris_mcp.storage.feedback_cache import FEEDBACK_CACHE_MAX_AGE_HOURS

import structlog

_logger = structlog.get_logger()


# ---------------------------------------------------------------------------
# Tool definition
# ---------------------------------------------------------------------------

def generate_code_map_tool() -> Tool:
    return Tool(
        name="generate_code_map",
        description=(
            "Scan the application codebase to build a complete JSON navigation graph. "
            "Covers all pages, routes (including dynamic segments), navigation flows "
            "(direct, conditional, role-based, success/error paths), clickable elements, "
            "forms, modals, and auth boundaries. "
            "Saves to a temp directory and is automatically used by generate_tests "
            "for more accurate, source-grounded test case generation."
        ),
        inputSchema={
            "type": "object",
            "properties": {
                "target_path": {
                    "type": "string",
                    "description": (
                        "Absolute path to the application root directory to scan. "
                        "Defaults to current working directory."
                    ),
                },
                "base_url": {
                    "type": "string",
                    "description": (
                        "The app's local development URL (e.g. http://localhost:3000). "
                        "Used to populate test generation base URL."
                    ),
                },
                "force_refresh": {
                    "type": "boolean",
                    "description": (
                        "Re-scan even if codemap.json already exists. "
                        "Use when the codebase has changed significantly."
                    ),
                },
            },
        },
    )


# ---------------------------------------------------------------------------
# Handler
# ---------------------------------------------------------------------------

async def handle_generate_code_map(arguments: dict) -> list[TextContent]:
    import os

    from maeris_mcp.scanners.code_reader import (
        collect_scan_files,
        filter_files_for_test_generation,
        prioritize_files_for_test_scan,
        build_import_distances,
        detect_app_domain,
    )
    from maeris_mcp.scanners.file_summarizer import summarize_file
    from maeris_mcp.scanners.nav_scanner import extract_full_navigation, entry_to_dict

    raw_target = arguments.get("target_path") or os.getcwd()
    target = Path(raw_target).resolve()
    base_url: str | None = arguments.get("base_url")
    force: bool = bool(arguments.get("force_refresh", False))

    if not target.exists():
        return [TextContent(type="text", text=json.dumps({
            "status": "error",
            "message": f"target_path does not exist: {target}",
        }))]

    out_path = codemap_path_for(target)

    # Return cached map if present and not forcing refresh — with staleness checks
    if out_path.exists() and not force:
        try:
            existing = json.loads(out_path.read_text(encoding="utf-8"))
            meta = existing.get("meta", {})

            source_stale = _is_codemap_source_stale(meta)
            feedback_stale = await _is_feedback_stale(meta, str(target))

            if not source_stale and not feedback_stale:
                # Fully fresh — return cached
                return [TextContent(type="text", text=json.dumps({
                    "status": "loaded_existing",
                    "message": (
                        f"codemap.json already exists (generated {meta.get('generated_at', 'unknown')}). "
                        "Pass force_refresh=true to re-scan."
                    ),
                    "codemap": existing,
                }, indent=2))]

            if not source_stale and feedback_stale:
                # Source is fresh but feedback is stale — enrich only, no re-scan
                _logger.info("codemap.feedback_stale_enriching")
                enriched = await _enrich_with_feedback(existing, str(target))
                out_path.write_text(json.dumps(enriched, indent=2), encoding="utf-8")
                return [TextContent(type="text", text=json.dumps({
                    "status": "enriched",
                    "message": "Codemap re-enriched with latest test result patterns (no source re-scan).",
                    "codemap": enriched,
                }, indent=2))]

            # source_stale → fall through to full re-scan
            _logger.info("codemap.source_stale_rescanning")
        except Exception:
            pass  # Corrupt cache — re-scan

    # -----------------------------------------------------------------------
    # Phase 1: Collect and prioritize source files
    # -----------------------------------------------------------------------
    all_files, _ = collect_scan_files(str(target))
    page_files, _ = filter_files_for_test_generation(all_files)

    if not page_files:
        return [TextContent(type="text", text=json.dumps({
            "status": "error",
            "message": f"No scannable source files found at {target}",
        }))]

    base_for_rel = target
    rel_files = []
    for f in page_files:
        try:
            rel_files.append(str(Path(f).relative_to(base_for_rel)))
        except ValueError:
            rel_files.append(f)

    distances = build_import_distances(rel_files, base_for_rel)
    prioritized = prioritize_files_for_test_scan(page_files, distances)

    # -----------------------------------------------------------------------
    # Phase 2: Detect framework and domain
    # -----------------------------------------------------------------------
    framework = _detect_framework(target)
    pkg_content = _read_package_json(target)
    domain_info = detect_app_domain(all_files, pkg_content)

    # -----------------------------------------------------------------------
    # Phase 3: Extract routes from dedicated routing config files
    # -----------------------------------------------------------------------
    router_routes = _extract_router_config_routes(target, framework)

    # -----------------------------------------------------------------------
    # Phase 4: Scan every page/component file
    # -----------------------------------------------------------------------
    # Maps rel_path → {"summary": {...}, "nav": [NavEntry...], "fpath": str}
    file_data: dict[str, dict] = {}

    for fpath in prioritized:
        try:
            content = Path(fpath).read_text(encoding="utf-8", errors="replace")
        except Exception:
            continue

        try:
            rel = str(Path(fpath).relative_to(base_for_rel))
        except ValueError:
            rel = fpath

        summary = summarize_file(rel, content)
        nav_entries = extract_full_navigation(content, rel)

        # Keep pages and components (not pure utils / api routes)
        if summary.get("t") in ("page", "component"):
            file_data[rel] = {
                "summary": summary,
                "nav": nav_entries,
                "fpath": fpath,
            }

    # -----------------------------------------------------------------------
    # Phase 5: Build route → file mapping
    # -----------------------------------------------------------------------
    route_map = _build_route_map(target, framework, file_data, router_routes)

    # -----------------------------------------------------------------------
    # Phase 6: Build graph nodes and edges
    # -----------------------------------------------------------------------
    nodes, edges = _build_graph(route_map, file_data)

    # -----------------------------------------------------------------------
    # Phase 7: Detect auth boundary
    # -----------------------------------------------------------------------
    auth = _detect_auth(nodes, file_data)

    # -----------------------------------------------------------------------
    # Phase 8: Build navigation_tree (generate_tests-compatible format)
    # -----------------------------------------------------------------------
    nav_tree = _build_nav_tree(nodes, edges, auth)

    # -----------------------------------------------------------------------
    # Phase 9: Assemble and persist
    # -----------------------------------------------------------------------
    conditional_edges = [e for e in edges if e.get("condition")]
    pages_with_children = len([
        n for n in nodes.values()
        if n.get("type") == "page" and n.get("child_components")
    ])
    codemap = {
        "meta": {
            "generated_at": datetime.now(timezone.utc).isoformat(),
            "target_path": str(target),
            "framework": framework,
            "base_url": base_url,
            "domain": domain_info.get("domain"),
            "total_pages": len([n for n in nodes.values() if n["type"] == "page"]),
            "total_components": len([n for n in nodes.values() if n["type"] == "component"]),
            "total_edges": len(edges),
            "total_conditional_edges": len(conditional_edges),
            "total_files_scanned": len(file_data),
            "pages_with_aggregated_children": pages_with_children,
        },
        "nodes": nodes,
        "edges": edges,
        "auth": auth,
        "navigation_tree": nav_tree,
    }

    # -----------------------------------------------------------------------
    # Phase 10: Enrich with test result feedback patterns
    # -----------------------------------------------------------------------
    codemap = await _enrich_with_feedback(codemap, str(target))

    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(json.dumps(codemap, indent=2), encoding="utf-8")

    return [TextContent(type="text", text=json.dumps({
        "status": "success",
        "saved_to": str(out_path),
        "summary": codemap["meta"],
        "codemap": codemap,
    }, indent=2))]


# ---------------------------------------------------------------------------
# Staleness and feedback enrichment helpers
# ---------------------------------------------------------------------------


def _is_codemap_source_stale(meta: dict) -> bool:
    """Check if the codemap needs a full re-scan (source code too old)."""
    generated_at = meta.get("generated_at", "")
    if not generated_at:
        return True
    try:
        gen_dt = datetime.fromisoformat(generated_at.replace("Z", "+00:00"))
        age_hours = (datetime.now(timezone.utc) - gen_dt).total_seconds() / 3600
        return age_hours > FEEDBACK_CACHE_MAX_AGE_HOURS
    except Exception:
        return True


async def _is_feedback_stale(meta: dict, target_path: str) -> bool:
    """Check if the feedback enrichment in the codemap needs updating."""
    # No feedback data yet → stale
    if not meta.get("feedback_analyzed_at"):
        return True

    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()
        if not app_id:
            return False  # Not logged in — can't check, treat as fresh

        async with MaerisClient() as client:
            # Check testcase count
            testcases = await client.get_testcases()
            tc_count = len(testcases) if testcases else 0
            if tc_count != meta.get("testcase_count_at_generation", 0):
                _logger.info("codemap.feedback_stale_tc_count",
                             cached=meta.get("testcase_count_at_generation", 0),
                             current=tc_count)
                return True

            # Check for new results (sample first 10 testcases)
            if testcases:
                tc_ids = [
                    tc.get("_id") or tc.get("id") or tc.get("testcase_id") or ""
                    for tc in testcases[:10]
                    if tc.get("_id") or tc.get("id") or tc.get("testcase_id")
                ]
                if tc_ids:
                    try:
                        sample_results = await client.get_test_results_for_testcases(tc_ids)
                        latest_ts = ""
                        for results in sample_results.values():
                            for r in results:
                                ts = r.get("created_at") or ""
                                if ts and ts > latest_ts:
                                    latest_ts = ts
                        if latest_ts and latest_ts > (meta.get("last_result_created_at") or ""):
                            _logger.info("codemap.feedback_stale_new_results")
                            return True
                    except Exception:
                        pass

        return False
    except Exception:
        return False  # Can't check — treat as fresh


async def _enrich_with_feedback(codemap: dict, target_path: str) -> dict:
    """Run feedback analysis if needed and enrich the codemap dict.

    Writes proven selectors and reliability scores onto codemap nodes,
    and updates feedback metadata in codemap.meta.
    Returns the enriched codemap (original dict is modified in place).
    """
    try:
        from maeris_mcp.auth.token_store import TokenStore
        from maeris_mcp.maeris.client import MaerisClient
        from maeris_mcp.storage.feedback_cache import RepoFeedbackCache
        from maeris_mcp.analysis.feedback_analyzer import analyze_test_results

        token_store = TokenStore()
        app_id = token_store.get_app_id()
        if not app_id:
            _logger.info("codemap.enrich_skipped_no_app")
            return codemap

        cache = RepoFeedbackCache(target_path, app_id)
        cm_hash = codemap_content_hash(codemap_path_for(target_path))

        async with MaerisClient() as client:
            testcases = await client.get_testcases()
            tc_count = len(testcases) if testcases else 0

            if tc_count == 0:
                _logger.info("codemap.enrich_skipped_no_testcases")
                return codemap

            # Check if analysis is needed
            needs_full = cache.is_stale(tc_count, cm_hash)
            needs_incremental = False

            if not needs_full:
                tc_ids = [
                    tc.get("_id") or tc.get("id") or tc.get("testcase_id") or ""
                    for tc in testcases[:10]
                    if tc.get("_id") or tc.get("id") or tc.get("testcase_id")
                ]
                latest_ts = ""
                if tc_ids:
                    try:
                        sample_results = await client.get_test_results_for_testcases(tc_ids)
                        for results in sample_results.values():
                            for r in results:
                                ts = r.get("created_at") or ""
                                if ts and ts > latest_ts:
                                    latest_ts = ts
                    except Exception:
                        pass
                needs_incremental = cache.needs_incremental_update(latest_ts)

            if needs_full or needs_incremental:
                _logger.info("codemap.enrich_analyzing",
                             mode="full" if needs_full else "incremental",
                             tc_count=tc_count)
                await analyze_test_results(
                    client=client,
                    cache=cache,
                    incremental=needs_incremental and not needs_full,
                    codemap_hash=cm_hash,
                )

        # Enrich the codemap with cached patterns
        entry = cache.load()
        codemap = cache.enrich_codemap(codemap)

        # Add feedback metadata to codemap.meta
        meta = codemap.setdefault("meta", {})
        meta["feedback_analyzed_at"] = datetime.now(timezone.utc).isoformat()
        meta["testcase_count_at_generation"] = entry.testcase_count_at_analysis
        meta["last_result_created_at"] = entry.last_result_created_at
        meta["proven_selectors_count"] = len(entry.proven_selectors)
        meta["flaky_selectors_count"] = len(entry.flaky_selectors)

        _logger.info("codemap.enriched",
                     proven_selectors=len(entry.proven_selectors),
                     flaky_selectors=len(entry.flaky_selectors))
        return codemap

    except Exception as e:
        _logger.warning("codemap.enrich_failed", error=str(e))
        return codemap


# ---------------------------------------------------------------------------
# Framework detection
# ---------------------------------------------------------------------------

def _read_package_json(target: Path) -> str | None:
    pkg = target / "package.json"
    if pkg.exists():
        try:
            return pkg.read_text(encoding="utf-8", errors="replace")
        except Exception:
            pass
    return None


def _detect_framework(target: Path) -> str:
    """Detect the frontend framework from project files."""
    # Angular
    if (target / "angular.json").exists():
        return "angular"

    pkg_content = _read_package_json(target)
    if pkg_content:
        try:
            pkg = json.loads(pkg_content)
        except Exception:
            pkg = {}
        all_deps = {
            **pkg.get("dependencies", {}),
            **pkg.get("devDependencies", {}),
        }

        if "next" in all_deps:
            # Next.js — distinguish app dir from pages dir
            if (target / "app").exists() or (target / "src" / "app").exists():
                return "nextjs-app"
            return "nextjs-pages"

        if "react-router-dom" in all_deps or "react-router" in all_deps:
            return "react-router"

        if "vue" in all_deps or "@vue/core" in all_deps:
            return "vue"

        if "svelte" in all_deps or "@sveltejs/kit" in all_deps:
            return "svelte"

        if "nuxt" in all_deps:
            return "nuxt"

    # Python web frameworks
    for fname in ("pyproject.toml", "requirements.txt", "setup.py"):
        fpath = target / fname
        if fpath.exists():
            try:
                txt = fpath.read_text(encoding="utf-8", errors="replace").lower()
                if "flask" in txt:
                    return "flask"
                if "django" in txt:
                    return "django"
                if "fastapi" in txt:
                    return "fastapi"
            except Exception:
                pass

    return "unknown"


# ---------------------------------------------------------------------------
# Router config route extraction
# ---------------------------------------------------------------------------

_ROUTE_PATH_RE = re.compile(
    r'(?:path\s*[:=]\s*|<Route[^>]+path=)["\']([^"\']{1,120})["\']'
)
_VUE_ROUTE_RE = re.compile(
    r'path\s*:\s*["\']([^"\']{1,120})["\']'
)
_NG_ROUTE_RE = re.compile(
    r'\{\s*path\s*:\s*["\']([^"\']{1,120})["\']'
)


def _extract_router_config_routes(target: Path, framework: str) -> list[dict]:
    """Extract routes from dedicated router config files."""
    routes: list[dict] = []
    seen_paths: set[str] = set()

    def _add_route(path: str, source_file: str, dynamic: bool = False) -> None:
        if not path or path in seen_paths:
            return
        if path.startswith("#") or "." in path.split("/")[-1]:
            return
        seen_paths.add(path)
        routes.append({
            "path": path if path.startswith("/") else "/" + path,
            "file": source_file,
            "dynamic": dynamic or bool(re.search(r'[\[:]', path)),
        })

    if framework in ("nextjs-pages",):
        # File-system routing from pages/ directory
        for pages_dir in (target / "pages", target / "src" / "pages"):
            if pages_dir.exists():
                for f in pages_dir.rglob("*.tsx"):
                    _add_nextjs_pages_route(f, pages_dir, routes, seen_paths)
                for f in pages_dir.rglob("*.jsx"):
                    _add_nextjs_pages_route(f, pages_dir, routes, seen_paths)
                for f in pages_dir.rglob("*.ts"):
                    _add_nextjs_pages_route(f, pages_dir, routes, seen_paths)
                for f in pages_dir.rglob("*.js"):
                    _add_nextjs_pages_route(f, pages_dir, routes, seen_paths)

    elif framework in ("nextjs-app",):
        # File-system routing from app/ directory
        for app_dir in (target / "app", target / "src" / "app"):
            if app_dir.exists():
                for f in app_dir.rglob("page.tsx"):
                    _add_nextjs_app_route(f, app_dir, routes, seen_paths)
                for f in app_dir.rglob("page.jsx"):
                    _add_nextjs_app_route(f, app_dir, routes, seen_paths)
                for f in app_dir.rglob("page.js"):
                    _add_nextjs_app_route(f, app_dir, routes, seen_paths)

    elif framework == "vue":
        for router_file in ["src/router/index.js", "src/router/index.ts",
                            "src/router.js", "src/router.ts", "router/index.js"]:
            rf = target / router_file
            if rf.exists():
                try:
                    content = rf.read_text(encoding="utf-8", errors="replace")
                    for m in _VUE_ROUTE_RE.finditer(content):
                        path = m.group(1)
                        if path and path != "*":
                            _add_route(path, router_file, ":" in path)
                except Exception:
                    pass

    elif framework == "angular":
        for rf in target.rglob("*-routing.module.ts"):
            try:
                content = rf.read_text(encoding="utf-8", errors="replace")
                for m in _NG_ROUTE_RE.finditer(content):
                    path = m.group(1)
                    if path:
                        _add_route(path, str(rf.relative_to(target)), ":" in path)
            except Exception:
                pass

    else:
        # React Router — look for routes.tsx, App.tsx, router.tsx
        for router_file in ["src/routes.tsx", "src/routes.ts", "src/routes.jsx",
                            "src/App.tsx", "src/App.jsx", "src/router.tsx",
                            "src/router.ts", "routes.tsx", "routes.ts"]:
            rf = target / router_file
            if rf.exists():
                try:
                    content = rf.read_text(encoding="utf-8", errors="replace")
                    for m in _ROUTE_PATH_RE.finditer(content):
                        path = m.group(1)
                        if path:
                            _add_route(path, router_file, ":" in path or "[" in path)
                except Exception:
                    pass

    return routes


def _add_nextjs_pages_route(
    f: Path, pages_dir: Path, routes: list[dict], seen_paths: set[str]
) -> None:
    """Derive Next.js pages-dir route from file path."""
    try:
        rel = f.relative_to(pages_dir)
    except ValueError:
        return

    parts = list(rel.with_suffix("").parts)
    # Skip _app, _document, api routes
    if parts and parts[0].startswith("_"):
        return
    if parts and parts[0] == "api":
        return

    # index → parent route
    if parts and parts[-1] == "index":
        parts = parts[:-1]

    path = "/" + "/".join(parts) if parts else "/"
    dynamic = "[" in path or "..." in path
    if path not in seen_paths:
        seen_paths.add(path)
        routes.append({
            "path": path,
            "file": str(f.relative_to(pages_dir.parent)),
            "dynamic": dynamic,
        })


def _add_nextjs_app_route(
    f: Path, app_dir: Path, routes: list[dict], seen_paths: set[str]
) -> None:
    """Derive Next.js app-dir route from file path."""
    try:
        rel = f.parent.relative_to(app_dir)
    except ValueError:
        return

    parts = list(rel.parts)
    # Remove route groups (parenthesized segments) like (auth) or (dashboard)
    parts = [p for p in parts if not (p.startswith("(") and p.endswith(")"))]

    path = "/" + "/".join(parts) if parts else "/"
    dynamic = "[" in path
    if path not in seen_paths:
        seen_paths.add(path)
        routes.append({
            "path": path,
            "file": str(f.relative_to(app_dir.parent)),
            "dynamic": dynamic,
        })


# ---------------------------------------------------------------------------
# Graph builder
# ---------------------------------------------------------------------------

_SELECTOR_ATTR_MAP = {
    "data-testid": "data-testid",
    "data-cy": "data-cy",
    "id": "id",
    "name": "name",
    "placeholder": "placeholder",
    "aria-label": "aria-label",
}

_INPUT_TYPES = {"email", "password", "text", "search", "number", "tel", "url", "date", "textarea"}
_BUTTON_LABELS = {"submit", "button", "reset"}


def _make_selector(sel_pair: list[str]) -> str:
    attr, val = sel_pair[0], sel_pair[1]
    if attr == "data-testid":
        return f"[data-testid='{val}']"
    if attr == "id":
        return f"#{val}"
    if attr == "name":
        return f"[name='{val}']"
    if attr in ("aria-label",):
        return f"[{attr}='{val}']"
    return f"[{attr}='{val}']"


def _classify_element(sel_pair: list[str], txt: list[str]) -> str:
    """Classify element kind from selector attributes."""
    attr, val = sel_pair[0], sel_pair[1].lower()
    if val in _INPUT_TYPES or "input" in val or "field" in val or "email" in val or "password" in val:
        return "input"
    if "btn" in val or "button" in val or "submit" in val or "cta" in val:
        return "button"
    if "link" in val or "nav" in val or "menu" in val:
        return "link"
    if "check" in val or "checkbox" in val:
        return "checkbox"
    if "select" in val or "dropdown" in val:
        return "select"
    return "element"


# ---------------------------------------------------------------------------
# JSX interactive element extractor — finds buttons, inputs, links, selects
# from the actual JSX markup regardless of data-testid/id/name attributes.
# ---------------------------------------------------------------------------

# JSX attribute matching: [^>]* doesn't work because JSX attributes can contain
# `>` inside curly-brace expressions like onClick={() => handleFoo()}.
# This pattern matches: plain attrs, "strings", 'strings', and {expr} blocks (1 level deep).
_JSX_ATTRS = r'(?:[^>"\'{]|"[^"]*"|\'[^\']*\'|\{[^}]*\})*'

# <button ...>text</button> or self-closing <button ... />
# Inner text: allow up to 120 chars, may contain whitespace but not child tags
_JSX_BUTTON_RE = re.compile(
    r'<button\b(' + _JSX_ATTRS + r')>([^<]{0,120})</button>'
    r'|<button\b(' + _JSX_ATTRS + r')/\s*>'
    r'|<button\b(' + _JSX_ATTRS + r')>',  # opening tag only (nested children)
    re.DOTALL,
)
# <input type="..." ... />
_JSX_INPUT_RE = re.compile(
    r'<input\b(' + _JSX_ATTRS + r')(?:/\s*)?>',
    re.DOTALL,
)
# <select ...>...</select>
_JSX_SELECT_RE = re.compile(
    r'<select\b(' + _JSX_ATTRS + r')>',
    re.DOTALL,
)
# <textarea ...>
_JSX_TEXTAREA_RE = re.compile(
    r'<textarea\b(' + _JSX_ATTRS + r')>',
    re.DOTALL,
)
# <a ...>text</a> (but not <a> from Link components — those are handled by nav scanner)
_JSX_ANCHOR_RE = re.compile(
    r'<a\b(' + _JSX_ATTRS + r')>([^<]{0,120})</a>',
    re.DOTALL,
)

# Attribute extractors for JSX element attrs string
_ATTR_TYPE_RE = re.compile(r'type=["\']([^"\']+)["\']')
_ATTR_TESTID_RE = re.compile(r'data-testid=["\']([^"\']+)["\']')
_ATTR_ID_RE = re.compile(r'\bid=["\']([^"\']+)["\']')
_ATTR_NAME_RE = re.compile(r'\bname=["\']([^"\']+)["\']')
_ATTR_PLACEHOLDER_RE = re.compile(r'placeholder=["\']([^"\']+)["\']')
_ATTR_ARIA_LABEL_RE = re.compile(r'aria-label=["\']([^"\']+)["\']')
_ATTR_CLASSNAME_RE = re.compile(r'className=\{([^}]+)\}|className=["\']([^"\']+)["\']')
# Match the primary function being called in onClick — skip arrow params like (e) =>
_ATTR_ONCLICK_RE = re.compile(
    r'onClick=\{'
    r'(?:\([^)]*\)\s*=>\s*)?'  # skip optional (e) => or () => prefix
    r'[^}]*?([a-zA-Z_$][a-zA-Z0-9_$]*)\s*[\(\.]'  # capture function name before ( or .
)
_ATTR_HREF_RE = re.compile(r'href=["\']([^"\']+)["\']')


def _best_selector_from_attrs(
    attrs: str, tag: str, type_val: str | None = None, inner_text: str | None = None
) -> str:
    """Build the best available CSS selector from a JSX element's attributes.

    Falls back to text content or className when no stable attribute is found.
    """
    # Priority order: data-testid > id > name > aria-label > type > placeholder > className > text
    m = _ATTR_TESTID_RE.search(attrs)
    if m:
        return f"[data-testid='{m.group(1)}']"
    m = _ATTR_ID_RE.search(attrs)
    if m:
        return f"#{m.group(1)}"
    m = _ATTR_NAME_RE.search(attrs)
    if m:
        return f"{tag}[name='{m.group(1)}']"
    m = _ATTR_ARIA_LABEL_RE.search(attrs)
    if m:
        return f"{tag}[aria-label='{m.group(1)}']"
    if type_val:
        return f"{tag}[type='{type_val}']"
    m = _ATTR_PLACEHOLDER_RE.search(attrs)
    if m:
        return f"{tag}[placeholder='{m.group(1)}']"
    # Try className — extract the most specific class name
    m = _ATTR_CLASSNAME_RE.search(attrs)
    if m:
        raw_class = (m.group(1) or m.group(2) or "").strip()
        # Handle CSS module: styles.backButton → .backButton
        cm = re.search(r'styles\.([a-zA-Z_][a-zA-Z0-9_]*)', raw_class)
        if cm:
            return f"{tag}.{cm.group(1)}"
        # Handle plain class string
        if raw_class and not raw_class.startswith("{") and " " not in raw_class:
            return f"{tag}.{raw_class}"
    # Try text content: button >> text="Back" (Playwright text selector style)
    if inner_text:
        clean = inner_text.strip()
        if clean and len(clean) <= 50 and not clean.startswith("{"):
            return f'{tag}:text("{clean}")'
    # Try onClick handler as disambiguator
    m = _ATTR_ONCLICK_RE.search(attrs)
    if m:
        handler = m.group(1)
        if handler and len(handler) > 1:
            return f"{tag}:handler({handler})"
    # Fallback to just the tag
    return tag


def _label_from_attrs_and_text(
    attrs: str, inner_text: str | None, tag: str, type_val: str | None = None
) -> str:
    """Derive a human-readable label for an element."""
    # Try aria-label first
    m = _ATTR_ARIA_LABEL_RE.search(attrs)
    if m:
        return m.group(1)
    # Try placeholder
    m = _ATTR_PLACEHOLDER_RE.search(attrs)
    if m:
        return m.group(1)
    # Try inner text (cleaned)
    if inner_text:
        clean = inner_text.strip()
        # Skip text that looks like JSX expressions
        if clean and not clean.startswith("{") and len(clean) <= 60:
            return clean
    # Try type (for inputs)
    if type_val:
        return f"{type_val} {tag}"
    # Try name
    m = _ATTR_NAME_RE.search(attrs)
    if m:
        return m.group(1)
    # Try className (CSS module or plain)
    m = _ATTR_CLASSNAME_RE.search(attrs)
    if m:
        raw = (m.group(1) or m.group(2) or "").strip()
        cm = re.search(r'styles\.([a-zA-Z_][a-zA-Z0-9_]*)', raw)
        if cm:
            # Convert camelCase to words: appCard → "app card"
            name = re.sub(r'([a-z])([A-Z])', r'\1 \2', cm.group(1)).lower()
            return f"{name} {tag}"
    # Try onClick handler name
    m = _ATTR_ONCLICK_RE.search(attrs)
    if m:
        handler = m.group(1)
        # Convert camelCase: handleSelectApp → "select app"
        clean = handler.replace("handle", "").replace("Handle", "")
        name = re.sub(r'([a-z])([A-Z])', r'\1 \2', clean).strip().lower()
        if name:
            return f"{name} {tag}"
    return tag


def _extract_onclick_handler(attrs: str) -> str | None:
    """Extract the onClick handler function name."""
    m = _ATTR_ONCLICK_RE.search(attrs)
    return m.group(1) if m else None


def _extract_jsx_elements(content: str) -> list[dict]:
    """Extract interactive elements from JSX source code.

    Returns list of element dicts: {selector, kind, label, handler, type}.
    This works even when elements lack data-testid/id/name attributes.
    """
    elements: list[dict] = []
    seen_selectors: set[str] = set()

    def _add(selector: str, kind: str, label: str, handler: str | None = None,
             type_val: str | None = None) -> None:
        if selector in seen_selectors:
            return
        seen_selectors.add(selector)
        entry: dict = {"selector": selector, "kind": kind, "label": label}
        if handler:
            entry["handler"] = handler
        if type_val:
            entry["type"] = type_val
        elements.append(entry)

    # --- Buttons ---
    for m in _JSX_BUTTON_RE.finditer(content):
        if m.group(1) is not None:
            # <button attrs>text</button> (simple text child)
            attrs, inner = m.group(1), m.group(2)
        elif m.group(3) is not None:
            # <button attrs /> (self-closing)
            attrs, inner = m.group(3), None
        elif m.group(4) is not None:
            # <button attrs> (opening tag with nested children — no simple inner text)
            attrs, inner = m.group(4), None
        else:
            continue

        type_val = None
        tm = _ATTR_TYPE_RE.search(attrs)
        if tm:
            type_val = tm.group(1)

        sel = _best_selector_from_attrs(attrs, "button", type_val, inner)
        label = _label_from_attrs_and_text(attrs, inner, "button", type_val)
        handler = _extract_onclick_handler(attrs)
        _add(sel, "button", label, handler, type_val)

    # --- Inputs ---
    for m in _JSX_INPUT_RE.finditer(content):
        attrs = m.group(1)
        type_val = None
        tm = _ATTR_TYPE_RE.search(attrs)
        if tm:
            type_val = tm.group(1)
            # Skip hidden inputs
            if type_val == "hidden":
                continue

        sel = _best_selector_from_attrs(attrs, "input", type_val)
        label = _label_from_attrs_and_text(attrs, None, "input", type_val)
        kind = "input"
        if type_val in ("checkbox", "radio"):
            kind = type_val
        _add(sel, kind, label, type_val=type_val)

    # --- Selects ---
    for m in _JSX_SELECT_RE.finditer(content):
        attrs = m.group(1)
        sel = _best_selector_from_attrs(attrs, "select")
        label = _label_from_attrs_and_text(attrs, None, "select")
        _add(sel, "select", label)

    # --- Textareas ---
    for m in _JSX_TEXTAREA_RE.finditer(content):
        attrs = m.group(1)
        sel = _best_selector_from_attrs(attrs, "textarea")
        label = _label_from_attrs_and_text(attrs, None, "textarea")
        _add(sel, "input", label, type_val="textarea")

    # --- Anchors (plain <a>, not <Link>) ---
    for m in _JSX_ANCHOR_RE.finditer(content):
        attrs, inner = m.group(1), m.group(2)
        href_m = _ATTR_HREF_RE.search(attrs)
        href = href_m.group(1) if href_m else None
        sel = _best_selector_from_attrs(attrs, "a")
        label = _label_from_attrs_and_text(attrs, inner, "a")
        entry: dict = {"selector": sel, "kind": "link", "label": label}
        if href:
            entry["href"] = href
        if sel not in seen_selectors:
            seen_selectors.add(sel)
            elements.append(entry)

    return elements[:40]  # Cap at 40 elements


def _build_route_map(
    target: Path,
    framework: str,
    file_data: dict[str, dict],
    router_routes: list[dict],
) -> dict[str, str]:
    """Map route path → source file rel_path."""
    route_map: dict[str, str] = {}

    # Add routes extracted from router config files
    for r in router_routes:
        route_map[r["path"]] = r.get("file", "")

    # For each page file, try to derive its route
    for rel_path, data in file_data.items():
        if data["summary"].get("t") != "page":
            continue

        # Derive route from routes extracted in the file summary
        for route in data["summary"].get("rts", []):
            if route and route.startswith("/") and route not in route_map:
                route_map[route] = rel_path

        # Derive route from file path (for file-system routing frameworks)
        derived = _derive_route_from_path(rel_path, framework)
        if derived and derived not in route_map:
            route_map[derived] = rel_path

    return route_map


def _derive_route_from_path(rel_path: str, framework: str) -> str | None:
    """Attempt to derive a URL route from a file's relative path."""
    p = Path(rel_path)
    parts = p.with_suffix("").parts

    # Find the pages/views/screens/app segment
    for i, part in enumerate(parts):
        if part.lower() in ("pages", "views", "screens"):
            route_parts = list(parts[i + 1:])
            # Remove index
            if route_parts and route_parts[-1].lower() == "index":
                route_parts = route_parts[:-1]
            # Skip _app, _document
            if route_parts and route_parts[0].startswith("_"):
                return None
            path = "/" + "/".join(route_parts) if route_parts else "/"
            return path

        if part.lower() == "app" and framework in ("nextjs-app",):
            route_parts = list(parts[i + 1:])
            # Remove route groups
            route_parts = [rp for rp in route_parts if not (rp.startswith("(") and rp.endswith(")"))]
            # Remove 'page' suffix
            if route_parts and route_parts[-1].lower() == "page":
                route_parts = route_parts[:-1]
            path = "/" + "/".join(route_parts) if route_parts else "/"
            return path

    return None


def _generate_file_description(
    rel_path: str,
    summary: dict,
    elements: list[dict],
    forms: list[dict],
    modals: list[dict],
    nav_entries: list,
) -> str:
    """Generate a concise human-readable description of what a file/page does.

    Synthesises context from file path, UI text, interactive elements, forms,
    modals, and navigation targets so that downstream test generation has
    immediate context about each page's purpose without re-reading source.
    """
    parts: list[str] = []

    # --- Derive purpose from path segments ---
    path_lower = rel_path.replace("\\", "/").lower()
    path_segments = [s for s in path_lower.split("/") if s and s not in (
        "src", "app", "pages", "views", "screens", "components",
    )]
    # Strip file extension from last segment
    if path_segments:
        last = re.sub(r'\.(tsx?|jsx?|vue|svelte|py)$', '', path_segments[-1])
        path_segments[-1] = last

    _PATH_HINTS: dict[str, str] = {
        "login": "User login / authentication",
        "signin": "User sign-in",
        "signup": "User registration / sign-up",
        "register": "User registration",
        "dashboard": "Main dashboard",
        "settings": "Settings management",
        "profile": "User profile",
        "account": "Account management",
        "billing": "Billing / payment management",
        "pricing": "Pricing page",
        "checkout": "Checkout flow",
        "cart": "Shopping cart",
        "search": "Search functionality",
        "notifications": "Notifications",
        "admin": "Admin panel",
        "onboarding": "Onboarding flow",
        "home": "Home / landing page",
        "index": "Main entry page",
        "layout": "Layout wrapper",
        "navbar": "Navigation bar",
        "sidebar": "Sidebar navigation",
        "header": "Page header",
        "footer": "Page footer",
        "modal": "Modal dialog",
        "404": "Not-found / 404 page",
        "error": "Error page",
        "forgot-password": "Password recovery",
        "reset-password": "Password reset",
    }

    for segment in reversed(path_segments):
        for hint_key, hint_desc in _PATH_HINTS.items():
            if hint_key in segment:
                parts.append(hint_desc)
                break
        if parts:
            break

    # --- Derive purpose from UI text ---
    ui_texts = summary.get("txt", [])
    if ui_texts and not parts:
        # Use the first meaningful text as a fallback purpose hint
        parts.append(f'Page displaying "{ui_texts[0]}"')

    # --- Describe interactive capabilities ---
    input_els = [e for e in elements if e.get("kind") == "input"]
    button_els = [e for e in elements if e.get("kind") == "button"]
    select_els = [e for e in elements if e.get("kind") == "select"]

    if forms:
        field_count = sum(len(f.get("fields", [])) for f in forms)
        has_submit = any(f.get("submit") for f in forms)
        form_desc = f"Contains form with {field_count} field(s)"
        if has_submit:
            form_desc += " and submit button"
        parts.append(form_desc)
    elif input_els:
        input_types = [e.get("type", e.get("label", "")) for e in input_els[:5]]
        input_types = [t for t in input_types if t]
        if input_types:
            parts.append(f"Has inputs: {', '.join(input_types[:4])}")

    if button_els:
        btn_labels = [e.get("label", "") for e in button_els[:4] if e.get("label")]
        if btn_labels:
            parts.append(f"Actions: {', '.join(btn_labels[:3])}")

    if modals:
        modal_names = [m.get("modal_id", "") for m in modals[:3] if m.get("modal_id")]
        if modal_names:
            parts.append(f"Triggers modals: {', '.join(modal_names[:2])}")

    # --- Describe navigation targets ---
    route_targets = [
        e.to for e in nav_entries
        if hasattr(e, 'to') and e.to and e.to.startswith("/")
    ]
    if route_targets:
        parts.append(f"Navigates to: {', '.join(route_targets[:3])}")

    # --- Fallback ---
    if not parts:
        comp_type = summary.get("t", "component")
        parts.append(f"{comp_type.replace('_', ' ').title()} file")

    return ". ".join(parts)


def _resolve_import_to_rel_path(
    importing_file: str,
    import_spec: str,
    known_files: set[str],
) -> str | None:
    """Resolve a relative import spec (e.g. './Login/LoginForm') to a known rel_path.

    Tries common extension and index-file patterns to match against known_files.
    """
    # Normalise to forward slashes
    importing_file = importing_file.replace("\\", "/")
    import_spec = import_spec.replace("\\", "/")

    # Compute the directory of the importing file
    importing_dir = "/".join(importing_file.split("/")[:-1]) if "/" in importing_file else ""

    # Resolve the relative path
    if import_spec.startswith("./") or import_spec.startswith("../"):
        if importing_dir:
            candidate_base = importing_dir + "/" + import_spec
        else:
            candidate_base = import_spec
    else:
        candidate_base = import_spec

    # Normalise: resolve ../ segments
    parts = candidate_base.split("/")
    resolved: list[str] = []
    for p in parts:
        if p == ".":
            continue
        elif p == ".." and resolved:
            resolved.pop()
        else:
            resolved.append(p)
    candidate_base = "/".join(resolved)

    # Try exact match first (already has extension stripped by summarizer)
    _EXTENSIONS = [".tsx", ".ts", ".jsx", ".js", ".vue", ".svelte"]
    _INDEX_FILES = ["index.tsx", "index.ts", "index.jsx", "index.js"]

    for ext in _EXTENSIONS:
        candidate = candidate_base + ext
        # Normalise separators for matching
        norm = candidate.replace("\\", "/")
        for known in known_files:
            if known.replace("\\", "/") == norm:
                return known

    # Try as directory with index file
    for idx in _INDEX_FILES:
        candidate = candidate_base + "/" + idx
        norm = candidate.replace("\\", "/")
        for known in known_files:
            if known.replace("\\", "/") == norm:
                return known

    return None


def _collect_descendant_elements(
    rel_path: str,
    file_data: dict[str, dict],
    known_files: set[str],
    visited: set[str] | None = None,
    max_depth: int = 6,
) -> tuple[list[dict], list[dict], list[dict], list[str]]:
    """Recursively walk imports from a file, collecting elements/forms/modals/text.

    Returns (elements, forms, modals, text_content) aggregated from all child
    components in the import tree.
    """
    if visited is None:
        visited = set()
    if rel_path in visited or max_depth <= 0:
        return [], [], [], []
    visited.add(rel_path)

    data = file_data.get(rel_path)
    if not data:
        return [], [], [], []

    summary = data["summary"]
    imports = summary.get("imp", [])

    agg_elements: list[dict] = []
    agg_forms: list[dict] = []
    agg_modals: list[dict] = []
    agg_text: list[str] = []

    for imp_spec in imports:
        child_rel = _resolve_import_to_rel_path(rel_path, imp_spec, known_files)
        if not child_rel or child_rel in visited:
            continue

        child_data = file_data.get(child_rel)
        if not child_data:
            continue

        # Only aggregate from components (not pages — those are separate nodes)
        if child_data["summary"].get("t") != "component":
            continue

        # Collect this child's own elements
        child_summary = child_data["summary"]
        child_elements: list[dict] = []
        for sel_pair in child_summary.get("sel", []):
            if len(sel_pair) >= 2:
                sel = _make_selector(sel_pair)
                child_elements.append({
                    "selector": sel,
                    "kind": _classify_element(sel_pair, child_summary.get("txt", [])),
                    "label": sel_pair[1],
                    "source_file": child_rel,
                })

        # Also extract JSX elements from the child's raw source
        try:
            raw_content = Path(child_data["fpath"]).read_text(encoding="utf-8", errors="replace")
            jsx_elements = _extract_jsx_elements(raw_content)
            child_seen = {e["selector"] for e in child_elements}
            for jsx_el in jsx_elements:
                if jsx_el["selector"] not in child_seen:
                    child_seen.add(jsx_el["selector"])
                    jsx_el["source_file"] = child_rel
                    child_elements.append(jsx_el)
        except Exception:
            pass

        agg_elements.extend(child_elements)

        # Child forms
        child_inputs = [e for e in child_elements if e.get("kind") == "input"]
        child_buttons = [e for e in child_elements if e.get("kind") == "button"]
        if child_inputs:
            agg_forms.append({
                "fields": [e["selector"] for e in child_inputs],
                "submit": child_buttons[0]["selector"] if child_buttons else None,
                "source_file": child_rel,
            })

        # Child modals
        for nav_entry in child_data["nav"]:
            if nav_entry.mechanism in ("modal", "state") and nav_entry.fn:
                agg_modals.append({
                    "trigger_selector": nav_entry.selector_hint,
                    "modal_id": nav_entry.fn,
                    "source_file": child_rel,
                })

        # Child text content
        agg_text.extend(child_summary.get("txt", []))

        # Recurse into the child's imports
        sub_els, sub_forms, sub_modals, sub_text = _collect_descendant_elements(
            child_rel, file_data, known_files, visited, max_depth - 1,
        )
        agg_elements.extend(sub_els)
        agg_forms.extend(sub_forms)
        agg_modals.extend(sub_modals)
        agg_text.extend(sub_text)

    return agg_elements, agg_forms, agg_modals, agg_text


def _element_relevance_key(
    element: dict,
    page_dir: str,
) -> tuple[int, int, int]:
    """Score an element for relevance-based sorting (lower = more relevant).

    Returns (directory_distance, selector_weakness, source_order) where:
    - directory_distance: 0 if same directory tree as page, 1 if shared/layout
    - selector_weakness: 0 for data-testid/data-automation-id, 1 for id, 2 for name,
                         3 for aria-label/placeholder, 4 for generic tag-only selectors
    - source_order: preserved original order as tiebreaker
    """
    src = (element.get("source_file") or "").replace("\\", "/").lower()
    page_dir_norm = page_dir.replace("\\", "/").lower()

    # Directory relevance: components in the same domain as the page rank higher.
    # e.g., for pages/notebook.js, components/notebook/* is relevant,
    # components/LeftPanel/* is shared layout.
    # Extract the page's "domain" from its path: pages/notebook.js → "notebook"
    if page_dir_norm and src:
        # Check if source is in a directory that shares the page's name/domain
        if page_dir_norm in src:
            dir_distance = 0
        else:
            dir_distance = 1
    else:
        dir_distance = 0  # page's own elements

    # Selector strength
    sel = element.get("selector", "")
    if sel.startswith("[data-testid=") or sel.startswith("[data-automation-id=") or sel.startswith("[data-cy="):
        sel_weakness = 0
    elif sel.startswith("#") and not sel.startswith("#${"):
        sel_weakness = 1
    elif sel.startswith("[name="):
        sel_weakness = 2
    elif sel.startswith("[aria-label=") or sel.startswith("[placeholder="):
        sel_weakness = 3
    elif sel in ("button", "input", "select", "textarea", "a") or ":text(" in sel or ":handler(" in sel:
        sel_weakness = 5
    else:
        sel_weakness = 4

    return (dir_distance, sel_weakness, 0)


def _page_domain_hint(rel_path: str) -> str:
    """Extract a domain hint from a page's file path for relevance scoring.

    e.g., 'pages/notebook.js' → 'notebook'
          'pages/settings/profile.tsx' → 'settings'
          'src/pages/dashboard/index.tsx' → 'dashboard'
    """
    norm = rel_path.replace("\\", "/").lower()
    parts = [p for p in norm.split("/") if p]

    # Find the segment after pages/views/screens/app
    for i, part in enumerate(parts):
        if part in ("pages", "views", "screens", "app") and i + 1 < len(parts):
            next_part = parts[i + 1]
            # Strip file extension
            next_part = re.sub(r'\.(tsx?|jsx?|vue|svelte|py)$', '', next_part)
            if next_part and next_part not in ("index", "page", "_app", "_document"):
                return next_part

    return ""


def _aggregate_child_components(
    nodes: dict[str, dict],
    file_data: dict[str, dict],
    file_to_routes: dict[str, list[str]],
) -> None:
    """Walk each page node's import tree and merge child component elements.

    Modifies nodes in-place. Only page nodes are enriched — component nodes
    are left as-is since they serve as the source, not the aggregation target.

    Elements are sorted by relevance before capping:
    1. Same-domain components (e.g., notebook/* for /notebook page) come first
    2. Strong selectors (data-testid, id) beat weak ones (bare tag names)
    3. Shared layout elements (LeftPanel, common/) are deprioritised
    """
    known_files = set(file_data.keys())

    for route, node in nodes.items():
        if node.get("type") != "page":
            continue

        rel_path = node.get("file", "")
        if not rel_path or rel_path not in file_data:
            continue

        child_els, child_forms, child_modals, child_text = _collect_descendant_elements(
            rel_path, file_data, known_files,
        )

        if not child_els and not child_forms and not child_modals:
            continue

        # Merge elements — dedup by selector
        existing_selectors = {e["selector"] for e in node.get("elements", [])}
        for el in child_els:
            if el["selector"] not in existing_selectors:
                existing_selectors.add(el["selector"])
                node["elements"].append(el)

        # Merge forms
        existing_form_fields = {
            tuple(f.get("fields", [])) for f in node.get("forms", [])
        }
        for form in child_forms:
            form_key = tuple(form.get("fields", []))
            if form_key not in existing_form_fields:
                existing_form_fields.add(form_key)
                node["forms"].append(form)

        # Merge modals
        existing_modal_ids = {m.get("modal_id") for m in node.get("modals", [])}
        for modal in child_modals:
            if modal.get("modal_id") not in existing_modal_ids:
                existing_modal_ids.add(modal.get("modal_id"))
                node["modals"].append(modal)

        # Merge text content (dedup)
        existing_text = set(node.get("text_content", []))
        for txt in child_text:
            if txt not in existing_text:
                existing_text.add(txt)
                node["text_content"].append(txt)

        # Track which component files contributed to this page
        contributing_files = sorted({
            el.get("source_file", "") for el in child_els if el.get("source_file")
        })
        if contributing_files:
            node["child_components"] = contributing_files

        # Sort elements by relevance: same-domain + strong selectors first
        page_domain = _page_domain_hint(rel_path)
        node["elements"].sort(
            key=lambda el: _element_relevance_key(el, page_domain),
        )

        # Regenerate description now that we have richer element data
        data = file_data.get(rel_path)
        if data:
            node["description"] = _generate_file_description(
                rel_path, data["summary"], node["elements"],
                node["forms"], node["modals"], data["nav"],
            )

    # Cap element lists to prevent codemap bloat.
    # Use a higher cap now that elements are relevance-sorted (important ones first).
    _MAX_ELEMENTS_PER_PAGE = 120
    for route, node in nodes.items():
        if len(node.get("elements", [])) > _MAX_ELEMENTS_PER_PAGE:
            node["elements"] = node["elements"][:_MAX_ELEMENTS_PER_PAGE]


def _build_graph(
    route_map: dict[str, str],
    file_data: dict[str, dict],
) -> tuple[dict, list]:
    """Build nodes dict and edges list."""
    nodes: dict[str, dict] = {}
    edges: list[dict] = []
    seen_edges: set[tuple] = set()

    # Reverse map: file → list of routes it implements
    file_to_routes: dict[str, list[str]] = {}
    for route, file_rel in route_map.items():
        file_to_routes.setdefault(file_rel, []).append(route)

    def _current_routes_for_file(rel_path: str) -> list[str]:
        return file_to_routes.get(rel_path, [])

    # Build nodes
    for rel_path, data in file_data.items():
        summary = data["summary"]
        file_routes = _current_routes_for_file(rel_path)
        comp_type = summary.get("t", "component")

        # Build elements list from two sources:
        # 1) Selector attributes (data-testid, id, name, etc.) from file_summarizer
        elements = []
        seen_selectors: set[str] = set()
        for sel_pair in summary.get("sel", []):
            if len(sel_pair) >= 2:
                sel = _make_selector(sel_pair)
                if sel not in seen_selectors:
                    seen_selectors.add(sel)
                    elements.append({
                        "selector": sel,
                        "kind": _classify_element(sel_pair, summary.get("txt", [])),
                        "label": sel_pair[1],
                    })

        # 2) JSX interactive elements (buttons, inputs, links, selects, textareas)
        #    from raw source — catches elements that lack data-testid/id/name
        try:
            raw_content = Path(data["fpath"]).read_text(encoding="utf-8", errors="replace")
            jsx_elements = _extract_jsx_elements(raw_content)
            for jsx_el in jsx_elements:
                if jsx_el["selector"] not in seen_selectors:
                    seen_selectors.add(jsx_el["selector"])
                    elements.append(jsx_el)
        except Exception:
            pass

        # Detect forms: group input selectors
        form_inputs = [e for e in elements if e.get("kind") == "input"]
        form_buttons = [e for e in elements if e.get("kind") == "button"]
        forms = []
        if form_inputs:
            forms.append({
                "fields": [e["selector"] for e in form_inputs],
                "submit": _pick_submit_button(form_buttons),
            })

        # Detect modals from nav entries
        modals = [
            {"trigger_selector": e.selector_hint, "modal_id": e.fn}
            for e in data["nav"]
            if e.mechanism in ("modal", "state") and e.fn
        ]

        # Generate file description for downstream context
        description = _generate_file_description(
            rel_path, summary, elements, forms, modals, data["nav"],
        )

        # Determine the primary node key
        if file_routes:
            primary_route = file_routes[0]
        else:
            # Fallback: use derived path or file path
            derived = _derive_route_from_path(rel_path, "unknown")
            primary_route = derived if derived else ("component:" + rel_path)

        if primary_route not in nodes:
            nodes[primary_route] = {
                "type": comp_type,
                "file": rel_path,
                "description": description,
                "dynamic": bool(re.search(r'[\[:]', primary_route)),
                "auth_required": False,  # updated by auth detection pass
                "directly_accessible": True,  # updated by edge analysis
                "elements": elements,
                "forms": forms,
                "modals": modals,
                "text_content": summary.get("txt", []),
            }

        # Add alias nodes for additional routes
        for extra_route in file_routes[1:]:
            if extra_route not in nodes:
                nodes[extra_route] = {**nodes[primary_route], "file": rel_path}

    # ------------------------------------------------------------------
    # Import-chain aggregation: walk each page's import tree and merge
    # elements/forms/modals/text from child components into the page node.
    # This ensures a page like /notebook that imports TestManagementModal
    # → BasicInformationSection gets all their selectors aggregated.
    # ------------------------------------------------------------------
    _aggregate_child_components(nodes, file_data, file_to_routes)

    # Build edges from navigation entries
    for rel_path, data in file_data.items():
        file_routes = _current_routes_for_file(rel_path)
        from_route = file_routes[0] if file_routes else ("component:" + rel_path)

        for nav_entry in data["nav"]:
            if nav_entry.mechanism in ("modal", "state"):
                dest_key = f"modal:{nav_entry.fn}"
                edge_key = (from_route, dest_key, nav_entry.mechanism, nav_entry.condition)
                if edge_key not in seen_edges:
                    seen_edges.add(edge_key)
                    edges.append({
                        "from": from_route,
                        "to": dest_key,
                        "mechanism": nav_entry.mechanism,
                        "condition": nav_entry.condition,
                        "trigger": nav_entry.trigger,
                        "selector_hint": nav_entry.selector_hint,
                    })
            elif nav_entry.mechanism == "tab":
                dest_key = nav_entry.to or ""
                edge_key = (from_route, dest_key, "external_tab", nav_entry.condition)
                if edge_key not in seen_edges and dest_key:
                    seen_edges.add(edge_key)
                    edges.append({
                        "from": from_route,
                        "to": dest_key,
                        "mechanism": "external_tab",
                        "condition": nav_entry.condition,
                        "trigger": nav_entry.trigger,
                        "selector_hint": nav_entry.selector_hint,
                    })
            elif nav_entry.to and nav_entry.to.startswith("/"):
                dest_key = nav_entry.to
                mech = nav_entry.mechanism
                edge_key = (from_route, dest_key, mech, nav_entry.condition)
                if edge_key not in seen_edges and dest_key != from_route:
                    seen_edges.add(edge_key)
                    edges.append({
                        "from": from_route,
                        "to": dest_key,
                        "mechanism": mech,
                        "condition": nav_entry.condition,
                        "trigger": nav_entry.trigger,
                        "selector_hint": nav_entry.selector_hint,
                    })
                    # Mark destination as not directly accessible if it's a post-action destination
                    if dest_key in nodes and nav_entry.trigger in ("on_success", "on_submit"):
                        nodes[dest_key]["directly_accessible"] = False

    return nodes, edges


# ---------------------------------------------------------------------------
# Auth detection
# ---------------------------------------------------------------------------

_AUTH_ROUTE_RE = re.compile(
    r'(login|signin|sign-in|sign_in|auth/|authenticate)',
    re.IGNORECASE,
)
_PROTECTED_SIGNAL_RE = re.compile(
    r'useAuth|useSession|useUser|isAuthenticated|isLoggedIn|requireAuth|'
    r'PrivateRoute|AuthGuard|withAuth|getServerSession|getSession|'
    r'auth\.protect|protectedRoute',
    re.IGNORECASE,
)
_EMAIL_SEL_RE = re.compile(r'\[(?:type=["\']email["\']|name=["\']email["\']|id=["\']email["\'])\]')
_PASSWORD_SEL_RE = re.compile(r'\[type=["\']password["\']|\[name=["\']password["\']')

# Submit button selection heuristics
_SUBMIT_TEXT_RE = re.compile(
    r'\b(sign[\s\-]?in|log[\s\-]?in|submit|continue|next|get[\s\-]?started|create[\s\-]?account)\b',
    re.IGNORECASE,
)
_OAUTH_EXCLUDE_RE = re.compile(
    r'google|github|facebook|twitter|apple|microsoft|oauth|sso|social',
    re.IGNORECASE,
)


def _pick_submit_button(buttons: list[dict]) -> str | None:
    """Pick the most likely form submit button from a list.

    Priority:
    1. button with type="submit" selector
    2. button whose label matches submit-text heuristics (not OAuth)
    3. any button that is not an OAuth/social button
    4. first button (last resort)
    """
    if not buttons:
        return None

    # Priority 1: explicit type=submit
    for b in buttons:
        sel = b.get("selector", "")
        if '[type="submit"]' in sel or "[type='submit']" in sel or 'type="submit"' in sel:
            return sel

    # Priority 2: submit-text heuristic, non-OAuth
    for b in buttons:
        label = b.get("label", "") or ""
        sel = b.get("selector", "")
        if _SUBMIT_TEXT_RE.search(label) and not _OAUTH_EXCLUDE_RE.search(label) and not _OAUTH_EXCLUDE_RE.search(sel):
            return sel

    # Priority 3: any non-OAuth button
    for b in buttons:
        label = b.get("label", "") or ""
        sel = b.get("selector", "")
        if not _OAUTH_EXCLUDE_RE.search(label) and not _OAUTH_EXCLUDE_RE.search(sel):
            return sel

    # Fallback: first button
    return buttons[0].get("selector") if buttons else None


def _detect_auth(
    nodes: dict[str, dict],
    file_data: dict[str, dict],
) -> dict:
    """Detect auth configuration from the scanned files."""
    login_route: str | None = None
    email_selector: str | None = None
    password_selector: str | None = None
    submit_selector: str | None = None
    protected_routes: list[str] = []

    def _extract_login_selectors(node: dict) -> tuple[str | None, str | None, str | None]:
        """Extract email, password, submit selectors from a login page node."""
        _email = _password = _submit = None
        elements = node.get("elements", [])
        buttons = [e for e in elements if e.get("kind") == "button"]
        for el in elements:
            sel = el.get("selector", "")
            if not _email and (
                '[type="email"]' in sel or "[type='email']" in sel
                or 'type="email"' in sel or "email" in sel.lower()
            ):
                _email = sel
            if not _password and (
                '[type="password"]' in sel or "[type='password']" in sel
                or 'type="password"' in sel or "password" in sel.lower()
            ):
                _password = sel
        _submit = _pick_submit_button(buttons)
        # Normalize email selector to standard format when possible
        if _email and "email" in _email.lower() and "[type=" not in _email:
            _email = '[type="email"]'
        if _password and "password" in _password.lower() and "[type=" not in _password:
            _password = '[type="password"]'
        return _email, _password, _submit

    # Pass 1: prefer actual page routes (not component: prefix)
    for route, node in nodes.items():
        if route.startswith("component:") or route.startswith("modal:"):
            continue
        if _AUTH_ROUTE_RE.search(route):
            login_route = route
            email_selector, password_selector, submit_selector = _extract_login_selectors(node)
            break

    # Pass 2: fallback to component nodes if no page route found
    if not login_route:
        for route, node in nodes.items():
            if _AUTH_ROUTE_RE.search(route):
                login_route = route
                email_selector, password_selector, submit_selector = _extract_login_selectors(node)
                break

    # Identify protected routes by auth usage patterns.
    # Only add REAL page routes (start with "/", not "/api/", not component: prefix).
    for rel_path, data in file_data.items():
        try:
            content = Path(data["fpath"]).read_text(encoding="utf-8", errors="replace")
        except Exception:
            continue

        if _PROTECTED_SIGNAL_RE.search(content):
            for route, node in nodes.items():
                if (
                    node.get("file") == rel_path
                    and route != login_route
                    and route.startswith("/")
                    and not route.startswith("/api/")
                    and not route.startswith("component:")
                    and not route.startswith("modal:")
                ):
                    if route not in protected_routes:
                        protected_routes.append(route)
                    node["auth_required"] = True
                    node["directly_accessible"] = False

    has_auth = bool(login_route or protected_routes)

    return {
        "has_auth": has_auth,
        "login_route": login_route,
        "email_selector": email_selector,
        "password_selector": password_selector,
        "submit_selector": submit_selector,
        "protected_routes": protected_routes,
    }


# ---------------------------------------------------------------------------
# Navigation tree builder (generate_tests-compatible)
# ---------------------------------------------------------------------------

def _build_nav_tree(
    nodes: dict[str, dict],
    edges: list[dict],
    auth: dict,
) -> dict:
    """Build generate_tests-compatible navigation_tree from nodes + edges."""
    nav_tree: dict[str, dict] = {}

    # Build component-file → page-route resolver.
    # When an edge's "from" is "component:some/file.js" we resolve it to the
    # parent page route so the LLM gets an actionable page path, not a file ref.
    comp_to_page: dict[str, str] = {}
    for route, node in nodes.items():
        if route.startswith("component:") or route.startswith("modal:"):
            continue
        file_rel = node.get("file", "")
        if file_rel:
            comp_key = "component:" + file_rel
            if comp_key not in comp_to_page:
                comp_to_page[comp_key] = route

    def _resolve_to_page(from_val: str) -> str:
        """Resolve a component: path to its parent page route."""
        if from_val.startswith("component:"):
            return comp_to_page.get(from_val, from_val)
        return from_val

    # Initialize every page node
    for route, node in nodes.items():
        if route.startswith("component:") or route.startswith("modal:"):
            continue
        entry: dict = {
            "auth_required": node.get("auth_required", False),
        }
        if node.get("directly_accessible", True) and not node.get("auth_required", False):
            entry["directly_accessible"] = True
        nav_tree[route] = entry

    # Enrich with edge information
    # Collect all incoming edges per destination
    incoming: dict[str, list[dict]] = {}
    for edge in edges:
        dest = edge["to"]
        if not dest.startswith("/"):
            continue
        incoming.setdefault(dest, []).append(edge)

    for dest, inc_edges in incoming.items():
        if dest not in nav_tree:
            nav_tree[dest] = {"auth_required": False}

        # Find the primary unconditional incoming edge
        unconditional = [e for e in inc_edges if not e.get("condition")]
        conditional = [e for e in inc_edges if e.get("condition")]

        if unconditional:
            primary = unconditional[0]
            resolved_from = _resolve_to_page(primary["from"])
            nav_tree[dest]["reachable_via"] = resolved_from
            nav_tree[dest]["action"] = _describe_action(primary)
            # Expose selector_hint so the LLM knows which element to click
            if primary.get("selector_hint"):
                nav_tree[dest]["selector_hint"] = primary["selector_hint"]

        if conditional:
            nav_tree[dest]["conditional_paths"] = [
                {
                    "from": _resolve_to_page(e["from"]),
                    "condition": e["condition"],
                    "trigger": e.get("trigger"),
                    "selector_hint": e.get("selector_hint"),
                }
                for e in conditional
            ]

        # Remove directly_accessible if reachable_via is set
        if "reachable_via" in nav_tree[dest]:
            nav_tree[dest].pop("directly_accessible", None)

    # Mark login page as entry point
    if auth.get("login_route") and auth["login_route"] in nav_tree:
        nav_tree[auth["login_route"]]["directly_accessible"] = True
        nav_tree[auth["login_route"]]["auth_required"] = False

    return nav_tree


def _describe_action(edge: dict) -> str:
    trigger = edge.get("trigger") or ""
    sel = edge.get("selector_hint")
    mech = edge.get("mechanism", "route")

    if trigger == "on_submit" and sel:
        return f"submit form ({sel})"
    if trigger == "on_success":
        return "redirect on success"
    if trigger == "on_click" and sel:
        return f"click {sel}"
    if mech == "redirect":
        return "guard redirect"
    if sel:
        return f"interact with {sel}"
    return "navigate"
