import pymoku
import logging
from functools import partial
from typing import Optional

from PySide6.QtWidgets import (QMainWindow, QApplication, QWidget, QLineEdit, QComboBox,
                               QHBoxLayout, QVBoxLayout, QPushButton, QMessageBox,
                               QFileDialog, QGroupBox, QLabel, QCheckBox, QGridLayout, QButtonGroup,
                               QSplitter, QToolBar, QTabWidget, QTabBar, QStyle, QSizePolicy, QToolButton)
from PySide6.QtCore import QObject, Signal, QMargins
from pyqttoast import Toast, ToastPosition, ToastPreset
from pymoku.version import current_fw_ver
from pymoku.finder import MokuInfo
from pymoku.progress import ProgressHandler, register_progress_handler
from pymoku.applets import log, settings, ratelimit, doasync
from pymoku.applets.device_list import MokuListWidget
from pymoku.applets.device_config import MokuConfigDialog
from pymoku.applets.statistics_dialog import StatisticsDialog
from pymoku.applets.node_tree import SlotFunctionsTreeWidget

import pymoku.applets.nodes as nodes  # noqa


class EntryControl(QWidget):
    def __init__(self, label, check=False, *args, **kwargs):
        super().__init__(*args, **kwargs)
        lbl = QLabel(label)
        text = QLineEdit()
        lyt = QHBoxLayout()
        lyt.addWidget(lbl)
        lyt.addWidget(text)
        if check:
            chk = QCheckBox()
            lyt.addWidget(chk)
        self.setLayout(lyt)


class EnumControl(QWidget):
    def __init__(self, label, enum_type, *args, **kwargs):
        super().__init__(*args, **kwargs)
        lbl = QLabel(self, text=label).pack()
        cmb = QComboBox()
        cmb.addItems(sorted([e.name for e in enum_type]))
        lyt = QHBoxLayout()
        lyt.addWidget(lbl)
        lyt.addWidget(cmb)
        self.setLayout(lyt)


class PinControlFrame(QGroupBox):
    PIN_STATES = dict(input=(0, 0),
                      outa=(1, 2),
                      outb=(1, 3),
                      high=(1, 1),
                      low=(1, 0),
                      opena=(2, 0),
                      openb=(3, 0))

    def __init__(self, moku, state):
        super().__init__('Pin Control')
        self.moku = moku
        self.pin_state_cmbs = []

        glyt = QGridLayout()
        for i in range(16):
            glyt.addWidget(QLabel(f"{i:2d}"), i, 0)
            state_cmb = QComboBox()
            state_cmb.addItems(list(self.PIN_STATES.keys()))
            state_cmb.currentIndexChanged.connect(self.update_state)
            state_cmb.setCurrentIndex(self._pin_state_to_index(state, i))
            glyt.addWidget(state_cmb, i, 1)
            self.pin_state_cmbs.append(state_cmb)
        self.setLayout(glyt)

    @classmethod
    def _pin_state_to_index(cls, state, i):
        c_bits = (state['dio']['ctrl'] >> (i * 2)) & 0x3
        d_bits = (state['dio']['dir_ctrl'] >> (i * 2)) & 0x3
        try:
            return list(cls.PIN_STATES.values()).index((d_bits, c_bits))
        except ValueError:
            return 0

    def update_state(self):
        ctrl = 0
        dir_ctrl = 0
        for cmb in reversed(self.pin_state_cmbs):
            dval, val = self.PIN_STATES[cmb.currentText()]
            ctrl = (ctrl << 2) | val
            dir_ctrl = (dir_ctrl << 2) | dval
        self.moku.dio_output(ctrl=ctrl, dir_ctrl=dir_ctrl)


