#if HWTEST
#if !SELFTEST
#error HWTEST は SELFTEST の部品（SelfTestPump・SelfTestCheck・PatchColorError）を使うため，SELFTEST と同時に定義すること
#endif
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using Cv2 = OpenCvSharp.Cv2;
using Mat = OpenCvSharp.Mat;

namespace TIASshot {

    /// <summary>
    /// 実機テストのチャートが必要な検査（--with-chart．PLAN_06 フェーズ 2．HWTEST 限定）．
    /// 前提: カメラの前に舌診チャートがはまっている．校正は止めない（CameraBase.HwTestCalibrationSuppressed は false）．
    /// 順序: connect → calibrate → shot → close-saving → close-calibrating → cleanup．
    /// 保存先は一時フォルダ（Config の File/SaveFolder は作業フォルダ配下に差し替え済み）．判定はすべて HwTestThresholds の暫定しきい値と
    /// HwTestJudge・PatchColorError の純粋関数で行い，測定値は合否にかかわらず詳細行に出す．
    /// </summary>
    internal static partial class HwTestRunner {

        // ================================================================================
        // calibrate
        // ================================================================================

        /// <summary>
        /// 接続後のプレビューで自動校正（チャート検出 → ホワイトバランスの調整 → 変換行列の算出）が完了するのを待ち，
        /// 所要時間の内訳・撮影ボタンの有効化・校正データ・校正後のフレームを変換した色票の参照値との差を確かめる．
        /// </summary>
        private static void CheckCalibrate(Session session, SelfTestCheck c) {
            var form = session.Form;
            var camera = session.Camera;
            var freq = Stopwatch.Frequency;

            SetStep(session, "最初のフレームの待機");
            var first = SelfTestPump.PumpUntil(() => camera.HwTestFirstFrameAt != 0, HwTestThresholds.FirstFrameTimeout);
            if (!c.Expect(first, $"{HwTestThresholds.FirstFrameTimeout.TotalSeconds:0} 秒以内にフレームが届きません（接続後 {form.HwTestFrameCount} フレーム）")) return;

            SetStep(session, "自動校正の完了の待機（チャート検出 → ホワイトバランスの調整 → 変換行列の算出）");
            var messages = new List<string>();
            var done = SelfTestPump.PumpUntil(() => {
                RecordDistinctMessage(messages, form.HwTestMessage);
                return camera.HwTestIsCalibrated;
            }, HwTestThresholds.CalibrationWait);
            RecordDistinctMessage(messages, form.HwTestMessage);

            var t0 = camera.HwTestFirstFrameAt;
            var tDetected = camera.HwTestChartDetectedAt;
            var tCalibrated = camera.HwTestCalibratedAt;
            c.Info($"校正の所要時間: 最初のフレーム → チャート検出（固定判定を含む） {FormatMs(HwTestJudge.TimestampDeltaMs(t0, tDetected, freq))}，"
                 + $"チャート検出 → 校正完了（ホワイトバランス {Config.GetInt("Calib/Frames")} フレーム・変換行列の算出・校正データの保存） {FormatMs(HwTestJudge.TimestampDeltaMs(tDetected, tCalibrated, freq))}，"
                 + $"合計 {FormatMs(HwTestJudge.TimestampDeltaMs(t0, tCalibrated, freq))}（上限 暫定 {HwTestThresholds.CalibrationWait.TotalSeconds:0} 秒）");
            c.Info($"校正中の表示（重複を除いた順）: {string.Join(" → ", messages.Select(m => $"「{m}」"))}");
            if (!c.Expect(done,
                    $"{HwTestThresholds.CalibrationWait.TotalSeconds:0} 秒以内に自動校正が完了しません（状態: {(camera.IsCalibrating ? "校正中" : "チャート未検出")}，"
                    + $"{form.HwTestFrameCount} フレーム，最後の表示「{form.HwTestMessage}」．チャートがはまっているか・照明を確認してください）")) return;

            SetStep(session, "撮影ボタンの有効化の待機");
            var enabled = SelfTestPump.PumpUntil(() => form.HwTestShotsEnabled, HwTestThresholds.ShotsEnabledWait);
            c.Expect(enabled, "校正完了後に撮影ボタンが有効になりません（CalcTcc → EnableShots）", "校正完了後に撮影ボタンが有効になりました");
            c.Info($"校正完了後の表示: 「{form.HwTestMessage}」");

            var refPath = Config.GetString("File/TccSrgbRef");
            using (var reference = IoUtil.LoadMatFromCsv(refPath)) {
                if (!c.Expect(reference != null && reference.Rows == 24 && reference.Cols == 3, $"参照値（{refPath}）を 24x3 として読み込めません")) return;
                var refArray = PatchColorError.ToArray(reference);
                var channels = camera.HwTestConversionChannels;
                c.Info($"変換行列の項目数（Calib/ConversionChannels）: {string.Join(", ", channels)}");

                CheckCalibrationData(session, c, camera.HwTestCalibrationFolder, channels, refArray);
                CheckCalibratedFrame(session, c, camera, channels, refArray);
            }
            c.Info("カメラのホワイトバランスは校正で変わっています（次の接続でデバイス状態ファイルの値に戻ります．デバイス状態ファイルには書き込みません）");
        }

