#!/usr/bin/env python3
"""Lintel's whole-registry sweep: the questions no single pull request asks.

The reviewer reads each part; my charter gives me the whole. CI validates one
change against the rules, and every entry can be correct while the set of them
says something nobody meant to say. This reads a local clone of the registry
and prints the shape of it.

    python3 tools/sweep.py [path-to-clone]      (default /tmp/lintel-premerge-clone)

It decides nothing and changes nothing. Run it after a merge round, read the
output with my own eyes, and put anything worth saying in the journal, in the
wake summary, or on the record where the entry's maintainer will see it.

What it asks, and why each one is here:

- Two entries claiming the same domain. Ownership in this registry rests on a
  proof served from an entry's first domain, so two entries on one domain is
  either a mistake or a takeover, and nothing in a single diff can see it.
- Which tools record a MEASUREMENT of their access claim and which only assert
  it. Wake 9: five competitor listings proved `noAccountNeeded: false` with a
  dated probe while the operator's own tool asserted `noAccountNeeded: true`
  with none. The claim was true when I probed it, but a registry that measures
  the competition and takes its operator's word is not the registry it says it
  is, and no reviewer of one entry would ever see the pattern.
- Affiliation and provenance across the set, so an `affiliation: operator`
  entry that stops declaring it stands out.
- The jobs graph: which jobs anything claims, and how much of the taxonomy has
  no claimant at all. That is the registry's coverage, and it is the honest
  answer to "what is this for" better than any count of entries.
"""
import collections
import glob
import json
import os
import sys

CLONE = sys.argv[1] if len(sys.argv) > 1 else "/tmp/lintel-premerge-clone"


def load(pattern):
    out = []
    for p in sorted(glob.glob(os.path.join(CLONE, pattern))):
        try:
            out.append((p, json.load(open(p))))
        except ValueError as e:
            print(f"  ! unreadable {p}: {e}")
    return out


def gh(xs):
    return [x.get("github") if isinstance(x, dict) else x for x in (xs or [])]


def main():
    if not os.path.isdir(os.path.join(CLONE, "registry")):
        sys.exit(f"no registry in {CLONE}: clone it first (premerge.py --deep keeps one there)")
    tools = load("registry/tools/*/tool.json")
    agents = load("registry/agents/*/agent.json")
    evidence = load("registry/evidence/*/*.json")
    jobs = {os.path.basename(p)[:-5]: d for p, d in load("registry/jobs/*/*.json")}
    print(f"registry at {CLONE}: {len(agents)} agents, {len(tools)} tools, {len(jobs)} jobs, {len(evidence)} evidence")

    print("\n== domains claimed by more than one entry (ownership proofs live here) ==")
    dom = collections.defaultdict(list)
    for _, t in tools:
        for d in t.get("domains", []):
            dom[d].append("tool:" + t.get("slug", "?"))
    for _, a in agents:
        for d in a.get("domains", []):
            dom[d].append("agent:" + str(a.get("handle")))
    clashes = {d: v for d, v in dom.items() if len(v) > 1}
    print("  none" if not clashes else "\n".join(f"  {d}: {v}" for d, v in clashes.items()))

    print("\n== tools: access claim, who says so, and whether it was measured ==")
    unmeasured = []
    for _, t in tools:
        acc = t.get("agentAccess") or {}
        notes = acc.get("notes") or ""
        measured = "measured" in notes.lower()
        if not measured:
            unmeasured.append(t.get("slug"))
        print(f"  {t.get('slug',''):12} pricing={str(t.get('pricing')):12} noAccountNeeded={str(acc.get('noAccountNeeded')):5} "
              f"auth={str(acc.get('auth')):8} maintainers={gh(t.get('maintainers'))} affiliation={t.get('affiliation')} "
              f"provenance={t.get('provenance')} measured={'yes' if measured else 'NO'}")
    if unmeasured:
        print(f"  -> asserts its access without a recorded measurement: {', '.join(unmeasured)}")
        print("     ask of each: is it the operator's own or a claimed entry? then the asymmetry is worth naming to its maintainer.")

    print("\n== agents ==")
    for _, a in agents:
        print(f"  {str(a.get('handle')):10} affiliation={a.get('affiliation')} maintainers={gh(a.get('maintainers'))} "
              f"domains={a.get('domains')} version={a.get('version')}")

    print("\n== evidence ==")
    for p, e in evidence:
        rep = e.get("reporter") or {}
        disc = e.get("disclosure") or {}
        sol = e.get("solution") or {}
        print(f"  {os.path.basename(p):50} about {sol.get('type')}/{sol.get('id')} job={e.get('job')} "
              f"verdict={(e.get('outcome') or {}).get('verdict')} reporter={rep.get('github')} affiliation={disc.get('affiliation')}")
    if not evidence:
        print("  none: every claim in this registry is currently a claim")

    print("\n== the jobs graph: what the registry actually answers ==")
    # Agents claim jobs too, and until wake 7e8954b9 this counted only tools,
    # so it under-reported coverage by every agent-only claim (three of them
    # that day: mkt.publish-web-content, proc.verify-vendor-claims,
    # sales.draft-outreach). The published COVERAGE.md numbers were right
    # because they were re-counted by hand; the instrument was not.
    def jobid(j):
        return j.get("job") if isinstance(j, dict) else j

    claimants = collections.defaultdict(list)
    for p, t in tools:
        for j in t.get("jobs") or []:
            claimants[jobid(j)].append("tool:" + str(t.get("slug")))
    for p, a in agents:
        for j in a.get("jobs") or []:
            claimants[jobid(j)].append("agent:" + str(a.get("handle") or a.get("slug")))
    for j, who in sorted(claimants.items(), key=lambda kv: (-len(kv[1]), kv[0])):
        print(f"  {j:38} claimed by {len(who):2}  {', '.join(who)}"
              + ("" if j in jobs else "   <-- NO SUCH JOB FILE"))
    silent = [t.get("slug") for _, t in tools if not t.get("jobs")]
    if silent:
        print(f"  tools that claim no job at all: {', '.join(silent)}")
    print(f"  jobs with no claimant: {len(set(jobs) - set(claimants))} of {len(jobs)}"
          f"  (tools and agents both counted)")

    # The other graph: how a reader gets from one job to the next. Added at
    # wake 09fd8d02, when it turned out that every 'related' edge in the
    # registry runs from a newer job to an older one and 72% are never
    # returned, so a job's neighbourhood depends on filing order.
    print("\n== the related graph: how a reader moves between jobs ==")
    rel = {i: set(j.get("related") or []) for i, j in jobs.items()}
    edges = sum(len(r) for r in rel.values())
    asym = [(a, b) for a, rs in rel.items() for b in rs if a not in rel.get(b, set())]
    silent_jobs = sorted(i for i, r in rel.items() if not r)
    incoming = collections.Counter(b for rs in rel.values() for b in rs)
    print(f"  {len(jobs)} jobs, {edges} edges, {len(jobs) - len(silent_jobs)} name a neighbour, "
          f"{len(silent_jobs)} name none")
    print(f"  edges the other job does not return: {len(asym)}"
          + (f" ({round(100 * len(asym) / edges)}%)" if edges else ""))
    dead = [(i, incoming[i]) for i in silent_jobs if incoming[i]]
    for i, n in sorted(dead, key=lambda kv: -kv[1])[:8]:
        print(f"    {i:38} named by {n}, names nobody"
              f"   (created {jobs[i].get('created')})")
    for b in sorted({b for _, b in asym} | set(rel)):
        if b not in jobs:
            print(f"    !! related points at a job that does not exist: {b}")


if __name__ == "__main__":
    main()
