using System.Reflection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Master.Catalogs;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master.Catalogs;
using SolutionErp.Infrastructure.Persistence;
using SolutionErp.Infrastructure.Persistence.Interceptors;
using SolutionErp.Infrastructure.Tests.Common;

namespace SolutionErp.Infrastructure.Tests.Application;

// [K1 — S164 2026-07-31] Master-data "Danh mục Hợp đồng" (SOL-CCM-SP-002: 86 dòng × 8 nhóm duyệt).
// Test-AFTER (feature mới, `docs/rules.md §7`) — code BE đã land trên đĩa (Mig 70, chưa commit).
//
// Code under test (test theo CODE, KHÔNG theo spec — spec chỉ dùng để đối chiếu):
//   · `DbInitializer.cs:3163-3324`  SeedContractCatalogAsync — 86 tuple, per-Code idempotent,
//                                   dò existing bằng `IgnoreQueryFilters()` (:3293-3296) né #75/#76
//   · `ContractCatalogEntryConfiguration.cs:27-29` — UNIQUE Code FILTERED `[IsDeleted] = 0`
//                                   (#57) + index ApprovalGroup + query filter soft-delete
//   · `ContractCatalogFeatures.cs`  List/Get/Create/Update/Delete + 2 validator (1..8)
//   · `MenuKeys.cs:32 + :173`       const `ContractCatalog` **VÀO `All`** ⇒ Program.cs sinh
//                                   4 policy; `DbInitializer.cs:1776` menu row parent `Catalogs`
//
// ── 2 quyết định hạ tầng, cả hai là ĐIỀU KIỆN để phép đo có nghĩa ───────────────────────────
// (1) Fixture RIÊNG có `AuditingInterceptor` (KHÔNG dùng `SqliteDbFixture`). `TestApplicationDbContext`
//     KHÔNG wire interceptor (`Common/SqliteDbFixture.cs:12-32`) ⇒ `Remove()` = xoá **CỨNG**
//     (bài S155/S161). Ở đây thứ cần đo CHÍNH LÀ ý nghĩa của `Remove()`:
//       · seeder skip-if-exists dựa vào row soft-deleted CÒN NẰM LẠI trong bảng;
//       · rào UNIQUE filtered phân biệt row active ⟂ row đã xoá.
//     Xoá cứng thì T2/T5 vẫn XANH kể cả khi `IgnoreQueryFilters()` bị gỡ hoặc index mất filter
//     ⇒ đúng bẫy "assert rỗng nghĩa".
// (2) MỖI "lần boot"/"request" chạy trên MỘT DbContext MỚI (`NewDb()`), chung 1 connection —
//     mô phỏng scoped-per-request của prod; seeder lần 2 buộc phải đọc lại ĐĨA chứ không ăn
//     ké ChangeTracker của lần 1 (nếu ăn ké thì phép đo idempotent là giả).
//
// Vị trí file: `Application/` (cùng chỗ MasterCatalogFilteredUniqueTests / *SeedTests) chứ không
// `Services/` như lệnh gợi ý — `Services/` là chỗ của code-generator + workflow-service.
public class ContractCatalogTests
{
    private static readonly DateTime FrozenNow = new(2026, 7, 31, 3, 0, 0, DateTimeKind.Utc);

    // Phân bố CHUẨN theo transcribe SP-002 (nguồn: sub-invest-fable-b2-cum1.md:41).
    private static readonly Dictionary<int, int> ExpectedByApprovalGroup = new()
    {
        [1] = 5, [2] = 10, [3] = 5, [4] = 23, [5] = 14, [6] = 15, [7] = 13, [8] = 1,
    };

    private static readonly Dictionary<string, int> ExpectedBySection = new()
    {
        ["A1"] = 5, ["A2"] = 1, ["A3"] = 9, ["A4"] = 5,
        ["B1"] = 23, ["B2"] = 14, ["B3"] = 15,
        ["C1"] = 6, ["C2"] = 3, ["C3"] = 4, ["CEO"] = 1,
    };

