import logging
import os
import re
import tempfile
import zipfile
from functools import partial
from importlib.metadata import version
from io import BytesIO
from pathlib import Path

import petab.v1 as petab
import qtawesome as qta
import yaml
from petab.versions import get_major_version
from PySide6.QtCore import QSettings, Qt, QTimer, QUrl
from PySide6.QtGui import (
    QAction,
    QDesktopServices,
    QKeySequence,
    QUndoStack,
)
from PySide6.QtWidgets import (
    QApplication,
    QCheckBox,
    QFileDialog,
    QHBoxLayout,
    QInputDialog,
    QLineEdit,
    QMessageBox,
    QTableView,
    QToolButton,
    QWhatsThis,
    QWidget,
)

from ..C import APP_NAME, REPO_URL
from ..models import PEtabModel, SbmlViewerModel
from ..settings_manager import SettingsDialog, settings_manager
from ..utils import (
    CaptureLogHandler,
    get_selected,
    process_file,
)
from ..views import TaskBar
from ..views.other_views import NextStepsPanel
from .logger_controller import LoggerController
from .sbml_controller import SbmlController
from .table_controllers import (
    ConditionController,
    MeasurementController,
    ObservableController,
    ParameterController,
    VisualizationController,
)
from .utils import (
    RecentFilesManager,
    _WhatsThisClickHelp,
    filtered_error,
    prompt_overwrite_or_append,
)


