#!/usr/bin/env python3
"""段階1の集計とお題単位のパーセンタイル・ブートストラップ（標準ライブラリのみ）。

python3 -I analyze_v1.py --ai texts-v1.jsonl --human human-v1.jsonl --normalize-html
"""
import argparse
import csv
from collections import Counter, defaultdict
from itertools import combinations
from pathlib import Path
import random
import sys
import time

# -I でも同じディレクトリの計数の正本を使う。
sys.path.insert(0, str(Path(__file__).resolve().parent))
from ai_style_count import (FEATURES, classify_loto_predictions, count_text,
                            excerpt_at_sentence_end, load_jsonl, features_for)
from html_to_text import html_to_markdownish, read_html, slice_by_heading

SEED = 20261101
ITERATIONS = 2000
HUMAN_TOPICS = ("s01", "s02", "s03", "s04", "s11")
SELECTED_IDS = ("F01", "F02", "F03", "F05", "F06", "F07", "P10", "R01", "R02",
                "R05", "H01", "S01", "S04", "S05", "S06", "S07", "S08")
METRICS = tuple(next(f for f in FEATURES if f.split("_")[0] == mid)
                for mid in SELECTED_IDS) + ("chars",)


def metrics_for(markers="v1"):
    ids = []
    for mid in SELECTED_IDS:
        if markers == "v1.1" and mid == "F05":
            ids.extend(("F05a", "F05b"))
        elif markers == "v1" or mid != "S04":
            ids.append(mid)
    return tuple(next(f for f in features_for(markers) if f.split("_")[0] == mid)
                 for mid in ids) + ("chars",)


def load_rows(path, normalize_html=False, excerpt=None, markers="v1"):
    """HTMLの本文・節の抽出後に、正本の切り出し・計数関数を適用する。"""
    rows = []
    for item in load_jsonl(path):
        if "text" in item:
            text = item["text"]
        elif normalize_html and "htmlPath" in item:
            html_path = Path(item["htmlPath"])
            if not html_path.is_absolute():
                html_path = Path(path).resolve().parent / html_path
            text = html_to_markdownish(read_html(str(html_path)))
            if item.get("sectionStart"):
                text = slice_by_heading(text, item["sectionStart"], item.get("sectionEnd") or None)
            elif item.get("sectionEnd"):
                raise ValueError("sectionEnd には sectionStart が必要です")
        else:
            raise ValueError("text が必要です。htmlPath には --normalize-html を指定してください")
        if excerpt is not None:
            text = excerpt_at_sentence_end(text, excerpt)
        rows.append({**{k: item.get(k) for k in ("model", "condition", "questionId", "runIndex")},
                     **count_text(text, markers)})
    return rows


def sample_topics(topics, rng):
    """重複を保持したまま、お題を元のお題数だけ復元抽出する。"""
    return [rng.choice(topics) for _ in topics]


def sample_topic_blocks(strata, topics, rng, within=False):
    """各お題の全本を返す。within=Trueではお題×群内で同数を復元抽出する。

    同じお題が複数回選ばれた場合も、その回ごとに独立に内側の抽出を行う。
    """
    for topic in sample_topics(topics, rng):
        yield {group: ([rng.choice(values) for _ in values] if within else values)
               for group, values in strata[topic].items()}


def group_means(blocks):
    """文章を等重みで集計（本数が異なるお題の平均を等重みにはしない）。"""
    sums, counts = {}, Counter()
    for block in blocks:
        for group, values in block.items():
            if group not in sums:
                sums[group] = [0.0] * len(values[0])
            counts[group] += len(values)
            for vector in values:
                for i, value in enumerate(vector):
                    sums[group][i] += value
    return {group: tuple(value / counts[group] for value in vector)
            for group, vector in sums.items()}


def bootstrap(strata, *, within=False, seed=SEED, iterations=ITERATIONS):
    """群間で同じお題抽出を共有し、対応のある差を計算できる平均を返す。"""
    topics = sorted(strata)
    if not topics or iterations < 1:
        raise ValueError("お題と反復が必要です")
    groups = set(strata[topics[0]])
    if any(set(strata[q]) != groups or any(not v for v in strata[q].values()) for q in topics):
        raise ValueError("各お題に全群の文章が必要です")
    rng = random.Random(seed)
    point = group_means(strata[q] for q in topics)
    draws = [group_means(sample_topic_blocks(strata, topics, rng, within))
             for _ in range(iterations)]
    return point, draws


