#!/usr/bin/env python3
"""night_runner.py — 夜のあいだ、並べた回を全部回す（依頼 v12 の 4 章）

  python3 tools/night_runner.py --queue queue.json --parallel 2 --night 20261026

やること
  ・始める前に、KIOXIA がつながっているか・実行ファイルがあるかを確かめる（無ければ始めない）
  ・caffeinate で Mac をスリープさせない（終わったら必ず解く）
  ・N 本まで同時に実行ファイルを起動する。1本終わったら次を起動する
  ・台帳に completed の行がある回は飛ばす（途中で止めても続きから回せる）
  ・1回の上限（既定 90 分）を超えたら止め、status=stopped の行を台帳に足す
  ・落ちた回は status=failed の行を足す
  ・止まった・落ちた回は、その夜のうちに1度だけやり直す（出力フォルダに _try2）
  ・途中で KIOXIA が外れたら、新しい回を始めずに待ち、走っている回が終わったら止まる
  ・時刻では止めない（並べた回が全部終わるまで回す）
  ・記録は service_sim/unity/night/{night}/ に3つ
      runner_log.txt … いつ・どの回を・何本目で始めて、どう終わったか（人が読む用）
      runs.csv       … 1回ごとの開始・終了の時刻（あとで重なりを計算する用）
      queue.json     … その夜に並べた回の写し（何をどの順で並べたか）
  ・全部終わったら judge_night.py を呼ぶ

実行ファイルの呼び方（依頼 v12 の 2 章）
  {実行ファイル} -batchmode -nographics -logFile {その回のログ}
      -ssCondition fam_p1_s4_r1_a42 -ssSeed 3 -ssLabel night -ssNight 20261026
      -ssParallel N  -ssRunning M   （N は決めた本数・M はそのとき実際に走る本数）
  終了コード：0 = 最後まで回った／2 = 途中で止まった／1 = 失敗

出力フォルダの名前（依頼 v12 の 2 章）
  {night}_{ラベル}_{cs}_{店}_r{台数}_s{種}        例 20261026_night_cs3_fam_p1_r1_s3
  ★ この形は **実行ファイル（Assets 側・依頼 v12 の 1〜3 章）と合わせる決まり**。
    いまの Unity 側（ServiceSimStageB.FolderName）は "cs{N}" なので、それにそろえてある。
    形は cs{N} に固定（Python だけの設定にすると C# と食い違うため。
    2026-10-26・codex レビュー A7）。

標準ライブラリだけで動く。台帳は tools/ledger.py を通してしか触らない。
"""
import argparse
import csv
import datetime as dt
import json
import os
import re
import shutil
import signal
import subprocess
import sys
import time

import ledger as L

HERE = os.path.dirname(os.path.abspath(__file__))
PROJECT = os.path.dirname(HERE)

DEFAULT_VOLUME = '/Volumes/KIOXIA'
DEFAULT_UNITY_DIR = '/Volumes/KIOXIA/r2r2r-data/service_sim/unity'
DEFAULT_BUILD = os.path.join(PROJECT, 'Builds', 'headless', 'ServiceSim.app',
                             'Contents', 'MacOS', 'ServiceSim')
DEFAULT_LIMIT_MIN = 90.0

COND_RE = re.compile(r'^(?P<store>[a-z0-9_]+?)_s(?P<staff>\d+)_r(?P<robots>\d+)_a(?P<arr>\d+)$')


# ---------------- 小さな道具 ----------------

class Log(object):
    def __init__(self, path):
        self.path = path
        d = os.path.dirname(path)
        if d and not os.path.isdir(d):
            os.makedirs(d)
        self._f = open(path, 'a', encoding='utf-8')

    def __call__(self, msg):
        line = '%s  %s' % (dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), msg)
        print(line, flush=True)
        self._f.write(line + '\n')
        self._f.flush()

    def close(self):
        try:
            self._f.close()
        except Exception:
            pass


def parse_condition(cid):
    m = COND_RE.match(cid)
    if not m:
        return None
    return {'store': m.group('store'), 'staff': int(m.group('staff')),
            'robots': int(m.group('robots')), 'arrivals_per_h': int(m.group('arr'))}


