"""Core Winston framework for Windows application and dialog management.

This module provides the fundamental infrastructure for Winston including:
- Windows COM integration and thread management
- Speech synthesis via JAWS API and SAPI
- Console I/O redirection for dialog-based interaction
- Application lifecycle management (Anchor class)
- Applet carousel for managing multiple concurrent dialogs
- Keyboard modifier tracking and critical hotkeys
- Configuration management via INI files
- Output logging and backup utilities

The framework is designed for accessibility, integrating with screen readers
and providing keyboard-driven interfaces without mouse interaction.

Key Components:
    Anchor: Root application object managing lifecycle and resources
    Carousel: List-based container managing active applet threads
    COMThread: Thread subclass ensuring proper COM initialization
    TTS: Text-to-speech engine wrapper for JAWS API
    SAPIEngine: Alternative SAPI-based speech engine
    ModifiedKey: Keyboard event with modifier key tracking
    WinStdin/StdWriter: Console I/O redirection for dialog integration

Example:
    Create and run a Winston application::

        anchor = Anchor(restartable=True)
        anchor.launch_applet('explorer')
        # Application runs with applet carousel

Typical usage in clem.py::

    from winston.framework import Anchor
    app = Anchor(restartable=True)
    # Event loop handles keyboard input and applet switching
"""

from __future__ import annotations
from typing import Any, Callable, TypedDict, cast, TYPE_CHECKING
from collections.abc import Sequence
from configparser import ConfigParser as cp
import sys
import ctypes
import pygetwindow as gw  # type: ignore[import]
import json
import inspect
import threading
import pythoncom  # type: ignore[import]
import time
import importlib
import string
import types
from datetime import datetime as dt
import shutil
import winsound
import os
import win32com  # type: ignore[import]
import win32com.client  # type: ignore[import]
import queue
import builtins
import math
import io
from collections import deque
from . import ordinal_date
from . import punctuator
import win32gui  # type: ignore[import]
import win32process  # type: ignore[import]

# Type checking imports to avoid circular dependencies
if TYPE_CHECKING:
    from .subdialogs import Applet, AppletDialog

try:
    import numpy as np  # type: ignore[import]
    import simpleaudio as sa  # type: ignore[import]
    _HAVE_SA = True
except Exception:
    _HAVE_SA = False

# Constant used in COMThread to determine if thread is STA or MTA
_RPC_E_CHANGED_MODE = -2147417850  # 0x80010106


def beep(
    freq: int = 500,
    duration: int = 250,
    tag: str | None = None,
    *,
    volume: float = 0.5
) -> None:
    """Play a tone asynchronously and optionally print a tag.

    Attempts to play audio using simpleaudio first, falling back to winsound.Beep,
    then MessageBeep, and finally the console bell character. Playback occurs on a
    background thread to avoid blocking.

    Args:
        freq: Frequency in Hz. Defaults to 500.
        duration: Duration in milliseconds. Defaults to 250.
        tag: Optional string to print after initiating tone. Defaults to None.
        volume: Volume for simpleaudio backend (0.0 to 1.0). Defaults to 0.5.

    Note:
        The tag is printed before the tone completes due to async playback.
        Consider using lower freq/duration values to differentiate Winston
        beeps from system sounds during debugging.

    Example:
        >>> beep(750, 300, "File saved")
        File saved
    """
    # consider putting below 2 lines under a debug env variable to help differentiate
    # winston beeps from other application/windows generated beeps by making winston
    # beeps unusually low and short
    # freq=max(350,freq-300)
    # duration=max(300,duration-300)

    def _play() -> None:
        def _sa_beep() -> bool:
            if not _HAVE_SA:
                return False
            try:
                sr = 44100
                t = np.linspace(0, duration / 1000.0, int(sr * duration / 1000.0), False)
                tone = np.sin(2 * math.pi * freq * t)
                audio = (tone * (2**15 - 1) * volume).astype(np.int16)
                play_obj = sa.play_buffer(audio, 1, 2, sr)
                # Non-blocking: don't wait_done()
                return True
            except Exception:
                return False

        def _ws_beep() -> bool:
            try:
                # winsound.Beep is blocking, so run it in this thread
                winsound.Beep(freq, duration)
                return True
            except Exception:
                return False

        def _msg_beep() -> bool:
            try:
                winsound.MessageBeep(-1)
                return True
            except Exception:
                return False

        played = _sa_beep() or _ws_beep() or _msg_beep()
        if not played:
            sys.stdout.write("\a")
            sys.stdout.flush()

    # Start playback in a background thread
    threading.Thread(target=_play, daemon=True).start()
    if tag:
        print(tag)


def caller_details(frame: types.FrameType) -> str:
    """Extract caller information from a stack frame.

    Args:
        frame: Python stack frame object from inspect.currentframe().

    Returns:
        String formatted as "qualified_name at line lineno".

    Example:
        >>> frame = inspect.currentframe()
        >>> caller_details(frame)
        'caller_details at line 42'
    """
    qualified_name = frame.f_code.co_qualname
    lineno = frame.f_lineno
    return f'{qualified_name} at line {lineno}'


def ensure_exists(cached_file_path: str) -> bool:
    """Ensure a JSON cache file exists, creating empty list if missing.

    Args:
        cached_file_path: Path to JSON file.

    Returns:
        Always True after ensuring file exists.

    Note:
        Creates an empty JSON array [] if file doesn't exist.
        Uses UTF-8 encoding with ignore errors.
    """
    if not os.path.exists(cached_file_path):
        with open(cached_file_path, 'w', encoding='utf-8', errors='ignore') as f:
            json.dump([], f)
    return True


def play_wav(sound_data: bytes) -> None:
    """Play a WAV sound from memory asynchronously on Windows.

    Uses Windows PlaySound API with SND_MEMORY and SND_ASYNC flags to play
    audio without blocking.

    Args:
        sound_data: WAV file data as bytes.

    Note:
        This function returns immediately. Sound plays asynchronously.
        Only works on Windows platform.
    """
    # Allocate a global memory block to hold the sound data
    buffer = ctypes.create_string_buffer(sound_data)
    # Get a pointer to the memory block
    ptr = ctypes.cast(buffer, ctypes.c_void_p).value
    # Use the PlaySound function from the Windows API
    ctypes.windll.winmm.PlaySoundW(
        ctypes.c_wchar_p(ptr),  # Pointer to the sound data
        None,  # Handle to module containing the sound resource
        winsound.SND_MEMORY | winsound.SND_ASYNC  # Flags
    )


def ensure_dir_exists(path: str) -> None:
    """Create directory if it doesn't exist.

    Args:
        path: Directory path to create.

    Note:
        Creates parent directories as needed (equivalent to mkdir -p).
    """
    if not os.path.exists(path):
        os.makedirs(path)


def test_match(
    target: str,
    substring: str,
    match_case: bool,
    match_type: str
) -> bool:
    """Test if substring matches target string using specified criteria.

    Args:
        target: String to search within.
        substring: String to search for.
        match_case: If False, perform case-insensitive match.
        match_type: Type of match - "startswith", "contains", or "equals".

    Returns:
        True if substring matches target according to match_type criteria.

    Raises:
        ValueError: If match_type is not one of the supported values.

    Example:
        >>> test_match("Hello World", "hello", False, "startswith")
        True
        >>> test_match("Hello World", "WORLD", False, "contains")
        True
    """
    if not match_case:
        target = target.lower()
        substring = substring.lower()
    if match_type == 'startswith':
        return target.startswith(substring)
    elif match_type == 'contains':
        return substring in target
    elif match_type == 'equals':
        return target == substring
    else:
        raise ValueError(
            f"{match_type} is not a valid match type; "
            f"supported types are 'startswith', 'contains' and 'equals'"
        )


