#!/usr/bin/env python3
"""Small clangd navigation helper for agent-friendly C++ code lookup.

Examples:
  python scripts/dev/clangd_nav.py symbols --file src/app_core/brush_ui.h
  python scripts/dev/clangd_nav.py symbols --file src/app_core/brush_ui.h --name-regex "execute_.*preset"
  python scripts/dev/clangd_nav.py symbols --file src/app_core/document_export.h --detail-regex "Export.*Plan"
  python scripts/dev/clangd_nav.py definition --file src/node_panel_brush.cpp --line 410 --column 30
  python scripts/dev/clangd_nav.py references --file src/app_core/brush_ui.h --line 192 --column 43 --path-regex "src[\\\\/]app_core"
  python scripts/dev/clangd_nav.py self-test
"""

from __future__ import annotations

import argparse
import json
import os
from pathlib import Path
import queue
import re
import subprocess
import sys
import threading
import time
from typing import Any, Pattern


DEFAULT_BUILD_DIRS = (
    "out/build/windows-clangcl-asan",
    "out/build/android-arm64",
)


def _repo_root() -> Path:
    return Path(__file__).resolve().parents[2]


def _find_compile_commands_dir(repo_root: Path, requested: str | None) -> Path:
    if requested:
        path = Path(requested).expanduser()
        if not path.is_absolute():
            path = repo_root / path
        if path.is_file():
            path = path.parent
        if not (path / "compile_commands.json").exists():
            raise SystemExit(f"compile_commands.json not found in {path}")
        return path.resolve()

    env_dir = os.environ.get("PP_CLANGD_COMPILE_COMMANDS_DIR")
    if env_dir:
        return _find_compile_commands_dir(repo_root, env_dir)

    for candidate in DEFAULT_BUILD_DIRS:
        path = repo_root / candidate
        if (path / "compile_commands.json").exists():
            return path.resolve()

    matches = sorted((repo_root / "out" / "build").glob("*/compile_commands.json"))
    if matches:
        return matches[0].parent.resolve()

    raise SystemExit(
        "No compile_commands.json found. Configure a Ninja CMake preset first, "
        "or pass --compile-commands-dir."
    )


def _resolve_file(repo_root: Path, file_arg: str) -> Path:
    path = Path(file_arg).expanduser()
    if not path.is_absolute():
        path = repo_root / path
    if not path.exists():
        raise SystemExit(f"file not found: {path}")
    return path.resolve()


def _read_lsp_message(stream: Any) -> dict[str, Any] | None:
    content_length: int | None = None
    while True:
        line = stream.readline()
        if not line:
            return None
        if line in (b"\r\n", b"\n"):
            break
        name, _, value = line.decode("ascii", errors="replace").partition(":")
        if name.lower() == "content-length":
            content_length = int(value.strip())

    if content_length is None:
        return None

    payload = stream.read(content_length)
    if not payload:
        return None
    return json.loads(payload.decode("utf-8"))


def _write_lsp_message(stream: Any, message: dict[str, Any]) -> None:
    payload = json.dumps(message, separators=(",", ":")).encode("utf-8")
    header = f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii")
    stream.write(header + payload)
    stream.flush()


