#!/usr/bin/env python3

# Defer some imports to improve initialization performance.
from functools import cmp_to_key
import logging
import math

from britekit.core import util


class Label:
    """
    Represent a label generated by a classifier.

    Attributes:
        recording (str):
        class_code (str):
        start (float):
        end (float):
        score (float):
    """

    def __init__(
        self, recording: str, class_code: str, start: float, end: float, score: float
    ):
        self.recording = recording
        self.class_code = class_code
        self.start = start
        self.end = end
        self.score = score
        self.matched = False

    def __str__(self):
        return f"recording={self.recording}, class_code={self.class_code}, start={self.start:.1f}, end={self.end:.1f}, score={self.score:.3f}"


class BaseTester:
    """
    Base class for test reporting classes.
    """

    def __init__(self):
        # variables for classes in annotations
        self.y_true_annotated_df = None
        self.y_pred_annotated_df = None
        self.y_true_annotated = None
        self.y_pred_annotated = None

        # variables for classes in training set (superset of annotated)
        self.y_true_trained_df = None
        self.y_pred_trained_df = None
        self.y_true_trained = None
        self.y_pred_trained = None

        self.map_codes = {}  # sub-class can map old class codes to new ones here
        self.annotated_classes = []
        self.trained_classes = []
        self.recordings = None
        self.label_regex = None
        self.segment_len = None
        self.overlap = None
        self.per_recording = False

    # ============================================================================
    # Public methods - Data loading and initialization
    # ============================================================================

    def get_labels(
        self,
        label_dir,
        segment_len=None,
        overlap=None,
        ignore_unannotated=False,
        trim_overlap=True,
    ):
        """
        Read all label files from the given directory and process them into Label objects.

        This method loads inference output files (CSV or text format) and converts them
        into Label objects organized by recording. It also calculates segment overlap
        and assigns segment numbers to labels.

        Args:
            label_dir (str): Directory containing label files to process
            segment_len (float, optional): Length of each segment in seconds.
                If None, will be detected from the first label
            overlap (float, optional): Overlap between segments in seconds.
                If None, will be calculated automatically
            ignore_unannotated (bool): If True, skip classes not in annotated_class_indexes
            trim_overlap (bool): If True, trim overlapping labels to avoid double-counting

        Raises:
            SystemExit: If no labels are found or if label durations are inconsistent
        """

        # get label info from CSV or text files into a dataframe
        self.label_df = util.inference_output_to_dataframe(label_dir)
        self.prediction_scores = self.label_df["score"]
        trained_classes = self.label_df["name"].tolist()
        self.trained_class_set = set(trained_classes)
        self.trained_classes = sorted(list(self.trained_class_set))

        # create list of label objects per recording
        self.labels_per_recording = {}
        for (recording, name), group in self.label_df.groupby(["recording", "name"]):
            if ignore_unannotated and name not in self.annotated_class_indexes:
                continue

            if recording not in self.labels_per_recording:
                self.labels_per_recording[recording] = []

            for row in group.itertuples(index=False):
                if self.segment_len is None:
                    self.segment_len = row.end_time - row.start_time
                elif not math.isclose(row.end_time - row.start_time, self.segment_len):
                    logging.error(
                        f"Error: detected different label durations ({self.segment_len} and {row.end_time - row.start_time})"
                    )
                    quit()

                if row.start_time % self.segment_len != 0:
                    self.labels_overlap = True

                label = Label(recording, name, row.start_time, row.end_time, row.score)
                self.labels_per_recording[recording].append(label)

        # sort labels, calculate overlap and assign segments to labels
        longest_recording = None  # use recording with most labels to calc overlap
        max_labels = 0
        for recording in self.labels_per_recording:
            self.labels_per_recording[recording] = sorted(
                self.labels_per_recording[recording],
                key=lambda label: (label.class_code, label.start),
            )
            if len(self.labels_per_recording[recording]) > max_labels:
                longest_recording = recording
                max_labels = len(self.labels_per_recording[recording])

        if longest_recording is None:
            logging.error("Error: no labels found")
            quit()

        self.overlap = self._calculate_overlap(
            self.labels_per_recording[longest_recording]
        )
        logging.info(
            f"Detected segment length={self.segment_len:.2f} and label overlap={self.overlap:.2f}"
        )

        segment_len = self.segment_len if segment_len is None else segment_len
        overlap = self.overlap if overlap is None else overlap

        if segment_len is not None and overlap is not None:
            for recording in self.labels_per_recording:
                for label in self.labels_per_recording[recording]:
                    label.segment = label.start // (segment_len - overlap)

        if trim_overlap:
            # eliminate the overlap between labels to avoid over-counting
            self._trim_overlapping_labels()

    def init_y_pred(self, segments_per_recording=None, use_max_score=True):
        """
        Create prediction dataframes from processed labels.

        This method converts the processed Label objects into pandas DataFrames
        that represent the model's predictions. It creates both trained and annotated
        versions of the prediction data.

        Args:
            segments_per_recording (dict, optional): Dictionary mapping recording names
                to lists of segment numbers. If None, uses all segments
            use_max_score (bool): If True, use the maximum score when multiple labels
                exist for the same class in the same segment. If False, use the last score

        Note:
            This method sets self.y_pred_trained_df and self.y_pred_annotated_df
        """

        import pandas as pd

        self.segments_per_recording = segments_per_recording

        # set segment_dict[recording][segment][class_codes] = score (if there is a matching label)
        segment_dict = {}
        for recording in self.labels_per_recording:
            segment_dict[recording] = {}
            if recording in self.segments_per_recording:
                for segment in self.segments_per_recording[recording]:
                    segment_dict[recording][segment] = {}

            for label in self.labels_per_recording[recording]:
                if label.segment in segment_dict[recording]:
                    if (
                        use_max_score
                        and label.class_code in segment_dict[recording][label.segment]
                    ):
                        segment_dict[recording][label.segment][label.class_code] = max(
                            label.score,
                            segment_dict[recording][label.segment][label.class_code],
                        )
                    else:
                        segment_dict[recording][label.segment][
                            label.class_code
                        ] = label.score

        # do trained classes (superset of annotated classes)
        rows = []
        for recording in sorted(self.labels_per_recording.keys()):
            for segment in sorted(segment_dict[recording].keys()):
                row = [f"{recording}-{segment}"]
                row.extend([0 for class_code in self.trained_classes])
                for i, class_code in enumerate(self.trained_classes):
                    if class_code in segment_dict[recording][segment]:
                        row[self.trained_class_indexes[class_code] + 1] = segment_dict[
                            recording
                        ][segment][class_code]

                rows.append(row)

        self.y_pred_trained_df = pd.DataFrame(rows, columns=[""] + self.trained_classes)

        # create version for annotated classes only
        self.y_pred_annotated_df = self.y_pred_trained_df.copy()
        for i, column in enumerate(self.y_pred_annotated_df.columns):
            if i == 0:
                continue  # skip the index column

            if column not in self.annotated_class_set:
                self.y_pred_annotated_df = self.y_pred_annotated_df.drop(column, axis=1)

    def set_class_indexes(self):
        """
        Create index mappings for class codes.

        This method creates dictionaries that map class codes to their numerical
        indices in the data arrays. This is used for efficient lookup when
        processing predictions and ground truth data.

        Note:
            Sets self.trained_class_indexes and self.annotated_class_indexes
        """

        self.trained_class_indexes = {}
        for i, class_code in enumerate(self.trained_classes):
            self.trained_class_indexes[class_code] = i

        self.annotated_class_indexes = {}
        for i, class_code in enumerate(self.annotated_classes):
            self.annotated_class_indexes[class_code] = i

    def convert_to_numpy(self):
        """
        Convert pandas DataFrames to numpy arrays for metric calculations.

        This method converts the pandas DataFrames (y_true_*_df and y_pred_*_df)
        to numpy arrays (y_true_* and y_pred_*) that are used by scikit-learn
        metric functions. The first column (recording/segment identifiers) is dropped.

        Raises:
            Exception: If y_true_annotated_df or y_pred_annotated_df is None

        Note:
            Sets self.y_true_annotated, self.y_pred_annotated, self.y_true_trained,
            and self.y_pred_trained as numpy arrays
        """
        import numpy as np

        if self.y_true_annotated is not None and self.y_pred_annotated is not None:
            return  # done already

        if self.y_true_annotated_df is None:
            raise Exception("self.y_true_annotated_df is None in BaseTester class")

        if self.y_pred_annotated_df is None:
            raise Exception("self.y_pred_df is None in BaseTester class")

        # convert to numpy and drop the first column
        self.y_true_annotated = self.y_true_annotated_df.to_numpy()[:, 1:].astype(
            np.float32
        )
        self.y_pred_annotated = self.y_pred_annotated_df.to_numpy()[:, 1:].astype(
            np.float32
        )

        self.y_true_trained = self.y_true_trained_df.to_numpy()[:, 1:].astype(
            np.float32
        )
        self.y_pred_trained = self.y_pred_trained_df.to_numpy()[:, 1:].astype(
            np.float32
        )

    def check_if_arrays_match(self):
        """
        Validate that ground truth and prediction arrays have matching dimensions and structure.

        This method performs basic validation to ensure that the ground truth and
        prediction DataFrames have the same number of rows and columns, and that
        the recording/segment identifiers match. This is important for ensuring
        that metric calculations are valid.

        Raises:
            SystemExit: If arrays don't match in dimensions or structure

        Note:
            This is a basic validation - it doesn't check all possible mismatches
        """

        if self.y_true_annotated_df is None or self.y_pred_annotated_df is None:
            logging.error("y_true_annotated_df and y_pred_df are not both defined")
            quit()

        if self.y_true_annotated_df.shape[0] != self.y_pred_annotated_df.shape[0]:
            logging.info("Row count mismatch")
            logging.info(
                f"y_true_annotated_df row count = {self.y_true_annotated_df.shape[0]}"
            )
            logging.info(f"y_pred_df row count = {self.y_pred_annotated_df.shape[0]}")

            y_true_annotated_file_list = self.y_true_annotated_df[""].tolist()
            y_pred_file_list = self.y_pred_annotated_df[""].tolist()
            if len(y_true_annotated_file_list) > len(y_pred_file_list):
                longer_list = y_true_annotated_file_list
                shorter_list = y_pred_file_list
                longer_name = "y_true_annotated_df"
                shorter_name = "y_pred_annotated_df"
            else:
                longer_list = y_pred_file_list
                shorter_list = y_true_annotated_file_list
                longer_name = "y_pred_annotated_df"
                shorter_name = "y_true_annotated_df"

            for i in range(len(shorter_list)):
                if shorter_list[i] != longer_list[i]:
                    logging.info(
                        f"{longer_list[i]} is row {i} for {longer_name} but not {shorter_name}, which has {shorter_list[i]}"
                    )
                    break

            quit()

        if self.y_true_annotated_df.shape[1] != self.y_pred_annotated_df.shape[1]:
            logging.info("Column count mismatch")
            logging.info(
                f"y_true_annotated_df column count = {self.y_true_annotated_df.shape[1]}"
            )
            logging.info(
                f"y_pred_annotated_df column count = {self.y_pred_annotated_df.shape[1]}"
            )
            quit()

        for i in range(self.y_true_annotated_df.shape[0]):
            if (
                self.y_true_annotated_df.iloc[i].iloc[0]
                != self.y_pred_annotated_df.iloc[i].iloc[0]
            ):
                logging.info(f"First column mismatch at row index {i}")
                logging.info(
                    f"y_true_annotated_df value = {self.y_true_annotated_df.iloc[i].iloc[0]}"
                )
                logging.info(
                    f"y_pred_annotated_df value = {self.y_pred_annotated_df.iloc[i].iloc[0]}"
                )
                quit()

    # ============================================================================
    # Public methods - Statistics and metrics
    # ============================================================================

    def get_pr_auc_stats(self):
        """
        Calculate Precision-Recall Area-Under-Curve (PR-AUC) statistics.

        This method calculates PR-AUC scores using different averaging strategies:
        - macro: Average of PR-AUC scores for each class
        - micro: PR-AUC calculated on flattened arrays
        - none: PR-AUC score for each class individually

        Returns:
            dict: Dictionary containing PR-AUC statistics with keys:
                - macro_pr_auc: Macro-averaged PR-AUC for annotated classes
                - micro_pr_auc_annotated: Micro-averaged PR-AUC for annotated classes
                - micro_pr_auc_trained: Micro-averaged PR-AUC for all trained classes
                - class_pr_auc: PR-AUC score for each annotated class
                - combined_pr_auc_annotated: Average of macro and micro PR-AUC for annotated classes
                - combined_pr_auc_trained: Average of macro and micro PR-AUC for trained classes

        Note:
            Macro and none averaging are only defined for classes with annotations,
            but micro averaging is defined for all trained classes
        """
        from sklearn import metrics

        macro_pr_auc = metrics.average_precision_score(
            self.y_true_annotated, self.y_pred_annotated, average="macro"
        )
        micro_pr_auc_annotated = metrics.average_precision_score(
            self.y_true_annotated, self.y_pred_annotated, average="micro"
        )
        micro_pr_auc_trained = metrics.average_precision_score(
            self.y_true_trained, self.y_pred_trained, average="micro"
        )
        class_pr_auc = metrics.average_precision_score(
            self.y_true_annotated, self.y_pred_annotated, average=None
        )
        class_pr_auc_score = {}
        if len(self.annotated_classes) == 1:
            # class is a scalar
            class_pr_auc_score[self.annotated_classes[0]] = class_pr_auc
        else:
            # class map is an array
            for i, class_code in enumerate(self.annotated_classes):
                class_pr_auc_score[class_code] = class_pr_auc[i]

        # create a dictionary with details and return it
        ret_dict = {}
        ret_dict["macro_pr_auc"] = macro_pr_auc
        ret_dict["micro_pr_auc_annotated"] = micro_pr_auc_annotated
        ret_dict["micro_pr_auc_trained"] = micro_pr_auc_trained
        ret_dict["class_pr_auc"] = class_pr_auc_score
        ret_dict["combined_pr_auc_annotated"] = (
            macro_pr_auc + micro_pr_auc_annotated
        ) / 2
        ret_dict["combined_pr_auc_trained"] = (macro_pr_auc + micro_pr_auc_trained) / 2

        return ret_dict

    def get_roc_auc_stats(self):
        """
        Calculate Receiver Operating Characteristic (ROC) statistics.

        This method calculates ROC-AUC scores and curves using different averaging strategies:
        - macro: Average of ROC-AUC scores for each class
        - micro: ROC-AUC calculated on flattened arrays
        - none: ROC-AUC score for each class individually

        The method handles edge cases where ROC-AUC is not defined (e.g., when
        all samples belong to the same class) by adding synthetic data points.

        Returns:
            dict: Dictionary containing ROC statistics with keys:
                - macro_roc_auc: Macro-averaged ROC-AUC for annotated classes
                - micro_roc_auc_annotated: Micro-averaged ROC-AUC for annotated classes
                - micro_roc_auc_trained: Micro-averaged ROC-AUC for all trained classes
                - class_roc_auc: ROC-AUC score for each annotated class
                - roc_fpr_annotated: False positive rates for annotated classes
                - roc_tpr_annotated: True positive rates for annotated classes
                - roc_thresholds_annotated: Thresholds for annotated classes
                - roc_fpr_trained: False positive rates for trained classes
                - roc_tpr_trained: True positive rates for trained classes
                - roc_thresholds_trained: Thresholds for trained classes
                - combined_roc_auc_annotated: Average of macro and micro ROC-AUC for annotated classes
                - combined_roc_auc_trained: Average of macro and micro ROC-AUC for trained classes

        Note:
            Macro and none averaging are only defined for classes with annotations,
            but micro averaging is defined for all trained classes
        """
        import numpy as np
        from sklearn import metrics

        # ROC-AUC is not defined if y_true has a column with all ones
        y_true_has_column_with_all_ones = False
        column_sum = np.sum(self.y_true_annotated, axis=0)
        num_rows = self.y_true_annotated.shape[0]
        for i in range(len(column_sum)):
            if column_sum[i] == num_rows:
                y_true_has_column_with_all_ones = True
                break

        if y_true_has_column_with_all_ones:
            # append a row with all zeros so ROC-AUC is defined
            zeros = np.zeros((1, self.y_true_annotated.shape[1]))
            y_true_annotated = np.append(self.y_true_annotated, zeros, axis=0)
            y_pred_annotated = np.append(self.y_pred_annotated, zeros, axis=0)

            zeros = np.zeros((1, self.y_true_trained.shape[1]))
            y_true_trained = np.append(self.y_true_trained, zeros, axis=0)
            y_pred_trained = np.append(self.y_pred_trained, zeros, axis=0)
        else:
            y_true_annotated = self.y_true_annotated
            y_pred_annotated = self.y_pred_annotated
            y_true_trained = self.y_true_trained
            y_pred_trained = self.y_pred_trained

        macro_roc_auc = metrics.roc_auc_score(
            y_true_annotated, y_pred_annotated, average="macro"
        )
        micro_roc_auc_annotated = metrics.roc_auc_score(
            y_true_annotated, y_pred_annotated, average="micro"
        )
        micro_roc_auc_trained = metrics.roc_auc_score(
            y_true_trained, y_pred_trained, average="micro"
        )
        class_roc_auc = metrics.roc_auc_score(
            y_true_annotated, y_pred_annotated, average=None
        )
        class_roc_auc_score = {}
        if len(self.annotated_classes) == 1:
            # class_roc_auc is a scalar
            class_roc_auc_score[self.annotated_classes[0]] = class_roc_auc
        else:
            # class_roc_auc is an array
            for i, class_code in enumerate(self.annotated_classes):
                class_roc_auc_score[class_code] = class_roc_auc[i]

        # get the ROC curve
        fpr_annotated, tpr_annotated, thresholds_annotated = metrics.roc_curve(
            y_true_annotated.ravel(), y_pred_annotated.ravel()
        )
        fpr_trained, tpr_trained, thresholds_trained = metrics.roc_curve(
            y_true_trained.ravel(), y_pred_trained.ravel()
        )

        # create a dictionary with details and return it
        ret_dict = {}
        ret_dict["macro_roc_auc"] = macro_roc_auc
        ret_dict["micro_roc_auc_annotated"] = micro_roc_auc_annotated
        ret_dict["micro_roc_auc_trained"] = micro_roc_auc_trained
        ret_dict["class_roc_auc"] = class_roc_auc_score
        ret_dict["roc_fpr_annotated"] = fpr_annotated
        ret_dict["roc_tpr_annotated"] = tpr_annotated
        ret_dict["roc_thresholds_annotated"] = thresholds_annotated
        ret_dict["roc_fpr_trained"] = fpr_trained
        ret_dict["roc_tpr_trained"] = tpr_trained
        ret_dict["roc_thresholds_trained"] = thresholds_trained
        ret_dict["combined_roc_auc_annotated"] = (
            macro_roc_auc + micro_roc_auc_annotated
        ) / 2
        ret_dict["combined_roc_auc_trained"] = (
            macro_roc_auc + micro_roc_auc_trained
        ) / 2

        return ret_dict

    def get_precision_recall(self, threshold, details=False):
        """
        Calculate precision and recall statistics at a given threshold.

        This method calculates precision, recall, and related metrics by applying
        a threshold to the prediction scores. Values below the threshold are set to 0,
        values at or above the threshold are set to 1.

        Args:
            threshold (float): Score threshold for binary classification
            details (bool): If True, include detailed per-class and per-recording statistics

        Returns:
            dict: Dictionary containing precision/recall statistics with keys:
                - precision_annotated: Precision for annotated classes
                - recall_annotated: Recall for annotated classes
                - precision_trained: Precision for all trained classes
                - recall_trained: Recall for all trained classes
                - precision_secs: Precision calculated in seconds
                - tp_secs: True positive seconds
                - fp_secs: False positive seconds
                - class_valid: Valid seconds per class (if details=True)
                - class_invalid: Invalid seconds per class (if details=True)
                - class_precision: Precision per class (if details=True)
                - class_recall: Recall per class (if details=True)
                - true_positives: Detailed TP information (if details=True)
                - false_positives: Detailed FP information (if details=True)
                - false_negatives: Detailed FN information (if details=True)
                - rec_info: Recording-level statistics (if details=True)
                - tp_seconds: Total TP seconds (if details=True)
                - fp_seconds: Total FP seconds (if details=True)
                - fn_seconds: Total FN seconds (if details=True)

        Note:
            Returns all zeros if no predictions meet the threshold
        """
        from sklearn import metrics

        # copy y_pred, set values < threshold to 0 and values >= threshold to 1
        y_pred_trained = self.y_pred_trained.copy()
        y_pred_trained[y_pred_trained < threshold] = 0
        y_pred_trained[y_pred_trained >= threshold] = 1

        y_pred_annotated = self.y_pred_annotated.copy()
        y_pred_annotated[y_pred_annotated < threshold] = 0
        y_pred_annotated[y_pred_annotated >= threshold] = 1

        if y_pred_trained.sum() == 0:
            # no predictions at or above this threshold
            return {
                "precision_annotated": 0,
                "recall_annotated": 0,
                "precision_trained": 0,
                "recall_trained": 0,
                "precision_secs": 0,
                "tp_secs": 0,
                "fp_secs": 0,
                "class_valid": [0 for i in range(len(self.annotated_classes))],
                "class_invalid": [0 for i in range(len(self.annotated_classes))],
                "class_precision": [0 for i in range(len(self.annotated_classes))],
                "class_recall": [0 for i in range(len(self.annotated_classes))],
                "true_positives": 0,
                "false_positives": 0,
                "false_negatives": 0,
                "rec_info": {},
                "tp_seconds": 0,
                "fp_seconds": 0,
                "fn_seconds": 0,
            }

        # calculate precision and recall
        if len(self.annotated_classes) == 1:
            average = "binary"
        else:
            average = "micro"

        ret_dict = {}
        ret_dict["precision_annotated"] = metrics.precision_score(
            self.y_true_annotated, y_pred_annotated, average=average, zero_division=0
        )
        ret_dict["recall_annotated"] = metrics.recall_score(
            self.y_true_annotated, y_pred_annotated, average=average, zero_division=0
        )

        ret_dict["precision_trained"] = metrics.precision_score(
            self.y_true_trained, y_pred_trained, average="micro", zero_division=0
        )
        ret_dict["recall_trained"] = metrics.recall_score(
            self.y_true_trained, y_pred_trained, average="micro", zero_division=0
        )

        # per-second precision is different from above only if segments used here are longer than label segments
        precision_secs, tp_secs, fp_secs, class_valid, class_invalid = (
            self._calc_precision_in_seconds(threshold, details=details)
        )
        ret_dict["precision_secs"] = precision_secs
        ret_dict["tp_secs"] = tp_secs
        ret_dict["fp_secs"] = fp_secs
        ret_dict["class_valid"] = class_valid
        ret_dict["class_invalid"] = class_invalid

        if details:
            ret_dict["class_precision"] = metrics.precision_score(
                self.y_true_annotated, y_pred_annotated, average=None, zero_division=0
            )
            ret_dict["class_recall"] = metrics.recall_score(
                self.y_true_annotated, y_pred_annotated, average=None, zero_division=0
            )

            (
                tp_dict,
                fp_dict,
                fn_dict,
                seconds_dict,
                tp_seconds,
                fp_seconds,
                fn_seconds,
            ) = self._get_threshold_details(threshold)
            ret_dict["true_positives"] = tp_dict
            ret_dict["false_positives"] = fp_dict
            ret_dict["false_negatives"] = fn_dict
            ret_dict["rec_info"] = seconds_dict
            ret_dict["tp_seconds"] = tp_seconds
            ret_dict["fp_seconds"] = fp_seconds
            ret_dict["fn_seconds"] = fn_seconds

        return ret_dict

    def get_non_annotated_class_details(self):
        """
        Get prediction statistics for classes without ground truth annotations.

        Since non-annotated classes don't have ground truth data, we can't calculate
        precision or recall. Instead, this method returns the count of predictions
        that fall into different score ranges.

        Returns:
            dict: Dictionary mapping class codes to lists of prediction counts:
                - Index 0: Count of predictions with score >= 0.25
                - Index 1: Count of predictions with score >= 0.5
                - Index 2: Count of predictions with score >= 0.75

        Note:
            Only includes classes that are in trained_classes but not in annotated_classes
        """

        ret_dict = {}
        for class_code in self.trained_classes:
            if class_code not in self.annotated_classes:
                column = self.y_pred_trained[:, self.trained_class_indexes[class_code]]
                count1 = (column >= 0.25).sum()
                count2 = (column >= 0.5).sum()
                count3 = (column >= 0.75).sum()
                ret_dict[class_code] = [count1, count2, count3]

        return ret_dict

    # ============================================================================
    # Public methods - Utility
    # ============================================================================

    def interpolate(self, x_values, y_values, increasing=True, decimal_places=2):
        """
        Interpolate y values for evenly spaced x values.

        This method takes x and y coordinate pairs and creates a new set of evenly
        spaced x values with corresponding interpolated y values. It handles both
        increasing and decreasing x values and ensures the output is within [0, 1].

        Args:
            x_values (array-like): Input x coordinates
            y_values (array-like): Input y coordinates
            increasing (bool): If True, assume x values should be increasing.
                If False, assume x values should be decreasing
            decimal_places (int): Number of decimal places for rounding x values

        Returns:
            tuple: (x_new, y_new) where:
                - x_new: Array of evenly spaced x values
                - y_new: Array of interpolated y values

        Note:
            Assumes input values are in range [0, 1]. Output y values are clipped to [0, 1].
            Uses linear interpolation for smooth results.
        """
        import numpy as np

        # x input to CubicSpline has to be monotonically increasing,
        # but we have to deal with case where it's really decreasing
        x_list = []
        y_list = []
        if increasing:
            for i in range(len(x_values)):
                if i == 0:
                    prev_x = x_values[i]
                    x_list.append(x_values[i])
                    y_list.append(y_values[i])
                elif x_values[i] > prev_x:
                    prev_x = x_values[i]
                    x_list.append(x_values[i])
                    y_list.append(y_values[i])
        else:
            for i in range(len(x_values) - 1, -1, -1):
                if i == len(x_values) - 1:
                    prev_x = x_values[i]
                    x_list.append(x_values[i])
                    y_list.append(y_values[i])
                elif x_values[i] > prev_x:
                    prev_x = x_values[i]
                    x_list.append(x_values[i])
                    y_list.append(y_values[i])

        x_increasing = np.array(x_list)
        y_increasing = np.array(y_list)

        # round first and last values of x and use as bounds
        increment = 10**-decimal_places
        start = round(x_increasing[0], decimal_places)
        if x_increasing[0] < start:
            start -= increment
        start = max(start, 0)

        end = round(x_increasing[-1], decimal_places)
        if x_increasing[-1] > end:
            end += increment
        end = round(min(end, 1.0), decimal_places)

        x_new = np.arange(start, end, increment)
        for i in range(len(x_new)):
            x_new[i] = round(x_new[i], decimal_places)  # e.g. change 1.000000004 to 1.0

        # "y_new = scipy.interpolate.CubicSpline(x_increasing, y_increasing)(x_new).clip(0, 1)"
        # is smoother, but can lead to strange distortions in some cases
        y_new = np.interp(x_new, x_increasing, y_increasing).clip(0, 1)
        return x_new, y_new

    def list_to_string(self, my_list):
        """
        Convert a list to a space-separated string for output formatting.

        Args:
            my_list (list): List of items to convert to string

        Returns:
            str: Space-separated string representation of the list

        Note:
            This is a utility method for formatting output in reports
        """

        ret_str = ""
        for i in range(len(my_list)):
            ret_str += my_list[i]
            if i < len(my_list):
                ret_str += " "

        return ret_str

    # ============================================================================
    # Private methods - Label processing
    # ============================================================================

    def _calculate_overlap(self, label_list):
        """
        Calculate the overlap from a list of labels.
        """

        if len(label_list) < 2:
            return 0  # not enough data to determine overlap, but use 0

        # sort the start values, then remove duplicates
        sorted_start_values = sorted([label.start for label in label_list])
        start_values = []
        for start_value in sorted_start_values:
            if len(start_values) == 0 or start_value > start_values[-1]:
                start_values.append(start_value)

        # determine overlap from the minimum gap between adjacent start values
        diffs = [
            start_values[i + 1] - start_values[i] for i in range(len(start_values) - 1)
        ]
        min_diff = min(diffs)
        overlap = self.segment_len - min_diff
        return overlap

    def _label_segment_duration(self, label, segment):
        """
        Return the duration of a label within a given segment
        """

        segment_len = self.segment_len - self.overlap
        return min((segment + 1) * segment_len, label.end) - max(
            segment * segment_len, label.start
        )

    def _trim_overlapping_labels(self):
        """
        Trim end times on overlapping labels, so they don't overlap.
        This way we can add label durations without double-counting.
        """

        def compare_labels(a, b):
            if a.class_code < b.class_code:
                return -1
            elif a.class_code > b.class_code:
                return 1
            elif a.start < b.start:
                return -1
            else:
                return 1

        compare_key = cmp_to_key(compare_labels)

        for recording in self.labels_per_recording:
            self.labels_per_recording[recording].sort(key=compare_key)
            for i in range(len(self.labels_per_recording[recording]) - 1):
                label = self.labels_per_recording[recording][i]
                if (
                    label.class_code
                    == self.labels_per_recording[recording][i + 1].class_code
                ):
                    if label.end > self.labels_per_recording[recording][i + 1].start:
                        # current overlaps next, so change end time of current, i.e. trim it
                        label.end = self.labels_per_recording[recording][i + 1].start

    # ============================================================================
    # Private methods - Statistics calculations
    # ============================================================================

    def _calc_precision_in_seconds(self, threshold, details=False):
        """
        Calculate precision in seconds for the given threshold
        """
        import numpy as np

        # create y_secs array containing seconds predicted >= threshold per class/file-id
        y_secs = np.zeros(
            (self.y_true_annotated.shape[0], self.y_true_annotated.shape[1])
        )
        row_num = 0
        for recording in sorted(self.labels_per_recording.keys()):
            if self.per_recording:
                for label in self.labels_per_recording[recording]:
                    if label.class_code not in self.annotated_class_indexes:
                        continue

                    column_num = self.annotated_class_indexes[label.class_code]
                    if label.score >= threshold:
                        y_secs[row_num][column_num] += label.end - label.start

                row_num += 1
            else:
                if recording not in self.segments_per_recording:
                    continue

                for segment in self.segments_per_recording[recording]:
                    for label in self.labels_per_recording[recording]:
                        if (
                            label.class_code in self.annotated_class_indexes
                            and segment == label.segment
                            and label.score >= threshold
                        ):
                            # calculate duration of this label in this segment
                            duration = self._label_segment_duration(label, segment)
                            column_num = self.annotated_class_indexes[label.class_code]
                            y_secs[row_num][column_num] += duration

                    row_num += 1

        # calculate TP/FP seconds and then precision;
        # handle differently for details=True/False for efficiency
        valid_secs = self.y_true_annotated * y_secs
        if details:
            class_valid = np.sum(valid_secs, axis=0)
            tp_secs = np.sum(class_valid)
        else:
            tp_secs = np.sum(valid_secs)
            class_valid = None

        invalid_secs = (1 - self.y_true_annotated) * y_secs
        if details:
            class_invalid = np.sum(invalid_secs, axis=0)
            fp_secs = np.sum(class_invalid)
        else:
            fp_secs = np.sum(invalid_secs)
            class_invalid = None

        if tp_secs == 0 and fp_secs == 0:
            precision = 0
        else:
            precision = tp_secs / (tp_secs + fp_secs)

        return precision, tp_secs, fp_secs, class_valid, class_invalid

    def _get_threshold_details(self, threshold):
        """
        Return details about true/false positives/negatives for the given threshold.
        """

        if self.recordings is None:
            logging.error("Error: subclass failed to initialize self.recordings")
            quit()

        tp_seconds = 0  # total TP seconds
        fp_seconds = 0  # total FP seconds
        fn_seconds = 0  # total FN seconds

        if self.per_recording:
            tp_dict = {}  # tp_dict[recording] = [TP labels for the file]
            fp_dict = {}  # fp_dict[recording] = [FP labels for the file]
            fn_dict = {}  # fn_dict[recording] = [FN classes for the file]
            seconds_dict = (
                {}
            )  # seconds_dict[recording] = {'tp_seconds': X, 'fp_seconds': X, 'fn_seconds': X}
            for row_index in range(self.y_true_trained.shape[0]):
                recording = self.recordings[row_index]
                seconds_dict[recording] = {
                    "tp_seconds": 0,
                    "fp_seconds": 0,
                    "fn_seconds": 0,
                }
                detected_classes = {}
                for i, class_code in enumerate(self.trained_classes):
                    detected_classes[class_code] = False

                for label in self.labels_per_recording[recording]:
                    if label.score >= threshold:
                        detected_classes[label.class_code] = True
                        if (
                            self.y_true_trained[
                                row_index,
                                self.trained_class_indexes[label.class_code],
                            ]
                            == 0
                        ):
                            if recording not in fp_dict:
                                fp_dict[recording] = []

                            fp_dict[recording].append(
                                [label.class_code, label.score, label.start, label.end]
                            )
                            fp_seconds += label.end - label.start
                            seconds_dict[recording]["fp_seconds"] += (
                                label.end - label.start
                            )
                        else:
                            if recording not in tp_dict:
                                tp_dict[recording] = []

                            tp_dict[recording].append(
                                [label.class_code, label.score, label.start, label.end]
                            )
                            tp_seconds += label.end - label.start
                            seconds_dict[recording]["tp_seconds"] += (
                                label.end - label.start
                            )

                for col_index in range(self.y_true_trained.shape[1]):
                    if (
                        self.y_true_trained[row_index, col_index] == 1
                        and not detected_classes[self.trained_classes[col_index]]
                    ):
                        if recording not in fn_dict:
                            fn_dict[recording] = []

                        fn_dict[recording].append(self.trained_classes[col_index])
        else:
            tp_dict = {}  # tp_dict[recording][segment] = [TP labels for the segment]
            fp_dict = {}  # fp_dict[recording][segment] = [FP labels for the segment]
            fn_dict = {}  # fn_dict[recording][segment] = [FN classes for the segment]
            seconds_dict = (
                {}
            )  # seconds_dict[recording][segment] = {'tp_seconds': X, 'fp_seconds': X, 'fn_seconds': X}
            segment = 0
            prev_recording = None
            for row_index in range(self.y_true_trained.shape[0]):
                recording = self.recordings[row_index]
                if recording == prev_recording:
                    segment += 1
                else:
                    segment = 0
                    seconds_dict[recording] = []
                    tp_dict[recording] = []
                    fp_dict[recording] = []
                    fn_dict[recording] = []

                prev_recording = recording
                if self.segments_per_recording is not None:
                    use_segment = self.segments_per_recording[recording][segment]
                else:
                    use_segment = segment

                seconds_dict[recording].append({})
                tp_dict[recording].append([])
                fp_dict[recording].append([])
                fn_dict[recording].append([])
                # if segments_per_recording is specified, subclass needs to know the segment, so include that
                seconds_dict[recording][-1] = {
                    "segment": use_segment,
                    "tp_seconds": 0,
                    "fp_seconds": 0,
                    "fn_seconds": 0,
                }
                detected_classes = {}
                for i, class_code in enumerate(self.trained_classes):
                    detected_classes[class_code] = False

                for label in self.labels_per_recording[recording]:
                    if use_segment != label.segment:
                        continue

                    if (
                        label.class_code in self.trained_class_indexes
                        and label.score >= threshold
                    ):
                        label_duration = self._label_segment_duration(
                            label, use_segment
                        )
                        detected_classes[label.class_code] = True
                        if (
                            self.y_true_trained[
                                row_index,
                                self.trained_class_indexes[label.class_code],
                            ]
                            == 0
                        ):
                            fp_dict[recording][-1].append(
                                [label.class_code, label.score, label.start, label.end]
                            )
                            fp_seconds += label_duration
                            seconds_dict[recording][-1]["fp_seconds"] += label_duration
                        else:
                            tp_dict[recording][-1].append(
                                [label.class_code, label.score, label.start, label.end]
                            )
                            tp_seconds += label_duration
                            seconds_dict[recording][-1]["tp_seconds"] += label_duration

                for col_index in range(self.y_true_trained.shape[1]):
                    if (
                        self.y_true_trained[row_index, col_index] == 1
                        and not detected_classes[self.trained_classes[col_index]]
                    ):
                        fn_seconds += self.segment_len
                        fn_dict[recording][-1].append(self.trained_classes[col_index])
                        seconds_dict[recording][-1]["fn_seconds"] += self.segment_len

        return (
            tp_dict,
            fp_dict,
            fn_dict,
            seconds_dict,
            tp_seconds,
            fp_seconds,
            fn_seconds,
        )
