"""Dirty cell tracking — lazy sheet snapshots.

Instead of snapshotting the entire workbook, we proxy Worksheet access
so that a sheet's UsedRange.Value2 is captured on first touch.  After
execution we re-read only the touched sheets and diff.  Cost is O(touched
sheets) not O(workbook), and the agent gets raw COM objects for everything
below the Worksheet level — zero overhead on Range reads/writes.
"""

from typing import Any, TypedDict

from shortcut_xl._log import xl_log
from shortcut_xl.api.utils.com_utils import normalize_2d
from shortcut_xl.api.utils.helpers import col_letter


class _DirtyCellBase(TypedDict):
    """Required fields produced by _diff_snapshots for every changed cell."""

    sheet: str
    address: str  # e.g. "B5" — bare cell address, no sheet prefix
    oldValue: Any
    value: Any


class DirtyCell(_DirtyCellBase, total=False):
    """A changed cell as produced by DirtyTracker.collect_changes().

    Required: sheet, address, oldValue, value
    Optional fields are added during the enrichment pass in collect_changes().
    """

    formula: str  # new formula (if cell has one after the change)
    oldFormula: str  # previous formula (if cell had one before)
    workbook: str  # workbook name — added during enrichment
    numberFormat: str  # Excel number format string — added during enrichment


class _SheetSnapshot:
    """Immutable snapshot of a worksheet's UsedRange values and formulas."""

    __slots__ = ('top_row', 'left_col', 'rows', 'cols', 'data', 'formulas')

    def __init__(self, ws):
        ur = ws.UsedRange
        self.top_row = ur.Row
        self.left_col = ur.Column
        self.rows = ur.Rows.Count
        self.cols = ur.Columns.Count
        self.data = normalize_2d(ur.Value, self.rows, self.cols)
        try:
            self.formulas = normalize_2d(ur.Formula, self.rows, self.cols)
        except Exception:
            self.formulas = None


def _get_formula(snap, ri, ci):
    """Get formula string from snapshot, or None if no formula data."""
    if snap.formulas is None:
        return None
    try:
        f = snap.formulas[ri][ci]
    except (IndexError, TypeError):
        return None
    # COM returns the raw value for non-formula cells; only keep actual formulas
    if isinstance(f, str) and f.startswith('='):
        return f
    return None


def _diff_snapshots(sheet_name: str, before: _SheetSnapshot, after: _SheetSnapshot) -> list[DirtyCell]:
    """Compare two _SheetSnapshots, return list of change dicts.

    Compares row-by-row first (tuple equality short-circuits) so
    unchanged rows cost almost nothing.
    """
    changes = []
    # Same geometry — fast path
    if (
        before.top_row == after.top_row
        and before.left_col == after.left_col
        and before.rows == after.rows
        and before.cols == after.cols
    ):
        for ri in range(before.rows):
            if before.data[ri] == after.data[ri]:
                continue
            row_num = before.top_row + ri
            for ci in range(before.cols):
                old = before.data[ri][ci]
                new = after.data[ri][ci]
                if old != new:
                    addr = f'{col_letter(before.left_col + ci - 1)}{row_num}'
                    entry = {
                        'sheet': sheet_name,
                        'address': addr,
                        'oldValue': old,
                        'value': new,
                    }
                    formula = _get_formula(after, ri, ci)
                    if formula:
                        entry['formula'] = formula
                    old_formula = _get_formula(before, ri, ci)
                    if old_formula:
                        entry['oldFormula'] = old_formula
                    changes.append(entry)
        return changes

    # Different geometry — union bounding box
    min_r = min(before.top_row, after.top_row)
    min_c = min(before.left_col, after.left_col)
    max_r = max(before.top_row + before.rows - 1, after.top_row + after.rows - 1)
    max_c = max(before.left_col + before.cols - 1, after.left_col + after.cols - 1)

    def _get(snap, row, col):
        r = row - snap.top_row
        c = col - snap.left_col
        if 0 <= r < snap.rows and 0 <= c < snap.cols:
            return snap.data[r][c]
        return None

    def _get_formula_abs(snap, row, col):
        r = row - snap.top_row
        c = col - snap.left_col
        if 0 <= r < snap.rows and 0 <= c < snap.cols:
            return _get_formula(snap, r, c)
        return None

    for row in range(min_r, max_r + 1):
        for col in range(min_c, max_c + 1):
            old = _get(before, row, col)
            new = _get(after, row, col)
            if old != new:
                addr = f'{col_letter(col - 1)}{row}'
                entry = {
                    'sheet': sheet_name,
                    'address': addr,
                    'oldValue': old,
                    'value': new,
                }
                formula = _get_formula_abs(after, row, col)
                if formula:
                    entry['formula'] = formula
                old_formula = _get_formula_abs(before, row, col)
                if old_formula:
                    entry['oldFormula'] = old_formula
                changes.append(entry)
    return changes


