"""Completed example: the capstone's shape, reduced to one file, on real files.

Practical Workflow Automation, modules 3 to 6. This is the finished version of
starter_daily_digest.py. Read it after you have attempted the starter.

    py complete_daily_digest.py --folder .                      dry run (default)
    py complete_daily_digest.py --folder . --apply              write the digest
    py complete_daily_digest.py --folder . --apply --fail-after 3   stop part-way
    py complete_daily_digest.py --folder . --health             check for silence

It writes only inside the folder you name, it never deletes anything, and it
makes no network request.
"""

import argparse
import csv
import datetime
import json
import pathlib
import sys

REGIONS = {"NW": "north", "NE": "north", "SW": "south", "SE": "south"}
STALE_AFTER_MINUTES = 26 * 60


def read_orders(path):
    """Every row of the CSV, as dictionaries, in file order."""
    with path.open(newline="", encoding="utf-8") as handle:
        return list(csv.DictReader(handle))


def validate(row):
    """Return (record, None) or (None, reason) for one row.

    The order of the checks is a decision: a row with two problems reports the
    first one, and reordering them changes what a reader is told to fix.
    """
    try:
        units = int(row["units"])
    except (KeyError, ValueError):
        return None, f"units: {row.get('units', '')!r} is not a whole number"
    if row["region_code"] not in REGIONS:
        return None, f"region_code: {row['region_code']} is not a known region"
    if row["status"] != "confirmed":
        return None, f"status: {row['status']} orders are not counted"
    return {**row, "units": units, "group": REGIONS[row["region_code"]]}, None


def build_digest(records, counts, quarantined, source, run_at):
    """The digest lines, in a fixed order so two days can be compared.

    The reconciliation line is computed rather than asserted, which is the
    difference between evidence and decoration.
    """
    totals = {}
    for record in records:
        group = totals.setdefault(record["group"], {"orders": 0, "units": 0, "value": 0.0})
        group["orders"] += 1
        group["units"] += record["units"]
        group["value"] += record["units"] * float(record["unit_price"])

    # skipped is a fact about this attempt, not a category of record: a
    # skipped order was processed by an earlier run and is already counted in
    # accepted or quarantined. Including it here would double-count it and
    # make every resumed run report as unreconciled.
    accounted = counts["accepted"] + counts["quarantined"] + counts["pending"]
    lines = [
        "Order Digest",
        f"generated from {source} at {run_at}",
        "",
        f"read {counts['read']}, accepted {counts['accepted']}, "
        f"quarantined {counts['quarantined']}, skipped {counts['skipped']}, "
        f"pending {counts['pending']}",
        f"reconciled: {'yes' if counts['read'] == accounted else 'no'}",
        "",
    ]
    for group in sorted(totals):
        figures = totals[group]
        lines.append(
            f"{group}: {figures['orders']} orders, {figures['units']} units, "
            f"{figures['value']:.2f}"
        )
    if quarantined:
        lines.append("")
        lines.append("quarantined:")
        for order_id, reason in quarantined:
            lines.append(f"  {order_id} {reason}")
    lines.append("")
    lines.append(
        "This digest is a reporting aid produced from the file it names. It does not"
    )
    lines.append(
        "give operational, financial, legal, or compliance advice, and no decision"
    )
    lines.append("should rest on it without checking the source records.")
    return lines


def resume_from(previous, rows):
    """Return (to_process, skipped) given the previous run's record, or None.

    Resumption is by record identity, never by position: tomorrow's file has
    new rows in it, so index 3 is a different order.
    """
    done = set(previous.get("processed", [])) if previous else set()
    to_process = [row for row in rows if row["order_id"] not in done]
    return to_process, len(rows) - len(to_process)


