"""Click-based interface for Songpal."""
import ast
import asyncio
import json
import logging
import sys
from functools import update_wrapper

import click

from songpal import Device, SongpalException
from songpal.common import ProtocolType
from songpal.containers import Setting
from songpal.discovery import Discover
from songpal.group import GroupControl


class OnOffBoolParamType(click.ParamType):
    """Custom boolean type for click."""

    name = "boolean"

    def convert(self, value, param, ctx):
        """Convert on/off to boolean."""
        if value == "on":
            return True
        elif value == "off":
            return False
        else:
            return click.BOOL.convert(value, param, ctx)


ONOFF_BOOL = OnOffBoolParamType()


def err(msg):
    """Pretty-print an error."""
    click.echo(click.style(msg, fg="red", bold=True))


def coro(f):
    """Run a coroutine and handle possible errors for the click cli.

    Source https://github.com/pallets/click/issues/85#issuecomment-43378930
    """

    def wrapper(*args, **kwargs):
        loop = asyncio.get_event_loop()
        try:
            return loop.run_until_complete(f(*args, **kwargs))
        except KeyboardInterrupt:
            click.echo("Got CTRL+C, quitting..")
            dev = args[0]
            loop.run_until_complete(dev.stop_listen_notifications())
        except SongpalException as ex:
            err("Error: %s" % ex)
            if len(args) > 0 and hasattr(args[0], "debug"):
                if args[0].debug > 0:
                    raise ex

    return update_wrapper(wrapper, f)


async def traverse_settings(dev, module, settings, depth=0):
    """Print all available settings."""
    for setting in settings:
        if setting.is_directory:
            print("{}{} ({})".format(depth * " ", setting.title, module))
            return await traverse_settings(dev, module, setting.settings, depth + 2)
        else:
            try:
                print_settings([await setting.get_value(dev)], depth=depth)
            except SongpalException as ex:
                err(f"Unable to read setting {setting}: {ex}")
                continue


def print_settings(settings, depth=0):
    """Print all available settings of the device."""
    # handle the case where a single setting is passed
    if isinstance(settings, Setting):
        settings = [settings]
    for setting in settings:
        cur = setting.currentValue
        print(
            "{}* {} ({}, value: {}, type: {})".format(
                " " * depth,
                setting.title,
                setting.target,
                click.style(cur, bold=True),
                setting.type,
            )
        )
        for opt in setting.candidate:
            if not opt.isAvailable:
                logging.debug("Unavailable setting %s", opt)
                continue
            click.echo(
                click.style(
                    "{}  - {} ({})".format(" " * depth, opt.title, opt.value),
                    bold=opt.value == cur,
                )
            )


pass_dev = click.make_pass_decorator(Device)


@click.group(invoke_without_command=False)
@click.option("--endpoint", envvar="SONGPAL_ENDPOINT", required=False)
@click.option("-d", "--debug", default=False, count=True)
@click.option("--post", is_flag=True, required=False)
@click.option("--websocket", is_flag=True, required=False)
@click.pass_context
@click.version_option(package_name="python-songpal")
@coro
async def cli(ctx, endpoint, debug, websocket, post):
    """Songpal CLI."""
    lvl = logging.INFO
    if debug:
        lvl = logging.DEBUG
        click.echo("Setting debug level to %s" % debug)
    logging.basicConfig(level=lvl)

    if ctx.invoked_subcommand == "discover":
        ctx.obj = {"debug": debug}
        return

    if endpoint is None:
        err("Endpoint is required except when with 'discover'!")
        return

    protocol = None
    if post and websocket:
        err("You can force either --post or --websocket")
        return
    elif websocket:
        protocol = ProtocolType.WebSocket
    elif post:
        protocol = ProtocolType.XHRPost

    logging.debug("Using endpoint %s", endpoint)
    x = Device(endpoint, force_protocol=protocol, debug=debug)
    try:
        await x.get_supported_methods()
    except SongpalException as ex:
        err("Unable to get supported methods: %s" % ex)
        sys.exit(-1)
    ctx.obj = x

    # this causes RuntimeError: This event loop is already running
    # if ctx.invoked_subcommand is None:
    # ctx.invoke(status)


