#!/usr/bin/env python
"""
Parser module for event and state segmentation in current recordings.
This module provides a framework for parsing current trace data. It includes an abstract base `Parser` class
and multiple concrete parser implementations for detecting spikes, noise, square pulses, and other event
structures in ionic current signals.

Some of the parsers and the parser base class were adapted from the
PyPore Package by Jacob Schreiber and Kevin Karplus (https://github.com/jmschrei/PyPore) -- Original License included in "PYPORE_LICENSE.txt".

"""


# import sys
# from itertools import tee, chain
# import re
#
# try:
#     import cupy
#     import cupyx.scipy.signal as signal
#     if not cupy.cuda.is_available():
#         raise ImportError
#
#     np = cupy
# except ImportError:
#
#     import numpy as np
#     from scipy import signal

from typing import TypeVar
from itertools import tee
# import time
import json
from scipy import signal
from scipy.fft import fft
import numpy as np
import pyximport
from ionique.core import Segment, MetaSegment
import abc
from ionique.cparsers import FastStatSplit
pyximport.install(setup_args={'include_dirs': np.get_include()})
AnyParser = TypeVar("AnyParser", bound="Parser")


#########################################
# EVENT PARSERS
#########################################


class Parser(abc.ABC):
    """
    This class defines the interface and shared utility methods for parser implementations.
    Parsers are used to segment  ionic current data
    into structured regions of interest (events).

    Subclasses should implement the `parse` method and define any additional logic
    required to detect features in the data.
    """
    # override this with the required attributes to get
    # from the parent segment that the parser needs
    required_parent_attributes: list[str] = ['current']
    @abc.abstractmethod
    def __init__(self):
        """
        Initialize a Parser instance
        """
        pass

    @classmethod
    def get_name(cls):
        """
        Get the name of the parser class.

        Returns
        -------
        str
            The name of the class.
        """
        return cls.__name__

    @classmethod
    def get_init_params(cls):
        """
        Get the initialization parameters of the parser class.

        Returns
        -------
        dict or None
            A dictionary of initialization parameters, or None if not defined.
        """
        return getattr(cls, "_params", None)

    @classmethod
    def get_process_inputs(cls):
        """Return the required inputs for the parse process.

        Returns
        -------
        dict or None
            A dictionary of parse input specifications, or None if not defined.
        """
        return getattr(cls, "_parse_inputs", None)

    @classmethod
    def get_process_outputs(cls):
        """
        Get the outputs generated by processing.

        Returns
        -------
        list or None
            A list of outputs generated by the parse method, or None if not defined.
        """
        return getattr(cls, "_parse_outputs", None)

    # @classmethod
    def get_required_parent_attributes(self):
        """
        Get the list of required attributes from the parent segment.

        Returns
        -------
        list[str]
            A list of required parent attribute names.
        """
        return getattr(self, "required_parent_attributes", [])

    def __repr__(self):
        """
        Return a representation of the parser in the form of all arguments.

        Returns
        -------
        str
            A JSON representation of the parser's attributes.
        """
        return self.to_json()

    def to_dict(self):
        """
        Convert the parser's attributes to a dictionary.

        Returns
        -------
        dict
            A dictionary representation of the parser's attributes.
        """

        dict_attr = {key: val for key, val in list(self.__dict__.items())
                     if key != 'param_dict' if type(val) in (int, float)
                     or ('Qt' not in repr(val)) and 'lambda' not in repr(val)}
        dict_attr['name'] = self.__class__.__name__
        return dict_attr

    def to_json(self, filename=False):
        """
        Convert the parser's attributes to a JSON string.

        Parameters
        ----------
        filename : str or bool, optional
            If a filename is given, writes the JSON string to the specified file.
            Defaults to False.

        Returns
        -------
        str
            A JSON string representation of the parser's attributes.
        """

        _json = json.dumps(self.to_dict(), indent=4, separators=(',', ' : '))
        if filename:
            with open(filename, 'w') as out:
                out.write(_json)
        return _json

    def parse(self, **kwargs):
        """
        Take in a current segment and return a list of segment boundary tuples.

        Subclasses must implement this.

        Parameters
        ----------
        **kwargs : dict
            Keyword arguments containing the data, such as ``current``.

        Returns
        -------
        list[tuple]
            A list of tuples representing the start index, end index, and a
            feature dictionary for each detected segment.
        """
        current = kwargs['current']
        return [(0, current.shape[0], {})]
        # return [ Segment( current=current, start=0, duration=current.shape[0]/100000 ) ]

    def set_params(self, param_dict):
        """
        Update each parameter to the value provided in the given dictionary.

        Parameters
        ----------
        param_dict : dict
            A dictionary mapping parameter names to their new values.
        """
        try:
            # for key, lineEdit in list(self.param_dict.items()):
            #     val = lineEdit.text()
            #     if '.' in val:
            #         setattr( self, key, float( val ) )
            #         continue
            #     for i, letter in enumerate(val):
            #         if not letter.isdigit():
            #             setattr( self, key, str( val ) )
            #             continue
            #         if i == len(val):
            #             setattr( self, key, int( val ) )
            self.param_dict = param_dict
            for key, value in self.param_dict.items():
                setattr(self, key, value)
        except:  # Add Error?
            pass

    @classmethod
    def from_json(self, _json):
        """
        Reconstruct a Parser instance from a JSON string or file.

        Parameters
        ----------
        _json : str
            A JSON string or path to a ``.json`` file to load.

        Returns
        -------
        Parser or None
            The reconstructed parser instance, or None.

        Notes
        -----
        Not yet implemented.
        """
        if _json.endswith(".json"):
            with open(_json, 'r') as infile:
                _json = ''.join(line for line in infile)

        d = json.loads(_json)
        name = d['name']
        del d['name']

        return None  # getattr( PyPore.parsers, name )( **d )


