#!/usr/bin/env python3
"""ledger.py — 台帳 unity_ledger_v1.csv に鍵をかけて1行足す（依頼 v12 の 3 章）

夜は実行ファイルを何本も同時に走らせるので、台帳に同時に書かないよう鍵をかける。
ここは **台帳を壊すと戻しにくい所** なので、決まりを狭くしてある。

決まり（C# の ServiceSimLedger と同じ）
  ・**追記だけ**。前の行は読むだけで、書き換えない・消さない
  ・同じ run_folder の行は2度書かない
  ・列の並びは **実体の1行め（ヘッダ）が正**。
    C# 側が「ヘッダとコードの列が1字でも違えば追記しない」を守っているので、
    実体のヘッダをそのまま信じれば、C# と Python の列がずれることはない。
    そのうえで、頭の並びと ledger_schema_version があることだけ確かめる
  ・値にカンマ・引用符・改行は入れない（CSV を壊さないため）

鍵
  unity_ledger_v1.lock を O_EXCL で作れたら持ち主。閉じるときに消す。
  作れなければ少し待って何度か試す。
  C# から new FileStream(lock, FileMode.CreateNew) で取るのと同じやり方なので、
  Unity 側（依頼 v12 の 3 章）と同じ鍵を取り合える。

  中身は「札 pid ホスト名 unix秒」の1行。札はこの回だけの字。
  2026-10-26（依頼 v14 の 1）：**古い鍵を年だけ見て壊す処理はやめた**。
  2本が同時に「古い」と判定すると、先に取り直した側の新しい鍵を
  もう一方が消して二重所有になりうるため。いまは
    ・待ちの上限（既定 60 秒）を過ぎたら LedgerError（＝台帳に書けなかった）
    ・取れたあと、読み直して自分の札か確かめる
    ・返すときも、自分の札のときだけ消す
  待つ長さは単調時計（time.monotonic）で測る。
  鍵が残ったときは人が中身を見て消す（自動では消さない）。
"""
import csv
import errno
import os
import socket
import uuid
import time

LEDGER_NAME = 'unity_ledger_v1.csv'
LOCK_NAME = 'unity_ledger_v1.lock'

# ヘッダの頭の並び（ここだけは変わらない前提で確かめる）
HEADER_PREFIX = ('timestamp', 'run_folder', 'status', 'unity_cs',
                 'layout_generation', 'params_model')
# これが無ければ台帳と見なさない
HEADER_MUST_HAVE = ('seed', 'store', 'robots', 'ledger_schema_version')

# 台帳の列（59列）。C# の ServiceSimLedger.Columns と **同じ名前・同じ順番**。
# 2026-10-26（依頼 v14 の 3）：前は「頭の6列」と「末尾10列」だけ見ていたので、
# 途中の列の並びが変わった台帳を通してしまった。全部の名前と順番を照合する。
# **列を足すときは、ここと C# の両方を直す**（data_manifest_rules.md 10節）
COLUMNS = (
    'timestamp', 'run_folder', 'status', 'unity_cs', 'layout_generation', 'params_model',
    'store', 'staff', 'robots', 'arrivals_per_h', 'mode', 'trays', 'fragile_by', 'seed',
    'staff_first_yield', 'yield_order_enabled', 'one_robot_per_aisle',
    'robot_first_wait_s', 'everyone_left', 'total_minutes', 'wall_seconds',
    'entry_wait_mean', 'staff_wait_mean', 'dish_wait_mean', 'cold_share', 'robot_share',
    'staff_util', 'robot_util', 'walk_km_per_staff', 'site_entry_wait_mean',
    'site_staff_wait_mean', 'site_dish_wait_mean', 'site_robot_share', 'robot_speed_mps',
    'pause_rate', 'pause_s_mean', 'stuck_rate', 'trips', 'timeouts', 'stuck_count',
    'stop_s_total', 'corner_stop_time_share', 'gave_up', 'warped', 'table_freed',
    'rescues', 'late_staff_dishes', 'staff_yield_share_of_staff_time',
    'ledger_schema_version', 'pay_wait_mean', 'site_pay_wait_mean', 'blocked_wait_mean',
    'site_blocked_wait_mean', 'turn_mean', 'site_turn_mean', 'clear_wait_mean_s',
    'clear_wait_max_s', 'clear_wait_over_count', 'pay_job_wait_mean_s')

