"""DearPyGui front-end for ADA (Actigraphic Data Analyzer).

Builds the full GUI layout — menu bar, three switchable pages (Home,
Analysis, Results), and modal dialogs — then runs the render loop.

The module is structured as follows:

    THEMES / FONTS       Visual constants loaded from TOML / TTF files.
    NAVIGATION BAR       Right-aligned Home / Analysis / Results tabs.
    VIEW SWITCHING       Helpers that show/hide the three pages.
    GUI STRUCTURE        Popups, preset lists, file-dialog wrappers.
    ANALYSIS HELPERS     Reusable builders for per-stage option panels.
    MAIN WINDOW          ``setup_main_window`` — the full widget tree.
    LAYOUT HELPERS       Per-frame centering, spacer, and resize logic.
    ENTRY POINT          ``main()`` — event/render loop.
"""

import dearpygui.dearpygui as dpg
import inspect
import os
import platform
import threading
import tomllib
import typing
import webbrowser

from ada_gui.config_parser import (
    sorted_extension_list, preset_function_names,
    epocher_function_names, epocher_object,
    scorer_function_names, scorer_object,
    long_function_names, long_object,
)
from ada_gui.backend import Backend, PROGRESS_TO_STRING, COLOR_WARNING, COLOR_OK


# ---------------------------------------------------------------------------
# External URLs
# ---------------------------------------------------------------------------

DOCS_URL = "https://fuw_software.gitlab.io/aktygrafy/"
TUTORIAL_URL = "https://ada-tutorial-tmp-4f5aa7.gitlab.io/TEST_GUI_tutorial.html"
ASSETS = os.path.join(os.path.dirname(__file__), 'assets')

# Path to locally shipped Sphinx HTML docs
_LOCAL_DOCS = os.path.join(os.path.dirname(__file__), 'static', 'html')


def _open_docs(page: str) -> None:
    """Open a documentation page in the default browser.

    Uses locally built HTML docs if available, otherwise falls back to the
    online URL.
    """
    local_path = os.path.join(_LOCAL_DOCS, page)
    if os.path.isfile(local_path):
        url = 'file://' + os.path.abspath(local_path)
    else:
        url = DOCS_URL + page
    webbrowser.open(url)


# ---------------------------------------------------------------------------
# Layout constants (platform-dependent)
# ---------------------------------------------------------------------------

BOTTOM_H_MIN = 80
INPUT_W = 150

if platform.system() == 'Windows':
    MAIN_H = 780
    MAIN_W = 650
else:
    MAIN_H = 760
    MAIN_W = 600

SET_H = MAIN_H - BOTTOM_H_MIN - 12   # height of the main content area
SET_W = MAIN_W
SET_POS = (0, 0)

_RESULTS_LIST_W = int(SET_W * 0.35)   # left-panel width in the Results browser


# ---------------------------------------------------------------------------
# DearPyGui context and viewport
# ---------------------------------------------------------------------------

dpg.create_context()
dpg.create_viewport(
    title='ADA: Actigraphic Data Analyzer',
    width=MAIN_W, height=MAIN_H, resizable=True,
)
dpg.set_viewport_small_icon(os.path.join(ASSETS, 'Ada_Lovelace.ico'))
dpg.set_viewport_large_icon(os.path.join(ASSETS, 'Ada_Lovelace.ico'))


# ===================================================================
#  THEMES
# ===================================================================

BTN_CLR = [140, 170, 220, 255]   # highlight color for instructional text


def parse_theme(theme_file_name):
    """Load a TOML theme file and register it as a DearPyGUI global theme."""
    if not theme_file_name.endswith('.toml'):
        theme_file_name += '.toml'
    with open(os.path.join(ASSETS, theme_file_name), 'rb') as f:
        theme_data = tomllib.load(f)
    with dpg.theme() as tag:
        with dpg.theme_component(0):
            for key, value in theme_data.get('theme', {}).items():
                try:
                    dpg.add_theme_color(getattr(dpg, key), value)
                except AttributeError:
                    pass  # skip unknown theme keys
            for key, value in theme_data.get('style', {}).items():
                try:
                    v = value if isinstance(value, list) else [value]
                    dpg.add_theme_style(getattr(dpg, key), *v)
                except AttributeError:
                    pass  # skip unknown style keys
    return tag


def disabled_btn_theme(text_rgba, btn_rgba):
    """Create a theme for disabled buttons with given text and background colors."""
    with dpg.theme() as handle:
        with dpg.theme_component(dpg.mvButton, enabled_state=False):
            dpg.add_theme_color(dpg.mvThemeCol_Text, text_rgba)
            for col in (dpg.mvThemeCol_Button, dpg.mvThemeCol_ButtonHovered,
                        dpg.mvThemeCol_ButtonActive):
                dpg.add_theme_color(col, btn_rgba)
    return handle


DISABLED_BTN_DARK = disabled_btn_theme([100, 100, 110, 150], [45, 48, 60, 180])
DISABLED_BTN_LIGHT = disabled_btn_theme([150, 155, 170, 180], [205, 210, 225, 255])

DARK_THEME = parse_theme("dark_theme")
LIGHT_THEME = parse_theme("light_theme")


def themes_callback(sender, app_data):
    """Toggle between dark and light themes."""
    if sender == "view_dark":
        dpg.bind_theme(DARK_THEME)
        dpg.set_value("view_light", False)
        dpg.bind_item_theme("runtime", DISABLED_BTN_DARK)
    else:
        dpg.bind_theme(LIGHT_THEME)
        dpg.set_value("view_dark", False)
        dpg.bind_item_theme("runtime", DISABLED_BTN_LIGHT)


# Raised 3-D button used on the Home page
with dpg.theme() as HOME_BTN_THEME:
    with dpg.theme_component(dpg.mvButton):
        dpg.add_theme_style(dpg.mvStyleVar_FramePadding, 16, 5)
        dpg.add_theme_style(dpg.mvStyleVar_FrameRounding, 8)
        dpg.add_theme_style(dpg.mvStyleVar_FrameBorderSize, 2)

