using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.Routing;          // HttpMethodAttribute — đọc verb thật của action (W2 KHKK)
using SolutionErp.Api.Controllers;
using SolutionErp.Domain.Identity;   // MenuKeys — chốt policy name có thật (F-1b S155)

namespace SolutionErp.Infrastructure.Tests.Api;

// Regression tests for gotcha #44 — Silent 403 từ class-level [Authorize(Policy = ...)]
// quá strict (Session 18, fix `f77ea38`).
//
// Bug ngày 2026-05-08: ApprovalWorkflowsV2Controller class-level
// `[Authorize(Policy = "Workflows.Read")]` → Drafter `nv.test` (chỉ có
// `PurchaseEvaluations.Read`) bị 403 silent khi GET /api/approval-workflows-v2.
// FE TanStack Query catch silent → Workspace dropdown empty không có error toast.
//
// Fix: split policy per action — class-level [Authorize] (any authenticated)
// cho list-pick GET, action-level [Authorize(Policy = "Workflows.Create")]
// cho POST/DELETE/PATCH admin-only.
//
// Regression coverage: nếu future ai đó add Policy="Workflows.Read" lên
// class-level OR add Policy lên GET action → test FAIL ngay, không cần UAT
// reproduce lại bug silent 403 mà FE catch không hiển thị.
public class AuthorizePolicyRegressionTests
{
    private static AuthorizeAttribute? GetClassLevelAuthorize(Type controllerType)
        => controllerType.GetCustomAttributes<AuthorizeAttribute>(inherit: false).FirstOrDefault();

    private static AuthorizeAttribute? GetActionAuthorize(Type controllerType, string methodName)
        => controllerType
            .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
            .FirstOrDefault(m => m.Name == methodName)
            ?.GetCustomAttributes<AuthorizeAttribute>(inherit: false)
            .FirstOrDefault();

    [Fact]
    public void ApprovalWorkflowsV2Controller_ClassLevel_AuthorizeOnly_NoPolicy()
    {
        var attr = GetClassLevelAuthorize(typeof(ApprovalWorkflowsV2Controller));

        attr.Should().NotBeNull("controller phải có [Authorize] class-level để chặn anonymous");
        attr!.Policy.Should().BeNull(
            "class-level KHÔNG được hardcode Policy — sẽ block Drafter 403 silent khi GET. " +
            "Gotcha #44: ràng buộc per-action thay vì class-level uniform.");
        attr.Roles.Should().BeNull("class-level KHÔNG được hardcode Roles");
    }

    [Fact]
    public void ApprovalWorkflowsV2Controller_Overview_GET_InheritsClassLevel_NoActionPolicy()
    {
        // GET Overview KHÔNG được override với [Authorize(Policy=...)] — Drafter
        // cần list workflow để pick lúc create PE/HĐ (read-only, không expose
        // business data nhạy cảm).
        var attr = GetActionAuthorize(typeof(ApprovalWorkflowsV2Controller), nameof(ApprovalWorkflowsV2Controller.Overview));

        attr.Should().BeNull(
            "GET Overview phải inherit class-level [Authorize] (any authenticated). " +
            "Nếu add [Authorize(Policy=...)] action-level → Drafter 403 silent (gotcha #44).");
    }

    [Fact]
    public void ApprovalWorkflowsV2Controller_Create_POST_RequiresWorkflowsCreatePolicy()
    {
        // POST Create chỉ admin Designer được tạo workflow — phải có policy guard.
        var attr = GetActionAuthorize(typeof(ApprovalWorkflowsV2Controller), nameof(ApprovalWorkflowsV2Controller.Create));

        attr.Should().NotBeNull("POST Create phải có [Authorize(Policy = ...)] admin-only");
        attr!.Policy.Should().Be("Workflows.Create",
            "POST Create chỉ admin được tạo workflow mới (Mig 22 V2 Designer).");
    }

