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

namespace ServiceSim
{
    /// <summary>
    /// 条件を回して記録する。1回の Play の中で、条件を順に通せる（段階3の Run All）。
    /// 数字と場所は ExperimentPlan の .asset に置く。回し方だけメニューが決める。
    ///
    /// この script は実験だけを扱う。店の運営は ServiceOps、人は GroupManager に任せる。
    /// </summary>
    public class ExperimentRunner : MonoBehaviour
    {
        /// <summary>時間の進め方</summary>
        public enum TimeMode { RealTime, FastTimeScale, FixedStep }

        /// <summary>回す条件1つ</summary>
        [System.Serializable]
        public class Job
        {
            public int aisleWidthIndex;
            public CrowdLevel crowd;
            public int robots;
            public int seed;
            public string folder;
            public string conditionId;

            /// <summary>
            /// 卓の停止点を通路の中心からずらす距離（m）。メニューが決める（C1）。
            /// applyDeliveryOffset が false のときは、.asset の値をそのまま使う。
            /// true のときも、使うのはこの回だけ。回し終わりに .asset の値へ戻すので、
            /// .asset は書き換わらない（run_info.json の delivery_offset_used_m に実際の値が出る）。
            /// </summary>
            public bool applyDeliveryOffset;
            public float deliveryOffset;

            // ---- サイトの条件で回す（v5 3章）----
            /// <summary>
            /// サイトの条件（unity_conditions）で回すか。
            /// true なら、店の回し方の数字は serving-robot-params.json から読み、
            /// 始め方もサイトと同じ「空の店から peak_min 分だけ来客し、全員が帰るまで」にする。
            /// </summary>
            public bool siteRun;

            /// <summary>unity_conditions の何番めか（siteRun のとき）</summary>
            public int conditionIndex = -1;

            /// <summary>試算の stores の名前（fam_p1 / fam_p5）。報告とフォルダ名に使う</summary>
            public string storeKey = "";
        }

        [Header("参照（Setup が入れる）")]
        public ExperimentPlan plan;
        public ServiceSpec service;

        [Tooltip("サイトの試算の値（v5 3章）。サイトの条件で回すときに読む")]
        public SimParams simParams;

        public StoreBuilder store;
        public NavMeshBaker baker;
        public PeopleSpawner people;
        public ServiceOps ops;
        public GroupManager groups;
        public JamWatcher jams;
        public RobotSpec robotSpec;
        public GameObject robotPrefab;
        public Transform robotsRoot;

        [Header("回し方（メニューが決める）")]
        public bool runOnPlay;

        /// <summary>
        /// 見る用の回か。字幕と描画をこの回だけ出し、時間は1倍速で進める。
        /// 設定ファイル（ExperimentPlan）は書き換えない。計測の比べには使わない。
        /// </summary>
        public bool viewMode;
        public TimeMode timeMode = TimeMode.FixedStep;
        public string runLabel = "v2";
        public float captureDeltaTimeOverride;

        [Header("いまの様子（読むだけ）")]
        [SerializeField] int _jobIndex;
        [SerializeField] int _jobCount;
        [SerializeField] float _simMinutes;
        [SerializeField] float _wallSeconds;
        [SerializeField] int _runsDone;
        [SerializeField] bool _running;

        // Play に入るとき、Unity はいちど script を読み直す（Domain Reload）。
        // そのときに消えないように、回す条件は .unity に書き出して持たせる。
        // メニューが入れた条件が Start() まで残らないと「回す条件がありません」で止まる。
        [SerializeField] List<Job> _queue = new List<Job>();

        readonly TripLogger _logger = new TripLogger();
        readonly List<ServingRobotController> _robots = new List<ServingRobotController>();
        readonly TripLogger.RunInfo _info = new TripLogger.RunInfo();
        readonly List<int> _walkerSamples = new List<int>();

        /// <summary>計測が始まってからこの回数走っても記録が0なら、取りこぼしとみて止める</summary>
        const int TripRecordAlarmRuns = 3;

        Job _job;

        // 停止点は、メニューが決めた値をこの回だけ使う（C1）。
        // .asset を書き換えたままにしないよう、もとの値を覚えて回し終わりに戻す。
        float _savedDeliveryOffset;
        bool _deliveryOffsetSaved;

        // サイトの試算の値も、この回だけ入れて回し終わりに戻す（v5 3章）
        readonly SiteValues _siteValues = new SiteValues();
        SimParams.Condition _condition;

        /// <summary>来客を受け付け終わる時刻（店の中, 秒）。siteRun のときだけ使う</summary>
        float _arrivalsEndS;

        /// <summary>全員が帰るのを待つ上限（店の中, 秒）。これを超えたら打ち切る</summary>
        float _hardEndS;

        /// <summary>全員が帰って終わったか（打ち切りでないか）</summary>
        bool _everyoneLeft;

        float _endS, _measureStartS, _wallStart, _lastLog, _timeOffset;
        int _tripSeq;
        int _runsFinished;        // ロボットが走り終わった回数（慣らしも含む）
        int _runsAfterMeasure;    // 計測が始まってから走り終わった回数
        bool _stoppedEarly;
        bool _jobRunning;

        /// <summary>この条件ぶんを書き出したか（途中で止めたときに二重に書かないため。v5 3章）</summary>
        bool _outputsWritten;

        public bool Running { get { return _running; } }

        /// <summary>回す条件を1つだけ入れる（段階2のやり方）</summary>
        public void SetSingleJob(Job job)
        {
            _queue.Clear();
            _queue.Add(job);
        }

        /// <summary>回す条件をまとめて入れる（段階3の Run All）</summary>
        public void SetQueue(IList<Job> jobs)
        {
            _queue.Clear();
            if (jobs != null) for (int i = 0; i < jobs.Count; i++) _queue.Add(jobs[i]);
        }

