"""Flow and Debug tools — execution flow, error tracking."""

from __future__ import annotations

from dataclasses import asdict, dataclass, field
from pathlib import Path
import re

from mcp.server.fastmcp import Context

from godotiq.license import gate_pro
from godotiq.parsers.gd_parser import GdFunction, GdScript
from godotiq.server import mcp
from godotiq.session import GodotIQSession
from godotiq.tools.output_limit import apply_output_limit

# Guard condition patterns (the "if" part only — return is checked on next line)
_FAIL_IF_NOT_RE = re.compile(r"if\s+not\s+(\w+)\s*:")
_FAIL_NULL_RE = re.compile(r"if\s+(\w+)\s*==\s*null\s*:")
_FAIL_IS_EMPTY_RE = re.compile(r"if\s+(\w+)\.is_empty\(\)\s*:")
_FAIL_INSTANCE_VALID_RE = re.compile(r"if\s+not\s+is_instance_valid\((\w+)\)\s*:")


@dataclass
class FlowStep:
    """One step in the flow trace."""

    file: str
    function: str
    line: int
    action: str
    emits: list[str] = field(default_factory=list)
    calls: list[str] = field(default_factory=list)


@dataclass
class FailurePoint:
    """A point where the flow can break."""

    step: int
    condition: str
    consequence: str


def _build_signal_listeners(
    scripts: dict[str, GdScript],
) -> dict[str, list[tuple[str, str]]]:
    """Build signal_name -> [(file_path, handler_function)] from all .connect() calls."""
    listeners: dict[str, list[tuple[str, str]]] = {}
    for path, gd in scripts.items():
        for conn in gd.signal_connections:
            # Skip lambda targets
            if conn.target.startswith("func"):
                continue
            # Skip qualified targets (handler on another object)
            if "." in conn.target:
                continue
            # Extract signal name from qualified source
            sig_name = (
                conn.signal_source.rsplit(".", 1)[-1]
                if "." in conn.signal_source
                else conn.signal_source
            )
            listeners.setdefault(sig_name, []).append((path, conn.target))
    return listeners


def _find_containing_function(script: GdScript, line: int) -> GdFunction | None:
    """Return the GdFunction whose line range contains the given line, or None."""
    for func in script.functions:
        end = func.end_line if func.end_line >= func.line else func.line
        if func.line <= line <= end:
            return func
    return None


def _find_trigger(
    trigger: str, scripts: dict[str, GdScript]
) -> list[tuple[str, str]]:
    """Find all (file_path, function_name) pairs matching the trigger exactly."""
    results: list[tuple[str, str]] = []
    seen: set[tuple[str, str]] = set()

    for file_path, script in scripts.items():
        # Function name match
        for func in script.functions:
            if func.name == trigger:
                key = (file_path, func.name)
                if key not in seen:
                    seen.add(key)
                    results.append(key)

    # Signal name match -> find functions that emit it across ALL scripts
    signal_defined = any(
        sig.name == trigger for script in scripts.values() for sig in script.signals
    )
    if signal_defined:
        for file_path, script in scripts.items():
            for emission in script.signal_emissions:
                if emission.signal_name == trigger:
                    containing = _find_containing_function(script, emission.line)
                    if containing is not None:
                        key = (file_path, containing.name)
                        if key not in seen:
                            seen.add(key)
                            results.append(key)

    return results


def _get_file_lines(
    file_path: str, project_root: Path, cache: dict[str, list[str]]
) -> list[str]:
    """Load and cache file lines for a res:// path."""
    if file_path not in cache:
        relative = file_path.removeprefix("res://")
        abs_path = project_root / relative
        try:
            cache[file_path] = abs_path.read_text(encoding="utf-8").splitlines()
        except (FileNotFoundError, OSError):
            cache[file_path] = []
    return cache[file_path]


