from __future__ import annotations

import json
from datetime import datetime, timezone
from dataclasses import dataclass
from pathlib import Path
from subprocess import TimeoutExpired
from time import perf_counter
from typing import Any, Sequence
from urllib.parse import urlparse
from uuid import uuid4

from acl_engine.actions.registry import ActionDefinition
from acl_engine.context import ACLVariableError, ContextStore
from acl_engine.parser import ACLCommand, ACLParseError, VariableRef
from acl_engine.policy import ExecutionPolicy
from acl_engine.resolver import resolve_params
from acl_engine.validator import ACLValidationError, Validator

ACL_VERSION = "1.0"
OUTPUT_VERSION = "1.2"


class ACLExecutionError(RuntimeError):
    def __init__(
        self,
        message: str,
        *,
        action: str | None = None,
        code: str = "execution_error",
        retryable: bool = False,
    ) -> None:
        self.message = message
        self.action = action
        self.code = code
        self.retryable = retryable
        super().__init__(message)


class ACLSafetyError(ACLExecutionError):
    def __init__(self, message: str, *, action: str | None = None) -> None:
        super().__init__(message, action=action, code="safety_error")


class ACLTimeoutError(ACLExecutionError):
    def __init__(self, message: str, *, action: str | None = None) -> None:
        super().__init__(message, action=action, code="timeout_error", retryable=True)


@dataclass(frozen=True)
class ExecutionRuntime:
    workspace_root: Path
    timeout: int
    memory: dict[str, Any]
    trace_id: str
    policy_name: str


