"""Worksheet wrapper — thin layer over a COM Worksheet object.

Output formats match the SpreadJS equivalents in
client/shared/src/lib/spreadsheet/spreadjs/ so the LLM sees
consistent results regardless of platform.
"""

from __future__ import annotations

import json
import re
from typing import Any

from shortcut_xl._log import xl_log
from shortcut_xl.api import write_op
from shortcut_xl.api.categorize import com_error_to_str, is_com_error
from shortcut_xl.api.picture import add_picture
from shortcut_xl.api.range_formatter import format_cell_range
from shortcut_xl.api.style import read_style
from shortcut_xl.api.utils.com_utils import make_range, normalize_2d
from shortcut_xl.api.utils.helpers import (
    index_to_address,
    parse_range,
)
from shortcut_xl.api.utils.numerical import NUMERIC_TYPES

# Excel xlAutoFillType enum values
_XL_FILL_DEFAULT = 0
_XL_FILL_COPY = 1


class Worksheet:
    """Thin wrapper around a COM Worksheet object.

    Does NOT manage calculation mode, events, or screen updating —
    that is ``run_managed``'s job.
    """

    def __init__(self, ws, workbook: Any = None):
        self._ws = ws
        self._wb = workbook  # parent Workbook wrapper (optional)

    # ------------------------------------------------------------------
    # Used range helpers
    # ------------------------------------------------------------------

    def getUsedRange(self) -> str:
        """Return used range address like 'A1:F20', or '' if empty."""
        ur = self._ws.UsedRange
        if ur is None:
            return ''
        r = ur.Row  # 1-based
        c = ur.Column  # 1-based
        rows = ur.Rows.Count
        cols = ur.Columns.Count
        if rows == 0 or cols == 0:
            return ''
        start = index_to_address(r - 1, c - 1)
        end = index_to_address(r - 1 + rows - 1, c - 1 + cols - 1)
        if start == end:
            return start
        return f'{start}:{end}'

    def _used_range_bounds(self) -> tuple[int, int, int, int] | None:
        """Return (start_row, start_col, end_row, end_col) 0-based, or None."""
        ur = self._ws.UsedRange
        if ur is None:
            return None
        r = ur.Row - 1
        c = ur.Column - 1
        rows = ur.Rows.Count
        cols = ur.Columns.Count
        if rows == 0 or cols == 0:
            return None
        return r, c, r + rows - 1, c + cols - 1

    # ------------------------------------------------------------------
    # Single cell read
    # ------------------------------------------------------------------

    def getCell(self, address: str, include_style: bool = True) -> str:
        """Formatted cell string matching SpreadJS SheetProxy.getCell output.

        Format: ``value(FORMULA) (style_json)``
        - Formula always shown (without leading '=').
        - Appends ``[0]`` if raw value is 0 but display text differs.
        - Style as JSON dict if present and include_style is True.
        """
        if ':' in address:
            raise ValueError(f'getCell expects a single cell address, got range: {address}')
        cell = self._ws.Range(address)
        text = cell.Text
        raw = cell.Value

        if not text and raw is None:
            return ''

        result = str(text)

        # Zero display annotation — matches SpreadJS behavior
        # Exclude booleans: Python False == 0 is True, but FALSE is not zero
        if raw == 0 and not isinstance(raw, bool) and text != '0':
            result = result + ' [0]'

        formula = cell.Formula
        if formula and isinstance(formula, str) and formula.startswith('='):
            result = f'{text}({formula})'

        if include_style:
            style = read_style(cell)
            if style:
                result = f'{result} ({json.dumps(style)})'

        return result

    # ------------------------------------------------------------------
    # Range read — markdown representation
    # ------------------------------------------------------------------

    def getCellRange(self, range_addr: str, include_style: bool = True) -> str:
        """Return featurized representation of a cell range.

        Matches the condensed CSV format from SpreadJS RangeFormatter /
        shared processCellRange:

        - ``A1:value(formula) | B1:value`` per row
        - Frequent formulas abbreviated as F1, F2, etc.
        - Style patterns grouped by style (range descriptions)
        - Context from cells to the left (row labels)
        - Context from cells above (column headers via voting)

        Limited to 3000 cells.
        """
        return format_cell_range(self._ws, range_addr, include_style)

    # ------------------------------------------------------------------
    # Raw data reads
    # ------------------------------------------------------------------

    def getRawCellData(self, address: str, formula: bool = False) -> Any:
        cell = self._ws.Range(address)
        val = cell.Formula if formula else cell.Value
        if is_com_error(val):
            return com_error_to_str(val)
        return val

    def getRawRangeData(self, range_addr: str, formula: bool = False) -> list[list]:
        sr, sc, er, ec = parse_range(range_addr)
        rows = er - sr + 1
        cols = ec - sc + 1
        rng = make_range(self._ws, sr, sc, er, ec)
        raw = rng.Formula if formula else rng.Value
        return [list(row) for row in normalize_2d(raw, rows, cols)]

    # ------------------------------------------------------------------
    # Single cell write
    # ------------------------------------------------------------------

    @write_op
    def setCell(self, address: str, value: Any, number_format: str | None = None, note: str | None = None) -> None:
        """Write a value (or formula) to a single cell.

        Strings starting with '=' are treated as formulas.
        """
        if isinstance(value, str) and len(value) > 32767:
            raise ValueError(f"Cell value exceeds Excel's 32,767 character limit ({len(value)} chars)")
        cell = self._ws.Range(address)
        if value is None or value == '':
            cell.Value = None
        elif isinstance(value, str) and value.startswith('='):
            cell.Formula = value
        else:
            cell.Value = value

        if number_format:
            cell.NumberFormat = number_format

        if note:
            self._addNote(address, note)

    # ------------------------------------------------------------------
    # Bulk cell write
    # ------------------------------------------------------------------

    def _addNote(self, address: str, text: str) -> None:
        """Add a note to a cell, clearing any existing note/comment first.

        In COM, ``AddComment`` creates what modern Excel displays as a "note"
        (simple annotation with red triangle).  Threaded comments use the
        separate ``AddCommentThreaded`` API.
        """
        cell = self._ws.Range(address)
        # A cell can only have one annotation type — clear both kinds first
        try:
            cell.ClearComments()
        except Exception:
            pass
        try:
            cell.ClearNotes()
        except Exception:
            pass
        cell.AddComment(text)

    @write_op
    def setCellRange(self, range_addr: str, values: list[list], number_format: str | None = None) -> None:
        """Bulk write values to a range. Formulas detected by leading '='.

        ``values`` is a 2D list matching range dimensions.
        Optional ``number_format`` applies a single format to the entire range.
        """
        sr, sc, er, ec = parse_range(range_addr)
        rows = er - sr + 1
        cols = ec - sc + 1

        if len(values) != rows:
            raise ValueError(f'Row count mismatch: range has {rows} rows, got {len(values)}')
        for i, row in enumerate(values):
            if len(row) != cols:
                raise ValueError(f'Col count mismatch in row {i}: range has {cols} cols, got {len(row)}')

        rng = make_range(self._ws, sr, sc, er, ec)
        data = tuple(tuple(row) for row in values)

        has_formula = any(isinstance(v, str) and v.startswith('=') for row in values for v in row)

        # COM Range.Formula handles the mix — formulas start with '=',
        # everything else is treated as a value. Bulk assign either way.
        if has_formula:
            rng.Formula = data
        else:
            rng.Value = data

        if number_format:
            rng.NumberFormat = number_format

    # ------------------------------------------------------------------
    # Search
    # ------------------------------------------------------------------

    def regexSearch(self, patterns: list[str], match_case: bool = False) -> list[dict]:
        """Search used range with regex patterns.

        Returns list of ``{"address": "A1", "value": <raw>}`` dicts,
        matching SpreadJS SearchDelegate.regexSearch output.
        """
        if not isinstance(patterns, list) or len(patterns) == 0:
            raise ValueError('patterns must be a non-empty list')

        flags = 0 if match_case else re.IGNORECASE
        compiled = []
        for p in patterns:
            if not isinstance(p, str):
                raise ValueError(f'Invalid pattern type: expected str, got {type(p).__name__}')
            try:
                compiled.append(re.compile(p, flags))
            except re.error as e:
                raise ValueError(f'Invalid regex pattern: "{p}"') from e

        bounds = self._used_range_bounds()
        if bounds is None:
            return []
        sr, sc, er, ec = bounds
        rows = er - sr + 1
        cols = ec - sc + 1

        # Bulk-read values in one COM call to skip empty cells cheaply.
        rng = make_range(self._ws, sr, sc, er, ec)
        values = normalize_2d(rng.Value, rows, cols)

        results = []
        for r in range(rows):
            for c in range(cols):
                if values[r][c] is None:
                    continue
                # Only make per-cell .Text call for non-empty cells.
                text = str(self._ws.Cells(sr + r + 1, sc + c + 1).Text)
                if not text:
                    continue
                if any(rx.search(text) for rx in compiled):
                    addr = index_to_address(sr + r, sc + c)
                    results.append({'address': addr, 'value': text})
        return results

    # ------------------------------------------------------------------
    # IB Text Colors
    # ------------------------------------------------------------------

    @write_op
    def setIBTextColors(
        self, range_addr: str, ignored_constants: list[int] | None = None, colors: dict | None = None
    ) -> None:
        """Apply Investment Banking (IB) text color conventions.

        Colors numeric inputs vs formulas vs cross-sheet references.
        BGR integers (native COM format):
        - 16711680 (0xFF0000 BGR = blue): hard-coded numeric values
        - 0 (black): formulas referencing same sheet
        - 32768 (0x008000 BGR = green): formulas with cross-sheet references ('!')

        Text strings and booleans are left untouched.
        """
        colors = colors or {}
        blue = colors.get('inputColor', 16711680)  # 0xFF0000 BGR = blue
        black = colors.get('formulaColor', 0)  # black
        green = colors.get('crossSheetColor', 32768)  # 0x008000 BGR = green
        ignored = set(ignored_constants or [])

        sr, sc, er, ec = parse_range(range_addr)
        rows = er - sr + 1
        cols = ec - sc + 1

        rng = make_range(self._ws, sr, sc, er, ec)
        all_values = normalize_2d(rng.Value, rows, cols)
        all_formulas = normalize_2d(rng.Formula, rows, cols)

        for r in range(rows):
            for c in range(cols):
                val = all_values[r][c]
                formula = all_formulas[r][c]

                has_formula = isinstance(formula, str) and formula.startswith('=')

                if not has_formula and (isinstance(val, bool) or not isinstance(val, NUMERIC_TYPES)):
                    continue
                if not has_formula and isinstance(val, float) and val != val:
                    continue  # NaN

                if not has_formula and val in ignored:
                    continue

                cell = self._ws.Cells(sr + r + 1, sc + c + 1)
                if has_formula:
                    if '!' in formula:
                        cell.Font.Color = green
                    else:
                        cell.Font.Color = black
                elif isinstance(val, NUMERIC_TYPES):
                    cell.Font.Color = blue

    # ------------------------------------------------------------------
    # Sheet summary
    # ------------------------------------------------------------------

    def getSheetSummary(self) -> str:
        """Overview of the sheet matching Office.js getSheetSummary output."""
        parts = [f'Sheet: "{self._ws.Name}" | Used Range: {self.getUsedRange() or "(empty)"}']

        # Tables (ListObjects) — with properties matching TS output
        try:
            tables = self._ws.ListObjects
            if tables.Count > 0:
                parts.append(f'\nTables ({tables.Count}):')
                for i in range(1, tables.Count + 1):
                    t = tables.Item(i)
                    props = [
                        f'style={t.TableStyle.Name}' if t.TableStyle else 'style=None',
                        f'bandedRows={t.ShowTableStyleRowStripes}',
                        f'bandedCols={t.ShowTableStyleColumnStripes}',
                        f'headers={t.ShowHeaders}',
                        f'totals={t.ShowTotals}',
                    ]
                    if t.ShowTableStyleFirstColumn:
                        props.append('highlightFirstCol')
                    if t.ShowTableStyleLastColumn:
                        props.append('highlightLastCol')
                    parts.append(f'  - {t.Name} [{t.Range.Address}] [{", ".join(props)}]')
        except Exception as e:
            xl_log(f'getSheetSummary: Tables failed: {e}')

        # Pivot tables — with range addresses
        try:
            pivots = self._ws.PivotTables()
            count = len(pivots) if hasattr(pivots, '__len__') else pivots.Count
            if count > 0:
                parts.append(f'\nPivot Tables ({count}):')
                for i in range(1, count + 1):
                    pt = pivots(i) if callable(pivots) else pivots.Item(i)
                    try:
                        addr = pt.TableRange2.Address
                        parts.append(f'  - {pt.Name} [{addr}]')
                    except Exception:
                        parts.append(f'  - {pt.Name}')
        except Exception as e:
            xl_log(f'getSheetSummary: PivotTables failed: {e}')

        # Conditional Formats
        try:
            cfs = self._ws.Cells.FormatConditions
            if cfs.Count > 0:
                parts.append(f'\nConditional Formats ({cfs.Count}):')
                for i in range(1, cfs.Count + 1):
                    cf = cfs.Item(i)
                    try:
                        cf_type = cf.Type
                        priority = cf.Priority
                        addr = cf.AppliesTo.Address
                        props = [f'priority={priority}']
                        try:
                            if cf.StopIfTrue:
                                props.append('stopIfTrue')
                        except Exception:
                            pass
                        parts.append(f'  - {cf_type} on {addr} [{", ".join(props)}]')
                    except Exception as e:
                        xl_log(f'getSheetSummary: ConditionalFormat item failed: {e}')
        except Exception as e:
            xl_log(f'getSheetSummary: ConditionalFormats failed: {e}')

        # AutoFilter — with filtering status
        try:
            af = self._ws.AutoFilter
            if af is not None:
                rng_addr = af.Range.Address
                # Check if any filter is actively applied
                is_filtered = False
                try:
                    for j in range(1, af.Filters.Count + 1):
                        if af.Filters.Item(j).On:
                            is_filtered = True
                            break
                except Exception:
                    pass
                status = 'filtering active' if is_filtered else 'no active filters'
                parts.append(f'\nAutoFilter present [{rng_addr}] [{status}]')
        except Exception as e:
            xl_log(f'getSheetSummary: AutoFilter failed: {e}')

        # Charts — with chart type
        try:
            charts = self._ws.ChartObjects()
            if charts.Count > 0:
                parts.append(f'\nCharts ({charts.Count}):')
                for i in range(1, charts.Count + 1):
                    co = charts.Item(i)
                    title = co.Chart.ChartTitle.Text if co.Chart.HasTitle else co.Name
                    chart_type = co.Chart.ChartType
                    parts.append(f'  - "{title}" [type={chart_type}]')
        except Exception as e:
            xl_log(f'getSheetSummary: Charts failed: {e}')

        return '\n'.join(parts)

    @write_op
    def addPicture(self, name: str, base64_data: str, anchor_cell: str):
        """Add a picture from base64-encoded image data.

        Returns the COM Shape object.
        """
        return add_picture(self._ws, name, base64_data, anchor_cell)

    # ------------------------------------------------------------------
    # AutoFill
    # ------------------------------------------------------------------

    @write_op
    def autoFill(self, source_range: str, target_range: str, fill_mode: str = 'auto') -> None:
        """AutoFill — equivalent to Excel's drag-fill.

        fill_mode: 'auto' (default) or 'constant'.
        Source must be contained within target.
        """
        # Validate source is contained within target
        s_sr, s_sc, s_er, s_ec = parse_range(source_range)
        t_sr, t_sc, t_er, t_ec = parse_range(target_range)
        if s_sr < t_sr or s_sc < t_sc or s_er > t_er or s_ec > t_ec:
            raise ValueError(
                f'Source range {source_range} must be contained within target range '
                f'{target_range}. Example: autoFill("A1:A2", "A1:A10")'
            )

        src = self._ws.Range(source_range)
        tgt = self._ws.Range(target_range)
        xl_type = _XL_FILL_COPY if fill_mode == 'constant' else _XL_FILL_DEFAULT
        src.AutoFill(tgt, xl_type)
