#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Shared utilities for the listening experiments.

Provides display, audio, serial port, logging, and dialog helpers
used by both Day 1 (Le Petit Prince) and Day 2 (optimized stories).
"""

import os
import time
import serial
from datetime import datetime
from psychopy import visual, core, event, gui, logging, monitors, sound


# ── Hardware defaults ────────────────────────────────────────────────
# Override per-site by passing a custom config dict to the functions.
HARDWARE_DEFAULTS = {
    'monitor_name': 'DellExperiment',
    'monitor_distance': 20,
    'resolution': [1920, 1080],
    'frame_rate': 60.001,
    'serial_port': 'COM5',
    'photodiode_pos': [910, 490],
    'photodiode_size': 40,
}


# ── Setup helpers ────────────────────────────────────────────────────

def show_dialog(exp_name, exp_info, field_order):
    """Show PsychoPy dialog box. Returns exp_info dict or raises SystemExit."""
    dlg = gui.DlgFromDict(dictionary=exp_info, title=exp_name, order=field_order)
    if dlg.OK:
        print('dialogbox OK')
        return exp_info
    else:
        print('User cancelled at dialogbox')
        raise SystemExit


def open_serial_port(port_name):
    """Open serial port for triggers.

    Returns (serial_obj, True) on success, (None, False) on failure.
    Logs a visible WARNING so missing triggers are not silently ignored.
    """
    try:
        ser = serial.Serial()
        ser.port = port_name
        # To find the port name on Windows: Device Manager > Ports (COM & LPT)
        ser.open()
        print('Serial port OK')
        logging.data(f'Serial port opened on {port_name}')
        return ser, True
    except Exception as e:
        msg = f'WARNING: No serial port on {port_name} ({e}) — triggers will NOT be sent!'
        print(msg)
        logging.warning(msg)
        return None, False


def setup_window(config=None):
    """Create and return the PsychoPy window."""
    cfg = {**HARDWARE_DEFAULTS, **(config or {})}
    mon = monitors.Monitor(cfg['monitor_name'], distance=cfg['monitor_distance'])
    win = visual.Window(cfg['resolution'], fullscr=None, monitor=mon,
                        color='black', units='pix')
    event.Mouse(visible=False)
    return win


def setup_logging(subject_id, log_dir='../logs'):
    """Set up timestamped log file. Creates log_dir if needed."""
    os.makedirs(log_dir, exist_ok=True)
    now = datetime.now()
    dt_string = now.strftime("%d-%m-%Y-%Hh%Mm%Ss")
    time_string = time.strftime('%H:%M:%S, %d-%m-%Y', time.localtime())
    log_filename = f'{dt_string}_{subject_id}'
    log_file = logging.LogFile(os.path.join(log_dir, log_filename + '.log'),
                               level=logging.DATA)
    return time_string, log_file


# ── Display functions ────────────────────────────────────────────────

def stop_exp_or_pass_win(win, iframe=0):
    """Handle ESC (abort) or 'c' (skip screen) during timed displays."""
    if event.getKeys(keyList=['escape']):
        logging.data('Stop experiment manually')
        win.close()
        core.quit()
    elif event.getKeys(keyList=['c']):
        iframe = 10**10
        logging.data('Switched to the next screen manually')
    return iframe


def stop_exp_or_pass_sound(win, sound_stim, iframe=0):
    """Handle ESC (abort) or 'c' (skip) during audio playback."""
    if event.getKeys(keyList=['escape']):
        logging.data('Stop experiment manually')
        win.close()
        core.quit()
    elif event.getKeys(keyList=['c']):
        iframe = 10**10
        sound_stim.stop()
        logging.data('Switched to the next screen manually')
    return iframe


def draw_photodiode(win, config=None):
    """Draw the photodiode synchronization square."""
    cfg = {**HARDWARE_DEFAULTS, **(config or {})}
    square = visual.Rect(win, fillColor='white',
                         size=[cfg['photodiode_size']],
                         pos=cfg['photodiode_pos'])
    square.draw()


def cross_show(win, display_time=0, photodiode=1, config=None):
    '''
    Show a fixation cross.

    Args:
        win:          PsychoPy window
        display_time: duration in frames (0 = wait for Space)
        photodiode:   whether to draw the photodiode square
        config:       hardware config overrides
    '''
    if not isinstance(display_time, int):
        raise TypeError('display_time must be an integer (number of frames)')

    cross = visual.TextStim(win, text='+', pos=(0.0, 0.0),
                            units="norm", height=0.1, wrapWidth=0.9)
    if display_time > 0:
        iframe = 0
        while iframe < display_time:
            if iframe == 0:
                win.logOnFlip(level=logging.DATA, msg='first frame for cross')
            cross.draw()
            if photodiode:
                draw_photodiode(win, config)
            win.flip()
            iframe += 1
            iframe = stop_exp_or_pass_win(win, iframe)
    else:
        cross.draw()
        win.flip()
        logging.flush()
        event.waitKeys(keyList=['space'])


def instruction_show(win, text_cond, display_time=0, description_text=None):
    '''
    Show instruction screens.

    Args:
        win:              PsychoPy window
        text_cond:        one of 'description', 'start', 'continue',
                          'rest', 'baseline', 'end'
        display_time:     duration in frames (0 = wait for Space)
        description_text: custom text for the 'description' condition
                          (required when text_cond == 'description')
    '''
    if not isinstance(display_time, int):
        raise TypeError('display_time must be an integer (number of frames)')

    texts = {
        'start': "Pressez 'Espace' pour commencer",
        'continue': "Pressez 'Espace' pour continuer",
        'rest': "Période de repos.\nLorsque vous etes prêt(e), "
                "pressez \"Espace\" pour continuer.",
        'baseline': 'Fixez la croix et ne bougez plus.',
        'end': "Fin de l'expérience, merci pour votre participation !",
    }

    if text_cond == 'description':
        if description_text is None:
            raise ValueError("description_text is required for 'description' condition")
        sent = description_text
    else:
        sent = texts[text_cond]

    print(text_cond)
    txt = visual.TextStim(win, text=sent, pos=(0.0, 0.0),
                          units="norm", height=0.1, wrapWidth=2)
    if text_cond in ('baseline', 'end'):
        iframe = 0
        while iframe < display_time:
            if iframe == 0:
                win.logOnFlip(level=logging.DATA,
                              msg='first frame for instruction text: ' + sent)
            txt.draw()
            win.flip()
            iframe += 1
            iframe = stop_exp_or_pass_win(win, iframe)
    else:
        txt.draw()
        win.logOnFlip(level=logging.DATA,
                      msg='first frame for instruction text: ' + sent)
        win.flip()
        logging.flush()
        event.waitKeys(keyList=['space'])


# ── Audio functions ──────────────────────────────────────────────────

def sound_play(win, sound_stim, filename, frame_rate, config=None):
    """Play a pre-loaded sound while showing a fixation cross with photodiode.

    Duration is read from the sound object itself (no hardcoded durations).
    """
    duration_wav = sound_stim.getDuration()
    cross = visual.TextStim(win, text='+', pos=(0.0, 0.0),
                            units="norm", height=0.1, wrapWidth=0.9)
    cross.draw()
    nextFlip = win.getFutureFlipTime(clock='ptb')
    sound_stim.play(when=nextFlip)
    win.flip()
    iframe = 0
    total_frames = int(duration_wav * frame_rate)
    while iframe < total_frames:
        if iframe == 0:
            win.logOnFlip(level=logging.DATA,
                          msg='first frame for sound: ' + filename)
        cross.draw()
        draw_photodiode(win, config)
        win.flip()
        iframe += 1
        iframe = stop_exp_or_pass_sound(win, sound_stim, iframe)


def preload_sounds(filenames, audio_dir, filename_pattern='{name}.wav'):
    """Pre-load WAV files into a dict keyed by filename.

    Args:
        filenames:        list of logical names (e.g. ['ch1', 'ch2', ...])
        audio_dir:        directory containing the WAV files
        filename_pattern: format string with {name} placeholder
                          (e.g. '6684_story_{name}.wav')
    Returns:
        dict mapping each name to a psychopy.sound.Sound object
    """
    sound_stims = {}
    for fname in filenames:
        fpath = os.path.join(audio_dir, filename_pattern.format(name=fname))
        if not os.path.exists(fpath):
            print(f'WARNING: {fpath} not found, skipping')
            continue
        try:
            sound_stims[fname] = sound.Sound(fpath)
        except Exception as e:
            print(f'WARNING: could not load {fname}: {e}')
    return sound_stims