is_false: Callable[[bool], bool] = lambda x=False: x == False # type: ignore[misc]

# Modifier key names for tracking keyboard state
modifier_keys = ['Lcontrol', 'Lmenu', 'Lshift', 'Lwin', 'Rcontrol', 'Rmenu', 'Rshift', 'Capital']


def caps_state() -> tuple[bool, bool, int, int]:
    """Get Caps Lock key state from Windows.

    Returns:
        Tuple of (toggled, pressed_async, raw_state, raw_async_state) where:
        - toggled: True if Caps Lock is toggled on
        - pressed_async: True if Caps Lock is currently pressed
        - raw_state: Raw GetKeyState result
        - raw_async_state: Raw GetAsyncKeyState result

    Note:
        Uses Windows API GetKeyState and GetAsyncKeyState functions.
    """
    VK_CAPITAL = 0x14
    state = ctypes.windll.user32.GetKeyState(VK_CAPITAL)
    async_state = ctypes.windll.user32.GetAsyncKeyState(VK_CAPITAL)
    return (bool(state & 1), bool(async_state & 1), state, async_state)


def is_swapped(char: str, shift: bool) -> bool:
    """Check if character case is swapped due to Caps Lock.

    Args:
        char: Character to check.
        shift: True if Shift key is pressed.

    Returns:
        True if char case is opposite of what shift would produce,
        indicating Caps Lock is active.

    Note:
        Only applies to alphabetic characters. Returns False for
        non-alphabetic characters.

    Example:
        >>> is_swapped('A', False)  # Capital without shift -> Caps on
        True
        >>> is_swapped('a', True)  # Lowercase with shift -> Caps on
        True
    """
    if char.isalpha():
        return char.isupper() != shift  # char state is different from state of shift key which implies caps lock
    else:
        return False


def resync_caps_lock(char: str, shift: bool) -> str:
    """Resynchronize Caps Lock state if character indicates mismatch.

    Checks if character case matches expected state based on shift key.
    If mismatch detected, simulates Caps Lock key press to toggle state.

    Args:
        char: Character that was typed.
        shift: True if Shift key was pressed.

    Returns:
        The character with case swapped if Caps Lock was toggled,
        otherwise original character.

    Note:
        Uses Windows API keybd_event to simulate Caps Lock press.
        This modifies system-wide Caps Lock state.
    """
    VK_CAPITAL = 0x14
    if is_swapped(char, shift) != bool(ctypes.windll.user32.GetKeyState(VK_CAPITAL) & 1):
        ctypes.windll.user32.keybd_event(VK_CAPITAL, 0, 0, 0)
        ctypes.windll.user32.keybd_event(VK_CAPITAL, 0, 2, 0)
        return char.swapcase()
    else:
        return char


def make_file_template(base_name: str) -> Any:
    """Create an ElementTemplate for a file (used in explorer).

    Args:
        base_name: Base filename without path.

    Returns:
        ElementTemplate instance configured for file display.

    Note:
        Late imports explorer and dialogs to avoid circular imports.
        Used by explorer applet for file list construction.
    """
    from explorer import FileElement  # type: ignore[import]
    from .key_handler import ElementTemplate
    full_name = os.path.join(os.getcwd(), base_name)
    values = [base_name, 'no last modified date available', 'no size available']
    return ElementTemplate(FileElement, full_name, base_name, values=values)


def load(mod_name: str, silent: bool = False) -> types.ModuleType:
    """Load or reload a Python module by name with cache invalidation.

    If module is not already loaded, imports it. If already loaded,
    invalidates Python's import caches and reloads to pick up changes from disk.

    Args:
        mod_name: Module name (e.g., 'winston.applets.editor').
        silent: If True, suppress output (currently unused). Defaults to False.

    Returns:
        The loaded/reloaded module object.

    Note:
        Cache invalidation ensures Python doesn't serve stale .pyc files from
        __pycache__/. This is critical for Control+F5 reload functionality.

    Example:
        >>> mod = load('winston.applets.explorer')
        >>> # Edit explorer.py on disk
        >>> mod = load('winston.applets.explorer')  # Reload with fresh cache
    """
    mod = sys.modules.get(mod_name, None)
    if not mod:
        mod = importlib.import_module(mod_name)
    else:
        # CRITICAL: Invalidate caches before reload to ensure fresh bytecode
        # Without this, Python may serve stale .pyc files from __pycache__/
        importlib.invalidate_caches()
        importlib.reload(mod)
    return mod


def backup(filename: str) -> None:
    """Create timestamped backup of file or directory.

    Copies file/directory to backup/ subdirectory with timestamp in name.
    Only creates backup if app.backup configuration is True.

    Args:
        filename: Full path to file or directory to backup.

    Raises:
        TypeError: If filename is neither a file nor a directory.

    Note:
        Creates backup/ subdirectory if it doesn't exist.
        Backup filename format: original_YYYY_MM_DD_HH_MM_SS.ext
        Requires global 'app' to be set (Anchor instance).

    Example:
        >>> backup('config.ini')
        # Creates backup/config_2025_01_15_14_30_00.ini
    """
    if not app.backup:  # type: ignore[name-defined]
        return
    os.makedirs('backup', exist_ok=True)  # create backup subdirectory of cwd if doesn't already exist
    ts = dt.now().strftime("_%Y_%m_%d_%H_%M_%S")
    f, e = os.path.splitext(os.path.basename(filename))
    b_name = f'backup\\{f}{ts}{e}'
    if os.path.isfile(filename):
        shutil.copy(filename, b_name)
    elif os.path.isdir(filename):
        shutil.copytree(filename, b_name)
    else:
        raise TypeError(f'argument to backup is not a file or folder path : {filename}')


class WinQuit(Exception):
    """Exception raised to quit Winston application gracefully.

    Raise this exception to trigger controlled application shutdown.
    The exception propagates up through dialog stack to main event loop.
    """
    pass


class ChildQuit(Exception):
    """Exception raised when child dialog requests to close.

    Used to signal that a child/modal dialog wants to close without
    terminating the parent dialog or application.
    """
    pass


