"""Pure parser + export helpers for CC pass-through expenses — a FAITHFUL
port of the legacy ``/srv/apps/work/lib/cc-expenses.ts`` code of record
(cc-expenses.md B12–B18, B31, B23, B44–B46). No I/O, no ORM.

Faithfulness is load-bearing: the M7 gate
(``tests/cc/test_parser_gate.py``) runs the 102 REAL migrated statement
lines through BOTH this module and the legacy TS via ``tsx`` and byte-
diffs the result. So the semantics are matched exactly, warts included:

  - ``parse_currency`` replicates JS ``Math.round(n*100)/100`` on IEEE-754
    doubles (Python floats ARE doubles), NOT banker's rounding — hence
    ``math.floor(x + 0.5)`` instead of ``round()``.
  - ``parse_expense_date`` keeps the legacy's loose validation (day 1–31,
    no per-month calendar check — "Feb 31" passes; B13 wart, T-CCE-D-007).
    The rebuild leaves this AS-IS so the byte-diff stays exact; the fix is
    tracked as an open consideration (docs/features/cc-expenses.md).
  - ``amount = debit ?? credit`` with no sign flip (B16).
  - partial parses keep the row so the user can fix it (B17).

Money is returned as ``Decimal`` (the column is ``numeric(12,2)``);
everything else mirrors the TS return shapes 1:1.
"""

from __future__ import annotations

import math
import re
from dataclasses import dataclass
from decimal import Decimal

#: month name -> number (full names + abbreviations, incl. sep/sept). B13.
MONTHS: dict[str, int] = {
    "jan": 1, "january": 1,
    "feb": 2, "february": 2,
    "mar": 3, "march": 3,
    "apr": 4, "april": 4,
    "may": 5,
    "jun": 6, "june": 6,
    "jul": 7, "july": 7,
    "aug": 8, "august": 8,
    "sep": 9, "sept": 9, "september": 9,
    "oct": 10, "october": 10,
    "nov": 11, "november": 11,
    "dec": 12, "december": 12,
}

_ISO_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
_LONG_RE = re.compile(r"^([A-Za-z]+)\.?\s+(\d{1,2}),?\s+(\d{4})$")


@dataclass(frozen=True)
class ParsedCcLine:
    """1:1 with the TS ``ParsedCcLine`` (amount/balance are Decimals)."""

    raw_line: str
    expense_date: str | None
    description: str | None
    amount: Decimal | None
    balance: Decimal | None


def parse_expense_date(raw: str) -> str | None:
    """Long statement format ("May 20, 2026") or ISO passthrough ->
    ``YYYY-MM-DD`` | None. B13 — loose validation carried verbatim
    (day 1–31, year 1900–2999, NO per-month calendar check)."""
    s = raw.strip()
    if not s:
        return None
    if _ISO_RE.match(s):
        return s
    long_match = _LONG_RE.match(s)
    if long_match:
        mon = MONTHS.get(long_match.group(1).lower())
        if not mon:
            return None
        day = int(long_match.group(2))
        year = int(long_match.group(3))
        if day < 1 or day > 31 or year < 1900 or year > 2999:
            return None
        return f"{year:04d}-{mon:02d}-{day:02d}"
    return None


def _js_round(x: float) -> float:
    """JS ``Math.round`` — round half toward +Infinity (NOT Python's
    round-half-even). Matches ``Math.round`` across the currency domain,
    including negatives (``Math.round(-500.5) == -500``)."""
    return math.floor(x + 0.5)


def parse_currency(raw: str) -> Decimal | None:
    """"$3,856.56" / "7.15" / "-5" -> Decimal | None. Strips a SINGLE
    leading ``$`` then ALL commas (B14); ``Number(...)`` semantics;
    non-finite -> None; rounds via JS ``Math.round(n*100)/100``. Sign
    quirk preserved: ``$-5.00`` -> -5, ``-$5.00`` -> None.

    Micro-divergence (documented, gate-irrelevant): JS ``Number`` accepts
    hex/octal/binary literals ("0x10" -> 16) that ``float`` rejects (->
    None). Currency columns never carry those; the 102-line byte-diff
    gate is unaffected."""
    s = re.sub(r"^\$", "", raw.strip()).replace(",", "")
    if not s:
        return None
    try:
        n = float(s)
    except (ValueError, OverflowError):
        return None
    if not math.isfinite(n):
        return None
    rounded = _js_round(n * 100) / 100
    # str(float) is shortest round-trip -> clean 2dp Decimals ("7.16").
    return Decimal(str(rounded))


