#!/usr/bin/env python3
"""expiries.py: which dated future event in this registry has quietly become past?

Wake 0cdd47c0 (2026-10-14), pre-reading #272. That head records a vendor's own
sentence:

    Beginning October 31, 2026, Stripe MCP no longer accepts full-access secret
    keys or restricted API keys without the Agent tag.

Seventeen days out, on the headless path that entry's access section documents
as the way in. On 2026-10-31 that entry's `agentAccess` becomes wrong about the
surface it describes, and NOTHING IN THIS REGISTRY IS WATCHING:

  - `validate` checks shapes and versions, not calendars;
  - `check-links` asks whether a URL answers, and the vendor's page will answer
    200 the day after the cutoff while saying the opposite of what we quoted;
  - `fresh.py` catches a frozen page behind a live URL;
  - `drift.py` and `quoted.py` catch a page whose words changed.

A deadline needs none of those to break an entry. The page can stay byte-exact
and the entry can stay quotation-perfect, and the entry still goes false,
because the SENTENCE WAS ALWAYS ABOUT A DATE and the date arrived. That is the
one decay mode where the vendor is not the thing that changed: time is.

So this asks one question per dated span: is a date that an entry describes in
the FUTURE TENSE now in the past?

WHAT IT IS NOT. It is not a date lint. Most dates in this registry are
measurement stamps ("read 2026-10-14 at 12:27Z"), and a measurement stamp is
SUPPOSED to recede into the past -- that is what makes it evidence. Flagging
those would bury the one row that matters under two hundred that do not, which
is the failure mode of every sweep in this toolbox that I have had to narrow
later. The discriminator is the PROSE AROUND the date, not the date itself.

THREE VERDICTS:
  LAPSED    a future-tense date that is now in the past. Read it today: the
            entry is asserting something about a surface that has changed.
  DUE       a future-tense date still in the future, with the days left. A
            watchlist, not a defect.
  (silent)  a measurement stamp, a revision date, a copyright year, a
            `created`/`updated` field, a vendor's published-on date.

A LAPSED row is not automatically a defect either: an entry may say "Beginning
October 31, 2026, X" as a historical record of what the vendor announced, and
that stays true forever. The verdict means READ IT, the same way SHELL and
CHALLENGED mean "this page did not answer, do not conclude".

  python3 tools/expiries.py --clone /tmp/reg              # main
  python3 tools/expiries.py --clone /tmp/reg --ref pr272   # one tree
  python3 tools/expiries.py --selftest                    # no network, no clone
"""

import argparse
import datetime as dt
import json
import re
import subprocess
import sys

# Dates this registry writes. Two shapes, because entries carry ISO stamps and
# vendors write prose.
ISO = re.compile(r"\b(20\d{2})-(\d{2})-(\d{2})\b")
MONTHS = ("january february march april may june july august september "
          "october november december").split()
PROSE = re.compile(
    r"\b(" + "|".join(MONTHS) + r")\s+(\d{1,2}),?\s+(20\d{2})\b", re.I)

# TWO TIERS, and the reason is the first live run of this script: with one tier
# and a 90-character window it returned 200 LAPSED rows out of 398 files, which
# is not an instrument, it is a haystack. The offenders were "by ", "before "
# and "from " -- words that appear in ordinary past-tense narration beside any
# date ("kept by the promoter since 2026-09-08", "sealed by hash before the
# start ... ended void on 2026-09-10"). A cue has to GOVERN the date, not merely
# share a line with it.
#
# Tier 1, STRONG: verbs that only point forward. A line containing one of these
# is talking about a surface that stops working, wherever in the line it sits.
STRONG_CUES = (
    "no longer", "will stop", "will be removed", "will be retired",
    "sunset", "deprecat", "retiring", "shut down", "shutting down",
    "end of life", "end-of-life", "expires", "expiring", "expiry",
    "cutoff", "cut-off", "no later than", "must be replaced",
    "stops working", "stop working", "ceases", "will cease",
    # " ends " moved to TEMPORAL_CUES below: it needs the date as its object.
)

# Cues that must take the DATE as their object, within this many characters,
# because they are ordinary words about non-temporal things otherwise. First
# live run: " ends " matched "the chain ends at https://..." and "its section
# 13 ends at 61", neither of which is a deadline.
TEMPORAL_OBJECT = re.compile(
    r"\b(ends|ending|expires|expiring|closes|lapses)\s+(on\s+)?$", re.I)
# Tier 2, ADJACENT: prepositions that make a date a deadline ONLY when they
# immediately govern it. Checked in the characters right before the date.
# The cue must be the token that GOVERNS the date, which is not the same as a
# cue being nearby. "kept by the promoter since 2026-09-08" has "by" four words
# before the date and the word actually governing it is "since", which is a
# history word. So: strip filler determiners off the end of the preceding text,
# then require it to END with a cue. That also kills the substring hits that a
# character window cannot see past -- "ashby/2026-09-17" matching "by", and
# "byte for byte the one the 2026-09-17 record" matching it twice.
ADJACENT_CUES = (
    "beginning", "starting", "start", "as of", "by", "until",
    "before", "on or after", "no later than", "not after",
)
FILLER = ("the", "a", "an", "its", "their", "our", "this", "that", "of")
ADJACENT_SPAN = 40  # characters of preceding text to tokenise

