"""Diff highlighting — CF overlay for cell changes + revert.

After user code executes and dirty cells are collected, this module:
1. Applies Conditional Formatting to highlight changed cells (yellow)
   — skipped for >10K changes (too many COM calls)
2. Stores CF state and revert data keyed by transaction ID

TS only receives the cfTxId. Revert data stays server-side — no
round-trip through HTTP/JSON. Cleanup and revert are triggered by
the TS layer via /exec control requests referencing the cfTxId.

Thread safety: all public methods are called via _run_on_main
(serialized on the main thread) AND s_exec_lock in C serializes
all /exec requests. _exec_entry owns the single DiffHighlightStore
instance, so no concurrent access to its state.
"""

from shortcut_xl._log import xl_log
from shortcut_xl._tracking import DirtyCell

# Excel constants
_XL_EXPRESSION = 2  # xlExpression CF type
_HIGHLIGHT_COLOR = 0x00FFFF  # yellow (BGR for #FFFF00) — Track Changes color

# Thresholds
_MAX_HIGHLIGHT_CELLS = 10_000  # skip CF above this — too many COM calls
_ADDR_BATCH_LIMIT = 255  # max chars per comma-separated address string
_MAX_TX_ENTRIES = 10  # evict oldest if exceeded (guards against leaked entries)


# ---------------------------------------------------------------------------
# Stateless helpers
# ---------------------------------------------------------------------------


def _group_by_sheet(dirty_cells: list[DirtyCell]):
    by_sheet = {}
    for cell in dirty_cells:
        sheet = cell.get('sheet', '')
        if not sheet:
            xl_log(f'_group_by_sheet: skipping cell with no sheet key: {cell}')
            continue
        by_sheet.setdefault(sheet, []).append(cell)
    return by_sheet


def _build_union(app, ws, addresses):
    """Build a multi-area Range from address list, batching to stay under limits."""
    if not addresses:
        return None
    batches = []
    current = ''
    for addr in addresses:
        candidate = f'{current},{addr}' if current else addr
        if len(candidate) > _ADDR_BATCH_LIMIT and current:
            batches.append(current)
            current = addr
        else:
            current = candidate
    if current:
        batches.append(current)

    rng = ws.Range(batches[0])
    for b in batches[1:]:
        rng = app.Union(rng, ws.Range(b))
    return rng


def _apply_cf_highlights(app, by_sheet):
    """Apply CF rules to highlight changed cells. Returns list of rule COM objects."""
    rules = []
    for sheet_name, cells in by_sheet.items():
        try:
            ws = app.Worksheets(sheet_name)
            rng = _build_union(app, ws, [c['address'] for c in cells])
            if rng is None:
                continue
            rule = rng.FormatConditions.Add(_XL_EXPRESSION, None, '=TRUE')
            rule.Interior.Color = _HIGHLIGHT_COLOR
            rules.append(rule)
        except Exception as e:
            xl_log(f'_apply_cf_highlights({sheet_name}): {e}')
    return rules


def revert_cells(app, dirty_cells: list[DirtyCell]):
    """Restore old values/formulas for rejected changes.

    Takes an explicit list of dirty cell dicts. All data stays in-process
    (no JSON round-trip) — datetime and other Python types are preserved.
    """
    try:
        app.ScreenUpdating = False
    except Exception:
        pass

    for sheet_name, cells in _group_by_sheet(dirty_cells).items():
        try:
            ws = app.Worksheets(sheet_name)
            for cell in cells:
                addr = cell.get('address', '')
                if not addr:
                    continue
                try:
                    if cell.get('oldFormula'):
                        ws.Range(addr).Formula = cell['oldFormula']
                    else:
                        ws.Range(addr).Value = cell.get('oldValue')
                except Exception as e:
                    xl_log(f'revert_cells({sheet_name}!{addr}): {e}')
        except Exception as e:
            xl_log(f'revert_cells({sheet_name}): {e}')

    try:
        app.ScreenUpdating = True
    except Exception:
        pass


# ---------------------------------------------------------------------------
# Transaction store
# ---------------------------------------------------------------------------


class DiffHighlightStore:
    """Owns CF highlight state and revert data keyed by transaction ID.

    Create one instance per HTTP server lifetime (_exec_entry holds it).
    Tests can create isolated instances without shared global state.
    """

    def __init__(self, max_tx_entries: int = _MAX_TX_ENTRIES):
        self._next_tx_id = 0
        self._cf_state = {}  # tx_id -> list of CF rule COM objects
        self._revert_data = {}  # tx_id -> list of dirty cell dicts
        self._max_tx_entries = max_tx_entries

    def highlight_changes(self, app, dirty_cells: list[DirtyCell]):
        """Apply CF highlights and store revert data for changed cells.

        Returns a transaction ID (string) for later cleanup/revert via
        cleanup_by_tx or revert_by_tx. Always returns an ID, even if no
        highlights were applied (for consistent API).
        """
        tx_id = str(self._next_tx_id)
        self._next_tx_id += 1

        # Evict oldest entries if we're at the cap (guards against leaks
        # when TS crashes before sending cleanup).
        while len(self._revert_data) >= self._max_tx_entries:
            oldest = next(iter(self._revert_data))
            self._evict_tx(oldest)

        # Always store revert data (even above highlight threshold).
        if dirty_cells:
            self._revert_data[tx_id] = dirty_cells

        if not dirty_cells or len(dirty_cells) > _MAX_HIGHLIGHT_CELLS:
            return tx_id

        by_sheet = _group_by_sheet(dirty_cells)
        try:
            app.ScreenUpdating = False
        except Exception:
            pass

        rules = _apply_cf_highlights(app, by_sheet)
        if rules:
            self._cf_state[tx_id] = rules

        try:
            app.ScreenUpdating = True
        except Exception:
            pass

        return tx_id

    def cleanup_by_tx(self, tx_id):
        """Remove CF highlights and revert data for a transaction.

        Called after user accepts changes — clears both CF rules and stored
        revert data so the transaction is fully released.
        """
        tx_id = str(tx_id)
        self._revert_data.pop(tx_id, None)
        rules = self._cf_state.pop(tx_id, [])
        for rule in rules:
            try:
                rule.Delete()
            except Exception as e:
                xl_log(f'cleanup_by_tx: {e}')

    def revert_by_tx(self, app, tx_id):
        """Look up revert data by transaction ID and restore old values.

        Returns the number of cells reverted (0 if tx_id not found).
        Also cleans up CF highlights for the transaction.
        """
        tx_id = str(tx_id)
        dirty_cells = self._revert_data.pop(tx_id, None)
        # Clean up CF highlights too.
        for rule in self._cf_state.pop(tx_id, []):
            try:
                rule.Delete()
            except Exception as e:
                xl_log(f'revert_by_tx cleanup: {e}')
        if not dirty_cells:
            return 0
        revert_cells(app, dirty_cells)
        return len(dirty_cells)

    def _evict_tx(self, tx_id):
        """Remove all state for a transaction (CF rules + revert data)."""
        self._revert_data.pop(tx_id, None)
        for rule in self._cf_state.pop(tx_id, []):
            try:
                rule.Delete()
            except Exception:
                pass