        void Start()
        {
            if (!runOnPlay) return;
            if (!Resolve()) return;
            if (_queue.Count == 0) { Debug.LogError("[ServiceSim] 回す条件がありません"); return; }

            Application.runInBackground = true;
            ApplyTimeMode();
            // 見る用の回は、描画を止めない（字幕も ServiceSimLabels が出す）
            if (plan.disableRenderingWhileRunning && !viewMode) DisableCameras();

            _jobIndex = 0;
            _jobCount = _queue.Count;
            _runsDone = 0;
            _wallStart = Time.realtimeSinceStartup;
            _running = true;
            StartJob(_queue[0]);
        }

        bool Resolve()
        {
            if (store == null) store = FindObjectOfType<StoreBuilder>();
            if (baker == null) baker = FindObjectOfType<NavMeshBaker>();
            if (people == null) people = FindObjectOfType<PeopleSpawner>();
            if (ops == null) ops = FindObjectOfType<ServiceOps>();
            if (groups == null) groups = FindObjectOfType<GroupManager>();
            if (jams == null) jams = FindObjectOfType<JamWatcher>();
            if (robotsRoot == null && store != null && store.transform.parent != null)
                robotsRoot = store.transform.parent.Find("Robots");

            if (plan == null || service == null || store == null || baker == null || people == null ||
                ops == null || groups == null || jams == null || robotPrefab == null ||
                robotsRoot == null || store.layout == null)
            {
                Debug.LogError("[ServiceSim] ExperimentRunner の参照が足りません。ServiceSim/Setup を実行してください");
                return false;
            }
            return true;
        }

        void ApplyTimeMode()
        {
            Time.captureDeltaTime = 0f;
            switch (timeMode)
            {
                case TimeMode.RealTime: Time.timeScale = 1f; break;
                case TimeMode.FastTimeScale: Time.timeScale = plan.trialTimeScaleFast; break;
                default:
                    Time.timeScale = 1f;
                    Time.captureDeltaTime = captureDeltaTimeOverride > 0f
                        ? captureDeltaTimeOverride : plan.measureCaptureDeltaTime;
                    break;
            }
        }

        void DisableCameras()
        {
            Camera[] cams = FindObjectsOfType<Camera>();
            for (int i = 0; i < cams.Length; i++) cams[i].enabled = false;
        }

        // ---------------- 1条件ぶん ----------------

        void StartJob(Job job)
        {
            _job = job;
            _logger.Clear();
            _info.frameDeltas.Clear();
            _info.robotSteps.Clear();
            _info.peopleSteps.Clear();
            _info.chairPullsByReason.Clear();
            _info.chairPulls = 0;
            _walkerSamples.Clear();
            _tripSeq = 0;
            _runsFinished = 0;
            _runsAfterMeasure = 0;
            _stoppedEarly = false;
            _everyoneLeft = false;
            _outputsWritten = false;
            _condition = null;

            StoreLayout layout = store.layout;
            layout.aisleWidthIndex = job.aisleWidthIndex;

            // 停止点は条件の1つ（C1）。メニューが決めた値を、組み立てる前に入れる。
            // .asset のもとの値は覚えておき、ぜんぶ終わったら戻す（.asset は書き換えない）。
            if (!_deliveryOffsetSaved)
            {
                _savedDeliveryOffset = layout.deliveryOffsetFromAisleCenter;
                _deliveryOffsetSaved = true;
            }
            layout.deliveryOffsetFromAisleCenter = job.applyDeliveryOffset
                ? job.deliveryOffset : _savedDeliveryOffset;

            job.conditionId = ExperimentPlan.ConditionId(layout.AisleWidth, job.crowd, job.robots);

            // サイトの条件で回すとき（v5 3章）。
            // 建てる前に値を入れる（卓の数・来客のペース・時間の決まりが組み立てと来客に効く）。
            // 1つでも読めない値があれば、回さずに止める（Unity の .asset で埋めない）
            if (job.siteRun && !ApplySiteValues(job))
            {
                AbortRun();
                return;
            }

            store.Build();

            // 建てた卓の数が、試算の stores.tables と違ったら回さない（v5 3章）。
            // 店の形（JSON）が合わないときは Unity で埋めずに止める
            if (job.siteRun && !TablesMatchSite(job))
            {
                AbortRun();
                return;
            }

            baker.Bake();

            // 見る用の回は、床の塗り分けを Build Store と同じに作り直す。
            // 計測の回では作らない（見た目だけのものなので、速さを落とさない）
            if (viewMode) store.PaintFloor(baker, people.spec != null ? people.spec.personRadius : 0.2f);

            ops.spec = service;
            ops.robotSpec = robotSpec;
            ops.store = store;
            ops.Setup();

            people.service = service;
            people.ops = ops;
            people.Spawn(job.seed);

            groups.spec = service;
            groups.people = people.spec;
            groups.ops = ops;
            groups.store = store;
            groups.spawner = people;     // 人の動きに合わせて椅子を引く（B6）
            groups.bodyRoot = transform;

            jams.spec = people.spec;
            jams.ops = ops;
            jams.store = store;
            jams.Setup();

            SpawnRobots(job.robots);

            // 始め方（v5 3章）。
            //   サイトの条件 … 空の店から、peak_min 分だけ来客し、全員が帰るまで回す
            //                  （max_extra_min で打ち切り）。慣らしは無し
            //   それ以外     … これまでどおり、8割埋まった店＋慣らし 15 分
            if (job.siteRun)
            {
                groups.seedWarmupGroups = false;
                _arrivalsEndS = service.peakMinutes * 60f;
                groups.arrivalsEndS = _arrivalsEndS;
                _hardEndS = _arrivalsEndS + service.maxExtraMin * 60f;
                _measureStartS = 0f;
                _endS = _hardEndS;
            }
            else
            {
                groups.seedWarmupGroups = true;
                groups.arrivalsEndS = 0f;
                _arrivalsEndS = 0f;
                _hardEndS = 0f;
                _measureStartS = plan.warmupMinutes * 60f;
                _endS = (plan.warmupMinutes + plan.measureMinutes) * 60f;
            }

            groups.Setup(job.crowd, job.seed, people.Targets);
            ops.SetStaffCount(people.StaffSpawned);

            _timeOffset = Time.time;
            _lastLog = 0f;
            _jobRunning = true;

            Debug.Log("[ServiceSim] 回し始め " + (_jobIndex + 1) + "/" + _jobCount +
                      "：" + job.conditionId + " 種" + job.seed +
                      (job.siteRun
                        ? "（サイトの条件。空の店から来客 " + service.peakMinutes +
                          " 分 ＋ 全員が帰るまで（上限 " + service.maxExtraMin + " 分）" +
                          "・スタッフ " + people.StaffSpawned + " 人・ロボット " + job.robots + " 台）"
                        : "（慣らし " + plan.warmupMinutes + " 分 ＋ 計測 " + plan.measureMinutes + " 分）"));
        }