# The words that mark a date as a MEASUREMENT or a RECORD, which outrank a cue.
# "read 2026-10-14" wins over the "from " in the same clause.
STAMP_CUES = (
    "read", "re-read", "reread", "measured", "re-measured", "observed",
    "dated", "is dated", "was dated",
    "probed", "fetched", "at 0", "at 1", "at 2",  # "at 12:27Z" style
    "published", "dateModified", "date_modified", "last_updated",
    "last-modified", "posted", "filed", "version ", "v1 (", "v2 (", "v3 (",
    "v4 (", "v5 (", "v6 (", "v7 (", "revision", "created", "updated",
    "copyright", "accessed", "snapshot", "archive", "transcript",
    # An "effective <date>" on a terms page is when a document STARTED binding:
    # a record, not a deadline. Found on intercom and linear in the first run.
    "effective",
    # Past tense is history, whatever preposition governs the date. This kills
    # "Before June 1, 2026, billing WAS premium request-based" and "the two
    # skill tools ... added by 2026-09-27", both of which are true forever.
    " was ", " were ", " had been ", " used to ",
    "added", "introduced", "launched", "released", "shipped", "announced",
)

WINDOW = 90  # characters either side of the date that count as "around" it


def _norm_prose(m):
    month = MONTHS.index(m.group(1).lower()) + 1
    return dt.date(int(m.group(3)), month, int(m.group(2)))


def _norm_iso(m):
    try:
        return dt.date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
    except ValueError:
        return None


def dates_in(line):
    """Every date in one line, with its span. ISO first, then prose."""
    out = []
    for m in ISO.finditer(line):
        d = _norm_iso(m)
        if d:
            out.append((d, m.start(), m.end(), m.group(0)))
    for m in PROSE.finditer(line):
        try:
            out.append((_norm_prose(m), m.start(), m.end(), m.group(0)))
        except ValueError:
            pass
    return out


def classify(line, start, end):
    """Is this date a deadline (True), a stamp (False), or neither (None)?

    A stamp cue anywhere in the window outranks a future cue, because this
    registry's house style puts the reading time right beside the date.
    """
    lo = max(0, start - WINDOW)
    hi = min(len(line), end + WINDOW)
    around = line[lo:hi].lower()
    if any(c in around for c in STAMP_CUES):
        return False
    if any(c in around for c in STRONG_CUES):
        return True
    if TEMPORAL_OBJECT.search(line[max(0, start - 24):start]):
        return True
    return governs(line[max(0, start - ADJACENT_SPAN):start]) or None


def governs(before):
    """Does the text immediately before a date end with a deadline cue?

    Filler determiners are stripped off the end first, so "by October 31" and
    "effective the 2026-10-31 release" both reach their cue, while "since" and
    "ashby/" do not become one.
    """
    words = re.findall(r"[a-z]+", before.lower())
    while words and words[-1] in FILLER:
        words.pop()
    if not words:
        return False
    for n in (3, 2, 1):  # "on or after", "as of", "by"
        if len(words) >= n and " ".join(words[-n:]) in ADJACENT_CUES:
            return True
    return False


def scan_text(path, text, today):
    rows = []
    for n, line in enumerate(text.splitlines(), 1):
        for d, s, e, raw in dates_in(line):
            if classify(line, s, e) is not True:
                continue
            delta = (d - today).days
            verdict = "LAPSED" if delta < 0 else "DUE"
            ctx = line[max(0, s - 110):min(len(line), e + 110)].strip()
            rows.append((verdict, abs(delta), path, n, raw, ctx))
    return rows


def git_files(clone, ref):
    out = subprocess.run(["git", "-C", clone, "ls-tree", "-r", "--name-only",
                          ref, "registry/"], capture_output=True, text=True,
                         check=True).stdout.split()
    return [f for f in out if f.endswith((".md", ".json"))]


def git_show(clone, ref, path):
    return subprocess.run(["git", "-C", clone, "show", f"{ref}:{path}"],
                          capture_output=True, text=True, check=True).stdout


def json_strings(obj, prefix=""):
    """Every string in a JSON document, so a cell's prose is scanned too."""
    if isinstance(obj, dict):
        for k, v in obj.items():
            yield from json_strings(v, f"{prefix}.{k}" if prefix else k)
    elif isinstance(obj, list):
        for i, v in enumerate(obj):
            yield from json_strings(v, f"{prefix}[{i}]")
    elif isinstance(obj, str):
        yield prefix, obj


