"""Async TCP client for the NUT (Network UPS Tools) upsd protocol."""

from __future__ import annotations

import asyncio
import logging
import re
from dataclasses import dataclass
from typing import Any

_LOGGER = logging.getLogger(__name__)

_DEFAULT_PORT = 3493
_READ_TIMEOUT = 10.0
_WRITE_TIMEOUT = 10.0
_CONNECT_TIMEOUT = 5.0

# Allowed characters for NUT protocol identifiers (UPS names, variable names).
# Prevents command injection via newlines or special characters.
_SAFE_NAME_RE = re.compile(r"^[\w.\-]+$")

# Regex to parse VAR lines: VAR <ups> <name> "<value>"
_VAR_RE = re.compile(r'^VAR\s+(\S+)\s+(\S+)\s+"(.*)"$')
# Regex to parse UPS lines: UPS <name> "<description>"
_UPS_RE = re.compile(r'^UPS\s+(\S+)\s+"(.*)"$')


def _validate_name(value: str, label: str) -> None:
    """Validate that *value* is a safe NUT protocol identifier.

    Raises ``NutClientError`` if *value* contains characters outside
    ``[\\w.\\-]``, which could be used for protocol command injection.
    """
    if not _SAFE_NAME_RE.match(value):
        raise NutClientError(
            f"Invalid {label}: {value!r} contains disallowed characters"
        )


class NutClientError(Exception):
    """Error communicating with NUT server."""


@dataclass
class NutUpsDevice:
    """A UPS device reported by upsd."""

    name: str
    description: str


class NutClient:
    """Async TCP client for the NUT upsd protocol.

    Protocol reference: https://networkupstools.org/docs/developer-guide.chunked/ar01s09.html
    """

    def __init__(
        self,
        host: str,
        port: int = _DEFAULT_PORT,
        username: str | None = None,
        password: str | None = None,
    ) -> None:
        self.host = host
        self.port = port
        self._username = username
        self._password = password
        self._reader: asyncio.StreamReader | None = None
        self._writer: asyncio.StreamWriter | None = None

    async def connect(self) -> None:
        """Open TCP connection and optionally authenticate."""
        try:
            self._reader, self._writer = await asyncio.wait_for(
                asyncio.open_connection(self.host, self.port),
                timeout=_CONNECT_TIMEOUT,
            )
        except (OSError, asyncio.TimeoutError) as exc:
            raise NutClientError(f"Cannot connect to {self.host}:{self.port}: {exc}") from exc

        if self._username:
            await self._command(f"USERNAME {self._username}")
        if self._password:
            await self._command(f"PASSWORD {self._password}")

    async def disconnect(self) -> None:
        """Send LOGOUT and close the TCP connection.

        ``drain`` is bounded by ``_WRITE_TIMEOUT`` so a half-closed peer
        cannot hold the disconnect indefinitely — a tracker tick would
        otherwise be pinned by a stuck NUT server and block the scheduler
        slot (``max_instances=1``).
        """
        if self._writer is not None:
            try:
                self._writer.write(b"LOGOUT\n")
                await asyncio.wait_for(self._writer.drain(), timeout=_WRITE_TIMEOUT)
            except (OSError, asyncio.TimeoutError):
                pass
            self._writer.close()
            try:
                await asyncio.wait_for(
                    self._writer.wait_closed(), timeout=_WRITE_TIMEOUT,
                )
            except (OSError, asyncio.TimeoutError):
                pass
            self._reader = None
            self._writer = None

    async def list_ups(self) -> list[NutUpsDevice]:
        """List all UPS devices configured on the server."""
        lines = await self._list_command("LIST UPS")
        devices: list[NutUpsDevice] = []
        for line in lines:
            m = _UPS_RE.match(line)
            if m:
                devices.append(NutUpsDevice(name=m.group(1), description=m.group(2)))
        return devices

    async def list_var(self, ups_name: str) -> dict[str, str]:
        """Get all variables for a UPS device."""
        _validate_name(ups_name, "UPS name")
        lines = await self._list_command(f"LIST VAR {ups_name}")
        variables: dict[str, str] = {}
        for line in lines:
            m = _VAR_RE.match(line)
            if m and m.group(1) == ups_name:
                variables[m.group(2)] = m.group(3)
        return variables

    async def get_var(self, ups_name: str, var_name: str) -> str:
        """Get a single variable value."""
        _validate_name(ups_name, "UPS name")
        _validate_name(var_name, "variable name")
        response = await self._command(f"GET VAR {ups_name} {var_name}")
        m = _VAR_RE.match(response)
        if m:
            return m.group(3)
        raise NutClientError(f"Unexpected response for GET VAR: {response}")

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    async def _send(self, cmd: str) -> None:
        """Send a command line to upsd."""
        if self._writer is None:
            raise NutClientError("Not connected")
        self._writer.write(f"{cmd}\n".encode())
        try:
            await asyncio.wait_for(self._writer.drain(), timeout=_WRITE_TIMEOUT)
        except asyncio.TimeoutError as exc:
            raise NutClientError("Write timeout") from exc

    async def _readline(self) -> str:
        """Read one line from upsd, stripping trailing newline."""
        if self._reader is None:
            raise NutClientError("Not connected")
        try:
            data = await asyncio.wait_for(self._reader.readline(), timeout=_READ_TIMEOUT)
        except asyncio.TimeoutError as exc:
            raise NutClientError("Read timeout") from exc
        if not data:
            raise NutClientError("Connection closed by server")
        return data.decode("utf-8", errors="replace").rstrip("\n")

    async def _command(self, cmd: str) -> str:
        """Send a single command and return the response line."""
        await self._send(cmd)
        line = await self._readline()
        if line.startswith("ERR "):
            raise NutClientError(f"NUT error: {line}")
        return line

    async def _list_command(self, cmd: str) -> list[str]:
        """Send a LIST command and collect lines between BEGIN/END markers."""
        await self._send(cmd)
        # Expect: BEGIN LIST ...
        begin = await self._readline()
        if begin.startswith("ERR "):
            raise NutClientError(f"NUT error: {begin}")
        if not begin.startswith("BEGIN "):
            raise NutClientError(f"Expected BEGIN, got: {begin}")

        lines: list[str] = []
        while True:
            line = await self._readline()
            if line.startswith("END "):
                break
            if line.startswith("ERR "):
                raise NutClientError(f"NUT error mid-list: {line}")
            lines.append(line)
        return lines
