#!/usr/bin/env python3
"""Invoice Chaser free sample: late fee and reminder stage calculator. v1.0.0
Standard library only. Kitwise Studio, MIT license.

Usage:
  python3 late_fee.py --amount 2400 --due 2026-09-15 [--today 2026-10-16]
      [--monthly-rate 0.015] [--grace 0] [--flat 0] [--cap-pct 0] [--reminders-sent 0]

Money: all money is parsed from the command line text into decimal.Decimal.
  No binary floats are used for money math.
Fee: interest = amount x monthly_rate x 12 / 365 x chargeable_days (simple, no
  compounding), chargeable_days = max(0, days_overdue - grace).
  Interest is rounded once, to cents, ROUND_HALF_UP. Nothing else is rounded
  before that point. --flat is added once chargeable_days > 0. --cap-pct is a
  fraction of the amount (0.1 = 10 percent, 0 = no cap); the cap amount is
  rounded to cents ROUND_HALF_UP and the fee is limited to it.
fee_per_extra_week: the real increase in the fee over the next 7 days, found by
  running the same calculation at today + 7 and subtracting the unrounded fee
  today, then rounding once to cents. It is 0 when the invoice is not overdue
  today, when the rate is 0, and when the cap is already reached.
Stage: stage_by_days from days overdue: 0 not due, 1 (1-7), 2 (8-21), 3 (22-44),
  4 (45+). --reminders-sent N is the highest stage already sent (0-4).
  stage = min(4, max(stage_by_days, N + 1)) when overdue, else 0.
Errors: prints {"error": "...", "field": "..."} to stdout and exits with code 2.
General calculation only, not legal advice. Check your contract.
"""
import argparse
import datetime as dt
import json
import sys
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation, localcontext

VERSION = "1.0.0"
CENT = Decimal("0.01")
STAGES = [(0, "not overdue"), (1, "friendly reminder"), (8, "firm reminder"),
          (22, "final notice"), (45, "escalate")]


class InputError(Exception):
    def __init__(self, field, message):
        super().__init__(message)
        self.field = field
        self.message = message


def to_cents(x):
    return x.quantize(CENT, rounding=ROUND_HALF_UP)


def stage_by_days(days):
    if days <= 0:
        return 0
    s = 0
    for i, (d, _) in enumerate(STAGES[1:], start=1):
        if days >= d:
            s = i
    return s


def pick_stage(days, reminders_sent):
    by_days = stage_by_days(days)
    if by_days == 0:
        return by_days, 0
    return by_days, min(4, max(by_days, reminders_sent + 1))


def _raw_fee(amount, monthly_rate, grace, flat, cap, days):
    """Unrounded fee for a given number of days overdue. Returns (interest_raw, fee_raw, flat_applied, capped)."""
    chargeable = max(0, days - grace)
    interest = amount * monthly_rate * 12 / 365 * chargeable
    flat_applied = flat if chargeable > 0 else Decimal("0")
    fee = interest + flat_applied
    capped = False
    if cap is not None and fee > cap:
        fee, capped = cap, True
    return interest, fee, flat_applied, capped


def late_fee(amount, due, today, monthly_rate=Decimal("0.015"), grace=0,
             flat=Decimal("0"), cap_pct=Decimal("0"), reminders_sent=0):
    with localcontext() as ctx:
        ctx.prec = 50
        days = (today - due).days
        cap = to_cents(amount * cap_pct) if cap_pct > 0 else None
        interest_raw, fee_raw, flat_applied, capped = _raw_fee(amount, monthly_rate, grace, flat, cap, days)
        interest = to_cents(interest_raw)  # the one rounding step for interest
        fee = interest + flat_applied
        if cap is not None and fee > cap:
            fee, capped = cap, True
        if days > 0:
            _, fee_next, _, _ = _raw_fee(amount, monthly_rate, grace, flat, cap, days + 7)
            extra = to_cents(fee_next - fee_raw)
        else:
            extra = Decimal("0")
        by_days, stage = pick_stage(days, reminders_sent)
        return {
            "amount": to_cents(amount), "due": due.isoformat(), "today": today.isoformat(),
            "days_overdue": max(0, days), "chargeable_days": max(0, days - grace),
            "monthly_rate": monthly_rate, "interest": interest, "flat_fee": to_cents(flat_applied),
            "late_fee": to_cents(fee), "capped": capped, "total_due": to_cents(amount + fee),
            "stage_by_days": by_days, "reminders_sent": reminders_sent,
            "stage": stage, "stage_label": STAGES[stage][1],
            "fee_per_extra_week": extra,
        }


def _dec(field, text, allow_zero=True):
    try:
        v = Decimal(str(text).strip())
    except (InvalidOperation, ValueError):
        raise InputError(field, "not a number: %r" % text)
    if not v.is_finite():
        raise InputError(field, "must be a finite number")
    if v < 0 or (v == 0 and not allow_zero):
        raise InputError(field, "must be greater than 0" if not allow_zero else "must not be negative")
    return v


def _int(field, text, lo=0, hi=None):
    try:
        v = int(str(text).strip())
    except ValueError:
        raise InputError(field, "not a whole number: %r" % text)
    if v < lo or (hi is not None and v > hi):
        raise InputError(field, "must be between %d and %s" % (lo, hi if hi is not None else "any"))
    return v


def _date(field, text):
    try:
        return dt.date.fromisoformat(str(text).strip())
    except ValueError:
        raise InputError(field, "not a valid YYYY-MM-DD date: %r" % text)


class _Parser(argparse.ArgumentParser):
    def error(self, message):
        field = "arguments"
        for name in ("amount", "due"):
            if "--" + name in message:
                field = name
        raise InputError(field, message)


def _default(o):
    if isinstance(o, Decimal):
        # quantized Decimals with 2dp convert to the same short float repr (36.69)
        return float(o)
    raise TypeError(type(o))


def main(argv=None):
    p = _Parser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("--version", action="version", version="late_fee.py " + VERSION)
    p.add_argument("--amount", required=True)
    p.add_argument("--due", required=True)
    p.add_argument("--today")
    p.add_argument("--monthly-rate", default="0.015")
    p.add_argument("--grace", default="0")
    p.add_argument("--flat", default="0")
    p.add_argument("--cap-pct", default="0")
    p.add_argument("--reminders-sent", default="0")
    try:
        a = p.parse_args(argv)
        r = late_fee(
            amount=_dec("amount", a.amount, allow_zero=False),
            due=_date("due", a.due),
            today=_date("today", a.today) if a.today else dt.date.today(),
            monthly_rate=_dec("monthly_rate", a.monthly_rate),
            grace=_int("grace", a.grace),
            flat=_dec("flat", a.flat),
            cap_pct=_dec("cap_pct", a.cap_pct),
            reminders_sent=_int("reminders_sent", a.reminders_sent, 0, 4),
        )
    except InputError as e:
        print(json.dumps({"error": e.message, "field": e.field}))
        return 2
    print(json.dumps(r, indent=2, default=_default))
    return 0


if __name__ == "__main__":
    sys.exit(main())
