#if SELFTEST
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Windows.Forms;
using System.Xml;
using OpenCvSharp;
using OpenCvSharp.Aruco;
using OpenCvSharp.Extensions;

namespace TIASshot {

    /// <summary>
    /// ヘッドレスのセルフテスト（SELFTEST 限定）．<c>TIASshot.exe --selftest</c> で起動すると，
    /// フォームを表示せずに次の 3 項目を実行し，終了コードと結果ファイル（＋標準出力）で結果を返す．
    /// <list type="number">
    /// <item>deps: 依存アセンブリのロード（バインディングリダイレクト含む）と OpenCvSharp の最小実行</item>
    /// <item>preview: 偽カメラ → Form1.ShowImage（FastClone）→ 本モニタ/副モニタの表示用 Bitmap の経路</item>
    /// <item>calib-save: 合成チャートでチャート検出 → 色変換行列算出 → 撮影 → sRGB 変換画像の保存</item>
    /// </list>
    /// 実カメラ SDK には触れない．保存先は一時フォルダに差し替え，ユーザーの設定・データは書き換えない．
    ///
    /// 引数:
    ///   --selftest                 セルフテストを実行する
    ///   --selftest-out &lt;path&gt;     結果ファイルの出力先（省略時は %TEMP%\TIASshot-selftest\result-&lt;日時&gt;.txt）
    ///   --selftest-keep            合格時も作業フォルダ（保存画像・CSV）を削除しない
    /// 終了コード: 0=全項目合格，1=不合格あり，2=セルフテスト自体の異常，3=タイムアウト
    ///
    /// 【注意】本クラスのメソッド本体・フィールドでは OpenCvSharp 等の依存型を直接使わず，
    /// 各検査はラムダ（別メソッドとして遅延 JIT される）の中で使う．依存 DLL が欠けていても
    /// JIT 失敗が検査単位の例外として捕捉され，どの依存が欠けたかを報告できるようにするため．
    /// </summary>
    internal static class SelfTestRunner {

        public const string FlagSelfTest = "--selftest";
        private const string FlagOut = "--selftest-out";
        private const string FlagKeep = "--selftest-keep";

        public const int ExitPass = 0;
        public const int ExitFail = 1;
        public const int ExitError = 2;
        public const int ExitTimeout = 3;

        private static readonly TimeSpan OverallTimeout = TimeSpan.FromMinutes(5);
        private static readonly TimeSpan PreviewTimeout = TimeSpan.FromSeconds(30);
        private static readonly TimeSpan CalibrationTimeout = TimeSpan.FromSeconds(120);
        private static readonly TimeSpan SaveTimeout = TimeSpan.FromSeconds(60);

        // カメラ SDK 由来のアセンブリ（SDK 未導入環境ではロードできなくても判定に含めない）
        private static readonly string[] CameraSdkAssemblyPrefixes = { "TIS.", "lumenera." };

        // 必ずロードできなければならない依存（NuGet 由来．依存更新で壊れやすいもの）
        private static readonly string[] RequiredAssemblies = {
            "OpenCvSharp",
            "OpenCvSharp.Extensions",
            "System.Memory",
            "System.Buffers",
            "System.Runtime.CompilerServices.Unsafe",
            "System.Numerics.Vectors",
            "System.Drawing.Common",
        };

        public static bool IsRequested(string[] args) {
            return HasFlag(args, FlagSelfTest);
        }

