"""Search and security tools for the code-intel MCP server.

Tools: semantic_search, semantic_search_status, security_scan, fast_search,
       regex_search.
"""

from __future__ import annotations

import os
import threading
from typing import TYPE_CHECKING

from attocode_intel._internal.integrations.context.frecency import FrecencyResult
from attocode_intel._shared import (
    _get_ast_service,
    _get_context_mgr,
    _get_frecency_tracker,
    _get_remote_service,
    _get_service,
    mcp,
)
from attocode_intel.tools.pin_tools import pin_stamped

if TYPE_CHECKING:
    from attocode_intel._internal.integrations.context.semantic_search import SemanticSearchManager
    from attocode_intel._internal.integrations.context.trigram_index import TrigramIndex
    from attocode_intel._internal.integrations.security.scanner import SecurityScanner


def _get_project_dir() -> str:
    """Dynamic dispatch to :func:`attocode_intel._shared._get_project_dir`.

    Previously this module did ``from ..._shared import _get_project_dir``
    which cached the original function reference at import time. That
    made it invisible to test fixtures that monkeypatch
    ``_shared._get_project_dir`` — the fixture's new function would be
    installed in ``_shared`` but ``search_tools._get_project_dir`` would
    keep returning the original.

    Looking the function up fresh on every call keeps cross-module
    monkeypatching consistent AND avoids a whole class of test
    pollution where a fixture-scoped patch of ``_shared`` leaks into
    search_tools via some other path.
    """
    from attocode_intel import _shared
    return _shared._get_project_dir()


def _empty_frecency(ai_mode: bool) -> FrecencyResult:
    """Create a zero-score FrecencyResult."""
    return FrecencyResult(score=0, accesses=0, last_access=None, is_ai_mode=ai_mode)

# ---------------------------------------------------------------------------
# Lazy singletons
# ---------------------------------------------------------------------------

_semantic_search: SemanticSearchManager | None = None
_semantic_search_lock = threading.Lock()

_trigram_index: TrigramIndex | None = None
_trigram_index_lock = threading.Lock()


def _get_semantic_search() -> SemanticSearchManager:
    """Lazily initialize the semantic search manager (thread-safe)."""
    from attocode_intel.request_context import current_request
    if (request := current_request.get()) is not None:
        return request.service._get_semantic_search()
    global _semantic_search
    if _semantic_search is None:
        with _semantic_search_lock:
            if _semantic_search is None:
                from attocode_intel._internal.integrations.context.semantic_search import (
                    SemanticSearchManager,
                )
                project_dir = _get_project_dir()
                _semantic_search = SemanticSearchManager(root_dir=project_dir)
    return _semantic_search


_security_scanner: SecurityScanner | None = None


def _get_security_scanner() -> SecurityScanner:
    """Lazily initialize the security scanner."""
    from attocode_intel.request_context import current_request
    if (request := current_request.get()) is not None:
        return request.service._get_security_scanner()
    global _security_scanner
    if _security_scanner is None:
        from attocode_intel._internal.integrations.security.scanner import SecurityScanner
        project_dir = _get_project_dir()
        _security_scanner = SecurityScanner(root_dir=project_dir)
    return _security_scanner


def _get_trigram_index() -> TrigramIndex | None:
    """Lazily initialize the trigram index (thread-safe).

    Loads an existing index from disk if available. Returns None
    if no index has been built yet.
    """
    from attocode_intel.request_context import current_request, scoped_store
    if current_request.get() is not None:
        def build(root):
            import os

            from attocode_intel._internal.integrations.context.trigram_index import TrigramIndex
            index = TrigramIndex(index_dir=os.path.join(root, ".attocode", "index"))
            if index.load():
                return index
            index.close()
            return None
        return scoped_store("trigrams", build)
    global _trigram_index
    import os
    index_dir = os.path.realpath(os.path.join(_get_project_dir(), ".attocode", "index"))
    with _trigram_index_lock:
        # The legacy direct-call path can select a different project between calls.
        cached_dir = getattr(_trigram_index, "index_dir", None)
        if isinstance(cached_dir, (str, os.PathLike)) and os.path.realpath(cached_dir) != index_dir:
            _trigram_index.close()
            _trigram_index = None
        if _trigram_index is None and os.path.isdir(index_dir):
            from attocode_intel._internal.integrations.context.trigram_index import TrigramIndex
            idx = TrigramIndex(index_dir=index_dir)
            try:
                if idx.load():
                    _trigram_index = idx
                else:
                    idx.close()
            except Exception:
                idx.close()
    return _trigram_index


# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------


@mcp.tool()
@pin_stamped
def semantic_search(
    query: str,
    top_k: int = 10,
    file_filter: str = "",
    branch: str = "",
    mode: str = "auto",
    context_file: str = "",
) -> str:
    """Search the code by meaning with a natural-language query. Returns files, functions and classes, best first.

    Args:
        query: For example "authentication middleware".
        file_filter: Optional glob, for example "*.py".
        branch: Remote service only. Local search reads the working tree.
        mode: "auto" (default), "keyword", or "vector" (waits for embeddings).
        context_file: Optional workspace-relative file; local lexical search prioritizes its direct imports/importers.
    """
    return _get_service().semantic_search(
        query=query,
        top_k=top_k,
        file_filter=file_filter,
        branch=branch,
        mode=mode,
        **({"context_file": context_file} if context_file else {}),
    )


@mcp.tool()
def semantic_search_status() -> str:
    """Get the status of the semantic search index.

    Returns: provider name, coverage percentage, files indexed/total,
    indexing status, and whether vector search is active.
    """
    remote = _get_remote_service()
    if remote is not None:
        return remote.semantic_search_status()

    mgr = _get_semantic_search()
    progress = mgr.get_index_progress()
    discovered_files = 0
    ast_indexed_files = 0
    degradation_reasons: list[str] = []

    try:
        ctx = _get_context_mgr()
        discovered = getattr(ctx, "_files", None)
        if isinstance(discovered, list):
            discovered_files = len(discovered)
        if discovered_files == 0:
            discovered_files = len(ctx.discover_files())
    except Exception as exc:
        degradation_reasons.append(f"context_error:{type(exc).__name__}")

    try:
        ast_svc = _get_ast_service()
        stats = ast_svc._store.stats() if hasattr(ast_svc, "_store") else {}
        ast_indexed_files = int(
            stats.get("files", 0)
            or stats.get("files_indexed", 0)
            or len(getattr(ast_svc, "_ast_cache", {}))
        )
    except Exception as exc:
        degradation_reasons.append(f"ast_error:{type(exc).__name__}")

    if discovered_files == 0:
        degradation_reasons.append("no_files_discovered")
    if ast_indexed_files == 0:
        degradation_reasons.append("no_files_indexed")

    lines = [
        "Semantic search status:",
        f"  Provider: {mgr.provider_name}",
        f"  Available: {mgr.is_available}",
        f"  Status: {progress.status}",
        f"  Coverage: {progress.coverage:.0%} ({progress.indexed_files}/{progress.total_files} files)",
        f"  Failed: {progress.failed_files}",
        f"  Vector search active: {mgr.is_index_ready()}",
        f"  Discovery count: {discovered_files}",
        f"  AST indexed files: {ast_indexed_files}",
        f"  Health: {'degraded' if degradation_reasons else 'healthy'}",
    ]
    try:
        _store = getattr(mgr, "_store", None)
        _store_model = getattr(_store, "model_name", "") if _store else ""
        if _store_model and _store_model != mgr.provider_name:
            lines.append(
                f"  Model mismatch: index built with {_store_model}, active "
                f"{mgr.provider_name} — run reindex to rebuild vectors."
            )
    except Exception:
        pass
    if degradation_reasons:
        lines.append(f"  Degradation reason: {', '.join(degradation_reasons)}")
    if getattr(progress, "degraded_reason", ""):
        lines.append(f"  Backend degradation: {progress.degraded_reason}")
    if getattr(progress, "last_error", ""):
        lines.append(f"  Last error: {progress.last_error}")
    if progress.elapsed_seconds > 0:
        lines.append(f"  Elapsed: {progress.elapsed_seconds:.1f}s")
    return "\n".join(lines)


