"""Coverage CLI commands: ui and api coverage reporting."""

import asyncio
import os
import re
import sys
from pathlib import Path

import click

from ._core import coverage
from ._utils import (
    _spinner,
    _print_csv,
    _print_json,
    _repo_config_dir,
    _resolve_structured_output_format,
    _write_output_file,
    _json_text,
    _csv_text,
)

# ---------------------------------------------------------------------------
# Area discovery — shared helpers
# ---------------------------------------------------------------------------

# Dirs whose entire subtree is always skipped
_SKIP_TREES: frozenset[str] = frozenset({
    "node_modules", "dist", "build", ".next", ".nuxt", ".output",
    "coverage", "vendor", ".git", ".github", "__pycache__",
    ".cache", ".turbo", ".vercel", ".svelte-kit",
})

# Dirs that are structural containers — don't use their name as an area label,
# but DO recurse into their children.
_STRUCTURAL_DIRS: frozenset[str] = frozenset({
    "components", "pages", "app", "features", "modules", "screens",
    "views", "containers", "layouts", "templates", "sections",
    "frontend", "backend", "client", "server", "src",
})

# Dirs whose name is too generic to be a meaningful UI area label
_GENERIC_DIRS: frozenset[str] = frozenset({
    "common", "shared", "utils", "helpers", "hooks", "lib", "libs",
    "data", "assets", "static", "public", "styles", "css", "fonts",
    "images", "icons", "constants", "types", "typings", "config", "configs",
    "contexts", "hoc", "providers", "store", "stores", "redux",
    "actions", "reducers", "selectors", "middleware", "services",
    "mocks", "fixtures", "__tests__", "specs", "spec",
    "ui", "base", "core", "internal", "error", "errors",
})

# Page file stems to skip (framework reserved, not real UI areas)
_SKIP_PAGE_STEMS: frozenset[str] = frozenset({
    "index", "404", "500", "_app", "_document", "_error", "layout", "loading",
    "not-found", "error", "global-error", "template",
})


def _camel_to_words(name: str) -> str:
    """Split CamelCase / camelCase into words: 'ApiFlow' → 'Api Flow'."""
    s = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", name)
    s = re.sub(r"(?<=[A-Z])(?=[A-Z][a-z])", " ", s)
    return s.strip()


def _dir_to_label(d: Path) -> str:
    """Convert a directory or file stem to a human-readable area label."""
    raw = _camel_to_words(d.name if d.is_dir() else d.stem)
    return raw.replace("-", " ").replace("_", " ").title()


def _normalize(text: str) -> str:
    """Normalize for fuzzy matching: lowercase, CamelCase split, strip punctuation."""
    text = _camel_to_words(text)
    text = text.replace("-", " ").replace("_", " ")
    text = re.sub(r"[^a-z0-9 ]", "", text.lower())
    return re.sub(r"\s+", " ", text).strip()


# Stopwords excluded from token-overlap matching
_STOPWORDS: frozenset[str] = frozenset({
    "the", "a", "an", "and", "or", "of", "in", "on", "at", "to", "for",
    "is", "are", "be", "was", "were", "test", "tests", "testing", "page",
    "view", "screen", "component", "feature", "module",
})

# ---------------------------------------------------------------------------
# coverage api — route extraction helpers
# ---------------------------------------------------------------------------

# Route definition patterns: (compiled_re, framework_tag)
_ROUTE_PATTERNS: list[tuple[re.Pattern, str]] = [
    # Express/Koa/Fastify: router.get('/path', ...)
    (re.compile(
        r'(?:router|app|server)\s*\.\s*(get|post|put|patch|delete|head|options)'
        r'\s*\(\s*[\'"`]([^\'"`\s]+)[\'"`]', re.IGNORECASE), "express"),
    # FastAPI/Starlette: @router.get('/path')
    (re.compile(
        r'@\s*(?:router|app)\s*\.\s*(get|post|put|patch|delete|head|options)'
        r'\s*\(\s*[\'"]([^\'"]+)[\'"]', re.IGNORECASE), "fastapi"),
    # Flask: @app.route('/path', methods=['GET'])
    (re.compile(
        r'@\s*(?:\w+\.)*route\s*\(\s*[\'"]([^\'"]+)[\'"]'
        r'(?:[^)]*methods\s*=\s*\[([^\]]+)\])?', re.IGNORECASE), "flask"),
    # Django: path('endpoint/', view) / re_path(r'^endpoint/')
    (re.compile(
        r'(?:^|\s)(?:path|re_path|url)\s*\(\s*r?[\'"]([^\'"]+)[\'"]',
        re.IGNORECASE | re.MULTILINE), "django"),
    # Spring: @GetMapping('/path'), @PostMapping('/path')
    (re.compile(
        r'@(Get|Post|Put|Patch|Delete|Head|Options)Mapping\s*\(\s*'
        r'(?:value\s*=\s*)?[\'"]([^\'"]+)[\'"]', re.IGNORECASE), "spring"),
]