        /// <summary>
        /// 合わない値があって回せないとき。書き出さずに Play を抜ける（v5 3章）。
        /// .asset に入れた値は、OnDisable と同じようにもとへ戻す
        /// </summary>
        void AbortRun()
        {
            _running = false;
            _jobRunning = false;
            RestoreDeliveryOffset();
            RestoreSiteValues();
            Time.captureDeltaTime = 0f;
            Time.timeScale = 1f;
            Debug.LogError("[ServiceSim] 回しませんでした。Console の上の行を見てください");
#if UNITY_EDITOR
            UnityEditor.EditorApplication.isPlaying = false;
#endif
        }

        /// <summary>
        /// 建てた卓の数が、試算の stores.{店}.tables と合っているか（v5 3章）。
        /// 合わなければ、どちらかが古い。Unity で埋めずに止める
        /// </summary>
        bool TablesMatchSite(Job job)
        {
            JsonStoreLayout js = store.layout as JsonStoreLayout;
            if (js == null || simParams == null) return true;

            // ops.Setup() はこのあとなので、建てた数は店の形（JSON）から数えたものを見る
            int want = Mathf.RoundToInt(simParams.StoreValue(job.storeKey, "tables", -1f));
            int got = js.TableCount;
            if (want < 0 || want == got) return true;

            Debug.LogError("[ServiceSim] 卓の数が合いません（回しません）：試算 stores." + job.storeKey +
                           ".tables = " + want + " / 建てた店 = " + got +
                           "\n  どちらかが古いので、サイトから写し直してください");
            return false;
        }

        /// <summary>
        /// サイトの試算の値を、この回だけ ServiceSpec / PeopleSpec / RobotSpec に入れる（v5 3章）。
        /// 版の番号（店の形の meta.generation ↔ 試算の meta.layout_generation）が合わなければ回さない。
        /// 読めない値が1つでもあれば回さない（Unity の .asset で埋めない）。
        /// </summary>
        bool ApplySiteValues(Job job)
        {
            if (simParams == null)
            {
                Debug.LogError("[ServiceSim] SimParams が入っていません（ServiceSim/Setup を実行してください）");
                return false;
            }

            JsonStoreLayout js = store.layout as JsonStoreLayout;
            if (js == null)
            {
                Debug.LogError("[ServiceSim] サイトの条件で回すには、JSON から建てた店（JsonStoreLayout）が要ります。" +
                               "いまの店: " + store.layout);
                return false;
            }

            string storeKey = string.IsNullOrEmpty(job.storeKey) ? js.ParamsStoreKey : job.storeKey;
            job.storeKey = storeKey;

            if (!simParams.Load(js.ReadGeneration()))
            {
                string why = "";
                for (int i = 0; i < simParams.Problems.Count; i++) why += "\n  - " + simParams.Problems[i];
                Debug.LogError("[ServiceSim] 試算の値を読めません（回しません）" + why);
                return false;
            }

            _condition = job.conditionIndex >= 0 ? simParams.ConditionAt(job.conditionIndex) : null;
            if (_condition == null)
            {
                Debug.LogError("[ServiceSim] 条件 " + job.conditionIndex + " が unity_conditions にありません");
                return false;
            }
            if (_condition.store != storeKey)
            {
                Debug.LogError("[ServiceSim] 条件の店（" + _condition.store + "）と、建てる店（" +
                               storeKey + "）が違います");
                return false;
            }

            if (!_siteValues.Apply(simParams, storeKey, _condition, service, people.spec, robotSpec))
            {
                string why = "";
                for (int i = 0; i < _siteValues.Problems.Count; i++) why += "\n  - " + _siteValues.Problems[i];
                Debug.LogError("[ServiceSim] 試算の値が足りません（回しません）" + why);
                return false;
            }

            job.robots = _condition.robots;
            job.conditionId = _condition.Id;
            Debug.Log("[ServiceSim] " + _siteValues.Report());
            return true;
        }

        void SpawnRobots(int count)
        {
            ops.ClearRobots();
            for (int i = robotsRoot.childCount - 1; i >= 0; i--) Destroy(robotsRoot.GetChild(i).gameObject);
            _robots.Clear();

            // 置き始めは、1台に1つの待機場所へ（v5）。
            // 受け渡し口の前に重ねて置くと、はじめからふさぎ合う
            Vector3 pass = store.PassPoint;
            IList<Vector3> standby = store.RobotStandbyPoints;
            for (int i = 0; i < count; i++)
            {
                GameObject go = Instantiate(robotPrefab, robotsRoot);
                go.name = "ServingRobot" + (i + 1).ToString("00");
                ServingRobotController c = go.GetComponent<ServingRobotController>();
                if (c == null) { Destroy(go); continue; }
                c.robotId = i + 1;
                Vector3 start = standby.Count > 0
                    ? standby[i % standby.Count]
                    : pass + new Vector3(0f, 0f, -0.8f * i);
                c.PlaceAt(start, Quaternion.LookRotation(Vector3.back, Vector3.up));
                c.TripCompleted += OnRunDone;
                _robots.Add(c);
                ops.RegisterRobot(c);
            }
        }

