import os
import pathlib
import typing
import zipfile
import shutil
from datetime import datetime

import tttrlib
import json
import time
import numpy as np
import pandas as pd

import pyqtgraph as pg
import matplotlib

from chisurf import logging
import chisurf.fio as io
import chisurf.fio.fluorescence
import chisurf.math
import chisurf.gui.decorators
import chisurf.fluorescence.burst
from chisurf.gui import QtGui, QtWidgets, QtCore, uic
from chisurf.math.signal import fill_small_gaps_in_array
from chisurf.settings.path_utils import get_path
from chisurf.settings.file_utils import safe_open_file
from chisurf.gui.widgets.wizard.tttr_channel_definition import save_detector_setups, load_detector_setups
from chisurf.gui.widgets.progress import EnhancedProgressDialog
from chisurf.fluorescence.burst.utils import create_array_with_ones


QValidator = QtGui.QValidator
colors = chisurf.settings.gui['plot']['colors']




class ProgressWindow(QtWidgets.QDialog):
    def __init__(self, title="Processing Files", message="Loading files...", max_value=100, parent=None):
        super().__init__(parent)
        self.setWindowTitle(title)
        self.setWindowModality(QtCore.Qt.WindowModal)
        self.layout = QtWidgets.QVBoxLayout()
        self.label = QtWidgets.QLabel(message)
        self.progress_bar = QtWidgets.QProgressBar()
        self.progress_bar.setRange(0, max_value)
        self.layout.addWidget(self.label)
        self.layout.addWidget(self.progress_bar)
        self.setLayout(self.layout)

    def set_value(self, value: int):
        self.progress_bar.setValue(value)
        QtWidgets.QApplication.processEvents()


class CommaSeparatedIntegersValidator(QValidator):

    """
    QValidator to ensure input is a comma-separated list of valid
    integers between 0 and 255.
    """
    def validate(self, input_str, pos):
        if not input_str:
            return QValidator.Intermediate, input_str, pos

        parts = input_str.split(',')
        for part in parts:
            part = part.strip()
            if part == '':
                continue
            if not part.isdigit():
                return QValidator.Intermediate, input_str, pos
            num = int(part)
            if num < 0 or num > 255:
                return QValidator.Invalid, input_str, pos

        if input_str.endswith(','):
            return QValidator.Intermediate, input_str, pos

        return QValidator.Acceptable, input_str, pos

    def fixup(self, input_str):
        input_str = input_str.rstrip(',')
        parts = input_str.split(',')
        valid_parts = []
        for part in parts:
            part = part.strip()
            if part.isdigit():
                num = int(part)
                if 0 <= num <= 255:
                    valid_parts.append(str(num))
        return ', '.join(valid_parts)


