import asyncio
import re
from typing import Final, Any, Protocol, Mapping, runtime_checkable, cast

from dbus_next import BusType, Variant
from dbus_next.aio import MessageBus
from dbus_next.errors import DBusError

from gateway_transport.dbus.errors import (
    DbusClientError,
    DbusAccessDenied,
    DbusTimeout,
    DbusDisconnected,
    DbusMethodNotFound,
    DbusCallError,
)


@runtime_checkable
class PropertiesIface(Protocol):
    async def call_get_all(self, interface: str) -> Mapping[str, Variant]: ...

    async def call_get(self, interface: str, prop: str) -> Variant: ...

    async def call_set(self, interface: str, prop: str, value: Variant) -> None: ...


class DbusClient:
    """
    Generic async D-Bus client for one service/path/interface.

    Features:
    - Lazy connect; auto-reconnect on NoReply/Disconnected
    - Dynamic method calling by PascalCase names (e.g., "StartUnit")
    - Property helpers (GetAll/Get/Set) with Variant unwrapping
    - Per-path introspection cache
    """

    _ERROR_MAP: Final[dict[str, type[DbusClientError]]] = {
        "org.freedesktop.DBus.Error.AccessDenied": DbusAccessDenied,
        "org.freedesktop.PolicyKit1.Error.Failed": DbusAccessDenied,
        "org.freedesktop.DBus.Error.TimedOut": DbusTimeout,
        "org.freedesktop.DBus.Error.NoReply": DbusDisconnected,
        "org.freedesktop.DBus.Error.Disconnected": DbusDisconnected,
    }
    _TO_SNAKE_RE = re.compile(r"(?<!^)(?=[A-Z])")

    def __init__(
        self, *, service: str, path: str, interface: str, bus: BusType = BusType.SYSTEM, opTimeout: float = 5.0
    ) -> None:
        """
        Create a D-Bus client bound to a specific interface.

        :param service: D-Bus service name (e.g., org.freedesktop.systemd1)
        :param path: object path (e.g., /org/freedesktop/systemd1)
        :param interface: interface name (e.g., org.freedesktop.systemd1.Manager)
        :param bus: system or session bus
        :param opTimeout: default timeout (seconds) for method calls
        """
        self._service: Final[str] = service
        self._path: Final[str] = path
        self._iface: Final[str] = interface
        self._busType = bus
        self._opTimeout = opTimeout

        self._bus: MessageBus | None = None
        self._ifaceProxy: Any = None
        self._lock = asyncio.Lock()
        self._connectTask: asyncio.Task | None = None
        self._introspectionCache: dict[str, Any] = {}

        try:
            loop = asyncio.get_running_loop()
            # Create task on a local variable so AST linters recognize the callback attachment.
            self._connectTask = loop.create_task(self._connect_locked())
            self._connectTask.add_done_callback(self._onConnectTaskDone)
        except RuntimeError:
            # No running loop at construction time; will connect lazily.
            self._connectTask = None

    # =========================
    # Public API
    # =========================
    async def callMethod(self, methodName: str, *args: Any, timeout: float | None = None) -> Any:
        """
        Call a D-Bus method by PascalCase name (e.g., "StartUnit").

        - Converts to dbus-next "call_start_unit" under the hood
        - Retries once after NoReply/Disconnected by reconnecting

        :param methodName: D-Bus method in PascalCase
        :param args: method arguments
        :param timeout: per-call timeout in seconds (overrides default)
        :return: result value from the D-Bus call
        """
        await self._ensureReady()
        callAttr = f"call_{self._toSnake(methodName)}"
        fn = getattr(self._ifaceProxy, callAttr, None)
        if not fn:
            raise DbusMethodNotFound(f"Method not found: {self._iface}.{methodName}")

        effTimeout = timeout if timeout is not None else self._opTimeout

        async def _do_call() -> Any:
            return await asyncio.wait_for(fn(*args), timeout=effTimeout)

        try:
            return await _do_call()

        except DBusError as e:
            errName = self._extractErrorName(e)

            if errName in ("org.freedesktop.DBus.Error.NoReply", "org.freedesktop.DBus.Error.Disconnected"):
                self._reset()
                await self._ensureReady()

                callAttr = f"call_{self._toSnake(methodName)}"
                fn = getattr(self._ifaceProxy, callAttr, None)
                if not fn:
                    raise DbusMethodNotFound(f"Method not found: {self._iface}.{methodName}")

                async def _do_call() -> Any:
                    return await asyncio.wait_for(fn(*args), timeout=effTimeout)

                try:
                    return await _do_call()
                except DBusError as e2:
                    self._mapAndRaise(
                        self._extractErrorName(e2), "D-Bus call failed after reconnect", methodName=methodName
                    )

            self._mapAndRaise(errName, "D-Bus call failed", methodName=methodName)

        except asyncio.TimeoutError:
            raise DbusTimeout(f"Timeout calling {self._iface}.{methodName}")

        except Exception as e:
            self._reset()
            raise DbusCallError(f"Unexpected D-Bus error: {e!s}") from e

    async def getAllProperties(self, iface: str, *, objectPath: str | None = None) -> dict[str, Any]:
        """
        Read all properties of an interface and unwrap Variant values.

        :param iface: interface to query (e.g., org.freedesktop.systemd1.Unit)
        :param objectPath: optional object path override
        :return: dict of property -> Python value
        """
        await self._ensureReady()
        path = objectPath or self._path
        propsIf = cast(PropertiesIface, await self._getIfaceProxy(path, "org.freedesktop.DBus.Properties"))
        raw = await propsIf.call_get_all(iface)
        return {k: v.value for k, v in raw.items()}

    async def getProperty(self, iface: str, prop: str, *, objectPath: str | None = None) -> Any:
        """
        Read a single property and unwrap its Variant.

        :param iface: interface to query
        :param prop: property name
        :param objectPath: optional object path override
        :return: Python value of the property
        """
        await self._ensureReady()
        path = objectPath or self._path
        propsIf = cast(PropertiesIface, await self._getIfaceProxy(path, "org.freedesktop.DBus.Properties"))
        v = await propsIf.call_get(iface, prop)
        return v.value

    async def setProperty(
        self, iface: str, prop: str, value: Any, signature: str, *, objectPath: str | None = None
    ) -> None:
        """
        Set a single property on an object.

        :param iface: interface to modify
        :param prop: property name
        :param value: Python value to set
        :param signature: D-Bus signature for the value (e.g., 's', 'b', 'u', '(ss)')
        :param objectPath: optional object path override
        """
        await self._ensureReady()
        path = objectPath or self._path
        propsIf = cast(PropertiesIface, await self._getIfaceProxy(path, "org.freedesktop.DBus.Properties"))
        await propsIf.call_set(iface, prop, Variant(signature, value))

    async def close(self) -> None:
        """
        Close the D-Bus connection if open
        """
        if self._bus is not None:
            try:
                await self._bus.wait_for_disconnect()
            except Exception:
                pass
        self._reset()

    async def __aenter__(self) -> "DbusClient":
        """
        Enter async context and ensure the client is connected
        """
        await self._ensureReady()
        return self

    async def __aexit__(self, exc_type, exc, tb) -> None:
        """
        Exit async context and close the connection
        """
        await self.close()

    # ======================
    # Private internals
    # ======================
    async def _connect_locked(self) -> None:
        """
        Establish a connection and bind the primary interface proxy.
        """
        async with self._lock:
            if self._bus and self._ifaceProxy:
                return
            self._bus = await MessageBus(bus_type=self._busType).connect()
            self._ifaceProxy = await self._getIfaceProxy(self._path, self._iface)

    async def _ensureReady(self) -> None:
        """
        Ensure the client has a bound interface proxy (connect if needed).
        """
        if self._ifaceProxy:
            return
        if self._connectTask:
            try:
                await self._connectTask
            finally:
                self._connectTask = None
        if not self._ifaceProxy:
            await self._connect_locked()

    def _reset(self) -> None:
        """
        Reset connection state (keeps the introspection cache).
        """
        self._bus = None
        self._ifaceProxy = None
        self._connectTask = None

    async def _getIfaceProxy(self, objectPath: str, iface: str) -> Any:
        """
        Resolve a proxy interface for a given object path and interface name.

        :param objectPath: object path to resolve
        :param iface: interface name
        :return: dbus-next proxy interface object
        """
        assert self._bus is not None
        introspection = self._introspectionCache.get(objectPath)
        if introspection is None:
            introspection = await self._bus.introspect(self._service, objectPath)
            self._introspectionCache[objectPath] = introspection
        obj = self._bus.get_proxy_object(self._service, objectPath, introspection)
        return obj.get_interface(iface)

    @classmethod
    def _toSnake(cls, methodName: str) -> str:
        """
        Convert PascalCase method names to snake_case (dbus-next style)
        """
        return cls._TO_SNAKE_RE.sub("_", methodName).lower()

    @staticmethod
    def _extractErrorName(e: DBusError) -> str | None:
        """
        Extract a D-Bus error name from a DBusError if available
        """
        for attr in ("name", "_dbus_error_name", "dbus_error_name"):
            v = getattr(e, attr, None)
            if v:
                return v
        for a in getattr(e, "args", ()) or ():
            if isinstance(a, str) and a.startswith("org.freedesktop.DBus.Error."):
                return a
        m = re.search(r"(org\.freedesktop\.(?:DBus|PolicyKit1)\.Error\.[A-Za-z]+)", str(e) or "")
        return m.group(1) if m else None

    def _mapAndRaise(self, errName: str | None, fallbackMsg: str, *, methodName: str | None = None) -> None:
        """
        Map a D-Bus error name to a domain exception and raise it

        :param errName: D-Bus error name (or None if unknown)
        :param fallbackMsg: message if no error name is available
        :param methodName: optional method for context in the message
        """
        if not errName:
            raise DbusCallError(fallbackMsg)
        excType = self._ERROR_MAP.get(errName)
        if excType:
            raise excType(f"{errName}{f' while calling {methodName}' if methodName else ''}")
        raise DbusCallError(f"D-Bus error: {errName}", name=errName)

    # ----------------------
    # Task lifecycle hook
    # ----------------------
    def _onConnectTaskDone(self, task: asyncio.Task) -> None:
        """
        Background task sentinel:
        - Consumes the result to avoid "Task exception was never retrieved"
        - Clears the reference if this was the active connect task
        - Soft-resets on failure so a subsequent call can reconnect cleanly
        """
        try:
            # Consume result/exception
            task.result()
        except Exception:
            # Any failure should drop state so _ensureReady() can reconnect
            self._reset()
        finally:
            # Clear the reference only if this callback belongs to the current task
            if getattr(self, "_connectTask", None) is task:
                self._connectTask = None
