"""Symbol extraction and workspace context via LSP.

Replaces otto/repomap.py (tree-sitter extraction) and otto/repo_context.py
(per-session context store). Symbols are extracted via LSP documentSymbol,
ranked using the same algorithm, and context is injected passively.
"""

import fnmatch
import re
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from otto.log import get_logger
from otto.lsp.client import DocumentSymbol

log = get_logger(__name__)


@dataclass(frozen=True)
class Symbol:
    """A code symbol — same shape as the old repomap.Symbol."""

    name: str
    kind: str
    file: str
    line: int
    signature: str
    parent: str | None = None


# ---------------------------------------------------------------------------
# Context store (replaces repo_context.py)
# ---------------------------------------------------------------------------


@dataclass
class RepoContext:
    """Cached repo map skeleton for a single project root."""

    root: Path
    skeleton: str
    created_at: float = field(default_factory=time.monotonic)


_active: dict[str, RepoContext] = {}


def set_context(chat_id: str, root: Path, skeleton: str) -> None:
    """Store (or replace) the active repo context for a chat session."""
    _active[chat_id] = RepoContext(root=root, skeleton=skeleton)


def get_context(chat_id: str) -> RepoContext | None:
    return _active.get(chat_id)


def clear_context(chat_id: str) -> None:
    _active.pop(chat_id, None)


def format_for_prompt(ctx: RepoContext) -> str:
    return f'\n<repo_context path="{ctx.root}">\n{ctx.skeleton}\n</repo_context>'


# ---------------------------------------------------------------------------
# LSP symbol extraction
# ---------------------------------------------------------------------------

# LSP SymbolKind → human-readable kind name
_KIND_MAP: dict[int, str] = {
    5: "class",
    6: "method",
    9: "constructor",
    10: "enum",
    11: "interface",
    12: "function",
    23: "struct",
    # Additional useful kinds
    2: "module",
    3: "namespace",
    7: "property",
    8: "field",
    13: "variable",
    14: "constant",
    22: "enum_member",
    26: "type_parameter",
}


def _doc_symbol_to_symbol(ds: DocumentSymbol) -> Symbol:
    """Convert an LSP DocumentSymbol to our Symbol dataclass."""
    kind = _KIND_MAP.get(ds.kind, "unknown")
    return Symbol(
        name=ds.name,
        kind=kind,
        file=ds.file,
        line=ds.line,
        signature=ds.signature,
        parent=ds.parent,
    )


async def lsp_document_symbols(pool: Any, root: Path, file_paths: list[Path]) -> list[Symbol]:
    """Extract symbols from files using LSP documentSymbol.

    Falls back gracefully — returns empty list on any error.
    """
    symbols: list[Symbol] = []
    for fpath in file_paths:
        try:
            doc_symbols = await pool.get_document_symbols(fpath)
            for ds in doc_symbols:
                symbols.append(_doc_symbol_to_symbol(ds))
        except Exception:
            continue
    return symbols


# ---------------------------------------------------------------------------
# Passive workspace context builder
# ---------------------------------------------------------------------------

# Track which chat_ids have already had context built
_context_built: set[str] = set()

# Default directories/patterns to skip (same as old repomap)
_DEFAULT_IGNORE: list[str] = [
    ".git",
    ".hg",
    ".svn",
    "__pycache__",
    ".venv",
    "venv",
    ".tox",
    ".nox",
    ".eggs",
    "*.egg-info",
    ".mypy_cache",
    ".pytest_cache",
    ".ruff_cache",
    ".pytype",
    "node_modules",
    ".next",
    ".nuxt",
    ".vitepress",
    ".svelte-kit",
    "*.min.js",
    "*.bundle.js",
    "dist",
    "build",
    "out",
    "target",
    "_build",
    "vendor",
    ".cache",
    ".parcel-cache",
    ".turbo",
    ".vercel",
    ".output",
    ".terraform",
    ".idea",
    ".vs",
    ".vscode",
    "coverage",
    ".nyc_output",
    "htmlcov",
    "*.generated.*",
    "*.pb.go",
    "package-lock.json",
    "yarn.lock",
    "pnpm-lock.yaml",
    "poetry.lock",
    "Pipfile.lock",
]

