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

namespace ServiceSim
{
    /// <summary>
    /// trips.csv・conditions.json・summary.json を書く。
    /// この script は実験だけを扱う。店・ロボット・人は動かさない。
    /// 1往復ぶんの値は ExperimentRunner が Row に詰めて渡す。
    /// </summary>
    public class TripLogger
    {
        /// <summary>trips.csv の1行</summary>
        public struct Row
        {
            public string conditionId;
            public float aisleWidthM;
            public string crowd;
            public int robots;
            public int seed;
            public int robotId;
            public int tripId;
            public int tableId;
            public float distanceM;
            public float totalS;
            public float stoppedS;
            public int nStops;
            public float[] stopDurationsS;
            public bool stuck;
            public bool timeout;
            public float waitAisleS;
            public float maxDecelMps2;
            public float minClearanceM;
            public bool metRobot;
            // v2：1行＝ロボットの1走り
            public int[] tableIds;          // 回った卓
            public int dishes;              // 載せた料理の数
            public float[] dishReadyS;      // 各料理ができた時刻
            public float[] dishArrivedS;    // 各料理が届いた時刻（-1 は届かなかった）
            public int undelivered;         // 届けられず受け渡し口に戻した数

            // ---- 往復ごとの測り（v5 3章・設計書2章の4つ）----
            /// <summary>その往復で実際に走れた速さ（m/秒）＝ distance_m /（total_s − stopped_s）</summary>
            public float speedMps;

            /// <summary>動けなくなってスタッフを呼んだ回数と、スタッフを待った秒数</summary>
            public int stuckCount;
            public float stuckWaitS;

            /// <summary>その往復でサイトの道順が 0.8 m の道を使ったか（routes の use が narrow）</summary>
            public bool viaNarrow;

            /// <summary>止まりの回数と秒数を、狭い通路の中と外に分けたもの（⑦の読み分け用）</summary>
            public int stopsInNarrow, stopsOutNarrow;
            public float stoppedInNarrowS, stoppedOutNarrowS;
        }

        /// <summary>groups.csv の1行（1組ぶん）</summary>
        public struct GroupRow
        {
            public string conditionId;
            public int seed;
            public int groupId;
            public int size;
            public int tableId;

            /// <summary>座った卓の大きさ（2 なら2人席、4 なら4人席。0 は座れなかった）</summary>
            public int tableSeats;
            public float arriveS, guideStartS, seatedS, eatEndS, payS, leftS;

            /// <summary>会計を始めた時刻（食べ終わって会計待ちに入った時刻, 秒）</summary>
            public float payStartS;
            public float waitedMin;
            public bool warmup;
            public string state;
        }

        /// <summary>stops.csv の1行（止まり1回ぶん）</summary>
        public struct StopRow
        {
            public string conditionId;
            public int seed;
            public int robotId;
            public int tripId;
            public float tS;            // 店の中の時刻
            public float x, z;
            public string aisle;        // 通路の名前

            /// <summary>
            /// サイトの決まりで「狭い通路」（幅が meta.settings.normal_width 未満）の中で
            /// 止まったか（v5 3章。⑦の読み分け。③は 1.2 m なのでいつも 0）
            /// </summary>
            public bool inNarrow;
            public string cause;
            public string causeDetail;
            public bool pathBlocked;    // 行き先への道が引けていなかったか
            public float durationS;
            public string resolved;
            public float clearanceLeftCm;
            public float clearanceRightCm;
            public float minGapCm;

            // 止まっている間、何で動けなかったかの内訳（秒）。B7 を調べるため
            public float blockedS;
            public float noPathS;
            public float yieldS;
            public float clearWaitS;
            public int replans;

            // 道を引いたときの中身（調べるためだけ。動きは変えない）
            /// <summary>寄せたあとの出発点・行き先（"x;z"）</summary>
            public string solveFrom, solveTo;
            /// <summary>CalculatePath の結果と、引けた角の数</summary>
            public string solveStatus;
            public int solveCorners;
            /// <summary>ロボットの位置から、いちばん近い焼いた面までの距離（m）。-1 は見つからない</summary>
            public float offMeshM;
        }

        /// <summary>
        /// 止まった原因の名前。観察シート（serving-robot-observation.html）の
        /// 「止まった原因」の選択肢にそろえる。Unity にしかない原因は足した。
        /// </summary>
        public static string CauseLabel(ServingRobotController.StopCause cause)
        {
            switch (cause)
            {
                case ServingRobotController.StopCause.Guest: return "お客さんとすれ違った";
                case ServingRobotController.StopCause.Staff: return "スタッフとすれ違った";
                case ServingRobotController.StopCause.PersonStanding: return "人が立ち止まっていた";
                case ServingRobotController.StopCause.Chair: return "椅子・荷物・ベビーカー";
                case ServingRobotController.StopCause.NoPath: return "通路の幅が足りない";
                case ServingRobotController.StopCause.OtherRobot: return "もう1台のロボット";
                case ServingRobotController.StopCause.AisleQueue: return "通路の順番待ち";
                default: return "分からない";
            }
        }

        /// <summary>どう動き出したかの名前</summary>
        public static string ResolveLabel(ServingRobotController.StopResolve r)
        {
            switch (r)
            {
                case ServingRobotController.StopResolve.SelfResume: return "自分で動き出した";
                case ServingRobotController.StopResolve.Replanned: return "経路を引き直した";
                case ServingRobotController.StopResolve.Stuck: return "動けなくなった";
                default: return "入口で待った";
            }
        }

        readonly List<Row> _rows = new List<Row>();
        readonly List<StopRow> _stops = new List<StopRow>();

        public int StopCount { get { return _stops.Count; } }

        /// <summary>
        /// 一時停止の回数の合計（pauses）。
        /// 往復ごとの n_stops（自分で動き出した止まり）を足す。
        /// 通路の入口で待った分と、動けなくなった分は入らない
        /// </summary>
        public int TotalStopCount()
        {
            int n = 0;
            for (int i = 0; i < _rows.Count; i++) n += _rows[i].nStops;
            return n;
        }
        public IReadOnlyList<StopRow> Stops { get { return _stops; } }

        public void AddStop(StopRow row) { _stops.Add(row); }

        public int RowCount { get { return _rows.Count; } }
        public IReadOnlyList<Row> Rows { get { return _rows; } }

        public void Add(Row row)
        {
            _rows.Add(row);
        }

        public void Clear()
        {
            _rows.Clear();
            _stops.Clear();
            _groups.Clear();
        }

        // ---------------- trips.csv ----------------

        public const string Header =
            "condition_id,aisle_width_m,crowd,robots,seed,robot_id,trip_id,table_id," +
            "distance_m,total_s,stopped_s,n_stops,stop_durations_s,stuck,timeout,wait_aisle_s," +
            "max_decel_mps2,min_clearance_m,met_robot," +
            "table_ids,dishes,dish_ready_s,dish_arrived_s,undelivered," +
            // v5 3章：往復ごとの測り（設計書2章の4つ）。⑦は狭い通路の中と外を分ける
            "speed_mps,stuck_count,stuck_wait_s,via_narrow," +
            "stops_in_narrow,stops_out_narrow,stopped_in_narrow_s,stopped_out_narrow_s";

