"""Answer the README's question from the fetched JSON. Writes results.md and monthly.csv.

    python research/wikipedia-traffic/fetch.py      # once
    python research/wikipedia-traffic/analyse.py

Every number in results.md and in the journal entry comes from here.
"""

import csv
import json
import os
import statistics
import sys

RAW = os.path.expanduser("~/data/wikipedia-traffic")
HERE = os.path.dirname(os.path.abspath(__file__))
BN = 1e9
M = 1e6


def series(project, access, agent):
    """{'YYYY-MM': views} from one fetched file."""
    path = os.path.join(RAW, "%s__%s__%s.json" % (project, access, agent))
    with open(path) as fh:
        data = json.load(fh)
    out = {}
    for item in data["items"]:
        stamp = item["timestamp"]                       # YYYYMMDDHH
        key = "%s-%s" % (stamp[:4], stamp[4:6])
        out[key] = int(item.get("views", item.get("devices", 0)))
    return out


def devices(site):
    path = os.path.join(RAW, "unique-devices__%s.json" % site)
    with open(path) as fh:
        data = json.load(fh)
    return {"%s-%s" % (i["timestamp"][:4], i["timestamp"][4:6]): int(i["devices"])
            for i in data["items"]}


def prev(month, years=1):
    return "%d-%s" % (int(month[:4]) - years, month[5:])


def yoy(s, month):
    p = prev(month)
    if p not in s or month not in s or not s[p]:
        return None
    return s[month] / s[p] - 1.0


def w(s, months):
    return sum(s.get(m, 0) for m in months)


def change(s, t12, p12):
    a, b = w(s, t12), w(s, p12)
    return (a / b - 1.0) if b else None


def tail_negative_run(s, floor="2016-07"):
    """The unbroken run of negative year-over-year months ending at the last month."""
    run = []
    for m in reversed([m for m in sorted(s) if m >= floor]):
        v = yoy(s, m)
        if v is None or v >= 0:
            break
        run.append(m)
    return list(reversed(run))


def country_noise():
    """Median absolute year-over-year change per country, from country.py's table.

    Empty when country.csv has not been generated. Section 8 is produced here
    rather than appended by hand, because this file writes results.md from
    scratch: anything added to that file by hand is destroyed by the next run.
    """
    path = os.path.join(HERE, "country.csv")
    if not os.path.exists(path):
        return {}
    with open(path) as fh:
        rows = list(csv.DictReader(fh))
    by_month = {r["month"]: r for r in rows}
    out = {}
    for country in [c for c in rows[0] if c != "month"]:
        changes = []
        for month in by_month:
            a = by_month.get(prev(month), {}).get(country)
            b = by_month[month].get(country)
            if a and b and float(a):
                changes.append(abs(float(b) / float(a) - 1.0))
        if changes:
            out[country] = statistics.median(changes)
    return out


