"""Cell diff formatter — port of cell-diff-formatter.ts.

Produces a human-readable summary of dirty cells for the LLM,
with Good / Issues breakdown and row sampling.
"""

import re

from shortcut_xl._tracking import DirtyCell
from shortcut_xl.api.categorize import (
    GOOD,
    HARDCODED_NUMBER,
    HARDCODED_NUMBER_IN_FORMULA,
    INVALID_FORMULA,
    LARGE_PERCENTAGE,
    categorize_cells,
    com_error_to_str,
    is_com_error,
)
from shortcut_xl.api.utils.helpers import address_to_index, col_letter
from shortcut_xl.api.utils.numerical import CURRENCY_RE, NUMERIC_TYPES

# Display constants
MAX_ROWS_TO_SHOW = 10
MAX_SAMPLES_PER_ROW = 5
DECIMAL_PLACES = 3
MAX_FORMAT_DECIMALS = 2
UNDEFINED_SYMBOL = '\u2205'  # ∅
NO_CHANGES_MSG = 'No changes made.'

# (category_key, must_fix)
_PROBLEM_SECTIONS = (
    (LARGE_PERCENTAGE, False),
    (HARDCODED_NUMBER, False),
    (HARDCODED_NUMBER_IN_FORMULA, False),
    (INVALID_FORMULA, True),
)


# ---------- value formatting ----------


def format_value(value, number_format=None):
    """Format a raw cell value for display."""
    if value is None:
        return UNDEFINED_SYMBOL
    if isinstance(value, bool):
        return 'TRUE' if value else 'FALSE'
    if is_com_error(value):
        return com_error_to_str(value)
    if isinstance(value, str):
        return 'EMPTY' if len(value) == 0 else value
    # COM may return Decimal or other numeric types — coerce to float
    if not isinstance(value, NUMERIC_TYPES):
        try:
            value = float(value)
        except (TypeError, ValueError):
            return str(value)

    if not number_format:
        return '0' if value == 0 else f'{value:.{DECIMAL_PLACES}f}'

    fmt = number_format
    is_pct = '%' in fmt
    currency_m = re.search(CURRENCY_RE, fmt)
    currency = currency_m.group(0) if currency_m else None
    dec_m = re.search(r'\.([0#]+)', fmt)
    decimals = min(len(dec_m.group(1)), MAX_FORMAT_DECIMALS) if dec_m else MAX_FORMAT_DECIMALS
    use_parens = '(' in fmt

    num = value * 100 if is_pct else value
    abs_str = f'{abs(num):,.{decimals}f}'
    neg = num < 0

    if is_pct:
        return f'({abs_str}%)' if neg and use_parens else f'{"-" if neg else ""}{abs_str}%'
    if currency:
        return f'({currency}{abs_str})' if neg and use_parens else f'{"-" if neg else ""}{currency}{abs_str}'
    return f'({abs_str})' if neg and use_parens else f'{"-" if neg else ""}{abs_str}'


def _display_value(cell):
    """Get display string for a cell — prefer displayValue, then format with numberFormat."""
    dv = cell.get('displayValue')
    if dv is not None:
        return 'EMPTY' if dv == '' else dv
    return format_value(cell.get('value'), cell.get('numberFormat'))


# ---------- sampling ----------


def _sample_first_last(items, max_n):
    """Sample up to max_n items, always including first and last.

    Uses evenly-spaced indices for deterministic output."""
    n = len(items)
    if n <= max_n:
        return list(items)
    if max_n <= 0:
        return []
    if max_n == 1:
        return [items[0]]
    # Evenly spaced indices including 0 and n-1
    indices = [round(i * (n - 1) / (max_n - 1)) for i in range(max_n)]
    return [items[i] for i in indices]


# ---------- filtering ----------


def _parse_address(address):
    """Parse an A1-style address into zero-based row/col indices."""
    try:
        return address_to_index(address)
    except (ValueError, AttributeError):
        return 0, 0


def _should_include(cell, issues):
    """Include all changes except trivial None↔None (no actual change)."""
    value = cell.get('value')
    old = cell.get('oldValue')
    if value is None and old is None:
        return False
    return True


# ---------- grouping ----------