        void Update()
        {
            if (!_running || !_jobRunning) return;

            float dt = Time.deltaTime;
            _wallSeconds = Time.realtimeSinceStartup - _wallStart;
            _info.frameDeltas.Add(dt);

            float step = 0f;
            for (int i = 0; i < _robots.Count; i++)
                if (_robots[i] != null) step = Mathf.Max(step, _robots[i].LastStepM);
            _info.robotSteps.Add(step);

            // 人の1コマの移動量も記録する（B10）。通路を歩く人のうちいちばん大きいもの
            float peopleStep = 0f;
            IReadOnlyList<PersonBody> bodies = PersonBody.All;
            for (int i = 0; i < bodies.Count; i++)
            {
                PersonBody b = bodies[i];
                if (b == null || !b.walksInAisle) continue;
                if (b.LastStepM > peopleStep) peopleStep = b.LastStepM;
            }
            _info.peopleSteps.Add(peopleStep);

            ops.Advance(dt);
            groups.Tick(dt);
            jams.Tick(dt);
            _simMinutes = ops.NowS / 60f;

            if (ops.NowS >= _measureStartS) _walkerSamples.Add(groups.WalkingBodies + people.StaffSpawned);

            if (_wallSeconds - _lastLog >= 60f)
            {
                _lastLog = _wallSeconds;

                // 「走り」はロボットが走り終わった回数、「記録」は trips.csv に書いた行数。
                // 走りがあるのに記録が増えないときは、記録の取りこぼしなのでその場で止める
                Debug.Log("[ServiceSim] とちゅう " + (_jobIndex + 1) + "/" + _jobCount + "：" +
                          _simMinutes.ToString("0.0") + " / " +
                          (_job.siteRun
                            ? "来客 " + (_arrivalsEndS / 60f).ToString("0") + " 分・上限 " +
                              (_hardEndS / 60f).ToString("0") + " 分"
                            : (_endS / 60f).ToString("0") + " 分") + "、" +
                          "組 " + groups.GroupsArrived + "（待ち " + groups.WaitingGroups +
                          "・帰った " + groups.GroupsDone + "）、" +
                          "走り " + _runsFinished + " 回・記録 " + _logger.RowCount + " 行、" +
                          "椅子 " + ops.chairPulls + " 回、" +
                          "あきらめ " + (ops.gaveUpCount + ops.warpedCount + ops.tableFreedCount) + " 回、" +
                          "立ち往生 " + ops.rescueCalls + " 回、" +
                          "実時間 " + (_wallSeconds / 60f).ToString("0.0") + " 分");

                CheckTripsAreRecorded();
            }

            if (ShouldFinish()) FinishJob();
        }

        /// <summary>
        /// 1条件を終える時。
        ///   サイトの条件（v5 3章）… 来客を受け付け終わって、全員が帰ったら終わり。
        ///                            帰らないまま max_extra_min を過ぎたら打ち切る
        ///   それ以外               … これまでどおり、慣らし＋計測の長さで終わり
        /// </summary>
        bool ShouldFinish()
        {
            if (!_job.siteRun) return ops.NowS >= _endS;

            if (ops.NowS >= _arrivalsEndS && !groups.AnyGroupInStore)
            {
                _everyoneLeft = true;
                return true;
            }
            return ops.NowS >= _hardEndS;
        }

        /// <summary>
        /// 走りがあるのに trips.csv の行が増えないときは、記録の取りこぼし。
        /// 気づかないまま何時間も回さないように、その場で止めて知らせる。
        /// 慣らしの間に始まった走りは数えないので、計測が始まってから見る。
        /// </summary>
        void CheckTripsAreRecorded()
        {
            if (ops.NowS < _measureStartS) return;
            if (_runsAfterMeasure <= 0) return;
            if (_logger.RowCount > 0) return;

            // 計測が始まってから走り終わった回数がこれを超えても記録が0なら、おかしい
            if (_runsAfterMeasure < TripRecordAlarmRuns) return;

            Debug.LogError("[ServiceSim] 走りが " + _runsAfterMeasure +
                           " 回あるのに trips.csv の行が 0 です。記録を取りこぼしています。" +
                           "\n  止めました。ExperimentRunner.OnRunDone と " +
                           "ServingRobotController.RunStartS を確かめてください" +
                           "\n  店の中 " + (ops.NowS / 60f).ToString("0.0") + " 分 / 計測の始まり " +
                           (_measureStartS / 60f).ToString("0.0") + " 分");
            StopEarly();
        }

        /// <summary>途中で止める（書き出しはする）</summary>
        void StopEarly()
        {
            _stoppedEarly = true;
            FinishJob();
            AllDone();
        }