    [Fact]
    public void ApprovalWorkflowsV2Controller_Delete_RequiresWorkflowsCreatePolicy()
    {
        var attr = GetActionAuthorize(typeof(ApprovalWorkflowsV2Controller), nameof(ApprovalWorkflowsV2Controller.Delete));

        attr.Should().NotBeNull("DELETE phải có [Authorize(Policy = ...)] admin-only");
        attr!.Policy.Should().Be("Workflows.Create",
            "DELETE workflow chỉ admin (Designer).");
    }

    [Fact]
    public void ApprovalWorkflowsV2Controller_SetUserSelectable_PATCH_RequiresWorkflowsCreatePolicy()
    {
        // Mig 25 — admin toggle stick/unstick "cho user pick lúc create phiếu".
        var attr = GetActionAuthorize(typeof(ApprovalWorkflowsV2Controller), nameof(ApprovalWorkflowsV2Controller.SetUserSelectable));

        attr.Should().NotBeNull("PATCH user-selectable phải có [Authorize(Policy = ...)] admin-only");
        attr!.Policy.Should().Be("Workflows.Create",
            "PATCH user-selectable chỉ admin (Mig 25 Designer pin/unpin).");
    }

    // ===================================================================
    // F-1b (S155 2026-07-27) — PUT sửa quy trình TẠI CHỖ.
    //
    // 🔴 Nghiêm trọng hơn một endpoint ghi thường, vì CẢ HAI thao tác mà lệnh này
    // cho phép đều là hành vi CẤP QUYỀN:
    //   (i)  "thêm người vào Cấp"  ⇒ tự thêm mình vào Cấp đang chờ ⇒ lọt guard
    //        match-approver ⇒ DUYỆT ĐƯỢC PHIẾU NGƯỜI KHÁC;
    //   (ii) "bật cờ Allow*"       ⇒ gồm chính F6 AllowApproverDelete ⇒ TỰ CẤP
    //        QUYỀN XOÁ phiếu đang duyệt.
    // Class-level chỉ [Authorize] trần (cố ý, gotcha #44) nên nó KHÔNG tự bảo vệ:
    // thiếu đúng 1 dòng attribute = leo thang đặc quyền cho MỌI tài khoản đăng nhập.
    // ===================================================================

    [Fact]
    public void ApprovalWorkflowsV2Controller_Update_PUT_RequiresWorkflowsCreatePolicy()
    {
        var attr = GetActionAuthorize(typeof(ApprovalWorkflowsV2Controller), nameof(ApprovalWorkflowsV2Controller.Update));

        attr.Should().NotBeNull(
            "PUT sửa-tại-chỗ phải có [Authorize(Policy = ...)] — class-level [Authorize] trần cho lọt mọi user đăng nhập");
        attr!.Policy.Should().Be("Workflows.Create",
            "khớp cả 3 verb ghi còn lại (POST/PATCH/DELETE) của chính controller này — sửa quy trình = quyền admin Designer.");
    }

    // Bẫy đi kèm (spec §B3): `Program.cs` CHỈ đăng ký policy `{menu}.{action}` cho
    // menu key nằm trong `MenuKeys.All`. Gắn một policy KHÔNG được đăng ký thì
    // ASP.NET ném lúc chạy — test attribute ở trên vẫn xanh mà endpoint vẫn hỏng.
    // ⇒ chốt luôn rằng tên policy đang dùng là tên CÓ THẬT trong bộ sinh policy.
    [Fact]
    public void ApprovalWorkflowsV2Controller_WriteVerbs_PolicyName_IsActuallyRegisterable()
    {
        var writeActions = new[]
        {
            nameof(ApprovalWorkflowsV2Controller.Create),
            nameof(ApprovalWorkflowsV2Controller.Update),
            nameof(ApprovalWorkflowsV2Controller.SetUserSelectable),
            nameof(ApprovalWorkflowsV2Controller.Delete),
        };

        foreach (var action in writeActions)
        {
            var policy = GetActionAuthorize(typeof(ApprovalWorkflowsV2Controller), action)?.Policy;
            policy.Should().NotBeNullOrEmpty($"action {action} phải có policy");

            var parts = policy!.Split('.');
            parts.Should().HaveCount(2, $"policy {policy} phải đúng dạng {{menuKey}}.{{action}}");
            MenuKeys.All.Should().Contain(parts[0],
                $"policy \"{policy}\" của {action} trỏ menu key KHÔNG có trong MenuKeys.All ⇒ Program.cs không sinh policy đó ⇒ 500 lúc chạy (bẫy Pe_* trong spec S155 §B3)");
            MenuKeys.Actions.Should().Contain(parts[1],
                $"policy \"{policy}\" của {action} dùng action không có trong MenuKeys.Actions");
        }
    }