def percentile(values, probability):
    """ソート後の (n−1)p の位置を線形補間する。"""
    values = sorted(values)
    position = (len(values) - 1) * probability
    lower = int(position)
    upper = min(lower + 1, len(values) - 1)
    return values[lower] + (values[upper] - values[lower]) * (position - lower)


def interval(values):
    return percentile(values, .025), percentile(values, .975)


def cell(point, values, percent=False):
    lo, hi = interval(values)
    fmt = (lambda x: f"{x:.1%}") if percent else (lambda x: f"{x:.3f}")
    # 端点が0の区間、[0, 0] も「0を含む」として明示する。
    star = "＊" if lo <= 0 <= hi else ""
    return f"{fmt(point)} [{fmt(lo)}, {fmt(hi)}]{star}"


def metric_table(point, draws, contrasts, metrics=METRICS):
    """contrasts: (列名, 左群, 右群またはNone)。差は左−右。"""
    lines = ["| 指標 | " + " | ".join(label for label, _, _ in contrasts) + " |",
             "|---|" + "---:|" * len(contrasts)]
    for i, metric in enumerate(metrics):
        cells = []
        for _, left, right in contrasts:
            value = point[left][i] - (point[right][i] if right is not None else 0)
            values = [d[left][i] - (d[right][i] if right is not None else 0) for d in draws]
            cells.append(cell(value, values))
        lines.append(f"| {metric} | " + " | ".join(cells) + " |")
    return lines + [""]


def metric_strata(rows, human=False, metrics=METRICS):
    strata = defaultdict(lambda: defaultdict(list))
    for row in rows:
        group = row["condition"] if human and row["model"] == "human" else (row["model"], row["condition"])
        strata[row["questionId"]][group].append(tuple(row[f] for f in metrics))
    return strata


def q1_results(rows, markers="v1", *, train_only_scaling=False):
    predictions = classify_loto_predictions(rows, markers, train_only_scaling=train_only_scaling)
    strata = defaultdict(lambda: defaultdict(list))
    for row, pred in zip(rows, predictions):
        hit = (int(row["model"] == pred),)
        strata[row["questionId"]]["全社"].append(hit)
        strata[row["questionId"]][row["model"]].append(hit)
    point, draws = bootstrap(strata)
    groups = ["全社"] + sorted({r["model"] for r in rows})
    lines = ["| Q1・お題 | " + " | ".join(short(g) for g in groups) + " |",
             "|---|" + "---:|" * len(groups)]
    for topic in sorted(strata):
        lines.append(f"| {topic} 正解数/本数 | " + " | ".join(
            f"{sum(v[0] for v in strata[topic][g])}/{len(strata[topic][g])}" for g in groups) + " |")
    lines.append("| 合計 正解数/本数 | " + " | ".join(
        f"{sum(v[0] for q in strata.values() for v in q[g])}/{sum(len(q[g]) for q in strata.values())}"
        for g in groups) + " |")
    lines.append("| 正解率／社ごとの再現率 [95%区間] | " + " | ".join(
        cell(point[g][0], [d[g][0] for d in draws], percent=True) for g in groups) + " |")
    hit = sum(r["model"] == p for r, p in zip(rows, predictions))
    return lines + [""], (hit, len(rows), interval([d["全社"][0] for d in draws]))


def train_only_report(rows, markers="v1"):
    """既存の全本標準化の結果を保持し、漏れを除いた分類だけを別途報告する。"""
    table, accuracy = q1_results(rows, markers, train_only_scaling=True)
    predictions = classify_loto_predictions(rows, markers, train_only_scaling=True)
    models = sorted({r["model"] for r in rows})
    lines = [f"# LOTO・訓練お題だけで標準化（{markers}）", "",
             "各回で伏せた1お題を除き、残り11お題・330本だけから各特徴の平均・母標準偏差を算出。"
             "同じ値で訓練330本・検証30本をz変換し、訓練側のモデル別重心へのユークリッド距離で分類。"
             "母標準偏差0の特徴の分母は1。12回の検証予測を元の行順で保持。", "",
             "特徴はfeatures_forの全列（charsを除く）。v1にはS04を含み、v1.1はS04を除外してF05を分割。"
             "区間は予測を固定したお題単位のブートストラップ（seed=20261101、2000回、"
             "2.5・97.5パーセンタイル）。ブートストラップの各回で分類器は再学習しない。", ""]
    lines += table + ["| 実際＼予測 | " + " | ".join(short(m) for m in models) + " |",
                      "|---|" + "---:|" * len(models)]
    for model in models:
        lines.append(f"| {short(model)} | " + " | ".join(str(sum(
            r["model"] == model and pred == target for r, pred in zip(rows, predictions)))
            for target in models) + " |")
    return "\n".join(lines) + "\n", accuracy