        /// <summary>
        /// セルフテストを実行して終了コードを返す（UI スレッド＝STA のメインスレッドから呼ぶこと）．
        /// </summary>
        public static int Run(string[] args) {
            var baseDir = AppDomain.CurrentDomain.BaseDirectory;
            var stamp = $"{DateTime.Now:yyyyMMdd-HHmmss}-{Process.GetCurrentProcess().Id}";
            var tempRoot = Path.Combine(Path.GetTempPath(), "TIASshot-selftest");
            var workDir = Path.Combine(tempRoot, $"work-{stamp}");
            var outPath = GetArgValue(args, FlagOut) ?? Path.Combine(tempRoot, $"result-{stamp}.txt");
            var keep = HasFlag(args, FlagKeep);

            SelfTestReport report;
            try {
                // 不正な --selftest-out（空文字・不正文字等）も結果ファイルを作成できない場合と同じく異常（2）とする
                outPath = Path.GetFullPath(outPath);
                report = new SelfTestReport(outPath);
            } catch (Exception ex) {
                Debug.WriteLine($"セルフテスト結果ファイルを作成できません: {outPath}: {ex}");
                return ExitError;
            }

            using (report)
            using (var done = new ManualResetEventSlim(false)) {
                StartWatchdog(report, done);
                AppDomain.CurrentDomain.UnhandledException += (s, e) => {
                    report.WriteLine($"[FAIL] unhandled: 未処理例外でプロセスが終了します: {SelfTestReport.DescribeException(e.ExceptionObject as Exception ?? new Exception(e.ExceptionObject?.ToString()))}");
                    report.WriteLine("RESULT: FAIL (unhandled exception)");
                    Environment.Exit(ExitError);
                };

                var resolveLog = new AssemblyResolveLog();
                resolveLog.Attach();

                report.WriteLine("TIASshot selftest");
                report.WriteLine($"  exe: {Assembly.GetExecutingAssembly().Location}");
                report.WriteLine($"  version: {Assembly.GetExecutingAssembly().GetName().Version}");
                report.WriteLine($"  build: {BuildDescription()}");
                report.WriteLine($"  process: {(Environment.Is64BitProcess ? "x64" : "x86")} / CLR {Environment.Version}");
                report.WriteLine($"  started: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
                report.WriteLine($"  work dir: {workDir}");
                report.WriteLine($"  result file: {outPath}");
                report.WriteLine();

                int exitCode;
                try {
                    // WinForms の UI 例外をダイアログ表示ではなく例外として呼び出し元（DoEvents）へ伝播させる
                    Application.SetUnhandledExceptionMode(UnhandledExceptionMode.ThrowException);

                    var checks = new List<SelfTestCheck>();
                    var ctx = new Context { BaseDir = baseDir, WorkDir = workDir };

                    checks.Add(RunCheck(report, resolveLog, "deps", "依存アセンブリのロードと OpenCvSharp の最小実行",
                        c => CheckDependencies(c, ctx, resolveLog)));

                    string setupError = PrepareConfig(ctx);

                    checks.Add(RunCheck(report, resolveLog, "preview", "偽カメラ → ShowImage（FastClone）→ 表示用 Bitmap のプレビュー経路",
                        c => {
                            if (setupError != null) { c.Fail(setupError); return; }
                            CheckPreview(c);
                        }));

                    checks.Add(RunCheck(report, resolveLog, "calib-save", "合成チャートでチャート検出 → 色変換行列算出 → 撮影 → sRGB 変換画像の保存",
                        c => {
                            if (setupError != null) { c.Fail(setupError); return; }
                            CheckCalibrationAndSave(c, ctx);
                        }));

                    var failed = checks.Where(x => x.Status == SelfTestStatus.Fail).ToList();
                    report.WriteLine();
                    if (failed.Count == 0) {
                        report.WriteLine($"RESULT: PASS ({checks.Count}/{checks.Count})");
                        exitCode = ExitPass;
                    } else {
                        report.WriteLine($"RESULT: FAIL ({failed.Count}/{checks.Count} failed: {string.Join(", ", failed.Select(x => x.Id))})");
                        exitCode = ExitFail;
                    }
                } catch (Exception ex) {
                    report.WriteLine($"[FAIL] runner: セルフテスト自体が異常終了しました: {SelfTestReport.DescribeException(ex)}");
                    report.WriteLine("RESULT: FAIL (runner error)");
                    exitCode = ExitError;
                }

                CleanupWorkDir(report, workDir, keep || exitCode != ExitPass);
                done.Set();
                return exitCode;
            }
        }

        // ================================================================================
        // 共通
        // ================================================================================

        private sealed class Context {
            public string BaseDir;
            public string WorkDir;
            public string SaveRoot;         // 一時設定の File/SaveFolder
            public string UserSaveFolder;   // 元の config.xml の File/SaveFolder（書き込まないことの確認用）
        }

        private static string BuildDescription() {
            var flags = new List<string>();
#if DEBUG
            flags.Add("DEBUG");
#endif
#if MEMMONITOR
            flags.Add("MEMMONITOR");
#endif
#if WHITEBOARD
            flags.Add("WHITEBOARD");
#endif
            flags.Add("SELFTEST");
            return string.Join(" ", flags);
        }

        private static bool HasFlag(string[] args, string name) {
            return args.Any(a => string.Equals(a, name, StringComparison.OrdinalIgnoreCase));
        }

        /// <summary>
        /// name の直後の引数を値として返す．name が無い・末尾にある・直後が別フラグ（"--" 始まり）の場合は null（値の欠落扱い）．
        /// </summary>
        private static string GetArgValue(string[] args, string name) {
            for (var i = 0; i < args.Length - 1; i++) {
                if (!string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)) continue;
                var value = args[i + 1];
                return value.StartsWith("--", StringComparison.Ordinal) ? null : value;
            }
            return null;
        }

        private static void StartWatchdog(SelfTestReport report, ManualResetEventSlim done) {
            var watchdog = new Thread(() => {
                if (done.Wait(OverallTimeout)) return;
                report.WriteLine($"[FAIL] watchdog: {OverallTimeout.TotalSeconds:0} 秒以内に終了しませんでした（ハングの疑い．Invoke の相互待機・モーダル表示等）");
                report.WriteLine("RESULT: FAIL (timeout)");
                Environment.Exit(ExitTimeout);
            }) {
                IsBackground = true,
                Name = "SelfTest.Watchdog",
            };
            watchdog.Start();
        }

        /// <summary>
        /// 1 項目を実行し，想定外の例外は FAIL として記録する．項目の実行中に解決できなかった
        /// アプリ依存アセンブリ（AssemblyResolve）があれば，その項目の FAIL として記録する．
        /// </summary>
        private static SelfTestCheck RunCheck(SelfTestReport report, AssemblyResolveLog resolveLog,
                string id, string title, Action<SelfTestCheck> body) {
            var check = new SelfTestCheck(id, title);
            var resolveStart = resolveLog.Count;
            var sw = Stopwatch.StartNew();
            try {
                body(check);
            } catch (Exception ex) {
                check.FailException("想定外の例外", ex);
            }
            var ignorable = 0;
            foreach (var entry in resolveLog.Since(resolveStart)) {
                if (entry.IsIgnorable) {
                    ignorable++;    // サテライトリソース等の探索（見つからないのが通常）
                } else if (IsCameraSdkAssembly(entry.Name)) {
                    check.Warn($"AssemblyResolve（カメラ SDK・判定対象外）: {entry}");
                } else {
                    check.Fail($"アセンブリを解決できませんでした（AssemblyResolve）: {entry}");
                }
            }
            if (ignorable > 0) check.Info($"リソース系の AssemblyResolve {ignorable} 件（サテライトリソースの探索．判定対象外）");
            check.Info($"所要時間 {sw.ElapsedMilliseconds} ms");
            report.WriteCheck(check);
            return check;
        }

        /// <summary>検査の 1 ステップを実行し，例外は FAIL として記録して次のステップへ進む</summary>
        private static bool Step(SelfTestCheck c, string name, Action action) {
            try {
                action();
                return true;
            } catch (Exception ex) {
                c.FailException(name, ex);
                return false;
            }
        }