def health(records, now):
    """Return (status, message): healthy, stale, or never succeeded.

    Only a completed run counts as a heartbeat. Counting a failed run would
    let a job that fails every night report healthy forever.
    """
    completed = [record for record in records if record.get("status") == "completed"]
    if not completed:
        return "never succeeded", "no completed run on record"
    latest = max(
        datetime.datetime.fromisoformat(record["finished_at"]) for record in completed
    )
    age = int((now - latest).total_seconds() // 60)
    if age > STALE_AFTER_MINUTES:
        return "stale", f"last success {age} minutes ago, allowed {STALE_AFTER_MINUTES}"
    return "healthy", f"last success {age} minutes ago, within {STALE_AFTER_MINUTES}"


def load_records(state_path):
    if not state_path.exists():
        return []
    return json.loads(state_path.read_text(encoding="utf-8"))["records"]


def write_atomically(path, text):
    """Write to a temporary name and rename, so an interrupted write leaves the old file."""
    temporary = path.with_suffix(path.suffix + ".tmp")
    temporary.write_text(text, encoding="utf-8")
    temporary.replace(path)


def main(argv=None):
    parser = argparse.ArgumentParser(description="Build a daily order digest.")
    parser.add_argument("--folder", required=True, help="the practice folder to work in")
    parser.add_argument("--orders", default="sample-orders.csv", help="the CSV to read")
    parser.add_argument("--apply", action="store_true", help="write the digest and the record")
    parser.add_argument("--fail-after", type=int, default=None, help="stop after N new orders")
    parser.add_argument("--health", action="store_true", help="report on past runs and exit")
    args = parser.parse_args(argv)

    folder = pathlib.Path(args.folder).resolve()
    state_path = folder / "state" / "run-records.json"
    now = datetime.datetime.now().replace(microsecond=0)

    if args.health:
        status, message = health(load_records(state_path), now)
        print(f"{status}: {message}")
        return 0 if status == "healthy" else 1

    orders_path = folder / args.orders
    if not orders_path.is_file():
        print(f"refused: required input {args.orders} is missing from {folder}")
        return 2

    rows = read_orders(orders_path)
    previous = (load_records(state_path) or [None])[-1]
    to_process, skipped = resume_from(previous, rows)

    handled_ids = list(previous.get("processed", [])) if previous else []
    new = 0
    for row in to_process:
        if args.fail_after is not None and new >= args.fail_after:
            break
        validate(row)  # a row is only "handled" once it has been decided
        # Recorded after the row was handled, and by id rather than by
        # position: tomorrow's file has new rows in it, so an index means a
        # different order.
        handled_ids.append(row["order_id"])
        new += 1

    # The digest is a statement about the day, not about this attempt, so it
    # is derived from every processed order rather than from the ones this run
    # handled. Validation is pure, so re-deciding a row costs nothing and
    # guarantees a resumed run reports the same totals as an uninterrupted one.
    processed_ids = set(handled_ids)
    accepted, quarantined = [], []
    for row in rows:
        if row["order_id"] not in processed_ids:
            continue
        record, reason = validate(row)
        if reason is None:
            accepted.append(record)
        else:
            quarantined.append((row["order_id"], reason))

    handled = len(processed_ids)
    status = "completed" if handled == len(rows) else "partial"
    counts = {
        "read": len(rows),
        "accepted": len(accepted),
        "quarantined": len(quarantined),
        "skipped": skipped,
        # Read but not attempted, because this run stopped early. Counting it
        # is what keeps a partial run's numbers reconciling: "we stopped" and
        # "a record went missing" are different facts and must not share a line.
        "pending": len(rows) - handled,
    }
    lines = build_digest(accepted, counts, quarantined, args.orders, now.isoformat())

    if not args.apply:
        print("dry run: would write these lines and change nothing.\n")
        print("\n".join(lines))
        return 0

    (folder / "output").mkdir(exist_ok=True)
    (folder / "state").mkdir(exist_ok=True)
    write_atomically(folder / "output" / "digest.txt", "\n".join(lines) + "\n")

    records = load_records(state_path)
    records.append(
        {
            "run_id": now.isoformat(),
            "status": status,
            "finished_at": now.isoformat(),
            "counts": counts,
            "processed": handled_ids,
            "reason": "" if status == "completed" else f"stopped after {new} new orders",
        }
    )
    write_atomically(state_path, json.dumps({"records": records}, indent=2))
    print(
        f"order-digest {status}: read {counts['read']}, accepted {counts['accepted']}, "
        f"quarantined {counts['quarantined']}, skipped {skipped}, pending {counts['pending']}"
    )
    return 0 if status == "completed" else 1


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