    // ===================================================================
    // Coverage gap #3 (MAJOR — S35/S36 backlog, closed S45 2026-06-01).
    // gotcha #44 regression cho 2 controller HRM bị MISS (chỉ ApprovalWorkflowsV2 có trước đó).
    //
    // 2 controller shape KHÁC nhau — lock đúng intent (KHÔNG ép về cùng 1 kiểu):
    //   - HrmConfigsController: class [Authorize] trần (any-authenticated GET dropdown), writes Admin-role.
    //   - EmployeesController: class Policy-gated "Hrm_HoSo.Read" + per-action Create/Update/Delete policy.
    // ===================================================================

    // ---------- HrmConfigsController (class [Authorize] trần + writes Admin-role) ----------

    [Fact]
    public void HrmConfigsController_ClassLevel_AuthorizeOnly_NoPolicy_NoRoles()
    {
        // Class-level [Authorize] trần — GET (leave-types/holidays/shifts/ot-policies) phải mở cho
        // MỌI authenticated user (dropdown master data). KHÔNG được hardcode Policy/Roles ở class-level
        // → sẽ block role không-Admin 403 silent khi GET (gotcha #44).
        var attr = GetClassLevelAuthorize(typeof(HrmConfigsController));

        attr.Should().NotBeNull("controller phải có [Authorize] class-level chặn anonymous");
        attr!.Policy.Should().BeNull("class-level KHÔNG được hardcode Policy — GET dropdown cho mọi authenticated");
        attr.Roles.Should().BeNull("class-level KHÔNG được hardcode Roles — chỉ writes mới gắn Roles=Admin");
    }

    [Fact]
    public void HrmConfigsController_ListHolidays_GET_InheritsClassLevel_NoActionAttr()
    {
        // GET list KHÔNG được override [Authorize(...)] action-level — mọi authenticated user
        // cần đọc cho dropdown (gotcha #44 silent 403 prevention).
        var attr = GetActionAuthorize(typeof(HrmConfigsController), nameof(HrmConfigsController.ListHolidays));

        attr.Should().BeNull("GET ListHolidays phải inherit class-level [Authorize] (any authenticated)");
    }

    [Fact]
    public void HrmConfigsController_CreateHoliday_POST_RequiresAdminRole()
    {
        var attr = GetActionAuthorize(typeof(HrmConfigsController), nameof(HrmConfigsController.CreateHoliday));

        attr.Should().NotBeNull("POST CreateHoliday phải có [Authorize(Roles = ...)] write-guard");
        attr!.Roles.Should().Be("Admin", "ghi Holiday chỉ Admin (role-based, KHÔNG policy).");
        attr.Policy.Should().BeNull("HrmConfigs writes dùng Roles=Admin, KHÔNG dùng Policy.");
    }

    [Fact]
    public void HrmConfigsController_UpdateHoliday_PUT_RequiresAdminRole()
    {
        var attr = GetActionAuthorize(typeof(HrmConfigsController), nameof(HrmConfigsController.UpdateHoliday));

        attr.Should().NotBeNull("PUT UpdateHoliday phải có [Authorize(Roles = ...)] write-guard");
        attr!.Roles.Should().Be("Admin");
    }

