#!/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 章）と同じ鍵を取り合える。
  持ち主が落ちて鍵が残ったときは、中の時刻を見て古ければ1度だけ壊す。
"""
import csv
import errno
import os
import socket
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')

LOCK_STALE_SEC = 120.0      # これより古い鍵は、持ち主が落ちたと見て壊す


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

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

    def __enter__(self):
        end = time.time() + self.timeout
        broke = False
        while True:
            try:
                self._fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
                os.write(self._fd, ('%d %s %f\n' % (os.getpid(), socket.gethostname(),
                                                    time.time())).encode('utf-8'))
                return self
            except OSError as e:
                if e.errno != errno.EEXIST:
                    raise
            # もう誰かが持っている
            if not broke and self._stale():
                broke = True
                self._note('台帳の鍵が %.0f 秒より古いので壊します: %s'
                           % (LOCK_STALE_SEC, self.path))
                try:
                    os.unlink(self.path)
                except OSError:
                    pass
                continue
            if time.time() >= end:
                raise LedgerError('台帳の鍵を %.0f 秒待っても取れません: %s'
                                  % (self.timeout, self.path))
            time.sleep(self.poll)

    def _stale(self):
        try:
            age = time.time() - os.path.getmtime(self.path)
        except OSError:
            return False
        return age > LOCK_STALE_SEC

    def __exit__(self, *exc):
        if self._fd is not None:
            try:
                os.close(self._fd)
            except OSError:
                pass
            self._fd = None
        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 = line.rstrip('\r\n').split(',')
    if tuple(cols[:len(HEADER_PREFIX)]) != HEADER_PREFIX:
        raise LedgerError('台帳のヘッダの頭が違います: %s\n  実体: %s\n  待っていたもの: %s'
                          % (p, ','.join(cols[:len(HEADER_PREFIX)]), ','.join(HEADER_PREFIX)))
    missing = [c for c in HEADER_MUST_HAVE if c not in cols]
    if missing:
        raise LedgerError('台帳のヘッダに %s がありません: %s' % (missing, p))
    return cols


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 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)
        # 1行めが改行で終わっていない台帳に足すと行がくっつくので、確かめてから足す
        with open(p, 'rb') as f:
            if f.seek(0, os.SEEK_END) > 0:
                f.seek(-1, os.SEEK_END)
                needs_nl = f.read(1) != b'\n'
            else:
                needs_nl = False
        with open(p, 'a', encoding='utf-8', newline='') as f:
            if needs_nl:
                f.write('\n')
            f.write(line + '\n')
            f.flush()
            os.fsync(f.fileno())
        note('台帳に1行足しました: %s / %s' % (folder, clean(row.get('status'))))
        return True
