"""
Brewblox-ctl installation commands
"""

import re
from glob import glob
from os import path
from time import sleep
from typing import List

import click
import usb.core
from brewblox_ctl import click_helpers, const, utils
from brewblox_ctl.commands import snapshot
from brewblox_ctl.utils import sh

LISTEN_MODE_WAIT_S = 1


@click.group(cls=click_helpers.OrderedGroup)
def cli():
    """Command collector"""


@cli.command()
@click.pass_context
@click.option('--use-defaults/--no-use-defaults',
              default=None,
              help='Use default settings for installation.')
@click.option('--apt-install/--no-apt-install',
              default=None,
              help='Update and install apt dependencies. Overrides --use-defaults if set.')
@click.option('--docker-install/--no-docker-install',
              default=None,
              help='Install docker. Overrides --use-defaults if set.')
@click.option('--docker-user/--no-docker-user',
              default=None,
              help='Add user to docker group. Overrides --use-defaults if set.')
@click.option('--dir',
              help='Brewblox directory.')
@click.option('--no-reboot',
              is_flag=True,
              help='Do not reboot after install is done.')
@click.option('--release',
              default='edge',
              help='Brewblox release track.')
@click.option('--snapshot', 'snapshot_file',
              help='Load system snapshot generated by `brewblox-ctl snapshot save`.')
def install(ctx: click.Context,
            use_defaults,
            apt_install,
            docker_install,
            docker_user,
            no_reboot,
            dir,
            release,
            snapshot_file):
    """Create Brewblox directory; install system dependencies; reboot.

    Brewblox can be installed multiple times on the same computer.
    Settings and databases are stored in a Brewblox directory (default: ./brewblox).

    This command also installs system-wide dependencies (docker).
    After `brewblox-ctl install`, run `brewblox-ctl setup` in the created Brewblox directory.

    A reboot is required after installing docker, or adding the user to the 'docker' group.

    By default, `brewblox-ctl install` attempts to download packages using the apt package manager.
    If you are using a system without apt (eg. Synology NAS), this step will be skipped.
    You will need to manually install any missing libraries.

    When using the `--snapshot ARCHIVE` option, no dir is created.
    Instead, the directory in the snapshot is extracted.
    It will be renamed to the desired name of the Brewblox directory.

    \b
    Steps:
        - Install apt packages.
        - Install docker.
        - Add user to 'docker' group.
        - Create Brewblox directory (default ./brewblox).
        - Set variables in .env file.
        - Reboot.
    """
    utils.confirm_mode()

    apt_deps = 'curl net-tools libssl-dev libffi-dev avahi-daemon tio'
    user = utils.getenv('USER')
    default_dir = path.abspath('./brewblox')
    prompt_reboot = True

    if use_defaults is None:
        use_defaults = utils.confirm('Do you want to install with default settings?')

    # Check if packages should be installed
    if not utils.command_exists('apt'):
        utils.info('Apt is not available. You may need to find another way to install dependencies.')
        utils.info(f'Apt packages: "{apt_deps}"')
        apt_install = False

    if apt_install is None:
        if use_defaults:
            apt_install = True
        else:
            apt_install = utils.confirm(f'Do you want to install apt packages "{apt_deps}"?')

    # Check if docker should be installed
    if utils.command_exists('docker'):
        utils.info('Docker is already installed.')
        docker_install = False

    if docker_install is None:
        if use_defaults:
            docker_install = True
        else:
            docker_install = utils.confirm('Do you want to install docker?')

    # Check if user should be added to docker group
    if utils.is_docker_user():
        utils.info(f'{user} already belongs to the docker group.')
        docker_user = False

    if docker_user is None:
        if use_defaults:
            docker_user = True
        else:
            docker_user = utils.confirm('Do you want to run docker commands without sudo?')

    # Check used directory
    if dir is None:
        if use_defaults or utils.confirm(f"The default directory is '{default_dir}'. Do you want to continue?"):
            dir = default_dir
        else:
            return

    if utils.path_exists(dir):
        if not utils.confirm(f'The `{dir}` directory already exists.' +
                             ' Do you want to continue and erase the current contents?'):
            return

    if not no_reboot:
        prompt_reboot = utils.confirm('A reboot is required after installation. ' +
                                      'Do you want to be prompted before that happens?')

    # Install Apt packages
    if apt_install:
        utils.info('Installing apt packages...')
        sh([
            'sudo apt update',
            'sudo apt upgrade -y',
            f'sudo apt install -y {apt_deps}',
        ])
    else:
        utils.info('Skipped: apt install.')

    # Install docker
    if docker_install:
        utils.info('Installing docker...')
        sh('curl -sL get.docker.com | sh', check=False)
    else:
        utils.info('Skipped: docker install.')

    # Always fix IPv6 for Docker.
    utils.fix_ipv6(None, False)

    # Add user to 'docker' group
    if docker_user:
        utils.info(f"Adding {user} to 'docker' group...")
        sh('sudo usermod -aG docker $USER')
    else:
        utils.info(f"Skipped: adding {user} to 'docker' group.")

    if snapshot_file:
        ctx.invoke(snapshot.load,
                   dir=dir,
                   file=snapshot_file,
                   force=True)
    else:
        ctx.invoke(init,
                   dir=dir,
                   release=release,
                   force=True,
                   skip_confirm=use_defaults)

    utils.info('Done!')

    # Reboot
    if not no_reboot:
        if prompt_reboot:
            utils.info('Press ENTER to reboot.')
            input()
        else:
            utils.info('Rebooting in 10 seconds...')
            sleep(10)
        sh('sudo reboot')
    else:
        utils.info('Skipped: reboot.')


