"""Tree-sitter based specialists for TypeScript/JavaScript/TSX files."""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional

logger = logging.getLogger(__name__)

try:
    import tree_sitter_javascript as _tsjs
    import tree_sitter_typescript as _tsts
    from tree_sitter import Language, Parser, Node

    _JS_LANG = Language(_tsjs.language())
    _TS_LANG = Language(_tsts.language_typescript())
    _TSX_LANG = Language(_tsts.language_tsx())
    _HAS_TREE_SITTER = True
except ImportError:
    _HAS_TREE_SITTER = False

from autodebug.agent.specialists.base import Finding


# ---------------------------------------------------------------------------
# Tree-sitter helpers
# ---------------------------------------------------------------------------

def _get_parser(file_path: str) -> Optional["Parser"]:
    """Get the right parser for a file extension."""
    if not _HAS_TREE_SITTER:
        return None
    ext = Path(file_path).suffix.lower()
    lang_map = {
        ".js": _JS_LANG,
        ".jsx": _JS_LANG,
        ".ts": _TS_LANG,
        ".tsx": _TSX_LANG,
        ".mjs": _JS_LANG,
        ".mts": _TS_LANG,
    }
    lang = lang_map.get(ext)
    if not lang:
        return None
    return Parser(lang)


def _walk(node: "Node"):
    """Walk all nodes in a tree-sitter tree."""
    yield node
    for child in node.children:
        yield from _walk(child)


def _node_text(node: "Node", source: bytes) -> str:
    """Get the text of a node."""
    return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")


def _find_enclosing_function(node: "Node") -> Optional[str]:
    """Walk up to find the enclosing function name."""
    current = node.parent
    while current:
        if current.type in ("function_declaration", "method_definition",
                            "arrow_function", "function"):
            # Try to get the name
            name_node = current.child_by_field_name("name")
            if name_node:
                return name_node.text.decode("utf-8", errors="replace")
            # For arrow functions assigned to const
            if current.parent and current.parent.type == "variable_declarator":
                name_node = current.parent.child_by_field_name("name")
                if name_node:
                    return name_node.text.decode("utf-8", errors="replace")
            return None
        current = current.parent
    return None


# ---------------------------------------------------------------------------
# Specialist checks
# ---------------------------------------------------------------------------

def _is_awaited_or_chained(node: "Node") -> bool:
    """Check if a call_expression is awaited, either directly or via chaining.

    Handles patterns like:
      await res.json()              — direct await
      await res.json().catch(...)   — chained then awaited
    """
    current = node
    while current.parent:
        parent = current.parent
        if parent.type == "await_expression":
            return True
        # Walk through chained member_expression + call_expression
        # e.g. res.json().catch(...) → call(member(call(member)))
        if parent.type == "member_expression":
            current = parent
            continue
        if parent.type == "call_expression" and current == parent.child_by_field_name("function"):
            # current is the function part of a parent call — keep walking up the chain
            current = parent
            continue
        break
    return False


def _is_inside_promise_combinator(node: "Node") -> bool:
    """Check if a call is inside Promise.all / Promise.allSettled / Promise.race."""
    current = node.parent
    while current:
        if current.type == "call_expression":
            func = current.child_by_field_name("function")
            if func and func.type == "member_expression":
                obj = func.child_by_field_name("object")
                prop = func.child_by_field_name("property")
                if (obj and obj.type == "identifier"
                        and obj.text and obj.text.decode("utf-8", errors="replace") == "Promise"
                        and prop and prop.text
                        and prop.text.decode("utf-8", errors="replace") in ("all", "allSettled", "race")):
                    return True
        current = current.parent
    return False