class SpikeParser(Parser):
    """
    A parser for detecting spike-like events in ionic current data.

    This parser wraps `scipy.signal.find_peaks`, supporting thresholds
    for features like peak height, prominence, and width. It computes event characteristics such
    as dwell time, relative prominence, and peak positions.
    """
    required_parent_attributes = ["current", "sampling_freq", "mean", "start", "std"]
    _fractionable_attrs = ['height', 'threshold', 'prominence']
    _time_attrs = ['distance', 'prominence', 'width', 'wlen', 'plateau_size']
    _other_attrs = ['rel_height']

    def __init__(self, height=None, threshold=None, distance=None,
                 prominence=None, prominence_snr=None, width=None,
                 wlen=None, rel_height: float = 0.5, plateu_size=None,
                 fractional: bool = True) -> None:
        # Add super()__init__ to instantiate class Parser?
        # super().__init__()
        """
        Initialize the SpikeParser.

        Parameters
        ----------
        height : float or tuple or None, optional
            Minimum spike height. Defaults to None.
        threshold : float or tuple or None, optional
            Minimum vertical threshold. Defaults to None.
        distance : float or tuple or None, optional
            Minimum horizontal distance between peaks. Defaults to None.
        prominence : float or tuple or None, optional
            Minimum peak prominence. Defaults to None.
        prominence_snr : tuple or None, optional
            Alternative to prominence, specified as a tuple of signal-to-noise
            (SNR) ratios. Defaults to None.
        width : float or tuple or None, optional
            Full width at half maximum of the peak. Defaults to None.
        wlen : float or None, optional
            Width of the window used to calculate prominence. Defaults to None.
        rel_height : float, optional
            Relative height used in width calculation (range 0 to 1).
            Defaults to 0.5.
        plateu_size : float or None, optional
            Minimum length of a flat-topped peak. Defaults to None.
        fractional : bool, optional
            Whether to interpret height, prominence, etc., as a fraction of the
            signal mean. Defaults to True.
        """
        self._height = height
        self._threshold = threshold
        self._distance = distance
        self._prominence = prominence
        self._prominence_snr = prominence_snr
        self._width = width
        self._wlen = wlen
        self._rel_height = rel_height
        self._plateau_size = plateu_size
        self._fractional = fractional

    def _calculate_absolute_from_fractionals(self, mean):
        """Calculate absolute parameter values from fractional ones given segment mean."""
        pass

    def parse(self, current, sampling_freq, mean, std, start):
        """
        Detect spike events in the current array using ``scipy.signal.find_peaks``.

        Parameters
        ----------
        current : numpy.ndarray
            Array of ionic current samples to search for peaks.
        sampling_freq : float
            Sampling frequency of the data in Hz.
        mean : float
            Mean current level of the parent segment, used to scale fractional
            parameters.
        std : float
            Standard deviation of the parent segment current.
        start : int
            Absolute start index of the segment within the full recording.

        Returns
        -------
        generator
            Yields ``(event_start, event_end, unique_features)`` tuples for
            each detected spike, where ``unique_features`` contains keys such
            as ``idx_rel``, ``idx_abs``, ``peak_val``, ``deli``, ``dwell``,
            ``dt``, and ``frac``.
        """
        from scipy import signal
        abs_start = start
        for key in self._time_attrs:
            _key = '_' + key
            attr = getattr(self, _key, None)
            # if attr != None:
            if attr is not None:
                if type(attr) in [tuple, list]:
                    # print(sampling_freq)
                    setattr(self, key,
                            (sampling_freq * attr[0] if attr[0] is not None else None,
                             sampling_freq * attr[1] if attr[0] is not None else None))
                else:
                    setattr(self, key, sampling_freq * attr)
            else:
                setattr(self, key, attr)

        for key in self._fractionable_attrs:
            _key = '_' + key
            attr = getattr(self, _key, None)
            if self._fractional:
                # if attr !=None:
                if attr is not None:
                    if type(attr) in [tuple, list]:
                        setattr(self, key,
                                (mean * attr[0] if attr[0] is not None else None,
                                 mean * attr[1] if attr[0] is not None else None))
                    else:
                        setattr(self, key, mean * attr)
                else:
                    setattr(self, key, attr)
            else:
                setattr(self, key, attr)

        if self._prominence_snr is not None and self._fractional:
            if type(self._prominence_snr) in [tuple, list]:
                try:
                    iticks = np.linspace(0, len(current), 11).astype(int)
                    std_rolling = np.array([np.std(current[ii:jj])
                                            for ii, jj in zip(iticks[:-1], iticks[1:])])
                    mean_rolling = np.array([np.mean(current[ii:jj])
                                             for ii, jj in zip(iticks[:-1], iticks[1:])])
                    std_1stq = np.quantile(std_rolling, q=0.25)
                    mean_1stq = np.quantile(mean_rolling, q=0.25)
                    level = std_1stq / np.sqrt(np.abs(mean_1stq))
                    self.prominence = (self._prominence_snr[0] * level
                                       if self._prominence_snr[0] is not None else None,
                                       self._prominence_snr[1] * np.abs(mean_1stq)
                                       if self._prominence_snr[1] is not None
                                       else self.prominence[1]
                                       if type(self.prominence) in [list, tuple] else None)
                except Exception as error:
                    print("PARSING ERROR:\n", error)
                    print("using whole mean/std instead of median of rolling...")
                    level = std / np.sqrt(np.abs(mean))
                    self.prominence = (self._prominence_snr[0] * level
                                       if self._prominence_snr[0] is not None else None,
                                       self._prominence_snr[1] * np.abs(mean)
                                       if self._prominence_snr[1] is not None else
                                       self.prominence[1]
                                       if type(self.prominence) in [list, tuple] else None)

        for key in self._other_attrs:
            setattr(self, key, getattr(self, "_" + key))

        all_keys = self._fractionable_attrs + self._time_attrs + self._other_attrs
        find_peaks_args = dict([(key, getattr(self, key)) for key in all_keys])
        peaks, props = signal.find_peaks(current, ** find_peaks_args)
        dt = np.array(0)
        dt = np.append(dt, np.diff(peaks) / sampling_freq)
        for i, peak in enumerate(peaks):
            event_start = round(props["left_ips"][i])
            event_end = round(props["right_ips"][i])
            unique_features = {
                "idx_rel": peak,
                "idx_abs": abs_start + peak,
                "peak_val": current[peak],
                "deli": props["prominences"][i],
                "dwell": props["widths"][i] * 1e6 / sampling_freq,
                "dt": dt[i],
                "frac": props["prominences"][i] / mean}
            yield event_start, event_end, unique_features
        return


