#!/usr/bin/env python3
"""推論の記録 (runs/pi05_policy/) の判定ファイルを読む・足す共通の部品 (依頼 183).

判定ファイル: /Volumes/KIOXIA/r2r2r-data/runs/pi05_policy_verdicts_v1.csv (MASTER の保存先テーブル)
  - 追記だけ. 既存の行は書き換えない. 同じ run_stem はいちばん新しい行 (ファイルの後ろの行) が正
  - labeler=ai の行は未確定. 確定は run_input・人の行 (human / human_fixed_ai / human_only)・rule
    (依頼 185. run_input は推論を終えたときにエイイチが y/n を打った判定なので人の確定に数える.
     rule はエイイチの決めで機械的に付けた行: 送ったチャンク 0 → not_applicable, 素材が足りない → not_judgeable)
  - 成功率は確定した回の success / (success + failure). not_applicable・not_judgeable・unknown は分母に入れない
  - 書く前に KIOXIA のマウントを確かめる. なければ止める (ローカルに逃がさない)
元の記録 (runs/pi05_policy/ の中身) は読むだけ.
"""
import csv
import fcntl
import io
import json
import os
import sys
from datetime import datetime
from pathlib import Path

KIOXIA_MOUNT = Path("/Volumes/KIOXIA")
DATA_ROOT = KIOXIA_MOUNT / "r2r2r-data"
RUNS_DIR = DATA_ROOT / "runs" / "pi05_policy"
VERDICTS_CSV = DATA_ROOT / "runs" / "pi05_policy_verdicts_v1.csv"

COLUMNS = ["labeled_at", "run_stem", "pick", "place", "outcome", "labeler",
           "ai_outcome", "ai_reason", "evidence", "note"]
PICK_PLACE = ("yes", "no", "unknown")
PLACE_VALUES = PICK_PLACE + ("not_applicable",)  # つかめず place まで行かなかった回 (依頼 193. pick には足さない)
OUTCOMES = ("success", "failure", "unknown", "not_judgeable", "not_applicable")
AI_OUTCOMES = ("success", "failure", "unknown", "not_judgeable")  # AI が付けてよい outcome (not_applicable は rule だけ)
LABELERS = ("run_input", "ai", "human", "human_fixed_ai", "human_only", "rule")
HUMAN_LABELERS = ("human", "human_fixed_ai", "human_only")
CONFIRMED_LABELERS = ("run_input",) + HUMAN_LABELERS + ("rule",)  # 確定の扱い (依頼 185)
RULE_NOTE = "エイイチの決め 2026-11-14 依頼185"
EVIDENCES = ("top.mp4", "frames", "both", "")
MAX_NOTE = 500
MAX_REASON = 2000


class NotMounted(RuntimeError):
    pass


def ensure_ssd_mounted(csv_path=VERDICTS_CSV):
    """KIOXIA の下に書くときはマウントを確かめる. なければ NotMounted (ローカルへは書かない).
    テストで KIOXIA の外の一時ファイルに書くときは確かめない."""
    p = Path(csv_path)
    if str(p).startswith(str(KIOXIA_MOUNT) + os.sep) or p == KIOXIA_MOUNT:
        if not os.path.ismount(str(KIOXIA_MOUNT)):
            raise NotMounted(f"KIOXIA がマウントされていません ({KIOXIA_MOUNT}). 書かずに止めます")
        if not p.parent.is_dir():
            raise NotMounted(f"書き込み先のフォルダがありません ({p.parent}). 書かずに止めます")


def now_iso():
    return datetime.now().astimezone().isoformat(timespec="seconds")


def validate_row(row):
    r = {c: ("" if row.get(c) is None else str(row.get(c))) for c in COLUMNS}
    # 長すぎる値は切る (csv の読み取りの上限を超えると以後読めなくなる. codex 3 回目 R3)
    for c, n in (("note", MAX_NOTE), ("ai_reason", MAX_REASON)):
        if len(r[c]) > n:
            r[c] = r[c][:n] + "…"
    if not r["run_stem"]:
        raise ValueError("run_stem が空です")
    if r["pick"] not in PICK_PLACE:
        raise ValueError(f"pick は {PICK_PLACE} のどれか: {r['pick']!r}")
    if r["place"] not in PLACE_VALUES:
        raise ValueError(f"place は {PLACE_VALUES} のどれか: {r['place']!r}")
    if r["outcome"] not in OUTCOMES:
        raise ValueError(f"outcome は {OUTCOMES} のどれか: {r['outcome']!r}")
    if r["labeler"] not in LABELERS:
        raise ValueError(f"labeler は {LABELERS} のどれか: {r['labeler']!r}")
    if r["ai_outcome"] not in OUTCOMES + ("",):
        raise ValueError(f"ai_outcome は {OUTCOMES} のどれか か空: {r['ai_outcome']!r}")
    if r["evidence"] not in EVIDENCES:
        raise ValueError(f"evidence は {EVIDENCES} のどれか: {r['evidence']!r}")
    if not r["labeled_at"]:
        r["labeled_at"] = now_iso()
    return r


