"""
B-ASIC Signal Flow Graph Editor Module.

This file opens the main SFG editor window of the GUI for B-ASIC when run.
"""

import importlib.util
import logging
import os
import sys
import webbrowser
from collections import deque
from types import ModuleType
from typing import TYPE_CHECKING, Deque, Dict, List, Optional, Sequence, Tuple, cast

from qtpy.QtCore import QCoreApplication, QFileInfo, QSettings, QSize, Qt, QThread, Slot
from qtpy.QtGui import QCursor, QIcon, QKeySequence, QPainter
from qtpy.QtWidgets import (
    QAction,
    QApplication,
    QFileDialog,
    QGraphicsItem,
    QGraphicsScene,
    QGraphicsTextItem,
    QGraphicsView,
    QInputDialog,
    QLineEdit,
    QListWidget,
    QListWidgetItem,
    QMainWindow,
    QShortcut,
    QStatusBar,
)

import b_asic.core_operations
import b_asic.special_operations
from b_asic._version import __version__
from b_asic.GUI._preferences import GAP, GRID, MINBUTTONSIZE, PORTHEIGHT
from b_asic.GUI.arrow import Arrow
from b_asic.GUI.drag_button import DragButton
from b_asic.GUI.gui_interface import Ui_main_window
from b_asic.GUI.port_button import PortButton
from b_asic.GUI.precedence_graph_window import PrecedenceGraphWindow
from b_asic.GUI.select_sfg_window import SelectSFGWindow
from b_asic.GUI.simulate_sfg_window import SimulateSFGWindow
from b_asic.GUI.simulation_worker import SimulationWorker
from b_asic.GUI.util_dialogs import FaqWindow, KeybindingsWindow
from b_asic.gui_utils.about_window import AboutWindow
from b_asic.gui_utils.decorators import decorate_class, handle_error
from b_asic.gui_utils.icons import get_icon
from b_asic.gui_utils.plot_window import PlotWindow
from b_asic.operation import Operation
from b_asic.port import InputPort, OutputPort
from b_asic.save_load_structure import python_to_sfg, sfg_to_python
from b_asic.signal import Signal
from b_asic.signal_flow_graph import SFG
from b_asic.simulation import Simulation
from b_asic.special_operations import Input, Output

if TYPE_CHECKING:
    from qtpy.QtWidgets import QGraphicsProxyWidget

logging.basicConfig(level=logging.INFO)

QCoreApplication.setOrganizationName("Linköping University")
QCoreApplication.setOrganizationDomain("liu.se")
QCoreApplication.setApplicationName("B-ASIC SFG GUI")
QCoreApplication.setApplicationVersion(__version__)


