"""Backend logic for the ADA GUI.

This module contains:

* **Backend** — the single stateful object that mediates between the
  DearPyGui front-end (``gui.py``) and the ``ada`` analysis library.
  It holds file selections, algorithm parameters, output-format flags,
  and drives child-process pipelines via ``multiprocess.Process``.
* **Decorators** — ``@error_popup``, ``@runtime_handler``,
  ``@callback_updater`` wrap callbacks with error handling, queue
  communication, and GUI-state synchronisation.
* **Results browser** — thumbnail and CSV-table previews of the output
  directory, with viewport-responsive image scaling.
* **tqdm bridge** — ``_tqdm_to_queue`` monkey-patches ``tqdm.update``
  so internal progress bars push to the GUI status queue.
"""

import csv
import inspect
import os
import platform
import subprocess
import time
import typing
import unicodedata
from contextlib import contextmanager
from copy import deepcopy
from functools import wraps

import dearpygui.dearpygui as dpg
import matplotlib.pyplot as plt
import numpy as np
from multiprocess import Process
import multiprocess
from PIL import Image
import tqdm as _tqdm_module

from ada_gui.config_parser import (
    preset_components, preset_save_defaults, preset_function_names,
    epocher_object, epocher_function_names,
    scorer_object, long_object, object_description,
)
from ada.database_utils.summary import (
    plot, summary_sleep_report, summary_long_report, daily_profile, metadata_summary,
)
from ada.io.file_manager import FileManager
from ada.long.cosinor import _Cosinor


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

PROGRESS_TO_STRING = {
    0: '',
    1: "Loading from file",
    2: "Epoching data",
    3: "Scoring data",
    4: "Fitting long model to the data",
    5: "Generating summary table",
    10: "Converting data to ADA format",
}

COLOR_WARNING = [240, 160, 30, 255]  # amber — something needs attention
COLOR_OK = [80, 200, 110, 255]  # green — all good

# Limits for inline previews in the results browser
_TEX_MAX_W = 1200  # max texture resolution width (px); stored once in GPU memory
_THUMB_PADDING = 22  # horizontal padding subtracted from panel width
_CSV_MAX_ROWS = 50  # max rows loaded from a CSV for the table preview
_CSV_TABLE_H_MAX = 200  # max table container height (px)


# ---------------------------------------------------------------------------
# tqdm → GUI progress bridge
# ---------------------------------------------------------------------------

@contextmanager
def _tqdm_to_queue(status_queue, stage, base=0.0, weight=1.0, text=None):
    """Monkey-patch tqdm.tqdm.update so internal progress bars forward to the GUI queue."""
    _orig_update = _tqdm_module.tqdm.update

    def _patched(self, n=1):
        result = _orig_update(self, n)
        if self.total and self.total > 0:
            frac = min(self.n / self.total, 1.0)
            overall = base + frac * weight
            msg = (stage, min(overall, 1.0), text) if text else (stage, min(overall, 1.0))
            status_queue.put(msg)
        return result

    _tqdm_module.tqdm.update = _patched
    try:
        yield
    finally:
        _tqdm_module.tqdm.update = _orig_update


# ---------------------------------------------------------------------------
# Platform-aware file / folder opening
# ---------------------------------------------------------------------------

def _open_with_system(path):
    """Open a file or directory using the platform's default handler."""
    system = platform.system()
    if system == "Darwin":
        subprocess.Popen(["open", path])
    elif system == "Windows":
        os.startfile(path)
    else:
        subprocess.Popen(["xdg-open", path])


# ===================================================================
#  BACKEND
# ===================================================================

