"""LLM-guided API coverage analysis tool for MCP server."""

from __future__ import annotations

import asyncio
import json
import os
import re
from pathlib import Path
from typing import Any

from mcp.types import TextContent, Tool

from maeris_mcp.maeris.client import MaerisClient
from maeris_mcp.scanners.code_reader import collect_scan_files, get_file_contents
from maeris_mcp.storage.coverage_store import CoverageStore


# ---------------------------------------------------------------------------
# Fuzzy-matching helpers (reused from coverage_cmds.py)
# ---------------------------------------------------------------------------

_PARAM_RE = re.compile(r':\w+|\{[\w_]+\}|<\w+(?::\w+)?>|<[\w_]+>')
_DJANGO_CLEAN_RE = re.compile(
    r'\(\?P<\w+>[^)]+\)|\(\?:[^)]+\)|[\^\$\\]|\([^)]+\)'
)


def _normalize_path(raw: str) -> str:
    """Normalize a URL or path string for comparison.

    Handles full URLs, plain paths, and Maeris-style template URLs such as
    '{{BASE_URL}}/auth/login' produced by setup_api_automation.
    """
    from urllib.parse import urlparse

    # Strip {{VARIABLE}} placeholders before any parsing so that
    # '{{BASE_URL}}/auth/login' → '/auth/login', not '/{{param}}/auth/login'
    raw = re.sub(r"\{\{[\w_]+\}\}", "", raw)

    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 _paths_match(repo_path: str, repo_method: str, maeris_url: str, maeris_method: str) -> bool:
    """Return True if a derived 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

    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


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


def check_api_coverage_tool() -> Tool:
    """Return the tool definition for check_api_coverage."""
    return Tool(
        name="check_api_coverage",
        description=(
            "LLM-guided API coverage analysis. Scans a repo to derive real API flows from code, "
            "then compares them against flows already registered in Maeris.\n\n"
            "## Workflow\n\n"
            "### Phase 1 — Scanning (repeat until hasMore=false)\n"
            "1. Call with `target_path` (first call) or `session_id` (subsequent calls).\n"
            "2. Pass `include_content=true` to receive file batches.\n"
            "3. Analyze each file: extract HTTP endpoints into `derived_apis`.\n"
            "   As you scan, note which endpoints belong to the same user journey.\n"
            "4. Submit `derived_apis` alongside the next `offset` on each call.\n"
            "5. When `hasMore=false`, make one final call with `is_last=true`,\n"
            "   submitting your complete `derived_flows` (grouped journeys across all files).\n\n"
            "### Phase 2 — Report (automatic on is_last=true)\n"
            "The tool fetches existing Maeris APIs and flows, compares against your derived findings,\n"
            "and returns a full coverage report — no further action needed.\n\n"
            "## derived_apis schema\n"
            '{"name": str, "method": str, "endpoint": str, "description": str (optional)}\n\n'
            "## derived_flows schema\n"
            '{"name": str, "description": str, "steps": [{"api_name": str, "method": str, "endpoint": str}]}'
        ),
        inputSchema={
            "type": "object",
            "properties": {
                "target_path": {
                    "type": "string",
                    "description": "Repo root to scan (first call only).",
                },
                "session_id": {
                    "type": "string",
                    "description": "Continue existing session.",
                },
                "include_content": {
                    "type": "boolean",
                    "description": "Include file contents in the response. Default: false",
                    "default": False,
                },
                "offset": {
                    "type": "integer",
                    "description": "Skip first N files when including content. Default: 0",
                    "default": 0,
                },
                "limit": {
                    "type": "integer",
                    "description": "Max files to return content for (1-10). Default: 10",
                    "default": 10,
                },
                "derived_apis": {
                    "type": "array",
                    "items": {"type": "object"},
                    "description": "HTTP endpoints extracted from the current file batch.",
                },
                "derived_flows": {
                    "type": "array",
                    "items": {"type": "object"},
                    "description": "Logical user flows derived from the code.",
                },
                "is_last": {
                    "type": "boolean",
                    "description": "Set true on the final scanning call to trigger the coverage report.",
                },
            },
        },
    )


# ---------------------------------------------------------------------------
# Analysis instructions
# ---------------------------------------------------------------------------


def _expected_schema() -> dict:
    """JSON schema for derived_apis and derived_flows submitted by the LLM."""
    return {
        "derived_apis": {
            "type": "array",
            "description": "HTTP endpoints found in the codebase",
            "items": {
                "type": "object",
                "required": ["name", "method", "endpoint"],
                "properties": {
                    "name": {"type": "string", "description": "Human-readable endpoint name, e.g. 'Get User Profile'"},
                    "method": {
                        "type": "string",
                        "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
                    },
                    "endpoint": {"type": "string", "description": "Path only, e.g. /users/{id}"},
                    "description": {"type": "string", "description": "Optional: what the endpoint does"},
                },
            },
        },
        "derived_flows": {
            "type": "array",
            "description": "Logical user journeys (submit only on is_last=true call)",
            "items": {
                "type": "object",
                "required": ["name", "steps"],
                "properties": {
                    "name": {"type": "string", "description": "User-facing flow name, e.g. 'User Login'"},
                    "description": {"type": "string", "description": "Optional: what the flow achieves"},
                    "steps": {
                        "type": "array",
                        "description": "Ordered API calls in this flow",
                        "items": {
                            "type": "object",
                            "required": ["api_name", "method", "endpoint"],
                            "properties": {
                                "api_name": {"type": "string", "description": "Must match a name from derived_apis"},
                                "method": {"type": "string"},
                                "endpoint": {"type": "string"},
                            },
                        },
                    },
                },
            },
        },
    }


def _analysis_instructions() -> str:
    return (
        "check_api_coverage scanning instructions:\n"
        "1. Call with include_content=true and offset=0 to receive the first file batch.\n"
        "2. Analyze each file for:\n"
        "   a. All HTTP API endpoints (routes, controllers, handlers) → submit as derived_apis\n"
        "      Each: {name, method, endpoint, description (optional)}\n"
        "   b. Logical user flows — sequences of API calls that represent a user journey\n"
        "      (e.g. 'User Login' = [POST /auth/login, GET /users/me])\n"
        "      Group by feature area, not by file. Aim for 3-8 steps per flow.\n"
        "      Each: {name, description, steps: [{api_name, method, endpoint}]}\n"
        "      → submit as derived_flows\n"
        "3. Call again with derived_apis=[...] alongside the next offset.\n"
        "4. Repeat until hasMore=false.\n"
        "5. On the final call, submit all remaining derived_apis=[...], your complete\n"
        "   derived_flows=[...] (only accepted here, once you have the full picture),\n"
        "   and set is_last=true.\n"
        "   The tool will then compare against Maeris and return a full coverage report.\n"
        "Note: The MCP tool stores your submissions; Claude must perform the analysis."
    )


# ---------------------------------------------------------------------------
# Comparison logic
# ---------------------------------------------------------------------------


def _compare_flows(
    derived_flows: list[dict],
    derived_apis: list[dict],
    maeris_flow_steps: dict[str, list[str]],
    maeris_apis: list[dict],
    maeris_api_url: dict[str, str],
) -> dict[str, Any]:
    """Compare derived flows/APIs against Maeris data by endpoint overlap."""

    # Build Maeris api_id → (normalized_path, method) for fast lookup.
    # Field order matches coverage_cmds.py: api_id → id → _id.
    maeris_api_index: list[tuple[str, str, str]] = [
        (
            str(a.get("api_id") or a.get("id") or a.get("_id") or ""),
            _normalize_path(a.get("url") or ""),
            (a.get("type") or a.get("method") or "*").upper(),
        )
        for a in maeris_apis
        if (a.get("url") or "").strip()
    ]

    def _find_maeris_api_id(endpoint: str, method: str) -> str | None:
        """Return the Maeris api_id whose URL matches endpoint+method, or None."""
        for aid, m_path, m_method in maeris_api_index:
            if _paths_match(endpoint, method, m_path, m_method):
                return aid
        return None

    # Build Maeris flow → set of normalized endpoint paths
    # (used to match derived flows by content, not by name)
    maeris_flow_endpoints: dict[str, set[str]] = {}
    for fname, api_ids in maeris_flow_steps.items():
        eps: set[str] = set()
        for aid in api_ids:
            url = maeris_api_url.get(aid, "")
            if url:
                eps.add(_normalize_path(url))
        if eps:
            maeris_flow_endpoints[fname] = eps

    # Track which Maeris flows were matched (for maeris-only detection)
    matched_maeris_flow_names: set[str] = set()

    # Track derived flow step endpoints (for apis-not-in-any-flow)
    endpoints_in_derived_flows: set[str] = set()
    for flow in derived_flows:
        for step in flow.get("steps") or []:
            ep = (step.get("endpoint") or "").strip()
            if ep:
                endpoints_in_derived_flows.add(_normalize_path(ep))

    flows_report: list[dict] = []

    for derived_flow in derived_flows:
        flow_name = derived_flow.get("name") or ""
        steps = derived_flow.get("steps") or []

        # Collect this derived flow's normalized endpoint set
        derived_eps: set[str] = set()
        for step in steps:
            ep = (step.get("endpoint") or "").strip()
            if ep:
                derived_eps.add(_normalize_path(ep))

        # Match to the Maeris flow with the highest endpoint Jaccard overlap
        best_match: str | None = None
        best_score = 0.0
        for fname, m_eps in maeris_flow_endpoints.items():
            if not derived_eps or not m_eps:
                continue
            intersection = len(derived_eps & m_eps)
            union = len(derived_eps | m_eps)
            score = intersection / union if union else 0.0
            if score > best_score and score >= 0.4:
                best_score = score
                best_match = fname

        if best_match:
            matched_maeris_flow_names.add(best_match)

        # Per-step: check if the endpoint exists in Maeris and in the matched flow
        steps_report: list[dict] = []
        for step in steps:
            step_ep = (step.get("endpoint") or "").strip()
            step_method = (step.get("method") or "*").upper()

            matched_api_id = _find_maeris_api_id(step_ep, step_method)

            step_in_maeris_flow = False
            if matched_api_id and best_match:
                step_in_maeris_flow = matched_api_id in maeris_flow_steps.get(best_match, [])

            steps_report.append({
                "apiName": step.get("api_name") or step.get("name") or "",
                "method": step_method,
                "endpoint": step_ep,
                "inMaeris": matched_api_id is not None,
                "inMaerisFlow": step_in_maeris_flow,
            })

        flow_entry: dict[str, Any] = {
            "name": flow_name,
            "inMaeris": best_match is not None,
            "steps": steps_report,
        }
        if best_match:
            flow_entry["maerisFlowName"] = best_match
            flow_entry["matchScore"] = round(best_score, 2)

        flows_report.append(flow_entry)

    # Maeris-only flows: no derived flow matched them
    maeris_only: list[dict] = [
        {
            "name": fname,
            "note": "No corresponding code flow found — may be stale or undiscovered",
        }
        for fname in maeris_flow_endpoints
        if fname not in matched_maeris_flow_names
    ]

    # APIs not referenced by any derived flow step
    apis_not_in_any_flow: list[dict] = []
    for api in derived_apis:
        ep = (api.get("endpoint") or "").strip()
        if not ep or _normalize_path(ep) not in endpoints_in_derived_flows:
            api_method = (api.get("method") or "*").upper()
            in_maeris_api = _find_maeris_api_id(ep, api_method) is not None
            apis_not_in_any_flow.append({
                "name": api.get("name") or ep,
                "method": api_method,
                "endpoint": ep,
                "inMaeris": in_maeris_api,
            })

    # Summary stats
    flows_covered = sum(1 for f in flows_report if f["inMaeris"])
    total_derived_flows = len(flows_report)
    all_steps = [s for f in flows_report for s in f.get("steps", [])]
    apis_covered_by_flows = sum(1 for s in all_steps if s.get("inMaeris"))
    coverage_pct = round(flows_covered / total_derived_flows * 100) if total_derived_flows else 0

    return {
        "coverage": {
            "flowsCovered": flows_covered,
            "flowsNotInMaeris": total_derived_flows - flows_covered,
            "maerisFlowsNotDerived": len(maeris_only),
            "apisCoveredByFlows": apis_covered_by_flows,
            "totalApisInFlowSteps": len(all_steps),
            "apisNotInAnyFlow": len(apis_not_in_any_flow),
            "coveragePct": coverage_pct,
        },
        "flows": flows_report,
        "apisNotInAnyFlow": apis_not_in_any_flow,
        "maerisOnlyFlows": maeris_only,
    }


# ---------------------------------------------------------------------------
# Finalize: fetch Maeris data and produce report
# ---------------------------------------------------------------------------


async def _finalize(store: CoverageStore, session_id: str) -> list[TextContent]:
    """Fetch Maeris data, compare against derived findings, return coverage report."""
    session = store.get_session(session_id)
    if not session:
        return [TextContent(type="text", text=f"Error: session not found: {session_id}")]

    try:
        async with MaerisClient() as client:
            maeris_apis, maeris_flows = await asyncio.gather(
                client.get_all_apis(),
                client.get_api_flows(),
            )

            # Build flow_id → name map.
            # Field order matches coverage_cmds.py: flow_id → id.
            flow_id_name: dict[str, str] = {
                (f.get("flow_id") or f.get("id") or f.get("_id") or ""): (f.get("name") or f.get("flow_name") or "")
                for f in maeris_flows
                if f.get("flow_id") or f.get("id") or f.get("_id")
            }

            # Fetch flow maps in parallel
            async def _safe_map(fid: str) -> tuple[str, list]:
                try:
                    fmap = await client.get_api_flow_map(fid)
                    if isinstance(fmap, list):
                        return fid, fmap
                    for key in ("flow_map", "map", "data", "items", "results"):
                        val = fmap.get(key)
                        if isinstance(val, list):
                            return fid, val
                    return fid, []
                except Exception:
                    return fid, []

            flow_map_results = await asyncio.gather(
                *[_safe_map(fid) for fid in flow_id_name]
            )

        # Build maeris_flow_name → [api_ids] lookup
        maeris_flow_steps: dict[str, list[str]] = {}
        for fid, items in flow_map_results:
            fname = flow_id_name.get(fid, "")
            if not fname:
                continue
            for item in items:
                # Field order matches coverage_cmds.py: child_id → api_id → id.
                aid = str(item.get("child_id") or item.get("api_id") or item.get("id") or "")
                if aid:
                    maeris_flow_steps.setdefault(fname, []).append(aid)

        # Build maeris api_id → url lookup.
        # Field order matches coverage_cmds.py (the proven reference): api_id → id → _id.
        maeris_api_url: dict[str, str] = {}
        for a in maeris_apis:
            aid = str(a.get("api_id") or a.get("id") or a.get("_id") or "")
            url = a.get("url") or ""
            if aid and url:
                maeris_api_url[aid] = url

    except Exception as exc:
        return [TextContent(type="text", text=f"Error fetching Maeris data: {exc}")]

    flow_report = _compare_flows(
        session.derived_flows,
        session.derived_apis,
        maeris_flow_steps,
        maeris_apis,
        maeris_api_url,
    )

    store.mark_completed(session_id)

    result: dict[str, Any] = {
        "sessionId": session_id,
        "status": "completed",
        "scanRoot": session.target_path,
        "filesAnalyzed": len(session.file_list),
        "derivedApis": len(session.derived_apis),
        "derivedFlows": len(session.derived_flows),
        **flow_report,
    }

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


# ---------------------------------------------------------------------------
# Phase routing
# ---------------------------------------------------------------------------


async def handle_check_api_coverage(
    store: CoverageStore, arguments: dict
) -> list[TextContent]:
    """Handle the check_api_coverage tool call."""
    session_id = arguments.get("session_id") or arguments.get("sessionId")

    if not session_id:
        return await _start_session(store, arguments)

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

    if session.status == "completed":
        return [TextContent(
            type="text",
            text=json.dumps({"sessionId": session_id, "status": "completed"}, indent=2),
        )]

    return await _continue_scan(store, session_id, arguments)


# ---------------------------------------------------------------------------
# Phase 1: Start session
# ---------------------------------------------------------------------------


async def _start_session(store: CoverageStore, arguments: dict) -> list[TextContent]:
    target_path = arguments.get("target_path") or os.getcwd()
    scan_root = os.path.abspath(target_path)

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

    path = Path(scan_root)
    project_root = path if path.is_dir() else path.parent
    base_for_rel = str(
        project_root.parent if project_root.parent != project_root else project_root
    )

    files_abs, _info = collect_scan_files(scan_root, api_signals_only=True)
    file_list = [os.path.relpath(p, base_for_rel) for p in files_abs]

    session_id = store.create_session(
        target_path=scan_root,
        base_for_rel=base_for_rel,
        file_list=file_list,
    )

    response: dict[str, Any] = {
        "sessionId": session_id,
        "status": "scanning",
        "targetPath": scan_root,
        "filesTotal": len(file_list),
        "analysisInstructions": _analysis_instructions(),
        "expectedSchema": _expected_schema(),
        "nextStep": (
            "Call again with include_content=true, offset=0 to fetch file contents. "
            "Submit derived_apis=[...] and derived_flows=[...] alongside each subsequent offset. "
            "Set is_last=true on the final call to trigger the coverage report."
        ),
    }
    return [TextContent(type="text", text=json.dumps(response, indent=2))]


# ---------------------------------------------------------------------------
# Phase 1: Continue scan
# ---------------------------------------------------------------------------


async def _continue_scan(
    store: CoverageStore, session_id: str, arguments: dict
) -> list[TextContent]:
    session = store.get_session(session_id)
    if not session:
        return [TextContent(type="text", text=f"Error: session not found: {session_id}")]

    include_content = bool(arguments.get("include_content", False))
    try:
        offset = int(arguments.get("offset", 0))
        limit = max(1, min(int(arguments.get("limit", 10)), 10))
    except (ValueError, TypeError):
        return [TextContent(type="text", text="Error: offset and limit must be integers")]

    raw_apis = arguments.get("derived_apis") or []
    raw_flows = arguments.get("derived_flows") or []
    is_last = bool(arguments.get("is_last") or arguments.get("isLast"))

    # Store submitted derived APIs incrementally; flows only on is_last=true
    # (flows require seeing the full codebase before meaningful grouping is possible)
    valid_apis = [a for a in raw_apis if isinstance(a, dict)]
    valid_flows = [f for f in raw_flows if isinstance(f, dict)] if is_last else []
    store.add_derived(session_id, valid_apis, valid_flows)

    # Refresh session after mutation
    session = store.get_session(session_id)
    if not session:
        return [TextContent(type="text", text=f"Error: session not found: {session_id}")]

    base_response: dict[str, Any] = {
        "sessionId": session_id,
        "status": "scanning",
        "targetPath": session.target_path,
        "filesTotal": len(session.file_list),
        "derivedApisTotal": len(session.derived_apis),
        "derivedFlowsTotal": len(session.derived_flows),
    }

    # Finalize → trigger coverage report
    if is_last:
        return await _finalize(store, session_id)

    # Return next file batch
    if include_content:
        file_list = session.file_list
        if not file_list:
            base_response.setdefault("warnings", [])
            base_response["warnings"].append("No scannable files found at the target path.")
            base_response["nextStep"] = "Call with is_last=true to finalize."
            return [TextContent(type="text", text=json.dumps(base_response, indent=2))]

        if offset >= len(file_list):
            return [TextContent(
                type="text",
                text=f"Error: offset {offset} is out of range — file list has {len(file_list)} file(s)",
            )]

        base_for_rel = session.base_for_rel
        paginated_rels = file_list[offset: offset + limit]
        abs_paths = [str(Path(base_for_rel) / p) for p in paginated_rels]
        contents = get_file_contents(abs_paths)

        has_more = offset + limit < len(file_list)
        base_response["pagination"] = {
            "offset": offset,
            "limit": limit,
            "returned": len(paginated_rels),
            "total": len(file_list),
            "hasMore": has_more,
            "nextOffset": offset + limit if has_more else None,
        }

        _MAX_FILE_CHARS = 8_000
        truncated = 0
        file_contents_map: dict[str, str] = {}
        for rel, abs_path in zip(paginated_rels, abs_paths):
            content = contents.get(abs_path, "")
            if len(content) > _MAX_FILE_CHARS:
                content = content[:_MAX_FILE_CHARS]
                truncated += 1
            file_contents_map[rel] = content
        base_response["fileContents"] = file_contents_map
        if truncated:
            base_response.setdefault("warnings", []).append(
                f"{truncated} file(s) truncated to {_MAX_FILE_CHARS} chars to stay within response limits."
            )
        if offset == 0:
            base_response["analysisInstructions"] = _analysis_instructions()
            base_response["expectedSchema"] = _expected_schema()
    else:
        base_response["nextStep"] = (
            "Call again with include_content=true, offset=0 to fetch file contents. "
            "Submit derived_apis=[...] and derived_flows=[...] alongside each offset. "
            "Set is_last=true on the final call to trigger the coverage report."
        )

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