class Executor:
    def __init__(
        self,
        registry: Any,
        validator: Validator,
        *,
        workspace_root: Path,
        timeout: int,
        policy: ExecutionPolicy,
        stop_on_error: bool,
        trace_id: str | None = None,
    ) -> None:
        self._registry = registry
        self._validator = validator
        self._workspace_root = workspace_root.resolve()
        self._timeout = timeout
        self._policy = policy
        self._stop_on_error = stop_on_error
        self._trace_id = trace_id or str(uuid4())

    def execute(self, commands: Sequence[ACLCommand]) -> dict[str, Any]:
        context = ContextStore()
        started_at = datetime.now(timezone.utc)
        started_clock = perf_counter()
        runtime = ExecutionRuntime(
            workspace_root=self._workspace_root,
            timeout=self._timeout,
            memory={},
            trace_id=self._trace_id,
            policy_name=self._policy.name,
        )
        logs: list[dict[str, Any]] = []
        last_error: BaseException | None = None
        steps_completed = 0
        steps_failed = 0

        for step_index, command in enumerate(commands, start=1):
            definition: ActionDefinition | None = None
            raw_log_params: dict[str, Any] = command.params
            resolved_log_params: dict[str, Any] = command.params
            step_started = perf_counter()
            try:
                definition, prepared_params = self._validator.validate(command)
                raw_log_params = prepared_params
                resolved_log_params = prepared_params
                resolved_params = resolve_params(prepared_params, context)
                resolved_log_params = resolved_params
                self._validator.validate_resolved(command, definition, resolved_params)
                self._enforce_safety(command.action, resolved_params)
                result = self._dispatch(definition, resolved_params, runtime)
                if command.assign:
                    context.set(command.assign, result)
                steps_completed += 1
                logs.append(
                    _build_log_entry(
                        command,
                        step=step_index,
                        raw_params=_redact_params(raw_log_params, definition),
                        resolved_params=_redact_params(resolved_log_params, definition),
                        status="success",
                        result=result,
                        duration_ms=_duration_ms(step_started),
                    )
                )
            except (
                ACLExecutionError,
                ACLParseError,
                ACLSafetyError,
                ACLTimeoutError,
                ACLValidationError,
                ACLVariableError,
            ) as error:
                last_error = error
                steps_failed += 1
                logs.append(
                    _build_log_entry(
                        command,
                        step=step_index,
                        raw_params=_redact_params(raw_log_params, definition),
                        resolved_params=_redact_params(resolved_log_params, definition),
                        status="error",
                        error=error,
                        duration_ms=_duration_ms(step_started),
                    )
                )
                if self._stop_on_error:
                    return format_result(
                        "error",
                        context.snapshot(),
                        logs,
                        error,
                        trace_id=self._trace_id,
                        summary=_build_summary(
                            started_at=started_at,
                            started_clock=started_clock,
                            steps_total=len(commands),
                            steps_completed=steps_completed,
                            steps_failed=steps_failed,
                            policy_name=self._policy.name,
                        ),
                    )
            except Exception as error:  # pragma: no cover - defensive boundary
                wrapped_error = ACLExecutionError(str(error), action=command.action)
                last_error = wrapped_error
                steps_failed += 1
                logs.append(
                    _build_log_entry(
                        command,
                        step=step_index,
                        raw_params=_redact_params(raw_log_params, definition),
                        resolved_params=_redact_params(resolved_log_params, definition),
                        status="error",
                        error=wrapped_error,
                        duration_ms=_duration_ms(step_started),
                    )
                )
                if self._stop_on_error:
                    return format_result(
                        "error",
                        context.snapshot(),
                        logs,
                        wrapped_error,
                        trace_id=self._trace_id,
                        summary=_build_summary(
                            started_at=started_at,
                            started_clock=started_clock,
                            steps_total=len(commands),
                            steps_completed=steps_completed,
                            steps_failed=steps_failed,
                            policy_name=self._policy.name,
                        ),
                    )

        if last_error is not None:
            return format_result(
                "error",
                context.snapshot(),
                logs,
                last_error,
                trace_id=self._trace_id,
                summary=_build_summary(
                    started_at=started_at,
                    started_clock=started_clock,
                    steps_total=len(commands),
                    steps_completed=steps_completed,
                    steps_failed=steps_failed,
                    policy_name=self._policy.name,
                ),
            )
        return format_result(
            "success",
            context.snapshot(),
            logs,
            trace_id=self._trace_id,
            summary=_build_summary(
                started_at=started_at,
                started_clock=started_clock,
                steps_total=len(commands),
                steps_completed=steps_completed,
                steps_failed=steps_failed,
                policy_name=self._policy.name,
            ),
        )

    def _dispatch(
        self,
        definition: ActionDefinition,
        params: dict[str, Any],
        runtime: ExecutionRuntime,
    ) -> Any:
        action_name = definition.name
        try:
            if definition.pass_runtime:
                return definition.func(**params, runtime=runtime)
            return definition.func(**params)
        except TimeoutExpired as error:
            raise ACLTimeoutError(f"action '{action_name}' timed out", action=action_name) from error
        except ACLExecutionError:
            raise
        except Exception as error:
            raise ACLExecutionError(f"action '{action_name}' failed: {error}", action=action_name) from error

    def _enforce_safety(self, action_name: str, params: dict[str, Any]) -> None:
        definition = self._registry.get(action_name)
        if self._policy.allowed_capabilities is not None and definition.capabilities:
            denied = definition.capabilities - self._policy.allowed_capabilities
            if denied:
                raise ACLSafetyError(
                    f"action '{action_name}' requires denied capabilities: {', '.join(sorted(denied))}",
                    action=action_name,
                )

        if action_name.startswith("file."):
            self._ensure_file_safe(action_name, params)
            return

        if action_name == "system.run":
            self._ensure_system_safe(params)
            return

        if action_name == "net.request":
            self._ensure_network_safe(params)

    def _ensure_workspace_path(self, path_value: Any, action_name: str) -> Path:
        if not isinstance(path_value, str):
            raise ACLSafetyError(f"action '{action_name}' requires a string path", action=action_name)

        candidate = Path(path_value)
        if not candidate.is_absolute():
            candidate = self._workspace_root / candidate
        resolved = candidate.resolve()

        if not resolved.is_relative_to(self._workspace_root):
            raise ACLSafetyError(f"path '{path_value}' is outside the workspace", action=action_name)
        return resolved

    def _ensure_file_safe(self, action_name: str, params: dict[str, Any]) -> None:
        if action_name in {"file.copy", "file.move"}:
            source_path = self._ensure_workspace_path(params.get("source"), action_name)
            self._ensure_workspace_path(params.get("destination"), action_name)
            if action_name == "file.move" and not self._policy.allow_delete:
                raise ACLSafetyError("file moves are disabled by policy", action=action_name)
            if source_path.exists() and source_path.is_file() and source_path.stat().st_size > self._policy.max_file_bytes:
                raise ACLSafetyError("file exceeds maximum readable size", action=action_name)
            return

        resolved_path = self._ensure_workspace_path(params.get("path"), action_name)
        if action_name == "file.delete" and not self._policy.allow_delete:
            raise ACLSafetyError("file deletion is disabled by policy", action=action_name)
        if action_name in {"file.read", "file.read_json"} and resolved_path.exists() and resolved_path.stat().st_size > self._policy.max_file_bytes:
            raise ACLSafetyError("file exceeds maximum readable size", action=action_name)
        if action_name in {"file.write", "file.append"}:
            content_size = len(str(params.get("content", "")).encode("utf-8"))
            if content_size > self._policy.max_file_bytes:
                raise ACLSafetyError("content exceeds maximum writable size", action=action_name)
        if action_name == "file.write_json":
            payload = json.dumps(
                params.get("value"),
                indent=params.get("indent"),
                sort_keys=bool(params.get("sort_keys", False)),
            )
            if len(payload.encode("utf-8")) > self._policy.max_file_bytes:
                raise ACLSafetyError("content exceeds maximum writable size", action=action_name)
        if action_name == "file.glob":
            pattern = params.get("pattern")
            if not isinstance(pattern, str):
                raise ACLSafetyError("file.glob requires a string pattern", action=action_name)
            pattern_parts = Path(pattern).parts
            if Path(pattern).is_absolute() or ".." in pattern_parts:
                raise ACLSafetyError("glob pattern cannot escape the workspace", action=action_name)

    def _ensure_system_safe(self, params: dict[str, Any]) -> None:
        command = params.get("command")
        args = params.get("args") or []
        cwd = params.get("cwd")

        if not isinstance(command, str):
            raise ACLSafetyError("system.run requires a string command", action="system.run")
        if command not in self._policy.allowed_commands:
            raise ACLSafetyError(f"command '{command}' is not allowed", action="system.run")
        if not isinstance(args, list):
            raise ACLSafetyError("system.run args must be a list", action="system.run")

        tokens = [command, *[str(value) for value in args]]
        for token in tokens:
            for pattern in self._policy.blocked_patterns:
                if pattern.isalpha():
                    if token == pattern:
                        raise ACLSafetyError(f"blocked token '{token}' in system command", action="system.run")
                elif pattern in token:
                    raise ACLSafetyError(f"blocked shell pattern in token '{token}'", action="system.run")

        if cwd is not None:
            self._ensure_workspace_path(cwd, "system.run")

    def _ensure_network_safe(self, params: dict[str, Any]) -> None:
        if not self._policy.allow_network:
            raise ACLSafetyError("network access is disabled by policy", action="net.request")

        url = params.get("url")
        if not isinstance(url, str):
            raise ACLSafetyError("net.request requires a string url", action="net.request")

        if self._policy.allowed_domains is None:
            return

        hostname = urlparse(url).hostname
        if hostname not in self._policy.allowed_domains:
            raise ACLSafetyError(f"domain '{hostname}' is not allowed", action="net.request")