@decorate_class(handle_error)
class SFGMainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self._logger = logging.getLogger(__name__)
        self._window = self
        self._ui = Ui_main_window()
        self._ui.setupUi(self)
        self.setWindowIcon(QIcon("small_logo.png"))
        self._scene = QGraphicsScene(self._ui.splitter)
        self._operations_from_name: Dict[str, Operation] = {}
        self._zoom = 1
        self._drag_operation_scenes: Dict[DragButton, "QGraphicsProxyWidget"] = {}
        self._drag_buttons: Dict[Operation, DragButton] = {}
        self._mouse_pressed = False
        self._mouse_dragging = False
        self._starting_port = None
        self._pressed_operations: List[DragButton] = []
        self._arrow_ports: Dict[Arrow, List[Tuple[PortButton, PortButton]]] = {}
        self._operation_to_sfg: Dict[DragButton, SFG] = {}
        self._pressed_ports: List[PortButton] = []
        self._sfg_dict: Dict[str, SFG] = {}
        self._plot: Dict[Simulation, PlotWindow] = {}
        self._ports: Dict[DragButton, List[PortButton]] = {}

        # Create Graphics View
        self._graphics_view = QGraphicsView(self._scene, self._ui.splitter)
        self._graphics_view.setRenderHint(QPainter.Antialiasing)
        self._graphics_view.setGeometry(
            self._ui.operation_box.width(), 20, self.width(), self.height()
        )
        self._graphics_view.setDragMode(QGraphicsView.RubberBandDrag)

        # Create toolbar
        self._toolbar = self.addToolBar("Toolbar")
        self._toolbar.addAction(get_icon('open'), "Load SFG", self.load_work)
        self._toolbar.addAction(get_icon('save'), "Save SFG", self.save_work)
        self._toolbar.addSeparator()
        self._toolbar.addAction(
            get_icon('new-sfg'), "Create SFG", self.create_sfg_from_toolbar
        )
        self._toolbar.addAction(
            get_icon('close'), "Clear workspace", self._clear_workspace
        )

        # Create status bar
        self._statusbar = QStatusBar(self)
        self.setStatusBar(self._statusbar)

        # Add operations
        self._max_recent_files = 4
        self._recent_files_actions: List[QAction] = []
        self._recent_files_paths: Deque[str] = deque(maxlen=self._max_recent_files)

        self.add_operations_from_namespace(
            b_asic.core_operations, self._ui.core_operations_list
        )
        self.add_operations_from_namespace(
            b_asic.special_operations, self._ui.special_operations_list
        )

        self._shortcut_refresh_operations = QShortcut(
            QKeySequence("Ctrl+R"), self._ui.operation_box
        )
        self._shortcut_refresh_operations.activated.connect(
            self._refresh_operations_list_from_namespace
        )
        self._scene.selectionChanged.connect(self._select_operations)

        self.move_button_index = 0

        self._ui.actionShowPC.triggered.connect(self._show_precedence_graph)
        self._ui.actionSimulateSFG.triggered.connect(self.simulate_sfg)
        self._ui.actionSimulateSFG.setIcon(get_icon('sim'))

        # About menu
        self._ui.faqBASIC.triggered.connect(self.display_faq_page)
        self._ui.faqBASIC.setShortcut(QKeySequence("Ctrl+?"))
        self._ui.faqBASIC.setIcon(get_icon('faq'))
        self._ui.aboutBASIC.triggered.connect(self.display_about_page)
        self._ui.aboutBASIC.setIcon(get_icon('about'))
        self._ui.keybindsBASIC.triggered.connect(self.display_keybindings_page)
        self._ui.keybindsBASIC.setIcon(get_icon('keys'))
        self._ui.documentationBASIC.triggered.connect(self._open_documentation)
        self._ui.documentationBASIC.setIcon(get_icon('docs'))

        # Operation lists
        self._ui.core_operations_list.itemClicked.connect(
            self._on_list_widget_item_clicked
        )
        self._ui.special_operations_list.itemClicked.connect(
            self._on_list_widget_item_clicked
        )
        self._ui.custom_operations_list.itemClicked.connect(
            self._on_list_widget_item_clicked
        )
        self._ui.save_menu.triggered.connect(self.save_work)
        self._ui.save_menu.setIcon(get_icon('save'))
        self._ui.save_menu.setShortcut(QKeySequence("Ctrl+S"))
        self._ui.load_menu.triggered.connect(self.load_work)
        self._ui.load_menu.setIcon(get_icon('open'))
        self._ui.load_menu.setShortcut(QKeySequence("Ctrl+O"))
        self._ui.load_operations.triggered.connect(self.add_namespace)
        self._ui.load_operations.setIcon(get_icon('add-operations'))
        self._ui.exit_menu.triggered.connect(self.exit_app)
        self._ui.exit_menu.setIcon(get_icon('quit'))
        self._ui.select_all.triggered.connect(self._select_all)
        self._ui.select_all.setShortcut(QKeySequence("Ctrl+A"))
        self._ui.select_all.setIcon(get_icon('all'))
        self._ui.unselect_all.triggered.connect(self._unselect_all)
        self._ui.unselect_all.setIcon(get_icon('none'))
        self._shortcut_signal = QShortcut(QKeySequence(Qt.Key_Space), self)
        self._shortcut_signal.activated.connect(self._connect_callback)
        self._create_recent_file_actions_and_menus()

        # View menu

        # Operation names
        self._show_names = True
        self._check_show_names = QAction("&Operation names")
        self._check_show_names.triggered.connect(self.view_operation_names)
        self._check_show_names.setCheckable(True)
        self._check_show_names.setChecked(True)
        self._ui.view_menu.addAction(self._check_show_names)

        self._ui.view_menu.addSeparator()

        # Toggle toolbar
        self._ui.view_menu.addAction(self._toolbar.toggleViewAction())

        # Toggle status bar
        self._statusbar_visible = QAction("&Status bar")
        self._statusbar_visible.setCheckable(True)
        self._statusbar_visible.setChecked(True)
        self._statusbar_visible.triggered.connect(self._toggle_statusbar)
        self._ui.view_menu.addAction(self._statusbar_visible)

        # Zoom to fit
        self._ui.view_menu.addSeparator()
        self._zoom_to_fit_action = QAction(get_icon('zoom-to-fit'), "Zoom to &fit")
        self._zoom_to_fit_action.triggered.connect(self._zoom_to_fit)
        self._ui.view_menu.addAction(self._zoom_to_fit_action)

        # Toggle full screen
        self._fullscreen_action = QAction(
            get_icon('full-screen'), "Toggle f&ull screen"
        )
        self._fullscreen_action.setCheckable(True)
        self._fullscreen_action.triggered.connect(self._toggle_fullscreen)
        self._fullscreen_action.setShortcut(QKeySequence("F11"))
        self._ui.view_menu.addAction(self._fullscreen_action)

        # Non-modal dialogs
        self._keybindings_page = None
        self._about_page = None
        self._faq_page = None

        self._logger.info("Finished setting up GUI")
        self._logger.info(
            "For questions please refer to 'Ctrl+?', or visit the 'Help' "
            "section on the _toolbar."
        )

        self.cursor = QCursor()

    def resizeEvent(self, event) -> None:
        ui_width = self._ui.operation_box.width()
        self._ui.operation_box.setGeometry(10, 10, ui_width, self.height())
        self._graphics_view.setGeometry(
            ui_width + 20,
            60,
            self.width() - ui_width - 20,
            self.height() - 30,
        )
        super().resizeEvent(event)

    def wheelEvent(self, event) -> None:
        if event.modifiers() == Qt.KeyboardModifier.ControlModifier:
            old_zoom = self._zoom
            self._zoom += event.angleDelta().y() / 2500
            self._graphics_view.scale(self._zoom, self._zoom)
            self._zoom = old_zoom

    def view_operation_names(self, event=None) -> None:
        self._show_names = self._check_show_names.isChecked()

        for operation in self._drag_operation_scenes:
            operation.label.setOpacity(self._show_names)
            operation.show_name = self._show_names

    def _save_work(self) -> None:
        if not self.sfg_widget.sfg:
            self.update_statusbar("No SFG selected - saving cancelled")
        sfg = cast(SFG, self.sfg_widget.sfg)
        file_dialog = QFileDialog()
        file_dialog.setDefaultSuffix(".py")
        module, accepted = file_dialog.getSaveFileName()
        if not accepted:
            return

        self._logger.info("Saving SFG to path: " + str(module))
        operation_positions = {}
        for op_drag, op_scene in self._drag_operation_scenes.items():
            operation_positions[op_drag.operation.graph_id] = (
                int(op_scene.x()),
                int(op_scene.y()),
                op_drag.is_flipped(),
            )

        try:
            with open(module, "w+") as file_obj:
                file_obj.write(
                    sfg_to_python(sfg, suffix=f"positions = {operation_positions}")
                )
        except Exception as e:
            self._logger.error(
                f"Failed to save SFG to path: {module}, with error: {e}."
            )
            return

        self._logger.info("Saved SFG to path: " + str(module))
        self.update_statusbar("Saved SFG to path: " + str(module))

    def save_work(self, event=None) -> None:
        if not self._sfg_dict:
            self.update_statusbar("No SFG to save")
            return
        self.sfg_widget = SelectSFGWindow(self)
        self.sfg_widget.show()

        # Wait for input to dialog.
        self.sfg_widget.ok.connect(self._save_work)

    def load_work(self, event=None) -> None:
        module, accepted = QFileDialog().getOpenFileName()
        if not accepted:
            return
        self._load_from_file(module)

    def _load_from_file(self, module) -> None:
        self._logger.info("Loading SFG from path: " + str(module))
        try:
            sfg, positions = python_to_sfg(module)
        except ImportError as e:
            self._logger.error(
                f"Failed to load module: {module} with the following error: {e}."
            )
            return

        self._add_recent_file(module)

        while sfg.name in self._sfg_dict:
            self._logger.warning(
                f"Duplicate SFG with name: {sfg.name} detected. "
                "Please choose a new name."
            )
            name, accepted = QInputDialog.getText(
                self, "Change SFG Name", "Name: ", QLineEdit.Normal
            )
            if not accepted:
                return

            sfg.name = name
        self._load_sfg(sfg, positions)
        self._logger.info("Loaded SFG from path: " + str(module))
        self.update_statusbar(f"Loaded SFG from {module}")

    def _load_sfg(self, sfg: SFG, positions=None) -> None:
        if positions is None:
            positions = {}

        for op in sfg.split():
            self.add_operation(
                op,
                positions[op.graph_id][0:2] if op.graph_id in positions else None,
                positions[op.graph_id][-1] if op.graph_id in positions else None,
            )

        def connect_ports(ports: Sequence[InputPort]):
            for port in ports:
                for signal in port.signals:
                    sources = [
                        source
                        for source in self._drag_buttons[
                            signal.source_operation
                        ].port_list
                        if source.port is signal.source
                    ]
                    destinations = [
                        destination
                        for destination in self._drag_buttons[
                            signal.destination.operation
                        ].port_list
                        if destination.port is signal.destination
                    ]

                    if sources and destinations:
                        self._connect_button(sources[0], destinations[0])

            for pressed_port in self._pressed_ports:
                pressed_port.select_port()

        for op in sfg.split():
            connect_ports(op.inputs)

        for op in sfg.split():
            self._drag_buttons[op].setToolTip(sfg.name)
            self._operation_to_sfg[self._drag_buttons[op]] = sfg

        self._sfg_dict[sfg.name] = sfg
        self.update()

    def _create_recent_file_actions_and_menus(self):
        for i in range(self._max_recent_files):
            recent_file_action = QAction(self._ui.recent_sfg)
            recent_file_action.setVisible(False)
            recent_file_action.triggered.connect(
                lambda b=0, x=recent_file_action: self._open_recent_file(x)
            )
            self._recent_files_actions.append(recent_file_action)
            self._ui.recent_sfg.addAction(recent_file_action)

        self._update_recent_file_list()

    def _toggle_fullscreen(self, event=None):
        """Callback for toggling full screen mode."""
        if self.isFullScreen():
            self.showNormal()
            self._fullscreen_action.setIcon(get_icon('full-screen'))
        else:
            self.showFullScreen()
            self._fullscreen_action.setIcon(get_icon('full-screen-exit'))

    def _update_recent_file_list(self):
        settings = QSettings()

        rfp = cast(deque, settings.value("SFG/recentFiles"))

        # print(rfp)
        if rfp:
            dequelen = len(rfp)
            if dequelen > 0:
                for i in range(dequelen):
                    action = self._recent_files_actions[i]
                    action.setText(rfp[i])
                    action.setData(QFileInfo(rfp[i]))
                    action.setVisible(True)

                for i in range(dequelen, self._max_recent_files):
                    self._recent_files_actions[i].setVisible(False)

    def _open_recent_file(self, action):
        self._load_from_file(action.data().filePath())

    def _add_recent_file(self, module):
        settings = QSettings()

        rfp = cast(deque, settings.value("SFG/recentFiles"))

        if rfp:
            if module not in rfp:
                rfp.append(module)
        else:
            rfp = deque(maxlen=self._max_recent_files)
            rfp.append(module)

        settings.setValue("SFG/recentFiles", rfp)

        self._update_recent_file_list()

    def exit_app(self, event=None) -> None:
        """Exit the application."""
        self._logger.info("Exiting the application.")
        QApplication.quit()

    def update_statusbar(self, msg: str) -> None:
        """
        Write *msg* to the statusbar with temporarily policy.

        Parameters
        ----------
        msg : str
            The message to write.
        """
        self._statusbar.showMessage(msg)

    def _clear_workspace(self) -> None:
        self._logger.info("Clearing workspace from operations and SFGs.")
        self._pressed_operations.clear()
        self._pressed_ports.clear()
        self._drag_buttons.clear()
        self._drag_operation_scenes.clear()
        self._arrow_ports.clear()
        self._ports.clear()
        self._sfg_dict.clear()
        self._scene.clear()
        self._logger.info("Workspace cleared.")
        self.update_statusbar("Workspace cleared.")

    def create_sfg_from_toolbar(self) -> None:
        """Callback to create an SFG."""
        inputs = []
        outputs = []
        for pressed_op in self._pressed_operations:
            if isinstance(pressed_op.operation, Input):
                inputs.append(pressed_op.operation)
            elif isinstance(pressed_op.operation, Output):
                outputs.append(pressed_op.operation)

        name, accepted = QInputDialog.getText(
            self, "Create SFG", "Name: ", QLineEdit.Normal
        )
        if not accepted:
            return

        if not name:
            self._logger.warning("Failed to initialize SFG with empty name.")
            return

        self._logger.info("Creating SFG with name: %s from selected operations." % name)

        sfg = SFG(inputs=inputs, outputs=outputs, name=name)
        self._logger.info("Created SFG with name: %s from selected operations." % name)
        self.update_statusbar(f"Created SFG: {name}")

        def check_equality(signal: Signal, signal_2: Signal) -> bool:
            source_operation = cast(Operation, signal.source_operation)
            source_operation2 = cast(Operation, signal_2.source_operation)
            dest_operation = cast(Operation, signal.destination_operation)
            dest_operation2 = cast(Operation, signal_2.destination_operation)
            if not (
                source_operation.type_name() == source_operation2.type_name()
                and dest_operation.type_name() == dest_operation2.type_name()
            ):
                return False

            if (
                hasattr(source_operation, "value")
                and hasattr(source_operation2, "value")
                and hasattr(dest_operation, "value")
                and hasattr(dest_operation2, "value")
            ):
                if not (
                    source_operation.value == source_operation2.value
                    and dest_operation.value == dest_operation2.value
                ):
                    return False

            if (
                hasattr(source_operation, "name")
                and hasattr(source_operation2, "name")
                and hasattr(dest_operation, "name")
                and hasattr(dest_operation2, "name")
            ):
                if not (
                    source_operation.name == source_operation2.name
                    and dest_operation.name == dest_operation2.name
                ):
                    return False

            try:
                signal_source_index = [
                    source_operation.outputs.index(port)
                    for port in source_operation.outputs
                    if signal in port.signals
                ]
                signal_2_source_index = [
                    source_operation2.outputs.index(port)
                    for port in source_operation2.outputs
                    if signal_2 in port.signals
                ]
            except ValueError:
                return False  # Signal output connections not matching

            try:
                signal_destination_index = [
                    dest_operation.inputs.index(port)
                    for port in dest_operation.inputs
                    if signal in port.signals
                ]
                signal_2_destination_index = [
                    dest_operation2.inputs.index(port)
                    for port in dest_operation2.inputs
                    if signal_2 in port.signals
                ]
            except ValueError:
                return False  # Signal input connections not matching

            return (
                signal_source_index == signal_2_source_index
                and signal_destination_index == signal_2_destination_index
            )

        for _pressed_op in self._pressed_operations:
            for operation in sfg.operations:
                for input_ in operation.inputs:
                    for signal in input_.signals:
                        for arrow in self._arrow_ports:
                            if check_equality(arrow.signal, signal):
                                arrow.set_source_operation(signal.source_operation)
                                arrow.set_destination_operation(
                                    signal.destination_operation
                                )

                for output_ in operation.outputs:
                    for signal in output_.signals:
                        for arrow in self._arrow_ports:
                            if check_equality(arrow.signal, signal):
                                arrow.set_source_operation(signal.source_operation)
                                arrow.set_destination_operation(
                                    signal.destination_operation
                                )

        for pressed_op in self._pressed_operations:
            pressed_op.setToolTip(sfg.name)
            self._operation_to_sfg[pressed_op] = sfg

        self._sfg_dict[sfg.name] = sfg

    def _show_precedence_graph(self, event=None) -> None:
        """Callback for showing precedence graph."""
        if not self._sfg_dict:
            self.update_statusbar("No SFG to show")
            return
        self._precedence_graph_dialog = PrecedenceGraphWindow(self)
        self._precedence_graph_dialog.add_sfg_to_dialog()
        self._precedence_graph_dialog.show()

    def _toggle_statusbar(self, event=None) -> None:
        """Callback for toggling the status bar."""
        self._statusbar.setVisible(self._statusbar_visible.isChecked())

    def get_operations_from_namespace(self, namespace: ModuleType) -> List[str]:
        """
        Return a list of all operations defined in a namespace (module).

        Parameters
        ----------
        namespace : module
            A loaded Python module containing operations.

        Returns
        -------
        list
            A list of names of all the operations in the module.
        """
        self._logger.info(
            "Fetching operations from namespace: " + str(namespace.__name__)
        )
        return [
            comp
            for comp in dir(namespace)
            if hasattr(getattr(namespace, comp), "type_name")
        ]

    def add_operations_from_namespace(
        self, namespace: ModuleType, list_widget: QListWidget
    ) -> None:
        """
        Add operations from namespace (module) to a list widget.

        Parameters
        ----------
        namespace : module
            A loaded Python module containing operations.
        list_widget : QListWidget
            The widget to add operations to.
        """
        for attr_name in self.get_operations_from_namespace(namespace):
            attr = getattr(namespace, attr_name)
            try:
                attr.type_name()
                item = QListWidgetItem(attr_name)
                list_widget.addItem(item)
                self._operations_from_name[attr_name] = attr
            except NotImplementedError:
                pass

        self._logger.info("Added operations from namespace: " + str(namespace.__name__))

    def add_namespace(self, event=None) -> None:
        """Callback for adding namespace."""
        module, accepted = QFileDialog().getOpenFileName()
        if not accepted:
            return
        self._add_namespace(module)

    def _add_namespace(self, module: str):
        spec = importlib.util.spec_from_file_location(
            f"{QFileInfo(module).fileName()}", module
        )
        namespace = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(namespace)

        self.add_operations_from_namespace(namespace, self._ui.custom_operations_list)

    def _update(self):
        self._scene.update()
        self._graphics_view.update()

    def add_operation(
        self,
        op: Operation,
        position: Optional[Tuple[float, float]] = None,
        is_flipped: bool = False,
    ) -> None:
        """
        Add operation to GUI.

        Parameters
        ----------
        op : Operation
            The operation to add.
        position : (float, float), optional
            (x, y)-position for operation.
        is_flipped : bool, default: False
            Whether the operation is flipped.
        """
        try:
            if op in self._drag_buttons:
                self._logger.warning("Multiple instances of operation with same name")
                return

            attr_button = DragButton(op, True, window=self)
            if position is None:
                attr_button.move(GRID * 3, GRID * 2)
            else:
                attr_button.move(*position)

            max_ports = max(op.input_count, op.output_count)
            button_height = max(
                MINBUTTONSIZE, max_ports * PORTHEIGHT + (max_ports - 1) * GAP
            )

            attr_button.setFixedSize(MINBUTTONSIZE, button_height)
            attr_button.setStyleSheet(
                "background-color: white; border-style: solid;"
                "border-color: black; border-width: 2px"
            )
            attr_button.add_ports()
            self._ports[attr_button] = attr_button.port_list

            icon_path = os.path.join(
                os.path.dirname(__file__),
                "operation_icons",
                f"{op.type_name().lower()}.png",
            )
            if not os.path.exists(icon_path):
                icon_path = os.path.join(
                    os.path.dirname(__file__),
                    "operation_icons",
                    "custom_operation.png",
                )
            attr_button.setIcon(QIcon(icon_path))
            attr_button.setIconSize(QSize(MINBUTTONSIZE, MINBUTTONSIZE))
            attr_button.setToolTip("No SFG")
            attr_button.setStyleSheet(
                "QToolTip { background-color: white; color: black }"
            )
            attr_button.setParent(None)
            attr_button_scene = self._scene.addWidget(attr_button)
            if position is None:
                attr_button_scene.moveBy(
                    int(self._scene.width() / 4), int(self._scene.height() / 4)
                )
            attr_button_scene.setFlag(QGraphicsItem.ItemIsSelectable, True)
            operation_label = QGraphicsTextItem(op.name, attr_button_scene)
            if not self._show_names:
                operation_label.setOpacity(0)
            operation_label.setTransformOriginPoint(
                operation_label.boundingRect().center()
            )
            operation_label.moveBy(10, -20)
            attr_button.add_label(operation_label)

            if isinstance(is_flipped, bool):
                if is_flipped:
                    attr_button._flip()

            self._drag_buttons[op] = attr_button
            self._drag_operation_scenes[attr_button] = attr_button_scene

        except Exception as e:
            self._logger.error(
                "Unexpected error occurred while creating operation: " + str(e)
            )

    def _create_operation_item(self, item) -> None:
        self._logger.info("Creating operation of type: %s" % str(item.text()))
        try:
            attr_operation = self._operations_from_name[item.text()]()
            self.add_operation(attr_operation)
            self.update_statusbar(f"{item.text()} added.")
        except Exception as e:
            self._logger.error(
                "Unexpected error occurred while creating operation: " + str(e)
            )

    def _refresh_operations_list_from_namespace(self) -> None:
        self._logger.info("Refreshing operation list.")
        self._ui.core_operations_list.clear()
        self._ui.special_operations_list.clear()

        self.add_operations_from_namespace(
            b_asic.core_operations, self._ui.core_operations_list
        )
        self.add_operations_from_namespace(
            b_asic.special_operations, self._ui.special_operations_list
        )
        self._logger.info("Finished refreshing operation list.")

    def _on_list_widget_item_clicked(self, item) -> None:
        self._create_operation_item(item)

    def keyPressEvent(self, event) -> None:
        if event.key() == Qt.Key.Key_Delete:
            for pressed_op in self._pressed_operations:
                pressed_op.remove()
                self.move_button_index -= 1
            self._pressed_operations.clear()
        super().keyPressEvent(event)

    def _connect_callback(self, *event) -> None:
        """Callback for connecting operation buttons."""
        if len(self._pressed_ports) < 2:
            self._logger.warning(
                "Cannot connect less than two ports. Please select at least two."
            )
            return

        pressed_op_inports = [
            pressed
            for pressed in self._pressed_ports
            if isinstance(pressed.port, InputPort)
        ]
        pressed_op_outports = [
            pressed
            for pressed in self._pressed_ports
            if isinstance(pressed.port, OutputPort)
        ]

        if len(pressed_op_outports) != 1:
            raise ValueError("Exactly one output port must be selected!")

        pressed_op_outport = pressed_op_outports[0]
        for pressed_op_input_port in pressed_op_inports:
            self._connect_button(pressed_op_outport, pressed_op_input_port)

        for port in self._pressed_ports:
            port.select_port()

    def _connect_button(self, source: PortButton, destination: PortButton) -> None:
        """
        Connect two PortButtons with an Arrow.

        Parameters
        ----------
        source : PortButton
            The PortButton to start the signal at.
        destination : PortButton
            The PortButton to end the signal at.

        Returns
        -------
        None.
        """
        signal_exists = (
            signal
            for signal in source.port.signals
            if signal.destination is destination.port
        )
        self._logger.info(
            "Connecting: %s -> %s."
            % (
                source.operation.type_name(),
                destination.operation.type_name(),
            )
        )
        try:
            arrow = Arrow(source, destination, self, signal=next(signal_exists))
        except StopIteration:
            arrow = Arrow(source, destination, self)

        if arrow not in self._arrow_ports:
            self._arrow_ports[arrow] = []

        self._arrow_ports[arrow].append((source, destination))
        self._scene.addItem(arrow)

        self.update()

    def paintEvent(self, event) -> None:
        for arrow in self._arrow_ports:
            arrow.update_arrow()

    def _select_operations(self) -> None:
        """Select an operation button."""
        selected = [button.widget() for button in self._scene.selectedItems()]
        for button in selected:
            button._toggle_button(pressed=False)

        for button in self._pressed_operations:
            if button not in selected:
                button._toggle_button(pressed=True)

        self._pressed_operations = selected

    def _select_all(self, event=None) -> None:
        """Callback for selecting all operation buttons."""
        if not self._drag_buttons:
            self.update_statusbar("No operations to select")
            return

        for operation in self._drag_buttons.values():
            operation._toggle_button(pressed=False)
        self.update_statusbar("Selected all operations")

    def _unselect_all(self, event=None) -> None:
        """Callback for unselecting all operation buttons."""
        if not self._drag_buttons:
            self.update_statusbar("No operations to unselect")
            return

        for operation in self._drag_buttons.values():
            operation._toggle_button(pressed=True)
        self.update_statusbar("Unselected all operations")

    def _zoom_to_fit(self, event=None):
        """Callback for zoom to fit SFGs in window."""
        self._graphics_view.fitInView(
            self._scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio
        )

    def _simulate_sfg(self) -> None:
        """Callback for simulating SFGs in separate threads."""
        self._thread = dict()
        self._sim_worker = dict()
        for sfg, properties in self._simulation_dialog._properties.items():
            self._logger.info("Simulating SFG with name: %s" % str(sfg.name))
            self._sim_worker[sfg] = SimulationWorker(sfg, properties)
            self._thread[sfg] = QThread()
            self._sim_worker[sfg].moveToThread(self._thread[sfg])
            self._thread[sfg].started.connect(self._sim_worker[sfg].start_simulation)
            self._sim_worker[sfg].finished.connect(self._thread[sfg].quit)
            self._sim_worker[sfg].finished.connect(self._show_plot_window)
            self._sim_worker[sfg].finished.connect(self._sim_worker[sfg].deleteLater)
            self._thread[sfg].finished.connect(self._thread[sfg].deleteLater)
            self._thread[sfg].start()

    def _show_plot_window(self, sim: Simulation):
        """Callback for displaying simulation results window."""
        self._plot[sim] = PlotWindow(sim.results, sfg_name=sim.sfg.name)
        self._plot[sim].show()

    def simulate_sfg(self, event=None) -> None:
        """Callback for showing simulation dialog."""
        if not self._sfg_dict:
            self.update_statusbar("No SFG to simulate")
            return

        self._simulation_dialog = SimulateSFGWindow(self)

        for _, sfg in self._sfg_dict.items():
            self._simulation_dialog.add_sfg_to_dialog(sfg)

        self._simulation_dialog.show()

        # Wait for input to dialog.
        # Kinda buggy because of the separate window in the same thread.
        self._simulation_dialog.simulate.connect(self._simulate_sfg)

    @Slot()
    def _open_documentation(self, event=None) -> None:
        """Callback to open documentation web page."""
        webbrowser.open_new_tab("https://da.gitlab-pages.liu.se/B-ASIC/")

    def display_faq_page(self, event=None) -> None:
        """Callback for displaying FAQ dialog."""
        if self._faq_page is None:
            self._faq_page = FaqWindow(self)
        self._faq_page._scroll_area.show()

    def display_about_page(self, event=None) -> None:
        """Callback for displaying about dialog."""
        if self._about_page is None:
            self._about_page = AboutWindow(self)
        self._about_page.show()

    def display_keybindings_page(self, event=None) -> None:
        """Callback for displaying keybindings dialog."""
        if self._keybindings_page is None:
            self._keybindings_page = KeybindingsWindow(self)
        self._keybindings_page.show()


def start_editor(sfg: Optional[SFG] = None) -> Dict[str, SFG]:
    """
    Start the SFG editor.

    Parameters
    ----------
    sfg : SFG, optional
        The SFG to start the editor with.

    Returns
    -------
    dict
        All SFGs currently in the editor.
    """
    if not QApplication.instance():
        app = QApplication(sys.argv)
    else:
        app = QApplication.instance()
    window = SFGMainWindow()
    if sfg:
        window._load_sfg(sfg)
    window.show()
    app.exec_()
    return window._sfg_dict


if __name__ == "__main__":
    start_editor()