class COMThread(threading.Thread):
    """Thread subclass that ensures COM initialization.

    Automatically calls pythoncom.CoInitializeEx before running and
    pythoncom.CoUninitialize after running. Handles both STA (Apartment
    Threaded) and MTA (Multi-Threaded) initialization modes.

    Threaded COM objects (like SAPI speech) must be created and used on
    threads initialized with COM.

    Example:
        >>> def my_com_task():
        ...     voice = win32com.client.Dispatch("SAPI.SpVoice")
        ...     voice.Speak("Hello")
        >>> thread = COMThread(target=my_com_task)
        >>> thread.start()

    Note:
        Winston applet threads are automatically COMThread instances via
        Carousel.add() method.
    """

    def run(self) -> None:
        """Execute thread target with COM initialization.

        Initializes COM as STA (Apartment Threaded) first. If thread is
        already initialized as MTA, switches to MTA initialization.
        Ensures CoUninitialize is called even if exception occurs.

        Raises:
            Any exception raised by the thread target function.
        """
        inited = False
        try:
            try:
                pythoncom.CoInitializeEx(pythoncom.COINIT_APARTMENTTHREADED)  # STA
                inited = True
            except pythoncom.com_error as e:
                # If the thread is already MTA, switch to initializing as MTA
                if getattr(e, "hresult", None) == _RPC_E_CHANGED_MODE:
                    pythoncom.CoInitializeEx(pythoncom.COINIT_MULTITHREADED)  # MTA
                    inited = True
                else:
                    raise
            super().run()
        finally:
            if inited:
                try:
                    pythoncom.CoUninitialize()
                except Exception:
                    pass


class WinStdin(io.TextIOBase):
    """Replacement for sys.stdin that redirects input to Winston dialogs.

    Intercepts builtins.input() and sys.stdin.read*() calls, redirecting
    them to the current applet's lines_sink method for GUI-based input.
    Automatically disables speech purging during input to prevent
    interruption of prompts.

    Attributes:
        app: The Anchor application instance.
        original_input: Reference to builtins.input before replacement.
        default_prompt: Default prompt text when none provided.
        input_prompt: Current prompt text for next input operation.

    Note:
        Created and assigned to sys.stdin in Anchor.__init__().
        Requires current applet to have lines_sink method.
    """

    def __init__(self, app: Anchor) -> None:
        """Initialize WinStdin and replace builtins.input.

        Args:
            app: The Anchor application instance.
        """
        self.app = app
        self.original_input = builtins.input
        builtins.input = self.input  # type: ignore[assignment]
        self.default_prompt = 'Input Prompt'
        self.input_prompt = self.default_prompt
        super().__init__()

    def input(self, prompt: str = 'Unprompted Input') -> str:
        """Replacement for builtins.input() that uses dialog input.

        Temporarily disables speech purging, shows input dialog with prompt,
        prints result to output, then restores speech purging state.

        Args:
            prompt: Prompt text to display. Defaults to 'Unprompted Input'.

        Returns:
            User input string (without trailing newline).

        Note:
            The prompt is not passed to original_input() to avoid duplication
            in output stream. It's spoken when focus moves to input element.
        """
        # temporary disabling of speech purge is usually effected on the current applet,
        # but if for some reason there is no current applet then it is effected globally
        # on the tts engine
        current_app = self.app.applets.current()
        current: Any = current_app if current_app else self.app.tts
        original_purge_speech = current.purge_speech
        current.purge_speech = False
        self.input_prompt = prompt
        # now let original builtins.input() execute as normal which will invoke
        # sys.stdin.readline(). sys.stdin has already been set to self which now has
        # the prompt ready for use
        # not passing prompt to original input() means it is not replicated in the
        # output stream which seems more intuitive as it is spoken when focus moves
        # to the input element which now has the prompt as it's name
        result = self.original_input()
        print(result)
        self.input_prompt = self.default_prompt
        current.purge_speech = original_purge_speech
        return result

    def readline(self, size: int = -1) -> str: # type: ignore[override]
        """Read single line of input from current applet.

        Args:
            size: Maximum characters to read (currently ignored). Defaults to -1.

        Returns:
            Input line with trailing newline.

        Note:
            Delegates to current applet's lines_sink(multi_line=False).
            Returns '\\n' if no current applet or applet lacks lines_sink.
        """
        input_line = '\n'
        current_app = self.app.applets.current()
        if current_app and hasattr(current_app, 'lines_sink'):
            input_line = current_app.lines_sink(multi_line=False, prompt=self.input_prompt)
        return input_line

    def read(self, size: int = -1) -> str: # type: ignore[override]
        """Read multi-line input from current applet.

        Args:
            size: Maximum characters to read (currently ignored). Defaults to -1.

        Returns:
            Multi-line input string.

        Note:
            Delegates to current applet's lines_sink(multi_line=True).
        """
        input_string = self.app.applets.current().lines_sink(multi_line=True,prompt='multi-line input prompt')
        return input_string

    def readlines(self, hint: int = -1) -> list[str]:  # type: ignore[override]
        """Read multi-line input and return as list of lines.

        Args:
            hint: Hint for number of lines (currently ignored). Defaults to -1.

        Returns:
            List of lines including trailing newlines.

        Note:
            Calls read() and splits on newlines with keepends=True.
        """
        return self.read().splitlines(True)

    def writable(self) -> bool:
        """Check if stream is writable.

        Returns:
            Always False (stdin is read-only).
        """
        return False


class SAPIEngine:
    """SAPI-based text-to-speech engine (alternative to JAWS API).

    Uses Microsoft Speech API (SAPI) SpVoice COM object for speech synthesis.
    Speech requests are queued and must be processed on the thread that
    created the voice object (COM threading requirement).

    Attributes:
        queue: Queue of speech requests (func, *args) tuples.
        voice: SAPI SpVoice COM object.
        voice_index: Current voice index for cycling voices.

    Note:
        Currently unused in favor of TTS (JAWS API). Kept for fallback
        or alternative speech engine support.

    Example:
        >>> engine = SAPIEngine()
        >>> engine.speak("Hello world")
        >>> engine.process_functions()  # Call in main loop
    """

    def __init__(self) -> None:
        """Initialize SAPI SpVoice COM object and request queue."""
        # Initialize SAPI SpVoice COM object
        # pythoncom.CoInitialize()  # Ensure COM is initialized for the current thread
        # all speech requests via calls to self.say() are placed on self.queue and must
        # be dequeued and passed to the sapi voice in the thread that created this object
        # and hence created the sapi voice via win32com
        self.queue: queue.SimpleQueue[tuple[Any, ...]] = queue.SimpleQueue()
        self.voice: Any = win32com.client.Dispatch("SAPI.SpVoice")
        self.voice_index: int = 0

    def process_functions(self) -> None:
        """Process all queued speech requests.

        Dequeues (func, *args) tuples and calls func(*args) for each.
        Must be called from thread that created the voice COM object.

        Note:
            Call this in main event loop to process speech requests.
        """
        while not self.queue.empty():
            func, *args = self.queue.get()
            func(*args)

    def speak(self, s: str, interrupt: bool = True, wait: bool = False) -> None:
        """Queue speech request for processing.

        Args:
            s: Text to speak.
            interrupt: If True, interrupt current speech. Defaults to True.
            wait: If True, wait until speech finishes. Defaults to False.

        Note:
            Speech occurs when process_functions() is called, not immediately.
        """
        self.queue.put((self.do_speak, s, interrupt, wait))

    def do_speak(self, s: str, interrupt: bool = True, wait: bool = False) -> None:
        """Actually speak text using SAPI (internal, use speak() instead).

        Args:
            s: Text to speak.
            interrupt: If True, purge speech queue before speaking.
            wait: If True, block until speech completes.

        Note:
            Must be called from thread that created voice COM object.
        """
        # Set the appropriate flags based on interrupt and wait
        tts_flags = 0
        if interrupt:
            tts_flags |= 1  # SPF_PURGEBEFORESPEAK
        if not wait:
            tts_flags |= 2  # SPF_ASYNC
        # Speak the text
        self.voice.Speak(s, tts_flags)

    def set_rate(self, increment: int) -> None:
        """Queue request to adjust speech rate.

        Args:
            increment: Amount to increase (positive) or decrease (negative) rate.

        Note:
            Announces new rate after adjustment. Takes effect when
            process_functions() is called.
        """
        self.queue.put((self.do_set_rate, increment))

    def do_set_rate(self, increment: int) -> None:
        """Adjust speech rate (internal, use set_rate() instead).

        Args:
            increment: Amount to adjust rate by.

        Note:
            Speaks announcement at new rate to demonstrate change.
        """
        self.voice.Rate += increment
        self.speak(
            f"voice rate is now {self.voice.Rate} as you can tell from the speed of this announcement",
            wait=True
        )

    def cycle_voices(self) -> None:
        """Queue request to cycle to next available voice.

        Note:
            Takes effect when process_functions() is called.
            Speaks sample text with new voice.
        """
        self.queue.put((self.do_cycle_voices,))

    def do_cycle_voices(self) -> None:
        """Switch to next voice and speak sample (internal, use cycle_voices()).

        Cycles through available SAPI voices, wrapping to first voice after last.
        """
        voices = self.voice.GetVoices()
        self.voice_index = (self.voice_index + 1) % voices.Count
        self.voice.Voice = voices.Item(self.voice_index)
        voice_name = self.voice.Voice.GetDescription()
        self.speak(f"This is a sample of the {voice_name} voice.", interrupt=False, wait=True)