@mcp.tool()
def security_scan(
    mode: str = "full",
    path: str = "",
) -> str:
    """Scan the codebase for security issues.

    Detects hardcoded secrets, code anti-patterns, and dependency
    pinning issues. All scanning is local (no external API calls).
    Returns a compliance score (0-100) and categorized findings.

    Args:
        mode: Scan mode -- 'quick' (secrets), 'full' (all), 'secrets', 'patterns', 'dependencies'.
        path: Subdirectory to scan (relative to project root, empty for all).
    """
    return _get_service().security_scan(mode=mode, path=path)


@mcp.tool()
def dataflow_scan(
    files: list[str] | None = None,
    path: str = "",
) -> str:
    """Scan for data flow vulnerabilities using taint analysis.

    Tracks user-controlled data (request parameters, input, argv) through
    variable assignments to dangerous sinks (SQL execution, shell commands,
    file operations, HTML output). Detects SQL injection (CWE-89), command
    injection (CWE-78), XSS (CWE-79), path traversal (CWE-22), and SSRF
    (CWE-918).

    Currently supports Python and JavaScript/TypeScript. Analysis is
    intra-procedural (within individual functions).

    Args:
        files: Specific files to analyze (relative paths). Default: all.
        path: Subdirectory to restrict analysis to.
    """
    from attocode_intel._internal.integrations.security.dataflow import (
        analyze_project,
        format_report,
    )

    project_dir = _get_project_dir()
    if path and not files:
        import os
        scan_dir = os.path.join(project_dir, path)
        if not os.path.isdir(scan_dir):
            return f"Error: Path not found: {path}"
        # Discover files in subdirectory
        extensions = {".py", ".js", ".ts", ".jsx", ".tsx"}
        files = []
        for dirpath, _, filenames in os.walk(scan_dir):
            for fname in filenames:
                if os.path.splitext(fname)[1].lower() in extensions:
                    files.append(os.path.relpath(
                        os.path.join(dirpath, fname), project_dir,
                    ))

    report = analyze_project(project_dir, paths=files or None)
    return format_report(report)


