"""RBridge — manages a persistent R subprocess and exposes a Pythonic API."""

from __future__ import annotations

import importlib.resources
import logging
import os
import subprocess
import sys
import threading
import time
from typing import Any

from r_bridge.deserializer import deserialize
from r_bridge.exceptions import RBridgeError, RError, RStartupError, RTimeoutError
from r_bridge.protocol import make_request, parse_response, SENTINEL
from r_bridge.serializer import serialize
from r_bridge.type_hints import TypeHint, resolve_hint
from r_bridge.utils import drain_stderr, get_r_executable

logger = logging.getLogger(__name__)

# Names that are RBridge Python attributes — never forwarded to R.
_BRIDGE_ATTRS = frozenset(
    {
        "_process",
        "_stderr_buf",
        "_stderr_thread",
        "_call_lock",
        "_dead",
        "_r_executable",
        "_startup_timeout",
        "_call_timeout",
        "_env",
        "_r_libs",
        "_log_level",
        "_verbose",
        "_arrow_available",
        "_script_path",
    }
)


class _RFuncProxy:
    """Returned by __getattr__ for names that look like R functions."""

    def __init__(self, bridge: "RBridge", name: str) -> None:
        self._bridge = bridge
        self._name = name

    def __call__(self, *args, result_type_hint=None, timeout=None, **kwargs):
        return self._bridge.call(
            self._name, *args, result_type_hint=result_type_hint, timeout=timeout, **kwargs
        )

    def __repr__(self) -> str:
        return f"<RFuncProxy '{self._name}'>"