class GPIOConfigDialog(QWidget):
    def __init__(self, moku):
        super().__init__()
        self.moku = moku
        self.setWindowTitle("GPIO")
        self.init()

    def init(self):
        state = self.moku.modify_hardware()
        slots = [f'instr{i:d}' for i in range(self.moku.num_slots)]

        loops = []
        for instr in slots:
            for i in range(self.moku.num_slot_outputs):
                loops.append(f'{instr}:{i:d}')

        def combobox(state, callback):
            cmb = QComboBox()
            cmb.addItems(loops)
            cmb.setCurrentIndex(state)
            cmb.currentIndexChanged.connect(callback)
            return cmb

        glyt = QGridLayout()
        glyt.addWidget(QLabel('Source A'), 0, 0)
        glyt.addWidget(combobox(state['dio']['source0'], lambda idx:
                       self.moku.dio_output(source0=idx)), 0, 1)

        glyt.addWidget(QLabel('Source B'), 1, 0)
        glyt.addWidget(combobox(state['dio']['source1'], lambda idx:
                       self.moku.dio_output(source1=idx)), 1, 1)

        glyt.addWidget(QLabel('Dir Source A'), 2, 0)
        glyt.addWidget(combobox(state['dio']['dir_source0'], lambda idx:
                       self.moku.dio_output(dir_source0=idx)), 2, 1)

        glyt.addWidget(QLabel('Dir Source B'), 3, 0)
        glyt.addWidget(combobox(state['dio']['dir_source1'], lambda idx:
                       self.moku.dio_output(dir_source1=idx)), 3, 1)
        fr = QGroupBox('dio')
        fr.setLayout(glyt)

        pin_ctrl = PinControlFrame(self.moku, state)

        lyt = QHBoxLayout()
        lyt.addWidget(fr)
        lyt.addWidget(pin_ctrl)
        self.setLayout(lyt)


class RelayChannel(QGroupBox):
    def __init__(self, moku, rly_dev):
        super().__init__(rly_dev)
        self.moku = moku
        self.rly_dev = rly_dev
        self.state = self.moku.modify_hardware()
        self.lyt = QVBoxLayout()
        self.setLayout(self.lyt)
        self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)

    def add_button_list(self, labels, check_idx, ids=None):
        lyt = QHBoxLayout()
        bgrp = QButtonGroup()
        for idx, lbl in enumerate(labels):
            # Use QToolButton rather than QPushButton as they take up less horizontal space
            btn = QToolButton(text=lbl)
            btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
            btn.setStyleSheet('QToolButton:disabled { color: gray }')  # need this to explicitly gray out disabled buttons
            btn.setCheckable(True)
            btn.setChecked(idx == check_idx)

            bgrp.addButton(btn)
            bgrp.setId(btn, idx if ids is None else ids[idx])
            lyt.addWidget(btn)

        self.lyt.addLayout(lyt)

        return bgrp


class ADCRelayChannelMoku20(RelayChannel):
    ATTEN = [0, 20]

    def __init__(self, moku, rly_dev):
        super().__init__(moku, rly_dev)

        self.dc = self.add_button_list(labels=['AC', 'DC'],
                                       check_idx=int(self.state[self.rly_dev]['dc']))
        self.dc.buttonClicked.connect(self._update)

        self.fiftyr = self.add_button_list(labels=['1MΩ', '50Ω'],
                                           check_idx=int(self.state[self.rly_dev]['fiftyr']))
        self.fiftyr.buttonClicked.connect(self._update)

        self.atten = self.add_button_list(labels=['0dB', '-20dB'],
                                          check_idx=self.ATTEN.index(self.state[self.rly_dev]['atten']))
        self.atten.buttonClicked.connect(self._update)

        self.enable = QCheckBox('Enable')
        self.enable.setChecked(self.state[self.rly_dev]['enable'])
        self.enable.checkStateChanged.connect(self._update)

        self.lyt.addWidget(self.enable)

    @ratelimit
    def _update(self, *args):
        self.moku.set_frontend(
            int(self.rly_dev[-1]),
            enable=self.enable.isChecked(),
            dc=bool(self.dc.checkedId()),
            fiftyr=bool(self.fiftyr.checkedId()),
            atten=self.ATTEN[self.atten.checkedId()])


class ADCRelayChannelMokuPro(RelayChannel):
    GAIN = [0, -20, -40]
    BW = [300, 600]
    SOURCE = ['BLEND', 'HADC', 'LADC16', 'LADC32']

    def __init__(self, moku, rly_dev):
        super().__init__(moku, rly_dev)

        self.dc = self.add_button_list(labels=['AC', 'DC'],
                                       check_idx=int(self.state[self.rly_dev]['dc']))
        self.dc.buttonClicked.connect(self._update)

        self.fiftyr = self.add_button_list(labels=['1MΩ', '50Ω'],
                                           check_idx=int(self.state[self.rly_dev]['fiftyr']))
        self.fiftyr.buttonClicked.connect(self._update)

        self.gain = self.add_button_list(labels=['0dB', '-20dB', '-40dB'],
                                         check_idx=self.GAIN.index(self.state[self.rly_dev]['gain']))
        self.gain.buttonClicked.connect(self._update)

        self.bw = self.add_button_list(labels=['300MHz', '600MHz'],
                                       check_idx=self.BW.index(self.state[self.rly_dev]['bw']))
        self.bw.buttonClicked.connect(self._update)

        self.source = self.add_button_list(labels=['Blend', 'HADC', 'LADC16', 'LADC32'],
                                           check_idx=self.SOURCE.index(self.state[self.rly_dev]['source']))
        self.source.buttonClicked.connect(self._update)

    @ratelimit
    def _update(self, *args):
        self.moku.set_frontend(
            int(self.rly_dev[-1]),
            dc=bool(self.dc.checkedId()),
            fiftyr=bool(self.fiftyr.checkedId()),
            gain=self.GAIN[self.gain.checkedId()],
            bw=self.BW[self.bw.checkedId()],
            source=self.SOURCE[self.source.checkedId()])