@mcp.tool()
@pin_stamped
def fast_search(
    pattern: str,
    path: str = "",
    max_results: int = 50,
    case_insensitive: bool = False,
    selectivity_threshold: float = 0.10,
    explain: bool = False,
) -> str:
    """Search file contents with a regular expression. A trigram index selects the candidate files first.

    Args:
        pattern: A regular expression, for example "def process_.*event".
        path: Optional subdirectory relative to the project root.
        selectivity_threshold: Skip the index when more than this share of files can match (default 0.10).
        explain: Add search diagnostics to the output.
    """
    remote = _get_remote_service()
    if remote is not None:
        return remote.fast_search(
            pattern=pattern,
            path=path,
            max_results=max_results,
            case_insensitive=case_insensitive,
            selectivity_threshold=selectivity_threshold,
            explain=explain,
        )

    import re
    from pathlib import Path

    project_dir = _get_project_dir()
    root = Path(project_dir)
    if path:
        root = root / path
    root = root.resolve()

    if not root.exists():
        return f"Error: Path not found: {root}"

    flags = re.IGNORECASE if case_insensitive else 0
    try:
        regex = re.compile(pattern, flags)
    except re.error as e:
        return f"Error: Invalid regex pattern: {e}"

    # Try trigram pre-filtering
    trigram_idx = _get_trigram_index()
    candidates: list[str] | None = None
    index_status = "no index"
    query_result = None  # QueryResult when explain=True

    if trigram_idx is not None and trigram_idx.is_ready():
        if explain:
            from attocode_intel._internal.integrations.context.trigram_index import QueryResult

            query_result = trigram_idx.query(
                pattern,
                case_insensitive=case_insensitive,
                selectivity_threshold=selectivity_threshold,
                explain=True,
            )
            candidates = query_result.candidates
            index_status = query_result.mode
        else:
            candidates = trigram_idx.query(
                pattern,
                case_insensitive=case_insensitive,
                selectivity_threshold=selectivity_threshold,
            )
            if candidates is not None:
                index_status = f"trigram filter: {len(candidates)} candidates"
            else:
                index_status = "no trigrams extractable or threshold exceeded, full scan"
    else:
        # Try to build the index on first use
        try:
            from attocode_intel._internal.integrations.context.trigram_index import TrigramIndex
            index_dir = os.path.join(project_dir, ".attocode", "index")
            idx = TrigramIndex(index_dir=index_dir)
            stats = idx.build(project_dir)
            from attocode_intel.request_context import current_request
            if (request := current_request.get()) is not None:
                request.stores["trigrams"] = idx
            else:
                global _trigram_index
                _trigram_index = idx
            if explain:
                query_result = idx.query(
                    pattern,
                    case_insensitive=case_insensitive,
                    selectivity_threshold=selectivity_threshold,
                    explain=True,
                )
                candidates = query_result.candidates
                index_status = (
                    f"built index ({stats['files_indexed']} files, "
                    f"{stats['build_time_ms']}ms), mode: {query_result.mode}"
                )
            else:
                candidates = idx.query(
                    pattern,
                    case_insensitive=case_insensitive,
                    selectivity_threshold=selectivity_threshold,
                )
                index_status = (
                    f"built index ({stats['files_indexed']} files, "
                    f"{stats['build_time_ms']}ms), "
                    f"{len(candidates) if candidates is not None else 'N/A'} candidates"
                )
        except Exception:
            index_status = "index build failed, full scan"
            if explain:
                from attocode_intel._internal.integrations.context.trigram_index import QueryResult

                query_result = QueryResult(
                    candidates=None, trigram_literals=[], posting_sizes=[],
                    total_files=0, candidate_count=0, selectivity=0.0,
                    threshold=selectivity_threshold, threshold_triggered=False,
                    mode="index-build-failed",
                )

    # Determine files to search
    if candidates is not None:
        files = sorted(root / c for c in candidates)
    else:
        files = sorted(root.rglob("*"))

    matches: list[str] = []
    for file in files:
        if not file.is_file() or file.name.startswith("."):
            continue
        try:
            content = file.read_text(encoding="utf-8", errors="strict")
        except (UnicodeDecodeError, OSError):
            continue
        for i, line in enumerate(content.splitlines(), 1):
            if regex.search(line):
                try:
                    rel = file.relative_to(Path(project_dir))
                except ValueError:
                    rel = file.name
                matches.append(f"{rel}:{i}: {line.strip()}")
                if len(matches) >= max_results:
                    break
        if len(matches) >= max_results:
            break

    if not matches:
        result = f"No matches found ({index_status})"
    else:
        result = "\n".join(matches)
        if len(matches) >= max_results:
            result += f"\n... (limited to {max_results} results)"
        result += f"\n({index_status})"

    # Append diagnostics when explain=True
    if explain and query_result is not None:
        qr = query_result
        sel_pct = f"{qr.selectivity * 100:.2f}%"
        threshold_pct = f"{qr.threshold * 100:.1f}%"
        verdict = "SKIP (threshold exceeded)" if qr.threshold_triggered else "PASS"
        diag_lines = [
            "",
            "--- Search Diagnostics ---",
            f"Pattern:            {pattern}",
            f"Trigrams:           {qr.trigram_literals!r}",
            f"Posting sizes:      {qr.posting_sizes!r}",
            f"Candidates:         {qr.candidate_count} / {qr.total_files} files ({sel_pct})",
            f"Selectivity:        {verdict} (threshold: {threshold_pct})",
            f"Mode:               {qr.mode}",
        ]
        result += "\n".join(diag_lines)

    return result