# Normalizes all URL parameter syntaxes to {param}
_PARAM_RE = re.compile(r':\w+|\{[\w_]+\}|<\w+(?::\w+)?>|<[\w_]+>')

# Strips Django regex metacharacters
_DJANGO_CLEAN_RE = re.compile(
    r'\(\?P<\w+>[^)]+\)|\(\?:[^)]+\)|[\^\$\\]|\([^)]+\)'
)

# Next.js App Router exported HTTP method handlers
_NEXTJS_METHOD_RE = re.compile(
    r'export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b',
)

# Next.js dynamic segment: [param] or [...param] or [[...param]]
_NEXTJS_DYN_SEG_RE = re.compile(r'\[\[?\.\.\.([\w]+)\]\]?|\[([\w]+)\]')


def _area_matches_functionality(area: str, functionality: str) -> bool:
    """
    Return True if a UI area label matches a testcase_functionality string.

    Uses a tiered strategy:
      1. Exact or substring match after normalization.
      2. At least one meaningful token in common.
      3. SequenceMatcher fuzzy ratio ≥ 0.55.
    """
    import difflib

    a_norm = _normalize(area)
    f_norm = _normalize(functionality)
    if not a_norm or not f_norm:
        return False

    # Tier 1 — substring
    if a_norm in f_norm or f_norm in a_norm:
        return True

    # Tier 2 — meaningful token overlap
    a_tokens = set(a_norm.split()) - _STOPWORDS
    f_tokens = set(f_norm.split()) - _STOPWORDS
    if a_tokens and f_tokens and (a_tokens & f_tokens):
        return True

    # Tier 3 — fuzzy similarity
    return difflib.SequenceMatcher(None, a_norm, f_norm).ratio() >= 0.55


def _area_tokens(area: str) -> set[str]:
    """Tokenize an area label for component matching."""
    tokens = set(_normalize(area).split()) - _STOPWORDS
    return {t for t in tokens if len(t) >= 3}


def _extract_component_tokens(comp: dict) -> set[str]:
    """Extract searchable tokens from a component dict for coverage matching."""
    import re
    from urllib.parse import urlparse

    toks: set[str] = set()

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

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

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

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

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

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

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

    return toks


