# Copyright (c) 2026 Muhammad Husnain
# This file is part of SymDex.
# License: See LICENSE file in the project root.

import json
import os
from typing import Optional

import typer
from rich.console import Console
from rich.markup import escape
from rich.table import Table

from symdex.core.indexer import index_folder as _index_folder, invalidate as _invalidate
from symdex.core.watcher import watch as _watch_repo
from symdex.core.storage import (
    get_connection,
    get_db_path,
    get_registry_path,  # noqa: F401 — imported for monkeypatching
    get_stale_repos,
    query_file_symbols,
    query_repos,
    query_routes,
    remove_repo,
    search_text_in_index,
    upsert_repo,
)
from symdex.core.token_metrics import build_search_roi_summary_from_rows
from symdex.search.symbol_search import search_symbols as _search_symbols
from symdex.search.semantic import search_semantic as _search_semantic

app = typer.Typer(name="symdex", help="SymDex — universal code indexer")
console = Console()
err_console = Console(stderr=True)


def _format_language_breakdown(languages: dict[str, int]) -> str:
    if not languages:
        return "none"
    parts = [f"{name}: {count}" for name, count in sorted(languages.items())]
    return ", ".join(parts)


def _print_code_summary(summary: dict) -> None:
    table = Table(title="Code Summary")
    table.add_column("Metric", style="cyan")
    table.add_column("Value", style="green")
    table.add_row("Files", str(summary.get("file_count", 0)))
    table.add_row("Lines of Code", str(summary.get("lines_of_code", 0)))
    table.add_row("Symbols", str(summary.get("symbol_count", 0)))
    table.add_row("Functions", str(summary.get("functions", 0)))
    table.add_row("Classes", str(summary.get("classes", 0)))
    table.add_row("Methods", str(summary.get("methods", 0)))
    table.add_row("Constants", str(summary.get("constants", 0)))
    table.add_row("Variables", str(summary.get("variables", 0)))
    table.add_row("Routes", str(summary.get("routes", 0)))
    table.add_row("Languages", _format_language_breakdown(summary.get("language_distribution", {})))
    table.add_row("Skipped", str(summary.get("skipped", 0)))
    table.add_row("Errors", str(summary.get("errored", 0)))
    console.print(table)


def _repo_root(repo: str) -> str | None:
    for entry in query_repos():
        if entry["name"] == repo:
            return entry["root_path"]
    return None


def _print_search_roi(summary: dict) -> None:
    console.print()
    console.print("[bold]ROI[/bold]")
    console.print(f"Lines searched: [cyan]{summary['lines_searched']}[/cyan]")
    console.print(
        f"Without SymDex: ~[red]{summary['estimated_tokens_without_symdex']}[/red] tokens"
    )
    console.print(
        f"With SymDex: ~[green]{summary['estimated_tokens_with_symdex']}[/green] tokens"
    )
    console.print(f"Saved: ~[green]{summary['estimated_tokens_saved']}[/green] tokens")
    console.print("[italic]You're in good hands.[/italic]")


def _search_roi_summary(repo: str, rows: list[dict], result_kind: str) -> dict | None:
    root = _repo_root(repo)
    if not root or not rows:
        return None
    conn = get_connection(get_db_path(repo))
    try:
        return build_search_roi_summary_from_rows(
            conn,
            repo=repo,
            rows=rows,
            repo_root=root,
            result_kind=result_kind,
        )
    finally:
        conn.close()


@app.command()
def index(
    path: str = typer.Argument(..., help="Directory to index"),
    repo: str = typer.Option(
        None,
        "--repo",
        "--name",
        "-r",
        "-n",
        help="Repo name (omit to auto-generate from git branch and path hash)",
    ),
) -> None:
    """Index a folder and register it."""
    if not os.path.isdir(path):
        err_console.print(f"[red]Error:[/red] Path does not exist: {path}")
        raise typer.Exit(code=1)
    result = _index_folder(path, repo=repo, progress_callback=lambda msg: console.print(f"[dim]{msg}[/dim]"))
    upsert_repo(result.repo, root_path=os.path.abspath(path), db_path=result.db_path)
    table = Table(title="Index Result")
    table.add_column("Repo", style="cyan")
    table.add_column("Indexed", style="green")
    table.add_column("Skipped", style="yellow")
    table.add_column("DB Path")
    table.add_row(result.repo, str(result.indexed_count), str(result.skipped_count), result.db_path)
    console.print(table)
    console.print()
    _print_code_summary(result.summary)


