"""Main Mozart CLI document."""

import argparse
import asyncio
import contextlib
import ipaddress
import sys
import threading
from dataclasses import dataclass
from pprint import pprint
from typing import Final, cast

from aioconsole import ainput
from zeroconf import ServiceBrowser, ServiceListener, Zeroconf

from mozart_api import __version__
from mozart_api.models import BeolinkJoinRequest, VolumeLevel, VolumeMute
from mozart_api.mozart_client import (
    MozartClient,
    WebSocketEventType,
    check_valid_serial_number,
)

MOZART_MDNS_TYPE: Final[str] = "_bangolufsen._tcp.local."
MDNS_TIMEOUT: Final[int] = 10
DISCOVER_MODE: Final[str] = "discover"
VERSION_MODE: Final[str] = "version"

AVAILABLE_COMMANDS: Final[list[str]] = [
    "preset",
    "play",
    "pause",
    "next",
    "previous",
    "mute",
    "unmute",
    "volume",
    "join",
    "info",
    "allstandby",
]


@dataclass
class MozartDevice:
    """Dataclass for describing Zeroconf discovered Mozart devices."""

    friendly_name: str | None = ""
    model_name: str = ""
    serial_number: str = ""
    ip_address: str = ""
    sw_version: str = ""

    def __str__(self) -> str:
        """Format for easier viewing."""
        return f"""
friendly_name: {self.friendly_name}
model_name: {self.model_name}
serial_number: {self.serial_number}
ip_address: {self.ip_address}
sw_version: {self.sw_version}
"""


mozart_devices: list[MozartDevice] = []


class MozartListener(ServiceListener):
    """Listener for Zeroconf discovery of Mozart devices."""

    def __init__(self, mode: str, verbose: bool, event: threading.Event) -> None:
        """Initialize listener."""
        super().__init__()
        self.mode = mode
        self.verbose = verbose
        self.event = event

    def update_service(self, zc: Zeroconf, type_: str, name: str) -> None:
        """Unused."""

    def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None:
        """Unused."""

    def add_service(self, zc: Zeroconf, type_: str, name: str) -> None:
        """Add discovered Mozart device."""
        info = zc.get_service_info(type_, name)

        # Sometimes service info is None.
        if not info:
            print(f"Error getting {name}")
            return

        # Create MozartDevice object from MDNS discovered information.
        ip_address = info.parsed_addresses()[0]
        serial_number = cast("bytes", info.properties.get(b"sn")).decode("utf-8")
        model_name = cast("str", info.server)[:-16].replace("-", " ")
        sw_version = cast("bytes", info.properties.get(b"fv")).decode("utf-8")

        friendly_name = info.properties.get(b"fn")

        mozart_device = MozartDevice(
            friendly_name.decode("utf-8") if friendly_name is not None else None,
            model_name,
            serial_number,
            ip_address,
            sw_version,
        )

        mozart_devices.append(mozart_device)

        # Stop discovery if the desired Mozart device has been found.
        if self.mode == serial_number:
            print(f"Desired Mozart device: {self.mode} found: {mozart_device}")
            self.event.set()

        # Only print the discovered devices if in 'discover' mode or verbose is enabled.
        elif self.mode == DISCOVER_MODE or self.verbose:
            print(mozart_device)


def discover_devices(mode: str, timeout: int, verbose: bool) -> list[MozartDevice]:
    """MDNS discovery of devices on the current network."""
    event = threading.Event()

    zeroconf = Zeroconf()
    listener = MozartListener(mode, verbose, event)
    browser = ServiceBrowser(zeroconf, MOZART_MDNS_TYPE, listener)

    if mode == "discover" or verbose:
        print("Discovering Mozart devices. Scanning _bangolufsen._tcp.local.")

    if timeout == -1:
        input("Press 'enter' to stop discovery.\n\r")

    else:
        # Stop if the serial number has been found with MDNS
        timeout_status = event.wait(timeout)

        if not timeout_status:
            print(f"Discovery timed out with timeout of {timeout} seconds.")

    browser.cancel()
    zeroconf.close()

    return mozart_devices


def parse_mode(mode: str) -> str:
    """Parse mode input."""
    # Check for valid mode, serial number or IP address
    if (
        mode in (DISCOVER_MODE, VERSION_MODE)
        or check_valid_serial_number(mode)
        or ipaddress.ip_address(mode)
    ):
        return mode
    raise argparse.ArgumentTypeError


def init_argument_parser() -> argparse.ArgumentParser:
    """Initialize  and add arguments."""
    parser = argparse.ArgumentParser(
        prog="mozart_api",
        description="CLI for sending simple commands to Mozart devices.",
    )

    parser.add_argument(
        "--verbose",
        "-v",
        action="store_true",
        help="Specify if the output should be verbose.",
    )

    parser.add_argument(
        "--websocket",
        "-w",
        action="store_true",
        help="Specify if the websocket listener should be active.",
    )

    parser.add_argument(
        "--timeout",
        "-t",
        action="store",
        help="""Specify Zeroconf discovery timeout.
                '-1' for input-interrupted discovery in 'discover' mode.""",
    )

    parser.add_argument(
        "mode",
        type=parse_mode,
        help=f"""Specify the serial number or IP address for command execution
                ,'{DISCOVER_MODE}' for Zeroconf discovery of Mozart devices or "{VERSION_MODE}" to get the API client version.""",
    )

    parser.add_argument(
        "command",
        nargs="?",
        choices=AVAILABLE_COMMANDS,
        help="Specify the command.",
    )

    parser.add_argument(
        "command_args",
        nargs="*",
        help="Specify the command arguments if needed.",
    )
    return parser