def build_info_path(build_path):
    """
    build_info.json の場所。**実行ファイルの .app と同じ所** だけを見る
    （依頼 v14 の 5）。

    実行ファイルは …/headless/ServiceSim.app/Contents/MacOS/ServiceSim なので、
    .app から1つ上（…/headless/）に build_info.json がある。
    2026-10-26：前は上に何階層も探したうえ、見つからなければ cm status に
    頼っていた。そのせいで「実行ファイルの版」ではなく
    「いまの作業ツリーの版」を台帳に書く道があった（取り違えの元）
    """
    p = os.path.abspath(build_path)
    # …/X.app/Contents/MacOS/exe → …/X.app
    app = p
    for _ in range(3):
        app = os.path.dirname(app)
    if app.endswith('.app'):
        return os.path.join(os.path.dirname(app), 'build_info.json')
    # .app の形でないとき（素の実行ファイル）は、そのそば
    return os.path.join(os.path.dirname(p), 'build_info.json')


def read_build_info(build_path):
    """
    build_info.json を読む。無い・読めないときは None
    （依頼 v14 の 5：cm status には頼らない）
    """
    p = build_info_path(build_path)
    if not os.path.exists(p):
        return None, p
    try:
        with open(p, encoding='utf-8') as f:
            return json.load(f), p
    except Exception:
        return None, p


def cs_token(unity_cs):
    """
    ServiceSim/cs:3 → cs3（出力フォルダの名前に入る字）

    2026-10-26（codex レビュー A7）：前は --cs-token で形を変えられたが、
    その設定は Python だけが持っていて実行ファイルには渡らないので、
    変えると **Python と C# でフォルダ名が食い違う**（実行係が
    「もう回した」と誤って飛ばす／同じ回を2度回す）。
    形は cs{N} に固定した。C# 側は HeadlessRun.CsToken が同じ式を持つ
    """
    m = re.search(r'cs:(\d+)', unity_cs or '')
    n = m.group(1) if m else 'unknown'
    return 'cs' + n


def norm_pass_by(v):
    """
    すれ違いの入切を on / off のどちらかにそろえる（2026-10-26・codex レビュー A7）。
    C# 側は ToLowerInvariant して "on" だけを入れ、それ以外は off にする。
    Python が大小をそのまま使うと、"On" で C# は off・Python は on 扱いになり
    フォルダ名が食い違う
    """
    return 'on' if str(v).strip().lower() == 'on' else 'off'


def folder_name(night, label, token, cond, seed, pass_by='off', tryno=1):
    """
    出力フォルダの名前。
    すれ違い「入」の回だけ _passon を付ける（2026-10-26。依頼 v13）。
    同じ条件・同じ種で 入 と 切 を両方回すので、付けないと名前がぶつかる。
    「切」の回の名前は付ける前と同じまま（基準データと並べるため）
    """
    name = '%s_%s_%s_%s_r%d_s%d' % (night, label, token, cond['store'], cond['robots'], seed)
    if norm_pass_by(pass_by) == 'on':
        name += '_passon'
    return name if tryno <= 1 else '%s_try%d' % (name, tryno)


def volume_ok(volume, unity_dir):
    return os.path.ismount(volume) and os.path.isdir(unity_dir)


# ---------------- 実行係 ----------------