class MemoryParse(object):
    """
    A parser based on being fed previous split points, and splitting a raw file based
    those splits. Used predominately when loading previous split points from the
    database cache, to reconstruct a parsed file from "memory.
    """
    def __init__(self, starts, ends):
        """
        Initialize MemoryParse with precomputed split boundaries.

        Parameters
        ----------
        starts : list[int]
            A list of start indices for each segment.
        ends : list[int]
            A list of end indices corresponding to each start index.
        """
        self.starts = starts
        self.ends = ends

    def parse(self, current):
        """Reconstruct segments from previously recorded start/end positions.

        Parameters
        ----------
        current : numpy.ndarray
            The ionic current array to slice into segments.

        Returns
        -------
        list[Segment]
            Segments constructed from the stored start/end pairs.
        """
        return [Segment(current=np.array(current[int(s): int(e)], copy=True),
                        start=s, duration=e - s) for s, e in zip(self.starts, self.ends)]


class lambda_event_parser(Parser):  # Rename class to Upper case!
    """
    A simple rule-based parser which defines events as a sequential series
    of points which are below a certain threshold, then filtered based on other
    critereon such as total time or minimum current. Rules can be passed in at
    initiation, or set later, but must be a lambda function takes in a PreEvent
    object and performs some boolean operation.
    """

    def __init__(self, threshold=90, rules=None):
        """
        Initialize the lambda_event_parser.

        Parameters
        ----------
        threshold : float, optional
            The threshold for detecting events (e.g., current < threshold).
            Defaults to 90.
        rules : list[callable] or None, optional
            A list of lambda functions that accept an event and return a bool.
            Defaults to None, which applies a default set of rules.
        """
        super().__init__()  # Add super()__init__ to instantiate class Parser?
        self.threshold = threshold
        self.rules = rules or [lambda event: event.duration > 100000,
                               lambda event: event.min > -0.5,
                               lambda event: event.max < self.threshold]

    def _lambda_select(self, events):
        """
        From all of the events, filter based on whatever set of rules has been initiated with.
        """
        return [event for event in events if np.all([rule(event) for rule in self.rules])]

    def parse(self, current):
        """
        Perform a large capture of events by creating a boolean mask for when
        the current is below a threshold, then detecting the edges in those masks,
        and using the edges to partitition the sample. The events are
        then filtered before being returned.
        """
        # Find where the current is below a threshold, replace with 1's
        mask = np.where(current < self.threshold, 1, 0)
        # Find the edges, marking them with a 1, by derivative
        mask = np.abs(np.diff(mask))
        tics = np.concatenate(([0], np.where(mask == 1)[0] + 1, [current.shape[0]]))
        del mask
        events = [Segment(current=np.array(current), copy=True, start=tics[i],
                          duration=current.shape[0])
                  for i, current in enumerate(np.split(current, tics[1:-1]))]
        return [event for event in self._lambda_select(events)]

    def set_params(self):
        """
        Read in the data from the GUI and use it to customize
        the rules or threshold of the parser.
        """
        self.rules = []
        self.threshold = float(self.threshInput.text())
        self.rules.append(lambda event: event.max < self.threshold)
        if self.minCurrentInput.text() != '':
            self.rules.append(lambda event: event.min > float(self.minCurrentInput.text()))
        if self.timeInput.text() != '':
            if str(self.timeDirectionInput.currentText()) == '<':
                self.rules.append(lambda event: event.duration < float(self.timeInput.text()))
            elif str(self.timeDirectionInput.currentText()) == '>':
                self.rules.append(lambda event: event.duration > float(self.timeInput.text()))
        if self.rules == []:
            self.rules = None