class Backend:
    """Central state object connecting the DearPyGui front-end to the ``ada`` library.

    Responsibilities:
        * Store user selections (files, output directory, algorithms, parameters).
        * Launch analysis pipelines in child processes and relay progress/errors.
        * Manage the results browser (thumbnails, CSV previews, image resizing).
        * Provide DPG callbacks for every interactive widget.

    The *snapshot* mechanism (``make_snapshot`` / ``restore_snapshot``) lets the
    Analysis settings page implement a Cancel button: all mutable state listed
    in ``SNAPSHOT_FIELDS`` is deep-copied on entry and restored on cancel.
    """

    # Fields deep-copied by make_snapshot / restore_snapshot
    SNAPSHOT_FIELDS = [
        'current_epocher', 'epocher_params', 'use_epocher', 'daily_profile_window',
        'current_scorer', 'scorer_params', 'use_scorer',
        'current_long', 'long_params', 'use_long',
        'save_plot', 'save_ada', 'save_csv', 'save_summary',
    ]

    def __init__(self):
        self.manager = multiprocess.Manager()
        self.error_queue = self.manager.Queue()
        self.status_queue = self.manager.Queue()

        # Callback set by gui.py to keep the nav-bar highlighting in sync
        self.set_nav_active = lambda tag: None

        # Texture tags created for image previews (cleaned up on refresh)
        self._result_textures = []
        # Image display metadata: list of (tex_tag, img_tag, tex_w, tex_h)
        self._result_images = []
        # Ordered list of full paths shown in the results file list
        self._result_files = []
        # Tag of the currently selected selectable in the file list
        self._selected_result = None
        # Double-click detection: (sender, timestamp)
        self._last_result_click = (None, 0.0)
        # Timestamp recorded just before the last analysis started
        self._last_analysis_time = None

        # Process when running analysis, else None
        self.runtime = None

        # Tags of child_windows that need resizing on viewport resize
        self.window_tags = []
        self.epocher_advanced = []
        self.scorer_advanced = []
        self.long_advanced = []

        # File selection
        self.input_files = None
        self.out_path = None

        # Algorithm selection
        self.current_epocher = "Activity Index"
        self.epocher_params = {"_epoch_length": 30}
        self.use_epocher = False
        self.daily_profile_window = 15

        self.current_scorer = "Unified Filter"
        self.scorer_params = {}
        self.use_scorer = False

        self.current_long = "Hill-transform Cosinor"
        self.long_params = {}
        self.use_long = False

        # Output format toggles
        self.save_plot = {'epocher': True, 'scorer': True, 'long': True, 'profile': True}
        self.save_ada = {'epocher': True, 'scorer': True, 'long': True}
        self.save_csv = {'epocher': True, 'scorer': True, 'long': True}
        self.save_summary = {'scorer': True, 'long': True}

        self.snapshot = {}

    # -------------------------------------------------------------------
    # Decorators
    # -------------------------------------------------------------------
    # These are plain functions (not methods) defined in the class body.
    # Python treats them as unbound descriptors, so they work as
    # decorators applied with ``@error_popup`` on instance methods.
    # Each decorator receives ``self`` as part of the wrapped call.

    def error_popup(func):
        """Wrap a callback so that any exception is shown in the error popup."""
        @wraps(func)
        def wrapper(self, sender, app_data, *args):
            try:
                func(self, sender, app_data, *args)
            except Exception as e:
                dpg.set_value("error_text", e)
                dpg.configure_item("error", show=True)
                dpg.configure_item("runtime", label="START PROCESSING")
                dpg.set_value("pbar", 0)
                dpg.configure_item("pbar", overlay="0%")
                dpg.set_value("pbar_text", '')
                raise e
        return wrapper

    def runtime_handler(func):
        """Wrap a data-processing function so it reports success/failure via the queues.

        Convention: ``args[0]`` is the ``error_queue`` passed by the caller
        (``start_processing_callback``).  The wrapped function receives
        ``args[1:]`` so it sees only its own positional arguments.
        """
        @wraps(func)
        def wrapper(self, *args, **kwargs):
            try:
                func(self, *args[1:], **kwargs)
                args[0].put("Analysis finished successfully!")
            except Exception as exception:
                args[0].put(exception)
                self.status_queue.put((0, 0))
            finally:
                self.runtime = None
                self.status_queue.put((0, 0))
        return wrapper

    def generic_converter_handler(func):
        """Like runtime_handler but with a different success message."""
        @wraps(func)
        def wrapper(self, *args, **kwargs):
            try:
                func(self, *args[1:], **kwargs)
                args[0].put("Exported .ada files to output path!")
            except Exception as exception:
                args[0].put(exception)
                self.status_queue.put((0, 0))
            finally:
                self.runtime = None
                self.status_queue.put((0, 0))
        return wrapper

    def callback_updater(func):
        """Wrap a callback so it refreshes the runtime-selection text afterwards."""
        @wraps(func)
        def wrapper(self, sender, app_data, *args, **kwargs):
            func(self, sender, app_data, *args, **kwargs)
            desc = ""
            for stage in ['epocher', 'scorer', 'long']:
                if getattr(self, f"use_{stage}"):
                    desc += f"{object_description(stage, getattr(self, f'current_{stage}'))}\n"
            if desc:
                dpg.configure_item("runtime_selection", color=COLOR_OK)
                dpg.set_value("runtime_selection", desc)
                dpg.hide_item("how_to_use_group")
            if not self.use_epocher and not self.use_scorer and not self.use_long:
                dpg.configure_item("runtime_selection", color=COLOR_WARNING)
                dpg.set_value("runtime_selection", "No preset or pipeline selected.")
                dpg.show_item("how_to_use_group")
            self.check_runtime_status()
        return wrapper

    # -------------------------------------------------------------------
    # Runtime communication
    # -------------------------------------------------------------------

    @error_popup
    def start_processing_callback(self, sender, app_data):
        if self.input_files is None:
            raise RuntimeError("No files selected!")
        elif self.out_path is None:
            raise RuntimeError("No output folder selected!")
        elif not self.use_epocher and not self.use_scorer and not self.use_long:
            raise RuntimeError("No preset or pipeline selected!")

        if self.runtime is None:
            self._last_analysis_time = time.time()
            dpg.set_value("pbar_text", "Starting the process...")
            self.runtime = Process(
                target=self.pipeline,
                args=(self.error_queue, self.status_queue,
                      (self.save_plot, self.save_ada, self.save_csv, self.save_summary),
                      self.epocher_params, self.scorer_params, self.long_params))
            self.runtime.start()
        else:
            raise RuntimeError("Analysis is already running!")

    @error_popup
    def cancel_processing_callback(self, sender, app_data):
        if self.runtime is None:
            return
        self.runtime.terminate()
        self.runtime = None
        self.status_queue.put((0, 0))
        raise RuntimeWarning("Analysis canceled!")

    @error_popup
    def generic_converter_callback(self, sender, app_data):
        if self.input_files is None:
            raise RuntimeError("No files selected!")
        elif self.out_path is None:
            raise RuntimeError("No output folder selected!")
        if self.runtime is None:
            self.runtime = Process(target=self.generic_converter,
                                   args=(self.error_queue, self.status_queue))
            self.runtime.start()

    def check_runtime_status(self):
        """Check the error queue and update the START/CANCEL button. Returns True on success."""
        success = False
        if not self.error_queue.empty():
            error_msg = str(self.error_queue.get())
            if error_msg in ("Analysis finished successfully!",
                             "Exported .ada files to output path!"):
                dpg.set_item_label("error", '')
                success = True
            else:
                dpg.set_item_label("error", "ERROR")
            dpg.set_value("error_text", error_msg)
            dpg.configure_item("error", show=True)

        if self.runtime is None:
            dpg.set_item_label("runtime", "START PROCESSING")
            dpg.set_item_callback("runtime", self.start_processing_callback)
        else:
            dpg.set_item_label("runtime", "CANCEL")
            dpg.set_item_callback("runtime", self.cancel_processing_callback)
            if not self.runtime.is_alive():
                self.runtime = None
        return success

    # -------------------------------------------------------------------
    # Data processing pipelines (run in a child process)
    # -------------------------------------------------------------------

    @generic_converter_handler
    def generic_converter(self, status_queue):
        n_data = len(self.input_files.values())
        for file_idx, file in enumerate(self.input_files.values()):
            filename = ''.join(os.path.basename(file).split('.')[:-1])
            text = f"Loading file {file_idx + 1}/{n_data}" if n_data > 1 else "Loading from file"
            status_queue.put((1, 0.0, text))
            with _tqdm_to_queue(status_queue, 1, 0.0, 1.0, text):
                data = FileManager.load_file(file)
            text = f"Converting file {file_idx + 1}/{n_data}" if n_data > 1 else "Converting data to ADA format"
            status_queue.put((10, 0.0, text))
            FileManager.export_generic(os.path.join(self.out_path, filename + '.ada'), data)

    @runtime_handler
    def pipeline(self, status_queue, save_stages, epocher_params, scorer_params, long_params):
        save_plot, save_ada, save_csv, save_summary = save_stages

        # --- Instantiate the selected algorithms ---
        # Parameter dicts use composite keys like "Activity Index threshold"
        # (algorithm display name + space + param name).  The comprehension
        # below filters for entries belonging to the current algorithm and
        # extracts just the param name (the last space-delimited token).
        if self.use_epocher:
            epocher_args = {
                i.split(' ')[-1]: epocher_params[i]
                for i in epocher_params
                if self.current_epocher == ' '.join(i.split(' ')[:-1])}
            if "_epoch_length" not in epocher_args:
                epocher_args["_epoch_length"] = epocher_params["_epoch_length"]
            epocher = epocher_object(self.current_epocher)(**epocher_args)
        if self.use_scorer:
            scorer_args = {
                i.split(' ')[-1]: scorer_params[i]
                for i in scorer_params
                if self.current_scorer == ' '.join(i.split(' ')[:-1])}
            scorer = scorer_object(self.current_scorer)(**scorer_args)
        if self.use_long:
            long_args = {
                i.split(' ')[-1]: long_params[i]
                for i in long_params
                if self.current_long == ' '.join(i.split(' ')[:-1])}
            long = long_object(self.current_long)(**long_args)

        scored_all = []
        longed_all = []
        n_data = len(self.input_files.values())

        for file_idx, file in enumerate(self.input_files.values()):
            filename = ''.join(os.path.basename(file).split('.')[:-1])

            text = f"Loading file {file_idx + 1}/{n_data}" if n_data > 1 else "Loading from file"
            status_queue.put((1, 0.0, text))
            with _tqdm_to_queue(status_queue, 1, 0.0, 1.0, text):
                data = FileManager.load_file(file)

            if self.use_epocher:
                text = f"Epoching data ({file_idx + 1}/{n_data})" if n_data > 1 else "Epoching data"
                status_queue.put((2, 0.0, text))
                with _tqdm_to_queue(status_queue, 2, 0.0, 1.0, text):
                    epoched = epocher.to_epoch(data)
                if save_ada['epocher']:
                    epoched.export(os.path.join(self.out_path, f"{filename}_epoched.ada"))
                if save_csv['epocher']:
                    FileManager.save_csv(os.path.join(self.out_path, f"{filename}_epoched.csv"), epoched)
                if save_plot['epocher']:
                    plot(epoched, timestamp=False, window_name="Epoched data")
                    if n_data > 1:
                        plt.savefig(os.path.join(self.out_path, f"{filename}_epoched.png"))
                        plt.close()
                if save_plot['profile']:
                    daily_profile(epoched, self.daily_profile_window)
                    if n_data > 1:
                        plt.savefig(os.path.join(self.out_path, f"{filename}_daily_profile.png"))
                        plt.close()
                data = epoched

            if self.use_scorer:
                text = f"Scoring data ({file_idx + 1}/{n_data})" if n_data > 1 else "Scoring data"
                status_queue.put((3, 0.0, text))
                with _tqdm_to_queue(status_queue, 3, 0.0, 1.0, text):
                    scored = scorer.to_score(data)
                if save_ada['scorer']:
                    scored.export(os.path.join(self.out_path, f"{filename}_scored.ada"))
                if save_csv['scorer']:
                    FileManager.save_csv(os.path.join(self.out_path, f"{filename}_scored.csv"), scored)
                if save_plot['scorer']:
                    plot(data, scored, timestamp=False, window_name="Sleep/wake scoring")
                    if n_data > 1:
                        plt.savefig(os.path.join(self.out_path, f"{filename}_scored.png"))
                        plt.close()
                scored_all.append(scored)

            if self.use_long:
                text = f"Fitting long model ({file_idx + 1}/{n_data})" if n_data > 1 else "Fitting long model to the data"
                status_queue.put((4, 0.0, text))
                with _tqdm_to_queue(status_queue, 4, 0.0, 1.0, text):
                    long_fit = long.fit(data)
                if isinstance(long_fit, list):
                    long_fit = long_fit[0]
                if save_ada['long']:
                    long_fit.export(os.path.join(self.out_path, f"{filename}_long.ada.long"))
                if save_csv['long']:
                    long_fit.save_csv(os.path.join(self.out_path, f"{filename}_long.csv"))
                if save_plot['long']:
                    if isinstance(long, _Cosinor):
                        if self.use_scorer:
                            plot(data, scored, long_fit, timestamp=False,
                                 window_name="Cosinor fit with sleep/wake scoring")
                        else:
                            plot(data, None, long_fit, timestamp=False,
                                 window_name="Cosinor fit")
                        if n_data > 1:
                            plt.savefig(os.path.join(self.out_path, f"{filename}_long.png"))
                            plt.close()
                    else:
                        if n_data == 1:
                            long_fit.plot(None)
                        else:
                            long_fit.plot(os.path.join(self.out_path, f"{filename}_long.png"))
                longed_all.append(long_fit)

            if n_data == 1:
                plt.show()

        if self.use_scorer and save_summary['scorer']:
            status_queue.put((5, 1.0, "Generating summary table"))
            summary_sleep_report(scored_all, os.path.join(self.out_path, "summary_sleep_report.csv"))
        if self.use_long and save_summary['long']:
            status_queue.put((5, 1.0, "Generating summary table"))
            summary_long_report(longed_all, os.path.join(self.out_path, "summary_long_report.csv"))

    # ===================================================================
    #  RESULTS BROWSER
    # ===================================================================

    def open_result_file(self, filepath):
        """Open a single result file with the system's default application."""
        _open_with_system(filepath)

    def open_output_folder(self):
        """Open the output directory in the system file manager."""
        if self.out_path and os.path.isdir(self.out_path):
            _open_with_system(self.out_path)

    def refresh_results(self):
        """Rebuild the results file list (left panel) and clear the preview (right panel)."""
        # Clean up textures from the previous refresh
        for tex_tag in self._result_textures:
            try:
                dpg.delete_item(tex_tag)
            except Exception:
                pass
        self._result_textures.clear()
        self._result_images.clear()
        self._result_files.clear()
        self._selected_result = None

        dpg.delete_item("results_file_list", children_only=True)
        dpg.delete_item("results_preview", children_only=True)

        # No output directory — show the "Choose" button
        if self.out_path is None or not os.path.isdir(self.out_path):
            dpg.show_item("results_no_dir_btn")
            dpg.hide_item("results_path_text")
            dpg.hide_item("results_hint_text")
            return

        # Show the clickable path and hint
        dpg.hide_item("results_no_dir_btn")
        dpg.configure_item("results_path_text",
                           label=unicodedata.normalize('NFC', self.out_path))
        dpg.set_value("results_path_text", False)
        dpg.show_item("results_path_text")
        dpg.show_item("results_hint_text")

        # Filter options
        filter_results = dpg.get_value("results_filter")
        filter_last = (dpg.get_value("results_last_analysis")
                       and self._last_analysis_time is not None)

        files = sorted(os.listdir(self.out_path),
                       key=lambda f: os.path.getmtime(
                           os.path.join(self.out_path, f)),
                       reverse=True)
        for fname in files:
            full = os.path.join(self.out_path, fname)
            if not os.path.isfile(full):
                continue

            ext = fname.rsplit('.', 1)[-1].lower() if '.' in fname else ''

            if filter_results and ext not in ('png', 'csv'):
                continue

            # Show only files generated by the last analysis run
            if filter_last:
                mtime = os.path.getmtime(full)
                if mtime < self._last_analysis_time:
                    continue

            self._result_files.append(full)

            # Clickable filename — selects the file and shows preview
            dpg.add_selectable(
                label=unicodedata.normalize('NFC', fname),
                parent="results_file_list",
                callback=self._result_file_selected,
                user_data=full,
            )

        # Auto-select the first file
        if self._result_files:
            children = dpg.get_item_children("results_file_list", 1)
            if children:
                first_tag = children[0]
                dpg.set_value(first_tag, True)
                self._selected_result = first_tag
                self._show_result_preview(self._result_files[0])

    def _result_file_selected(self, sender, app_data, user_data):
        """Selection callback — single click selects, double-click opens in system viewer."""
        now = time.monotonic()
        prev_sender, prev_time = self._last_result_click
        self._last_result_click = (sender, now)

        # Double-click: same item clicked twice within 400 ms
        if sender == prev_sender and (now - prev_time) < 0.4:
            self.open_result_file(user_data)
            dpg.set_value(sender, True)
            return

        # Single click: select and show preview
        if self._selected_result is not None and self._selected_result != sender:
            try:
                dpg.set_value(self._selected_result, False)
            except Exception:
                pass
        dpg.set_value(sender, True)
        self._selected_result = sender
        self._show_result_preview(user_data)

    def _show_result_preview(self, full_path):
        """Show a preview of *full_path* in the right panel."""
        # Clean up previous preview textures
        for tex_tag in self._result_textures:
            try:
                dpg.delete_item(tex_tag)
            except Exception:
                pass
        self._result_textures.clear()
        self._result_images.clear()

        dpg.delete_item("results_preview", children_only=True)

        # Ensure the shared texture registry exists
        if not dpg.does_item_exist("_results_tex_reg"):
            dpg.add_texture_registry(tag="_results_tex_reg")

        fname = os.path.basename(full_path)
        ext = fname.rsplit('.', 1)[-1].lower() if '.' in fname else ''

        # Filename and "Open" button
        with dpg.group(horizontal=True, parent="results_preview"):
            dpg.add_text(unicodedata.normalize('NFC', fname))
            dpg.add_button(label="Open",
                           callback=lambda: self.open_result_file(full_path))
        dpg.add_spacer(height=4, parent="results_preview")

        # --- Image preview (.png) ---
        if ext == 'png':
            try:
                img = Image.open(full_path).convert("RGBA")
                tex_ratio = min(_TEX_MAX_W / img.width, 1.0)
                tex_w = int(img.width * tex_ratio)
                tex_h = int(img.height * tex_ratio)
                img = img.resize((tex_w, tex_h), Image.LANCZOS)
                data = np.array(img, dtype=np.float32).ravel() / 255.0

                tex_tag = f"_rtex_{id(self)}_{fname}"
                dpg.add_static_texture(tex_w, tex_h, data, tag=tex_tag,
                                       parent="_results_tex_reg")
                self._result_textures.append(tex_tag)

                # Compute display size to fill the right panel width
                avail_w = self._right_panel_avail_w()
                disp_ratio = avail_w / tex_w
                disp_w = int(tex_w * disp_ratio)
                disp_h = int(tex_h * disp_ratio)

                img_tag = dpg.add_image(tex_tag, parent="results_preview",
                                        width=disp_w, height=disp_h)
                self._result_images.append((tex_tag, img_tag, tex_w, tex_h))
            except Exception:
                dpg.add_text("Could not load image.",
                             parent="results_preview", color=COLOR_WARNING)

        # --- Scrollable table preview (.csv) ---
        elif ext == 'csv':
            try:
                with open(full_path, 'r', newline='') as f:
                    reader = csv.reader(f)
                    rows = [row for i, row in zip(range(_CSV_MAX_ROWS), reader)]
                if rows:
                    n_cols = max(len(r) for r in rows)
                    header = rows[0]
                    data_rows = rows[1:]
                    table_h = min(_CSV_TABLE_H_MAX,
                                  26 * (len(data_rows) + 1) + 8)
                    with dpg.child_window(parent="results_preview",
                                          height=table_h, width=-1,
                                          horizontal_scrollbar=True):
                        with dpg.table(
                            header_row=True,
                            scrollX=True, scrollY=True,
                            borders_innerH=True, borders_innerV=True,
                            borders_outerH=True, borders_outerV=True,
                            resizable=True,
                            policy=dpg.mvTable_SizingFixedFit,
                        ):
                            for ci in range(n_cols):
                                lbl = header[ci] if ci < len(header) else ""
                                dpg.add_table_column(label=lbl)
                            for row in data_rows:
                                with dpg.table_row():
                                    for ci in range(n_cols):
                                        val = row[ci] if ci < len(row) else ""
                                        dpg.add_text(val)
            except Exception:
                dpg.add_text("Could not load CSV.",
                             parent="results_preview", color=COLOR_WARNING)

        # --- No preview available ---
        else:
            dpg.add_text("No preview available for this file type.",
                         parent="results_preview", color=[170, 180, 195, 200])

    def _right_panel_avail_w(self):
        """Return the usable pixel width inside the right preview panel."""
        try:
            # get_item_rect_size returns the actual rendered size (not the
            # configured value, which is -1 for auto-fill panels).
            panel_w = dpg.get_item_rect_size("results_right_panel")[0]
            if panel_w > 100:
                return max(200, panel_w - _THUMB_PADDING)
        except Exception:
            pass
        # Fallback: compute from viewport width minus the left panel proportion
        vp_w = max(500, dpg.get_viewport_client_width())
        return max(200, int(vp_w * 0.65) - _THUMB_PADDING)

    def resize_result_images(self):
        """Update the display size of all image thumbnails to fit the right panel width."""
        if not self._result_images:
            return
        avail_w = self._right_panel_avail_w()
        for _tex_tag, img_tag, tex_w, tex_h in self._result_images:
            try:
                ratio = avail_w / tex_w
                dpg.configure_item(img_tag,
                                   width=int(tex_w * ratio),
                                   height=int(tex_h * ratio))
            except Exception:
                pass

    # ===================================================================
    #  VIEW SWITCHING
    # ===================================================================

    def show_results_view(self):
        """Switch to the results browser — hides bottom panel and expands to full height."""
        dpg.hide_item('files_view')
        dpg.hide_item('analysis_view')
        dpg.hide_item('bottom_panel')
        dpg.show_item('results_view')

        # Expand to fill the full viewport (no bottom panel on this page)
        h = dpg.get_viewport_client_height()
        dpg.configure_item('results_view', height=h - 12)

        dpg.set_value("view_files", False)
        dpg.set_value("view_analysis", False)
        dpg.set_value("view_results", True)
        self.set_nav_active("nav_btn_results")
        self.refresh_results()

    def view_callback(self, sender, app_data):
        """Handle View menu checkboxes (Home page / Analysis settings / Results)."""
        if sender == "view_analysis":
            if not dpg.is_item_shown('analysis_view'):
                self.make_snapshot()
                dpg.hide_item('files_view')
                dpg.hide_item('results_view')
                dpg.show_item('bottom_panel')
                dpg.show_item('analysis_view')
                dpg.hide_item('bottom_files')
                dpg.show_item('bottom_analysis')
                dpg.set_value("view_analysis", True)
                dpg.set_value("view_files", False)
                dpg.set_value("view_results", False)
                self.set_nav_active("nav_btn_analysis")
            else:
                dpg.set_value("view_analysis", True)

        elif sender == "view_results":
            if not dpg.is_item_shown('results_view'):
                self.show_results_view()
            else:
                dpg.set_value("view_results", True)

        else:   # view_files (Home)
            if not dpg.is_item_shown('files_view'):
                dpg.hide_item('analysis_view')
                dpg.hide_item('results_view')
                dpg.show_item('bottom_panel')
                dpg.hide_item('bottom_analysis')
                dpg.show_item('files_view')
                dpg.show_item('bottom_files')
                dpg.set_value("view_files", True)
                dpg.set_value("view_analysis", False)
                dpg.set_value("view_results", False)
                self.set_nav_active("nav_btn_home")
            else:
                dpg.set_value("view_files", True)

    # ===================================================================
    #  SNAPSHOT (save / restore analysis settings)
    # ===================================================================

    def make_snapshot(self):
        """Save a deep copy of all analysis settings before editing."""
        for field in self.SNAPSHOT_FIELDS:
            self.snapshot[field] = deepcopy(getattr(self, field))

    def restore_snapshot(self):
        """Restore analysis settings from the last snapshot (for Cancel)."""
        for key, value in self.snapshot.items():
            setattr(self, key, deepcopy(value))

    @callback_updater
    def switch_screens_callback(self, sender, app_data):
        """Toggle between the Home page and the Analysis settings view."""
        if dpg.is_item_shown('analysis_view'):
            dpg.hide_item('analysis_view')
            dpg.show_item('files_view')
            dpg.hide_item('bottom_analysis')
            dpg.show_item('bottom_files')
            dpg.set_value("view_analysis", False)
            dpg.set_value("view_files", True)
            dpg.set_value("view_results", False)
            self.set_nav_active("nav_btn_home")
        else:
            self.make_snapshot()
            dpg.hide_item('files_view')
            dpg.hide_item('results_view')
            dpg.show_item('analysis_view')
            dpg.hide_item('bottom_files')
            dpg.show_item('bottom_analysis')
            dpg.set_value("view_analysis", True)
            dpg.set_value("view_files", False)
            dpg.set_value("view_results", False)
            self.set_nav_active("nav_btn_analysis")

    @callback_updater
    def cancel_callback(self, sender, app_data):
        """Cancel Analysis editing — restore snapshot and return to Home."""
        dpg.hide_item('analysis_view')
        dpg.hide_item('results_view')
        dpg.show_item('files_view')
        dpg.hide_item('bottom_analysis')
        dpg.show_item('bottom_files')
        dpg.set_value("view_analysis", False)
        dpg.set_value("view_files", True)
        dpg.set_value("view_results", False)
        self.set_nav_active("nav_btn_home")
        self.restore_snapshot()
        self.update_fields()

    # ===================================================================
    #  FILE / PRESET / PIPELINE CALLBACKS
    # ===================================================================

    def input_selector_callback(self, sender, app_data):
        """Handle the input-files dialog result."""
        self.input_files = app_data['selections']
        dpg.configure_item("selected_files_text", color=COLOR_OK)
        dpg.set_value("selected_files_text", f"{len(self.input_files)} file(s) selected.")
        if self.input_files:
            text = ""
            try:
                for filename, file in self.input_files.items():
                    text += f"{filename}\n{metadata_summary(file)}\n\n"
            except ValueError:
                text = "No preview available."
            dpg.set_value('file_description', text)
            dpg.show_item('files_view_info_header')

    def output_selector_callback(self, sender, app_data):
        """Handle the output-directory dialog result."""
        self.out_path = os.path.dirname(list(app_data['selections'].values())[0])
        dpg.set_value("output_directory", f"Output: {self.out_path}")
        dpg.configure_item("output_directory", color=COLOR_OK)
        self.refresh_results()

    # --- Presets ---

    def update_fields(self):
        """Synchronise all GUI widgets with current backend state."""
        for field in ['epocher', 'scorer', 'long']:
            for item, value in getattr(self, f"{field}_params").items():
                if value is None:
                    getattr(self, f"{field}_check_text_callback")(f"{item} checkbox", True)
                    dpg.set_value(f"{item} checkbox", True)
                elif isinstance(value, (int, float, bool)):
                    dpg.set_value(f"{item}", value)
                elif isinstance(value, (tuple, list)) and len(value) == 2:
                    dpg.set_value(f"{item} min", value[0])
                    dpg.set_value(f"{item} max", value[1])
                else:
                    raise ValueError("Invalid value in preset!")

            for fmt in ['csv', 'ada', 'plot']:
                dpg.set_value(f"{field}_{fmt}", getattr(self, f"save_{fmt}")[field])

            self.pipeline_checkbox_callback(field, getattr(self, f"use_{field}"))
            self.combo_dropdown_callback(f"{field}_combo", getattr(self, f"current_{field}"))

        dpg.set_value('scorer_summary', self.save_summary['scorer'])
        dpg.set_value('long_summary', self.save_summary['long'])
        dpg.set_value('profile_plot', self.save_plot['profile'])

    @callback_updater
    def preset_button_callback(self, sender, app_data, user_data=None):
        """Apply a processing preset.

        The preset key is passed via *user_data* (set by the GUI).
        """
        # DPG may not pass user_data as a callback argument for all item
        # types (e.g. menu items); fall back to querying the item directly.
        func_name = user_data or dpg.get_item_user_data(sender)
        components = preset_components(func_name)
        for component, value in components.items():
            if value is not None:
                if 'settings' not in component:
                    setattr(self, f"current_{component}", value)
                    self.pipeline_checkbox_callback(component, True)
                    dpg.set_value(f"{component}_combo", value)
                    self.combo_dropdown_callback(f"{component}_combo", value)
                else:
                    # Settings entries — convert short param names from the
                    # preset into composite keys ("AlgorithmName param").
                    name = component.split('_')[0]
                    temp = {}
                    for k, v in value.items():
                        if '_epoch_length' in k:
                            temp['_epoch_length'] = v
                        else:
                            temp[f"{components[name]} {k}"] = v
                    getattr(self, f"{name}_params").update(temp)
            else:
                self.pipeline_checkbox_callback(component, False)

        output_options = preset_save_defaults(func_name)
        for field, value in output_options.items():
            getattr(self, f"save_{field}").update(value)

        for field in ['epocher', 'scorer', 'long']:
            self.advanced_reset_callback(f"{field}_reset", None)
        self.update_fields()

    # --- Pipeline stage controls ---

    def results_save_checkbox_callback(self, sender, app_data):
        """Toggle an output-format checkbox (csv / ada / plot / summary)."""
        fmt = sender.split('_')[1]
        mode = sender.split('_')[0]
        save_dict = {'csv': self.save_csv, 'ada': self.save_ada,
                     'plot': self.save_plot, 'summary': self.save_summary}.get(fmt)
        if save_dict is not None:
            save_dict[mode] = app_data
        if mode == 'preset':
            for _, func in preset_function_names():
                dpg.set_value(f"preset_{fmt}_{func}", app_data)

    @callback_updater
    def pipeline_checkbox_callback(self, sender, app_data):
        """Enable / disable a processing stage."""
        name = sender.split('_')[0]
        dpg.set_value(f"{name}_checkbox", app_data)
        setattr(self, f"use_{name}", app_data)
        if app_data:
            dpg.enable_item(f"{name}_header")
            dpg.show_item(f"{name}_header")
            dpg.set_item_label(f"{name}_details", "Hide details")
        else:
            dpg.hide_item(f"{name}_header")
            dpg.set_item_label(f"{name}_details", "Show details")
            dpg.disable_item(f"{name}_header")

    @callback_updater
    def pipeline_details_callback(self, sender, app_data):
        """Show / hide the detailed options panel for a stage."""
        name = sender.split('_')[0]
        if dpg.is_item_shown(f"{name}_header"):
            dpg.hide_item(f"{name}_header")
            dpg.set_item_label(sender, "Show details")
        else:
            dpg.show_item(f"{name}_header")
            dpg.set_item_label(sender, "Hide details")

    def combo_dropdown_callback(self, sender, app_data):
        """Handle algorithm selection in a combo dropdown."""
        name = sender.split('_')[0]
        dpg.set_value(sender, app_data)
        setattr(self, f"current_{name}", app_data)
        if dpg.get_value(f"{name}_advanced"):
            for tag in getattr(self, f"{name}_advanced"):
                dpg.hide_item(tag)
            dpg.show_item(getattr(self, f"current_{name}"))
        self.check_runtime_status()

    def advanced_reset_callback(self, sender, app_data):
        """Reset all advanced parameters for a stage to their defaults."""
        stage = sender.split('_')[0]
        for name in getattr(self, f"{stage}_advanced"):
            slots = globals()[f"{stage}_object"](name)
            sign = inspect.signature(slots.__init__)
            for e in sign.parameters:
                if e in ("self", "_epoch_length"):
                    continue
                tag = f"{name} {e}"
                ann = sign.parameters[e].annotation
                default = sign.parameters[e].default
                if ann == typing.Tuple[float, float]:
                    dpg.set_value(f"{tag} min", default[0])
                    dpg.set_value(f"{tag} max", default[1])
                elif ann in (float | None, int | None):
                    dpg.set_value(f"{tag} checkbox", True)
                    dpg.set_value(tag, 1)
                else:
                    dpg.set_value(tag, default)
                if tag in getattr(self, f"{stage}_params"):
                    getattr(self, f"{stage}_params")[tag] = default

        if stage == 'epocher':
            self.daily_profile_window = 15
            for name in epocher_function_names():
                dpg.set_value(f'{name} daily_wlen', 15)

    def advanced_checkbox_callback(self, sender, app_data):
        """Show / hide the advanced parameter group for the selected algorithm."""
        name = sender.split('_')[0]
        for tag in getattr(self, f"{name}_advanced"):
            dpg.hide_item(tag)
        if app_data and getattr(self, f"current_{name}") is not None:
            dpg.show_item(getattr(self, f"current_{name}"))

    # --- Per-stage parameter callbacks ---
    #
    # Each processing stage (epocher / scorer / long) needs textfield,
    # checkbox, and check_text callbacks.  The logic is identical — only
    # the target params dict differs.  Shared helpers below keep it DRY;
    # the named methods are still needed because gui.py resolves them
    # via  getattr(backend, f"{stage}_textfield_callback")  etc.

    def _stage_textfield(self, sender, app_data, params):
        """Update *params* from a numeric text field or min/max pair."""
        parts = sender.split(' ')
        name = ' '.join(parts[:-1])
        if parts[-1] in ('min', 'max'):
            tup = (dpg.get_value(f"{name} min"), dpg.get_value(f"{name} max"))
            if tup[0] >= tup[1]:
                raise ValueError("First value must be smaller!")
            params[name] = tup
        else:
            params[sender] = app_data

    def _stage_check_text(self, sender, app_data, params):
        """Toggle between auto (None) and manual value in *params*."""
        name = ' '.join(sender.split(' ')[:-1])
        if app_data:
            dpg.disable_item(name)
            params[name] = None
        else:
            params[name] = dpg.get_value(name)
            dpg.enable_item(name)

    @error_popup
    def epocher_textfield_callback(self, sender, app_data):
        self._stage_textfield(sender, app_data, self.epocher_params)

    def epocher_checkbox_callback(self, sender, app_data):
        self.epocher_params[sender] = app_data

    @error_popup
    def epocher_check_text_callback(self, sender, app_data):
        self._stage_check_text(sender, app_data, self.epocher_params)

    @error_popup
    def epocher_daily_profile_callback(self, sender, app_data):
        self.daily_profile_window = app_data
        for name in epocher_function_names():
            dpg.set_value(f'{name} daily_wlen', app_data)

    @error_popup
    def scorer_textfield_callback(self, sender, app_data):
        self._stage_textfield(sender, app_data, self.scorer_params)

    def scorer_checkbox_callback(self, sender, app_data):
        self.scorer_params[sender] = app_data

    @error_popup
    def scorer_check_text_callback(self, sender, app_data):
        self._stage_check_text(sender, app_data, self.scorer_params)

    @error_popup
    def long_textfield_callback(self, sender, app_data):
        self._stage_textfield(sender, app_data, self.long_params)

    def long_checkbox_callback(self, sender, app_data):
        self.long_params[sender] = app_data

    @error_popup
    def long_check_text_callback(self, sender, app_data):
        self._stage_check_text(sender, app_data, self.long_params)