EXPECT_COL_COUNT = len(COLUMNS)

# 台帳の版（C# の ServiceSimLedger.SchemaVersion と同じ値）。
# Python が書く行（止めた回・落ちた回）にも入れる（codex レビュー D7）
SCHEMA_VERSION = 2

# 2026-10-26（依頼 v14 の 1）：古い鍵を年だけ見て壊す処理はやめた。
# 鍵が残ったときは待ちの上限で失敗にし、人が中身を見て消す


class LedgerError(Exception):
    pass


# ---------------- 鍵 ----------------

class Lock(object):
    """with Lock(dir) as lk: ... の形で使う"""

    def __init__(self, unity_dir, timeout=60.0, poll=0.25, log=None):
        self.path = os.path.join(unity_dir, LOCK_NAME)
        self.timeout = timeout
        self.poll = poll
        self._fd = None
        self._log = log
        # この回だけの札（C# の NewTag と同じ使い方）
        self._tag = 'p%d-%s' % (os.getpid(), uuid.uuid4().hex[:12])

    def _body(self):
        return '%s %d %s %f\n' % (self._tag, os.getpid(), socket.gethostname(), time.time())

    def _tag_of(self):
        """鍵の中身の先頭（札）。読めなければ None"""
        try:
            with open(self.path, encoding='utf-8') as f:
                line = f.readline()
        except OSError:
            return None
        if not line:
            return ''
        return line.split(' ', 1)[0].strip()

    def _note(self, msg):
        if self._log:
            self._log(msg)

    def __enter__(self):
        start = time.monotonic()           # 待つ長さは単調時計で測る
        while True:
            try:
                self._fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
                os.write(self._fd, self._body().encode('utf-8'))
                os.fsync(self._fd)
                if self._tag_of() == self._tag:    # 取れたあと読み直して確かめる
                    return self
                self._note('台帳の鍵を取り直します（取った直後に札が自分ではありません）: %s'
                           % self.path)
                try:
                    os.close(self._fd)
                except OSError:
                    pass
                self._fd = None
            except OSError as e:
                if e.errno != errno.EEXIST:
                    raise
            # ★ 古い鍵を **年だけ見て壊すことはしない**（依頼 v14 の 1）。
            #   2本が同時に「古い」と見ると、先に取り直した側の新しい鍵を
            #   もう一方が消して二重所有になりうる。
            #   鍵が残ったときは、上限を過ぎて LedgerError を投げ、
            #   呼ぶ側が「台帳に書けなかった回」として扱う（残った鍵は人が消す）
            if time.monotonic() - start >= self.timeout:
                raise LedgerError(
                    '台帳の鍵を %.0f 秒待っても取れません: %s\n'
                    '  ほかの回が書いている途中か、前の回の鍵が残っています。\n'
                    '  残っているなら、中身（札 pid ホスト名 時刻）を見てから'
                    '手で消してください（自動では消しません）'
                    % (self.timeout, self.path))
            time.sleep(self.poll)

    def __exit__(self, *exc):
        if self._fd is not None:
            try:
                os.close(self._fd)
            except OSError:
                pass
            self._fd = None
        # 自分の札のときだけ消す（A2）
        now = self._tag_of()
        if now is None:
            return False
        if now != self._tag:
            self._note('台帳の鍵が自分のものではないので消しません: %s（いま %s / 自分 %s）'
                       % (self.path, now, self._tag))
            return False
        try:
            os.unlink(self.path)
        except OSError:
            pass
        return False


# ---------------- 読む ----------------

def ledger_path(unity_dir):
    return os.path.join(unity_dir, LEDGER_NAME)


def read_header(unity_dir):
    """実体の1行めを列の一覧で返す。台帳の形でなければ LedgerError"""
    p = ledger_path(unity_dir)
    if not os.path.exists(p):
        raise LedgerError('台帳がありません: %s' % p)
    with open(p, encoding='utf-8') as f:
        line = f.readline()
    if not line:
        raise LedgerError('台帳が空です（ヘッダがありません）: %s' % p)
    cols = tuple(line.rstrip('\r\n').split(','))
    if cols == COLUMNS:
        return list(cols)

    # どこが違うかを出す（直すときに分かるように）
    if len(cols) != EXPECT_COL_COUNT:
        raise LedgerError('台帳の列の数が %d ではありません（%d 列）: %s\n  実体: %s'
                          % (EXPECT_COL_COUNT, len(cols), p, ','.join(cols)))
    diff = [(i, a, b) for i, (a, b) in enumerate(zip(cols, COLUMNS)) if a != b]
    raise LedgerError('台帳のヘッダがコードの列と違います（%d か所）: %s\n%s'
                      % (len(diff), p,
                         '\n'.join('  %d 列め: 実体 %s / コード %s' % (i + 1, a, b)
                                   for i, a, b in diff[:5])))