def pairwise(iterable):
    """
    Return successive overlapping pairs from an iterable.

    ``s -> (s0,s1), (s1,s2), (s2, s3), ...``

    Parameters
    ----------
    iterable : iterable
        The input iterable to consume pairwise.

    Returns
    -------
    zip
        An iterator of consecutive overlapping 2-tuples.
    """
    cur_item, next_item = tee(iterable)
    next(next_item, None)
    return zip(cur_item, next_item)


class SpeedyStatSplit(Parser):
    """
    A parser wrapper for the Cython-accelerated `FastStatSplit` algorithm.

    See cparsers.pyx FastStatSplit for full documentation. This is just a
    wrapper for the cyton implementation to add a GUI.
    """
    uiTemplate = [("sampling_freq", "spin",
                   {'value': 200000, 'step': 1, 'dec': True, 'bounds': [0, None],
                    'suffix': 'Hz', 'siPrefix': True}),
                  ("min_width", "spin", {"value": 100, 'step': 1, 'dec': True,
                                         'min': 2, "int": True}),
                  ("max_width", "spin", {"value": 1000000, 'step': 1, 'dec': True, "int": True}),
                  ("window_width", "spin", {"value": 10000, 'step': 1, 'dec': True, "int": True}),
                  #  ("min_gain_per_sample", "(float,None,"hidden"),
                  #  "false_positive_rate":(float,None,"hidden")
                  #  "prior_segments_per_second":(float,None,"hidden"),
                  ("cutoff_freq", "spin", {"hidden": True})]
    _parse_return_type = (list, Segment)
    _parse_meta_return_type = (list, MetaSegment)

    _parse_inputs = [("Current", np.ndarray)]
    _parse_outputs = [("Segments", list)]

    def __init__(self, sampling_freq, min_width=100, max_width=1000000, window_width=10000,
                 min_gain_per_sample=None, false_positive_rate=None,
                 prior_segments_per_second=None, cutoff_freq=None):
        """
        Initialize the SpeedyStatSplit instance.

        Parameters
        ----------
        sampling_freq : int
            The sampling frequency of the data in Hz.
        min_width : int, optional
            Minimum width of a segment in samples. Defaults to 100.
        max_width : int, optional
            Maximum width of a segment in samples. Defaults to 1000000.
        window_width : int, optional
            Width of the sliding window in samples. Defaults to 10000.
        min_gain_per_sample : float or None, optional
            Minimum gain per sample for a split to be accepted. Defaults to None.
        false_positive_rate : float or None, optional
            Expected false positive rate for split detection. Defaults to None.
        prior_segments_per_second : float or None, optional
            Prior number of segments per second. Defaults to None.
        cutoff_freq : float or None, optional
            Cutoff frequency for optional pre-filtering. Defaults to None.
        """

        self.min_width = min_width
        self.max_width = max_width
        self.min_gain_per_sample = min_gain_per_sample
        self.window_width = window_width
        self.prior_segments_per_second = prior_segments_per_second
        self.false_positive_rate = false_positive_rate
        self.sampling_freq = sampling_freq
        self.cutoff_freq = cutoff_freq

    def parse(self, current):
        """
        Parse the current data to detect segments using FastStatSplit.

        Parameters
        ----------
        current : numpy.ndarray
            The ionic current data to be parsed.

        Returns
        -------
        list[tuple]
            A list of ``(start, end, {})`` tuples representing each detected
            segment boundary.
        """
        parser = FastStatSplit(self.min_width, self.max_width,
                               self.window_width, self.min_gain_per_sample,
                               self.false_positive_rate, self.prior_segments_per_second,
                               self.sampling_freq, self.cutoff_freq)
        results=parser.parse(current)

        #print([(seg.start,seg.end,{}) for seg in results])
        return [(seg.start,seg.end,{}) for seg in results]

    def parse_meta(self, current):
        """
        Parse the current data and return MetaSegment objects.

        Parameters
        ----------
        current : numpy.ndarray
            The ionic current data to be parsed.

        Returns
        -------
        list[MetaSegment]
            A list of MetaSegment objects describing each detected segment.
        """
        parser = FastStatSplit(self.min_width, self.max_width, self.window_width,
                               self.min_gain_per_sample, self.false_positive_rate,
                               self.prior_segments_per_second, self.sampling_freq,
                               self.cutoff_freq)

        return parser.parse_meta(current)

    def best_single_split(self, current):
        """
        Determine the best single split point in the current data.

        Parameters
        ----------
        current : numpy.ndarray
            The ionic current data to be analyzed.

        Returns
        -------
        int or None
            The index of the best split point, or None if no split is found.
        """
        parser = FastStatSplit(self.min_width, self.max_width,
                               self.window_width, self.min_gain_per_sample,
                               self.false_positive_rate,
                               self.prior_segments_per_second, self.sampling_freq)
        return parser.best_single_split(current)

    def set_params(self):
        """
        Set the parameters
        """
        try:
            self.min_width = int(self.minWidth.text())
            self.max_width = int(self.maxWidth.text())
            self.window_width = int(self.windowWidth.text())
            self.min_gain_per_sample = float(self.minGain.text())
        except:
            pass

#########################################
# STATE PARSERS
#########################################


