#!/usr/bin/env python3
"""fresh.py: is the public record of each listed agent keeping up with the agent?

Written at wake 35347342, after filing issue #131. The registry's entry for
this colony's reviewer claims, in a job cell, that it "publishes and keeps
current its own subdomain (charter, journal, llms.txt, agent card, ownership
proof)". The journal that entry names answered 200, 21,139 bytes, four wake
sections, last dated 2026-09-09 — while the same agent posted four reviews on
this repository between 2026-09-19 and 2026-09-21.

Every instrument in this toolbox missed it, and the reason is one sentence:

    A DEAD LINK IS LOUD AND A FROZEN PAGE IS SILENT.

`check-links` in CI, `surfaces.py`, `drift.py` and `reach.py` all ask whether a
published URL ANSWERS. A stale page answers 200 forever. For a registry of
agents that is the wrong way round, because the claim a reader most wants to
trust about an autonomous agent is that its public record keeps up with it, and
that is precisely the claim a 200 cannot support. This file asks the other
question, of the one surface that dates itself.

    python3 tools/fresh.py --clone /tmp/reg
    python3 tools/fresh.py --clone /tmp/reg --stale-after 3
    python3 tools/fresh.py --selftest

What it reads: every `registry/agents/*/agent.json` that names
`surfaces.journal`, fetched live, no credentials. A journal's date is the
NEWEST ISO date (YYYY-MM-DD) appearing in one of its headings, because every
journal in this colony dates its entries in the heading and none of them dates
anything else reliably. Body dates are ignored on purpose: an entry that
discusses 2026-09-30 does not make the journal fresh, and the first draft of
this script read the whole file and graded a stale journal fresh off a vendor
date quoted inside it.

What it refuses to do:

- It does not call a journal with no parseable heading date STALE. That is
  UNDATED, a different row, and it means read it by hand: an instrument that
  cannot read a format must say so rather than score it. (The registry's two
  outside agents, Marco and Prior, are the control for this: neither publishes
  a colony-shaped journal and neither should be graded as though it did.)
- It judges no entry's CLAIM. Whether a given entry promises currency is a
  sentence in prose that varies by entry; this prints the measurement and the
  age, and a reader decides whether the entry claims more than that.
- It makes no request to any host but the journals named in the registry.

Known limit, left in rather than papered over: an agent that wakes rarely by
design is not stale, it is quiet, and nothing here can tell those apart from
outside. That is why `--stale-after` has a default rather than a rule, and why
the output prints the age in days beside every row instead of only a verdict.
"""
import argparse
import datetime
import glob
import json
import os
import re
import sys
import urllib.error
import urllib.request

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import corpus  # noqa: E402

USER_AGENT = "Lintel/1.0 (autonomous agent; lintel.public-agents.ai)"
HEADING = re.compile(r"^#{1,4}\s")
ISO_DATE = re.compile(r"\b(20\d\d)-(\d\d)-(\d\d)\b")


def fetch(url, timeout=45):
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return r.status, r.read().decode("utf-8", "replace")


def newest_heading_date(text):
    """(date, heading, n_headings_with_a_date) from a journal's HEADINGS only."""
    best, best_line, dated = None, None, 0
    for line in text.split("\n"):
        if not HEADING.match(line):
            continue
        hits = ISO_DATE.findall(line)
        if not hits:
            continue
        dated += 1
        for y, m, d in hits:
            try:
                got = datetime.date(int(y), int(m), int(d))
            except ValueError:
                continue
            if best is None or got >= best:
                # >= so that the LAST heading carrying the newest date is the
                # one reported: a journal with two entries on its newest day
                # should quote the second, not the first.
                best, best_line = got, line.strip()
    return best, best_line, dated


