"""
Petnetizen Feeder BLE Device Controller

Main library class for controlling feeder devices.
"""

import asyncio
import logging
from collections.abc import Awaitable, Callable
from datetime import datetime
from typing import Any, List, Dict, Optional
from .protocol import (
    FeederBLEProtocol,
    discover_feeders,  # noqa: F401 (re-exported via __init__)
    CMD_FEEDING,
    CMD_SET_FEEDER_PLAN,
    CMD_CHILD_LOCK,
    CMD_REMINDER_TONE,
    CMD_QUERY_FEEDER_PLAN,
    DEFAULT_VERIFICATION_CODE,
)

_LOGGER = logging.getLogger(__name__)

ConnectionFactory = Callable[[], Awaitable[Any]]

# Weekday bitmask values (from FeedInfo.Companion.getWeekValue)
WEEKDAY_BITMASK = {
    "sun": 1,
    "mon": 2,
    "tue": 4,
    "wed": 8,
    "thu": 16,
    "fri": 32,
    "sat": 64,
}


class Weekday:
    """Weekday constants for schedule"""

    SUNDAY = "sun"
    MONDAY = "mon"
    TUESDAY = "tue"
    WEDNESDAY = "wed"
    THURSDAY = "thu"
    FRIDAY = "fri"
    SATURDAY = "sat"

    ALL_DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]
    WEEKDAYS = ["mon", "tue", "wed", "thu", "fri"]
    WEEKEND = ["sat", "sun"]


class FeedSchedule:
    """Represents a single feed schedule entry"""

    def __init__(
        self, weekdays: List[str], time: str, portions: int, enabled: bool = True
    ):
        """
        Args:
            weekdays: List of weekday names (e.g., ["mon", "wed", "fri"])
            time: Time in HH:MM format (e.g., "08:00")
            portions: Number of portions to feed (1-15)
            enabled: Whether this schedule is enabled
        """
        self.weekdays = weekdays
        self.time = time
        self.portions = portions
        self.enabled = enabled

    def to_bytes(self) -> bytes:
        """Convert schedule to protocol format"""
        # Calculate week bitmask
        week_value = 0
        for day in self.weekdays:
            day_lower = day.lower()
            if day_lower in WEEKDAY_BITMASK:
                week_value |= WEEKDAY_BITMASK[day_lower]

        # Parse time
        hour, minute = map(int, self.time.split(":"))

        # Format: week(1 hex) + hour(1 hex) + minute(1 hex) + count(1 hex) + enabled(1 hex)
        return bytes(
            [week_value, hour, minute, self.portions, 1 if self.enabled else 0]
        )