def _discover_ui_areas(scan_root: Path) -> list[str]:
    """
    Discover all UI feature areas from a frontend codebase using multiple
    strategies:

    * Pages Router  — pages/**/*.{js,jsx,ts,tsx,vue}
    * App Router    — app/**/page.{js,jsx,ts,tsx} route segments
    * Components    — components/ immediate subdirs (depth 1) + meaningful
                      subdirs at depth 2 (e.g. LeftPanel/ApplicationDetails)
    * Feature dirs  — features/, modules/, screens/, views/, containers/
    * src/ mirror   — all of the above under src/

    Returns a deduplicated, human-readable list of area names.
    """
    areas: list[str] = []
    seen_norm: set[str] = set()

    def _add(label: str) -> None:
        n = _normalize(label)
        if n and len(n) > 1 and n not in seen_norm:
            seen_norm.add(n)
            areas.append(label)

    def _should_skip_tree(d: Path) -> bool:
        return d.name in _SKIP_TREES

    # --- Pages Router -------------------------------------------------------
    def _scan_pages(pages_root: Path) -> None:
        for p in sorted(pages_root.rglob("*")):
            if not p.is_file():
                continue
            if p.suffix not in {".js", ".jsx", ".ts", ".tsx", ".vue"}:
                continue
            # Skip files inside api/ subdirectory
            try:
                rel = p.relative_to(pages_root)
            except ValueError:
                continue
            if rel.parts and rel.parts[0] == "api":
                continue
            stem = p.stem
            if stem.startswith("_") or stem in _SKIP_PAGE_STEMS:
                continue
            _add(_dir_to_label(p))
            # Parent dirs (route segments) as areas
            for part in rel.parts[:-1]:
                if (
                    not part.startswith("_")
                    and part not in _SKIP_PAGE_STEMS
                    and part not in _GENERIC_DIRS
                    and part not in _STRUCTURAL_DIRS
                ):
                    _add(_camel_to_words(part).replace("-", " ").replace("_", " ").title())

    # --- App Router ---------------------------------------------------------
    def _scan_app_router(app_root: Path) -> None:
        for p in sorted(app_root.rglob("page.*")):
            if p.suffix not in {".js", ".jsx", ".ts", ".tsx"}:
                continue
            try:
                rel_parts = p.parent.relative_to(app_root).parts
            except ValueError:
                continue
            for part in rel_parts:
                if part.startswith("(") and part.endswith(")"):
                    continue  # route groups like (auth)
                if part.startswith("[") and part.endswith("]"):
                    part = part[1:-1]  # dynamic [slug] → slug
                if part and part not in _SKIP_PAGE_STEMS and part not in _GENERIC_DIRS:
                    _add(_camel_to_words(part).replace("-", " ").replace("_", " ").title())

    # --- Directory tree scanner (components, features, etc.) ----------------
    def _scan_dir_for_areas(base: Path, depth: int, max_depth: int) -> None:
        if depth > max_depth or not base.is_dir():
            return
        for d in sorted(base.iterdir()):
            if not d.is_dir():
                continue
            if d.name.startswith(".") or _should_skip_tree(d):
                continue
            name_lower = d.name.lower()
            if name_lower in _STRUCTURAL_DIRS:
                # Recurse but don't use as a label
                _scan_dir_for_areas(d, depth + 1, max_depth)
                continue
            if name_lower in _GENERIC_DIRS:
                # Too generic — skip label, skip children for labels too
                continue
            # Meaningful feature dir — add as area label
            _add(_dir_to_label(d))
            # Also scan children (e.g. LeftPanel → ApplicationDetails)
            _scan_dir_for_areas(d, depth + 1, max_depth)

    # --- Run all strategies -------------------------------------------------
    # Try both root and root/src
    roots_to_try = [scan_root, scan_root / "src"]
    for root in roots_to_try:
        if not root.is_dir():
            continue

        for pages_name in ("pages", "Pages"):
            pd = root / pages_name
            if pd.is_dir():
                _scan_pages(pd)

        for app_name in ("app", "App"):
            ad = root / app_name
            if ad.is_dir():
                _scan_app_router(ad)

        for comp_name in ("components", "Components"):
            cd = root / comp_name
            if cd.is_dir():
                _scan_dir_for_areas(cd, depth=1, max_depth=2)

        for feat_name in ("features", "modules", "screens", "views", "containers"):
            fd = root / feat_name
            if fd.is_dir():
                _scan_dir_for_areas(fd, depth=1, max_depth=2)

    # Fallback: scan root-level dirs if nothing was found yet
    if not areas:
        _scan_dir_for_areas(scan_root, depth=1, max_depth=1)

    return areas


# ---------------------------------------------------------------------------
# coverage ui
# ---------------------------------------------------------------------------