# Draggable splitter between results panels
with dpg.theme() as SPLITTER_THEME:
    with dpg.theme_component(dpg.mvChildWindow):
        dpg.add_theme_color(dpg.mvThemeCol_ChildBg, [50, 55, 65, 255])
        dpg.add_theme_color(dpg.mvThemeCol_Border, [0, 0, 0, 0])
        dpg.add_theme_style(dpg.mvStyleVar_WindowPadding, 0, 0)

with dpg.theme() as SPLITTER_HOVER_THEME:
    with dpg.theme_component(dpg.mvChildWindow):
        dpg.add_theme_color(dpg.mvThemeCol_ChildBg, [100, 170, 255, 180])
        dpg.add_theme_color(dpg.mvThemeCol_Border, [0, 0, 0, 0])
        dpg.add_theme_style(dpg.mvStyleVar_WindowPadding, 0, 0)

_SPLITTER_W = 6          # width of the draggable handle (px)
_SPLITTER_MIN_L = 100    # minimum left panel width
_SPLITTER_MIN_R = 150    # minimum right panel width
_splitter_dragging = False

# Larger checkbox for stage toggles (Epoching / Scoring / Long)
with dpg.theme() as STAGE_CHECKBOX_THEME:
    with dpg.theme_component(dpg.mvCheckbox):
        dpg.add_theme_style(dpg.mvStyleVar_FramePadding, 6, 6)

# Matching button for stage Show/Hide details
with dpg.theme() as STAGE_BTN_THEME:
    with dpg.theme_component(dpg.mvButton):
        dpg.add_theme_style(dpg.mvStyleVar_FramePadding, 6, 6)


# ===================================================================
#  FONTS  — Roboto with extended Unicode coverage
# ===================================================================

def add_font(path, size):
    """Register a font with extended glyph ranges (European, punctuation, symbols)."""
    with dpg.font(path, size) as tag:
        dpg.add_font_range_hint(dpg.mvFontRangeHint_Default)
        dpg.add_font_range(0x0100, 0x017F)   # Latin Extended-A (Polish, Czech, …)
        dpg.add_font_range(0x0180, 0x024F)   # Latin Extended-B (Romanian, Croatian, …)
        dpg.add_font_range(0x0250, 0x02AF)   # IPA Extensions
        dpg.add_font_range(0x1E00, 0x1EFF)   # Latin Extended Additional (Vietnamese, …)
        dpg.add_font_range(0x2000, 0x206F)   # General Punctuation (dashes, quotes, …)
        dpg.add_font_range(0x2070, 0x209F)   # Superscripts and Subscripts
        dpg.add_font_range(0x20A0, 0x20CF)   # Currency Symbols
        dpg.add_font_range(0x2100, 0x214F)   # Letterlike Symbols (℃, №, …)
    return tag


with dpg.font_registry():
    default_font = add_font(os.path.join(ASSETS, "Roboto-Regular.ttf"), 18)
    screen_title_font = add_font(os.path.join(ASSETS, "Roboto-Medium.ttf"), 22)
    header_font = add_font(os.path.join(ASSETS, "Roboto-Medium.ttf"), 18)
    nav_font = add_font(os.path.join(ASSETS, "Roboto-Regular.ttf"), 14)


# ===================================================================
#  NAVIGATION BAR  (Home / Analysis / Results buttons in the menu bar)
# ===================================================================

_NAV_BTNS = ["nav_btn_home", "nav_btn_analysis", "nav_btn_results"]
_NAV_ACTIVE_TAG = "nav_btn_home"

# Flat, borderless button style — active nav tab (subdued compared to menus)
with dpg.theme() as NAV_ACTIVE_THEME:
    with dpg.theme_component(dpg.mvButton):
        dpg.add_theme_color(dpg.mvThemeCol_Button, [0, 0, 0, 0])
        dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, [255, 255, 255, 15])
        dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, [255, 255, 255, 30])
        dpg.add_theme_color(dpg.mvThemeCol_Text, [200, 185, 150, 255])
        dpg.add_theme_color(dpg.mvThemeCol_Border, [0, 0, 0, 0])
        dpg.add_theme_style(dpg.mvStyleVar_FrameRounding, 0)
        dpg.add_theme_style(dpg.mvStyleVar_FrameBorderSize, 0)
        dpg.add_theme_style(dpg.mvStyleVar_FramePadding, 6, 4)

# Flat, borderless button style — inactive nav tab (very subtle)
with dpg.theme() as NAV_INACTIVE_THEME:
    with dpg.theme_component(dpg.mvButton):
        dpg.add_theme_color(dpg.mvThemeCol_Button, [0, 0, 0, 0])
        dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, [255, 255, 255, 15])
        dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, [255, 255, 255, 30])
        dpg.add_theme_color(dpg.mvThemeCol_Text, [145, 135, 110, 200])
        dpg.add_theme_color(dpg.mvThemeCol_Border, [0, 0, 0, 0])
        dpg.add_theme_style(dpg.mvStyleVar_FrameRounding, 0)
        dpg.add_theme_style(dpg.mvStyleVar_FrameBorderSize, 0)
        dpg.add_theme_style(dpg.mvStyleVar_FramePadding, 6, 4)

# Viewport-level draw list for the coloured underline beneath the active tab
_NAV_DRAWLIST = dpg.add_viewport_drawlist(front=True)
_NAV_UNDERLINE = dpg.draw_line(
    parent=_NAV_DRAWLIST, p1=[0, 0], p2=[0, 0],
    color=[200, 175, 120, 255], thickness=3, show=False,
)


def _set_nav_active(active_tag):
    """Highlight the active navigation button and dim the rest."""
    global _NAV_ACTIVE_TAG
    _NAV_ACTIVE_TAG = active_tag
    for tag in _NAV_BTNS:
        theme = NAV_ACTIVE_THEME if tag == active_tag else NAV_INACTIVE_THEME
        dpg.bind_item_theme(tag, theme)


def _update_nav_underline():
    """Redraw the underline beneath the currently active nav button (called every frame)."""
    try:
        mn = dpg.get_item_rect_min(_NAV_ACTIVE_TAG)
        mx = dpg.get_item_rect_max(_NAV_ACTIVE_TAG)
        dpg.configure_item(_NAV_UNDERLINE, p1=[mn[0], mx[1]], p2=[mx[0], mx[1]], show=True)
    except Exception:
        pass


# ---------------------------------------------------------------------------
# View switching helpers
# ---------------------------------------------------------------------------

