"""Summarize CSV expenses using Decimal, functions and dictionaries. Python 3.10+."""
from __future__ import annotations
from collections import defaultdict
from decimal import Decimal, InvalidOperation
from io import StringIO
from pathlib import Path
import csv


DATA = """category,amount
food,12.50
travel,4.25
food,8.00
books,15.00
"""


def parse_expenses(text):
    """Pre: category,amount CSV. Post: valid categories and finite nonnegative decimal amounts."""
    reader = csv.DictReader(StringIO(text))
    if reader.fieldnames != ["category", "amount"]:
        raise ValueError("CSV header must be category,amount")
    rows = []
    # Invariant: rows contains validated preceding records. Bound: remaining finite records.
    for row in reader:
        if None in row or row["category"] is None or row["amount"] is None:
            raise ValueError("every record must contain exactly two CSV fields")
        category = row["category"].strip()
        try:
            amount = Decimal(row["amount"])
        except InvalidOperation as error:
            raise ValueError("amount must be a decimal number") from error
        if not category or not amount.is_finite() or amount < 0 or amount.as_tuple().exponent < -2:
            raise ValueError("need a category and nonnegative amount with <=2 decimal places")
        rows.append((category, amount))
    return rows


def summarize(rows):
    """Pre: validated pairs. Post: exact category totals."""
    totals = defaultdict(Decimal)
    # Invariant: totals sums precisely the processed rows. Bound: remaining rows.
    for category, amount in rows:
        totals[category] += amount
    return dict(totals)


def demo(path=None):
    rows = parse_expenses(DATA if path is None else Path(path).read_text(encoding="utf-8"))
    totals = summarize(rows)
    print("Records:", len(rows))
    for category in sorted(totals):
        print(f"{category}: USD {totals[category]:.2f}")
    print(f"Total: USD {sum(totals.values(), Decimal(0)):.2f}")
    print("Try another CSV in the REPL: summarize(parse_expenses(csv_text))")


def self_test():
    check(summarize(parse_expenses(DATA)),
          {"food": Decimal("20.50"), "travel": Decimal("4.25"), "books": Decimal("15.00")})
    check(summarize([]), {})
    check(summarize([("a", Decimal(".1")), ("a", Decimal(".2"))]), {"a": Decimal(".3")})
    shared = [1]
    tuple_with_list = (shared,)
    shared.append(2)
    check(tuple_with_list, ([1, 2],))
    values = [1, 2]
    check(values.pop(), 2)
    check(values, [1])
    return 6

def check(actual, expected):
    """Require an expected result; this check also runs under python -O."""
    if actual != expected:
        raise AssertionError(f"expected {expected!r}, got {actual!r}")


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--self-test", action="store_true")
    parser.add_argument("--csv", help="read a category,amount CSV instead of the embedded sample")
    args = parser.parse_args()
    if args.self_test:
        print(f"{self_test()} checks passed")
    else:
        demo(args.csv)