class MainController:
    """Main controller class.

    Handles the communication between controllers. Handles general tasks.
    Mother controller to all other controllers. One controller to rule them
    all.
    """

    def __init__(self, view, model: PEtabModel):
        """Initialize the main controller.

        Parameters
        ----------
        view: MainWindow
            The main window.
        model: PEtabModel
            The PEtab model.
        """
        self.undo_stack = QUndoStack()
        self.task_bar = None
        self.view = view
        self.model = model
        self.logger = LoggerController(view.logger_views)
        # CONTROLLERS
        self.measurement_controller = MeasurementController(
            self.view.measurement_dock,
            self.model.measurement,
            self.logger,
            self.undo_stack,
            self,
        )
        self.observable_controller = ObservableController(
            self.view.observable_dock,
            self.model.observable,
            self.logger,
            self.undo_stack,
            self,
        )
        self.parameter_controller = ParameterController(
            self.view.parameter_dock,
            self.model.parameter,
            self.logger,
            self.undo_stack,
            self,
        )
        self.condition_controller = ConditionController(
            self.view.condition_dock,
            self.model.condition,
            self.logger,
            self.undo_stack,
            self,
        )
        self.visualization_controller = VisualizationController(
            self.view.visualization_dock,
            self.model.visualization,
            self.logger,
            self.undo_stack,
            self,
        )
        self.simulation_controller = MeasurementController(
            self.view.simulation_dock,
            self.model.simulation,
            self.logger,
            self.undo_stack,
            self,
        )
        self.sbml_controller = SbmlController(
            self.view.sbml_viewer, self.model.sbml, self.logger, self
        )
        self.controllers = [
            self.measurement_controller,
            self.observable_controller,
            self.parameter_controller,
            self.condition_controller,
            self.sbml_controller,
            self.visualization_controller,
            self.simulation_controller,
        ]
        # Recent Files
        self.recent_files_manager = RecentFilesManager(max_files=10)
        # Checkbox states for Find + Replace
        self.petab_checkbox_states = {
            "measurement": False,
            "observable": False,
            "parameter": False,
            "condition": False,
            "visualization": False,
            "simulation": False,
        }
        self.sbml_checkbox_states = {"sbml": False, "antimony": False}
        self.unsaved_changes = False
        # Next Steps Panel
        self.next_steps_panel = NextStepsPanel(self.view)
        self.next_steps_panel.dont_show_again_changed.connect(
            self._handle_next_steps_dont_show_again
        )
        self.filter = QLineEdit()
        self.filter_active = {}  # Saves which tables the filter applies to
        self.actions = self.setup_actions()
        self.view.setup_toolbar(self.actions)

        self.plotter = None
        self.init_plotter()
        self.setup_connections()
        self.setup_task_bar()
        self.setup_context_menu()

    @property
    def window_title(self):
        """Return the window title based on the model."""
        if isinstance(self.model.sbml, SbmlViewerModel):
            return self.model.sbml.model_id
        return APP_NAME

    def setup_context_menu(self):
        """Sets up context menus for the tables."""
        for controller in self.controllers:
            if controller == self.sbml_controller:
                continue
            controller.setup_context_menu(self.actions)

    def setup_task_bar(self):
        """Create shortcuts for the main window."""
        self.view.task_bar = TaskBar(self.view, self.actions)
        self.task_bar = self.view.task_bar

    # CONNECTIONS
    def setup_connections(self):
        """Setup connections.

        Sets all connections that communicate from one different
        Models/Views/Controllers to another. Also sets general connections.
        """
        # Rename Observable
        self.observable_controller.observable_2be_renamed.connect(
            partial(
                self.measurement_controller.rename_value,
                column_names="observableId",
            )
        )
        self.observable_controller.observable_2be_renamed.connect(
            partial(
                self.visualization_controller.rename_value,
                column_names="yValues",
            )
        )
        # Maybe TODO: add renaming dataset id?
        # Rename Condition
        self.condition_controller.condition_2be_renamed.connect(
            partial(
                self.measurement_controller.rename_value,
                column_names=[
                    "simulationConditionId",
                    "preequilibrationConditionId",
                ],
            )
        )
        # Plotting Disable Temporarily
        for controller in self.controllers:
            if controller == self.sbml_controller:
                continue
            controller.model.plotting_needs_break.connect(
                self.plotter.disable_plotting
            )
        # Add new condition or observable
        self.model.measurement.relevant_id_changed.connect(
            lambda x, y, z: self.observable_controller.maybe_add_observable(
                x, y
            )
            if z == "observable"
            else self.condition_controller.maybe_add_condition(x, y)
            if z == "condition"
            else None
        )
        # Maybe Move to a Plot Model
        self.view.measurement_dock.table_view.selectionModel().selectionChanged.connect(
            self._on_table_selection_changed
        )
        self.view.simulation_dock.table_view.selectionModel().selectionChanged.connect(
            self._on_simulation_selection_changed
        )
        # Unsaved Changes
        self.model.measurement.something_changed.connect(
            self.unsaved_changes_change
        )
        self.model.observable.something_changed.connect(
            self.unsaved_changes_change
        )
        self.model.parameter.something_changed.connect(
            self.unsaved_changes_change
        )
        self.model.condition.something_changed.connect(
            self.unsaved_changes_change
        )
        self.model.visualization.something_changed.connect(
            self.unsaved_changes_change
        )
        self.model.simulation.something_changed.connect(
            self.unsaved_changes_change
        )
        self.model.sbml.something_changed.connect(self.unsaved_changes_change)
        # Visibility
        self.sync_visibility_with_actions()
        # Recent Files
        self.recent_files_manager.open_file.connect(
            partial(self.open_file, mode="overwrite")
        )
        # Settings logging
        settings_manager.new_log_message.connect(self.logger.log_message)
        # Update Parameter SBML Model
        self.sbml_controller.overwritten_model.connect(
            self.parameter_controller.update_handler_sbml
        )
        # Plotting update. Regulated through a Timer
        self._plot_update_timer = QTimer()
        self._plot_update_timer.setSingleShot(True)
        self._plot_update_timer.setInterval(0)
        self._plot_update_timer.timeout.connect(self.init_plotter)
        for controller in [
            self.measurement_controller,
            self.condition_controller,
            self.visualization_controller,
            self.simulation_controller,
        ]:
            controller.overwritten_df.connect(self._schedule_plot_update)

    def setup_actions(self):
        """Setup actions for the main controller."""
        actions = {
            "close": QAction(qta.icon("mdi6.close"), "&Close", self.view)
        }
        # Close
        actions["close"].setShortcut(QKeySequence.Close)
        actions["close"].triggered.connect(self.view.close)
        # New File
        actions["new"] = QAction(
            qta.icon("mdi6.file-document"), "&New", self.view
        )
        actions["new"].setShortcut(QKeySequence.New)
        actions["new"].triggered.connect(self.new_file)
        # Open File
        actions["open"] = QAction(
            qta.icon("mdi6.folder-open"), "&Open...", self.view
        )
        actions["open"].setShortcut(QKeySequence.Open)
        actions["open"].triggered.connect(
            partial(self.open_file, mode="overwrite")
        )
        # Add File
        actions["add"] = QAction(qta.icon("mdi6.table-plus"), "Add", self.view)
        actions["add"].setShortcut("Ctrl+Shift+O")
        actions["add"].triggered.connect(
            partial(self.open_file, mode="append")
        )
        # Load Examples
        actions["load_example_boehm"] = QAction(
            qta.icon("mdi6.book-open-page-variant"),
            "Load Example: Boehm",
            self.view,
        )
        actions["load_example_boehm"].triggered.connect(
            partial(self.load_example, "Boehm")
        )
        actions["load_example_simple"] = QAction(
            qta.icon("mdi6.book-open-page-variant"),
            "Load Example: Simple Conversion",
            self.view,
        )
        actions["load_example_simple"].triggered.connect(
            partial(self.load_example, "Simple_Conversion")
        )
        # Save
        actions["save"] = QAction(
            qta.icon("mdi6.content-save-all"), "&Save As...", self.view
        )
        actions["save"].setShortcut(QKeySequence.Save)
        actions["save"].triggered.connect(self.save_model)
        actions["save_single_table"] = QAction(
            qta.icon("mdi6.table-arrow-down"), "Save This Table", self.view
        )
        actions["save_single_table"].triggered.connect(self.save_single_table)
        actions["save_sbml"] = QAction(
            qta.icon("mdi6.file-code"), "Export SBML Model", self.view
        )
        actions["save_sbml"].triggered.connect(self.save_sbml_model)
        # Find + Replace
        actions["find"] = QAction(qta.icon("mdi6.magnify"), "Find", self.view)
        actions["find"].setShortcut(QKeySequence.Find)
        actions["find"].triggered.connect(self.find)
        actions["find+replace"] = QAction(
            qta.icon("mdi6.find-replace"), "Find/Replace", self.view
        )
        actions["find+replace"].setShortcut(QKeySequence.Replace)
        actions["find+replace"].triggered.connect(self.replace)
        # Copy / Paste
        actions["copy"] = QAction(
            qta.icon("mdi6.content-copy"), "Copy", self.view
        )
        actions["copy"].setShortcut(QKeySequence.Copy)
        actions["copy"].triggered.connect(self.copy_to_clipboard)
        actions["paste"] = QAction(
            qta.icon("mdi6.content-paste"), "Paste", self.view
        )
        actions["paste"].setShortcut(QKeySequence.Paste)
        actions["paste"].triggered.connect(self.paste_from_clipboard)
        actions["cut"] = QAction(
            qta.icon("mdi6.content-cut"), "&Cut", self.view
        )
        actions["cut"].setShortcut(QKeySequence.Cut)
        actions["cut"].triggered.connect(self.cut)
        # add/delete row
        actions["add_row"] = QAction(
            qta.icon("mdi6.table-row-plus-after"), "Add Row", self.view
        )
        actions["add_row"].triggered.connect(self.add_row)
        actions["delete_row"] = QAction(
            qta.icon("mdi6.table-row-remove"), "Delete Row(s)", self.view
        )
        actions["delete_row"].triggered.connect(self.delete_rows)
        # add/delete column
        actions["add_column"] = QAction(
            qta.icon("mdi6.table-column-plus-after"),
            "Add Column...",
            self.view,
        )
        actions["add_column"].triggered.connect(self.add_column)
        actions["delete_column"] = QAction(
            qta.icon("mdi6.table-column-remove"), "Delete Column(s)", self.view
        )
        actions["delete_column"].triggered.connect(self.delete_column)
        # check petab model
        actions["check_petab"] = QAction(
            qta.icon("mdi6.checkbox-multiple-marked-circle-outline"),
            "Check PEtab",
            self.view,
        )
        actions["check_petab"].triggered.connect(self.check_model)
        actions["reset_model"] = QAction(
            qta.icon("mdi6.restore"), "Reset SBML Model", self.view
        )
        actions["reset_model"].triggered.connect(
            self.sbml_controller.reset_to_original_model
        )
        # Recent Files
        actions["recent_files"] = self.recent_files_manager.tool_bar_menu

        # simulate action
        actions["simulate"] = QAction(
            qta.icon("mdi6.play"), "Simulate", self.view
        )
        actions["simulate"].triggered.connect(self.simulate)

        # Filter widget
        filter_widget = QWidget()
        filter_layout = QHBoxLayout()
        filter_layout.setContentsMargins(0, 0, 0, 0)
        filter_widget.setLayout(filter_layout)
        self.filter_input = QLineEdit()
        self.filter_input.setPlaceholderText("Filter...")
        filter_layout.addWidget(self.filter_input)
        for table_n, table_name in zip(
            ["m", "p", "o", "c", "v", "s"],
            [
                "measurement",
                "parameter",
                "observable",
                "condition",
                "visualization",
                "simulation",
            ],
            strict=False,
        ):
            tool_button = QToolButton()
            icon = qta.icon(
                f"mdi6.alpha-{table_n}",
                "mdi6.filter",
                options=[
                    {"scale_factor": 1.5, "offset": (-0.2, -0.2)},
                    {"off": "mdi6.filter-off", "offset": (0.3, 0.3)},
                ],
            )
            tool_button.setIcon(icon)
            tool_button.setCheckable(True)
            tool_button.setChecked(True)
            tool_button.setToolTip(f"Filter for {table_name} table")
            filter_layout.addWidget(tool_button)
            self.filter_active[table_name] = tool_button
            self.filter_active[table_name].toggled.connect(self.filter_table)
        actions["filter_widget"] = filter_widget
        self.filter_input.textChanged.connect(self.filter_table)

        # show/hide elements
        for element in [
            "measurement",
            "observable",
            "parameter",
            "condition",
            "visualization",
            "simulation",
        ]:
            actions[f"show_{element}"] = QAction(
                f"{element.capitalize()} Table", self.view
            )
            actions[f"show_{element}"].setCheckable(True)
            actions[f"show_{element}"].setChecked(True)
        actions["show_logger"] = QAction("Info", self.view)
        actions["show_logger"].setCheckable(True)
        actions["show_logger"].setChecked(True)
        actions["show_plot"] = QAction("Data Plot", self.view)
        actions["show_plot"].setCheckable(True)
        actions["show_plot"].setChecked(True)
        actions["show_sbml_editor"] = QAction("SBML Editor", self.view)
        actions["show_sbml_editor"].setCheckable(True)
        actions["show_sbml_editor"].setChecked(True)

        # What's This action
        actions["whats_this"] = QAction(
            qta.icon("mdi6.help-circle"), "Enter Help Mode", self.view
        )
        actions["whats_this"].setCheckable(True)
        actions["whats_this"].setShortcut("Shift+F1")
        self._whats_this_filter = _WhatsThisClickHelp(actions["whats_this"])
        actions["whats_this"].toggled.connect(self._toggle_whats_this_mode)

        # About action
        actions["about"] = QAction(
            qta.icon("mdi6.information"), "&About", self.view
        )
        actions["about"].triggered.connect(self.about)

        # connect actions
        actions["reset_view"] = QAction(
            qta.icon("mdi6.view-grid-plus"), "Reset View", self.view
        )
        actions["reset_view"].triggered.connect(self.view.default_view)
        # Clear Log
        actions["clear_log"] = QAction(
            qta.icon("mdi6.delete"), "Clear Log", self.view
        )
        actions["clear_log"].triggered.connect(self.logger.clear_log)
        # Settings
        actions["settings"] = QAction(
            qta.icon("mdi6.cog"), "Settings", self.view
        )
        actions["settings"].triggered.connect(self.open_settings)

        # Opening the PEtab documentation
        actions["open_documentation"] = QAction(
            qta.icon("mdi6.web"), "View PEtab Documentation", self.view
        )
        actions["open_documentation"].triggered.connect(
            lambda: QDesktopServices.openUrl(
                QUrl(
                    "https://petab.readthedocs.io/en/latest/v1/"
                    "documentation_data_format.html"
                )
            )
        )

        # Show next steps panel action
        actions["next_steps"] = QAction(
            qta.icon("mdi6.lightbulb-on"), "Possible next steps...", self.view
        )
        actions["next_steps"].triggered.connect(self._show_next_steps_panel)

        # Undo / Redo
        actions["undo"] = QAction(qta.icon("mdi6.undo"), "&Undo", self.view)
        actions["undo"].setShortcut(QKeySequence.Undo)
        actions["undo"].triggered.connect(self.undo_stack.undo)
        actions["undo"].setEnabled(self.undo_stack.canUndo())
        self.undo_stack.canUndoChanged.connect(actions["undo"].setEnabled)
        actions["redo"] = QAction(qta.icon("mdi6.redo"), "&Redo", self.view)
        actions["redo"].setShortcut(QKeySequence.Redo)
        actions["redo"].triggered.connect(self.undo_stack.redo)
        actions["redo"].setEnabled(self.undo_stack.canRedo())
        self.undo_stack.canRedoChanged.connect(actions["redo"].setEnabled)
        # Clear cells
        actions["clear_cells"] = QAction(
            qta.icon("mdi6.delete"), "&Clear Cells", self.view
        )
        actions["clear_cells"].setShortcuts(
            [QKeySequence.Delete, QKeySequence.Backspace]
        )
        actions["clear_cells"].triggered.connect(self.clear_cells)
        return actions

    def sync_visibility_with_actions(self):
        """Sync dock visibility and QAction states in both directions."""
        dock_map = {
            "measurement": self.view.measurement_dock,
            "observable": self.view.observable_dock,
            "parameter": self.view.parameter_dock,
            "condition": self.view.condition_dock,
            "logger": self.view.logger_dock,
            "plot": self.view.plot_dock,
            "visualization": self.view.visualization_dock,
            "simulation": self.view.simulation_dock,
        }

        for key, dock in dock_map.items():
            action = self.actions[f"show_{key}"]

            # Initial sync: block signal to avoid triggering unwanted
            # visibility changes
            was_blocked = action.blockSignals(True)
            action.setChecked(dock.isVisible())
            action.blockSignals(was_blocked)

            # Connect QAction ↔ DockWidget syncing
            action.toggled.connect(dock.setVisible)
            dock.visibilityChanged.connect(action.setChecked)

        # Connect SBML editor visibility toggle
        sbml_action = self.actions["show_sbml_editor"]
        sbml_widget = self.view.sbml_viewer.sbml_widget

        # Store action reference in view for context menus
        self.view.sbml_viewer.sbml_toggle_action = sbml_action
        self.view.sbml_viewer.save_sbml_action = self.actions["save_sbml"]

        # Connect menu action to widget visibility
        sbml_action.toggled.connect(sbml_widget.setVisible)

    def save_model(self):
        options = QFileDialog.Options()
        file_name, filtering = QFileDialog.getSaveFileName(
            self.view,
            "Save Project",
            "",
            "COMBINE Archive (*.omex);;Zip Files (*.zip);;Folder",
            options=options,
        )
        if not file_name:
            return False

        if filtering == "COMBINE Archive (*.omex)":
            self.model.save_as_omex(file_name)
        elif filtering == "Folder":
            if file_name.endswith("."):
                file_name = file_name[:-1]
            target = Path(file_name)
            target.mkdir(parents=True, exist_ok=True)
            self.model.save(str(target))
            file_name = str(target)
        else:
            if not file_name.endswith(".zip"):
                file_name += ".zip"

            # Create a temporary directory to save the model's files
            with tempfile.TemporaryDirectory() as temp_dir:
                self.model.save(temp_dir)

                # Create a bytes buffer to hold the zip file in memory
                buffer = BytesIO()
                with zipfile.ZipFile(buffer, "w") as zip_file:
                    # Add files to zip archive
                    for root, _, files in os.walk(temp_dir):
                        for file in files:
                            file_path = os.path.join(root, file)
                            with open(file_path, "rb") as f:
                                zip_file.writestr(file, f.read())
                with open(file_name, "wb") as f:
                    f.write(buffer.getvalue())

        QMessageBox.information(
            self.view,
            "Save Project",
            f"Project saved successfully to {file_name}",
        )

        # Show next steps panel if not disabled
        dont_show = settings_manager.get_value(
            "next_steps/dont_show_again", False, bool
        )
        if not dont_show:
            self.next_steps_panel.show_panel()

        return True

    def save_single_table(self):
        """Save the currently active table to a tsv-file."""
        active_controller = self.active_controller()
        if not active_controller:
            QMessageBox.warning(
                self.view,
                "Save Table",
                "No active table to save.",
            )
            return None
        file_name, _ = QFileDialog.getSaveFileName(
            self.view,
            "Save Table (as *.tsv)",
            f"{active_controller.model.table_type}.tsv",
            "TSV Files (*.tsv)",
        )
        if not file_name:
            return False
        active_controller.save_table(file_name)
        return True

    def save_sbml_model(self):
        """Export the SBML model to an XML file."""
        if not self.model.sbml or not self.model.sbml.sbml_text:
            QMessageBox.warning(
                self.view,
                "Export SBML Model",
                "No SBML model to export.",
            )
            return False

        file_name, _ = QFileDialog.getSaveFileName(
            self.view,
            "Export SBML Model",
            f"{self.model.sbml.model_id}.xml",
            "SBML Files (*.xml *.sbml);;All Files (*)",
        )
        if not file_name:
            return False

        try:
            with open(file_name, "w") as f:
                f.write(self.model.sbml.sbml_text)
                self.logger.log_message(
                    "SBML model exported successfully to file.", color="green"
                )
            return True
        except Exception as e:
            QMessageBox.critical(
                self.view,
                "Export SBML Model",
                f"Failed to export SBML model: {e}",
            )
            return False

    def handle_selection_changed(self):
        """Update the plot when selection in the measurement table changes."""
        self.update_plot()

    def handle_data_changed(self, top_left, bottom_right, roles):
        """Update the plot when the data in the measurement table changes."""
        if not roles or Qt.DisplayRole in roles:
            self.update_plot()

    def update_plot(self):
        """Update the plot with the selected measurement data.

        Extracts the selected data points from the measurement table and
        updates the plot visualization with this data.
        """
        selection_model = (
            self.view.measurement_dock.table_view.selectionModel()
        )
        indexes = selection_model.selectedIndexes()
        if not indexes:
            return

        selected_points = {}
        for index in indexes:
            if index.row() == self.model.measurement.get_df().shape[0]:
                continue
            row = index.row()
            observable_id = self.model.measurement._data_frame.iloc[row][
                "observableId"
            ]
            if observable_id not in selected_points:
                selected_points[observable_id] = []
            selected_points[observable_id].append(
                {
                    "x": self.model.measurement._data_frame.iloc[row]["time"],
                    "y": self.model.measurement._data_frame.iloc[row][
                        "measurement"
                    ],
                }
            )
        if selected_points == {}:
            return

        measurement_data = self.model.measurement._data_frame
        plot_data = {"all_data": [], "selected_points": selected_points}
        for observable_id in selected_points:
            observable_data = measurement_data[
                measurement_data["observableId"] == observable_id
            ]
            plot_data["all_data"].append(
                {
                    "observable_id": observable_id,
                    "x": observable_data["time"].tolist(),
                    "y": observable_data["measurement"].tolist(),
                }
            )

        self.view.plot_dock.update_visualization(plot_data)

    def open_file(self, file_path=None, mode=None):
        """Determines appropriate course of action for a given file.

        Course of action depends on file extension, separator and header
        structure. Opens the file in the appropriate controller.
        """
        if not file_path:
            file_path, _ = QFileDialog.getOpenFileName(
                self.view,
                "Open File",
                "",
                "All supported (*.yaml *.yml *.xml *.sbml *.tsv *.csv *.txt "
                "*.omex);;"
                "PEtab Problems (*.yaml *.yml);;SBML Files (*.xml *.sbml);;"
                "PEtab Tables or Data Matrix (*.tsv *.csv *.txt);;"
                "COMBINE Archive (*.omex);;"
                "All files (*)",
            )
        if not file_path:
            return
        # handle file appropriately
        actionable, sep = process_file(file_path, self.logger)
        if actionable in ["yaml", "omex"] and mode == "append":
            self.logger.log_message(
                f"Append mode is not supported for *.{actionable} files.",
                color="red",
            )
            return
        if actionable in ["sbml"] and mode == "append":
            self.logger.log_message(
                "Append mode is not supported for SBML models.",
                color="orange",
            )
            return
        if not actionable:
            return
        if mode is None:
            if actionable in ["yaml", "sbml", "omex"]:
                mode = "overwrite"
            else:
                mode = prompt_overwrite_or_append(self)
        if mode is None:
            return
        self.recent_files_manager.add_file(file_path)
        self._open_file(actionable, file_path, sep, mode)

    def _open_file(self, actionable, file_path, sep, mode):
        """Overwrites the File in the appropriate controller.

        Actionable dictates which controller to use.
        """
        if actionable == "yaml":
            self.open_yaml_and_load_files(file_path)
        elif actionable == "omex":
            self.open_omex_and_load_files(file_path)
        elif actionable == "sbml":
            self.sbml_controller.overwrite_sbml(file_path)
        elif actionable == "measurement":
            self.measurement_controller.open_table(file_path, sep, mode)
        elif actionable == "observable":
            self.observable_controller.open_table(file_path, sep, mode)
        elif actionable == "parameter":
            self.parameter_controller.open_table(file_path, sep, mode)
        elif actionable == "condition":
            self.condition_controller.open_table(file_path, sep, mode)
        elif actionable == "visualization":
            self.visualization_controller.open_table(file_path, sep, mode)
        elif actionable == "simulation":
            self.simulation_controller.open_table(file_path, sep, mode)
        elif actionable == "data_matrix":
            self.measurement_controller.process_data_matrix_file(
                file_path, mode, sep
            )

    def _validate_yaml_structure(self, yaml_content):
        """Validate PEtab YAML structure before attempting to load files.

        Parameters
        ----------
        yaml_content : dict
            The parsed YAML content.

        Returns
        -------
        tuple
            (is_valid: bool, errors: list[str])
        """
        errors = []

        # Check format version
        if "format_version" not in yaml_content:
            errors.append("Missing 'format_version' field")

        # Check problems array
        if "problems" not in yaml_content:
            errors.append("Missing 'problems' field")
            return False, errors

        if (
            not isinstance(yaml_content["problems"], list)
            or not yaml_content["problems"]
        ):
            errors.append("'problems' must be a non-empty list")
            return False, errors

        problem = yaml_content["problems"][0]

        # Optional but recommended fields
        if (
            "visualization_files" not in problem
            or not problem["visualization_files"]
        ):
            errors.append("Warning: No visualization_files specified")

        # Required fields in problem
        for field in [
            "sbml_files",
            "measurement_files",
            "observable_files",
            "condition_files",
        ]:
            if field not in problem or not problem[field]:
                errors.append("Problem must contain at least one SBML file")

        # Check parameter_file (at root level)
        if "parameter_file" not in yaml_content:
            errors.append("Missing 'parameter_file' at root level")

        return len([e for e in errors if "Warning" not in e]) == 0, errors

    def _validate_files_exist(self, yaml_dir, yaml_content):
        """Validate that all files referenced in YAML exist.

        Parameters
        ----------
        yaml_dir : Path
            The directory containing the YAML file.
        yaml_content : dict
            The parsed YAML content.

        Returns
        -------
        tuple
            (all_exist: bool, missing_files: list[str])
        """
        missing_files = []
        problem = yaml_content["problems"][0]

        # Check SBML files
        for sbml_file in problem.get("sbml_files", []):
            if not (yaml_dir / sbml_file).exists():
                missing_files.append(str(sbml_file))

        # Check measurement files
        for meas_file in problem.get("measurement_files", []):
            if not (yaml_dir / meas_file).exists():
                missing_files.append(str(meas_file))

        # Check observable files
        for obs_file in problem.get("observable_files", []):
            if not (yaml_dir / obs_file).exists():
                missing_files.append(str(obs_file))

        # Check condition files
        for cond_file in problem.get("condition_files", []):
            if not (yaml_dir / cond_file).exists():
                missing_files.append(str(cond_file))

        # Check parameter file
        if "parameter_file" in yaml_content:
            param_file = yaml_content["parameter_file"]
            if not (yaml_dir / param_file).exists():
                missing_files.append(str(param_file))

        # Check visualization files (optional)
        for vis_file in problem.get("visualization_files", []):
            if not (yaml_dir / vis_file).exists():
                missing_files.append(str(vis_file))

        return len(missing_files) == 0, missing_files

    def _load_file_list(self, controller, file_list, file_type, yaml_dir):
        """Load multiple files for a given controller.

        Parameters
        ----------
        controller : object
            The controller to load files into (e.g., measurement_controller).
        file_list : list[str]
            List of file names to load.
        file_type : str
            Human-readable file type for logging (e.g., "measurement").
        yaml_dir : Path
            The directory containing the YAML and data files.
        """
        for i, file_name in enumerate(file_list):
            file_mode = "overwrite" if i == 0 else "append"
            controller.open_table(yaml_dir / file_name, mode=file_mode)
            self.logger.log_message(
                f"Loaded {file_type} file ({i + 1}/{len(file_list)}): {file_name}",
                color="blue",
            )

    def open_yaml_and_load_files(self, yaml_path=None, mode="overwrite"):
        """Open files from a YAML configuration.

        Opens a dialog to upload yaml file. Creates a PEtab problem and
        overwrites the current PEtab model with the new problem.
        """
        if not yaml_path:
            yaml_path, _ = QFileDialog.getOpenFileName(
                self.view, "Open YAML File", "", "YAML Files (*.yaml *.yml)"
            )
        if not yaml_path:
            return
        try:
            for controller in self.controllers:
                if controller == self.sbml_controller:
                    continue
                controller.release_completers()

            # Load the YAML content
            with open(yaml_path, encoding="utf-8") as file:
                yaml_content = yaml.safe_load(file)

            # Validate PEtab version
            if (major := get_major_version(yaml_content)) != 1:
                raise ValueError(
                    f"Only PEtab v1 problems are currently supported. "
                    f"Detected version: {major}.x."
                )

            # Validate YAML structure
            is_valid, errors = self._validate_yaml_structure(yaml_content)
            if not is_valid:
                error_msg = "Invalid YAML structure:\n  - " + "\n  - ".join(
                    [e for e in errors if "Warning" not in e]
                )
                self.logger.log_message(error_msg, color="red")
                QMessageBox.critical(
                    self.view, "Invalid PEtab YAML", error_msg
                )
                return

            # Log warnings but continue
            warnings = [e for e in errors if "Warning" in e]
            for warning in warnings:
                self.logger.log_message(warning, color="orange")

            # Resolve the directory of the YAML file to handle relative paths
            yaml_dir = Path(yaml_path).parent

            # Validate file existence
            all_exist, missing_files = self._validate_files_exist(
                yaml_dir, yaml_content
            )
            if not all_exist:
                error_msg = (
                    "The following files referenced in the YAML are missing:\n  - "
                    + "\n  - ".join(missing_files)
                )
                self.logger.log_message(error_msg, color="red")
                QMessageBox.critical(self.view, "Missing Files", error_msg)
                return

            problem = yaml_content["problems"][0]

            # Load SBML model (required, single file)
            sbml_files = problem.get("sbml_files", [])
            if sbml_files:
                sbml_file_path = yaml_dir / sbml_files[0]
                self.sbml_controller.overwrite_sbml(sbml_file_path)
                self.logger.log_message(
                    f"Loaded SBML file: {sbml_files[0]}", color="blue"
                )

            # Load measurement files (multiple allowed)
            measurement_files = problem.get("measurement_files", [])
            if measurement_files:
                self._load_file_list(
                    self.measurement_controller,
                    measurement_files,
                    "measurement",
                    yaml_dir,
                )

            # Load observable files (multiple allowed)
            observable_files = problem.get("observable_files", [])
            if observable_files:
                self._load_file_list(
                    self.observable_controller,
                    observable_files,
                    "observable",
                    yaml_dir,
                )

            # Load condition files (multiple allowed)
            condition_files = problem.get("condition_files", [])
            if condition_files:
                self._load_file_list(
                    self.condition_controller,
                    condition_files,
                    "condition",
                    yaml_dir,
                )

            # Load parameter file (required, single file at root level)
            if "parameter_file" in yaml_content:
                param_file = yaml_content["parameter_file"]
                self.parameter_controller.open_table(yaml_dir / param_file)
                self.logger.log_message(
                    f"Loaded parameter file: {param_file}", color="blue"
                )

            # Load visualization files (optional, multiple allowed)
            visualization_files = problem.get("visualization_files", [])
            if visualization_files:
                self._load_file_list(
                    self.visualization_controller,
                    visualization_files,
                    "visualization",
                    yaml_dir,
                )
            else:
                self.visualization_controller.clear_table()

            # Simulation should be cleared
            self.simulation_controller.clear_table()

            self.logger.log_message(
                "All files opened successfully from the YAML configuration.",
                color="green",
            )
            self.check_model()

            # Rerun the completers
            for controller in self.controllers:
                if controller == self.sbml_controller:
                    continue
                controller.setup_completers()
            self.unsaved_changes_change(False)

        except FileNotFoundError as e:
            error_msg = f"File not found: {e.filename if hasattr(e, 'filename') else str(e)}"
            self.logger.log_message(error_msg, color="red")
            QMessageBox.warning(self.view, "File Not Found", error_msg)
        except KeyError as e:
            error_msg = f"Missing required field in YAML: {str(e)}"
            self.logger.log_message(error_msg, color="red")
            QMessageBox.warning(self.view, "Invalid YAML", error_msg)
        except ValueError as e:
            error_msg = f"Invalid YAML structure: {str(e)}"
            self.logger.log_message(error_msg, color="red")
            QMessageBox.warning(self.view, "Invalid YAML", error_msg)
        except yaml.YAMLError as e:
            error_msg = f"YAML parsing error: {str(e)}"
            self.logger.log_message(error_msg, color="red")
            QMessageBox.warning(self.view, "YAML Parsing Error", error_msg)
        except Exception as e:
            error_msg = f"Unexpected error loading YAML: {str(e)}"
            self.logger.log_message(error_msg, color="red")
            logging.exception("Full traceback for YAML loading error:")
            QMessageBox.critical(self.view, "Error", error_msg)

    def open_omex_and_load_files(self, omex_path=None):
        """Opens a petab problem from a COMBINE Archive."""
        if not omex_path:
            omex_path, _ = QFileDialog.getOpenFileName(
                self.view,
                "Open COMBINE Archive",
                "",
                "COMBINE Archive (*.omex);;All files (*)",
            )
        if not omex_path:
            return
        try:
            combine_archive = petab.problem.Problem.from_combine(omex_path)
        except Exception as e:
            self.logger.log_message(
                f"Failed to open files from OMEX: {str(e)}", color="red"
            )
            return
        # overwrite current model
        self.measurement_controller.overwrite_df(
            combine_archive.measurement_df
        )
        self.observable_controller.overwrite_df(combine_archive.observable_df)
        self.condition_controller.overwrite_df(combine_archive.condition_df)
        self.parameter_controller.overwrite_df(combine_archive.parameter_df)
        self.visualization_controller.overwrite_df(
            combine_archive.visualization_df
        )
        self.sbml_controller.overwrite_sbml(sbml_model=combine_archive.model)

    def new_file(self):
        """Empty all tables. In case of unsaved changes, ask to save."""
        if self.unsaved_changes:
            reply = QMessageBox.question(
                self.view,
                "Unsaved Changes",
                "You have unsaved changes. Do you want to save them?",
                QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel,
                QMessageBox.Save,
            )
            if reply == QMessageBox.Save:
                self.save_model()
        for controller in self.controllers:
            if controller == self.sbml_controller:
                controller.clear_model()
                continue
            controller.clear_table()
        self.view.plot_dock.plot_it()
        self.unsaved_changes_change(False)

    def load_example(self, example_name):
        """Load an internal example PEtab problem.

        Parameters
        ----------
        example_name : str
            Name of the example subdirectory (e.g., "Boehm", "Simple_Conversion").

        Finds and loads the example dataset from the package directory.
        No internet connection required - the example is bundled with the package.
        """
        try:
            # Use importlib.resources to access packaged example files
            from importlib.resources import as_file, files

            example_files = files("petab_gui.example")

            # Check if the example package exists
            if not example_files.is_dir():
                error_msg = (
                    "Could not find the example dataset. "
                    "The example folder may not be properly installed."
                )
                self.logger.log_message(error_msg, color="red")
                QMessageBox.warning(self.view, "Example Not Found", error_msg)
                return

            # Get the problem.yaml file path for the specified example
            yaml_file = example_files.joinpath(example_name, "problem.yaml")

            with as_file(yaml_file) as yaml_path:
                if not yaml_path.exists():
                    error_msg = f"Example '{example_name}' not found or problem.yaml file is missing."
                    self.logger.log_message(error_msg, color="red")
                    QMessageBox.warning(
                        self.view, "Example Invalid", error_msg
                    )
                    return

                # Load the example
                self.logger.log_message(
                    f"Loading '{example_name}' example dataset...",
                    color="blue",
                )
                self.open_yaml_and_load_files(str(yaml_path))

        except ModuleNotFoundError as e:
            error_msg = (
                "Example dataset not found. It may not be installed properly. "
                f"Error: {str(e)}"
            )
            self.logger.log_message(error_msg, color="red")
            QMessageBox.warning(self.view, "Example Not Found", error_msg)
        except Exception as e:
            error_msg = f"Failed to load example: {str(e)}"
            self.logger.log_message(error_msg, color="red")
            QMessageBox.critical(self.view, "Error Loading Example", error_msg)

    def check_model(self):
        """Check the consistency of the model. And log the results."""
        capture_handler = CaptureLogHandler()
        logger_lint = logging.getLogger("petab.v1.lint")
        logger_vis = logging.getLogger("petab.v1.visualize.lint")
        logger_lint.addHandler(capture_handler)
        logger_vis.addHandler(capture_handler)

        try:
            # Run the consistency check
            failed = self.model.test_consistency()

            # Process captured logs
            if capture_handler.records:
                captured_output = "<br>&nbsp;&nbsp;&nbsp;&nbsp;".join(
                    capture_handler.get_formatted_messages()
                )
                self.logger.log_message(
                    f"Captured petab lint logs:<br>"
                    f"&nbsp;&nbsp;&nbsp;&nbsp;{captured_output}",
                    color="purple",
                )

            # Log the consistency check result
            if not failed:
                self.logger.log_message(
                    "PEtab problem has no errors.", color="green"
                )
                for model in self.model.pandas_models.values():
                    model.reset_invalid_cells()
            else:
                self.logger.log_message(
                    "PEtab problem has errors.", color="red"
                )
        except Exception as e:
            msg = f"PEtab linter failed at some point: {filtered_error(e)}"
            self.logger.log_message(msg, color="red")
        finally:
            # Always remove the capture handler
            logger_lint.removeHandler(capture_handler)
            logger_vis.removeHandler(capture_handler)

    def unsaved_changes_change(self, unsaved_changes: bool):
        self.unsaved_changes = unsaved_changes
        if unsaved_changes:
            self.view.setWindowTitle(f"{self.window_title} - Unsaved Changes")
        else:
            self.view.setWindowTitle(self.window_title)

    def maybe_close(self):
        if not self.unsaved_changes:
            self.view.allow_close = True
            return
        reply = QMessageBox.question(
            self.view,
            "Unsaved Changes",
            "You have unsaved changes. Do you want to save them?",
            QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel,
            QMessageBox.Save,
        )
        if reply == QMessageBox.Save:
            saved = self.save_model()
            self.view.allow_close = saved
        elif reply == QMessageBox.Discard:
            self.view.allow_close = True
        else:
            self.view.allow_close = False
        if self.view.allow_close:
            app = QApplication.instance()
            if app and hasattr(self, "_whats_this_filter"):
                app.removeEventFilter(self._whats_this_filter)

    def active_widget(self):
        active_widget = self.view.tab_widget.currentWidget()
        if active_widget == self.view.data_tab:
            active_widget = self.view.data_tab.focusWidget()
        if active_widget and isinstance(active_widget, QTableView):
            return active_widget
        return None

    def active_controller(self):
        active_widget = self.active_widget()
        if active_widget == self.view.measurement_dock.table_view:
            return self.measurement_controller
        if active_widget == self.view.observable_dock.table_view:
            return self.observable_controller
        if active_widget == self.view.parameter_dock.table_view:
            return self.parameter_controller
        if active_widget == self.view.condition_dock.table_view:
            return self.condition_controller
        if active_widget == self.view.visualization_dock.table_view:
            return self.visualization_controller
        if active_widget == self.view.simulation_dock.table_view:
            return self.simulation_controller
        return None

    def delete_rows(self):
        controller = self.active_controller()
        if controller:
            controller.delete_row()

    def add_row(self):
        controller = self.active_controller()
        if controller:
            controller.add_row()

    def add_column(self):
        controller = self.active_controller()
        if controller:
            controller.add_column()

    def delete_column(self):
        controller = self.active_controller()
        if controller:
            controller.delete_column()

    def clear_cells(self):
        controller = self.active_controller()
        if controller:
            controller.clear_cells()

    def filter_table(self):
        """Filter the currently activated tables."""
        filter_text = self.filter_input.text()
        for table_name, tool_button in self.filter_active.items():
            if tool_button.isChecked():
                controller = getattr(self, f"{table_name}_controller")
                controller.filter_table(filter_text)
            else:
                controller = getattr(self, f"{table_name}_controller")
                controller.remove_filter()

    def copy_to_clipboard(self):
        controller = self.active_controller()
        if controller:
            controller.copy_to_clipboard()

    def paste_from_clipboard(self):
        controller = self.active_controller()
        if controller:
            controller.paste_from_clipboard()

    def cut(self):
        controller = self.active_controller()
        if controller:
            controller.copy_to_clipboard()
            controller.clear_cells()

    def open_settings(self):
        """Opens the settings Dialogue."""
        # retrieve all current columns from the tables
        table_columns = {
            "observable": self.observable_controller.get_columns(),
            "parameter": self.parameter_controller.get_columns(),
            "measurement": self.measurement_controller.get_columns(),
            "condition": self.condition_controller.get_columns(),
            "visualization": self.visualization_controller.get_columns(),
            "simulation": self.simulation_controller.get_columns(),
        }
        settings_dialog = SettingsDialog(table_columns, self.view)
        settings_dialog.exec()

    def find(self):
        """Create a find replace bar if it is non existent."""
        if self.view.find_replace_bar is None:
            self.view.create_find_replace_bar()
        self.view.toggle_find()

    def replace(self):
        """Create a find replace bar if it is non existent."""
        if self.view.find_replace_bar is None:
            self.view.create_find_replace_bar()
        self.view.toggle_replace()

    def init_plotter(self):
        """(Re-)initialize the plotter."""
        self.view.plot_dock.initialize(
            self.measurement_controller.proxy_model,
            self.simulation_controller.proxy_model,
            self.condition_controller.proxy_model,
            self.visualization_controller.proxy_model,
            self.model,
        )
        self.plotter = self.view.plot_dock
        self.plotter.highlighter.click_callback = self._on_plot_point_clicked

    def _on_plot_point_clicked(self, x, y, label, data_type):
        # Extract observable ID from label, if formatted like 'obsId (label)'
        proxy = self.measurement_controller.proxy_model
        view = self.measurement_controller.view.table_view
        if data_type == "simulation":
            proxy = self.simulation_controller.proxy_model
            view = self.simulation_controller.view.table_view
        obs = label

        x_axis_col = "time"
        y_axis_col = data_type
        observable_col = "observableId"

        def column_index(name):
            for col in range(proxy.columnCount()):
                if proxy.headerData(col, Qt.Horizontal) == name:
                    return col
            raise ValueError(f"Column '{name}' not found.")

        x_col = column_index(x_axis_col)
        y_col = column_index(y_axis_col)
        obs_col = column_index(observable_col)

        for row in range(proxy.rowCount()):
            row_obs = proxy.index(row, obs_col).data()
            row_x = proxy.index(row, x_col).data()
            row_y = proxy.index(row, y_col).data()
            try:
                row_x, row_y = float(row_x), float(row_y)
            except ValueError:
                continue
            if row_obs == obs and row_x == x and row_y == y:
                view.selectRow(row)
                break

    def _on_table_selection_changed(self, selected, deselected):
        """Highlight the cells selected in measurement table."""
        selected_rows = get_selected(
            self.measurement_controller.view.table_view
        )
        self.plotter.highlight_from_selection(selected_rows)

    def _on_simulation_selection_changed(self, selected, deselected):
        selected_rows = get_selected(
            self.simulation_controller.view.table_view
        )
        self.plotter.highlight_from_selection(
            selected_rows,
            proxy=self.simulation_controller.proxy_model,
            y_axis_col="simulation",
        )

    def simulate(self):
        """Simulate the model."""
        # obtain petab problem
        petab_problem = self.model.current_petab_problem

        # import petabsimualtor
        import basico
        from basico.petab import PetabSimulator

        # report current basico / COPASI version
        self.logger.log_message(
            f"Simulate with basico: {basico.__version__}, COPASI: {basico.COPASI.__version__}",
            color="green",
        )

        import tempfile

        # create temp directory in temp folder:
        with tempfile.TemporaryDirectory() as temp_dir:
            # settings is only current solution statistic for now:
            settings = {"method": {"name": basico.PE.CURRENT_SOLUTION}}
            # create simulator
            simulator = PetabSimulator(
                petab_problem, settings=settings, working_dir=temp_dir
            )

            # simulate
            sim_df = simulator.simulate()

        # assign to simulation table
        self.simulation_controller.overwrite_df(sim_df)
        self.simulation_controller.model.reset_invalid_cells()

    def _schedule_plot_update(self):
        """Start the plot schedule timer."""
        self._plot_update_timer.start()

    def _toggle_whats_this_mode(self, on: bool):
        """Enable/disable click-to-help mode by installing/removing the global filter.

        On enter: show a short instruction bubble.
        """
        app = QApplication.instance()
        if not app:
            return
        if not on:
            QWhatsThis.hideText()
            try:
                QApplication.restoreOverrideCursor()
            except Exception:
                pass
            app.removeEventFilter(self._whats_this_filter)
            self.logger.log_message("Enden the Help mode.", color="blue")
            return
        # install filter
        app.installEventFilter(self._whats_this_filter)
        QApplication.setOverrideCursor(Qt.WhatsThisCursor)
        self.logger.log_message(
            "Started the Help mode. Click on any widget to see its help.",
            color="blue",
        )
        self._show_help_welcome()

    def _show_help_welcome(self):
        """Centered welcome with a 'Don't show again' option persisted in QSettings."""
        settings = settings_manager.settings
        if settings.value("help_mode/welcome_disabled", False, type=bool):
            return
        msg = QMessageBox(self.view if hasattr(self, "view") else None)
        msg.setIcon(QMessageBox.Information)
        msg.setWindowTitle("Help mode")
        msg.setTextFormat(Qt.RichText)
        msg.setText(
            "<b>Welcome to help mode</b><br>"
            "<ul>"
            "<li>Click any widget, tab, or column header to see its help.</li>"
            "<li>Click the same item again or press <b>Esc</b> to close the bubble.</li>"
            "<li>Press <b>Esc</b> with no bubble, or toggle the <i>?</i> button, to exit.</li>"
            "</ul>"
        )
        dont = QCheckBox("Don't show again")
        msg.setCheckBox(dont)
        msg.exec()
        if dont.isChecked():
            settings.setValue("help_mode/welcome_disabled", True)

    def about(self):
        """Show an about dialog."""
        config_file = settings_manager.settings.fileName()
        QMessageBox.about(
            self.view,
            f"About {APP_NAME}",
            f"<b>{APP_NAME}</b><br>"
            f"Version: {version('petab-gui')}<br>"
            f"PEtab version: {version('petab')}<br><br>"
            f"{APP_NAME} is a tool for editing and visualizing PEtab "
            f"problems.<br><br>"
            f"Visit the GitHub repository at "
            f"<a href='{REPO_URL}'>{REPO_URL}</a> "
            "for more information.<br><br>"
            f"<small>Settings are stored in "
            f"<a href='file://{config_file}'>{config_file}</a></small>",
        )

    def _show_next_steps_panel(self):
        """Show the next steps panel (ignores 'don't show again' preference)."""
        # Sync checkbox state with current settings
        dont_show = settings_manager.get_value(
            "next_steps/dont_show_again", False, bool
        )
        self.next_steps_panel.set_dont_show_again(dont_show)
        self.next_steps_panel.show_panel()

    def _handle_next_steps_dont_show_again(self, dont_show: bool):
        """Handle the 'don't show again' checkbox state change.

        Connected to the next steps panel's dont_show_again_changed signal.
        Persists the user's preference to settings.

        Args:
            dont_show: Whether to suppress the panel on future saves
        """
        settings_manager.set_value("next_steps/dont_show_again", dont_show)

    def get_current_problem(self):
        """Get the current PEtab problem from the model."""
        return self.model.current_petab_problem