def _group_by_sheet_row(entries):
    """Group (cell, issues) entries by sheet -> row.
    Returns {sheet: {row: [(cell, issues, col), ...]}}
    """
    grouped = {}
    for cell, issues in entries:
        if not _should_include(cell, issues):
            continue
        row, col = _parse_address(cell.get('address', 'A1'))
        sheet = cell.get('sheet', 'Sheet1')
        grouped.setdefault(sheet, {}).setdefault(row, []).append((cell, issues, col))
    # Sort within each row by column
    for sheet_rows in grouped.values():
        for row_entries in sheet_rows.values():
            row_entries.sort(key=lambda t: t[2])
    return grouped


# ---------- row formatting ----------


def _format_row(sheet, row, entries, indent=2, show_issues=True, show_count=True):
    """Format a single row with sampled cells."""
    ind = ' ' * indent
    sind = ' ' * (indent + 2)
    lines = []

    cols = [e[2] for e in entries]
    min_c, max_c = min(cols), max(cols)
    col_range = col_letter(min_c) if min_c == max_c else f'{col_letter(min_c)}-{col_letter(max_c)}'

    count_suffix = f': {len(entries)} cells' if show_count else ':'
    lines.append(f'{ind}{sheet}!Row {row + 1} ({col_range}){count_suffix}')

    samples = _sample_first_last(entries, MAX_SAMPLES_PER_ROW)
    for cell, _issues, _col in samples:
        fmt = cell.get('numberFormat')
        old_str = format_value(cell.get('oldValue'), fmt)
        new_str = _display_value(cell)
        # Show formula text as annotation when relevant (not category names —
        # the section header already tells you the category)
        ann = f' [{cell["formula"]}]' if show_issues and cell.get('formula') else ''
        lines.append(f'{sind}\u2192 {cell.get("address", "?")}: {old_str} -> {new_str}{ann}')

    if len(entries) > MAX_SAMPLES_PER_ROW:
        lines.append(f'{sind}... and {len(entries) - MAX_SAMPLES_PER_ROW} more cells')

    return lines


# ---------- section formatting ----------


def _format_section(entries, title, must_fix=False):
    """Format a titled section of (cell, issues) entries."""
    filtered = [(c, iss) for c, iss in entries if _should_include(c, iss)]
    if not filtered:
        return ''

    prefix = 'MUST FIX: ' if must_fix else ''
    lines = [f'{prefix}{title}: {len(filtered)} total cells']

    grouped = _group_by_sheet_row(filtered)
    # Flatten to sorted list of (sheet, row, entries)
    all_rows = []
    for sheet in sorted(grouped):
        for row in sorted(grouped[sheet]):
            all_rows.append((sheet, row, grouped[sheet][row]))

    selected = _sample_first_last(all_rows, MAX_ROWS_TO_SHOW)
    for sheet, row, row_entries in selected:
        lines.extend(_format_row(sheet, row, row_entries))

    if len(all_rows) > MAX_ROWS_TO_SHOW:
        lines.append(f'  ... and {len(all_rows) - MAX_ROWS_TO_SHOW} more rows')

    return '\n'.join(lines)


# ---------- public API ----------


def format_cell_diff(dirty_cells: list[DirtyCell]):
    """Format a list of dirty cell dicts into a human-readable summary.

    This is the main entry point — call it with the raw dirty_cells list
    from DirtyTracker.collect_changes().  It categorizes issues and produces
    the same style of output as the Office.js client's cell-diff-formatter.

    Returns a string ready to be printed to stdout for the LLM to see.
    """
    if not dirty_cells:
        return NO_CHANGES_MSG

    cats = categorize_cells(dirty_cells)

    sections = [
        '--- CELL DIFF SUMMARY ---',
        f'({UNDEFINED_SYMBOL} = undefined/empty value)\n',
    ]

    # Good cells
    if cats[GOOD]:
        good_entries = [(c, []) for c in cats[GOOD]]
        s = _format_section(good_entries, 'Changed without issues')
        if s:
            sections.append(s)

    # Problem cells — driven by _PROBLEM_SECTIONS config
    problem_parts = []
    for cat_key, must_fix in _PROBLEM_SECTIONS:
        if cats[cat_key]:
            s = _format_section(cats[cat_key], cat_key, must_fix=must_fix)
            if s:
                problem_parts.append(s)

    if problem_parts:
        sections.append('Cells that need review:\n' + '\n\n'.join(problem_parts))

    return '\n\n'.join(sections) if len(sections) > 2 else NO_CHANGES_MSG