class ClangdClient:
    def __init__(
        self,
        clangd: str,
        compile_commands_dir: Path,
        timeout_seconds: float,
        background_index: bool) -> None:
        self._timeout_seconds = timeout_seconds
        self._next_id = 1
        self._responses: dict[int, dict[str, Any]] = {}
        self._condition = threading.Condition()
        self._messages: "queue.Queue[dict[str, Any]]" = queue.Queue()
        clangd_args = [
            clangd,
            f"--compile-commands-dir={compile_commands_dir}",
            "--log=error",
        ]
        if not background_index:
            clangd_args.append("--background-index=false")

        self._process = subprocess.Popen(
            clangd_args,
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
        )
        if self._process.stdin is None or self._process.stdout is None:
            raise RuntimeError("failed to open clangd stdio pipes")

        self._stdin = self._process.stdin
        self._stdout = self._process.stdout
        self._reader = threading.Thread(target=self._reader_loop, daemon=True)
        self._reader.start()

    def close(self) -> None:
        try:
            self.notify("exit", {})
        except Exception:
            pass
        try:
            self._process.terminate()
            self._process.wait(timeout=2)
        except Exception:
            self._process.kill()

    def _reader_loop(self) -> None:
        while True:
            try:
                message = _read_lsp_message(self._stdout)
            except Exception as exc:
                message = {"error": {"message": f"failed to read clangd response: {exc}"}}
            if message is None:
                return
            if "id" in message:
                with self._condition:
                    self._responses[int(message["id"])] = message
                    self._condition.notify_all()
            else:
                self._messages.put(message)

    def request(self, method: str, params: dict[str, Any]) -> Any:
        request_id = self._next_id
        self._next_id += 1
        _write_lsp_message(
            self._stdin,
            {
                "jsonrpc": "2.0",
                "id": request_id,
                "method": method,
                "params": params,
            },
        )
        deadline = time.monotonic() + self._timeout_seconds
        with self._condition:
            while request_id not in self._responses:
                remaining = deadline - time.monotonic()
                if remaining <= 0:
                    raise TimeoutError(f"clangd request timed out: {method}")
                self._condition.wait(remaining)
            response = self._responses.pop(request_id)

        if "error" in response:
            raise RuntimeError(response["error"].get("message", "clangd request failed"))
        return response.get("result")

    def notify(self, method: str, params: dict[str, Any]) -> None:
        _write_lsp_message(
            self._stdin,
            {
                "jsonrpc": "2.0",
                "method": method,
                "params": params,
            },
        )


def _position_params(file_path: Path, line: int, column: int) -> dict[str, Any]:
    if line < 1 or column < 1:
        raise SystemExit("--line and --column are 1-based and must be positive")
    return {
        "textDocument": { "uri": file_path.as_uri() },
        "position": { "line": line - 1, "character": column - 1 },
    }


def _range_to_json(range_value: dict[str, Any]) -> dict[str, Any]:
    start = range_value["start"]
    end = range_value["end"]
    return {
        "start": { "line": start["line"] + 1, "column": start["character"] + 1 },
        "end": { "line": end["line"] + 1, "column": end["character"] + 1 },
    }


def _location_to_json(value: dict[str, Any]) -> dict[str, Any]:
    if "targetUri" in value:
        uri = value["targetUri"]
        range_value = value.get("targetRange", value.get("targetSelectionRange"))
    else:
        uri = value["uri"]
        range_value = value["range"]
    return {
        "uri": uri,
        "path": _uri_to_path(uri),
        "range": _range_to_json(range_value),
    }


def _uri_to_path(uri: str) -> str:
    if uri.startswith("file:///"):
        raw = uri[8:]
        if len(raw) >= 3 and raw[1] == ":":
            return raw.replace("/", "\\")
        return "/" + raw
    return uri


def _locations_to_json(result: Any) -> list[dict[str, Any]]:
    if result is None:
        return []
    if isinstance(result, dict):
        return [_location_to_json(result)]
    return [_location_to_json(item) for item in result]


def _symbols_to_json(symbols: list[dict[str, Any]]) -> list[dict[str, Any]]:
    def convert(symbol: dict[str, Any]) -> dict[str, Any]:
        item = {
            "name": symbol.get("name", ""),
            "detail": symbol.get("detail", ""),
            "kind": symbol.get("kind", 0),
            "range": _range_to_json(symbol["range"]),
            "selectionRange": _range_to_json(symbol.get("selectionRange", symbol["range"])),
        }
        children = symbol.get("children")
        if children:
            item["children"] = [convert(child) for child in children]
        return item

    return [convert(symbol) for symbol in symbols or []]


