#!/usr/bin/env python3
"""judge_night.py — 夜に回した Unity の結果を自動で振り分ける（読むだけ。Unity も台帳も変えない）

振り分け（2026-10-25 エイイチ決定）
  回の失敗        : 最後まで回っていない・台帳に行がない・ロボットが1コマで0.05m超え動いた
  Unity の作り    : 立ち往生（ロボットが30秒以上止まった）などが、基準データと比べて外れた
  試算を見直す候補 : ロボットを増やしたときの向きが、種の過半数で試算と逆
                    または Unity ÷ 試算 の大きさが、基準データの同じ条件の範囲から外れた
  正常            : どれにも当たらない
  試算との差の大きさそのものは、記録するだけで印にしない（歩く距離の差などは既知のずれとして扱う）

決めた線
  STUCK_S = 30            立ち往生とみなす1回の止まりの長さ（Unity の『動けなくなった』60秒とは別の、判定だけの線）
  DEAD_BAND = 5% と 0.2分  これより小さい変化は「変わらない」として向きに数えない
  基準データ               段階B・ServiceSim/cs:7 の18回（③⑦ × 0/1/2台 × 種0〜2）。cs:144 の18回は使わない

使い方（service_sim/unity/ で）
  python3 judge_night.py --runs '20261026_night_*' --out night/20261026
  python3 judge_night.py --runs '20261025_stageB_cs144_*' --out /tmp/self   （基準どうしの確かめ）
標準ライブラリだけで動く。
"""
import argparse, csv, fnmatch, glob, json, os, statistics as st, sys, collections

STUCK_S = 30.0
DEAD_REL, DEAD_ABS = 0.05, 0.2
BASELINE_DEFAULT = '20261025_stageB_cs7_*'   # 2026-10-26：新しいプロジェクト（ServiceSim/cs:7）の18回に入れ替え。cs:144 の18回は当時のエディタの状態に引きずられていた（⑦）
LEDGER = 'unity_ledger_v1.csv'
# pass_by は台帳の列にないので run_info.json から取る（無い回 = 切）。robots は必ず最後（向きの比べで使う）
COND_KEYS = ('store', 'staff', 'arrivals_per_h', 'mode', 'trays', 'pass_by', 'robots')
# 向きと大きさを見る数字（Unity の列, 試算の列, 表示名）
PAIRS = [('entry_wait_mean', 'site_entry_wait_mean', '入店待ち'),
         ('dish_wait_mean', 'site_dish_wait_mean', '料理の待ち'),
         ('staff_wait_mean', 'site_staff_wait_mean', '席は空いているのに待った')]
# Unity の作りを見る数字（1回ごと）。多いほど悪い
BUILD_METRICS = [('stuck30_per_trip', '30秒以上の止まり（1往復あたり）'),
                 ('robot_robot30', '30秒以上の止まりのうち、もう1台のロボットが原因'),
                 ('stuck_count', '動けなくなった（60秒）'),
                 ('table_freed', '卓を空けた（逃がし弁）'),
                 ('warped', '瞬間移動'),
                 ('timeouts', '打ち切り')]


def fnum(x):
    try:
        return float(x)
    except (TypeError, ValueError):
        return None


def load_ledger():
    rows = {}
    with open(LEDGER, encoding='utf-8') as f:
        for r in csv.DictReader(f):
            rows[r['run_folder']] = r   # 同じ出力フォルダは2度書かない決まりなので後勝ちでよい
    return rows


def run_record(folder, ledger):
    """1回分の数字をまとめる。足りないものは失敗の理由に入れる"""
    rec = {'run_folder': folder, 'fail': []}
    lr = ledger.get(folder)
    if lr is None:
        rec['fail'].append('台帳に行がない')
        return rec
    rec['ledger'] = lr
    rec['seed'] = lr.get('seed')
    pass_by = '切'
    if lr.get('status') != 'completed':
        rec['fail'].append(f"status={lr.get('status')}")
    if lr.get('everyone_left') not in ('1', '1.0', 'True', 'true'):
        rec['fail'].append('全員が帰っていない')
    ri_path = os.path.join(folder, 'run_info.json')
    if os.path.exists(ri_path):
        ri = json.load(open(ri_path, encoding='utf-8'))
        if (ri.get('pass_by') or {}).get('enabled'):
            pass_by = '入'
        if (ri.get('frames_over_005m') or 0) > 0:
            rec['fail'].append(f"ロボットが1コマで0.05m超え {ri['frames_over_005m']}コマ")
    else:
        rec['fail'].append('run_info.json がない')
    vals = dict(lr)
    vals['pass_by'] = pass_by
    rec['cond'] = tuple(vals.get(k, '') for k in COND_KEYS)
    # 止まり
    stops_path = os.path.join(folder, 'stops.csv')
    long_n = rr_n = 0
    if os.path.exists(stops_path):
        with open(stops_path, encoding='utf-8') as f:
            for s in csv.DictReader(f):
                d = fnum(s.get('duration_s')) or 0
                if d >= STUCK_S:
                    long_n += 1
                    if s.get('cause') == 'もう1台のロボット':
                        rr_n += 1
    trips = fnum(lr.get('trips')) or 0
    rec['m'] = {
        'stuck30': long_n,
        'stuck30_per_trip': (long_n / trips) if trips else 0.0,
        'robot_robot30': rr_n,
        'stuck_count': fnum(lr.get('stuck_count')) or 0,
        'table_freed': fnum(lr.get('table_freed')) or 0,
        'warped': fnum(lr.get('warped')) or 0,
        'timeouts': fnum(lr.get('timeouts')) or 0,
    }
    for u, s, _ in PAIRS:
        rec['m'][u] = fnum(lr.get(u))
        rec['m'][s] = fnum(lr.get(s))
    return rec