class ADCRelayChannelMokuDelta(RelayChannel):
    def __init__(self, moku, rly_dev):
        super().__init__(moku, rly_dev)
        state = self.state[self.rly_dev]

        # List the buttons in the order that the relays are applied to the input signal.

        self.rf = self.add_button_list(labels=['Baseband', 'RF'],
                                       check_idx=int(state['rf']))
        self.rf.buttonClicked.connect(self._update)

        self.dc = self.add_button_list(labels=['AC', 'DC'],
                                       check_idx=int(state['dc']))
        self.dc.buttonClicked.connect(self._update)

        # Use a combo box here rather than two rows of buttons as not all
        # combinations of impedence and gain are supported. The combo
        # box only shows the supported combinations.
        self.impedence_and_gain = QComboBox(self)
        self.impedence_and_gain.setEditable(False)
        self.impedence_and_gain.addItem('1MΩ -32dB', [False, -32])
        self.impedence_and_gain.addItem('1MΩ   0dB', [False, 0])
        self.impedence_and_gain.addItem('50Ω -20dB', [True, -20])
        self.impedence_and_gain.addItem('50Ω   0dB', [True, 0])
        self.impedence_and_gain.addItem('50Ω  20dB', [True, 20])
        self.impedence_and_gain.currentIndexChanged.connect(self._update)
        curr_idx = self.impedence_and_gain.findData([state['fiftyr'], state['gain']])
        self.impedence_and_gain.setCurrentIndex(curr_idx)
        self.lyt.addWidget(self.impedence_and_gain)

    @ratelimit
    def _update(self, *args):
        fiftyr, gain = self.impedence_and_gain.currentData()
        self.moku.set_frontend(
            int(self.rly_dev[-1]),
            dc=bool(self.dc.checkedId()),
            fiftyr=fiftyr,
            gain=gain,
            rf=bool(self.rf.checkedId()))


class DACRelayChannelMokuDelta(RelayChannel):
    FILTER_INDEX = [
        'bypass',
        'lowpass1',
        'lowpass2',
        'bandpass',
    ]
    GAINS = [0, 20]

    def __init__(self, moku, rly_dev):
        super().__init__(moku, rly_dev)

        self.enable = QCheckBox('Enable')
        self.enable.setChecked(self.state[self.rly_dev]['enable'])
        self.enable.checkStateChanged.connect(self._update)
        self.lyt.addWidget(self.enable)

        self.gain = self.add_button_list(labels=['0dB', '20dB'],
                                         check_idx=self.state[self.rly_dev].get('gain', 0))
        self.gain.buttonClicked.connect(self._update)

        self.rf = self.add_button_list(labels=['Baseband', 'RF'],
                                       check_idx=int(self.state[self.rly_dev].get('rf', False)))
        self.rf.buttonClicked.connect(self._update)

        self.filter = self.add_button_list(labels=['Bypass', 'LP1', 'LP2', 'BP'],
                                           check_idx=self.FILTER_INDEX.index(self.state[self.rly_dev].get('rffilter')))
        self.filter.buttonClicked.connect(self._update)

    @ratelimit
    def _update(self, *args):
        d = dict(
            gain=self.GAINS[self.gain.checkedId()],
            rf=bool(self.rf.checkedId()),
            enable=self.enable.isChecked(),
            rffilter=self.FILTER_INDEX[self.filter.checkedId()],
            leds=dict(rgb=(0, 0, 0), brightness=1.0),
        )
        self.moku.modify_hardware(**{self.rly_dev: d})