@cli.command()
@pass_dev
@coro
async def status(dev: Device):
    """Display status information."""
    power = await dev.get_power()
    click.echo(click.style("%s" % power, bold=bool(power)))

    vol = await dev.get_volume_information()
    click.echo(vol.pop())

    play_infos = await dev.get_play_info()
    for play_info in play_infos:
        if not play_info.is_idle:
            click.echo("Playing %s" % play_info)
        else:
            click.echo("Not playing any media")

    outs = await dev.get_inputs()
    for out in outs:
        if out.active:
            click.echo("Active output: %s" % out)

    sysinfo = await dev.get_system_info()
    click.echo("System information: %s" % sysinfo)


@cli.command()
@coro
@click.option("--source-address", required=False)
@click.pass_context
async def discover(ctx, source_address):
    """Discover supported devices."""
    TIMEOUT = 5

    async def print_discovered(dev):
        pretty_name = f"{dev.name} - {dev.model_number}"

        click.echo(click.style("\nFound %s" % pretty_name, bold=True))
        click.echo("* API version: %s" % dev.version)
        click.echo("* Endpoint: %s" % dev.endpoint)
        click.echo("  Services:")
        for serv in dev.services:
            click.echo("    - Service: %s" % serv)
        click.echo("\n[UPnP]")
        click.echo("* URL: %s" % dev.upnp_location)
        click.echo("* UDN: %s" % dev.udn)
        click.echo("  Services:")
        for serv in dev.upnp_services:
            click.echo("    - Service: %s" % serv)

    click.echo("Discovering for %s seconds" % TIMEOUT)
    await Discover.discover(
        TIMEOUT,
        ctx.obj["debug"] or 0,
        callback=print_discovered,
        source_address=source_address,
    )


@cli.command()
@click.argument("cmd", required=False)
@click.argument("target", required=False)
@click.argument("value", required=False)
@pass_dev
@coro
async def power(dev: Device, cmd, target, value):
    """Turn on and off, control power settings.

    Accepts commands 'on', 'off', and 'settings'.
    """

    async def try_turn(cmd):
        state = True if cmd == "on" else False
        try:
            return await dev.set_power(state)
        except SongpalException as ex:
            if ex.code == 3:
                err("The device is already %s." % cmd)
            else:
                raise ex

    if cmd == "on" or cmd == "off":
        click.echo(await try_turn(cmd))
    elif cmd == "settings":
        settings = await dev.get_power_settings()
        print_settings(settings)
    elif cmd == "set" and target and value:
        click.echo(await dev.set_power_settings(target, value))
    else:
        power = await dev.get_power()
        click.echo(click.style(str(power), bold=bool(power)))


@cli.command()
@click.option("--output", type=str, required=False)
@click.argument("input", required=False)
@pass_dev
@coro
async def input(dev: Device, input, output):
    """Get and change outputs."""
    inputs = await dev.get_inputs()
    if input:
        click.echo("Activating %s" % input)
        try:
            input = next(x for x in inputs if x.title == input)
        except StopIteration:
            click.echo("Unable to find input %s" % input)
            return
        zone = None
        if output:
            zone = await dev.get_zone(output)
            if zone.uri not in input.outputs:
                click.echo(f"Input {input.title} not valid for zone {output}")
                return

        await input.activate(zone)
    else:
        click.echo("Inputs:")
        for input in inputs:
            act = False
            if input.active:
                act = True
            click.echo("  * " + click.style(str(input), bold=act))
            for out in input.outputs:
                click.echo("    - %s" % out)


@cli.command()
@click.argument("zone", required=False)
@click.argument("activate", required=False, type=ONOFF_BOOL)
@pass_dev
@coro
async def zone(dev: Device, zone, activate):
    """Get and change outputs."""
    if zone:
        zone = await dev.get_zone(zone)
        click.echo("{} {}".format("Activating" if activate else "Deactivating", zone))
        await zone.activate(activate)
    else:
        click.echo("Zones:")
        for zone in await dev.get_zones():
            act = False
            if zone.active:
                act = True
            click.echo("  * " + click.style(str(zone), bold=act))


