#!/usr/bin/env python3
"""Turn an inventory snapshot CSV into answers. No AI in this file; the AI wrote it.

    python3 snapshot-summary.py sample-inventory.csv                    # counts by room and by category
    python3 snapshot-summary.py sample-inventory.csv --by product       # counts by product
    python3 snapshot-summary.py sample-inventory.csv --aging 90         # packages received 90+ days ago
    python3 snapshot-summary.py sample-inventory.csv --low 10           # products with 10 or fewer units on hand
    python3 snapshot-summary.py sample-inventory.csv --diff sample-inventory-prev.csv   # what changed since another day
    python3 snapshot-summary.py --self-test                             # checks the math on the sample files

Expected columns (rename yours with --map, e.g. --map "Package Label=tag,Qty=quantity"):
    snapshot_date, tag, product, category, room, quantity, unit, received_on
Anything extra is ignored. Grams and eaches are never added together; every total carries its unit.
Version 1.0, tested 2026-09-17 with Python 3.11 on the bundled sample files. CC0 1.0.
"""
import argparse
import csv
import sys
from collections import defaultdict
from datetime import date, datetime
from pathlib import Path

REQUIRED = ["tag", "product", "room", "quantity", "unit"]


def load(path, mapping):
    with open(path, newline="", encoding="utf-8-sig") as f:
        rows = list(csv.DictReader(f))
    out = []
    for r in rows:
        row = {mapping.get(k, k).strip().lower().replace(" ", "_"): (v or "").strip() for k, v in r.items()}
        missing = [c for c in REQUIRED if c not in row]
        if missing:
            sys.exit(f"{path}: missing column(s) {missing}. Use --map 'Their Name=ours,...'")
        row["quantity"] = float(row["quantity"] or 0)
        row["unit"] = (row["unit"] or "ea").lower()
        row["tag"] = row["tag"].upper()
        out.append(row)
    return out


def totals(rows, key):
    """Sum quantity per (key, unit). Units never mix."""
    acc = defaultdict(float)
    for r in rows:
        acc[(r[key], r["unit"])] += r["quantity"]
    return acc


def print_table(title, acc):
    print(f"\n== {title} ==")
    width = max((len(k[0]) for k in acc), default=10)
    for (name, unit), qty in sorted(acc.items(), key=lambda kv: (-kv[1], kv[0])):
        q = f"{qty:,.1f}" if unit == "g" else f"{qty:,.0f}"
        print(f"{name:<{width}}  {q:>12} {unit}")


def aging(rows, days, today):
    old = []
    for r in rows:
        rec = r.get("received_on")
        if not rec:
            continue
        age = (today - datetime.strptime(rec, "%Y-%m-%d").date()).days
        if age >= days and r["quantity"] > 0:
            old.append((age, r))
    print(f"\n== Received {days}+ days ago (as of {today}) ==")
    for age, r in sorted(old, key=lambda t: -t[0]):
        print(f"{age:>4} days  {r['tag']}  {r['product']}  {r['quantity']:g} {r['unit']}  ({r['room']})")
    if not old:
        print("none")
    return old


def low_stock(rows, limit):
    acc = totals(rows, "product")
    low = {k: v for k, v in acc.items() if k[1] == "ea" and v <= limit}
    print(f"\n== Products at or under {limit} units (eaches only; bulk grams listed separately) ==")
    for (name, unit), qty in sorted(low.items(), key=lambda kv: kv[1]):
        print(f"{qty:>6.0f} {unit}  {name}")
    if not low:
        print("none")
    return low


def diff(cur, prev):
    a = {r["tag"]: r for r in prev}
    b = {r["tag"]: r for r in cur}
    gone = sorted(set(a) - set(b))
    new = sorted(set(b) - set(a))
    changed = []
    for t in sorted(set(a) & set(b)):
        d = b[t]["quantity"] - a[t]["quantity"]
        if abs(d) > 0.005:
            changed.append((t, a[t]["quantity"], b[t]["quantity"], d, b[t]["unit"], b[t]["product"]))
    print("\n== Changed since previous snapshot ==")
    for t, q0, q1, d, u, p in changed:
        print(f"{t}  {q0:g} -> {q1:g} {u}  ({d:+g})  {p}")
    print(f"\nnew tags: {len(new)}")
    for t in new:
        print(f"  + {t}  {b[t]['product']}  {b[t]['quantity']:g} {b[t]['unit']}")
    print(f"tags no longer present: {len(gone)}")
    for t in gone:
        print(f"  - {t}  {a[t]['product']}  was {a[t]['quantity']:g} {a[t]['unit']}")
    return changed, new, gone


def self_test():
    here = Path(__file__).resolve().parent
    cur = load(here / "sample-inventory.csv", {})
    prev = load(here / "sample-inventory-prev.csv", {})
    by_room = totals(cur, "room")
    assert by_room[("Sales Floor", "ea")] == 36 + 18 + 9 + 5 + 72, by_room[("Sales Floor", "ea")]
    assert by_room[("Vault B", "g")] == 2268.4 + 410.0 + 9070.0
    assert ("Vault B", "ea") not in by_room, "grams leaked into eaches"
    old = aging(cur, 90, date(2026, 9, 17))
    assert {r["tag"][-3:] for _, r in old} == {"103", "105", "108", "111", "113", "117", "118"}, [r["tag"] for _, r in old]
    low = low_stock(cur, 10)
    names = {k[0] for k in low}
    assert "Citrus Burst Gummies 100mg" in names and "Sleepy Hollow 2:1 Tincture 30ml" in names
    assert "Sour Diesel Flower 3.5g Jar" in names  # zero on hand counts as low
    assert "Blue Dream Flower 3.5g Jar" not in names
    changed, new, gone = diff(cur, prev)
    assert len(new) == 2 and new[0].endswith("114") and new[1].endswith("115")
    assert gone == ["1A4FF0100000022000000121"]
    tags = {c[0][-3:]: c[3] for c in changed}
    assert tags["101"] == -36 and tags["102"] == 12 and tags["120"] == -10
    print("\nself-test passed: room totals, unit separation, aging, low stock, diff")


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("csv", nargs="?")
    ap.add_argument("--by", choices=["room", "category", "product"], help="group totals by this column")
    ap.add_argument("--aging", type=int, metavar="DAYS")
    ap.add_argument("--low", type=float, metavar="UNITS")
    ap.add_argument("--diff", metavar="PREV_CSV")
    ap.add_argument("--map", default="", help='rename columns: "Their Name=tag,Qty=quantity"')
    ap.add_argument("--today", default=date.today().isoformat())
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.csv:
        ap.error("give a CSV, or --self-test")
    mapping = dict(p.split("=", 1) for p in a.map.split(",") if "=" in p)
    rows = load(a.csv, mapping)
    print(f"{len(rows)} packages, {sum(1 for r in rows if r['quantity'] > 0)} with stock, snapshot {rows[0].get('snapshot_date', '?') if rows else '?'}")
    if a.by:
        print_table(f"Total by {a.by}", totals(rows, a.by))
    elif not (a.aging or a.low is not None or a.diff):
        print_table("Total by room", totals(rows, "room"))
        if "category" in rows[0]:
            print_table("Total by category", totals(rows, "category"))
    if a.aging:
        aging(rows, a.aging, date.fromisoformat(a.today))
    if a.low is not None:
        low_stock(rows, a.low)
    if a.diff:
        diff(rows, load(a.diff, mapping))


if __name__ == "__main__":
    main()