    [Fact]
    public void HrmConfigsController_DeleteHoliday_DELETE_RequiresAdminRole()
    {
        var attr = GetActionAuthorize(typeof(HrmConfigsController), nameof(HrmConfigsController.DeleteHoliday));

        attr.Should().NotBeNull("DELETE DeleteHoliday phải có [Authorize(Roles = ...)] write-guard");
        attr!.Roles.Should().Be("Admin");
    }

    // ---------- EmployeesController (class Policy-gated + per-action policy) ----------

    [Fact]
    public void EmployeesController_ClassLevel_RequiresHrmHoSoReadPolicy()
    {
        // Class-level Policy "Hrm_HoSo.Read" — KHÁC HrmConfigs (hồ sơ NS nhạy cảm hơn dropdown).
        // Lock intent: role thiếu Read sẽ 403 (FE PermissionGuard wrap để tránh silent UX — gotcha #44).
        var attr = GetClassLevelAuthorize(typeof(EmployeesController));

        attr.Should().NotBeNull("EmployeesController phải có [Authorize] class-level");
        attr!.Policy.Should().Be("Hrm_HoSo.Read",
            "hồ sơ nhân sự gate bằng policy Read class-level (KHÁC HrmConfigs any-authenticated).");
    }

    [Fact]
    public void EmployeesController_Create_POST_RequiresHrmHoSoCreatePolicy()
    {
        var attr = GetActionAuthorize(typeof(EmployeesController), nameof(EmployeesController.Create));

        attr.Should().NotBeNull("POST Create phải override action-level policy");
        attr!.Policy.Should().Be("Hrm_HoSo.Create");
    }

    [Fact]
    public void EmployeesController_Update_PUT_RequiresHrmHoSoUpdatePolicy()
    {
        var attr = GetActionAuthorize(typeof(EmployeesController), nameof(EmployeesController.Update));

        attr.Should().NotBeNull("PUT Update phải override action-level policy");
        attr!.Policy.Should().Be("Hrm_HoSo.Update");
    }

    [Fact]
    public void EmployeesController_Delete_DELETE_RequiresHrmHoSoDeletePolicy()
    {
        var attr = GetActionAuthorize(typeof(EmployeesController), nameof(EmployeesController.Delete));

        attr.Should().NotBeNull("DELETE Delete phải override action-level policy");
        attr!.Policy.Should().Be("Hrm_HoSo.Delete");
    }

    [Fact]
    public void EmployeesController_CreateWorkHistory_Satellite_RequiresHrmHoSoCreatePolicy()
    {
        // 1 satellite representative — satellite write inherit cùng Create policy như parent.
        var attr = GetActionAuthorize(typeof(EmployeesController), nameof(EmployeesController.CreateWorkHistory));

        attr.Should().NotBeNull("POST satellite CreateWorkHistory phải có action-level policy");
        attr!.Policy.Should().Be("Hrm_HoSo.Create");
    }

    // ===================================================================
    // S118 (2026-07-14) — Reviewer catch: ReportsController lỗ hổng lộ tài chính toàn công ty.
    //
    // Trước S118: cả controller chỉ [Authorize] class-level trần → MỌI user đăng nhập gọi được
    // GET /reports/dashboard (tổng giá trị HĐ, top NCC/dự án theo giá trị, xu hướng 12 tháng) +
    // GET /reports/contracts/export (xuất TOÀN BỘ HĐ ra Excel) → lộ tài chính HĐ toàn công ty.
    //
    // Fix (owner chốt, khớp S92 "HĐ chỉ Admin thấy"): 2 endpoint company-wide gate Policy
    // "Reports.Read" (hiện chỉ Admin có) — mở role khác = cấp qua ma trận, KHÔNG sửa code.
    // my-dashboard CỐ Ý giữ mở (scope theo currentUser — mỗi user chỉ thấy số của chính mình).
    //
    // Regression coverage: nếu ai gỡ Policy khỏi 2 endpoint company-wide → tái lộ tài chính;
    // hoặc thêm nhầm Policy vào my-dashboard → khóa dashboard CÁ NHÂN của role không-Admin.
    // Cả 2 hướng đều FAIL test ngay, không cần UAT reproduce.
    // ===================================================================