        public void WriteTripsCsv(string path)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(Header).Append('\n');
            for (int i = 0; i < _rows.Count; i++) AppendRow(sb, _rows[i]);
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        static void AppendRow(StringBuilder sb, Row r)
        {
            sb.Append(r.conditionId).Append(',')
              .Append(F(r.aisleWidthM)).Append(',')
              .Append(r.crowd).Append(',')
              .Append(r.robots).Append(',')
              .Append(r.seed).Append(',')
              .Append(r.robotId).Append(',')
              .Append(r.tripId).Append(',')
              .Append(r.tableId).Append(',')
              .Append(F(r.distanceM)).Append(',')
              .Append(F(r.totalS)).Append(',')
              .Append(F(r.stoppedS)).Append(',')
              .Append(r.nStops).Append(',')
              .Append(Join(r.stopDurationsS)).Append(',')
              .Append(r.stuck ? 1 : 0).Append(',')
              .Append(r.timeout ? 1 : 0).Append(',')
              .Append(F(r.waitAisleS)).Append(',')
              .Append(F(r.maxDecelMps2)).Append(',')
              .Append(F(r.minClearanceM)).Append(',')
              .Append(r.metRobot ? 1 : 0).Append(',')
              .Append(JoinInt(r.tableIds)).Append(',')
              .Append(r.dishes).Append(',')
              .Append(Join(r.dishReadyS)).Append(',')
              .Append(Join(r.dishArrivedS)).Append(',')
              .Append(r.undelivered).Append(',')
              .Append(F(r.speedMps)).Append(',')
              .Append(r.stuckCount).Append(',')
              .Append(F(r.stuckWaitS)).Append(',')
              .Append(r.viaNarrow ? 1 : 0).Append(',')
              .Append(r.stopsInNarrow).Append(',')
              .Append(r.stopsOutNarrow).Append(',')
              .Append(F(r.stoppedInNarrowS)).Append(',')
              .Append(F(r.stoppedOutNarrowS))
              .Append('\n');
        }

        static string Join(float[] values)
        {
            if (values == null || values.Length == 0) return "";
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < values.Length; i++)
            {
                if (i > 0) sb.Append(';');
                sb.Append(F(values[i]));
            }
            return sb.ToString();
        }

