# pylint: disable=unidiomatic-typecheck
from __future__ import annotations

import asyncio
import itertools
import logging
from typing import TYPE_CHECKING, Any

from ._frame_helper.base import (  # noqa: F401
    MAX_NAME_LEN,
    APIFrameHelper,
    safe_label_str,
)
from ._frame_helper.plain_text import APIPlaintextFrameHelper  # noqa: F401
from .api_pb2 import (  # type: ignore[attr-defined]
    BluetoothConnectionsFreeResponse,
    BluetoothDeviceConnectionResponse,
    BluetoothGATTErrorResponse,
    BluetoothGATTGetServicesDoneResponse,
    BluetoothGATTGetServicesResponse,
    BluetoothGATTNotifyDataResponse,
    BluetoothGATTNotifyResponse,
    BluetoothGATTReadResponse,
    BluetoothGATTWriteResponse,
    BluetoothLEAdvertisementResponse,
    BluetoothScannerStateResponse,
    CameraImageResponse,
    HomeassistantActionRequest,
    InfraredRFReceiveEvent,
    SerialProxyDataReceived,
    SubscribeHomeAssistantStateResponse,
    ZWaveProxyRequest,
)
from .connection import ConnectionParams
from .core import APIConnectionError
from .model import (
    APIVersion,
    BluetoothLEAdvertisement,
    BluetoothScannerStateResponse as BluetoothScannerStateResponseModel,
    CameraState,
    DeviceInfo,
    EntityState,
    HomeassistantServiceCall,
    InfraredRFReceiveEvent as InfraredRFReceiveEventModel,
    SerialProxyDataReceived as SerialProxyDataReceivedModel,
    ZWaveProxyRequest as ZWaveProxyRequestModel,
)
from .model_conversions import SUBSCRIBE_STATES_RESPONSE_TYPES
from .util import build_log_name, create_eager_task
from .zeroconf import ZeroconfInstanceType, ZeroconfManager

if TYPE_CHECKING:
    from collections.abc import Callable, Coroutine

    from google.protobuf import message

    from .connection import APIConnection

_LOGGER = logging.getLogger(__name__)

# We send a ping every 20 seconds, and the timeout ratio is 4.5x the
# ping interval. This means that if we don't receive a ping for 90.0
# seconds, we'll consider the connection dead and reconnect.
#
# This was chosen because the 20s is around the expected time for a
# device to reboot and reconnect to wifi, and 90 seconds is the absolute
# maximum time a device can take to respond when its behind + the WiFi
# connection is poor.
KEEP_ALIVE_FREQUENCY = 20.0

# Caps on the per-subscription camera reassembly buffer. The peer fully
# controls cam_msg.key and cam_msg.done, so without these limits a single
# device can stream chunks indefinitely (memory exhaustion DoS) or rotate
# keys to allocate ~4 billion list entries.
MAX_CAMERA_FRAME_BYTES = 8 * 1024 * 1024
MAX_INFLIGHT_CAMERA_KEYS = 4


def on_state_msg(
    on_state: Callable[[EntityState], None],
    image_stream: dict[int, bytearray | None],
    msg: message.Message,
) -> None:
    """Handle a state message."""
    msg_type = type(msg)
    if (cls := SUBSCRIBE_STATES_RESPONSE_TYPES.get(msg_type)) is not None:
        on_state(cls.from_pb(msg))
    elif msg_type is CameraImageResponse:
        cam_msg: CameraImageResponse = msg
        msg_key = cam_msg.key
        buf = image_stream.get(msg_key)
        if buf is None:
            if msg_key in image_stream:
                # Key was discarded earlier (oversized frame); ignore further
                # chunks until done=True so the peer can't restart with fresh
                # data and emit a truncated frame.
                if cam_msg.done:
                    del image_stream[msg_key]
                return
            if len(image_stream) >= MAX_INFLIGHT_CAMERA_KEYS:
                _LOGGER.warning(
                    "Dropping camera frame chunk for key %s: too many in-flight"
                    " camera streams (%d)",
                    msg_key,
                    len(image_stream),
                )
                return
            buf = bytearray()
            image_stream[msg_key] = buf

        # Check the new size before extending so an oversized chunk can't
        # transiently grow the buffer past the cap (or duplicate the
        # peer-controlled bytes into the bytearray) before we drop it.
        if len(buf) + len(cam_msg.data) > MAX_CAMERA_FRAME_BYTES:
            if cam_msg.done:
                # Stream is over — clear the entry instead of leaving a
                # tombstone, otherwise oversized done=True chunks could
                # exhaust MAX_INFLIGHT_CAMERA_KEYS until the peer sends
                # another done=True for the same key.
                del image_stream[msg_key]
            else:
                image_stream[msg_key] = None
            _LOGGER.warning(
                "Dropping camera frame for key %s: exceeded %d byte limit",
                msg_key,
                MAX_CAMERA_FRAME_BYTES,
            )
            return
        buf.extend(cam_msg.data)
        if cam_msg.done:
            del image_stream[msg_key]
            on_state(
                CameraState(
                    key=cam_msg.key, data=bytes(buf), device_id=cam_msg.device_id
                )  # type: ignore[call-arg]
            )