@cli.command()
@click.argument("target", required=False)
@click.argument("value", required=False)
@pass_dev
@coro
async def googlecast(dev: Device, target, value):
    """Return Googlecast settings."""
    if target and value:
        click.echo(f"Setting {target} = {value}")
        await dev.set_googlecast_settings(target, value)
    print_settings(await dev.get_googlecast_settings())


@cli.command()
@click.argument("scheme", required=False)
@pass_dev
@coro
async def source(dev: Device, scheme: str):
    """List available sources.

    If no `scheme` is given, will list sources for all sc hemes.
    """
    if scheme is None:
        all_schemes = await dev.get_schemes()
        schemes = [str(scheme.scheme) for scheme in all_schemes]
    else:
        schemes = [scheme]

    for schema in schemes:
        try:
            sources = await dev.get_source_list(schema)
        except SongpalException as ex:
            click.echo(f"Unable to get sources for {schema}: {ex}")
            continue
        for src in sources:
            click.echo(src)
            if src.isBrowsable:
                try:
                    count = await dev.get_content_count(src.source)
                    if count.count > 0:
                        click.echo("  %s" % count)
                        for content in await dev.get_contents(src.source):
                            click.echo(f"   {content.title}\n\t{content.uri}")
                    else:
                        click.echo("  No content to list.")
                except SongpalException as ex:
                    click.echo("  %s" % ex)


@cli.command()
@click.option("--output", type=str, required=False)
@click.argument("volume", required=False)
@pass_dev
@coro
async def volume(dev: Device, volume, output):
    """Get and set the volume settings.

    Passing 'mute' as new volume will mute the volume,
    'unmute' removes it.
    """
    vol = None
    vol_controls = await dev.get_volume_information()
    if output is not None:
        click.echo("Using output: %s" % output)
        output_uri = (await dev.get_zone(output)).uri
        for v in vol_controls:
            if v.output == output_uri:
                vol = v
                break
    else:
        vol = vol_controls[0]

    if vol is None:
        err("Unable to find volume controller: %s" % output)
        return

    if volume and volume == "mute":
        click.echo("Muting")
        await vol.set_mute(True)
    elif volume and volume == "unmute":
        click.echo("Unmuting")
        await vol.set_mute(False)
    elif volume:
        click.echo("Setting volume to %s" % volume)
        await vol.set_volume(volume)

    if output is not None:
        click.echo(vol)
    else:
        for ctl in vol_controls:
            click.echo(ctl)


@cli.command()
@pass_dev
@coro
async def schemes(dev: Device):
    """Print supported uri schemes."""
    schemes = await dev.get_schemes()
    for scheme in schemes:
        click.echo(scheme)


@cli.command()
@click.option("--internet", is_flag=True, default=True)
@click.option("--update", is_flag=True, default=False)
@pass_dev
@coro
async def check_update(dev: Device, internet: bool, update: bool):
    """Print out update information."""
    if internet:
        print("Checking updates from network")
    else:
        print("Not checking updates from internet")
    update_info = await dev.get_update_info(from_network=internet)
    if not update_info.isUpdatable:
        click.echo("No updates available.")
        return
    if not update:
        click.echo("Update available: %s" % update_info.swInfo)
        click.echo("Use --update to activate update!")
    else:
        click.echo("Activating update, please be seated.")
        res = await dev.activate_system_update()
        click.echo("Update result: %s" % res)


@cli.command()
@click.argument("target", required=False)
@click.argument("value", required=False)
@pass_dev
@coro
async def bluetooth(dev: Device, target, value):
    """Get or set bluetooth settings."""
    if target and value:
        await dev.set_bluetooth_settings(target, value)

    print_settings(await dev.get_bluetooth_settings())


@cli.command()
@pass_dev
@coro
async def sysinfo(dev: Device):
    """Print out system information (version, MAC addrs)."""
    click.echo(await dev.get_system_info())
    click.echo(await dev.get_interface_information())


@cli.command()
@pass_dev
@coro
async def misc(dev: Device):
    """Print miscellaneous settings."""
    print_settings(await dev.get_misc_settings())