        static string JoinInt(int[] values)
        {
            if (values == null || values.Length == 0) return "";
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < values.Length; i++)
            {
                if (i > 0) sb.Append(';');
                sb.Append(values[i]);
            }
            return sb.ToString();
        }

        static string F(float v)
        {
            return v.ToString("0.####", CultureInfo.InvariantCulture);
        }

        // ---------------- groups.csv ----------------

        readonly List<GroupRow> _groups = new List<GroupRow>();
        public int GroupRowCount { get { return _groups.Count; } }
        public void AddGroup(GroupRow r) { _groups.Add(r); }

        /// <summary>table_seats は座った卓の大きさ（2＝2人席・4＝4人席・0＝座れなかった）</summary>
        public const string GroupHeader =
            "condition_id,seed,group_id,size,table_id,table_seats,arrive_s,guide_start_s,seated_s," +
            "eat_end_s,pay_start_s,pay_s,left_s,waited_min,warmup,state";

        public void WriteGroupsCsv(string path)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(GroupHeader).Append('\n');
            for (int i = 0; i < _groups.Count; i++)
            {
                GroupRow r = _groups[i];
                sb.Append(r.conditionId).Append(',').Append(r.seed).Append(',')
                  .Append(r.groupId).Append(',').Append(r.size).Append(',').Append(r.tableId).Append(',')
                  .Append(r.tableSeats).Append(',')
                  .Append(F(r.arriveS)).Append(',').Append(F(r.guideStartS)).Append(',')
                  .Append(F(r.seatedS)).Append(',').Append(F(r.eatEndS)).Append(',')
                  .Append(F(r.payStartS)).Append(',')
                  .Append(F(r.payS)).Append(',').Append(F(r.leftS)).Append(',')
                  .Append(F(r.waitedMin)).Append(',').Append(r.warmup ? 1 : 0).Append(',')
                  .Append(r.state).Append('\n');
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- people_jams.csv ----------------

        public const string JamHeader =
            "condition_id,seed,t_s,x,z,aisle,people,duration_s,resolved,robot_blocked,robot_id";

        public static void WriteJamsCsv(string path, string conditionId, int seed,
                                        IReadOnlyList<JamWatcher.JamRow> rows)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(JamHeader).Append('\n');
            if (rows != null)
            {
                for (int i = 0; i < rows.Count; i++)
                {
                    JamWatcher.JamRow r = rows[i];
                    sb.Append(conditionId).Append(',').Append(seed).Append(',')
                      .Append(F(r.tS)).Append(',').Append(F(r.x)).Append(',').Append(F(r.z)).Append(',')
                      .Append(r.aisle).Append(',').Append(r.people).Append(',')
                      .Append(F(r.durationS)).Append(',').Append(r.resolved).Append(',')
                      .Append(r.robotBlocked ? 1 : 0).Append(',').Append(r.robotId).Append('\n');
                }
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- give_ups.csv（B1）----------------

        public const string GiveUpHeader =
            "condition_id,seed,t_s,who,job,table_id,to_x,to_z,x,z,aisle,waited_s,action";

        /// <summary>あきらめた・移した・卓を空けたことを1回1行で書く</summary>
        public static void WriteGiveUpsCsv(string path, string conditionId, int seed,
                                           IReadOnlyList<GiveUpRow> rows)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(GiveUpHeader).Append('\n');
            if (rows != null)
            {
                for (int i = 0; i < rows.Count; i++)
                {
                    GiveUpRow r = rows[i];
                    sb.Append(conditionId).Append(',').Append(seed).Append(',')
                      .Append(F(r.tS)).Append(',').Append(r.who).Append(',').Append(r.job).Append(',')
                      .Append(r.tableId).Append(',')
                      .Append(F(r.toX)).Append(',').Append(F(r.toZ)).Append(',')
                      .Append(F(r.x)).Append(',').Append(F(r.z)).Append(',')
                      .Append(r.aisle).Append(',').Append(F(r.waitedS)).Append(',')
                      .Append(r.action).Append('\n');
                }
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- contacts.csv（人がロボットに近づきすぎた）----------------

        public const string ContactHeader =
            "condition_id,seed,t_s,x,z,aisle,place,who,careless,robot_id,gap_cm";

        /// <summary>人がロボットの外周の近くまで来た1回を1行で書く</summary>
        public static void WriteContactsCsv(string path, string conditionId, int seed,
                                            IReadOnlyList<ContactRow> rows)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(ContactHeader).Append('\n');
            if (rows != null)
            {
                for (int i = 0; i < rows.Count; i++)
                {
                    ContactRow r = rows[i];
                    sb.Append(conditionId).Append(',').Append(seed).Append(',')
                      .Append(F(r.tS)).Append(',').Append(F(r.x)).Append(',').Append(F(r.z)).Append(',')
                      .Append(r.aisle).Append(',').Append(r.place).Append(',')
                      .Append(r.who).Append(',')
                      .Append(r.careless ? 1 : 0).Append(',').Append(r.robotId).Append(',')
                      .Append(F(r.gapM * 100f)).Append('\n');
                }
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- dishes.csv（B8）----------------

        public const string RequeueHeader =
            "condition_id,seed,t_s,dish_id,table_id,robot_id,fragile,ready_s,waited_s,requeued_so_far,why";

        /// <summary>届けられずに受け渡し口へ戻した1回を1行で書く</summary>
        public static void WriteRequeuesCsv(string path, string conditionId, int seed,
                                            IReadOnlyList<RequeueRow> rows)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(RequeueHeader).Append('\n');
            if (rows != null)
            {
                for (int i = 0; i < rows.Count; i++)
                {
                    RequeueRow r = rows[i];
                    sb.Append(conditionId).Append(',').Append(seed).Append(',')
                      .Append(F(r.tS)).Append(',').Append(r.dishId).Append(',').Append(r.tableId).Append(',')
                      .Append(r.robotId).Append(',').Append(r.fragile ? 1 : 0).Append(',')
                      .Append(F(r.readyS)).Append(',').Append(F(r.tS - r.readyS)).Append(',')
                      .Append(r.requeuedSoFar).Append(',').Append(r.why).Append('\n');
                }
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- stops.csv ----------------

        public const string StopHeader =
            "condition_id,seed,robot_id,trip_id,t_s,x,z,aisle,in_narrow,cause,cause_detail,path_blocked," +
            "duration_s,resolved,clearance_left_cm,clearance_right_cm,min_gap_cm," +
            "blocked_s,no_path_s,yield_s,clear_wait_s,replans," +
            // 道が引けない原因を追うための列（記録だけ。動きは変えない）
            "solve_from,solve_to,solve_status,solve_corners,off_mesh_m";

        public void WriteStopsCsv(string path)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(StopHeader).Append('\n');
            for (int i = 0; i < _stops.Count; i++)
            {
                StopRow r = _stops[i];
                sb.Append(r.conditionId).Append(',')
                  .Append(r.seed).Append(',')
                  .Append(r.robotId).Append(',')
                  .Append(r.tripId).Append(',')
                  .Append(F(r.tS)).Append(',')
                  .Append(F(r.x)).Append(',')
                  .Append(F(r.z)).Append(',')
                  .Append(r.aisle).Append(',')
                  .Append(r.inNarrow ? 1 : 0).Append(',')
                  .Append(r.cause).Append(',')
                  .Append(r.causeDetail).Append(',')
                  .Append(r.pathBlocked ? 1 : 0).Append(',')
                  .Append(F(r.durationS)).Append(',')
                  .Append(r.resolved).Append(',')
                  .Append(F(r.clearanceLeftCm)).Append(',')
                  .Append(F(r.clearanceRightCm)).Append(',')
                  .Append(F(r.minGapCm)).Append(',')
                  .Append(F(r.blockedS)).Append(',')
                  .Append(F(r.noPathS)).Append(',')
                  .Append(F(r.yieldS)).Append(',')
                  .Append(F(r.clearWaitS)).Append(',')
                  .Append(r.replans).Append(',')
                  .Append(r.solveFrom).Append(',')
                  .Append(r.solveTo).Append(',')
                  .Append(r.solveStatus).Append(',')
                  .Append(r.solveCorners).Append(',')
                  .Append(F(r.offMeshM))
                  .Append('\n');
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- run_info.json（1回ぶんの回し方とコマの粗さ）----------------

        /// <summary>1回ぶんの回し方と、コマの粗さの記録</summary>
        public class RunInfo
        {
            public string runLabel = "";
            public string conditionId = "";
            public string timeMode = "";
            public string storeKind = "";
            public string crowd = "";
            public int tripsRequested;
            public int tripsDone;
            public int robots;
            public int seed;
            public float aisleWidthM;

            /// <summary>卓の停止点を通路の中心からずらす距離（m）。.asset に入れた値（C1）</summary>
            public float deliveryOffsetM;

            /// <summary>そのうち、実際に使った分（通路が狭いときは NavMesh に乗る範囲に抑える）</summary>
            public float deliveryOffsetUsedM;

            public float simSeconds;
            public float wallSeconds;
            public float timeScale;
            public float captureDeltaTime;
            public bool stoppedEarly;

            /// <summary>1コマの時間（秒）。Time.deltaTime をそのまま</summary>
            public readonly List<float> frameDeltas = new List<float>();

            /// <summary>1コマでロボットが進んだ距離（m）。台のうちいちばん大きいもの</summary>
            public readonly List<float> robotSteps = new List<float>();

            /// <summary>
            /// 1コマで人が進んだ距離（m）。通路を歩く人のうちいちばん大きいもの（B10）。
            /// スタッフは 1.0 m/秒 なので、1コマ 0.05 秒だと 0.05 m でちょうど境目になる。
            /// </summary>
            public readonly List<float> peopleSteps = new List<float>();

            /// <summary>椅子を引いた回数（B6）</summary>
            public int chairPulls;

            /// <summary>椅子を引いたきっかけごとの回数（B6）</summary>
            public readonly List<KeyValuePair<string, int>> chairPullsByReason =
                new List<KeyValuePair<string, int>>();
        }

        /// <summary>コマの粗さを1行にまとめる（Console 用）</summary>
        public static string FrameStatsText(RunInfo info)
        {
            if (info == null || info.frameDeltas.Count == 0) return "コマの記録なし";
            int over = 0;
            for (int i = 0; i < info.robotSteps.Count; i++) if (info.robotSteps[i] > 0.05f) over++;
            return "コマ " + info.frameDeltas.Count +
                   " / 1コマの時間 中央 " + F(Percentile(info.frameDeltas, 0.5f)) +
                   "s・90% " + F(Percentile(info.frameDeltas, 0.9f)) +
                   "s・最大 " + F(Percentile(info.frameDeltas, 1f)) + "s" +
                   " / 1コマで進む距離 最大 " + F(Percentile(info.robotSteps, 1f)) +
                   "m・90% " + F(Percentile(info.robotSteps, 0.9f)) + "m" +
                   " / 0.05 m を超えたコマ ロボット " + over + "（" +
                   (info.robotSteps.Count > 0 ? (100f * over / info.robotSteps.Count).ToString("0.0") : "0") +
                   "%）・人 " + CountOver(info.peopleSteps, 0.05f) + "（" +
                   (info.peopleSteps.Count > 0
                        ? (100f * CountOver(info.peopleSteps, 0.05f) / info.peopleSteps.Count).ToString("0.0")
                        : "0") + "%）";
        }

        public static void WriteRunInfoJson(string path, RunInfo info)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append("{\n");
            sb.Append("  \"run_label\": ").Append(Str(info.runLabel)).Append(",\n");
            sb.Append("  \"written_at\": ").Append(Str(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))).Append(",\n");
            sb.Append("  \"condition_id\": ").Append(Str(info.conditionId)).Append(",\n");
            sb.Append("  \"store_kind\": ").Append(Str(info.storeKind)).Append(",\n");
            sb.Append("  \"time_mode\": ").Append(Str(info.timeMode)).Append(",\n");
            sb.Append("  \"time_scale\": ").Append(F(info.timeScale)).Append(",\n");
            sb.Append("  \"capture_delta_time\": ").Append(F(info.captureDeltaTime)).Append(",\n");
            sb.Append("  \"aisle_width_m\": ").Append(F(info.aisleWidthM)).Append(",\n");
            sb.Append("  \"delivery_offset_m\": ").Append(F(info.deliveryOffsetM)).Append(",\n");
            sb.Append("  \"delivery_offset_used_m\": ").Append(F(info.deliveryOffsetUsedM)).Append(",\n");
            sb.Append("  \"crowd\": ").Append(Str(info.crowd)).Append(",\n");
            sb.Append("  \"robots\": ").Append(info.robots).Append(",\n");
            sb.Append("  \"seed\": ").Append(info.seed).Append(",\n");
            sb.Append("  \"trips_requested\": ").Append(info.tripsRequested).Append(",\n");
            sb.Append("  \"trips_done\": ").Append(info.tripsDone).Append(",\n");
            sb.Append("  \"stopped_early\": ").Append(info.stoppedEarly ? "true" : "false").Append(",\n");
            sb.Append("  \"sim_seconds\": ").Append(F(info.simSeconds)).Append(",\n");
            sb.Append("  \"wall_seconds\": ").Append(F(info.wallSeconds)).Append(",\n");
            sb.Append("  \"speedup\": ").Append(F(info.wallSeconds > 0.01f ? info.simSeconds / info.wallSeconds : 0f)).Append(",\n");

            sb.Append("  \"frame_delta_s\": ");
            AppendStats(sb, info.frameDeltas);
            sb.Append(",\n");

            sb.Append("  \"robot_step_m\": ");
            AppendStats(sb, info.robotSteps);
            sb.Append(",\n");

            sb.Append("  \"people_step_m\": ");
            AppendStats(sb, info.peopleSteps);
            sb.Append(",\n");

            // すり抜けの確かめ（検査3）。ロボットと人を分けて出す
            int over = CountOver(info.robotSteps, 0.05f);
            int overPeople = CountOver(info.peopleSteps, 0.05f);
            sb.Append("  \"frames_over_005m\": ").Append(over).Append(",\n");
            sb.Append("  \"frames_over_005m_ratio\": ")
              .Append(F(info.robotSteps.Count > 0 ? (float)over / info.robotSteps.Count : 0f)).Append(",\n");
            sb.Append("  \"people_frames_over_005m\": ").Append(overPeople).Append(",\n");
            sb.Append("  \"people_frames_over_005m_ratio\": ")
              .Append(F(info.peopleSteps.Count > 0 ? (float)overPeople / info.peopleSteps.Count : 0f)).Append(",\n");

            // 椅子を引いた回数（B6）
            sb.Append("  \"chair_pulls\": ").Append(info.chairPulls).Append(",\n");
            sb.Append("  \"chair_pulls_by_reason\": {");
            for (int i = 0; i < info.chairPullsByReason.Count; i++)
            {
                if (i > 0) sb.Append(", ");
                sb.Append(Str(info.chairPullsByReason[i].Key)).Append(": ")
                  .Append(info.chairPullsByReason[i].Value);
            }
            sb.Append("}\n");
            sb.Append("}\n");

            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        /// <summary>しきい値を超えたコマの数</summary>
        static int CountOver(List<float> values, float limit)
        {
            if (values == null) return 0;
            int n = 0;
            for (int i = 0; i < values.Count; i++) if (values[i] > limit) n++;
            return n;
        }

        static void AppendStats(StringBuilder sb, List<float> values)
        {
            if (values == null || values.Count == 0) { sb.Append("null"); return; }
            float sum = 0f;
            for (int i = 0; i < values.Count; i++) sum += values[i];
            sb.Append("{\"count\": ").Append(values.Count);
            sb.Append(", \"mean\": ").Append(F(sum / values.Count));
            sb.Append(", \"min\": ").Append(F(Percentile(values, 0f)));
            sb.Append(", \"p50\": ").Append(F(Percentile(values, 0.5f)));
            sb.Append(", \"p90\": ").Append(F(Percentile(values, 0.9f)));
            sb.Append(", \"p99\": ").Append(F(Percentile(values, 0.99f)));
            sb.Append(", \"max\": ").Append(F(Percentile(values, 1f)));
            sb.Append("}");
        }

        // ---------------- conditions.json ----------------

        /// <summary>
        /// 18条件の中身と、設定ファイル（.asset）の値すべてを書く。
        /// 値は実行したときのものを JsonUtility でそのまま出す。
        /// </summary>
        public static void WriteConditionsJson(string path, string runName,
                                               StoreLayout layout, RobotSpec robotSpec,
                                               PeopleSpec peopleSpec, ExperimentPlan plan,
                                               List<ExperimentPlan.Condition> conditions)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append("{\n");
            sb.Append("  \"run_name\": ").Append(Str(runName)).Append(",\n");
            sb.Append("  \"written_at\": ").Append(Str(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))).Append(",\n");
            sb.Append("  \"unity_version\": ").Append(Str(Application.unityVersion)).Append(",\n");
            sb.Append("  \"trips_csv_columns\": ").Append(Str(Header)).Append(",\n");

            sb.Append("  \"settings\": {\n");
            sb.Append("    \"store_layout\": ").Append(Dump(layout)).Append(",\n");
            sb.Append("    \"robot_spec\": ").Append(Dump(robotSpec)).Append(",\n");
            sb.Append("    \"people_spec\": ").Append(Dump(peopleSpec)).Append(",\n");
            sb.Append("    \"experiment_plan\": ").Append(Dump(plan)).Append("\n");
            sb.Append("  },\n");

            // 組み立てた形から出る値（conditions.json だけ見れば店の大きさが分かるように）
            sb.Append("  \"derived\": {\n");
            if (layout != null)
            {
                StorePlan sp = layout.NewPlan();
                sb.Append("    \"store_kind\": ").Append(Str(layout.StoreKindName)).Append(",\n");
                sb.Append("    \"aisle_width_m\": ").Append(F(layout.AisleWidth)).Append(",\n");
                if (sp != null)
                {
                    sb.Append("    \"floor_min_x\": ").Append(F(sp.floor.xMin)).Append(",\n");
                    sb.Append("    \"floor_max_x\": ").Append(F(sp.floor.xMax)).Append(",\n");
                    sb.Append("    \"floor_min_z\": ").Append(F(sp.floor.zMin)).Append(",\n");
                    sb.Append("    \"floor_max_z\": ").Append(F(sp.floor.zMax)).Append(",\n");
                    sb.Append("    \"outer_size_x_m\": ").Append(F(sp.floor.SizeX)).Append(",\n");
                    sb.Append("    \"outer_size_z_m\": ").Append(F(sp.floor.SizeZ)).Append(",\n");
                    sb.Append("    \"table_count\": ").Append(sp.seats.Count).Append(",\n");
                    sb.Append("    \"aisle_count\": ").Append(sp.aisles.Count).Append(",\n");
                    sb.Append("    \"narrow_aisle_count\": ").Append(sp.NarrowAisleCount).Append("\n");
                }
                else
                {
                    sb.Append("    \"plan\": null\n");
                }
            }
            sb.Append("  },\n");

            sb.Append("  \"conditions\": [\n");
            if (conditions != null)
            {
                for (int i = 0; i < conditions.Count; i++)
                {
                    ExperimentPlan.Condition c = conditions[i];
                    sb.Append("    {");
                    sb.Append("\"condition_id\": ").Append(Str(c.id)).Append(", ");
                    sb.Append("\"aisle_width_m\": ").Append(F(c.aisleWidthM)).Append(", ");
                    sb.Append("\"aisle_width_index\": ").Append(c.aisleWidthIndex).Append(", ");
                    sb.Append("\"crowd\": ").Append(Str(ExperimentPlan.CrowdName(c.crowd))).Append(", ");
                    sb.Append("\"walkers\": ").Append(peopleSpec != null ? peopleSpec.WalkerCount(c.crowd) : 0).Append(", ");
                    sb.Append("\"robots\": ").Append(c.robots).Append(", ");
                    sb.Append("\"trips\": ").Append(plan != null ? plan.tripsPerCondition : 0).Append(", ");
                    sb.Append("\"seeds\": ").Append(IntArray(plan != null ? plan.seeds : null)).Append(", ");
                    sb.Append("\"aisle_yielding\": ").Append(robotSpec != null && robotSpec.aisleYielding ? "true" : "false");
                    sb.Append('}');
                    if (i < conditions.Count - 1) sb.Append(',');
                    sb.Append('\n');
                }
            }
            sb.Append("  ]\n");
            sb.Append("}\n");

            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        static string Dump(ScriptableObject asset)
        {
            if (asset == null) return "null";
            return JsonUtility.ToJson(asset, false);
        }

        static string IntArray(int[] values)
        {
            if (values == null || values.Length == 0) return "[]";
            StringBuilder sb = new StringBuilder("[");
            for (int i = 0; i < values.Length; i++)
            {
                if (i > 0) sb.Append(", ");
                sb.Append(values[i]);
            }
            return sb.Append(']').ToString();
        }

        static string Str(string s)
        {
            if (s == null) return "null";
            StringBuilder sb = new StringBuilder("\"");
            for (int i = 0; i < s.Length; i++)
            {
                char c = s[i];
                if (c == '"' || c == '\\') sb.Append('\\').Append(c);
                else if (c == '\n') sb.Append("\\n");
                else if (c == '\r') sb.Append("\\r");
                else if (c == '\t') sb.Append("\\t");
                else sb.Append(c);
            }
            return sb.Append('"').ToString();
        }

        // ---------------- summary.json ----------------

        /// <summary>条件ごとにまとめる</summary>
        /// <summary>試算（haizen_sim.py）と同じ意味の数字。名前も同じにする</summary>
        public class SimLike
        {
            public List<float> dishWaitMin = new List<float>();
            public List<float> seatWaitMin = new List<float>();
            public List<float> turnMin = new List<float>();
            public float coldMin = 3f;
            public int robotDishes, staffDishes, requeuedDishes, groups;

            /// <summary>あきらめた・移した・卓を空けた回数（B1）</summary>
            public int gaveUp, warped, tableFreed;

            /// <summary>人どうしの詰まりのうち、近くでロボットが止まっていたもの（B9）</summary>
            public int robotBlockedJams;

            /// <summary>人がロボットに近づきすぎた回数・待った回数と秒数・回り道した回数</summary>
            public int robotContacts, robotYieldWaits, robotYieldDetours;
            public float robotYieldWaitSeconds;

            /// <summary>レジの前にいた人数（いちばん多いとき・平均）</summary>
            public int registerCrowdMax;
            public float registerCrowdMean;

            /// <summary>会計の回数と、そのうちスタッフが店員側にいなかった回数（0 になるはず）</summary>
            public int registerPayCount, registerPayNotAtBackCount;

            /// <summary>立つ点から離れて会計した回数（0 になるはず）と、その距離</summary>
            public int registerPayOffSpotCount;
            public float registerPayMeanOffsetM, registerPayMaxOffsetM;

            /// <summary>会計でないスタッフが店員側にいた回数と秒数（どちらも 0 になるはず）</summary>
            public int registerBackIntruderCount;
            public float registerBackIntruderSeconds;

            /// <summary>スタッフの時間の使い方（仕事の名前 → 秒）</summary>
            public readonly List<KeyValuePair<string, float>> staffTime =
                new List<KeyValuePair<string, float>>();

            /// <summary>仕事が板に出てから取られるまでの秒数の平均（仕事の名前 → 秒）</summary>
            public readonly List<KeyValuePair<string, float>> jobWaitMean =
                new List<KeyValuePair<string, float>>();

            /// <summary>届いた料理の、届くまでの内訳（ロボット・スタッフ別に分ける）</summary>
            public readonly List<ServiceOps.DishParts> dishParts = new List<ServiceOps.DishParts>();

            /// <summary>椅子を引いた回数（B6）</summary>
            public int chairPulls;

            /// <summary>ドリンクバー：並んだ回数・あきらめた回数・並んで待った分（B4）</summary>
            public int drinkBarQueued, drinkBarGaveUp;
            public readonly List<float> drinkBarWaitMin = new List<float>();
            public int jamCount; public float jamTotalS;
            public float walkersMean;
            public float measuredMinutes;

            // ---- サイトと同じ名前で出す値（v5 3章・meta.metrics）----
            /// <summary>入店待ちの内訳のもと（分）。分け方はサイトの試算と同じ</summary>
            public readonly List<float> entryWaitMin = new List<float>();
            public readonly List<float> fullWaitMin = new List<float>();
            public readonly List<float> blockedWaitMin = new List<float>();
            public readonly List<float> guideWaitMin = new List<float>();
            public readonly List<float> staffWaitMin = new List<float>();

            /// <summary>walk_km_per_staff・staff_util・robot_util</summary>
            public float walkKmPerStaff, staffUtil, robotUtil;

            /// <summary>rescues：立ち往生でスタッフを呼んだ回数（着いて動かした回数も残す）</summary>
            public int rescues, rescuesDone;
            public float rescueWaitS;

            /// <summary>pauses：一時停止の回数（自分で動き出した止まりの合計）</summary>
            public int pauses;

            /// <summary>サイトの条件で回したか（v5 3章）と、その条件の名前</summary>
            public bool siteRun;
            public string siteConditionId = "";

            /// <summary>
            /// サイトの試算の答え（unity_conditions の site_result）。
            /// 同じ summary.json に入れておけば、並べた表をあとから手で写さずに作れる
            /// </summary>
            public readonly List<KeyValuePair<string, float>> siteResult =
                new List<KeyValuePair<string, float>>();

            /// <summary>来客を受け付けた分と、全員が帰るまでの分（打ち切りなら everyoneLeft が false）</summary>
            public float arrivalsMinutes, totalMinutes;
            public bool everyoneLeft;
        }

        SimLike _sim;
        public void SetSimLike(SimLike s) { _sim = s; }

        public void WriteSummaryJson(string path, string runName)
        {
            // 条件ごとに行を分ける（出てきた順を保つ）
            List<string> order = new List<string>();
            Dictionary<string, List<Row>> byCondition = new Dictionary<string, List<Row>>();
            for (int i = 0; i < _rows.Count; i++)
            {
                string id = _rows[i].conditionId;
                if (!byCondition.ContainsKey(id)) { byCondition[id] = new List<Row>(); order.Add(id); }
                byCondition[id].Add(_rows[i]);
            }

            StringBuilder sb = new StringBuilder();
            sb.Append("{\n");
            sb.Append("  \"run_name\": ").Append(Str(runName)).Append(",\n");
            sb.Append("  \"written_at\": ").Append(Str(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))).Append(",\n");
            sb.Append("  \"note\": ").Append(Str(
                "speed_mps = distance_m / (total_s - stopped_s)。" +
                "stopped_s には、動けなくなった60秒と、スタッフを待った秒数（stuck_wait_s）、" +
                "通路の入口で待った秒数も入る。" +
                "pause_s は stop_durations_s（自分で動き出した止まりだけ）の分布。" +
                "site_metrics はサイトの meta.metrics と同じ名前・同じ定義。" +
                "入店待ちの分け方もサイトの試算と同じで、" +
                "blocked_wait は片付け待ちの卓の数だけ待っている列の前から順に足した分、" +
                "full_wait は seat_wait − blocked_wait、" +
                "staff_wait は blocked_wait + guide_wait。" +
                "pauses と rescues は、サイト側が unity_measures の仮の値から出した数なので出どころが違う。"))
              .Append(",\n");
            if (_sim != null)
            {
                sb.Append("  \"sim_like\": {");
                sb.Append("\"dish_wait_mean\": ").Append(F(Mean(_sim.dishWaitMin))).Append(", ");
                sb.Append("\"dish_wait_p90\": ").Append(F(Percentile(_sim.dishWaitMin, 0.9f))).Append(", ");
                sb.Append("\"cold_share\": ").Append(F(Share(_sim.dishWaitMin, _sim.coldMin))).Append(", ");
                sb.Append("\"seat_wait_mean\": ").Append(F(Mean(_sim.seatWaitMin))).Append(", ");
                sb.Append("\"turn_mean\": ").Append(F(Mean(_sim.turnMin))).Append(", ");
                sb.Append("\"robot_share\": ").Append(F(_sim.robotDishes + _sim.staffDishes > 0
                        ? (float)_sim.robotDishes / (_sim.robotDishes + _sim.staffDishes) : 0f)).Append(", ");
                sb.Append("\"robot_dishes\": ").Append(_sim.robotDishes).Append(", ");
                sb.Append("\"staff_dishes\": ").Append(_sim.staffDishes).Append(", ");
                sb.Append("\"requeued_dishes\": ").Append(_sim.requeuedDishes).Append(", ");
                sb.Append("\"groups\": ").Append(_sim.groups).Append(", ");
                sb.Append("\"people_jams\": ").Append(_sim.jamCount).Append(", ");
                sb.Append("\"robot_blocked_jams\": ").Append(_sim.robotBlockedJams).Append(", ");
                sb.Append("\"robot_blocked_jam_share\": ").Append(F(_sim.jamCount > 0
                        ? (float)_sim.robotBlockedJams / _sim.jamCount : 0f)).Append(", ");
                sb.Append("\"chair_pulls\": ").Append(_sim.chairPulls).Append(", ");
                sb.Append("\"robot_contacts\": ").Append(_sim.robotContacts).Append(", ");
                sb.Append("\"robot_yield_waits\": ").Append(_sim.robotYieldWaits).Append(", ");
                sb.Append("\"robot_yield_wait_s\": ").Append(F(_sim.robotYieldWaitSeconds)).Append(", ");
                sb.Append("\"robot_yield_detours\": ").Append(_sim.robotYieldDetours).Append(", ");
                sb.Append("\"register_crowd_max\": ").Append(_sim.registerCrowdMax).Append(", ");
                sb.Append("\"register_crowd_mean\": ").Append(F(_sim.registerCrowdMean)).Append(", ");
                sb.Append("\"register_pay_count\": ").Append(_sim.registerPayCount).Append(", ");
                sb.Append("\"register_pay_not_at_back\": ").Append(_sim.registerPayNotAtBackCount).Append(", ");
                sb.Append("\"register_pay_off_spot\": ").Append(_sim.registerPayOffSpotCount).Append(", ");
                sb.Append("\"register_pay_offset_mean_m\": ").Append(F(_sim.registerPayMeanOffsetM)).Append(", ");
                sb.Append("\"register_pay_offset_max_m\": ").Append(F(_sim.registerPayMaxOffsetM)).Append(", ");
                sb.Append("\"register_back_intruders\": ").Append(_sim.registerBackIntruderCount).Append(", ");
                sb.Append("\"register_back_intruder_s\": ").Append(F(_sim.registerBackIntruderSeconds)).Append(", ");
                sb.Append("\"drink_bar_queued\": ").Append(_sim.drinkBarQueued).Append(", ");
                sb.Append("\"drink_bar_gave_up\": ").Append(_sim.drinkBarGaveUp).Append(", ");
                sb.Append("\"drink_bar_wait_mean\": ").Append(F(Mean(_sim.drinkBarWaitMin))).Append(", ");
                sb.Append("\"drink_bar_wait_p90\": ").Append(F(Percentile(_sim.drinkBarWaitMin, 0.9f))).Append(", ");
                sb.Append("\"gave_up\": ").Append(_sim.gaveUp).Append(", ");
                sb.Append("\"warped\": ").Append(_sim.warped).Append(", ");
                sb.Append("\"table_freed\": ").Append(_sim.tableFreed).Append(", ");
                sb.Append("\"people_jam_total_s\": ").Append(F(_sim.jamTotalS)).Append(", ");
                sb.Append("\"walkers_mean\": ").Append(F(_sim.walkersMean)).Append(", ");
                sb.Append("\"measured_minutes\": ").Append(F(_sim.measuredMinutes));
                sb.Append("},\n");

                AppendSiteMetrics(sb);

                // スタッフの時間の使い方（割合も出す）
                float staffTotal = 0f;
                for (int i = 0; i < _sim.staffTime.Count; i++) staffTotal += _sim.staffTime[i].Value;
                sb.Append("  \"staff_time_s\": {");
                for (int i = 0; i < _sim.staffTime.Count; i++)
                {
                    if (i > 0) sb.Append(", ");
                    sb.Append(Str(_sim.staffTime[i].Key)).Append(": ").Append(F(_sim.staffTime[i].Value));
                }
                sb.Append("},\n");
                // 料理ができてから届くまでの内訳（受け渡し口で待った／運んでいた／戻されて失った）
                sb.Append("  \"dish_wait_parts\": {");
                AppendDishParts(sb, "robot", true);
                sb.Append(", ");
                AppendDishParts(sb, "staff", false);
                sb.Append(", ");
                AppendDishParts(sb, "all", null);
                sb.Append("},\n");

                sb.Append("  \"job_wait_mean_s\": {");
                for (int i = 0; i < _sim.jobWaitMean.Count; i++)
                {
                    if (i > 0) sb.Append(", ");
                    sb.Append(Str(_sim.jobWaitMean[i].Key)).Append(": ").Append(F(_sim.jobWaitMean[i].Value));
                }
                sb.Append("},\n");
                sb.Append("  \"staff_time_share\": {");
                for (int i = 0; i < _sim.staffTime.Count; i++)
                {
                    if (i > 0) sb.Append(", ");
                    sb.Append(Str(_sim.staffTime[i].Key)).Append(": ")
                      .Append(F(staffTotal > 0.01f ? _sim.staffTime[i].Value / staffTotal : 0f));
                }
                sb.Append("},\n");
            }

            // 止まり全体のまとめ（B7 の前後を並べるため）
            sb.Append("  \"stops\": {");
            sb.Append("\"count\": ").Append(_stops.Count).Append(", ");
            sb.Append("\"stuck_count\": ").Append(StuckStopCount()).Append(", ");
            sb.Append("\"partial_path_count\": ").Append(PartialPathStopCount());
            sb.Append("},\n");

            sb.Append("  \"stop_causes\": [\n");
            AppendCauseSummary(sb);
            sb.Append("  ],\n");

            sb.Append("  \"conditions\": [\n");

            for (int i = 0; i < order.Count; i++)
            {
                List<Row> rows = byCondition[order[i]];
                AppendConditionSummary(sb, order[i], rows);
                if (i < order.Count - 1) sb.Append(',');
                sb.Append('\n');
            }

            sb.Append("  ]\n");
            sb.Append("}\n");
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        /// <summary>
        /// サイトと同じ名前・同じ定義で出す欄（v5 3章・meta.metrics）。
        /// site_result と並べるときは、この欄だけを見れば足りるようにする。
        ///
        /// 入店待ちの分け方は、サイトの試算（haizen_sim.py）と同じ:
        ///   seat_wait    店に来てから案内が始まるまで
        ///   blocked_wait そのうち、卓は空いたのに片付けが終わらず待った分
        ///                （片付け待ちの卓の数だけ、待っている列の前から順に足す。サイトの _acc と同じ）
        ///   full_wait    seat_wait − blocked_wait
        ///   guide_wait   案内の仕事が板に出てから、スタッフが取るまで
        ///   staff_wait   blocked_wait ＋ guide_wait
        ///   entry_wait   店に来てから卓に着くまで（案内の歩きと guide_s を含む）
        /// </summary>
        void AppendSiteMetrics(StringBuilder sb)
        {
            sb.Append("  \"site_metrics\": {");
            sb.Append("\"entry_wait_mean\": ").Append(F(Mean(_sim.entryWaitMin))).Append(", ");
            sb.Append("\"full_wait_mean\": ").Append(F(Mean(_sim.fullWaitMin))).Append(", ");
            sb.Append("\"staff_wait_mean\": ").Append(F(Mean(_sim.staffWaitMin))).Append(", ");
            sb.Append("\"blocked_wait_mean\": ").Append(F(Mean(_sim.blockedWaitMin))).Append(", ");
            sb.Append("\"guide_wait_mean\": ").Append(F(Mean(_sim.guideWaitMin))).Append(", ");
            sb.Append("\"seat_wait_mean\": ").Append(F(Mean(_sim.seatWaitMin))).Append(", ");
            sb.Append("\"dish_wait_mean\": ").Append(F(Mean(_sim.dishWaitMin))).Append(", ");
            sb.Append("\"dish_wait_p90\": ").Append(F(Percentile(_sim.dishWaitMin, 0.9f))).Append(", ");
            sb.Append("\"cold_share\": ").Append(F(Share(_sim.dishWaitMin, _sim.coldMin))).Append(", ");
            sb.Append("\"turn_mean\": ").Append(F(Mean(_sim.turnMin))).Append(", ");
            sb.Append("\"walk_km_per_staff\": ").Append(F(_sim.walkKmPerStaff)).Append(", ");
            sb.Append("\"staff_util\": ").Append(F(_sim.staffUtil)).Append(", ");
            sb.Append("\"robot_util\": ").Append(F(_sim.robotUtil)).Append(", ");
            sb.Append("\"robot_share\": ").Append(F(_sim.robotDishes + _sim.staffDishes > 0
                    ? (float)_sim.robotDishes / (_sim.robotDishes + _sim.staffDishes) : 0f)).Append(", ");
            sb.Append("\"rescues\": ").Append(_sim.rescues).Append(", ");
            sb.Append("\"pauses\": ").Append(_sim.pauses).Append(", ");
            sb.Append("\"groups\": ").Append(_sim.groups);
            sb.Append("},\n");

            sb.Append("  \"site_run\": {");
            sb.Append("\"site_run\": ").Append(_sim.siteRun ? "true" : "false").Append(", ");
            sb.Append("\"condition_id\": ").Append(Str(_sim.siteConditionId)).Append(", ");
            sb.Append("\"arrivals_minutes\": ").Append(F(_sim.arrivalsMinutes)).Append(", ");
            sb.Append("\"total_minutes\": ").Append(F(_sim.totalMinutes)).Append(", ");
            sb.Append("\"everyone_left\": ").Append(_sim.everyoneLeft ? "true" : "false").Append(", ");
            sb.Append("\"rescues_done\": ").Append(_sim.rescuesDone).Append(", ");
            sb.Append("\"rescue_wait_s\": ").Append(F(_sim.rescueWaitS)).Append(", ");
            // 逃がし弁（0 回に近いことを確かめる。仕組みは消さない）
            sb.Append("\"gave_up\": ").Append(_sim.gaveUp).Append(", ");
            sb.Append("\"warped\": ").Append(_sim.warped).Append(", ");
            sb.Append("\"table_freed\": ").Append(_sim.tableFreed);
            sb.Append("},\n");

            // サイトの試算の答え（並べる表のもと）
            sb.Append("  \"site_result\": {");
            for (int i = 0; i < _sim.siteResult.Count; i++)
            {
                if (i > 0) sb.Append(", ");
                sb.Append(Str(_sim.siteResult[i].Key)).Append(": ").Append(F(_sim.siteResult[i].Value));
            }
            sb.Append("},\n");
        }

        /// <summary>
        /// 原因ごとの回数と秒数の合計。
        /// stuck_count は「動けなくなった」で終わった止まり（止まりの上限に達した回数）。
        /// partial_path_count は「途中までの経路」に縛られて引き直して出た止まり（B7）。
        /// この2つを、停止点を変えた2回で並べれば「椅子が原因で上限に達した回数」の差が出る。
        /// </summary>
        void AppendCauseSummary(StringBuilder sb)
        {
            string stuckLabel = ResolveLabel(ServingRobotController.StopResolve.Stuck);

            List<string> order = new List<string>();
            Dictionary<string, int> count = new Dictionary<string, int>();
            Dictionary<string, float> secs = new Dictionary<string, float>();
            Dictionary<string, float> longest = new Dictionary<string, float>();
            Dictionary<string, int> blocked = new Dictionary<string, int>();
            Dictionary<string, int> stuck = new Dictionary<string, int>();
            Dictionary<string, int> partial = new Dictionary<string, int>();
            Dictionary<string, float> noPath = new Dictionary<string, float>();

            for (int i = 0; i < _stops.Count; i++)
            {
                string c = _stops[i].cause;
                if (!count.ContainsKey(c))
                {
                    order.Add(c); count[c] = 0; secs[c] = 0f; longest[c] = 0f; blocked[c] = 0;
                    stuck[c] = 0; partial[c] = 0; noPath[c] = 0f;
                }
                count[c]++;
                secs[c] += _stops[i].durationS;
                if (_stops[i].durationS > longest[c]) longest[c] = _stops[i].durationS;
                if (_stops[i].pathBlocked) blocked[c]++;
                if (_stops[i].resolved == stuckLabel) stuck[c]++;
                if (HasPartialPathMark(_stops[i].causeDetail)) partial[c]++;
                noPath[c] += _stops[i].noPathS;
            }

            for (int i = 0; i < order.Count; i++)
            {
                string c = order[i];
                sb.Append("    {\"cause\": ").Append(Str(c))
                  .Append(", \"count\": ").Append(count[c])
                  .Append(", \"total_s\": ").Append(F(secs[c]))
                  .Append(", \"mean_s\": ").Append(F(count[c] > 0 ? secs[c] / count[c] : 0f))
                  .Append(", \"longest_s\": ").Append(F(longest[c]))
                  .Append(", \"path_blocked_count\": ").Append(blocked[c])
                  .Append(", \"stuck_count\": ").Append(stuck[c])
                  .Append(", \"partial_path_count\": ").Append(partial[c])
                  .Append(", \"no_path_total_s\": ").Append(F(noPath[c]))
                  .Append('}');
                if (i < order.Count - 1) sb.Append(',');
                sb.Append('\n');
            }
        }

        /// <summary>
        /// 届くまでの内訳を1つぶん書く。byRobot が null なら全部。
        /// 秒は「分」に直して出す（dish_wait_mean と同じ単位にそろえる）。
        /// </summary>
        void AppendDishParts(StringBuilder sb, string name, bool? byRobot)
        {
            int n = 0, requeued = 0;
            float pass = 0f, carry = 0f, requeue = 0f;

            if (_sim != null)
            {
                for (int i = 0; i < _sim.dishParts.Count; i++)
                {
                    ServiceOps.DishParts p = _sim.dishParts[i];
                    if (byRobot.HasValue && p.byRobot != byRobot.Value) continue;
                    n++;
                    pass += p.passWaitS;
                    carry += p.carryS;
                    requeue += p.requeueS;
                    requeued += p.requeued;
                }
            }

            float d = n > 0 ? n : 1f;
            sb.Append(Str(name)).Append(": {");
            sb.Append("\"dishes\": ").Append(n).Append(", ");
            sb.Append("\"pass_wait_mean_min\": ").Append(F(pass / d / 60f)).Append(", ");
            sb.Append("\"carry_mean_min\": ").Append(F(carry / d / 60f)).Append(", ");
            sb.Append("\"requeue_lost_mean_min\": ").Append(F(requeue / d / 60f)).Append(", ");
            sb.Append("\"total_mean_min\": ").Append(F((pass + carry + requeue) / d / 60f)).Append(", ");
            sb.Append("\"requeued_per_dish\": ").Append(F(requeued / d)).Append(", ");
            sb.Append("\"requeued_total\": ").Append(requeued);
            sb.Append("}");
        }

        /// <summary>この止まりが「途中までの経路」に縛られたものか（B7）</summary>
        static bool HasPartialPathMark(string causeDetail)
        {
            return !string.IsNullOrEmpty(causeDetail) &&
                   causeDetail.Contains(ServingRobotController.PartialPathMark);
        }

        /// <summary>「動けなくなった」で終わった止まりの数（止まりの上限に達した回数）</summary>
        int StuckStopCount()
        {
            string label = ResolveLabel(ServingRobotController.StopResolve.Stuck);
            int n = 0;
            for (int i = 0; i < _stops.Count; i++) if (_stops[i].resolved == label) n++;
            return n;
        }

        /// <summary>「途中までの経路」から引き直して出た止まりの数（B7）</summary>
        int PartialPathStopCount()
        {
            int n = 0;
            for (int i = 0; i < _stops.Count; i++) if (HasPartialPathMark(_stops[i].causeDetail)) n++;
            return n;
        }

        static void AppendConditionSummary(StringBuilder sb, string id, List<Row> rows)
        {
            List<float> speeds = new List<float>();
            List<float> stopped = new List<float>();
            List<float> pauses = new List<float>();
            List<float> waits = new List<float>();
            int stuckCount = 0, timeoutCount = 0, pausedCount = 0, metRobotCount = 0;
            float aisleWidth = 0f; int robots = 0; string crowd = "";

            for (int i = 0; i < rows.Count; i++)
            {
                Row r = rows[i];
                aisleWidth = r.aisleWidthM; robots = r.robots; crowd = r.crowd;

                float moving = r.totalS - r.stoppedS;
                if (moving > 0.01f) speeds.Add(r.distanceM / moving);
                stopped.Add(r.stoppedS);
                waits.Add(r.waitAisleS);
                if (r.stopDurationsS != null)
                    for (int k = 0; k < r.stopDurationsS.Length; k++) pauses.Add(r.stopDurationsS[k]);
                if (r.stuck) stuckCount++;
                if (r.timeout) timeoutCount++;
                if (r.nStops >= 1) pausedCount++;
                if (r.metRobot) metRobotCount++;
            }

            int n = rows.Count;
            sb.Append("    {");
            sb.Append("\"condition_id\": ").Append(Str(id)).Append(", ");
            sb.Append("\"aisle_width_m\": ").Append(F(aisleWidth)).Append(", ");
            sb.Append("\"crowd\": ").Append(Str(crowd)).Append(", ");
            sb.Append("\"robots\": ").Append(robots).Append(", ");
            sb.Append("\"trips\": ").Append(n).Append(", ");
            sb.Append("\"robot_speed_mps_median\": ").Append(F(Percentile(speeds, 0.5f))).Append(", ");
            sb.Append("\"stopped_s_median\": ").Append(F(Percentile(stopped, 0.5f))).Append(", ");
            sb.Append("\"stopped_s_p90\": ").Append(F(Percentile(stopped, 0.9f))).Append(", ");
            sb.Append("\"pause_s_median\": ").Append(F(Percentile(pauses, 0.5f))).Append(", ");
            sb.Append("\"pause_s_p90\": ").Append(F(Percentile(pauses, 0.9f))).Append(", ");
            sb.Append("\"pause_count\": ").Append(pauses.Count).Append(", ");
            sb.Append("\"wait_aisle_s_median\": ").Append(F(Percentile(waits, 0.5f))).Append(", ");
            sb.Append("\"stuck_rate\": ").Append(F(n > 0 ? stuckCount / (float)n : 0f)).Append(", ");
            sb.Append("\"stuck_count\": ").Append(stuckCount).Append(", ");
            sb.Append("\"timeout_rate\": ").Append(F(n > 0 ? timeoutCount / (float)n : 0f)).Append(", ");
            sb.Append("\"timeout_count\": ").Append(timeoutCount).Append(", ");
            sb.Append("\"pause_rate\": ").Append(F(n > 0 ? pausedCount / (float)n : 0f)).Append(", ");
            sb.Append("\"met_robot_rate\": ").Append(F(n > 0 ? metRobotCount / (float)n : 0f));
            sb.Append('}');
        }

        static float Mean(List<float> v)
        {
            if (v == null || v.Count == 0) return -1f;
            float s = 0f;
            for (int i = 0; i < v.Count; i++) s += v[i];
            return s / v.Count;
        }

        static float Share(List<float> v, float over)
        {
            if (v == null || v.Count == 0) return -1f;
            int n = 0;
            for (int i = 0; i < v.Count; i++) if (v[i] > over) n++;
            return (float)n / v.Count;
        }

        /// <summary>小さい順に並べて p の位置の値を返す（0〜1）。空なら -1</summary>
        public static float Percentile(List<float> values, float p)
        {
            if (values == null || values.Count == 0) return -1f;
            List<float> v = new List<float>(values);
            v.Sort();
            if (v.Count == 1) return v[0];
            float pos = Mathf.Clamp01(p) * (v.Count - 1);
            int lo = Mathf.FloorToInt(pos);
            int hi = Mathf.Min(lo + 1, v.Count - 1);
            float t = pos - lo;
            return Mathf.Lerp(v[lo], v[hi], t);
        }
    }
}