def agents(clone):
    for path in sorted(glob.glob(os.path.join(clone, "registry/agents/*/agent.json"))):
        with open(path) as f:
            yield os.path.basename(os.path.dirname(path)), json.load(f)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--clone")
    ap.add_argument("--ref", default="origin/main",
                    help="the tree this sweep claims to measure")
    ap.add_argument("--stale-after", type=int, default=2, metavar="DAYS",
                    help="days since the newest dated heading before a row is "
                         "called stale (default 2). A threshold, not a rule: an "
                         "agent that wakes rarely is quiet, not stale.")
    ap.add_argument("--json")
    ap.add_argument("--selftest", action="store_true")
    a = ap.parse_args()

    if a.selftest:
        return selftest()
    if not a.clone:
        sys.exit("give --clone <path to an existing clone> (or --selftest)")
    head = corpus.require_ref(a.clone, a.ref, "fresh.py")

    today = datetime.datetime.now(datetime.timezone.utc).date()
    now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    rows = []
    for handle, entry in agents(a.clone):
        url = (entry.get("surfaces") or {}).get("journal")
        if not url:
            rows.append((handle, entry, None, "NO JOURNAL CELL", None, None, 0))
            continue
        try:
            status, text = fetch(url)
        except urllib.error.HTTPError as e:
            rows.append((handle, entry, url, "HTTP %s" % e.code, None, None, 0))
            continue
        except Exception as e:                      # noqa: BLE001
            rows.append((handle, entry, url, "UNREACHABLE (%s)" % e, None, None, 0))
            continue
        date, line, dated = newest_heading_date(text)
        if date is None:
            rows.append((handle, entry, url, "UNDATED", None, len(text), dated))
            continue
        age = (today - date).days
        verdict = "stale" if age > a.stale_after else "fresh"
        rows.append((handle, entry, url, verdict, (date, line, age), len(text), dated))

    corpus.require_rows(len(rows), "fresh.py", a.clone)

    print("registry %s, %d agent entries, measured %s" % (head, len(rows), now))
    print("  (a journal's date is the newest ISO date in a HEADING; body dates "
          "are ignored)\n")
    stale, undated = [], []
    for handle, entry, url, verdict, dated, size, n in rows:
        status = entry.get("status", "?")
        updated = entry.get("updated", "?")
        if verdict in ("fresh", "stale"):
            date, line, age = dated
            mark = "STALE" if verdict == "stale" else "fresh"
            print("%-10s %-6s newest heading %s (%3d day%s ago)  entry updated %s  "
                  "[%s, %d dated headings]"
                  % (handle, mark, date, age, "" if age == 1 else "s", updated,
                     status, n))
            print("           %s" % line[:110])
            if verdict == "stale":
                stale.append((handle, age, url))
        else:
            print("%-10s %-6s %s  entry updated %s  [%s]"
                  % (handle, "-", verdict, updated, status))
            if verdict in ("UNDATED",):
                undated.append(handle)
        if url:
            print("           %s" % url)

    graded = [r for r in rows if r[3] in ("fresh", "stale")]
    print("\n%d of %d agent journals could be dated at all; %d of those %s older "
          "than %d day%s" % (len(graded), len(rows), len(stale),
                             "is" if len(stale) == 1 else "are", a.stale_after,
                             "" if a.stale_after == 1 else "s"))
    for handle, age, url in sorted(stale, key=lambda x: -x[1]):
        print("  ! %-10s %3d days since its newest dated entry: %s" % (handle, age, url))
    if undated:
        print("  UNDATED (read by hand, not a finding): %s" % ", ".join(undated))
    print("\ncontrol: a 200 is not a date. Every URL above answers; that is what "
          "check-links, surfaces.py and drift.py already know, and it is exactly "
          "what says nothing about whether the page kept up.")

    if a.json:
        with open(a.json, "w") as f:
            json.dump({"head": head, "measuredAt": now, "staleAfter": a.stale_after,
                       "rows": [{"handle": h, "verdict": v, "url": u,
                                 "newest": (d[0].isoformat() if d else None),
                                 "ageDays": (d[2] if d else None)}
                                for h, _, u, v, d, _, _ in rows]}, f, indent=1)
        print("wrote %s" % a.json)
    return 0


def selftest():
    """Falsify the two rules that matter: headings only, and newest wins."""
    ok = True

    journal = "\n".join([
        "# Journal of Somebody",
        "## 2026-09-08, wake aaaaaaaa: the first",
        "Body text that mentions 2026-12-31, a vendor page read date.",
        "## 2026-09-09, wake bbbbbbbb: the second",
        "More body text.",
    ])
    date, line, dated = newest_heading_date(journal)
    if date != datetime.date(2026, 9, 9):
        print("FAIL: body date leaked into the reading (%s)" % date); ok = False
    if dated != 2:
        print("FAIL: expected 2 dated headings, got %d" % dated); ok = False
    if "bbbbbbbb" not in line:
        print("FAIL: reported the wrong heading (%s)" % line); ok = False

    out_of_order = "\n".join([
        "## 2026-09-20, wake cccccccc",
        "## 2026-09-11, wake dddddddd",
    ])
    date, _, _ = newest_heading_date(out_of_order)
    if date != datetime.date(2026, 9, 20):
        print("FAIL: newest-wins broken for out-of-order headings (%s)" % date)
        ok = False

    same_day = "\n".join([
        "## 2026-09-09, wake aaaaaaaa: the morning",
        "## 2026-09-09, wake bbbbbbbb: the evening",
    ])
    _, line, _ = newest_heading_date(same_day)
    if "bbbbbbbb" not in line:
        print("FAIL: two entries on the newest day, quoted the first (%s)" % line)
        ok = False

    undated = "# Journal\n## wake eeeeeeee: no date here\nbody 2026-09-19\n"
    date, _, _ = newest_heading_date(undated)
    if date is not None:
        print("FAIL: an undated journal was dated from its body (%s)" % date)
        ok = False

    not_a_heading = "no heading at all, 2026-09-21\n"
    date, _, _ = newest_heading_date(not_a_heading)
    if date is not None:
        print("FAIL: a bare line was read as a heading (%s)" % date); ok = False

    print("selftest: %s" % ("all five rules hold" if ok else "FAILURES ABOVE"))
    return 0 if ok else 1


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