def short(group):
    if isinstance(group, tuple):
        return f"{short(group[0])}・{group[1]}"
    return group.split("/")[-1]


def report(ai, human_full, human_600, markers="v1"):
    """本文を含めず、表と方法の注記のみを生成する。"""
    metrics = metrics_for(markers)
    q1_table, accuracy = q1_results(ai, markers)
    groups = sorted({(r["model"], r["condition"]) for r in ai})
    models = sorted({r["model"] for r in ai})
    lines = ["| 対象 | お題数 | 本数 |", "|---|---:|---:|",
             f"| AI（Q1〜Q3） | {len({r['questionId'] for r in ai})} | {len(ai)} |",
             f"| 人（全文・600字版それぞれ） | {len({r['questionId'] for r in human_full})} | {len(human_full)} |", "",
             f"注: seed={SEED}、反復={ITERATIONS}。各区間はブートストラップ分布の2.5・97.5パーセンタイル"
             "（位置(n−1)pを線形補間）、多重比較の補正なし。表は点推定 [95%区間]。"
             "＊は丸める前の区間が0を含むことを示す（端点0・[0, 0]も含む）。", "",
             "注: 計数・文分割・600字切り出し・LOTO判定は ai_style_count.py の関数を使用。"
             "F・P・R・Hは1,000字あたり、S01は文長の平均（字）、S04は体言止め等の率、"
             "S05は文長の変動係数、S06は隣接文長差の平均÷平均文長、S07は段落文数の変動係数、"
             "S08は段落文数の平均、charsは空白を除く字数。", "",
             "注: Q1は既存LOTOの判定結果を固定し、12お題を12回復元抽出して正解数÷本数を計算。"
             "分類器の再学習は行わない。標準化も既存実装どおり全360本から算出。", ""]
    if markers == "v1.1":
        lines = [line.replace("S04は体言止め等の率、", "S04は除外、") for line in lines]
        lines = ["v1.1（誤検出確認後の事後修正）。生成前固定のv1とは分けて報告する。", "",
                 "F02は記号後の空白を要求（・のみ例外）。F05aは区切り線の行数、F05bはそれ以外の行の連続ダッシュ数。"
                 "F06は行頭の語＋コロンのみ（時刻・URL・文中ラベルを除外）。"
                 "F07は鉤括弧の数（強調に限らない・v1の1〜15字制限を維持）。"
                 "R05は否定で終わる文の数（直後に文があるもの）。F07・R05は計数を変更しない。"
                 "S04は列・分類から除外。その他の文末という名称に改め、S03の計算は維持。", ""] + lines
    lines += q1_table
    lines += ["注: Q2・Q3は12お題を12回復元抽出し、選ばれたお題の全本を重複も含めて投入。"
              "平均は文章を等重みとし、差の両辺には同じお題抽出を使う。各社×条件は60本（各お題5本）。", ""]
    point, draws = bootstrap(metric_strata(ai, metrics=metrics))
    lines += metric_table(point, draws, [(f"Q2 平均 {short(g)}", g, None) for g in groups], metrics=metrics)
    lines += metric_table(point, draws, [(f"Q3 {short(m)} natural − plain", (m, "natural"), (m, "plain"))
                                        for m in models], metrics=metrics)
    lines += metric_table(point, draws, [(f"Q2 plain {short(a)} − {short(b)}", (a, "plain"), (b, "plain"))
                                        for a, b in combinations(models, 2)], metrics=metrics)
    if not human_full:
        lines += ["人の修正済み入力が未提供のため、人との比較は未実施。--human-jsonl で差し替えて再実行する。", ""]
        return "\n".join(lines), accuracy
    selected_ai = [r for r in ai if r["questionId"] in HUMAN_TOPICS]
    people = [{**r, "condition": version} for version, rows in (("人・全文", human_full), ("人・600字", human_600))
              for r in rows if r["questionId"] in HUMAN_TOPICS]
    human_counts = Counter(r["questionId"] for r in human_full)
    lines += ["注: 人との比較はブログ・解説・感想の5お題（" + "、".join(
        f"{q}: 人{human_counts[q]}本" for q in HUMAN_TOPICS) + "）に限定。AIは各社×条件25本。"
        "5お題を5回復元抽出し、その出現ごとにお題×社×条件内のAI本とお題内の人の本を、"
        "それぞれ元と同数だけ独立に復元抽出（層別）。人は出どころの種類をまとめる。"
        "全文版・600字版それぞれについて抽出し、差の両辺で同じお題抽出を共有。"
        "点推定・反復の平均は文章を等重み（人はお題ごとに3〜4本）。"
        "600字版は入力本文（HTML入力は正規化・指定節の抽出後）の冒頭600字を超えた最初の文末または改行まで。", ""]
    point, draws = bootstrap(metric_strata(selected_ai + people, human=True, metrics=metrics), within=True)
    lines += metric_table(point, draws, [(f"5お題 AI平均 {short(g)}", g, None) for g in groups], metrics=metrics)
    lines += metric_table(point, draws, [(f"5お題 {version}平均", version, None)
                                        for version in ("人・全文", "人・600字")], metrics=metrics)
    for version in ("人・全文", "人・600字"):
        lines += metric_table(point, draws, [(f"5お題 {version} − {short(g)}", version, g) for g in groups], metrics=metrics)
    return "\n".join(lines), accuracy