        void OnRunDone(ServingRobotController r)
        {
            _runsFinished++;
            if (ops.NowS >= _measureStartS) _runsAfterMeasure++;

            // 走り始めた時刻はロボットが持っている（ServingRobotController.RunStartS）。
            // ここで覚えておくと、走り終わってすぐ次の仕事が入ったとき、
            // 「空いている」状態を一度も見ないまま時刻が古いままになり、全部の走りを取りこぼす。
            float startS = r.RunStartS >= 0f ? r.RunStartS : ops.NowS - r.TotalS;
            if (startS < _measureStartS) return;

            IList<DishOrder> batch = ops.LastBatchOf(r);
            _tripSeq++;

            TripLogger.Row row = new TripLogger.Row();
            row.conditionId = _job.conditionId;
            row.aisleWidthM = store.layout.AisleWidth;
            row.crowd = ExperimentPlan.CrowdName(_job.crowd);
            row.robots = _job.robots;
            row.seed = _job.seed;
            row.robotId = r.robotId;
            row.tripId = _tripSeq;
            row.tableId = r.TableIds.Count > 0 ? r.TableIds[0] : 0;
            row.distanceM = r.DistanceM;
            row.totalS = r.TotalS;
            row.stoppedS = r.StoppedS;
            row.nStops = r.StopCount;
            row.stopDurationsS = ToArray(r.StopDurationsS);
            row.stuck = r.Stuck;
            row.timeout = r.TimedOut;
            row.waitAisleS = r.WaitAisleS;
            row.maxDecelMps2 = r.MaxDecelMps2;
            row.minClearanceM = r.MinClearanceM;
            row.metRobot = r.MetRobot;

            int[] ids = new int[r.TableIds.Count];
            for (int i = 0; i < ids.Length; i++) ids[i] = r.TableIds[i];
            row.tableIds = ids;
            row.dishes = batch.Count;

            float[] ready = new float[batch.Count], arrived = new float[batch.Count];
            int undelivered = 0;
            for (int i = 0; i < batch.Count; i++)
            {
                ready[i] = batch[i].readyS;
                arrived[i] = batch[i].arrivedS;
                if (batch[i].arrivedS < 0f) undelivered++;
            }
            row.dishReadyS = ready;
            row.dishArrivedS = arrived;
            row.undelivered = undelivered;

            // ---- 往復ごとの測り（v5 3章・設計書2章の4つ）----
            // 実際に走れた速さは、止まっていた分を除いた時間で出す（止まり込みの平均は別物）
            float movingS = r.TotalS - r.StoppedS;
            row.speedMps = movingS > 0.01f ? r.DistanceM / movingS : 0f;
            row.stuckCount = r.StuckCount;
            row.stuckWaitS = r.StuckWaitS;
            row.viaNarrow = r.LegWasNarrow;
            SplitStopsByAisle(r, ref row);

            _logger.Add(row);

            AddStopRows(r, _tripSeq);
        }

        void AddStopRows(ServingRobotController robot, int tripId)
        {
            float normal = NormalWidthM();
            IReadOnlyList<ServingRobotController.StopEvent> events = robot.StopEvents;
            for (int i = 0; i < events.Count; i++)
            {
                ServingRobotController.StopEvent e = events[i];
                TripLogger.StopRow s = new TripLogger.StopRow();
                s.conditionId = _job.conditionId;
                s.seed = _job.seed;
                s.robotId = robot.robotId;
                s.tripId = tripId;
                s.tS = e.startTimeS - _timeOffset;
                s.x = e.position.x;
                s.z = e.position.z;
                s.aisle = AisleNameAt(e.position);
                s.inNarrow = InNarrowAisle(e.position, normal);
                s.cause = TripLogger.CauseLabel(e.cause);
                s.causeDetail = e.causeDetail;
                s.pathBlocked = e.pathBlocked;
                s.durationS = e.durationS;
                s.resolved = TripLogger.ResolveLabel(e.resolve);
                s.clearanceLeftCm = e.clearanceLeftM * 100f;
                s.clearanceRightCm = e.clearanceRightM * 100f;
                s.minGapCm = Mathf.Min(s.clearanceLeftCm, s.clearanceRightCm);

                // 何で動けなかったかの内訳（B7）
                s.blockedS = e.blockedS;
                s.noPathS = e.noPathS;
                s.yieldS = e.yieldS;
                s.clearWaitS = e.clearWaitS;
                s.replans = e.replans;

                // 道を引いたときの中身（調べるためだけ）
                s.solveFrom = Xz(e.solveFrom);
                s.solveTo = Xz(e.solveTo);
                s.solveStatus = string.IsNullOrEmpty(e.solveStatus) ? "なし" : e.solveStatus;
                s.solveCorners = e.solveCorners;
                s.offMeshM = e.offMeshM;

                _logger.AddStop(s);
            }
        }

        /// <summary>
        /// 止まりを、狭い通路の中と外に分けて数える（v5 3章。⑦の読み分け）。
        /// 「狭い」はサイトの決まりのまま、幅が meta.settings.normal_width（1.0 m）未満の通路。
        /// ③の通路は 1.2 m なので、ぜんぶ「外」になる。
        /// </summary>
        void SplitStopsByAisle(ServingRobotController robot, ref TripLogger.Row row)
        {
            float normal = NormalWidthM();
            IReadOnlyList<ServingRobotController.StopEvent> events = robot.StopEvents;
            for (int i = 0; i < events.Count; i++)
            {
                if (InNarrowAisle(events[i].position, normal))
                {
                    row.stopsInNarrow++;
                    row.stoppedInNarrowS += events[i].durationS;
                }
                else
                {
                    row.stopsOutNarrow++;
                    row.stoppedOutNarrowS += events[i].durationS;
                }
            }
        }

        /// <summary>サイトの「ふだん走らせる道の幅」（m）。JSON に無ければ 0（＝ぜんぶ「外」）</summary>
        float NormalWidthM()
        {
            JsonStoreLayout js = store != null ? store.layout as JsonStoreLayout : null;
            return js != null ? js.NormalWidth : 0f;
        }

        bool InNarrowAisle(Vector3 p, float normalWidth)
        {
            if (normalWidth <= 0f) return false;
            IList<StoreBuilder.AisleInfo> aisles = store.Aisles;
            for (int i = 0; i < aisles.Count; i++)
            {
                if (aisles[i].width >= normalWidth) continue;
                Bounds b = aisles[i].zone;
                if (p.x < b.min.x || p.x > b.max.x || p.z < b.min.z || p.z > b.max.z) continue;
                return true;
            }
            return false;
        }