def _check_missing_await(node: "Node", source: bytes) -> list[Finding]:
    """Detect missing await on async calls like .json(), fetch(), etc."""
    findings: list[Finding] = []

    # Known async methods that need await
    _ASYNC_METHODS = {
        "json", "text", "blob", "arrayBuffer", "formData",  # Response methods
        "getToken", "auth",  # Clerk
    }
    _ASYNC_FUNCTIONS = {
        "fetch", "getToken", "auth",
    }

    for n in _walk(node):
        if n.type != "call_expression":
            continue

        line = n.start_point[0] + 1

        # Check if the call is already awaited (directly or via chaining like .json().catch())
        if _is_awaited_or_chained(n):
            continue
        # Check if it's in a return that might be awaited by caller
        parent = n.parent
        if parent and parent.type == "return_statement":
            continue
        # Check if inside Promise.all / Promise.allSettled / Promise.race
        if _is_inside_promise_combinator(n):
            continue

        # Check if it's a call on .json(), .text() etc
        func_node = n.child_by_field_name("function")
        if func_node and func_node.type == "member_expression":
            prop = func_node.child_by_field_name("property")
            if prop:
                method_name = _node_text(prop, source)
                if method_name in _ASYNC_METHODS:
                    # Check parent function is async
                    fn = n.parent
                    while fn:
                        if fn.type in ("function_declaration", "method_definition",
                                       "arrow_function", "function"):
                            if b"async" in source[fn.start_byte:fn.start_byte + 20]:
                                findings.append(Finding(
                                    line=line,
                                    severity="HIGH",
                                    message=f"Missing `await` on `.{method_name}()` — returns a Promise, not the resolved value",
                                    specialist="ts_missing_await",
                                    confidence="DEFINITE",
                                ))
                            break
                        fn = fn.parent

        # Check if it's a bare fetch() without await
        if func_node and func_node.type == "identifier":
            func_name = _node_text(func_node, source)
            if func_name in _ASYNC_FUNCTIONS:
                fn = n.parent
                while fn:
                    if fn.type in ("function_declaration", "method_definition",
                                   "arrow_function", "function"):
                        if b"async" in source[fn.start_byte:fn.start_byte + 20]:
                            findings.append(Finding(
                                line=line,
                                severity="HIGH",
                                message=f"Missing `await` on `{func_name}()` — returns a Promise",
                                specialist="ts_missing_await",
                                confidence="LIKELY",
                            ))
                        break
                    fn = fn.parent

    return findings


def _check_empty_catch(node: "Node", source: bytes) -> list[Finding]:
    """Detect empty catch blocks."""
    findings: list[Finding] = []

    for n in _walk(node):
        if n.type != "catch_clause":
            continue

        body = n.child_by_field_name("body")
        if body and body.type == "statement_block":
            # Check if the body is empty (only whitespace/comments)
            has_comment = any(c.type == "comment" for c in body.children)
            children = [c for c in body.children
                        if c.type not in ("{", "}", "comment")]
            if not children and not has_comment:
                findings.append(Finding(
                    line=n.start_point[0] + 1,
                    severity="MEDIUM",
                    message="Empty `catch` block — exception silently swallowed",
                    specialist="ts_empty_catch",
                    confidence="DEFINITE",
                ))

    return findings


def _check_loose_equality(node: "Node", source: bytes) -> list[Finding]:
    """Detect == and != instead of === and !==."""
    findings: list[Finding] = []

    for n in _walk(node):
        if n.type != "binary_expression":
            continue

        op_node = None
        for child in n.children:
            if child.type in ("==", "!="):
                op_node = child
                break

        if op_node:
            op = _node_text(op_node, source)
            # Skip == null / != null (common intentional pattern for null/undefined check)
            right = n.child_by_field_name("right")
            if right and _node_text(right, source) in ("null", "undefined"):
                continue
            left = n.child_by_field_name("left")
            if left and _node_text(left, source) in ("null", "undefined"):
                continue

            fix = "===" if op == "==" else "!=="
            findings.append(Finding(
                line=n.start_point[0] + 1,
                severity="LOW",
                message=f"Loose equality `{op}` — use `{fix}` for type-safe comparison",
                specialist="ts_loose_equality",
                confidence="DEFINITE",
            ))

    return findings


def _check_xss(node: "Node", source: bytes) -> list[Finding]:
    """Detect dangerouslySetInnerHTML usage."""
    findings: list[Finding] = []

    for n in _walk(node):
        if n.type == "jsx_attribute":
            name_node = None
            for child in n.children:
                if child.type == "property_identifier" or child.type == "identifier":
                    name_node = child
                    break
            if name_node and _node_text(name_node, source) == "dangerouslySetInnerHTML":
                findings.append(Finding(
                    line=n.start_point[0] + 1,
                    severity="HIGH",
                    message="`dangerouslySetInnerHTML` — XSS risk if content is user-controlled. Use a sanitizer",
                    specialist="ts_xss",
                    confidence="LIKELY",
                ))

    return findings