def parse_cc_line(raw: str) -> ParsedCcLine | None:
    """One pasted line -> ParsedCcLine | None. Blank/whitespace-only ->
    None. Otherwise split on TAB, trim each column; greedy slots
    date/description/debit/credit/balance (cols[5+] ignored). Partial
    parses keep the row (B15–B17); ``amount = debit ?? credit`` (B16)."""
    if not raw.strip():
        return None
    cols = [c.strip() for c in raw.split("\t")]

    def col(i: int) -> str:
        return cols[i] if i < len(cols) else ""

    date = parse_expense_date(col(0)) if col(0) else None
    description = col(1) or None
    debit = parse_currency(col(2)) if col(2) else None
    credit = parse_currency(col(3)) if col(3) else None
    amount = debit if debit is not None else credit
    balance = parse_currency(col(4)) if col(4) else None
    return ParsedCcLine(
        raw_line=raw,
        expense_date=date,
        description=description,
        amount=amount,
        balance=balance,
    )


def parse_cc_paste(text: str) -> list[ParsedCcLine]:
    """Multi-line paste -> ParsedCcLine list. Splits on CRLF/LF (B18),
    drops blank lines, preserves order."""
    out: list[ParsedCcLine] = []
    for line in re.split(r"\r?\n", text):
        parsed = parse_cc_line(line)
        if parsed is not None:
            out.append(parsed)
    return out


def find_highlight_match(description: str | None, keywords: list[str]) -> str | None:
    """Case-insensitive substring contains; returns the FIRST keyword to
    hit in the order passed (page loads keywords ASC) or None. Null/empty
    description never matches; blank keywords skipped (B31)."""
    if not description:
        return None
    d = description.lower()
    for keyword in keywords:
        if not keyword:
            continue
        if keyword.lower() in d:
            return keyword
    return None


@dataclass(frozen=True)
class AutoRuleMatch:
    """The fields the matcher stamps onto an inserted/re-applied line."""

    id: str
    keyword: str
    project_id: str
    assignment_description: str | None
    category: str | None


def find_auto_rule_match(
    description: str | None, rules: list[AutoRuleMatch]
) -> AutoRuleMatch | None:
    """Same substring rule as highlights; returns the whole matched rule.
    First match in list order wins — the caller passes rules created_at
    DESC, so the NEWEST matching rule wins (B23)."""
    if not description:
        return None
    d = description.lower()
    for rule in rules:
        if not rule.keyword:
            continue
        if rule.keyword.lower() in d:
            return rule
    return None


@dataclass(frozen=True)
class AssignedCcLineForPaste:
    """One export row's fields — client/project carried SEPARATELY
    end-to-end (rebuild delta: kills the legacy ``' : '`` split wart)."""

    client: str
    project: str
    assignment_description: str
    amount: Decimal
    expense_date: str
    category: str


def format_cc_paste_block(
    lines: list[AssignedCcLineForPaste], *, operator_label: str = "Bowden Works"
) -> str:
    """The PlusROI paste block (B46): one row per assigned line, 7
    tab-separated columns, rows joined with ``\\n``, NO trailing newline.

        {client} : {project}\\t{desc}\\t{amount}\\t{date}\\t\\t{operator}\\t{category}

    ``amount`` is ``toFixed(2)`` (2 decimals, no ``$``). Column 6
    (``operator_label``) is a TENANT SETTING (M6 parity — no business
    literal in code); the Bowden Works default renders byte-identically
    to the legacy hard-coded ``'Bowden Works'``. Byte-diffed vs the
    legacy ``formatCcPasteBlock`` in tests/cc/test_parser_gate.py."""
    rows = []
    for line in lines:
        rows.append(
            "\t".join(
                [
                    f"{line.client} : {line.project}",
                    line.assignment_description,
                    f"{line.amount:.2f}",
                    line.expense_date,
                    "",
                    operator_label,
                    line.category,
                ]
            )
        )
    return "\n".join(rows)
