#!/usr/bin/env python3
"""Analyze EN/JA Wikipedia article-creation timing with right censoring."""

from __future__ import annotations

import argparse
import csv
import datetime as dt
import gzip
import hashlib
import html
import json
import math
import pathlib
from collections import Counter


DAY_SECONDS = 86_400
HORIZONS = (30, 90, 180, 365, 730)
CLASS_PRIORITY = (
    ("Q9143", "programming_language", "プログラミング言語"),
    ("Q271680", "software_framework", "ソフトウェアフレームワーク"),
    ("Q188860", "software_library", "ソフトウェアライブラリ"),
    ("Q9135", "operating_system", "オペレーティングシステム"),
    ("Q7397", "other_software", "その他のソフトウェア"),
)


def parse_utc(value: str) -> dt.datetime:
    parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
    return parsed.astimezone(dt.timezone.utc)


def load_rows(file: pathlib.Path) -> list[dict]:
    opener = gzip.open if file.suffix == ".gz" else open
    with opener(file, "rt", encoding="utf-8") as stream:
        return [json.loads(line) for line in stream if line.strip()]


def sha256_file(file: pathlib.Path) -> str:
    digest = hashlib.sha256()
    with file.open("rb") as stream:
        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def canonical_class(class_ids: list[str]) -> tuple[str, str]:
    values = set(class_ids)
    for class_id, key, label in CLASS_PRIORITY:
        if class_id in values:
            return key, label
    raise ValueError(f"No frozen class found in {class_ids}")


def prepare_rows(raw_rows: list[dict], cutoff: dt.datetime) -> list[dict]:
    rows = []
    for raw in raw_rows:
        en_time = parse_utc(raw["enFirstRevision"])
        if en_time > cutoff:
            raise ValueError(f"English first revision is after cutoff: {raw['itemId']}")

        ja_value = raw.get("jaFirstRevision")
        ja_time = parse_utc(ja_value) if ja_value else None
        if ja_time and ja_time > cutoff:
            ja_time = None

        event = bool(ja_time)
        raw_delay = (ja_time - en_time).total_seconds() / DAY_SECONDS if ja_time else None
        duration = max(0.0, raw_delay) if event else (cutoff - en_time).total_seconds() / DAY_SECONDS
        class_key, class_label = canonical_class(raw["classIds"])
        rows.append({
            "itemId": raw["itemId"],
            "enTitle": raw["enResolvedTitle"] or raw["enTitle"],
            "jaTitle": raw.get("jaResolvedTitle") or raw.get("jaTitle") or "",
            "enPageId": raw.get("enPageId"),
            "jaPageId": raw.get("jaPageId"),
            "enRedirected": bool(raw.get("enRedirected")),
            "jaRedirected": bool(raw.get("jaRedirected")),
            "classIds": raw["classIds"],
            "canonicalClass": class_key,
            "canonicalClassLabel": class_label,
            "enFirstRevision": raw["enFirstRevision"],
            "jaFirstRevision": ja_value if event else None,
            "durationDays": duration,
            "potentialFollowupDays": (cutoff - en_time).total_seconds() / DAY_SECONDS,
            "event": event,
            "jaOnOrBeforeEn": bool(event and raw_delay is not None and raw_delay <= 0),
            "singleClass": len(raw["classIds"]) == 1,
        })
    return rows


def km_curve(rows: list[dict]) -> list[dict]:
    if not rows:
        return []
    times = sorted({row["durationDays"] for row in rows})
    survival = 1.0
    greenwood = 0.0
    curve = []
    for time_days in times:
        at_risk = sum(row["durationDays"] >= time_days for row in rows)
        events = sum(row["event"] and row["durationDays"] == time_days for row in rows)
        censored = sum((not row["event"]) and row["durationDays"] == time_days for row in rows)
        if events:
            survival *= 1 - events / at_risk
            if at_risk > events:
                greenwood += events / (at_risk * (at_risk - events))
        lower, upper = survival_ci(survival, greenwood)
        curve.append({
            "timeDays": time_days,
            "atRisk": at_risk,
            "events": events,
            "censored": censored,
            "survival": survival,
            "survivalCiLow": lower,
            "survivalCiHigh": upper,
        })
    return curve