class BrokenCsv(RuntimeError):
    pass


def _check_existing(data):
    """追記の前に今のファイルを確かめる. 見出しがちがう・最後が改行で終わらない・列の数がちがう行があれば止める
    (そのまま足すと既存の最後の行に続けて書いてしまう. codex R4)."""
    if not data:
        return []
    if not data.endswith(b"\n"):
        raise BrokenCsv("判定ファイルの最後が改行で終わっていません (書きかけの行がある). 書かずに止めます")
    try:  # strict: 閉じていない引用符も止める (codex 2 回目 R1)
        rows = list(csv.reader(io.StringIO(data.decode("utf-8"), newline=""), strict=True))
    except (csv.Error, UnicodeDecodeError) as e:
        raise BrokenCsv(f"判定ファイルを読めません ({e}). 書かずに止めます")
    if not rows or rows[0] != COLUMNS:
        raise BrokenCsv("判定ファイルの見出しがちがいます. 書かずに止めます")
    for i, r in enumerate(rows[1:], 2):
        if len(r) != len(COLUMNS):
            raise BrokenCsv(f"判定ファイルの {i} 行目の列の数が {len(r)} です ({len(COLUMNS)} のはず). 書かずに止めます")
    return [dict(zip(COLUMNS, r)) for r in rows[1:]]


def append_rows(rows, csv_path=VERDICTS_CSV, skip_if_labeled_by=None, forbid_dirs=()):
    """行を末尾に足すだけ. 既存のバイトは触らない (追記モードで開く). 見出しはファイルが空のときだけ書く.
    skip_if_labeled_by: この labeler の行がすでにある run_stem の行は足さない. 確かめはロックの中で行う
    (seed が人の確定のあとに AI の行を足して未確定へ戻さないため. codex R2).
    forbid_dirs: この中には書かない (使っている記録のフォルダ. codex 2 回目 R3). 書いた行を返す."""
    rows = [validate_row(r) for r in rows]
    ensure_ssd_mounted(csv_path)
    if not rows:
        return []
    p = Path(csv_path)
    if (p.suffix != ".csv" or within(RUNS_DIR, p) or within(DATA_ROOT / "datasets", p)
            or any(within(d, p) for d in forbid_dirs)):
        raise BrokenCsv(f"判定ファイルとして書けない場所です ({p}). 元の記録・datasets の中や .csv でないファイルには書きません")
    if p.is_symlink():
        raise BrokenCsv(f"判定ファイルがシンボリックリンクです ({p}). 書かずに止めます")
    fd = os.open(str(p), os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o644)
    with os.fdopen(fd, "r+b") as f:
        fcntl.flock(f, fcntl.LOCK_EX)
        try:
            f.seek(0)
            existing = _check_existing(f.read())
            if skip_if_labeled_by:
                done = {r["run_stem"] for r in existing if r.get("labeler") in skip_if_labeled_by}
                rows = [r for r in rows if r["run_stem"] not in done]
                if not rows:
                    return []
            buf = io.StringIO()
            w = csv.DictWriter(buf, fieldnames=COLUMNS, lineterminator="\n")
            if not existing and f.seek(0, os.SEEK_END) == 0:
                w.writeheader()
            for r in rows:
                w.writerow(r)
            f.write(buf.getvalue().encode("utf-8"))
            f.flush()
            os.fsync(f.fileno())
        finally:
            fcntl.flock(f, fcntl.LOCK_UN)
    return rows


def within(root, path):
    """path がリンクをたどっても root の中にあるか (codex R6)."""
    try:
        Path(path).resolve().relative_to(Path(root).resolve())
        return True
    except (ValueError, OSError):
        return False


def read_rows(csv_path=VERDICTS_CSV):
    p = Path(csv_path)
    if not p.exists():
        return []
    with open(p, newline="", encoding="utf-8") as f:
        return list(csv.DictReader(f))


def latest_by_stem(rows, labelers=None):
    """run_stem ごとにいちばん新しい行 (ファイルの後ろの行). labelers を渡すとその種類の行だけから選ぶ."""
    out = {}
    for r in rows:
        if labelers is not None and r.get("labeler") not in labelers:
            continue
        out[r["run_stem"]] = r
    return out


def current_by_stem(rows):
    """run_stem ごとの今の判定. 確定の行があればそのいちばん新しい行, なければいちばん新しい行 (AI など).
    確定のあとに足された AI の行で未確定へ戻らない."""
    out = latest_by_stem(rows)
    out.update(latest_by_stem(rows, labelers=CONFIRMED_LABELERS))
    return out