@cli.command()
@pass_dev
@coro
async def settings(dev: Device):
    """Print out all possible settings."""
    settings_tree = await dev.get_settings()

    for module in settings_tree:
        await traverse_settings(dev, module.usage, module.settings)


@cli.command()
@pass_dev
@coro
async def storage(dev: Device):
    """Print storage information."""
    storages = await dev.get_storage_list()
    for storage in storages:
        click.echo(storage)


@cli.command()
@click.argument("target", required=False)
@click.argument("value", required=False)
@pass_dev
@coro
async def sound(dev: Device, target, value):
    """Get or set sound settings."""
    if target and value:
        click.echo(f"Setting {target} to {value}")
        click.echo(await dev.set_sound_settings(target, value))

    print_settings(await dev.get_sound_settings())


@cli.command()
@pass_dev
@click.argument("soundfield", required=False)
@coro
async def soundfield(dev: Device, soundfield: str):
    """Get or set sound field."""
    if soundfield is not None:
        await dev.set_sound_settings("soundField", soundfield)
    soundfields = await dev.get_sound_settings("soundField")
    print_settings(soundfields)


@cli.command()
@pass_dev
@coro
async def eq(dev: Device):
    """Return EQ information."""
    click.echo(await dev.get_custom_eq())


@cli.command()
@click.argument("cmd", required=False)
@click.argument("target", required=False)
@click.argument("value", required=False)
@pass_dev
@coro
async def playback(dev: Device, cmd, target, value):
    """Get and set playback settings, e.g. repeat and shuffle.."""
    if target and value:
        await dev.set_playback_settings(target, value)
    if cmd == "support":
        click.echo("Supported playback functions:")
        supported = await dev.get_supported_playback_functions("storage:usb1")
        for i in supported:
            print(i)
    elif cmd == "settings":
        print_settings(await dev.get_playback_settings())
        # click.echo("Playback functions:")
        # funcs = await dev.get_available_playback_functions()
        # print(funcs)
    else:
        infos = await dev.get_play_info()

        for info in infos:
            click.echo("Currently playing: %s" % info)


@cli.command()
@click.argument("target", required=False)
@click.argument("value", required=False)
@pass_dev
@coro
async def speaker(dev: Device, target, value):
    """Get and set external speaker settings."""
    if target and value:
        click.echo(f"Setting {target} to {value}")
        await dev.set_speaker_settings(target, value)

    print_settings(await dev.get_speaker_settings())


@cli.command()
@click.argument("notification", required=False)
@click.option("--listen-all", is_flag=True)
@pass_dev
@coro
async def notifications(dev: Device, notification: str, listen_all: bool):
    """List available notifications and listen to them.

    Using --listen-all [notification] allows to listen to all notifications
     from the given subsystem.
    If the subsystem is omited, notifications from all subsystems are
    requested.
    """
    notifications = await dev.get_notifications()

    async def handle_notification(x):
        click.echo("got notification: %s" % x)

    if listen_all:
        if notification is not None:
            await dev.services[notification].listen_all_notifications(
                handle_notification
            )
        else:
            click.echo("Listening to all possible notifications")
            await dev.listen_notifications(fallback_callback=handle_notification)

    elif notification:
        click.echo("Subscribing to notification %s" % notification)
        for notif in notifications:
            if notif.name == notification:
                await notif.activate(handle_notification)

        click.echo("Unable to find notification %s" % notification)

    else:
        click.echo(click.style("Available notifications", bold=True))
        for notif in notifications:
            click.echo("* %s" % notif)


@cli.command()
@pass_dev
@coro
async def sleep(dev: Device):
    """Return sleep settings."""
    click.echo(await dev.get_sleep_timer_settings())


@cli.command()
@pass_dev
def list_all(dev: Device):
    """List all available API calls."""
    for name, service in dev.services.items():
        click.echo(click.style("\nService %s" % name, bold=True))
        for method in service.methods:
            click.echo("  %s" % method.name)


