"""Specialist: dead/unused functions (decorator and callback aware)."""

from __future__ import annotations

import re
import subprocess
from autodebug.agent.specialists.base import ASTData, Finding, Specialist


# Decorator names that register functions as callbacks/handlers
_FRAMEWORK_DECORATORS = {
    "route", "get", "post", "put", "delete", "patch", "head", "options",
    "api_view", "action", "task", "periodic_task", "shared_task",
    "fixture", "parametrize", "mark",
    "property", "staticmethod", "classmethod", "abstractmethod", "override",
    "cached_property", "lru_cache", "cache",
    "contextmanager", "asynccontextmanager",
    "system_prompt", "tool", "on_event",
}

# Substrings in decorator names that indicate registration
_DECORATOR_KEYWORDS = (
    "register", "hook", "handler", "callback", "event", "signal",
    "listener", "command", "route", "endpoint", "subscribe",
    "middleware", "validator", "serializer", "add",
)


class DeadCodeSpecialist(Specialist):
    name = "dead_code"
    trigger_features: list[str] = []

    async def run(self, source: str, ast_data: ASTData) -> list[Finding]:
        findings: list[Finding] = []

        # Find functions defined but never called in-file
        called = set(ast_data.call_sites.keys())
        candidates: list[tuple[str, int]] = []

        for name, line in ast_data.defined_functions.items():
            if name in called:
                continue

            # --- Deterministic SKIP rules ---

            # 1. Dunder methods — called by Python runtime
            if name.startswith("__") and name.endswith("__"):
                continue

            # 2. Test functions
            if name.startswith("test_"):
                continue

            # 2b. Class methods — may be overrides called by framework
            if name in ast_data.class_methods:
                continue

            # 3. Has any decorator — registered by framework
            decorators = ast_data.decorated_functions.get(name, [])
            if decorators:
                # Check if it's a known framework decorator
                skip = False
                for dec in decorators:
                    dec_lower = dec.lower()
                    if any(fd in dec_lower for fd in _FRAMEWORK_DECORATORS):
                        skip = True
                        break
                    if any(kw in dec_lower for kw in _DECORATOR_KEYWORDS):
                        skip = True
                        break
                if skip:
                    continue
                # Even unknown decorators likely register the function
                continue

            # 4. Name appears as a reference (callback) — without "()" after it
            # e.g., callback=func_name, [func_name, other], page.on("x", func_name)
            pattern = re.compile(r'\b' + re.escape(name) + r'\b(?!\s*\()')
            if pattern.search(source):
                # Check it's not just the def line itself
                matches = list(pattern.finditer(source))
                non_def_matches = [
                    m for m in matches
                    if not source[max(0, m.start() - 10):m.start()].rstrip().endswith("def")
                ]
                if non_def_matches:
                    continue

            # 5. Listed in __all__
            if f"'{name}'" in source or f'"{name}"' in source:
                # Quick check: is it in __all__ = [...]?
                if re.search(r'__all__\s*=.*\b' + re.escape(name) + r'\b', source):
                    continue

            candidates.append((name, line))

        if not candidates:
            return findings

        # Only flag private functions (_name) — public functions may be
        # part of a library's API and called by external consumers.
        private = [(n, ln) for n, ln in candidates if n.startswith("_") and not n.startswith("__")]

        if not private:
            return findings

        # Cross-file grep to confirm they're truly unused
        private_names = [n for n, _ in private]
        externally_found: set[str] = set()
        if private_names:
            externally_found = await _batch_grep_external(
                private_names,
                source_dirs=["."],
            )

        for name, line in private:
            if name in externally_found:
                continue
            findings.append(Finding(
                line=line, severity="LOW",
                message=f"`{name}()` defined but never called (no references found)",
                specialist=self.name, confidence="LIKELY",
            ))

        return findings


async def _batch_grep_external(
    func_names: list[str],
    source_dirs: list[str],
) -> set[str]:
    """Grep for function references across source directories.

    Returns set of function names found externally.
    """
    import os
    import asyncio

    # Filter to dirs that exist
    dirs = [d for d in source_dirs if os.path.isdir(d)]
    if not dirs or not func_names:
        return set()

    # Build alternation pattern: func1\(|func2\(
    pattern = "|".join(re.escape(n) + r"\(" for n in func_names)

    try:
        proc = await asyncio.create_subprocess_exec(
            "grep", "-rlE", pattern, *dirs, "--include=*.py",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0)
        files_with_refs = stdout.decode("utf-8", errors="replace").strip().splitlines()
    except Exception:
        return set()

    found: set[str] = set()
    for name in func_names:
        for f in files_with_refs:
            # Check if the reference is in a different file
            try:
                content = open(f, encoding="utf-8", errors="replace").read(100000)
                if name + "(" in content:
                    found.add(name)
                    break
            except Exception:
                continue

    return found