# Extensions we care about for symbol extraction
_SYMBOL_EXTENSIONS: set[str] = {
    ".py",
    ".pyi",
    ".js",
    ".jsx",
    ".mjs",
    ".cjs",
    ".ts",
    ".tsx",
    ".go",
    ".rs",
    ".java",
    ".kt",
    ".c",
    ".h",
    ".cpp",
    ".cc",
    ".hpp",
    ".cs",
    ".rb",
    ".php",
    ".swift",
    ".lua",
    ".zig",
    ".ex",
    ".exs",
    ".hs",
    ".ml",
    ".scala",
    ".dart",
    ".jl",
    ".nim",
    ".cr",
    ".sh",
    ".bash",
}


def _should_ignore(rel_path: str, patterns: list[str]) -> bool:
    parts = rel_path.split("/")
    for pattern in patterns:
        clean = pattern.rstrip("/")
        if any(fnmatch.fnmatch(part, clean) for part in parts):
            return True
        if fnmatch.fnmatch(rel_path, pattern):
            return True
    return False


def _load_gitignore(root: Path) -> list[str]:
    patterns = list(_DEFAULT_IGNORE)
    gitignore = root / ".gitignore"
    if gitignore.exists():
        try:
            for line in gitignore.read_text(encoding="utf-8").splitlines():
                line = line.strip()
                if line and not line.startswith("#"):
                    patterns.append(line)
        except OSError:
            pass
    return patterns


def _iter_workspace_files(root: Path) -> list[Path]:
    """Collect source files in a workspace, respecting ignore patterns."""
    patterns = _load_gitignore(root)
    results: list[Path] = []
    try:
        for path in root.rglob("*"):
            if not path.is_file():
                continue
            if path.suffix.lower() not in _SYMBOL_EXTENSIONS:
                continue
            rel = str(path.relative_to(root))
            if _should_ignore(rel, patterns):
                continue
            results.append(path)
    except OSError:
        pass
    return results


async def build_workspace_context(
    pool: Any,
    root: Path,
    chat_id: str,
) -> str | None:
    """Build workspace symbol context on first file operation.

    Returns the skeleton string, or None if context was already built
    or no symbols could be extracted.
    """
    if chat_id in _context_built:
        return None

    _context_built.add(chat_id)

    files = await _iter_workspace_files_async(root)
    if not files:
        return None

    symbols = await lsp_document_symbols(pool, root, files)
    if not symbols:
        return None

    skeleton = query_symbols(symbols, "", max_results=60)
    set_context(chat_id, root, skeleton)
    return skeleton


async def _iter_workspace_files_async(root: Path) -> list[Path]:
    """Async wrapper around _iter_workspace_files."""
    import asyncio

    return await asyncio.to_thread(_iter_workspace_files, root)


def reset_context_tracking() -> None:
    """Reset the context-built tracking (for testing)."""
    _context_built.clear()
    _active.clear()


# ---------------------------------------------------------------------------
# Query / ranking (ported from repomap.py)
# ---------------------------------------------------------------------------

_GENERIC_QUERY_KEYWORDS: set[str] = {
    "architecture",
    "codebase",
    "entry",
    "entries",
    "files",
    "main",
    "map",
    "module",
    "modules",
    "overview",
    "project",
    "repo",
    "skeleton",
    "structure",
    "points",
}

_KIND_WEIGHTS: dict[str, float] = {
    "class": 3.0,
    "interface": 3.0,
    "struct": 3.0,
    "enum": 2.5,
    "trait": 2.5,
    "function": 2.0,
    "method": 1.5,
    "constructor": 1.5,
    "impl": 1.0,
    "type": 1.0,
    "module": 1.0,
    "namespace": 1.0,
}