@app.command()
def search(
    query: str = typer.Argument(..., help="Symbol name to search for"),
    repo: str = typer.Option(None, "--repo", "-r", help="Repo name (omit to search all repos)"),
    kind: str = typer.Option(None, "--kind", "-k", help="Symbol kind filter"),
    limit: int = typer.Option(20, "--limit", "-l", help="Max results"),
    json_output: bool = typer.Option(False, "--json", help="Output raw JSON"),
) -> None:
    """Find functions/classes by name (omit --repo to search all indexed repos)."""
    if repo:
        conn = get_connection(get_db_path(repo))
        try:
            symbols = _search_symbols(conn, repo=repo, query=query, kind=kind, limit=limit)
        finally:
            conn.close()
    else:
        from symdex.graph.registry import search_across_repos
        symbols = search_across_repos(query=query, kind=kind, limit=limit)
    if not symbols:
        err_console.print(f"[red]Error:[/red] No symbols found matching: {query}")
        raise typer.Exit(code=1)
    if json_output:
        payload = {"symbols": symbols}
        if repo:
            roi = _search_roi_summary(repo, symbols, "symbol")
            if roi is not None:
                payload["roi"] = roi
        typer.echo(json.dumps(payload))
        return
    table = Table(title=f"Symbols matching '{query}'")
    table.add_column("Repo", style="blue")
    table.add_column("Name", style="cyan")
    table.add_column("Kind", style="magenta")
    table.add_column("File")
    table.add_column("Start", style="dim")
    for s in symbols:
        table.add_row(s.get("repo", repo or ""), s["name"], s["kind"], s["file"], str(s["start_byte"]))
    console.print(table)
    if repo:
        roi = _search_roi_summary(repo, symbols, "symbol")
        if roi is not None:
            _print_search_roi(roi)


@app.command()
def find(
    name: str = typer.Argument(..., help="Exact symbol name"),
    repo: str = typer.Option(None, "--repo", "-r", help="Repo name"),
    json_output: bool = typer.Option(False, "--json", help="Output raw JSON"),
) -> None:
    """Exact symbol name lookup by symbol name."""
    if not repo:
        err_console.print("[red]Error:[/red] --repo is required")
        raise typer.Exit(code=1)
    conn = get_connection(get_db_path(repo))
    try:
        symbols = _search_symbols(conn, repo=repo, query=name, limit=1)
    finally:
        conn.close()
    if not symbols:
        err_console.print(f"[red]Error:[/red] Symbol not found: {name}")
        raise typer.Exit(code=1)
    if json_output:
        payload = {"symbols": symbols}
        roi = _search_roi_summary(repo, symbols, "symbol")
        if roi is not None:
            payload["roi"] = roi
        typer.echo(json.dumps(payload))
        return
    s = symbols[0]
    table = Table(title=f"Symbol: {name}")
    table.add_column("Field")
    table.add_column("Value")
    for k, v in s.items():
        table.add_row(k, str(v) if v is not None else "")
    console.print(table)
    roi = _search_roi_summary(repo, symbols, "symbol")
    if roi is not None:
        _print_search_roi(roi)


@app.command()
def outline(
    file: str = typer.Argument(..., help="Relative file path within repo"),
    repo: str = typer.Option(..., "--repo", "-r", help="Repo name"),
    json_output: bool = typer.Option(False, "--json", help="Output raw JSON"),
) -> None:
    """List all symbols in a file."""
    conn = get_connection(get_db_path(repo))
    try:
        symbols = query_file_symbols(conn, repo=repo, file=file)
    finally:
        conn.close()
    if not symbols:
        err_console.print(f"[red]Error:[/red] No symbols found in: {file}")
        raise typer.Exit(code=1)
    if json_output:
        typer.echo(json.dumps({"symbols": symbols}))
        return
    table = Table(title=f"Outline: {file}")
    table.add_column("Name", style="cyan")
    table.add_column("Kind", style="magenta")
    table.add_column("Start", style="dim")
    table.add_column("End", style="dim")
    for s in symbols:
        table.add_row(s["name"], s["kind"], str(s["start_byte"]), str(s["end_byte"]))
    console.print(table)