class snakebase_parser(Parser):  # Upper case for class name!
    """
    A simple parser based on dividing when the peak-to-peak
    amplitude of a wave exceeds a certain threshold.
    """

    def __init__(self, threshold=1.5):
        self.threshold = threshold

    def parse(self, current):
        """Segment the current by detecting edges where cumulative amplitude exceeds the threshold.

        Parameters
        ----------
        current : numpy.ndarray
            The ionic current array to segment.

        Returns
        -------
        list[Segment]
            Segments corresponding to regions that pass the amplitude threshold.
        """
        # Take the derivative of the current first
        diff = np.abs(np.diff(current))
        # Find the places where the derivative is low
        tics = np.concatenate(([0], np.where(diff < 1e-3)[0], [diff.shape[0]]))
        # For pieces between these tics, make each point the cumulative
        # sum of that piece and put it together piecewise
        cumsum = np.concatenate(([np.cumsum(diff[tics[i]: tics[i + 1]])
                                  for i in range(tics.shape[0] - 1)]))
        # Find the edges where the cumulative sum passes a threshold
        split_points = np.where(np.abs(np.diff(np.where(cumsum >
                                                        self.threshold, 1, 0))) == 1)[0] + 1
        # Return segments which do pass the threshold
        return [Segment(current=current[tics[i]: tics[i + 1]], start=tics[i])
                for i in range(1, tics.shape[0] - 1, 2)]

    def set_params(self):
        self.threshold = float(self.threshInput.text())


class FilterDerivativeSegmenter(Parser):
    """
    This parser will segment an event using a filter-derivative method. It will
    first apply a bessel filter at a certain cutoff to the current, then it will
    take the derivative of that, and segment when the derivative passes a
    threshold.
    """

    def __init__(self, low_threshold=1, high_threshold=2, cutoff_freq=1000., sampling_freq=1.e5):
        super().__init__()  # Add super()__init__ to isinitate class PArser?
        self.low_threshold = low_threshold
        self.high_threshold = high_threshold
        self.cutoff_freq = cutoff_freq
        self.sampling_freq = sampling_freq

    def parse(self, current):
        """
        Apply the filter-derivative method to filter the ionic current.
        """

        # Filter the current using a first order Bessel filter twice, one in
        # both directions to preserve phase
        nyquist = self.sampling_freq / 2.
        numerator, denominator = signal.bessel(1, self.cutoff_freq /
                                               nyquist, btype='low', analog=0, output='ba')
        filtered_current = signal.filtfilt(numerator, denominator, np.array(current).copy())

        # Take the derivative
        deriv = np.abs(np.diff(filtered_current))

        # Find the edges of the blocks which fulfill pass the lower threshold
        blocks = np.where(deriv > self.low_threshold, 1, 0)
        block_edges = np.abs(np.diff(blocks))
        tics = np.where(block_edges == 1)[0] + 1

        # Split points are points in the each block which pass the high
        # threshold, with a maximum of one per block
        split_points = [0]

        # for start, end in it.izip( tics[:-1:2], tics[1::2] ): # For all pairs of edges for a block
        # izip is not in python3
        for start, end in zip(tics[:-1:2], tics[1::2]):  # For all pairs of edges for a block
            segment = deriv[start:end]  # Save all derivatives in that block to a segment
            # If the max derivative in that block is above a threshold
            if np.argmax(segment) > self.high_threshold:
                # Save the edges of the segment
                split_points = np.concatenate((split_points, [start, end]))

        # Now you have the edges of all transitions saved, and so the states are
        # the current between these transitions
        tics = np.concatenate((split_points, [current.shape[0]]))
        tics = list(map(int, tics))
        return [Segment(current=current[tics[i]:tics[i + 1]], start=tics[i])
                for i in range(0, len(tics) - 1, 2)]

    def set_params(self):
        """
        Read threshold values from the GUI and update the parser parameters.
        """
        self.low_thresh = float(self.lowThreshInput.text())
        self.high_thresh = float(self.highThreshInput.text())


class NoiseFilterParser(Parser):
    """
    This unit is a parser subclass. when invoked on a segment (whole file or subsegment),
    it identifies the areas where bad things happened, and areas where the data is clean.
    Examples of bad things include high noise (60Hz noise from opening the cage), membrane
    rupture, and membrane formation attempts. it produces children from the good
    regions of the data and ignore the bad regions.

    """
    def __init__(self, noise_threshold=60, detect_noise=True):
        super().__init__()
        self.noise_threshold = noise_threshold
        self.detect_noise = detect_noise
        self.meta_segments = []

    def _detect_noisy_regions(self, data, segment_start, segment_end):
        noisy_regions = []
        freq_spectrum = np.abs(fft(data))
        freq_bins = np.fft.fftfreq(len(data))

        for i, amplitude in enumerate(freq_spectrum):
            if abs(freq_bins[i] - 60) < 1 and amplitude > self.noise_threshold:
                start, end = self._find_noise_segment(data, i, segment_start, segment_end)
                noisy_regions.append((start, end))

        return self._merge_overlapping_regions(noisy_regions)

    def parse(self, data, segment_start=0, segment_end=None):
        """Identify clean and noisy regions and return MetaSegment boundaries.

        Parameters
        ----------
        data : numpy.ndarray
            The ionic current array to analyse.
        segment_start : int, optional
            Start index within `data`. Defaults to 0.
        segment_end : int or None, optional
            End index within `data`. Defaults to len(data).

        Returns
        -------
        list[MetaSegment]
            MetaSegments tagged with ``unique_features="clean"`` or
            ``unique_features="noisy"``.
        """
        if segment_end is None:
            segment_end = len(data)

        noisy_regions = self._detect_noisy_regions(data, segment_start, segment_end)
        last_end = segment_start

        for start, end in noisy_regions:
            start = max(segment_start, start)
            end = min(segment_end, end)
            if start > last_end:
                self.meta_segments.append(MetaSegment(last_end, start, unique_features="clean"))
            self.meta_segments.append(MetaSegment(start, end, unique_features="noisy"))
            last_end = end

        if last_end < segment_end:
            self.meta_segments.append(MetaSegment(last_end, segment_end, unique_features="clean"))

        # Ensure all segments fit strictly within the parent range
        return [
            seg for seg in self.meta_segments
            if segment_start <= seg.start < seg.end <= segment_end
        ]