def _check_hardcoded_secrets(node: "Node", source: bytes) -> list[Finding]:
    """Detect hardcoded API keys, tokens, passwords in JS/TS."""
    findings: list[Finding] = []

    _SECRET_PATTERNS = [
        (re.compile(r'sk-[a-zA-Z0-9]{20,}'), "OpenAI API key"),
        (re.compile(r'ghp_[a-zA-Z0-9]{36}'), "GitHub token"),
        (re.compile(r'xox[bpsa]-[a-zA-Z0-9-]+'), "Slack token"),
        (re.compile(r'AKIA[0-9A-Z]{16}'), "AWS access key"),
        (re.compile(r'eyJ[a-zA-Z0-9_-]{20,}\.eyJ'), "JWT token"),
    ]

    _SECRET_NAMES = re.compile(
        r'(?:password|passwd|secret|api_key|apiKey|accessToken|authToken|'
        r'privateKey|secretKey|credentials?|connectionString)$',
        re.IGNORECASE,
    )

    for n in _walk(node):
        if n.type not in ("variable_declarator", "assignment_expression",
                          "pair", "property_assignment"):
            continue

        # Get name and value
        name_node = n.child_by_field_name("name") or n.child_by_field_name("key")
        value_node = n.child_by_field_name("value") or n.child_by_field_name("right")

        if not name_node or not value_node:
            continue

        var_name = _node_text(name_node, source)
        if value_node.type not in ("string", "template_string"):
            continue

        value = _node_text(value_node, source).strip("'\"`")
        if len(value) < 8:
            continue
        if value.startswith(("http://", "https://", "/", ".")):
            continue

        # Check known patterns
        for pattern, secret_type in _SECRET_PATTERNS:
            if pattern.search(value):
                findings.append(Finding(
                    line=n.start_point[0] + 1,
                    severity="HIGH",
                    message=f"Hardcoded {secret_type} in `{var_name}` — use environment variable",
                    specialist="ts_secrets",
                    confidence="DEFINITE",
                ))
                break
        else:
            # Check variable name
            if _SECRET_NAMES.search(var_name):
                if any(p in value.lower() for p in ("xxx", "changeme", "placeholder", "example", "test", "dummy")):
                    continue
                # Skip env variable references
                if "process.env" in value or "env(" in value:
                    continue
                findings.append(Finding(
                    line=n.start_point[0] + 1,
                    severity="HIGH",
                    message=f"Possible hardcoded secret in `{var_name}` — use environment variable",
                    specialist="ts_secrets",
                    confidence="LIKELY",
                ))

    return findings


def _check_fetch_no_error_handling(node: "Node", source: bytes) -> list[Finding]:
    """Detect fetch() calls without .ok / status check or try-catch."""
    findings: list[Finding] = []

    for n in _walk(node):
        if n.type != "call_expression":
            continue

        func = n.child_by_field_name("function")
        if not func:
            continue

        # Match fetch() or await fetch()
        is_fetch = False
        if func.type == "identifier" and _node_text(func, source) == "fetch":
            is_fetch = True
        if not is_fetch:
            continue

        # Skip fetch inside Promise.all/allSettled/race (error handling is at the combinator level)
        if _is_inside_promise_combinator(n):
            continue

        # Check if inside a try block
        in_try = False
        parent = n.parent
        while parent:
            if parent.type == "try_statement":
                in_try = True
                break
            parent = parent.parent

        if not in_try:
            # Check if .ok or .status is checked nearby
            # Look at the containing statement block for resp.ok patterns
            stmt = n.parent
            while stmt and stmt.type not in ("expression_statement", "variable_declaration",
                                              "lexical_declaration"):
                stmt = stmt.parent

            if stmt and stmt.parent:
                block_text = _node_text(stmt.parent, source)
                if ".ok" not in block_text and ".status" not in block_text and "catch" not in block_text:
                    findings.append(Finding(
                        line=n.start_point[0] + 1,
                        severity="MEDIUM",
                        message="`fetch()` without error handling — no `try/catch`, `.ok`, or `.status` check",
                        specialist="ts_fetch_no_error",
                        confidence="LIKELY",
                    ))

    return findings


# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------

def run_ts_specialists(
    source: str,
    file_path: str,
) -> list[Finding]:
    """Run all TS/JS specialists on a file. Returns findings."""
    if not _HAS_TREE_SITTER:
        return []

    parser = _get_parser(file_path)
    if not parser:
        return []

    source_bytes = source.encode("utf-8")
    try:
        tree = parser.parse(source_bytes)
    except Exception:
        return []

    root = tree.root_node
    findings: list[Finding] = []

    findings.extend(_check_missing_await(root, source_bytes))
    findings.extend(_check_empty_catch(root, source_bytes))
    findings.extend(_check_loose_equality(root, source_bytes))
    findings.extend(_check_xss(root, source_bytes))
    findings.extend(_check_hardcoded_secrets(root, source_bytes))
    findings.extend(_check_fetch_no_error_handling(root, source_bytes))

    # Set file_path on all findings
    for f in findings:
        f.file_path = file_path

    return findings