def collect(pattern, ledger, superseded=None):
    """出力フォルダがある回と、台帳にだけ行がある回（failed / stopped で
    フォルダが無いもの）の両方を拾う。やり直し（_try2）があるときは1つにまとめる：
    最後まで回った方を使い、どちらも失敗なら後の方を使う"""
    names = {p for p in glob.glob(pattern) if os.path.isdir(p)}
    names |= {k for k in ledger if fnmatch.fnmatch(k, pattern)}
    recs = [run_record(f, ledger) for f in sorted(names)]
    by_key = collections.OrderedDict()
    for r in recs:
        key = r['run_folder'][:-len('_try2')] if r['run_folder'].endswith('_try2') else r['run_folder']
        by_key.setdefault(key, []).append(r)
    out = []
    for key, rs in by_key.items():
        rs.sort(key=lambda r: r['run_folder'].endswith('_try2'))   # 元 → やり直し の順
        ok = [r for r in rs if not r['fail']]
        pick = ok[-1] if ok else rs[-1]
        out.append(pick)
        if superseded is not None:
            superseded.extend(r['run_folder'] for r in rs if r is not pick)
    return out


def by_cond(recs):
    g = collections.defaultdict(list)
    for r in recs:
        if 'cond' in r and not r['fail']:
            g[r['cond']].append(r)
    return g


def sign(a, b):
    """a→b の向き。小さい変化は 0"""
    if a is None or b is None:
        return None
    d = b - a
    if abs(d) < max(DEAD_ABS, DEAD_REL * max(abs(a), abs(b))):
        return 0
    return 1 if d > 0 else -1


def direction_flags(groups):
    """台数を1つ増やしたときの向きを、種ごとに試算と比べる。過半数が逆なら印"""
    out = []   # (条件の頭, 台数a→b, 数字, 逆の種の数, 比べられた種の数, 試算の向き)
    heads = collections.defaultdict(dict)
    for cond, rs in groups.items():
        heads[cond[:-1]][cond[-1]] = rs
    for head, byr in heads.items():
        counts = sorted(byr, key=lambda x: float(x))
        for a, b in zip(counts, counts[1:]):
            sa = {r['seed']: r for r in byr[a]}
            sb = {r['seed']: r for r in byr[b]}
            seeds = sorted(set(sa) & set(sb), key=lambda x: int(x))
            for u, s, name in PAIRS:
                site_a = [sa[k]['m'][s] for k in seeds if sa[k]['m'][s] is not None]
                site_b = [sb[k]['m'][s] for k in seeds if sb[k]['m'][s] is not None]
                if not site_a or not site_b:
                    continue
                site_dir = sign(st.mean(site_a), st.mean(site_b))
                if site_dir in (None, 0):
                    continue   # 試算が変わらないところは向きを比べない
                opp = n = 0
                for k in seeds:
                    d = sign(sa[k]['m'][u], sb[k]['m'][u])
                    if d is None:
                        continue
                    n += 1
                    if d == -site_dir:
                        opp += 1
                if n and opp * 2 > n:
                    out.append((head, f'{a}→{b}台', name, opp, n, '短くなる' if site_dir < 0 else '長くなる'))
    return out


def ratio_values(rs, u, s):
    v = []
    for r in rs:
        a, b = r['m'][u], r['m'][s]
        if a is not None and b:
            v.append(a / b)
    return v