class TTS:
    """Text-to-speech engine using JAWS API.

    Primary speech synthesis system for Winston, using Freedom Scientific
    JAWS API for screen reader integration. Manages speech queue, logging,
    and punctuation substitution.

    Attributes:
        app: The Anchor application instance.
        enabled: True if JAWS API loaded successfully.
        purge_speech: Global flag controlling speech interruption.
        speech_log: Deque of recent speech requests with caller info.
        logging: If True, log all speech requests.
        engine: JAWS API COM object.

    Note:
        Clears gen_py cache on initialization to avoid COM issues.
        Falls back gracefully if JAWS API unavailable.
    """

    def __init__(self, app: Anchor) -> None:
        """Initialize JAWS API speech engine.

        Args:
            app: The Anchor application instance.

        Note:
            Deletes gen_py cache before loading COM to avoid stale cache issues.
            Sets enabled=True only if JAWS API loads successfully.
        """
        self.app = app
        self.enabled = False
        """
        the following global purge_speech flag may never be used as the toggle command on
        dialog objects toggles the home_dialog.purge_speech attribute which is tested by
        dialog.say. The global flag is only set False if there is no active dialog
        """
        self.purge_speech = True  # toggled by command_purge_speech and overrides interrupt flag on speak if set to False
        self.speech_log: deque[tuple[str, Any]] = deque(maxlen=50)
        self.reset_speech_log()
        self.logging = False
        try:
            gen_py = 'c:\\users\\chris\\appdata\\local\\temp\\gen_py'
            shutil.rmtree(gen_py)
            # print('removed gen_py')
        except Exception as e:
            pass
            # print(f'failed to remove {gen_py}: {e}')
        # import needs to be here not at start of module otherwise barfs because of
        # some sort of gen_py problem which is fixed by above delete from the cache
        from libloader.com import load_com  # type: ignore[import]
        self.engine: Any = load_com("FreedomSci.JawsApi", "jfwapi")
        # print('loaded engine and about to enable tts')
        self.enabled = True

    def reset_speech_log(self) -> None:
        """Clear speech log and add initialization message.

        Note:
            Log entry includes timestamp of reset.
        """
        self.speech_log.clear()
        initialized_msg = 'Initialized speech log: ' + dt.now().strftime('%H:%M:%S') + '\n'
        self.speech_log.append((initialized_msg, initialized_msg))

    def speak(
        self,
        s: str,
        interrupt: bool = True,
        substitutions: dict[str, str] = punctuator.most
    ) -> bool:
        """Speak text using JAWS API with optional punctuation substitution.

        If logging is enabled, records speech request with caller stack frame
        information for debugging.

        Args:
            s: Text to speak.
            interrupt: If True, interrupt current speech. Defaults to True.
            substitutions: Dictionary of punctuation substitutions. Defaults
                to punctuator.most.

        Returns:
            Return value from engine.SayString() (typically bool indicating success).

        Note:
            Actual interruption is controlled by self.purge_speech global flag.
            Empty strings and lines with only whitespace/^^ are not logged.
        """
        if self.logging:  # Control+Alt+L in critical keys toggles
            from . import debugging  # late import to avoid cyclic import
            if str(s).strip().strip('^'):  # skip lines with only whitespace or ^^^
                frame = inspect.currentframe()
                frame= frame.f_back  # type: ignore[union-attr]
                caller = caller_details(frame)  # type: ignore[arg-type]
                while caller.strip().startswith('Dialog.say'):  # skip back over dialog.say, say_current, etc as not very interesting
                    frame = frame.f_back  # type: ignore[union-attr]
                    caller = caller_details(frame)  # type: ignore[arg-type]
                self.speech_log.append(
                    (
                        punctuator.zwnj.join([s, caller]),
                        debugging.ValueObjectList(debugging.format_stack(frame)) # type: ignore[arg-type]
                    )
                )
        return self.engine.SayString(
            punctuator.substitute(s, substitutions),
            interrupt and self.purge_speech
        )


