import ast
import json
import os
from pathlib import Path
import re
import shutil
import sys
from typing import Iterator, List, Literal, Optional, TypedDict, Union

from packaging.version import InvalidVersion
from packaging.version import parse as parse_version

from azure_functions_doctor.logging_config import get_logger
from azure_functions_doctor.target_resolver import resolve_target_value

logger = get_logger(__name__)


def _source_contains_ast(source: str, identifier: str) -> bool:
    """Return True when the source contains a decorator like `@identifier.xxx`."""
    try:
        tree = ast.parse(source)
    except SyntaxError:
        return False

    def decorator_matches(dec: ast.expr) -> bool:
        # @app.route() is ast.Call(func=Attribute(...)); @app.route is ast.Attribute
        node: ast.expr = dec.func if isinstance(dec, ast.Call) else dec
        if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name):
            return node.value.id == identifier
        return False

    for node in ast.walk(tree):
        if isinstance(node, (ast.FunctionDef, ast.ClassDef)) and node.decorator_list:
            for dec in node.decorator_list:
                if decorator_matches(dec):
                    return True
    return False


def _iter_project_py_contents(path: Path) -> Iterator[tuple[Path, str]]:
    """Yield (py_file, content) for each .py file under path, skipping excluded dirs."""
    excluded_dirs = {".venv", "build", "dist", ".pytest_cache", "__pycache__"}
    for py_file in path.rglob("*.py"):
        if any(part in excluded_dirs for part in py_file.parts):
            continue
        content = _read_project_python_file(py_file)
        if content is None:
            continue
        yield py_file, content


def _read_project_python_file(py_file: Path) -> Optional[str]:
    """Read Python source without failing the whole traversal."""
    try:
        return py_file.read_text(encoding="utf-8")
    except PermissionError:
        logger.warning(f"Permission denied reading {py_file}")
        return None
    except UnicodeDecodeError:
        try:
            return py_file.read_text(encoding="utf-8", errors="ignore")
        except (OSError, PermissionError, ValueError) as exc:
            logger.debug(f"Skip {py_file}: {exc}")
            return None
    except (MemoryError, OSError, ValueError) as exc:
        logger.debug(f"Skip {py_file}: {exc}")
        return None


def _create_result(status: str, detail: str, internal_error: bool = False) -> dict[str, str]:
    """Create a standardized result dictionary (status limited to 'pass'/'fail')."""
    res: dict[str, str] = {"status": status, "detail": detail}
    if internal_error:
        res["internal_error"] = "true"
    return res


def _handle_exception(operation: str, exc: Exception) -> dict[str, str]:
    """Handle exceptions consistently across all handlers (always fail)."""
    error_msg = f"Error during {operation}: {exc}"
    logger.error(error_msg, exc_info=True)
    return _create_result("fail", error_msg, internal_error=True)


def _handle_specific_exceptions(operation: str, exc: Exception) -> dict[str, str]:
    """Handle specific exception types with user-friendly messages (fail only)."""
    if isinstance(exc, UnicodeDecodeError):
        return _create_result("fail", f"Encoding error in {operation}: {exc}.", internal_error=True)
    if isinstance(exc, (ValueError, TypeError)):
        return _create_result(
            "fail", f"Configuration error in {operation}: {exc}.", internal_error=True
        )
    if isinstance(exc, (OSError, PermissionError)):
        return _create_result(
            "fail", f"File system error in {operation}: {exc}", internal_error=True
        )
    if isinstance(exc, ImportError):
        return _create_result("fail", f"Import error in {operation}: {exc}", internal_error=True)
    if isinstance(exc, MemoryError):
        return _create_result("fail", "Memory error: file too large", internal_error=True)
    if isinstance(exc, KeyboardInterrupt):
        raise exc
    if isinstance(exc, SystemExit):
        raise exc
    logger.error(f"Unexpected error in {operation}: {exc}", exc_info=True)
    return _create_result("fail", f"Unexpected error in {operation}", internal_error=True)


class Condition(TypedDict, total=False):
    target: str
    operator: str
    value: Union[str, int, float]
    keyword: str
    mode: Literal["string", "ast"]  # for source_code_contains: "string" (default) or "ast"
    jsonpath: str
    targets: list[str]
    patterns: list[str]
    pypi: str


class Rule(TypedDict, total=False):
    id: str
    type: Literal[
        "compare_version",
        "env_var_exists",
        "path_exists",
        "file_exists",
        "package_installed",
        "source_code_contains",
        "conditional_exists",
        "callable_detection",
        "executable_exists",
        "any_of_exists",
        "file_glob_check",
        "host_json_property",
    ]
    label: str
    category: str
    section: str
    description: str
    required: bool
    condition: Condition
    hint: str
    fix: str
    fix_command: str
    hint_url: str
    check_order: int


