#!/usr/bin/env python
"""Turn certs.csv into results.md. The verdict rules are README.md's, not this file's.

    python analyse.py --certs ~/data/tls-issuers-certs.csv --out results.md
"""

import argparse
import collections
import csv
import datetime as dt
import json
import statistics

# SC-081v3: the first step of the schedule. A certificate issued on or after
# this date may not be valid for more than 200 days.
CAP_FROM = dt.date(2026, 3, 15)
CAP_DAYS = 200


def load(path):
    with open(path, newline="") as fh:
        return list(csv.DictReader(fh))


def families(path):
    with open(path) as fh:
        return json.load(fh)["families"]


def fam_of(row, table):
    hit = table.get(row["issuer_org"])
    if hit:
        return hit["family"], hit["access"]
    return (row["issuer_org"] or "(no organisation)"), "unmapped"


def share_table(rows, table, key):
    counts = collections.Counter()
    for r in rows:
        counts[fam_of(r, table)[{"family": 0, "access": 1}[key]]] += 1
    return counts


SERVER_FAMILIES = ("cloudflare", "cloudfront", "amazons3", "awselb", "akamai",
                   "fastly", "varnish", "openresty", "nginx", "apache",
                   "microsoft-iis", "litespeed", "tengine", "caddy", "envoy",
                   "gse", "gws", "sffe", "esf", "bigip", "kestrel", "qrator")


def _server_family(raw):
    """The `server` header folded to a family by substring, in a fixed order."""
    low = (raw or "").lower()
    for name in SERVER_FAMILIES:
        if name in low:
            return name
    return low.strip() or "(none)"


def pct(n, d):
    return f"{100.0 * n / d:.1f} %" if d else "—"


def block(rows, table, label, out):
    n = len(rows)
    out.append(f"### {label} — {n} certificates\n")
    out.append("| issuer | certificates | share |")
    out.append("| --- | --: | --: |")
    for fam, k in share_table(rows, table, "family").most_common():
        out.append(f"| {fam} | {k} | {pct(k, n)} |")
    out.append("")
    out.append("| how it is obtained | certificates | share |")
    out.append("| --- | --: | --: |")
    for acc, k in share_table(rows, table, "access").most_common():
        out.append(f"| {acc} | {k} | {pct(k, n)} |")
    out.append("")


def lifetimes(rows, out):
    days = sorted(int(r["lifetime_days"]) for r in rows if r["lifetime_days"])
    n = len(days)
    q = statistics.quantiles(days, n=4)
    out.append(f"- **n** {n}; **median** {statistics.median(days):.0f} days; "
               f"quartiles {q[0]:.0f} / {q[1]:.0f} / {q[2]:.0f}; "
               f"min {days[0]}, max {days[-1]}")
    for edge in (47, 90, 100, 200, 366):
        k = sum(1 for d in days if d <= edge)
        out.append(f"- at or under **{edge} days**: {k} ({pct(k, n)})")
    out.append("")
    buckets = collections.Counter()
    for d in days:
        for lo, hi, name in ((0, 47, "≤ 47"), (48, 90, "48–90"), (91, 100, "91–100"),
                             (101, 200, "101–200"), (201, 400, "201–400"),
                             (401, 10 ** 6, "> 400")):
            if lo <= d <= hi:
                buckets[name] += 1
                break
    out.append("| lifetime | certificates | share |")
    out.append("| --- | --: | --: |")
    for name in ("≤ 47", "48–90", "91–100", "101–200", "201–400", "> 400"):
        out.append(f"| {name} days | {buckets[name]} | {pct(buckets[name], n)} |")
    out.append("")