        /// <summary>点を "x;z" の1つの欄にする（stops.csv の solve_from / solve_to）</summary>
        static string Xz(Vector3 p)
        {
            return p.x.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture) + ";" +
                   p.z.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
        }

        string AisleNameAt(Vector3 p)
        {
            string wide = "";
            IList<StoreBuilder.AisleInfo> aisles = store.Aisles;
            for (int i = 0; i < aisles.Count; i++)
            {
                Bounds b = aisles[i].zone;
                if (p.x < b.min.x || p.x > b.max.x || p.z < b.min.z || p.z > b.max.z) continue;
                if (aisles[i].narrow) return aisles[i].name;
                if (wide.Length == 0) wide = aisles[i].name;
            }
            return wide;
        }

        static float[] ToArray(IReadOnlyList<float> list)
        {
            if (list == null || list.Count == 0) return new float[0];
            float[] a = new float[list.Count];
            for (int i = 0; i < a.Length; i++) a[i] = list[i];
            return a;
        }

        // ---------------- 書き出して次へ ----------------

        void FinishJob()
        {
            _jobRunning = false;
            AddGroupRows();
            WriteOutputs(_job.folder, _stoppedEarly);

            _jobIndex++;
            if (_jobIndex < _queue.Count) { StartJob(_queue[_jobIndex]); return; }
            AllDone();
        }

