"""Main-thread work queue for ShortcutXL.

Python win32com cannot call Excel COM from background threads reliably
(GetActiveObject fails from MTA, Dispatch creates a new instance).
This matches the pattern used by PyXLL, Excel-DNA, and xlwings:
background threads enqueue work, a timer on the main thread drains it.
"""

import ctypes
import queue
import threading
from ctypes import wintypes
from typing import TypedDict

from shortcut_xl._log import xl_log

# COM error code: Excel is busy (VBA_E_IGNORE = 0x800AC472)
_VBA_E_IGNORE = -2146777998

_MAX_ITEMS_PER_TICK = 64  # cap per timer tick to avoid starving Excel
_MAX_BUSY_RETRIES = 2400  # ~120s at 50ms intervals before giving up
_DRAIN_INTERVAL_MS = 50  # SetTimer interval for queue drain
_DEFAULT_TIMEOUT_S = 30.0  # max wait for main-thread dispatch + execution


class _ResultBox(TypedDict, total=False):
    """Typed dict for main-thread dispatch results.

    All keys are optional (total=False) because the box starts nearly empty
    and is populated progressively during dispatch.
    """

    result: object
    error: Exception | None
    _retries: int
    _cancelled: bool
    _running: bool


class ExecutionTimeout(BaseException):
    """Raised on the main thread when a caller's timeout expires mid-execution.

    Inherits from BaseException (not Exception) so that user code's
    ``except Exception:`` blocks cannot swallow it."""

    pass


def _is_excel_busy(e):
    """Check if a COM exception is VBA_E_IGNORE (Excel is busy)."""
    if not hasattr(e, 'hresult'):
        return False
    if e.hresult == _VBA_E_IGNORE:
        return True
    ei = getattr(e, 'excepinfo', None)
    if ei and len(ei) > 5 and ei[5] == _VBA_E_IGNORE:
        return True
    return False


# Persist main thread ID in builtins so it survives hot-reload.
import builtins

_main_thread_id = getattr(builtins, '_shortcutxl_main_thread_id', None)
if _main_thread_id is None:
    _main_thread_id = threading.current_thread().ident
    builtins._shortcutxl_main_thread_id = _main_thread_id

_work_queue = queue.Queue()
_draining = False


def _drain_queue():
    """Drain pending work items.  Runs on the main thread from the timer.

    The reentrancy guard is essential: work items may call
    PumpWaitingMessages(), which can dispatch another WM_TIMER and
    re-enter this function.  Without the guard the queue can be
    drained out-of-order and done_events can be missed."""
    global _draining
    if _draining:
        return
    _draining = True
    try:
        for _ in range(_MAX_ITEMS_PER_TICK):
            try:
                fn, result_box, done_event = _work_queue.get_nowait()
            except queue.Empty:
                break
            if result_box.get('_cancelled'):
                continue
            result_box['_running'] = True
            try:
                result_box['result'] = fn()
                result_box['error'] = None
                done_event.set()
            except ExecutionTimeout:
                result_box['error'] = RuntimeError('Execution interrupted: caller timed out')
                done_event.set()
            except Exception as e:
                if _is_excel_busy(e):
                    result_box['_retries'] = result_box.get('_retries', 0) + 1
                    if result_box['_retries'] < _MAX_BUSY_RETRIES:
                        _work_queue.put((fn, result_box, done_event))
                        break
                    xl_log(f'_drain_queue: Excel busy exceeded {_MAX_BUSY_RETRIES} retries')
                result_box['error'] = e
                done_event.set()
    finally:
        _draining = False


# SetTimer callback — called by Windows on the main thread during message pump
_TIMERPROC = ctypes.WINFUNCTYPE(None, wintypes.HWND, ctypes.c_uint, ctypes.c_size_t, wintypes.DWORD)


def _on_timer(hwnd, msg, id_event, dw_time):
    _drain_queue()


# prevent GC of the callback (prevents crash when timer fires)
_timer_proc_ref = _TIMERPROC(_on_timer)


def _start_drain_timer():
    """Create a 50ms timer on the main thread.  Safe to call on hot-reload."""
    try:
        old_id = getattr(builtins, '_shortcutxl_timer_id', None)
        if old_id:
            ctypes.windll.user32.KillTimer(None, ctypes.c_size_t(old_id))
        timer_id = ctypes.windll.user32.SetTimer(None, ctypes.c_size_t(0), _DRAIN_INTERVAL_MS, _timer_proc_ref)
        builtins._shortcutxl_timer_id = timer_id
        xl_log(f'_start_drain_timer: timer_id={timer_id}')
    except Exception as e:
        xl_log(f'_start_drain_timer: FAILED — {e}')
        import traceback

        xl_log(traceback.format_exc())


# Only start the drain timer on the main thread (xlAutoOpen).
if threading.current_thread().ident == _main_thread_id:
    _start_drain_timer()


def _run_on_main(fn, timeout=_DEFAULT_TIMEOUT_S):
    """Run fn() on the main thread and return its result.  Blocks the caller.
    If already on the main thread, calls fn() directly."""
    if threading.current_thread().ident == _main_thread_id:
        return fn()
    result_box: _ResultBox = {'_retries': 0}
    done_event = threading.Event()
    _work_queue.put((fn, result_box, done_event))
    if not done_event.wait(timeout=timeout):
        if result_box.get('_running'):
            # fn is mid-execution on the main thread — inject an async
            # exception to interrupt it.  This fires at the next Python
            # bytecode boundary (won't interrupt a blocking C call, but
            # will fire between COM calls / sleep increments).
            ctypes.pythonapi.PyThreadState_SetAsyncExc(
                ctypes.c_ulong(_main_thread_id), ctypes.py_object(ExecutionTimeout)
            )
            # Wait for the main thread to handle the exception and finish.
            done_event.wait(timeout=5.0)
            # Always clear — if the exception already fired this is a
            # harmless no-op; if fn() completed in the race window between
            # injection and delivery, this prevents the exception from
            # leaking to the next work item.
            ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_ulong(_main_thread_id), None)
            if not done_event.is_set():
                xl_log('_run_on_main: fn did not stop after async exception')
        else:
            result_box['_cancelled'] = True
        raise RuntimeError('_run_on_main: timed out waiting for main thread')
    if result_box.get('error') is not None:
        raise result_box['error']  # type: ignore[reportTypedDictNotRequiredAccess]  # guarded by .get() check above
    return result_box.get('result')