def _flatten_symbols(symbols: list[dict[str, Any]], parent: str = "") -> list[dict[str, Any]]:
    flattened: list[dict[str, Any]] = []
    for symbol in symbols:
        qualified_name = f"{parent}::{symbol['name']}" if parent else symbol["name"]
        item = {
            "name": symbol["name"],
            "qualifiedName": qualified_name,
            "detail": symbol.get("detail", ""),
            "kind": symbol.get("kind", 0),
            "range": symbol["range"],
            "selectionRange": symbol["selectionRange"],
        }
        flattened.append(item)
        flattened.extend(_flatten_symbols(symbol.get("children", []), qualified_name))
    return flattened


def _limit_results(values: list[dict[str, Any]], max_results: int) -> tuple[list[dict[str, Any]], bool]:
    if max_results < 1:
        return values, False
    return values[:max_results], len(values) > max_results


def _hover_to_json(result: Any) -> dict[str, Any] | None:
    if not result:
        return None
    contents = result.get("contents")
    if isinstance(contents, dict):
        value = contents.get("value", "")
    elif isinstance(contents, list):
        value = "\n".join(str(item.get("value", item)) if isinstance(item, dict) else str(item) for item in contents)
    else:
        value = str(contents)
    output = { "contents": value }
    if "range" in result:
        output["range"] = _range_to_json(result["range"])
    return output


def _compile_optional_regex(pattern: str | None, option_name: str, ignore_case: bool) -> Pattern[str] | None:
    if not pattern:
        return None
    flags = re.IGNORECASE if ignore_case else 0
    try:
        return re.compile(pattern, flags)
    except re.error as exc:
        raise SystemExit(f"invalid {option_name}: {exc}") from exc


def _regex_matches(regex: Pattern[str] | None, value: str) -> bool:
    return regex is None or regex.search(value) is not None


def _filter_flat_symbols(
    symbols: list[dict[str, Any]],
    name_substring: str | None,
    name_regex: Pattern[str] | None,
    detail_regex: Pattern[str] | None,
) -> list[dict[str, Any]]:
    filtered = symbols
    if name_substring:
        needle = name_substring.lower()
        filtered = [
            symbol for symbol in filtered
            if needle in symbol["qualifiedName"].lower()
        ]
    if name_regex:
        filtered = [
            symbol for symbol in filtered
            if name_regex.search(symbol["qualifiedName"])
        ]
    if detail_regex:
        filtered = [
            symbol for symbol in filtered
            if detail_regex.search(symbol.get("detail", ""))
        ]
    return filtered


def _filter_locations(
    locations: list[dict[str, Any]],
    path_regex: Pattern[str] | None,
) -> list[dict[str, Any]]:
    if not path_regex:
        return locations
    return [
        location for location in locations
        if _regex_matches(path_regex, location.get("path", ""))
        or _regex_matches(path_regex, location.get("uri", ""))
    ]


def _run_self_test() -> int:
    name_regex = _compile_optional_regex(r"node(panel|dialog)::open_.*", "--name-regex", True)
    detail_regex = _compile_optional_regex(r"export.*plan", "--detail-regex", True)
    path_regex = _compile_optional_regex(r"src[\\/]app(_dialogs)?\.cpp$", "--path-regex", True)
    case_sensitive_regex = _compile_optional_regex(r"Brush", "--name-regex", False)

    symbols = [
        {
            "qualifiedName": "NodePanel::open_project",
            "detail": "void()",
        },
        {
            "qualifiedName": "NodeDialog::open_export",
            "detail": "ExportTargetPlan()",
        },
        {
            "qualifiedName": "Brush::open_project",
            "detail": "BrushPlan()",
        },
    ]
    name_matches = _filter_flat_symbols(symbols, None, name_regex, None)
    detail_matches = _filter_flat_symbols(symbols, None, None, detail_regex)
    case_sensitive_matches = _filter_flat_symbols(symbols, None, case_sensitive_regex, None)

    locations = [
        {
            "path": r"D:\Dev\panopainter\src\app.cpp",
            "uri": "file:///D:/Dev/panopainter/src/app.cpp",
        },
        {
            "path": r"D:\Dev\panopainter\src\app_dialogs.cpp",
            "uri": "file:///D:/Dev/panopainter/src/app_dialogs.cpp",
        },
        {
            "path": r"D:\Dev\panopainter\docs\modernization\roadmap.md",
            "uri": "file:///D:/Dev/panopainter/docs/modernization/roadmap.md",
        },
    ]
    path_matches = _filter_locations(locations, path_regex)

    checks = {
        "nameRegexMatchesAlternationAndWildcard": len(name_matches) == 2,
        "detailRegexMatchesSymbolDetail": len(detail_matches) == 1
            and detail_matches[0]["qualifiedName"] == "NodeDialog::open_export",
        "caseSensitiveRegexHonorsNoIgnoreCase": len(case_sensitive_matches) == 1
            and case_sensitive_matches[0]["qualifiedName"] == "Brush::open_project",
        "pathRegexFiltersLocations": len(path_matches) == 2
            and all("src" in location["path"] for location in path_matches),
    }
    ok = all(checks.values())
    print(json.dumps(
        {
            "ok": ok,
            "command": "self-test",
            "checks": checks,
        },
        indent=2,
    ))
    return 0 if ok else 1