    // ===================================================================
    // Fixture: SQLite in-memory + AuditingInterceptor (xoá MỀM thật).
    // ===================================================================
    private sealed class CatalogFixture : IDisposable
    {
        private readonly SqliteConnection _conn;
        public FixedDateTime Clock { get; } = new(FrozenNow);
        public TestCurrentUser Actor { get; } = new(Guid.NewGuid(), "Quản trị danh mục", "admin@test.local", AppRoles.Admin);

        public CatalogFixture()
        {
            _conn = new SqliteConnection("DataSource=:memory:");
            _conn.Open();
            using var boot = NewDb();
            boot.Database.EnsureCreated();
        }

        /// Context MỚI cho mỗi "lần boot API" / mỗi request (xem ghi chú (2) đầu file).
        public TestApplicationDbContext NewDb()
        {
            var options = new DbContextOptionsBuilder<ApplicationDbContext>()
                .UseSqlite(_conn)
                .EnableSensitiveDataLogging()
                .AddInterceptors(new AuditingInterceptor(Actor, Clock))
                .Options;
            return new TestApplicationDbContext(options);
        }

        public void Dispose() => _conn.Dispose();
    }

    // `SeedContractCatalogAsync` là `private static` (ApplicationDbContext, ILogger) — gọi qua
    // REFLECTION (khuôn S67), KHÔNG đổi visibility production, KHÔNG re-implement seeder trong test.
    private static async Task InvokeSeedAsync(ApplicationDbContext db)
    {
        var mi = typeof(DbInitializer).GetMethod(
            "SeedContractCatalogAsync", BindingFlags.NonPublic | BindingFlags.Static);
        mi.Should().NotBeNull(
            "DbInitializer.SeedContractCatalogAsync phải tồn tại (private static, (ApplicationDbContext, ILogger)) — đổi tên/signature thì cập nhật test");
        await (Task)mi!.Invoke(null, new object[] { db, NullLogger.Instance })!;
    }

    private static ContractCatalogEntry NewEntry(string code, bool isDeleted = false) => new()
    {
        Id = Guid.NewGuid(),
        Code = code,
        TenVi = "Hạng mục " + code,
        TenEn = "Item " + code,
        GroupCode = "B1",
        ApprovalGroup = 4,
        SignerRole = "TP.PRO",
        InitialsChain = "TBP.PRO → NV.PRO",
        IsActive = true,
        SortOrder = 1,
        IsDeleted = isDeleted,
    };

    private static CreateContractCatalogEntryCommand CreateCmd(int approvalGroup = 4, string code = "Z-01") =>
        new(code, "Hạng mục thử", "Test item", "B1", approvalGroup, "TP.PRO", "TBP.PRO → NV.PRO", null, true, 7);

    private static UpdateContractCatalogEntryCommand UpdateCmd(Guid id, int approvalGroup = 4, string code = "Z-01") =>
        new(id, code, "Hạng mục thử", "Test item", "B1", approvalGroup, "TP.PRO", "TBP.PRO → NV.PRO", null, true, 7);

    // =====================================================================================
    // T1 — Seed idempotent: 2 lần boot ⇒ vẫn ĐÚNG 86 row, không nhân đôi.
    //      (acceptance K1.f: "Restart API lần 2 → vẫn 86", khuôn nghiệm-thu-bằng-restart S91)
    // =====================================================================================
    [Fact]
    public async Task Seed_RunTwice_Keeps86Rows_NoDuplicateCode()
    {
        using var f = new CatalogFixture();

        await using (var db = f.NewDb()) { await InvokeSeedAsync(db); }        // boot 1

        await using (var check1 = f.NewDb())
        {
            (await check1.ContractCatalogEntries.CountAsync()).Should().Be(86,
                "boot 1 phải nạp đủ 86 dòng SP-002 — nếu 0 thì mọi assert idempotent dưới là rỗng nghĩa");
        }

        await using (var db = f.NewDb()) { await InvokeSeedAsync(db); }        // boot 2 (= restart API)

        await using var check = f.NewDb();
        (await check.ContractCatalogEntries.CountAsync()).Should().Be(86,
            "seeder skip-if-Code-exists ⇒ lần boot thứ 2 KHÔNG thêm row nào");
        (await check.ContractCatalogEntries.IgnoreQueryFilters().CountAsync()).Should().Be(86,
            "kể cả tính row đã xoá mềm cũng chỉ 86 — không có bản sao ẩn nào sinh ra");

        var duplicated = await check.ContractCatalogEntries.IgnoreQueryFilters()
            .GroupBy(x => x.Code).Where(g => g.Count() > 1).Select(g => g.Key).ToListAsync();
        duplicated.Should().BeEmpty("mỗi mã danh mục chỉ được tồn tại 1 lần sau N lần boot");
    }