def main(argv=None):
    ap = argparse.ArgumentParser()
    ap.add_argument("--certs", required=True)
    ap.add_argument("--families", default="families.json")
    ap.add_argument("--out", required=True)
    ap.add_argument("--servers", default=None,
                    help="serverhdr.py output; adds the unregistered section")
    args = ap.parse_args(argv)

    allrows = load(args.certs)
    table = families(args.families)
    ok = [r for r in allrows if r["ok"] == "1"]
    bad = [r for r in allrows if r["ok"] != "1"]
    no_dns = [r for r in bad if "gaierror" in r["error"]]

    out = [f"# Results — {len(ok)} certificates from the Tranco top {len(allrows)}",
           "",
           f"Scan run {dt.datetime.now(dt.timezone.utc).date().isoformat()} from the "
           "VPS that serves untilnextsession.com. Method and verdict rules: "
           "`README.md`, written first.", ""]

    out.append("## Reachability, and the kill rule")
    out.append("")
    out.append(f"- handshake completed: **{len(ok)} of {len(allrows)}** "
               f"({pct(len(ok), len(allrows))})")
    out.append(f"- **no address at the apex**: {len(no_dns)} — the domain has no A or "
               "AAAA record of its own. These are infrastructure zones "
               "(`akamaiedge.net`, `cloudfront.net`, `akadns.net`, `apple-dns.net`, "
               "`trafficmanager.net`, …) that rank on the strength of their "
               "subdomains. They are not websites and there is nothing to hand me.")
    others = collections.Counter(r["error"].split(":")[0] for r in bad
                                 if "gaierror" not in r["error"])
    out.append(f"- other failures: {len(bad) - len(no_dns)} — "
               + ", ".join(f"{k} {v}" for k, v in others.most_common()))
    out.append("")
    if len(ok) < 800:
        out.append(f"**The kill rule fired.** Fewer than 800 of the 1,000 completed a "
                   f"handshake, so nothing below is a share of the top 1,000: every "
                   f"figure is a share of the **{len(ok)}** domains in the top 1,000 "
                   f"that actually serve TLS at their own apex. Said once here so it "
                   f"does not have to be repeated in every row.")
        out.append("")

    out.append("## Who issued them")
    out.append("")
    block(ok, table, "The reachable top 1,000", out)
    top100 = [r for r in ok if int(r["rank"]) <= 100]
    block(top100, table, "The reachable top 100", out)

    out.append("## How long they live")
    out.append("")
    out.append("Lifetime is `notAfter − notBefore` in **whole days**. Many CAs "
               "backdate `notBefore` by about an hour, so a certificate sold as "
               "90 days measures 89 here and one sold as 47 measures 46. Every "
               "boundary below is therefore soft by one day, in one direction, and "
               "the round numbers a CA advertises sit one above what this counts.")
    out.append("")
    out.append("### All reachable")
    out.append("")
    lifetimes(ok, out)
    fresh = [r for r in ok if r["not_before"] and
             dt.date.fromisoformat(r["not_before"]) >= CAP_FROM]
    over = [r for r in fresh if int(r["lifetime_days"]) > CAP_DAYS]
    out.append(f"### Against the 200-day cap (SC-081v3, in force {CAP_FROM.isoformat()})")
    out.append("")
    out.append(f"- issued on or after {CAP_FROM.isoformat()}: **{len(fresh)}** of "
               f"{len(ok)} ({pct(len(fresh), len(ok))})")
    out.append(f"- of those, valid for more than {CAP_DAYS} days: **{len(over)}** "
               f"({pct(len(over), len(fresh))})")
    if over:
        out.append("")
        out.append("| domain | issuer | issued | expires | days |")
        out.append("| --- | --- | --- | --- | --: |")
        for r in sorted(over, key=lambda r: -int(r["lifetime_days"]))[:25]:
            out.append(f"| {r['domain']} | {r['issuer_org']} | {r['not_before']} | "
                       f"{r['not_after']} | {r['lifetime_days']} |")
    out.append("")
    older = [r for r in ok if r["not_before"] and
             dt.date.fromisoformat(r["not_before"]) < CAP_FROM]
    long_old = [r for r in older if int(r["lifetime_days"]) > CAP_DAYS]
    out.append(f"Issued before the cap: {len(older)}, of which {len(long_old)} run "
               f"longer than {CAP_DAYS} days. Those are not violations — they were "
               "legal when they were signed — and they are what the cap replaces.")
    out.append("")

    out.append("## Keys, TLS and trust")
    out.append("")
    keys = collections.Counter(f"{r['key_alg']} {r['key_bits']}" for r in ok)
    out.append("| key | certificates | share |")
    out.append("| --- | --: | --: |")
    for k, v in keys.most_common():
        out.append(f"| {k} | {v} | {pct(v, len(ok))} |")
    out.append("")
    tls = collections.Counter(r["tls"] for r in ok)
    out.append("| negotiated | connections | share |")
    out.append("| --- | --: | --: |")
    for k, v in tls.most_common():
        out.append(f"| {k or '(none)'} | {v} | {pct(v, len(ok))} |")
    out.append("")
    unverified = [r for r in ok if r["verified"] == "0"]
    out.append(f"Would not verify against this machine's trust store with the apex as "
               f"the hostname: **{len(unverified)}** ({pct(len(unverified), len(ok))}).")
    if unverified:
        out.append("")
        out.append("| domain | why |")
        out.append("| --- | --- |")
        for r in sorted(unverified, key=lambda r: int(r["rank"]))[:30]:
            out.append(f"| {r['domain']} | {r['verify_error'][:90]} |")
    out.append("")

    if args.servers:
        out.append("## Unregistered: who is terminating the TLS")
        out.append("")
        out.append("**Not in the plan.** This was added after the issuer counts came "
                   "back, because the second-largest issuer is a certificate "
                   "authority most of those sites have never dealt with, and the "
                   "obvious explanation is measurable rather than guessable. One "
                   "request per domain, `server` header taken verbatim and folded "
                   "into families by substring (`serverhdr.py`). A stripped or "
                   "absent header is `(none)` and is not evidence of anything.")
        out.append("")
        srv = {}
        with open(args.servers, newline="") as fh:
            for r in csv.DictReader(fh):
                srv[r["domain"]] = _server_family(r["server"])
        seen = [r for r in ok if r["domain"] in srv]
        edges = collections.Counter(srv[r["domain"]] for r in seen)
        out.append("| server header | domains | share |")
        out.append("| --- | --: | --: |")
        for k, v in edges.most_common(12):
            out.append(f"| {k} | {v} | {pct(v, len(seen))} |")
        out.append("")
        out.append("| issuer | the edges its certificates sit behind |")
        out.append("| --- | --- |")
        tab = collections.defaultdict(collections.Counter)
        for r in seen:
            tab[fam_of(r, table)[0]][srv[r["domain"]]] += 1
        for fam, _ in share_table(seen, table, "family").most_common(8):
            top = ", ".join(f"{k} {v}" for k, v in tab[fam].most_common(4))
            out.append(f"| {fam} | {top} |")
        out.append("")

    with open(args.out, "w") as fh:
        fh.write("\n".join(out) + "\n")
    print(f"wrote {args.out}: {len(ok)} certificates")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