class StdWriter:
    """Replacement for sys.stdout that logs, speaks, and feeds consumers.

    Intercepts print() and sys.stdout.write() calls to:
    1. Speak output using TTS (last line of output)
    2. Log output to app.output_log deque
    3. Write to file
    4. Feed line-by-line to registered consumer functions

    Attributes:
        app: The Anchor application instance.
        stdout_lock: Threading lock for thread-safe writes.
        out: File handle for output log file.
        current_line: Buffer for accumulating partial lines.
        print: If True, write to file. If False, suppress file output.
        consumers: List of functions to call with each complete line.

    Note:
        Assigned to sys.stdout and sys.stderr in Anchor.__init__().
        Consumer functions receive complete lines (with newline stripped).
    """

    def __init__(self, app: Anchor, filename: str) -> None:
        """Initialize StdWriter and open output file.

        Args:
            app: The Anchor application instance.
            filename: Path to output log file.
        """
        self.app = app
        self.stdout_lock = threading.Lock()
        self.out = open(filename, 'w', encoding='utf-8', errors='ignore')
        self.current_line = ''
        self.print = True
        self.consumers: list[Callable[[str], None]] = []

    def register(self, consumer: Callable[[str], None]) -> None:
        """Register a consumer function to receive output lines.

        Args:
            consumer: Function that accepts a string (complete line).

        Note:
            Thread-safe. Consumer is only added if not already registered.
        """
        with self.stdout_lock:
            if consumer not in self.consumers:
                self.consumers.append(consumer)

    def unregister(self, consumer: Callable[[str], None]) -> None:
        """Unregister a consumer function.

        Args:
            consumer: Function to remove from consumers list.

        Note:
            Flushes buffered output before removing consumer.
        """
        self.flush()
        with self.stdout_lock:
            if consumer in self.consumers:
                self.consumers.remove(consumer)

    def write(self, s: str) -> None:
        """Write string to output, speaking last line and feeding consumers.

        Args:
            s: String to write (may contain newlines).

        Note:
            Thread-safe. Speaks last line (after final newline) non-interruptively.
            Strips whitespace before adding to output_log.
        """
        # take care with below lock acquisition if tts has it's own lock (not currently
        # the case) as this may cause deadlock unless lock acquisition/release order is
        # carefully co-ordinated
        with self.stdout_lock:
            if hasattr(self.app, 'tts') and self.app.tts.enabled:
                self.app.tts.speak(s.strip('\n').split('\n')[-1], interrupt=False)
            if len(stripped_line := s.strip()):
                self.app.output_log.append(stripped_line)
            if self.print:
                self.out.write(s)
            self.feed_consumers(s)

    def feed_consumers(self, s: str) -> None:
        """Parse string into lines and feed complete lines to consumers.

        Accumulates partial lines in self.current_line. Only complete lines
        (ending with newline) are fed to consumers.

        Args:
            s: String to feed (may contain multiple lines).

        Note:
            If s ends with newline, current_line becomes empty after feeding.
        """
        self.current_line += s
        lines = self.current_line.split('\n')
        for line in lines[:-1]:
            for consumer_function in self.consumers:
                consumer_function(line)
        # when s ended with a newline (the normal case for print without end='', then
        # current_line becomes empty string as a result of the earlier split
        self.current_line = lines[-1]

    def flush(self, force: bool = False) -> None:
        """Flush file buffer and optionally feed incomplete line to consumers.

        Args:
            force: If True, append newline to incomplete line before feeding.
                Defaults to False.

        Note:
            Always flushes file. If force=True, incomplete line is processed as
            complete line. If force=False and line exists, feeds incomplete line
            as-is to consumers.
        """
        with self.stdout_lock:
            self.out.flush()
        # Always send current_line to consumers if it has content
        # If force=True, append newline to ensure it gets processed as a complete line
        if len(self.current_line):
            if force:
                # Append newline and feed consumers, which will process it as a complete line
                self.feed_consumers('\n')
            else:
                # Send the incomplete line as-is to consumers
                for consumer_function in self.consumers:
                    consumer_function(self.current_line)
                self.current_line = ''

    def close(self) -> None:
        """Close output file after flushing all buffers.

        Note:
            Forces flush to ensure no output is lost.
        """
        self.flush(force=True)  # Force flush on close to ensure nothing is lost
        self.out.close()


class ModifiedKey:
    """Keyboard event with modifier keys (Shift, Control, Alt, Win).

    Represents a key press along with active modifiers, building a
    standardized modified key name like "Control+Shift+A".

    Attributes:
        key: Key name (e.g., 'A', 'Return', 'F1').
        ascii: ASCII code of key (or 0 for non-ASCII keys).
        modifiers: Set of active modifier key names.
        modified_name: String like "Control+Shift+A".
        event: Optional raw event object.

    Example:
        >>> mods = {'Lcontrol', 'Lshift'}
        >>> mk = ModifiedKey('A', mods, 65)
        >>> print(mk.modified_name)
        'Control+Shift+A'
    """

    def __init__(
        self,
        key: str,
        modifiers: set[str],
        ascii: int,
        event: Any = None
    ) -> None:
        """Initialize ModifiedKey and build modified name string.

        Args:
            key: Key name.
            modifiers: Set of modifier key names (e.g., {'Lcontrol', 'Lshift'}).
            ascii: ASCII code (or 0 for non-printable keys).
            event: Optional raw keyboard event. Defaults to None.
        """
        self.key = key
        self.ascii = ascii
        self.modifiers = modifiers
        self.modified_name = self.build_name()
        self.event = event

    def build_name(self) -> str:
        """Build modified key name from key and modifiers.

        Returns:
            String like "Control+Shift+A" or "F1" (no modifiers).

        Note:
            Order is: Shift+Control+Alt+Win+Key.
            Only includes modifiers that are active.
        """
        modified_name = 'Shift+' if not self.modifiers.isdisjoint({'Lshift', 'Rshift'}) else ''
        modified_name += 'Control+' if 'Lcontrol' in self.modifiers else ''
        modified_name += 'Alt+' if 'Lmenu' in self.modifiers else ''
        modified_name += 'Win+' if 'Lwin' in self.modifiers else ''
        modified_name += self.key
        return modified_name

    def __str__(self) -> str:
        """Get string representation of modified key.

        Returns:
            String like "ModifiedKey Control+A, ascii = 1".
        """
        return f"ModifiedKey {self.modified_name}, ascii = {self.ascii}"