        /// <summary>
        /// 校正データ（一時フォルダの「校正」フォルダ）の有無と，校正に使った色票の値を変換した値（TCC_sRGB{CN}.csv）の参照値との差（当てはめの誤差．参考）を出す．
        /// </summary>
        private static void CheckCalibrationData(Session session, SelfTestCheck c, string folder, IReadOnlyList<int> channels, double[,] refArray) {
            if (!c.Expect(!string.IsNullOrEmpty(folder) && Directory.Exists(folder), $"校正データのフォルダがありません: {folder}")) return;
            c.Expect(SelfTestRunner.IsUnder(folder, session.SaveRoot), $"校正データが一時フォルダの外に保存されました: {folder}",
                $"校正データ: {folder}（{Directory.GetFiles(folder).Length} ファイル）");
            foreach (var ch in channels) {
                var name = Config.GetString("File/TccSrgbValues").Replace("{CN}", $"{ch:00}");
                var path = Path.Combine(folder, name);
                using (var fitted = File.Exists(path) ? IoUtil.LoadMatFromCsv(path) : null) {
                    if (!c.Expect(fitted != null && fitted.Rows == 24 && fitted.Cols == 3, $"{name} を 24x3 として読み込めません: {path}")) continue;
                    var s = PatchColorError.Compare(PatchColorError.ToArray(fitted), refArray);
                    c.Info($"{ch} 次元: 校正に使った色票の値の変換値と参照値の差（当てはめの誤差．参考．{name}）: {DescribeColorError(s)}");
                }
            }
        }

        /// <summary>
        /// 校正後に届いたフレームを校正で得た変換行列で sRGB に変換し（撮影の保存と同じ ColorCorrector.ConvertImage），
        /// 校正時のチャートマスクで 24 色票の平均値を取り，参照値（tcc_srgb）との差を変換行列の項目数ごとに判定する．
        /// </summary>
        private static void CheckCalibratedFrame(Session session, SelfTestCheck c, CameraBase camera, IReadOnlyList<int> channels, double[,] refArray) {
            SetStep(session, "校正後のフレームの複製の待機");
            Mat frame = null;
            List<Mat> masks = null;
            camera.HwTestRequestCalibratedFrame();
            try {
                var got = SelfTestPump.PumpUntil(() => camera.HwTestTakeCalibratedFrame(out frame, out masks), HwTestThresholds.CalibratedFrameWait);
                if (!c.Expect(got, $"{HwTestThresholds.CalibratedFrameWait.TotalSeconds:0} 秒以内に校正後のフレームが届きません")) return;
                if (!c.Expect(masks.Count == 24, $"チャートマスクが 24 個ではありません: {masks.Count} 個")) return;
                c.Info($"校正後のフレーム: {frame.Width}x{frame.Height}");

                // 白色票は参照値（BGR の和が最大の色票）から決める（tcc_srgb.csv の行の並びに依存させない）
                var white = PatchColorError.WhitePatchIndex(refArray);
                var rawWhite = Cv2.Mean(frame, masks[white]);
                c.Info($"変換前の白色票（{white + 1} 番．参照値の BGR の和が最大の色票）: B{rawWhite.Val0:0.0} G{rawWhite.Val1:0.0} R{rawWhite.Val2:0.0}"
                     + $"（校正の目標 {Config.GetString("Calib/Reference/B")}/{Config.GetString("Calib/Reference/G")}/{Config.GetString("Calib/Reference/R")}．参考）");

                // 変換前の色票内の画素（白飛びの調査で 8bit に切り詰める前の値を求めるため．並びは変換後の画素と同じ）
                var rawPixels = masks.Select(m => MaskedPixels(frame, m)).ToList();
                c.Info($"変換前の白色票（{white + 1} 番）の画素: {DescribePixelStats(HwTestJudge.SummarizePatchPixels(rawPixels[white]), false)}");

                var evidenceDir = session.Options.Keep ? PrepareEvidenceDir(session, c) : null;
                if (evidenceDir != null) SaveEvidenceImage(c, evidenceDir, "calibrated-frame-raw.png", frame);

                foreach (var ch in channels) {
                    SetStep(session, $"校正後のフレームの変換（{ch} 次元）と色票の差");
                    var measured = new double[24, 3];
                    var clipped = new HwTestPatchPixelStats[24];
                    using (var converted = camera.HwTestConvertToSrgb(frame, ch)) {
                        for (var i = 0; i < 24; i++) {
                            var mean = Cv2.Mean(converted, masks[i]);
                            for (var j = 0; j < 3; j++) measured[i, j] = mean[j];
                            clipped[i] = HwTestJudge.SummarizePatchPixels(MaskedPixels(converted, masks[i]));
                        }
                        if (evidenceDir != null) SaveEvidenceImage(c, evidenceDir, $"calibrated-frame-srgb{ch:00}.png", converted);
                    }

                    SetStep(session, $"校正後のフレームの 8bit に切り詰める前の値（{ch} 次元）");
                    var unclipped = new HwTestPatchPixelStats[24];
                    var unclippedMeans = new double[24, 3];
                    for (var i = 0; i < 24; i++) {
                        unclipped[i] = HwTestJudge.SummarizePatchPixels(camera.HwTestConvertPixelsUnclipped(rawPixels[i], ch));
                        for (var j = 0; j < 3; j++) unclippedMeans[i, j] = unclipped[i].Mean[j];
                    }

                    var s = PatchColorError.Compare(measured, refArray);
                    var otherMax = PatchColorError.MaxAbsExcluding(measured, refArray, white, out var otherMaxPatch);
                    var whiteMax = PatchColorError.PatchMaxAbs(measured, refArray, white);
                    var ok = HwTestJudge.JudgeColorError(s.MeanAbs, otherMax, whiteMax,
                        HwTestThresholds.MaxCalibrationMeanError, HwTestThresholds.MaxCalibrationMaxError, HwTestThresholds.MaxCalibrationWhiteMaxError);
                    var rule = $"上限 暫定 平均 {HwTestThresholds.MaxCalibrationMeanError} / 白色票を除いた最大 {HwTestThresholds.MaxCalibrationMaxError}"
                             + $" / 白色票 {HwTestThresholds.MaxCalibrationWhiteMaxError}（白飛びの調査までの暫定）";
                    var split = $"白色票（{white + 1} 番）を除いた最大 {otherMax:0.00}（{otherMaxPatch + 1} 番）・白色票の最大 {whiteMax:0.00}";
                    c.Expect(ok,
                        $"{ch} 次元: 校正後のフレームを変換した色票が参照値から離れています: {DescribeColorError(s)}，{split}（{rule}）",
                        $"{ch} 次元: 校正後のフレームを変換した色票と参照値の差: {DescribeColorError(s)}，{split}（{rule}）");
                    c.Info($"{ch} 次元: 色票ごとの差の最大（1〜24 番．8bit 階調）: {DescribePerPatch(measured, refArray)}");

                    // 白飛び（255 への頭打ち）の調査用の参考値（判定には使わない）
                    c.Info($"{ch} 次元: 白色票（{white + 1} 番）の変換後の 8bit の画素: {DescribePixelStats(clipped[white], false)}");
                    c.Info($"{ch} 次元: 白色票（{white + 1} 番）の 8bit に切り詰める前の画素: {DescribePixelStats(unclipped[white], true)}"
                         + $"（参照値 B{refArray[white, 0]:0.0} G{refArray[white, 1]:0.0} R{refArray[white, 2]:0.0}）");
                    c.Info($"{ch} 次元: 色票ごとのいずれかのチャネルが 255 の画素の割合（%．1〜24 番．変換後の 8bit）: {DescribePerPatchFraction(clipped, x => x.FractionAnyAtOrAboveMax)}");
                    c.Info($"{ch} 次元: 色票ごとのいずれかのチャネルが 0 の画素の割合（%．1〜24 番．変換後の 8bit）: {DescribePerPatchFraction(clipped, x => x.FractionAnyAtOrBelowMin)}");
                    c.Info($"{ch} 次元: 色票ごとのいずれかのチャネルが 255 を超えた画素の割合（%．1〜24 番．8bit に切り詰める前）: {DescribePerPatchFraction(unclipped, x => x.FractionAnyAboveMax)}");
                    c.Info($"{ch} 次元: 色票ごとのいずれかのチャネルが 0 未満の画素の割合（%．1〜24 番．8bit に切り詰める前）: {DescribePerPatchFraction(unclipped, x => x.FractionAnyBelowMin)}");
                    c.Info($"{ch} 次元: 色票ごとの 8bit に切り詰める前の最大（1〜24 番．B/G/R）: {DescribePerPatchMax(unclipped)}");
                    c.Info($"{ch} 次元: 色票ごとの変換後の 8bit の平均・最小・最大（1〜24 番．B/G/R）: {DescribePerPatchStats(clipped)}");
                    c.Info($"{ch} 次元: 参考 8bit に切り詰める前の値で色票の平均を取った場合の参照値との差: {DescribeColorError(PatchColorError.Compare(unclippedMeans, refArray))}");
                }
            } finally {
                frame?.Dispose();
                if (masks != null) {
                    foreach (var m in masks) m?.Dispose();
                }
            }
        }