class RBridge:
    """Persistent R subprocess bridge."""

    def __init__(
        self,
        r_executable: str | None = None,
        startup_timeout: float = 15.0,
        call_timeout: float = 60.0,
        env: dict[str, str] | None = None,
        r_libs: list[str] | None = None,
        log_level: str = "WARNING",
        verbose: bool = False,
    ) -> None:
        # Use object.__setattr__ to avoid triggering our custom __setattr__
        object.__setattr__(self, "_r_executable", r_executable)
        object.__setattr__(self, "_startup_timeout", startup_timeout)
        object.__setattr__(self, "_call_timeout", call_timeout)
        object.__setattr__(self, "_env", env or {})
        object.__setattr__(self, "_r_libs", r_libs or [])
        object.__setattr__(self, "_log_level", log_level)
        object.__setattr__(self, "_verbose", verbose)
        object.__setattr__(self, "_process", None)
        object.__setattr__(self, "_stderr_buf", [])
        object.__setattr__(self, "_stderr_thread", None)
        object.__setattr__(self, "_call_lock", threading.Lock())
        object.__setattr__(self, "_dead", False)
        object.__setattr__(self, "_arrow_available", False)
        object.__setattr__(self, "_script_path", _find_r_script())
        logging.getLogger("r_bridge").setLevel(log_level)

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    def start(self) -> "RBridge":
        env = os.environ.copy()
        if os.name != "nt":
            env["LANG"] = "en_US.UTF-8"
        env.update(self._env)
        if self._r_libs:
            existing = env.get("R_LIBS_USER", "")
            env["R_LIBS_USER"] = os.pathsep.join(self._r_libs + ([existing] if existing else []))

        r_exec = get_r_executable(self._r_executable)
        proc = subprocess.Popen(
            [r_exec, "--vanilla", self._script_path],
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            env=env,
            text=True,
            encoding="utf-8",
            errors="replace",
            bufsize=1,
        )
        object.__setattr__(self, "_process", proc)
        object.__setattr__(self, "_stderr_buf", [])
        object.__setattr__(self, "_dead", False)

        stderr_buf = self._stderr_buf
        on_stderr = (lambda line: print(f"[R stderr] {line}", end="", file=sys.stderr)) if self._verbose else None
        t = drain_stderr(proc, stderr_buf, on_line=on_stderr)
        object.__setattr__(self, "_stderr_thread", t)

        # Wait for ready signal
        self._wait_for_ready()
        return self

    def _wait_for_ready(self) -> None:
        proc = self._process
        ready = threading.Event()
        result: dict = {}

        def _reader():
            try:
                line = proc.stdout.readline()
                if line:
                    result["line"] = line
                    ready.set()
            except Exception as exc:
                result["exc"] = exc
                ready.set()

        t = threading.Thread(target=_reader, daemon=True)
        t.start()

        if not ready.wait(timeout=self._startup_timeout):
            proc.kill()
            stderr = "".join(self._stderr_buf)
            raise RStartupError(
                f"R process did not send ready signal within {self._startup_timeout}s. "
                f"stderr: {stderr}"
            )

        if "exc" in result:
            raise RStartupError(f"Error reading R startup: {result['exc']}")

        try:
            resp = parse_response(result["line"])
        except Exception as exc:
            raise RStartupError(f"Unexpected startup line: {result['line']!r}") from exc

        if resp.get("status") != "ok" or not resp.get("payload", {}).get("ready"):
            payload = resp.get("payload", {})
            raise RStartupError(f"R startup failed: {payload.get('message', resp)}")

        object.__setattr__(self, "_arrow_available", resp["payload"].get("arrow_available", False))

    def stop(self, timeout: float = 5.0) -> None:
        proc = self._process
        if proc is None or self._dead:
            return
        try:
            with self._call_lock:
                proc.stdin.write(make_request("shutdown", {}))
                proc.stdin.flush()
        except Exception:
            pass
        try:
            proc.wait(timeout=timeout)
        except subprocess.TimeoutExpired:
            proc.terminate()
            try:
                proc.wait(timeout=2)
            except subprocess.TimeoutExpired:
                proc.kill()
        object.__setattr__(self, "_dead", True)

    def is_alive(self) -> bool:
        proc = self._process
        if proc is None or self._dead:
            return False
        return proc.poll() is None

    def __enter__(self) -> "RBridge":
        self.start()
        return self

    def __exit__(self, *args) -> None:
        self.stop()

    # ------------------------------------------------------------------
    # Attribute protocol
    # ------------------------------------------------------------------

    def __setattr__(self, name: str, value: Any) -> None:
        if name.startswith("_") or name in _BRIDGE_ATTRS:
            object.__setattr__(self, name, value)
        else:
            self.set(name, value)

    def __getattr__(self, name: str) -> Any:
        # Called only when normal attribute lookup has already failed.
        # Check if it's a variable in R's global env; if not, return a proxy.
        if name.startswith("_"):
            raise AttributeError(name)
        try:
            r_names = self._ls_raw()
        except Exception:
            return _RFuncProxy(self, name)

        if name in r_names:
            return self.get(name)
        return _RFuncProxy(self, name)

    # ------------------------------------------------------------------
    # Core operations
    # ------------------------------------------------------------------

    def set(self, name: str, value: Any, /, *, type_hint=None) -> None:
        payload = {"name": name, "value": serialize(value)}
        resp = self._call_r("set_var", payload)
        _check_response(resp)

    def get(self, name: str, *, result_type_hint=None) -> Any:
        resp = self._call_r("get_var", {"name": name})
        _check_response(resp)
        hint = resolve_hint(result_type_hint)
        return deserialize(resp["payload"], hint)

    def call(
        self,
        func: str,
        /,
        *args,
        result_type_hint=None,
        timeout: float | None = None,
        **kwargs,
    ) -> Any:
        payload = {
            "func": func,
            "args": [serialize(a) for a in args],
            "kwargs": {k: serialize(v) for k, v in kwargs.items()},
        }
        resp = self._call_r("call_func", payload, timeout=timeout)
        _check_response(resp)
        hint = resolve_hint(result_type_hint)
        return deserialize(resp["payload"], hint)

    def execute(self, code: str, /, *, timeout: float | None = None) -> None:
        """Execute an R script string in the global environment.

        Unlike :meth:`eval`, the return value is always discarded.  Use this
        for multi-line scripts, function definitions, or any code where the
        side-effects matter and the return value does not.
        """
        resp = self._call_r("exec_script", {"code": code}, timeout=timeout)
        _check_response(resp)

    def eval(self, expr: str, /, *, result_type_hint=None, timeout: float | None = None) -> Any:
        resp = self._call_r("eval_expr", {"expr": expr}, timeout=timeout)
        _check_response(resp)
        hint = resolve_hint(result_type_hint)
        return deserialize(resp["payload"], hint)

    def ls(self) -> list[str]:
        return self._ls_raw()

    def _ls_raw(self) -> list[str]:
        resp = self._call_r("ls", {})
        _check_response(resp)
        return list(resp["payload"].get("names", []))

    def ping(self) -> float:
        t0 = time.monotonic()
        resp = self._call_r("ping", {})
        elapsed = time.monotonic() - t0
        _check_response(resp)
        return elapsed

    @property
    def last_stderr(self) -> str:
        return "".join(self._stderr_buf)

    # ------------------------------------------------------------------
    # Internal send / receive
    # ------------------------------------------------------------------

    def _call_r(self, op: str, payload: dict, *, timeout: float | None = None) -> dict:
        if self._dead or not self.is_alive():
            raise RBridgeError("R process is not running. Call start() first.")

        effective_timeout = timeout if timeout is not None else self._call_timeout
        request_line = make_request(op, payload)
        response: dict = {}
        event = threading.Event()

        if self._verbose:
            print(_format_verbose_call(op, payload), file=sys.stderr)

        def _reader():
            try:
                while True:
                    line = self._process.stdout.readline()
                    if not line:
                        # EOF — process died
                        object.__setattr__(self, "_dead", True)
                        event.set()
                        return
                    if line.startswith(SENTINEL):
                        response["line"] = line
                        event.set()
                        return
                    # Non-protocol line: log and optionally print
                    logger.debug("R stdout (non-protocol): %s", line.rstrip())
                    if self._verbose:
                        print(f"[R stdout] {line}", end="", file=sys.stderr)
            except Exception as exc:
                response["exc"] = exc
                event.set()

        with self._call_lock:
            t = threading.Thread(target=_reader, daemon=True)
            t.start()
            try:
                self._process.stdin.write(request_line)
                self._process.stdin.flush()
            except BrokenPipeError as exc:
                object.__setattr__(self, "_dead", True)
                raise RBridgeError("R process pipe broken") from exc

            if not event.wait(timeout=effective_timeout):
                object.__setattr__(self, "_dead", True)
                raise RTimeoutError(
                    f"R call '{op}' timed out after {effective_timeout}s"
                )

        if "exc" in response:
            raise RBridgeError(f"Error reading R response: {response['exc']}")

        if "line" not in response:
            raise RBridgeError("R process died before sending a response")

        return parse_response(response["line"])


# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------

def _check_response(resp: dict) -> None:
    if resp["status"] == "error":
        payload = resp["payload"]
        raise RError(
            message=payload.get("message", "Unknown R error"),
            call=payload.get("call"),
            traceback=[payload.get("traceback", "")],
            warnings=resp.get("warnings", []),
        )


def _format_verbose_call(op: str, payload: dict) -> str:
    """Return a human-readable description of an R call for verbose output."""
    if op == "call_func":
        func = payload.get("func", "?")
        args = payload.get("args", [])
        kwargs = payload.get("kwargs", {})
        parts = [repr(a) for a in args] + [f"{k}={v!r}" for k, v in kwargs.items()]
        return f"[R call] {func}({', '.join(parts)})"
    if op == "eval_expr":
        return f"[R eval] {payload.get('expr', '')}"
    if op == "exec_script":
        code = payload.get("code", "")
        first_line = code.splitlines()[0] if code else ""
        suffix = " ..." if "\n" in code else ""
        return f"[R exec] {first_line}{suffix}"
    if op == "set_var":
        return f"[R set]  {payload.get('name')} = {payload.get('value')!r}"
    if op == "get_var":
        return f"[R get]  {payload.get('name')}"
    if op == "ls":
        return "[R call] ls()"
    if op == "ping":
        return "[R call] ping()"
    if op == "shutdown":
        return "[R call] shutdown()"
    return f"[R op]   {op}"


def _find_r_script() -> str:
    """Locate bridge_loop.R relative to this package."""
    try:
        ref = importlib.resources.files("r_bridge") / "_r_scripts" / "bridge_loop.R"
        return str(ref)
    except Exception:
        import pathlib
        return str(pathlib.Path(__file__).parent / "_r_scripts" / "bridge_loop.R")