def compare_to_base(groups, base_groups):
    """条件ごとに、種の中央値が基準の同じ条件の最小〜最大から外れたか"""
    build, site, nobase = [], [], []
    for cond, rs in groups.items():
        brs = base_groups.get(cond)
        if not brs:
            nobase.append(cond)   # 比べる相手がないので判定しない（Unity の作りにしない）
            continue
        for key, name in BUILD_METRICS:
            now = st.median(r['m'][key] for r in rs)
            bv = [r['m'][key] for r in brs]
            if now > max(bv) and now - max(bv) >= (0.01 if key == 'stuck30_per_trip' else 1):
                build.append((cond, name, round(now, 3), round(min(bv), 3), round(max(bv), 3)))
        for u, s, name in PAIRS:
            nv, bv = ratio_values(rs, u, s), ratio_values(brs, u, s)
            if not nv or not bv:
                continue
            now = st.median(nv)
            if now < min(bv) or now > max(bv):
                site.append((cond, f'{name}（Unity÷試算）', round(now, 2), round(min(bv), 2), round(max(bv), 2)))
    return build, site, nobase


def cond_label(c):
    d = dict(zip(COND_KEYS, c))
    pb = '・すれ違い入' if d.get('pass_by') == '入' else ''
    return f"{d['store']} スタッフ{d['staff']} ロボット{d['robots']}台{pb}"