        /// <summary>
        /// 1条件ぶんの記録を書き出す（v5 3章で切り出した）。
        /// 途中で Play を止めたときも、ここまでの分を同じ形で書き出せるようにするため、
        /// 置き場所と「途中で止めたか」を渡す形にした。
        /// 書き出しの中で、オブジェクトを探したり消したりしないこと
        /// （Play を抜ける最中にも呼ばれるため）。
        /// </summary>
        void WriteOutputs(string dir, bool stoppedEarly)
        {
            _outputsWritten = true;
            try
            {
                Directory.CreateDirectory(dir);

                TripLogger.SimLike sim = new TripLogger.SimLike();
                sim.dishWaitMin.AddRange(ops.dishWaitMin);
                sim.seatWaitMin.AddRange(ops.seatWaitMin);
                sim.turnMin.AddRange(ops.turnMin);
                sim.coldMin = service.coldMin;
                sim.robotDishes = ops.robotDishes;
                sim.staffDishes = ops.staffDishes;
                sim.requeuedDishes = ops.requeuedDishes;
                sim.groups = groups.GroupsDone;
                sim.jamCount = jams.JamCount;
                sim.jamTotalS = jams.TotalJamSeconds;
                sim.robotBlockedJams = jams.RobotBlockedJamCount;   // B9
                sim.chairPulls = ops.chairPulls;                     // B6
                sim.robotContacts = ops.Contacts.Count;              // 人がロボットをよける
                sim.robotYieldWaits = ops.robotYieldWaits;
                sim.robotYieldWaitSeconds = ops.robotYieldWaitSeconds;
                sim.robotYieldDetours = ops.robotYieldDetours;
                sim.registerCrowdMax = ops.registerCrowdMax;         // レジの前の人数
                sim.registerCrowdMean = ops.RegisterCrowdMean;
                sim.registerPayCount = ops.registerPayCount;
                sim.registerPayNotAtBackCount = ops.registerPayNotAtBackCount;
                sim.registerPayOffSpotCount = ops.registerPayOffSpotCount;
                sim.registerPayMeanOffsetM = ops.RegisterPayMeanOffsetM;
                sim.registerPayMaxOffsetM = ops.registerPayMaxOffsetM;
                sim.registerBackIntruderCount = ops.registerBackIntruderCount;
                sim.registerBackIntruderSeconds = ops.registerBackIntruderSeconds;
                sim.staffTime.Clear();
                foreach (KeyValuePair<string, float> kv in ops.StaffTime) sim.staffTime.Add(kv);
                sim.dishParts.Clear();
                sim.dishParts.AddRange(ops.DishPartsList);
                sim.jobWaitMean.Clear();
                foreach (KeyValuePair<string, float> kv in ops.JobWaitSum)
                {
                    int n;
                    ops.JobWaitCount.TryGetValue(kv.Key, out n);
                    sim.jobWaitMean.Add(new KeyValuePair<string, float>(kv.Key, n > 0 ? kv.Value / n : 0f));
                }
                sim.drinkBarQueued = ops.drinkBarQueued;             // B4
                sim.drinkBarGaveUp = ops.drinkBarGaveUp;
                sim.drinkBarWaitMin.Clear();
                sim.drinkBarWaitMin.AddRange(ops.drinkBarWaitMin);
                sim.gaveUp = ops.gaveUpCount;                        // B1
                sim.warped = ops.warpedCount;
                sim.tableFreed = ops.tableFreedCount;
                sim.walkersMean = MeanInt(_walkerSamples);
                sim.measuredMinutes = _job.siteRun ? ops.NowS / 60f : plan.measureMinutes;

                // ---- サイトと同じ名前で出す値（v5 3章・meta.metrics）----
                sim.entryWaitMin.AddRange(ops.entryWaitMin);
                sim.fullWaitMin.AddRange(ops.fullWaitMin);
                sim.blockedWaitMin.AddRange(ops.blockedWaitMin);
                sim.guideWaitMin.AddRange(ops.guideWaitMin);
                sim.staffWaitMin.AddRange(ops.staffWaitMin);
                sim.walkKmPerStaff = ops.WalkKmPerStaff;
                sim.staffUtil = StaffUtil();
                sim.robotUtil = ops.RobotUtil;
                sim.rescues = ops.rescueCalls;
                sim.rescuesDone = ops.rescueDone;
                sim.rescueWaitS = ops.rescueWaitSeconds;
                sim.pauses = _logger.TotalStopCount();
                sim.siteRun = _job.siteRun;
                sim.siteConditionId = _job.conditionId;
                sim.siteResult.Clear();
                if (_condition != null)
                    foreach (KeyValuePair<string, float> kv in _condition.siteResult)
                        sim.siteResult.Add(kv);
                sim.arrivalsMinutes = _job.siteRun ? _arrivalsEndS / 60f : 0f;
                sim.totalMinutes = ops.NowS / 60f;
                sim.everyoneLeft = _everyoneLeft;
                _logger.SetSimLike(sim);

                _logger.WriteTripsCsv(Path.Combine(dir, "trips.csv"));
                _logger.WriteStopsCsv(Path.Combine(dir, "stops.csv"));
                _logger.WriteGroupsCsv(Path.Combine(dir, "groups.csv"));
                TripLogger.WriteJamsCsv(Path.Combine(dir, "people_jams.csv"),
                                        _job.conditionId, _job.seed, jams.Rows);
                TripLogger.WriteGiveUpsCsv(Path.Combine(dir, "give_ups.csv"),
                                        _job.conditionId, _job.seed, ops.GiveUps);
                TripLogger.WriteRequeuesCsv(Path.Combine(dir, "dishes.csv"),
                                        _job.conditionId, _job.seed, ops.Requeues);
                TripLogger.WriteContactsCsv(Path.Combine(dir, "contacts.csv"),
                                        _job.conditionId, _job.seed, ops.Contacts);
                _logger.WriteSummaryJson(Path.Combine(dir, "summary.json"), plan.runName);

                _info.runLabel = runLabel;
                _info.conditionId = _job.conditionId;
                _info.timeMode = timeMode.ToString();
                _info.tripsRequested = 0;
                _info.tripsDone = _logger.RowCount;
                _info.robots = _job.robots;
                _info.seed = _job.seed;
                _info.aisleWidthM = store.layout.AisleWidth;
                _info.deliveryOffsetM = store.layout.deliveryOffsetFromAisleCenter;
                _info.deliveryOffsetUsedM = store.layout.DeliveryOffset();
                _info.crowd = ExperimentPlan.CrowdName(_job.crowd);
                _info.simSeconds = ops.NowS;
                _info.wallSeconds = _wallSeconds;
                _info.timeScale = timeMode == TimeMode.FastTimeScale ? plan.trialTimeScaleFast : 1f;
                _info.captureDeltaTime = Time.captureDeltaTime;

                // 椅子を引いた回数（B6）
                _info.chairPulls = ops.chairPulls;
                _info.chairPullsByReason.Clear();
                foreach (KeyValuePair<string, int> kv in ops.ChairPullsByReason)
                    _info.chairPullsByReason.Add(kv);
                _info.stoppedEarly = stoppedEarly;
                _info.storeKind = store.layout.name;
                TripLogger.WriteRunInfoJson(Path.Combine(dir, "run_info.json"), _info);

                TripLogger.WriteConditionsJson(Path.Combine(dir, "conditions.json"),
                    plan.runName, store.layout, robotSpec, people.spec, plan,
                    plan.BuildConditions(store.layout));

                _runsDone++;
                Debug.Log("[ServiceSim] 書き出し " + (_jobIndex + 1) + "/" + _jobCount + "：" + _job.conditionId +
                          " 種" + _job.seed +
                          "\n  走り " + _runsFinished + " 回 / 記録 " + _logger.RowCount + " 行 / 止まり " +
                          _logger.StopCount + " 回 / 組 " + _logger.GroupRowCount +
                          "\n  椅子を引いた " + ops.chairPulls + " 回 / 人どうしの詰まり " + jams.JamCount +
                          " 回（うちロボットが近くで止まっていた " + jams.RobotBlockedJamCount + " 回）" +
                          "\n  会計 " + ops.registerPayCount + " 回（店員側にいなかった " +
                          ops.registerPayNotAtBackCount + " 回・立つ点から離れた " +
                          ops.registerPayOffSpotCount + " 回・いちばん離れた " +
                          ops.registerPayMaxOffsetM.ToString("0.00") + " m・会計でないのに店員側にいた " +
                          ops.registerBackIntruderCount + " 回）/ レジの前の人数 最大 " +
                          ops.registerCrowdMax + "・平均 " + ops.RegisterCrowdMean.ToString("0.0") +
                          "\n  人がロボットを待った " + ops.robotYieldWaits + " 回・" +
                          ops.robotYieldWaitSeconds.ToString("0") + " 秒 / 回り道 " +
                          ops.robotYieldDetours + " 回 / 近づきすぎ " + ops.Contacts.Count + " 回" +
                          "\n  戻りの線を使えなかった " + ReturnLineFallbacks() + " 回" +
                          "（使えないときは受け渡し口の前へ戻る）" +
                          "\n  立ち往生で呼んだ " + ops.rescueCalls + " 回（動かした " + ops.rescueDone +
                          " 回・待った " + ops.rescueWaitSeconds.ToString("0") + " 秒）" +
                          "\n  入店待ち " + Mean(ops.entryWaitMin).ToString("0.00") + " 分（満席 " +
                          Mean(ops.fullWaitMin).ToString("0.00") + "・片付け待ち " +
                          Mean(ops.blockedWaitMin).ToString("0.00") + "・案内待ち " +
                          Mean(ops.guideWaitMin).ToString("0.00") + "）/ スタッフの歩き " +
                          ops.WalkKmPerStaff.ToString("0.00") + " km・手がふさがり " +
                          (StaffUtil() * 100f).ToString("0") + "%・ロボット " +
                          (ops.RobotUtil * 100f).ToString("0") + "%" +
                          "\n  あきらめた " + ops.gaveUpCount + " / 移した " + ops.warpedCount +
                          " / 卓を空けた " + ops.tableFreedCount +
                          " / 戻した皿 " + ops.requeuedDishes + "（届いた " + ops.robotDishes + "）" +
                          "\n  " + TripLogger.FrameStatsText(_info) +
                          "\n  " + dir);
            }
            catch (System.Exception e)
            {
                Debug.LogError("[ServiceSim] 書き出しに失敗: " + dir + "\n" + e);
            }
        }