def survival_ci(survival: float, greenwood: float, z: float = 1.959963984540054) -> tuple[float, float]:
    if survival <= 0:
        return 0.0, 0.0
    if survival >= 1 or greenwood <= 0:
        return survival, survival
    transformed = math.log(-math.log(survival))
    standard_error = math.sqrt(greenwood) / abs(math.log(survival))
    lower = math.exp(-math.exp(transformed + z * standard_error))
    upper = math.exp(-math.exp(transformed - z * standard_error))
    return max(0.0, lower), min(1.0, upper)


def estimate_at(curve: list[dict], rows: list[dict], horizon: int) -> dict:
    point = None
    for candidate in curve:
        if candidate["timeDays"] <= horizon:
            point = candidate
        else:
            break
    if not point:
        survival = low = high = 1.0
    else:
        survival = point["survival"]
        low = point["survivalCiLow"]
        high = point["survivalCiHigh"]
    return {
        "days": horizon,
        "creationProbability": round(1 - survival, 6),
        "ciLow": round(1 - high, 6),
        "ciHigh": round(1 - low, 6),
        "atRisk": sum(row["durationDays"] >= horizon for row in rows),
    }


def summarize(rows: list[dict]) -> dict:
    curve = km_curve(rows)
    median_point = next((point for point in curve if point["survival"] <= 0.5), None)
    median_days = median_point["timeDays"] if median_point else None
    observed = sum(row["event"] for row in rows)
    on_or_before = sum(row["jaOnOrBeforeEn"] for row in rows)
    return {
        "n": len(rows),
        "observedJaArticles": observed,
        "rightCensored": len(rows) - observed,
        "snapshotJaAbsentRate": round((len(rows) - observed) / len(rows), 6) if rows else None,
        "jaOnOrBeforeEn": on_or_before,
        "kmMedianDays": round(median_days, 3) if median_days is not None else None,
        "kmMedianReached": median_days is not None,
        "kmMedianAtRisk": median_point["atRisk"] if median_point else None,
        "kmMedianLowSupportWarning": bool(median_point and median_point["atRisk"] < max(10, math.ceil(len(rows) * 0.1))),
        "horizons": [estimate_at(curve, rows, horizon) for horizon in HORIZONS],
    }


def write_csv(file: pathlib.Path, rows: list[dict], fields: list[str]) -> None:
    file.parent.mkdir(parents=True, exist_ok=True)
    with file.open("w", encoding="utf-8", newline="") as stream:
        writer = csv.DictWriter(stream, fieldnames=fields)
        writer.writeheader()
        writer.writerows(rows)


def esc(value) -> str:
    return html.escape(str(value), quote=True)


def svg_header(title: str, description: str, width: int, height: int) -> list[str]:
    return [
        f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img" aria-labelledby="title desc">',
        f'<title id="title">{esc(title)}</title>',
        f'<desc id="desc">{esc(description)}</desc>',
        '<rect width="100%" height="100%" fill="#f7f8fa"/>',
        '<style>text{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans JP",sans-serif;fill:#18212b}.title{font-size:30px;font-weight:700}.sub{font-size:16px;fill:#5a6773}.label{font-size:17px}.small{font-size:14px;fill:#5a6773}.value{font-size:17px;font-weight:700}.grid{stroke:#d9dee4;stroke-width:1}.axis{stroke:#7b8792;stroke-width:1.5}</style>',
        f'<text class="title" x="70" y="58">{esc(title)}</text>',
        f'<text class="sub" x="70" y="88">{esc(description)}</text>',
    ]