def _open_document(client: ClangdClient, file_path: Path) -> None:
    language_id = "cpp"
    if file_path.suffix.lower() in { ".h", ".hpp", ".hh", ".hxx" }:
        language_id = "cpp"
    elif file_path.suffix.lower() == ".c":
        language_id = "c"

    client.notify(
        "textDocument/didOpen",
        {
            "textDocument": {
                "uri": file_path.as_uri(),
                "languageId": language_id,
                "version": 1,
                "text": file_path.read_text(encoding="utf-8", errors="replace"),
            }
        },
    )


def run(args: argparse.Namespace) -> int:
    if args.command == "self-test":
        return _run_self_test()

    if not args.file:
        raise SystemExit("--file is required for clangd navigation commands")

    symbol_filters = [args.name, args.name_regex, args.detail_regex]
    if any(symbol_filters) and args.command != "symbols":
        raise SystemExit("--name, --name-regex, and --detail-regex are only supported by the symbols command")
    if args.path_regex and args.command not in { "definition", "declaration", "implementation", "references" }:
        raise SystemExit("--path-regex is only supported by location commands")
    if args.hierarchical and any(symbol_filters):
        raise SystemExit("--name, --name-regex, and --detail-regex require flat symbols; omit --hierarchical")

    repo_root = _repo_root()
    compile_commands_dir = _find_compile_commands_dir(repo_root, args.compile_commands_dir)
    file_path = _resolve_file(repo_root, args.file)

    name_regex = _compile_optional_regex(args.name_regex, "--name-regex", args.ignore_case)
    detail_regex = _compile_optional_regex(args.detail_regex, "--detail-regex", args.ignore_case)
    path_regex = _compile_optional_regex(args.path_regex, "--path-regex", args.ignore_case)

    if args.command == "references" and not args.background_index and not args.allow_incomplete_references:
        raise SystemExit(
            "references may be incomplete without clangd background indexing. "
            "Pass --background-index for a broader best-effort query or "
            "--allow-incomplete-references for current-translation-unit lookup."
        )

    client = ClangdClient(args.clangd, compile_commands_dir, args.timeout, args.background_index)
    try:
        client.request(
            "initialize",
            {
                "processId": None,
                "rootUri": repo_root.as_uri(),
                "capabilities": {
                    "textDocument": {
                        "definition": { "linkSupport": True },
                        "declaration": { "linkSupport": True },
                        "implementation": { "linkSupport": True },
                        "references": {},
                        "hover": {},
                        "documentSymbol": { "hierarchicalDocumentSymbolSupport": True },
                    }
                },
            },
        )
        client.notify("initialized", {})
        _open_document(client, file_path)

        command = args.command
        result: Any
        result_count: int | None = None
        truncated = False
        if command == "symbols":
            symbols = _symbols_to_json(
                client.request("textDocument/documentSymbol", { "textDocument": { "uri": file_path.as_uri() } })
            )
            if args.hierarchical:
                result = symbols
                result_count = len(symbols)
            else:
                flattened = _flatten_symbols(symbols)
                flattened = _filter_flat_symbols(flattened, args.name, name_regex, detail_regex)
                result_count = len(flattened)
                result, truncated = _limit_results(flattened, args.max_results)
        elif command == "hover":
            result = _hover_to_json(client.request("textDocument/hover", _position_params(file_path, args.line, args.column)))
        elif command == "references":
            params = _position_params(file_path, args.line, args.column)
            params["context"] = { "includeDeclaration": args.include_declaration }
            locations = _locations_to_json(client.request("textDocument/references", params))
            locations = _filter_locations(locations, path_regex)
            result_count = len(locations)
            result, truncated = _limit_results(locations, args.max_results)
        else:
            method = {
                "definition": "textDocument/definition",
                "declaration": "textDocument/declaration",
                "implementation": "textDocument/implementation",
            }[command]
            locations = _locations_to_json(client.request(method, _position_params(file_path, args.line, args.column)))
            locations = _filter_locations(locations, path_regex)
            result_count = len(locations)
            result, truncated = _limit_results(locations, args.max_results)

        print(json.dumps(
            {
                "ok": True,
                "command": command,
                "file": str(file_path),
                "compileCommandsDir": str(compile_commands_dir),
                "backgroundIndex": args.background_index,
                "referenceCompleteness": (
                    "not-applicable" if command != "references"
                    else ("best-effort-background-index" if args.background_index else "current-translation-unit-only")
                ),
                "filters": {
                    "name": args.name,
                    "nameRegex": args.name_regex,
                    "detailRegex": args.detail_regex,
                    "pathRegex": args.path_regex,
                    "ignoreCase": args.ignore_case,
                },
                "resultCount": result_count,
                "truncated": truncated,
                "result": result,
            },
            indent=2,
        ))
        return 0
    finally:
        client.close()


