"""run_managed — execute user code against Excel on the main thread."""

import threading

from shortcut_xl._com import xl_app
from shortcut_xl._exec_sandbox import active_exec_sandbox, build_safe_builtins
from shortcut_xl._log import xl_log
from shortcut_xl._threading import _run_on_main
from shortcut_xl._tracking import DirtyTracker, TrackedApp
from shortcut_xl.api.format import format_cell_diff
from shortcut_xl.api.utils.helpers import (
    address_to_index,
    col_index,
    col_letter,
    hex_to_bgr,
    index_to_address,
)
from shortcut_xl.api.workbook import Workbook
from shortcut_xl.api.worksheet import Worksheet

_EDIT_MODE_SCODE = -2146827284

# Excel-specific symbols injected into user code namespace (not importable by
# name). Stdlib modules are intentionally NOT pre-injected — user code imports
# them normally, which routes through _safe_import and patches their
# __builtins__ so they cannot be used to recover blocked primitives.
_USER_GLOBALS = {
    'Workbook': Workbook,
    'Worksheet': Worksheet,
    'hex_to_bgr': hex_to_bgr,
    'index_to_address': index_to_address,
    'address_to_index': address_to_index,
    'col_letter': col_letter,
    'col_index': col_index,
}


class EditModeError(Exception):
    """Raised when Excel is in cell edit mode at request start."""


def _is_edit_mode_error(e):
    """Check if a COM exception is the Excel cell-edit-mode failure."""
    if hasattr(e, 'hresult') and e.hresult == _EDIT_MODE_SCODE:
        return True
    ei = getattr(e, 'excepinfo', None)
    if ei and len(ei) > 5 and ei[5] == _EDIT_MODE_SCODE:
        return True
    return False


def _probe_edit_mode(app):
    """Return True when Excel is currently in cell edit mode."""
    try:
        app.Calculation = app.Calculation
        return False
    except Exception as e:
        if _is_edit_mode_error(e):
            return True
        raise


def run_managed(code_str):
    """Compile and execute user code on the main thread with Excel state managed.

    Preserves Calculation/Iteration settings, disables Events/Alerts,
    runs code with a tracking proxy, collects dirty cells, then
    restores Excel state.

    Returns the list of dirty cells. Caller (_exec_entry) is responsible
    for CF highlighting, JSON response building, and stdout capture.
    The text summary is still printed to stdout (captured by _exec_entry)
    so the agent sees what changed in the tool result output.
    """
    code = compile(code_str, '<excel_exec>', 'exec')

    def _work():
        app = xl_app()
        has_workbooks = app.Workbooks.Count > 0

        if has_workbooks and _probe_edit_mode(app):
            raise EditModeError('Excel is in cell edit mode. Exit edit mode in Excel and retry.')

        # Save all mutable Excel state — including Iteration settings
        # which are coupled to Calculation and can wedge it if left dirty.
        orig_ev = app.EnableEvents
        orig_da = app.DisplayAlerts
        orig_calc = app.Calculation if has_workbooks else None
        # Iteration/MaxIterations/MaxChange are workbook-level settings in
        # Excel's COM API — with zero workbooks they return COM error
        # sentinels, so guard them the same way as Calculation.
        orig_iteration = app.Iteration if has_workbooks else None
        orig_max_iterations = app.MaxIterations if has_workbooks else None
        orig_max_change = app.MaxChange if has_workbooks else None

        # Setup is inside try/finally so partial setup always gets cleaned up.
        tracker = None
        dirty = []
        try:
            app.EnableEvents = False
            app.DisplayAlerts = False
            tracker = DirtyTracker(app)
            tracked = TrackedApp(app, tracker)
            safe_builtins = build_safe_builtins()
            ns = {**_USER_GLOBALS, 'app': tracked, '__builtins__': safe_builtins}
            with active_exec_sandbox(safe_builtins):
                exec(code, ns)
        finally:
            # Collect changes BEFORE restoring calc-related state so we only see
            # direct writes, not formula recalculations.
            try:
                if tracker is not None:
                    dirty.extend(tracker.collect_changes())
            except BaseException as e:
                xl_log(f'run_managed: collect_changes failed: {e}')

            # Restore Excel state.  We must catch BaseException (not
            # Exception) because ExecutionTimeout is a BaseException that
            # can fire at any bytecode boundary via PyThreadState_SetAsyncExc.
            # A single outer try/except wraps ALL restoration steps so that
            # an async exception landing between individual try-blocks
            # cannot skip the remaining steps.
            def _restore():
                try:
                    app.DisplayAlerts = orig_da
                except BaseException as e:
                    xl_log(f'run_managed: restore DisplayAlerts failed: {e}')
                try:
                    app.EnableEvents = orig_ev
                except BaseException as e:
                    xl_log(f'run_managed: restore EnableEvents failed: {e}')
                # Restore Iteration settings BEFORE Calculation — Iteration
                # state can make Calculation non-settable via COM.
                # Guarded by is-not-None because with zero workbooks these
                # properties return COM error sentinels.
                try:
                    if orig_iteration is not None:
                        app.Iteration = orig_iteration
                except BaseException as e:
                    xl_log(f'run_managed: restore Iteration failed: {e}')
                try:
                    if orig_max_iterations is not None:
                        app.MaxIterations = orig_max_iterations
                except BaseException as e:
                    xl_log(f'run_managed: restore MaxIterations failed: {e}')
                try:
                    if orig_max_change is not None:
                        app.MaxChange = orig_max_change
                except BaseException as e:
                    xl_log(f'run_managed: restore MaxChange failed: {e}')
                try:
                    if orig_calc is not None:
                        app.Calculation = orig_calc
                except BaseException as e:
                    xl_log(f'run_managed: restore Calculation failed: {e}')

            try:
                _restore()
            except BaseException:
                # ExecutionTimeout landed between inner try-blocks;
                # retry once — the remaining props are cheap to set.
                try:
                    _restore()
                except BaseException:
                    xl_log('run_managed: restoration failed twice')

        return dirty

    try:
        dirty = _run_on_main(_work)
    except Exception as e:
        # Strip infrastructure traceback — _exec_entry formats it via
        # traceback.format_exc(), so the extra frames are noise.
        e.__traceback__ = None
        raise
    if dirty:
        summary = format_cell_diff(dirty)
        if summary:
            print(summary)
    return dirty


def xl_batch(fn):
    """Run fn(app) on the main thread with ScreenUpdating disabled."""

    def _batched():
        import pythoncom

        app = xl_app()
        app.ScreenUpdating = False
        try:
            fn(app)
        finally:
            try:
                app.ScreenUpdating = True
            except BaseException:
                pass
            try:
                pythoncom.PumpWaitingMessages()
            except BaseException:
                pass

    _run_on_main(_batched)


def schedule_call(fn, delay_seconds=0.0):
    """Schedule fn() to be called after delay_seconds on a background thread."""
    import time

    def _run():
        if delay_seconds > 0:
            time.sleep(delay_seconds)
        try:
            fn()
        except Exception as e:
            import traceback

            xl_log(f'schedule_call error: {e}\n{traceback.format_exc()}')

    t = threading.Thread(target=_run, daemon=True)
    t.start()
    return t