        private static bool IsCameraSdkAssembly(string simpleName) {
            if (simpleName == null) return false;
            return CameraSdkAssemblyPrefixes.Any(p => simpleName.StartsWith(p, StringComparison.OrdinalIgnoreCase));
        }

        /// <summary>
        /// セルフテスト用の一時設定を作り，Config に読み込む．同梱 config.xml を複製し，
        /// 保存先（File/SaveFolder）を作業フォルダに，参照 CSV を実行ファイル基準の絶対パスに，
        /// 効果音を存在しないパス（再生しない）に差し替える．元の config.xml は変更しない．
        /// </summary>
        /// <returns>失敗時はその理由，成功時は null</returns>
        private static string PrepareConfig(Context ctx) {
            try {
                // 一時設定の作成・読み込みは層②のテストハーネスと共通（SelfTestConfig）
                var prepared = SelfTestConfig.Prepare(Path.Combine(ctx.BaseDir, "config.xml"), ctx.BaseDir, ctx.WorkDir);
                ctx.UserSaveFolder = prepared.UserSaveFolder;
                ctx.SaveRoot = prepared.SaveRoot;
                if (!Config.IsLoaded()) return $"一時設定ファイルを読み込めません: {prepared.ConfigPath}";
                return null;
            } catch (Exception ex) {
                return $"一時設定の準備に失敗しました: {SelfTestReport.DescribeException(ex)}";
            }
        }

        private static void CleanupWorkDir(SelfTestReport report, string workDir, bool keep) {
            if (!Directory.Exists(workDir)) return;
            if (keep) {
                report.WriteLine($"work dir kept: {workDir}");
                return;
            }
            try {
                Directory.Delete(workDir, true);
            } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) {
                report.WriteLine($"WARN: 作業フォルダを削除できませんでした（手動で削除してください）: {workDir}: {ex.Message}");
            }
        }