class Anchor:
    """Root application object managing Winston lifecycle and resources.

    The Anchor serves as the root of the dialog tree and manages:
    - Configuration loading from INI files
    - COM initialization and threading
    - TTS (text-to-speech) engine
    - Output logging and redirection
    - Applet carousel for managing concurrent dialogs
    - Critical keyboard shortcuts (Escape, Up/Down, etc.)
    - Google API credentials and contacts (optional)
    - Debugging and tracing facilities

    Class Attributes:
        debugger: Winbugger instance accessible globally as Anchor.debugger.
        app: Reference to the Anchor instance, accessible as Anchor.app.

    Attributes:
        dialog: Currently active dialog (initially None).
        app: Reference to self (for dialog parent chain).
        key_q: Key queue for root level (None - applets have their own).
        restartable: If True, allows restart after crash.
        key_filter: If True, enable key filtering. Toggle with F11.
        hwnd: Console window handle.
        config: ConfigParser with loaded INI settings.
        backup: If True, enable automatic backups.
        window_titles: List of window title strings.
        window_handles: List of window handles.
        console_hwnd: Console window handle.
        console_owner_pid: PID of console owner process.
        modifiers: Set of currently pressed modifier keys.
        quitting: True when quit sequence initiated.
        quit_now: True when quit message received.
        break_on_hotkey: If True, break to debugger on hotkey.
        output_log: Deque of recent output lines.
        stdout: StdWriter instance for stdout/stderr.
        tts: TTS engine instance.
        applets: Carousel of active applet threads.
        critical_keys: CriticalHandler for essential shortcuts.
        google_api_lock: RLock for thread-safe Google API access.
        google_creds: Google API credentials (if available).
        _contacts: Private contacts list (access via contacts property).
        _recent_contacts: Private recent contacts list.

    Example:
        >>> app = Anchor(restartable=True)
        >>> app.launch_applet('explorer')
        >>> # App is running with applets carousel
    """

    debugger: Any = None  # class attribute so can always access as Anchor.debugger

    def __init__(self, restartable: bool) -> None:
        """Initialize Winston application.

        Sets up configuration, console window, output redirection, TTS engine,
        applet carousel, debugging facilities, and optional Google API integration.

        Args:
            restartable: If True, allow restart after unhandled exception.

        Note:
            Loads configuration from multiple INI files in priority order:
            1. config/defaults.ini
            2. ~/.winston.ini
            3. winston.ini (local development override)
        """
        self.dialog: Any = None
        self.app = self  # dialogs always set self.app = parent.app so here we set self.app to self to Anchor the tree
        """
        Dialog.__init__ sets self.key_q = self.parent.key_q if self.parent.key_q else SimpleQueue()
        So if parent is app (i.e. the anchor object, then the dialog object is an applet which
        runs on its own thread with its own key_q, otherwise it inherits the key_q of it's
        parent and is therefore modal
        """
        self.key_q: queue.SimpleQueue[Any] | None = None
        self.restartable = restartable  # used to determine if give user option to restart
        self.key_filter = True  # toggled with F11 to override key filtering
        self.hwnd: int = ctypes.windll.kernel32.GetConsoleWindow()
        Anchor.app = self  # can use this class attribute to retire the use of app in every Dialog instance
        self.config = cp()
        config_paths = [
            'config/defaults.ini',  # Default settings
            os.path.expanduser('~/.winston.ini'),  # User's home directory override
            'winston.ini'  # Local directory override (for development)
        ]
        self.config.read(config_paths)
        self.backup = self.config['application'].getboolean('backup')
        if len(sys.argv) and not (sys.argv[0].endswith('winston.py')):
            # hack to not let name become winston until work out how to delete winston
            # profile in JAWS which is making winston silent
            self.window_titles = [os.path.splitext(os.path.basename(sys.argv[0]))[0]]
        else:
            self.window_titles = [self.config['application'].get('title')]  # type: ignore[list-item]
        self.window_handles: list[int] = [gw.getActiveWindow()._hWnd]
        os.system(f'title {self.window_titles[0]}')
        self.console_hwnd: int = ctypes.windll.kernel32.GetConsoleWindow()
        if self.console_hwnd:
            # Get the PID of the console host process (conhost.exe)
            _, self.console_owner_pid = win32process.GetWindowThreadProcessId(self.console_hwnd)
        else:
            self.console_owner_pid = None
        self.modifiers: set[str] = set()  # the set of modifier key states
        self.quitting = False
        self.quit_now = False  # set to True when quit_message is received
        self.break_on_hotkey = False
        self.output_log: deque[str] = deque(maxlen=1000)
        out_name = self.app.config['application'].get('output')
        if out_name is None:
            out_name = 'output.txt'  # Default fallback
        # Ensure output directory exists
        os.makedirs(os.path.dirname(out_name), exist_ok=True)
        # take a copy of previous output log to review if necessary after restarting
        backup_name = os.path.join(os.path.dirname(out_name), '$' + os.path.basename(out_name))
        try:
            shutil.copy(out_name, backup_name)
        except Exception as e:
            print(f'exception {e} attempting to backup {out_name}')
        self.stdout = StdWriter(self.app, out_name)
        sys.stdout = self.stdout  # type: ignore[assignment]
        sys.stderr = self.stdout  # type: ignore[assignment]
        self.init_debugging()
        print(dt.now().strftime('%H %M on ' + ordinal_date.today()))  # indicate started execution
        self.tts = TTS(app=self)
        # self.sapi_tts=SAPIEngine()
        self.applets = Carousel(app=self)
        self.critical_keys = CriticalHandler(app=self)
        self.google_api_lock = threading.RLock()
        self.google_creds: Any = None  # Google API credentials (Credentials or None)
        self.get_creds_failure: str = ""  # Error message if credential loading failed
        self.set_creds()
        self.set_contacts()
        self.add_root()
        """
        now that the carousel is not empty re-direct stdin to a WinStdin object.
        WinStdin will instantiate a StdinDialog form on the current applet whenever
        it's readline(), read() or readlines() method is called
        StdinDialog has 2 fields, input and output.
        """
        sys.stdin = WinStdin(self)  # type: ignore[assignment]

    def init_debugging(self) -> None:
        """Initialize debugging facilities (tracer and debugger).

        Sets Anchor.debugger class attribute for global access.

        Note:
            Late imports from debugging module to avoid circular import.
        """
        from .debugging import Tracer
        from .debugger import Winbugger
        self.tracer = Tracer(self)  # used by Control+Alt+P and Alt+P
        # set the debugger as a class attribute so can easily access in exception handler
        Anchor.debugger = Winbugger(skip=[os.path.join(os.getcwd(), 'framework.py')], app=self)

    def toggle_key_filter(self) -> None:
        """Toggle key filtering on/off and announce state.

        Note:
            Key filtering is used in main event loop. F11 toggles this.
        """
        self.key_filter = not self.key_filter
        print('key filtering and logging set: ', self.key_filter)

    def set_creds(self) -> None:
        """Load Google API credentials.

        Sets self.google_creds to credentials object if successful,
        or None if failed. Sets self.get_creds_failure error message
        on failure.

        Note:
            Late imports google_api module. Handles ImportError gracefully.
        """
        try:
            from . import google_api  # type: ignore[attr-defined]
        except ImportError:
            google_api = None  # type: ignore[assignment]
        if google_api is None:
            self.get_creds_failure = "Google API module not available in package"
            self.google_creds = None
            return
        try:
            google_creds = google_api.get_creds()
        except Exception as e:
            google_creds = f"{e}"  # type: ignore[assignment]
        if isinstance(google_creds, str):
            self.get_creds_failure = f'Failed to get creds at start up, may be offline, error exception: {google_creds}'
            self.google_creds = None
        else:
            self.google_creds = google_creds

    def set_contacts(self) -> None:
        """Load Google contacts from cache and start async refresh.

        Loads contacts and recent_contacts from JSON cache files,
        then starts background thread to refresh from Google People API.

        Note:
            Late imports google_api and google_contacts modules. Handles ImportError gracefully.
            Updates are thread-safe via google_api_lock.
        """
        try:
            from . import google_api  # type: ignore[attr-defined]
            from . import google_contacts  # type: ignore[attr-defined]
            from .secrets_paths import (
                get_contacts_cache_path_with_fallback,
                get_recent_contacts_path_with_fallback
            )
        except ImportError:
            return  # Skip if modules not available
        """
        _contacts and _recent_contacts are private attributes to store the contacts from
        the Google people api and the resource cache, accessed as app.contacts which uses
        getter/setter methods to allow property style rather than method style access
        elsewhere. This allows for lock management within the getter and setter as contacts
        may be updated on async thread and recent_contacts may be updated from the mail thread.
        """
        self._contacts: list[Any] = []
        self.errors_in_contacts: list[str] = []
        self._recent_contacts: list[Any] = []
        try:
            self.contacts = google_contacts.get_contacts_from_cache(get_contacts_cache_path_with_fallback())
            self.recent_contacts = google_contacts.get_contacts_from_cache(get_recent_contacts_path_with_fallback())
            async_get_contacts_thread = threading.Thread(target=google_contacts.contacts_from_api, args=[self])
            async_get_contacts_thread.start()
        except Exception as e:
            print(e)


    @property
    def contacts(self) -> list[Any]:
        """Get contacts list (thread-safe).

        Returns:
            List of contact objects from Google People API.
        """
        with self.google_api_lock:
            return self._contacts

    @contacts.setter
    def contacts(self, value: list[Any]) -> None:
        """Set contacts list (thread-safe).

        Args:
            value: New contacts list.
        """
        with self.google_api_lock:
            self._contacts = value

    @property
    def recent_contacts(self) -> list[Any]:
        """Get recent contacts list (thread-safe).

        Returns:
            List of recent contact objects.
        """
        with self.google_api_lock:
            return self._recent_contacts

    @recent_contacts.setter
    def recent_contacts(self, value: list[Any]) -> None:
        """Set recent contacts list (thread-safe).

        Args:
            value: New recent contacts list.
        """
        with self.google_api_lock:
            self._recent_contacts = value

    def add_root(self) -> None:
        """Add root launcher dialog to applets carousel.

        Auto-discovers applets by scanning winston/applets directory for .py files.
        Creates RootDialog with AppletElement entries for each discovered applet.

        Note:
            Late import of subdialogs and elements module to avoid circular import.
            Excludes __init__.py and __pycache__ from applet list.
        """
        from . import subdialogs  # type: ignore[import]
        from . import elements  # type: ignore[import]

        # Auto-discover applets from applets directory
        applets_dir = os.path.join(os.path.dirname(__file__), 'applets')
        entries = []
        if os.path.exists(applets_dir):
            for filename in sorted(os.listdir(applets_dir)):
                if filename.endswith('.py') and filename != '__init__.py':
                    # Remove .py extension to get applet name
                    applet_name = filename[:-3]
                    entries.append(applet_name)

        root = subdialogs.RootDialog(
            parent=self.app,
            name_seed='Launcher',
            element_type=elements.AppletElement,
            filler=entries
        )
        self.applets.add(root, index=0)

    def launch_applet(
        self,
        applet_name: str,
        filler: Any = None,
        revert_to: Any = None
    ) -> None:
        """Load and launch an applet by name.

        Imports applet module from winston.applets package (or current directory
        as fallback), instantiates XXXDialog class, and adds to carousel.

        Args:
            applet_name: Applet module name (e.g., 'explorer', 'editor').
            filler: Optional data to pass to applet constructor. Defaults to None.
            revert_to: Optional applet to return to on close. Defaults to None.

        Note:
            Applet class name is derived by capitalizing applet_name and
            appending 'Dialog'. Falls back to AppletDialog if not found.

        Example:
            >>> app.launch_applet('explorer', filler='/home/user')
        """
        # First try to import from the applets package
        try:
            module_name = f'winston.applets.{applet_name}'
            mod = importlib.import_module(module_name)
            globals()[applet_name] = mod
        except ImportError:
            print(f"ImportError trying to import {module_name}")
            # Fallback to checking if it exists as a standalone file in current directory
            if os.path.exists(applet_name + '.py'):
                module_name = applet_name
                mod = importlib.import_module(module_name)
                globals()[module_name] = mod
            else:
                raise
        # if this is launch for a safe version of an applet then strip off the leading
        # 'safe_' to get to the actual applet dialog class name
        d_class_name = applet_name.replace('safe_', '') + 'Dialog'
        # make first letter upper case but don't use capitalize as that forces camel case
        # to all lower except for first letter
        d_class_name = d_class_name[0].upper() + d_class_name[1:]
        d_class = getattr(mod, d_class_name)
        # below launch assumes that the generate_content method of the applet's Dialog
        # subclass (e.g. ExplorerDialog) can determine what to put into the Dialog based
        # purely on it's class name if filler is None
        self.launch(
            d_class,
            element_type=None,
            name_seed=filler,
            filler=filler,
            match_list=['filler'],
            revert_to=revert_to
        )

    def launch(
        self,
        d_class: type[Any],
        element_type: type[Any] | None,
        name_seed: Any,
        filler: Any = None,
        match_list: list[str] = ['name'],
        revert_to: Any = None
    ) -> None:
        """Launch a dialog class, reusing existing instance if found.

        Searches applets for existing dialog matching d_class and name_seed.
        If found, switches to it. Otherwise, creates new instance and adds
        to carousel.

        Args:
            d_class: Dialog class to instantiate.
            element_type: Element type for dialog (or None for default).
            name_seed: Seed for generating instance name.
            filler: Optional data to pass to constructor. Defaults to None.
            match_list: Attributes to match for reuse check. Defaults to ['name'].
            revert_to: Optional applet to return to on close. Defaults to None.

        Note:
            If element_type is None, uses dialog class default element_type.
        """
        details: dict[str, Any] = {}
        details['__class__'] = d_class
        details['name'] = d_class.instance_name(self, name_seed)
        if not self.applets.set_current_from_details(details):
            # print(f'failed to set_current_from_details so now adding applet')
            """
            passing None as the element_type argument to launch_app means use the default
            element_type specified in the XXXDialog.__init__ signature. Hence the slightly
            opaque code below which does not provide an element_type parameter to the object
            instantiation if it was set to None on the call to launch_app
            """
            if element_type:
                self.applets.add(
                    d_class(self, name_seed, element_type=element_type, filler=filler, revert_to=revert_to)
                )
            else:
                self.applets.add(d_class(self, name_seed, filler=filler, revert_to=revert_to))


