"""Stripe "All Activity" paste -> a Wave-importable ledger (Date, Description,
Amount).

**Why a parser and not a CSV upload:** Stripe's activity table is copied out of
the browser, and a browser copy of that table yields ONE FIELD PER LINE, not
tab-separated columns — nine lines per transaction, in the table's column
order. So this reads a flat line stream in fixed groups of nine, exactly the
shape the clipboard produces.

**The money shape.** Stripe reports a transaction as (amount, fees, total).
Wave wants each fee as its own ledger line, so one Stripe row becomes one or
two output rows: the amount, then the fee — and the fee line is DROPPED when
there is no fee (Stripe writes an em dash) or it is zero. `total` is never
emitted: it is the sum of the two lines we already wrote, so emitting it too
would double-count.

**Signs ride through untouched.** Stripe already writes outflows negative
(payouts, fees) and inflows positive, which is what Wave expects; re-deriving
them here would be a second source of truth for a number Stripe already got
right.
"""

from __future__ import annotations

import csv
import io
import re
from dataclasses import dataclass, field
from datetime import date, timedelta
from decimal import Decimal, InvalidOperation

from app.services.errors import ServiceError

#: The activity table's columns, in the order a browser copy emits them.
FIELDS = (
    "amount",
    "fees",
    "total",
    "type",
    "balance_type",
    "description",
    "balance_transaction",
    "created",
    "available_on",
)

#: The header row, when the copy includes it (it usually does).
HEADER = (
    "amount",
    "fees",
    "total",
    "type",
    "balance type",
    "description",
    "balance transaction",
    "created",
    "available on",
)

#: Stripe writes "no value" as an em dash; en dash / hyphen accepted too.
_BLANKS = {"—", "–", "-", "", "--"}

_MONTHS = {
    "jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
    "jul": 7, "aug": 8, "sep": 9, "sept": 9, "oct": 10, "nov": 11, "dec": 12,
}

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

#: A newest-first list may legitimately sit a little in the future — Stripe
#: back-dates nothing, but clocks and timezones disagree by hours, and an
#: "Available on" style date can lead. Only a gap wider than this means the
#: paste is really from last year.
FUTURE_GRACE = timedelta(days=31)

#: type -> (how to label the amount line, how to label the fee line).
#: An unknown type falls back to the raw description and is REPORTED rather
#: than guessed at — a silently mislabelled money row is worse than a warning.
_LABELS: dict[str, tuple[str, str]] = {
    "charge": ("Stripe Charge - {description}", "Credit Card Fee"),
    "payout": ("Stripe Payout", "Stripe Fee"),
    "stripe fee": ("Stripe Fee", "Stripe Fee"),
}
_UNKNOWN_LABELS = ("{description}", "Stripe Fee")


def parse_money(raw: str | None) -> Decimal | None:
    """'US$1,000.00' -> 1000.00; '-US$29.30' -> -29.30; an em dash -> None.

    Currency SYMBOLS and codes are stripped rather than validated: the column
    is single-currency per Stripe account, and refusing an unexpected prefix
    would fail a whole paste over cosmetics."""
    if raw is None:
        return None
    text = raw.strip()
    if text in _BLANKS:
        return None
    negative = text.lstrip().startswith("-") or text.startswith("(")
    cleaned = re.sub(r"[^0-9.]", "", text)
    if not cleaned or cleaned.count(".") > 1:
        return None
    try:
        value = Decimal(cleaned)
    except InvalidOperation:
        return None
    return -value if negative else value


def parse_short_date(raw: str) -> tuple[int, int] | None:
    """'Sep 4' -> (9, 4). The YEAR is absent from Stripe's table and is
    resolved separately by `assign_years`."""
    match = _DATE_RE.match(raw.strip())
    if match is None:
        return None
    month = _MONTHS.get(match.group(1).lower())
    if month is None:
        return None
    day = int(match.group(2))
    if not 1 <= day <= 31:
        return None
    return month, day


@dataclass
class Activity:
    """One parsed Stripe row, before the fee split."""

    amount: Decimal | None
    fees: Decimal | None
    total: Decimal | None
    type: str
    balance_type: str
    description: str
    balance_transaction: str
    created_raw: str
    available_raw: str
    created: date | None = None


@dataclass
class ParseResult:
    activities: list[Activity] = field(default_factory=list)
    #: Lines left over when the paste is not a whole number of records.
    leftover_lines: int = 0
    #: Rows whose Created cell could not be read (kept, dated None).
    undated: int = 0
    #: Types with no label rule — reported so nothing is silently mislabelled.
    unknown_types: list[str] = field(default_factory=list)


def _looks_like_header(chunk: list[str]) -> bool:
    return tuple(c.strip().lower() for c in chunk) == HEADER