class Runner(object):
    def __init__(self, a, log):
        self.a = a
        self.log = log
        self.unity_dir = a.unity_dir
        self.night_dir = os.path.join(a.unity_dir, 'night', a.night)
        self.limit_s = a.limit_min * 60.0
        self.build_info, self.build_info_at = read_build_info(a.build)
        self.unity_cs = ((self.build_info or {}).get('unity_cs') or '').strip()
        self.cs_from = self.build_info_at
        self.token = cs_token(self.unity_cs)
        self.build_id = ''
        self.running = []       # [{proc, item, folder, started, tryno, logfile}]
        self.done = []          # [(folder, status, wall_s, tryno, item)]
        self.retry = []         # やり直す回
        self.stopping = False   # KIOXIA が外れたら True（新しい回を始めない）
        self.runs_csv = None    # 1回ごとの開始・終了（2026-10-26）
        self.lost = 0           # 記録が欠けた数（codex レビュー C4）
        self.ledger_failed = []  # 台帳に書けなかった回（終了コード 3。依頼 v14 の 3）

    def note_lost(self, msg):
        """
        記録（runs.csv）が書けなかったとき。
        ログ自体が書けないこともあるので、必ず標準エラーにも出す。
        夜の本体は止めず、欠けた数を最後のまとめに出す（codex レビュー C4）
        """
        self.lost += 1
        try:
            self.log(msg)
        except Exception:
            pass
        try:
            sys.stderr.write('[night_runner] %s\n' % msg)
            sys.stderr.flush()
        except Exception:
            pass

    # ---- 前の確かめ ----
    def precheck(self, queue):
        ok = True
        if not volume_ok(self.a.volume, self.unity_dir):
            self.log('NG: KIOXIA がつながっていません（%s / %s）' % (self.a.volume, self.unity_dir))
            ok = False
        if not (os.path.isfile(self.a.build) and os.access(self.a.build, os.X_OK)):
            # から回し（--dry-run）は、実行ファイルを作る前に打つコマンドを見るためのものなので通す
            msg = '実行ファイルがありません（または実行できません）: %s' % self.a.build
            if self.a.dry_run:
                self.log('（から回しなので通します）' + msg)
            else:
                self.log('NG: ' + msg)
                ok = False
        if ok:
            try:
                cols = L.read_header(self.unity_dir)
                self.log('台帳: %s（%d 列）' % (L.ledger_path(self.unity_dir), len(cols)))
            except L.LedgerError as e:
                self.log('NG: %s' % e)
                ok = False
        # ★ 版は build_info.json からしか取らない（依頼 v14 の 5）。
        #   無ければ始めない（cm status には頼らない）
        if self.build_info is None:
            msg = ('build_info.json がありません（実行ファイルの .app と同じ所）: %s\n'
                   '  実行ファイルを ServiceSim/画面なしの実行ファイルを作る で'
                   '作り直してください' % self.build_info_at)
            if self.a.dry_run:
                self.log('（から回しなので通します）' + msg)
            else:
                self.log('NG: ' + msg)
                ok = False
        elif not self.unity_cs:
            msg = ('build_info.json に unity_cs がありません: %s' % self.build_info_at)
            if self.a.dry_run:
                self.log('（から回しなので通します）' + msg)
            else:
                self.log('NG: ' + msg)
                ok = False
        else:
            self.log('版: %s（%s）→ 出力フォルダの字 %s'
                     % (self.unity_cs, os.path.relpath(self.build_info_at, PROJECT), self.token))

        # ★ 保留がある状態で作った実行ファイルでは始めない（依頼 v14 の 6）。
        #   台帳の unity_cs が cs:N なのに中身が違う、という取り違えが残る
        pending = ((self.build_info or {}).get('pending_changes') or '').strip()
        if pending:
            msg = ('実行ファイルは保留中の変更があるまま作られています: %s\n'
                   '  台帳の unity_cs（%s）と中身が合いません。'
                   'check-in して保留 0 で作り直してください' % (pending, self.unity_cs))
            if self.a.dry_run:
                self.log('（から回しなので通します）' + msg)
            else:
                self.log('NG: ' + msg)
                ok = False
        if self.build_info is not None:
            self.build_id = (self.build_info.get('build_id') or '').strip()

        pm = queue.get('params_model', '')
        if pm:
            self.log('並べた回の試算の版: %s / 店の形の版: %s'
                     % (pm, queue.get('layout_generation', '')))
        return ok

    # ---- 1回ごとの開始・終了（runs.csv。2026-10-26）----
    #
    # なぜ要るか：run_info.json の parallel_actual は **起動したときの** 本数なので、
    # 並びの最後で同時本数が減るのは出てこない（減るのは回している途中）。
    # 1回ごとの開始・終了の時刻をそのまま残せば、
    # 「その回が回っている間、ほかに何本走っていたか」を **あとから正確に計算**できる。
    # 台帳の列は増やさない（data_manifest_rules.md 10節）。
    # started_mono / ended_mono は単調時計（time.monotonic）の値
    # （2026-10-26・codex レビュー A10）。
    # 夜の途中で時計が合わせ直されても、重なりの計算が狂わないため。
    # 絶対の時刻（人が読む用）は started_iso / started_unix を見る
    RUNS_CSV_HEADER = ('folder,condition,seed,pass_by,tryno,launch_running,parallel,'
                       'started_iso,ended_iso,started_unix,ended_unix,'
                       'started_mono,ended_mono,wall_s,status,exit_code\n')

    def _runs_csv_path(self):
        return os.path.join(self.night_dir, 'runs.csv')

    def _runs_csv_open(self):
        """追記で開く。ヘッダはコードの並びと同じでなければ足さない（台帳と同じ用心）"""
        if self.runs_csv is not None or self.a.dry_run:
            return
        p = self._runs_csv_path()
        if not volume_ok(self.a.volume, self.unity_dir):
            self.note_lost('runs.csv を開けません（KIOXIA がつながっていません）')
            return
        try:
            if not os.path.isdir(self.night_dir):
                os.makedirs(self.night_dir)
            new = not os.path.exists(p) or os.path.getsize(p) == 0
            if not new:
                with open(p, encoding='utf-8') as f:
                    first = f.readline()
                if first != self.RUNS_CSV_HEADER:
                    self.log('runs.csv のヘッダがコードと違うので書きません: %s' % p)
                    return
            self.runs_csv = open(p, 'a', encoding='utf-8')
            if new:
                self.runs_csv.write(self.RUNS_CSV_HEADER)
                self.runs_csv.flush()
        except Exception as e:
            self.log('runs.csv を開けません（%s）: %s' % (p, e))
            self.runs_csv = None

    def _runs_csv_row(self, r, status, code, wall):
        """1回ぶんを runs.csv に足す。失敗しても夜は止めない"""
        self._runs_csv_open()
        if self.runs_csv is None:
            return
        cond = parse_condition(r['item']['condition']) or {}
        ended = r['started'] + wall

        def iso(t):
            return dt.datetime.fromtimestamp(t).strftime('%Y-%m-%dT%H:%M:%S')

        try:
            # csv.writer で書く（2026-10-26・codex レビュー B2）。
            # 前は % で組んでいたので、名札や条件にカンマが入ると列がずれた
            w = csv.writer(self.runs_csv, lineterminator='\n')
            mono0 = r.get('started_mono')
            w.writerow([r['folder'], r['item']['condition'], r['item']['seed'],
                        norm_pass_by(r['item'].get('pass_by', 'off')), r['tryno'],
                        r.get('launch_running', 0), self.a.parallel,
                        iso(r['started']), iso(ended),
                        '%.3f' % r['started'], '%.3f' % ended,
                        '' if mono0 is None else '%.3f' % mono0,
                        '' if mono0 is None else '%.3f' % (mono0 + wall),
                        '%.3f' % wall,
                        status, '' if code is None else code])
            self.runs_csv.flush()
            os.fsync(self.runs_csv.fileno())
        except Exception as e:
            self.note_lost('runs.csv に書けませんでした: %s' % e)

    def _runs_csv_close(self):
        if self.runs_csv is not None:
            try:
                self.runs_csv.close()
            except Exception:
                pass
            self.runs_csv = None

    # ---- 1回ぶん ----
    def start_one(self, item, tryno):
        cond = parse_condition(item['condition'])
        if cond is None:
            self.log('飛ばします（条件の名前が読めません）: %s' % item['condition'])
            self.done.append((item['condition'], 'skipped', 0.0, tryno, item))
            return
        pass_by = norm_pass_by(item.get('pass_by', 'off'))
        folder = folder_name(self.a.night, self.a.label, self.token, cond, item['seed'],
                             pass_by, tryno)
        logfile = os.path.join(self.night_dir, 'logs', folder + '.log')
        if not self.a.dry_run:
            d = os.path.dirname(logfile)
            if not os.path.isdir(d):
                os.makedirs(d)

        # この回を起動したときに、実際に同時に走ることになる本数（この回を含む）。
        # -ssParallel は「決めた N」のまま、-ssRunning が「そのときの実際」。
        # 並びの最後の N-1 本は N より少なくなるので、
        # あとで「薄い回だったか」を台帳と run_info.json から見分けられるようにする
        # （2026-10-26。busycheck で同時本数が stuck_count・robot_share などを
        #   動かすと分かったため）
        running_now = len(self.running) + 1
        if self.a.dry_run:
            # から回しは実際に起動しないので self.running に積まれない。
            # 本番の起動時の本数を模して出す（1,2,…,N,N,…）。
            # ★ これは **起動したとき** の本数。並びの最後の方で
            #   同時本数が減るのは回している **途中** なので、この数には出ない（下の注）
            running_now = min(self.a.parallel, len(self.done) + 1)

        cmd = [self.a.build, '-batchmode', '-nographics', '-logFile', logfile,
               '-ssCondition', item['condition'], '-ssSeed', str(item['seed']),
               '-ssLabel', self.a.label, '-ssNight', self.a.night,
               '-ssParallel', str(self.a.parallel),
               '-ssRunning', str(running_now),
               # 2026-10-26（依頼 v13）：すれ違いの入切を回ごとに渡す
               '-ssPassBy', pass_by]
        if tryno > 1:
            cmd += ['-ssTry', str(tryno)]
        if self.a.dry_run:
            self.log('（から回し）%d 回め %s 種%s すれ違い%s 同時%d → %s' %
                     (len(self.done) + 1, item['condition'], item['seed'],
                      '入' if pass_by == 'on' else '切', running_now, folder))
            self.log('  %s' % ' '.join(cmd))
            self.done.append((folder, 'dry-run', 0.0, tryno, item))
            return

        proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        self.running.append({'proc': proc, 'item': item, 'folder': folder,
                             'started': time.time(), 'started_mono': time.monotonic(),
                             'tryno': tryno, 'logfile': logfile,
                             'launch_running': running_now})
        self.log('始めました（%d本目・同時 %d）: %s 種%s すれ違い%s → %s（pid %d）'
                 % (len(self.done) + len(self.running), running_now,
                    item['condition'], item['seed'],
                    '入' if pass_by == 'on' else '切', folder, proc.pid))

    def reap(self):
        """
        終わった回を片付ける。1つでも片付けたら True。

        2026-10-26（codex レビュー A10）：終わった回の **時刻を先に全部採ってから**、
        そのあとで台帳や runs.csv に書く。
        前は1本ずつ「見つける→台帳に書く（鍵を最大60秒待つ）」としていたので、
        2本目以降の終了時刻に、1本目の鍵待ちの時間が混ざっていた
        """
        found = []
        for r in list(self.running):
            code = r['proc'].poll()
            over = (time.time() - r['started']) > self.limit_s
            if code is None and not over:
                continue
            if code is None and over:
                self.log('上限 %.1f 分を超えたので止めます: %s（pid %d）'
                         % (self.a.limit_min, r['folder'], r['proc'].pid))
                self._kill(r['proc'])
                code = 2
            found.append((r, code, time.monotonic()))   # ← ここで採時する（単調時計）

        changed = False
        for r, code, ended_mono in found:
            wall = ended_mono - r.get('started_mono', ended_mono)
            # 終了コード 3 は「回は終わったが台帳に書けなかった」（依頼 v14 の 3）
            status = {0: 'completed', 2: 'stopped', 3: 'ledger_failed'}.get(code, 'failed')
            if code == 3:
                self.ledger_failed.append(r['folder'])
                self.log('★ 台帳に書けなかった回です（終了コード 3）: %s\n'
                         '  ログを見てください: %s' % (r['folder'], r['logfile']))

            # 時間切れで止める直前に、実行ファイルが completed の行を
            # 書き終えていることがある（2026-10-26・codex レビュー A5）。
            # そのときは台帳が正しいので、runs.csv もそれに合わせ、やり直しもしない。
            # 台帳と記録で status が食い違ったままにしない
            if status != 'completed' and volume_ok(self.a.volume, self.unity_dir):
                try:
                    if r['folder'] in L.completed_folders(self.unity_dir):
                        self.log('台帳には completed の行がもうあるので、'
                                 'この回は completed として扱います: %s' % r['folder'])
                        status = 'completed'
                        code = 0
                except (L.LedgerError, OSError) as e:
                    self.log('台帳を読めなかったので status はそのままにします: %s' % e)

            self.log('おわり: %s / %s / 終了コード %s / 実時間 %.1f 分'
                     % (r['folder'], status, code, wall / 60.0))
            # 1回ごとの開始・終了を残す（completed も含めて全部）
            self._runs_csv_row(r, status, code, wall)
            if status not in ('completed', 'ledger_failed'):
                self._write_row(r, status, wall)
            elif status == 'ledger_failed':
                # 実行ファイルが書けなかった所に実行係が書いても、たぶん同じ理由で書けない。
                # ここでは書かず、記録とまとめに出すだけにする（人が見て決める）
                self.log('  （実行係からも台帳には書きません。原因を直してから回し直してください）')
                base = r['folder']
                already = base in getattr(self, 'tried2', set())
                if r['tryno'] == 1 and not self.a.no_retry and not already:
                    self.retry.append(r['item'])
                    self.log('その夜のうちに1度やり直します: %s 種%s'
                             % (r['item']['condition'], r['item']['seed']))
                elif already:
                    self.log('やり直しはもう使っているので、やり直しません: %s' % base)
            self.running.remove(r)
            self.done.append((r['folder'], status, wall, r['tryno'], r['item']))
            changed = True
        return changed

    def _kill(self, proc):
        """
        止める。SIGTERM → 10 秒待つ → SIGKILL → **終わるまで待つ**。

        2026-10-26（codex レビュー A5）：前は SIGKILL のあと待っていなかったので、
        まだ生きている回が台帳に書く余地があった（二重書きの道）。
        止めるのに失敗したらログに出す（黙らない）
        """
        try:
            proc.send_signal(signal.SIGTERM)
        except Exception as e:
            self.log('SIGTERM を送れませんでした（pid %s）: %s' % (proc.pid, e))
        for _ in range(100):           # 10 秒待つ
            if proc.poll() is not None:
                return
            time.sleep(0.1)
        try:
            proc.kill()
        except Exception as e:
            self.log('SIGKILL を送れませんでした（pid %s）: %s' % (proc.pid, e))
        try:
            proc.wait(timeout=30)      # 終わるまで待つ（これが抜けていた）
        except Exception as e:
            self.log('★ 止めた回がまだ終わっていません（pid %s）: %s'
                     '\n  台帳に二重に書く恐れがあります。あとで確かめてください'
                     % (proc.pid, e))

    def _write_row(self, r, status, wall):
        """止まった・落ちた回の行を台帳に足す（実行ファイルが書けていないときだけ）"""
        if not volume_ok(self.a.volume, self.unity_dir):
            self.log('台帳に書けません（KIOXIA が外れています）: %s' % r['folder'])
            return
        cond = parse_condition(r['item']['condition']) or {}
        row = {
            'timestamp': dt.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
            'run_folder': r['folder'],
            'status': status,
            'unity_cs': self.unity_cs,
            'store': cond.get('store', ''),
            'staff': cond.get('staff', ''),
            'robots': cond.get('robots', ''),
            'arrivals_per_h': cond.get('arrivals_per_h', ''),
            'mode': r['item'].get('mode', ''),
            'trays': r['item'].get('trays', ''),
            'fragile_by': r['item'].get('fragile_by', ''),
            'seed': r['item']['seed'],
            'wall_seconds': '%.4f' % wall,
            # 台帳の版（2026-10-26・codex レビュー D7）。
            # 前は空欄だったので、Python が書いた行だけ版が分からなかった
            'ledger_schema_version': str(L.SCHEMA_VERSION),
        }
        try:
            L.append_row(self.unity_dir, row, log=self.log)
        except L.LedgerError as e:
            self.log('台帳に書けませんでした: %s' % e)

    def _copy_queue(self, queue_path):
        """
        並べた回（queue.json）を夜のフォルダに写す（2026-10-26）。
        queue.json はプロジェクトの中で夜ごとに作り直す（VCS に入れない）ので、
        写しておかないと **その夜に何をどの順で並べたか** がどこにも残らない。
        出力のそばに置けば、あとで judge と突き合わせられる。
        もうあれば上書きしない（同じ夜を続きから回したときに、
        最初に並べたものを残すため）
        """
        if self.a.dry_run:
            return
        # KIOXIA が無いときに Mac の中へ同じ道のフォルダを作らない
        # （2026-10-26・codex レビュー D6）
        if not volume_ok(self.a.volume, self.unity_dir):
            self.log('並べた回を写せません（KIOXIA がつながっていません）')
            return
        dst = os.path.join(self.night_dir, 'queue.json')
        try:
            if not os.path.isdir(self.night_dir):
                os.makedirs(self.night_dir)
            if os.path.exists(dst):
                self.log('並べた回の写しはもうあります（上書きしません）: %s' % dst)
                return
            # 一時ファイルに写してから置く（2026-10-26・codex レビュー B5）。
            # 直に copy すると、途中で切れた写しが「もうある」と見なされる
            tmp = dst + '.tmp'
            shutil.copy2(queue_path, tmp)
            os.replace(tmp, dst)
            self.log('並べた回を写しました: %s' % dst)
        except Exception as e:
            self.log('並べた回を写せませんでした（%s）: %s' % (dst, e))

    # ---- 回す ----
    def run(self, queue):
        self._copy_queue(self.a.queue)
        # もう completed の回は飛ばす。
        # 2026-10-26（codex レビュー A6・C2）：前は tryno=1 の名前だけ見ていたので、
        # 「初回 failed・やり直し（_try2）completed」の回を再開すると、
        # もう一度 初回 を回し、そのあと _try2 まで回してしまう道があった。
        # **初回と _try2 の両方**を見て、どちらかが completed なら飛ばす。
        # また、_try2 の出力がもうある回は、やり直しをもう使ったものとして扱う
        skip = L.completed_folders(self.unity_dir)
        items, skipped = [], 0
        self.tried2 = set()
        for item in queue['runs']:
            cond = parse_condition(item['condition'])
            if cond is None:
                continue
            pb = norm_pass_by(item.get('pass_by', 'off'))
            f1 = folder_name(self.a.night, self.a.label, self.token, cond, item['seed'], pb, 1)
            f2 = folder_name(self.a.night, self.a.label, self.token, cond, item['seed'], pb, 2)
            if f1 in skip or f2 in skip:
                skipped += 1
                continue
            # やり直しの行がもう台帳にある（completed でなくても）なら、
            # その回のやり直しは使い切っている
            if f2 in L.all_folders(self.unity_dir):
                self.tried2.add(f1)
            items.append(item)
        self.log('並べた回 %d / もう completed の回を飛ばす %d / 回す %d'
                 % (len(queue['runs']), skipped, len(items)))

        queue_items = list(items)
        while queue_items or self.running or self.retry:
            # KIOXIA が外れたら、新しい回を始めない
            if not self.stopping and not volume_ok(self.a.volume, self.unity_dir):
                self.stopping = True
                self.log('KIOXIA が外れました。新しい回は始めません。'
                         '走っている %d 本が終わったら止まります' % len(self.running))

            while (not self.stopping and len(self.running) < self.a.parallel
                   and (queue_items or self.retry)):
                # 1本ごとに確かめる（2026-10-26・codex レビュー C3）。
                # 前は外側のループだけだったので、4本を立ち上げている途中に
                # 外れると、残りを始めてしまった
                if not volume_ok(self.a.volume, self.unity_dir):
                    self.stopping = True
                    self.log('KIOXIA が外れました（立ち上げの途中）。'
                             'これ以上は始めません。走っている %d 本が終わったら止まります'
                             % len(self.running))
                    break
                if queue_items:
                    self.start_one(queue_items.pop(0), 1)
                else:
                    self.start_one(self.retry.pop(0), 2)
                if self.a.stagger > 0 and not self.a.dry_run:
                    time.sleep(self.a.stagger)

            if not self.running:
                if self.stopping:
                    break
                if not queue_items and not self.retry:
                    break
                continue
            if not self.reap():
                time.sleep(self.a.poll)

        self._runs_csv_close()
        return not self.stopping

    def record_interrupted(self, why):
        """
        中断されたとき、まだ走っている回を runs.csv に残す
        （2026-10-26・codex レビュー A9）。
        Ctrl+C や SIGTERM では、前は子を止めるだけで記録が残らなかった。
        ★ SIGKILL・電源断は救えない。その場合も runner_log.txt の
          「始めました」の行に開始時刻が残るので、そこから追える
        """
        now = time.time()
        for r in list(self.running):
            wall = now - r['started']
            self._runs_csv_row(r, 'interrupted', None, wall)
            self.log('中断で記録しました（%s）: %s / 実時間 %.1f 分'
                     % (why, r['folder'], wall / 60.0))
        self._runs_csv_close()

    # ---- まとめ ----
    def summary(self):
        n = {}
        for _, status, _, _, _ in self.done:
            n[status] = n.get(status, 0) + 1
        self.log('--- まとめ ---')
        for k in sorted(n):
            self.log('  %s: %d 回' % (k, n[k]))
        walls = [w for _, s, w, _, _ in self.done if s == 'completed' and w > 0]
        if walls:
            self.log('  completed の実時間: 平均 %.1f 分 / 最長 %.1f 分'
                     % (sum(walls) / len(walls) / 60.0, max(walls) / 60.0))
        return n

    def call_judge(self):
        judge = os.path.join(HERE, 'judge_night.py')
        if not os.path.exists(judge):
            self.log('judge_night.py がないので判定は呼びません: %s' % judge)
            return None
        pattern = '%s_%s_*' % (self.a.night, self.a.label)
        out = os.path.join('night', self.a.night)
        cmd = [sys.executable, judge, '--runs', pattern, '--out', out]
        if self.a.baseline:
            cmd += ['--baseline', self.a.baseline]
        self.log('判定を呼びます（%s で）: %s' % (self.unity_dir, ' '.join(cmd)))
        if self.a.dry_run:
            self.log('（から回しなので呼びません）')
            return None
        r = subprocess.run(cmd, cwd=self.unity_dir, capture_output=True, text=True)
        if r.returncode != 0:
            self.log('判定が失敗しました（終了コード %d）: %s' % (r.returncode, r.stderr.strip()))
        else:
            self.log('判定を書きました: %s' % os.path.join(self.unity_dir, out, 'judge.md'))
        return r.returncode


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--queue', default=os.path.join(PROJECT, 'queue.json'))
    ap.add_argument('--parallel', type=int, default=2, help='同時に走らせる本数')
    ap.add_argument('--night', required=True, help='その夜の名前（例 20261026）')
    ap.add_argument('--label', default='night', help='出力フォルダに入る名札')
    ap.add_argument('--build', default=DEFAULT_BUILD, help='画面なしの実行ファイル')
    ap.add_argument('--volume', default=DEFAULT_VOLUME)
    ap.add_argument('--unity-dir', default=DEFAULT_UNITY_DIR)
    ap.add_argument('--limit-min', type=float, default=DEFAULT_LIMIT_MIN,
                    help='1回の上限（分）。超えたら止めて stopped にする')
    ap.add_argument('--baseline', default='', help='判定に渡す基準データの glob')
    ap.add_argument('--stagger', type=float, default=5.0,
                    help='同時に起こすときに少しずらす秒数（焼き直しがぶつからないように）')
    ap.add_argument('--poll', type=float, default=5.0, help='終わったかを見に行く間隔（秒）')
    ap.add_argument('--no-retry', action='store_true', help='止まった回をやり直さない')
    ap.add_argument('--no-caffeinate', action='store_true')
    ap.add_argument('--dry-run', action='store_true',
                    help='実行ファイルを起こさず、打つコマンドだけ見る')
    a = ap.parse_args()

    if a.parallel < 1:
        sys.exit('--parallel は 1 以上にしてください')
    if not re.match(r'^\d{8}$', a.night):
        sys.exit('--night は 20261026 の形にしてください')
    if not os.path.exists(a.queue):
        sys.exit('並べた回がありません: %s（先に make_queue.py）' % a.queue)

    # ★ 書くより先にマウントを確かめる（2026-10-26・codex レビュー D6）。
    # 前はここで夜フォルダを作ってログを開いていたので、
    # KIOXIA がつながっていないと **Mac の中に同じ道のフォルダを作って**
    # 書き始めてしまった（precheck はそのあと）
    if not volume_ok(a.volume, a.unity_dir):
        sys.exit('KIOXIA がつながっていません（%s / %s）。\n'
                 '  何も作らずに終わります（Mac の中には書きません）'
                 % (a.volume, a.unity_dir))

    night_dir = os.path.join(a.unity_dir, 'night', a.night)
    try:
        if not os.path.isdir(night_dir):
            os.makedirs(night_dir)
    except OSError as e:
        sys.exit('記録の置き場所を作れません: %s\n  %s' % (night_dir, e))
    log = Log(os.path.join(night_dir, 'runner_log.txt'))

    with open(a.queue, encoding='utf-8') as f:
        queue = json.load(f)

    log('=== 夜の実行係を始めます（night %s・同時 %d 本・上限 %.1f 分）==='
        % (a.night, a.parallel, a.limit_min))
    log('並べた回: %s（%d 回）' % (a.queue, len(queue.get('runs') or [])))

    r = Runner(a, log)
    if not r.precheck(queue):
        log('確かめで引っかかったので始めません')
        log.close()
        return 1

    caff = None
    if not a.no_caffeinate and not a.dry_run:
        try:
            caff = subprocess.Popen(['/usr/bin/caffeinate', '-dimsu'])
            log('スリープを止めました（caffeinate pid %d）' % caff.pid)
        except Exception as e:
            log('caffeinate を起こせませんでした（手でスリープを切ってください）: %s' % e)

    # SIGTERM（kill や再起動）でも、Ctrl+C と同じ道を通して記録を残す（A9）
    def on_term(signum, frame):
        raise KeyboardInterrupt()
    try:
        signal.signal(signal.SIGTERM, on_term)
        signal.signal(signal.SIGHUP, on_term)
    except Exception:
        pass

    code = 0
    try:
        finished = r.run(queue)
        n = r.summary()
        if getattr(r, 'ledger_failed', None):
            log('★ 台帳に書けなかった回が %d 件あります（終了コード 3）:' % len(r.ledger_failed))
            for f in r.ledger_failed:
                log('    %s' % f)
            log('  台帳の鍵が残っていないか・ヘッダが合っているかを見てください')
            code = 2
        if getattr(r, 'lost', 0):
            log('★ 記録（runs.csv）が %d 回ぶん書けませんでした。'
                '重なりの計算はその分だけ抜けます' % r.lost)
        if not finished:
            log('KIOXIA が外れたので、途中で止まりました')
            code = 2
        elif n.get('failed') or n.get('stopped'):
            code = 2
        if finished and not a.dry_run:
            r.call_judge()
    except KeyboardInterrupt:
        log('手で止められました。走っている回を止めます')
        for x in list(r.running):
            r._kill(x['proc'])
        r.record_interrupted('手で止めた')        # runs.csv に残す（A9）
        code = 2
    finally:
        if caff is not None:
            try:
                caff.terminate()
                log('スリープの設定を戻しました')
            except Exception:
                pass
        log('=== 夜の実行係を終わります（終了コード %d）===' % code)
        log.close()
    return code


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