def write_horizon_svg(summary: dict, output: pathlib.Path) -> None:
    width, height = 1200, 710
    left, right, top, bottom = 105, 60, 145, 125
    plot_w, plot_h = width - left - right, height - top - bottom
    values = summary["horizons"]
    y_max = max(0.6, max(value["ciHigh"] for value in values) * 1.12)
    y_max = min(1.0, y_max)
    lines = svg_header(
        "英語版作成後、日本語版が作られた推定割合",
        f"Kaplan–Meier推定、95%信頼区間。対象{summary['n']}件、未作成を右打ち切りとして保持。",
        width,
        height,
    )
    for tick in range(6):
        rate = y_max * tick / 5
        y = top + plot_h - plot_h * rate / y_max
        lines.extend([
            f'<line class="grid" x1="{left}" y1="{y:.1f}" x2="{width-right}" y2="{y:.1f}"/>',
            f'<text class="small" x="{left-14}" y="{y+5:.1f}" text-anchor="end">{rate*100:.0f}%</text>',
        ])
    slot = plot_w / len(values)
    bar_w = slot * 0.52
    for index, value in enumerate(values):
        center = left + slot * index + slot / 2
        bar_h = plot_h * value["creationProbability"] / y_max
        y = top + plot_h - bar_h
        ci_top = top + plot_h - plot_h * value["ciHigh"] / y_max
        ci_bottom = top + plot_h - plot_h * value["ciLow"] / y_max
        lines.extend([
            f'<rect x="{center-bar_w/2:.1f}" y="{y:.1f}" width="{bar_w:.1f}" height="{bar_h:.1f}" fill="#087f8c" rx="3"/>',
            f'<line x1="{center:.1f}" y1="{ci_top:.1f}" x2="{center:.1f}" y2="{ci_bottom:.1f}" stroke="#18212b" stroke-width="3"/>',
            f'<line x1="{center-9:.1f}" y1="{ci_top:.1f}" x2="{center+9:.1f}" y2="{ci_top:.1f}" stroke="#18212b" stroke-width="3"/>',
            f'<line x1="{center-9:.1f}" y1="{ci_bottom:.1f}" x2="{center+9:.1f}" y2="{ci_bottom:.1f}" stroke="#18212b" stroke-width="3"/>',
            f'<text class="value" x="{center:.1f}" y="{y-17:.1f}" text-anchor="middle">{value["creationProbability"]*100:.1f}%</text>',
            f'<text class="label" x="{center:.1f}" y="{top+plot_h+34}" text-anchor="middle">{value["days"]}日</text>',
            f'<text class="small" x="{center:.1f}" y="{top+plot_h+58}" text-anchor="middle">追跡中 n={value["atRisk"]}</text>',
        ])
    lines.extend([
        f'<line class="axis" x1="{left}" y1="{top+plot_h}" x2="{width-right}" y2="{top+plot_h}"/>',
        '<text class="small" x="70" y="674">出典: Wikidata・英語版/日本語版Wikipedia API。記事作成時差であり、技術の普及速度ではありません。Trendiumo Research。</text>',
        '</svg>',
    ])
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text("\n".join(lines), encoding="utf-8")


def write_class_svg(classes: list[dict], output: pathlib.Path) -> None:
    rows = sorted(classes, key=lambda row: row["twoYearCreationProbability"], reverse=True)
    width, height = 1200, 820
    left, right, top, bottom = 330, 100, 145, 120
    plot_w = width - left - right
    lines = svg_header(
        "カテゴリ別：2年以内の日本語版記事作成割合",
        "Kaplan–Meier推定。カテゴリはWikidataの直接P31から優先順位で一意化。小標本は探索的。",
        width,
        height,
    )
    row_h = (height - top - bottom) / len(rows)
    for tick in range(6):
        rate = tick / 5
        x = left + plot_w * rate
        lines.extend([
            f'<line class="grid" x1="{x:.1f}" y1="{top-15}" x2="{x:.1f}" y2="{height-bottom}"/>',
            f'<text class="small" x="{x:.1f}" y="{height-bottom+35}" text-anchor="middle">{rate*100:.0f}%</text>',
        ])
    for index, row in enumerate(rows):
        y = top + row_h * index + row_h / 2
        x_low = left + plot_w * row["twoYearCiLow"]
        x_high = left + plot_w * row["twoYearCiHigh"]
        x_value = left + plot_w * row["twoYearCreationProbability"]
        lines.extend([
            f'<text class="label" x="{left-18}" y="{y+5:.1f}" text-anchor="end">{esc(row["label"])}</text>',
            f'<line x1="{x_low:.1f}" y1="{y:.1f}" x2="{x_high:.1f}" y2="{y:.1f}" stroke="#087f8c" stroke-width="8" stroke-linecap="round"/>',
            f'<circle cx="{x_value:.1f}" cy="{y:.1f}" r="9" fill="#18212b"/>',
            f'<text class="value" x="{min(x_value+15,width-right+2):.1f}" y="{y+6:.1f}">{row["twoYearCreationProbability"]*100:.1f}%</text>',
            f'<text class="small" x="{width-right+7}" y="{y+6:.1f}">n={row["n"]}</text>',
        ])
    lines.extend([
        '<text class="small" x="70" y="790">95%信頼区間。カテゴリ間の因果差や日本での利用状況は示しません。Trendiumo Research。</text>',
        '</svg>',
    ])
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text("\n".join(lines), encoding="utf-8")