@cli.command()
@click.option('--dir',
              default='./brewblox',
              help='Brewblox directory.')
@click.option('--release',
              default='edge',
              help='Brewblox release track.')
@click.option('--force',
              is_flag=True,
              help='Do not prompt if directory already exists.')
@click.option('--skip-confirm/--no-skip-confirm',
              help='Set the skip-confirm flag in env.')
def init(dir, release, force, skip_confirm):
    """Create and init Brewblox directory.

    This is also called by `brewblox-ctl install`.
    """
    utils.confirm_mode()

    if utils.path_exists(dir) and not utils.is_empty_dir(dir):
        if not utils.is_brewblox_dir(dir):
            raise FileExistsError(f'`{dir}` is not a Brewblox directory.')
        if force or utils.confirm(f'`{dir}` already exists. ' +
                                  'Do you want to continue and erase its contents?'):
            sh(f'sudo rm -rf {dir}/*')
        else:
            return

    utils.info(f'Creating Brewblox directory `{dir}`...')
    sh(f'mkdir -p {dir}')

    # Set variables in .env file
    utils.info('Setting variables in .env file...')
    dotenv_path = path.abspath(f'{dir}/.env')
    sh(f'touch {dotenv_path}')
    utils.setenv(const.RELEASE_KEY, release, dotenv_path)
    utils.setenv(const.CFG_VERSION_KEY, '0.0.0', dotenv_path)
    utils.setenv(const.SKIP_CONFIRM_KEY, str(skip_confirm), dotenv_path)


def run_particle_flasher(release: str, pull: bool, cmd: str):
    tag = utils.docker_tag(release)
    sudo = utils.optsudo()

    opts = ' '.join([
        '-it',
        '--rm',
        '--privileged',
        '-v /dev:/dev',
        '--pull ' + ('always' if pull else 'missing'),
    ])

    sh(f'{sudo}docker-compose --log-level CRITICAL down', check=False)
    sh(f'{sudo}docker run {opts} brewblox/firmware-flasher:{tag} {cmd}')


def run_esp_flasher(release: str, pull: bool):
    tag = utils.docker_tag(release)
    sudo = utils.optsudo()

    opts = ' '.join([
        '-it',
        '--rm',
        '--privileged',
        '-v /dev:/dev',
        '-w /app/firmware',
        '--entrypoint bash',
        '--pull ' + ('always' if pull else 'missing'),
    ])

    sh(f'{sudo}docker-compose --log-level CRITICAL down', check=False)
    sh(f'{sudo}docker run {opts} brewblox/brewblox-devcon-spark:{tag} flash')


def discover_usb_sparks() -> List[str]:
    devices = sh('lsusb', capture=True)
    output = []
    for match in re.finditer(r'ID (?P<id>\w{4}:\w{4})',
                             devices,
                             re.MULTILINE):
        id = match.group('id').lower()
        if id in ['2b04:c006', '2b04:d006']:  # photon, photon DFU
            output.append('Spark v2')
        if id in ['2b04:c008', '2b04:d008']:  # p1, p1 DFU
            output.append('Spark v3')
        if id in ['10c4:ea60']:  # ESP32
            output.append('Spark v4')

    return output


def prompt_usb_spark() -> str:
    while True:
        devices = discover_usb_sparks()
        num_devices = len(devices)
        if num_devices == 0:
            utils.warn('No USB-connected Spark detected')
            utils.confirm_usb()
        elif num_devices == 1:
            return devices[0]
        else:
            utils.warn(f'Multiple USB-connected Sparks detected: {", ".join(devices)}')
            utils.confirm_usb()


@cli.command()
@click.option('--release', default=None, help='Brewblox release track')
@click.option('--pull/--no-pull', default=True)
def flash(release, pull):
    """Flash firmware on Spark.

    This requires the Spark to be connected over USB.

    After the first install, firmware updates can also be installed using the UI.

    \b
    Steps:
        - Stop running services.
        - Pull flasher image.
        - Run flash command.
    """
    utils.confirm_mode()
    spark = prompt_usb_spark()

    utils.info(f'Flashing {spark}...')

    if spark in ['Spark v2', 'Spark v3']:
        run_particle_flasher(release, pull, 'flash')
    elif spark in ['Spark v4']:
        run_esp_flasher(release, pull)
    else:
        raise ValueError(f'Unknown device "{spark}"')