class HandlerRegistry:
    """Registry for diagnostic check handlers with individual handler methods."""

    def __init__(self) -> None:
        self._handlers = {
            "compare_version": self._handle_compare_version,
            "env_var_exists": self._handle_env_var_exists,
            "path_exists": self._handle_path_exists,
            "file_exists": self._handle_file_exists,
            "package_installed": self._handle_package_installed,
            "package_declared": self._handle_package_declared,
            "source_code_contains": self._handle_source_code_contains,
            "conditional_exists": self._handle_conditional_exists,
            "callable_detection": self._handle_callable_detection,
            "executable_exists": self._handle_executable_exists,
            "any_of_exists": self._handle_any_of_exists,
            "file_glob_check": self._handle_file_glob_check,
            "host_json_property": self._handle_host_json_property,
        }

    def handle(self, rule: Rule, path: Path) -> dict[str, str]:
        """Route rule execution to appropriate handler."""
        check_type = rule.get("type")
        if check_type is None:
            return _create_result("fail", "Missing check type in rule")
        handler = self._handlers.get(check_type)

        if not handler:
            return _create_result("fail", f"Unknown check type: {check_type}")

        try:
            return handler(rule, path)
        except Exception as exc:
            return _handle_specific_exceptions(f"executing {check_type} check", exc)

    def _handle_compare_version(self, rule: Rule, path: Path) -> dict[str, str]:
        """Handle version comparison checks."""
        condition = rule.get("condition", {}) or {}
        target = condition.get("target")
        operator = condition.get("operator")
        value = condition.get("value")

        if not (target and operator and value):
            return _create_result("fail", "Missing condition fields for compare_version")

        if target == "python":
            current_version = (
                f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
            )
            current = parse_version(current_version)
            expected = parse_version(str(value))
            passed = {
                ">=": current >= expected,
                "<=": current <= expected,
                "==": current == expected,
                ">": current > expected,
                "<": current < expected,
            }.get(operator, False)
            # Simplified concise-style detail for Python version
            return _create_result(
                "pass" if passed else "fail",
                f"Python {current_version} ({operator}{value})",
            )

        if target == "func_core_tools":
            raw = resolve_target_value("func_core_tools")
            if raw in ("not_installed", "timeout", "unknown_error") or raw.startswith("error_"):
                return _create_result("fail", f"func: {raw}")
            try:
                current = parse_version(raw)
            except InvalidVersion:
                return _create_result("fail", f"func version unparseable: {raw}")
            expected = parse_version(str(value))
            passed = {
                ">=": current >= expected,
                "<=": current <= expected,
                "==": current == expected,
                ">": current > expected,
                "<": current < expected,
            }.get(operator, False)
            return _create_result(
                "pass" if passed else "fail",
                f"func {raw} ({operator}{value})",
            )

        return _create_result("fail", f"Unknown target for version comparison: {target}")

    def _handle_env_var_exists(self, rule: Rule, path: Path) -> dict[str, str]:
        """Handle environment variable existence checks."""
        condition = rule.get("condition", {}) or {}
        target = condition.get("target")

        if not target:
            return _create_result("fail", "Missing environment variable name")

        exists = os.getenv(target) is not None
        return _create_result(
            "pass" if exists else "fail",
            f"{target} is {'set' if exists else 'not set'}",
        )

    def _handle_path_exists(self, rule: Rule, path: Path) -> dict[str, str]:
        """Handle path existence checks."""
        condition = rule.get("condition", {}) or {}
        target = condition.get("target")

        if not target:
            return _create_result("fail", "Missing target path")

        if target == "sys.executable":
            if not sys.executable:
                return _create_result("fail", "sys.executable is empty")
            resolved_path = Path(sys.executable)
        else:
            resolved_path = path / target

        exists = resolved_path.exists()
        detail = f"{resolved_path} {'exists' if exists else 'missing'}"
        if not exists and not rule.get("required", True):
            detail += " (optional)"
        return _create_result("pass" if exists else "fail", detail)

    def _handle_file_exists(self, rule: Rule, path: Path) -> dict[str, str]:
        """Handle file existence checks."""
        condition = rule.get("condition", {}) or {}
        target = condition.get("target")

        if not target:
            return _create_result("fail", "Missing file path")
        file_path = path / target
        exists = file_path.is_file()
        detail = f"{file_path} {'exists' if exists else 'not found'}"
        if not exists and not rule.get("required", True):
            detail += " (optional)"
        return _create_result("pass" if exists else "fail", detail)

    def _handle_package_installed(self, rule: Rule, path: Path) -> dict[str, str]:
        """Handle Python package installation checks."""
        condition = rule.get("condition", {}) or {}
        target = condition.get("target")

        if not target:
            return _create_result("fail", "Missing package name")

        import_path_str: str = str(target)

        try:
            __import__(import_path_str)
            return _create_result("pass", f"Module '{import_path_str}' is installed")
        except ImportError as exc:
            return _create_result("fail", f"Module '{import_path_str}' is not installed: {exc}")
        except Exception as exc:
            return _handle_exception(f"importing module '{import_path_str}'", exc)

    def _handle_source_code_contains(self, rule: Rule, path: Path) -> dict[str, str]:
        """Handle source code keyword search checks (string or AST mode)."""
        condition = rule.get("condition", {}) or {}
        keyword = condition.get("keyword")
        mode = condition.get("mode", "string")

        if not isinstance(keyword, str):
            return _create_result("fail", "Missing or invalid 'keyword' in condition")

        found = False
        if mode == "ast":
            ast_identifier = keyword.strip().lstrip("@").rstrip(".")
            if not ast_identifier:
                return _create_result("fail", "Invalid 'keyword' for AST mode")
            for _py_file, content in _iter_project_py_contents(path):
                if _source_contains_ast(content, ast_identifier):
                    found = True
                    break
        else:
            for _py_file, content in _iter_project_py_contents(path):
                if keyword in content:
                    found = True
                    break

        detail_suffix = " (AST)" if mode == "ast" else ""
        return _create_result(
            "pass" if found else "fail",
            (
                f"Keyword '{keyword}' {'found' if found else 'not found'} "
                f"in source code{detail_suffix}"
            ),
        )

    def _handle_package_declared(self, rule: Rule, path: Path) -> dict[str, str]:
        """Check that a package name appears in requirements.txt (declaration-level)."""
        condition = rule.get("condition", {}) or {}
        package_name_obj = condition.get("package") or condition.get("target")
        req_file_obj = condition.get("file", "requirements.txt")
        if not isinstance(package_name_obj, str):
            return _create_result("fail", "Missing 'package' in condition")
        package_name = package_name_obj
        req_file = str(req_file_obj)
        req_path = path / Path(req_file)
        if not req_path.exists():
            return _create_result("fail", f"{req_path} not found")
        try:
            content = req_path.read_text(encoding="utf-8").splitlines()
        except Exception as exc:
            return _handle_specific_exceptions(f"reading {req_file}", exc)
        normalized = [
            re.split(pattern=r"[=<>!~]", string=line.strip(), maxsplit=1)[0].lower()
            for line in content
            if line.strip() and not line.strip().startswith("#")
        ]
        declared = package_name.lower() in normalized
        return _create_result(
            "pass" if declared else "fail",
            f"Package '{package_name}' {'declared' if declared else 'not declared'} in {req_file}",
        )

    def _handle_conditional_exists(self, rule: Rule, path: Path) -> dict[str, str]:
        """Handle host.json checks that only matter when a related feature is detected."""
        durable_keywords = [
            "durable",
            "DurableOrchestrationContext",
            "durable_functions",
            "orchestrator",
        ]
        uses_durable = False

        try:
            for py_file in path.rglob("*.py"):
                content = _read_project_python_file(py_file)
                if content is None:
                    continue
                lowered = content.lower()
                if any(k in lowered for k in durable_keywords):
                    uses_durable = True
                    break
        except Exception as exc:
            return _handle_specific_exceptions("scanning for durable usage", exc)

        if not uses_durable:
            return _create_result("pass", "No Durable Functions usage detected; check skipped")

        condition = rule.get("condition", {}) or {}
        jsonpath = condition.get("jsonpath")

        if not jsonpath:
            return _create_result(
                "fail",
                "Missing jsonpath in condition for conditional_exists check",
            )

        if not isinstance(jsonpath, str):
            return _create_result("fail", "jsonpath must be a string for conditional_exists check")

        host_path = path / "host.json"
        if not host_path.exists():
            return _create_result("fail", "host.json missing (durable usage)")

        try:
            host_data = json.loads(host_path.read_text(encoding="utf-8"))
        except Exception as exc:
            return _handle_specific_exceptions("reading host.json", exc)

        pointer = jsonpath.lstrip("$.")
        parts = pointer.split(".") if pointer else []
        node = host_data
        for p in parts:
            if isinstance(node, dict) and p in node:
                node = node[p]
            else:
                return _create_result(
                    "fail",
                    f"Required host.json property '{jsonpath}' not found",
                )

        return _create_result("pass", f"host.json contains '{jsonpath}'")

    def _handle_callable_detection(self, rule: Rule, path: Path) -> dict[str, str]:
        """Detect ASGI/WSGI callable exposure in source files (basic heuristics)."""
        patterns = [
            r"\bFastAPI\s*\(|\bStarlette\s*\(|\bFlask\s*\(|\bQuart\s*\(",
            r"\bapp\s*=",
            r"ASGIApp|WSGIApp|asgi_app|wsgi_app",
        ]

        found_items: List[str] = []
        try:
            for py_file in path.rglob("*.py"):
                content = _read_project_python_file(py_file)
                if content is None:
                    continue
                for pat in patterns:
                    if re.search(pat, content):
                        found_items.append(f"{py_file.relative_to(path)}:{pat}")
                        break
        except Exception as exc:
            return _handle_specific_exceptions("scanning for ASGI/WSGI callables", exc)

        if found_items:
            return _create_result("pass", f"Detected ASGI/WSGI-related patterns: {found_items[:3]}")

        return _create_result("fail", "No ASGI/WSGI callable detected in project source")

    # --- adapters / additional handlers ---

    def _handle_executable_exists(self, rule: Rule, path: Path) -> dict[str, str]:
        """Check if an executable is available on PATH."""
        condition = rule.get("condition", {}) or {}
        target = condition.get("target")
        if not target:
            return _create_result("fail", "Missing 'target' for executable_exists")
        found = shutil.which(target) is not None
        if found:
            # Concise style: "<name> detected"
            return _create_result("pass", f"{target} detected")
        return _create_result("fail", f"{target} not found")

    def _handle_any_of_exists(self, rule: Rule, path: Path) -> dict[str, str]:
        """Check if any of a list of targets exist (env vars, host.json keys, files)."""
        condition = rule.get("condition", {}) or {}
        targets = condition.get("targets", [])
        if not targets or not isinstance(targets, list):
            return _create_result("fail", "Missing 'targets' list for any_of_exists")

        for t in targets:
            if isinstance(t, str) and t.startswith("host.json:"):
                key = t.split("host.json:", 1)[1].lstrip(".")
                host_path = path / "host.json"
                if host_path.exists():
                    try:
                        data = json.loads(host_path.read_text(encoding="utf-8"))
                        node = data
                        for p in key.split("."):
                            if isinstance(node, dict) and p in node:
                                node = node[p]
                            else:
                                node = None
                                break
                        if node is not None:
                            return _create_result("pass", f"host.json:{key} present")
                    except json.JSONDecodeError as exc:
                        logger.debug(f"Skip invalid host.json while checking {key}: {exc}")
            else:
                # env var
                if os.getenv(str(t)) is not None:
                    return _create_result("pass", f"env:{t} set")
                # file path
                candidate = path / str(t)
                if candidate.exists():
                    return _create_result("pass", f"path:{candidate.name} present")
        # Shorter failure detail for concise output integration
        return _create_result("fail", "Targets not found")

    def _handle_file_glob_check(self, rule: Rule, path: Path) -> dict[str, str]:
        """Detect unwanted files by glob patterns."""
        condition = rule.get("condition", {}) or {}
        patterns = condition.get("patterns", [])
        if not patterns or not isinstance(patterns, list):
            return _create_result("fail", "Missing 'patterns' list for file_glob_check")
        matches: List[str] = []
        try:
            for pat in patterns:
                for p in path.rglob(pat):
                    matches.append(str(p.relative_to(path)))
                    if len(matches) >= 5:
                        break
                if len(matches) >= 5:
                    break
        except Exception as exc:
            return _handle_specific_exceptions("checking file globs", exc)
        if matches:
            return _create_result("fail", f"Found unwanted files: {matches[:5]}")
        return _create_result("pass", "No unwanted files detected")

    def _handle_host_json_property(self, rule: Rule, path: Path) -> dict[str, str]:
        """Check a property exists in host.json using simple jsonpath-like pointer."""
        condition = rule.get("condition", {}) or {}
        jsonpath = condition.get("jsonpath")
        if not jsonpath or not isinstance(jsonpath, str):
            return _create_result("fail", "Missing or invalid 'jsonpath' in condition")
        host_path = path / "host.json"
        if not host_path.exists():
            return _create_result("fail", "host.json not found")
        try:
            host_data = json.loads(host_path.read_text(encoding="utf-8"))
        except Exception as exc:
            return _handle_specific_exceptions("reading host.json", exc)
        pointer = jsonpath.lstrip("$.")
        parts = pointer.split(".") if pointer else []
        node = host_data
        for p in parts:
            if isinstance(node, dict) and p in node:
                node = node[p]
            else:
                return _create_result("fail", f"host.json property '{jsonpath}' not found")
        return _create_result("pass", f"host.json contains '{jsonpath}'")


# Global registry instance
_registry = HandlerRegistry()


def generic_handler(rule: Rule, path: Path) -> dict[str, str]:
    """
    Execute a diagnostic rule based on its type and condition.

    This function maintains backward compatibility while delegating to the registry.

    Args:
        rule: The diagnostic rule to execute.
        path: Path to the Azure Functions project.

    Returns:
        A dictionary with the status and detail of the check.
    """
    return _registry.handle(rule, path)