def head_label(h):
    d = dict(zip(COND_KEYS[:-1], h))
    pb = '・すれ違い入' if d.get('pass_by') == '入' else ''
    return f"{d['store']} スタッフ{d['staff']}{pb}"


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--runs', required=True, help='振り分ける回の出力フォルダ（glob）')
    ap.add_argument('--baseline', default=BASELINE_DEFAULT, help='基準データの出力フォルダ（glob）')
    ap.add_argument('--out', required=True, help='judge.csv と judge.md を書くフォルダ')
    a = ap.parse_args()
    if not os.path.exists(LEDGER):
        sys.exit(f'{LEDGER} がない。service_sim/unity/ で動かす（KIOXIA がつながっているか確かめる）')
    ledger = load_ledger()
    superseded = []
    recs = collect(a.runs, ledger, superseded)
    base = [r for r in collect(a.baseline, ledger) if not r['fail']]
    if not recs:
        sys.exit('振り分ける回が見つからない')
    if not base:
        sys.exit(f'基準データが見つからない（{a.baseline}）。--baseline を確かめる')
    groups, bgroups = by_cond(recs), by_cond(base)
    build, site_mag, nobase = compare_to_base(groups, bgroups)
    dirs = direction_flags(groups)
    known_dirs = {(d[0], d[1], d[2]) for d in direction_flags(bgroups)}

    # 1回ごとの判定（回の失敗だけは1回ごと。ほかは条件ごとの印を、その条件の回に付ける）
    build_conds = {b[0] for b in build}
    site_conds = {c[0] for c in site_mag}
    for d in dirs:
        if (d[0], d[1], d[2]) not in known_dirs:
            for cond in groups:
                if cond[:-1] == d[0]:
                    site_conds.add(cond)
    os.makedirs(a.out, exist_ok=True)
    counts = collections.Counter()
    with open(os.path.join(a.out, 'judge.csv'), 'w', encoding='utf-8', newline='') as f:
        w = csv.writer(f)
        w.writerow(['run_folder', 'store', 'staff', 'robots', 'seed', 'judge', 'reasons', 'stuck30', 'stuck30_per_trip', 'robot_robot30'])
        for r in recs:
            if r['fail']:
                j, why = '回の失敗', ' / '.join(r['fail'])
            elif r['cond'] in nobase:
                j, why = '基準なし', '基準データに同じ条件がない（判定していない）'
            elif r['cond'] in build_conds:
                j, why = 'Unity の作り', '条件ごとの印（judge.md）'
            elif r['cond'] in site_conds:
                j, why = '試算を見直す候補', '条件ごとの印（judge.md）'
            else:
                j, why = '正常', ''
            counts[j] += 1
            d = dict(zip(COND_KEYS, r.get('cond', ('',) * len(COND_KEYS))))
            m = r.get('m', {})
            w.writerow([r['run_folder'], d['store'], d['staff'], d['robots'], r.get('seed', ''), j, why,
                        m.get('stuck30', ''), round(m.get('stuck30_per_trip', 0), 4) if m else '', m.get('robot_robot30', '')])

    L = []
    L.append(f'# 夜の結果の振り分け（{a.runs}）\n')
    L.append(f'基準データ: {a.baseline}（{len(base)}回）／振り分けた回: {len(recs)}回\n')
    pm_now = sorted({r['ledger'].get('params_model', '') for r in recs if 'ledger' in r} - {''})
    pm_base = sorted({r['ledger'].get('params_model', '') for r in base if 'ledger' in r} - {''})
    if pm_now != pm_base:
        L.append(f'**注意：試算の版が基準と違う（今回 {", ".join(pm_now)}／基準 {", ".join(pm_base)}）。試算との比べは参考にとどめる**\n')
    L.append('| 振り分け | 回数 |\n|---|---|')
    for k in ('正常', '試算を見直す候補', 'Unity の作り', '回の失敗', '基準なし'):
        L.append(f'| {k} | {counts.get(k, 0)} |')
    L.append('\n## 回の失敗\n')
    fails = [r for r in recs if r['fail']]
    L += [f"- {r['run_folder']}: {' / '.join(r['fail'])}" for r in fails] or ['- なし']
    if superseded:
        L.append('\nやり直しで置き換えた回（判定に入れていない）: ' + ', '.join(superseded))
    if nobase:
        L.append('\n## 基準なし（基準データに同じ条件がないので判定していない）\n')
        L += [f'- {cond_label(c)}' for c in sorted(nobase)]
    L.append('\n## Unity の作り（種の中央値が、基準の同じ条件の最大を超えた）\n')
    L += [f'- {cond_label(c)}: {name} {now}（基準 {lo}〜{hi}）' if now is not None else f'- {cond_label(c)}: {name}'
          for c, name, now, lo, hi in build] or ['- なし']
    L.append('\n## 試算を見直す候補\n')
    L.append('### ロボットを増やしたときの向きが、種の過半数で試算と逆\n')
    if dirs:
        for h, step, name, opp, n, sd in dirs:
            tag = '基準 v2 でも同じ（既知）' if (h, step, name) in known_dirs else '新しく出た'
            L.append(f'- {head_label(h)} {step}: {name}（試算は{sd}）— 逆 {opp}/{n} 種 — {tag}')
    else:
        L.append('- なし')
    L.append('\n### Unity÷試算 の大きさが、基準の同じ条件の範囲から外れた\n')
    L += [f'- {cond_label(c)}: {name} {now}倍（基準 {lo}〜{hi}倍）' for c, name, now, lo, hi in site_mag] or ['- なし']
    L.append('\n## 記録（印にしない）：試算との差の大きさ（条件ごとの種の中央値・Unity÷試算）\n')
    L.append('| 条件 | 種の数 | ' + ' | '.join(p[2] for p in PAIRS) + ' |')
    L.append('|---|---|' + '---|' * len(PAIRS))
    for cond in sorted(groups):
        rs = groups[cond]
        cells = []
        for u, s, _ in PAIRS:
            v = ratio_values(rs, u, s)
            cells.append(f'{st.median(v):.2f}' if v else '-')
        L.append(f'| {cond_label(cond)} | {len(rs)} | ' + ' | '.join(cells) + ' |')
    # すれ違い 入 の条件は、同じ夜の 切 の同じ条件と並べる（記録。印にしない）
    pb_i = COND_KEYS.index('pass_by')
    pairs = [(c, c[:pb_i] + ('切',) + c[pb_i + 1:]) for c in groups if c[pb_i] == '入']
    if pairs:
        L.append('\n## すれ違い 入 と 切 の比べ（同じ夜・同じ条件・種の中央値。印にしない）\n')
        cols = [('entry_wait_mean', '入店待ち'), ('dish_wait_mean', '料理の待ち'), ('staff_wait_mean', '席は空いているのに待った'),
                ('stuck_count', '動けなくなった'), ('gave_up', 'あきらめた'), ('robot_share', 'ロボットが運んだ割合')]
        L.append('| 条件 | ' + ' | '.join(n for _, n in cols) + ' |')
        L.append('|---|' + '---|' * len(cols))
        for on, off in pairs:
            for c in (off, on):
                rs = groups.get(c, [])
                cells = []
                for k, _ in cols:
                    v = [fnum(r['ledger'].get(k)) for r in rs if fnum(r['ledger'].get(k)) is not None]
                    cells.append(f'{st.median(v):.2f}' if v else '-')
                L.append(f'| {cond_label(c)}（{len(rs)}回） | ' + ' | '.join(cells) + ' |')
    L.append(f'\n線: 立ち往生 {STUCK_S:.0f}秒以上の止まり／向きは {int(DEAD_REL*100)}%・{DEAD_ABS}分より小さい変化を数えない／外れは基準の同じ条件の最小〜最大')
    open(os.path.join(a.out, 'judge.md'), 'w', encoding='utf-8').write('\n'.join(L) + '\n')
    print('\n'.join(L))


if __name__ == '__main__':
    main()