@coverage.command("ui")
@click.option("--source", "-s", default=None, help="UI source directory (default: cwd).")
@click.option("--csv", "as_csv", is_flag=True, default=False, help="Output as CSV.")
@click.option("--json", "-j", "as_json", is_flag=True, default=False, help="Output as JSON.")
@click.option("--output", "output_path", default=None, help="Write output to a .json or .csv file.")
def coverage_ui_cmd(source: str | None, as_csv: bool, as_json: bool, output_path: str | None) -> None:
    """Show UI test coverage grouped by functionality.

    \b
    Scans the source for pages and component feature areas, then maps them
    against testcase components (FLUs/components) from Maeris to show what is
    and isn't covered.

    \b
    Examples:
      maeris coverage ui
      maeris coverage ui --source ../my-frontend-app
    """
    output_format = _resolve_structured_output_format(as_json, as_csv, output_path)

    from maeris_mcp.auth.token_store import TokenStore

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

    async def _fetch():
        from maeris_mcp.maeris.client import MaerisClient
        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        async with MaerisClient() as client:
            testcases = await client.get_testcases()
            ids = [
                tc.get("testcase_id") or tc.get("id") or tc.get("_id")
                for tc in testcases
                if tc.get("testcase_id") or tc.get("id") or tc.get("_id")
            ]
            components = await client.get_components_for_testcases(ids) if ids else {}
            return testcases, components

    try:
        with _spinner("Fetching test data…"):

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

    # Build testcase → component token map
    tc_map = {
        m.get("testcase_id"): m.get("component_ids", [])
        for m in (component_data.get("testcase_component_maps", []) if isinstance(component_data, dict) else [])
    }
    comp_map = {
        c.get("component_id") or c.get("id"): c
        for c in (component_data.get("unique_components", []) if isinstance(component_data, dict) else [])
    }

    testcase_tokens: dict[str, set[str]] = {}
    for tc in testcases:
        tc_id = tc.get("testcase_id") or tc.get("id") or tc.get("_id") or ""
        if not tc_id:
            continue
        tokens: set[str] = set()
        for cid in tc_map.get(tc_id, []):
            comp = comp_map.get(cid)
            if comp:
                tokens |= _extract_component_tokens(comp)
        testcase_tokens[tc_id] = tokens

    scan_root = Path(source) if source else Path.cwd()
    ui_areas = _discover_ui_areas(scan_root)

    if not ui_areas:
        click.secho(
            "  ⚠  No UI source areas discovered. Try --source <path> pointing at your "
            "frontend root (should contain pages/ or components/).",
            fg="yellow",
        )

    # Match each source area to testcase component tokens
    area_rows: list[dict] = []

    for area in ui_areas:
        area_toks = _area_tokens(area)
        matched_test_names: list[str] = []
        for tc_id, toks in testcase_tokens.items():
            if area_toks and toks and (area_toks & toks):
                # Find testcase name by id
                tc = next((t for t in testcases if (t.get("testcase_id") or t.get("id") or t.get("_id")) == tc_id), None)
                name = (tc.get("name") or tc.get("testcase_name") or tc_id) if tc else tc_id
                matched_test_names.append(name)
        if matched_test_names:
            area_rows.append({
                "area": area,
                "test_names": matched_test_names,
                "tests": len(matched_test_names),
                "covered": True,
            })
        else:
            area_rows.append({
                "area": area,
                "test_names": [],
                "tests": 0,
                "covered": False,
            })

    covered = sum(1 for r in area_rows if r["covered"])
    total = len(area_rows)
    pct = round(covered / total * 100) if total else 0

    payload = {
        "areas": area_rows,
        "covered": covered,
        "total": total,
        "coverage_pct": pct,
    }

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

    if output_format == "csv":
        csv_rows = [
            {
                "row_type": "area",
                "area": row["area"],
                "test_names": ", ".join(row["test_names"]),
                "tests": row["tests"],
                "covered": row["covered"],
                "unknown_testcases": "",
                "covered_areas": "",
                "total_areas": "",
                "coverage_pct": "",
            }
            for row in area_rows
        ]
        csv_rows.append(
            {
                "row_type": "summary",
                "area": "",
                "test_names": "",
                "tests": "",
                "covered": "",
                "unknown_testcases": "",
                "covered_areas": covered,
                "total_areas": total,
                "coverage_pct": pct,
            }
        )
        columns = [
            "row_type",
            "area",
            "test_names",
            "tests",
            "covered",
            "unknown_testcases",
            "covered_areas",
            "total_areas",
            "coverage_pct",
        ]
        if output_path:
            written = _write_output_file(_csv_text(csv_rows, columns), output_path)
            click.echo(f"Wrote CSV to {written}")
        else:
            _print_csv(csv_rows, columns)
        return

    # ── Terminal output ──────────────────────────────────────────────────────
    click.echo(f"\n  UI Test Coverage — {scan_root.name}\n")

    col_w = max((len(r["area"]) for r in area_rows), default=20) + 2
    header = f"  {'Area':<{col_w}}  {'Test Names':<28}  {'Tests':>5}  Status"
    sep = "  " + "─" * (len(header) - 2)
    click.echo(header)
    click.echo(sep)

    # Covered areas first, then uncovered
    for r in sorted(area_rows, key=lambda x: (not x["covered"], x["area"])):
        if r["covered"]:
            status = click.style("✓", fg="green")
            names = r.get("test_names") or []
            names_label = ", ".join(names[:2])
            if len(names) > 2:
                names_label = f"{names_label} +{len(names) - 2} more"
            func_label = names_label[:27]
            tests_str = str(r["tests"])
        else:
            status = click.style("✗ no tests", fg="red")
            func_label = click.style("—", fg="bright_black")
            tests_str = click.style("—", fg="bright_black")
        click.echo(f"  {r['area']:<{col_w}}  {func_label:<28}  {tests_str:>5}  {status}")

    click.echo(sep)

    # Summary bar
    bar_filled = "█" * (pct // 5)
    bar_empty = "░" * (20 - pct // 5)
    click.echo(f"\n  Coverage: {covered}/{total} areas  [{bar_filled}{bar_empty}]  {pct}%")

    click.echo()


def _normalize_path(raw: str) -> str:
    """Normalize a URL or path string for comparison."""
    from urllib.parse import urlparse
    try:
        parsed = urlparse(raw)
        path = parsed.path if parsed.scheme else raw
    except Exception:
        path = raw
    path = _DJANGO_CLEAN_RE.sub("", path)
    path = _PARAM_RE.sub("{param}", path)
    path = path.lower()
    while "//" in path:
        path = path.replace("//", "/")
    path = path.rstrip("/")
    if not path.startswith("/"):
        path = "/" + path
    return path


def _extract_nextjs_routes(scan_root: Path) -> list[dict]:
    """Discover API routes from Next.js file-based routing conventions.

    Supports:
      - App Router:   app/**/route.{js,jsx,ts,tsx}  → reads exported methods
      - Pages Router: pages/api/**/*.{js,jsx,ts,tsx} → assumes all methods (*)
      - Also checks under src/ prefix for both patterns.
    """
    routes: list[dict] = []
    seen: set[tuple[str, str]] = set()
    route_exts = {".js", ".jsx", ".ts", ".tsx"}

    def _seg_to_path(rel: Path, strip_prefix: str) -> str:
        """Convert a relative filesystem path to a URL path.

        E.g. app/api/user/[email]/route.js → /api/user/{param}
        """
        parts = list(rel.parts)
        # Remove the prefix (e.g. "app") and filename (e.g. "route.js")
        parts = parts[1:]  # drop "app" or "pages"
        if parts and parts[-1].startswith("route."):
            parts = parts[:-1]  # App Router: drop route.js
        elif parts:
            # Pages Router: last part is the filename, use its stem
            stem = Path(parts[-1]).stem
            if stem != "index":
                parts[-1] = stem
            else:
                parts = parts[:-1]

        # Convert Next.js dynamic segments to {param}
        converted = []
        for p in parts:
            p = _NEXTJS_DYN_SEG_RE.sub("{param}", p)
            converted.append(p)

        return "/" + "/".join(converted) if converted else "/"

    def _add(method: str, path: str, fpath: str) -> None:
        key = (method, _normalize_path(path))
        if key not in seen:
            seen.add(key)
            routes.append({"method": method, "path": _normalize_path(path), "file": fpath})

    # Search for app/ and src/app/ directories
    app_dirs: list[tuple[Path, str]] = []
    pages_dirs: list[tuple[Path, str]] = []
    for prefix in [scan_root, scan_root / "src"]:
        app_candidate = prefix / "app"
        if app_candidate.is_dir():
            app_dirs.append((app_candidate, "app"))
        pages_candidate = prefix / "pages"
        if pages_candidate.is_dir():
            pages_dirs.append((pages_candidate, "pages"))

    # App Router: app/**/route.{js,jsx,ts,tsx}
    for app_dir, _ in app_dirs:
        for route_file in app_dir.rglob("route.*"):
            if route_file.suffix not in route_exts:
                continue
            if any(p in _SKIP_TREES for p in route_file.parts):
                continue
            try:
                content = route_file.read_text(encoding="utf-8", errors="ignore")
            except Exception:
                continue
            rel = route_file.relative_to(app_dir.parent)
            url_path = _seg_to_path(rel, "app")
            methods = _NEXTJS_METHOD_RE.findall(content)
            if methods:
                for m in methods:
                    _add(m.upper(), url_path, str(route_file))
            else:
                # Default export handler — assume all methods
                _add("*", url_path, str(route_file))

    # Pages Router: pages/api/**/*.{js,jsx,ts,tsx}
    for pages_dir, _ in pages_dirs:
        api_dir = pages_dir / "api"
        if not api_dir.is_dir():
            continue
        for api_file in api_dir.rglob("*"):
            if not api_file.is_file() or api_file.suffix not in route_exts:
                continue
            if any(p in _SKIP_TREES for p in api_file.parts):
                continue
            rel = api_file.relative_to(pages_dir.parent)
            url_path = _seg_to_path(rel, "pages")
            # Pages Router uses default export handler — method unknown
            _add("*", url_path, str(api_file))

    return routes


def _extract_routes_from_content(file_path: str, content: str) -> list[dict]:
    """Extract route definitions from file content using _ROUTE_PATTERNS."""
    routes: list[dict] = []
    seen: set[tuple[str, str]] = set()

    for pattern, framework in _ROUTE_PATTERNS:
        for m in pattern.finditer(content):
            if framework in ("express", "fastapi", "spring"):
                method = m.group(1).upper()
                raw_path = m.group(2)
            elif framework == "flask":
                raw_path = m.group(1)
                methods_str = m.group(2) if m.lastindex and m.lastindex >= 2 else None
                if methods_str:
                    methods = [x.strip().strip("'\"").upper() for x in methods_str.split(",")]
                else:
                    methods = ["GET"]
                for method in methods:
                    norm = _normalize_path(raw_path)
                    if norm.startswith("$") or norm.startswith("{") or "/" not in norm:
                        continue
                    key = (method, norm)
                    if key not in seen:
                        seen.add(key)
                        routes.append({"method": method, "path": norm, "file": file_path})
                continue
            elif framework == "django":
                raw_path = m.group(1)
                method = "*"
            else:
                continue

            norm = _normalize_path(raw_path)
            if norm.startswith("$") or norm.startswith("{") or "/" not in norm:
                continue
            key = (method, norm)
            if key not in seen:
                seen.add(key)
                routes.append({"method": method, "path": norm, "file": file_path})

    return routes


def _paths_match(repo_path: str, repo_method: str, maeris_url: str, maeris_method: str) -> bool:
    """Return True if a repo route matches a Maeris API entry."""
    rp = _normalize_path(repo_path)
    mp = _normalize_path(maeris_url)

    rm = repo_method.upper()
    mm = maeris_method.upper()
    if rm != "*" and mm != "*" and rm != mm:
        return False

    if rp == mp:
        return True

    # Jaccard overlap on non-{param} segments
    r_segs = [s for s in rp.split("/") if s and s != "{param}"]
    m_segs = [s for s in mp.split("/") if s and s != "{param}"]
    if not r_segs or not m_segs:
        return False
    r_set = set(r_segs)
    m_set = set(m_segs)
    union = r_set | m_set
    if not union:
        return False
    jaccard = len(r_set & m_set) / len(union)
    return jaccard >= 0.6


# ---------------------------------------------------------------------------
# coverage api
# ---------------------------------------------------------------------------


@coverage.command("api")
@click.option("--source", "-s", default=None, help="Repo root to scan (default: cwd).")
@click.option("--csv", "as_csv", is_flag=True, default=False, help="Output as CSV.")
@click.option("--json", "-j", "as_json", is_flag=True, default=False, help="Output as JSON.")
@click.option("--output", "output_path", default=None, help="Write output to a .json or .csv file.")
def coverage_api_cmd(source: str | None, as_csv: bool, as_json: bool, output_path: str | None) -> None:
    """Show API flow coverage by scanning the repo for route definitions.

    \b
    Scans the source repo for real API route definitions, then reports which
    endpoints are registered in Maeris and which are included in a flow.

    \b
    Coverage = endpoint is in Maeris AND is part of at least one flow.

    \b
    Examples:
      maeris coverage api
      maeris coverage api --source ../my-backend-app
    """
    output_format = _resolve_structured_output_format(as_json, as_csv, output_path)

    from maeris_mcp.auth.token_store import TokenStore

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

    # ── Step 1: Scan repo for route definitions ──────────────────────────────
    from maeris_mcp.scanners.code_reader import collect_scan_files, get_file_contents

    scan_root = Path(source) if source else Path.cwd()
    click.echo(f"Scanning {scan_root} for route definitions…")
    abs_paths, _ = collect_scan_files(str(scan_root), api_signals_only=True)
    file_contents = get_file_contents(abs_paths)

    # Extract and deduplicate routes globally
    all_routes: list[dict] = []
    seen_global: set[tuple[str, str]] = set()
    for fpath, content in file_contents.items():
        for route in _extract_routes_from_content(fpath, content):
            key = (route["method"], route["path"])
            if key not in seen_global:
                seen_global.add(key)
                all_routes.append(route)

    if not all_routes:
        click.secho(
            "  ⚠  No route definitions found. Try --source <path> pointing at your "
            "backend root (should contain route/controller files).",
            fg="yellow",
        )
        sys.exit(0)

    files_scanned = len(abs_paths)

    # ── Step 2: Fetch Maeris data ────────────────────────────────────────────
    async def _fetch():
        from maeris_mcp.maeris.client import MaerisClient
        os.environ["MAERIS_CONFIG_DIR"] = str(config_dir)
        async with MaerisClient() as client:
            apis, flows = await asyncio.gather(
                client.get_all_apis(),
                client.get_api_flows(),
            )
            flow_id_name: dict[str, str] = {
                (f.get("flow_id") or f.get("id") or ""): (f.get("name") or "")
                for f in flows
                if f.get("flow_id") or f.get("id")
            }

            async def _safe_map(fid: str):
                try:
                    fmap = await client.get_api_flow_map(fid)
                    return fid, fmap if isinstance(fmap, list) else fmap.get("map", [])
                except Exception:
                    return fid, []

            flow_maps = await asyncio.gather(*[_safe_map(fid) for fid in flow_id_name])
            api_to_flows: dict[str, list[str]] = {}
            for fid, items in flow_maps:
                fname = flow_id_name[fid]
                for item in items:
                    aid = str(
                        item.get("child_id") or item.get("api_id") or item.get("id") or ""
                    )
                    if aid:
                        api_to_flows.setdefault(aid, []).append(fname)

            return apis, flow_id_name, api_to_flows

    click.echo("Fetching Maeris data…")
    try:
        with _spinner("Fetching Maeris data…"):

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

    # ── Step 3: Match repo routes against Maeris APIs ────────────────────────
    rows: list[dict] = []
    not_in_maeris_count = 0

    for route in all_routes:
        r_path = route["path"]
        r_method = route["method"]
        r_file = route["file"]

        matched_api: dict | None = None
        for api in maeris_apis:
            m_url = api.get("url") or ""
            m_method = (api.get("type") or api.get("method") or "*").upper()
            if _paths_match(r_path, r_method, m_url, m_method):
                matched_api = api
                break

        if matched_api is None:
            not_in_maeris_count += 1
            rows.append({
                "endpoint": r_path,
                "method": r_method,
                "file": r_file,
                "in_maeris": False,
                "maeris_api_name": None,
                "maeris_api_id": None,
                "in_flow": False,
                "flows": [],
            })
        else:
            aid = str(
                matched_api.get("api_id") or matched_api.get("id") or matched_api.get("_id") or ""
            )
            api_name = matched_api.get("name") or matched_api.get("api_name") or aid
            flow_names = api_to_flows.get(aid, [])
            rows.append({
                "endpoint": r_path,
                "method": r_method,
                "file": r_file,
                "in_maeris": True,
                "maeris_api_name": api_name,
                "maeris_api_id": aid,
                "in_flow": bool(flow_names),
                "flows": flow_names,
            })

    in_maeris_count = sum(1 for r in rows if r["in_maeris"])
    in_flow_count = sum(1 for r in rows if r["in_flow"])
    in_maeris_not_in_flow = in_maeris_count - in_flow_count
    total = len(rows)
    coverage_pct = round(in_flow_count / total * 100) if total else 0

    # ── Step 4: Output ───────────────────────────────────────────────────────
    payload = {
        "scan_root": str(scan_root),
        "files_scanned": files_scanned,
        "endpoints": rows,
        "summary": {
            "total": total,
            "in_maeris": in_maeris_count,
            "in_flow": in_flow_count,
            "not_in_maeris": not_in_maeris_count,
            "in_maeris_not_in_flow": in_maeris_not_in_flow,
            "coverage_pct": coverage_pct,
        },
    }

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

    if output_format == "csv":
        csv_rows = [
            {
                "row_type": "endpoint",
                "endpoint": row["endpoint"],
                "method": row["method"],
                "file": row["file"],
                "in_maeris": row["in_maeris"],
                "maeris_api_name": row["maeris_api_name"] or "",
                "maeris_api_id": row["maeris_api_id"] or "",
                "in_flow": row["in_flow"],
                "flows": ", ".join(row["flows"]),
                "files_scanned": "",
                "total_endpoints": "",
                "in_maeris_count": "",
                "in_flow_count": "",
                "not_in_maeris_count": "",
                "in_maeris_not_in_flow": "",
                "coverage_pct": "",
            }
            for row in rows
        ]
        csv_rows.append(
            {
                "row_type": "summary",
                "endpoint": "",
                "method": "",
                "file": "",
                "in_maeris": "",
                "maeris_api_name": "",
                "maeris_api_id": "",
                "in_flow": "",
                "flows": "",
                "files_scanned": files_scanned,
                "total_endpoints": total,
                "in_maeris_count": in_maeris_count,
                "in_flow_count": in_flow_count,
                "not_in_maeris_count": not_in_maeris_count,
                "in_maeris_not_in_flow": in_maeris_not_in_flow,
                "coverage_pct": coverage_pct,
            }
        )
        columns = [
            "row_type",
            "endpoint",
            "method",
            "file",
            "in_maeris",
            "maeris_api_name",
            "maeris_api_id",
            "in_flow",
            "flows",
            "files_scanned",
            "total_endpoints",
            "in_maeris_count",
            "in_flow_count",
            "not_in_maeris_count",
            "in_maeris_not_in_flow",
            "coverage_pct",
        ]
        if output_path:
            written = _write_output_file(_csv_text(csv_rows, columns), output_path)
            click.echo(f"Wrote CSV to {written}")
        else:
            _print_csv(csv_rows, columns)
        return

    # Terminal output
    click.echo(f"\n  API Flow Coverage — {scan_root.name}  ({files_scanned} files scanned)\n")

    ep_w = max((len(r["endpoint"]) for r in rows), default=24) + 2
    ep_w = min(ep_w, 40)
    header = (
        f"  {'Endpoint':<{ep_w}}  {'Method':<8}  {'In Maeris':>10}  {'In Flow':>8}  Flows"
    )
    sep = "  " + "─" * (len(header) - 2)
    click.echo(header)
    click.echo(sep)

    def _sort_key(r: dict) -> tuple:
        return (not r["in_flow"], not r["in_maeris"], r["endpoint"])

    for r in sorted(rows, key=_sort_key):
        ep = r["endpoint"][: ep_w - 1]
        mth = r["method"]

        in_m = click.style("yes", fg="green") if r["in_maeris"] else click.style("no", fg="red")
        if r["in_flow"]:
            in_f = click.style("yes", fg="cyan")
            flows_str = ", ".join(r["flows"])
        elif r["in_maeris"]:
            in_f = click.style("no", fg="yellow")
            flows_str = click.style("—", fg="bright_black")
        else:
            in_f = click.style("—", fg="bright_black")
            flows_str = click.style("—", fg="bright_black")

        click.echo(f"  {ep:<{ep_w}}  {mth:<8}  {in_m:>10}  {in_f:>8}  {flows_str}")

    click.echo(sep)

    bar_filled = "█" * (coverage_pct // 5)
    bar_empty = "░" * (20 - coverage_pct // 5)
    click.echo(
        f"\n  Coverage: {in_flow_count}/{total} endpoints in flows"
        f"  [{bar_filled}{bar_empty}]  {coverage_pct}%"
    )

    if not_in_maeris_count:
        click.secho(
            f"  ⚠  {not_in_maeris_count} endpoint(s) not registered in Maeris"
            f" — run setup_api_automation first",
            fg="yellow",
        )

    click.echo()