    [Fact]
    public void ReportsController_ClassLevel_AuthorizeOnly_NoPolicy_NoRoles()
    {
        // Class-level [Authorize] trần (any-authenticated) — gate company-wide đặt PER-ACTION.
        // Nếu hardcode Policy/Roles ở class-level sẽ khóa luôn my-dashboard cá nhân (gotcha #44).
        var attr = GetClassLevelAuthorize(typeof(ReportsController));

        attr.Should().NotBeNull("ReportsController phải có [Authorize] class-level chặn anonymous");
        attr!.Policy.Should().BeNull(
            "class-level KHÔNG được hardcode Policy — my-dashboard cần mở cho mọi authenticated. " +
            "Gate company-wide đặt action-level (Reports.Read).");
        attr.Roles.Should().BeNull("class-level KHÔNG được hardcode Roles");
    }

    [Fact]
    public void ReportsController_Dashboard_GET_RequiresReportsReadPolicy()
    {
        // GET /reports/dashboard = báo cáo TOÀN CÔNG TY → phải gate Reports.Read (chỉ Admin, S118).
        var attr = GetActionAuthorize(typeof(ReportsController), nameof(ReportsController.Dashboard));

        attr.Should().NotBeNull("GET dashboard (báo cáo toàn công ty) phải có [Authorize(Policy = ...)]");
        attr!.Policy.Should().Be("Reports.Read",
            "dashboard company-wide (tổng giá trị HĐ, top NCC/dự án, xu hướng 12 tháng) chỉ Admin — " +
            "gate Reports.Read (S118 vá lỗ hổng lộ tài chính). Gỡ Policy = tái lộ toàn công ty.");
    }

    [Fact]
    public void ReportsController_ExportContracts_GET_RequiresReportsReadPolicy()
    {
        // GET /reports/contracts/export = xuất TOÀN BỘ HĐ ra Excel → gate Reports.Read (chỉ Admin, S118).
        var attr = GetActionAuthorize(typeof(ReportsController), nameof(ReportsController.ExportContracts));

        attr.Should().NotBeNull("GET contracts/export (xuất toàn bộ HĐ) phải có [Authorize(Policy = ...)]");
        attr!.Policy.Should().Be("Reports.Read",
            "export toàn bộ HĐ ra Excel = nhạy cảm → gate Reports.Read chỉ Admin (S118).");
    }

    [Fact]
    public void ReportsController_MyDashboard_GET_StaysOpen_NoActionPolicy()
    {
        // GET /reports/my-dashboard CỐ Ý giữ mở (chỉ inherit class-level [Authorize] any-authenticated)
        // — scope theo currentUser.UserId, mỗi user chỉ thấy số của CHÍNH MÌNH, không lộ toàn công ty.
        // Nếu ai thêm [Authorize(Policy=...)] action-level → khóa nhầm dashboard CÁ NHÂN của mọi role
        // không có Reports.Read (403 silent). Cho phép bare [Authorize] (vô hại) nhưng CHẶN mọi Policy.
        var attr = GetActionAuthorize(typeof(ReportsController), nameof(ReportsController.MyDashboard));

        (attr?.Policy).Should().BeNull(
            "GET my-dashboard KHÔNG được gate Policy — mỗi user chỉ thấy số của chính mình. " +
            "Thêm policy sẽ khóa dashboard cá nhân của role không có Reports.Read (S118 giữ mở có chủ đích).");
    }