class CriticalHandler:
    """Handler for critical keyboard shortcuts that work globally.

    Manages essential hotkeys that function regardless of which applet is active:
    - Escape: Quit application
    - Up/Down: Cycle through applets
    - Left: Switch to launcher
    - Right: Switch to explorer
    - J: Toggle tracing
    - L: Toggle speech logging

    Attributes:
        app: The Anchor application instance.
        handlers: Dictionary mapping key names to handler methods.
    """

    def __init__(self, app: Anchor) -> None:
        """Initialize critical handlers.

        Args:
            app: The Anchor application instance.
        """
        self.app = app
        self.handlers: dict[str, Callable[[], None]] = {
            'Escape': self.close_app,
            'Up': self.previous_applet,
            'Down': self.next_applet,
            'Left': self.set_launcher,
            'Right': self.set_explorer,
            'J': self.app.tracer.toggle,
            'L': self.log_speech
        }

    def close_app(self) -> None:
        """Quit application, prompting if unsaved work exists.

        Checks for dirty applets and confirms exit if any found.
        Sets restartable=False and quitting=True to initiate shutdown.
        """
        if names := self.app.applets.get_dirty():
            if self.app.applets[0].message(
                title=f'unsaved work in {", ".join(names)}',
                buttons=['exit anyway', 'cancel']
            ) == 'exit anyway':
                self.app.restartable = False
                self.app.quitting = True
        else:
            self.app.restartable = False
            self.app.quitting = True

    def next_applet(self) -> None:
        """Switch to next applet in carousel."""
        self.app.applets.rotate(1)

    def previous_applet(self) -> None:
        """Switch to previous applet in carousel."""
        self.app.applets.rotate(-1)

    def set_explorer(self, debugger: bool = False) -> None:
        """Launch or switch to explorer applet.

        Args:
            debugger: Unused parameter (for signature compatibility). Defaults to False.
        """
        self.app.launch_applet('explorer')

    def set_launcher(self) -> None:
        """Switch to launcher applet (applets[0]).

        If Shift is pressed, closes app instead.
        Does not switch if launcher is paused.
        """
        if 'Lshift' in self.app.modifiers:
            self.close_app()
        elif not self.app.applets[0].is_paused:  # type: ignore[has-type]
            self.app.applets.set_current(0)
        else:
            beep(tag='cannot switch to launcher while it is paused')

    def log_speech(self) -> None:
        """Toggle speech logging on/off and announce state.

        Note:
            Use Shift+Alt+Right to view speech log in debugger.
        """
        self.app.tts.logging = not self.app.tts.logging
        self.app.tts.speak(
            f"speech logging {'enabled' if self.app.tts.logging else 'disabled'}. "
            f"Use Shift+Alt+Right to view log."
        )