    // =====================================================================================
    // T1b — Chống HỒI SINH (#75/#76): admin xoá mềm 1 dòng ⇒ lần boot sau KHÔNG tạo lại.
    //       Đây là phép đo cho `IgnoreQueryFilters()` ở DbInitializer.cs:3294 — hit
    //       `IgnoreQueryFilters` ĐẦU TIÊN trong src/Backend, cố ý làm khác khuôn :2970.
    //       Nếu bỏ `IgnoreQueryFilters()`: row soft-deleted vô hình ⇒ seeder Add lại ⇒
    //       active vẫn 86 (TRÔNG NHƯ ĐÚNG!) nhưng tổng thành 87 ⇒ 2 assert dưới bắt được.
    // =====================================================================================
    [Fact]
    public async Task Seed_AfterSoftDeleteOfOneCode_DoesNotResurrectIt()
    {
        using var f = new CatalogFixture();
        await using (var db = f.NewDb()) { await InvokeSeedAsync(db); }

        // Xoá MỀM đúng đường prod đi (DeleteContractCatalogEntryHandler → Remove + interceptor).
        await using (var db = f.NewDb())
        {
            var row = await db.ContractCatalogEntries.SingleAsync(x => x.Code == "A1-01");
            db.ContractCatalogEntries.Remove(row);
            await db.SaveChangesAsync();
        }

        // Sanity CHỐNG-VACUOUS: phải là xoá MỀM. Xoá cứng thì phép đo dưới đo nhầm cơ chế.
        await using (var probe = f.NewDb())
        {
            var raw = await probe.ContractCatalogEntries.IgnoreQueryFilters()
                .SingleAsync(x => x.Code == "A1-01");
            raw.IsDeleted.Should().BeTrue("AuditingInterceptor phải biến Remove() thành xoá mềm");
        }

        await using (var db = f.NewDb()) { await InvokeSeedAsync(db); }        // boot kế tiếp

        await using var check = f.NewDb();
        (await check.ContractCatalogEntries.CountAsync()).Should().Be(85,
            "dòng admin đã xoá phải Ở LẠI trạng thái đã xoá — seeder KHÔNG được kéo nó về");
        (await check.ContractCatalogEntries.IgnoreQueryFilters().CountAsync()).Should().Be(86,
            "tổng row vẫn 86 ⇒ seeder KHÔNG chèn thêm bản sao active của A1-01 (nếu thiếu IgnoreQueryFilters ⇒ 87)");
        (await check.ContractCatalogEntries.IgnoreQueryFilters().CountAsync(x => x.Code == "A1-01"))
            .Should().Be(1, "chỉ đúng 1 row mang mã A1-01, và nó đang ở trạng thái đã xoá");
    }