class ADCRelayChannelMokuGo(RelayChannel):
    ATTEN = [0, -14]

    def __init__(self, moku, rly_dev):
        super().__init__(moku, rly_dev)
        self.atten = self.add_button_list(labels=['0dB', '-14dB'],
                                          check_idx=self.ATTEN.index(self.state[self.rly_dev]['gain']))
        self.atten.buttonClicked.connect(self._update)

        self.dc = self.add_button_list(labels=['AC', 'DC'],
                                       check_idx=int(self.state[self.rly_dev]['dc']))
        self.dc.buttonClicked.connect(self._update)

    @ratelimit
    def _update(self, *args):
        self.moku.set_frontend(
            int(self.rly_dev[-1]),
            gain=self.ATTEN[self.atten.checkedId()],
            dc=bool(self.dc.checkedId()))


class DACRelayChannelMokuPro(RelayChannel):
    GAIN = [0, 14]

    def __init__(self, moku, rly_dev, *args, **kwargs):
        super().__init__(moku, rly_dev, *args, **kwargs)

        self.enable = QCheckBox('Enable')
        self.enable.setChecked(self.state[self.rly_dev]['enable'])
        self.enable.checkStateChanged.connect(self._update)
        self.lyt.addWidget(self.enable)

        self.gain = self.add_button_list(labels=['0dB', '14dB'],
                                         check_idx=self.GAIN.index(self.state[self.rly_dev]['gain']))
        self.gain.buttonClicked.connect(self._update)

    @ratelimit
    def _update(self, *args):
        d = dict(gain=self.GAIN[self.gain.checkedId()],
                 enable=self.enable.isChecked())
        self.moku.modify_hardware(**{self.rly_dev: d})


class DACRelayChannelMokuGo(RelayChannel):
    def __init__(self, moku, rly_dev):
        super().__init__(moku, rly_dev)

        self.enable = QCheckBox('Enable')
        self.enable.setChecked(self.state[self.rly_dev]['enable'])
        self.enable.checkStateChanged.connect(self._update)

        self.lyt.addWidget(self.enable)

    @ratelimit
    def _update(self, *args):
        d = dict(enable=self.enable.isChecked())
        self.moku.modify_hardware(**{self.rly_dev: d})


class RelayControlDialog(QWidget):
    def __init__(self, moku):
        super().__init__()
        self.setWindowTitle("Relays")
        self.moku = moku

        adcs, dacs, adc_rly_type, dac_rly_type = {
            'moku20': (
                ['adc0', 'adc1'],
                ['dac0', 'dac1'],
                ADCRelayChannelMoku20,
                None
            ),
            'mokugo': (
                ['adc0', 'adc1'],
                ['dac0', 'dac1'],
                ADCRelayChannelMokuGo,
                DACRelayChannelMokuGo
            ),
            'mokupro': (
                ['adc0', 'adc1', 'adc2', 'adc3'],
                ['dac0', 'dac1', 'dac2', 'dac3'],
                ADCRelayChannelMokuPro,
                DACRelayChannelMokuPro
            ),
            'mokudelta': (
                [f'adc{i:d}' for i in range(8)],
                [f'dac{i:d}' for i in range(8)],
                ADCRelayChannelMokuDelta,
                DACRelayChannelMokuDelta
            ),
        }.get(moku.DEV_NAME, (None, None, None, None))

        lyt = QGridLayout()
        with moku.cached_state():
            row = -1
            MAX_WIDGETS_PER_ROW = 8
            if adc_rly_type is not None:
                for i, adc in enumerate(adcs):
                    try:
                        ch = adc_rly_type(self.moku, adc)
                        col = i % MAX_WIDGETS_PER_ROW
                        if col == 0:
                            row += 1
                        lyt.addWidget(ch, row, col)
                    except Exception:
                        log.exception('Error loading ADC')

            if dac_rly_type is not None:
                for i, dac in enumerate(dacs):
                    try:
                        ch = dac_rly_type(self.moku, dac)
                        col = i % MAX_WIDGETS_PER_ROW
                        if col == 0:
                            row += 1
                        lyt.addWidget(ch, row, col)
                    except Exception:
                        log.exception('Error loading DAC')

        self.setLayout(lyt)