def on_home_assistant_action_request(
    on_action: Callable[[HomeassistantServiceCall], None],
    msg: HomeassistantActionRequest,
) -> None:
    on_action(HomeassistantServiceCall.from_pb(msg))


def on_bluetooth_le_advertising_response(
    on_bluetooth_le_advertisement: Callable[[BluetoothLEAdvertisement], None],
    msg: BluetoothLEAdvertisementResponse,
) -> None:
    on_bluetooth_le_advertisement(BluetoothLEAdvertisement.from_pb(msg))  # type: ignore[misc]


def on_bluetooth_connections_free_response(
    on_bluetooth_connections_free_update: Callable[[int, int, list[int]], None],
    msg: BluetoothConnectionsFreeResponse,
) -> None:
    on_bluetooth_connections_free_update(msg.free, msg.limit, list(msg.allocated))


def on_bluetooth_gatt_notify_data_response(
    address: int,
    handle: int,
    on_bluetooth_gatt_notify: Callable[[int, bytearray], None],
    msg: BluetoothGATTNotifyDataResponse,
) -> None:
    """Handle a BluetoothGATTNotifyDataResponse message."""
    if address == msg.address and handle == msg.handle:
        on_bluetooth_gatt_notify(handle, bytearray(msg.data))


def on_bluetooth_scanner_state_response(
    on_bluetooth_scanner_state: Callable[[BluetoothScannerStateResponseModel], None],
    msg: BluetoothScannerStateResponse,
) -> None:
    on_bluetooth_scanner_state(BluetoothScannerStateResponseModel.from_pb(msg))


def on_subscribe_home_assistant_state_response(
    on_state_sub: Callable[[str, str | None], None],
    on_state_request: Callable[[str, str | None], None] | None,
    msg: SubscribeHomeAssistantStateResponse,
) -> None:
    if on_state_request and msg.once:
        on_state_request(msg.entity_id, msg.attribute)
    else:
        on_state_sub(msg.entity_id, msg.attribute)


def on_bluetooth_device_connection_response(
    connect_future: asyncio.Future[None],
    address: int,
    on_bluetooth_connection_state: Callable[[bool, int, int], None],
    msg: BluetoothDeviceConnectionResponse,
) -> None:
    """Handle a BluetoothDeviceConnectionResponse message."""
    if address == msg.address:
        on_bluetooth_connection_state(msg.connected, msg.mtu, msg.error)
        # Resolve on ANY connection state since we do not want
        # to wait the whole timeout if the device disconnects
        # or we get an error.
        if not connect_future.done():
            connect_future.set_result(None)


def on_bluetooth_handle_message(
    address: int,
    handle: int,
    msg: (
        BluetoothGATTErrorResponse
        | BluetoothGATTNotifyResponse
        | BluetoothGATTReadResponse
        | BluetoothGATTWriteResponse
        | BluetoothDeviceConnectionResponse
    ),
) -> bool:
    """Filter a Bluetooth message for an address and handle."""
    if type(msg) is BluetoothDeviceConnectionResponse:
        return bool(msg.address == address)
    return bool(msg.address == address and msg.handle == handle)