def particle_wifi(dev: usb.core.Device):

    if utils.ctx_opts().dry_run:
        utils.info('Dry run: skipping activation of Spark listening mode')
    else:
        dev.reset()

        # Magic numbers for the USB control call
        HOST_TO_DEVICE = 0x40  # bmRequestType
        REQUEST_INIT = 1  # bRequest
        REQUEST_SEND = 3  # bRequest
        PARTICLE_LISTEN_INDEX = 70  # wIndex
        PARTICLE_LISTEN_VALUE = 0  # wValue
        PARTICLE_BUF_SIZE = 64  # wLength

        dev.ctrl_transfer(
            HOST_TO_DEVICE,
            REQUEST_INIT,
            PARTICLE_LISTEN_VALUE,
            PARTICLE_LISTEN_INDEX,
            PARTICLE_BUF_SIZE
        )

        dev.ctrl_transfer(
            HOST_TO_DEVICE,
            REQUEST_SEND,
            PARTICLE_LISTEN_VALUE,
            PARTICLE_LISTEN_INDEX,
            PARTICLE_BUF_SIZE
        )

    sleep(LISTEN_MODE_WAIT_S)

    try:
        path = glob('/dev/serial/by-id/usb-Particle_*').pop()
    except IndexError:
        path = '/dev/ttyACM0'

    utils.info('Press w to start Wifi configuration.')
    utils.info('Press Ctrl + ] to cancel.')
    utils.info('The Spark must be restarted after canceling.')
    sh(f'miniterm.py -q {path} 2>/dev/null')


def esp_wifi():
    utils.info('Spark 4 Wifi credentials are set over Bluetooth, using the ESP BLE Provisioning app.')
    utils.info('')
    utils.info('To set Wifi credentials:')
    utils.info('- Press the (R)ESET button on your Spark.')
    utils.info('- While the Spark restarts, press and hold the OK button for five seconds.')
    utils.info('- The Spark is ready for provisioning if its buttons are blinking blue.')
    utils.info('- Download the ESP BLE Provisioning app.')
    utils.info('- Enable Bluetooth in your phone settings.')
    utils.info('- Open the app.')
    utils.info('- Click Provision New Device.')
    utils.info("- Click I don't have a QR code.")
    utils.info('- Select the PROV_BREWBLOX_ device.')
    utils.info('- Select your Wifi network, and enter your credentials.')
    utils.info('')
    utils.info('The app will set the Wifi credentials for your Spark.')
    utils.info('An additional IP address will be shown in the top left corner of the Spark display.')


@cli.command()
def wifi():
    """Configure Spark Wifi settings.

    This requires the Spark to be connected over USB.

    \b
    Steps:
        - Stop running services.
        - Look for valid USB device.
        - Spark 2 / Spark 3:
            - Trigger listening mode.
            - Connect to device serial to set up Wifi.
        - Spark 4:
            - Print provisioning instructions.
    """
    utils.confirm_mode()

    while True:
        particle_dev = usb.core.find(idVendor=0x2b04)
        esp_dev = usb.core.find(idVendor=0x10c4, idProduct=0xea60)

        if particle_dev:
            particle_wifi(particle_dev)
            return
        elif esp_dev:
            esp_wifi()
            return
        else:
            utils.confirm_usb()


@cli.command()
@click.option('--release', default=None, help='Brewblox release track')
@click.option('--pull/--no-pull', default=True)
@click.option('-c', '--command', default='')
def particle(release, pull, command):
    """Start a Docker container with access to the Particle CLI.

    This requires the Spark to be connected over USB.

    \b
    Steps:
        - Stop running services.
        - Pull flasher image.
        - Start flasher image.
    """
    utils.confirm_mode()
    utils.confirm_usb()

    utils.info('Starting Particle image...')
    utils.info("Type 'exit' and press enter to exit the shell")
    run_particle_flasher(release, pull, command)


@cli.command()
@click.option('--config-file', help='Path to Docker daemon config. Defaults to /etc/docker/daemon.json.')
def fix_ipv6(config_file):
    """Fix IPv6 support on the host machine.

    Reason: https://github.com/docker/for-linux/issues/914
    Unlike globally disabling IPv6 support on the host,
    this has no impact outside the Docker environment.

    Some hosts (Synology) may be using a custom location for the daemon config file.
    If the --config-file argument is not set, the --config-file argument for the active docker daemon is used.
    If it is not set, the default /etc/docker/daemon.json is used.
    """
    utils.fix_ipv6(config_file)