def validate_inputs(ai, full, excerpt):
    expected = {(q, m, c): 5 for q in sorted({r["questionId"] for r in ai})
                for m in sorted({r["model"] for r in ai}) for c in ("natural", "plain")}
    actual = Counter((r["questionId"], r["model"], r["condition"]) for r in ai)
    if len(ai) != 360 or len({r["questionId"] for r in ai}) != 12 or len({r["model"] for r in ai}) != 3 or actual != expected:
        raise ValueError("AIは12お題×3社×2条件×5本の360本が必要です")
    for rows in (() if full is None else (full, excerpt)):
        if set(r["questionId"] for r in rows) != set(HUMAN_TOPICS) or any(
                n < 3 for n in Counter(r["questionId"] for r in rows).values()):
            raise ValueError("人は指定5お題・各3本以上が必要です")


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--ai", required=True)
    source = parser.add_mutually_exclusive_group()
    source.add_argument("--human", "--human-jsonl", dest="human")
    source.add_argument("--human-csv", help="v1.1の公開用全文CSV（本文不要）")
    parser.add_argument("--human-600-csv", help="v1.1の公開用600字CSV")
    parser.add_argument("--markers", choices=("v1", "v1.1"), default="v1")
    parser.add_argument("--human-no-normalize", action="store_true",
                        help="整形済みtextをそのまま使用（HTML変換を無効化）")
    parser.add_argument("--normalize-html", action="store_true")
    parser.add_argument("--loto-train-only", action="store_true",
                        help="訓練お題だけで標準化するQ1の補足結果を別出力")
    parser.add_argument("--out", type=Path, default=None)
    args = parser.parse_args()
    start = time.perf_counter()
    ai = load_rows(args.ai, markers=args.markers)
    normalize = args.normalize_html and not args.human_no_normalize
    full = load_rows(args.human, normalize, markers=args.markers) if args.human else None
    excerpt = load_rows(args.human, normalize, 600, args.markers) if args.human else None
    if args.human_csv:
        if args.markers != "v1.1" or not args.human_600_csv:
            parser.error("公開用CSVは--markers v1.1と--human-600-csvが必要です")
        def read_metrics(path):
            with open(path, encoding="utf-8", newline="") as f:
                return [{"model": "human", "condition": r["source_type"], "questionId": r["questionId"],
                         **{k: float(r[k]) for k in ("chars", *features_for("v1.1"))}}
                        for r in csv.DictReader(f)]
        full, excerpt = read_metrics(args.human_csv), read_metrics(args.human_600_csv)
    validate_inputs(ai, full, excerpt)
    md, (hit, total, ci) = (train_only_report(ai, args.markers) if args.loto_train_only
                            else report(ai, full or [], excerpt or [], args.markers))
    if args.out is None:
        args.out = Path(__file__).resolve().parent / f"results-v1/analysis-{'loto-train-only-' if args.loto_train_only else ''}{args.markers}.md"
    args.out.parent.mkdir(parents=True, exist_ok=True)
    args.out.write_text(md, encoding="utf-8")
    print(f"{args.out}: {time.perf_counter() - start:.2f}秒; Q1 {hit}/{total} = {hit/total:.1%} "
          f"[95%区間 {ci[0]:.1%}, {ci[1]:.1%}]")


if __name__ == "__main__":
    main()