    // =====================================================================================
    // T2 — Seed đủ 8 nhóm duyệt: phân bố 5/10/5/23/14/15/13/1 + phân bố theo section
    //      + tên VN/EN đủ 86/86 + denorm vai-ký nhất quán trong từng nhóm.
    // =====================================================================================
    [Fact]
    public async Task Seed_ApprovalGroupAndSectionDistribution_MatchesSp002()
    {
        using var f = new CatalogFixture();
        await using (var db = f.NewDb()) { await InvokeSeedAsync(db); }

        await using var check = f.NewDb();
        var rows = await check.ContractCatalogEntries.AsNoTracking().ToListAsync();
        rows.Should().HaveCount(86);

        rows.GroupBy(r => r.ApprovalGroup).ToDictionary(g => g.Key, g => g.Count())
            .Should().BeEquivalentTo(ExpectedByApprovalGroup,
                "phân bố nhóm duyệt N1..N8 = 5/10/5/23/14/15/13/1 (acceptance K1.f) — sai 1 dòng bị gán nhầm nhóm là phiếu KHKK chạy nhầm quy trình");

        rows.GroupBy(r => r.GroupCode).ToDictionary(g => g.Key, g => g.Count())
            .Should().BeEquivalentTo(ExpectedBySection,
                "phân bố theo section A1..CEO — bắt trường hợp tổng vẫn 86 nhưng 1 dòng bị chuyển section");

        rows.Where(r => string.IsNullOrWhiteSpace(r.TenVi)).Should().BeEmpty("tên VN đủ 86/86");
        rows.Where(r => string.IsNullOrWhiteSpace(r.TenEn)).Should().BeEmpty("tên EN đủ 86/86 (acceptance K1.f)");
        rows.Where(r => string.IsNullOrWhiteSpace(r.SignerRole)).Should().BeEmpty("vai ký chính bắt buộc (in trên khối ký GĐ4)");
        rows.Where(r => r.ApprovalGroup is < 1 or > 8).Should().BeEmpty("ApprovalGroup chỉ được nằm trong 1..8");
        rows.Should().OnlyContain(r => r.IsActive, "86 dòng seed đều đang hiệu lực");
        rows.Should().OnlyContain(r => r.NduqNote == null, "[OG-3] NĐUQ per-dòng chưa có trong ảnh gốc ⇒ seed null, nhập tay sau");

        // Denorm: SignerRole/InitialsChain chép theo NHÓM vào từng row ⇒ trong 1 nhóm phải ĐỒNG NHẤT.
        // Bắt lỗi gõ tay lệch 1 ký tự ở 1 dòng (thứ mà đếm-số-lượng không bao giờ thấy).
        foreach (var g in rows.GroupBy(r => r.ApprovalGroup).OrderBy(g => g.Key))
        {
            g.Select(r => r.SignerRole).Distinct().Should().HaveCount(1,
                $"nhóm N{g.Key} phải dùng CÙNG 1 chuỗi vai ký chính cho cả {g.Count()} dòng");
            g.Select(r => r.InitialsChain).Distinct().Should().HaveCount(1,
                $"nhóm N{g.Key} phải dùng CÙNG 1 chuỗi ký nháy cho cả {g.Count()} dòng");
        }

        // Neo 2 mốc đầu–cuối bảng gốc (chống trường hợp phân bố đúng mà nội dung trôi).
        rows.Single(r => r.Code == "CEO-01").ApprovalGroup.Should().Be(8);
        rows.Single(r => r.Code == "CEO-01").SignerRole.Should().Be("CEO");
        rows.Single(r => r.Code == "A1-01").ApprovalGroup.Should().Be(1);
    }

