using System.Reflection;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using SolutionErp.Domain.Identity;
using SolutionErp.Infrastructure.Persistence;
using SolutionErp.Infrastructure.Tests.Common;

namespace SolutionErp.Infrastructure.Tests.Application;

// [K4a — S166 2026-08-01] Test-after cho sidebar KHKK 8 NHÓM DUYỆT (danh mục SOL-CCM-SP-002
// Mig 70 chia 86 dòng thành 8 nhóm; mỗi nhóm 1 workflow `KHKK-N{n}` type-10 — K3).
//
// BE đã land: `MenuKeys.KhkkGroupNumbers/KhkkLeafActions/KhkkGroupMenuKeys` (49 key spread vào
// `All`) + vòng seed 49 menu-row trong `SeedMenuTreeAsync` + entry `labelBackfill["Khkk_G1"]`
// + site-2 grant `KhkkKeys().Concat(MenuKeys.KhkkGroupMenuKeys)`.
//
// KHÔNG nhân bản test đã có: `ContractCatalogTests.MenuKey_ContractCatalog_...` (T6 lane K1)
// đã assert `MenuKeys.All.Except(menuKeysInDb).BeEmpty()` sau `SeedMenuTreeAsync` ⇒ invariant
// "mọi key ∈ All có menu-row" (thiếu row = Permission FK nổ lúc boot) TỰ ĐỘNG phủ 49 key mới.
// File này đo phần T6 KHÔNG đo:
//   T1 hình bộ sinh (49 key, group-trước-6-leaf, đúng chuỗi ký tự `Khkk_G{n}_{action}`)
//   T2 `All` — chứa đủ 49 + KHÔNG key đôi + số tổng runtime + nhóm 1 CỐ Ý đứng ngoài
//   T3 seed ×2 idempotent — 56 row họ Khkk (7 nhóm-1 + 49 mới), 0 nhân đôi, hình cây đúng
//   T4 6 leaf nhóm 2..8 MIRROR nhãn/icon/Order của nhóm 1 (đo DB↔DB, không bảng hardcode)
//   T5 labelBackfill đổi NHÃN mà KHÔNG thêm row + đối chứng key KHÔNG có entry thì KHÔNG tự lành
//   T6 site-2 phát CanRead cho 49 key mới (read-only) + đối chứng Pe_* cùng lượt được CanCreate
//
// Harness: seeder là `private static` → REFLECTION (KHÔNG sửa prod đổi visibility).
//   `SeedMenuTreeAsync(ApplicationDbContext, ILogger)`
//   `SeedAllRolesReviewReadPermissionsAsync(ApplicationDbContext, RoleManager<Role>, ILogger)`
// Fixture `IdentityFixture` (cần RoleManager cho site-2) — KHÔNG chế fixture mới.
public class KhkkGroupMenuSeedTests
{
    // ── 49 key kỳ vọng, VIẾT TAY (KHÔNG gọi lại `MenuKeys.KhkkGroup/KhkkLeaf` — gọi lại là
    // tautology: bộ sinh sai chuỗi thì kỳ vọng cũng sai theo và test xanh vô nghĩa). ────────
    private static readonly string[] Expected49 =
    {
        "Khkk_G2", "Khkk_G2_WfView", "Khkk_G2_List", "Khkk_G2_Create", "Khkk_G2_Pending", "Khkk_G2_Approved", "Khkk_G2_Deleted",
        "Khkk_G3", "Khkk_G3_WfView", "Khkk_G3_List", "Khkk_G3_Create", "Khkk_G3_Pending", "Khkk_G3_Approved", "Khkk_G3_Deleted",
        "Khkk_G4", "Khkk_G4_WfView", "Khkk_G4_List", "Khkk_G4_Create", "Khkk_G4_Pending", "Khkk_G4_Approved", "Khkk_G4_Deleted",
        "Khkk_G5", "Khkk_G5_WfView", "Khkk_G5_List", "Khkk_G5_Create", "Khkk_G5_Pending", "Khkk_G5_Approved", "Khkk_G5_Deleted",
        "Khkk_G6", "Khkk_G6_WfView", "Khkk_G6_List", "Khkk_G6_Create", "Khkk_G6_Pending", "Khkk_G6_Approved", "Khkk_G6_Deleted",
        "Khkk_G7", "Khkk_G7_WfView", "Khkk_G7_List", "Khkk_G7_Create", "Khkk_G7_Pending", "Khkk_G7_Approved", "Khkk_G7_Deleted",
        "Khkk_G8", "Khkk_G8_WfView", "Khkk_G8_List", "Khkk_G8_Create", "Khkk_G8_Pending", "Khkk_G8_Approved", "Khkk_G8_Deleted",
    };