class WizardTTTRPhotonFilter(QtWidgets.QWizardPage):
    """
    WizardTTTRPhotonFilter loads TTTR files and applies photon filtering
    based on count-rate or burst-search criteria, channel selection,
    microtime ranges, and optional gap-filling.

    Parameters
    ----------
    windows : dict
        Dictionary of predefined PIE windows. Passed to the relevant UI comboBox.
    detectors : dict
        Dictionary of predefined detectors. Passed to the relevant UI comboBox.
    show_dT : bool, default=True
        Whether to show the dT plot (between consecutive photons).
    show_filter : bool, default=True
        Whether to show the filter/selection plot.
    show_mcs : bool, default=True
        Whether to show the intensity trace (MCS) plot.
    show_decay : bool, default=True
        Whether to show the decay plot (microtime histogram).
    show_burst : bool, default=True
        Whether to show the burst histogram plot.
    default_dT_min : float, default=0.0001
        Initial lower bound for delta macro-time filter (in ms).
    default_dT_max : float, default=0.15
        Initial upper bound for delta macro-time filter (in ms).
    use_dT_min : bool, default=True
        Whether the lower bound of delta macro-time filter is active initially.
    use_dT_max : bool, default=True
        Whether the upper bound of delta macro-time filter is active initially.
    default_photon_threshold : int, default=160
        Initial threshold for count rate or burst search.
    default_count_rate_window_ms : float, default=1.0
        Initial time window (ms) for count-rate based filtering.
    invert_filter : bool, default=False
        Whether to invert the filter initially (applies to all filter types).
    default_filter_mode : str, default='count_rate'
        Selects which filter mode is active at initialization.
        Valid options: 'count_rate', 'burst', 'bocpd', or 'kalman'.
        - If 'count_rate', the "Count rate" option is selected in the combobox.
        - If 'burst', the "Burst" option is selected in the combobox.
        - If 'bocpd', the "BOCPD Burst" option is selected in the combobox.
        - If 'kalman', the "Kalman Burst" option is selected in the combobox.

    Notes
    -----
    The filter mode can also be changed by the user at runtime. The
    current mode can be queried from the `used_filter` property.
    """

    @property
    def photon_number_threshold(self):
        return self.spinBox.value()

    @property
    def target_path(self) -> pathlib.Path:
        return pathlib.Path(self.lineEdit_2.text())

    @property
    def filename(self) -> pathlib.Path:
        return pathlib.Path(self.lineEdit.text())

    @property
    def filetype(self) -> str | None:
        setup_name = self.comboBox.currentText()
        if not setup_name or setup_name == "No setups available":
            # Display warning message if no setup is selected
            QtWidgets.QMessageBox.warning(
                self,
                "No Setup Selected",
                "Please define a setup first in the Detector Configuration page."
            )
            return None

        # Load setups from the detector setups file
        setups = load_detector_setups()

        # Check if the selected setup exists
        if setup_name in setups.get("setups", {}):
            # Get the file type from the setup's tttr_reading section
            setup_data = setups["setups"][setup_name]
            if "tttr_reading" in setup_data and "file_type" in setup_data["tttr_reading"]:
                file_type = setup_data["tttr_reading"]["file_type"]

                # If file type is Auto, try to infer from current file
                if file_type == 'Auto':
                    current_file = self.current_tttr_filename
                    if current_file and pathlib.Path(current_file).exists():
                        file_type_int = tttrlib.inferTTTRFileType(current_file)
                        if file_type_int is not None and file_type_int >= 0:
                            container_names = tttrlib.TTTR.get_supported_container_names()
                            if 0 <= file_type_int < len(container_names):
                                return container_names[file_type_int]
                    return None
                return file_type

        # If setup doesn't exist or doesn't have file type, return None without showing warning
        # This prevents the error message from appearing on startup
        return None

    @property
    def trace_bin_width(self) -> float:
        return float(self.doubleSpinBox_4.value())

    @property
    def use_lower(self) -> bool:
        return bool(self.checkBox_2.isChecked())

    @property
    def use_upper(self) -> bool:
        return bool(self.checkBox_3.isChecked())

    @property
    def use_gap_fill(self):
        return bool(self.checkBox_5.isChecked())

    @property
    def plot_min(self):
        return self.spinBox_2.value()

    @property
    def plot_max(self):
        return self.spinBox_3.value()

    @plot_max.setter
    def plot_max(self, v):
        self.spinBox_3.blockSignals(True)
        self.spinBox_3.setValue(v)
        self.spinBox_3.blockSignals(False)

    @property
    def current_tttr_filename(self):
        v = self.spinBox_4.value()
        try:
            filenames = self.settings.get('tttr_filenames', [])
        except Exception:
            filenames = []
        if 0 <= v < len(filenames):
            return filenames[v]
        else:
            # Return empty string for safe use in setText and truthiness checks
            return ""

    @property
    def decay_coarse(self):
        return self.spinBox_5.value()

    @property
    def max_gap(self):
        if not self.use_gap_fill:
            return 0
        return self.spinBox_7.value()

    @property
    def number_of_burst_bins(self):
        return self.spinBox_6.value()

    @property
    def channels(self) -> typing.List[int]:
        s = self.lineEdit_4.text()
        if len(s) > 0:
            return [int(x) for x in s.split(',')]
        return []

    @property
    def min_ph(self):
        """
        The minimum number of photons for burst detection (or any other usage).
        This property gets the current spinBox value.
        """
        return self.spinBox.value()

    @min_ph.setter
    def min_ph(self, value):
        """
        Sets the spinBox value to the specified integer/float.
        """
        self.spinBox.setValue(value)

    @property
    def ph_window(self):
        """
        The photon window size for burst detection (or other usage).
        This property gets the current spinBox_8 value.
        """
        return self.spinBox_8.value()

    @ph_window.setter
    def ph_window(self, value):
        """
        Sets the spinBox_8 value to the specified integer/float.
        """
        self.spinBox_8.setValue(value)

    @property
    def bocpd_prior_count(self):
        """
        The prior photon count before data for BOCPD Gamma prior.
        This property gets the current doubleSpinBox_5 value.
        """
        return self.doubleSpinBox_5.value()

    @bocpd_prior_count.setter
    def bocpd_prior_count(self, value):
        """
        Sets the doubleSpinBox_5 value to the specified float.
        """
        self.doubleSpinBox_5.setValue(value)

    # For backward compatibility
    @property
    def bocpd_alpha(self):
        """
        Deprecated: Use bocpd_prior_count instead.
        The alpha parameter for BOCPD Gamma prior.
        """
        return self.bocpd_prior_count

    @bocpd_alpha.setter
    def bocpd_alpha(self, value):
        """
        Deprecated: Use bocpd_prior_count instead.
        Sets the alpha parameter for BOCPD Gamma prior.
        """
        self.bocpd_prior_count = value

    @property
    def bocpd_prior_duration(self):
        """
        The time window assumed for prior_count for BOCPD Gamma prior.
        This property gets the current doubleSpinBox_6 value.
        """
        return self.doubleSpinBox_6.value()

    @bocpd_prior_duration.setter
    def bocpd_prior_duration(self, value):
        """
        Sets the doubleSpinBox_6 value to the specified float.
        """
        self.doubleSpinBox_6.setValue(value)

    # For backward compatibility
    @property
    def bocpd_beta(self):
        """
        Deprecated: Use bocpd_prior_duration instead.
        The beta parameter for BOCPD Gamma prior.
        """
        return self.bocpd_prior_duration

    @bocpd_beta.setter
    def bocpd_beta(self, value):
        """
        Deprecated: Use bocpd_prior_duration instead.
        Sets the beta parameter for BOCPD Gamma prior.
        """
        self.bocpd_prior_duration = value

    @property
    def bocpd_changepoint_prob(self):
        """
        The probability of burst start in any bin for BOCPD.
        This property gets the current doubleSpinBox_7 value.
        """
        return self.doubleSpinBox_7.value()

    @bocpd_changepoint_prob.setter
    def bocpd_changepoint_prob(self, value):
        """
        Sets the doubleSpinBox_7 value to the specified float.
        """
        self.doubleSpinBox_7.setValue(value)

    # For backward compatibility
    @property
    def bocpd_hazard(self):
        """
        Deprecated: Use bocpd_changepoint_prob instead.
        The hazard rate (probability of change point) for BOCPD.
        """
        return self.bocpd_changepoint_prob

    @bocpd_hazard.setter
    def bocpd_hazard(self, value):
        """
        Deprecated: Use bocpd_changepoint_prob instead.
        Sets the hazard rate (probability of change point) for BOCPD.
        """
        self.bocpd_changepoint_prob = value

    @property
    def kalman_q(self):
        """
        The process noise parameter for Kalman filter.
        This property gets the current doubleSpinBox_8 value.
        """
        return self.doubleSpinBox_8.value()

    @kalman_q.setter
    def kalman_q(self, value):
        """
        Sets the doubleSpinBox_8 value to the specified float.
        """
        self.doubleSpinBox_8.setValue(value)

    @property
    def kalman_r_scale(self):
        """
        The measurement noise scaling parameter for Kalman filter.
        This property gets the current doubleSpinBox_9 value.
        """
        return self.doubleSpinBox_9.value()

    @kalman_r_scale.setter
    def kalman_r_scale(self, value):
        """
        Sets the doubleSpinBox_9 value to the specified float.
        """
        self.doubleSpinBox_9.setValue(value)

    @property
    def kalman_z_thresh(self):
        """
        The threshold for burst detection in Kalman filter.
        This property gets the current doubleSpinBox_10 value.
        """
        return self.doubleSpinBox_10.value()

    @kalman_z_thresh.setter
    def kalman_z_thresh(self, value):
        """
        Sets the doubleSpinBox_10 value to the specified float.
        """
        self.doubleSpinBox_10.setValue(value)

    @property
    def kalman_min_len(self):
        """
        The minimum burst length in bins for Kalman filter.
        This property gets the current spinBox_9 value.
        """
        return self.spinBox_9.value()

    @kalman_min_len.setter
    def kalman_min_len(self, value):
        """
        Sets the spinBox_9 value to the specified integer.
        """
        self.spinBox_9.setValue(value)

    @property
    def kalman_merge_gap(self):
        """
        The maximum gap between bursts to merge them in Kalman filter.
        This property gets the current spinBox_7 value.
        """
        return self.spinBox_7.value()

    @kalman_merge_gap.setter
    def kalman_merge_gap(self, value):
        """
        Sets the spinBox_7 value to the specified integer.
        """
        self.spinBox_7.setValue(value)

    @property
    def microtime_ranges(self) -> typing.Optional[typing.List[typing.Tuple[int, int]]]:
        s = self.lineEdit_5.text()

        # Check if the input string is empty
        if not s:
            return None

        try:
            ranges = [tuple(map(int, item.split('-'))) for item in s.split(';')]

            # Check if each range has exactly two values
            if all(len(r) == 2 for r in ranges):
                return ranges
            else:
                chisurf.logging.log(1, "::microtime_ranges: Invalid format for microsecond ranges.")
                return None

        except (ValueError, TypeError):
            chisurf.logging.log(1, "::microtime_ranges: Invalid values in microsecond ranges.")
            return None

    @property
    def dT(self):
        if isinstance(self.tttr, tttrlib.TTTR):
            mT = self.tttr.get_macro_times()
            d = np.diff(mT, prepend=mT[0])
            h = self.tttr.header
            d = d * h.macro_time_resolution * 1000.0
            return d

    @property
    def dT_min(self) -> float:
        return self._dT_min

    @property
    def dT_max(self) -> float:
        return self._dT_max

    @property
    def used_filter(self):
        """
        Returns the currently active filter mode based on comboBox_burst_filter selection:
        - 'count_rate' if "Count rate" is selected
        - 'burst' if "Burst" is selected
        - 'bocpd' if "BOCPD Burst" is selected
        - 'kalman' if "Kalman Burst" is selected
        """
        current_text = self.comboBox_burst_filter.currentText()
        if current_text == "Count rate":
            return 'count_rate'
        elif current_text == "Burst":
            return 'burst'
        elif current_text == "BOCPD Burst":
            return 'bocpd'
        elif current_text == "Kalman Burst":
            return 'kalman'

    @property
    def selected(self):
        """
        Returns a boolean mask (as np.uint8 array) of selected photons
        based on all applied filters: channel/microtime filter,
        dT thresholding, count-rate filter (if active),
        or burst filter (if active), plus optional gap-filling.
        """
        start_time = time.time()
        dT = self.dT
        tttr = self.tttr
        if dT is None:
            return list()

        s = np.ones_like(dT, dtype=bool)
        chs = self.channels
        if len(chs) > 0:
            mask = tttrlib.TTTRMask()
            mask.select_channels(tttr, chs, mask=True)
            m = mask.get_mask()
            s = np.logical_and(s, m)

        if self.microtime_ranges:
            mask = tttrlib.TTTRMask()
            mask.select_microtime_ranges(tttr, self.microtime_ranges)
            mask.flip()
            m = mask.get_mask()
            s = np.logical_and(s, m)

        if self.use_lower:
            s = np.logical_and(s, dT >= self.dT_min)
        if self.use_upper:
            s = np.logical_and(s, dT <= self.dT_max)

        # Apply filter depending on combobox selection and filter_active setting
        if not self.settings.get('filter_active', True):
            # If filter is not active, skip filter application
            pass
        elif self.used_filter == 'count_rate':
            filter_options = self.settings['count_rate_filter']
            selection_idx = chisurf.fluorescence.burst.count_rate_filter(
                tttr=self.tttr,
                n_ph_max=filter_options['n_ph_max'],
                time_window=filter_options['time_window'],
                invert=self.settings.get('invert_filter', False),
                make_mask=True
            )
            s = np.logical_and(s, selection_idx >= 0)

        elif self.used_filter == 'burst':
            min_ph = self.min_ph
            ph_window = self.ph_window
            tw = self.dT_max / 1000.0
            sel = chisurf.fluorescence.burst.burst_filter(
                tttr=tttr,
                min_ph=min_ph,
                ph_window=ph_window,
                time_window=tw
            )
            s = np.logical_and(s, sel)

        elif self.used_filter == 'bocpd':
            # Get channels
            channel_list = self.channels

            # Get macro times and routing channels
            macro_times = tttr.macro_times
            time_unit = tttr.header.macro_time_resolution
            timestamps = macro_times * time_unit  # Convert to seconds
            channels = tttr.routing_channels

            # If no channels are selected, use all available channels
            if len(channel_list) < 1:
                channel_list = tttr.get_used_routing_channels()

            # Extract timestamps for each channel
            timestamps_list = []
            for channel in channel_list:
                channel_timestamps = timestamps[channels == channel]
                timestamps_list.append(channel_timestamps)

                if len(channel_timestamps) == 0:
                    chisurf.logging.log(1, f"No photons found for channel {channel}")
                    return s.astype(dtype=np.uint8)

            # Get BOCPD parameters from UI
            prior_count = self.bocpd_prior_count
            prior_duration = self.bocpd_prior_duration
            changepoint_prob = self.bocpd_changepoint_prob
            min_counts = self.min_ph  # Use min_ph as min_counts
            max_run = 256  # Default max run length

            # Run BOCPD burst detection with multiple channels
            bursts, _, _, _, _ = chisurf.fluorescence.burst.bocpd_burst_detection_multi(
                timestamps_list,
                dt=self.trace_bin_width / 1000.0,  # bin width from UI trace_bin_width
                prior_count=prior_count,
                prior_duration=prior_duration,
                changepoint_prob=changepoint_prob,
                max_run=max_run,
                min_counts=min_counts
            )

            # Convert bursts to start-stop indices
            start_stop = chisurf.fluorescence.burst.bocpd.convert_bursts_to_start_stop(bursts, tttr)

            if len(start_stop) == 0:
                return s.astype(dtype=np.uint8)

            # Create mask
            n = len(tttr)
            sel = create_array_with_ones(start_stop, n)
            s = np.logical_and(s, sel)

        elif self.used_filter == 'kalman':
            # Get channels
            channel_list = self.channels

            # Get macro times and routing channels
            macro_times = tttr.macro_times
            time_unit = tttr.header.macro_time_resolution
            timestamps = macro_times * time_unit  # Convert to seconds
            channels = tttr.routing_channels

            # If no channels are selected, use all available channels
            if len(channel_list) < 1:
                channel_list = tttr.get_used_routing_channels()

            # Extract timestamps for each channel
            timestamps_list = []
            for channel in channel_list:
                channel_timestamps = timestamps[channels == channel]
                timestamps_list.append(channel_timestamps)

                if len(channel_timestamps) == 0:
                    chisurf.logging.log(1, f"No photons found for channel {channel}")
                    return s.astype(dtype=np.uint8)

            # Get Kalman filter parameters
            q = self.kalman_q
            r_scale = self.kalman_r_scale
            z_thresh = self.kalman_z_thresh
            min_len = self.kalman_min_len
            merge_gap = self.kalman_merge_gap
            min_counts = self.min_ph  # Use min_ph as min_counts

            # Run Kalman filter burst detection with multiple channels
            bursts, _, _, _, _ = chisurf.fluorescence.burst.kalman_burst_detection_multi(
                timestamps_list,
                dt=self.trace_bin_width / 1000.0,  # bin width from UI trace_bin_width
                q=q,
                r_scale=r_scale,
                z_thresh=z_thresh,
                min_len=min_len,
                merge_gap=merge_gap,
                min_counts=min_counts
            )

            # Convert bursts to start-stop indices
            start_stop = chisurf.fluorescence.burst.kalman.convert_bursts_to_start_stop(bursts, tttr)

            if len(start_stop) == 0:
                return s.astype(dtype=np.uint8)

            # Create mask
            n = len(tttr)
            sel = create_array_with_ones(start_stop, n)
            s = np.logical_and(s, sel)

        # Apply invert logic if the invert checkbox is checked (for all filter modes)
        # First check top-level setting, then fall back to count_rate_filter for backward compatibility
        if self.settings.get('invert_filter', False) and self.used_filter != 'count_rate':
            s = ~s

        if self.max_gap > 0 and self.use_gap_fill:
            s = fill_small_gaps_in_array(s, max_gap=self.max_gap)

        return s.astype(dtype=np.uint8)

    @property
    def burst_start_stop(self):
        """
        Returns the start-stop indices of bursts in the selected mask,
        allowing for small gaps with max_gap=4 by default.
        """
        selected = self.selected
        max_gap = self.max_gap
        if len(selected) > max_gap:
            return chisurf.math.signal.find_bursts(selected, max_gap=max_gap)
        else:
            return np.array([], dtype=np.uint64)

    @property
    def burst_lengths(self):
        brst = self.burst_start_stop
        if len(brst) > 2:
            n = brst.T[1] - brst.T[0]
        else:
            n = np.array([], dtype=np.uint64)
        return n

    @property
    def save_sl5(self):
        return self.checkBox_6.isChecked()

    @property
    def save_bur(self):
        return self.checkBox_7.isChecked()

    @property
    def save_hdf5(self):
        # For testing purposes, return True
        # In a production environment, this would check a checkbox or configuration setting
        return True  # Change to False to disable HDF5 output

    def update_filter_plot(self):
        """
        Update the plot showing which photons are selected (1) vs unselected (0).
        """
        try:
            selected = self.selected
            if isinstance(selected, (np.ndarray, list)) and len(selected) > 0:
                n_min = self.plot_min
                n_max = self.plot_max
                x = np.arange(n_min, n_max)
                y = selected[n_min:n_max]
                self.plot_select.setData(x=x, y=y)
            else:
                # If selected is not available, clear the plot
                self.plot_select.setData([], [])
        except (AttributeError, IndexError, TypeError):
            # Handle the case when self.selected is not available or returns an error
            self.plot_select.setData([], [])

    def update_dt_plot(self):
        """
        Update the plot of dT vs photon index, highlighting selected vs unselected points.
        """
        try:
            dT = self.dT
            if isinstance(dT, np.ndarray):
                n_min = self.plot_min
                n_max = self.plot_max
                mask = self.selected[n_min:n_max].astype(bool)
                y = dT[n_min:n_max]
                x = np.arange(n_min, n_max)

                mx = np.ma.masked_array(x, mask=~mask)
                my = np.ma.masked_array(y, mask=~mask)
                self.plot_selected.setData(x=mx.compressed(), y=my.compressed())

                mx = np.ma.masked_array(x, mask=mask)
                my = np.ma.masked_array(y, mask=mask)
                self.plot_unselected.setData(x=mx.compressed(), y=my.compressed())
            else:
                # If dT is not available, clear the plots
                self.plot_selected.setData([], [])
                self.plot_unselected.setData([], [])
        except AttributeError:
            # Handle the case when self.dT is not available
            self.plot_selected.setData([], [])
            self.plot_unselected.setData([], [])

    @property
    def do_mcs_plot(self) -> bool:
        return self.toolButton_2.isChecked()

    @do_mcs_plot.setter
    def do_mcs_plot(self, v):
        self.toolButton_2.setChecked(v)

    @property
    def do_burst_photon_plot(self) -> bool:
        return self.toolButton_7.isChecked()

    @do_burst_photon_plot.setter
    def do_burst_photon_plot(self, v):
        self.toolButton_7.setChecked(v)

    @property
    def do_microtime_plot(self) -> bool:
        return self.toolButton_3.isChecked()

    @do_microtime_plot.setter
    def do_microtime_plot(self, v):
        self.toolButton_3.setChecked(v)

    def update_mcs_plot(self):
        """
        Update the intensity trace plot (MCS) for both all photons and the selected subset.
        """
        if self.do_mcs_plot:
            try:
                tttr = self.tttr
                if isinstance(tttr, tttrlib.TTTR):
                    try:
                        selected = self.selected
                        if isinstance(selected, (np.ndarray, list)) and len(selected) > 0:
                            idx = np.where(selected)[0]
                            tw = self.trace_bin_width / 1000.0
                            trace_selected = tttr[idx].get_intensity_trace(time_window_length=tw)
                            x1 = np.arange(len(trace_selected)) * tw
                            self.plot_mcs_selected.setData(x1, trace_selected)
                        else:
                            self.plot_mcs_selected.setData([], [])
                    except (AttributeError, IndexError, TypeError):
                        self.plot_mcs_selected.setData([], [])

                    trace_all = tttr.get_intensity_trace(time_window_length=self.trace_bin_width / 1000.0)
                    x2 = np.arange(len(trace_all)) * (self.trace_bin_width / 1000.0)
                    self.plot_mcs_all.setData(x2, trace_all)
                else:
                    self.plot_mcs_all.setData([], [])
                    self.plot_mcs_selected.setData([], [])
            except (AttributeError, IndexError, TypeError):
                self.plot_mcs_all.setData([], [])
                self.plot_mcs_selected.setData([], [])
        else:
            self.plot_mcs_all.setData(x=[1.0], y=[1.0])
            self.plot_mcs_selected.setData(x=[1.0], y=[1.0])

    def update_burst_histogram(self):
        """
        Update the histogram of burst lengths (only relevant if burst mode is active).
        """
        if self.do_burst_photon_plot:
            try:
                burst_lengths = self.burst_lengths
                if isinstance(burst_lengths, np.ndarray) and len(burst_lengths) > 0:
                    num_bins = self.number_of_burst_bins
                    hist, bin_edges = np.histogram(burst_lengths, bins=num_bins)

                    self.pw_burst_histogram.clear()
                    self.pw_burst_histogram.addItem(
                        pg.BarGraphItem(
                            x0=bin_edges[:-1],
                            x1=bin_edges[1:],
                            y0=0,
                            y1=hist,
                            brush='b',
                            pen='w'
                        )
                    )
                    self.plot_burst_histogram = self.pw_burst_histogram.plot(
                        bin_edges,
                        hist,
                        pen='b',
                        stepMode=True
                    )

                    total_bursts = np.sum(hist)
                    pos_x = 0.5 * (bin_edges[0] + bin_edges[-1]) if bin_edges.size > 1 else 0
                    pos_y = (hist.max() * 0.9) if hist.size > 0 else 1.0
                    self.total_bursts_label = pg.TextItem(
                        f"Total bursts: {total_bursts}",
                        anchor=(0, 0),
                        color='w'
                    )
                    self.total_bursts_label.setPos(pos_x, pos_y)
                    self.pw_burst_histogram.addItem(self.total_bursts_label)
                    self.pw_burst_histogram.setYRange(0.0, max(hist) if hist.size > 0 else 1.0)
                    self.pw_burst_histogram.setXRange(0.0, max(bin_edges) if bin_edges.size > 0 else 1.0)
                else:
                    self.pw_burst_histogram.clear()
                    self.pw_burst_histogram.addItem(
                        pg.TextItem(
                            "No burst data available",
                            anchor=(0.5, 0.5),
                            color='w'
                        )
                    )
            except (AttributeError, IndexError, TypeError, ValueError):
                self.pw_burst_histogram.clear()
                self.pw_burst_histogram.addItem(
                    pg.TextItem(
                        "Error processing burst data",
                        anchor=(0.5, 0.5),
                        color='w'
                    )
                )

    def update_decay_plot(self):
        """
        Update the microtime decay plot (histogram), comparing all photons vs selected photons.
        """
        if self.do_microtime_plot:
            try:
                tttr = self.tttr
                if isinstance(tttr, tttrlib.TTTR):
                    try:
                        selected = self.selected
                        if isinstance(selected, (np.ndarray, list)) and len(selected) > 0:
                            idx = np.where(selected)[0]
                            y, x = tttr[idx].get_microtime_histogram(self.decay_coarse)
                            if np.any(y > 0):
                                idx_max = np.where(y > 0)[0][-1]
                                x = x[:idx_max]
                                y = y[:idx_max]
                                x *= 1e9
                                self.plot_decay_selected.setData(x=x, y=y)
                            else:
                                self.plot_decay_selected.setData([], [])
                        else:
                            self.plot_decay_selected.setData([], [])
                    except (AttributeError, IndexError, TypeError):
                        self.plot_decay_selected.setData([], [])

                    y, x = tttr.get_microtime_histogram(self.decay_coarse)
                    if np.any(y > 0):
                        idx_max = np.where(y > 0)[0][-1]
                        x = x[:idx_max]
                        y = y[:idx_max]
                        x *= 1e9
                        self.plot_decay_all.setData(x=x, y=y)
                    else:
                        self.plot_decay_all.setData([], [])
                else:
                    self.plot_decay_all.setData([], [])
                    self.plot_decay_selected.setData([], [])
            except (AttributeError, IndexError, TypeError):
                self.plot_decay_all.setData([], [])
                self.plot_decay_selected.setData([], [])
        else:
            self.plot_decay_all.setData(x=[1.0], y=[1.0])
            self.plot_decay_selected.setData(x=[1.0], y=[1.0])

    def update_plots(self, selection: str = "all"):
        """
        Convenience method to update different sets of plots
        ('mcs', 'decay', 'dT', 'filter', 'burst').
        """
        if 'mcs' in selection:
            self.update_mcs_plot()
        if 'decay' in selection:
            self.update_decay_plot()
        if 'dT' in selection:
            self.update_dt_plot()
        if 'filter' in selection:
            self.update_filter_plot()
        else:
            self.update_dt_plot()
            self.update_decay_plot()
            self.update_mcs_plot()
            self.update_filter_plot()
            self.update_burst_histogram()

    def read_tttr(self):
        """
        Called when the user changes the active file.
        Looks up the preloaded TTTR object in self.tttr_objects.
        """
        fn = self.current_tttr_filename
        if not fn:
            return

        p = pathlib.Path(fn).resolve()
        p_str = str(p)

        if p_str in self.tttr_objects:
            try:
                self.tttr = self.tttr_objects[p_str]
                self.plot_max = len(self.tttr) - 1
                header = self.tttr.get_header()
                s = header.json
                d = json.loads(s)
                self.settings['header'] = d
                self.update_plots()
            except Exception as e:
                # If there's an error accessing the TTTR object's properties,
                # display an error message and exit early
                QtWidgets.QMessageBox.critical(
                    self,
                    "Error Reading File",
                    f"Failed to read file '{p.name}' with the selected setup.\n\n"
                    f"Error: {str(e)}\n\n"
                    f"Please check that you have selected the correct setup for this file type."
                )
                # Remove the problematic TTTR object from the dictionary
                if p_str in self.tttr_objects:
                    del self.tttr_objects[p_str]
                self.tttr_objects = dict()
                self.onClearFiles()
                return  # Exit early to prevent undefined state

    def update_output_path(self):
        """
        Update the suggested output path (lineEdit_2) based on the current settings.
        """
        if len(self.channels) > 0:
            chs = ','.join([str(x) for x in self.channels])
        else:
            chs = 'All'

        # Determine path format based on filter mode
        if self.used_filter == "count_rate":
            path_prefix = "countrate"
        elif self.used_filter == "bocpd":
            path_prefix = "bocpd"
        elif self.used_filter == "kalman":
            path_prefix = "kalman"
        else:
            path_prefix = "burstwise"

        s = f"{path_prefix}_{chs} {self.dT_max:.4f}#{self.photon_number_threshold}"
        self.lineEdit_2.setText(s)

    def update_parameter(self):
        """
        Update internal settings whenever the user changes filters or region selectors.
        Robust to early initialization when region_selector/settings may not yet exist.
        """
        # Ensure settings dicts exist (early init safety)
        if not hasattr(self, 'settings') or not isinstance(self.settings, dict):
            self.settings = {}
        self.settings.setdefault('count_rate_filter', {})
        self.settings.setdefault('delta_macro_time_filter', {})

        # Determine lb/ub even if region_selector is not yet created
        try:
            lb, ub = self.region_selector.getRegion()
        except Exception:
            # Fallback to spin boxes or sane defaults
            try:
                lb = float(self.doubleSpinBox_2.value())
                ub = float(self.doubleSpinBox_3.value())
            except Exception:
                lb, ub = 0.0001, 0.15
            # Convert to log space if axis is in log mode (to match later back-conversion)
            try:
                if self.pw_dT.getAxis('left').logMode:
                    lb = np.log10(lb) if lb > 0 else -4
                    ub = np.log10(ub) if ub > 0 else 0
            except Exception:
                pass

        self.settings['filter_active'] = self.checkBox_4.isChecked()
        self.settings['count_rate_filter']['n_ph_max'] = int(self.spinBox.value())
        self.settings['count_rate_filter']['time_window'] = float(self.doubleSpinBox.value()) * 1e-3
        # Store invert setting at top level for all filter types
        self.settings['invert_filter'] = bool(self.checkBox.isChecked())
        # For backward compatibility, also store in count_rate_filter
        self.settings['count_rate_filter']['invert'] = bool(self.checkBox.isChecked())

        # Map from (maybe log) lb/ub back to linear if axis is log
        try:
            is_log = self.pw_dT.getAxis('left').logMode
        except Exception:
            is_log = False

        self.settings['delta_macro_time_filter']['dT_min'] = 10.0 ** lb if is_log else lb
        self.settings['delta_macro_time_filter']['dT_max'] = 10.0 ** ub if is_log else ub
        self.settings['delta_macro_time_filter']['dT_min_active'] = self.checkBox_2.isChecked()
        self.settings['delta_macro_time_filter']['dT_max_active'] = self.checkBox_3.isChecked()

        # Avoid updating plots too early if plot widgets not ready
        try:
            self.update_plots()
        except Exception:
            pass
        try:
            self.update_output_path()
        except Exception:
            pass

    def onClearFiles(self):
        """
        Clears the list of filenames, resets the spinBox, clears the lineEdit,
        unsets the current TTTR object, and also clears all plots.
        """
        lst = self.settings.get('tttr_filenames')
        if isinstance(lst, list):
            lst.clear()
        # Reset index spinbox safely
        self.spinBox_4.blockSignals(True)
        self.spinBox_4.setMaximum(0)
        self.spinBox_4.setValue(0)
        self.spinBox_4.blockSignals(False)
        self.comboBox.setEnabled(True)
        self.lineEdit.clear()
        self.tttr = None
        # Reset resolved output directory cache to avoid stale paths
        self._resolved_output_dir = None
        # Also clear any cached TTTR objects
        try:
            if hasattr(self, 'tttr_objects') and isinstance(self.tttr_objects, dict):
                self.tttr_objects.clear()
        except Exception:
            pass

        # Clear each plot item
        self.plot_unselected.setData([], [])
        self.plot_selected.setData([], [])
        self.plot_mcs_all.setData([], [])
        self.plot_mcs_selected.setData([], [])
        self.plot_decay_all.setData([], [])
        self.plot_decay_selected.setData([], [])
        self.plot_select.setData([], [])

        # Clear the burst histogram entirely (removes bars/text)
        self.pw_burst_histogram.clear()

    def updateUI(self):
        """
        Updates the lineEdit with the currently active file name.
        Safe when there is no current file.
        """
        fn = self.current_tttr_filename
        self.lineEdit.setText(fn if isinstance(fn, str) else "")

    def onRegionUpdate(self):
        """
        Sync the numeric spinBoxes (doubleSpinBox_2/3) with the region item in the dT plot.
        """
        lb, ub = self.doubleSpinBox_2.value(), self.doubleSpinBox_3.value()
        if self.pw_dT.getAxis('left').logMode:
            lb, ub = np.log10(lb), np.log10(ub)
        self.region_selector.setRegion(rgn=(lb, ub))

    def get_unique_folder_path(self, base_path: pathlib.Path) -> pathlib.Path:
        """
        Return a path that is unique w.r.t. both the directory and a sibling .zip file.
        E.g., if 'burstwise_All#60' folder OR 'burstwise_All#60.zip' exists, try suffixes.
        """

        def name_taken(p: pathlib.Path) -> bool:
            return p.exists() or (p.parent / f"{p.name}.zip").exists()

        if not name_taken(base_path):
            return base_path

        counter = 0
        while True:
            candidate = base_path.parent / f"{base_path.name}_{counter}"
            if not name_taken(candidate):
                return candidate
            counter += 1

    @property
    def parent_directories(self) -> typing.List[pathlib.Path]:
        """
        Resolve and cache a unique analysis output directory once, then reuse it.
        - If a unique output directory was already chosen (e.g., during saving), reuse it (stable).
        - Otherwise, compute the unique directory based on the first TTTR file and cache it.
        """
        # Reuse the resolved directory if available (ensures consistency across calls)
        if getattr(self, "_resolved_output_dir", None):
            return [self._resolved_output_dir for _ in self.settings['tttr_filenames']]
        # Compute and cache based on the first file only (all outputs are stored together)
        if not self.settings['tttr_filenames']:
            return []
        first = self.settings['tttr_filenames'][0].replace('\x00', '')
        fn = pathlib.Path(first).absolute()
        base_path = fn.parent / self.target_path
        self._resolved_output_dir = self.get_unique_folder_path(base_path)
        return [self._resolved_output_dir for _ in self.settings['tttr_filenames']]

    @property
    def original_directories(self) -> typing.List[pathlib.Path]:
        """
        For each TTTR filename, get the parent directory with the configured target path
        without adding numeric suffixes. These are the original folder names.
        """
        r = []
        for filename in self.settings['tttr_filenames']:
            filename = filename.replace('\x00', '')
            fn = pathlib.Path(filename).absolute()
            base_path = fn.parent / self.target_path
            r.append(base_path)
        return r

    def save_selection(self, output_types=None, zip_output=False, remove_folder=False):
        """
        Save the selection data in .bur or .json.gz, depending on user checkboxes or specified output_types,
        and display a progress bar while saving. Optionally zip the output folder after saving and remove
        the original folder if requested. Also saves a JSON file with all parameters to an info folder.

        Parameters:
        -----------
        output_types : set, optional
            Set of output types to save. If provided, this overrides the checkbox settings.
            Possible values: "bur", "sl5", "hdf5"
        zip_output : bool, optional
            Whether to zip the output folder after saving. Default is False.
        remove_folder : bool, optional
            Whether to remove the original folder after zipping. Default is False.
            This parameter is only used if zip_output is True.
        """
        logger = logging.getLogger(__name__)
        logger.debug("Called save_selection with output_types=%s, zip_output=%s, remove_folder=%s",
                     output_types, zip_output, remove_folder)

        # --------------------------------------------------------------------
        # Reserve all unique output directories once, before writing any files
        from pathlib import Path
        fn = self.settings['tttr_filenames'][0]
        fn_path = Path(fn.replace('\x00', '')).resolve()
        base_path = fn_path.parent / self.target_path
        unique_path = self.get_unique_folder_path(base_path)
        # Cache the resolved output directory for consistent access by other pages
        self._resolved_output_dir = unique_path
        # --------------------------------------------------------------------

        total_files = len(self.settings['tttr_filenames'])
        # Determine output types
        if output_types is None:
            output_types = set()
            if self.save_bur:
                output_types.add("bur")
            if self.save_sl5:
                output_types.add("sl5")
            if self.save_hdf5:
                output_types.add("hdf5")
        logger.debug("Output types resolved to: %s", output_types)

        # Calculate total tasks
        total_tasks = 0
        if "bur" in output_types:
            total_tasks += total_files
        if "sl5" in output_types:
            total_tasks += total_files
        if "hdf5" in output_types:
            total_tasks += 1  # single combined HDF5
        if zip_output:
            total_tasks += 2  # zip and optional removal
        logger.debug("Total tasks calculated: %d", total_tasks)

        # Warn if remove_folder true without zip
        if remove_folder and not zip_output:
            logger.warning("remove_folder=True but zip_output=False; remove_folder will be ignored.")

        # Initialize progress dialog
        progress = EnhancedProgressDialog("Saving Selection", "Initializing...", 0, total_tasks, self)
        progress.show()
        current_task = 0
        all_dfs = []

        # Write .bur and collect for HDF5
        for filename in self.settings['tttr_filenames']:
            fn = Path(filename)
            self.tttr = self.tttr_objects[str(fn.resolve())]
            logger.debug("Processing file %s", fn.name)

            include_zeros = "bur" in output_types
            df = io.fluorescence.burst.generate_burst_dataframe(
                start_stop=self.burst_start_stop,
                filename=fn,
                tttr=self.tttr,
                windows=self.windows,
                detectors=self.detectors,
                include_interleaved_zeros=include_zeros
            )
            logger.debug("Generated DataFrame for %s; rows=%d", fn.name, len(df) if df is not None else 0)

            if "bur" in output_types:
                bur_dir = unique_path / 'bi4_bur'
                bur_dir.mkdir(parents=True, exist_ok=True)
                bur_file = bur_dir / f"{fn.stem}.bur"
                progress.update_text(f"Saving BUR file: {fn.stem}.bur")
                io.fluorescence.burst.write_dataframe_to_bur(df, bur_file)
                mt = self.tttr.macro_times[-1] * self.tttr.header.macro_time_resolution
                io.fluorescence.burst.write_mti_summary(
                    filename=fn,
                    analysis_dir=unique_path,
                    max_macro_time=mt,
                    append=True
                )
                current_task += 1
                progress.update_progress(current_task)
                logger.debug("Saved BUR file: %s", fn.stem)
                if progress.wasCanceled():
                    logger.warning("Operation canceled by user during BUR saving.")
                    return

            if "hdf5" in output_types and df is not None:
                if not "bur" in output_types and include_zeros:
                    # drop all-zero rows
                    df = df.loc[~(df.select_dtypes(include=['number']) == 0).all(axis=1)]
                df_copy = df.copy()
                df_copy['Source File'] = str(fn)
                all_dfs.append(df_copy)

        # Combined HDF5

        if "hdf5" in output_types and all_dfs:
            progress.update_text("Creating combined HDF5 file (compact)...")
            combined = pd.concat(all_dfs, ignore_index=True)

            # --- drop empty columns
            combined = combined.dropna(axis=1, how="all")

            # --- downcast numerics
            for c in combined.select_dtypes(include=["integer"]).columns:
                if (combined[c] >= 0).all():
                    combined[c] = pd.to_numeric(combined[c], downcast="unsigned")
                else:
                    combined[c] = pd.to_numeric(combined[c], downcast="integer")
            for c in combined.select_dtypes(include=["floating"]).columns:
                combined[c] = combined[c].astype(np.float32)

            # --- encode strings/objects as categorical codes (to keep format='fixed')
            #     store mapping for reconstruction; -1 will represent NaN
            cat_map = {}
            obj_cols = combined.select_dtypes(include=["object"]).columns
            # strongly recommend encoding these (very repetitive paths)
            must_encode = {"Source File", "First File", "Last File"} & set(obj_cols)

            for col in obj_cols:
                # Decide if encoding is worth it: always for must_encode, else low-cardinality
                nunique = combined[col].nunique(dropna=False)
                if (col in must_encode) or (nunique <= 0.5 * len(combined)):
                    cat = pd.Categorical(combined[col], ordered=False)
                    cat_map[col] = cat.categories.tolist()
                    # use smallest int that can hold -1 and max code
                    codes = cat.codes.astype(np.int32)
                    combined[col] = codes
                else:
                    # If you really want to keep these as strings, *convert to bytes*
                    # or skip entirely; better is to encode everything to codes for size.
                    cat = pd.Categorical(combined[col], ordered=False)
                    cat_map[col] = cat.categories.tolist()
                    combined[col] = cat.codes.astype(np.int32)

            # pick compression with fallback
            complib = "bzip2"
            try:
                # test if available on this build
                pd.HDFStore(str(unique_path / '___tmp__.h5'), mode='w', complib=complib).close()
                (unique_path / '___tmp__.h5').unlink(missing_ok=True)
            except Exception:
                complib = "blosc"  # fast & good ratio
                try:
                    pd.HDFStore(str(unique_path / '___tmp__.h5'), mode='w', complib=complib).close()
                    (unique_path / '___tmp__.h5').unlink(missing_ok=True)
                except Exception:
                    complib = "zlib"  # last resort, always available

            hdf5_dir = unique_path / 'hdf5'
            hdf5_dir.mkdir(parents=True, exist_ok=True)
            timestamp = time.strftime("%Y%m%d-%H%M%S")
            h5_file = hdf5_dir / f"burst_data_{timestamp}.h5"

            progress.update_text(f"Writing HDF5 file: {h5_file.name}")

            # Write with FIXED format (dense), no index
            with pd.HDFStore(h5_file, mode='w', complib=complib, complevel=9) as store:
                store.put('results', combined, format='fixed', index=False)
                st = store.get_storer('results')
                # Save mapping; -1 in codes = NaN
                st.attrs.category_map = json.dumps(cat_map)

            current_task += 1
            progress.update_progress(current_task, "HDF5 file created (compact)")
            logger.debug("HDF5 compact file written: %s (complib=%s)", h5_file, complib)
            if progress.wasCanceled():
                logger.warning("Operation canceled by user during HDF5 writing.")
                return

        # SL5 output
        if "sl5" in output_types:
            sl5_dir = unique_path / 'sl5'
            for filename in self.settings['tttr_filenames']:
                fn = Path(filename)
                sl5_dir.mkdir(parents=True, exist_ok=True)
                progress.update_text(f"Saving SL5 file: {fn.stem}.json.gz")
                data = {
                    'filename': os.path.relpath(fn, unique_path),
                    'filetype': self.filetype,
                    'count_rate_filter': self.settings['count_rate_filter'],
                    'delta_macro_time_filter': self.settings['delta_macro_time_filter'],
                    'filter': chisurf.fio.compress_numpy_array(self.selected)
                }
                output_file = sl5_dir / f"{fn.stem}.json.gz"
                with io.open_maybe_zipped(output_file, 'w') as f:
                    f.write(json.dumps(data))
                current_task += 1
                progress.update_progress(current_task)
                logger.debug("Saved SL5 file: %s", fn)
                if progress.wasCanceled():
                    logger.warning("Operation canceled by user during SL5 saving.")
                    return

        # Save parameters Info
        info_dir = unique_path / 'Info'
        info_dir.mkdir(parents=True, exist_ok=True)
        params = self.get_burst_selection_parameters()
        params.update({
            'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
            'selected_setup': self.comboBox.currentText(),
            'channels': self.channels,
            'decay_coarse': self.decay_coarse,
            **({"microtime_ranges": self.microtime_ranges} if self.microtime_ranges else {}),
            'files': [Path(f).name for f in self.settings['tttr_filenames']]
        })
        with open(info_dir / 'photon_selection_parameters.json', 'w') as f:
            json.dump(params, f, indent=4)
        with open(info_dir / 'datetime.txt', 'w') as f:
            now = datetime.now()
            f.write(f"Date: {now.strftime('%Y-%m-%d')}\nTime: {now.strftime('%H:%M:%S')}\n")
        current_task += 1
        progress.update_progress(current_task)

        # Finish or zip
        if not zip_output:
            progress.finish("Selection saved successfully")
        else:
            # Zip the first output directory
            zip_target = unique_path
            progress.update_text(f"Zipping output folder: {zip_target}")
            current_task += 1
            progress.update_progress(current_task)
            zip_file = self.zip_output_folder(zip_target, progress)
            if zip_file and remove_folder:
                shutil.rmtree(zip_target)
            progress.finish("ZIP archive completed")

    def fill_pie_windows(self, k):
        self.windows = k
        self.comboBox_3.addItems(k.keys())

    def zip_output_folder(self, output_folder, existing_progress=None, add_timestamp=False):
        """
        Zip the output folder and its contents.

        Parameters:
        -----------
        output_folder : pathlib.Path
            Path to the folder to be zipped.
        existing_progress : QtWidgets.QProgressDialog, optional
            An existing progress dialog to use instead of creating a new one.
        add_timestamp : bool, optional
            Whether to add a timestamp to the zip filename. Default is False.

        Returns:
        --------
        pathlib.Path
            Path to the created zip file.
        """
        if not output_folder.exists() or not output_folder.is_dir():
            return None

        def unique_zip_path(base: pathlib.Path) -> pathlib.Path:
            """Return a unique zip path alongside base (base.name.zip, or _N.zip if taken)."""
            first = base.parent / f"{base.name}.zip"
            if not first.exists():
                return first
            i = 0
            while True:
                cand = base.parent / f"{base.name}_{i}.zip"
                if not cand.exists():
                    return cand
                i += 1

        if add_timestamp:
            timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
            zip_filename = output_folder.parent / f"{output_folder.name}_{timestamp}.zip"
            # belt & suspenders in case a same-timestamp file exists:
            if zip_filename.exists():
                zip_filename = unique_zip_path(output_folder)
        else:
            zip_filename = unique_zip_path(output_folder)

        # Use existing progress dialog if provided, otherwise create a new one
        using_existing_progress = existing_progress is not None
        if not using_existing_progress:
            progress = EnhancedProgressDialog("Creating ZIP Archive", "Zipping output folder...", 0, 100, self)
            progress.show()
        else:
            progress = existing_progress
            progress.update_text("Zipping output folder...")

        progress.setValue(10)  # Show some initial progress
        QtWidgets.QApplication.processEvents()

        try:
            # Create the zip file
            with zipfile.ZipFile(zip_filename, 'w', zipfile.ZIP_DEFLATED) as zipf:
                # Get total number of files for better progress tracking
                total_files = sum([len(files) for _, _, files in os.walk(output_folder)])
                processed_files = 0

                # Walk through all files and subdirectories in the output folder
                for root, dirs, files in os.walk(output_folder):
                    # Convert root path to a pathlib.Path for easier manipulation
                    root_path = pathlib.Path(root)

                    # Add each file to the zip
                    for file in files:
                        file_path = root_path / file
                        # Calculate the relative path for the file in the zip
                        rel_path = file_path.relative_to(output_folder)
                        # Add the file to the zip
                        zipf.write(file_path, rel_path)

                        # Update progress based on files processed
                        processed_files += 1
                        progress_value = 10 + int(80 * processed_files / total_files) if total_files > 0 else 90
                        if isinstance(progress, EnhancedProgressDialog):
                            progress.update_progress(progress_value, f"Zipping: {rel_path}")
                        else:
                            progress.setValue(progress_value)
                            progress.setLabelText(f"Zipping: {rel_path}")
                            QtWidgets.QApplication.processEvents()

                        if progress.wasCanceled():
                            return None

            # Final progress update
            if isinstance(progress, EnhancedProgressDialog):
                progress.update_progress(100, "ZIP archive completed")
            else:
                progress.setValue(100)
                progress.setLabelText("ZIP archive completed")
                QtWidgets.QApplication.processEvents()

            return zip_filename

        except Exception as e:
            # Update progress dialog instead of showing a message box
            error_message = f"Error creating ZIP: {str(e)}"
            if isinstance(progress, EnhancedProgressDialog):
                progress.update_text(error_message)
                QtWidgets.QApplication.processEvents()
                time.sleep(2)  # Give user time to see the error
            elif using_existing_progress:
                progress.setLabelText(error_message)
                QtWidgets.QApplication.processEvents()
                time.sleep(2)  # Give user time to see the error
            else:
                # Only show message box if we created our own progress dialog and it's not enhanced
                QtWidgets.QMessageBox.critical(
                    self,
                    "Error Creating ZIP",
                    f"Failed to create ZIP archive: {str(e)}"
                )
            return None
        finally:
            # Close the progress dialog only if we created it
            if not using_existing_progress:
                progress.finish("ZIP operation completed")

    def fill_detectors(self, k):
        self.detectors = k
        # Add "All" option at the beginning
        self.comboBox_2.addItem("All")
        self.comboBox_2.addItems(k.keys())

    def update_detectors(self):
        """
        Sync the comboBox_2 selection to the channel lineEdit_4.
        If "All" is selected, empty the channel numbers line widget.
        """
        key = self.comboBox_2.currentText()
        if key == "All":
            # Empty the channel numbers line widget
            self.lineEdit_4.setText("")
        else:
            # Set the channel numbers from the selected detector
            s = ", ".join([str(i) for i in self.detectors[key]["chs"]])
            self.lineEdit_4.setText(s)
        self.update_parameter()

    def update_pie_windows(self):
        """
        Sync the comboBox_3 selection to the lineEdit_5 for microtime ranges.
        """
        key = self.comboBox_3.currentText()
        pie_win = self.windows[key]

        # Check if pie_win is a list/tuple or a single integer
        if isinstance(pie_win, (list, tuple)):
            # Handle list/tuple of windows
            formatted_windows = []
            # If pie_win is a list with exactly 2 elements and both are integers,
            # it's likely a single window definition [start, end]
            if len(pie_win) == 2 and all(isinstance(x, int) for x in pie_win):
                formatted_windows.append(f"{pie_win[0]}-{pie_win[1]}")
            else:
                # Otherwise, process each item in the list
                for i in pie_win:
                    if isinstance(i, (list, tuple)) and len(i) >= 2:
                        # Normal case: i is a tuple/list with at least two elements
                        formatted_windows.append(f"{i[0]}-{i[1]}")
                    elif isinstance(i, int):
                        # Handle case where i is a single integer
                        formatted_windows.append(f"{i}-{i}")
                    else:
                        # Skip invalid entries
                        continue
            s = ";".join(formatted_windows)
        elif isinstance(pie_win, int):
            # Handle case where pie_win is a single integer
            s = f"{pie_win}-{pie_win}"
        else:
            # Default to empty string for unexpected types
            s = ""

        self.lineEdit_5.setText(s)
        self.update_parameter()

    def load_available_setups(self):
        """
        Load available setups from the detector setups file and populate the comboBox.
        If no setups are available, display a message in the comboBox.
        """
        self.comboBox.clear()

        # Load setups from the detector setups file
        setups = load_detector_setups()
        setup_names = list(setups.get("setups", {}).keys())

        if not setup_names:
            # If no setups are available, add a placeholder item
            self.comboBox.addItem("No setups available")
        else:
            # Add all available setups to the comboBox
            self.comboBox.addItems(setup_names)

            # If there's a last used setup, select it
            if setups.get("last_used") and setups["last_used"] in setup_names:
                index = self.comboBox.findText(setups["last_used"])
                if index >= 0:
                    self.comboBox.setCurrentIndex(index)

    def get_burst_selection_parameters(self):
        """
        Collect all burst selection parameters from the UI.

        Returns:
            dict: A dictionary containing all burst selection parameters.
        """
        # Get the region selector values
        lb, ub = self.region_selector.getRegion()
        dT_min = 10.0 ** lb if self.pw_dT.getAxis('left').logMode else lb
        dT_max = 10.0 ** ub if self.pw_dT.getAxis('left').logMode else ub

        # Collect all parameters
        params = {
            "dT_min": dT_min,
            "dT_max": dT_max,
            "use_dT_min": self.checkBox_2.isChecked(),
            "use_dT_max": self.checkBox_3.isChecked(),
            "photon_threshold": self.spinBox.value(),
            "count_rate_window_ms": self.doubleSpinBox.value(),
            "invert_filter": self.checkBox.isChecked(),
            "filter_mode": self.used_filter,
            "filter_active": self.checkBox_4.isChecked(),
            "use_gap_fill": self.checkBox_5.isChecked(),
            "max_gap": self.spinBox_7.value(),
            "trace_bin_width": self.doubleSpinBox_4.value(),
            "number_of_burst_bins": self.spinBox_6.value(),
            "channels": self.channels,
            "decay_coarse": self.decay_coarse,
            "ph_window": self.ph_window
        }

        # Add microtime ranges if available
        if self.microtime_ranges:
            params["microtime_ranges"] = self.microtime_ranges

        # Add BOCPD parameters if BOCPD is selected
        if self.used_filter == 'bocpd':
            params.update({
                "bocpd_prior_count": self.bocpd_prior_count,
                "bocpd_prior_duration": self.bocpd_prior_duration,
                "bocpd_changepoint_prob": self.bocpd_changepoint_prob
            })

        # Add Kalman filter parameters if Kalman is selected
        if self.used_filter == 'kalman':
            params.update({
                "kalman_q": self.kalman_q,
                "kalman_r_scale": self.kalman_r_scale,
                "kalman_z_thresh": self.kalman_z_thresh,
                "kalman_min_len": self.kalman_min_len,
                "kalman_merge_gap": self.kalman_merge_gap
            })

        return params

    def save_burst_selection_parameters(self):
        """
        Save the burst selection parameters to the current setup.
        """
        setup_name = self.comboBox.currentText()
        if not setup_name or setup_name == "No setups available":
            QtWidgets.QMessageBox.warning(
                self,
                "No Setup Selected",
                "Please select a setup first to save burst selection parameters."
            )
            return False

        # Load setups from the detector setups file
        setups = load_detector_setups()

        # Check if the selected setup exists
        if setup_name in setups.get("setups", {}):
            # Get the burst selection parameters
            burst_params = self.get_burst_selection_parameters()

            # Add the burst selection parameters to the setup
            setup_data = setups["setups"][setup_name]
            setup_data["burst_selection"] = burst_params

            # Save the updated setups
            if save_detector_setups(setups):
                QtWidgets.QMessageBox.information(
                    self,
                    "Success",
                    f"Burst selection parameters saved to setup '{setup_name}' successfully."
                )
                return True
            else:
                QtWidgets.QMessageBox.critical(
                    self,
                    "Error",
                    f"Failed to save burst selection parameters to setup '{setup_name}'."
                )
                return False
        else:
            QtWidgets.QMessageBox.warning(
                self,
                "Invalid Setup",
                f"The selected setup '{setup_name}' does not exist."
            )
            return False

    def update_micro_time_binning(self, setup_name=None):
        """
        Update the micro time binning (Decay bin) from the selected setup.

        Args:
            setup_name (str, optional): The name of the setup to use. If None, uses the currently selected setup.
        """
        if setup_name is None:
            setup_name = self.comboBox.currentText()

        if not setup_name or setup_name == "No setups available":
            return

        # Load setups from the detector setups file
        setups = load_detector_setups()

        # Check if the selected setup exists
        if setup_name in setups.get("setups", {}):
            # Get the micro time binning from the setup's tttr_reading section
            setup_data = setups["setups"][setup_name]
            if "tttr_reading" in setup_data and "micro_time_binning" in setup_data["tttr_reading"]:
                micro_time_binning = setup_data["tttr_reading"]["micro_time_binning"]
                # Update the spinBox_5 value (decay_coarse)
                self.spinBox_5.setValue(micro_time_binning)
                # Update the plots
                self.update_plots()

    def update_channel_routing(self, setup_name=None):
        """
        Update the channel routing (detectors) from the selected setup.

        Args:
            setup_name (str, optional): The name of the setup to use. If None, uses the currently selected setup.
        """
        if setup_name is None:
            setup_name = self.comboBox.currentText()

        if not setup_name or setup_name == "No setups available":
            return

        # Load setups from the detector setups file
        setups = load_detector_setups()

        # Check if the selected setup exists
        if setup_name in setups.get("setups", {}):
            # Get the detectors from the setup
            setup_data = setups["setups"][setup_name]
            if "detectors" in setup_data:
                # Update the detectors attribute
                self.detectors = setup_data["detectors"]

                # Clear and repopulate comboBox_2
                self.comboBox_2.blockSignals(True)
                self.comboBox_2.clear()

                # Add "All" option at the beginning
                self.comboBox_2.addItem("All")

                # Add detector names
                self.comboBox_2.addItems(self.detectors.keys())

                # Select the first detector by default (or "All" if no detectors)
                if self.comboBox_2.count() > 0:
                    self.comboBox_2.setCurrentIndex(0)

                self.comboBox_2.blockSignals(False)

                # Update the channel numbers in lineEdit_4
                self.update_detectors()

    def update_pie_windows_from_setup(self, setup_name=None):
        """
        Update the PIE windows from the selected setup.

        Args:
            setup_name (str, optional): The name of the setup to use. If None, uses the currently selected setup.
        """
        if setup_name is None:
            setup_name = self.comboBox.currentText()

        if not setup_name or setup_name == "No setups available":
            return

        # Load setups from the detector setups file
        setups = load_detector_setups()

        # Check if the selected setup exists
        if setup_name in setups.get("setups", {}):
            # Get the windows from the setup
            setup_data = setups["setups"][setup_name]
            if "windows" in setup_data:
                # Update the windows attribute
                self.windows = setup_data["windows"]

                # Clear and repopulate comboBox_3
                self.comboBox_3.blockSignals(True)
                self.comboBox_3.clear()

                # Add window names
                self.comboBox_3.addItems(self.windows.keys())

                # Select the first window by default
                if self.comboBox_3.count() > 0:
                    self.comboBox_3.setCurrentIndex(0)

                self.comboBox_3.blockSignals(False)

                # Update the microtime ranges in lineEdit_5
                self.update_pie_windows()

    def update_spinbox_7_state(self):
        """
        Update the enabled state of spinBox_7 based on the state of checkBox_5.
        When gap filling is disabled (checkBox_5 is unchecked), spinBox_7 should be disabled.
        """
        self.spinBox_7.setEnabled(self.checkBox_5.isChecked())

    def setup_connections(self):
        """
        Set up all signal-slot connections for UI elements.
        This centralizes all connections in one place for better maintainability.
        """
        # Action connections
        self.actionUpdate_Values.triggered.connect(self.update_parameter)
        self.actionUpdateUI.triggered.connect(self.updateUI)
        self.actionFile_changed.triggered.connect(self.read_tttr)
        self.actionRegionUpdate.triggered.connect(self.onRegionUpdate)

        # Tool button connections
        self.toolButton_2.toggled.connect(self.pw_mcs.setVisible)
        self.toolButton_3.toggled.connect(self.pw_decay.setVisible)
        self.toolButton_4.toggled.connect(self.pw_filter.setVisible)
        self.toolButton_7.toggled.connect(self.pw_burst_histogram.setVisible)
        self.toolButton_5.clicked.connect(self.save_selection)
        self.toolButton_6.clicked.connect(self.onClearFiles)

        # Combo box connections
        self.comboBox_2.currentTextChanged.connect(self.update_detectors)
        self.comboBox_3.currentTextChanged.connect(self.update_pie_windows)
        self.comboBox.currentTextChanged.connect(self.update_micro_time_binning)
        self.comboBox.currentTextChanged.connect(self.update_burst_selection_parameters)
        self.comboBox.currentTextChanged.connect(self.update_channel_routing)
        self.comboBox.currentTextChanged.connect(self.update_pie_windows_from_setup)

        # BOCPD element connections
        self.doubleSpinBox_5.valueChanged.connect(self.update_parameter)  # Alpha
        self.doubleSpinBox_6.valueChanged.connect(self.update_parameter)  # Beta
        self.doubleSpinBox_7.valueChanged.connect(self.update_parameter)  # Hazard

        # Gap fill checkbox connection
        self.checkBox_5.stateChanged.connect(self.update_spinbox_7_state)

        # Save parameters button connection is already connected in the UI file
        # Removing duplicate connection to prevent the save_burst_selection_parameters method from being called twice

    def update_burst_selection_parameters(self, setup_name=None):
        """
        Update the burst selection parameters from the selected setup.

        Args:
            setup_name (str, optional): The name of the setup to use. If None, uses the currently selected setup.
        """
        if setup_name is None:
            setup_name = self.comboBox.currentText()

        if not setup_name or setup_name == "No setups available":
            return

        # Load setups from the detector setups file
        setups = load_detector_setups()

        # Check if the selected setup exists
        if setup_name in setups.get("setups", {}):
            # Get the burst selection parameters from the setup
            setup_data = setups["setups"][setup_name]
            if "burst_selection" in setup_data:
                burst_params = setup_data["burst_selection"]

                # Update the UI with the burst selection parameters
                if "dT_min" in burst_params and "dT_max" in burst_params:
                    dT_min = burst_params["dT_min"]
                    dT_max = burst_params["dT_max"]
                    if self.pw_dT.getAxis('left').logMode:
                        dT_min = np.log10(dT_min) if dT_min > 0 else -4  # Default to -4 if dT_min is 0 or negative
                        dT_max = np.log10(dT_max) if dT_max > 0 else 0   # Default to 0 if dT_max is 0 or negative
                    self.region_selector.setRegion((dT_min, dT_max))
                    self._dT_min = burst_params["dT_min"]
                    self._dT_max = burst_params["dT_max"]
                    self.doubleSpinBox_2.setValue(burst_params["dT_min"])
                    self.doubleSpinBox_3.setValue(burst_params["dT_max"])

                if "use_dT_min" in burst_params:
                    self.checkBox_2.setChecked(burst_params["use_dT_min"])

                if "use_dT_max" in burst_params:
                    self.checkBox_3.setChecked(burst_params["use_dT_max"])

                if "photon_threshold" in burst_params:
                    self.spinBox.setValue(burst_params["photon_threshold"])

                if "ph_window" in burst_params:
                    self.ph_window = burst_params["ph_window"]

                if "count_rate_window_ms" in burst_params:
                    self.doubleSpinBox.setValue(burst_params["count_rate_window_ms"])

                if "invert_filter" in burst_params:
                    self.checkBox.setChecked(burst_params["invert_filter"])
                # For backward compatibility
                elif "invert_count_rate_filter" in burst_params:
                    self.checkBox.setChecked(burst_params["invert_count_rate_filter"])

                if "filter_active" in burst_params:
                    self.checkBox_4.setChecked(burst_params["filter_active"])

                if "filter_mode" in burst_params:
                    if burst_params["filter_mode"] == "count_rate":
                        self.comboBox_burst_filter.setCurrentText("Count rate")
                    elif burst_params["filter_mode"] == "burst":
                        self.comboBox_burst_filter.setCurrentText("Burst")
                    elif burst_params["filter_mode"] == "bocpd":
                        self.comboBox_burst_filter.setCurrentText("BOCPD Burst")
                    elif burst_params["filter_mode"] == "kalman":
                        self.comboBox_burst_filter.setCurrentText("Kalman Burst")
                    else:
                        # Default to burst mode if unknown
                        self.comboBox_burst_filter.setCurrentText("Burst")

                # BOCPD parameters
                if burst_params.get("filter_mode") == "bocpd":
                    if "bocpd_prior_count" in burst_params:
                        self.bocpd_prior_count = burst_params["bocpd_prior_count"]
                    if "bocpd_prior_duration" in burst_params:
                        self.bocpd_prior_duration = burst_params["bocpd_prior_duration"]
                    if "bocpd_changepoint_prob" in burst_params:
                        self.bocpd_changepoint_prob = burst_params["bocpd_changepoint_prob"]

                # Kalman filter parameters
                if burst_params.get("filter_mode") == "kalman":
                    if "kalman_q" in burst_params:
                        self.kalman_q = burst_params["kalman_q"]
                    if "kalman_r_scale" in burst_params:
                        self.kalman_r_scale = burst_params["kalman_r_scale"]
                    if "kalman_z_thresh" in burst_params:
                        self.kalman_z_thresh = burst_params["kalman_z_thresh"]
                    if "kalman_min_len" in burst_params:
                        self.kalman_min_len = burst_params["kalman_min_len"]
                    if "kalman_merge_gap" in burst_params:
                        self.kalman_merge_gap = burst_params["kalman_merge_gap"]

                if "use_gap_fill" in burst_params:
                    self.checkBox_5.setChecked(burst_params["use_gap_fill"])

                if "max_gap" in burst_params:
                    self.spinBox_7.setValue(burst_params["max_gap"])

                if "trace_bin_width" in burst_params:
                    self.doubleSpinBox_4.setValue(burst_params["trace_bin_width"])

                if "number_of_burst_bins" in burst_params:
                    self.spinBox_6.setValue(burst_params["number_of_burst_bins"])

                self.spinBox_5.setValue(burst_params["decay_coarse"])

                # Update the plots
                self.update_plots()

    @chisurf.gui.decorators.init_with_ui("tttr_photon_filter.ui")
    def __init__(
            self,
            *args,
            windows,
            detectors,
            show_dT: bool = True,
            show_filter: bool = True,
            show_mcs: bool = True,
            show_decay: bool = True,
            show_burst: bool = True,
            default_mcs_dT: float = 1.0,
            default_dT_min: float = 0.0001,
            default_dT_max: float = 0.15,
            use_dT_min: bool = False,
            use_dT_max: bool = True,
            default_photon_threshold: int = 60,
            default_count_rate_window_ms: float = 1.0,
            invert_filter: bool = True,
            default_filter_mode: str = 'burst',
            use_gap_fill: bool = False,
            default_max_gap: int = 3,
            **kwargs
    ):
        """
        Initialize the photon-filter wizard.

        Parameters
        ----------
        windows : dict
            Dictionary of predefined PIE windows. Passed to the relevant UI comboBox.
        detectors : dict
            Dictionary of predefined detectors. Passed to the relevant UI comboBox.
        show_dT : bool, default=True
            Whether to show the dT plot (between consecutive photons).
        show_filter : bool, default=True
            Whether to show the filter/selection plot.
        show_mcs : bool, default=True
            Whether to show the intensity trace (MCS) plot.
        show_decay : bool, default=True
            Whether to show the microtime histogram (decay plot).
        show_burst : bool, default=True
            Whether to show the burst histogram.
        default_dT_min : float, default=0.0001
            Initial lower bound for delta macro-time filter (in ms).
        default_dT_max : float, default=0.15
            Initial upper bound for delta macro-time filter (in ms).
        default_mcs_dT : float, default=1.0
            Default / initial bin width value of intensity trace (in ms).
        use_dT_min : bool, default=False
            Whether the lower bound of delta macro-time filter is active initially.
        use_dT_max : bool, default=True
            Whether the upper bound of delta macro-time filter is active initially.
        default_photon_threshold : int, default=60
            Initial threshold for count rate or burst search.
        default_count_rate_window_ms : float, default=1.0
            Initial time window (ms) for count-rate based filtering.
        invert_filter : bool, default=True
            Whether to invert the filter initially (applies to all filter types).
        default_filter_mode : str, default='burst'
            Which filter mode radio button is selected by default.
            Valid: 'count_rate', 'burst', or 'bocpd'.
        use_gap_fill : bool, default=False
            Whether gap-filling is enabled by default.
        default_max_gap : int, default=3
            Maximum number of consecutive unselected photons that can be 'filled'
            when gap-filling is applied.

        Returns
        -------
        None. Initializes the UI and sets default widget states.
        """
        self.setTitle("Photon filter")
        self.windows = windows
        self.detectors = detectors
        self.fill_detectors(detectors)
        self.fill_pie_windows(windows)

        # Dictionary to store all TTTR objects
        self.tttr_objects = dict()

        # Load available setups for comboBox
        self.load_available_setups()

        # Main settings
        self.settings: dict = {}
        self.settings['tttr_filenames'] = []
        self.settings['count_rate_filter'] = {}
        self.settings['delta_macro_time_filter'] = {}
        # Initialize top-level invert_filter setting
        self.settings['invert_filter'] = invert_filter
        self.filter_data_saved = False

        # Cached resolved output directory for stable access across pages
        self._resolved_output_dir = None

        # Find the layout containing the burst filter combobox
        layout = self.comboBox_burst_filter.parentWidget().layout()

        # Connect the burst filter combobox to the actionUpdate_Values action
        self.comboBox_burst_filter.currentIndexChanged.connect(self.actionUpdate_Values.trigger)

        # Function to update parameter visibility based on selected filter mode
        def update_parameter_visibility(filter_mode):
            is_kalman = filter_mode == "Kalman Burst"
            is_bocpd = filter_mode == "BOCPD Burst"
            is_count_rate_or_burst = filter_mode in ["Count rate", "Burst"]

            # Show/hide Kalman filter parameters (label_15, label_16, label_17, doubleSpinBox_8, doubleSpinBox_9, doubleSpinBox_10)
            self.label_15.setVisible(is_kalman)  # Q parameter
            self.doubleSpinBox_8.setVisible(is_kalman)  # Q parameter
            self.label_16.setVisible(is_kalman)  # R scale parameter
            self.doubleSpinBox_9.setVisible(is_kalman)  # R scale parameter
            self.label_17.setVisible(is_kalman)  # Z threshold parameter
            self.doubleSpinBox_10.setVisible(is_kalman)  # Z threshold parameter
            self.label_18.setVisible(is_kalman)  # Min length parameter
            self.spinBox_9.setVisible(is_kalman)  # Min length parameter

            # Show/hide BOCPD parameters (label_12, label_13, label_14, doubleSpinBox_5, doubleSpinBox_6, doubleSpinBox_7)
            self.label_12.setVisible(is_bocpd)
            self.label_13.setVisible(is_bocpd)
            self.label_14.setVisible(is_bocpd)
            self.doubleSpinBox_5.setVisible(is_bocpd)
            self.doubleSpinBox_6.setVisible(is_bocpd)
            self.doubleSpinBox_7.setVisible(is_bocpd)

            # Show/hide count rate & burstwise parameters (label, label_11, spinBox, spinBox_8)
            # label_2 and doubleSpinBox should be shown only for "Count rate"
            is_count_rate = filter_mode == "Count rate"
            self.label_2.setVisible(is_count_rate)
            self.label.setVisible(is_count_rate_or_burst)
            self.label_11.setVisible(is_count_rate_or_burst)
            self.doubleSpinBox.setVisible(is_count_rate)
            self.spinBox.setVisible(is_count_rate_or_burst)
            self.spinBox_8.setVisible(is_count_rate_or_burst)

        # Assign the function to the instance
        self.update_parameter_visibility = update_parameter_visibility

        # Set up connections to show/hide parameters based on the selected filter mode
        self.comboBox_burst_filter.currentTextChanged.connect(self.update_parameter_visibility)

        # Initialize parameter visibility based on current selection
        self.update_parameter_visibility(self.comboBox_burst_filter.currentText())

        # Set the default filter mode
        if default_filter_mode == 'count_rate':
            self.comboBox_burst_filter.setCurrentText("Count rate")
        elif default_filter_mode == 'burst':
            self.comboBox_burst_filter.setCurrentText("Burst")
        elif default_filter_mode == 'bocpd':
            self.comboBox_burst_filter.setCurrentText("BOCPD Burst")
        elif default_filter_mode == 'kalman':
            self.comboBox_burst_filter.setCurrentText("Kalman Burst")

        def after_file_drop():
            """
            Callback to load *all* TTTR files after they're dropped or specified.
            Ensures that files with restricted extensions require explicit file type selection.
            If a folder is dropped, all files with supported extensions (found directly in that folder)
            are added.
            Uses tttrlib.inferTTTRFileType to automatically detect file types when possible.
            """
            # Generate allowed extensions dynamically from tttrlib
            allowed_extensions = {
                f".{ext.lower()}" if not ext.startswith('.') else ext.lower()
                for ext in tttrlib.TTTR.get_supported_container_names()
            }

            # Expand directories: if an entry in tttr_filenames is a folder,
            # replace it with all files (in that folder only) with allowed extensions.
            expanded_files = []
            for path_str in self.settings['tttr_filenames']:
                p = pathlib.Path(path_str).resolve()
                if p.is_dir():
                    for child in p.iterdir():
                        if child.is_file() and child.suffix.lower() in allowed_extensions:
                            expanded_files.append(str(child.resolve()))
                else:
                    expanded_files.append(str(p))
            # IMPORTANT: mutate the existing list in-place to preserve the drag/drop injector reference
            lst = self.settings.get('tttr_filenames')
            if isinstance(lst, list):
                lst[:] = expanded_files

            # List of restricted extensions requiring manual selection (if needed)
            RESTRICTED_EXTENSIONS = [".spc"]  # Extend or modify as required

            requires_filetype_selection = False
            restricted_files = []

            for fn in self.settings['tttr_filenames']:
                p = pathlib.Path(fn).resolve()
                file_extension = p.suffix.lower()

                # Check if the file extension requires explicit selection
                if file_extension in RESTRICTED_EXTENSIONS:
                    if self.filetype == "Auto":  # Only warn if no file type is preselected
                        requires_filetype_selection = True
                        restricted_files.append(p.name)

            if requires_filetype_selection:
                QtWidgets.QMessageBox.warning(
                    self, "File Type Required",
                    "The following files require an explicit file type selection before loading:\n\n"
                    + "\n".join(restricted_files)
                    + "\n\nPlease select the correct file type from the dropdown menu."
                )
                self.onClearFiles()
                return  # Prevent loading any files

            # Get file type from the selected setup
            file_type = self.filetype
            # If no setup is selected or the setup doesn't have a file type,
            # we've already shown a warning in the filetype property

            # Proceed with loading the files and showing progress
            total_files = len(self.settings['tttr_filenames'])
            progress_window = ProgressWindow(title="Loading Files", message="Processing files...",
                                             max_value=total_files, parent=self)
            progress_window.show()

            for i, fn in enumerate(self.settings['tttr_filenames'], start=1):
                p = pathlib.Path(fn).resolve()
                p_str = str(p)

                if p_str not in self.tttr_objects:
                    if p.exists() and p.is_file():
                        file_type = self.filetype
                        try:
                            if isinstance(file_type, str):
                                self.tttr_objects[p_str] = tttrlib.TTTR(p_str, file_type)
                            elif p.suffix.lower() not in RESTRICTED_EXTENSIONS:
                                # Use inferTTTRFileType for better auto-detection
                                file_type_int = tttrlib.inferTTTRFileType(p_str)
                                if file_type_int is not None and file_type_int >= 0:
                                    self.tttr_objects[p_str] = tttrlib.TTTR(p_str, file_type_int)
                                else:
                                    # Fall back to default auto-detection if inference fails
                                    self.tttr_objects[p_str] = tttrlib.TTTR(p_str)
                        except Exception as e:
                            progress_window.close()
                            QtWidgets.QMessageBox.critical(
                                self,
                                "Error Loading File",
                                f"Failed to load file '{p.name}' with the selected setup.\n\n"
                                f"Error: {str(e)}\n\n"
                                f"Please check that you have selected the correct setup for this file type."
                            )
                            self.onClearFiles()
                            return  # Exit early to prevent undefined state

                progress_window.set_value(i)

            progress_window.close()

            n_files = len(self.settings['tttr_filenames'])
            self.spinBox_4.setMaximum(n_files - 1)
            if n_files > 0:
                self.spinBox_4.setValue(n_files - 1)
            self.read_tttr()

        # Inject file-drop logic
        self.textEdit.setVisible(False)
        # Expose the drop handler so external code (e.g., batch processing) can reuse the standard flow
        self._after_file_drop = after_file_drop
        chisurf.gui.decorators.lineEdit_dragFile_injector(
            self.lineEdit, call=after_file_drop, target=self.settings['tttr_filenames']
        )

        sizePolicy = QtWidgets.QSizePolicy(
            QtWidgets.QSizePolicy.Expanding,
            QtWidgets.QSizePolicy.Expanding
        )
        self.setSizePolicy(sizePolicy)

        # Internal TTTR reference
        self.tttr = None

        # Initialize dT_min/dT_max
        self._dT_min = default_dT_min
        self._dT_max = default_dT_max

        # Create & configure plots
        self.pw_burst_histogram = pg.PlotWidget(parent=self, title="Burst histogram")
        self.plot_burst_histogram = self.pw_burst_histogram.getPlotItem()
        self.pw_burst_histogram.setLabel('left', 'Counts')
        self.pw_burst_histogram.setLabel('bottom', 'Burst size (Nbr. Photons)')
        self.pw_burst_histogram.resize(100, 80)

        color_all = QtGui.QColor(255, 255, 0, 64)
        color_selected = QtGui.QColor(0, 255, 255, 255)
        pen2 = pg.mkPen(color_all, width=1, style=QtCore.Qt.SolidLine)
        pen1 = pg.mkPen(color_selected, width=1, style=QtCore.Qt.SolidLine)

        self.pw_dT = pg.PlotWidget(parent=self, title="Delta macro-time")
        self.pw_dT.setLabel('left', 'dT (ms)')
        self.pw_dT.setLabel('bottom', 'Photon Index')
        self.plot_item_dt = self.pw_dT.getPlotItem()
        self.plot_unselected = self.plot_item_dt.plot(x=[1.0], y=[1.0], pen=pen2)
        self.plot_selected = self.plot_item_dt.plot(x=[1.0], y=[1.0], pen=pen1)
        self.pw_dT.resize(200, 40)

        self.pw_mcs = pg.PlotWidget(parent=self, title="Count rate display")
        self.pw_mcs.setLabel('left', 'Intensity (kHz)')
        self.pw_mcs.setLabel('bottom', 'Time (s)')
        self.plot_item_mcs = self.pw_mcs.getPlotItem()
        self.plot_mcs_all = self.plot_item_mcs.plot(x=[1.0], y=[1.0], pen=pen2)
        self.plot_mcs_selected = self.plot_item_mcs.plot(x=[1.0], y=[1.0], pen=pen1)
        self.pw_mcs.resize(200, 80)

        self.pw_decay = pg.PlotWidget(parent=self, title="Microtime histogram")
        self.pw_decay.setLabel('left', 'Counts')
        self.pw_decay.setLabel('bottom', 'Microtime (ns)')
        self.plot_item_decay = self.pw_decay.getPlotItem()
        self.plot_decay_all = self.plot_item_decay.plot(x=[1.0], y=[1.0], pen=pen2)
        self.plot_decay_selected = self.plot_item_decay.plot(x=[1.0], y=[1.0], pen=pen1)
        self.pw_decay.resize(200, 80)

        self.pw_filter = pg.PlotWidget(parent=self, title="Filter/selection")
        self.pw_filter.setLabel('left', 'Selected (1) / Unselected (0)')
        self.pw_filter.setLabel('bottom', 'Photon Index')
        self.pw_filter.setXLink(self.pw_dT)
        self.pw_dT.setMouseEnabled(x=False, y=False)
        self.pw_filter.setMouseEnabled(x=False, y=False)
        self.plot_item_sel = self.pw_filter.getPlotItem()
        self.plot_select = self.plot_item_sel.plot(x=[1.0], y=[1.0])
        self.pw_filter.resize(200, 20)

        self.plot_item_dt.setLogMode(False, True)
        self.plot_item_decay.setLogMode(False, True)
        self.plot_item_sel.setLogMode(False, False)

        ca = list(matplotlib.colors.hex2color(colors["region_selector"]))
        co = [ca[0] * 255, ca[1] * 255, ca[2] * 255, colors["region_selector_alpha"]]
        self.region_selector = pg.LinearRegionItem(
            brush=co, orientation='horizontal',
            values=(np.log10(self._dT_min), np.log10(self._dT_max))
        )
        self.pw_dT.addItem(self.region_selector)

        def onRegionUpdate(evt):
            lb, ub = self.region_selector.getRegion()
            if self.pw_dT.getAxis('left').logMode:
                lb, ub = 10 ** lb, 10 ** ub
            self._dT_min = lb
            self._dT_max = ub
            self.doubleSpinBox_2.blockSignals(True)
            self.doubleSpinBox_3.blockSignals(True)
            self.doubleSpinBox_2.setValue(lb)
            self.doubleSpinBox_3.setValue(ub)
            self.doubleSpinBox_2.blockSignals(False)
            self.doubleSpinBox_3.blockSignals(False)
            self.update_plots()
            self.update_output_path()

        self.region_selector.sigRegionChangeFinished.connect(onRegionUpdate)

        # Place plots in layout
        self.gridLayout_6.addWidget(self.pw_dT, 0, 0, 1, 3)
        self.gridLayout_6.addWidget(self.pw_filter, 1, 0, 1, 3)
        self.gridLayout_6.addWidget(self.pw_mcs, 2, 0, 1, 1)
        self.gridLayout_6.addWidget(self.pw_decay, 2, 1, 1, 1)
        self.gridLayout_6.addWidget(self.pw_burst_histogram, 0, 1, 2, 1)

        # Setup all signal-slot connections
        self.setup_connections()

        # Custom validator
        validator = CommaSeparatedIntegersValidator()
        self.lineEdit_4.setValidator(validator)

        # Initialize defaults
        self.spinBox.setValue(default_photon_threshold)
        self.doubleSpinBox.setValue(default_count_rate_window_ms)
        self.checkBox.setChecked(invert_filter)
        self.checkBox_2.setChecked(use_dT_min)
        self.checkBox_3.setChecked(use_dT_max)
        self.doubleSpinBox_4.setValue(default_mcs_dT)

        # -- NEW: set default gap-fill checkbox/spinbox --
        self.checkBox_5.setChecked(use_gap_fill)  # <--- gap-fill checkbox
        self.spinBox_7.setValue(default_max_gap)  # <--- max-gap spinbox
        self.update_spinbox_7_state()  # Update spinBox_7 enabled state based on checkBox_5

        # Control initial plot visibility
        self.pw_dT.setVisible(show_dT)
        self.toolButton_4.setChecked(show_filter)
        self.toolButton_2.setChecked(show_mcs)
        self.toolButton_3.setChecked(show_decay)
        self.toolButton_7.setChecked(show_burst)

        # -- Set the default filter mode --
        if default_filter_mode == 'burst':
            self.comboBox_burst_filter.setCurrentText("Burst")
        else:
            # Fallback or default to 'count_rate'
            self.comboBox_burst_filter.setCurrentText("Count rate")

        # Update micro time binning and burst selection parameters from the selected setup
        self.update_micro_time_binning()
        self.update_burst_selection_parameters()

        # Final initial update
        self.update_parameter()