def _detect_failure_points(
    file_path: str,
    func: GdFunction,
    step_index: int,
    project_root: Path,
    file_lines_cache: dict[str, list[str]],
) -> list[FailurePoint]:
    """Detect common GDScript guard/early-return patterns in a function body."""
    lines = _get_file_lines(file_path, project_root, file_lines_cache)
    if not lines:
        return []

    end_line = func.end_line if func.end_line >= func.line else func.line
    start_idx = func.line - 1
    end_idx = min(end_line, len(lines))
    body_lines = lines[start_idx:end_idx]

    failures: list[FailurePoint] = []
    patterns = [
        (_FAIL_IF_NOT_RE, lambda m: f"not {m.group(1)}"),
        (_FAIL_NULL_RE, lambda m: f"{m.group(1)} == null"),
        (_FAIL_IS_EMPTY_RE, lambda m: f"{m.group(1)}.is_empty()"),
        (_FAIL_INSTANCE_VALID_RE, lambda m: f"not is_instance_valid({m.group(1)})"),
    ]
    for i, source_line in enumerate(body_lines):
        stripped = source_line.strip()
        # Check if next line is a return (GDScript puts return on its own line)
        has_return = "return" in stripped
        if not has_return and i + 1 < len(body_lines):
            next_stripped = body_lines[i + 1].strip()
            has_return = next_stripped == "return" or next_stripped.startswith("return ")
        if not has_return:
            continue
        for regex, fmt in patterns:
            match = regex.search(stripped)
            if match:
                failures.append(FailurePoint(
                    step=step_index,
                    condition=fmt(match),
                    consequence="flow stops",
                ))
    return failures


def _find_local_calls(
    script: GdScript,
    func: GdFunction,
    file_path: str,
    project_root: Path,
    file_lines_cache: dict[str, list[str]],
) -> list[str]:
    """Find calls to other functions defined in the same script."""
    all_func_names = {f.name for f in script.functions}
    lines = _get_file_lines(file_path, project_root, file_lines_cache)
    if not lines:
        return []

    start = func.line - 1  # 0-indexed
    end_line = func.end_line if func.end_line >= func.line else func.line
    end = min(end_line, len(lines))

    call_pattern = re.compile(r"(?:self\.)?(\w+)\s*\(")
    found: set[str] = set()

    for i in range(start, end):
        stripped = lines[i].strip()
        if stripped.startswith("#"):
            continue
        for match in call_pattern.finditer(lines[i]):
            called_name = match.group(1)
            if called_name in all_func_names and called_name != func.name:
                found.add(called_name)

    return list(found)


def _trace_from_function(
    file_path: str,
    function_name: str,
    scripts: dict[str, GdScript],
    listeners: dict[str, list[tuple[str, str]]],
    autoloads: dict[str, str],
    visited: set[tuple[str, str]],
    depth: int,
    max_depth: int,
    file_lines_cache: dict[str, list[str]],
    project_root: Path,
) -> tuple[list[FlowStep], list[FailurePoint]]:
    """Recursively trace execution flow from a function through signals and autoload calls."""
    if depth >= max_depth:
        return [], []

    key = (file_path, function_name)
    if key in visited:
        return [], []
    visited.add(key)

    script = scripts.get(file_path)
    if script is None:
        return [], []

    # Find function
    func = None
    for f in script.functions:
        if f.name == function_name:
            func = f
            break
    if func is None:
        return [], []

    end_line = func.end_line if func.end_line >= func.line else func.line

    # Collect emissions within function body
    emits: list[str] = []
    for emission in script.signal_emissions:
        if func.line <= emission.line <= end_line:
            emits.append(emission.signal_name)

    # Collect autoload calls within function body
    calls: list[str] = []
    lines = _get_file_lines(file_path, project_root, file_lines_cache)
    for ref in script.autoload_refs:
        if func.line <= ref.line <= end_line:
            if ref.pattern != "direct":
                continue
            if not ref.access:
                continue
            # Verify it's a method call by checking for "(" on the source line
            if ref.line - 1 < len(lines):
                source_line = lines[ref.line - 1]
                call_pattern = f"{ref.autoload_name}.{ref.access}("
                if call_pattern not in source_line:
                    continue
            calls.append(f"{ref.autoload_name}.{ref.access}")

    # Build action description
    parts = []
    if emits:
        parts.append("Emits: " + ", ".join(emits))
    if calls:
        parts.append("Calls: " + ", ".join(calls))
    action = "; ".join(parts) if parts else "(no outgoing signals or calls)"

    step = FlowStep(
        file=file_path,
        function=function_name,
        line=func.line,
        action=action,
        emits=emits,
        calls=calls,
    )

    all_steps = [step]
    all_failures = _detect_failure_points(
        file_path, func, 0, project_root, file_lines_cache,
    )

    # Recurse on emissions
    for sig_name in emits:
        for listener_file, listener_func in listeners.get(sig_name, []):
            sub_steps, sub_failures = _trace_from_function(
                listener_file, listener_func, scripts, listeners, autoloads,
                visited, depth + 1, max_depth, file_lines_cache, project_root,
            )
            all_steps.extend(sub_steps)
            all_failures.extend(sub_failures)

    # Recurse on autoload calls
    for call in calls:
        parts_split = call.split(".", 1)
        if len(parts_split) == 2:
            autoload_name, method_name = parts_split
            autoload_path = autoloads.get(autoload_name)
            if autoload_path:
                sub_steps, sub_failures = _trace_from_function(
                    autoload_path, method_name, scripts, listeners, autoloads,
                    visited, depth + 1, max_depth, file_lines_cache, project_root,
                )
                all_steps.extend(sub_steps)
                all_failures.extend(sub_failures)

    # Recurse on local (intra-file) function calls
    local_calls = _find_local_calls(
        script, func, file_path, project_root, file_lines_cache,
    )
    if local_calls:
        step.calls.extend(f"self.{name}" for name in local_calls)
        # Rebuild action description with local calls included
        parts_desc = []
        if step.emits:
            parts_desc.append("Emits: " + ", ".join(step.emits))
        if step.calls:
            parts_desc.append("Calls: " + ", ".join(step.calls))
        step.action = "; ".join(parts_desc) if parts_desc else step.action

        for called_func_name in local_calls:
            sub_steps, sub_failures = _trace_from_function(
                file_path, called_func_name, scripts, listeners, autoloads,
                visited, depth + 1, max_depth, file_lines_cache, project_root,
            )
            all_steps.extend(sub_steps)
            all_failures.extend(sub_failures)

    return all_steps, all_failures