def on_bluetooth_message_types(
    address: int,
    msg_types: tuple[type[message.Message], ...],
    msg: (
        BluetoothGATTErrorResponse
        | BluetoothGATTNotifyResponse
        | BluetoothGATTReadResponse
        | BluetoothGATTWriteResponse
        | BluetoothDeviceConnectionResponse
        | BluetoothGATTGetServicesResponse
        | BluetoothGATTGetServicesDoneResponse
    ),
) -> bool:
    """Filter Bluetooth messages of a specific type and address."""
    return type(msg) in msg_types and bool(msg.address == address)


def on_zwave_proxy_request_message(
    on_zwave_proxy_request: Callable[[ZWaveProxyRequestModel], None],
    msg: ZWaveProxyRequest,
) -> None:
    on_zwave_proxy_request(ZWaveProxyRequestModel.from_pb(msg))


def on_infrared_rf_receive_event(
    on_infrared_rf_receive: Callable[[InfraredRFReceiveEventModel], None],
    msg: InfraredRFReceiveEvent,
) -> None:
    on_infrared_rf_receive(InfraredRFReceiveEventModel.from_pb(msg))


def on_serial_proxy_data_received(
    on_data: Callable[[SerialProxyDataReceivedModel], None],
    msg: SerialProxyDataReceived,
) -> None:
    on_data(SerialProxyDataReceivedModel.from_pb(msg))


str_ = str


def _stringify_or_none(value: str_ | None) -> str | None:
    """Convert a string like object to a str or None.

    The noise_psk is sometimes passed into
    the client as an Estr, but we want to pass it
    to the API as a string or None.
    """
    return None if value is None else str(value)