def _overview_path_multiplier(file_path: str) -> float:
    if "/.vitepress/" in f"/{file_path}/" or file_path.startswith(".vitepress/"):
        return 0.05
    top = file_path.split("/", 1)[0]
    if top == "otto":
        return 1.8
    if top == "tests":
        return 0.25
    if top == "docs":
        return 0.35
    if top in {"examples", "scripts"}:
        return 0.6
    return 1.0


def _rank_overview(symbols: list[Symbol], *, max_results: int) -> list[Symbol]:
    if not symbols:
        return []

    by_file: dict[str, list[Symbol]] = {}
    for sym in symbols:
        by_file.setdefault(sym.file, []).append(sym)

    def _file_score(file_path: str, syms: list[Symbol]) -> float:
        depth = file_path.count("/")
        score = 0.0
        for s in syms:
            kind_w = _KIND_WEIGHTS.get(s.kind, 1.0)
            if s.parent is not None:
                kind_w *= 0.35
            score += kind_w
            if s.parent is None:
                score += 1.0
        score *= _overview_path_multiplier(file_path)
        score += max(0.0, 2.0 - (depth * 0.5))
        return score

    ranked_files = sorted(
        ((-_file_score(fp, syms), fp) for fp, syms in by_file.items()),
        key=lambda x: (x[0], x[1]),
    )

    per_file_limit = max(4, max_results // 12)

    ranked: list[Symbol] = []
    for _, file_path in ranked_files:
        syms = by_file[file_path]
        syms_sorted = sorted(
            syms,
            key=lambda s: (
                s.parent is not None,
                -_KIND_WEIGHTS.get(s.kind, 1.0),
                s.line,
                s.name,
            ),
        )
        for sym in syms_sorted[:per_file_limit]:
            ranked.append(sym)
            if len(ranked) >= max_results:
                return ranked

    return ranked[:max_results]


def _tokenize(text: str) -> set[str]:
    words = re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", text.lower())
    stopwords = {
        "the",
        "a",
        "an",
        "is",
        "are",
        "in",
        "on",
        "for",
        "to",
        "of",
        "and",
        "or",
        "it",
        "with",
        "that",
        "this",
        "how",
        "what",
        "get",
        "set",
    }
    return {w for w in words if len(w) > 1 and w not in stopwords}


def _score_symbol(sym: Symbol, keywords: set[str]) -> float:
    name_tokens = set(re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", sym.name.lower()))
    file_tokens = set(re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", sym.file.lower()))
    sig_tokens = set(re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", sym.signature.lower()))
    score = 0.0
    score += 3.0 * len(keywords & name_tokens)
    score += 1.0 * len(keywords & file_tokens)
    score += 0.5 * len(keywords & sig_tokens)
    return score


def _format_skeleton(symbols: list[Symbol]) -> str:
    if not symbols:
        return "(no symbols found)"
    by_file: dict[str, list[Symbol]] = {}
    for sym in symbols:
        by_file.setdefault(sym.file, []).append(sym)
    lines: list[str] = []
    for file_path, syms in by_file.items():
        lines.append(f"{file_path}:")
        for sym in sorted(syms, key=lambda s: s.line):
            indent = "    " if sym.parent else "  "
            lines.append(f"{indent}{sym.signature}")
        lines.append("")
    return "\n".join(lines).rstrip()


def query_symbols(symbols: list[Symbol], task_description: str = "", max_results: int = 30) -> str:
    """Return a skeleton view of symbols ranked by relevance."""
    keywords = _tokenize(task_description)

    if not keywords or keywords <= _GENERIC_QUERY_KEYWORDS:
        ranked = _rank_overview(symbols, max_results=max_results)
    else:
        scored: list[tuple[float, Symbol]] = []
        for sym in symbols:
            score = _score_symbol(sym, keywords)
            if score > 0:
                scored.append((score, sym))
        scored.sort(key=lambda x: x[0], reverse=True)
        ranked = [sym for _, sym in scored[:max_results]]

    return _format_skeleton(ranked)