class IVCurveParser:
    """
    Parser for identifying IV curve regions in voltage data by pattern matching.

    The class accepts voltage information and performs pattern matching to
    detect one or more of several known IV curve sweep patterns (cyclical,
    ramp, flip-flop) within the recording.

    Parameters
    ----------
    voltage : numpy.ndarray
        The voltage array to search for IV curve patterns.

    Notes
    -----
    This class is not yet implemented.
    """

    def __init__(self, voltage):
        #super().__init__()
        self.voltage = voltage

    def parse(self):
        """
        Search for IV curve patterns in the voltage data.

        If no pattern is found, the data file does not contain IV curves.
        If one or more IV curves are detected, returns their start and end
        regions.

        Returns
        -------
        dict
            A dictionary of matched pattern definitions keyed by pattern name.
        """
        patterns = self._defined_pattern()
        iv_regions = []

        for pattern_name, pattern in patterns.items():
            match_regions = self._find_pattern(self.voltage, pattern)
            if match_regions:
                for start, end in match_regions:
                    iv_regions.append((start, end))

        return patterns

    def _defined_pattern(self):
        """
        Return a dictionary of common IV curve voltage patterns.

        Returns
        -------
        dict
            A dictionary mapping pattern names to lists of normalised voltage
            step values.
        """

        patterns = {
            "cyclical": [0, 0.25, 0.5, 0.75, 1, 0.75, 0.5, 0.25, 0, -0.25, -0.5, -0.75, -1],
            "ramp": [0, 0.25, 0.5, 0.75, 1, 0, -0.25, -0.5, -0.75, -1],
            "flip_flop_zero": [0, 0.25, 0, -0.25, 0, 0.5, 0, -0.5, 0, 0.75, 0, -0.75, 0, 1, 0, -1, 0],
            "flip_flop_no_zero": [0, 0.25, -0.25, 0.5, -0.5, 0.75, -0.75, 1, -1]
        }
        return patterns

    def _find_pattern(self, pattern):
        """
        Match a pattern against the voltage array to find all occurrences.

        Parameters
        ----------
        pattern : list[float]
            The normalised voltage step sequence to search for.

        Returns
        -------
        list[tuple[int, int]]
            A list of ``(start, end)`` index pairs for each match found.
        """

        pattern_length = len(pattern)
        match_region = []
        for i in range(len(self.voltage) - pattern_length + 1):
            # find a pattern comparing voltage array and the pattern
            if np.allclose(self.voltage[i:i + pattern_length], pattern, atol=0.001):
                match_region.append((i, i + pattern_length - 1))

        return match_region


class IVCurveAnalyzer(Parser):
    """
    Analyzer for computing current statistics across IV curve voltage steps.

    Confirms that a parent segment has at least two voltage steps, divides
    the data into per-voltage segments, and computes the mean and standard
    deviation of the current for each step, returning an array of
    ``(voltage: (imean, istd))`` pairs.

    Parameters
    ----------
    current : numpy.ndarray
        The ionic current array.
    parent_segment : Segment
        The parent segment containing voltage-step children.
    sampling_frequency : float
        Sampling frequency of the recording in Hz.
    method : str, optional
        Analysis method; either ``"simple"`` or ``"extra"``.
        Defaults to ``"simple"``.

    Notes
    -----
    This class is not yet implemented.
    """

    def __init__(self, current, parent_segment, sampling_frequency, method="simple"):
        super().__init__()
        self.current = current
        self.method = method
        self.parent_segment = parent_segment
        self.sampling_frequency = sampling_frequency

    def analyze(self):
        """
        Analyze each voltage step in the parent segment.

        Returns
        -------
        dict
            A dictionary mapping each voltage value to a ``(imean, istd)``
            tuple of current statistics.

        Raises
        ------
        ValueError
            If the parent segment has fewer than two voltage-step children or
            if an invalid method is specified.
        """
        # condition: at list two voltage steps are present
        if len(self.parent_segment.children) < 2:
            raise ValueError("The segment should have at least two voltage steps")
        result = {}
        for vstep in self.parent_segment.children:
            voltage = vstep.voltage
            current = vstep.current
            if self.method == "simple":
                imean, istd = self._simple_stats(current)
            elif self.method == "extra":
                imean, istd = self._extra_stats(current)
            else:
                raise ValueError("Invalid method. Choose 'simple' or 'extra'")
            result[voltage] = (imean, istd)

        return result

    def _simple_stats(self, current):
        return np.mean(current), np.std(current)
    def _extra_stats(self, current):
        """
        Compute robust current statistics by finding the most-open subsegment.

        Divides the segment into n subsegments (default 10, or enough to cover
        0.1 s, whichever is larger), sorts by subsegment mean, and returns the
        mean and standard deviation from the subsegment with the highest
        current magnitude.  This ignores regions where a biological pore is
        gating and focuses on the most-open state.

        Parameters
        ----------
        current : numpy.ndarray
            Ionic current samples for a single voltage step.

        Returns
        -------
        tuple[float, float]
            A ``(imean, istd)`` pair for the most-open subsegment.
        """
        subsegment_duration = 0.1
        current_data = current
        n_subsegments = int(max(10, subsegment_duration))