    // =====================================================================================
    // T3 — UNIQUE Code FILTERED `[IsDeleted] = 0` (gotcha #57) đo ở TẦNG DB, 2 CHIỀU:
    //      (a) 2 row ACTIVE cùng Code  → DbUpdateException  (index có tồn tại thật)
    //      (b) Code trùng row ĐÃ XOÁ MỀM → OK               (index có mệnh đề WHERE thật)
    //      Thiếu chiều (a) ⇒ test xanh cả khi index chưa từng được tạo.
    //      Thiếu chiều (b) ⇒ test xanh cả khi index là UNIQUE trần (bug #57).
    // =====================================================================================
    [Fact]
    public async Task UniqueCode_IsFilteredByIsDeleted_BlocksActiveDup_AllowsReuseAfterSoftDelete()
    {
        using var f = new CatalogFixture();

        await using (var db = f.NewDb())
        {
            db.ContractCatalogEntries.Add(NewEntry("X-01"));
            await db.SaveChangesAsync();
        }

        // (a) trùng Code trên row ĐANG ACTIVE → DB phải chặn.
        await using (var db = f.NewDb())
        {
            db.ContractCatalogEntries.Add(NewEntry("X-01"));
            var act = async () => await db.SaveChangesAsync();
            await act.Should().ThrowAsync<DbUpdateException>(
                "UNIQUE index trên Code phải chặn 2 dòng danh mục ACTIVE cùng mã");
        }

        // Xoá mềm dòng đang chiếm slot.
        await using (var db = f.NewDb())
        {
            var row = await db.ContractCatalogEntries.SingleAsync(x => x.Code == "X-01");
            db.ContractCatalogEntries.Remove(row);
            await db.SaveChangesAsync();
        }

        // (b) slot đã xoá mềm ⇒ tái dùng được mã.
        await using (var db = f.NewDb())
        {
            db.ContractCatalogEntries.Add(NewEntry("X-01"));
            var act = async () => await db.SaveChangesAsync();
            await act.Should().NotThrowAsync(
                "index có mệnh đề WHERE [IsDeleted] = 0 ⇒ row đã xoá mềm KHÔNG chiếm slot mã nữa (#57)");
        }

        await using var check = f.NewDb();
        (await check.ContractCatalogEntries.CountAsync(x => x.Code == "X-01")).Should().Be(1,
            "chỉ 1 dòng active mang mã X-01");
        (await check.ContractCatalogEntries.IgnoreQueryFilters().CountAsync(x => x.Code == "X-01")).Should().Be(2,
            "bản đã xoá giữ lại cho vết audit + bản active mới");
    }

    // =====================================================================================
    // T4 — Validator: ApprovalGroup ngoài 1..8 ⇒ FAIL, trong 1..8 ⇒ PASS (cả Create lẫn Update).
    //      Assert THÊM tên field lỗi: chống trường hợp "fail nhưng vì field khác sai".
    // =====================================================================================
    [Fact]
    public void Validator_ApprovalGroupOutOfRange_Fails_InRange_Passes()
    {
        var createV = new CreateContractCatalogEntryValidator();
        var updateV = new UpdateContractCatalogEntryValidator();
        var id = Guid.NewGuid();

        foreach (var bad in new[] { 0, 9, -1, 99 })
        {
            var rc = createV.Validate(CreateCmd(bad));
            rc.IsValid.Should().BeFalse($"Create với ApprovalGroup={bad} nằm ngoài 1..8 (N1..N8)");
            rc.Errors.Select(e => e.PropertyName).Should()
                .Contain(nameof(CreateContractCatalogEntryCommand.ApprovalGroup),
                    $"lỗi phải rơi ĐÚNG field ApprovalGroup (giá trị {bad}), không phải field khác");

            var ru = updateV.Validate(UpdateCmd(id, bad));
            ru.IsValid.Should().BeFalse($"Update với ApprovalGroup={bad} nằm ngoài 1..8");
            ru.Errors.Select(e => e.PropertyName).Should()
                .Contain(nameof(UpdateContractCatalogEntryCommand.ApprovalGroup));
        }

        foreach (var ok in new[] { 1, 4, 8 })
        {
            createV.Validate(CreateCmd(ok)).IsValid.Should().BeTrue(
                $"Create với ApprovalGroup={ok} hợp lệ — biên 1 và 8 phải LỌT (InclusiveBetween)");
            updateV.Validate(UpdateCmd(id, ok)).IsValid.Should().BeTrue(
                $"Update với ApprovalGroup={ok} hợp lệ");
        }
    }