def read_rows(unity_dir):
    """台帳の行を run_folder → 行（dict）で返す。読むだけ"""
    p = ledger_path(unity_dir)
    out = {}
    if not os.path.exists(p):
        return out
    with open(p, encoding='utf-8') as f:
        for r in csv.DictReader(f):
            folder = (r.get('run_folder') or '').strip()
            if folder:
                out[folder] = r
    return out


def has_row(unity_dir, run_folder):
    return run_folder in read_rows(unity_dir)


def all_folders(unity_dir):
    """
    台帳にある run_folder をぜんぶ返す（status は見ない）。
    2026-10-26（codex レビュー A6）：やり直し（_try2）の行がもうあるかを
    調べるのに使う。failed のやり直しを2度使わないため
    """
    try:
        return set(read_rows(unity_dir).keys())
    except (LedgerError, OSError):
        return set()


def completed_folders(unity_dir, status='completed'):
    return {k for k, r in read_rows(unity_dir).items()
            if (r.get('status') or '').strip() == status}


# ---------------- 書く ----------------

def clean(value):
    """CSV を壊さない字にする（C# の ServiceSimLedger.Clean と同じ）"""
    if value is None:
        return ''
    s = str(value)
    s = s.replace(',', ' ').replace('"', ' ')
    s = s.replace('\r', ' ').replace('\n', ' ')
    return s.strip()


def append_row(unity_dir, row, log=None, timeout=60.0):
    """
    台帳に1行足す。足したら True、足さなかったら False（理由は log に出す）。
    row は 列名 → 値。ヘッダに無い列名が入っていたら LedgerError（打ち間違いを通さない）。
    鍵は呼ぶ側で取らず、ここで取る。
    """
    def note(msg):
        if log:
            log(msg)

    folder = clean(row.get('run_folder'))
    if not folder:
        raise LedgerError('run_folder が空なので足しません（照合キーです）')

    with Lock(unity_dir, timeout=timeout, log=log):
        cols = read_header(unity_dir)          # 鍵を取ってから読む
        unknown = [k for k in row if k not in cols]
        if unknown:
            raise LedgerError('台帳に無い列名です: %s' % ', '.join(sorted(unknown)))

        if folder in read_rows(unity_dir):
            note('もう台帳にあるので足しません（2度書かない）: %s' % folder)
            return False

        line = ','.join(clean(row.get(c)) for c in cols)
        if line.count(',') != len(cols) - 1:
            raise LedgerError('列の数が合いません（%d 列のはず）' % len(cols))

        p = ledger_path(unity_dir)
        # 末尾が改行で終わっていなければ **足さない**（2026-10-26・codex レビュー A4）。
        # 前は改行を1つ継ぎ足していたが、それでは
        # 「前の追記が途中で切れた」ことが台帳の中に黙って残る。
        # C# 側（ServiceSimLedger.EndsWithNewline）と同じ断り方にそろえた。
        # 自動で直さない（台帳は書き換えない決まり）
        with open(p, 'rb') as f:
            if f.seek(0, os.SEEK_END) > 0:
                f.seek(-1, os.SEEK_END)
                if f.read(1) != b'\n':
                    raise LedgerError(
                        '台帳の最後が改行で終わっていません（前の追記が途中で切れた疑い）: %s\n'
                        '  つなげて書くと列がずれるので、何も足しませんでした。\n'
                        '  最後の行を見て、エイイチに相談してから直してください'
                        '（自動では直しません）' % p)
        # 1行ぶんを UTF-8 の byte にしてから **1回で** 書く（依頼 v14 の 2）
        data = (line + '\n').encode('utf-8')
        with open(p, 'ab') as f:
            f.write(data)
            f.flush()
            os.fsync(f.fileno())
        note('台帳に1行足しました: %s / %s' % (folder, clean(row.get('status'))))
        return True