class MokuTabWidget(QWidget):
    def __init__(self, moku, **opts):
        self.moku = moku
        self._relay_window = None
        super().__init__(**opts)

        self._relay_window: Optional[RelayControlDialog] = None
        self._stats_window: Optional[StatisticsDialog] = None
        self._console = None

        self.lyt = QVBoxLayout()
        self.lyt.setSpacing(0)
        self.lyt.setContentsMargins(0, 0, 0, 0)
        self._create_toolbar()
        self._create_node_editor()
        self.setLayout(self.lyt)
        self.set_moku(moku)

    def set_moku(self, moku):
        self.moku = moku
        self.slot_windows = [None] * self.moku.num_slots
        self._node_graph.set_moku(moku)
        self._reload_plugins_gui()
        self.restore_state()

    def _set_platform_combo(self):
        """ Reload the platform list and set current platform
        """
        self.plat_cmb.clear()

        idx = 0
        for i, p in enumerate(
                sorted(self.moku.platforms_for_moku(), key=lambda x: (x['slots'], x['version']))):
            self.plat_cmb.addItem(f"{p['slots']:d} Slot - v{p['version']}",
                                  userData=(p['_id'], p['sha256']))
            if p['sha256'] == self.moku.platform_sha:
                idx = i

        self.plat_cmb.setCurrentIndex(idx)

    def _create_toolbar(self):
        toolbar = QToolBar('Utils')
        self.plat_cmb = QComboBox()
        toolbar.addWidget(self.plat_cmb)
        self._set_platform_combo()

        if self.moku.DEV_NAME in ['mokudelta']:
            self.clock_cmb = QComboBox()
            toolbar.addWidget(self.clock_cmb)
            for freq in [5000, 4166, 3570, 3124]:
                self.clock_cmb.addItem(f'{freq:d} MHz', freq)

        self.deploy_btn = QPushButton('Deploy')
        self.deploy_btn.clicked.connect(self._plat_deploy)
        toolbar.addWidget(self.deploy_btn)

        toolbar.addAction('Config', self.open_config)

        toolbar.setObjectName('utilToolbar')
        toolbar.addAction('Relays', self.relays)

        if self.moku.DEV_NAME in ['mokugo', 'mokudelta']:
            toolbar.addAction('GPIO', self.gpio)

        if self.moku.DEV_NAME in ['mokugo']:
            toolbar.addAction('Power', self.power)

        if self.moku.DEV_NAME in ['mokudelta']:
            self._rfdc_dialog = None
            toolbar.addAction('RFDC', self._show_rfdc)

        toolbar.addAction('Stats', self.stats)
        toolbar.addAction('Sync', self.sync)

        if self.moku.DEV_NAME in ['mokupro']:
            self.adc_mode_cmb = QComboBox()
            toolbar.addWidget(self.adc_mode_cmb)

            modes = ['1.25', '1.25A', '1.25B', '1.25C', '1.25D',
                     '2.5AC', '2.5BC', '2.5AD', '2.5BD',
                     '5A', '5B', '5C', '5D',
                     'RAMP', 'PULSE', 'PULSE11', 'PULSE15']
            self.adc_mode_cmb.addItems(modes)
            self.adc_mode_cmb.currentTextChanged.connect(
                lambda text: self.moku.modify_hardware(adc_mode=text)
            )

        toolbar.addAction('Console', self.console)

        self.lyt.addWidget(toolbar)

    def open_config(self):
        diag = MokuConfigDialog(self, MokuInfo(ip_addr=self.moku.ip_addr, fwver=self.moku.fw_ver))
        diag.exec()

    def _show_rfdc(self):
        if self._rfdc_dialog is None:
            from . import rfdc_dialog
            self._rfdc_dialog = rfdc_dialog.RfdcDialog(self.moku)
        self._rfdc_dialog.show()
        self._rfdc_dialog.raise_()
        self._rfdc_dialog.activateWindow()

    def console(self):
        from .default import JupyterConsoleWidget
        if self._console is None:
            self._console = JupyterConsoleWidget()
            namespace = dict(moku=self.moku, pymoku=pymoku, frame=self)
            self._console.kernel_manager.kernel.shell.push(dict(**namespace))
            self._console.set_default_style("linux")

        self._console.show()
        self._console.raise_()
        self._console.activateWindow()

    def _reload_plugins(self):
        @doasync
        def a():
            self.moku.load_plugins_for_moku()

        a.result.connect(self._reload_plugins_gui)
        a()

    def _reload_plugins_gui(self):
        self._set_platform_combo()
        self.nodes_palette.update()
        self.nodes_palette.collapseAll()

    def _create_node_editor(self):
        self._node_graph = nodes.MokuNodeGraph()
        nodes_palette = SlotFunctionsTreeWidget(parent=self, node_graph=self._node_graph)

        split = QSplitter()
        vbox = QVBoxLayout()
        vbox.setSpacing(0)
        vbox.setContentsMargins(2, 2, 2, 2)

        wdgt = QWidget()
        wdgt.setLayout(vbox)
        vbox.addWidget(nodes_palette)

        reload_btn = QPushButton('Reload')
        reload_btn.clicked.connect(self._reload_plugins)
        vbox.addWidget(reload_btn)

        split.addWidget(self._node_graph.widget)
        split.addWidget(wdgt)
        self.nodes_palette = nodes_palette
        self.lyt.addWidget(split)

        self._node_graph.node_double_clicked.connect(self._launch_slot_for_node)
        self._node_graph.node_created.connect(self._slot_deploy)
        self._node_graph.slot_removed.connect(self._slot_removed)

    def _launch_slot_for_node(self, node):
        self.launch_slot(node.slot_inst, node.name())

    def launch_slot(self, slot_inst, name=''):
        if (win := self.slot_windows[slot_inst.slot]) is not None:
            win.show()
            win.raise_()
            win.activateWindow()
            return

        slot_inst.attach()
        try:
            from pymoku.applets.default import DefaultFrame
            if (frame_cls := slot_inst.PLUGIN.applet_frame()) is not None:
                slot_frame = frame_cls(self.moku, slot_inst, name)
            else:
                slot_frame = DefaultFrame(self.moku, slot_inst, name)

            self.slot_windows[slot_inst.slot] = slot_frame
            slot_frame.request_reload.connect(self._reload_slot)
            slot_frame.show()
        except Exception as e:
            log.exception("Error launching slot")
            QMessageBox.critical(self, 'Error', str(e))

    def _reload_slot(self, slot_frame):
        self._slot_removed(slot_frame.slot_inst)
        # recreate the slot_inst completely
        slot_inst = self.moku._create_slot_instance(slot_frame.slot_inst.slot)
        self.launch_slot(slot_inst)

    def _upload(self):
        @doasync
        def a(fname):
            self.moku.upload_bundle(fname)

        fname, _ = QFileDialog.getOpenFileName(filter='Bitstream Bundle (*.tar.gz)')

        if fname:
            a(fname)

    def _plat_deploy(self):
        if self.moku.num_slots != 0:  # nothing deployed
            if QMessageBox.warning(self, 'Deploy Platform',
                                   'This will erase the currently deployed platform '
                                   'including all slots',
                                   QMessageBox.Ok | QMessageBox.Cancel) != QMessageBox.Ok:
                return

        self._close_slots()
        self._node_graph.set_moku(None)

        @doasync
        def a(plat_id, plat_sha, clock_id):
            if clock_id is not None:
                self.moku.modify_hardware(clk=dict(sample_rate=clock_id))
            self.moku.deploy_platform(plat_id, plat_sha=plat_sha, force=True)
            return self.moku

        plat_id, plat_sha = self.plat_cmb.currentData()
        clock_id = self.clock_cmb.currentData() if self.moku.DEV_NAME in ['mokudelta'] else None
        a.result.connect(self.set_moku)
        a.result.connect(self.nodes_palette.update)
        a.error.connect(lambda exc: self._deploy_failed(exc))
        a(plat_id, plat_sha, clock_id)

    def _slot_deploy(self, node):
        @doasync
        def a(node):
            # TODO racey, might deploy to same slot twice
            slot_inst = self.moku.deploy(node._id)
            return node, slot_inst

        # force = QApplication.keyboardModifiers() and Qt.ShiftModifier
        a.result.connect(self._slot_deployed)
        a.error.connect(lambda exc, node=node: self._deploy_failed(exc, node))
        a(node)

    def _slot_deployed(self, *args):
        # awkward callback to set_slot() in GUI thread and avoid node being GC'd
        node, slot_inst = args[0]
        node.set_slot(slot_inst)

    def _slot_removed(self, slot_inst):
        slot_frame = self.slot_windows[slot_inst.slot]
        if slot_frame is None:
            return

        slot_frame.close()
        slot_frame.deleteLater()
        self.slot_windows[slot_inst.slot] = None

    def _deploy_failed(self, exc, node=None):
        if node:
            self._node_graph.remove_node(node)
        self.showerror(exc)

    def showerror(self, exc):
        try:
            QMessageBox.critical(self, type(exc).__name__, str(exc.args[0]))
        except Exception:
            QMessageBox.critical(self, type(exc).__name__, str(exc))

    def relays(self):
        if self._relay_window is None:
            self._relay_window = RelayControlDialog(self.moku)
        self._relay_window.show()
        self._relay_window.raise_()
        self._relay_window.activateWindow()

    def sync(self):
        self.moku.sync(0xFF)  # TODO selective slots

    def gpio(self):
        self._gpio_window = GPIOConfigDialog(self.moku)
        self._gpio_window.show()

    def power(self):
        # self._power_window = PowerConfigDialog(self.moku)
        # self._power_window.show()
        pass  # TODO

    def stats(self):
        if self._stats_window is None:
            self._stats_window = StatisticsDialog(self.moku)
        self._stats_window.show()
        self._stats_window.raise_()
        self._stats_window.activateWindow()

    def save_state(self):
        open_slots = []
        for slot_frame in self.slot_windows:
            if slot_frame is not None and slot_frame.isVisible():
                open_slots.append(slot_frame.slot_inst.slot)
        settings.setValue(f'{self.moku.DEV_NAME}/{self.moku.serial:d}/open_slots', open_slots)

    def restore_state(self):
        # open previous slot windows
        open_slots = settings.value(f'{self.moku.DEV_NAME}/{self.moku.serial:d}/open_slots')
        if open_slots is not None:
            open_slots = map(int, open_slots)  # don't know why these end up as strings
            for slot_inst in self.moku._slots:
                node = self._node_graph.get_node_for_slot(slot_inst)
                if node is not None and node.slot_inst.slot in open_slots:
                    # TODO check deploy_id
                    self.launch_slot(node.slot_inst)

    def close(self):
        try:
            self.save_state()
            self._close_slots()
            self.moku.close()
        except Exception:
            log.exception('Error while closing Moku')

        # Close any child windows that have been opened.
        for child_window in [self._relay_window, self._stats_window, self._console]:
            if child_window is not None and child_window.isVisible():
                child_window.close()

    def _close_slots(self):
        for slot_frame in self.slot_windows:
            if slot_frame is not None:
                slot_frame.close()  # TODO need closeEvent
                slot_frame.deleteLater()


class MokuWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        register_progress_handler(ToastProgressHandler())
        Toast.setPositionRelativeToWidget(self)
        Toast.setPosition(ToastPosition.BOTTOM_LEFT)
        Toast.setOffset(15, 15)
        Toast.setMaximumOnScreen(8)
        self._create_moku_tabs()
        self.setWindowTitle(f"PyMoku {pymoku.__version__}")
        self._restore_state()

    def closeEvent(self, event):
        self._save_state()
        for i in range(self.moku_tabs.count()):
            self.moku_tabs.widget(i).close()
        super().closeEvent(event)

    def showEvent(self, event):
        size = settings.beginReadArray("main/open_mokus")
        for i in range(size):
            settings.setArrayIndex(i)
            ip_addr = settings.value("ip_addr")
            self._connect(MokuInfo(ip_addr=ip_addr))
        settings.endArray()
        super().showEvent(event)

    def _save_state(self):
        settings.beginGroup('main')
        settings.setValue("geometry", self.saveGeometry())
        settings.setValue("windowState", self.saveState())
        settings.beginWriteArray("open_mokus")
        for i in range(self.moku_tabs.count() - 1):
            # first tab is 'Devices'
            moku = self.moku_tabs.widget(i + 1).moku
            settings.setArrayIndex(i)
            settings.setValue('hw_ver', moku.hw_ver)
            settings.setValue('serial', moku.serial)
            settings.setValue('ip_addr', moku.ip_addr)
        settings.endArray()
        settings.endGroup()

    def _restore_state(self):
        settings.beginGroup('main')
        geom = settings.value("geometry")
        if geom is not None:
            self.restoreGeometry(geom)
        else:
            self.resize(600, 400)

        state = settings.value("windowState")
        if state is not None:
            self.restoreState(state)
        settings.endGroup()

    def _connect(self, minfo):
        for i in range(self.moku_tabs.count()):
            tab = self.moku_tabs.widget(i)
            if isinstance(tab, MokuTabWidget):
                if tab.moku.ip_addr == minfo.ip_addr:
                    self.moku_tabs.setCurrentIndex(i)
                    return

        @doasync
        def a(minfo):
            moku = pymoku.connect(minfo)
            return moku

        a.result.connect(self.connect_moku)
        a.error.connect(self.showerror)
        a(minfo)

    def connect_moku(self, moku):
        if moku.fw_ver not in [current_fw_ver, 65534]:
            QMessageBox.information(
                self, 'Firmware Mismatch',
                f'Moku {moku.serial:d} has FW {moku.fw_ver:d}\n'
                f'Expecting FW {current_fw_ver:d}')

        self.add_moku_tab(moku)

    def _create_moku_tabs(self):
        self.moku_tabs = QTabWidget(self)
        self.moku_tabs.setTabsClosable(True)
        self.moku_tabs.setDocumentMode(True)
        self.setCentralWidget(self.moku_tabs)
        self.moku_tabs.tabCloseRequested.connect(self._close_moku_tab)

        # create the first tab to show available mokus
        moku_list = MokuListWidget()
        moku_list.launch_moku.connect(self._connect)
        moku_list.start_discovery()
        self.moku_tabs.addTab(moku_list, 'Devices')

        # remove the close button from 'Devices'
        tab_bar = self.moku_tabs.tabBar()
        pos = QTabBar.ButtonPosition(tab_bar.style().styleHint(QStyle.SH_TabBar_CloseButtonPosition))
        tab_bar.tabButton(0, pos).deleteLater()
        tab_bar.setTabButton(0, pos, None)

    def add_moku_tab(self, moku):
        tab = MokuTabWidget(moku, parent=self)
        self.moku_tabs.addTab(tab, moku.name)
        self.moku_tabs.setCurrentWidget(tab)

    def _close_moku_tab(self, idx):
        tab = self.moku_tabs.widget(idx)
        tab.close()
        self.moku_tabs.removeTab(idx)

    def showerror(self, exc):
        QMessageBox.critical(self, str(exc), str(exc.args[0]))