def success_rate(rows):
    """確定した回だけの成功率. 分母は success + failure (not_applicable・not_judgeable・unknown は入れない).
    {"success", "failure", "denominator", "rate" (分母 0 なら None), "confirmed", "by_outcome"}."""
    conf = latest_by_stem(rows, labelers=CONFIRMED_LABELERS)
    by = {}
    for r in conf.values():
        by[r["outcome"]] = by.get(r["outcome"], 0) + 1
    s, f = by.get("success", 0), by.get("failure", 0)
    return {"success": s, "failure": f, "denominator": s + f, "rate": (s / (s + f)) if s + f else None,
            "confirmed": len(conf), "by_outcome": by}


def rule_row(run_stem, n_exec, ai_row):
    """エイイチの決め (2026-11-14 依頼 185) に当たる回の labeler=rule の行. 当たらなければ None.
    決め 2: 送ったチャンク 0 (アームを動かしていない) → not_applicable. 2 と 3 の両方に当たれば 2
    決め 3: AI が frames/ だけで not_judgeable とした回・素材がなく API を呼ばなかった回 → not_judgeable"""
    a = ai_row or {}
    if n_exec == 0:
        oc, note = "not_applicable", f"{RULE_NOTE} 決め2: 送ったチャンク 0 (アームを動かしていない・試行になっていない)"
    elif a.get("outcome") == "not_judgeable" and a.get("evidence") in ("frames", ""):
        src = "frames/ だけ" if a.get("evidence") == "frames" else "素材なし (API を呼んでいない)"
        oc, note = "not_judgeable", f"{RULE_NOTE} 決め3: AI が not_judgeable ({src}). 人が見ずに確定"
    else:
        return None
    return {"labeled_at": now_iso(), "run_stem": run_stem, "pick": "unknown", "place": "unknown",
            "outcome": oc, "labeler": "rule", "ai_outcome": a.get("outcome", ""),
            "ai_reason": a.get("ai_reason", ""), "evidence": a.get("evidence", ""), "note": note}


def human_labeler_for(outcome, ai_row):
    """人が付けたときの labeler. AI の行がなければ human_only, 同じなら human, 違えば human_fixed_ai."""
    if not ai_row:
        return "human_only"
    return "human" if ai_row.get("outcome") == outcome else "human_fixed_ai"


# ---- 元の記録を読む (読むだけ) ----

def list_run_stems(runs_dir=RUNS_DIR):
    return sorted(d.name for d in Path(runs_dir).iterdir()
                  if d.is_dir() and d.name.startswith("pi05_") and within(runs_dir, d))


def safe_file(run_dir, *rel):
    """記録の中のファイル. リンクをたどって記録の外へ出るもの・ないものは None (codex 2 回目 R4)."""
    p = Path(run_dir).joinpath(*rel)
    return p if p.is_file() and within(run_dir, p) else None


def read_run_json(run_dir):
    p = safe_file(run_dir, f"{Path(run_dir).name}.json")
    if p is None:
        return None
    try:
        return json.loads(p.read_text(encoding="utf-8"))
    except Exception:
        return None


def frame_files(run_dir):
    d = Path(run_dir) / "frames"
    if not d.is_dir():
        return []
    return sorted(f.name for f in d.iterdir()
                  if f.suffix.lower() == ".jpg" and not f.name.startswith("._") and within(run_dir, f))


def read_log(run_dir):
    p = safe_file(run_dir, "log.jsonl")
    out = []
    if p is None:
        return out
    for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            out.append(json.loads(line))
        except Exception:
            pass
    return out


def executed_chunks(log):
    """送ったチャンクの数: log.jsonl の chunk のある行のうち executed が True の行 (log.jsonl がなければ 0)."""
    return sum(1 for x in log if "chunk" in x and str(x.get("executed")) == "True")


def has_top_mp4(run_dir):
    return safe_file(run_dir, "recording", "top.mp4") is not None


def run_input_row(run_stem, run_json):
    """{run_stem}.json の outcome を写した行 (labeler=run_input). outcome がない / null なら None."""
    if not run_json or run_json.get("outcome") in (None, ""):
        return None
    oc = run_json["outcome"]
    if oc not in OUTCOMES:
        oc = "unknown"
    jd = run_json.get("judgments") or {}

    def pv(k):
        v = (jd.get(k) or {}).get("verdict")
        return v if v in PICK_PLACE else "unknown"
    return {"labeled_at": run_json.get("ended_at") or now_iso(), "run_stem": run_stem,
            "pick": pv("pick"), "place": pv("place"), "outcome": oc, "labeler": "run_input",
            "ai_outcome": "", "ai_reason": "", "evidence": "",
            "note": "{run_stem}.json の outcome を写した (記録したときの入力)"}


if __name__ == "__main__":
    rows = read_rows()
    lat = latest_by_stem(rows)
    print(f"{VERDICTS_CSV}: {len(rows)} 行 / {len(lat)} 回")
    sys.exit(0)
