from __future__ import annotations

import json
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple, Union

from .config import DEFAULT_SANITIZERS, DEFAULT_SINKS, DEFAULT_SOURCES
from .models import FindingDetail, Severity


def _php_ast_available() -> bool:
    """Return True if php with ext-ast is available."""
    php = shutil.which("php")
    if not php:
        return False
    try:
        out = subprocess.run([php, "-r", "echo function_exists('ast\\parse_code') ? 'yes' : 'no';"], capture_output=True, text=True, timeout=5)
        return out.stdout.strip() == "yes"
    except Exception:
        return False


def parse_php_ast(path: Path) -> Optional[dict]:
    """
    Use PHP ext-ast to parse a file into a JSON-serializable tree.
    Returns None if PHP/ext-ast is unavailable or parsing fails.
    """
    if not _php_ast_available():
        return None
    php = shutil.which("php")
    script = r"""
    <?php
    if (!function_exists('ast\parse_code')) { exit(1); }
    $code = file_get_contents($argv[1]);
    $ast = ast\parse_code($code, $version=80, $filename=$argv[1]);
    function node_to_array($node) {
        if ($node instanceof ast\Node) {
            $children = [];
            foreach ($node->children as $key => $child) {
                $children[$key] = node_to_array($child);
            }
            return [
                'kind' => $node->kind,
                'lineno' => $node->lineno,
                'children' => $children,
            ];
        }
        return $node;
    }
    echo json_encode(node_to_array($ast));
    """
    try:
        result = subprocess.run([php, "-r", script, str(path)], capture_output=True, text=True, timeout=10)
        if result.returncode != 0 or not result.stdout:
            return None
        return json.loads(result.stdout)
    except Exception:
        return None


@dataclass
class AstAnalysisResult:
    findings: List[FindingDetail]