        internal static bool IsUnder(string path, string root) {
            if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(root)) return false;
            var p = Path.GetFullPath(path).TrimEnd('\\') + "\\";
            var r = Path.GetFullPath(root).TrimEnd('\\') + "\\";
            return p.StartsWith(r, StringComparison.OrdinalIgnoreCase);
        }

        // ================================================================================
        // 項目 1: 依存アセンブリ
        // ================================================================================

        private static void CheckDependencies(SelfTestCheck c, Context ctx, AssemblyResolveLog resolveLog) {
            var loaded = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

            Step(c, "実行時設定ファイル（TIASshot.exe.config）", () => {
                var cfg = AppDomain.CurrentDomain.SetupInformation.ConfigurationFile;
                if (!File.Exists(cfg)) {
                    c.Fail($"実行時設定ファイルがありません（バインディングリダイレクトが効かない）: {cfg}");
                    return;
                }
                CheckBindingRedirects(c, ctx.BaseDir, cfg, loaded);
            });

            Step(c, "参照アセンブリの推移的ロード", () => CheckReferenceClosure(c, ctx.BaseDir, loaded));
            Step(c, "bin 内の管理アセンブリのロード", () => CheckBinAssemblies(c, ctx.BaseDir, loaded));

            foreach (var name in RequiredAssemblies) {
                c.Expect(loaded.Contains(name), $"必須の依存 {name} がロードされていません（bin に無い／参照が外れた可能性）");
            }

            Step(c, "OpenCvSharp の最小実行（ネイティブ DLL・Mat 演算・System.Memory 経由のアクセス）", () => CheckOpenCvRuntime(c));
            Step(c, "BitmapConverter（Mat↔Bitmap）と FastClone", () => CheckBitmapConverter(c));
            Step(c, "同梱の設定・参照ファイル", () => CheckAppFiles(c, ctx.BaseDir));
            Step(c, "Form1 の実コンストラクタ（InitializeComponent）", () => CheckRealForm1Constructor(c));

            var assemblies = AppDomain.CurrentDomain.GetAssemblies()
                .Where(a => !a.IsDynamic && IsUnder(a.Location, ctx.BaseDir))
                .Select(a => $"{a.GetName().Name} {a.GetName().Version}")
                .OrderBy(x => x);
            c.Info($"bin からロード済み: {string.Join(", ", assemblies)}");
        }

        /// <summary>
        /// バインディングリダイレクトの newVersion と bin の DLL のバージョンが一致し，
        /// リダイレクト先の完全名でロードできることを確認する（依存更新でリダイレクトがずれると起動時に FileLoadException）．
        /// </summary>
        private static void CheckBindingRedirects(SelfTestCheck c, string baseDir, string configFile, HashSet<string> loaded) {
            var doc = new XmlDocument();
            doc.Load(configFile);
            var ns = new XmlNamespaceManager(doc.NameTable);
            ns.AddNamespace("b", "urn:schemas-microsoft-com:asm.v1");
            var count = 0;
            foreach (XmlElement da in doc.SelectNodes("//b:dependentAssembly", ns)) {
                var id = da.SelectSingleNode("b:assemblyIdentity", ns) as XmlElement;
                var br = da.SelectSingleNode("b:bindingRedirect", ns) as XmlElement;
                if (id == null || br == null) continue;
                count++;
                var name = id.GetAttribute("name");
                var newVersion = br.GetAttribute("newVersion");
                var token = id.GetAttribute("publicKeyToken");
                var culture = string.IsNullOrEmpty(id.GetAttribute("culture")) ? "neutral" : id.GetAttribute("culture");
                var dll = Path.Combine(baseDir, name + ".dll");
                if (!File.Exists(dll)) {
                    // bin に無い＝フレームワーク提供か未使用（例: System.ValueTuple は net471 以降 mscorlib に統合）．
                    // 実際に参照されていれば推移的ロードで検査されるため，ここでは強制ロードしない
                    // （強制ロードすると不要な AssemblyResolve を自ら発生させる）
                    c.Info($"redirect {name} → {newVersion}: bin に DLL なし（フレームワーク提供または未使用．参照されていれば推移的ロードで検査）");
                    continue;
                }
                var binVersion = AssemblyName.GetAssemblyName(dll).Version;
                if (binVersion != new Version(newVersion)) {
                    c.Fail($"リダイレクト不一致: {name} は newVersion={newVersion} だが bin の DLL は {binVersion}（App.config の bindingRedirect を更新する必要がある）");
                    continue;
                }
                try {
                    Assembly.Load(new AssemblyName($"{name}, Version={newVersion}, Culture={culture}, PublicKeyToken={token}"));
                    loaded.Add(name);
                    c.Info($"redirect {name} → {newVersion}: bin の DLL と一致・ロード OK");
                } catch (Exception ex) {
                    c.FailException($"redirect {name} → {newVersion} のロード失敗", ex);
                }
            }
            c.Info($"バインディングリダイレクト {count} 件を確認");
        }

        /// <summary>
        /// 本体アセンブリの参照を推移的に辿って実際にロードする（bin 由来のアセンブリのみ再帰）．
        /// 実行時に初めて必要になる依存（例: OpenCvSharp → System.Memory → System.Runtime.CompilerServices.Unsafe）を起動前に解決させる．
        /// </summary>
        private static void CheckReferenceClosure(SelfTestCheck c, string baseDir, HashSet<string> loaded) {
            var root = typeof(SelfTestRunner).Assembly;
            var queue = new Queue<KeyValuePair<AssemblyName, string>>();
            foreach (var r in root.GetReferencedAssemblies()) queue.Enqueue(new KeyValuePair<AssemblyName, string>(r, root.GetName().Name));
            var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            var framework = 0;
            while (queue.Count > 0) {
                var item = queue.Dequeue();
                var an = item.Key;
                if (!seen.Add(an.Name)) continue;
                Assembly asm;
                try {
                    asm = Assembly.Load(an);
                } catch (Exception ex) {
                    var msg = $"{an.FullName}（参照元 {item.Value}）のロード失敗";
                    if (IsCameraSdkAssembly(an.Name)) {
                        c.Warn($"{msg}（カメラ SDK・判定対象外）: {SelfTestReport.DescribeException(ex)}");
                    } else {
                        c.FailException(msg, ex);
                    }
                    continue;
                }
                loaded.Add(an.Name);
                if (!IsUnder(asm.Location, baseDir)) {
                    framework++;
                    continue;
                }
                var actual = asm.GetName().Version;
                c.Info($"{an.Name} {actual}（参照 {an.Version}，参照元 {item.Value}）: bin からロード");
                if (IsCameraSdkAssembly(an.Name)) continue;     // SDK の内部依存までは辿らない
                foreach (var r in asm.GetReferencedAssemblies()) {
                    queue.Enqueue(new KeyValuePair<AssemblyName, string>(r, an.Name));
                }
            }
            c.Info($"参照を辿ってロードしたアセンブリ {seen.Count} 件（うち GAC/Framework {framework} 件）");
        }

        /// <summary>bin 直下の管理アセンブリ（DLL）がすべてロードできることを確認する（ネイティブ DLL は対象外）</summary>
        private static void CheckBinAssemblies(SelfTestCheck c, string baseDir, HashSet<string> loaded) {
            var managed = 0;
            foreach (var dll in Directory.GetFiles(baseDir, "*.dll")) {
                AssemblyName an;
                try {
                    an = AssemblyName.GetAssemblyName(dll);
                } catch (BadImageFormatException) {
                    continue;   // ネイティブ DLL（管理アセンブリでない）
                }
                managed++;
                if (loaded.Contains(an.Name)) continue;
                try {
                    Assembly.Load(an);
                    loaded.Add(an.Name);
                    c.Info($"{an.Name} {an.Version}: bin からロード（本体から直接は参照されない）");
                } catch (Exception ex) {
                    if (IsCameraSdkAssembly(an.Name)) {
                        c.Warn($"{an.FullName} のロード失敗（カメラ SDK・判定対象外）: {SelfTestReport.DescribeException(ex)}");
                    } else {
                        c.FailException($"bin の {Path.GetFileName(dll)}（{an.FullName}）のロード失敗", ex);
                    }
                }
            }
            c.Info($"bin 直下の管理アセンブリ {managed} 件を確認");
        }

        private static void CheckOpenCvRuntime(SelfTestCheck c) {
            c.Info($"OpenCV {Cv2.GetVersionString()}");

            using (var src = new Mat(48, 64, MatType.CV_8UC3, new Scalar(10, 20, 30)))
            using (var blurred = new Mat()) {
                Cv2.GaussianBlur(src, blurred, new OpenCvSharp.Size(5, 5), 0);
                var mean = Cv2.Mean(blurred);
                c.Expect(Math.Abs(mean.Val0 - 10) < 0.5 && Math.Abs(mean.Val1 - 20) < 0.5 && Math.Abs(mean.Val2 - 30) < 0.5,
                    $"GaussianBlur/Mean の結果が不正: {mean}");

                // System.Memory（Span）経由の画素アクセス
                var span = src.AsSpan<byte>();
                long sum = 0;
                for (var i = 0; i < span.Length; i++) sum += span[i];
                c.Expect(sum == 48L * 64 * (10 + 20 + 30), $"Mat.AsSpan の画素合計が不正: {sum}");
            }

            // 行列演算（CV_64F）: A * A^-1 ≈ I
            using (var a = new Mat(3, 3, MatType.CV_64FC1)) {
                var vals = new double[] { 4, 1, 2, 0, 3, 1, 1, 0, 2 };
                for (var i = 0; i < 9; i++) a.At<double>(i / 3, i % 3) = vals[i];
                // MatExpr もネイティブ資源を持つため，ToMat の元の式も using で解放する
                using (var invExpr = a.Inv())
                using (var inv = invExpr.ToMat())
                using (var prodExpr = a * inv)
                using (var prod = prodExpr.ToMat())
                using (var eyeExpr = Mat.Eye(3, 3, MatType.CV_64FC1))
                using (var eye = eyeExpr.ToMat()) {
                    var err = Cv2.Norm(prod, eye, NormTypes.L2);
                    c.Expect(err < 1e-9, $"行列演算（A*A^-1）の誤差が大きい: {err}");
                }
            }

            using (var dict = CvAruco.GetPredefinedDictionary(PredefinedDictionaryType.Dict4X4_50)) {
                c.Expect(dict.MarkerSize == 4, $"ArUco 辞書のマーカーサイズが不正: {dict.MarkerSize}");
            }
            c.Info("Mat 演算・行列演算・Span アクセス・ArUco 辞書: OK");
        }

        private static void CheckBitmapConverter(SelfTestCheck c) {
            using (var src = SelfTestChart.CreatePlainFrame(3, 64, 48)) {
                Cv2.Circle(src, new OpenCvSharp.Point(20, 20), 8, new Scalar(0, 255, 0), -1);
                using (var bmp = src.ToBitmap())
                using (var clone = BitmapUtil.FastClone(bmp))
                using (var back = clone.ToMat()) {
                    c.Expect(bmp.Width == 64 && bmp.Height == 48, $"ToBitmap のサイズが不正: {bmp.Width}x{bmp.Height}");
                    c.Expect(!ReferenceEquals(bmp, clone), "FastClone が同一インスタンスを返した");
                    var diff = Cv2.Norm(src, back, NormTypes.L1);
                    c.Expect(diff == 0, $"Mat → Bitmap → FastClone → Mat の往復で画素が変化: L1={diff}");
                }
            }
            c.Info("Mat → Bitmap → FastClone → Mat の往復: 画素一致");
        }

        private static void CheckAppFiles(SelfTestCheck c, string baseDir) {
            var configPath = Path.Combine(baseDir, "config.xml");
            if (!c.Expect(File.Exists(configPath), $"config.xml がありません: {configPath}")) return;
            Config.Load(configPath);
            c.Expect(Config.IsLoaded(), "config.xml を読み込めません（ルート要素 Config が無い）");
            foreach (var key in new[] { "File/TccSrgbRef", "File/TccXyzRef" }) {
                var path = Path.Combine(baseDir, Config.GetString(key));
                using (var m = IoUtil.LoadMatFromCsv(path)) {
                    c.Expect(m != null && m.Rows == 24 && m.Cols == 3,
                        $"{key}（{path}）を 24x3 として読み込めません");
                }
            }
            c.Info("config.xml・tcc_srgb.csv・tcc_xyz.csv: OK");
        }

        /// <summary>
        /// 通常起動と同じ Form1 コンストラクタ（InitializeComponent．IC Imaging Control のコントロール生成を含む）を実行する．
        /// Form1_Load（カメラ接続）は表示しないため発火しない．カメラ SDK 起因の失敗は判定対象外（WARN）とする．
        /// </summary>
        private static void CheckRealForm1Constructor(SelfTestCheck c) {
            try {
                using (var form = new Form1()) {
                    c.Info($"Form1 コンストラクタ: OK（{form.Text}）");
                }
            } catch (Exception ex) {
                if (IsCameraSdkRelated(ex)) {
                    c.Warn($"Form1 コンストラクタがカメラ SDK 起因で失敗（SDK 未導入環境では想定内）: {SelfTestReport.DescribeException(ex)}");
                } else {
                    c.FailException("Form1 コンストラクタの失敗", ex);
                }
            }
        }

        private static bool IsCameraSdkRelated(Exception ex) {
            for (var e = ex; e != null; e = e.InnerException) {
                var file = (e as FileNotFoundException)?.FileName ?? (e as FileLoadException)?.FileName;
                if (file != null && IsCameraSdkAssembly(new AssemblyName(file).Name)) return true;
                if ((e.StackTrace ?? "").Contains("TIS.Imaging")) return true;
            }
            return false;
        }

        // ================================================================================
        // 項目 2: プレビュー経路
        // ================================================================================

        private static void CheckPreview(SelfTestCheck c) {
            const int frames = 12;
            const int width = 640;
            const int height = 480;

            Form1 form = null;
            PreviewMonitor monitor = null;
            SelfTestCamera camera = null;
            try {
                form = new Form1(Form1.SelfTestHeadless.Instance);
                monitor = new PreviewMonitor();                          // 実コンストラクタ（InitializeComponent を含む）
                monitor.SelfTestForceShown(new System.Drawing.Size(1280, 800));  // 副ディスプレイ有りを模す
                form.SelfTestStart(monitor);

                camera = new SelfTestCamera(form, i => SelfTestChart.CreatePlainFrame(i, width, height));
                if (!c.Expect(camera.Connect(), $"偽カメラの接続（CameraBase.BootCheck）に失敗: {camera.ErrorMsg}",
                        "偽カメラ接続（CameraBase コンストラクタ・BootCheck）: OK")) return;

                camera.StartPreview(frames, 30, false);
                var finished = SelfTestPump.PumpUntil(() => !camera.IsPreviewRunning, PreviewTimeout);
                if (!c.Expect(finished, $"プレビューが {PreviewTimeout.TotalSeconds:0} 秒以内に終わりません（{camera.FramesDelivered}/{frames} フレーム）")) return;
                if (camera.PreviewError != null) c.FailException("プレビューコールバックの例外", camera.PreviewError);
                c.Expect(camera.FramesDelivered == frames, $"コールバックへ渡したフレーム数が不正: {camera.FramesDelivered}/{frames}",
                    $"コールバック → ProcessCalibrationFrame → Form1.ShowImage: {frames} フレーム");

                // 本モニタはタイマー（約 33ms）駆動．最終フレームが反映されるまでメッセージを回す
                SelfTestPump.PumpFor(TimeSpan.FromMilliseconds(300));
                monitor.SelfTestRefresh();

                // 入力 Bitmap（カメラ側の二重バッファ）をすべて解放してから表示側を検査する．
                // 表示側が入力と独立したコピー（FastClone）を持っていれば，解放後も読める．
                camera.Disconnect();
                var expected = SelfTestChart.PlainFrameColor(frames - 1);
                CheckDisplayedImage(c, "本モニタ picDisplay（MainRefreshTimer_Tick）", form.SelfTestDisplayedImage, camera, width, height, expected);
                CheckDisplayedImage(c, "副モニタ PreviewMonitor（UpdateImage → RefreshTimer_Tick）", monitor.SelfTestDisplayedImage, camera, width, height, expected);

                c.Expect(form.SelfTestLastError == null, $"エラー通知（ShowError）が発生: {form.SelfTestLastError}");
            } finally {
                ShutdownCamera(camera);
                form?.SelfTestShutdown();
                form?.Dispose();
                monitor?.SelfTestShutdown();
                monitor?.Dispose();
            }
        }

        private static void CheckDisplayedImage(SelfTestCheck c, string label, Image image, SelfTestCamera camera,
                int width, int height, Scalar expectedBgr) {
            if (!c.Expect(image != null, $"{label}: 表示用 Bitmap が設定されていません")) return;
            c.Expect(!camera.IsProducedBitmap(image), $"{label}: 表示用 Bitmap がカメラの入力 Bitmap そのもの（UI 所有コピーになっていない）");
            var bmp = image as Bitmap;
            if (!c.Expect(bmp != null, $"{label}: 表示画像が Bitmap ではありません（{image.GetType().Name}）")) return;
            try {
                c.Expect(bmp.Width == width && bmp.Height == height, $"{label}: サイズが不正 {bmp.Width}x{bmp.Height}（期待 {width}x{height}）");
                var px = bmp.GetPixel(width / 2, height / 2);
                var ok = px.B == (int)expectedBgr.Val0 && px.G == (int)expectedBgr.Val1 && px.R == (int)expectedBgr.Val2;
                c.Expect(ok, $"{label}: 画素値が最終フレームと一致しない（R{px.R} G{px.G} B{px.B}，期待 R{expectedBgr.Val2} G{expectedBgr.Val1} B{expectedBgr.Val0}）",
                    $"{label}: 最終フレームの UI 所有コピーを表示（入力 Bitmap 解放後も有効）");
            } catch (ArgumentException ex) {
                // 解放済み Bitmap へのアクセスは ArgumentException（パラメーターが無効）になる
                c.FailException($"{label}: 表示用 Bitmap が入力の解放とともに無効になった（所有権モデル違反の疑い）", ex);
            }
        }

        private static void ShutdownCamera(SelfTestCamera camera) {
            if (camera == null) return;
            camera.RequestStopPreview();
            // プレビュースレッドは Invoke で UI スレッドを待つことがあるため，Join ではなくメッセージを回して待つ
            SelfTestPump.PumpUntil(() => !camera.IsPreviewRunning, TimeSpan.FromSeconds(10));
            SelfTestPump.PumpUntil(() => !camera.IsSaveThreadStarted, TimeSpan.FromSeconds(10));
            camera.Disconnect();
        }

        // ================================================================================
        // 項目 3: 校正〜保存
        // ================================================================================

        private static void CheckCalibrationAndSave(SelfTestCheck c, Context ctx) {
            c.Info($"保存先を一時フォルダに差し替え: {ctx.SaveRoot}（config.xml の {ctx.UserSaveFolder} には書き込まない）");

            Mat tccSrgb = null;
            Mat chart = null;
            Form1 form = null;
            SelfTestCamera camera = null;
            try {
                tccSrgb = IoUtil.LoadMatFromCsv(Config.GetString("File/TccSrgbRef"));
                if (!c.Expect(tccSrgb != null, "tcc_srgb.csv を読み込めません")) return;
                chart = SelfTestChart.CreateChartFrame(tccSrgb);
                var chartForFactory = chart;

                form = new Form1(Form1.SelfTestHeadless.Instance);
                form.SelfTestStart(null);
                camera = new SelfTestCamera(form, i => chartForFactory.Clone());
                if (!c.Expect(camera.Connect(), $"偽カメラの接続に失敗: {camera.ErrorMsg}")) return;

                // --- 自動校正（チャート検出 → 固定判定 → ホワイトバランス → 変換行列算出） ---
                var maxFrames = Config.GetInt("Calib/ChartSetCount") + Config.GetInt("Calib/Frames") + 60;
                camera.StartPreview(maxFrames, 0, true);
                var finished = SelfTestPump.PumpUntil(() => !camera.IsPreviewRunning, CalibrationTimeout);
                if (!c.Expect(finished, $"校正が {CalibrationTimeout.TotalSeconds:0} 秒以内に終わりません（{camera.FramesDelivered} フレーム）")) return;
                if (camera.PreviewError != null) {
                    c.FailException("プレビュー（校正）中の例外", camera.PreviewError);
                    return;
                }
                if (!c.Expect(camera.IsCalibrated,
                        $"校正が完了しません（{camera.FramesDelivered} フレーム．最後の表示: 「{form.SelfTestMessage}」．チャート検出の失敗が疑われる）")) return;
                SelfTestPump.PumpFor(TimeSpan.FromMilliseconds(100));    // CalcTcc の ShowMessage / EnableShots（Invoke）を処理させる
                c.Info($"チャート検出・校正完了: {camera.FramesDelivered} フレーム，疑似ゲイン {camera.DescribeGain()}，表示「{form.SelfTestMessage}」");
                c.Expect(form.SelfTestShotsEnabled, "校正完了後に撮影ボタンが有効化されていません（CalcTcc → EnableShots）");

                var white = camera.MeasureWhitePatch();
                var refB = Config.GetFloat("Calib/Reference/B");
                var refG = Config.GetFloat("Calib/Reference/G");
                var refR = Config.GetFloat("Calib/Reference/R");
                c.Expect(Math.Abs(white.Val0 - refB) < 10 && Math.Abs(white.Val1 - refG) < 10 && Math.Abs(white.Val2 - refR) < 10,
                    $"白色票がホワイトバランス目標に収束していません: B{white.Val0:0.0} G{white.Val1:0.0} R{white.Val2:0.0}（目標 {refB}/{refG}/{refR}）",
                    $"白色票（13 番）の計測値: B{white.Val0:0.0} G{white.Val1:0.0} R{white.Val2:0.0}（目標 {refB}/{refG}/{refR}）");

                var channels = GetConversionChannels();
                var frameSize = new OpenCvSharp.Size(SelfTestChart.FrameWidth, SelfTestChart.FrameHeight);
                CheckCalibrationOutputs(c, ctx, camera.CalibrationFolder, channels, frameSize, tccSrgb);

                // --- 撮影 → 保存（ShotOne → RunShotLoop → SaveThread → SaveImages） ---
                var shotOk = Step(c, "撮影（CameraBase.ShotOne）", () => camera.ShotOne());
                var saved = SelfTestPump.PumpUntil(() => !camera.IsSaveThreadStarted, SaveTimeout);
                if (!c.Expect(saved, $"画像保存が {SaveTimeout.TotalSeconds:0} 秒以内に終わりません")) return;
                SelfTestPump.PumpFor(TimeSpan.FromMilliseconds(100));    // SaveThread の ShowMessage / EnableShots（Invoke）を処理させる
                if (!shotOk) return;
                c.Expect(form.SelfTestLastError == null, $"エラー通知（ShowError）が発生: {form.SelfTestLastError}");
                c.Expect(form.SelfTestShotsEnabled, "保存完了後に撮影ボタンが再有効化されていません（SaveThread → EnableShots(true)）");
                c.Info($"撮影・保存完了: 表示「{form.SelfTestMessage}」");
                CheckShotOutputs(c, ctx, camera.CurrentSaveFolder, camera.CalibrationFolder, channels, frameSize, tccSrgb);
            } finally {
                ShutdownCamera(camera);
                form?.SelfTestShutdown();
                form?.Dispose();
                chart?.Dispose();
                tccSrgb?.Dispose();
            }
        }

        /// <summary>ColorCorrector と同じ規則で変換行列のチャネル数一覧を得る（4〜17 の範囲・重複除去・昇順）</summary>
        private static int[] GetConversionChannels() {
            return Config.GetString("Calib/ConversionChannels")
                .Split(',')
                .Select(x => int.Parse(x.Trim()))
                .Where(x => x >= 4 && x <= 17)
                .Distinct()
                .OrderBy(x => x)
                .ToArray();
        }

        private static string ChannelFile(string key, int channel, int no = 1) {
            return Config.GetString(key).Replace("{CN}", $"{channel:00}").Replace("{NO}", $"{no:0000}");
        }

        private static void CheckCalibrationOutputs(SelfTestCheck c, Context ctx, string folder, int[] channels,
                OpenCvSharp.Size frameSize, Mat tccSrgb) {
            if (!c.Expect(!string.IsNullOrEmpty(folder) && Directory.Exists(folder), $"校正データのフォルダがありません: {folder}")) return;
            c.Expect(IsUnder(folder, ctx.SaveRoot), $"校正データが一時フォルダの外に保存されました: {folder}");

            CheckImageFile(c, Path.Combine(folder, Config.GetString("File/TccImage")), frameSize);
            CheckImageFile(c, Path.Combine(folder, Config.GetString("File/TccRoisImage")), frameSize);
            CheckCsvMatrix(c, Path.Combine(folder, Config.GetString("File/TccRgbValues")), 24, 3);

            foreach (var ch in channels) {
                CheckCsvMatrix(c, Path.Combine(folder, ChannelFile("File/ConvRgb2Srgb", ch)), ch, 3);
                CheckCsvMatrix(c, Path.Combine(folder, ChannelFile("File/ConvRgb2Xyz", ch)), ch, 3);
                CheckCsvMatrix(c, Path.Combine(folder, ChannelFile("File/ConvSrgb2Xyz", ch)), ch, 3);
                if (ch == 17) CheckCsvMatrix(c, Path.Combine(folder, ChannelFile("File/ConvRgb2XyzOld", ch)), ch, 3);

                // 校正チャートを変換した色票値が参照値に戻っているか（合成チャートの歪みは 1 次式なので戻るはず）
                var convertedFile = ChannelFile("File/TccSrgbValues", ch);
                using (var converted = IoUtil.LoadMatFromCsv(Path.Combine(folder, convertedFile))) {
                    if (!c.Expect(converted != null && converted.Rows == 24 && converted.Cols == 3,
                            $"{convertedFile} を 24x3 として読み込めません")) continue;
                    var maxErr = PatchColorError.Compare(PatchColorError.ToArray(converted), PatchColorError.ToArray(tccSrgb)).MaxAbs;
                    c.Expect(maxErr < 5.0, $"{ch} 次元: 色票の変換値が参照値から離れすぎ（最大誤差 {maxErr:0.00}）",
                        $"{ch} 次元: 変換行列（有限値）と色票の変換値（参照値との最大誤差 {maxErr:0.00}）");
                }
            }
        }

        private static void CheckShotOutputs(SelfTestCheck c, Context ctx, string folder, string calibFolder, int[] channels,
                OpenCvSharp.Size frameSize, Mat tccSrgb) {
            if (!c.Expect(!string.IsNullOrEmpty(folder) && Directory.Exists(folder), $"撮影データのフォルダがありません: {folder}")) return;
            c.Expect(IsUnder(folder, ctx.SaveRoot), $"撮影データが一時フォルダの外に保存されました: {folder}");
            c.Expect(!string.Equals(folder, calibFolder, StringComparison.OrdinalIgnoreCase), "撮影データが校正データと同じフォルダに保存されました");

            var info = Path.Combine(folder, Config.GetString("File/Info"));
            if (c.Expect(File.Exists(info), $"撮影情報がありません: {info}")) {
                var text = File.ReadAllText(info);
                c.Expect(text.Contains("Shot,Time(ms),Note"), $"撮影情報の書式が不正: {info}");
            }
            CheckImageFile(c, Path.Combine(folder, ChannelFile("File/RgbImage", 0)), frameSize);

            foreach (var ch in channels) {
                var path = Path.Combine(folder, ChannelFile("File/SrgbImage", ch));
                if (!CheckImageFile(c, path, frameSize)) continue;
                // 変換画像上の色票が参照値（tcc_srgb）に戻っているか（JPEG 圧縮の誤差を許容）
                using (var img = Cv2.ImRead(path, ImreadModes.Color)) {
                    var measured = new double[24, 3];
                    for (var i = 0; i < 24; i++) {
                        var roi = SelfTestChart.PatchRoi(i);
                        var inner = new Rect(roi.X + 10, roi.Y + 10, roi.Width - 20, roi.Height - 20);
                        using (var patch = new Mat(img, inner)) {
                            var mean = Cv2.Mean(patch);
                            for (var j = 0; j < 3; j++) measured[i, j] = mean[j];
                        }
                    }
                    // 誤差の計算は実機テスト（calibrate）と共通（PatchColorError．平均は 24 色票 × 3 チャネル = 72 の平均）
                    var stats = PatchColorError.Compare(measured, PatchColorError.ToArray(tccSrgb));
                    var meanErr = stats.MeanAbs;
                    var maxErr = stats.MaxAbs;
                    c.Expect(meanErr < 6.0 && maxErr < 15.0,
                        $"{Path.GetFileName(path)}: 色票が参照値に戻っていない（平均誤差 {meanErr:0.00}，最大 {maxErr:0.00}）",
                        $"{Path.GetFileName(path)}: 保存・読み戻し OK，色票の参照値との誤差 平均 {meanErr:0.00} / 最大 {maxErr:0.00}");
                }
            }
        }

        private static bool CheckImageFile(SelfTestCheck c, string path, OpenCvSharp.Size expectedSize) {
            if (!c.Expect(File.Exists(path), $"画像がありません: {path}")) return false;
            using (var img = Cv2.ImRead(path, ImreadModes.Color)) {
                if (!c.Expect(!img.Empty(), $"画像を読み戻せません: {path}")) return false;
                return c.Expect(img.Size() == expectedSize, $"画像サイズが不正: {path}（{img.Width}x{img.Height}，期待 {expectedSize.Width}x{expectedSize.Height}）");
            }
        }

        private static void CheckCsvMatrix(SelfTestCheck c, string path, int rows, int cols) {
            if (!c.Expect(File.Exists(path), $"CSV がありません: {path}")) return;
            using (var m = IoUtil.LoadMatFromCsv(path)) {
                if (!c.Expect(m != null, $"CSV を読み戻せません: {path}")) return;
                if (!c.Expect(m.Rows == rows && m.Cols == cols, $"CSV の形状が不正: {path}（{m.Rows}x{m.Cols}，期待 {rows}x{cols}）")) return;
                var allFinite = true;
                var allZero = true;
                for (var i = 0; i < m.Rows; i++) {
                    for (var j = 0; j < m.Cols; j++) {
                        var v = m.At<double>(i, j);
                        if (double.IsNaN(v) || double.IsInfinity(v)) allFinite = false;
                        if (v != 0) allZero = false;
                    }
                }
                c.Expect(allFinite, $"CSV に非有限値（NaN/Infinity）が含まれます: {path}");
                c.Expect(!allZero, $"CSV が全て 0 です: {path}");
            }
        }

        // ================================================================================
        // AssemblyResolve の記録
        // ================================================================================

        /// <summary>
        /// AppDomain.AssemblyResolve（通常のバインドで見つからなかったアセンブリ）を記録する．
        /// 解決はせず null を返す（挙動は変えない）．
        /// </summary>
        private sealed class AssemblyResolveLog {
            internal sealed class Entry {
                public string Name;
                public string FullName;
                public string Requester;
                public bool IsIgnorable => Name.EndsWith(".resources", StringComparison.OrdinalIgnoreCase)
                                        || Name.EndsWith(".XmlSerializers", StringComparison.OrdinalIgnoreCase);
                public override string ToString() => $"{FullName}（要求元 {Requester ?? "不明"}）";
            }

            private readonly List<Entry> _entries = new List<Entry>();
            private readonly object _lock = new object();

            public int Count {
                get { lock (_lock) return _entries.Count; }
            }

            public void Attach() {
                AppDomain.CurrentDomain.AssemblyResolve += (s, e) => {
                    var name = new AssemblyName(e.Name).Name;
                    lock (_lock) {
                        _entries.Add(new Entry { Name = name, FullName = e.Name, Requester = e.RequestingAssembly?.GetName().Name });
                    }
                    return null;
                };
            }

            public List<Entry> Since(int start) {
                lock (_lock) return _entries.Skip(start).ToList();
            }
        }
    }
}
#endif