async def _build_trace_flow(
    session: GodotIQSession,
    trigger: str,
    depth: int = 10,
    detail: str = "normal",
) -> dict:
    """Build a complete trace flow for a trigger through the codebase."""
    if not trigger or not trigger.strip():
        return {"flow": [], "failure_points": [], "files_involved": 0, "total_steps": 0, "error": "Trigger must not be empty"}

    scripts = session._gd_scripts
    autoloads = session._index.autoloads
    project_root = session._index.project_root

    listeners = _build_signal_listeners(scripts)
    entry_points = _find_trigger(trigger, scripts)

    if not entry_points:
        return {"flow": [], "failure_points": [], "files_involved": 0, "total_steps": 0, "error": "Trigger not found"}

    all_steps: list[FlowStep] = []
    all_failures: list[FailurePoint] = []
    visited: set[tuple[str, str]] = set()
    file_lines_cache: dict[str, list[str]] = {}

    for file_path, function_name in entry_points:
        steps, failures = _trace_from_function(
            file_path, function_name, scripts, listeners, autoloads,
            visited, 0, depth, file_lines_cache, project_root,
        )
        all_steps.extend(steps)
        all_failures.extend(failures)

    files_involved = len({s.file for s in all_steps})

    # Build result
    flow_dicts = [asdict(s) for s in all_steps]
    failure_dicts = [asdict(f) for f in all_failures]

    if detail == "brief":
        flow_dicts = [
            {"file": s["file"], "function": s["function"], "action": s["action"]}
            for s in flow_dicts
        ]
        return {
            "flow": flow_dicts,
            "files_involved": files_involved,
            "total_steps": len(all_steps),
        }

    result = {
        "flow": flow_dicts,
        "failure_points": failure_dicts,
        "files_involved": files_involved,
        "total_steps": len(all_steps),
    }

    return apply_output_limit(result, detail, list_keys=["flow", "failure_points"])


@mcp.tool(
    name="godotiq_trace_flow",
    annotations={
        "readOnlyHint": True,
        "destructiveHint": False,
        "idempotentHint": True,
        "openWorldHint": False,
    },
)
async def godotiq_trace_flow(
    trigger: str,
    depth: int = 10,
    detail: str = "normal",
    ctx: Context = None,
) -> dict:
    """Trace execution flow from a function or signal through the entire codebase. "What happens when X is called?" Returns the chain of calls, signal emissions, and failure points. DO NOT manually trace call chains — this does it automatically.

    Args:
        trigger: Exact function or signal name to trace (e.g. "start_print_job", "order_completed").
        depth: Max recursion depth (default 10).
        detail: "brief" (steps only — file + function), "normal" (+ line numbers, failure points), "full".
    """
    session = ctx.request_context.lifespan_context["session"]
    if session is None:
        return {"error": "No Godot project loaded"}
    return gate_pro("godotiq_trace_flow", await _build_trace_flow(session, trigger, depth, detail))