class AstTaintAnalyzer:
    def __init__(self, sinks: Dict[str, str], sanitizers: Set[str]):
        self.sinks = sinks
        self.sanitizers = sanitizers

    def analyze(self, tree: dict, file_path: Path) -> AstAnalysisResult:
        self.findings: List[FindingDetail] = []
        self.tainted: Set[str] = set()
        self._walk(tree, file_path)
        return AstAnalysisResult(findings=self.findings)

    # --- AST walking helpers ---
    def _walk(self, node: Union[dict, list, str, int, None], file_path: Path) -> None:
        if isinstance(node, dict):
            kind = node.get("kind")
            children = node.get("children", {})
            if kind in (50, 57):  # AST_STMT_LIST or AST_TOPLEVEL
                for child in children.values():
                    self._walk(child, file_path)
            elif kind == 257:  # AST_ASSIGN
                var_node = children.get("var")
                expr_node = children.get("expr")
                var_name = self._var_name(var_node)
                if var_name:
                    if self._expr_is_sanitized(expr_node):
                        self.tainted.discard(var_name)
                    elif self._expr_is_tainted(expr_node):
                        self.tainted.add(var_name)
            elif kind == 1048:  # AST_ECHO
                exprs = children.values()
                for expr in exprs:
                    if self._expr_is_tainted(expr):
                        self._add_finding(
                            category="xss",
                            msg="User input echoed without escaping",
                            severity=Severity.HIGH,
                            file_path=file_path,
                            lineno=node.get("lineno"),
                        )
            elif kind == 269:  # AST_CALL
                self._handle_call(node, file_path)
            elif kind == 270:  # AST_METHOD_CALL
                self._handle_method_call(node, file_path)
            else:
                for child in children.values():
                    self._walk(child, file_path)
        elif isinstance(node, list):
            for item in node:
                self._walk(item, file_path)

    def _handle_call(self, node: dict, file_path: Path) -> None:
        children = node.get("children", {})
        name_node = children.get("expr")
        args = children.get("args", [])
        func_name = self._name_from_expr(name_node)
        if not func_name:
            return
        arg_tainted = any(self._expr_is_tainted(arg) for arg in args)
        if func_name in DEFAULT_SOURCES:
            # Assignments capture taint; here we only mark side-effects
            return
        if func_name in self.sinks and arg_tainted:
            self._add_finding(
                category="source-to-sink",
                msg=f"Tainted input flows into {func_name} ({self.sinks[func_name]})",
                severity=Severity.HIGH,
                file_path=file_path,
                lineno=node.get("lineno"),
            )
        if func_name == "unserialize" and arg_tainted:
            self._add_finding(
                category="object-injection",
                msg="unserialize on user input",
                severity=Severity.CRITICAL,
                file_path=file_path,
                lineno=node.get("lineno"),
            )

    def _handle_method_call(self, node: dict, file_path: Path) -> None:
        children = node.get("children", {})
        obj = children.get("expr")
        method = children.get("method")
        args = children.get("args", [])
        obj_name = self._var_name(obj)
        method_name = self._name_from_expr(method)
        arg_tainted = any(self._expr_is_tainted(arg) for arg in args)
        if not method_name:
            return
        if obj_name == "wpdb" and method_name in {"query", "get_results", "get_row", "get_var"}:
            if method_name != "prepare" and arg_tainted:
                self._add_finding(
                    category="sql-injection",
                    msg="$wpdb call without prepare uses user input",
                    severity=Severity.CRITICAL,
                    file_path=file_path,
                    lineno=node.get("lineno"),
                )
        if method_name in self.sinks.values() and arg_tainted:
            self._add_finding(
                category="source-to-sink",
                msg=f"Tainted input flows into {method_name}",
                severity=Severity.HIGH,
                file_path=file_path,
                lineno=node.get("lineno"),
            )

    def _expr_is_tainted(self, node: Union[dict, list, str, int, None]) -> bool:
        if isinstance(node, dict):
            kind = node.get("kind")
            children = node.get("children", {})
            # Variable usage
            if kind == 256:  # AST_VAR
                name = children.get("name")
                return isinstance(name, str) and name in self.tainted
            # Array dim fetch (superglobals)
            if kind == 265:  # AST_DIM
                var = children.get("expr")
                var_name = self._var_name(var)
                if var_name and var_name.strip("$") in DEFAULT_SOURCES:
                    return True
                return self._expr_is_tainted(var)
            # Function call: source?
            if kind == 269:  # AST_CALL
                func = self._name_from_expr(children.get("expr"))
                if func in DEFAULT_SOURCES:
                    return True
            # Binary ops: tainted if any side tainted
            for child in children.values():
                if self._expr_is_tainted(child):
                    return True
            return False
        if isinstance(node, list):
            return any(self._expr_is_tainted(item) for item in node)
        return False

    def _expr_is_sanitized(self, node: Union[dict, list, str, int, None]) -> bool:
        if isinstance(node, dict) and node.get("kind") == 269:  # AST_CALL
            func = self._name_from_expr(node.get("children", {}).get("expr"))
            return func in DEFAULT_SANITIZERS
        return False

    @staticmethod
    def _var_name(node: Union[dict, list, str, int, None]) -> Optional[str]:
        if isinstance(node, dict) and node.get("kind") == 256:  # AST_VAR
            name = node.get("children", {}).get("name")
            if isinstance(name, str):
                return name
        return None

    @staticmethod
    def _name_from_expr(node: Union[dict, list, str, int, None]) -> Optional[str]:
        if isinstance(node, dict):
            if node.get("kind") == 256:  # AST_VAR
                name = node.get("children", {}).get("name")
                if isinstance(name, str):
                    return name
            if node.get("kind") == 270:  # AST_METHOD_CALL as name part
                return AstTaintAnalyzer._name_from_expr(node.get("children", {}).get("method"))
        if isinstance(node, str):
            return node
        return None

    def _add_finding(self, category: str, msg: str, severity: Severity, file_path: Path, lineno: Optional[int]) -> None:
        self.findings.append(
            FindingDetail(
                category=category,
                message=msg,
                severity=severity,
                file=str(file_path),
                line=lineno,
            )
        )
