import asyncio
import errno
import os

from collections.abc import Callable
from functools import wraps
from typing import TextIO

from qtoggleserver.core import ports
from qtoggleserver.utils import json as json_utils


def _retry_after_configure(func: Callable) -> Callable:
    @wraps(func)
    async def wrapper(self, *args, **kwargs):  # noqa: ANN001, ANN202
        try:
            return await func(self, *args, **kwargs)
        except OSError as e:
            # If for some reason the GPIO controller has been disconnected and reconnected in the meantime,
            # we get an `ENODEV` and we retry after attempting a reconfiguration.
            if e.errno == errno.ENODEV:
                self.error("GPIO device missing (controller disconnected?), retrying")
                await asyncio.sleep(1)
                self._configure()
                return await func(self, *args, **kwargs)
            raise

    return wrapper


class GPIO(ports.Port):
    TYPE = ports.TYPE_BOOLEAN

    ADDITIONAL_ATTRDEFS = {
        "output": {
            "display_name": "Is Output",
            "description": "Controls the port direction.",
            "type": "boolean",
            "modifiable": True,
        }
    }

    BASE_PATH = "/sys/class/gpio"

    def __init__(self, no: int, def_value: bool | None = None, def_output: bool | None = None) -> None:
        self._no: int = no
        self._def_value: bool | None = def_value
        self._def_output: bool | None = def_output

        self._val_file: TextIO | None = None
        self._dir_file: TextIO | None = None

        super().__init__(port_id=f"gpio{no}")

    async def handle_enable(self) -> None:
        self._configure()

        if self._def_output is not None:
            await self.attr_set_output(self._def_output)

    @_retry_after_configure
    async def read_value(self) -> bool:
        self._val_file.seek(0)
        return self._val_file.read(1) == "1"

    @_retry_after_configure
    async def write_value(self, value: bool | None) -> None:
        if value is None:
            return

        self._val_file.seek(0)
        if value:
            value = "1"
        else:
            value = "0"

        self.debug('writing %s to "%s"', json_utils.dumps(value), self._val_file.name)
        self._val_file.write(value)
        self._val_file.flush()

    async def attr_is_writable(self) -> bool:
        return await self._is_output()

    async def attr_set_output(self, output: bool) -> None:
        if not self._dir_file:
            return

        self._dir_file.seek(0)

        if output:
            text = "out"
        else:
            text = "in"

        self.debug('writing "%s" to "%s"', text, self._dir_file.name)
        self._dir_file.write(text)
        self._dir_file.flush()

        if output and self._def_value is not None:
            await self.write_value(self._def_value)

    async def attr_is_output(self) -> bool:
        return await self._is_output()

    @_retry_after_configure
    async def _is_output(self) -> bool:
        if not self._dir_file:
            return False

        self._dir_file.seek(0)
        return self._dir_file.read(3) == "out"

    def _configure(self) -> None:
        self.debug("configuring GPIO")
        try:
            (self._val_file, self._dir_file) = self._open_files()
        except Exception as e:
            self.error("configuration failed: %s", e)
            raise

    def _open_files(self) -> tuple[TextIO, TextIO]:
        path = os.path.join(self.BASE_PATH, f"gpio{self._no}")

        if not os.path.exists(path):
            self.debug("exporting GPIO")
            with open(os.path.join(self.BASE_PATH, "export"), "w") as f:
                f.write(str(self._no))

        return open(os.path.join(path, "value"), "r+"), open(os.path.join(path, "direction"), "r+")
