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

Output formats match the SpreadJS equivalents.
"""

from __future__ import annotations

from shortcut_xl.api import write_op
from shortcut_xl.api.categorize import com_error_to_str, is_com_error, is_error_value
from shortcut_xl.api.named_ranges import get_named_range_info
from shortcut_xl.api.utils.com_utils import normalize_2d
from shortcut_xl.api.utils.helpers import (
    extract_sheet_and_range,
    index_to_address,
    parse_range,
)
from shortcut_xl.api.worksheet import Worksheet

# Error check constants (match SpreadJS error-checker.ts)
_MAX_PER_CATEGORY = 10

# Excel xlPasteType enum values
_XL_PASTE_ALL = -4104
_XL_PASTE_VALUES = -4163
_XL_PASTE_FORMATS = -4122
_XL_PASTE_FORMULAS = -4123


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

    def __init__(self, wb):
        self._wb = wb

    # ------------------------------------------------------------------
    # Sheet navigation
    # ------------------------------------------------------------------

    def getSheetNames(self) -> list[str]:
        return [self._wb.Worksheets(i).Name for i in range(1, self._wb.Worksheets.Count + 1)]

    def getWorkbookSummary(self) -> str:
        """Get sheet names and used ranges for this workbook."""
        app = self._wb.Application
        active_wb = app.ActiveWorkbook
        active_ws = app.ActiveSheet
        active_wb_name = active_wb.Name if active_wb else None
        active_ws_name = active_ws.Name if active_ws else None

        lines = ['Sheets and their used ranges:']
        for i in range(1, self._wb.Worksheets.Count + 1):
            ws = self._wb.Worksheets(i)
            used_range = Worksheet(ws, workbook=self).getUsedRange() or '(empty)'
            active_marker = (
                ' (active)' if self._wb.Name == active_wb_name and ws.Name == active_ws_name else ''
            )
            lines.append(f'  {ws.Name}: {used_range}{active_marker}')

        return '\n'.join(lines)

    def getSheet(self, name: str) -> Worksheet:
        """Get a sheet by name, returned as a wrapped Worksheet."""
        return Worksheet(self._wb.Worksheets(name), workbook=self)

    @write_op
    def addSheet(self, name: str, index: int | None = None) -> Worksheet:
        """Add a new sheet with the given name at 0-based *index*.

        index=0 inserts before the first sheet, index=Count inserts after
        the last sheet.  None uses Excel's default position.
        """
        # NOTE: Worksheets.Add(Before, After, Count, Type) — must pass
        # positionally; pywin32 keyword dispatch is unreliable for this method.
        if index is not None:
            count = self._wb.Worksheets.Count
            index = max(0, min(index, count))
            if index < count:
                ws = self._wb.Worksheets.Add(self._wb.Worksheets(index + 1))
            else:
                ws = self._wb.Worksheets.Add(None, self._wb.Worksheets(count))
        else:
            ws = self._wb.Worksheets.Add()
        ws.Name = name
        return Worksheet(ws, workbook=self)

    @write_op
    def moveSheet(self, name: str, index: int) -> None:
        """Move a sheet to the given 0-based *index*.

        index=0 moves to first position, index=Count-1 moves to last.
        Uses positional args — keyword args (Before=/After=) are broken
        in pywin32 and silently create new workbooks.
        """
        count = self._wb.Worksheets.Count
        index = max(0, min(index, count - 1))
        ws = self._wb.Worksheets(name)
        if index == 0:
            ws.Move(self._wb.Worksheets(1))
        else:
            ws.Move(None, self._wb.Worksheets(index + 1))  # COM 1-based

    # ------------------------------------------------------------------
    # Calculation
    # ------------------------------------------------------------------

    def calculate(self) -> None:
        """Recalculate the entire workbook."""
        self._wb.Application.Calculate()

    # ------------------------------------------------------------------
    # Named ranges
    # ------------------------------------------------------------------

    def getNamedRangeInfo(self, max_count: int = 50) -> str:
        """Get named ranges. Returns formatted string."""
        return get_named_range_info(self._wb, max_count)

    # ------------------------------------------------------------------
    # Error check — matches SpreadJS error-checker.ts output exactly
    # ------------------------------------------------------------------

    def errorCheck(self, ranges: list[str] | None = None) -> str:
        """Scan for Excel errors (#REF!, #DIV/0!, etc.).

        Output format matches SpreadJS checkSheetErrors:
        - Clean: ``"No issues found."``
        - Errors: ``"Errors (N):\\n  Sheet1!A1: #REF!\\n  ..."``

        ``ranges`` can be sheet names (``"Sheet1"``) or
        sheet-qualified ranges (``"Sheet1!A1:D50"``).
        If omitted, checks all sheets.
        """
        targets = self._resolve_error_check_targets(ranges)
        all_errors: list[str] = []

        for sheet_name, bounds in targets:
            ws = self._wb.Worksheets(sheet_name)
            errors = self._scan_sheet_errors(ws, sheet_name, bounds)
            all_errors.extend(errors)

        if not all_errors:
            return 'No issues found.'

        return _format_error_category('Errors', all_errors)

    def _resolve_error_check_targets(
        self, ranges: list[str] | None
    ) -> list[tuple[str, tuple[int, int, int, int] | None]]:
        """Resolve range specs into (sheet_name, optional_bounds) pairs."""
        if ranges is None:
            return [(self._wb.Worksheets(i).Name, None) for i in range(1, self._wb.Worksheets.Count + 1)]

        targets = []
        for spec in ranges:
            sheet, rest = extract_sheet_and_range(spec)
            if sheet is None:
                # Bare sheet name like "Sheet1"
                targets.append((spec, None))
            else:
                bounds = parse_range(rest)
                targets.append((sheet, bounds))
        return targets

    @staticmethod
    def _scan_sheet_errors(
        ws,
        sheet_name: str,
        bounds: tuple[int, int, int, int] | None = None,
    ) -> list[str]:
        """Scan a worksheet for error values. Returns formatted strings."""
        if bounds:
            sr, sc, er, ec = bounds
        else:
            ur = ws.UsedRange
            if ur is None:
                return []
            sr = ur.Row - 1
            sc = ur.Column - 1
            er = sr + ur.Rows.Count - 1
            ec = sc + ur.Columns.Count - 1

        rows = er - sr + 1
        cols = ec - sc + 1
        if rows <= 0 or cols <= 0:
            return []

        rng = ws.Range(
            ws.Cells(sr + 1, sc + 1),
            ws.Cells(er + 1, ec + 1),
        )
        data = normalize_2d(rng.Value, rows, cols)

        errors = []
        for r_idx, row in enumerate(data):
            for c_idx, val in enumerate(row):
                # COM .Value returns error strings for text-entered errors
                # and integer CVErr codes for formula-produced errors
                error_str = None
                if is_error_value(val):
                    error_str = val
                elif is_com_error(val):
                    error_str = com_error_to_str(val)

                if error_str:
                    addr = index_to_address(sr + r_idx, sc + c_idx)
                    errors.append(f'{sheet_name}!{addr}: {error_str}')

        return errors

    # ------------------------------------------------------------------
    # Copy/Paste
    # ------------------------------------------------------------------

    def _resolve_range(self, address: str):
        """Resolve a possibly sheet-qualified address to a COM Range.

        ``Application.Range()`` does not accept ``"Sheet1!A1"`` syntax,
        so we split on ``!`` and go through the sheet object instead.
        """
        sheet_name, cell_addr = extract_sheet_and_range(address)
        if sheet_name is not None:
            return self._wb.Sheets(sheet_name).Range(cell_addr)
        return self._wb.Application.Range(address)

    @write_op
    def copyPasteRange(self, from_range: str, to_address: str, paste_type: str = 'all') -> None:
        """Copy a range to another location within the same workbook.

        Uses range.Copy (NOT Sheet.Copy which is broken via pywin32).
        All addresses must include sheet name (e.g., ``"Sheet1!A1:D10"``).
        paste_type: 'all', 'values', 'formatting', 'formulas'.
        """
        src = self._resolve_range(from_range)
        src.Copy()

        dst = self._resolve_range(to_address)

        paste_map = {
            'all': _XL_PASTE_ALL,
            'values': _XL_PASTE_VALUES,
            'formatting': _XL_PASTE_FORMATS,
            'formulas': _XL_PASTE_FORMULAS,
        }
        xl_paste = paste_map.get(paste_type, _XL_PASTE_ALL)
        dst.PasteSpecial(Paste=xl_paste)

        # When pasting values, COM error cells become raw integers.
        # Replace them with the error string so downstream reads see
        # "#N/A" (text) instead of -2146826246 (number).
        # dst may be a single cell that Excel expanded; resize to match src.
        if paste_type == 'values':
            pasted = dst.Resize(src.Rows.Count, src.Columns.Count)
            self._fix_pasted_error_cells(pasted)

        # Clear clipboard
        try:
            self._wb.Application.CutCopyMode = False
        except Exception:
            pass

    def _fix_pasted_error_cells(self, rng) -> None:
        """Replace COM error integers with their error string text."""
        data = rng.Value
        if data is None:
            return
        # Single cell
        if not isinstance(data, tuple):
            if is_com_error(data):
                rng.Value = com_error_to_str(data)
            return
        # Multi-cell range
        for r_idx, row in enumerate(data):
            if not isinstance(row, tuple):
                row = (row,)
            for c_idx, val in enumerate(row):
                if is_com_error(val):
                    rng.Cells(r_idx + 1, c_idx + 1).Value = com_error_to_str(val)


# ---------------------------------------------------------------------------
# Error formatting — matches SpreadJS error-checker.ts formatCategory
# ---------------------------------------------------------------------------


def _format_error_category(title: str, items: list[str]) -> str:
    shown = items[:_MAX_PER_CATEGORY]
    lines = [f'{title} ({len(items)}):']
    lines.extend(f'  {s}' for s in shown)
    if len(items) > _MAX_PER_CATEGORY:
        lines.append(f'  ... and {len(items) - _MAX_PER_CATEGORY} more')
    return '\n'.join(lines)