        private static string DescribeColorError(PatchColorErrorStats s) {
            return $"チャネルごとの差の絶対値（8bit 階調．{s.Patches} 色票 × BGR）の平均 {s.MeanAbs:0.00}・最大 {s.MaxAbs:0.00}（{s.MaxAbsPatch + 1} 番），"
                 + $"参考 ΔE（CIE76．sRGB D65→Lab）の平均 {s.MeanDeltaE:0.00}・最大 {s.MaxDeltaE:0.00}（{s.MaxDeltaEPatch + 1} 番）";
        }

        private static string DescribePerPatch(double[,] measured, double[,] reference) {
            var parts = new List<string>();
            for (var i = 0; i < measured.GetLength(0); i++) {
                parts.Add($"{PatchColorError.PatchMaxAbs(measured, reference, i):0.0}");
            }
            return string.Join(" ", parts);
        }

        /// <summary>
        /// マスク（CV_8UC1．0 以外が色票）の中の画素の値を BGR の並びで取り出す（行 → 列の順．同じマスクなら画像が違っても同じ並び）．
        /// 画像は CV_8UC3（8bit）を受け付ける．
        /// </summary>
        private static double[] MaskedPixels(Mat image, Mat mask) {
            if (image.Type() != OpenCvSharp.MatType.CV_8UC3) throw new ArgumentException($"画素の取り出しは CV_8UC3 の画像だけに対応します（{image.Type()}）", nameof(image));
            if (image.Size() != mask.Size()) throw new ArgumentException($"画像とマスクの大きさが違います（画像 {image.Size()}，マスク {mask.Size()}）", nameof(mask));
            var rect = Cv2.BoundingRect(mask);
            var values = new List<double>();
            for (var y = rect.Top; y < rect.Bottom; y++) {
                for (var x = rect.Left; x < rect.Right; x++) {
                    if (mask.At<byte>(y, x) == 0) continue;
                    var p = image.At<OpenCvSharp.Vec3b>(y, x);
                    values.Add(p.Item0);
                    values.Add(p.Item1);
                    values.Add(p.Item2);
                }
            }
            return values.ToArray();
        }