        void AddGroupRows()
        {
            IList<GroupManager.Group> gs = groups.Groups;
            for (int i = 0; i < gs.Count; i++)
            {
                GroupManager.Group g = gs[i];
                TripLogger.GroupRow r = new TripLogger.GroupRow();
                r.conditionId = _job.conditionId;
                r.seed = _job.seed;
                r.groupId = g.id;
                r.size = g.size;
                r.tableId = g.table != null ? g.table.tableId : 0;
                r.tableSeats = g.table != null ? g.table.seatCount : 0;   // 2人席／4人席
                r.arriveS = g.arriveS;
                r.guideStartS = g.guideStartS;
                r.seatedS = g.seatedS;
                r.eatEndS = g.eatEndS;
                r.payStartS = g.payStartS;
                r.payS = g.payS;
                r.leftS = g.leftS;
                r.waitedMin = g.seatedS >= 0f && g.arriveS >= 0f ? (g.seatedS - g.arriveS) / 60f : -1f;
                r.warmup = g.warmup;
                r.state = g.state.ToString();
                _logger.AddGroup(r);
            }
        }

        /// <summary>
        /// staff_util：スタッフの手がふさがっていた割合（v5 3章）。
        /// ops.StaffTime が仕事ごとの秒を持っているので、「待機」以外の割合で出す。
        /// </summary>
        float StaffUtil()
        {
            float total = 0f, idle = 0f;
            foreach (KeyValuePair<string, float> kv in ops.StaffTime)
            {
                total += kv.Value;
                if (kv.Key == "待機") idle += kv.Value;
            }
            return total > 0.01f ? (total - idle) / total : 0f;
        }

        /// <summary>
        /// 戻りの線が使えず、今までどおり受け渡し口の前へ戻った回数（v5）。
        /// 0 でなければ、線が通れる床に乗っていないか、出ていく線・ドリンクバーの列に近すぎる
        /// </summary>
        int ReturnLineFallbacks()
        {
            int n = 0;
            for (int i = 0; i < _robots.Count; i++)
                if (_robots[i] != null) n += _robots[i].ReturnLineFallbacks;
            return n;
        }

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

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

        /// <summary>
        /// メニューが決めた停止点を、.asset のもとの値へ戻す（C1）。
        /// 書き出しが終わったあとに呼ぶ。.asset を書き換えたままにしないため。
        /// </summary>
        void RestoreDeliveryOffset()
        {
            if (!_deliveryOffsetSaved) return;
            _deliveryOffsetSaved = false;
            if (store != null && store.layout != null)
                store.layout.deliveryOffsetFromAisleCenter = _savedDeliveryOffset;
        }

        /// <summary>
        /// サイトの試算から入れた値を、.asset のもとの値へ戻す（v5 3章）。
        /// 停止点（C1）と同じ扱い。.asset を書き換えたままにしないため
        /// </summary>
        void RestoreSiteValues()
        {
            _siteValues.Restore();
        }

        void AllDone()
        {
            _running = false;
            RestoreDeliveryOffset();
            RestoreSiteValues();
            Time.captureDeltaTime = 0f;
            Time.timeScale = 1f;
            Debug.Log("[ServiceSim] ぜんぶ終わりました。" + _runsDone + " / " + _jobCount + " 条件、" +
                      "実時間 " + (_wallSeconds / 60f).ToString("0.0") + " 分");
#if UNITY_EDITOR
            UnityEditor.EditorApplication.isPlaying = false;
#endif
        }

        /// <summary>
        /// 途中で Play を止めたとき、そこまでの記録を書き出す（v5 3章）。
        ///
        /// 回している最中に止めると、これまでは何も残らなかった。
        /// 詰まった回でも give_ups.csv と summary.json を見られるように、同じ形で書き出す。
        ///   ・置き場所は、ふだんのフォルダ名に _stopped を付けたもの
        ///   ・run_info.json の stopped_early が true、summary.json の
        ///     site_run.everyone_left は false のまま
        /// 書き出しに失敗しても Play を抜けられるよう、まるごと try/catch で包む。
        /// Play を抜ける最中なので、ここから新しくオブジェクトを探したり消したりしない。
        /// </summary>
        void WriteOnStop()
        {
            if (!_running || !_jobRunning || _outputsWritten || _job == null) return;

            try
            {
                _stoppedEarly = true;
                _jobRunning = false;
                AddGroupRows();
                WriteOutputs(_job.folder + "_stopped", true);
                Debug.LogWarning("[ServiceSim] 途中で止めました。ここまでの記録を書き出しました" +
                                 "（計測には使えません）\n  店の中 " +
                                 (ops != null ? (ops.NowS / 60f).ToString("0.0") : "?") + " 分\n  " +
                                 _job.folder + "_stopped");
            }
            catch (System.Exception e)
            {
                Debug.LogError("[ServiceSim] 途中で止めたときの書き出しに失敗しました\n" + e);
            }
        }

        void OnApplicationQuit()
        {
            WriteOnStop();
        }

        void OnDisable()
        {
            // 回している最中に止めたときは、ここまでの記録を書き出す（v5 3章）
            WriteOnStop();

            // 途中で Play を抜けたときも、停止点とサイトの値を .asset のもとの値へ戻す
            RestoreDeliveryOffset();
            RestoreSiteValues();
            Time.captureDeltaTime = 0f;
            if (_running) Time.timeScale = 1f;
        }
    }
}