class AutoSquareParser(Parser):
    """
    Parser for detecting square-pulse blocking events in ionic current data.

    Builds a reusable pipeline that locates downward current deflections
    within a voltage step using histogram-based baseline estimation and
    customisable filtering rules.

    Parameters
    ----------
    threshold_baseline : float, optional
        Fraction of the estimated open-channel baseline below which a sample
        is considered part of an event. Defaults to 0.7.
    expected_conductance : float, optional
        Expected open-channel conductance in nS, used to estimate the baseline
        current from the voltage. Defaults to 1.9.
    conductance_tolerance : float, optional
        Multiplicative tolerance applied to the expected conductance when
        validating the event baseline. Defaults to 1.2.
    wrap_padding : int, optional
        Number of samples added around each event for context storage.
        Defaults to 50.
    rules : list[callable], optional
        Additional lambda rules used to filter detected events. Defaults to
        an empty list.
    """
    required_parent_attributes = ["current", "eff_sampling_freq", "voltage"]

    def __init__(self, threshold_baseline=0.7, expected_conductance=1.9,conductance_tolerance=1.2, wrap_padding:int=50, rules=[]):

        super().__init__()
        # self.voltage_range = voltage_range
        self.threshold = threshold_baseline
        self.wrap_padding = wrap_padding
        self.conductance_tolerance=conductance_tolerance
        self.expected_conductance = expected_conductance
        self.rules = rules

    def parse(self, current, eff_sampling_freq, voltage):
        """
        Detect square-pulse blocking events in the current array.

        Parameters
        ----------
        current : numpy.ndarray
            Ionic current samples for a single voltage step.
        eff_sampling_freq : float
            Effective sampling frequency of the data in Hz.
        voltage : float
            Voltage applied during this step, used to estimate the baseline
            current.

        Returns
        -------
        list[tuple]
            A list of ``(start, end, unique_features)`` tuples for each
            accepted event. ``unique_features`` contains keys ``baseline``,
            ``wrap``, ``mean``, and ``frac``.
        """
        sign=np.sign(voltage)
        if np.isclose(sign,0.0,atol=1e-3):
            sign=1
        # print(voltage, sign,len(current))
        results = []
        
        expected_baseline = voltage*sign*self.expected_conductance 
        current_positive=sign*current
        hist, edges = np.histogram(current_positive, bins=100, range=(expected_baseline*0.7,expected_baseline*1.4))
        centers = edges[:-1] + (edges[1] - edges[0]) * 0.5
        I0guess = centers[np.argmax(hist)]
        Ithresh = I0guess * self.threshold
        # if expected_baseline/1.2<I0guess <expected_baseline*1.2 :
        #     return []

        # lambda parser with dynamic rules
        lambda_parser = lambda_event_parser(
            threshold=Ithresh,
            rules=[
                      lambda event: 0 < event.mean < Ithresh*0.9,
                  ] + self.rules
        )
        events = lambda_parser.parse(current_positive)
        if len(events) == 0:
            return []
        ignored=[]
        for i in range(len(events)):

            
            """
            objective: find a baseline region to calculate the event-specific baseline
            cases: 
                if the event is the last in a set or the only event:
                    take the current between the end of the event and the end of the step
                otherwise, there is at least one event after this event, 
                    so take the current after the event up to the beginning of the next event as baseline.
                
            """
            if (events[i].start==0) or (events[i].start+events[i].duration)==current_positive.shape[0]:
                ignored.append(i)
                continue

            if i == len(events) - 1:
                bstart = events[i].start + events[i].duration

                bend = current_positive.shape[0]
            else:
                bstart = events[i].start + events[i].duration
                bend = events[i + 1].start
            # print(i, bstart,bend)
            baseline = current_positive[bstart:bend]

            #     return []
            if baseline.shape[0] > 0: 
                events[i].unique_features = {"baseline": np.median(baseline, axis=-1)}
            else:
                ignored.append(i) # this event does not have a baseline, meaning it ended at the end of the step. dump it
                continue

            if not(expected_baseline/self.conductance_tolerance<events[i].unique_features["baseline"] <expected_baseline*self.conductance_tolerance) :
                ignored.append(i)
                continue
            diff = np.diff(current_positive[events[i].start:events[i].start + events[i].duration])
            idxs = np.argwhere(diff > 0).ravel()
            if idxs.size == 0:
                # Skip this event if no positive difference is found 
                ignored.append(i)
                continue

            events[i].end=events[i].start+events[i].duration # assign temporary end of event to get the wrapping
            wstart = max(events[i].start -self.wrap_padding, 0) 
            if i>0:
                wstart = max(wstart,events[i-1].start+events[i-1].duration) #if previous event is too close, limit the wrap start
            wend = min(events[i].end + self.wrap_padding, current_positive.shape[0]) 
            if i<len(events)-1:
                wend = min(wend, events[i+1].start) #if next event is too close, limit the wrap end
            wstart = int(wstart) 
            wend = int(wend)

            events[i].unique_features["wrap"] = current[wstart:wend]
            
            new_start = events[i].start + idxs[0]
            new_end = events[i].start + idxs[-1] + 1

            events[i].start = new_start
            events[i].end = new_end
            # events[i].duration=events[i].start-events[i].end
        # print(ignored)
        # print(len(events))
        # for event in events:
            # print(event.start,event.end)    
        events = [event for i, event in enumerate(events) if not (i in ignored)]
        # print(len(events))
        # print("survived:",[(event.start,event.end) for event in events])
        for event in events:
            assert hasattr(event,"unique_features"), f"Faulty event situation at {event.start},{event.end}"
            assert ("baseline" in event.unique_features) and "wrap" in event.unique_features,f"Event with no baseline or wrapping slipped through the parser at {event.start},{event.end}"
            #events[i].unique_features = {"baseline": np.median(baseline, axis=-1)}
        # Adjust the events by removing transitions

        for event in events:
            # event.duration = (event.end - event.start) / eff_sampling_freq

            # Unique features
            event.unique_features["baseline"]*=sign
            event.unique_features["mean"] = np.mean(current[event.start:event.end])
            event.unique_features["frac"] = 1 - (event.unique_features["mean"] / event.unique_features["baseline"])
            # event.unique_features[""] = event.end-event.start
            # event.unique_features["current"] = current[event.start:event.end]
            # event.unique_features["start"] = event.start
            # event.unique_features["end"]=event.end
            results.append((event.start, event.end, event.unique_features))

        return results

