"""
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,
    CMD_FEEDING,
    CMD_SET_FEEDER_PLAN,
    CMD_CHILD_LOCK,
    CMD_REMINDER_TONE,
    CMD_QUERY_FEEDER_PLAN,
    CMD_QUERY_NAME_VERSION,
    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:
                    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 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 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.
        """
        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 child lock status")
        self._protocol.clear_notifications()
        before = len(self._protocol.received_data)
        await self._protocol.query_child_lock()
        await asyncio.sleep(1.5)
        for data in self._protocol.received_data[before:]:
            decoded = self._protocol.decode_notification(data)
            if decoded.get("command") == "0D" and "child_lock" in decoded:
                locked = decoded["child_lock"] == 1
                _LOGGER.debug("Child lock status: %s", "locked" if locked else "unlocked")
                return locked
        _LOGGER.warning("No child lock response received within 1.5s")
        return None

    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.
        """
        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 prompt sound status")
        self._protocol.clear_notifications()
        before = len(self._protocol.received_data)
        await self._protocol.query_reminder_tone()
        await asyncio.sleep(1.5)
        for data in self._protocol.received_data[before:]:
            decoded = self._protocol.decode_notification(data)
            if decoded.get("command") == "12" and "prompt_sound" in decoded:
                enabled = decoded["prompt_sound"] == 1
                _LOGGER.debug("Prompt sound status: %s", "on" if enabled else "off")
                return enabled
        _LOGGER.warning("No prompt sound response received within 1.5s")
        return None

    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)

    @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
        )