class FeederDevice:
    """
    Main class for controlling Petnetizen feeder devices via BLE.

    Example:
        async def main():
            feeder = FeederDevice("E6:C0:07:09:A3:D3")
            await feeder.connect()
            await feeder.feed(portions=2)
            await feeder.disconnect()

        asyncio.run(main())
    """

    def __init__(
        self,
        address: str,
        verification_code: str = DEFAULT_VERIFICATION_CODE,
        device_type: Optional[str] = None,
        connection_factory: Optional[ConnectionFactory] = None,
    ):
        """
        Initialize feeder device controller.

        Args:
            address: BLE device address (e.g., "E6:C0:07:09:A3:D3")
            verification_code: Verification code (default: "00000000")
            device_type: Optional "standard", "jk", or "ali" (auto-detected from name if not set)
            connection_factory: Optional async callable returning a connected BleakClient.
                When provided (e.g. from Home Assistant via ``establish_connection``),
                reconnection uses this factory instead of creating a raw ``BleakClient``.
                Standalone scripts can omit this to use the built-in connection logic.
        """
        self.address = address
        self.verification_code = verification_code
        self._connection_factory = connection_factory
        self._protocol = FeederBLEProtocol(address, device_type=device_type)
        if connection_factory is not None:
            self._protocol._managed_connection = True
        self._connected = False
        self._reconnect_lock = asyncio.Lock()

    async def connect(self, ble_client: Optional[Any] = None) -> bool:
        """
        Connect to the feeder device.

        Args:
            ble_client: Optional already-connected BleakClient (e.g. from bleak_retry_connector).
                        When provided, the library uses it instead of creating a new connection.

        Returns:
            True if connection successful, False otherwise
        """
        _LOGGER.debug("Connecting to feeder %s", self.address)
        if await self._protocol.connect(ble_client=ble_client):
            await self._protocol.send_verification_code(self.verification_code)
            self._connected = True
            _LOGGER.info("Connected to feeder %s", self.address)
            return True
        _LOGGER.warning("Failed to connect to feeder %s", self.address)
        return False

    async def reconnect(self, ble_client: Optional[Any] = None) -> bool:
        """
        Reconnect with an optional new BleakClient.

        Use this when the integration obtains a fresh BleakClient (e.g. via
        bleak_retry_connector) and needs to hand it to the library.
        """
        _LOGGER.debug("Reconnecting to feeder %s", self.address)
        self._connected = False
        if ble_client is not None:
            ok = await self._protocol.replace_client(ble_client)
        else:
            ok = await self._protocol.connect()
        if ok:
            await self._protocol.send_verification_code(self.verification_code)
            self._connected = True
            _LOGGER.info("Reconnected to feeder %s", self.address)
        else:
            _LOGGER.warning("Failed to reconnect to feeder %s", self.address)
        return ok

    async def disconnect(self):
        """Disconnect from the device"""
        _LOGGER.debug("Disconnecting from feeder %s", self.address)
        await self._protocol.disconnect()
        self._connected = False
        _LOGGER.info("Disconnected from feeder %s", self.address)

    async def ensure_connected(self) -> bool:
        """Ensure the device is connected, attempting reconnection if needed.

        When a ``connection_factory`` was supplied at init time (e.g. from
        Home Assistant's ``establish_connection``), reconnection uses it to
        obtain a fresh, adapter-managed ``BleakClient``.  Otherwise falls
        back to a direct ``BleakClient`` connection (standalone mode).

        Returns ``False`` (without raising) when reconnection fails so the
        caller can decide how to handle it.
        """
        if self.is_connected:
            return True
        if not self._connected:
            return False

        async with self._reconnect_lock:
            if self.is_connected:
                return True
            _LOGGER.info("Feeder %s disconnected, attempting reconnect", self.address)
            try:
                if self._connection_factory:
                    # Release the stale connection slot on the proxy BEFORE
                    # opening a new one.  Without this the ESP32 proxy leaks
                    # slots (max ~3) until it's completely stuck.
                    await self._release_stale_connection()
                    ble_client = await self._connection_factory()
                    ok = await self.reconnect(ble_client=ble_client)
                else:
                    ok = await self.reconnect()
                return ok
            except Exception as exc:
                _LOGGER.warning(
                    "Reconnection to feeder %s failed: %s",
                    self.address,
                    exc,
                )
                return False

    async def _release_stale_connection(self) -> None:
        """Tell the BLE proxy to release any stale connection to this device.

        Even when the BLE link is dead (``is_connected`` is False), the
        proxy (e.g. ESP32 via ESPHome) may still hold the connection slot.
        Calling ``disconnect()`` on the old client sends an explicit
        disconnect request to the proxy so it can free the slot before we
        open a new one.
        """
        client = self._protocol.client
        if client is None:
            return
        try:
            try:
                await client.disconnect()
            except Exception:
                pass
            _LOGGER.debug(
                "Released stale BLE connection for %s",
                self.address,
            )
        finally:
            self._protocol.client = None
        await asyncio.sleep(2.0)

    async def feed(self, portions: int = 1, *, fast: bool = True) -> bool:
        """
        Trigger manual feed with specified number of portions.

        Args:
            portions: Number of portions to feed (1-15, typically 1-3)
            fast: If True (default), skip pre-queries (fault, child lock, feeding status)
                  for a quicker response. Set False to check device state before feeding.

        Returns:
            True if feed command was acknowledged, False otherwise

        Raises:
            RuntimeError: If not connected
        """
        if not self._connected:
            raise RuntimeError("Not connected to device. Call connect() first.")

        if not await self.ensure_connected():
            raise RuntimeError("Connection lost. Please reconnect.")

        _LOGGER.debug("Feeding %d portion(s) (fast=%s)", portions, fast)

        if not fast:
            await self._protocol.query_fault()
            await asyncio.sleep(0.5)
            await self._protocol.query_child_lock()
            await asyncio.sleep(0.5)
            await self._protocol.query_feeding_status()
            await asyncio.sleep(0.5)

        command = self._protocol.encode_command(
            CMD_FEEDING, length=1, action_hex=f"{portions:02X}"
        )

        self._protocol.clear_notifications()
        notification_count_before = len(self._protocol.received_data)

        try:
            await self._protocol.client.write_gatt_char(
                self._protocol.write_uuid, command, response=False
            )
            _LOGGER.debug("Feed command sent, waiting for response")

            feed_triggered = False
            for _ in range(40):  # Wait up to 10 seconds
                await asyncio.sleep(0.25)
                if len(self._protocol.received_data) > notification_count_before:
                    new_notifications = self._protocol.received_data[
                        notification_count_before:
                    ]
                    for data in new_notifications:
                        decoded = self._protocol.decode_notification(data)
                        cmd = decoded.get("command", "")

                        if cmd == "08":
                            feed_triggered = True
                            _LOGGER.debug("Feed acknowledged by device")
                        elif cmd == "0C":
                            _LOGGER.info("Feed completed (%d portions)", portions)
                            return True

            if feed_triggered:
                _LOGGER.info(
                    "Feed triggered but no completion result within 10s (%d portions)",
                    portions,
                )
            else:
                _LOGGER.warning(
                    "No feed response from device within 10s (%d portions)",
                    portions,
                )
            return feed_triggered
        except Exception as e:
            raise RuntimeError(f"Failed to send feed command: {e}") from e

    async def set_schedule(self, schedules: List[FeedSchedule]) -> bool:
        """
        Set feed schedule.

        Args:
            schedules: List of FeedSchedule objects

        Returns:
            True if command was sent successfully

        Raises:
            RuntimeError: If not connected
        """
        if not self._connected:
            raise RuntimeError("Not connected to device. Call connect() first.")

        if not await self.ensure_connected():
            raise RuntimeError("Connection lost. Please reconnect.")

        _LOGGER.debug("Setting schedule with %d slot(s)", len(schedules))

        schedule_data = bytearray()
        for schedule in schedules:
            schedule_data.extend(schedule.to_bytes())

        command = self._protocol.encode_command(
            CMD_SET_FEEDER_PLAN,
            length=len(schedule_data),
            action_hex=schedule_data.hex().upper(),
        )

        try:
            await self._protocol.client.write_gatt_char(
                self._protocol.write_uuid, command, response=False
            )
            await asyncio.sleep(1)
            _LOGGER.info("Schedule set (%d slots)", len(schedules))
            return True
        except Exception as e:
            raise RuntimeError(f"Failed to set schedule: {e}") from e

    async def set_child_lock(self, locked: bool) -> bool:
        """
        Set child lock state.

        Args:
            locked: True to lock, False to unlock

        Returns:
            True if command was sent successfully

        Raises:
            RuntimeError: If not connected
        """
        if not self._connected:
            raise RuntimeError("Not connected to device. Call connect() first.")

        if not await self.ensure_connected():
            raise RuntimeError("Connection lost. Please reconnect.")

        _LOGGER.debug("Setting child lock to %s", locked)

        value = "01" if locked else "00"
        command = self._protocol.encode_command(
            CMD_CHILD_LOCK, length=1, action_hex=value
        )

        try:
            await self._protocol.client.write_gatt_char(
                self._protocol.write_uuid, command, response=False
            )
            await asyncio.sleep(1)
            _LOGGER.info("Child lock set to %s", "locked" if locked else "unlocked")
            return True
        except Exception as e:
            raise RuntimeError(f"Failed to set child lock: {e}") from e

    async def set_sound(self, enabled: bool) -> bool:
        """
        Set reminder tone/sound state.

        Args:
            enabled: True to enable sound, False to disable

        Returns:
            True if command was sent successfully

        Raises:
            RuntimeError: If not connected
        """
        if not self._connected:
            raise RuntimeError("Not connected to device. Call connect() first.")

        if not await self.ensure_connected():
            raise RuntimeError("Connection lost. Please reconnect.")

        _LOGGER.debug("Setting sound to %s", enabled)

        value = "01" if enabled else "00"
        command = self._protocol.encode_command(
            CMD_REMINDER_TONE, length=1, action_hex=value
        )

        try:
            await self._protocol.client.write_gatt_char(
                self._protocol.write_uuid, command, response=False
            )
            await asyncio.sleep(1)
            _LOGGER.info("Sound set to %s", "on" if enabled else "off")
            return True
        except Exception as e:
            raise RuntimeError(f"Failed to set sound: {e}") from e

    async def query_schedule(self) -> List[Dict]:
        """
        Query current feed schedule.

        Returns:
            List of schedule dictionaries

        Raises:
            RuntimeError: If not connected
        """
        if not self._connected:
            raise RuntimeError("Not connected to device. Call connect() first.")

        if not await self.ensure_connected():
            raise RuntimeError("Connection lost. Please reconnect.")

        _LOGGER.debug("Querying schedule")
        command = self._protocol.encode_command(CMD_QUERY_FEEDER_PLAN, length=0)

        self._protocol.clear_notifications()
        notification_count_before = len(self._protocol.received_data)

        try:
            await self._protocol.client.write_gatt_char(
                self._protocol.write_uuid, command, response=False
            )
            await asyncio.sleep(4.0)

            new_count = len(self._protocol.received_data) - notification_count_before
            if new_count > 0:
                new_notifications = self._protocol.received_data[
                    notification_count_before:
                ]
                _LOGGER.debug(
                    "query_schedule: got %d new notification(s)", len(new_notifications)
                )
                for data in new_notifications:
                    decoded = self._protocol.decode_notification(data)
                    cmd = decoded.get("command")
                    if cmd == "11":
                        slots = decoded.get("feed_plan_slots") or []
                        if slots:
                            _LOGGER.debug("Schedule received: %d slot(s)", len(slots))
                            return slots
                        if "data_hex" in decoded:
                            _LOGGER.debug(
                                "QUERY_FEEDER_PLAN response data_hex=%s len=%s",
                                decoded.get("data_hex"),
                                len(data) if hasattr(data, "__len__") else None,
                            )
                    elif cmd and "error" not in decoded:
                        _LOGGER.debug("query_schedule saw notification cmd=%s", cmd)
            else:
                _LOGGER.warning("No response to schedule query within 4s")
            return []
        except Exception as e:
            raise RuntimeError(f"Failed to query schedule: {e}") from e

    async def get_device_info(self) -> Dict:
        """
        Query device name and firmware version.

        Returns:
            Dict with "device_name", "device_version" (or empty strings if not available).
        """
        if not self._connected:
            raise RuntimeError("Not connected to device. Call connect() first.")
        if not await self.ensure_connected():
            raise RuntimeError("Connection lost. Please reconnect.")
        _LOGGER.debug("Querying device info")
        self._protocol.clear_notifications()
        before = len(self._protocol.received_data)
        await self._protocol.query_name_version()
        await asyncio.sleep(2)
        result: Dict = {"device_name": "", "device_version": ""}
        for data in self._protocol.received_data[before:]:
            decoded = self._protocol.decode_notification(data)
            if decoded.get("command") == "00":
                result["device_name"] = decoded.get("device_name", "") or ""
                result["device_version"] = decoded.get("device_version", "") or ""
                _LOGGER.debug(
                    "Device info: name=%s version=%s",
                    result["device_name"],
                    result["device_version"],
                )
                break
        else:
            _LOGGER.warning("No device info response received within 2s")
        return result

    async def _query_state(
        self, query_func, command_code, timeout: float = 1.5
    ) -> Optional[Dict]:
        """Send a query command and return the first matching decoded notification, or None."""
        if not self._connected:
            raise RuntimeError("Not connected to device. Call connect() first.")
        if not await self.ensure_connected():
            raise RuntimeError("Connection lost. Please reconnect.")
        self._protocol.clear_notifications()
        before = len(self._protocol.received_data)
        await query_func()
        await asyncio.sleep(timeout)
        codes = (command_code,) if isinstance(command_code, str) else command_code
        for data in self._protocol.received_data[before:]:
            decoded = self._protocol.decode_notification(data)
            if decoded.get("command") in codes:
                return decoded
        return None

    async def _set_feature(self, log_msg: str, coro) -> bool:
        """Connection-guard wrapper for protocol set commands."""
        if not self._connected:
            raise RuntimeError("Not connected to device. Call connect() first.")
        if not await self.ensure_connected():
            raise RuntimeError("Connection lost. Please reconnect.")
        _LOGGER.debug(log_msg)
        await coro
        return True

    async def get_child_lock_status(self) -> Optional[bool]:
        """
        Query child lock status from the device.

        Returns:
            True if locked, False if unlocked, or None if query failed or no response.
        """
        decoded = await self._query_state(self._protocol.query_child_lock, "0D")
        if decoded is None or "child_lock" not in decoded:
            _LOGGER.warning("No child lock response received within 1.5s")
            return None
        locked = decoded["child_lock"] == 1
        _LOGGER.debug("Child lock status: %s", "locked" if locked else "unlocked")
        return locked

    async def get_prompt_sound_status(self) -> Optional[bool]:
        """
        Query prompt sound / reminder tone status from the device.

        Returns:
            True if sound is on, False if off, or None if query failed or no response.
        """
        decoded = await self._query_state(self._protocol.query_reminder_tone, "12")
        if decoded is None or "prompt_sound" not in decoded:
            _LOGGER.warning("No prompt sound response received within 1.5s")
            return None
        enabled = decoded["prompt_sound"] == 1
        _LOGGER.debug("Prompt sound status: %s", "on" if enabled else "off")
        return enabled

    async def sync_time(self, dt: Optional[datetime] = None) -> None:
        """
        Sync device clock to the given time (default: now).

        Args:
            dt: Time to set on the device; defaults to datetime.now().
        """
        if not self._connected:
            raise RuntimeError("Not connected to device. Call connect() first.")
        _LOGGER.debug("Syncing time to %s", dt or "now")
        await self._protocol.send_sync_time(dt)

    async def get_fault_status(self) -> Optional[int]:
        """Query device fault status. Returns fault code int (0 = no fault), or None."""
        decoded = await self._query_state(self._protocol.query_fault, "0A")
        if decoded is None or "fault_code" not in decoded:
            _LOGGER.warning("No fault status response received within 1.5s")
            return None
        _LOGGER.debug("Fault status: %s", decoded["fault_code"])
        return decoded["fault_code"]

    async def get_feeding_status(self) -> Optional[str]:
        """Query current feeding status. Returns 'idle', 'feeding', 'error', or None."""
        decoded = await self._query_state(self._protocol.query_feeding_status, "09")
        if decoded is None or "feeding_status_text" not in decoded:
            _LOGGER.warning("No feeding status response received within 1.5s")
            return None
        status = decoded["feeding_status_text"].lower()
        _LOGGER.debug("Feeding status: %s", status)
        return status

    async def set_led(self, enabled: bool) -> bool:
        """Set LED indicator on/off."""
        return await self._set_feature(
            f"Setting LED to {enabled}", self._protocol.set_led(enabled)
        )

    async def set_auto_lock(self, enabled: bool) -> bool:
        """Set auto-lock on/off."""
        return await self._set_feature(
            f"Setting auto-lock to {enabled}", self._protocol.set_auto_lock(enabled)
        )

    async def set_atmosphere_light(self, enabled: bool) -> bool:
        """Set atmosphere light on/off."""
        return await self._set_feature(
            f"Setting atmosphere light to {enabled}",
            self._protocol.set_atmosphere_light(enabled),
        )

    async def factory_reset(self) -> bool:
        """Send factory reset command."""
        _LOGGER.warning("Sending factory reset to feeder %s", self.address)
        return await self._set_feature("Factory reset", self._protocol.factory_reset())

    async def get_do_not_disturb(self) -> Optional[Dict]:
        """
        Query Do Not Disturb settings.

        Returns:
            Dict with keys: enabled (bool), start_time (str HH:MM), end_time (str HH:MM),
            or None if no response.
        """
        decoded = await self._query_state(
            self._protocol.query_do_not_disturb, ("17", "18")
        )
        if decoded is None or "do_not_disturb" not in decoded:
            _LOGGER.warning("No DND response received within 1.5s")
            return None
        result = {
            "enabled": decoded["do_not_disturb"],
            "start_time": decoded.get("dnd_start", "22:00"),
            "end_time": decoded.get("dnd_end", "08:00"),
        }
        _LOGGER.debug("DND settings: %s", result)
        return result

    async def set_do_not_disturb(
        self, enabled: bool, start_time: str = "22:00", end_time: str = "08:00"
    ) -> bool:
        """Set Do Not Disturb. start_time/end_time in HH:MM format."""
        return await self._set_feature(
            f"Setting DND to {enabled} ({start_time}–{end_time})",
            self._protocol.set_do_not_disturb(enabled, start_time, end_time),
        )

    async def set_long_ring(self, enabled: bool) -> bool:
        """Set long ring / extended alarm tone on/off."""
        return await self._set_feature(
            f"Setting long ring to {enabled}", self._protocol.set_long_ring(enabled)
        )

    def get_last_feed_result(self) -> Optional[Dict]:
        """Return last feed result from notifications, or None if no feed has completed."""
        return self._protocol.last_feed_result

    @property
    def is_connected(self) -> bool:
        """Check if device is connected"""
        return self._connected and (
            self._protocol.client is not None and self._protocol.client.is_connected
        )
