"""Cell issue categorization — port of cell-categorizer.ts.

Detects hardcoded numbers, formula errors, large percentages,
and hardcoded numbers inside formulas.
"""

import re

from shortcut_xl._tracking import DirtyCell
from shortcut_xl.api.utils.numerical import CURRENCY_CHARS, NUMERIC_TYPES

# Issue types — also used as dict keys in categorize_cells() result
GOOD = 'good'
HARDCODED_NUMBER = 'hardcodes'
HARDCODED_NUMBER_IN_FORMULA = 'hardcodes in formulas'
LARGE_PERCENTAGE = 'large %'
INVALID_FORMULA = 'formula errs'
ALL_PROBLEMS = 'all problems'

# Number format characters that indicate date/time (skip these as hardcoded)
_DATE_TIME_CHARS = ('d', 'm', 'y', 'h', 's')


def _is_numeric(value):
    """Check if a value is numeric (int, float, Decimal, etc.)."""
    if isinstance(value, bool):
        return False
    if isinstance(value, NUMERIC_TYPES):
        return True
    # COM may return other numeric types
    try:
        float(value)
        return True
    except (TypeError, ValueError):
        return False


def _to_float(value):
    """Coerce a numeric value to float."""
    if isinstance(value, NUMERIC_TYPES):
        return float(value) if not isinstance(value, (int, float)) else value
    return float(value)


def is_hardcoded_number(cell):
    """Check if a cell contains a hardcoded number (no formula, numeric value,
    not a date/time/currency format, not a COM error code)."""
    if cell.get('formula'):
        return False
    value = cell.get('value')
    if not _is_numeric(value):
        return False
    if is_com_error(value):
        return False
    fmt = (cell.get('numberFormat') or '').lower()
    # Skip dates/times
    if any(ch in fmt for ch in _DATE_TIME_CHARS):
        return False
    # Skip currency/accounting
    if any(ch in fmt for ch in CURRENCY_CHARS):
        return False
    return True


# Matches standalone numbers in formulas, excluding cell refs / named ranges.
_HARDCODED_NUM_RE = re.compile(r'(?<![A-Z$!])(?<![A-Z]\d)\b\d+\.?\d*\b(?![A-Z])', re.IGNORECASE)


def has_hardcoded_numbers_in_formula(formula):
    """Check if a formula contains hardcoded numbers."""
    # Remove string literals to avoid false positives
    cleaned = re.sub(r'"[^"]*"', '', formula)
    return bool(_HARDCODED_NUM_RE.search(cleaned))


# Canonical set of Excel error strings — the single source of truth.
# Used for exact-match detection (e.g. values from COM .Value) and
# prefix-match detection (e.g. formulas containing error text).
_ERROR_VALUES = frozenset(
    (
        '#REF!',
        '#NAME?',
        '#VALUE!',
        '#DIV/0!',
        '#N/A',
        '#NULL!',
        '#NUM!',
        '#GETTING_DATA',
    )
)
_ERROR_PREFIXES = ('#ERROR', '#REF', '#VALUE', '#DIV/0', '#NUM', '#N/A', '#NAME', '#NULL')

# COM returns Excel error values as negative integers (CVErr codes).
# Map them to their string equivalents so categorization works.
_COM_ERROR_CODES = {
    -2146826281: '#DIV/0!',
    -2146826246: '#N/A',
    -2146826259: '#NAME?',
    -2146826265: '#REF!',
    -2146826273: '#VALUE!',
    -2146826252: '#NUM!',
    -2146826288: '#NULL!',
    -2146826245: '#GETTING_DATA',
}


def is_error_value(val) -> bool:
    """Check if a value is an Excel error string (exact match)."""
    return isinstance(val, str) and val in _ERROR_VALUES


def _is_error_string(s):
    """Check if a string starts with a known Excel error prefix.

    Looser than is_error_value() — catches partial matches in formulas.
    Excel error values always start with '#' and contain no spaces.
    """
    if not s or ' ' in s:
        return False
    return any(s.startswith(e) for e in _ERROR_PREFIXES)


def is_com_error(value):
    """Check if a value is a COM error code (negative integer)."""
    return isinstance(value, int) and value in _COM_ERROR_CODES


def com_error_to_str(value):
    """Convert a COM error code to its Excel error string, or None."""
    return _COM_ERROR_CODES.get(value)


def has_formula_error(cell):
    """Check if a cell has a formula error.

    Handles both string error values (#DIV/0!, #REF!, etc.) and
    COM integer error codes (-2146826281, etc.) returned by win32com.
    """
    value = cell.get('value')
    if is_com_error(value):
        return True
    value_str = str(value or '')
    formula_str = str(cell.get('formula') or '')
    return _is_error_string(value_str) or _is_error_string(formula_str)


def is_large_percentage(cell):
    """Check if a cell contains a large percentage (>= 100%, hardcoded)."""
    fmt = cell.get('numberFormat') or ''
    if '%' not in fmt:
        return False
    if cell.get('formula'):
        return False
    value = cell.get('value')
    if not _is_numeric(value):
        return False
    # In spreadsheets, 1.0 = 100%
    return abs(_to_float(value)) >= 1


def categorize_cell(cell: DirtyCell):
    """Return (cell, issues_list) for a single cell dict."""
    issues = []
    if is_large_percentage(cell):
        issues.append(LARGE_PERCENTAGE)
    if is_hardcoded_number(cell):
        issues.append(HARDCODED_NUMBER)
    is_error = has_formula_error(cell)
    if is_error:
        issues.append(INVALID_FORMULA)
    # Only flag hardcoded numbers in formula when there's no error —
    # error formulas need the error fixed first, flagging "=1/0" for
    # containing hardcoded 1 and 0 is just noise.
    if not is_error and cell.get('formula') and has_hardcoded_numbers_in_formula(cell.get('formula', '')):
        issues.append(HARDCODED_NUMBER_IN_FORMULA)
    return issues


def categorize_cells(dirty_cells: list[DirtyCell]):
    """Categorize a list of dirty cell dicts into groups.

    Returns a dict with keys:
      good, large_percentages, hardcoded_numbers,
      hardcoded_in_formulas, invalid_formulas, all_problems
    Each value is a list of (cell, issues) tuples (except good which is just cells).
    """
    good = []
    large_pct = []
    hardcoded = []
    hardcoded_in_formula = []
    invalid = []
    all_problems = []

    for cell in dirty_cells:
        issues = categorize_cell(cell)
        if not issues:
            good.append(cell)
        else:
            entry = (cell, issues)
            all_problems.append(entry)
            if LARGE_PERCENTAGE in issues:
                large_pct.append(entry)
            if HARDCODED_NUMBER in issues:
                hardcoded.append(entry)
            if HARDCODED_NUMBER_IN_FORMULA in issues:
                hardcoded_in_formula.append(entry)
            if INVALID_FORMULA in issues:
                invalid.append(entry)

    return {
        GOOD: good,
        LARGE_PERCENTAGE: large_pct,
        HARDCODED_NUMBER: hardcoded,
        HARDCODED_NUMBER_IN_FORMULA: hardcoded_in_formula,
        INVALID_FORMULA: invalid,
        ALL_PROBLEMS: all_problems,
    }