    // =====================================================================================
    // T5 — Vòng đời qua HANDLER thật: Create → List thấy → Create trùng mã = Conflict →
    //      Delete = XOÁ MỀM (List/Get ẩn, row còn trong bảng, có DeletedAt/DeletedBy) →
    //      tạo lại CÙNG mã thành công (dup-check chạy trên active-set, khớp index filtered).
    // =====================================================================================
    [Fact]
    public async Task Create_Then_Delete_IsSoft_HiddenFromListAndGet_RowRemains_CodeReusable()
    {
        using var f = new CatalogFixture();
        var ct = CancellationToken.None;
        Guid id;

        await using (var db = f.NewDb())
            id = await new CreateContractCatalogEntryHandler(db).Handle(CreateCmd(), ct);

        await using (var db = f.NewDb())
        {
            var list = await new ListContractCatalogHandler(db).Handle(new ListContractCatalogQuery(), ct);
            list.Should().ContainSingle(x => x.Id == id, "dòng vừa tạo phải xuất hiện trong danh sách");
        }

        // Trùng mã trên active-set → 409.
        await using (var db = f.NewDb())
        {
            var act = async () => await new CreateContractCatalogEntryHandler(db).Handle(CreateCmd(), ct);
            await act.Should().ThrowAsync<ConflictException>("mã danh mục đang được 1 dòng active dùng");
        }

        await using (var db = f.NewDb())
            await new DeleteContractCatalogEntryHandler(db).Handle(new DeleteContractCatalogEntryCommand(id), ct);

        await using (var db = f.NewDb())
        {
            var list = await new ListContractCatalogHandler(db).Handle(new ListContractCatalogQuery(), ct);
            list.Should().NotContain(x => x.Id == id, "query filter phải ẩn dòng đã xoá khỏi danh sách");

            var get = async () => await new GetContractCatalogEntryHandler(db)
                .Handle(new GetContractCatalogEntryQuery(id), ct);
            await get.Should().ThrowAsync<NotFoundException>("Get theo id của dòng đã xoá ⇒ 404");

            var raw = await db.ContractCatalogEntries.IgnoreQueryFilters().SingleAsync(x => x.Id == id);
            raw.IsDeleted.Should().BeTrue("xoá MỀM — row phải còn lại trong bảng (audit + K2 Line denorm tên)");
            raw.DeletedAt.Should().Be(FrozenNow, "AuditingInterceptor đóng dấu thời điểm xoá");
            raw.DeletedBy.Should().Be(f.Actor.UserId, "AuditingInterceptor đóng dấu người xoá");
        }

        // Tạo lại CÙNG mã: dup-check của handler chạy trên active-set ⇒ phải cho qua.
        await using (var db = f.NewDb())
        {
            var newId = await new CreateContractCatalogEntryHandler(db).Handle(CreateCmd(), ct);
            newId.Should().NotBe(id, "là dòng MỚI, không phải hồi sinh dòng cũ");
        }

        await using var check = f.NewDb();
        (await check.ContractCatalogEntries.CountAsync()).Should().Be(1);
        (await check.ContractCatalogEntries.IgnoreQueryFilters().CountAsync()).Should().Be(2);
    }

    // =====================================================================================
    // T5b — [gate-K1 F4 @S165] List MẶC ĐỊNH loại dòng ngừng hiệu lực (spec K1.c
    //       `IncludeInactive?` mặc-định-loại); includeInactive=true mới thấy cả hai.
    //       Chốt ngữ nghĩa TRƯỚC khi picker K2 xây trên List này.
    // =====================================================================================
    [Fact]
    public async Task List_DefaultExcludesInactive_IncludeInactiveShowsAll()
    {
        using var f = new CatalogFixture();
        var ct = CancellationToken.None;

        await using (var db = f.NewDb())
        {
            var off = NewEntry("Z-OFF");
            off.IsActive = false;
            db.ContractCatalogEntries.AddRange(NewEntry("Z-ON"), off);
            await db.SaveChangesAsync(ct);
        }

        await using (var db = f.NewDb())
        {
            var defaults = await new ListContractCatalogHandler(db).Handle(new ListContractCatalogQuery(), ct);
            defaults.Select(x => x.Code).Should().Contain("Z-ON")
                .And.NotContain("Z-OFF", "mặc định phải LOẠI dòng IsActive=false (spec K1.c)");

            var all = await new ListContractCatalogHandler(db)
                .Handle(new ListContractCatalogQuery(IncludeInactive: true), ct);
            all.Select(x => x.Code).Should().Contain(["Z-ON", "Z-OFF"]);
        }
    }