@mcp.tool()
@pin_stamped
def regex_search(
    pattern: str,
    path: str = "",
    max_results: int = 50,
    case_insensitive: bool = False,
) -> str:
    """Search code with regex, accelerated by trigram index.

    A straightforward regex search tool that leverages the trigram inverted
    index for 10-100x speedup over brute-force grep. Useful for finding
    specific code patterns like function signatures, import statements,
    or configuration values.

    Args:
        pattern: Regex pattern to search for.
        path: Subdirectory to restrict search to (relative to project root).
        max_results: Maximum number of matching lines to return.
        case_insensitive: Whether to match case-insensitively.
    """
    # NOTE: regex_search has no server-side HTTP route, so it cannot be
    # proxied to a remote. It always runs locally (against the trigram index /
    # local files), even when a remote is configured.
    import re
    from pathlib import Path

    project_dir = _get_project_dir()
    root = Path(project_dir)
    if path:
        root = root / path
    root = root.resolve()

    if not root.exists():
        return f"Error: Path not found: {root}"

    flags = re.IGNORECASE if case_insensitive else 0
    try:
        regex = re.compile(pattern, flags)
    except re.error as e:
        return f"Error: Invalid regex pattern: {e}"

    # Try trigram pre-filtering
    selectivity_threshold = 0.10
    trigram_idx = _get_trigram_index()
    candidates: list[str] | None = None
    used_index = False

    if trigram_idx is not None and trigram_idx.is_ready():
        candidates = trigram_idx.query(
            pattern,
            case_insensitive=case_insensitive,
            selectivity_threshold=selectivity_threshold,
        )
        if candidates is not None:
            used_index = True

    # Determine files to search
    if candidates is not None:
        files = sorted(root / c for c in candidates)
    else:
        files = sorted(root.rglob("*"))

    matches: list[str] = []
    for file in files:
        if not file.is_file() or file.name.startswith("."):
            continue
        try:
            content = file.read_text(encoding="utf-8", errors="strict")
        except (UnicodeDecodeError, OSError):
            continue
        for i, line in enumerate(content.splitlines(), 1):
            if regex.search(line):
                try:
                    rel = file.relative_to(Path(project_dir))
                except ValueError:
                    rel = file.name
                matches.append(f"{rel}:{i}: {line.strip()}")
                if len(matches) >= max_results:
                    break
        if len(matches) >= max_results:
            break

    total = len(matches)
    if not matches:
        return "No matches found."

    result = "\n".join(matches)
    if total >= max_results:
        result += f"\n... (limited to {max_results} results)"
    index_note = "trigram-indexed" if used_index else "brute-force"
    result += f"\n{total} match(es) found ({index_note})."
    return result