def _restore_results_height():
    """Reset results_view to the standard panel height (making room for bottom_panel)."""
    h = dpg.get_viewport_client_height()
    dpg.configure_item('results_view', height=max(SET_H, h - BOTTOM_H_MIN - 12))


def _nav_go_home(backend):
    """Switch to the Home (files) view."""
    _set_nav_active("nav_btn_home")
    dpg.hide_item('analysis_view')
    dpg.hide_item('results_view')
    dpg.hide_item('bottom_analysis')
    dpg.show_item('bottom_panel')
    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)
    _restore_results_height()


def _nav_go_analysis(backend):
    """Switch to the Analysis settings view."""
    _set_nav_active("nav_btn_analysis")
    backend.make_snapshot()
    dpg.hide_item('files_view')
    dpg.hide_item('results_view')
    dpg.hide_item('bottom_files')
    dpg.show_item('bottom_panel')
    dpg.show_item('analysis_view')
    dpg.show_item('bottom_analysis')
    dpg.set_value("view_files", False)
    dpg.set_value("view_analysis", True)
    dpg.set_value("view_results", False)
    _restore_results_height()


def _nav_go_results(backend):
    """Switch to the Results browser (delegates to backend for full-height layout)."""
    backend.show_results_view()


# ===================================================================
#  GUI STRUCTURE  — popups, dialogs, reusable builders
# ===================================================================

# Error popup
with dpg.window(label="ERROR", tag="error", show=False, pos=(300, 300),
                autosize=True, no_resize=True, modal=True, no_close=True):
    dpg.add_text(tag="error_text")
    dpg.add_spacer()
    dpg.add_button(label='OK', callback=lambda: dpg.configure_item("error", show=False))

# Modal overlay shown while a system file dialog is open
with dpg.window(tag="system_dialog_modal", show=False, modal=True,
                no_title_bar=True, autosize=True, no_resize=True,
                no_move=True, no_close=True):
    dpg.add_text("Waiting for file dialog...")


def _make_preset_callback(backend, hide_tag):
    """Build a DPG callback that applies a preset and hides *hide_tag*."""
    def _cb(sender, app_data, user_data):
        backend.preset_button_callback(sender, app_data, user_data=user_data)
        dpg.hide_item(hide_tag)
    return _cb


def _add_preset_items(backend: Backend, prefix="but", as_menu_items=True,
                      hide_tag=None):
    """Populate the current DPG container with one entry per preset.

    *prefix*: tag prefix (must differ between the menu and the popup).
    *as_menu_items*: True inside a dpg.menu, False inside a popup window.
    *hide_tag*: if set, hide this item after a preset is chosen.
    """
    add = dpg.add_menu_item if as_menu_items else dpg.add_button
    callback = (_make_preset_callback(backend, hide_tag)
                if hide_tag else backend.preset_button_callback)
    for label, func in preset_function_names():
        kwargs = {"width": 180} if not as_menu_items else {}
        add(label=label, tag=f"{prefix}_{func}", user_data=func,
            callback=callback, **kwargs)