class Carousel(list["AppletDialog"]):
    """Thread-safe list of applet dialogs with rotation and focus management.

    Extends list to manage active applet threads. Provides thread-safe methods
    for adding, removing, and switching between applets. Maintains cursor
    pointing to current applet.

    Attributes:
        app: The Anchor application instance.
        cursor: Index of currently active applet.
        lock: RLock for thread-safe operations.

    Note:
        Uses RLock (reentrant lock) to handle nested lock acquisition when
        set_by_name calls set_current.

    Example:
        >>> carousel = Carousel(app=app)
        >>> carousel.add(explorer_dialog)
        >>> carousel.rotate(1)  # Switch to next applet
    """

    def __init__(self, app: Anchor, *args: Any, **kwargs: Any) -> None:
        """Initialize empty carousel.

        Args:
            app: The Anchor application instance.
            *args: Positional arguments for list.__init__.
            **kwargs: Keyword arguments for list.__init__.
        """
        super().__init__(*args, **kwargs)
        self.app = app
        self.cursor = 0
        # RLock() handles re-entrant lock acquisition when set_by_name acquires the lock
        # and then invokes set_current(thread) with the found thread, which in turn re-acquires
        self.lock = threading.RLock()

    def current(self) -> AppletDialog:
        """Get currently active applet.

        Returns:
            Current applet dialog 

        Note:
            Thread-safe.
        """
        with self.lock:
            return self[self.cursor]

    def get_dirty(self) -> list[str]:
        """Get list of applet names with unsaved changes.

        Returns:
            List of basenames for applets where on_close=True and dirty=True.

        Note:
            Used by close_app to prompt before quitting.
        """
        return [
            os.path.basename(applet.name)
            for applet in self
            if (getattr(applet, 'on_close', False) and getattr(applet, 'dirty', False))
        ]

    def get_index_from_details(self, details: dict[str, Any]) -> int:
        """Find applet index matching all attributes in details dict.

        Args:
            details: Dictionary of attribute names and values to match.

        Returns:
            Index of first matching applet, or -1 if none found.

        Example:
            >>> details = {'__class__': ExplorerDialog, 'name': 'Explorer'}
            >>> index = carousel.get_index_from_details(details)
        """
        for i in range(len(self)):
            matches = [getattr(self[i], attribute, None) == details[attribute] for attribute in details]
            if all(matches):
                return i
        return -1  # no match found

    def set_current_from_details(self, details: dict[str, Any], silent: bool = False) -> bool:
        """Switch to applet matching details dict.

        Args:
            details: Dictionary of attributes to match.
            silent: If True, suppress speech announcement. Defaults to False.

        Returns:
            True if matching applet found and switched, False otherwise.

        Note:
            Thread-safe.
        """
        with self.lock:
            if (i := self.get_index_from_details(details)) != -1:
                return self.set_current(i, silent=silent)
            else:
                return False

    def set_current_applet(self, applet: AppletDialog, silent: bool = False) -> None:
        """Switch to specific applet instance.

        Args:
            applet: Applet dialog instance to switch to.
            silent: If True, suppress speech announcement. Defaults to False.

        Raises:
            ValueError: If set_current() returns False (applet is paused).

        Note:
            Thread-safe. Applet must be in carousel.
        """
        with self.lock:
            if applet in self:
                applet_index = self.index(applet)
            else:
                applet_index = 0
            if not self.set_current(applet_index, silent=silent):
                raise ValueError(
                    f'set_current declined to set {applet.name} with index {applet_index}: '
                    f'is_paused = {applet.is_paused}'  # type: ignore[has-type]
                )

    def set_current(self, cursor: int, silent: bool = False, interrupt: bool = True) -> bool:
        """Switch to applet at cursor index.

        Args:
            cursor: Index of applet to switch to.
            silent: If True, suppress speech announcement. Defaults to False.
            interrupt: If True, interrupt current speech. Defaults to True.

        Returns:
            False if applet is paused (cannot switch), True otherwise.

        Note:
            Thread-safe. Paused applets cannot become current because their
            key_q is not being serviced (waiting on wait_ui event).
        """
        with self.lock:
            if self[cursor].is_paused:  # type: ignore[has-type]  # do not set current to a paused applet
                return False
            else:
                self.cursor = cursor
                applet = self.current()
            if not (silent or self.app.quit_now):  # suppress speech when quitting
                applet.say_strings(
                    ['under inspection: ' if applet.under_inspection else '', *applet.breadcrumbs()],  # type: ignore[has-type]
                    interrupt=interrupt
                )
            return True

    def rotate(self, increment: int) -> None:
        """Rotate to next/previous applet, skipping paused applets.

        Args:
            increment: Number of positions to rotate (negative for backward).

        Raises:
            ValueError: If all applets in carousel are paused.

        Note:
            Thread-safe. Wraps around at carousel ends.
            Skips paused applets automatically.
        """
        with self.lock:
            # following logic should work if multiple applets need to be skipped
            direction = -1 if increment < 0 else 1
            skip_count = 0
            while not self.set_current((self.cursor + increment + direction * skip_count) % len(self), interrupt=True):
                skip_count += 1
                if skip_count > len(self):
                    raise ValueError('No applet in Carousel settable')

    def add(self, applet: AppletDialog, index: int | None = None) -> None:
        """Add applet to carousel and start its thread.

        Creates COMThread for applet, inserts at index, sets as current
        (silently), and starts the thread.

        Args:
            applet: Applet dialog instance to add.
            index: Position to insert at. If None, appends to end. Defaults to None.

        Note:
            Thread-safe. Thread is set as daemon so it won't block app shutdown.
            Thread name is derived from dialog class name (without 'Dialog' suffix).
        """
        applet.thread = COMThread(target=applet.run, name=applet.__class__.__name__.replace('Dialog', ''))  # type: ignore[attr-defined]
        with self.lock:
            if index is None:
                index = len(self)
            self.insert(index, applet)
            self.set_current(index, silent=True)  # set as current but don't speak name yet
            # setting applets as daemons means they will not hang winston if they are stuck
            # themselves but termination handling in clem makes best efforts to tidy up all
            # threads before closing the entire app
            self.current().thread.daemon = True  # type: ignore[attr-defined]  # current() guaranteed non-None after insert
            self.current().thread.start()  # type: ignore[attr-defined]

    def remove(self, applet: AppletDialog) -> None:
        """Remove applet from carousel and switch focus.

        If not quitting, switches to applet.revert_to (if valid) or launcher.
        Then removes applet from list.

        Args:
            applet: Applet dialog instance to remove.

        Note:
            Thread-safe. If revert_to applet is paused, attempts to unlock it
            by sending 'quit_now' to its key_q.
        """
        with self.lock:
            if not self.app.quit_now:
                if (applet.revert_to in self) and not applet.revert_to.is_paused:  # type: ignore[has-type,union-attr]
                    self.set_current_applet(applet.revert_to)  # type: ignore[arg-type]
                else:
                    if hasattr(applet.revert_to, 'is_paused') and applet.revert_to.is_paused:  # type: ignore[union-attr,has-type]
                        applet.revert_to.key_q.put('quit_now')  # type: ignore[union-attr,has-type]  # hopefully unlock this app
                    self.set_current(0)
            if applet in self:
                super().remove(applet)


# end of file