@app.command()
def text(
    query: str = typer.Argument(..., help="Text to search for"),
    repo: str = typer.Option(None, "--repo", "-r", help="Repo name"),
    pattern: str = typer.Option(None, "--pattern", "-p", help="File glob pattern"),
    json_output: bool = typer.Option(False, "--json", help="Output raw JSON"),
) -> None:
    """Text search across indexed files."""
    if not repo:
        err_console.print("[red]Error:[/red] --repo is required")
        raise typer.Exit(code=1)
    all_repos = query_repos()
    repo_info = next((r for r in all_repos if r["name"] == repo), None)
    if repo_info is None:
        err_console.print(f"[red]Error:[/red] Repo not indexed: {repo}")
        raise typer.Exit(code=1)
    conn = get_connection(get_db_path(repo))
    try:
        matches = search_text_in_index(conn, repo=repo, query=query, repo_root=repo_info["root_path"], file_pattern=pattern)
    finally:
        conn.close()
    if not matches:
        err_console.print(f"[red]Error:[/red] No matches found for: {query}")
        raise typer.Exit(code=1)
    if json_output:
        payload = {"matches": matches}
        roi = _search_roi_summary(repo, matches, "text")
        if roi is not None:
            payload["roi"] = roi
        typer.echo(json.dumps(payload))
        return
    table = Table(title=f"Text matches for '{query}'")
    table.add_column("File")
    table.add_column("Line", style="dim")
    table.add_column("Text")
    for m in matches:
        table.add_row(m["file"], str(m["line"]), m["text"])
    console.print(table)
    roi = _search_roi_summary(repo, matches, "text")
    if roi is not None:
        _print_search_roi(roi)


@app.command()
def semantic(
    query: str = typer.Argument(..., help="Natural language query"),
    repo: str = typer.Option(None, "--repo", "-r", help="Repo name"),
    limit: int = typer.Option(10, "--limit", "-l", help="Max results"),
    json_output: bool = typer.Option(False, "--json", help="Output raw JSON"),
) -> None:
    """Semantic similarity search by meaning."""
    from symdex.search.semantic import search_semantic
    if not repo:
        err_console.print("[red]Error:[/red] --repo is required")
        raise typer.Exit(code=1)
    conn = get_connection(get_db_path(repo))
    try:
        try:
            results = _search_semantic(
                conn,
                query=query,
                repo=repo,
                limit=limit,
                progress_callback=lambda msg: console.print(f"[dim]{msg}[/dim]"),
            )
        except Exception as exc:
            err_console.print(f"[red]Error:[/red] {escape(str(exc))}")
            raise typer.Exit(code=1)
    finally:
        conn.close()
    if not results:
        err_console.print(f"[red]Error:[/red] No semantic matches found for: {query}")
        raise typer.Exit(code=1)
    if json_output:
        payload = {"symbols": results}
        roi = _search_roi_summary(repo, results, "symbol")
        if roi is not None:
            payload["roi"] = roi
        typer.echo(json.dumps(payload))
        return
    table = Table(title=f"Semantic matches for '{query}'")
    table.add_column("Name", style="cyan")
    table.add_column("Kind", style="magenta")
    table.add_column("Score", style="green")
    table.add_column("File")
    for s in results:
        table.add_row(s["name"], s["kind"], str(s["score"]), s["file"])
    console.print(table)
    roi = _search_roi_summary(repo, results, "symbol")
    if roi is not None:
        _print_search_roi(roi)


@app.command()
def callers(
    name: str = typer.Argument(..., help="Function name to find callers of"),
    repo: str = typer.Option(..., "--repo", "-r", help="Repo name"),
    json_output: bool = typer.Option(False, "--json", help="Output raw JSON"),
) -> None:
    """Show all functions that call the named function."""
    from symdex.graph.call_graph import get_callers as _get_callers
    conn = get_connection(get_db_path(repo))
    try:
        results = _get_callers(conn, name=name, repo=repo)
    finally:
        conn.close()
    if not results:
        err_console.print(f"[red]Error:[/red] No callers found for: {name}")
        raise typer.Exit(code=1)
    if json_output:
        typer.echo(json.dumps({"callers": results}))
        return
    table = Table(title=f"Callers of '{name}'")
    table.add_column("Name", style="cyan")
    table.add_column("Kind", style="magenta")
    table.add_column("File")
    for s in results:
        table.add_row(s["name"], s["kind"], s["file"])
    console.print(table)


@app.command()
def callees(
    name: str = typer.Argument(..., help="Function name to find callees of"),
    repo: str = typer.Option(..., "--repo", "-r", help="Repo name"),
    json_output: bool = typer.Option(False, "--json", help="Output raw JSON"),
) -> None:
    """Show all functions called by the named function."""
    from symdex.graph.call_graph import get_callees as _get_callees
    conn = get_connection(get_db_path(repo))
    try:
        results = _get_callees(conn, name=name, repo=repo)
    finally:
        conn.close()
    if not results:
        err_console.print(f"[red]Error:[/red] No callees found for: {name}")
        raise typer.Exit(code=1)
    if json_output:
        typer.echo(json.dumps({"callees": results}))
        return
    table = Table(title=f"Callees of '{name}'")
    table.add_column("Name", style="cyan")
    table.add_column("File")
    for s in results:
        table.add_row(s["name"], s.get("file") or "")
    console.print(table)