def _show_presets_centered():
    """Show the presets modal centered in the viewport."""
    vw = dpg.get_viewport_client_width()
    vh = dpg.get_viewport_client_height()
    # Estimate popup size (180px wide buttons + padding, ~30px per item)
    pw, ph = 200, 310
    dpg.configure_item("presets_popup",
                       pos=[max(0, (vw - pw) // 2), max(0, (vh - ph) // 2)],
                       show=True)


# ---------------------------------------------------------------------------
# Native file dialogs
# ---------------------------------------------------------------------------
# DearPyGui's built-in file dialogs are custom-drawn widgets that look
# foreign on every OS.  We replace them with real system dialogs:
#   - macOS:  AppleScript ``choose file`` / ``choose folder`` via osascript
#   - other:  tkinter.filedialog in a subprocess (Tk must own the main
#             thread, which DPG already occupies, so a subprocess is needed)
# A background thread waits for the subprocess so the DPG render loop
# keeps running while the dialog is open.
# ---------------------------------------------------------------------------

_IS_MAC = platform.system() == 'Darwin'
# macOS and Windows have good native dialogs; Linux falls back to DPG's built-in
_SYSTEM_DIALOGS_DEFAULT = platform.system() in ('Darwin', 'Windows')


def _pick_files_system():
    """Show a native multi-file picker (tkinter subprocess); return selected paths or [].

    Tkinter's file dialog renders a native Cocoa/Win32/GTK dialog on each
    platform and supports a filetype dropdown.  A subprocess is needed
    because Tk must own the main thread, which DPG already occupies.
    """
    import subprocess
    import sys
    exts = sorted_extension_list()
    filetypes = repr([
        ("Actigraphic files", " ".join(f"*.{e}" for e in exts)),
        *((f".{e} files", f"*.{e}") for e in exts),
        ("All files", "*.*"),
    ])
    # On macOS, bring the subprocess to front so the dialog isn't hidden
    # behind the main DPG window.  Uses PyObjC (pyobjc-framework-Cocoa)
    # if installed; this is an *optional* dependency — not listed in
    # project requirements.  Without it the dialog still works but may
    # appear behind the main window.
    if _IS_MAC:
        activate = (
            "try:\n"
            "    from AppKit import NSApplication\n"
            "    _app = NSApplication.sharedApplication()\n"
            "    _app.setActivationPolicy_(0)\n"
            "    _app.activateIgnoringOtherApps_(True)\n"
            "except ImportError: pass\n"
        )
    else:
        activate = ""
    code = (
        "import tkinter as tk; from tkinter import filedialog\n"
        "root = tk.Tk(); root.withdraw()\n"
        f"{activate}"
        f"paths = filedialog.askopenfilenames(title='Select input files', filetypes={filetypes})\n"
        "root.destroy(); print('\\n'.join(paths))"
    )

    result = subprocess.run([sys.executable, "-c", code],
                            capture_output=True, text=True)
    output = result.stdout.strip()
    return output.splitlines() if output else []


def _pick_directory_macos():
    """Show a native macOS folder picker; return the path or ''."""
    import subprocess
    applescript = (
        'try\n'
        '    POSIX path of (choose folder with prompt '
        '"Select output directory")\n'
        'on error number -128\n'
        '    return ""\n'
        'end try'
    )
    result = subprocess.run(['osascript', '-e', applescript],
                            capture_output=True, text=True)
    return result.stdout.strip().rstrip('/')


def _pick_directory_tk():
    """Show a tkinter folder picker (subprocess); return the path or ''."""
    import subprocess
    import sys
    code = (
        "import tkinter as tk; from tkinter import filedialog\n"
        "root = tk.Tk(); root.withdraw()\n"
        "path = filedialog.askdirectory(title='Select output directory')\n"
        "root.destroy(); print(path)"
    )
    result = subprocess.run([sys.executable, "-c", code],
                            capture_output=True, text=True)
    return result.stdout.strip()


def _set_output_directory(backend, path):
    """Apply a chosen output directory to the backend and update the GUI."""
    backend.out_path = path
    dpg.set_value("output_directory", f"Output: {path}")
    dpg.configure_item("output_directory", color=COLOR_OK)
    backend.refresh_results()


def _open_input_dialog(backend):
    """Open a file picker for input files.

    Uses native system dialogs when *view_system_dialogs* is checked,
    otherwise the DearPyGui built-in file dialog.
    """
    if not dpg.get_value("view_system_dialogs"):
        dpg.show_item("dpg_input_dialog")
        return

    dpg.configure_item("system_dialog_modal", show=True)

    def _pick():
        try:
            paths = _pick_files_system()
            if paths:
                selections = {os.path.basename(p): p for p in paths}
                backend.input_selector_callback(None, {"selections": selections})
        finally:
            dpg.configure_item("system_dialog_modal", show=False)

    threading.Thread(target=_pick, daemon=True).start()


def _open_output_dialog(backend):
    """Open a directory picker for the output folder.

    Uses native system dialogs when *view_system_dialogs* is checked,
    otherwise the DearPyGui built-in directory dialog.
    """
    if not dpg.get_value("view_system_dialogs"):
        dpg.show_item("dpg_output_dialog")
        return

    dpg.configure_item("system_dialog_modal", show=True)

    def _pick():
        try:
            path = _pick_directory_macos() if _IS_MAC else _pick_directory_tk()
            if path:
                _set_output_directory(backend, path)
        finally:
            dpg.configure_item("system_dialog_modal", show=False)

    threading.Thread(target=_pick, daemon=True).start()


# ---------------------------------------------------------------------------
# Analysis-view helpers
# ---------------------------------------------------------------------------

def add_stage_header(stage, label, tooltip_text, backend: Backend):
    """Add the checkbox + title + Show details row for a processing stage."""
    with dpg.group(horizontal=True):
        dpg.add_checkbox(tag=f"{stage}_checkbox",
                         default_value=getattr(backend, f"use_{stage}"),
                         callback=backend.pipeline_checkbox_callback)
        dpg.bind_item_theme(f"{stage}_checkbox", STAGE_CHECKBOX_THEME)
        with dpg.tooltip(parent=f"{stage}_checkbox"):
            dpg.add_text(tooltip_text)
        dpg.add_text(label)
        dpg.bind_item_font(dpg.last_item(), screen_title_font)
        dpg.add_button(label="Show details", tag=f"{stage}_details",
                       callback=backend.pipeline_details_callback)
        dpg.bind_item_theme(f"{stage}_details", STAGE_BTN_THEME)
        with dpg.tooltip(parent=f"{stage}_details"):
            dpg.add_text(f"Expand/collapse {stage} options")


def add_output_options(stage, backend: Backend):
    """Add the output-options table (csv, ada, plot, summary/profile) for a stage."""
    dpg.add_text("Output options")
    dpg.add_spacer(height=2)
    with dpg.table(header_row=False):
        dpg.add_table_column(width_fixed=True, init_width_or_weight=160)
        dpg.add_table_column(width_fixed=True, init_width_or_weight=200)
        with dpg.table_row():
            dpg.add_checkbox(label='Save generic .csv', tag=f"{stage}_csv",
                             default_value=backend.save_csv[stage],
                             callback=backend.results_save_checkbox_callback)
            dpg.add_checkbox(label='Save .ada file', tag=f"{stage}_ada",
                             default_value=backend.save_ada[stage],
                             callback=backend.results_save_checkbox_callback)
        with dpg.table_row():
            dpg.add_checkbox(label='Plot results', tag=f"{stage}_plot",
                             default_value=backend.save_plot[stage],
                             callback=backend.results_save_checkbox_callback)
            if stage == 'epocher':
                dpg.add_checkbox(label="Daily profile", tag='profile_plot',
                                 default_value=backend.save_plot['profile'],
                                 callback=backend.results_save_checkbox_callback)
            else:
                dpg.add_checkbox(label='Save summary', tag=f"{stage}_summary",
                                 default_value=backend.save_summary[stage],
                                 callback=backend.results_save_checkbox_callback)


def add_advanced_options(names, backend: Backend, stage, object_fun):
    """Dynamically generate advanced-option widgets from the algorithm's ``__init__`` signature.

    Each parameter's *type annotation* determines the DPG widget used:

        bool                  checkbox
        int                   input_int
        float / float|list    input_float
        Tuple[float, float]   paired min / max input_floats
        float|None / int|None "auto" checkbox + numeric input (disabled when auto)
    """
    for name in names:
        with dpg.group(show=False, tag=name):
            slots = object_fun(name)
            getattr(backend, f"{stage}_advanced").append(name)
            sign = inspect.signature(slots.__init__)
            dpg.add_text("Advanced options")
            if stage == 'epocher':
                dpg.add_input_int(label='Daily profile window',
                                  tag=f"{name} daily_wlen",
                                  default_value=backend.daily_profile_window,
                                  callback=backend.epocher_daily_profile_callback)
            for e in sign.parameters:
                if e in ("self", "_epoch_length"):
                    continue
                ann = sign.parameters[e].annotation
                default = sign.parameters[e].default
                tag = f"{name} {e}"
                cb = getattr(backend, f"{stage}_textfield_callback")
                if ann == bool:
                    dpg.add_checkbox(label=e, tag=tag, default_value=default,
                                     callback=getattr(backend, f"{stage}_checkbox_callback"))
                elif ann == int:
                    dpg.add_input_int(label=e, tag=tag, default_value=default,
                                      width=INPUT_W, callback=cb)
                elif ann in (float, float | list[float]):
                    dpg.add_input_float(label=e, tag=tag, default_value=default,
                                        width=INPUT_W, callback=cb)
                elif ann == typing.Tuple[float, float]:
                    with dpg.group(horizontal=True):
                        dpg.add_input_float(label=f"min {e}", tag=f"{tag} min",
                                            default_value=default[0], width=INPUT_W, callback=cb)
                        dpg.add_input_float(label=f"max {e}", tag=f"{tag} max",
                                            default_value=default[1], width=INPUT_W, callback=cb)
                elif ann in (float | None, int | None):
                    # "auto" checkbox disables the numeric field; None → auto
                    with dpg.group(horizontal=True):
                        dpg.add_checkbox(label="auto", tag=f"{tag} checkbox",
                                         default_value=True,
                                         callback=getattr(backend, f"{stage}_check_text_callback"))
                        widget = dpg.add_input_float if ann == float | None else dpg.add_input_int
                        widget(label=e, tag=tag, default_value=1,
                               width=INPUT_W, callback=cb, enabled=False)


# ---------------------------------------------------------------------------
# Analysis stage builder
# ---------------------------------------------------------------------------

# (stage_key, section_title, checkbox_tooltip, combo_tooltip, names_fn, object_fn)
_STAGE_CONFIG = [
    ('epocher', "COLLAPSING INTO EPOCHS",
     "Enable/disable the epoching stage",
     "Select the epoching algorithm",
     epocher_function_names, epocher_object),
    ('scorer', "SLEEP/WAKE SCORING",
     "Enable/disable the sleep/wake scoring stage",
     "Select the sleep/wake scoring algorithm",
     scorer_function_names, scorer_object),
    ('long', "CIRCADIAN RHYTHMS",
     "Enable/disable the circadian rhythm analysis",
     "Select the circadian rhythm estimation method",
     long_function_names, long_object),
]


def _add_analysis_stage(stage, label, tooltip, combo_hint, names_fn, object_fn,
                        backend: Backend):
    """Build one complete analysis stage: header, algorithm combo,
    output options, and advanced parameters."""
    add_stage_header(stage, label, tooltip, backend)
    names = names_fn()

    with dpg.group(tag=f"{stage}_header", show=False, enabled=False):
        dpg.add_spacer(height=4)
        with dpg.group(horizontal=True):
            with dpg.group():
                dpg.add_text("Algorithm")
                dpg.add_combo(names, label="", width=240,
                              tag=f"{stage}_combo",
                              callback=backend.combo_dropdown_callback,
                              default_value=getattr(backend, f"current_{stage}"))
                with dpg.tooltip(parent=f"{stage}_combo"):
                    dpg.add_text(combo_hint)
            # Epocher has an extra "Epoch length" input
            if stage == 'epocher':
                with dpg.group():
                    dpg.add_text("Epoch length (s)")
                    dpg.add_input_int(default_value=30, label="",
                                      tag="_epoch_length", width=130,
                                      callback=backend.epocher_textfield_callback)
                    with dpg.tooltip(parent="_epoch_length"):
                        dpg.add_text("Duration of each epoch in seconds "
                                     "(e.g. 30 or 60)")
        dpg.add_spacer(height=8)
        add_output_options(stage, backend)
        dpg.add_spacer(height=8)
        with dpg.group(horizontal=True):
            dpg.add_checkbox(label="Show advanced", tag=f"{stage}_advanced",
                             default_value=False,
                             callback=backend.advanced_checkbox_callback)
            dpg.add_button(label="Reset to defaults", tag=f"{stage}_reset",
                           callback=backend.advanced_reset_callback)
        with dpg.tooltip(parent=f"{stage}_advanced"):
            dpg.add_text("Show/hide advanced algorithm parameters")
        with dpg.tooltip(parent=f"{stage}_reset"):
            dpg.add_text("Reset all advanced parameters to their "
                         "default values")
        add_advanced_options(names, backend, stage, object_fn)

    dpg.add_spacer(height=8)
    dpg.add_separator()
    dpg.add_spacer(height=12)


# ===================================================================
#  MAIN WINDOW
# ===================================================================

def setup_main_window(backend: Backend):
    """Create the entire widget tree: menu bar, three pages, bottom panel, and modal dialogs."""
    with dpg.window(tag='main', width=MAIN_W, height=MAIN_H,
                    no_resize=True, no_scrollbar=True,
                    no_scroll_with_mouse=True, menubar=True):

        # ---------------------------------------------------------------
        # Menu bar
        # ---------------------------------------------------------------
        with dpg.menu_bar():
            with dpg.menu(label="File"):
                dpg.add_menu_item(label="Select input files",
                                  callback=lambda: _open_input_dialog(backend))
                dpg.add_menu_item(label="Output directory",
                                  callback=lambda: _open_output_dialog(backend))
                dpg.add_separator()
                dpg.add_menu_item(label="Export to .ada",
                                  callback=backend.generic_converter_callback)
                dpg.add_separator()
                dpg.add_menu_item(label="Quit",
                                  callback=lambda: dpg.stop_dearpygui())

            dpg.add_separator()

            with dpg.menu(label="Analysis"):
                dpg.add_menu_item(label="Analysis settings",
                                  callback=backend.switch_screens_callback)
                with dpg.menu(label="Presets"):
                    _add_preset_items(backend, prefix="menu_preset")
                dpg.add_separator()
                dpg.add_menu_item(label="Start Processing",
                                  callback=backend.start_processing_callback)

            dpg.add_separator()

            with dpg.menu(label="View"):
                dpg.add_menu_item(label="Home page", tag="view_files",
                                  check=True, default_value=True,
                                  callback=backend.view_callback)
                dpg.add_menu_item(label="Analysis settings", tag="view_analysis",
                                  check=True, default_value=False,
                                  callback=backend.view_callback)
                dpg.add_menu_item(label="Results", tag="view_results",
                                  check=True, default_value=False,
                                  callback=backend.view_callback)
                dpg.add_separator()
                dpg.add_menu_item(label="Dark", tag="view_dark",
                                  check=True, default_value=True,
                                  callback=themes_callback)
                dpg.add_menu_item(label="Light", tag="view_light",
                                  check=True, default_value=False,
                                  callback=themes_callback)
                dpg.add_separator()
                dpg.add_menu_item(label="System file dialogs",
                                  tag="view_system_dialogs",
                                  check=True,
                                  default_value=_SYSTEM_DIALOGS_DEFAULT)

            dpg.add_separator()

            with dpg.menu(label="Help"):
                dpg.add_menu_item(label="Full tutorial",
                                  callback=lambda: _open_docs("START_PAGE.html"))
                dpg.add_menu_item(label="Quickstart",
                                  callback=lambda: _open_docs("quickstart.html"))
                dpg.add_menu_item(label="Data types",
                                  callback=lambda: _open_docs("data_types.html"))
                dpg.add_menu_item(label="API reference",
                                  callback=lambda: _open_docs("autoapi/index.html"))
                dpg.add_separator()
                dpg.add_menu_item(label="Project page",
                                  callback=lambda: webbrowser.open(
                                      "https://gitlab.com/fuw_software/aktygrafy"))

            dpg.add_separator()

            # Flexible spacer pushes nav buttons to the right edge
            dpg.add_spacer(tag="nav_spacer", width=1)

            # Navigation bookmarks — smaller, subdued buttons on the right
            dpg.add_button(label="Home", tag="nav_btn_home",
                           callback=lambda: _nav_go_home(backend))
            dpg.bind_item_font("nav_btn_home", nav_font)
            dpg.add_button(label="Analysis", tag="nav_btn_analysis",
                           callback=lambda: _nav_go_analysis(backend))
            dpg.bind_item_font("nav_btn_analysis", nav_font)
            dpg.add_button(label="Results", tag="nav_btn_results",
                           callback=lambda: _nav_go_results(backend))
            dpg.bind_item_font("nav_btn_results", nav_font)

        _set_nav_active("nav_btn_home")

        # ---------------------------------------------------------------
        # Home page (files_view)
        # ---------------------------------------------------------------
        with dpg.child_window(tag="files_view", width=SET_W, height=SET_H,
                              pos=SET_POS, show=True):
            backend.window_tags.append("files_view")
            dpg.add_spacer(height=32)

            # Input file(s)
            dpg.add_button(label="Input file(s)", tag='files_button',
                           callback=lambda: _open_input_dialog(backend))
            dpg.bind_item_font("files_button", header_font)
            dpg.bind_item_theme("files_button", HOME_BTN_THEME)
            dpg.add_spacer(height=2)
            dpg.add_text("No files selected.", tag="selected_files_text",
                         color=COLOR_WARNING)
            dpg.add_spacer(height=4)
            dpg.add_text("File details", tag="files_view_info_header", show=False)
            dpg.add_text("", tag="file_description", wrap=0)
            dpg.add_spacer(height=8)
            dpg.add_separator()

            # Analysis
            dpg.add_spacer(height=8)
            dpg.add_button(label="Analysis", tag='analysis_button',
                           callback=backend.switch_screens_callback)
            dpg.bind_item_font("analysis_button", header_font)
            dpg.bind_item_theme("analysis_button", HOME_BTN_THEME)
            dpg.add_spacer(height=2)
            dpg.add_text("No preset or pipeline selected.",
                         tag="runtime_selection", color=COLOR_WARNING, wrap=0)
            dpg.add_spacer(height=12)
            dpg.add_separator()

            # Output directory
            dpg.add_spacer(height=8)
            dpg.add_button(label="Output directory", tag='output_button',
                           callback=lambda: _open_output_dialog(backend))
            dpg.bind_item_font("output_button", header_font)
            dpg.bind_item_theme("output_button", HOME_BTN_THEME)
            dpg.add_spacer(height=2)
            dpg.add_text("Not selected.", tag="output_directory",
                         color=COLOR_WARNING, wrap=0)
            dpg.add_spacer(height=12)

            # How-to-use instructions (hidden once a preset is selected)
            with dpg.group(tag="how_to_use_group"):
                dpg.add_spacer(height=4)
                dpg.add_separator()
                dpg.add_spacer(height=12)
                dpg.add_text(
                    "This is the main sheet of planned computations, "
                    "showing chosen settings.", wrap=0, tag="how_to_use_intro")
                dpg.add_spacer(height=6)
                with dpg.group(horizontal=True):
                    dpg.add_text("   * Fill in desired options by clicking")
                    dpg.add_text("[Input file(s)],", color=BTN_CLR)
                    dpg.add_text("[Analysis]", color=BTN_CLR)
                    dpg.add_text("and")
                    dpg.add_text("[Output directory].", color=BTN_CLR)
                with dpg.group(horizontal=True):
                    dpg.add_text("   * When the information is sufficient "
                                 "and all files are loaded,")
                    dpg.add_text("[START PROCESSING]", color=BTN_CLR)
                dpg.add_text("     at the bottom becomes active.")
                with dpg.group(horizontal=True):
                    dpg.add_text("   * Detailed parameters of selected "
                                 "algorithms are on the")
                    dpg.add_text("[Analysis]", color=BTN_CLR)
                    dpg.add_text("page.")
                dpg.add_text("   * Alternatively, use the pull-down menus "
                             "at the top.")

        # ---------------------------------------------------------------
        # Analysis settings (analysis_view)
        # ---------------------------------------------------------------
        with dpg.child_window(tag="analysis_view", width=SET_W, height=SET_H,
                              pos=SET_POS, show=False):
            backend.window_tags.append("analysis_view")
            dpg.add_spacer(height=22)

            for stage, label, tip, hint, names_fn, obj_fn in _STAGE_CONFIG:
                _add_analysis_stage(stage, label, tip, hint,
                                    names_fn, obj_fn, backend)

        # ---------------------------------------------------------------
        # Results browser (results_view) — two-panel layout
        # ---------------------------------------------------------------
        with dpg.child_window(tag="results_view", width=SET_W, height=SET_H,
                              pos=SET_POS, show=False):
            backend.window_tags.append("results_view")
            dpg.add_spacer(height=32)

            dpg.add_text("Results")
            dpg.bind_item_font(dpg.last_item(), screen_title_font)

            # Hint — shown only when an output directory is set
            dpg.add_text(
                "Select a file on the left to preview it on the right. "
                "Click the path to open the output folder.",
                tag="results_hint_text", show=False, color=[170, 180, 195, 200])
            dpg.add_spacer(height=4)

            # Shown when no output directory has been selected yet
            dpg.add_button(label="Choose output directory", tag="results_no_dir_btn",
                           callback=lambda: _open_output_dialog(backend))
            dpg.bind_item_theme("results_no_dir_btn", HOME_BTN_THEME)

            # Clickable output path — opens the folder in the system file manager
            def _path_clicked():
                dpg.set_value("results_path_text", False)
                backend.open_output_folder()
            dpg.add_selectable(label="", tag="results_path_text", show=False,
                               callback=lambda: _path_clicked())

            dpg.add_spacer(height=4)
            dpg.add_checkbox(label="Results only (.png, .csv)",
                             tag="results_filter", default_value=True,
                             callback=lambda: backend.refresh_results())
            dpg.add_checkbox(label="Last analysis only",
                             tag="results_last_analysis", default_value=True,
                             callback=lambda: backend.refresh_results())

            dpg.add_spacer(height=4)
            dpg.add_separator()
            dpg.add_spacer(height=4)

            # Two-panel layout: file list (left) | splitter | preview (right)
            with dpg.group(horizontal=True, tag="results_panels"):
                with dpg.child_window(tag="results_left_panel",
                                      width=_RESULTS_LIST_W, height=-1):
                    with dpg.group(tag="results_file_list"):
                        pass
                with dpg.child_window(tag="results_splitter",
                                      width=_SPLITTER_W, height=-1,
                                      no_scrollbar=True,
                                      no_scroll_with_mouse=True):
                    pass
                dpg.bind_item_theme("results_splitter", SPLITTER_THEME)
                with dpg.child_window(tag="results_right_panel",
                                      width=-1, height=-1):
                    with dpg.group(tag="results_preview"):
                        pass

        # ---------------------------------------------------------------
        # Bottom panel (progress bar / action buttons)
        # ---------------------------------------------------------------
        with dpg.child_window(tag="bottom_panel", width=MAIN_W,
                              height=MAIN_H - SET_H - 12, pos=(0, SET_H),
                              no_scrollbar=True, no_scroll_with_mouse=True):
            backend.window_tags.append("bottom_panel")

            # Home page: START PROCESSING + progress bar
            with dpg.group(horizontal=False, tag="bottom_files"):
                with dpg.group(horizontal=True):
                    dpg.add_button(label="START PROCESSING", tag="runtime",
                                   callback=backend.start_processing_callback,
                                   width=180, height=30)
                    dpg.add_progress_bar(tag="pbar", default_value=0,
                                         width=-1, height=30, overlay="0%")
                    dpg.bind_item_theme("runtime", DISABLED_BTN_DARK)
                dpg.add_text(tag="pbar_text", default_value='')

            # Analysis page: ACCEPT / PRESETS / CANCEL
            with dpg.group(horizontal=True, show=False, tag="bottom_analysis"):
                dpg.add_button(label="ACCEPT", tag='done_button', height=30,
                               callback=backend.switch_screens_callback)
                dpg.bind_item_theme("done_button", STAGE_BTN_THEME)
                dpg.add_button(label="PRESETS", tag='presets_button', height=30,
                               callback=lambda: _show_presets_centered())
                dpg.bind_item_theme("presets_button", STAGE_BTN_THEME)
                dpg.add_button(label="CANCEL", tag='cancel_button', height=30,
                               callback=backend.cancel_callback)
                dpg.bind_item_theme("cancel_button", STAGE_BTN_THEME)

    # Presets modal (centered, dismisses on selection or Cancel)
    with dpg.window(tag='presets_popup', show=False, modal=True,
                    no_title_bar=True, autosize=True, no_resize=True):
        _add_preset_items(backend, prefix="but", as_menu_items=False,
                          hide_tag="presets_popup")
        dpg.add_separator()
        dpg.add_button(label="Cancel", width=180,
                       callback=lambda: dpg.hide_item("presets_popup"))

    # DPG built-in file dialogs — fallback when View > "System file dialogs"
    # is unchecked (default on Linux where tkinter subprocess can be flaky).
    exts = sorted_extension_list()
    all_patterns = " ".join(f".{e}" for e in exts)
    with dpg.file_dialog(directory_selector=False, show=False,
                         tag="dpg_input_dialog",
                         callback=backend.input_selector_callback,
                         width=500, height=400):
        dpg.add_file_extension(all_patterns, label="Actigraphic files")
        for e in exts:
            dpg.add_file_extension(f".{e}", label=f".{e} files")
        dpg.add_file_extension(".*", label="All files")

    def _dpg_output_callback(_sender, app_data):
        """Adapter: extract the path from DPG's app_data dict."""
        path = app_data['file_path_name']
        if path:
            _set_output_directory(backend, path)

    dpg.add_file_dialog(directory_selector=True, show=False,
                        tag="dpg_output_dialog",
                        callback=_dpg_output_callback,
                        width=500, height=400)


# ===================================================================
#  LAYOUT HELPERS  (run every frame or on resize)
# ===================================================================

# Tags of items that should be horizontally centered within their panel
_CENTER_TAGS = ["files_button", "analysis_button", "output_button", "bottom_analysis"]

# Right-alignment of nav buttons in the menu bar:
# We measure the total width of all fixed menu-bar content (menus + buttons)
# once the layout has settled, then on every frame set the spacer width so
# that  menus + spacer + buttons = viewport_width.
_FIXED_W = None            # total menu-bar content width (excluding the spacer)
_NAV_MEASURE_FRAME = 0     # counts frames before the first measurement


def _update_nav_spacer(viewport_w):
    """Adjust the menu-bar spacer so the nav buttons stay right-aligned.

    DPG doesn't support right-alignment natively, so we insert a flexible
    spacer between the menus and the nav buttons.  On the first few frames
    the spacer is collapsed to 1 px so we can measure the total width of
    all *fixed* content; after that, on every frame we set
    ``spacer_w = viewport_w - fixed_w``.
    """
    global _FIXED_W, _NAV_MEASURE_FRAME
    try:
        # --- Calibration phase (first ~5 frames) ---
        if _FIXED_W is None:
            _NAV_MEASURE_FRAME += 1
            if _NAV_MEASURE_FRAME == 1:
                dpg.configure_item("nav_spacer", width=1)
                return
            if _NAV_MEASURE_FRAME < 5:
                return   # let the layout settle with spacer = 1
            results_end = dpg.get_item_rect_max("nav_btn_results")[0]
            _FIXED_W = results_end - 1       # subtract the 1 px spacer
            if _FIXED_W <= 0:
                _FIXED_W = None
                return   # invalid measurement — retry next frame

        # --- Steady state: resize each frame ---
        spacer_w = viewport_w - _FIXED_W - 4
        dpg.configure_item("nav_spacer", width=max(1, int(spacer_w)))
    except Exception:
        pass


def _center_buttons(panel_w):
    """Center selected UI elements horizontally within the given panel width."""
    for tag in _CENTER_TAGS:
        try:
            item_w = dpg.get_item_rect_size(tag)[0]
            dpg.configure_item(tag, indent=max(0, (panel_w - item_w) // 2))
        except Exception:
            pass


def _update_splitter(dragging, backend):
    """Handle splitter drag between results panels.  Returns the new dragging state."""
    try:
        hovered = dpg.is_item_hovered("results_splitter")
    except Exception:
        return False

    # Visual feedback: highlight on hover or while dragging
    if hovered or dragging:
        dpg.bind_item_theme("results_splitter", SPLITTER_HOVER_THEME)
    else:
        dpg.bind_item_theme("results_splitter", SPLITTER_THEME)

    # Start drag when mouse pressed on the splitter
    if hovered and dpg.is_mouse_button_down(0):
        dragging = True

    if dragging:
        if dpg.is_mouse_button_down(0):
            mx = dpg.get_mouse_pos(local=False)[0]
            try:
                panel_x = dpg.get_item_rect_min("results_left_panel")[0]
            except Exception:
                panel_x = 0
            vp_w = dpg.get_viewport_client_width()
            new_w = int(mx - panel_x)
            new_w = max(_SPLITTER_MIN_L,
                        min(new_w, vp_w - _SPLITTER_MIN_R - _SPLITTER_W))
            dpg.configure_item("results_left_panel", width=new_w)
            backend.resize_result_images()
        else:
            dragging = False

    return dragging


def _on_resize(backend: Backend):
    """Handle viewport resize — update all panel sizes and positions."""
    w = dpg.get_viewport_client_width()
    h = dpg.get_viewport_client_height()

    _update_nav_spacer(w)
    dpg.configure_item('main', width=w, height=h)

    new_set_h = max(SET_H, h - BOTTOM_H_MIN - 12)
    new_set_w = max(500, w)

    for tag in backend.window_tags:
        # Results view uses the full height (no bottom panel)
        if tag == 'results_view' and dpg.is_item_shown('results_view'):
            dpg.configure_item(tag, width=new_set_w, height=h - 12)
        else:
            dpg.configure_item(tag, width=new_set_w, height=new_set_h)

    dpg.configure_item('bottom_panel',
                       height=max(BOTTOM_H_MIN, h - new_set_h - 12),
                       pos=(0, new_set_h))
    _center_buttons(new_set_w)

    # Rescale preview images on resize (left panel width stays as-is)
    if dpg.is_item_shown('results_view'):
        backend.resize_result_images()


# ===================================================================
#  ENTRY POINT
# ===================================================================

def main():
    """Initialise the backend, build the GUI, and run the DearPyGui render loop.

    The render loop performs three duties every frame:

    1. **Layout** — right-align the nav-bar buttons, redraw the active
       tab underline, and (during the first few frames) center the
       home-page buttons.
    2. **START PROCESSING gate** — enable or disable the button based on
       whether files, an output directory, and at least one pipeline
       stage have been selected.
    3. **Progress queue** — drain status tuples ``(stage, fraction)`` or
       ``(stage, fraction, text)`` posted by the child process.  The
       sentinel ``(0, 0)`` means "processing finished".
    """
    backend = Backend()
    backend.set_nav_active = _set_nav_active

    setup_main_window(backend)

    dpg.bind_font(default_font)
    dpg.set_viewport_resize_callback(lambda: _on_resize(backend))
    dpg.set_primary_window('main', True)
    dpg.setup_dearpygui()
    dpg.set_viewport_min_width(500)
    dpg.set_viewport_min_height(MAIN_H)
    dpg.show_viewport()
    dpg.bind_theme(DARK_THEME)

    global _splitter_dragging
    while dpg.is_dearpygui_running():

        # --- 1. Layout --------------------------------------------------
        _update_nav_spacer(dpg.get_viewport_client_width())
        _update_nav_underline()
        _center_buttons(dpg.get_viewport_client_width())

        # --- 1b. Results panel ------------------------------------------
        if dpg.is_item_shown('results_view'):
            _splitter_dragging = _update_splitter(_splitter_dragging, backend)
            backend.resize_result_images()

        # --- 2. START PROCESSING gate -----------------------------------
        if backend.runtime is not None:
            dpg.enable_item("runtime")
        elif (backend.out_path is None
              or backend.input_files is None
              or not (backend.use_epocher or backend.use_scorer or backend.use_long)):
            dpg.disable_item("runtime")
        else:
            dpg.enable_item("runtime")

        # --- 3. Progress queue ------------------------------------------
        # Drain all pending messages, keeping only the latest (or the
        # sentinel (0, 0) which signals that the child process is done).
        if not backend.status_queue.empty():
            status = backend.status_queue.get()
            while not backend.status_queue.empty():
                next_status = backend.status_queue.get()
                if next_status == (0, 0):
                    status = next_status
                    break
                status = next_status

            if status == (0, 0):
                backend.runtime = None

            was_success = backend.check_runtime_status()

            if status[0] == 0 and was_success and backend.out_path:
                backend.show_results_view()

            # Reset or update the progress bar
            if status[0] == 0:
                dpg.set_value("pbar", 0)
                dpg.configure_item("pbar", overlay="0%")
                dpg.set_value("pbar_text", '')
            else:
                text = status[2] if len(status) > 2 else PROGRESS_TO_STRING[status[0]]
                dpg.set_value("pbar_text", text)
                value = status[1]
                dpg.set_value("pbar", value)
                dpg.configure_item("pbar", overlay=f"{100 * value:.2f}%")

        dpg.render_dearpygui_frame()

    dpg.destroy_context()
