from __future__ import annotations

import re
from typing import Dict, List, Tuple

from bastion.analyzers.base import BaseAnalyzer
from bastion.models import Capability, Evidence

try:
    import esprima
    HAS_ESPRIMA = True
except ImportError:
    HAS_ESPRIMA = False

CATEGORY_DESCRIPTIONS = {
    "system_execution": "System command execution capability detected",
    "network_access": "Network access capability detected",
    "secret_access": "Secret/environment variable access detected",
    "dynamic_code": "Dynamic code execution capability detected",
    "obfuscation": "Code obfuscation patterns detected",
    "file_system_access": "File system access capability detected",
}


class JavaScriptAnalyzer(BaseAnalyzer):

    def analyze(self, file_path: str) -> List[Capability]:
        try:
            with open(file_path, "r", encoding="utf-8", errors="replace") as f:
                source = f.read()
        except (OSError, IOError):
            return []

        lines = source.splitlines()

        is_typescript = file_path.endswith((".ts", ".tsx"))

        if HAS_ESPRIMA and not is_typescript:
            try:
                return self._ast_analyze(source, file_path, lines)
            except Exception:
                pass

        return self._regex_analyze(file_path, lines)

    def _line_snippet(self, lines: List[str], lineno: int) -> str:
        if 1 <= lineno <= len(lines):
            return lines[lineno - 1].rstrip()
        return ""

    def _add_evidence(
        self,
        capabilities: Dict[str, Capability],
        category: str,
        severity: str,
        file_path: str,
        lineno: int,
        snippet: str,
        pattern_name: str,
    ) -> None:
        evidence = Evidence(
            file=file_path,
            line=lineno,
            code_snippet=snippet,
            pattern_name=pattern_name,
        )
        if category not in capabilities:
            capabilities[category] = Capability(
                category=category,
                description=CATEGORY_DESCRIPTIONS.get(category, category),
                evidence=[evidence],
                severity=severity,
            )
        else:
            cap = capabilities[category]
            cap.evidence.append(evidence)
            severity_order = {"Low": 0, "Medium": 1, "High": 2, "Critical": 3}
            if severity_order.get(severity, 0) > severity_order.get(cap.severity, 0):
                cap.severity = severity

    def _ast_analyze(self, source: str, file_path: str, lines: List[str]) -> List[Capability]:
        tree = esprima.parseScript(source, tolerant=True, loc=True)  # type: ignore[possibly-unbound]
        capabilities: Dict[str, Capability] = {}

        self._walk_ast(tree, file_path, lines, capabilities)
        self._check_obfuscation_patterns(file_path, lines, capabilities)

        return list(capabilities.values())

    def _walk_ast(self, node, file_path: str, lines: List[str], capabilities: Dict[str, Capability]) -> None:
        if node is None:
            return

        if hasattr(node, "type"):
            node_type = node.type

            if node_type == "CallExpression":
                self._check_ast_call(node, file_path, lines, capabilities)

            elif node_type == "ImportDeclaration":
                self._check_ast_import(node, file_path, lines, capabilities)

            elif node_type == "MemberExpression":
                self._check_ast_member(node, file_path, lines, capabilities)

            elif node_type == "NewExpression":
                self._check_ast_new(node, file_path, lines, capabilities)

        for attr_name in dir(node):
            if attr_name.startswith("_"):
                continue
            attr = getattr(node, attr_name, None)
            if attr is None:
                continue
            if isinstance(attr, list):
                for item in attr:
                    if hasattr(item, "type"):
                        self._walk_ast(item, file_path, lines, capabilities)
            elif hasattr(attr, "type") and attr is not node:
                self._walk_ast(attr, file_path, lines, capabilities)

    def _get_lineno(self, node) -> int:
        if hasattr(node, "loc") and node.loc and hasattr(node.loc, "start"):
            return node.loc.start.line
        return 0

    def _check_ast_call(self, node, file_path: str, lines: List[str], capabilities: Dict[str, Capability]) -> None:
        lineno = self._get_lineno(node)
        snippet = self._line_snippet(lines, lineno)
        callee = node.callee

        if hasattr(callee, "type"):
            if callee.type == "Identifier":
                name = callee.name
                if name == "eval":
                    self._add_evidence(capabilities, "dynamic_code", "Critical", file_path, lineno, snippet, "eval_call")
                elif name == "fetch":
                    self._add_evidence(capabilities, "network_access", "High", file_path, lineno, snippet, "fetch_call")
                elif name == "require":
                    self._check_require(node, file_path, lineno, lines, capabilities)
                elif name == "Function":
                    self._add_evidence(capabilities, "dynamic_code", "Critical", file_path, lineno, snippet, "Function_constructor")

            elif callee.type == "MemberExpression":
                obj_name = self._get_member_name(callee)
                if obj_name:
                    if obj_name in ("Deno.run", "Bun.spawn", "Bun.spawnSync"):
                        self._add_evidence(capabilities, "system_execution", "Critical", file_path, lineno, snippet, obj_name)
                    elif obj_name.startswith("vm."):
                        self._add_evidence(capabilities, "dynamic_code", "Critical", file_path, lineno, snippet, obj_name)
                    elif obj_name.startswith("fs."):
                        self._add_evidence(capabilities, "file_system_access", "Medium", file_path, lineno, snippet, obj_name)

    def _check_require(self, node, file_path: str, lineno: int, lines: List[str], capabilities: Dict[str, Capability]) -> None:
        snippet = self._line_snippet(lines, lineno)

        if not node.arguments:
            return

        first_arg = node.arguments[0]
        if hasattr(first_arg, "type") and first_arg.type == "Literal":
            mod = first_arg.value
            if mod == "child_process":
                self._add_evidence(capabilities, "system_execution", "Critical", file_path, lineno, snippet, "require_child_process")
            elif mod in ("http", "https", "node-fetch", "axios"):
                self._add_evidence(capabilities, "network_access", "High", file_path, lineno, snippet, f"require_{mod}")
            elif mod == "dotenv":
                self._add_evidence(capabilities, "secret_access", "High", file_path, lineno, snippet, "require_dotenv")
            elif mod == "vm":
                self._add_evidence(capabilities, "dynamic_code", "Critical", file_path, lineno, snippet, "require_vm")
            elif mod in ("fs", "fs/promises"):
                self._add_evidence(capabilities, "file_system_access", "Medium", file_path, lineno, snippet, f"require_{mod}")
        else:
            self._add_evidence(capabilities, "dynamic_code", "Critical", file_path, lineno, snippet, "dynamic_require")

    def _check_ast_import(self, node, file_path: str, lines: List[str], capabilities: Dict[str, Capability]) -> None:
        lineno = self._get_lineno(node)
        snippet = self._line_snippet(lines, lineno)

        if hasattr(node, "source") and hasattr(node.source, "value"):
            mod = node.source.value
            if mod == "child_process":
                self._add_evidence(capabilities, "system_execution", "Critical", file_path, lineno, snippet, "import_child_process")
            elif mod in ("http", "https", "node-fetch", "axios"):
                self._add_evidence(capabilities, "network_access", "High", file_path, lineno, snippet, f"import_{mod}")
            elif mod == "dotenv":
                self._add_evidence(capabilities, "secret_access", "High", file_path, lineno, snippet, "import_dotenv")
            elif mod == "vm":
                self._add_evidence(capabilities, "dynamic_code", "Critical", file_path, lineno, snippet, "import_vm")
            elif mod in ("fs", "fs/promises"):
                self._add_evidence(capabilities, "file_system_access", "Medium", file_path, lineno, snippet, f"import_{mod}")

    def _check_ast_member(self, node, file_path: str, lines: List[str], capabilities: Dict[str, Capability]) -> None:
        lineno = self._get_lineno(node)
        snippet = self._line_snippet(lines, lineno)
        name = self._get_member_name(node)

        if name == "process.env":
            self._add_evidence(capabilities, "secret_access", "High", file_path, lineno, snippet, "process.env")
        elif name == "Deno.env":
            self._add_evidence(capabilities, "secret_access", "High", file_path, lineno, snippet, "Deno.env")

    def _check_ast_new(self, node, file_path: str, lines: List[str], capabilities: Dict[str, Capability]) -> None:
        lineno = self._get_lineno(node)
        snippet = self._line_snippet(lines, lineno)

        if hasattr(node.callee, "type"):
            if node.callee.type == "Identifier":
                name = node.callee.name
                if name == "Function":
                    self._add_evidence(capabilities, "dynamic_code", "Critical", file_path, lineno, snippet, "new_Function")
                elif name == "WebSocket":
                    self._add_evidence(capabilities, "network_access", "High", file_path, lineno, snippet, "WebSocket")
                elif name == "XMLHttpRequest":
                    self._add_evidence(capabilities, "network_access", "High", file_path, lineno, snippet, "XMLHttpRequest")

    def _get_member_name(self, node) -> str | None:
        if not hasattr(node, "type") or node.type != "MemberExpression":
            return None
        obj = node.object
        prop = node.property
        prop_name = None
        if hasattr(prop, "name"):
            prop_name = prop.name
        elif hasattr(prop, "value"):
            prop_name = str(prop.value)

        if prop_name is None:
            return None

        if hasattr(obj, "type") and obj.type == "Identifier":
            return f"{obj.name}.{prop_name}"
        elif hasattr(obj, "type") and obj.type == "MemberExpression":
            parent = self._get_member_name(obj)
            if parent:
                return f"{parent}.{prop_name}"
        return None

    def _regex_analyze(self, file_path: str, lines: List[str]) -> List[Capability]:
        capabilities: Dict[str, Capability] = {}

        patterns: List[Tuple[str, str, str, str]] = [
            (r'''(?:require\s*\(\s*['"]child_process['"]\s*\)|from\s+['"]child_process['"])''', "system_execution", "Critical", "child_process"),
            (r'\b(?:exec|execSync|spawn|spawnSync|fork)\s*\(', "system_execution", "Critical", "exec_spawn_call"),
            (r'\bDeno\.run\s*\(', "system_execution", "Critical", "Deno.run"),
            (r'\bBun\.spawn\w*\s*\(', "system_execution", "Critical", "Bun.spawn"),

            (r'\bfetch\s*\(', "network_access", "High", "fetch_call"),
            (r'''(?:require\s*\(\s*['"](?:axios|node-fetch|https?)['"]\s*\)|from\s+['"](?:axios|node-fetch|https?)['"])''', "network_access", "High", "http_module"),
            (r'\bXMLHttpRequest\b', "network_access", "High", "XMLHttpRequest"),
            (r'\bnew\s+WebSocket\s*\(', "network_access", "High", "WebSocket"),

            (r'\bprocess\.env\b', "secret_access", "High", "process.env"),
            (r'\bDeno\.env\b', "secret_access", "High", "Deno.env"),
            (r'''(?:require\s*\(\s*['"]dotenv['"]\s*\)|from\s+['"]dotenv['"])''', "secret_access", "High", "dotenv"),
            (r'''\.env['"]''', "secret_access", "High", "env_file_read"),

            (r'\beval\s*\(', "dynamic_code", "Critical", "eval_call"),
            (r'\bnew\s+Function\s*\(', "dynamic_code", "Critical", "new_Function"),
            (r'(?<!\w)Function\s*\(', "dynamic_code", "Critical", "Function_constructor"),
            (r'\bvm\.\w+', "dynamic_code", "Critical", "vm_usage"),
            (r'\brequire\s*\(\s*[^\'"]', "dynamic_code", "Critical", "dynamic_require"),

            (r'''(?:require\s*\(\s*['"](?:fs|fs/promises)['"]\s*\)|from\s+['"](?:fs|fs/promises)['"])''', "file_system_access", "Medium", "fs_module"),
            (r'\bfs\.(readFile|writeFile|readFileSync|writeFileSync|readdir|readdirSync|mkdir|mkdirSync|unlink|unlinkSync|rename|renameSync|copyFile|copyFileSync|appendFile|appendFileSync|access|accessSync|stat|statSync|createReadStream|createWriteStream)\s*\(', "file_system_access", "Medium", "fs_call"),
            (r'\bfs\.promises\.\w+\s*\(', "file_system_access", "Medium", "fs_promises_call"),
        ]

        for i, line in enumerate(lines, 1):
            for pattern, category, severity, name in patterns:
                if re.search(pattern, line):
                    self._add_evidence(
                        capabilities, category, severity,
                        file_path, i, line.rstrip(), name,
                    )

        self._check_obfuscation_patterns(file_path, lines, capabilities)

        return list(capabilities.values())

    def _check_obfuscation_patterns(self, file_path: str, lines: List[str], capabilities: Dict[str, Capability]) -> None:
        for i, line in enumerate(lines, 1):
            stripped = line.strip()

            if re.search(r'\batob\s*\(', stripped) and re.search(r'\beval\s*\(', stripped):
                self._add_evidence(
                    capabilities, "obfuscation", "Critical",
                    file_path, i, stripped, "atob_eval",
                )

            if re.search(r'Buffer\.from\s*\(', stripped) and "base64" in stripped.lower() and re.search(r'\beval\s*\(', stripped):
                self._add_evidence(
                    capabilities, "obfuscation", "Critical",
                    file_path, i, stripped, "buffer_base64_eval",
                )

            if re.search(r'String\.fromCharCode\s*\(', stripped) and len(re.findall(r'\d+', stripped)) > 10:
                self._add_evidence(
                    capabilities, "obfuscation", "Critical",
                    file_path, i, stripped[:200] + ("..." if len(stripped) > 200 else ""),
                    "fromCharCode_suspicious",
                )

            if len(stripped) > 500 and re.search(r'[A-Za-z0-9+/=]{500,}', stripped):
                self._add_evidence(
                    capabilities, "obfuscation", "Critical",
                    file_path, i, stripped[:200] + "...[truncated]",
                    "long_encoded_string",
                )