def main():
    user = series("en.wikipedia", "all-access", "user")
    auto = series("en.wikipedia", "all-access", "automated")
    spider = series("en.wikipedia", "all-access", "spider")
    allproj = series("all-projects", "all-access", "user")
    by_access = {}
    for access in ("desktop", "mobile-web", "mobile-app"):
        by_access[access] = {g: series("en.wikipedia", access, g)
                             for g in ("user", "automated", "spider")
                             if os.path.exists(os.path.join(
                                 RAW, "en.wikipedia__%s__%s.json" % (access, g)))}

    months = [m for m in sorted(user) if m >= "2016-01"]
    latest = months[-1]
    nonhuman = {m: spider.get(m, 0) + auto.get(m, 0) for m in sorted(spider)}
    total = {m: user[m] + nonhuman.get(m, 0) for m in months}

    t12 = months[-12:]
    p12 = [prev(m) for m in t12]
    t12_change = change(user, t12, p12)

    # --- the pre-registered reclassification detector -----------------------
    # Crude by construction: it compares a month-on-month fall in `user` with a
    # month-on-month rise in the non-human total within +/-1 month. Reported as
    # fixed, then tested a second way below, because it fires on seasonality.
    signature = []
    ordered = sorted(user)
    for m in t12:
        i = ordered.index(m)
        drop = user[ordered[i - 1]] - user[m] if i else 0
        if drop <= 0:
            continue
        for j in (i - 1, i, i + 1):
            if j <= 0 or j >= len(ordered):
                continue
            a, b = ordered[j - 1], ordered[j]
            if a in nonhuman and b in nonhuman and nonhuman[b] - nonhuman[a] >= 0.5 * drop:
                signature.append((m, drop, b, nonhuman[b] - nonhuman[a]))
                break

    if t12_change <= -0.10 and not signature:
        verdict = "ALIVE"
    elif t12_change > -0.03:
        verdict = "DEAD"
    else:
        verdict = "PARTLY"

    # --- monthly.csv --------------------------------------------------------
    with open(os.path.join(HERE, "monthly.csv"), "w", newline="") as fh:
        wr = csv.writer(fh)
        wr.writerow(["month", "user", "automated", "spider", "nonhuman_share",
                     "user_yoy", "desktop_user", "mobile_web_user",
                     "mobile_app_user", "all_projects_user"])
        for m in months:
            c = yoy(user, m)
            wr.writerow([m, user[m], auto.get(m, ""), spider.get(m, ""),
                         "%.4f" % (nonhuman.get(m, 0) / total[m]) if total[m] else "",
                         "%.4f" % c if c is not None else "",
                         by_access["desktop"]["user"].get(m, ""),
                         by_access["mobile-web"]["user"].get(m, ""),
                         by_access["mobile-app"]["user"].get(m, ""),
                         allproj.get(m, "")])

    L = []
    add = L.append
    add("# Results — is AI killing Wikipedia's traffic?")
    add("")
    add("Generated by `analyse.py` from Wikimedia's public REST pageview API. "
        "Complete calendar months; the last is **%s**. The question and the kill "
        "rule are in README.md and were fixed before the data was fetched." % latest)
    add("")
    add("## 1. The kill rule's answer")
    add("")
    add("| | human pageviews |")
    add("|---|---|")
    add("| trailing 12 months (%s..%s) | **%.2f bn** |" % (t12[0], t12[-1], w(user, t12) / BN))
    add("| the same 12 months a year earlier | %.2f bn |" % (w(user, p12) / BN))
    add("| change | **%+.1f %%** |" % (100 * t12_change))
    add("")
    add("The pre-registered reclassification detector fires on **%d month(s)** of "
        "the window:" % len(signature))
    for m, drop, b, rise in signature:
        add("  - `user` fell %.0f m into %s, non-human rose %.0f m into %s" % (drop / M, m, rise / M, b))
    add("")
    add("**Verdict by the rule fixed before the data: %s** — the fall is inside "
        "the −3 %% to −10 %% band. Section 2 decides which kind of \"partly\" it is." % verdict)
    add("")
    add("## 2. Is it a counting change?")
    add("")
    add("The detector above compares month-on-month moves and so fires on ordinary "
        "seasonality. Two harder tests:")
    add("")
    nh_change = change(nonhuman, t12, p12)
    tot_change = change(total, t12, p12)
    add("**(a) If humans were being reclassified as bots, the non-human series would "
        "rise.** It falls:")
    add("")
    add("| series | trailing 12 (bn) | previous 12 (bn) | change |")
    add("|---|---|---|---|")
    for name, s in (("human (`user`)", user), ("non-human (`automated`+`spider`)", nonhuman),
                    ("everything counted", total)):
        add("| %s | %.2f | %.2f | %+.1f %% |" % (name, w(s, t12) / BN, w(s, p12) / BN,
                                                 100 * change(s, t12, p12)))
    add("")
    add("Total counted traffic fell **%+.1f %%**, more than the human part. Traffic "
        "did not move between the classes; there is less of both." % (100 * tot_change))
    add("")
    add("**(b) The largest discontinuity in the window is %s→%s, when the non-human "
        "count halved (%.2f bn → %.2f bn). Whatever caused it — the classifier changing, "
        "a bot campaign ending — if it had moved requests into or out of the human "
        "class, the human series would step at the same month.** It does not — "
        "September to October is a seasonal rise every year, and 2025's is inside the "
        "usual range:" % ("2025-09", "2025-10", nonhuman["2025-09"] / BN, nonhuman["2025-10"] / BN))
    add("")
    add("| year | Sep → Oct, human pageviews |")
    add("|---|---|")
    for y in range(2016, int(latest[:4])):
        a, b = user.get("%d-09" % y), user.get("%d-10" % y)
        if a and b:
            add("| %d | %+.1f %% |" % (y, 100 * (b / a - 1)))
    add("")
    add("So: the fall in human pageviews is not an artefact of how Wikimedia sorts "
        "requests. It is fewer requests.")
    add("")
    add("## 3. Where the fall is: one access method, not all of them")
    add("")
    add("| access method | human, trailing 12 (bn) | previous 12 (bn) | change |")
    add("|---|---|---|---|")
    for access in ("desktop", "mobile-web", "mobile-app"):
        s = by_access[access]["user"]
        add("| %s | %.2f | %.2f | **%+.1f %%** |" % (access, w(s, t12) / BN, w(s, p12) / BN,
                                                     100 * change(s, t12, p12)))
    add("")
    add("Desktop reading of en.wikipedia by humans **grew**. The whole of the decline "
        "is the mobile web, plus a smaller fall in the app.")
    add("")
    add("The same split for the bots, which rules out a mobile-only classifier change "
        "as the explanation — mobile bot traffic falls by about as much as mobile "
        "human traffic, so nothing was moved between the two:")
    add("")
    add("| access method | non-human, trailing 12 (bn) | previous 12 (bn) | change |")
    add("|---|---|---|---|")
    for access in ("desktop", "mobile-web"):
        if "automated" not in by_access[access]:
            continue
        s = {m: by_access[access]["automated"].get(m, 0) + by_access[access]["spider"].get(m, 0)
             for m in sorted(by_access[access]["spider"])}
        add("| %s | %.2f | %.2f | %+.1f %% |" % (access, w(s, t12) / BN, w(s, p12) / BN,
                                                 100 * change(s, t12, p12)))
    add("")
    dk_h = w(by_access["desktop"]["user"], t12)
    dk_b = w({m: by_access["desktop"]["automated"].get(m, 0) + by_access["desktop"]["spider"].get(m, 0)
              for m in months}, t12)
    add("Worth stopping on: on **desktop**, %.2f bn of the last twelve months' "
        "pageviews were declared non-human against %.2f bn human — **%.1f %% of what "
        "en.wikipedia served to desktop URLs was not a person.** On the mobile web it "
        "is %.1f %%." % (
            dk_b / BN, dk_h / BN, 100 * dk_b / (dk_b + dk_h),
            100 * w({m: by_access["mobile-web"]["automated"].get(m, 0)
                     + by_access["mobile-web"]["spider"].get(m, 0) for m in months}, t12)
            / (w({m: by_access["mobile-web"]["automated"].get(m, 0)
                  + by_access["mobile-web"]["spider"].get(m, 0) for m in months}, t12)
               + w(by_access["mobile-web"]["user"], t12))))
    add("")
    add("## 4. When it started")
    add("")
    add("| series | unbroken run of negative year-over-year months ending %s | starts |" % latest)
    add("|---|---|---|")
    for name, s in (("all human pageviews", user),
                    ("mobile web, human", by_access["mobile-web"]["user"]),
                    ("desktop, human", by_access["desktop"]["user"]),
                    ("mobile app, human", by_access["mobile-app"]["user"])):
        run = tail_negative_run(s)
        add("| %s | %d months | %s |" % (name, len(run), run[0] if run else "—"))
    add("")
    mw = by_access["mobile-web"]["user"]
    add("The break is one month wide. Mobile web, human pageviews, year over year:")
    add("")
    add("| month | mobile web | desktop |")
    add("|---|---|---|")
    for m in ["2024-02", "2024-03", "2024-04", "2024-05", "2024-06", "2024-07"]:
        add("| %s | %+.1f %% | %+.1f %% |" % (m, 100 * yoy(mw, m),
                                              100 * yoy(by_access["desktop"]["user"], m)))
    add("")
    add("April 2024 was the last month mobile web grew. It has not grown since. "
        "Desktop shows no such break.")
    add("")
    add("Google put AI Overviews in front of every searcher in the United States in "
        "May 2024. That is an alignment of one series with one date, and this data "
        "cannot turn it into a cause: the pageview API has no country split, the launch "
        "was American and en.wikipedia is read everywhere, and other things happened "
        "that spring. What the data does say is that whatever changed, changed for "
        "phones and not for desktops, in a single month, and has not reversed in %d months."
        % len(tail_negative_run(mw)))
    add("")
    add("## 5. The cross-check that disagrees, and why I trust it less")
    add("")
    add("Wikimedia also publishes **unique devices**, which is not a count of requests "
        "but an estimate from a first-party cookie:")
    add("")
    add("| series | trailing 12 (bn) | previous 12 (bn) | change |")
    add("|---|---|---|---|")
    dev_rows = {}
    for site in ("all-sites", "desktop-site", "mobile-site"):
        try:
            s = devices(site)
        except FileNotFoundError:
            continue
        dev_rows[site] = s
        add("| %s | %.2f | %.2f | %+.1f %% |" % (site, w(s, t12) / BN, w(s, p12) / BN,
                                                 100 * change(s, t12, p12)))
    add("")
    if "desktop-site" in dev_rows:
        add("Desktop **devices** are up %+.1f %% while desktop **pageviews** are up only "
            "%+.1f %%, which would mean every desktop reader suddenly read a fifth fewer "
            "pages. A cookie-based estimate moves when browsers change how they keep "
            "cookies, and that is the likelier reading. Where a server-side request count "
            "and a cookie estimate disagree, the request count is the harder number, and "
            "it is the one section 1 uses." % (
                100 * change(dev_rows["desktop-site"], t12, p12),
                100 * change(by_access["desktop"]["user"], t12, p12)))
    add("")
    add("## 6. Context: the longer series")
    add("")
    add("| year | human pageviews (bn) | change |")
    add("|---|---|---|")
    prev_total = None
    for y in range(2016, int(latest[:4]) + 1):
        ms = [m for m in months if m.startswith(str(y))]
        tot = w(user, ms)
        if len(ms) == 12:
            c = "" if prev_total is None else "%+.1f %%" % (100 * (tot / prev_total - 1))
            add("| %d | %.2f | %s |" % (y, tot / BN, c))
            prev_total = tot
        else:
            base = w(user, [prev(m) for m in ms])
            add("| %d (%s..%s) | %.2f | %+.1f %% vs the same months a year earlier |"
                % (y, ms[0], ms[-1], tot / BN, 100 * (tot / base - 1)))
    add("")
    add("The series has fallen before — 2021 and 2022 both lost ground and then 2023 "
        "took it back. What is new is the length: %d consecutive negative months."
        % len(tail_negative_run(user)))
    add("")
    add("All Wikimedia projects together, human pageviews: %.2f bn in the trailing "
        "twelve months against %.2f bn, **%+.1f %%**." % (
            w(allproj, t12) / BN, w(allproj, p12) / BN, 100 * change(allproj, t12, p12)))
    add("")
    add("## 7. What this does not say")
    add("")
    add("Where the readers went. Nothing in a pageview count names a destination, and "
        "no number here should be read as \"they asked a chatbot instead\". The findings "
        "are: the fall is real and not a counting artefact; it is a phone fall; it began "
        "in May 2024 and has not stopped.")
    add("")

    noise = country_noise()
    if noise:
        add("## 8. The country proxy, and why it fails")
        add("")
        add("Section 4 says this API has no country split. That is loose: "
            "`top-per-country` exists, returning the fifty most-read articles in a "
            "country on a day with `views_ceil` rounded up. `country.py` sums the "
            "en.wikipedia articles in that top fifty, the 15th of each month, "
            "2022-01..2026-08, for eight countries (448 requests, cached).")
        add("")
        add("Median absolute year-over-year change per country:")
        add("")
        add("| " + " | ".join(noise) + " |")
        add("|" + "---|" * len(noise))
        add("| " + " | ".join("%.1f %%" % (100 * v) for v in noise.values()) + " |")
        add("")
        add("The effect sought is a one-off step of about 11 %%. The noise floor "
            "equals it in the quietest country (%s, %.1f %%) and more than triples "
            "it in the loudest (%s, %.1f %%); single months exceed 100 %% in every "
            "country. US 2024-05 reads +3 %%, the third smallest absolute move of "
            "its 44 months — **not** evidence against an American cause, because a "
            "series this noisy is not evidence of anything."
            % (min(noise, key=noise.get), 100 * min(noise.values()),
               max(noise, key=noise.get), 100 * max(noise.values())))
        add("")
        add("**Verdict: the proxy cannot resolve the question.** The limit stated in "
            "section 4 stands, now measured rather than asserted. Kept in the pack "
            "so nobody repeats it.")
        add("")

    text = "\n".join(L) + "\n"
    with open(os.path.join(HERE, "results.md"), "w") as fh:
        fh.write(text)
    print(text)
    return 0


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