class ToastProgressHandler(QObject):
    """ Display a toast stack on the main window

    Most tasks are expected to come from other threads so we use Signals to
    cross into the GUI thread.
    """
    create = Signal(object)
    message = Signal(object, str)
    progress = Signal(object, float)
    complete = Signal(object)
    _toasts = []

    class _Proxy(ProgressHandler):
        def __init__(self, ref):
            self._ref = ref
            self.toast = None

        def start(self):
            self._ref.create.emit(self)

        def set_progress(self, val: float):
            self._ref.progress.emit(self, val)

        def set_message(self, message):
            self._ref.message.emit(self, message)

        def complete(self):
            self._ref.complete.emit(self)

    def __init__(self):
        super().__init__()
        self.create.connect(self._create)
        self.message.connect(self._message)
        self.progress.connect(self._progress)
        self.complete.connect(self._complete)

    def start(self):
        h = ToastProgressHandler._Proxy(self)
        h.start()
        return h

    def _message(self, proxy, msg):
        # TODO can't change the message after show()
        proxy.toast.setText(msg)
        proxy.toast.show()

    def _progress(self, proxy, val):
        # Kinda hacky, but this toast API does 'not' want to make it easy
        width = proxy.toast._Toast__duration_bar_container.width() * val
        proxy.toast._Toast__duration_bar_chunk.setFixedWidth(width)

    def _complete(self, proxy):
        proxy.toast._Toast__used = True  # HACK prevent race if hidden before show
        proxy.toast.hide()
        # HACK if it wasn't shown, we won't get the closed signal
        if proxy.toast not in Toast._Toast__currently_shown:
            self._remove_toast(proxy.toast)

    def _create(self, proxy):
        toast = Toast()
        toast.setDuration(0)
        toast.applyPreset(ToastPreset.INFORMATION_DARK)
        toast.setBorderRadius(3)
        toast.setMargins(QMargins(10, 10, 1, 1))
        toast.setMinimumWidth(180)
        toast.setMaximumWidth(180)
        toast.setShowCloseButton(False)
        toast.setShowIconSeparator(False)
        toast.setShowIcon(False)
        toast.setResetDurationOnHover(False)
        self._save_toast(toast)
        proxy.toast = toast

    def _save_toast(self, toast):
        """ Save a reference to the toast to prevent garbage collection """
        self._toasts.append(toast)
        toast.closed.connect(partial(self._remove_toast, toast))

    def _remove_toast(self, toast):
        self._toasts.remove(toast)


def main():
    from pathlib import Path
    from argparse import ArgumentParser  # noqa
    import pymoku.plugins  # noqa

    parser = ArgumentParser()
    parser.add_argument('-v', action='store_true', help="Verbose logging.")
    parser.add_argument('--clearprefs', action='store_true')
    parser.add_argument('--dev', type=Path, help="Local folder containing development barfiles/")
    args = parser.parse_args()

    pymoku.plugins.DEV_CACHE = args.dev

    if args.clearprefs:
        pymoku.applets.settings.clear()
        log.info('Cleared preferences')
        return

    lvl = [logging.INFO, logging.DEBUG][args.v]
    fmt = ['%(message)s', '%(asctime)8s.%(msecs)03d %(levelname)7s: %(message)s'][args.v]
    logging.basicConfig(format=fmt, datefmt='%H:%M:%S', level=lvl)

    app = QApplication()

    window = MokuWindow()
    window.show()

    app.exec()