SELFTEST = [
    # (text, expect_deadline_count, label)
    ("Beginning October 31, 2026, Stripe MCP no longer accepts full-access "
     "secret keys.", 1, "the row this tool was written for"),
    ("Re-read 2026-10-14 at 12:27Z: the page says the opposite.", 0,
     "a measurement stamp is not a deadline"),
    ("- v5 (2026-10-11) re-bases the Purchase quotations.", 0,
     "a revision line is not a deadline"),
    ("The vendor's changelog is dated July 21, 2026, before the read.", 0,
     "a published-on date is not a deadline"),
    ("Update API key authentication by October 31, 2026", 1,
     "'by <date>' is a deadline"),
    ("The beta endpoints are deprecated from 2027-01-01.", 1,
     "'deprecated from' is a deadline"),
    ("Measured 2026-09-23 at 12:15Z, a keyless POST answers 401.", 0,
     "a stamp with a time is not a deadline"),
    ("Support ends 2026-12-31 for the legacy path.", 1,
     "'ends' is a deadline"),
    ("Beginning 2026-10-31 the old keys stop working, read 2026-10-14.", 0,
     "a stamp cue in the window outranks a future cue (deliberate: this "
     "registry writes the reading beside the claim)"),
    ("copyright 2026-01-01", 0, "a copyright year is not a deadline"),
    ("\"created\": \"2026-09-13\",", 0, "a schema field is not a deadline"),
    ("Nothing dated here at all.", 0, "no date, no row"),
    ("The quote expires 2026-10-20 and the order is rejected after that.", 1,
     "'expires' is a deadline"),
    # Every rule below is one false positive from the first live run on main.
    ("Followed, the chain ends at https://sentry.io/auth/login/, checked "
     "2026-09-22.", 0, "'ends at' a URL is not a date ending (live run)"),
    ("Its section 13 ends at 61 and the 2026-09-29 artifact says no more.", 0,
     "'ends at' a counter is not a date ending (live run)"),
    ("The Terms of Service, effective September 8, 2026, say otherwise.", 0,
     "a terms-of-service effective date is a record (live run)"),
    ("Before June 1, 2026, billing was premium request-based.", 0,
     "past tense is history whatever governs the date (live run)"),
    ("the two skill tools getAgreementFilterSkill and getEnvelopeVsAgreement"
     "Skill added by 2026-09-27.", 0,
     "'added by <date>' is a record of what happened (live run)"),
    ("Support ends 2027-03-01 for the legacy path.", 1,
     "'ends <date>' still fires when the date IS the object"),
]


def selftest():
    today = dt.date(2026, 10, 14)
    ok = 0
    for text, want, label in SELFTEST:
        got = len(scan_text("t", text, today))
        mark = "ok  " if got == want else "FAIL"
        if got == want:
            ok += 1
        else:
            print(f"  {mark} want {want} got {got}: {label}\n       {text}")
    print(f"selftest: {ok} of {len(SELFTEST)} rules")
    # A LAPSED/DUE split must work off the clock, not off the text.
    past = scan_text("t", "Support ends 2026-01-01 for the legacy path.", today)
    future = scan_text("t", "Support ends 2027-01-01 for the legacy path.", today)
    assert past and past[0][0] == "LAPSED", past
    assert future and future[0][0] == "DUE", future
    print("  ok   LAPSED/DUE comes off the clock (2026-01-01 past, "
          "2027-01-01 future)")
    return 0 if ok == len(SELFTEST) else 1


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--clone")
    ap.add_argument("--ref", default="origin/main")
    ap.add_argument("--today", help="YYYY-MM-DD, for reproducing a past run")
    ap.add_argument("--selftest", action="store_true")
    a = ap.parse_args()

    if a.selftest:
        return selftest()
    if not a.clone:
        print("expiries.py: --clone <dir> or --selftest", file=sys.stderr)
        return 2

    today = (dt.date.fromisoformat(a.today) if a.today
             else dt.datetime.now(dt.timezone.utc).date())
    rows = []
    files = git_files(a.clone, a.ref)
    for path in files:
        text = git_show(a.clone, a.ref, path)
        if path.endswith(".json"):
            try:
                doc = json.loads(text)
            except json.JSONDecodeError:
                rows += scan_text(path, text, today)
                continue
            for cell, s in json_strings(doc):
                for v, dd, _p, _n, raw, ctx in scan_text(path, s, today):
                    rows.append((v, dd, path, cell, raw, ctx))
        else:
            rows += scan_text(path, text, today)

    lapsed = [r for r in rows if r[0] == "LAPSED"]
    due = sorted((r for r in rows if r[0] == "DUE"), key=lambda r: r[1])
    print(f"{a.ref} at {today.isoformat()}: {len(files)} registry files, "
          f"{len(lapsed)} LAPSED, {len(due)} DUE")
    for v, dd, path, where, raw, ctx in lapsed:
        print(f"\n! LAPSED {dd}d ago  {path}:{where}  [{raw}]\n    {ctx}")
    for v, dd, path, where, raw, ctx in due:
        print(f"\n  DUE in {dd}d  {path}:{where}  [{raw}]\n    {ctx}")
    if lapsed:
        print("\nA LAPSED row is not automatically a defect: an entry may "
              "record what a vendor ANNOUNCED, and that stays true. The "
              "verdict means read it today.")
    return 0


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