class MozartApiCli:
    """Mozart CLI client."""

    def __init__(self) -> None:
        """Init the Mozart CLI."""
        self.timeout = MDNS_TIMEOUT
        self.verbose = False
        self.websocket = False
        self.mode = ""
        self.command = ""
        self.host = ""
        self.command_args: list[str] = []
        self.mozart_devices: list[MozartDevice] = []

        parser = init_argument_parser()
        args = parser.parse_args()

        if args.timeout:
            self.timeout = int(args.timeout)

        self.verbose = bool(args.verbose)
        self.websocket = bool(args.websocket)
        self.mode = args.mode
        self.command = args.command
        self.command_args = args.command_args

        # Check if the API version should be printed
        if self.mode == VERSION_MODE:
            print(__version__)
            sys.exit(0)

        # Check if the mode defined is an ip address
        with contextlib.suppress(ValueError):
            ipaddress.ip_address(self.mode)
            self.host = self.mode

        # Discover devices if host has not been defined
        if not self.host:
            self.mozart_devices = discover_devices(
                self.mode,
                self.timeout,
                self.verbose,
            )

            # Get the ip address from the devices Mozart devices
            self.host = next(
                (
                    device
                    for device in self.mozart_devices
                    if device.serial_number == self.mode
                ),
                MozartDevice(),
            ).ip_address

        # Exit if in discover mode, no command has been defined
        # or desired host can't be found.
        if self.mode == DISCOVER_MODE or self.command == "" or self.host == "":
            sys.exit(0)

        asyncio.run(self._run_api())

        sys.exit(0)

    async def _run_api(self) -> None:
        """Run async API command handling."""
        # Generate MozartApi object for calling API endpoints.
        self.mozart_client = MozartClient(self.host)

        # Connect to the websocket notification channel if defined
        if self.websocket:
            print("Connecting to WebSocket channel")
            self.mozart_client.get_all_notifications(self.all_notifications)
            await self.mozart_client.connect_notifications(
                remote_control=True, reconnect=True
            )

        # Handle command
        await self._command_handler()

        # If WebSocket listener is enabled,
        # then wait for keypress before exiting the CLI
        if self.websocket:
            await ainput(
                "Listening to WebSocket events. Press 'enter' key to exit CLI.\n\r",
            )
            self.mozart_client.disconnect_notifications()

        await self.mozart_client.close_api_client()

    async def _beolink_join(self) -> BeolinkJoinRequest | None:
        """Showcase async API usage of the Beolink command."""
        # If no JID is specified, then join an active experience if available
        if len(self.command_args) == 0:
            status = await self.mozart_client.join_latest_beolink_experience()

        else:
            serial_number = self.command_args[0]

            # Check if a device with specified serial number
            # is available as a peer and get JID if available
            peers = await self.mozart_client.get_beolink_peers()

            # The peers may be outdated and still have now unavailable devices.
            if len(peers) == 0:
                print("No available Beolink peers.")
                return None

            jid = next(peer for peer in peers if serial_number in peer.jid).jid

            status = await self.mozart_client.join_beolink_peer(jid=jid)

        return status

    def all_notifications(
        self,
        notification: WebSocketEventType,
        _: str,
    ) -> None:
        """Handle all notifications."""
        print("WebSocket:")
        pprint(notification)
        print("\n\r")

    async def _command_handler(self) -> None:
        """Handle commands."""
        print(
            f"Sending command: '{self.command}' to device with args {self.command_args}.",
        )
        status = None

        if self.command == "preset":
            preset_id = int(self.command_args[0])
            await self.mozart_client.activate_preset(id=preset_id)

        elif self.command in ("play", "pause", "next", "previous"):
            await self.mozart_client.post_playback_command(command=self.command)

        elif self.command == "mute":
            await self.mozart_client.set_volume_mute(volume_mute=VolumeMute(muted=True))

        elif self.command == "unmute":
            await self.mozart_client.set_volume_mute(
                volume_mute=VolumeMute(muted=False),
            )

        elif self.command == "volume":
            volume_level = int(self.command_args[0])
            await self.mozart_client.set_current_volume_level(
                volume_level=VolumeLevel(level=volume_level),
            )

        elif self.command == "join":
            status = await self._beolink_join()

        # Currently show battery state, product state
        elif self.command == "info":
            battery_state = await self.mozart_client.get_battery_state()
            print(f"Info - battery state: {battery_state}\n\r")

            power_state = await self.mozart_client.get_product_state()
            print(f"Info - product state: {power_state}\n\r")

        elif self.command == "allstandby":
            await self.mozart_client.post_beolink_allstandby()

        else:
            print(f"Invalid command {self.command}.")
            return

        # Print verbose status information if defined.
        if self.verbose and self.command == "join":
            # Wait for the join-result to be available
            await asyncio.sleep(1)
            if status:
                print(
                    f"Beolink Join status:{await self.mozart_client.get_beolink_join_result(id=status.request_id)}\n\r"
                )