@cli.command()
@click.argument("service", required=True)
@click.argument("method")
@click.argument("parameters", required=False, default=None)
@pass_dev
@coro
async def command(dev, service, method, parameters):
    """Run a raw command."""
    params = None
    if parameters is not None:
        params = ast.literal_eval(parameters)
    click.echo(f"Calling {service}.{method} with params {params}")
    res = await dev.raw_command(service, method, params)
    click.echo(res)


@cli.command()
@click.argument("file", type=click.File("w"), required=False)
@pass_dev
@coro
async def dump_devinfo(dev: Device, file):
    """Dump developer information.

    Pass `file` to write the results directly into a file.
    """
    import attr

    methods = await dev.get_supported_methods(default_latest=True)
    res = {
        "supported_methods": {k: v.asdict() for k, v in methods.items()},
        "settings": [attr.asdict(x) for x in await dev.get_settings()],
        "sysinfo": attr.asdict(await dev.get_system_info()),
        "interface_info": attr.asdict(await dev.get_interface_information()),
    }
    if file:
        click.echo("Saving to file: %s" % file.name)
        json.dump(res, file, sort_keys=True, indent=4)
    else:
        click.echo(json.dumps(res, sort_keys=True, indent=4))


pass_groupctl = click.make_pass_decorator(GroupControl)


@cli.group()
@click.pass_context
@click.option("--url", required=True)
@coro
async def group(ctx, url):
    """Control device groups."""
    gc = GroupControl(url)
    await gc.connect()
    ctx.obj = gc


@group.command()
@pass_groupctl
@coro
async def info(gc: GroupControl):
    """Control information."""
    click.echo(await gc.info())


@group.command()
@pass_groupctl
@coro
async def state(gc: GroupControl):
    """Return current group state."""
    state = await gc.state()
    click.echo(state)
    click.echo("Full state info: %s" % repr(state))


@group.command()
@pass_groupctl
@coro
async def codec(gc: GroupControl):
    """Codec settings."""
    codec = await gc.get_codec()
    click.echo("Codec: %s" % codec)


@group.command()
@pass_groupctl
@coro
async def memory(gc: GroupControl):
    """Group memory."""
    mem = await gc.get_group_memory()
    click.echo("Memory: %s" % mem)


@group.command()
@click.argument("name")
@click.argument("slaves", nargs=-1, required=True)
@pass_groupctl
@coro
async def create(gc: GroupControl, name, slaves):
    """Create new group."""
    click.echo(f"Creating group {name} with slaves: {slaves}")
    click.echo(await gc.create(name, slaves))


@group.command()
@pass_groupctl
@coro
async def abort(gc: GroupControl):
    """Abort existing group."""
    click.echo("Aborting current group..")
    click.echo(await gc.abort())


@group.command()
@pass_groupctl
@click.argument("slaves", nargs=-1, required=True)
@coro
async def add(gc: GroupControl, slaves):
    """Add speakers to group."""
    click.echo("Adding to existing group: %s" % slaves)
    click.echo(await gc.add(slaves))


@group.command()
@pass_groupctl
@click.argument("slaves", nargs=-1, required=True)
@coro
async def remove(gc: GroupControl, slaves):
    """Remove speakers from group."""
    click.echo("Removing from existing group: %s" % slaves)
    click.echo(await gc.remove(slaves))


@group.command(name="volume")
@pass_groupctl
@click.argument("volume", type=int)
@coro
async def groupctl_volume(gc: GroupControl, volume):  # noqa: F811
    """Adjust volume [-100, 100]."""
    click.echo("Setting volume to %s" % volume)
    click.echo(await gc.set_group_volume(volume))


@group.command()
@pass_groupctl
@click.argument("mute", type=bool)
@coro
async def mute(gc: GroupControl, mute):
    """(Un)mute group."""
    click.echo("Muting group: %s" % mute)
    click.echo(await gc.set_mute(mute))


@group.command()
@pass_groupctl
@coro
async def play(gc: GroupControl):
    """Play."""
    click.echo("Sending play command: %s" % await gc.play())


@group.command()
@pass_groupctl
@coro
async def stop(gc: GroupControl):
    """Stop playing."""
    click.echo("Sending stop command: %s" % await gc.stop())


if __name__ == "__main__":
    cli()