        /// <summary>
        /// 証拠の画像（--hwtest-keep のときだけ）の保存先を作業フォルダの下に作る．一時フォルダ（作業フォルダ）の外になる場合は作らない．
        /// </summary>
        /// <returns>保存先（作れなければ null．理由は詳細行に出す）</returns>
        private static string PrepareEvidenceDir(Session session, SelfTestCheck c) {
            var dir = Path.Combine(session.WorkDir, "evidence");
            if (!SelfTestRunner.IsUnder(dir, session.WorkDir) || !SelfTestRunner.IsUnder(dir, Path.GetTempPath())) {
                c.Fail($"証拠の画像の保存先が一時フォルダ（作業フォルダ）の外です．保存しません: {dir}");
                return null;
            }
            try {
                Directory.CreateDirectory(dir);
            } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) {
                c.Warn($"証拠の画像の保存先を作れません．保存しません: {dir}: {ex.Message}");
                return null;
            }
            c.Info($"証拠の画像（--hwtest-keep）の保存先: {dir}");
            return dir;
        }

        private static void SaveEvidenceImage(SelfTestCheck c, string dir, string name, Mat image) {
            var path = Path.Combine(dir, name);
            if (IoUtil.SaveImage(path, image)) {
                c.Info($"証拠の画像を保存しました: {name}");
            } else {
                c.Warn($"証拠の画像を保存できません: {path}");
            }
        }

        /// <summary>色票内の画素の集計を 1 行にする（withUnclipped = true なら 255 超・0 未満の割合を出す．false なら 255・0 の割合）</summary>
        private static string DescribePixelStats(HwTestPatchPixelStats s, bool withUnclipped) {
            var text = $"{s.Pixels} 画素，平均 B{s.Mean[0]:0.0} G{s.Mean[1]:0.0} R{s.Mean[2]:0.0}，"
                     + $"最小 B{s.Min[0]:0.0} G{s.Min[1]:0.0} R{s.Min[2]:0.0}，最大 B{s.Max[0]:0.0} G{s.Max[1]:0.0} R{s.Max[2]:0.0}，";
            return withUnclipped
                ? text + $"いずれかのチャネルが 255 を超えた画素 {s.FractionAnyAboveMax:P2}・0 未満の画素 {s.FractionAnyBelowMin:P2}"
                : text + $"いずれかのチャネルが 255 の画素 {s.FractionAnyAtOrAboveMax:P2}・0 の画素 {s.FractionAnyAtOrBelowMin:P2}";
        }

        private static string DescribePerPatchFraction(IEnumerable<HwTestPatchPixelStats> stats, Func<HwTestPatchPixelStats, double> fraction) {
            return string.Join(" ", stats.Select(s => $"{fraction(s) * 100:0.0}"));
        }

        private static string DescribePerPatchMax(IEnumerable<HwTestPatchPixelStats> stats) {
            return string.Join(" ", stats.Select((s, i) => $"{i + 1}:{s.Max[0]:0}/{s.Max[1]:0}/{s.Max[2]:0}"));
        }

        private static string DescribePerPatchStats(IEnumerable<HwTestPatchPixelStats> stats) {
            return string.Join("; ", stats.Select((s, i) =>
                $"{i + 1}: 平均 {s.Mean[0]:0.0}/{s.Mean[1]:0.0}/{s.Mean[2]:0.0} 最小 {s.Min[0]:0}/{s.Min[1]:0}/{s.Min[2]:0} 最大 {s.Max[0]:0}/{s.Max[1]:0}/{s.Max[2]:0}"));
        }

        private static string FormatMs(double ms) {
            return double.IsNaN(ms) ? "（記録なし）" : $"{ms:0} ms";
        }

        /// <summary>表示の履歴に直前と違う表示だけを足す（件数の上限つき．校正の途中の表示を結果ファイルに残すため）</summary>
        private static void RecordDistinctMessage(List<string> messages, string message) {
            if (string.IsNullOrEmpty(message)) return;
            if (messages.Count > 0 && messages[messages.Count - 1] == message) return;
            if (messages.Count >= 30) return;
            messages.Add(message);
        }

        // ================================================================================
        // shot
        // ================================================================================

        /// <summary>
        /// 単発撮影 1 回と連続撮影（HwTestThresholds.ShotMultiCount 枚・ShotMultiIntervalMs 間隔）を一時フォルダに行い，
        /// 保存の完了（保存スレッドの終了）を待って，保存物・撮影ログ・撮影ボタンの再有効化を確かめる．
        /// </summary>
        private static void CheckShot(Session session, SelfTestCheck c) {
            var form = session.Form;
            var camera = session.Camera;
            if (!c.Expect(session.CameraConnected && camera.HwTestIsCalibrated && form.HwTestShotsEnabled,
                    "校正が済んでいない（撮影ボタンが無効）ため撮影できません（前の calibrate を確認してください）")) return;

            var one = ShotAndVerify(session, c, "単発", "hwtest-shot-one", 1, 0, form.HwTestClickShotOne);

            var n = HwTestThresholds.ShotMultiCount;
            var interval = HwTestThresholds.ShotMultiIntervalMs;
            form.HwTestSetMultiShots(n, interval);
            var multi = ShotAndVerify(session, c, "連続", "hwtest-shot-multi", n, interval, form.HwTestClickShotMulti);

            if (one != null && multi != null) {
                c.Expect(!string.Equals(one, multi, StringComparison.OrdinalIgnoreCase),
                    $"単発と連続の撮影データが同じフォルダに保存されました: {one}");
            }
        }