    // ===================================================================
    // [W2 KHKK — S161 2026-07-29] ContractSigningPlansController (GĐ2 "Kế hoạch ký kết HĐ").
    //
    // Shape KHÁC 3 controller ở trên — đừng ép về cùng một kiểu:
    //   class-level CÓ Policy `KeHoachKyKet.Read` (mọi endpoint tối thiểu Read) + per-action
    //   override GHI ĐỦ 15/15 action, KỂ CẢ action chỉ-đọc (viết lại Read cho tường minh).
    //   Lý do ghi thành lời ở `ContractSigningPlansController.cs:12-19`.
    //
    // Test này quét TOÀN BỘ action (không liệt tên tay) vì rủi ro thật của module mới là
    // action thứ 16 thêm sau này QUÊN attribute — liệt tay thì action mới không bao giờ bị soi.
    // 3 lớp răng, mỗi lớp bắt một hỏng khác nhau:
    //   (a) THIẾU attribute        → endpoint tụt xuống class-level Read ⇒ ai đọc được cũng GHI được;
    //   (b) SAI tên policy         → `Program.cs` chỉ sinh policy cho key ∈ `MenuKeys.All`; tên lạ
    //                                = 500 lúc chạy (KHÔNG phải 403) — attribute vẫn "trông đúng";
    //   (c) verb ghi mà policy Read → 403 không bao giờ xảy ra: user chỉ-xem GHI được vào phiếu.
    // ===================================================================

    /// Bộ action của controller = method public khai TRỰC TIẾP trên class + có `[HttpXxx]`.
    /// (Lọc theo HttpMethodAttribute để bỏ property/ctor/record lồng bên trong.)
    private static (string Name, string Verb, AuthorizeAttribute? Authz)[] GetActionsWithVerb(Type controllerType)
        => controllerType
            .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
            .Select(m => new
            {
                m.Name,
                Http = m.GetCustomAttributes<HttpMethodAttribute>(inherit: false).FirstOrDefault(),
                Authz = m.GetCustomAttributes<AuthorizeAttribute>(inherit: false).FirstOrDefault(),
            })
            .Where(x => x.Http is not null)
            .Select(x => (x.Name, x.Http!.HttpMethods.First().ToUpperInvariant(), x.Authz))
            .ToArray();

    /// 🔴 [K7 S167] NGOẠI LỆ CÓ TÊN — action tạo ra tài nguyên của MODULE KHÁC phải gate bằng
    /// key policy của **thứ được tạo**, không phải key của controller đang đứng (gotcha #85:
    /// "gate quyền dùng đúng key policy của endpoint"; `MenuPermissionHandler.cs:40` so khớp
    /// CHÍNH XÁC 1 key, không kế-thừa ⇒ gate nhầm key = cấp quyền chéo hoặc khoá oan).
    ///
    /// Cầu GĐ2→GĐ3 `POST {id}/create-contract` SINH RA một `Contract` ⇒ vế ghi = `Contracts.Create`.
    /// Vế đọc vẫn còn: class-level `KeHoachKyKet.Read` (`:28`) — ASP.NET Core cộng dồn class +
    /// action theo **AND**, nên endpoint này là HỘI 2 KHOÁ, KHÔNG phải nới lỏng.
    ///
    /// Viết dạng BẢN ĐỒ kỳ-vọng-CHÍNH-XÁC (không phải danh sách "bỏ qua"): action trong đây vẫn
    /// bị đo, chỉ đổi key kỳ vọng. Gate sai thành `KeHoachKyKet.Create` (hoặc bất cứ key nào khác)
    /// vẫn ĐỎ.
    private static readonly Dictionary<string, string> KhkkCrossModuleGates = new()
    {
        [nameof(ContractSigningPlansController.CreateContract)] = "Contracts.Create",
    };

