using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// 段階Bの通し台帳（2026-10-23 エイイチと決めた。依頼 v7）。
    ///
    /// 実体: {kioxiaOutputRoot}/unity_ledger_v1.csv
    ///       （MASTER の保存先「service_sim/unity/」の中。新しい置き場所は作らない）
    ///
    /// 決まり:
    ///   ・1回 = 1行・**追記のみ**。既存の行は書き換えない・消さない
    ///   ・同じ出力フォルダの行を2度書かない（run_folder が照合キー）
    ///   ・**追記ガード**：実体のヘッダとこのコードの列の並びが1字でも違えば追記しない
    ///   ・止めた回・失敗した回も status を付けて1行残す
    ///   ・書くときは **鍵をかける**（依頼 v12 の 3 章）。夜は実行ファイルを
    ///     何本も同時に走らせるので、同時に追記すると行が混ざる
    ///
    /// 列の名前と並びを変えるときは、先にエイイチに聞く（依頼 v7 の 2）。
    ///
    /// なぜ Editor/ の外にあるか（2026-10-26・依頼 v12 の 3 章）
    ///   画面なしの実行ファイル（BuildPipeline で作る player）には
    ///   Editor/ の中のコードが入らない。けれど夜の回も**同じコード**で
    ///   台帳に書かないと、列の作り方が2通りに分かれてしまう。
    ///   このクラスは UnityEditor の API を1つも使っていなかったので、
    ///   Experiment/ に移して player からも呼べるようにした。
    ///   中身（列・ガード・2度書かない決まり）は移すときに変えていない。
    ///   名前空間も ServiceSim にした。呼んでいたのは ServiceSimStageB だけで、
    ///   そちらは ServiceSim.EditorTools の中（= ServiceSim の内側）にあるので、
    ///   呼ぶ側の字は1つも変わらない。
    /// </summary>
    public static class ServiceSimLedger
    {
        /// <summary>
        /// 台帳の版。列を足したら 1 つ上げる。
        ///   1 … 依頼 v7（49 列）
        ///   2 … 依頼 v10b（会計待ち・片付け待ちの 10 列を後ろに足した。59 列）
        /// </summary>
        public const int SchemaVersion = 2;
        public const string FileName = "unity_ledger_v1.csv";

        /// <summary>
        /// 鍵ファイルの名前（依頼 v12 の 3 章）。
        /// tools/ledger.py の LOCK_NAME と同じ字。
        /// O_EXCL / FileMode.CreateNew で作れたら持ち主、閉じるときに消す。
        /// 中身は「pid ホスト名 unix秒」の1行（Python 側と同じ形）
        /// </summary>
        public const string LockFileName = "unity_ledger_v1.lock";

        /// <summary>鍵を待つ上限（秒）。これを超えたら書かずにあきらめる</summary>
        public const float LockTimeoutS = 60f;

        /// <summary>これより古い鍵は、持ち主が落ちたと見て1度だけ壊す（Python 側と同じ 120 秒）</summary>
        public const float LockStaleS = 120f;

        /// <summary>
        /// unity_cs の頭に付ける字（依頼 v11 の 2）。
        ///
        /// このプロジェクト（Robotics_UX_New から分けた ServiceSim 専用）は
        /// changeset の番号が 1 から振り直されるので、
        /// 台帳の中で前のプロジェクトの "cs:144" などと混ざらないように
        /// "ServiceSim/cs:1" の形で書く。
        /// 台帳の列は増やさない（data_manifest_rules.md 10節）
        /// </summary>
        public const string CsPrefix = "ServiceSim/";

        /// <summary>
        /// 列（この順。名前を変えるときは先にエイイチに聞く）。
        ///
        /// 足すときは **いちばん後ろ**（ledger_schema_version より後ろ）に足す。
        /// 途中に入れると前の行の列の位置がずれて、古い行が読めなくなる（依頼 v10b の 1）。
        /// 前の行は新しい列が無いまま（短い行）で置いておく。書き換えない
        /// </summary>
        public static readonly string[] 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",
            // ---- 依頼 v10b の 1（2026-10-25）。ここから後ろは schema 2 で足した列 ----
            // 会計待ち・片付け待ちを試算と並べるための 10 列。
            // clear_* / pay_job_* は「仕事が板に載ってから取られるまで」の秒数
            "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"
        };

        public static string HeaderLine { get { return string.Join(",", Columns); } }

        /// <summary>台帳の実体の場所</summary>
        public static string PathOf(ExperimentPlan plan)
        {
            string root = plan != null && !string.IsNullOrEmpty(plan.kioxiaOutputRoot)
                ? plan.kioxiaOutputRoot
                : "/Volumes/KIOXIA/r2r2r-data/service_sim/unity";
            return Path.Combine(root, FileName);
        }

        // ---------------- 追記ガード ----------------

        /// <summary>
        /// 追記していいか。
        ///   ・実体が無い … いい（header を書いて作る）
        ///   ・1行めがコードの列と同じ … いい
        ///   ・違う … だめ（why に理由）
        /// </summary>
        public static bool CanAppend(string path, out string why)
        {
            why = "";
            if (!File.Exists(path)) return true;

            string first;
            try
            {
                using (StreamReader r = new StreamReader(path, new UTF8Encoding(false)))
                    first = r.ReadLine();
            }
            catch (Exception e)
            {
                why = "台帳を読めません: " + path + "\n  " + e.Message;
                return false;
            }

            if (first == null)
            {
                why = "台帳が空です（ヘッダがありません）: " + path +
                      "\n  いちど消すか、ヘッダを書いてください";
                return false;
            }

            string have = first.TrimEnd('\r', '\n');
            if (have == HeaderLine) return true;

            why = "台帳のヘッダがコードの列と違うので追記しません（追記ガード）: " + path +
                  "\n  実体: " + have +
                  "\n  コード: " + HeaderLine +
                  "\n  列をそろえるか、エイイチに聞いてから直してください";
            return false;
        }

        /// <summary>その出力フォルダの行がもうあるか（run_folder が照合キー）</summary>
        public static bool HasRow(string path, string runFolder)
        {
            bool unreadable;
            return HasRow(path, runFolder, out unreadable);
        }

        /// <summary>
        /// 同じ出力フォルダの行がもうあるか。
        ///
        /// 2026-10-26（codex レビュー A5）：読めなかったときに false（＝行なし）を
        /// 返していたので、読めないまま2行目を書く道があった。
        /// unreadable で「分からない」を返し、呼ぶ側は **書かない** ようにする
        /// </summary>
        public static bool HasRow(string path, string runFolder, out bool unreadable)
        {
            unreadable = false;
            if (string.IsNullOrEmpty(runFolder)) return false;
            if (!File.Exists(path)) return false;

            int col = IndexOf("run_folder");
            try
            {
                using (StreamReader r = new StreamReader(path, new UTF8Encoding(false)))
                {
                    r.ReadLine();                       // ヘッダ
                    string line;
                    while ((line = r.ReadLine()) != null)
                    {
                        if (line.Length == 0) continue;
                        string[] f = line.Split(',');
                        if (col < f.Length && f[col] == runFolder) return true;
                    }
                }
            }
            catch (Exception e)
            {
                // 読めないときは「行が無い」と決めつけない（2026-10-26・A5）
                unreadable = true;
                Debug.LogWarning("[ServiceSim] 台帳を読めませんでした: " + e.Message);
            }
            return false;
        }

        /// <summary>
        /// 台帳の最後が改行で終わっているか（2026-10-26・codex レビュー A4）。
        ///
        /// 追記の途中で落ちると、最後の行が半端なまま残りうる。
        /// そこへ次の行を足すと **2つの行がつながって1行**になり、
        /// 列がずれた行が台帳に入る。
        /// なので足す前に末尾を見て、改行で終わっていなければ **書かない**。
        /// 自動で切り詰めることはしない（台帳は消さない・書き換えないのが決まり）
        /// </summary>
        public static bool EndsWithNewline(string path, out string why)
        {
            why = "";
            try
            {
                if (!File.Exists(path)) return true;
                using (FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
                {
                    if (fs.Length == 0) return true;
                    fs.Seek(-1, SeekOrigin.End);
                    int last = fs.ReadByte();
                    if (last == '\n') return true;
                    why = "台帳の最後が改行で終わっていません（前の追記が途中で切れた疑い）: " + path +
                          "\n  つなげて書くと列がずれるので、何も足しませんでした。" +
                          "\n  最後の行を見て、エイイチに相談してから直してください" +
                          "\n  （自動では直しません。台帳は書き換えない決まりです）";
                    return false;
                }
            }
            catch (Exception e)
            {
                why = "台帳の末尾を読めません: " + path + "\n  " + e.Message;
                return false;
            }
        }

        /// <summary>もう台帳にある出力フォルダの名前をぜんぶ返す（2度書きの歯止めに使う）</summary>
        public static HashSet<string> DoneFolders(string path)
        {
            HashSet<string> done = new HashSet<string>();
            if (!File.Exists(path)) return done;

            int col = IndexOf("run_folder");
            int statusCol = IndexOf("status");
            try
            {
                using (StreamReader r = new StreamReader(path, new UTF8Encoding(false)))
                {
                    r.ReadLine();
                    string line;
                    while ((line = r.ReadLine()) != null)
                    {
                        if (line.Length == 0) continue;
                        string[] f = line.Split(',');
                        if (col >= f.Length) continue;
                        // status に関係なく、1行あるものは「回した」とみなす
                        // （止めた回・失敗した回を何度も回し直さないため）
                        if (statusCol < f.Length && f[statusCol].Length == 0) continue;
                        done.Add(f[col]);
                    }
                }
            }
            catch (Exception e)
            {
                Debug.LogWarning("[ServiceSim] 台帳を読めませんでした: " + e.Message);
            }
            return done;
        }

        /// <summary>
        /// 回した回を照らし合わせるキー（依頼 v8 の 3）。
        ///
        /// **店・ロボットの台数・乱数の種・Unity の版** でそろえる。
        /// 出力フォルダ名（日付入り）では照らし合わせない。
        ///   ・同じ版・同じ条件・同じ種の行があれば、日付が違っても回さない
        ///   ・版が違えば回す（直したあとに同じ18回をもう一度回すため）
        /// </summary>
        public static string RunKey(string store, int robots, int seed, string unityCs)
        {
            return (store ?? "") + "|r" + robots + "|s" + seed + "|" + (unityCs ?? "");
        }

        /// <summary>台帳にある回のキーをぜんぶ返す（続きから回すのに使う）</summary>
        public static HashSet<string> DoneKeys(string path) { return DoneKeys(path, null); }

        /// <summary>
        /// 台帳にある回のキーを返す（続きから回すのに使う）。
        ///
        /// folderLabel を渡すと、run_folder に "_{名札}_" が入っている行だけを見る（依頼 v11 の 3）。
        /// 名札は「まとめて回した種類」で、stageB（基準データの18回）と
        /// split_check（移せたかの確かめ）を分けるために使う。
        /// こうしておくと、確かめで ③1台・種1 を2回回しても、
        /// そのあとの Run All が同じ条件を「済」として飛ばさない。
        /// 台帳の列は増やしていない（名札は run_folder の中にある）
        /// </summary>
        public static HashSet<string> DoneKeys(string path, string folderLabel)
        {
            HashSet<string> done = new HashSet<string>();
            if (!File.Exists(path)) return done;

            int cStore = IndexOf("store");
            int cRobots = IndexOf("robots");
            int cSeed = IndexOf("seed");
            int cCs = IndexOf("unity_cs");
            int cStatus = IndexOf("status");
            int cFolder = IndexOf("run_folder");
            string mark = string.IsNullOrEmpty(folderLabel) ? null : "_" + folderLabel + "_";

            try
            {
                using (StreamReader r = new StreamReader(path, new UTF8Encoding(false)))
                {
                    r.ReadLine();
                    string line;
                    while ((line = r.ReadLine()) != null)
                    {
                        if (line.Length == 0) continue;
                        string[] f = line.Split(',');
                        int need = Mathf.Max(Mathf.Max(cStore, cRobots), Mathf.Max(cSeed, cCs));
                        if (need >= f.Length) continue;
                        if (cStatus < f.Length && f[cStatus].Length == 0) continue;
                        if (mark != null)
                        {
                            if (cFolder >= f.Length) continue;
                            if (f[cFolder].IndexOf(mark, StringComparison.Ordinal) < 0) continue;
                        }

                        int robots, seed;
                        if (!int.TryParse(f[cRobots], out robots)) continue;
                        if (!int.TryParse(f[cSeed], out seed)) continue;
                        done.Add(RunKey(f[cStore], robots, seed, f[cCs]));
                    }
                }
            }
            catch (Exception e)
            {
                Debug.LogWarning("[ServiceSim] 台帳を読めませんでした: " + e.Message);
            }
            return done;
        }

        static int IndexOf(string name)
        {
            for (int i = 0; i < Columns.Length; i++) if (Columns[i] == name) return i;
            return -1;
        }

        // ---------------- 1行足す ----------------

        /// <summary>
        /// 1行追記する。ヘッダが合わない・同じ出力フォルダの行がもうある場合は足さない。
        /// row は列名 → 値。足りない列は空欄にする
        /// </summary>
        public static bool Append(string path, IDictionary<string, string> row, out string why)
        {
            why = "";

            string folder = Get(row, "run_folder");
            if (string.IsNullOrEmpty(folder))
            {
                why = "run_folder が空なので足しません（照合キーです）";
                return false;
            }

            // 書く字は鍵を取る前に作る（鍵を持っている時間を短くする）
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < Columns.Length; i++)
            {
                if (i > 0) sb.Append(',');
                sb.Append(Clean(Get(row, Columns[i])));
            }

            // ★ 本当にマウントされているかを確かめる（依頼 v14 の 7）。
            //   Directory.Exists だけだと、KIOXIA が外れたあとに残った
            //   /Volumes/KIOXIA というフォルダ（Mac の中）にも書けてしまう
            string mountWhy;
            if (!IsOnMountedVolume(path, out mountWhy))
            {
                why = mountWhy;
                return false;
            }

            // 2026-10-26（codex レビュー D6）：置き場所を **作らない**。
            // 作ってしまうと、KIOXIA が外れているときに Mac の中へ
            // 同じ道のフォルダを作って書いてしまう。
            // KIOXIA の置き場所は前からあるのが正しい形なので、無ければ書かない
            string dir = Path.GetDirectoryName(path);
            if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir))
            {
                why = "台帳の置き場所がありません（作りません）: " + dir +
                      "\n  KIOXIA がつながっているか確かめてください（Mac の中には書きません）";
                return false;
            }

            // ---- ここから鍵の中（依頼 v12 の 3 章）----
            // ヘッダの確かめと「もうあるか」の確かめも **鍵の中** でやる。
            // 外でやると、2本が同時に「まだ無い」と見てから両方書いてしまう
            string lockWhy;
            IDisposable key = TakeLock(path, out lockWhy);
            if (key == null)
            {
                why = lockWhy;
                return false;
            }
            try
            {
                if (!CanAppend(path, out why)) return false;

                // 末尾が半端なら足さない（A4）
                if (!EndsWithNewline(path, out why)) return false;

                bool unreadable;
                if (HasRow(path, folder, out unreadable))
                {
                    why = "もう台帳にあるので足しません（2度書かない）: " + folder;
                    return false;
                }
                if (unreadable)
                {
                    // 読めないまま書くと2行目になるかもしれない（A5）
                    why = "台帳を読めないので、2度書きを避けて足しませんでした: " + folder;
                    return false;
                }

                try
                {
                    // 1行ぶんを UTF-8 の byte にしてから **1回で** 書く（依頼 v14 の 2）。
                    // 何度も書くと、途中で落ちたときに半端な行が残りやすい
                    bool makeHeader = !File.Exists(path);
                    string text = (makeHeader ? HeaderLine + "\n" : "") + sb.ToString() + "\n";
                    byte[] bytes = new UTF8Encoding(false).GetBytes(text);
                    using (FileStream fs = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.Read))
                    {
                        fs.Write(bytes, 0, bytes.Length);
                        fs.Flush();
                        fs.Flush(true);          // 落ちても行が消えないよう、ディスクまで落とす
                    }
                }
                catch (Exception e)
                {
                    why = "台帳に書けません: " + path + "\n  " + e.Message;
                    return false;
                }
                return true;
            }
            finally
            {
                key.Dispose();               // 鍵ファイルを消す
            }
        }

        // ---------------- 本当にマウントされているか（依頼 v14 の 7）----------------

        /// <summary>
        /// その道が **マウントされたボリュームの上** にあるかを確かめる。
        ///
        /// なぜ Directory.Exists では足りないか：
        ///   KIOXIA を抜いたあと /Volumes/KIOXIA というフォルダが
        ///   Mac の中に残ることがある。Directory.Exists はそれを通すので、
        ///   外付けに書いたつもりで Mac の中に書いてしまう。
        ///
        /// やり方：DriveInfo.GetDrives()（＝いまマウントされている所の一覧）に、
        ///   その道の頭と一致する所があるかを見る。
        ///   一覧が取れない場合（player の実行環境による）は、
        ///   **台帳の実体があること**を代わりの印にして、そのことをログに出す
        /// </summary>
        public static bool IsOnMountedVolume(string path, out string why)
        {
            why = "";
            string full;
            try { full = Path.GetFullPath(path); }
            catch (Exception e)
            {
                why = "道を読めません: " + path + "\n  " + e.Message;
                return false;
            }

            // "/" だけのときは Mac の中。外付けに書く約束と合わない
            string best = null;
            bool listed = false;
            try
            {
                DriveInfo[] drives = DriveInfo.GetDrives();
                for (int i = 0; i < drives.Length; i++)
                {
                    DirectoryInfo root = null;
                    try { root = drives[i].RootDirectory; } catch (Exception) { }
                    if (root == null) continue;
                    listed = true;
                    string r = root.FullName;
                    if (r.Length > 1 && r.EndsWith("/", StringComparison.Ordinal))
                        r = r.Substring(0, r.Length - 1);
                    if (r.Length == 0) continue;
                    bool under = full == r ||
                                 full.StartsWith(r == "/" ? "/" : r + "/", StringComparison.Ordinal);
                    if (under && (best == null || r.Length > best.Length)) best = r;
                }
            }
            catch (Exception e)
            {
                Debug.LogWarning("[ServiceSim] マウントの一覧が取れません（代わりに台帳の実体で見ます）: " +
                                 e.Message);
            }

            if (listed)
            {
                if (best != null && best != "/") return true;
                why = "ここはマウントされた外付けではありません（Mac の中です）: " + full +
                      "\n  いちばん近いマウント地点: " + (best ?? "（見つかりません）") +
                      "\n  KIOXIA がつながっているか確かめてください（Mac の中には書きません）";
                return false;
            }

            // 一覧が取れなかったとき：台帳の実体があることを代わりの印にする
            if (File.Exists(path))
            {
                Debug.LogWarning("[ServiceSim] マウントの一覧が取れないので、" +
                                 "台帳の実体があることで代わりに確かめました: " + path);
                return true;
            }
            why = "マウントの一覧が取れず、台帳の実体もありません: " + full +
                  "\n  KIOXIA がつながっているか確かめてください";
            return false;
        }

        // ---------------- 鍵（依頼 v12 の 3 章）----------------

        /// <summary>
        /// 台帳の鍵ファイルの置き場所（台帳と同じフォルダ）。
        /// tools/ledger.py と同じ場所・同じ名前なので、
        /// Unity と Python が同じ鍵を取り合える
        /// </summary>
        public static string LockPathOf(string ledgerPath)
        {
            string dir = Path.GetDirectoryName(ledgerPath);
            return string.IsNullOrEmpty(dir) ? LockFileName : Path.Combine(dir, LockFileName);
        }

        /// <summary>
        /// 鍵を取る。取れたら Dispose で消える入れ物を返し、取れなければ null と理由を返す。
        ///
        /// やり方（tools/ledger.py と同じ）:
        ///   ・FileMode.CreateNew で作れたら持ち主。中身は「pid ホスト名 unix秒」
        ///   ・作れなければ少し待って何度も試す（上限 LockTimeoutS）
        ///   ・鍵が LockStaleS より古ければ、持ち主が落ちたと見て **1度だけ** 壊す
        /// </summary>
        public static IDisposable TakeLock(string ledgerPath, out string why)
        {
            why = "";
            string lockPath = LockPathOf(ledgerPath);

            // この回だけの札。鍵の中身の先頭に書き、返すときに
            // 「自分の鍵か」を確かめるのに使う
            string tag = NewTag();

            // 待つ長さは単調時計で測る（時計合わせで伸び縮みしないように）
            System.Diagnostics.Stopwatch clock = System.Diagnostics.Stopwatch.StartNew();

            while (true)
            {
                try
                {
                    FileStream fs = new FileStream(lockPath, FileMode.CreateNew,
                                                   FileAccess.Write, FileShare.Read);
                    bool mine = false;
                    try
                    {
                        byte[] bytes = new UTF8Encoding(false).GetBytes(LockBody(tag));
                        fs.Write(bytes, 0, bytes.Length);
                        fs.Flush();
                        mine = TagOf(lockPath) == tag;     // 取れたあと読み直して確かめる
                    }
                    catch (Exception) { mine = false; }

                    if (mine) return new LedgerKey(fs, lockPath, tag);

                    try { fs.Dispose(); } catch (Exception) { }
                    Debug.LogWarning("[ServiceSim] 台帳の鍵を取り直します" +
                                     "（取った直後に中身が自分の札ではありませんでした）: " + lockPath);
                }
                catch (IOException)
                {
                    // もう誰かが持っている（か、作れない）
                }
                catch (Exception e)
                {
                    why = "台帳の鍵が作れません: " + lockPath + "\n  " + e.Message;
                    return null;
                }

                // ★ 古い鍵を **年だけ見て壊すことはしない**（依頼 v14 の 1）。
                //   2本が同時に「古い」と見ると、先に取り直した側の新しい鍵を
                //   もう一方が消して二重所有になりうる。
                //   鍵が残ったときは、待ちの上限を過ぎて「台帳に書けなかった」として
                //   その回を失敗にする（残った鍵は人が消す）
                if (clock.Elapsed.TotalSeconds >= LockTimeoutS)
                {
                    why = "台帳の鍵を " + LockTimeoutS + " 秒待っても取れません: " + lockPath +
                          "\n  ほかの回が書いている途中か、前の回の鍵が残っています" +
                          "\n  残っているなら、中身（札 pid ホスト名 時刻）を見てから" +
                          "手で消してください（自動では消しません）";
                    return null;
                }
                System.Threading.Thread.Sleep(250);
            }
        }

        /// <summary>鍵の中身。「札 pid ホスト名 unix秒」の1行</summary>
        static string LockBody(string tag)
        {
            return tag + " " + Pid() + " " + HostName() + " " +
                   UnixNow().ToString("0.000", CultureInfo.InvariantCulture) + "\n";
        }

        /// <summary>鍵の中身の先頭（札）。読めなければ null</summary>
        static string TagOf(string lockPath)
        {
            try
            {
                if (!File.Exists(lockPath)) return null;
                using (FileStream fs = new FileStream(lockPath, FileMode.Open, FileAccess.Read,
                                                      FileShare.ReadWrite))
                using (StreamReader r = new StreamReader(fs, new UTF8Encoding(false)))
                {
                    string line = r.ReadLine();
                    if (line == null) return "";
                    int sp = line.IndexOf(' ');
                    return sp > 0 ? line.Substring(0, sp) : line;
                }
            }
            catch (Exception) { return null; }
        }

        /// <summary>この回だけの札（ほかの回とぶつからない字）</summary>
        static string NewTag()
        {
            return "k" + Pid().ToString(CultureInfo.InvariantCulture) + "-" +
                   Guid.NewGuid().ToString("N").Substring(0, 12);
        }

        static int Pid()
        {
            try { return System.Diagnostics.Process.GetCurrentProcess().Id; }
            catch (Exception) { return 0; }
        }

        static string HostName()
        {
            // Environment.MachineName（Python 側の socket.gethostname と同じ意味）。
            // SystemInfo はエンジンが要るので使わない（player でも Editor でも動く形にする）
            try { return Environment.MachineName; }
            catch (Exception) { return "unity"; }
        }

        static double UnixNow()
        {
            return (DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds;
        }

        /// <summary>取った鍵。Dispose で閉じて消す</summary>
        sealed class LedgerKey : IDisposable
        {
            FileStream _fs;
            readonly string _path;
            readonly string _tag;

            public LedgerKey(FileStream fs, string path, string tag)
            {
                _fs = fs; _path = path; _tag = tag;
            }

            public void Dispose()
            {
                if (_fs != null)
                {
                    try { _fs.Dispose(); } catch (Exception) { }
                    _fs = null;
                }
                // ★ 自分の札のときだけ消す（2026-10-26・codex レビュー A2）。
                //   前は中身を見ずに消していたので、
                //   自分の鍵が壊されたあとに他人の鍵を消す道があった
                try
                {
                    string now = TagOf(_path);
                    if (now == null) return;                  // もう無い・読めない
                    if (now != _tag)
                    {
                        Debug.LogWarning("[ServiceSim] 台帳の鍵が自分のものではないので消しません: " +
                                         _path + "\n  いま: " + now + " / 自分: " + _tag);
                        return;
                    }
                    File.Delete(_path);
                }
                catch (Exception) { }
            }
        }

        static string Get(IDictionary<string, string> row, string key)
        {
            string v;
            return row != null && row.TryGetValue(key, out v) && v != null ? v : "";
        }

        /// <summary>CSV を壊さないように、カンマ・改行・引用符は取り除く（値は数と短い名前だけ）</summary>
        static string Clean(string s)
        {
            if (string.IsNullOrEmpty(s)) return "";
            s = s.Replace(',', ' ').Replace('"', ' ');
            s = s.Replace("\r", " ").Replace("\n", " ");
            return s.Trim();
        }

        // ---------------- summary.json から1行を作る ----------------

        /// <summary>数を台帳の書き方にそろえる（小数4桁まで・指数表記にしない）</summary>
        public static string Num(float v)
        {
            return v.ToString("0.####", CultureInfo.InvariantCulture);
        }

        public static string Num(int v)
        {
            return v.ToString(CultureInfo.InvariantCulture);
        }

        public static string Flag(bool v) { return v ? "1" : "0"; }

        /// <summary>summary.json を読む（読めなければ null）</summary>
        public static JsonValue ReadSummary(string runDir)
        {
            string p = Path.Combine(runDir, "summary.json");
            if (!File.Exists(p)) return null;
            try
            {
                string err;
                JsonValue v = JsonValue.Parse(File.ReadAllText(p), out err);
                if (v == null || !v.IsObject)
                {
                    Debug.LogWarning("[ServiceSim] summary.json を読めません: " + p + "\n  " + err);
                    return null;
                }
                return v;
            }
            catch (Exception e)
            {
                Debug.LogWarning("[ServiceSim] summary.json を読めません: " + p + "\n  " + e.Message);
                return null;
            }
        }
    }
}