        /// <summary>
        /// 撮影ボタンを押して（UI スレッドで同期に撮影．保存は保存スレッドで続く），保存の完了を待ち，保存物を確かめる．
        /// </summary>
        /// <returns>撮影データのフォルダ（撮影・保存ができなければ null）</returns>
        private static string ShotAndVerify(Session session, SelfTestCheck c, string label, string dataName, int count, int intervalMs, Action click) {
            var form = session.Form;
            var camera = session.Camera;
            form.HwTestSetDataName(dataName);
            var errorsBefore = form.HwTestErrors.Count;

            SetStep(session, $"{label}撮影（{count} 枚・間隔 {intervalMs} ms）");
            var sw = Stopwatch.StartNew();
            click();
            var shotMs = sw.ElapsedMilliseconds;
            var savingAfterShot = camera.IsSaving;
            var enabledAfterShot = form.HwTestShotsEnabled;
            c.Info($"{label}撮影: 撮影 {shotMs} ms（ボタンを押してから戻るまで），直後の状態: 保存中 {(savingAfterShot ? "はい" : "いいえ")}，撮影ボタン {(enabledAfterShot ? "有効" : "無効")}");
            // 撮影ボタンは保存の完了まで有効にならない（保存スレッドが終わりに UI スレッドへ投稿する）．保存中なのに有効なら不変条件の破れ
            c.Expect(!(enabledAfterShot && savingAfterShot), $"{label}撮影: 保存中なのに撮影ボタンが有効です（保存の完了前に再有効化された）");

            if (!WaitForSave(session, c, label)) return null;

            var folder = camera.HwTestSaveFolder;
            VerifyShotFolder(session, c, label, folder, count, intervalMs);
            // エラー通知（ShowError）は項目の終わりに RunCheck がまとめて FAIL にする．ここでは件数だけを出す
            var newErrors = form.HwTestErrors.Count - errorsBefore;
            c.Info(newErrors == 0 ? $"{label}撮影: エラー通知なし" : $"{label}撮影: エラー通知 {newErrors} 件（内容は項目の最後の FAIL）");
            c.Info($"{label}撮影: 保存完了後の表示「{form.HwTestMessage}」");
            return folder;
        }

        /// <summary>保存スレッドの終了を待ち，保存スレッドからの投稿を処理させてから撮影ボタンの再有効化を確かめる</summary>
        /// <returns>上限内に保存が終わったら true</returns>
        private static bool WaitForSave(Session session, SelfTestCheck c, string label) {
            var form = session.Form;
            var camera = session.Camera;
            SetStep(session, $"{label}: 保存の完了（保存スレッドの終了）の待機");
            var sw = Stopwatch.StartNew();
            var saved = SelfTestPump.PumpUntil(() => !camera.IsSaving, HwTestThresholds.SaveWait);
            var saveMs = sw.ElapsedMilliseconds;
            if (!c.Expect(saved, $"{label}: {HwTestThresholds.SaveWait.TotalSeconds:0} 秒以内に保存が終わりません（表示「{form.HwTestMessage}」）")) return false;
            SelfTestPump.PumpFor(HwTestThresholds.AfterSaveSettle);
            var enabled = SelfTestPump.PumpUntil(() => form.HwTestShotsEnabled, HwTestThresholds.ShotsEnabledWait);
            c.Expect(enabled, $"{label}: 保存の完了後に撮影ボタンが再有効化されません（SaveThread → EnableShots(true)）",
                $"{label}: 撮影の後 {saveMs} ms で保存が終わり，撮影ボタンが再有効化されました");
            return true;
        }