@mcp.tool()
@pin_stamped
def frecency_search(
    pattern: str,
    path: str = "",
    max_results: int = 50,
    case_insensitive: bool = False,
    selectivity_threshold: float = 0.10,
    use_frecency: bool = True,
    ai_mode: bool = True,
) -> str:
    """Frecency-boosted regex search for AI agents.

    Like fast_search but boosts results by frecency score (how often and
    recently files were accessed). This helps prioritize commonly-used files
    when there are many matches.

    The frecency score is calculated from:
    - Access frequency (exponential decay with 10-day or 3-day half-life)
    - Recency (more recent = higher score)
    - Git modification bonus (recently modified files get +1-16 points)

    Args:
        pattern: Regex pattern to search for.
        path: Subdirectory to search (relative to project root, empty for all).
        max_results: Maximum number of matching lines to return (default 50).
        case_insensitive: Whether to match case-insensitively.
        selectivity_threshold: Skip trigram index when matching files exceeds
            this fraction (0.0-1.0, default 0.10).
        use_frecency: Whether to apply frecency boosting (default True).
        ai_mode: Use AI mode decay (3-day half-life) vs human mode (10-day).

    Returns:
        Search results with frecency-boosted ranking.
    """
    import re
    from collections import defaultdict
    from pathlib import Path

    project_dir = _get_project_dir()
    root = Path(project_dir)
    if path:
        root = root / path
    root = root.resolve()

    if not root.exists():
        return f"Error: Path not found: {root}"

    flags = re.IGNORECASE if case_insensitive else 0
    try:
        regex = re.compile(pattern, flags)
    except re.error as e:
        return f"Error: Invalid regex pattern: {e}"

    # Get trigram candidates
    trigram_idx = _get_trigram_index()
    candidates: list[str] | None = None
    index_status = "no index"

    if trigram_idx is not None and trigram_idx.is_ready():
        candidates = trigram_idx.query(
            pattern,
            case_insensitive=case_insensitive,
            selectivity_threshold=selectivity_threshold,
        )
        if candidates is not None:
            index_status = f"trigram filter: {len(candidates)} candidates"
        else:
            index_status = "no trigrams, full scan"
    else:
        index_status = "no index, full scan"

    # Get frecency scores
    frecency_tracker = _get_frecency_tracker() if use_frecency else None

    # Determine files to search
    if candidates is not None:
        files = sorted(root / c for c in candidates)
    else:
        all_files = sorted(root.rglob("*"))
        try:
            from attocode_intel._internal.integrations.utilities.ignore import IgnoreManager

            ignore_mgr = IgnoreManager(root=Path(project_dir))
            files = [
                f
                for f in all_files
                if f.is_file()
                and not f.name.startswith(".")
                and not ignore_mgr.is_ignored(
                    str(f.relative_to(Path(project_dir)))
                )
            ]
        except (ImportError, ValueError):
            files = [
                f
                for f in all_files
                if f.is_file() and not f.name.startswith(".")
            ]

    # Phase 1: Collect candidate file paths
    candidate_paths: list[Path] = []
    for file in files:
        if not file.is_file() or file.name.startswith("."):
            continue
        candidate_paths.append(file)

    # Phase 2: Pre-sort by frecency so high-value files are searched first
    presort_limit = 10_000
    file_scores: dict[str, FrecencyResult] = {}
    if (
        frecency_tracker is not None
        and use_frecency
        and len(candidate_paths) <= presort_limit
    ):
        path_strs: list[str] = []
        path_map: dict[str, Path] = {}
        for f in candidate_paths:
            try:
                rel = str(f.relative_to(Path(project_dir)))
            except ValueError:
                rel = str(f)
            path_strs.append(rel)
            path_map[rel] = f

        file_scores = frecency_tracker.get_scores_batch(path_strs, ai_mode=ai_mode)

        # Build reverse lookup to avoid recomputing relative_to() in sort
        path_to_rel: dict[Path, str] = {v: k for k, v in path_map.items()}

        # Sort: high-frecency files first, then alphabetical as tiebreaker
        candidate_paths.sort(
            key=lambda f: (
                -file_scores.get(
                    path_to_rel.get(f, str(f)),
                    _empty_frecency(ai_mode),
                ).score,
                str(f),
            ),
        )

    # Phase 3: Read content from frecency-sorted files
    file_matches: dict[str, list[tuple[int, str]]] = defaultdict(list)

    for file in candidate_paths:
        try:
            content = file.read_text(encoding="utf-8", errors="strict")
        except (UnicodeDecodeError, OSError):
            continue
        for i, line in enumerate(content.splitlines(), 1):
            if regex.search(line):
                try:
                    rel = str(file.relative_to(Path(project_dir)))
                except ValueError:
                    rel = file.name
                file_matches[rel].append((i, line.strip()))
                if sum(len(v) for v in file_matches.values()) >= max_results * 3:
                    break
        if sum(len(v) for v in file_matches.values()) >= max_results * 3:
            break

    if not file_matches:
        return f"No matches found ({index_status})"

    # Fetch scores for any files not yet scored (e.g. from trigram path)
    unscored = [p for p in file_matches if p not in file_scores]
    if unscored and frecency_tracker is not None:
        file_scores.update(
            frecency_tracker.get_scores_batch(unscored, ai_mode=ai_mode)
        )
    # Fill in defaults for unscored files
    for p in file_matches:
        if p not in file_scores:
            file_scores[p] = _empty_frecency(ai_mode)

    # Sort files by frecency score (descending), then by match count
    sorted_files = sorted(
        file_matches.items(),
        key=lambda kv: (
            -file_scores.get(kv[0], _empty_frecency(ai_mode)).score,
            -len(kv[1]),
        ),
    )

    # Build results respecting max_results
    results: list[str] = []
    for file_path, matches in sorted_files:
        score = file_scores.get(file_path, _empty_frecency(ai_mode)).score
        for line_num, line_content in matches[:5]:  # Max 5 matches per file
            results.append(f"{file_path}:{line_num}: [{score}] {line_content}")
            if len(results) >= max_results:
                break
        if len(results) >= max_results:
            break

    if not results:
        return f"No matches found ({index_status})"

    result = "\n".join(results)
    if len(results) >= max_results:
        result += f"\n... (limited to {max_results} results)"

    # Add frecency stats header
    total_files = len(file_matches)
    avg_score = sum(s.score for s in file_scores.values()) / max(len(file_scores), 1)
    result += f"\n({index_status}, {total_files} files matched, avg frecency: {avg_score:.1f})"

    return result