    // 7 key nhóm 1 KHÔNG-infix, seed từ S159 — CẤM đổi (đổi key = mồ côi permission-row, bài S155).
    private const string Group1GroupKey = "Khkk_G1";
    private static readonly string[] Group1LeafKeys =
        { "Khkk_WfView", "Khkk_List", "Khkk_Create", "Khkk_Pending", "Khkk_Approved", "Khkk_Deleted" };

    // Nhãn nhóm 1 sau K4a (entry `labelBackfill` mới) và nhãn skeleton S159 mà nó thay thế.
    private const string Group1LabelAfterK4a = "1. Thiết bị, máy móc, vật tư phụ (A1)";
    private const string Group1LabelSkeletonS159 = "1. Kế hoạch ký kết HĐ (NCC-TP)";

    // ── Reflection helper (mirror `ContractCatalogTests.InvokePrivateSeedAsync`) ────────────
    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)!;
    }

    private static Task SeedMenuAsync(TestApplicationDbContext db)
        => InvokePrivateSeedAsync(db, "SeedMenuTreeAsync");

    // Đếm họ Khkk bằng so-khớp Ordinal TRONG BỘ NHỚ (LIKE của SQLite case-insensitive ASCII —
    // không muốn phép đếm phụ thuộc collation của provider).
    private static async Task<List<string>> KhkkKeysInDbAsync(TestApplicationDbContext db)
    {
        var keys = await db.MenuItems.AsNoTracking().Select(m => m.Key).ToListAsync();
        return keys.Where(k => k.StartsWith("Khkk", StringComparison.Ordinal)).OrderBy(k => k, StringComparer.Ordinal).ToList();
    }

    private static Task<MenuItem?> RowAsync(TestApplicationDbContext db, string key)
        => db.MenuItems.AsNoTracking().FirstOrDefaultAsync(m => m.Key == key);

    // =====================================================================================
    // T1 — HÌNH BỘ SINH: đúng 49 key, đúng THỨ TỰ group-trước-6-leaf, đúng chuỗi ký tự.
    //      `Equal` (thứ tự có nghĩa) chứ không `BeEquivalentTo`: nếu ai đổi `SelectMany`
    //      thành "7 group trước rồi 42 leaf sau" thì vòng seed menu vẫn chạy nhưng cây menu
    //      đổi hình ⇒ phải ĐỎ. Kèm phép kiểm liền-kề tường minh (G{n} đứng NGAY TRƯỚC
    //      G{n}_WfView) để thông điệp lỗi chỉ đúng thuộc tính bị vỡ.
    // =====================================================================================
    [Fact]
    public void KhkkGroupMenuKeys_Is49Keys_GroupImmediatelyFollowedBySixLeaves()
    {
        MenuKeys.KhkkGroupMenuKeys.Should().HaveCount(49,
            "7 nhóm (2..8) × (1 group + 6 leaf) = 49 — số này quyết định delta `Menu keys` + `Policies` ở docs/STATUS.md");

        MenuKeys.KhkkGroupMenuKeys.Should().Equal(Expected49,
            "bộ sinh phải ra ĐÚNG chuỗi `Khkk_G{{n}}` / `Khkk_G{{n}}_{{action}}` theo thứ tự group → 6 leaf; " +
            "sai 1 ký tự là menu-row lệch key với `MenuKeys.All` ⇒ Permission FK nổ lúc boot");

        // Liền-kề tường minh: mỗi group đứng ngay trước leaf `_WfView` của chính nó.
        foreach (var n in MenuKeys.KhkkGroupNumbers)
        {
            var idx = Array.IndexOf(MenuKeys.KhkkGroupMenuKeys, $"Khkk_G{n}");
            idx.Should().BeGreaterThanOrEqualTo(0, $"phải có key nhóm Khkk_G{n}");
            MenuKeys.KhkkGroupMenuKeys[idx + 1].Should().Be($"Khkk_G{n}_WfView",
                $"leaf đầu của nhóm {n} phải đứng NGAY SAU key nhóm — vòng seed menu dựa vào hình này");
        }

        MenuKeys.KhkkLeafActions.Should().Equal(
            new[] { "WfView", "List", "Create", "Pending", "Approved", "Deleted" },
            "6 action khớp Y 6 leaf nhóm 1 (owner: 'y chang Duyệt NCC') — thêm/bớt action là đổi 2 số canonical");
    }

    // =====================================================================================
    // T2 — `MenuKeys.All`: chứa đủ 49 + KHÔNG key đôi + số tổng ĐO ĐƯỢC (Policies = |All|×4,
    //      DERIVED ⇒ số này là nguồn của 2 row canonical docs/STATUS.md).
    //      ⭐ Vế cuối = guard thật: nếu ai thêm số 1 vào `KhkkGroupNumbers` thì `Khkk_G1` vào
    //      `All` VÀ vòng seed thêm `tree.Add("Khkk_G1")` lần thứ hai (row cứng đã có ở
    //      initializer) ⇒ 2 entry cùng PK trong 1 lần SaveChanges = nổ lúc boot DB trống.
    //      🔄 [W3 — S187 2026-08-10] SỐ NEO update 113 → 120: wave W3 thêm 7 leaf Designer
    //      `AwV2_Hd*` (7 loại HĐ — spec `spec-duyet-hd-10-08-2026.md:10` §5). Đây là số neo
    //      |All| DUY NHẤT trong `tests/` (1 sự thật 1 chỗ) — hình dạng họ key mới khoá riêng ở
    //      `AwV2DesignerMenuKeyTests`, KHÔNG mở số neo thứ hai ở đó.
    // =====================================================================================
    [Fact]
    public void MenuKeys_All_ContainsThe49_NoDuplicate_AndKeepsGroup1Outside()
    {
        MenuKeys.All.Should().Contain(Expected49,
            "49 key phải nằm trong All — ngoài All thì Program.cs không sinh policy và Admin không được grant qua loop All");

        MenuKeys.All.Should().OnlyHaveUniqueItems(
            "key đôi trong All ⇒ SeedAdminPermissionsAsync chèn Permission trùng (RoleId, MenuKey) ⇒ nổ UNIQUE");

        MenuKeys.All.Should().HaveCount(120,
            "ĐÍCH sau W3 @S187 = 113 (số ĐO runtime tại S166 sau K4a) + 7 leaf Designer `AwV2_Hd*` cho " +
            "7 loại HĐ (HD-TP · HD-GK · HD-NCC · HD-DV · HD-MB · HD-NT-NCC · HD-NT-DV). " +
            "`Policies` = |All| × |Actions| = 120 × 4 = 480 — đổi số ở đây BẮT BUỘC đổi 2 row canonical " +
            "`Menu keys` / `Policies` trong docs/STATUS.md CÙNG COMMIT. " +
            "Đo được 113 = lane BE chưa land 7 key (spec-change: test + code CÙNG wave, rules §7)");

        MenuKeys.KhkkGroupNumbers.Should().Equal(new[] { 2, 3, 4, 5, 6, 7, 8 },
            "nhóm 1 CỐ Ý ngoài bộ sinh — 7 key nhóm 1 không-infix đã có row cứng trong SeedMenuTreeAsync");

        foreach (var g1 in Group1LeafKeys.Append(Group1GroupKey))
            MenuKeys.All.Should().NotContain(g1,
                $"{g1} (nhóm 1) giữ nguyên trạng thái NGOÀI All từ S159 — kéo vào All sẽ sinh menu-row trùng PK lúc seed");
    }

    // =====================================================================================
    // T3 — SEED ×2 IDEMPOTENT: 56 row họ Khkk (7 nhóm-1 + 49 mới), lần 2 KHÔNG nhân đôi,
    //      và hình cây đúng (7 group mới treo dưới root `KeHoachKyKet`, Order = n, mỗi group
    //      đúng 6 con). Đếm tổng MenuItems trước/sau lần 2 để bắt cả trường hợp seeder nhân
    //      row Ở CHỖ KHÁC (không chỉ họ Khkk).
    // =====================================================================================
    [Fact]
    public async Task SeedMenuTree_RunTwice_Has56KhkkRows_NoDuplication_AndCorrectTreeShape()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();

        await SeedMenuAsync(db);
        var khkkAfter1 = await KhkkKeysInDbAsync(db);
        var totalAfter1 = await db.MenuItems.AsNoTracking().CountAsync();

        khkkAfter1.Should().HaveCount(56,
            "7 row nhóm 1 (S159, không-infix) + 49 row nhóm 2..8 (K4a) — thiếu row nào là menu mất mục / Permission FK nổ");
        khkkAfter1.Should().Contain(Expected49, "49 key mới phải có menu-row thật");
        khkkAfter1.Should().Contain(Group1LeafKeys.Append(Group1GroupKey),
            "7 row nhóm 1 KHÔNG được mất khi thêm 8-nhóm");

        await SeedMenuAsync(db);   // ⭐ lần 2
        var khkkAfter2 = await KhkkKeysInDbAsync(db);
        var totalAfter2 = await db.MenuItems.AsNoTracking().CountAsync();

        khkkAfter2.Should().Equal(khkkAfter1, "idempotent — chạy lại KHÔNG thêm/bớt row họ Khkk");
        totalAfter2.Should().Be(totalAfter1, "idempotent trên TOÀN bảng menu, không riêng họ Khkk");

        // Hình cây: 7 group mới dưới root KeHoachKyKet, Order = số nhóm (xếp 1..8 cùng nhóm 1).
        var g1 = await RowAsync(db, Group1GroupKey);
        g1!.ParentKey.Should().Be(MenuKeys.KeHoachKyKet);
        g1.Order.Should().Be(1, "nhóm 1 giữ Order 1 — 8 nhóm xếp 1..8 dưới root");

        foreach (var n in MenuKeys.KhkkGroupNumbers)
        {
            var group = await RowAsync(db, $"Khkk_G{n}");
            group.Should().NotBeNull($"Khkk_G{n} phải có row");
            group!.ParentKey.Should().Be(MenuKeys.KeHoachKyKet, $"nhóm {n} treo dưới root Kế hoạch ký kết HĐ");
            group.Order.Should().Be(n, $"Order nhóm = {n} (8 nhóm xếp 1..8 theo danh mục SP-002)");
            group.Label.Should().NotBeNullOrWhiteSpace($"nhóm {n} phải có nhãn tiếng Việt (bảng 8 nhãn owner đã soát)");

            var children = await db.MenuItems.AsNoTracking()
                .Where(m => m.ParentKey == group.Key).Select(m => m.Key).ToListAsync();
            children.Should().HaveCount(6, $"nhóm {n} đúng 6 leaf (WfView/List/Create/Pending/Approved/Deleted)");
        }

        // 8 nhãn nhóm phải KHÁC NHAU — copy-paste sót 1 dòng dictionary là 2 nhóm cùng tên trên sidebar.
        var groupLabels = new List<string> { g1.Label };
        foreach (var n in MenuKeys.KhkkGroupNumbers)
            groupLabels.Add((await RowAsync(db, $"Khkk_G{n}"))!.Label);
        groupLabels.Should().OnlyHaveUniqueItems("8 nhóm duyệt = 8 nhãn phân biệt trên sidebar");
    }

    // =====================================================================================
    // T4 — 6 leaf nhóm 2..8 MIRROR Y nhóm 1 (nhãn + icon + Order). Đo DB↔DB: lấy row nhóm 1
    //      làm mốc thay vì bảng hardcode ⇒ đổi nhãn leaf mà quên đồng bộ 7 nhóm kia là ĐỎ.
    //      Chống vacuous: chốt trước rằng 6 nhãn nhóm 1 PHÂN BIỆT (nếu chúng trùng nhau hết
    //      thì phép so "khớp nhãn" đúng ở mọi thế giới).
    // =====================================================================================
    [Fact]
    public async Task SeedMenuTree_NewGroupLeaves_MirrorGroup1_LabelIconOrder()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        await SeedMenuAsync(db);

        var g1Leaves = new List<MenuItem>();
        foreach (var key in Group1LeafKeys)
        {
            var row = await RowAsync(db, key);
            row.Should().NotBeNull($"{key} (leaf nhóm 1, S159) phải còn — K4a không được đụng key cũ");
            g1Leaves.Add(row!);
        }

        g1Leaves.Select(x => x.Label).Should().OnlyHaveUniqueItems(
            "6 nhãn leaf nhóm 1 phải phân biệt — nếu trùng nhau, phép so mirror bên dưới đúng vô nghĩa");

        for (var i = 0; i < MenuKeys.KhkkLeafActions.Length; i++)
        {
            var action = MenuKeys.KhkkLeafActions[i];
            var mirror = g1Leaves[i];
            mirror.Key.Should().Be($"Khkk_{action}",
                "thứ tự KhkkLeafActions phải khớp thứ tự 6 leaf nhóm 1 — lệch thứ tự là so nhầm cặp");

            foreach (var n in MenuKeys.KhkkGroupNumbers)
            {
                var leaf = await RowAsync(db, $"Khkk_G{n}_{action}");
                leaf.Should().NotBeNull($"Khkk_G{n}_{action} phải có row");
                leaf!.ParentKey.Should().Be($"Khkk_G{n}", "leaf treo dưới đúng nhóm của nó");
                leaf.Label.Should().Be(mirror.Label, $"nhãn leaf {action} nhóm {n} phải Y nhóm 1 ({mirror.Label})");
                leaf.Icon.Should().Be(mirror.Icon, $"icon leaf {action} nhóm {n} phải Y nhóm 1");
                leaf.Order.Should().Be(mirror.Order, $"Order leaf {action} nhóm {n} phải Y nhóm 1 (1..6)");
            }
        }
    }

    // =====================================================================================
    // T5 — labelBackfill: đổi NHÃN nhóm 1 mà KHÔNG thêm row (gotcha #11 — sửa chữ trong
    //      `tree.Add` là vô tác dụng trên DB đang chạy vì upsert chỉ re-set Order).
    //      ⭐ MA TRẬN cùng-ruột-khác-1-biến: bôi hỏng nhãn của CẢ HAI key trong cùng 1 lần,
    //      rồi seed lại — `Khkk_G1` (CÓ entry backfill) tự lành, `Khkk_G2` (KHÔNG có entry)
    //      GIỮ NGUYÊN chữ hỏng. Khác biệt duy nhất = sự có mặt trong `labelBackfill` ⇒ chứng
    //      chính cơ chế backfill làm việc, không phải upsert chính "tiện tay" ghi đè Label.
    // =====================================================================================
    [Fact]
    public async Task SeedMenuTree_LabelBackfill_HealsGroup1Label_WithoutAddingRows_ControlKeyStaysDirty()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();

        await SeedMenuAsync(db);
        var countBefore = await db.MenuItems.AsNoTracking().CountAsync();
        (await RowAsync(db, Group1GroupKey))!.Label.Should().Be(Group1LabelAfterK4a,
            "DB trống: nhãn A1 đến thẳng từ tree initializer");

        // Bôi hỏng nhãn 2 key: G1 = nhãn skeleton S159 (ca THẬT trên prod), G2 = chuỗi lạ (đối chứng).
        const string dirtyG2 = "ZZZ nhãn hỏng đối chứng";
        var g1 = await db.MenuItems.FirstAsync(m => m.Key == Group1GroupKey);
        var g2 = await db.MenuItems.FirstAsync(m => m.Key == "Khkk_G2");
        g1.Label = Group1LabelSkeletonS159;
        g2.Label = dirtyG2;
        await db.SaveChangesAsync(CancellationToken.None);

        // Sanity: mutation ĐÃ land (nếu không, "sau seed nhãn đúng" xanh vì chưa từng hỏng).
        (await RowAsync(db, Group1GroupKey))!.Label.Should().Be(Group1LabelSkeletonS159);
        (await RowAsync(db, "Khkk_G2"))!.Label.Should().Be(dirtyG2);

        await SeedMenuAsync(db);   // ⭐ lần 2 — labelBackfill chạy

        (await RowAsync(db, Group1GroupKey))!.Label.Should().Be(Group1LabelAfterK4a,
            "entry labelBackfill[\"Khkk_G1\"] phải kéo nhãn skeleton S159 về nhãn nhóm A1 trên DB đang chạy");

        (await RowAsync(db, "Khkk_G2"))!.Label.Should().Be(dirtyG2,
            "ĐỐI CHỨNG: Khkk_G2 KHÔNG có entry backfill ⇒ upsert chính chỉ re-set Order, KHÔNG ghi đè Label " +
            "(gotcha #11). Đổi nhãn G2..G8 sau khi đã seed thì BẮT BUỘC thêm entry backfill.");

        var countAfter = await db.MenuItems.AsNoTracking().CountAsync();
        countAfter.Should().Be(countBefore, "đổi nhãn KHÔNG được sinh row mới (đổi key mới sinh row + mồ côi permission)");
    }

    // =====================================================================================
    // T6 — SITE-2 (`SeedAllRolesReviewReadPermissionsAsync`): role thường có CanRead trên 49
    //      key mới (điểm điều hướng của sidebar). Cờ ghi = false vì nhánh non-Pe là read-only.
    //      ⭐ Chống vacuous bằng ĐỐI CHỨNG CÙNG LƯỢT: cùng role đó, key `Pe_DuyetNcc_List`
    //      phải có CanCreate=true (nhánh isPe) ⇒ "C/U/D=false trên Khkk" không phải vì seeder
    //      không chạy, mà vì seeder PHÂN BIỆT 2 họ key.
    //      Chạy SeedMenuTree trước = smoke FK: mọi key trong grant-set phải có menu-row thật.
    // =====================================================================================
    [Fact]
    public async Task SeedAllRolesReviewRead_GrantsReadOnNew49KhkkKeys_ReadOnly_WithPeContrast()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var rm = fix.Services.GetRequiredService<RoleManager<Role>>();

        var admin = new Role { Id = Guid.NewGuid(), Name = AppRoles.Admin };
        var normal = new Role { Id = Guid.NewGuid(), Name = AppRoles.Drafter };
        (await rm.CreateAsync(admin)).Succeeded.Should().BeTrue();
        (await rm.CreateAsync(normal)).Succeeded.Should().BeTrue();

        await SeedMenuAsync(db);
        await InvokePrivateSeedAsync(db, "SeedAllRolesReviewReadPermissionsAsync", rm);

        var rows = (await db.Permissions.AsNoTracking()
                .Where(p => p.RoleId == normal.Id).ToListAsync())
            .ToDictionary(p => p.MenuKey);

        // Mục được đề bài gọi tên (leaf MỚI) — nêu riêng để thông điệp lỗi nói đúng chỗ.
        rows.Should().ContainKey("Khkk_G2_List", "leaf mới phải được site-2 phát quyền cho role thường");
        rows["Khkk_G2_List"].CanRead.Should().BeTrue(
            "menu hiện ⟺ CanRead=true (GetMyMenuTree lọc CanRead, KHÔNG admin-bypass cho role thường)");

        foreach (var key in Expected49)
        {
            rows.Should().ContainKey(key, $"{key} phải có Permission row cho role thường (site-2 grant)");
            var row = rows[key];
            row.CanRead.Should().BeTrue($"{key}: role thường phải đọc được mục sidebar");
            row.CanCreate.Should().BeFalse($"{key}: nhánh non-Pe là read-only (cờ GHI của KHKK do site-3 phát trên root)");
            row.CanUpdate.Should().BeFalse($"{key}: read-only");
            row.CanDelete.Should().BeFalse($"{key}: read-only");
        }

        // 7 key nhóm 1 vẫn được phát (K4a `.Concat` mở rộng, KHÔNG thay thế).
        foreach (var key in Group1LeafKeys.Append(Group1GroupKey))
            rows[key].CanRead.Should().BeTrue($"{key} (nhóm 1) phải giữ CanRead sau khi K4a nối thêm 49 key");

        // ⭐ ĐỐI CHỨNG cùng lượt seed: họ Pe_* đi nhánh isPe ⇒ CanCreate=true.
        var peList = MenuKeys.PurchaseEvaluationList("DuyetNcc");
        rows[peList].CanCreate.Should().BeTrue(
            "chứng seeder CÓ chạy và CÓ phân biệt 2 họ key — nếu không, mọi assert 'false' bên trên đúng vô nghĩa");
    }

    // =====================================================================================
    // T7 — [finding D, gate K4a @S166] ⭐ BẤT-BIẾN AN NINH-DỮ-LIỆU: `RevokeTemporarilyHidden-
    //      ModulesAsync` KHÔNG được đụng họ `Khkk_*`.
    //      Đây là ca ĐÃ VỠ THẬT trên prod, không phải giả định: 2026-07-29 grant 38 key bằng
    //      SQL tay bị CHÍNH revoker này lật ở app-restart kế (Run #423 — 447/494 row rơi,
    //      11/13 role về 0; gotcha #84). Thêm 1 prefix vào bộ lọc revoke = 49 row Khkk vừa cấp
    //      biến mất Ở LẦN RESTART SAU, im lặng, không test nào đỏ ⇒ Fact này bịt đúng lỗ đó.
    //      Khuôn 2-vế mirror `AdminOnlyModulesRevokeTests` (StillHidden ∧ Reopened).
    //
    //      🔴 BẪY VACUOUS ĐÃ CHỐNG (đọc từ mã `:2546-2551`): thiếu role Admin thì revoker
    //      RETURN SỚM (no-op) ⇒ mọi assert "Khkk còn nguyên" xanh vì revoker CHƯA TỪNG CHẠY.
    //      Nên test này (a) seed Admin, (b) có CONTROL DƯƠNG: cùng lượt đó, key `Off_*` và
    //      `Personal` của CHÍNH 2 role kia PHẢI bị hạ hết cờ. Control dương đỏ ⇔ phép đo hỏng.
    //      Đo trên 2 role non-Admin (khẳng định là "mọi role", 1 role không đủ nói) + kiểm
    //      Admin được loại trừ.
    // =====================================================================================
    [Fact]
    public async Task Revoke_DoesNotTouch_KhkkGroups()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var rm = fix.Services.GetRequiredService<RoleManager<Role>>();

        // Admin BẮT BUỘC tồn tại, nếu không revoker return sớm và test thành vô nghĩa.
        var admin = new Role { Id = Guid.NewGuid(), Name = AppRoles.Admin };
        var role1 = new Role { Id = Guid.NewGuid(), Name = AppRoles.Drafter };
        var role2 = new Role { Id = Guid.NewGuid(), Name = AppRoles.DeptManager };
        foreach (var r in new[] { admin, role1, role2 })
            (await rm.CreateAsync(r)).Succeeded.Should().BeTrue();

        await SeedMenuAsync(db);
        await InvokePrivateSeedAsync(db, "SeedAllRolesReviewReadPermissionsAsync", rm);

        // CONTROL DƯƠNG: 2 key NẰM TRONG tập revoke (Off* + Personal) — site-2 không phát
        // 2 key này nên cấp tay. Có menu-row thật từ SeedMenuTreeAsync ⇒ FK ok.
        var revokedSetKeys = new[] { MenuKeys.OffDanhBa, MenuKeys.Personal };
        foreach (var roleId in new[] { admin.Id, role1.Id, role2.Id })
            foreach (var key in revokedSetKeys)
                db.Permissions.Add(new Permission
                {
                    RoleId = roleId, MenuKey = key,
                    CanRead = true, CanCreate = false, CanUpdate = false, CanDelete = false,
                });

        // Mô phỏng admin tick thêm cờ GHI cho 1 mục Khkk trên ma trận Phân quyền — revoker
        // (nếu bắt nhầm `Khkk_*`) sẽ xoá luôn cờ này, không chỉ CanRead.
        const string tickedKey = "Khkk_G5_Create";
        var ticked = await db.Permissions.FirstAsync(p => p.RoleId == role1.Id && p.MenuKey == tickedKey);
        ticked.CanCreate = true;
        await db.SaveChangesAsync(CancellationToken.None);

        await InvokePrivateSeedAsync(db, "RevokeTemporarilyHiddenModulesAsync", rm);

        var after = (await db.Permissions.AsNoTracking().ToListAsync())
            .ToDictionary(p => (p.RoleId, p.MenuKey));

        // ── VẾ 1 (CONTROL DƯƠNG): revoker THẬT SỰ đã chạy và có hiệu lực trên non-Admin ──
        foreach (var roleId in new[] { role1.Id, role2.Id })
            foreach (var key in revokedSetKeys)
            {
                var row = after[(roleId, key)];
                row.CanRead.Should().BeFalse(
                    $"{key} thuộc tập ẩn (Hrm*/Off*/Personal) ⇒ PHẢI bị hạ cờ. Vế này ĐỎ = revoker không chạy " +
                    "(vd Admin chưa seed) ⇒ mọi khẳng định 'Khkk còn nguyên' bên dưới vô giá trị");
                row.CanCreate.Should().BeFalse();
                row.CanUpdate.Should().BeFalse();
                row.CanDelete.Should().BeFalse();
            }

        // Admin được loại trừ khỏi revoke (chứng bộ lọc `RoleId != adminRoleId` còn sống).
        after[(admin.Id, MenuKeys.OffDanhBa)].CanRead.Should().BeTrue(
            "Admin KHÔNG bị revoke — nếu vế này đỏ thì revoker đang quét quá tay, kết luận về Khkk cũng đáng ngờ");

        // ── VẾ 2 (BẤT-BIẾN CẦN BẢO VỆ): 56 key họ Khkk GIỮ NGUYÊN quyền trên MỌI role ──
        var allKhkkKeys = Expected49.Concat(Group1LeafKeys).Append(Group1GroupKey).ToArray();
        allKhkkKeys.Should().HaveCount(56);

        var survivors = 0;
        foreach (var roleId in new[] { role1.Id, role2.Id })
            foreach (var key in allKhkkKeys)
            {
                after.Should().ContainKey((roleId, key),
                    $"{key}: revoke chỉ HẠ CỜ, KHÔNG xoá row — mất row là hỏng nặng hơn cả gotcha #84");
                after[(roleId, key)].CanRead.Should().BeTrue(
                    $"{key} KHÔNG thuộc Hrm*/Off*/Personal ⇒ revoker phải THA. Đỏ ở đây = đúng ca prod Run #423 " +
                    "(grant xong bị lật ở restart kế) tái diễn cho sidebar KHKK 8 nhóm");
                survivors++;
            }

        // Nói bằng ĐƠN VỊ của sự cố thật (đếm row sống sót) — 56 key × 2 role non-Admin.
        survivors.Should().Be(112, "toàn bộ row Khkk của 2 role non-Admin phải sống qua revoke");

        after[(role1.Id, tickedKey)].CanCreate.Should().BeTrue(
            $"{tickedKey}: cờ GHI admin tự tick trên ma trận Phân quyền cũng không được revoker chạm");
    }
}