        /// <summary>
        /// 撮影データのフォルダの中身（撮影画像・sRGB 変換画像・Info.csv の過不足と大きさ）と，撮影ログの行数・撮影時刻の間隔を確かめる．
        /// </summary>
        private static void VerifyShotFolder(Session session, SelfTestCheck c, string label, string folder, int count, int intervalMs) {
            var camera = session.Camera;
            if (!c.Expect(!string.IsNullOrEmpty(folder) && Directory.Exists(folder), $"{label}: 撮影データのフォルダがありません: {folder}")) return;
            c.Expect(SelfTestRunner.IsUnder(folder, session.SaveRoot), $"{label}: 撮影データが一時フォルダの外に保存されました: {folder}");
            c.Expect(!string.Equals(folder, camera.HwTestCalibrationFolder, StringComparison.OrdinalIgnoreCase),
                $"{label}: 撮影データが校正データと同じフォルダに保存されました: {folder}");

            var infoName = Config.GetString("File/Info");
            var expected = HwTestJudge.ExpectedShotFiles(Config.GetString("File/RgbImage"), Config.GetString("File/SrgbImage"), infoName,
                camera.HwTestConversionChannels, count);
            var files = Directory.GetFiles(folder);
            HwTestJudge.CompareFileNames(expected, files.Select(Path.GetFileName), out var missing, out var unexpected);
            var empty = files.Where(f => new FileInfo(f).Length == 0).Select(Path.GetFileName).ToList();
            c.Info($"{label}: 撮影データ {folder}（{files.Length} ファイル．期待 {expected.Count}: 撮影画像 {count}・sRGB 変換画像 {count} × {camera.HwTestConversionChannels.Count}・{infoName}）");
            c.Expect(missing.Count == 0, $"{label}: 保存されていないファイルがあります: {string.Join(", ", missing)}");
            if (unexpected.Count > 0) c.Warn($"{label}: 想定していないファイルがあります: {string.Join(", ", unexpected)}");
            c.Expect(empty.Count == 0, $"{label}: 大きさ 0 のファイルがあります: {string.Join(", ", empty)}");

            var infoPath = Path.Combine(folder, infoName);
            if (!File.Exists(infoPath)) return;
            var rows = HwTestJudge.ParseShotLog(File.ReadAllLines(infoPath), out var parseError);
            if (!c.Expect(parseError == null, $"{label}: 撮影ログ（{infoName}）を読めません: {parseError}")) return;
            c.Info($"{label}: 撮影ログの撮影時刻(ms): {string.Join(", ", rows.Select(r => r.TimeMs))}");
            c.Expect(rows.Count == count, $"{label}: 撮影ログの行数が撮影枚数と違います: {rows.Count} 行（期待 {count}）");
            var notes = rows.Where(r => !string.IsNullOrEmpty(r.Note)).Select(r => $"{r.Shot}: {r.Note}").ToList();
            c.Expect(notes.Count == 0, $"{label}: 撮影ログに中止等の備考があります: {string.Join(" / ", notes)}");
            if (count >= 2) {
                var dev = HwTestJudge.MaxIntervalDeviationMs(rows.Select(r => r.TimeMs).ToList(), intervalMs);
                c.Expect(!double.IsNaN(dev) && dev <= HwTestThresholds.ShotIntervalToleranceMs,
                    $"{label}: 撮影時刻の間隔が設定値（{intervalMs} ms）から離れています: 差の最大 {dev:0} ms（許容 暫定 {HwTestThresholds.ShotIntervalToleranceMs} ms）",
                    $"{label}: 撮影時刻の間隔と設定値（{intervalMs} ms）の差の最大 {dev:0} ms（許容 暫定 {HwTestThresholds.ShotIntervalToleranceMs} ms）");
            }
        }

        // ================================================================================
        // close-saving
        // ================================================================================

        /// <summary>
        /// 保存に時間がかかる連続撮影（間隔 0）の直後，保存中（CameraBase.IsSaving）に閉じる処理を呼び，閉じるのが取り消されること
        /// （表示が Form1.SavingOnCloseMessage・後始末が走らない）を確かめる．保存の完了後にもう一度閉じる処理を呼び，閉じられること
        /// （段階 closing → timer → camera → light）を確かめる．保存中の状態を作れなかった場合は WARN にして枚数を増やして 1 回だけ再試行する．
        /// </summary>
        private static void CheckCloseSaving(Session session, SelfTestCheck c) {
            var form = session.Form;
            var camera = session.Camera;
            if (!c.Expect(session.CameraConnected && camera.HwTestIsCalibrated && form.HwTestShotsEnabled,
                    "校正が済んでいない（撮影ボタンが無効）ため保存中の状態を作れません（前の calibrate・shot を確認してください）")) return;
            var lightWasOpen = form.HwTestLightIsOpen;

            var decision = HwTestSavingAttempt.GiveUp;
            string folder = null;
            var count = 0;
            for (var attempt = 1; ; attempt++) {
                count = HwTestJudge.CloseSavingShotCount(attempt);
                form.HwTestSetDataName($"hwtest-close-saving-{attempt}");
                form.HwTestSetMultiShots(count, 0);
                SetStep(session, $"連続撮影（{count} 枚・間隔 0 ms．試行 {attempt}）");
                var sw = Stopwatch.StartNew();
                form.HwTestClickShotMulti();
                var saving = camera.IsSaving;
                folder = camera.HwTestSaveFolder;
                c.Info($"試行 {attempt}: 連続撮影 {count} 枚（間隔 0 ms）の撮影 {sw.ElapsedMilliseconds} ms，直後の保存中: {(saving ? "はい" : "いいえ")}");
                decision = HwTestJudge.DecideSavingAttempt(saving, attempt, HwTestThresholds.CloseSavingMaxAttempts);
                if (decision == HwTestSavingAttempt.Ready) break;
                c.Warn(decision == HwTestSavingAttempt.Retry
                    ? $"保存中の状態を作れなかった（撮影の直後に保存が終わっていた．試行 {attempt}）．枚数を増やして再試行します"
                    : $"保存中の状態を作れなかった（{attempt} 回試行）．保存中に閉じる取り消しの検査を省き，保存後に閉じられることだけを確かめます");
                if (!WaitForSave(session, c, $"試行 {attempt}")) return;
                if (decision == HwTestSavingAttempt.GiveUp) break;
            }

            if (decision == HwTestSavingAttempt.Ready && !camera.IsSaving) {
                // 連続撮影の直後の確認から閉じる処理を呼ぶまでの間に保存が終わった（ほぼ起きない）
                c.Warn("保存中の状態を作れなかった（閉じる処理を呼ぶ直前に保存が終わった）．保存中に閉じる取り消しの検査を省きます");
                decision = HwTestSavingAttempt.GiveUp;
                if (!WaitForSave(session, c, "保存の完了")) return;
            }
            if (decision == HwTestSavingAttempt.Ready) {
                if (!CheckCancelledClose(session, c, lightWasOpen)) return;
                if (!WaitForSave(session, c, "保存中に閉じた後")) return;
                VerifyShotFolder(session, c, "保存中に閉じた後", folder, count, 0);
            }

            CheckFinalClose(session, c, "保存の完了後に閉じる");
        }