class DirtyTracker:
    """Snapshots sheets on first access, diffs after execution."""

    __slots__ = ('_app', '_snapshots')

    def __init__(self, app):
        self._app = app
        self._snapshots = {}  # name -> (ws, _SheetSnapshot)

    def snapshot_sheet(self, ws):
        """Capture a sheet's data on first access.  No-op on subsequent calls."""
        try:
            name = ws.Name
        except Exception:
            return
        if name not in self._snapshots:
            try:
                self._snapshots[name] = (ws, _SheetSnapshot(ws))
            except Exception as e:
                xl_log(f'DirtyTracker.snapshot_sheet({name}): {e}')

    def collect_changes(self) -> list[DirtyCell]:
        """Re-read snapshotted sheets and return list of cell changes."""
        all_changes = []
        for name, (ws, before) in self._snapshots.items():
            try:
                after = _SheetSnapshot(ws)
                changes = _diff_snapshots(name, before, after)
                # Enrich with workbook name + numberFormat
                try:
                    wb_name = ws.Parent.Name
                except Exception:
                    wb_name = None
                for entry in changes:
                    if wb_name:
                        entry['workbook'] = wb_name
                    try:
                        fmt = ws.Range(entry['address']).NumberFormat
                        if fmt and fmt != 'General':
                            entry['numberFormat'] = str(fmt)
                    except Exception:
                        pass
                all_changes.extend(changes)
            except Exception as e:
                xl_log(f'DirtyTracker.collect_changes({name}): {e}')
        return all_changes


# ---------------------------------------------------------------------------
# Thin proxy classes (Application → Workbook → Worksheet access only)
# ---------------------------------------------------------------------------


class TrackedApp:
    """Proxy around Application.  Wraps workbook/sheet access to trigger
    snapshots; everything else passes straight through to COM."""

    _WB_ATTRS = frozenset(('ActiveWorkbook', 'ThisWorkbook'))
    _SHEET_ACCESSORS = frozenset(('Worksheets', 'Sheets'))
    _SHEET_DIRECT = frozenset(('ActiveSheet',))
    __slots__ = ('_app', '_tracker')

    def __init__(self, app, tracker):
        object.__setattr__(self, '_app', app)
        object.__setattr__(self, '_tracker', tracker)

    def __getattr__(self, name):
        val = getattr(object.__getattribute__(self, '_app'), name)
        tracker = object.__getattribute__(self, '_tracker')
        if name in TrackedApp._WB_ATTRS:
            return _TrackedWorkbook(val, tracker)
        if name == 'Workbooks':
            return _TrackedWorkbooks(val, tracker)
        if name in TrackedApp._SHEET_ACCESSORS:
            return _TrackedSheetAccessor(val, tracker)
        if name in TrackedApp._SHEET_DIRECT:
            tracker.snapshot_sheet(val)
            return val
        return val

    def __setattr__(self, name, value):
        setattr(object.__getattribute__(self, '_app'), name, value)

    def __repr__(self):
        return repr(object.__getattribute__(self, '_app'))


class _TrackedWorkbooks:
    """Proxy for Workbooks collection."""

    __slots__ = ('_obj', '_tracker')

    def __init__(self, obj, tracker):
        object.__setattr__(self, '_obj', obj)
        object.__setattr__(self, '_tracker', tracker)

    def __call__(self, *args, **kwargs):
        wb = object.__getattribute__(self, '_obj')(*args, **kwargs)
        return _TrackedWorkbook(wb, object.__getattribute__(self, '_tracker'))

    def __getattr__(self, name):
        return getattr(object.__getattribute__(self, '_obj'), name)

    def __iter__(self):
        tracker = object.__getattribute__(self, '_tracker')
        for wb in object.__getattribute__(self, '_obj'):
            yield _TrackedWorkbook(wb, tracker)


class _TrackedWorkbook:
    """Proxy for a Workbook — wraps Worksheet/Sheet access."""

    _SHEET_ACCESSORS = frozenset(('Worksheets', 'Sheets'))
    _SHEET_DIRECT = frozenset(('ActiveSheet',))
    __slots__ = ('_wb', '_tracker')

    def __init__(self, wb, tracker):
        object.__setattr__(self, '_wb', wb)
        object.__setattr__(self, '_tracker', tracker)

    def __getattr__(self, name):
        val = getattr(object.__getattribute__(self, '_wb'), name)
        tracker = object.__getattribute__(self, '_tracker')
        if name in _TrackedWorkbook._SHEET_ACCESSORS:
            return _TrackedSheetAccessor(val, tracker)
        if name in _TrackedWorkbook._SHEET_DIRECT:
            tracker.snapshot_sheet(val)
            return val
        return val

    def __setattr__(self, name, value):
        setattr(object.__getattribute__(self, '_wb'), name, value)

    def __repr__(self):
        return repr(object.__getattribute__(self, '_wb'))


class _TrackedSheetAccessor:
    """Proxy for Worksheets/Sheets collection — snapshots each sheet
    the first time it is accessed, then returns the real COM Worksheet."""

    __slots__ = ('_obj', '_tracker')

    def __init__(self, obj, tracker):
        object.__setattr__(self, '_obj', obj)
        object.__setattr__(self, '_tracker', tracker)

    def __call__(self, *args, **kwargs):
        ws = object.__getattribute__(self, '_obj')(*args, **kwargs)
        object.__getattribute__(self, '_tracker').snapshot_sheet(ws)
        return ws  # real COM object — no more proxying

    def __getattr__(self, name):
        return getattr(object.__getattribute__(self, '_obj'), name)

    def __iter__(self):
        tracker = object.__getattribute__(self, '_tracker')
        for ws in object.__getattribute__(self, '_obj'):
            tracker.snapshot_sheet(ws)
            yield ws