class APIClientBase:
    """Base client for ESPHome API clients."""

    __slots__ = (
        "_background_tasks",
        "_cached_device_info",
        "_call_id_counter",
        "_connection",
        "_debug_enabled",
        "_loop",
        "_notify_callbacks",
        "_params",
        "cached_name",
        "log_name",
    )

    def __init__(
        self,
        address: str_,  # allow subclass str
        port: int,
        password: str_ | None = None,
        *,
        client_info: str_ = "aioesphomeapi",
        keepalive: float = KEEP_ALIVE_FREQUENCY,
        zeroconf_instance: ZeroconfInstanceType | None = None,
        noise_psk: str_ | None = None,
        expected_name: str_ | None = None,
        addresses: list[str_] | None = None,
        expected_mac: str_ | None = None,
        timezone: str_ | None = None,
        provide_time: bool = True,
    ) -> None:
        """Create a client, this object is shared across sessions.

        :param address: The address to connect to; for example an IP address
          or .local name for mDNS lookup.
        :param port: The port to connect to
        :param password: Optional password to send to the device for authentication
        :param client_info: User Agent string to send.
        :param keepalive: The keepalive time in seconds (ping interval) for detecting stale connections.
            Every keepalive seconds a ping is sent, if no pong is received the connection is closed.
        :param zeroconf_instance: Pass a zeroconf instance to use if an mDNS lookup is necessary.
        :param noise_psk: Encryption preshared key for noise transport encrypted sessions.
        :param expected_name: Require the devices name to match the given expected name.
            Can be used to prevent accidentally connecting to a different device if
            IP passed as address but DHCP reassigned IP.
        :param addresses: Optional list of IP addresses to connect to which takes
            precedence over the address parameter. This is most commonly used when
            the device has dual stack IPv4 and IPv6 addresses and you do not know
            which one to connect to.
        :param expected_mac: Optional MAC address to check against the device.
            The format should be lower case without : or - separators.
            Example: 00:aa:22:33:44:55 -> 00aa22334455
        :param timezone: Optional IANA timezone name to send to ESPHome devices.
            If not provided, the system timezone will be detected automatically.
            Example: 'America/Chicago' or 'Europe/London'
        :param provide_time: If True, the client will respond to a server
            request for the current time and timezone.
        """
        self._debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG)
        self._params = ConnectionParams(
            addresses=[str(addr) for addr in addresses]
            if addresses
            else [str(address)],
            port=port,
            password=password,
            client_info=client_info,
            keepalive=keepalive,
            zeroconf_manager=ZeroconfManager(zeroconf_instance),
            # treat empty '' psk string as missing (like password)
            noise_psk=_stringify_or_none(noise_psk) or None,
            expected_name=_stringify_or_none(expected_name) or None,
            expected_mac=_stringify_or_none(expected_mac) or None,
            timezone=_stringify_or_none(timezone) or None,
            provide_time=provide_time,
        )
        self._connection: APIConnection | None = None
        self._cached_device_info: DeviceInfo | None = None
        self.cached_name: str | None = None
        self._background_tasks: set[asyncio.Task[Any]] = set()
        self._notify_callbacks: dict[tuple[int, int], Callable[[], None]] = {}
        self._loop = asyncio.get_running_loop()
        self._call_id_counter = itertools.count(1)
        self._set_log_name()

    def set_debug(self, enabled: bool) -> None:
        """Enable debug logging."""
        self._debug_enabled = enabled
        if self._connection is not None:
            self._connection.set_debug(enabled)

    @property
    def zeroconf_manager(self) -> ZeroconfManager:
        return self._params.zeroconf_manager

    @property
    def expected_name(self) -> str | None:
        return self._params.expected_name

    @expected_name.setter
    def expected_name(self, value: str | None) -> None:
        self._params.expected_name = value

    @property
    def loop(self) -> asyncio.AbstractEventLoop:
        return self._loop

    @property
    def address(self) -> str:
        return self._params.addresses[0]

    @property
    def port(self) -> int:
        return self._params.port

    @property
    def noise_psk(self) -> str | None:
        return self._params.noise_psk

    def clear_noise_psk(self) -> None:
        """Clear the noise PSK so future connections use plaintext.

        This is a security downgrade — callers must only invoke it after
        explicit opt-in (e.g. ``ReconnectLogic(allow_plaintext_fallback=
        True)`` for a one-way logger stream). Never call it for control-
        plane clients such as Home Assistant.
        """
        self._params.noise_psk = None

    @property
    def connected_address(self) -> str | None:
        """Return the address we are currently connected to, or None if not connected."""
        if self._connection is None:
            return None
        return self._connection.connected_address

    @property
    def api_version(self) -> APIVersion | None:
        if self._connection is None:
            return None
        return self._connection.api_version

    def _set_log_name(self) -> None:
        """Set the log name of the device."""
        connected_address: str | None = None
        if self._connection is not None and self._connection.connected_address:
            connected_address = self._connection.connected_address
        self.log_name = build_log_name(
            self.cached_name,
            self._params.addresses,
            connected_address,
        )
        if self._connection is not None:
            self._connection.set_log_name(self.log_name)

    def _set_name_from_device(self, name: str_) -> None:
        """Set the name from a DeviceInfo message."""
        # Sanitize peer-supplied name -- plaintext sessions are unauthenticated
        # for the whole connection, so CRLF/ANSI bytes in DeviceInfo.name would
        # otherwise forge log lines via "%s: ..." on self.log_name. str(...)
        # flattens esphome's Estr subclass.
        self.cached_name = safe_label_str(str(name), MAX_NAME_LEN)
        self._set_log_name()

    def set_cached_name_if_unset(self, name: str_) -> None:
        """Set the cached name of the device if not set."""
        if not self.cached_name:
            self._set_name_from_device(name)

    def _create_background_task(self, coro: Coroutine[Any, Any, None]) -> None:
        """Create a background task and add it to the background tasks set."""
        task = create_eager_task(coro)
        self._background_tasks.add(task)
        task.add_done_callback(self._background_tasks.discard)

    def _get_connection(self) -> APIConnection:
        if self._connection is None:
            msg = f"Not connected to {self.log_name}!"
            raise APIConnectionError(msg)
        if not self._connection.is_connected:
            msg = (
                f"Authenticated connection not ready yet for {self.log_name}; "
                f"current state is {self._connection.connection_state}!"
            )
            raise APIConnectionError(msg)
        return self._connection