def main(argv: list[str]) -> int:
    parser = argparse.ArgumentParser(description="Navigate C++ symbols through clangd JSON-RPC.")
    parser.add_argument(
        "command",
        choices=("definition", "declaration", "implementation", "references", "hover", "symbols", "self-test"),
    )
    parser.add_argument("--file", help="Source/header file to open.")
    parser.add_argument("--line", type=int, default=1, help="1-based line for position commands.")
    parser.add_argument("--column", type=int, default=1, help="1-based column for position commands.")
    parser.add_argument(
        "--compile-commands-dir",
        help="Directory containing compile_commands.json. Defaults to PP_CLANGD_COMPILE_COMMANDS_DIR or known build dirs.",
    )
    parser.add_argument("--clangd", default="clangd", help="clangd executable path.")
    parser.add_argument("--timeout", type=float, default=20.0, help="Request timeout in seconds.")
    parser.add_argument("--name", help="Case-insensitive symbol-name filter for symbols command.")
    parser.add_argument("--name-regex", help="Regex filter for symbols command, matched against qualifiedName.")
    parser.add_argument("--detail-regex", help="Regex filter for symbols command, matched against detail.")
    parser.add_argument("--path-regex", help="Regex filter for definition/declaration/implementation/references paths.")
    parser.add_argument(
        "--ignore-case",
        action=argparse.BooleanOptionalAction,
        default=True,
        help="Use case-insensitive regex matching for --name-regex, --detail-regex, and --path-regex. Enabled by default.",
    )
    parser.add_argument("--max-results", type=int, default=100, help="Maximum locations/symbols to print; <=0 disables.")
    parser.add_argument(
        "--background-index",
        action="store_true",
        help="Allow clangd to build/use its background index for broader cross-translation-unit references.",
    )
    parser.add_argument(
        "--allow-incomplete-references",
        action="store_true",
        help="Permit current-translation-unit-only references when --background-index is not enabled.",
    )
    parser.add_argument(
        "--hierarchical",
        action="store_true",
        help="Print nested document symbols instead of the compact flat symbol list.",
    )
    parser.add_argument(
        "--include-declaration",
        action=argparse.BooleanOptionalAction,
        default=True,
        help="Include declaration in references results.",
    )
    return run(parser.parse_args(argv))


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
