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)
        {
            if (!File.Exists(path) || string.IsNullOrEmpty(runFolder)) 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)
            {
                Debug.LogWarning("[ServiceSim] 台帳を読めませんでした（照合を飛ばします）: " + 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])));
            }

            try
            {
                string dir = Path.GetDirectoryName(path);
                if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
            }
            catch (Exception e)
            {
                why = "台帳の置き場所が作れません: " + path + "\n  " + e.Message;
                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;
                if (HasRow(path, folder))
                {
                    why = "もう台帳にあるので足しません（2度書かない）: " + folder;
                    return false;
                }

                try
                {
                    bool makeHeader = !File.Exists(path);
                    using (FileStream fs = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.Read))
                    using (StreamWriter w = new StreamWriter(fs, new UTF8Encoding(false)))
                    {
                        if (makeHeader) w.Write(HeaderLine + "\n");
                        w.Write(sb.ToString() + "\n");
                        w.Flush();
                        fs.Flush(true);          // 落ちても行が消えないよう、ディスクまで落とす
                    }
                }
                catch (Exception e)
                {
                    why = "台帳に書けません: " + path + "\n  " + e.Message;
                    return false;
                }
                return true;
            }
            finally
            {
                key.Dispose();               // 鍵ファイルを消す
            }
        }

        // ---------------- 鍵（依頼 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);
            DateTime end = DateTime.UtcNow.AddSeconds(LockTimeoutS);
            bool broke = false;

            while (true)
            {
                try
                {
                    FileStream fs = new FileStream(lockPath, FileMode.CreateNew,
                                                   FileAccess.Write, FileShare.None);
                    try
                    {
                        string body = Pid() + " " + HostName() + " " +
                                      UnixNow().ToString("0.000", CultureInfo.InvariantCulture) + "\n";
                        byte[] bytes = new UTF8Encoding(false).GetBytes(body);
                        fs.Write(bytes, 0, bytes.Length);
                        fs.Flush();
                    }
                    catch (Exception) { /* 中身が書けなくても、鍵そのものは取れている */ }
                    return new LedgerKey(fs, lockPath);
                }
                catch (IOException)
                {
                    // もう誰かが持っている（か、作れない）
                }
                catch (Exception e)
                {
                    why = "台帳の鍵が作れません: " + lockPath + "\n  " + e.Message;
                    return null;
                }

                if (!broke && StaleLock(lockPath))
                {
                    broke = true;
                    Debug.LogWarning("[ServiceSim] 台帳の鍵が " + LockStaleS +
                                     " 秒より古いので壊します: " + lockPath);
                    try { File.Delete(lockPath); } catch (Exception) { }
                    continue;
                }

                if (DateTime.UtcNow >= end)
                {
                    why = "台帳の鍵を " + LockTimeoutS + " 秒待っても取れません: " + lockPath +
                          "\n  ほかの回が書いている途中か、鍵が残っています";
                    return null;
                }
                System.Threading.Thread.Sleep(250);
            }
        }

        static bool StaleLock(string lockPath)
        {
            try
            {
                if (!File.Exists(lockPath)) return false;
                return (DateTime.UtcNow - File.GetLastWriteTimeUtc(lockPath)).TotalSeconds > LockStaleS;
            }
            catch (Exception) { return false; }
        }

        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;

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

            public void Dispose()
            {
                if (_fs != null)
                {
                    try { _fs.Dispose(); } catch (Exception) { }
                    _fs = null;
                }
                try { if (File.Exists(_path)) 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;
            }
        }
    }
}