@app.command()
def repos(
    json_output: bool = typer.Option(False, "--json", help="Output raw JSON"),
) -> None:
    """List all indexed repositories."""
    all_repos = query_repos()
    if not all_repos:
        err_console.print("[red]Error:[/red] No repos indexed yet.")
        raise typer.Exit(code=1)
    if json_output:
        typer.echo(json.dumps({"repos": all_repos}))
        return
    table = Table(title="Indexed Repositories")
    table.add_column("Name", style="cyan")
    table.add_column("Root Path")
    table.add_column("Last Indexed", style="dim")
    for r in all_repos:
        table.add_row(r["name"], r["root_path"], str(r.get("last_indexed", "")))
    console.print(table)


@app.command()
def invalidate(
    repo: str = typer.Option(..., "--repo", "-r", help="Repo name"),
    file: str = typer.Option(None, "--file", "-f", help="Specific file to invalidate"),
    json_output: bool = typer.Option(False, "--json", help="Output raw JSON"),
) -> None:
    """Force re-index of a repo or specific file."""
    count = _invalidate(repo, file=file)
    if json_output:
        typer.echo(json.dumps({"invalidated": count}))
        return
    console.print(f"Invalidated [green]{count}[/green] record(s) for repo '[cyan]{repo}[/cyan]'")


@app.command()
def routes(
    repo: str = typer.Argument(..., help="Repo name to query routes for."),
    method: Optional[str] = typer.Option(None, "--method", "-m", help="Filter by HTTP method (GET, POST, ...)."),
    path_contains: Optional[str] = typer.Option(None, "--path", "-p", help="Filter routes whose path contains this string."),
) -> None:
    """List HTTP routes indexed for a repo."""
    db_path = get_db_path(repo)
    conn = get_connection(db_path)
    try:
        rows = query_routes(conn, repo=repo, method=method, path_contains=path_contains)
    finally:
        conn.close()

    if not rows:
        console.print(f"[yellow]No routes indexed for repo '{repo}'.[/yellow]")
        return

    table = Table(title=f"Routes — {repo}", show_header=True, header_style="bold")
    table.add_column("Method", style="cyan", width=8)
    table.add_column("Path")
    table.add_column("Handler")
    table.add_column("File")
    for r in rows:
        table.add_row(r["method"], r["path"], r.get("handler") or "", r["file"])
    console.print(table)


@app.command()
def serve(
    port: int = typer.Option(None, "--port", "-p", help="HTTP port (omit for stdio mode)"),
) -> None:
    """Start the MCP server."""
    from symdex.mcp.server import mcp
    if port:
        mcp.run(transport="streamable-http", port=port)
    else:
        mcp.run()


@app.command()
def watch(
    path: str = typer.Argument(..., help="Path to the directory to watch."),
    repo: Optional[str] = typer.Option(
        None,
        "--repo",
        "--name",
        "-r",
        "-n",
        help="Repo name (omit to auto-generate from git branch and path hash)",
    ),
    interval: float = typer.Option(5.0, "--interval", "-i", help="Seconds between re-index cycles."),
) -> None:
    """Watch a directory and keep its index up to date automatically."""
    console.print(f"[bold]Watching[/bold] {path} (interval={interval}s) — Ctrl+C to stop")
    try:
        _watch_repo(path, repo=repo, interval=interval)
    except KeyboardInterrupt:
        console.print("\n[yellow]Watch stopped.[/yellow]")


@app.command()
def gc(
    json_output: bool = typer.Option(False, "--json", help="Output raw JSON"),
) -> None:
    """Remove stale index databases for repos whose directories no longer exist."""
    stale = get_stale_repos()
    removed = []
    for entry in stale:
        remove_repo(entry["name"])
        removed.append(entry["name"])
    if json_output:
        typer.echo(json.dumps({"removed": removed, "count": len(removed)}))
        return
    if not removed:
        console.print("Registry is clean — nothing to remove.")
        return
    for name in removed:
        console.print(f"Removed stale index: [cyan]{name}[/cyan]")
    console.print(f"[green]{len(removed)}[/green] stale index(es) removed.")


if __name__ == "__main__":
    app()