        /// <summary>保存中に閉じる処理を呼び，取り消されたことを確かめる</summary>
        /// <returns>取り消された（後続の検査を続けられる）なら true．閉じてしまったら false</returns>
        private static bool CheckCancelledClose(Session session, SelfTestCheck c, bool lightWasOpen) {
            var form = session.Form;
            var camera = session.Camera;
            var stepsBefore = form.HwTestCloseSteps.Count;
            SetStep(session, "保存中に閉じる処理（Form1_FormClosing．取り消されるはず）");
            var closed = RequestCloseObserved(session, stepsBefore, out var elapsed, out var error, out _, out var added);
            // 表示は閉じる処理が同期に設定する．保存スレッドの投稿（保存の進み具合）で上書きされる前に読む（メッセージを処理する前）
            var shown = form.HwTestMessage;
            var savingAfter = camera.IsSaving;

            c.Info($"保存中に閉じる処理: {elapsed.TotalMilliseconds:0} ms，閉じると決まった: {(closed ? "はい" : "いいえ")}，表示「{shown}」，"
                 + $"進んだ後始末の段階: {(added.Count == 0 ? "なし" : string.Join(" → ", added))}，直後も保存中: {(savingAfter ? "はい" : "いいえ")}");
            if (error != null) c.FailException("保存中に閉じる処理で例外", error);
            var problems = HwTestJudge.CancelledCloseProblems(closed, shown, Form1.SavingOnCloseMessage, added.Count,
                form.HwTestIsClosing, form.HwTestRefreshTimerRunning);
            foreach (var p in problems) c.Fail($"保存中に閉じる処理: {p}");
            if (problems.Count == 0) c.Info($"保存中に閉じる処理は取り消されました（表示「{Form1.SavingOnCloseMessage}」，後始末は走っていません）");
            c.Expect(form.HwTestLightIsOpen == lightWasOpen, "保存中に閉じる処理の後に光源のポートの状態が変わりました（後始末が走った疑い）");
            return !closed;
        }

        /// <summary>
        /// 閉じる処理を呼び，閉じられること（所要時間・例外の有無・後始末の順序・閉じた後のフレーム）を確かめる（close-saving・close-calibrating 共通）．
        /// </summary>
        private static void CheckFinalClose(Session session, SelfTestCheck c, string label) {
            var form = session.Form;
            if (!c.Expect(session.CameraConnected, $"{label}: カメラが接続されていません")) return;
            var stepsBefore = form.HwTestCloseSteps.Count;
            SetStep(session, $"{label}（Form1_FormClosing → ShutdownOnClose）");
            var closed = RequestCloseObserved(session, stepsBefore, out var elapsed, out var error, out var steps, out var added);

            c.Info($"{label}: 所要時間 {elapsed.TotalMilliseconds:0} ms，通過した段階: {string.Join(" → ", added)}");
            if (error != null) c.FailException($"{label}: 閉じる処理で例外", error);
            c.Expect(closed, $"{label}: 閉じる処理が取り消されました（保存中と判定された．保存中: {(session.Camera.IsSaving ? "はい" : "いいえ")}）");
            c.Expect(HwTestJudge.WithinLimit(elapsed, HwTestThresholds.CloseLimit),
                $"{label}: 閉じる処理が上限（暫定 {HwTestThresholds.CloseLimit.TotalMilliseconds:0} ms）を超えました: {elapsed.TotalMilliseconds:0} ms");
            c.Expect(HwTestJudge.IsCloseSequenceSince(steps, stepsBefore),
                $"{label}: 後始末の順序が不正です: {string.Join(" → ", added)}（期待 closing → timer → camera → light）");
            c.Expect(form.HwTestIsClosing, $"{label}: 閉じ始めのフラグが立っていません");
            c.Expect(!form.HwTestRefreshTimerRunning, $"{label}: 本モニタの再描画タイマーが止まっていません");
            c.Expect(!form.HwTestLightIsOpen, $"{label}: 閉じた後も光源のポートが開いています");

            SetStep(session, $"{label}: 閉じた後にフレームが止まったかの確認");
            var afterStart = form.HwTestFrameCount;
            SelfTestPump.PumpFor(HwTestThresholds.AfterCloseObserve);
            var afterFrames = form.HwTestFrameCount - afterStart;
            c.Expect(afterFrames <= HwTestThresholds.MaxFramesAfterClose,
                $"{label}: 閉じた後もフレームが届いています: {HwTestThresholds.AfterCloseObserve.TotalSeconds:0} 秒で {afterFrames} フレーム（上限 暫定 {HwTestThresholds.MaxFramesAfterClose}）",
                $"{label}: 閉じた後 {HwTestThresholds.AfterCloseObserve.TotalSeconds:0} 秒間に届いたフレーム: {afterFrames}");
        }