# slice_points = [(1000,6999),(5000000,60000000)] # list [ (start, end), ... ]
# ex=ExclusionParser(regions=slice_points)
#
# file.parse(parser=ex, at_child_rank="vstepgap",newrank-"vstep_clean")
#

# makes new identical children when steps are outside the slices
# makes new trimmed children when steps have overlaps with slices e.g., step: (10s - 20s), slice_points: (5s - 12s). new child of that step should be 12s-20s,
# makes no children when a step is completely within slice boundaries.


class ExclusionParser(Parser):
    """
    Parser that outputs clean children by excluding user-specified time regions.

    Regions are specified in global seconds and are subtracted from each
    segment to produce one or more clean sub-segments.

    Parameters
    ----------
    regions : list[tuple[float, float]]
        List of ``(start_sec, end_sec)`` pairs in global seconds to exclude.
    """

    required_parent_attributes = ["current", "eff_sampling_freq", "voltage", "start"]

    def __init__(self, regions: list[tuple[float, float]]):
        """
        Initialize ExclusionParser with the regions to exclude.

        Parameters
        ----------
        regions : list[tuple[float, float]]
            List of ``(start_sec, end_sec)`` pairs in global seconds that
            should be excluded from the output.
        """
        super().__init__()
        self.regions = regions

    def parse(self, current, eff_sampling_freq, start=None, **kwargs):
        """
        Exclude the specified regions and return the remaining clean intervals.

        Parameters
        ----------
        current : numpy.ndarray
            Ionic current samples for the segment being parsed.
        eff_sampling_freq : float
            Effective sampling frequency of the data in Hz, used to convert
            global seconds to sample indices.
        start : int or None, optional
            Absolute sample index of the segment's start within the recording.
            Defaults to None (treated as 0).
        **kwargs : dict
            Additional keyword arguments (unused).

        Returns
        -------
        list[tuple]
            A list of ``(start, end, features)`` tuples for each clean
            interval remaining after exclusion. ``features`` contains
            ``excluded_windows_local`` and ``parent_start_sec``.
        """
        num_samples = len(current)
        seg_start, seg_end = 0, num_samples

        # establish this child's absolute start time in seconds
        if start is not None:
            parent_start_sec = float(start) / float(eff_sampling_freq)

        else:
            parent_start_sec = 0.0

        excl = []
        for s_sec, e_sec in self.regions:
            ls_sec = s_sec - parent_start_sec
            le_sec = e_sec - parent_start_sec
            s = int(round(ls_sec * eff_sampling_freq))
            e = int(round(le_sec * eff_sampling_freq))
            if e <= 0 or s >= num_samples:
                continue
            start = max(0, s)
            end = min(num_samples, e)
            if start < end:
                excl.append((start, end))
                # print(s,e)

        # merge overlaps
        if excl:
            excl.sort()
            merged = []
            cs, ce = excl[0]
            for s, e in excl[1:]:
                if s <= ce:
                    ce = max(ce, e)
                else:
                    merged.append((cs, ce))
                    cs, ce = s, e
            merged.append((cs, ce))
            excl = merged
            # print(len(excl))

        # compute clean intervals
        clean = []
        last = seg_start
        for s, e in excl:
            if e <= seg_start or s >= seg_end:
                continue
            if s > last:
                clean.append((last, min(s, seg_end)))
            last = max(last, e)
            if last >= seg_end:
                break
        if last < seg_end:
            clean.append((last, seg_end))

        results = []
        for cstart, cend in clean:
            results.append((cstart, cend, {
                "excluded_windows_local": excl,
                "parent_start_sec": parent_start_sec,
            }))

        return results

