#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""SamSifter helps you create filter workflows for NGS data.

It is primarily used to process SAM files generated by MALT prior to
metagenomic analysis in MEGAN.

.. moduleauthor:: Florian Aldehoff <faldehoff@student.uni-tuebingen.de>
"""
import signal
import sys
if not (sys.version_info[0] >= 3):
    print("Error, I need python 3.x or newer")
    exit(1)

import platform
import argparse
import logging as log
from functools import partial
from os.path import basename, dirname, expanduser

""" Qt4 """
from PyQt4.QtGui import (
    QMainWindow, QHBoxLayout, QTreeView, QFrame, QIcon, QAction, QStandardItem,
    QDockWidget, QApplication, QLabel, QFileDialog, QVBoxLayout,
    QAbstractItemView, QMessageBox, QTextEdit, QColor, QKeySequence,
    QDesktopServices
)
from PyQt4.QtCore import (
    Qt, QSize, QProcess, QFileInfo, QPoint, QSettings, QTimer, QFile,
    PYQT_VERSION_STR, QT_VERSION_STR, QProcessEnvironment, QUrl
)

""" custom libraries """
from samsifter.gui.widgets import InputWidget, OutputWidget
from samsifter.gui.dialogs import (
    BashOptionsDialog, RmaOptionsDialog, RmaOptions
)
# Add your new tools/filter to this import list!
from samsifter.tools import (
    filter_read_list, filter_read_pmds, filter_read_identity,
    filter_read_conservation, filter_ref_list, filter_ref_identity,
    filter_ref_pmds, filter_ref_coverage, filter_taxon_list, filter_taxon_pmds,
    calculate_pmds, sort_by_coordinates, sort_by_names, remove_duplicates,
    sam_2_bam, bam_2_sam, compress, decompress, count_taxon_reads,
    better_remove_duplicates
)
from samsifter.models.filter_model import FilterTreeModel
from samsifter.views.filterviews import FilterListView
from samsifter.models.workflow import Workflow
from samsifter.util.validation import WorkflowValidator
from samsifter.models.filter import FilterItem
from samsifter.resources import resources
assert resources  # silence pyflakes
from samsifter.version import samsifter_version


__version__ = samsifter_version

""" globals """
VERSION = __version__
FILE_FORMATS = ["*.ssx", "*.SSX"]
TITLE = "SamSifter"
DESC = ("SamSifter helps you create filter workflows for next-generation "
        "sequencing data. It is primarily used to process SAM files generated "
        "by MALT prior to metagenomic analysis in MEGAN.")
COPYRIGHT = ("This program is free software: you can redistribute it "
             "and/or modify it under the terms of the GNU General Public "
             "License as published by the Free Software Foundation, either "
             "version 3 of the License, or (at your option) any later version."
             "<p>This program is distributed in the hope that it will be "
             "useful, but WITHOUT ANY WARRANTY; without even the implied "
             "warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR "
             "PURPOSE.  See the GNU General Public License for more details."
             "<p>You should have received a copy of the GNU General Public "
             "License along with this program. If not, see "
             '<a href="http://www.gnu.org/licenses/">'
             "http://www.gnu.org/licenses/</a>.")
GREETING = ("<h3>Welcome to %s version %s!</h3><p>%s<p>To start working add "
            "filters to the workflow above. You can add filters by "
            "doubleclicking available  entries in the righthand dock or by "
            "selecting an entry from the menu <b>[Edit > Add Filter...]</b>. "
            "Similarly, you can doubleclick filters in the workflow to edit "
            "their settings and parameters. The lefthand toolbar is used to "
            "move and delete filter steps."
            "<p>You can save your workflow to a file, execute it right now "
            "by clicking the <b>[Run]</b> button and even export it to a Bash "
            "script to run it on another workstation. Any errors and "
            "messages will be reported to you right here. Feel free to "
            "re-arrange these docks to make yourself comfortable - %s will "
            "remember your settings. Accidentally closed docks can always "
            "be re-opened from the menu <b>[View]<b>."
            % (TITLE, __version__, DESC, TITLE))
RIGHT_DOCK_MIN_HEIGHT = 220
RIGHT_DOCK_MIN_WIDTH = 300
BOTTOM_DOCK_MIN_HEIGHT = 200
RECENT_FILES_MAX_LENGTH = 9
STATUS_DELAY = 5000


class MainWindow(QMainWindow):
    """Main window of SamSifter GUI interface.

    Provides a central list view of filter items and freely arrangeable docks
    for filter settings, toolbox and log messages.
    """

    def __init__(self, verbose=False, debug=False):
        """Initialize main window.

        Parameters
        ----------
        verbose : bool, optional
            Enable printing of additional information to STDERR, defaults to
            False.
        debug : bool, optional
            Enable debug menu with additional options to analyze errors,
            defaults to False.
        """
        super(MainWindow, self).__init__()
        self.verbose = verbose
        self.debug = debug

        """ data models """

        self.recent_files = []

        self.filters = {}
        self.populate_filters()

        self.tree_model = FilterTreeModel(self)
        root = self.tree_model.invisibleRootItem()
        for category in self.filters:
            branch = QStandardItem(category)
            branch.setSelectable(False)
            branch.setEditable(False)
            root.appendRow(branch)
            for fltr in self.filters[category]:
                leaf = fltr
                branch.appendRow(leaf)
        # increase GUI haptics by making sure elements are always in sam
        self.tree_model.sort(0)

        self.wf = Workflow(self)
        self.wf.validity_changed.connect(self.on_validity_change)
        self.wf.changed.connect(self.update_status)

        # QProcess object for external app
        self.process = None
        self.pid = -1

        """ viewers and controllers """

        self.init_ui()

        """ previous settings """

        settings = QSettings()
        if settings.value("RecentFiles") is not None:
            self.recent_files = settings.value("RecentFiles")
        self.resize(settings.value("MainWindow/Size", QSize(800, 800)))
        self.move(settings.value("MainWindow/Position", QPoint(0, 0)))
        if settings.value("MainWindow/State") is not None:
            self.restoreState(settings.value("MainWindow/State"))

        self.rma_options = RmaOptions()
        if settings.value("SAM2RMA/TopPercent") is not None:
            self.rma_options.set_top_percent(
                float(settings.value("SAM2RMA/TopPercent"))
            )
        if settings.value("SAM2RMA/MaxExpected") is not None:
            self.rma_options.set_max_expected(
                float(settings.value("SAM2RMA/MaxExpected"))
            )
        if settings.value("SAM2RMA/MinScore") is not None:
            self.rma_options.set_min_score(
                float(settings.value("SAM2RMA/MinScore"))
            )
        if settings.value("SAM2RMA/MinSupportPercent") is not None:
            self.rma_options.set_min_support_percent(
                float(settings.value("SAM2RMA/MinSupportPercent"))
            )
        if settings.value("SAM2RMA/Sam2RmaPath") is not None:
            self.rma_options.set_sam2rma_path(
                settings.value("SAM2RMA/Sam2RmaPath")
            )

        self.populate_file_menu()
        QTimer.singleShot(0, self.load_initial_file)

        self.show()

    def populate_filters(self):
        """Compiles available filters to group them into named categories.

        Note
        ----
        To add a new filter or tool to the menu of available items simply
        choose a category and append your
        :py:class:`samsifter.models.filter.FilterItem`
        to the corresponding list. This is usually done by calling the
        ``item()`` method of your module in package :py:mod:`samsifter.tools`.
        Entries will be sorted by category name and item name prior to display
        in the GUI so the order within the list does not matter.

        In order to call the ``item()`` method of your new module you will
        also have to import it in the header of this module.
        """

        """ analyzers """
        analyzers = []
        analyzers.append(calculate_pmds.item())
        analyzers.append(count_taxon_reads.item())
        self.filters["Analyzers"] = analyzers

        """ converters """
        converters = []
        converters.append(sam_2_bam.item())
        converters.append(bam_2_sam.item())
        converters.append(compress.item())
        converters.append(decompress.item())
        self.filters["Format Converters"] = converters

        """ sorters """
        sorters = []
        sorters.append(sort_by_coordinates.item())
        sorters.append(sort_by_names.item())
        self.filters["File Sorters"] = sorters

        """ read filters """
        read_filters = []
        read_filters.append(filter_read_list.item())
        read_filters.append(filter_read_pmds.item())
        read_filters.append(filter_read_identity.item())
        read_filters.append(filter_read_conservation.item())
        read_filters.append(remove_duplicates.item())
        read_filters.append(better_remove_duplicates.item())
        self.filters["Read Filters"] = read_filters

        """ reference filters """
        reference_filters = []
        reference_filters.append(filter_ref_list.item())
        reference_filters.append(filter_ref_identity.item())
        reference_filters.append(filter_ref_pmds.item())
        reference_filters.append(filter_ref_coverage.item())
        self.filters["Reference Filters"] = reference_filters

        """ taxon filters """
        taxon_filters = []
        taxon_filters.append(filter_taxon_list.item())
        taxon_filters.append(filter_taxon_pmds.item())
        self.filters["Taxon Filters"] = taxon_filters

    def init_ui(self):
        """Initialize GUI components (widgets, actions, menus and toolbars)."""
        self.setWindowTitle(TITLE)
        self.setDockOptions(QMainWindow.AnimatedDocks
                            | QMainWindow.AllowNestedDocks)

        """ central workflow widget """

        self.input_widget = InputWidget(self)
        self.input_widget.file_entry.setText(self.wf.get_in_filename())
        self.wf.input_changed.connect(self.input_widget.set_filename)
        self.input_widget.file_entry.textChanged.connect(
            self.wf.set_in_filename
        )

        self.output_widget = OutputWidget(self)
        self.output_widget.file_entry.setText(self.wf.get_out_filename())
        self.wf.output_changed.connect(self.output_widget.set_filename)
        self.output_widget.file_entry.textChanged.connect(
            self.wf.set_out_filename
        )

        self.output_widget.compile_box.stateChanged.connect(
            self.wf.set_run_compile_stats
        )

        self.output_widget.sam2rma_box.stateChanged.connect(
            self.wf.set_run_sam2rma
        )
        self.output_widget.sam2rma_btn.clicked.connect(self.configure_sam2rma)

        self.list_view = FilterListView(self)
        self.list_view.setModel(self.wf.getModel())
        self.list_view.selectionModel().currentRowChanged.connect(
            self.update_settings_view)
        self.list_view.doubleClicked.connect(self.on_list_item_doubleclick)

        list_layout = QVBoxLayout()
        list_layout.setMargin(0)
        list_layout.addWidget(self.input_widget)
        list_layout.addWidget(self.list_view)
        list_layout.addWidget(self.output_widget)

        list_frame = QFrame(self)
        list_frame.setObjectName('ListFrame')
        list_frame.setFrameShape(QFrame.StyledPanel)
        list_frame.setLayout(list_layout)

        """ available filters dock """

        tree_view = QTreeView(self)
        tree_view.setDragEnabled(False)
        tree_view.setHeaderHidden(True)
        tree_view.setSelectionMode(QAbstractItemView.SingleSelection)
        tree_view.setModel(self.tree_model)
        tree_view.expandAll()
        tree_view.doubleClicked.connect(self.on_tree_item_doubleclick)

        tree_layout = QHBoxLayout()
        tree_layout.setMargin(0)
        tree_layout.addWidget(tree_view)

        tree_frame = QFrame(self)
        tree_frame.setFrameShape(QFrame.StyledPanel)
        tree_frame.setLayout(tree_layout)

        self.tree_dock = QDockWidget("Filters and Tools")
        self.tree_dock.setObjectName('TreeDock')
        self.tree_dock.setMinimumWidth(RIGHT_DOCK_MIN_WIDTH)
        self.tree_dock.setFeatures(QDockWidget.DockWidgetMovable
                                   | QDockWidget.DockWidgetFloatable
                                   | QDockWidget.DockWidgetClosable)
        self.tree_dock.setAllowedAreas(Qt.LeftDockWidgetArea
                                       | Qt.RightDockWidgetArea)
        self.tree_dock.setWidget(tree_frame)
        self.tree_dock.setVisible(True)

        """ settings dock """

        self.placeholder = QLabel(self)
        self.placeholder.setAlignment(Qt.AlignCenter)
        self.placeholder.setText("filter settings shown here")
        self.placeholder.setPixmap(
            QIcon.fromTheme('view-filter').pixmap(QSize(128, 128),
                                                  QIcon.Disabled,
                                                  QIcon.Off))

        self.current_editor = self.placeholder
        self.fset_layout = QVBoxLayout()
        self.fset_layout.setMargin(0)
        self.fset_layout.addWidget(self.current_editor)

        fset_frame = QFrame(self)
        fset_frame.setLayout(self.fset_layout)

        self.fset_dock = QDockWidget("Filter Settings")
        self.fset_dock.setObjectName('FilterSettingsDock')
        self.fset_dock.setMinimumWidth(RIGHT_DOCK_MIN_WIDTH)
        self.fset_dock.setMinimumHeight(RIGHT_DOCK_MIN_HEIGHT)
        self.fset_dock.setFeatures(
            QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable
            | QDockWidget.DockWidgetClosable
        )
        self.fset_dock.setAllowedAreas(
            Qt.BottomDockWidgetArea | Qt.TopDockWidgetArea
            | Qt.RightDockWidgetArea
        )
        self.fset_dock.setWidget(fset_frame)
        self.fset_dock.setVisible(False)

        """ progress dock """

        self.output = QTextEdit(self)
        self.output.setReadOnly(True)
        self.output.setFontPointSize(8)
        self.output.setFontFamily('Monospace')

        progress_layout = QVBoxLayout()
        progress_layout.setMargin(0)
        progress_layout.addWidget(self.output)

        progress_frame = QFrame(self)
        progress_frame.setLayout(progress_layout)

        self.progress_dock = QDockWidget("Messages")
        self.progress_dock.setObjectName('ProgressDock')
        self.progress_dock.setMinimumWidth(BOTTOM_DOCK_MIN_HEIGHT)
        self.progress_dock.setFeatures(
            QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable
            | QDockWidget.DockWidgetClosable
        )
        self.progress_dock.setAllowedAreas(
            Qt.BottomDockWidgetArea | Qt.TopDockWidgetArea
            | Qt.RightDockWidgetArea
        )

        self.progress_dock.setWidget(progress_frame)
        self.progress_dock.setVisible(False)

        """ main frame """

        self.addDockWidget(Qt.RightDockWidgetArea, self.tree_dock)
        self.addDockWidget(Qt.RightDockWidgetArea, self.fset_dock)
        self.addDockWidget(Qt.BottomDockWidgetArea, self.progress_dock)
        self.setCentralWidget(list_frame)

        """ actions """

        new_action_help = "Create a new workflow"
        new_action = QAction(QIcon.fromTheme('document-new'), "&New...", self)
        new_action.setShortcut(QKeySequence.New)
        new_action.setToolTip(new_action_help)
        new_action.setStatusTip(new_action_help)
        new_action.triggered.connect(self.new_file)

        open_action_help = "Open workflow from file"
        open_action = QAction(QIcon.fromTheme('document-open'), "&Open...",
                              self)
        open_action.setShortcut(QKeySequence.Open)
        open_action.setToolTip(open_action_help)
        open_action.setStatusTip(open_action_help)
        open_action.triggered.connect(self.open_file)

        save_action_help = "Save workflow to file"
        save_action = QAction(QIcon.fromTheme('document-save'), "&Save", self)
        save_action.setShortcut(QKeySequence.Save)
        save_action.setToolTip(save_action_help)
        save_action.setStatusTip(save_action_help)
        save_action.triggered.connect(self.save_file)

        save_as_action = QAction(QIcon.fromTheme('document-save-as'),
                                 '&Save as...', self)
        save_as_action.setShortcut('Ctrl+Shift+S')
        save_as_action.setStatusTip('Save workflow to new file')
        save_as_action.triggered.connect(self.save_file_as)

        quit_action = QAction(QIcon.fromTheme('application-exit'),
                              '&Quit', self)
        quit_action.setShortcut(QKeySequence.Quit)
        quit_action.setStatusTip('Exit application')
#        quit_action.triggered.connect(qApp.quit)
        quit_action.triggered.connect(self.close)

        self.run_action = QAction(QIcon.fromTheme('system-run'), '&Run', self)
        self.run_action.setShortcut('Ctrl+R')
        self.run_action.setStatusTip('Run workflow')
        self.run_action.triggered.connect(self.run_workflow_command)

        self.stopAction = QAction(QIcon.fromTheme('process-stop'), '&Stop',
                                  self)
        self.stopAction.setShortcut('Ctrl+K')
        self.stopAction.setStatusTip('Stop workflow')
        self.stopAction.setEnabled(False)
        self.stopAction.triggered.connect(self.stop_command)

        exportAction = QAction(QIcon.fromTheme('utilities-terminal'),
                               '&Export to Bash', self)
        exportAction.setShortcut('Ctrl+E')
        exportAction.setStatusTip('Export workflow to Bash script')
        exportAction.triggered.connect(self.configure_bash)

        moveUpAction = QAction(QIcon.fromTheme('go-up'), 'Move up', self)
        moveUpAction.setShortcut('Ctrl+Up')
        moveUpAction.setStatusTip('Move filter up')
        moveUpAction.triggered.connect(self.move_current_up)

        moveDownAction = QAction(QIcon.fromTheme('go-down'), 'Move down', self)
        moveDownAction.setShortcut('Ctrl+Down')
        moveDownAction.setStatusTip('Move filter down')
        moveDownAction.triggered.connect(self.move_current_down)

        removeAction = QAction(QIcon.fromTheme('edit-delete'), 'Remove', self)
        removeAction.setShortcut('Del')
        removeAction.setStatusTip('Remove filter')
        removeAction.triggered.connect(self.delete_item)

        """ debug actions BEGIN """
        validate_action = QAction(QIcon.fromTheme('dialog-information'),
                                  'Validate workflow',
                                  self)
        validate_action.setShortcut('F8')
        validate_action.setStatusTip('Validate workflow')
        validate_action.triggered.connect(self.validate_wf)

        print_cmd_action = QAction(QIcon.fromTheme('dialog-information'),
                                   'Print bash command',
                                   self)
        print_cmd_action.setShortcut('F9')
        print_cmd_action.setStatusTip('Print bash command')
        print_cmd_action.triggered.connect(self.print_command)

        print_xml_action = QAction(QIcon.fromTheme('dialog-information'),
                                   'Print XML',
                                   self)
        print_xml_action.setShortcut('F10')
        print_xml_action.setStatusTip('Print XML')
        print_xml_action.triggered.connect(self.print_xml)

        print_wf_action = QAction(QIcon.fromTheme('dialog-information'),
                                  'Print workflow',
                                  self)
        print_wf_action.setShortcut('F11')
        print_wf_action.setStatusTip('Print workflow')
        print_wf_action.triggered.connect(self.print_wf)
        """ debug actions END """

        self.showTreeAction = QAction('Available Filters', self)
        self.showTreeAction.setShortcut('F2')
        self.showTreeAction.setCheckable(True)
        self.showTreeAction.setChecked(self.tree_dock.isVisible())
        self.showTreeAction.setStatusTip('Toggle available filters dock')
#        self.showTreeAction.triggered.connect(self.toggle_tree_dock)
        self.showTreeAction.toggled.connect(self.tree_dock.setVisible)
        self.tree_dock.visibilityChanged.connect(
            self.showTreeAction.setChecked)

        self.showProgressAction = QAction('Workflow Progress', self)
        self.showProgressAction.setShortcut('F3')
        self.showProgressAction.setCheckable(True)
        self.showProgressAction.setChecked(self.fset_dock.isVisible())
        self.showProgressAction.setStatusTip('Toggle progress dock')
#        self.showProgressAction.triggered.connect(self.toggle_progress_dock)
        self.showProgressAction.toggled.connect(self.progress_dock.setVisible)
        self.progress_dock.visibilityChanged.connect(
            self.showProgressAction.setChecked)

        self.showSettingsAction = QAction('Filter Settings', self)
        self.showSettingsAction.setShortcut('F4')
        self.showSettingsAction.setCheckable(True)
        self.showSettingsAction.setChecked(self.fset_dock.isVisible())
        self.showSettingsAction.setStatusTip('Toggle filter settings dock')
#        self.showSettingsAction.triggered.connect(self.toggle_settings_dock)
        self.showSettingsAction.toggled.connect(self.fset_dock.setVisible)
        self.fset_dock.visibilityChanged.connect(
            self.showSettingsAction.setChecked)

        about_action = QAction(QIcon.fromTheme('help-about'),
                               'About %s' % TITLE, self)
        about_action.setShortcut('Ctrl+?')
        about_action.setStatusTip('Show information about this program.')
        about_action.triggered.connect(self.show_about)

        help_action = QAction(QIcon.fromTheme('help-contents'),
                              '%s Help' % TITLE, self)
        help_action.setShortcut('F1')
        help_action.setStatusTip('Launch browser to view the help pages.')
        help_action.triggered.connect(self.show_help)

        """ menus """

        menubar = self.menuBar()

        self.file_menu = menubar.addMenu('&File')
        self.file_menu_actions = (new_action,
                                  None,
                                  open_action, save_action, save_as_action,
                                  None,
                                  quit_action)
        self.file_menu.aboutToShow.connect(self.populate_file_menu)

        run_menu = menubar.addMenu('&Run')
        run_menu.addAction(self.run_action)
        run_menu.addAction(self.stopAction)
        run_menu.addAction(exportAction)

        edit_menu = menubar.addMenu('&Edit')
        self.filter_menu = edit_menu.addMenu(QIcon.fromTheme('list-add'),
                                             '&Add Filter...')
        self.populate_filter_menu()
        edit_menu.addSeparator()
        edit_menu.addAction(moveUpAction)
        edit_menu.addAction(removeAction)
        edit_menu.addAction(moveDownAction)

        view_menu = menubar.addMenu('&View')
        view_menu.addAction(self.showTreeAction)
        view_menu.addAction(self.showProgressAction)
        view_menu.addAction(self.showSettingsAction)

        if self.debug:
            debug_menu = menubar.addMenu('&Debug')
            debug_menu.addAction(validate_action)
            debug_menu.addAction(print_cmd_action)
            debug_menu.addAction(print_xml_action)
            debug_menu.addAction(print_wf_action)

        help_menu = menubar.addMenu('&Help')
        help_menu.addAction(help_action)
        help_menu.addAction(about_action)

        """ toolbars """

        wf_tools = self.addToolBar('Workflow Toolbar')
        wf_tools.setObjectName('WorkflowTools')
        wf_tools.addAction(new_action)
        wf_tools.addAction(open_action)
        wf_tools.addAction(save_action)
        wf_tools.addAction(save_as_action)
        wf_tools.addSeparator()
        wf_tools.addAction(exportAction)
        wf_tools.addAction(self.run_action)
        wf_tools.addAction(self.stopAction)

        f_tools = self.addToolBar('Filter Toolbar')
        f_tools.setObjectName('FilterTools')
        f_tools.addAction(moveUpAction)
        f_tools.addAction(removeAction)
        f_tools.addAction(moveDownAction)
        self.addToolBar(Qt.LeftToolBarArea, f_tools)

        """ status bar """

        self.steps_lbl = QLabel("%i steps" % self.wf.getModel().rowCount())
        self.steps_lbl.setFrameStyle(QFrame.StyledPanel | QFrame.Sunken)

        self.statusBar().setSizeGripEnabled(False)
        self.statusBar().addPermanentWidget(self.steps_lbl)
        self.statusBar().showMessage('Ready', STATUS_DELAY)

    def addActions(self, target, actions):
        """Adds actions in list to target menu.

        Overrides Qt4 base method to also insert separators for 'None'
        entries.

        Parameters
        ----------
        target : QMenu
            Target menu.
        actions : list of QAction
            List of actions to be inserted into menu.
        """
        for action in actions:
            if action is None:
                target.addSeparator()
            else:
                target.addAction(action)

    def populate_file_menu(self):
        """Fills the 'File' menu with actions and recently used files."""
        self.file_menu.clear()
        self.addActions(self.file_menu, self.file_menu_actions[:-1])

        # filter out non-existing files and the current file
        filtered_recent = []
        if self.recent_files is not None:
            for fname in self.recent_files:
                if fname != self.wf.get_filename() and QFile.exists(fname):
                    filtered_recent.append(fname)

        # generate entries for the menu
        if len(filtered_recent) > 0:
            for idx, fname in enumerate(filtered_recent, 1):
                action = QAction(QIcon(':/icon.png'), "&%d %s"
                                 % (idx, QFileInfo(fname).fileName()),
                                 self)
                action.setData(fname)
                action.triggered.connect(self.load_file)
                self.file_menu.addAction(action)
            self.file_menu.addSeparator()

        # add last remaining action (Quit)
        self.file_menu.addAction(self.file_menu_actions[-1])

    def populate_filter_menu(self):
        """Creates entries in the 'Edit > Add filter...' menu."""
        for item in self.tree_model.iterate_items():
            if item.hasChildren:
                for i in range(item.rowCount()):
                    child = item.child(i)
                    action = QAction(child.icon(), child.text(), self)
                    action.setStatusTip(child.get_description())
                    # using functools.partial to provide extra argument to slot
                    action.triggered.connect(partial(self.add_filter, child))
#                    action.triggered.connect(self.on_edit)
                    self.filter_menu.addAction(action)
                self.filter_menu.addSeparator()

    """ view event handlers """

    def update_settings_view(self, current_idx, previous_idx):
        """Updates filter settings view upon change of focus in workflow.

        Parameters
        ----------
        current_idx : QModelIndex
            Model index of currently focussed filter item.
        previous_idx: QModelIndex
            Model index of previously focussed filter item.
        """
        current_item = self.wf.getModel().data(current_idx, role=Qt.UserRole)

        self.current_editor.setParent(None)
        if current_item is None:
            self.fset_layout.addWidget(self.placeholder)
            self.placeholder.show()
        else:
            self.placeholder.hide()
            self.current_editor = current_item.make_widget()
            self.current_editor.value_changed.connect(self.on_settings_change)
            self.fset_layout.addWidget(self.current_editor)

    def on_settings_change(self):
        """Handle changes in the settings dialog.

        Indicates unsaved settings and re-validates the workflow.
        """
        self.wf.validator.validate()
        self.wf.set_dirty()

    def closeEvent(self, event):
        """Confirm before exiting and save settings for next launch.

        Overrides parent QT class method to ask for confirmation and save
        window settings, unsaved files and preferences.

        Parameters
        ----------
        event : QEvent
            Close event triggered by closing the main window or kernel signals.
        """
        if self.ok_to_continue():
            settings = QSettings()
            # save window settings
            settings.setValue("LastFile", self.wf.get_filename())
            settings.setValue("RecentFiles", self.recent_files)
            settings.setValue("MainWindow/Size", self.size())
            settings.setValue("MainWindow/Position", self.pos())
            settings.setValue("MainWindow/State", self.saveState())
            # save SAM2RMA settings
            settings.setValue(
                "SAM2RMA/TopPercent", self.rma_options.get_top_percent()
            )
            settings.setValue(
                "SAM2RMA/MaxExpected", self.rma_options.get_max_expected()
            )
            settings.setValue(
                "SAM2RMA/MinScore", self.rma_options.get_min_score()
            )
            settings.setValue(
                "SAM2RMA/MinSupportPercent",
                self.rma_options.get_min_support_percent()
            )
            settings.setValue(
                "SAM2RMA/Sam2RmaPath", self.rma_options.get_sam2rma_path()
            )
        else:
            event.ignore()

    def new_file(self):
        """Handles pressing of the 'New' button."""
        if not self.ok_to_continue():
            return

        self.add_recent_file(self.wf.get_filename())
        self.wf.clear()

    def open_file(self):
        """Handles pressing of the 'Open' button."""
        if not self.ok_to_continue():
            return

        current_name = self.wf.get_filename()
        if current_name is None:
            filedir = expanduser("~")
        else:
            filedir = dirname(current_name)

        next_name = QFileDialog.getOpenFileName(
            self,
            "Choose workflow",
            filedir,
            "SamSifter XML files (%s);;All files (*)" % " ".join(FILE_FORMATS)
        )
        if next_name:
            self.load_file(next_name)

    def show_about(self):
        """Show an informative About dialog."""
        QMessageBox.about(
            self,
            "About %s" % TITLE,
            """<b>%s</b> v%s
            <p>Copyright &copy; 2015 Florian Aldehoff.
            <p>%s
            <p>%s
            <p>Python %s - Qt %s - PyQt %s on %s"""
            % (TITLE, __version__, DESC, COPYRIGHT, platform.python_version(),
               QT_VERSION_STR, PYQT_VERSION_STR, platform.system())
        )

    def show_help(self):
        """Open browser to show the online help."""
        QDesktopServices.openUrl(QUrl(
            "http://www.biohazardous.de/samsifter/docs/help.html"
        ))

    def print_command(self):
        """Print current workflow command to console (debugging)."""
        self.output.clear()
        self.output.append(
            self.wf.commandline(hyphenated=False, multiline=True)
        )
        log.info(self.wf)

    def print_xml(self):
        """Print current workflow XML to console (debugging)."""
        xml = self.wf.to_xml_string()
        self.output.clear()
        self.output.append(xml)
        log.info(xml)

    def print_wf(self):
        """Print current workflow to console (debugging)."""
        self.output.clear()
        self.output.append(repr(self.wf))
        log.info(repr(self.wf))

    def validate_wf(self):
        """Validate current workflow (debugging)."""
        self.output.clear()
        validator = WorkflowValidator(self.wf)
        validator.validate()

    def run_workflow_command(self):
        """Runs the workflow."""
        cl = self.wf.commandline(hyphenated=False, multiline=False)
        self.progress_dock.setVisible(True)
        self.output.clear()

        self.process = QProcess()

        # QProcess emits `readyRead` when there is data to be read
        self.process.readyReadStandardError.connect(self.on_stderr)
        self.process.readyReadStandardOutput.connect(self.on_stdout)
        self.process.finished.connect(self.on_workflow_finished)

        # prevent accidentally running multiple processes
        self.process.started.connect(lambda: self.run_action.setEnabled(False))
        self.process.finished.connect(lambda: self.run_action.setEnabled(True))
        self.process.started.connect(lambda: self.stopAction.setEnabled(True))
        self.process.finished.connect(
            lambda: self.stopAction.setEnabled(False))

        workdir = dirname(self.wf.get_out_filename())
        self.process.setWorkingDirectory(workdir)
        log.info("setting working directory: %s" % workdir)

        # environment variable is used to set prefix of temporary stats files
        prefix = basename(self.wf.get_in_filename())
        prefix = 'reads_per_taxon'
        env = QProcessEnvironment.systemEnvironment()
        env.insert('filename', prefix)
        self.process.setProcessEnvironment(env)
        log.info("setting filename variable: %s" % prefix)

        self.process.start("bash", ['-e', '-c', cl])
        self.pid = self.process.pid()
        self.output.setTextColor(QColor("black"))
        self.output.append("PROCESS %i STARTED" % self.pid)
        log.info("started PID %i" % self.pid)

    def run_compile_stats_command(self):
        """Runs command to compile temp statistics files into one CSV file."""
        self.process = QProcess(self)

        # QProcess emits `readyRead` when there is data to be read
        self.process.readyReadStandardError.connect(self.on_stderr)
        self.process.readyReadStandardOutput.connect(self.on_stdout_stats)
        self.process.finished.connect(self.on_compilation_finished)

        # prevent accidentally running multiple processes
        self.process.started.connect(lambda: self.run_action.setEnabled(False))
        self.process.finished.connect(lambda: self.run_action.setEnabled(True))
        self.process.started.connect(lambda: self.stopAction.setEnabled(True))
        self.process.finished.connect(
            lambda: self.stopAction.setEnabled(False))

        # all statistics should be located with output file
        workdir = dirname(self.wf.get_out_filename())
        self.process.setWorkingDirectory(workdir)
        log.info("setting working directory: %s" % workdir)

        self.process.start('compile_stats', ['--remove', '--verbose'])
        self.pid = self.process.pid()

    def run_sam2rma_command(self):
        """Runs command to create RMA file from (zipped) SAM file."""
        self.process = QProcess(self)

        self.process.readyReadStandardError.connect(self.on_stderr)
        self.process.readyReadStandardOutput.connect(self.on_stdout)
        self.process.finished.connect(self.on_postprocessing_finished)

        # prevent accidentally running multiple processes
        self.process.started.connect(lambda: self.run_action.setEnabled(False))
        self.process.finished.connect(lambda: self.run_action.setEnabled(True))
        self.process.started.connect(lambda: self.stopAction.setEnabled(True))
        self.process.finished.connect(
            lambda: self.stopAction.setEnabled(False))

        # RMA should be located with output file
        workdir = dirname(self.wf.get_out_filename())
        self.process.setWorkingDirectory(workdir)
        log.info("setting working directory: %s" % workdir)
        log.info(self.rma_options.get_sam2rma_path())

        self.process.start(
            self.rma_options.get_sam2rma_path(),
            [
                '--minScore %.1f' % self.rma_options.get_min_score(),
                '--maxExpected %.2f' % self.rma_options.get_max_expected(),
                '--topPercent %.1f' % self.rma_options.get_top_percent(),
                '--minSupportPercent %.3f'
                % self.rma_options.get_min_support_percent(),
                '--in %s' % self.wf.get_out_filename(),
                '--out %s' % workdir
            ]
        )
        self.pid = self.process.pid()

    def stop_command(self):
        """Terminates current process and all child processes.

        Default termination of QProcess won't work here because bash spawns
        independent children that will continue to run.
        TODO: Use POSIX command 'kill $(ps -o pid= --ppid $PID)' where pkill
        is not available
        """
        killer = QProcess(self)
        killer.start('bash', ['-c', 'pkill -TERM -P ' + str(self.pid)])
#        killer.readyReadStandardError.connect(self.on_stderr)
#        killer.readyReadStandardOutput.connect(self.on_stdout)
        self.output.setTextColor(QColor("darkred"))
        self.output.append("PROCESS %i TERMINATING" % (self.pid, ))
        log.info("terminating PID %i" % self.pid)
        self.pid = -1

    def add_filter(self, item):
        """Insert cloned copy of filter item into the list model.

        Note
        ----
        C++ QWidgets do NOT support pythonic copy/deepcopy/pickle! Thus they
        can not be easily encoded to/decoded from MIME for standard Qt4
        drag&drop support and have to be cloned instead.
        """
        if isinstance(item, FilterItem):
            current_idx = self.list_view.currentIndex()
            if current_idx.row() == -1:
                # insert at end of list
                target_row = self.wf.getModel().rowCount()
            else:
                # insert after current item
                target_row = current_idx.row() + 1
            self.wf.getModel().insertItem(item.clone(), target_row)
            # put focus on new item
            new_idx = self.wf.getModel().index(target_row)
            self.list_view.setCurrentIndex(new_idx)
            self.update_status("Added filter step [%s] to workflow"
                               % item.text())

        else:
            log.error("not a FilterItem but a %s" % type(item))

    def on_tree_item_doubleclick(self):
        """Clone doubleclicked item in tree view into the list."""
        cur_tree_idx = self.sender().currentIndex()
        item = self.sender().model().itemFromIndex(cur_tree_idx)
        self.add_filter(item)

    def on_list_item_doubleclick(self):
        """Toggle settings dock to inspect settings of doubleclicked item."""
        if not self.fset_dock.isVisible():
            self.fset_dock.setVisible(True)

    def move_current(self, positions):
        """Move item in model.

        Parameters
        ----------
        positions : int
            number of positions to move currently selected item (negative = up,
            positive = down)

        Returns
        -------
        bool
            Success of moving operation.
        """
        current_idx = self.list_view.currentIndex()
        if not current_idx.isValid():
            return False

        model = self.wf.getModel()
        row = current_idx.row()
        if row + positions >= 0 and row + positions < model.rowCount():
            item = model.takeItem(row)
            model.insertItem(item, row + positions)
            mdlidx = model.index(row + positions)
            self.list_view.setCurrentIndex(mdlidx)
            return True
        else:
            return False

    def move_current_up(self):
        """Move currently selected item up by one position.

        Returns
        -------
        bool
            Success of moving operation.
        """
        return self.move_current(-1)

    def move_current_down(self):
        """Move currently selected item down by one position.

        Returns
        -------
        bool
            Success of moving operation.
        """
        return self.move_current(1)

    def delete_item(self):
        """Delete currently selected item from model.

        Returns
        -------
        bool
            Success of delete operation.
        """
        current_idx = self.list_view.currentIndex()
        if not current_idx.isValid():
            return False

        row = current_idx.row()
        return self.wf.getModel().removeRow(row)

    """ process event handlers """

    def on_stdout(self):
        """Handles STDOUT output from subprocess."""
        self.output.setTextColor(Qt.black)
        self.output.append(str(self.process.readAllStandardOutput(),
                               encoding='utf-8'))

    def on_stdout_stats(self):
        """Handles STDOUT output from statistics file compilation."""
        with open('%s.csv' % self.wf.get_out_filename(), 'a') as stats:
            print(
                str(self.process.readAllStandardOutput(), encoding='utf-8'),
                file=stats
            )

    def on_stderr(self):
        """Handles STDERR output from subprocess."""
        # convert QT4 binary array back into string
        message = str(self.process.readAllStandardError(), encoding='utf-8')
        message = message.rstrip()

        if message.startswith("INFO:"):
            self.output.setTextColor(QColor('darkgreen'))
        elif message.startswith("WARNING:"):
            self.output.setTextColor(QColor('orange'))
        elif message.startswith("ERROR:"):
            self.output.setTextColor(QColor('darkred'))
        elif "error:" in message:
            self.output.setTextColor(QColor('darkred'))
            self.stop_command()
        else:
            self.output.setTextColor(Qt.black)

        self.output.append(message)

    def on_workflow_finished(self):
        """Starts post-processing after end of workflow process."""
        self.output.setTextColor(QColor('black'))
        self.output.append("PROCESS %i FINISHED" % self.pid)
        log.info("finished PID %i" % self.pid)
        self.process = None
        self.pid = -1

        if self.wf.get_run_compile_stats() or self.wf.get_run_sam2rma():
            self.output.append("POST-PROCESSING")
        # always compile any temporary statistics files
        self.run_compile_stats_command()

    def on_compilation_finished(self):
        """Handles signal when statistics compilation has finished."""
        self.pid = -1
        if not self.wf.get_run_sam2rma():
            self.on_postprocessing_finished()
        else:
            # generate RMA file
            self.run_sam2rma_command()

    def on_postprocessing_finished(self):
        """Handles signal when post-processing has finished."""
        self.output.setTextColor(Qt.black)
        self.output.append("DONE")
        # reset command buttons
        self.stopAction.setEnabled(False)
        self.run_action.setEnabled(True)
        self.process = None
        self.pid = -1

    """ model event handlers """

    def update_status(self, message=None):
        """Updates window title and status bar.

        Parameters
        ----------
        message : str, optional
            Message to be displayed in status bar.
        """
        if message is not None:
            log.info(message)
            self.statusBar().showMessage(message, STATUS_DELAY)
        self.steps_lbl.setText("%i steps" % (self.wf.getModel().rowCount()))
        filename = self.wf.get_filename()
        if filename is None:
            self.setWindowTitle("untitled[*] - %s" % TITLE)
        else:
            self.setWindowTitle("%s[*] - %s" % (basename(filename), TITLE))
        self.setWindowModified(self.wf.is_dirty())

    def on_validity_change(self, message=None):
        """Show hints to resolve errors and prevent running invalid workflows.

        Parameters
        ----------
        message : str, optional
            Message to be displayed in message dock.
        """
        if message is not None:
            if message != "":
                log.info(message)
            self.output.clear()
            self.output.append(message)

        # prevent execution of invalid workflow
        self.run_action.setEnabled(self.wf.is_valid() and self.pid == -1)

        # set highlighting and tooltips of input/output widgets
        self.input_widget.highlight(not self.wf.infile_is_valid())
        self.input_widget.setToolTip(self.wf.validator.get_input_errors())
        self.output_widget.highlight(not self.wf.outfile_is_valid())
        self.output_widget.setToolTip(self.wf.validator.get_output_errors())

    """ file operations """

    def load_initial_file(self):
        """Restore workflow from previous session, greet user on first use."""
        settings = QSettings()
        fname = settings.value("LastFile")
        if fname and QFile.exists(fname):
            self.load_file(fname)
        else:
            self.update_status("Welcome to SamSifter!")
            self.progress_dock.setVisible(True)
            self.output.append(GREETING)

    def load_file(self, filename=None):
        """Open workflow data from file.

        Retrieves filename from user data of QAction when called from list of
        recently used files (signal emits a boolean).

        Parameters
        ----------
        filename : str, optional
            Path of file to be opened.
        """
        if filename is None or filename is False:
            action = self.sender()
            if isinstance(action, QAction):
                filename = action.data()
                if not self.ok_to_continue():
                    return
            else:
                return

        if filename:
            self.wf.set_filename(None)

            # actual deserialization is handled by workflow container
            (loaded, message) = self.wf.load(filename)
            if loaded:
                self.add_recent_file(filename)
#            self.update_status(message)

    def save_file(self):
        """Save workflow under current filename."""
        # save only if there are changes (commented out to always save)
#        if not self.wf.is_dirty():
#            return

        filename = self.wf.get_filename()
        if filename is None:
            self.save_file_as()
        else:
            # actual serialization is handled by workflow container
            (saved, message) = self.wf.save()
            self.update_status(message)

    def save_file_as(self):
        """Saves workflow under a new filename."""
        current_name = self.wf.get_filename()
        if current_name is not None:
            filedir = dirname(current_name)
        else:
            filedir = expanduser("~")

        new_name = QFileDialog.getSaveFileName(self, "Save workflow",
                                               filedir,
                                               "SamSifter XML files (%s);;"
                                               "All files (*)"
                                               % (" ".join(FILE_FORMATS), ))
        if new_name:
            # append missing extension
            if "." not in new_name:
                new_name += FILE_FORMATS[0].lstrip('*')
            self.add_recent_file(new_name)
            self.wf.set_filename(new_name)
            self.save_file()

    def add_recent_file(self, filename):
        """Adds a filename to the list of recently used files.

        Parameters
        ----------
        filename : str
            Path of file to be added to list.
        """
        if filename is None:
            return
        if filename not in self.recent_files:
            self.recent_files.insert(0, filename)
            while len(self.recent_files) > RECENT_FILES_MAX_LENGTH:
                self.recent_files.pop()

    def configure_bash(self):
        """Opens modal dialog to set options for exported bash script."""
        dialog = BashOptionsDialog()
        options = dialog.get_options()
        if options:
            self.export_bash(options)

    def configure_sam2rma(self):
        """Opens modal dialog to set options for sam2rma conversion."""
        dialog = RmaOptionsDialog(self.rma_options)
        options = dialog.get_options()
        if options:
            self.rma_options = options

    def export_bash(self, options):
        """Opens 'Save as...' dialogue and exports workflow to bash script."""
        dialog = QFileDialog()
        dialog.setDefaultSuffix('Bash script (*.sh *.SH)')
        fname = dialog.getSaveFileName(self, 'Export as',
                                       expanduser("~"),
                                       "Bash script (*.sh *.SH);;All files (*)"
                                       )
        if fname:
            self.wf.to_bash(fname, options, self.rma_options)

    def ok_to_continue(self):
        """Asks user for confirmation to save any unsaved changes.

        Returns
        -------
        bool
            True if ok to continue, False if unsaved changes still exist.
        """
        if self.wf.is_dirty():
            reply = QMessageBox.question(
                self, "Unsaved Changes",
                ("There are unsaved changes to the current workflow that will "
                 "be lost. Would you like to save them?"),
                QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel
            )
            if reply == QMessageBox.Cancel:
                return False
            elif reply == QMessageBox.Yes:
                self.save_file()
        return True


def sigint_handler(*args):
    """Handler for the SIGINT signal (Ctrl+C)."""
    sys.stderr.write('\r')
    if QMessageBox.question(
        None, '', "Are you sure you want to quit?",
        QMessageBox.Yes | QMessageBox.No, QMessageBox.No
    ) == QMessageBox.Yes:
        QApplication.quit()


def main():
    """Executable for SamSifter GUI.

    See ``--help`` for details on expected arguments. Takes no input from STDIN
    but logs errors and messages to STDERR.
    """
    # parse arguments
    parser = argparse.ArgumentParser(description=DESC)
    parser.add_argument('-v', '--verbose',
                        required=False,
                        action='store_true',
                        help='print additional information to stderr')
    parser.add_argument('-d', '--debug',
                        required=False,
                        action='store_true',
                        help='show debug options in menu')
    (args, remainArgs) = parser.parse_known_args()

    # configure logging
    if args.verbose:
        log.basicConfig(format="%(levelname)s: %(message)s", level=log.DEBUG)
    else:
        log.basicConfig(format="%(levelname)s: %(message)s")

    signal.signal(signal.SIGINT, sigint_handler)

    app = QApplication(remainArgs)

    timer = QTimer()
    timer.start(500)                     # You may change this if you wish.
    timer.timeout.connect(lambda: None)  # Let the interpreter run each 500 ms.

    app.setOrganizationName("Biohazardous")
    app.setOrganizationDomain("biohazardous.de")
    app.setApplicationVersion(__version__)
    app.setApplicationName(TITLE)
    app.setWindowIcon(QIcon(":/icon.png"))
    mw = MainWindow(args.verbose, args.debug)
    mw.show()
    sys.exit(app.exec_())

if __name__ == '__main__':
    main()