        /// <summary>
        /// 閉じる処理（HwTestRequestClose）を呼び，所要時間・例外・その回に進んだ後始末の段階を返す（CheckCancelledClose・CheckFinalClose 共通）．
        /// 後始末がカメラの切断まで進んだら session.CameraConnected を下ろす．メッセージは処理しない（直後の表示を呼び出し側が読めるように）．
        /// </summary>
        /// <param name="stepsBefore">閉じる処理の前の後始末の段階の記録の件数</param>
        /// <param name="steps">閉じる処理の後の後始末の段階の記録（全件）</param>
        /// <param name="added">steps のうち stepsBefore 以降（その回の閉じる処理の分）</param>
        /// <returns>閉じる処理が閉じると決めたか（例外のときは false）</returns>
        private static bool RequestCloseObserved(Session session, int stepsBefore, out TimeSpan elapsed, out Exception error,
                out IReadOnlyList<string> steps, out List<string> added) {
            var form = session.Form;
            var sw = Stopwatch.StartNew();
            var closed = false;
            error = null;
            try {
                closed = form.HwTestRequestClose();
            } catch (Exception ex) {
                error = ex;
            }
            elapsed = sw.Elapsed;
            steps = form.HwTestCloseSteps;
            added = HwTestJudge.StepsSince(steps, stepsBefore);
            if (added.Contains("camera")) session.CameraConnected = false;
            return closed;
        }

        // ================================================================================
        // close-calibrating
        // ================================================================================

        /// <summary>
        /// 画面の状態を戻して再接続し（校正前の状態に戻る），校正中（CameraBase.IsCalibrating）になるのを待って閉じる処理を呼び，
        /// すぐ戻ること（所要時間・例外の有無・後始末の順序・閉じた後のフレーム）を確かめる．
        /// </summary>
        private static void CheckCloseCalibrating(Session session, SelfTestCheck c) {
            var form = session.Form;
            if (session.CameraConnected) {
                // 前の項目で閉じられなかった場合．閉じる処理を通さずに切断してから再接続する
                SetStep(session, "切断（Disconnect）");
                form.HwTestDisconnectCamera();
                session.CameraConnected = false;
                c.Info("前の項目で閉じられなかったカメラを切断しました");
            }

            SetStep(session, "画面の状態を接続直後に戻す（閉じ始めのフラグ・再描画タイマー・光源）");
            var lightOpen = form.HwTestResumeAfterClose();
            c.Info($"画面の状態を戻しました（光源のポート: {(lightOpen ? "開いて点灯" : "開けなかった")}）");

            SetStep(session, "Connect（再接続．校正前の状態に戻る）");
            var sw = Stopwatch.StartNew();
            var camera = form.HwTestConnect(session.Options.Camera);
            var connectErrors = form.HwTestTakeConnectErrors();
            if (!c.Expect(camera != null, $"再接続の Connect() に失敗しました（{string.Join(" / ", connectErrors)}）")) return;
            session.Camera = camera;
            session.CameraConnected = true;
            c.Info($"再接続の Connect(): {sw.ElapsedMilliseconds} ms（機種名 {camera.DeviceName}，シリアル番号 {camera.SerialNumber}．ホワイトバランスはデバイス状態ファイルの値に戻る）");
            c.Expect(camera.SerialNumber == session.FirstSerial,
                $"再接続したカメラのシリアル番号が 1 回目と異なります: {camera.SerialNumber}（1 回目 {session.FirstSerial}）");

            SetStep(session, "校正中（チャート検出 → ホワイトバランスの調整中）になるまでの待機");
            var calibrating = SelfTestPump.PumpUntil(() => camera.IsCalibrating || camera.HwTestIsCalibrated, HwTestThresholds.CalibratingWait);
            var detectMs = HwTestJudge.TimestampDeltaMs(camera.HwTestFirstFrameAt, camera.HwTestChartDetectedAt, Stopwatch.Frequency);
            if (!c.Expect(calibrating && camera.IsCalibrating,
                    calibrating
                        ? "校正中になる前に校正が完了しました（校正中に閉じる検査になりません）"
                        : $"{HwTestThresholds.CalibratingWait.TotalSeconds:0} 秒以内に校正中になりません（{form.HwTestFrameCount} フレーム，最後の表示「{form.HwTestMessage}」．チャートを確認してください）")) return;
            c.Info($"校正中になりました（最初のフレーム → チャート検出 {FormatMs(detectMs)}）");

            SetStep(session, "校正の途中まで進める");
            SelfTestPump.PumpFor(HwTestThresholds.CalibratingBeforeClose);
            var stillCalibrating = camera.IsCalibrating;
            if (stillCalibrating) {
                c.Info($"閉じる処理の直前も校正中です（校正中になってから {HwTestThresholds.CalibratingBeforeClose.TotalMilliseconds:0} ms 後）");
            } else {
                c.Warn("閉じる処理を呼ぶ前に校正が終わりました（校正中に閉じる検査になっていません．Calib/Frames を確認してください）");
            }

            CheckFinalClose(session, c, "校正中に閉じる");
            c.Info($"閉じた後の校正の状態: {(camera.IsCalibrating ? "校正中のまま止まった" : camera.HwTestIsCalibrated ? "校正済み" : "未校正")}（切断で校正の途中の状態は捨てられ，次の接続でやり直す）");
        }
    }
}
#endif
