"""content-c6153-000 — FY2026 federal CONTRACT transactions whose award text matches an AI keyword, censused by
FPDS action type, with every negative (de-obligated) line and every termination line.

PRIMARY: api.usaspending.gov (the government's own award data, sourced from FPDS-NG).
  POST /api/v2/search/spending_by_transaction/   — transaction rows for the keyword + period + contract award types
  GET  /api/v2/awards/<generated_internal_id>/   — one award's own record (description, dates, potential value)
  GET  /api/v2/transactions/?award_id=...        — that award's modifications WITH action_type_description, which is
                                                   where the code meanings come from (never from memory)

⚠ WHAT THE KEYWORD FILTER ACTUALLY SELECTS: USAspending's `keywords` matches the award's own text (description,
recipient, ids), not a curated "AI program" list. So this is "contract actions whose text says artificial
intelligence / machine learning", which is a proxy, and every headline number below is reported as that.

Read-only. Run from the repository root:
  python ai_termination_census.py [--keyword "machine learning"] [--out <json>]
"""
import argparse
import collections
import json
import time
import urllib.error
import urllib.request

API = "https://api.usaspending.gov"
UA = {"Content-Type": "application/json", "User-Agent": "vibeagentmaking.com research"}
FY26 = {"start_date": "2025-10-01", "end_date": "2026-09-17"}
CONTRACTS = ["A", "B", "C", "D"]          # definitive contracts + purchase orders + delivery orders + BPA calls
FIELDS = ["Award ID", "Recipient Name", "Transaction Amount", "Action Date", "Action Type",
          "Transaction Description", "Awarding Agency", "Mod"]


def _post(path, body, tries=4):
    for i in range(tries):
        try:
            req = urllib.request.Request(API + path, data=json.dumps(body).encode("utf-8"), headers=UA)
            with urllib.request.urlopen(req, timeout=120) as r:
                return json.loads(r.read().decode("utf-8"))
        except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as e:
            if i == tries - 1:
                raise
            print("  retry %d after %s" % (i + 1, type(e).__name__))
            time.sleep(3 * (i + 1))


def _get(path, tries=4):
    for i in range(tries):
        try:
            req = urllib.request.Request(API + path, headers=UA)
            with urllib.request.urlopen(req, timeout=120) as r:
                return json.loads(r.read().decode("utf-8"))
        except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as e:
            if i == tries - 1:
                raise
            time.sleep(3 * (i + 1))


def transactions(keyword, page_limit=100):
    """Every transaction row for the keyword in FY2026. Paginated; the API caps a page at 100."""
    out, page = [], 1
    while True:
        body = {"filters": {"keywords": [keyword], "award_type_codes": CONTRACTS, "time_period": [FY26]},
                "fields": FIELDS, "sort": "Transaction Amount", "order": "asc", "limit": page_limit, "page": page}
        doc = _post("/api/v2/search/spending_by_transaction/", body)
        rows = doc.get("results") or []
        out += rows
        print("  page %d: %d rows (running %d)" % (page, len(rows), len(out)))
        if not (doc.get("page_metadata") or {}).get("hasNext") or not rows:
            return out
        page += 1
        time.sleep(0.4)


def action_type_words(rows, want, cap=6):
    """{code: the API's OWN description}, read from /api/v2/transactions/ for awards that carry that code."""
    words = {}
    for r in rows:
        code = str(r.get("Action Type") or "")
        if code not in want or code in words:
            continue
        gid = r.get("generated_internal_id")
        if not gid:
            continue
        try:
            doc = _get("/api/v2/transactions/?award_id=%s&limit=100" % urllib.parse.quote(str(gid), safe=""))
        except Exception as e:                                        # noqa: BLE001
            print("  transactions lookup failed for %s: %s" % (gid, type(e).__name__))
            continue
        for t in doc.get("results") or []:
            c = str(t.get("action_type") or "")
            d = t.get("action_type_description")
            if c and d and c not in words:
                words[c] = d
        time.sleep(0.3)
        if len(words) >= cap:
            break
    return words


def main():
    import urllib.parse                                              # noqa: F401  (used in action_type_words)
    ap = argparse.ArgumentParser()
    ap.add_argument("--keyword", default="artificial intelligence")
    ap.add_argument("--out", default="")
    a = ap.parse_args()
    print("USAspending spending_by_transaction — keyword %r, FY2026 (%s..%s), contract types %s"
          % (a.keyword, FY26["start_date"], FY26["end_date"], ",".join(CONTRACTS)))
    rows = transactions(a.keyword)
    by_type = collections.Counter()
    sum_by_type = collections.defaultdict(float)
    neg, pos = [], 0.0
    for r in rows:
        code = str(r.get("Action Type") or "(none)")
        amt = float(r.get("Transaction Amount") or 0)
        by_type[code] += 1
        sum_by_type[code] += amt
        if amt < 0:
            neg.append(r)
        else:
            pos += amt
    neg.sort(key=lambda r: float(r.get("Transaction Amount") or 0))
    print("\ntransactions: %d | obligated (positive) $%,.0f | de-obligated (negative) $%,.0f"
          .replace("%,", "%") % (len(rows), pos, sum(float(r["Transaction Amount"]) for r in neg)))
    print("by action type: code  n  net$")
    for code, n in by_type.most_common():
        print("  %-8s %4d  %15.2f" % (code, n, sum_by_type[code]))
    words = action_type_words(rows, want={c for c in by_type if c and c != "(none)"})
    print("\naction type descriptions, from the API's own transactions endpoint:")
    for c, d in sorted(words.items()):
        print("  %-4s %s" % (c, d))
    print("\nlargest de-obligations:")
    for r in neg[:15]:
        print("  %14.2f  %-16s %-32s %-26s %s %s" % (float(r["Transaction Amount"]), str(r.get("Award ID"))[:16],
              str(r.get("Recipient Name"))[:32], str(r.get("Awarding Agency"))[:26], r.get("Action Date"),
              str(r.get("Action Type"))))
    if a.out:
        with open(a.out, "w", encoding="utf-8") as fh:
            json.dump({"keyword": a.keyword, "period": FY26, "pulled_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
                       "n_transactions": len(rows), "obligated_positive": pos,
                       "deobligated_negative": sum(float(r["Transaction Amount"]) for r in neg),
                       "by_action_type": {k: {"n": v, "net": sum_by_type[k]} for k, v in by_type.items()},
                       "action_type_descriptions": words, "negatives": neg, "rows": rows}, fh, indent=1)
        print("\n-> %s" % a.out)


if __name__ == "__main__":
    main()