def parse_activity(text: str, *, today: date | None = None) -> ParseResult:
    """Flat clipboard lines -> dated `Activity` records.

    Blank lines are dropped first: the clipboard sprinkles them between table
    sections, and they carry no data. The header is skipped when present, so
    the same paste works whether or not the user grabbed the column titles.
    """
    lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
    if len(lines) >= len(HEADER) and _looks_like_header(lines[: len(HEADER)]):
        lines = lines[len(HEADER) :]

    result = ParseResult()
    count = len(lines) // len(FIELDS)
    result.leftover_lines = len(lines) - count * len(FIELDS)

    seen_unknown: set[str] = set()
    for index in range(count):
        chunk = lines[index * len(FIELDS) : (index + 1) * len(FIELDS)]
        row = dict(zip(FIELDS, chunk, strict=True))
        activity = Activity(
            amount=parse_money(row["amount"]),
            fees=parse_money(row["fees"]),
            total=parse_money(row["total"]),
            type=row["type"],
            balance_type=row["balance_type"],
            description=row["description"],
            balance_transaction=row["balance_transaction"],
            created_raw=row["created"],
            available_raw=row["available_on"],
        )
        key = activity.type.strip().lower()
        if key not in _LABELS and key not in seen_unknown:
            seen_unknown.add(key)
            result.unknown_types.append(activity.type.strip())
        result.activities.append(activity)

    assign_years(result, today=today)
    return result


def assign_years(result: ParseResult, *, today: date | None = None) -> None:
    """Stripe's table omits the year. Recover it from ORDER.

    The activity list is newest-first, so walking down it the month never
    increases — unless the list crossed a New Year, which is exactly the
    signal: month jumps UP going down the list => step back a year. The
    anchor is the current year, dropped by one if the newest row would
    otherwise land implausibly far in the future (a list pasted in January
    whose top rows are from December).
    """
    today = today or date.today()
    parsed = [parse_short_date(a.created_raw) for a in result.activities]

    year = today.year
    first = next((p for p in parsed if p is not None), None)
    if first is not None:
        candidate = _safe_date(year, first[0], first[1])
        if candidate is not None and candidate - today > FUTURE_GRACE:
            year -= 1

    previous_month: int | None = None
    for activity, part in zip(result.activities, parsed, strict=True):
        if part is None:
            result.undated += 1
            continue
        month, day = part
        if previous_month is not None and month > previous_month:
            year -= 1
        activity.created = _safe_date(year, month, day)
        if activity.created is None:
            result.undated += 1
        previous_month = month


def _safe_date(year: int, month: int, day: int) -> date | None:
    """Feb 30 and friends: a bad day number is data, not a crash."""
    try:
        return date(year, month, day)
    except ValueError:
        return None


def to_ledger_rows(result: ParseResult) -> list[dict]:
    """One Stripe row -> its amount line, then its fee line (when non-zero).

    A row we could not date is still emitted, with an empty date, so the user
    can see and fix it rather than wonder why the CSV is short.
    """
    rows: list[dict] = []
    for activity in result.activities:
        key = activity.type.strip().lower()
        amount_label, fee_label = _LABELS.get(key, _UNKNOWN_LABELS)
        stamp = activity.created.isoformat() if activity.created else ""

        if activity.amount is not None:
            rows.append(
                {
                    "date": stamp,
                    "description": amount_label.format(
                        description=activity.description
                    ),
                    "amount": f"{activity.amount:.2f}",
                }
            )
        if activity.fees is not None and activity.fees != 0:
            rows.append(
                {"date": stamp, "description": fee_label, "amount": f"{activity.fees:.2f}"}
            )
    return rows


def to_csv(rows: list[dict]) -> str:
    """Wave's import shape: Date, Description, Amount. CRLF because that is
    what `csv` emits by default and every spreadsheet accepts it."""
    buffer = io.StringIO()
    writer = csv.writer(buffer)
    writer.writerow(["Date", "Description", "Amount"])
    for row in rows:
        writer.writerow([row["date"], row["description"], row["amount"]])
    return buffer.getvalue()


def build_export(actor, text: str, *, today: date | None = None) -> dict:
    """The gated entry point the router calls: parse -> split -> CSV, plus the
    counts the page needs to show that nothing was silently dropped.

    The permission check lives HERE rather than in the router so a future
    second caller cannot reach the transform ungated (the same placement
    `services/cc` and `services/hub` use)."""
    if not actor.capabilities.get("can_transfer"):
        raise ServiceError(
            403,
            "NOT_ALLOWED",
            "Only an owner can use the Stripe importer.",
        )
    result = parse_activity(text, today=today)
    rows = to_ledger_rows(result)
    dated = [r["date"] for r in rows if r["date"]]
    return {
        "rows": rows,
        "csv": to_csv(rows),
        "stats": {
            "transactions": len(result.activities),
            "lines": len(rows),
            "leftover_lines": result.leftover_lines,
            "undated": result.undated,
            "unknown_types": result.unknown_types,
            # Surfaced so a mis-inferred year is obvious at a glance — the
            # year is DERIVED (Stripe omits it), so it must be checkable.
            "first_date": min(dated) if dated else None,
            "last_date": max(dated) if dated else None,
            "total": str(sum((Decimal(r["amount"]) for r in rows), Decimal(0))),
        },
    }