    // =====================================================================================
    // T6 — Menu key + grant seed. 3 mắt xích PHẢI khớp nhau, hỏng 1 là hỏng lúc BOOT:
    //      (i)  `ContractCatalog` ∈ `MenuKeys.All`  ⇒ Program.cs mới sinh 4 policy
    //           ContractCatalog.{Read,Create,Update,Delete}
    //      (ii) `SeedMenuTreeAsync` phải có row MenuItem cho key đó (FK Permission.MenuKey →
    //           MenuItem.Key, Cascade). Key ∈ All mà thiếu row menu ⇒ SeedAdminPermissionsAsync
    //           chèn Permission → nổ FK ngay lần boot đầu (SQLite Error 19 / FK prod).
    //      (iii) Admin được grant đủ 4 cờ CRUD qua loop `MenuKeys.All` (:2058).
    //      Bản thân việc CHẠY 2 seeder này = smoke-test cho invariant (ii) trên TOÀN BỘ `All`.
    // =====================================================================================
    [Fact]
    public async Task MenuKey_ContractCatalog_InAll_SeededUnderCatalogs_AndGrantedToAdmin()
    {
        MenuKeys.All.Should().Contain(MenuKeys.ContractCatalog,
            "Program.cs chỉ sinh policy cho key ∈ MenuKeys.All — key ngoài All thì [Authorize(Policy=...)] tương ứng sẽ 500 lúc chạy");

        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var rm = fix.Services.GetRequiredService<RoleManager<Role>>();
        var adminRole = new Role { Id = Guid.NewGuid(), Name = AppRoles.Admin };
        (await rm.CreateAsync(adminRole)).Succeeded.Should().BeTrue();

        await InvokePrivateSeedAsync(db, "SeedMenuTreeAsync");
        await InvokePrivateSeedAsync(db, "SeedAdminPermissionsAsync", rm);

        // (ii) row menu đúng chỗ: leaf dưới nhóm "Danh mục chi tiết".
        var item = await db.MenuItems.AsNoTracking()
            .FirstOrDefaultAsync(m => m.Key == MenuKeys.ContractCatalog);
        item.Should().NotBeNull("SeedMenuTreeAsync phải tạo row menu cho key mới");
        item!.ParentKey.Should().Be(MenuKeys.Catalogs, "leaf nằm dưới nhóm Danh mục chi tiết");

        // Invariant TOÀN BỘ All (nguồn của lỗi FK lúc boot).
        var menuKeysInDb = await db.MenuItems.AsNoTracking().Select(m => m.Key).ToListAsync();
        MenuKeys.All.Except(menuKeysInDb).Should().BeEmpty(
            "mọi key trong MenuKeys.All phải có row MenuItem — thiếu row là Permission FK nổ ngay lần boot đầu");

        // (iii) Admin có đủ 4 cờ.
        var perm = await db.Permissions.AsNoTracking()
            .FirstOrDefaultAsync(p => p.RoleId == adminRole.Id && p.MenuKey == MenuKeys.ContractCatalog);
        perm.Should().NotBeNull("SeedAdminPermissionsAsync loop qua MenuKeys.All phải cấp cho Admin");
        perm!.CanRead.Should().BeTrue();
        perm.CanCreate.Should().BeTrue();
        perm.CanUpdate.Should().BeTrue();
        perm.CanDelete.Should().BeTrue();
    }

    // Reflection cho seeder private static: (db, logger) hoặc (db, roleManager, logger).
    private static async Task InvokePrivateSeedAsync(
        TestApplicationDbContext db, string methodName, RoleManager<Role>? rm = null)
    {
        var mi = typeof(DbInitializer).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static);
        mi.Should().NotBeNull($"DbInitializer.{methodName} phải tồn tại (private static) — đổi signature thì cập nhật test");
        var args = rm is null
            ? new object[] { db, NullLogger.Instance }
            : new object[] { db, rm, NullLogger.Instance };
        await (Task)mi!.Invoke(null, args)!;
    }
}