def compact_views(value: int) -> str:
    if value >= 10_000:
        return f"{value / 10_000:.1f}万"
    return f"{value:,}"


def truncate_label(value: str, limit: int = 34) -> str:
    return value if len(value) <= limit else value[: limit - 1] + "…"


def write_missing_pageviews_svg(rows: list[dict], output: pathlib.Path) -> None:
    ranked = sorted(
        (row for row in rows if not row["event"]),
        key=lambda row: (-row["pageviews12m"], row["enTitle"]),
    )[:8]
    width, height = 1200, 850
    left, right, top, bottom = 390, 110, 150, 105
    plot_w = width - left - right
    maximum = max(row["pageviews12m"] for row in ranked)
    lines = svg_header(
        "日本語版sitelinkがない技術：英語版閲覧上位",
        "直近12か月の英語版Wikipedia pageview（all-agents）。対応する日本語版sitelinkがない項目。",
        width,
        height,
    )
    row_h = (height - top - bottom) / len(ranked)
    for tick in range(5):
        value = maximum * tick / 4
        x = left + plot_w * tick / 4
        lines.extend([
            f'<line class="grid" x1="{x:.1f}" y1="{top-14}" x2="{x:.1f}" y2="{height-bottom}"/>',
            f'<text class="small" x="{x:.1f}" y="{height-bottom+32}" text-anchor="middle">{esc(compact_views(round(value)))}</text>',
        ])
    for index, row in enumerate(ranked):
        y = top + row_h * index + row_h / 2
        bar_w = plot_w * row["pageviews12m"] / maximum
        lines.extend([
            f'<text class="label" x="{left-18}" y="{y+6:.1f}" text-anchor="end">{esc(truncate_label(row["enTitle"]))}</text>',
            f'<rect x="{left}" y="{y-15:.1f}" width="{bar_w:.1f}" height="30" fill="#087f8c" rx="3"/>',
            f'<text class="value" x="{min(left+bar_w+14, width-right+8):.1f}" y="{y+6:.1f}">{esc(compact_views(row["pageviews12m"]))}</text>',
        ])
    lines.extend([
        '<text class="small" x="70" y="817">閲覧数は需要の補助指標で、bot等を含みます。日本語の情報が存在しないことを意味しません。Trendiumo Research。</text>',
        '</svg>',
    ])
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text("\n".join(lines), encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--input", default="evidence/data/wikipedia-en-ja-technical-2016-2023.jsonl.gz")
    parser.add_argument("--metadata", default="evidence/fetch-metadata.json")
    parser.add_argument("--pageviews", default="evidence/data/enwiki-pageviews-2025-07_2026-06.jsonl.gz")
    parser.add_argument("--pageview-metadata", default="evidence/pageview-fetch-metadata.json")
    parser.add_argument("--explorer", default="evidence/explorer-cohort.json")
    parser.add_argument("--output", default="evidence/results.json")
    parser.add_argument("--figures", default="figures")
    args = parser.parse_args()

    input_file = pathlib.Path(args.input)
    metadata_file = pathlib.Path(args.metadata)
    pageviews_file = pathlib.Path(args.pageviews)
    pageview_metadata_file = pathlib.Path(args.pageview_metadata)
    explorer_file = pathlib.Path(args.explorer)
    output_file = pathlib.Path(args.output)
    figures_dir = pathlib.Path(args.figures)
    metadata = json.loads(metadata_file.read_text(encoding="utf-8"))
    if sha256_file(input_file) != metadata["datasetSha256"]:
        raise SystemExit("Dataset hash does not match fetch metadata")
    pageview_metadata = json.loads(pageview_metadata_file.read_text(encoding="utf-8"))
    if sha256_file(pageviews_file) != pageview_metadata["sha256"]:
        raise SystemExit("Pageview dataset hash does not match pageview metadata")

    cutoff = parse_utc(metadata["snapshotCutoff"])
    rows = prepare_rows(load_rows(input_file), cutoff)
    if len(rows) != metadata["eligibleRowCount"]:
        raise SystemExit("Dataset row count does not match fetch metadata")
    pageview_rows = load_rows(pageviews_file)
    if len(pageview_rows) != len(rows) or pageview_metadata["rowCount"] != len(rows):
        raise SystemExit("Pageview row count does not match the article cohort")
    pageviews_by_item = {row["itemId"]: row for row in pageview_rows}
    if set(pageviews_by_item) != {row["itemId"] for row in rows}:
        raise SystemExit("Pageview item IDs do not match the article cohort")
    for row in rows:
        pageview = pageviews_by_item[row["itemId"]]
        if pageview["enPageId"] != row["enPageId"] or pageview["enTitle"] != row["enTitle"]:
            raise SystemExit(f"Pageview identity mismatch: {row['itemId']}")
        if pageview.get("apiStatus") != 200:
            raise SystemExit(f"Pageview API status is not complete: {row['itemId']}")
        row["pageviews12m"] = int(pageview["views"])

    overall = summarize(rows)
    classes = []
    for _, key, label in CLASS_PRIORITY:
        selected = [row for row in rows if row["canonicalClass"] == key]
        if not selected:
            continue
        class_summary = summarize(selected)
        two_year = next(value for value in class_summary["horizons"] if value["days"] == 730)
        classes.append({
            "id": key,
            "label": label,
            "n": len(selected),
            "observedJaArticles": class_summary["observedJaArticles"],
            "rightCensored": class_summary["rightCensored"],
            "twoYearCreationProbability": two_year["creationProbability"],
            "twoYearCiLow": two_year["ciLow"],
            "twoYearCiHigh": two_year["ciHigh"],
            "smallSampleWarning": len(selected) < 30,
        })

    sensitivity_inputs = {
        "excludeJaOnOrBeforeEn": [row for row in rows if not row["jaOnOrBeforeEn"]],
        "singleDirectClassOnly": [row for row in rows if row["singleClass"]],
        "atLeastTwoYearsPotentialFollowup": [row for row in rows if row["potentialFollowupDays"] >= 730],
        "excludeRedirectedSitelinks": [row for row in rows if not row["enRedirected"] and not row["jaRedirected"]],
    }
    unique_en_rows = []
    seen_en_page_ids = set()
    for row in sorted(rows, key=lambda value: value["itemId"]):
        page_id = row["enPageId"]
        if page_id in seen_en_page_ids:
            continue
        seen_en_page_ids.add(page_id)
        unique_en_rows.append(row)
    sensitivity_inputs["oneItemPerResolvedEnglishPage"] = unique_en_rows
    sensitivities = {key: summarize(values) for key, values in sensitivity_inputs.items()}

    missing_rows = [row for row in rows if not row["event"]]
    ranked_missing = sorted(
        missing_rows,
        key=lambda row: (-row["pageviews12m"], row["enTitle"]),
    )
    unique_pageview_total = sum(row["pageviews12m"] for row in unique_en_rows)
    missing_pageview_total = sum(row["pageviews12m"] for row in missing_rows)
    explorer_items = []
    for row in sorted(rows, key=lambda value: value["itemId"]):
        status = (
            "ja_on_or_before_en"
            if row["jaOnOrBeforeEn"]
            else "created_after_en"
            if row["event"]
            else "no_ja_sitelink"
        )
        item = {
            "id": row["itemId"],
            "enTitle": row["enTitle"],
            "jaTitle": row["jaTitle"],
            "class": row["canonicalClass"],
            "classLabel": row["canonicalClassLabel"],
            "enFirstRevision": row["enFirstRevision"],
            "jaFirstRevision": row["jaFirstRevision"],
            "status": status,
            "pageviews12m": row["pageviews12m"],
            "enUrl": f"https://en.wikipedia.org/?curid={row['enPageId']}",
            "jaUrl": f"https://ja.wikipedia.org/?curid={row['jaPageId']}" if row["jaPageId"] else None,
            "wikidataUrl": f"https://www.wikidata.org/wiki/{row['itemId']}",
        }
        if row["event"]:
            item["lagDays"] = round(row["durationDays"], 3)
        else:
            item["observedDays"] = round(row["durationDays"], 3)
        explorer_items.append(item)
    explorer = {
        "schemaVersion": 1,
        "generatedFrom": {
            "cohortSha256": metadata["datasetSha256"],
            "pageviewsSha256": pageview_metadata["sha256"],
        },
        "pageviews": {
            "source": pageview_metadata["source"],
            "project": pageview_metadata["project"],
            "access": pageview_metadata["access"],
            "agent": pageview_metadata["agent"],
            "periodStart": pageview_metadata["periodStart"],
            "periodEnd": pageview_metadata["periodEnd"],
        },
        "summary": {
            "rowCount": len(rows),
            "uniqueEnglishPages": len(unique_en_rows),
            "createdAfterEnglish": sum(row["event"] and not row["jaOnOrBeforeEn"] for row in rows),
            "japaneseOnOrBeforeEnglish": sum(row["jaOnOrBeforeEn"] for row in rows),
            "noJapaneseSitelink": len(missing_rows),
            "uniqueEnglishPageviews12m": unique_pageview_total,
            "noJapaneseSitelinkPageviews12m": missing_pageview_total,
            "topMissingItem": {
                "id": ranked_missing[0]["itemId"],
                "title": ranked_missing[0]["enTitle"],
                "pageviews12m": ranked_missing[0]["pageviews12m"],
            },
        },
        "items": explorer_items,
    }
    explorer_file.parent.mkdir(parents=True, exist_ok=True)
    explorer_file.write_text(json.dumps(explorer, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")

    curve = km_curve(rows)
    curve_rows = [{
        "time_days": f"{point['timeDays']:.6f}",
        "at_risk": point["atRisk"],
        "events": point["events"],
        "censored": point["censored"],
        "survival": f"{point['survival']:.8f}",
        "creation_probability": f"{1-point['survival']:.8f}",
        "creation_ci_low": f"{1-point['survivalCiHigh']:.8f}",
        "creation_ci_high": f"{1-point['survivalCiLow']:.8f}",
    } for point in curve]
    cohort_rows = [{
        "item_id": row["itemId"],
        "en_title": row["enTitle"],
        "ja_title": row["jaTitle"],
        "canonical_class": row["canonicalClass"],
        "class_ids": "|".join(row["classIds"]),
        "en_page_id": row["enPageId"],
        "ja_page_id": row["jaPageId"] or "",
        "en_redirected": int(row["enRedirected"]),
        "ja_redirected": int(row["jaRedirected"]),
        "en_first_revision": row["enFirstRevision"],
        "ja_first_revision": row["jaFirstRevision"] or "",
        "duration_days": f"{row['durationDays']:.6f}",
        "event": int(row["event"]),
        "ja_on_or_before_en": int(row["jaOnOrBeforeEn"]),
        "en_pageviews_2025_07_to_2026_06": row["pageviews12m"],
    } for row in rows]
    class_rows = [{
        "id": row["id"],
        "label": row["label"],
        "n": row["n"],
        "observed_ja_articles": row["observedJaArticles"],
        "right_censored": row["rightCensored"],
        "two_year_creation_probability": f"{row['twoYearCreationProbability']:.6f}",
        "two_year_ci_low": f"{row['twoYearCiLow']:.6f}",
        "two_year_ci_high": f"{row['twoYearCiHigh']:.6f}",
        "small_sample_warning": str(row["smallSampleWarning"]).lower(),
    } for row in classes]

    results = {
        "schemaVersion": 2,
        "analysisType": "Kaplan-Meier estimate of Japanese Wikipedia article creation after the first English revision, with an exploratory English-pageview demand proxy",
        "snapshotCutoff": metadata["snapshotCutoff"],
        "datasetSha256": metadata["datasetSha256"],
        "cohort": {
            "periodStart": metadata["periodStart"],
            "periodEndExclusive": metadata["periodEndExclusive"],
            "rootClasses": metadata["rootClasses"],
            "canonicalClassPriority": [row[1] for row in CLASS_PRIORITY],
            "classMembershipCounts": dict(sorted(Counter(class_id for row in rows for class_id in row["classIds"]).items())),
            "uniqueResolvedEnglishPages": len({row["enPageId"] for row in rows}),
            "duplicateResolvedEnglishPageRows": len(rows) - len({row["enPageId"] for row in rows}),
            "englishRedirectedRows": sum(row["enRedirected"] for row in rows),
            "japaneseRedirectedRows": sum(row["jaRedirected"] for row in rows),
        },
        "overall": overall,
        "classes": classes,
        "sensitivities": sensitivities,
        "pageviews": {
            "periodStart": pageview_metadata["periodStart"],
            "periodEnd": pageview_metadata["periodEnd"],
            "agent": pageview_metadata["agent"],
            "uniqueEnglishPages": len(unique_en_rows),
            "uniqueEnglishPageviews": unique_pageview_total,
            "noJapaneseSitelinkItems": len(missing_rows),
            "noJapaneseSitelinkPageviews": missing_pageview_total,
            "topMissing": [{
                "id": row["itemId"],
                "title": row["enTitle"],
                "pageviews": row["pageviews12m"],
            } for row in ranked_missing[:10]],
            "datasetPath": args.pageviews,
            "datasetSha256": pageview_metadata["sha256"],
            "explorerPath": args.explorer,
            "explorerSha256": sha256_file(explorer_file),
            "explorerRowCount": len(explorer_items),
        },
        "interpretationGuardrails": [
            "The clock starts at the first English Wikipedia revision, not at software release or adoption.",
            "A Japanese revision on or before the English revision is treated as an event at day zero.",
            "No Japanese revision by the snapshot cutoff is retained as right-censored.",
            "The estimates describe article-creation timing and do not measure translation, awareness, usage, or causality.",
            "Direct Wikidata P31 classes and date completeness can create selection bias.",
            "The long-horizon median is low-support when fewer than 10% of the cohort remain at risk; fixed-horizon estimates are primary.",
            "The pageview extension is exploratory, uses all-agents requests, can include automated traffic, and is not a direct measure of Japanese demand.",
            "A missing Japanese sitelink does not prove that Japanese-language information is absent; coverage may be merged into another article.",
        ],
    }
    output_file.parent.mkdir(parents=True, exist_ok=True)
    output_file.write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    write_csv(pathlib.Path("evidence/cohort.csv"), cohort_rows, list(cohort_rows[0]))
    write_csv(pathlib.Path("evidence/km-overall.csv"), curve_rows, list(curve_rows[0]))
    write_csv(pathlib.Path("evidence/class-summary.csv"), class_rows, list(class_rows[0]))
    write_horizon_svg(overall, figures_dir / "article-creation-coverage.svg")
    write_class_svg(classes, figures_dir / "two-year-coverage-by-class.svg")
    write_missing_pageviews_svg(rows, figures_dir / "top-missing-pageviews.svg")
    print(json.dumps({
        "rows": len(rows),
        "observedJa": overall["observedJaArticles"],
        "rightCensored": overall["rightCensored"],
        "medianDays": overall["kmMedianDays"],
        "twoYearProbability": next(value["creationProbability"] for value in overall["horizons"] if value["days"] == 730),
        "resultsSha256": sha256_file(output_file),
    }, ensure_ascii=False))


if __name__ == "__main__":
    main()