    [Fact]
    public void ContractSigningPlansController_EveryWriteEndpoint_HasAuthorizePolicy()
    {
        var type = typeof(ContractSigningPlansController);

        // ---- (0) Class-level: 2 tầng — tầng 1 CÓ Policy Read (cố ý, khác ApprovalWorkflowsV2).
        var classAttr = GetClassLevelAuthorize(type);
        classAttr.Should().NotBeNull("controller phải có [Authorize] class-level chặn anonymous");
        classAttr!.Policy.Should().Be("KeHoachKyKet.Read",
            "tầng 1 của authz 2 tầng: MỌI endpoint KHKK tối thiểu phải có quyền đọc menu này. " +
            "Đây KHÔNG phải bẫy gotcha #44 (silent 403) vì module có menu key riêng — user được " +
            "cấp KeHoachKyKet mới thấy màn hình này, không phải mọi Drafter như ApprovalWorkflowsV2.");

        var actions = GetActionsWithVerb(type);

        // ---- Neo chống VACUOUS: foreach trên mảng rỗng luôn xanh. 15 action đo từ đĩa
        //      2026-07-29 (5 GET đọc + POST/PUT/DELETE phiếu + 3 dossier-item + 4 attachment).
        actions.Should().HaveCountGreaterThanOrEqualTo(15,
            "quét được {0} action — dưới 15 nghĩa là phép quét hỏng (đổi BindingFlags/attribute) " +
            "chứ không phải controller teo lại; lúc đó mọi assert dưới đây thành vô nghĩa",
            actions.Length);

        // Verb GHI = mọi thứ không phải GET/HEAD. Không hardcode danh sách endpoint.
        var writeVerbs = new[] { "POST", "PUT", "PATCH", "DELETE" };

        foreach (var (name, verb, authz) in actions)
        {
            // ---- (a) MỌI action phải có attribute riêng (kể cả GET — luật của module này).
            authz.Should().NotBeNull(
                "action {0} ({1}) thiếu [Authorize(Policy = ...)] — nó sẽ tụt xuống class-level " +
                "`KeHoachKyKet.Read`, nghĩa là ai ĐỌC được cũng GHI được (gotcha #82)", name, verb);
            authz!.Policy.Should().NotBeNullOrEmpty(
                "action {0} có [Authorize] TRẦN — `[Authorize]` không Policy = mọi user đăng nhập " +
                "lọt qua tầng 2 (bài F-1b S155)", name);

            var parts = authz.Policy!.Split('.');
            parts.Should().HaveCount(2,
                "policy \"{0}\" của {1} phải đúng dạng {{menuKey}}.{{action}}", authz.Policy, name);

            // ---- (b) Tên policy phải là tên CÓ THẬT trong bộ sinh policy của Program.cs.
            // Kỳ vọng mặc định = key của CHÍNH module; ngoại lệ CÓ TÊN (K7 cầu → HĐ) đổi kỳ vọng
            // sang key của tài nguyên ĐƯỢC TẠO — vẫn là so-khớp TUYỆT ĐỐI, không phải bỏ qua.
            var expectedPolicy = KhkkCrossModuleGates.GetValueOrDefault(name);
            if (expectedPolicy is not null)
            {
                authz.Policy.Should().Be(expectedPolicy,
                    "action {0} tạo ra tài nguyên của module KHÁC ⇒ gate bằng key policy của " +
                    "endpoint đích (gotcha #85). Gate bằng `KeHoachKyKet.*` sẽ cho người chỉ có " +
                    "quyền trên kế hoạch ký kết ĐẺ RA hợp đồng — quyền chéo đúng nghĩa", name);
            }
            else
            {
                parts[0].Should().Be(MenuKeys.KeHoachKyKet,
                    "action {0} phải gate bằng menu key của CHÍNH module này — Δ W1 chốt prefix " +
                    "`KeHoachKyKet` (spec cũ ghi `ContractSigningPlans`, đã bỏ); gate nhầm key module " +
                    "khác = cấp quyền chéo", name);
            }
            MenuKeys.All.Should().Contain(parts[0],
                "policy \"{0}\" của {1} trỏ menu key KHÔNG có trong MenuKeys.All ⇒ Program.cs không " +
                "sinh policy đó ⇒ 500 lúc chạy chứ không phải 403", authz.Policy, name);
            MenuKeys.Actions.Should().Contain(parts[1],
                "policy \"{0}\" của {1} dùng action không có trong MenuKeys.Actions", authz.Policy, name);

            // ---- (c) Verb GHI KHÔNG được gate bằng Read (403 sẽ không bao giờ xảy ra).
            if (writeVerbs.Contains(verb))
            {
                parts[1].Should().NotBe("Read",
                    "{0} là {1} (ghi) mà chỉ gate `Read` ⇒ user chỉ-xem VẪN ghi được vào phiếu", name, verb);
                parts[1].Should().BeOneOf(["Create", "Update", "Delete"],
                    "verb ghi {0} phải gate Create/Update/Delete", name);
            }
            else
            {
                parts[1].Should().Be("Read",
                    "{0} là {1} (đọc) — gate mạnh hơn Read sẽ khóa nhầm người chỉ có quyền xem " +
                    "(403 silent, gotcha #44)", name, verb);
            }
        }

        // ---- (d) Chốt 3 endpoint mấu chốt bằng TÊN — nếu ai đổi POST phiếu sang `Update`
        //      thì luật (c) ở trên vẫn xanh (Update cũng là verb ghi hợp lệ).
        var byName = actions.ToDictionary(a => a.Name, a => a.Authz?.Policy);
        byName[nameof(ContractSigningPlansController.Create)].Should().Be("KeHoachKyKet.Create",
            "POST tạo phiếu = quyền Create");
        byName[nameof(ContractSigningPlansController.UpdateDraft)].Should().Be("KeHoachKyKet.Update",
            "PUT sửa nháp = quyền Update");
        byName[nameof(ContractSigningPlansController.Delete)].Should().Be("KeHoachKyKet.Delete",
            "DELETE phiếu = quyền Delete RIÊNG (không gộp Update) — xoá là hành vi khác sửa");

        // Ghi vào phiếu (căn cứ b.8-9 / đính kèm) = Update chứ KHÔNG Create: "tạo" ở module
        // này nghĩa là tạo PHIẾU. Chốt lại để không ai "sửa cho nhất quán" nhầm hướng.
        byName[nameof(ContractSigningPlansController.CreateDossierItem)].Should().Be("KeHoachKyKet.Update",
            "thêm căn cứ = ghi vào phiếu đã có ⇒ Update; gate Create sẽ chặn oan người được giao " +
            "quyền sửa phiếu nhưng không được lập phiếu mới");
        byName[nameof(ContractSigningPlansController.UploadAttachment)].Should().Be("KeHoachKyKet.Update",
            "upload đính kèm = ghi vào phiếu đã có ⇒ Update");

        // ---- (e) [K7 S167] Cầu GĐ2→GĐ3 — chốt bằng TÊN, 2 chiều.
        byName[nameof(ContractSigningPlansController.CreateContract)].Should().Be("Contracts.Create",
            "cầu KHKK→HĐ sinh ra Contract ⇒ vế GHI phải là khoá của Contract (gotcha #85). " +
            "Đây là vế 2 của HỘI-2-KHOÁ; vế 1 = class-level `KeHoachKyKet.Read` đã chốt ở (0)");

        // Ngoại lệ phải trỏ action CÓ THẬT: đổi tên/xoá action mà quên dọn bản đồ thì mục đó
        // thành cấu-hình-chết (im lặng, không ai đo) — vắng-mặt trông giống ổn.
        KhkkCrossModuleGates.Keys.Should().BeSubsetOf(byName.Keys,
            "bản đồ ngoại lệ trỏ action không còn tồn tại trên controller");
    }
}