def format_result(
    status: str,
    data: dict[str, Any],
    logs: list[dict[str, Any]],
    error: BaseException | None = None,
    *,
    trace_id: str | None = None,
    summary: dict[str, Any] | None = None,
) -> dict[str, Any]:
    payload: dict[str, Any] = {
        "acl_version": ACL_VERSION,
        "output_version": OUTPUT_VERSION,
        "status": status,
        "trace_id": trace_id,
        "data": _serialize(data),
        "logs": _serialize(logs),
        "summary": _serialize(summary or {}),
        "error": _format_error(error) if error is not None else None,
    }
    return payload


def _build_log_entry(
    command: ACLCommand,
    *,
    step: int,
    raw_params: dict[str, Any],
    resolved_params: dict[str, Any],
    status: str,
    result: Any | None = None,
    error: BaseException | None = None,
    duration_ms: int,
) -> dict[str, Any]:
    entry = {
        "step": step,
        "line": command.line_number,
        "action": command.action,
        "raw_params": _serialize(raw_params),
        "resolved_params": _serialize(resolved_params),
        "status": status,
        "duration_ms": duration_ms,
        "meta": dict(command.meta),
    }
    if result is not None:
        entry["result"] = _serialize(result)
    if error is not None:
        entry["error"] = _format_error(error)
    return entry


def _serialize(value: Any) -> Any:
    if isinstance(value, dict):
        return {str(key): _serialize(item) for key, item in value.items()}
    if isinstance(value, list):
        return [_serialize(item) for item in value]
    if isinstance(value, tuple):
        return [_serialize(item) for item in value]
    if isinstance(value, Path):
        return str(value)
    if isinstance(value, VariableRef):
        payload: dict[str, Any] = {"variable": value.name}
        if value.selectors:
            payload["selectors"] = list(value.selectors)
        return payload
    return value


def _format_error(error: BaseException) -> dict[str, Any]:
    payload: dict[str, Any] = {
        "type": type(error).__name__,
        "message": str(error),
        "code": getattr(error, "code", "unknown_error"),
    }
    if hasattr(error, "line_number") and getattr(error, "line_number") is not None:
        payload["line"] = getattr(error, "line_number")
    if hasattr(error, "column") and getattr(error, "column") is not None:
        payload["column"] = getattr(error, "column")
    if hasattr(error, "action") and getattr(error, "action"):
        payload["action"] = getattr(error, "action")
    if hasattr(error, "retryable"):
        payload["retryable"] = bool(getattr(error, "retryable"))
    return payload


def _duration_ms(started_clock: float) -> int:
    return int((perf_counter() - started_clock) * 1000)


def _build_summary(
    *,
    started_at: datetime,
    started_clock: float,
    steps_total: int,
    steps_completed: int,
    steps_failed: int,
    policy_name: str,
) -> dict[str, Any]:
    finished_at = datetime.now(timezone.utc)
    return {
        "started_at": started_at.isoformat(),
        "finished_at": finished_at.isoformat(),
        "duration_ms": int((perf_counter() - started_clock) * 1000),
        "steps_total": steps_total,
        "steps_completed": steps_completed,
        "steps_failed": steps_failed,
        "policy": policy_name,
    }


def _redact_params(
    params: dict[str, Any],
    definition: ActionDefinition | None,
) -> dict[str, Any]:
    if definition is None:
        return params

    return {
        name: ("<redacted>" if definition.params.get(name) and definition.params[name].sensitive else value)
        for name, value in params.items()
    }
