using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.Contracts;
using SolutionErp.Application.Contracts.Dtos;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;

namespace SolutionErp.Infrastructure.Tests.Application;

// [W6 ĐỢT-2 — S161 2026-07-29] Đường ống HĐ V2: xem / hộp thư / vai được trình.
// Spec: `runs/2026-07-29-S160-khkk-dryrun-plan/spec-wave-w6-duong-ong-hd-v2-29-07-2026.md` §③-B.
// Timing = **test-after** (đợt-1 đã land, build sạch, 580 xanh) ⇒ 7 test này phải XANH ngay.
// Đỏ ở đây = hồi quy thật, KHÔNG được sửa test cho vừa.
//
// 5 tên đầu PIN theo spec (`:27`) — thiếu/đổi 1 chữ là TRƯỢT acceptance. 2 cái cuối là bonus
// (DR-4 admin-inbox + bẫy `CreatedBy` null của guard trình).
//
// ── Đo cái gì (map 1:1 spec §③-B) ────────────────────────────────────────────────
//   T1 hộp thư: approver Cấp ĐANG CHỜ thấy HĐ ChoDuyet   T2 (ÂM) cấp/bước khác KHÔNG thấy
//   T3 xem: approver V2 không-phải-người-soạn mở được    T4 LIST: vai bản-cứng thấy HĐ đã phát hành
//                                                           (+ khối kiểm hở XÓA MỀM, §③-B `:31`)
//   T5 (ÂM) người ngoài quy trình, role CỤ THỂ → 403     T6 bonus: admin thấy ChoDuyet (DR-4)
//   T7 bonus: guard trình nới đúng vai + bịt bẫy actor-null
//
// ── 3 quyết định hạ tầng (điều kiện để phép đo CÓ NGHĨA) ─────────────────────────
//
// (1) `Step.Order = 5 / 15`, KHÔNG phải 1 / 2. `CurrentWorkflowStepIndex` là **INDEX vào list
//     đã sort theo Order** chứ không phải giá trị `Order` (`ContractFeatures.cs:569-571`
//     `steps[idx]`). Để Order = 1/2 thì index 0/1 và Order 1/2 lệch nhau đúng 1 — một cài đặt
//     sai kiểu `First(s => s.Order == idx)` vẫn có thể vô tình đúng ở vài ca. Order 5/15 làm
//     hai đại lượng KHÔNG THỂ trùng nhau do may mắn.
//
// (2) `TestApplicationDbContext` **KHÔNG wire** `AuditingInterceptor` ⇒ (a) `CreatedBy` của HĐ
//     seed là **null tự nhiên** — đúng cấu hình để đo bẫy `null == null` ở T7; (b) `Remove()`
//     là xoá **CỨNG**, nên khối xoá-mềm của T4 set thẳng `IsDeleted = true` chứ KHÔNG gọi
//     `Remove` (bài S155: đo "xoá mềm" trên xoá cứng = đo nhầm cơ chế, vì rào ở đây là
//     global-filter che dòng, không phải dòng biến mất).
//
// (3) Mỗi test dựng `IdentityFixture` RIÊNG (SQLite :memory: riêng) ⇒ HĐ của test này không
//     lọt vào hộp thư/danh sách của test kia. Các assert dạng `BeEquivalentTo(<tập đủ>)` chỉ
//     có nghĩa khi thế giới đóng.
public class ContractV2PipelineTests
{
    private static readonly DateTime Clock2026 = new(2026, 7, 29, 3, 0, 0, DateTimeKind.Utc);

    // ===================================================================
    // Seed helpers
    // ===================================================================

    private static TestCurrentUser As(User u, params string[] roles)
        => new(u.Id, u.FullName, u.Email, roles);

    private static async Task<(Supplier sup, Project proj)> SeedMasterAsync(TestApplicationDbContext db)
    {
        var sup = new Supplier
        {
            Id = Guid.NewGuid(),
            Code = "BTBM",
            Name = "NCC Bê tông Bình Minh",
            Type = SupplierType.NhaThauPhu,
        };
        var proj = new Project { Id = Guid.NewGuid(), Code = "FLOCK01", Name = "Dự án FLOCK 01" };
        db.Suppliers.Add(sup);
        db.Projects.Add(proj);
        await db.SaveChangesAsync(CancellationToken.None);
        return (sup, proj);
    }

    /// `steps[i][j]` = danh sách NV của **Cấp j+1** trong **Bước i+1** (nhiều NV cùng Cấp = OR-of-N).
    /// `Step.Order` cố ý chạy 5 / 15 / 25 — xem ghi chú hạ tầng (1).
    private static async Task<ApprovalWorkflow> SeedWorkflowAsync(
        TestApplicationDbContext db, string code, Guid[][][] steps)
    {
        var wf = new ApprovalWorkflow
        {
            Id = Guid.NewGuid(),
            Code = code,
            Version = 1,
            Name = $"Quy trình duyệt HĐ {code}",
            ApplicableType = ApprovalWorkflowApplicableType.Contract,
            IsActive = true,
            IsUserSelectable = true,
        };
        db.ApprovalWorkflows.Add(wf);

        for (var s = 0; s < steps.Length; s++)
        {
            var step = new ApprovalWorkflowStep
            {
                Id = Guid.NewGuid(),
                ApprovalWorkflowId = wf.Id,
                Order = 5 + (s * 10),          // 5 / 15 / 25 ≠ index 0 / 1 / 2
                Name = $"Bước {s + 1}",
                DepartmentId = null,           // null = bỏ qua FK phòng ban (khuôn Pattern 11)
            };
            db.ApprovalWorkflowSteps.Add(step);

            for (var l = 0; l < steps[s].Length; l++)
            {
                foreach (var approverId in steps[s][l])
                {
                    db.ApprovalWorkflowLevels.Add(new ApprovalWorkflowLevel
                    {
                        Id = Guid.NewGuid(),
                        ApprovalWorkflowStepId = step.Id,
                        Order = l + 1,          // Cấp 1-based
                        Name = $"Cấp {l + 1}",
                        ApproverUserId = approverId,
                    });
                }
            }
        }

        await db.SaveChangesAsync(CancellationToken.None);
        return wf;
    }

    private static async Task<Contract> AddContractAsync(
        TestApplicationDbContext db,
        Supplier sup,
        Project proj,
        Guid drafterId,
        ContractPhase phase,
        string tenHopDong,
        Guid? workflowId = null,
        int? stepIndex = null,
        int? levelOrder = null)
    {
        var c = new Contract
        {
            Id = Guid.NewGuid(),
            Type = ContractType.HopDongThauPhu,
            Phase = phase,
            SupplierId = sup.Id,
            ProjectId = proj.Id,
            DrafterUserId = drafterId,
            TenHopDong = tenHopDong,
            GiaTri = 100_000_000m,
            ApprovalWorkflowId = workflowId,
            CurrentWorkflowStepIndex = stepIndex,
            CurrentApprovalLevelOrder = levelOrder,
        };
        db.Contracts.Add(c);
        await db.SaveChangesAsync(CancellationToken.None);
        return c;
    }

    // ===================================================================
    // Act helpers — gọi HANDLER THẬT, không mock
    // ===================================================================

    private static Task<List<ContractListItemDto>> InboxAsync(
        TestApplicationDbContext db, TestCurrentUser actor)
        => new GetMyInboxQueryHandler(db, actor).Handle(new GetMyInboxQuery(), CancellationToken.None);

    private static Task<PagedResult<ContractListItemDto>> ListAsync(
        TestApplicationDbContext db, TestCurrentUser actor, ContractPhase? phase = null)
        => new ListContractsQueryHandler(db, actor)
            .Handle(new ListContractsQuery(Phase: phase) { PageSize = 200 }, CancellationToken.None);

    private static Task<PagedResult<ContractListItemDto>> ListDeletedAsync(
        TestApplicationDbContext db, TestCurrentUser actor)
        => new ListDeletedContractsQueryHandler(db, actor)
            .Handle(new ListDeletedContractsQuery() { PageSize = 200 }, CancellationToken.None);

    private static Task<ContractDetailDto> ViewAsync(
        TestApplicationDbContext db, UserManager<User> um, TestCurrentUser actor, Guid contractId)
        => new GetContractQueryHandler(db, um, actor).Handle(new GetContractQuery(contractId), CancellationToken.None);

    private static async Task SoftDeleteAsync(TestApplicationDbContext db, Contract c)
    {
        // KHÔNG dùng `db.Contracts.Remove(c)`: fixture không wire `AuditingInterceptor` nên
        // `Remove` = xoá CỨNG (bài S155) ⇒ sẽ đo nhầm cơ chế. Rào thật là global query filter
        // `!IsDeleted` (`ContractConfiguration.cs:44`) + `IgnoreQueryFilters().Where(IsDeleted)`
        // ở màn "Đã xóa" — muốn đo nó thì phải để DÒNG CÒN ĐÓ và chỉ bật cờ.
        c.IsDeleted = true;
        c.DeletedAt = Clock2026;
        await db.SaveChangesAsync(CancellationToken.None);
    }

    // ===================================================================
    // (T1) Hộp thư — approver của Cấp ĐANG CHỜ thấy HĐ `ChoDuyet`. Spec §③-B `:27` tên #1.
    //
    // Trước W6 hộp thư 100% chạy bằng `PhaseActorRoles` (`ContractFeatures.cs:470-479`) — dict
    // đó KHÔNG có key `ChoDuyet` ⇒ mọi HĐ V2 đang chờ duyệt vô hình với đúng người phải duyệt.
    //
    // 🔴 Approver A cố ý mang **0 role**: khi đó `eligiblePhases` rỗng, nên đường DUY NHẤT để A
    // thấy HĐ là `v2InboxIds`. Điều này đồng thời ghim luôn sửa đổi ở `:520-522` — điều kiện
    // thoát sớm phải xét CẢ HAI nguồn; giữ nguyên `if (eligiblePhases.Count == 0) return [];`
    // thì A nhận hộp thư rỗng dù `v2InboxIds` có phiếu (một cái return sớm giết cả tính năng).
    // Approver B mang role thật (`Procurement`) ⇒ chứng vế OR mới không đè vế legacy.
    // ===================================================================
    [Fact]
    public async Task Inbox_PendingLevelApprover_SeesChoDuyetContract()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var (sup, proj) = await SeedMasterAsync(db);

        var drafter = await fix.CreateUserAsync("t1-drafter@test.local", "BCH soạn HĐ", null, [AppRoles.Drafter]);
        var a = await fix.CreateUserAsync("t1-a@test.local", "NV Cung ứng A", null, []);
        var b = await fix.CreateUserAsync("t1-b@test.local", "NV Cung ứng B", null, [AppRoles.Procurement]);
        var c2 = await fix.CreateUserAsync("t1-c@test.local", "Trưởng phòng C", null, []);

        // 1 Bước — Cấp 1 = {A, B} (OR-of-N), Cấp 2 = {C}.
        var wf = await SeedWorkflowAsync(db, "QT-CT-T1", [[[a.Id, b.Id], [c2.Id]]]);

        var pending = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.ChoDuyet,
            "HĐ đang chờ Cấp 1", wf.Id, stepIndex: 0, levelOrder: 1);
        var draft = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.DangSoanThao,
            "HĐ còn Nháp", wf.Id, stepIndex: null, levelOrder: null);

        // A: 0 role ⇒ chỉ có thể thấy qua nhánh V2 (xem ghi chú đầu test).
        var inboxA = await InboxAsync(db, As(a));
        inboxA.Select(i => i.Id).Should().BeEquivalentTo(new[] { pending.Id },
            "approver của Cấp ĐANG CHỜ phải thấy HĐ trong hộp thư — trước W6 dict `PhaseActorRoles` "
            + "không có key `ChoDuyet` nên hộp thư rỗng và HĐ V2 treo vô thời hạn");
        inboxA.Single().Phase.Should().Be(ContractPhase.ChoDuyet);

        // B cùng Cấp 1 (OR-of-N) + có role thật ⇒ cũng phải thấy.
        (await InboxAsync(db, As(b, AppRoles.Procurement))).Select(i => i.Id)
            .Should().BeEquivalentTo(new[] { pending.Id },
                "OR-of-N: người thứ hai cùng Cấp cũng thấy; chỉ 1 trong 2 thấy = match cứng theo NV đầu danh sách");

        // Người SOẠN không phải người DUYỆT — hộp thư là 'chờ tôi duyệt'.
        // (Drafter có role `Drafter` ⇒ `eligiblePhases` = {DangSoanThao, DangDamPhan, DangInKy}
        //  ⇒ HĐ Nháp của chính mình VẪN nằm trong hộp thư theo luật legacy — W6 cố ý KHÔNG gỡ.)
        var inboxDrafter = await InboxAsync(db, As(drafter, AppRoles.Drafter));
        inboxDrafter.Select(i => i.Id).Should().NotContain(pending.Id,
            "người soạn không có chân duyệt ⇒ HĐ ChoDuyet không phải việc của họ");
        inboxDrafter.Select(i => i.Id).Should().Contain(draft.Id,
            "vế legacy (`PhaseActorRoles[DangSoanThao]`) phải còn nguyên — W6 là THUẦN CỘNG THÊM");

        // HĐ Nháp không lọt hộp thư approver (`ResolveV2InboxIdsAsync` lọc `Phase == ChoDuyet`).
        inboxA.Select(i => i.Id).Should().NotContain(draft.Id,
            "HĐ chưa trình thì chưa chờ ai duyệt — lọt vào đây là approver đọc được bản nháp riêng tư");
    }

    // ===================================================================
    // (T2) ÂM — Cấp khác / Bước khác KHÔNG thấy. Spec §③-B `:27` tên #2.
    //
    // Đây là lưới chặn cách "sửa cho xong" nguy hiểm nhất: precompute id-set theo **workflow**
    // thay vì theo **con-trỏ** (tức ai có chân trong quy trình cũng thấy mọi HĐ dùng quy trình
    // đó). Nó làm T1 xanh y hệt, chỉ vỡ ở đây.
    //
    // 🔴 Mỗi ca ÂM đều đi kèm CHỨNG NHÂN DƯƠNG của cùng người đó (C thấy HĐ khi con-trỏ chỉ
    // Cấp 2; D thấy HĐ ở Bước 2). Thiếu vế này thì một `ResolveV2InboxIdsAsync` hỏng-luôn
    // (trả rỗng mọi lúc) cũng cho test xanh — đúng kiểu vacuous-pass.
    // ===================================================================
    [Fact]
    public async Task Inbox_OtherLevel_DoesNotSeeContract()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var (sup, proj) = await SeedMasterAsync(db);

        var drafter = await fix.CreateUserAsync("t2-drafter@test.local", "BCH soạn HĐ", null, []);
        var a = await fix.CreateUserAsync("t2-a@test.local", "Bước 1 Cấp 1", null, []);
        var c2 = await fix.CreateUserAsync("t2-c@test.local", "Bước 1 Cấp 2", null, []);
        var d = await fix.CreateUserAsync("t2-d@test.local", "Bước 2 Cấp 1", null, []);
        var outsider = await fix.CreateUserAsync("t2-out@test.local", "Ngoài quy trình", null, []);

        // Bước 1 (Order 5): Cấp 1 = {A}, Cấp 2 = {C} · Bước 2 (Order 15): Cấp 1 = {D}.
        var wf = await SeedWorkflowAsync(db, "QT-CT-T2", [[[a.Id], [c2.Id]], [[d.Id]]]);

        var atStep0Level1 = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.ChoDuyet,
            "HĐ chờ Bước 1 Cấp 1", wf.Id, stepIndex: 0, levelOrder: 1);
        var atStep1Level1 = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.ChoDuyet,
            "HĐ chờ Bước 2 Cấp 1", wf.Id, stepIndex: 1, levelOrder: 1);

        // ---- ca ÂM 1: C có chân trong CHÍNH quy trình đó nhưng Ở CẤP KHÁC.
        (await InboxAsync(db, As(c2))).Should().BeEmpty(
            "Cấp 2 chưa tới lượt ⇒ hộp thư rỗng; thấy = duyệt vượt lượt ngay từ hộp thư");

        // ---- ca ÂM 2: D ở BƯỚC KHÁC — chỉ thấy HĐ đang đứng ở bước của mình.
        (await InboxAsync(db, As(d))).Select(i => i.Id).Should().BeEquivalentTo(new[] { atStep1Level1.Id },
            "D chỉ thấy HĐ có con-trỏ Bước = index 1; thấy cả HĐ Bước 1 nghĩa là id-set gom theo "
            + "QUY TRÌNH chứ không theo CON-TRỎ");

        // A đối xứng: chỉ thấy HĐ Bước 1.
        (await InboxAsync(db, As(a))).Select(i => i.Id).Should().BeEquivalentTo(new[] { atStep0Level1.Id },
            "`CurrentWorkflowStepIndex` là INDEX vào list sort theo Order (5/15), KHÔNG phải Order — "
            + "đọc nhầm semantic thì A nhận HĐ của D hoặc không nhận gì");

        (await InboxAsync(db, As(outsider))).Should().BeEmpty("người ngoài quy trình không có việc ở đây");

        // ---- CHỨNG NHÂN DƯƠNG: đẩy con-trỏ sang Cấp 2 ⇒ C thấy, A hết thấy.
        // Cùng một HĐ, cùng một người, đổi ĐÚNG MỘT biến (con-trỏ Cấp).
        atStep0Level1.CurrentApprovalLevelOrder = 2;
        await db.SaveChangesAsync(CancellationToken.None);

        (await InboxAsync(db, As(c2))).Select(i => i.Id).Should().BeEquivalentTo(new[] { atStep0Level1.Id },
            "chuyển con-trỏ sang Cấp 2 thì C PHẢI thấy — nếu C vẫn rỗng thì ca ÂM ở trên xanh vì "
            + "hộp thư hỏng-luôn, không phải vì lọc đúng cấp");
        (await InboxAsync(db, As(a))).Should().BeEmpty(
            "và A phải mất quyền tương ứng — hộp thư đi theo con-trỏ ở CẢ HAI CHIỀU");
    }

    // ===================================================================
    // (T3) Xem — approver V2 KHÔNG phải người soạn mở được HĐ `ChoDuyet`. Spec §③-B `:27` tên #3.
    //
    // 3 vế chống "mở toang" (risk C của spec `:33`), mỗi vế là một cách hỏng khác nhau:
    //   • cross-workflow: approver của quy trình KHÁC vẫn 403 ⇒ điều kiện là THEO-TỪNG-HĐ
    //     (hỏi theo `c.ApprovalWorkflowId`), không phải theo role/theo "là approver ở đâu đó".
    //   • Nháp: chính approver đó vẫn 403 khi HĐ chưa trình (luật S89, `ContractFeatures.cs:615-616`).
    //   • Cấp 2 (chưa tới lượt) VẪN xem được — đây là ngữ nghĩa CỐ Ý của mã (`:611-613` hỏi
    //     `Any` trên MỌI Cấp của quy trình): xem-được ≠ tới-lượt. Ghim lại để lần sau ai siết
    //     thành "chỉ Cấp hiện tại" thì biết mình đang ĐỔI HỢP ĐỒNG, không phải sửa bug.
    // ===================================================================
    [Fact]
    public async Task View_V2Approver_NotDrafter_CanSeeChoDuyetContract()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var um = fix.Services.GetRequiredService<UserManager<User>>();
        var (sup, proj) = await SeedMasterAsync(db);

        var drafter = await fix.CreateUserAsync("t3-drafter@test.local", "BCH soạn HĐ", null, []);
        var a = await fix.CreateUserAsync("t3-a@test.local", "Approver Cấp 1", null, [AppRoles.Procurement]);
        var b = await fix.CreateUserAsync("t3-b@test.local", "Approver Cấp 2", null, [AppRoles.Procurement]);
        var other = await fix.CreateUserAsync("t3-other@test.local", "Approver quy trình KHÁC", null, [AppRoles.Procurement]);

        var wf = await SeedWorkflowAsync(db, "QT-CT-T3", [[[a.Id], [b.Id]]]);
        var wfOther = await SeedWorkflowAsync(db, "QT-CT-T3-KHAC", [[[other.Id]]]);

        var pending = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.ChoDuyet,
            "HĐ chờ duyệt", wf.Id, stepIndex: 0, levelOrder: 1);
        var draft = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.DangSoanThao,
            "HĐ còn Nháp", wf.Id, stepIndex: null, levelOrder: null);

        var actorA = As(a, AppRoles.Procurement);

        // Sanity chống vacuous: role của A KHÔNG cho A xem HĐ ở phase này bằng đường legacy.
        ListContractsQueryHandler.GetEligiblePhases(actorA.Roles).Should().NotContain(ContractPhase.ChoDuyet,
            "nếu role `Procurement` đã tự cho xem `ChoDuyet` thì test này không đo được vế V2 nào cả");
        pending.DrafterUserId.Should().NotBe(a.Id, "A phải KHÔNG phải người soạn — đó là điểm của tên test");

        // ---- Vế chính: A mở được.
        var dto = await ViewAsync(db, um, actorA, pending.Id);
        dto.Id.Should().Be(pending.Id);
        dto.ApprovalWorkflowId.Should().Be(wf.Id, "mở đúng HĐ pin đúng quy trình, không phải DTO rỗng");
        dto.Phase.Should().Be(ContractPhase.ChoDuyet);

        // ---- Cấp 2 chưa tới lượt vẫn XEM được (ngữ nghĩa cố ý — xem ghi chú đầu test).
        (await ViewAsync(db, um, As(b, AppRoles.Procurement), pending.Id)).Id.Should().Be(pending.Id,
            "mã hỏi `Any` trên MỌI Cấp của quy trình đã pin ⇒ approver Cấp sau đọc được hồ sơ "
            + "trước lượt mình (quyền XEM ≠ quyền DUYỆT — chặn duyệt là việc của service)");

        // ---- 🔴 Cross-workflow: approver quy trình KHÁC vẫn 403 (đây là vế 'per-HĐ' thật sự).
        var actOther = async () => await ViewAsync(db, um, As(other, AppRoles.Procurement), pending.Id);
        await actOther.Should().ThrowAsync<ForbiddenException>(
            "có chân duyệt ở MỘT quy trình nào đó không phải là vé xem HĐ pin quy trình KHÁC — "
            + "hỏng vế này là mở toang cho mọi approver của hệ thống");

        // ---- Nháp: chính A cũng 403 (S89).
        var actDraft = async () => await ViewAsync(db, um, actorA, draft.Id);
        await actDraft.Should().ThrowAsync<ForbiddenException>(
            "HĐ chưa trình là tài liệu riêng của người soạn; bỏ vế `isDraftPhase` thì approver "
            + "đọc được bản nháp chưa ai muốn cho xem");

        // Người soạn vẫn xem được bản nháp của mình — chứng 403 ở trên đến từ DANH TÍNH, không
        // phải vì HĐ nháp hỏng/không đọc được.
        (await ViewAsync(db, um, As(drafter), draft.Id)).Id.Should().Be(draft.Id);
    }

    // ===================================================================
    // (T4) LIST — vai bản-cứng (không admin) thấy HĐ `DaPhatHanh`. Spec §③-B `:27` tên #4
    //      (review F-01: chưa wave nào đo màn DANH SÁCH).
    //      + Khối 2 = kiểm hở XÓA MỀM (spec §③-B `:31`) — gộp vào đây vì cùng họ handler
    //        `List*ContractsQueryHandler` và cùng actor, nên đo được cặp "còn sống ⟂ đã xoá"
    //        chỉ khác ĐÚNG MỘT biến `IsDeleted`.
    //
    // Cặp đối chứng cốt lõi: CÙNG một HĐ `DaPhatHanh`, hai actor khác nhau ĐÚNG MỘT BIẾN là
    // role — Director (bản-cứng) thấy, Procurement (không bản-cứng) không thấy. Đó là bằng
    // chứng vế mới lọc theo `HardCopyActorRoles` chứ không phải "ai cũng thấy HĐ đã phát hành".
    // ===================================================================
    [Fact]
    public async Task List_HardCopyActor_NotAdmin_SeesDaPhatHanhContract()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var (sup, proj) = await SeedMasterAsync(db);

        var drafter = await fix.CreateUserAsync("t4-drafter@test.local", "BCH soạn HĐ", null, []);
        var director = await fix.CreateUserAsync("t4-dir@test.local", "Giám đốc ký", null, [AppRoles.Director]);
        var pmh = await fix.CreateUserAsync("t4-pmh@test.local", "NV Mua hàng", null, [AppRoles.Procurement]);
        var approver = await fix.CreateUserAsync("t4-ap@test.local", "Approver V2", null, [AppRoles.Procurement]);

        var wf = await SeedWorkflowAsync(db, "QT-CT-T4", [[[approver.Id]]]);

        var published = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.DaPhatHanh,
            "HĐ đã phát hành", wf.Id, stepIndex: null, levelOrder: null);
        var pending = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.ChoDuyet,
            "HĐ chờ duyệt", wf.Id, stepIndex: 0, levelOrder: 1);
        var trinhKy = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.DangTrinhKy,
            "HĐ legacy đang trình ký", null);

        var actorDirector = As(director, AppRoles.Director);
        actorDirector.Roles.Should().NotContain(AppRoles.Admin, "tên test ghi rõ NOT admin — actor phải thật sự không phải admin");

        // ---- Khối 1: LIST còn sống.
        var listDirector = await ListAsync(db, actorDirector);
        listDirector.Items.Select(i => i.Id).Should().Contain(published.Id,
            "vai bản-cứng khâu ký/đóng dấu phải thấy HĐ ĐÃ PHÁT HÀNH — trước W6 màn danh sách "
            + "trả 0 dòng cho họ vì `GetEligiblePhases(Director)` chỉ có `DangTrinhKy`");
        listDirector.Items.Select(i => i.Id).Should().Contain(trinhKy.Id,
            "vế legacy theo role vẫn nguyên (Director ↔ DangTrinhKy) — W6 chỉ CỘNG THÊM");
        listDirector.Items.Select(i => i.Id).Should().NotContain(pending.Id,
            "vế bản-cứng bị chặn theo PHASE (`&& Phase == DaPhatHanh`); bỏ vế phase là Director "
            + "thấy mọi HĐ đang chờ duyệt của toàn hệ thống");

        // Lọc `?phase=9` — đúng đường FE gọi ở acceptance §③-B `:29`.
        (await ListAsync(db, actorDirector, ContractPhase.DaPhatHanh)).Items
            .Select(i => i.Id).Should().BeEquivalentTo(new[] { published.Id });

        // ---- 🔴 Đối chứng 1 biến: cùng HĐ, role KHÔNG bản-cứng ⇒ không thấy.
        (await ListAsync(db, As(pmh, AppRoles.Procurement))).Items.Select(i => i.Id)
            .Should().NotContain(published.Id,
                "`Procurement` không nằm trong `HardCopyActorRoles` ⇒ vẫn không thấy HĐ đã phát hành; "
                + "thấy được nghĩa là vế mới đang mở theo PHASE cho mọi người");

        // Approver V2 thấy HĐ ChoDuyet của chính quy trình mình (vế V2 ở màn danh sách).
        (await ListAsync(db, As(approver, AppRoles.Procurement))).Items.Select(i => i.Id)
            .Should().Contain(pending.Id, "vế V2 per-HĐ cũng phải sống ở LIST, không chỉ ở Detail");

        // ---- Khối 2: XÓA MỀM — không được rò sang màn "Đã xóa" (spec §③-B `:31`).
        await SoftDeleteAsync(db, published);
        await SoftDeleteAsync(db, pending);

        (await ListAsync(db, actorDirector)).Items.Select(i => i.Id).Should().NotContain(published.Id,
            "global filter `!IsDeleted` che HĐ đã xoá khỏi màn danh sách thường");

        var deletedForDirector = (await ListDeletedAsync(db, actorDirector)).Items.Select(i => i.Id).ToList();
        deletedForDirector.Should().NotContain(published.Id,
            "màn 'Đã xóa' CỐ Ý không ghép vế bản-cứng (`ContractFeatures.cs:426-431`) — cùng HĐ, cùng "
            + "actor, chỉ đổi `IsDeleted` mà quyền lật ngược: đó là ranh giới đang được ghim");
        deletedForDirector.Should().NotContain(pending.Id);

        (await ListDeletedAsync(db, As(approver, AppRoles.Procurement))).Items.Select(i => i.Id)
            .Should().NotContain(pending.Id,
                "approver V2 thấy HĐ lúc còn sống nhưng KHÔNG được thấy ở màn Đã xóa — đây đúng là "
                + "cái lỗ mà spec cấm khi nhét `ChoDuyet` vào `GetEligiblePhases` dùng chung");

        // CHỨNG NHÂN: người soạn VẪN thấy cả hai ở màn Đã xóa ⇒ 2 assert âm trên không xanh
        // nhờ handler trả rỗng cho tất cả mọi người.
        (await ListDeletedAsync(db, As(drafter))).Items.Select(i => i.Id)
            .Should().BeEquivalentTo(new[] { published.Id, pending.Id },
                "người soạn giữ nguyên quyền xem HĐ đã xoá của mình — chứng màn 'Đã xóa' vẫn chạy");
    }

    // ===================================================================
    // (T5) ÂM — người ngoài quy trình, mang role CỤ THỂ → 403. Spec §③-B `:27` tên #5
    //      (review F-S4: cấm chọn role dễ-pass).
    //
    // Chọn `CostControl` làm actor chính là CÓ CHỦ Ý và là ca mạnh nhất: nó VỪA nằm trong
    // `HardCopyActorRoles` (`ContractFeatures.cs:376`) VỪA có eligible-phase legacy
    // {DangGopY, DangKiemTraCCM}. Nếu vế bản-cứng bị viết thiếu `&& Phase == DaPhatHanh` thì
    // đúng người này lọt vào HĐ `ChoDuyet` — không role nào khác bắt được lỗi đó.
    // ===================================================================
    [Fact]
    public async Task View_UserOutsideWorkflow_WithUnrelatedRole_Gets403()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var um = fix.Services.GetRequiredService<UserManager<User>>();
        var (sup, proj) = await SeedMasterAsync(db);

        var drafter = await fix.CreateUserAsync("t5-drafter@test.local", "BCH soạn HĐ", null, []);
        var approver = await fix.CreateUserAsync("t5-ap@test.local", "Approver hợp lệ", null, []);
        var ccm = await fix.CreateUserAsync("t5-ccm@test.local", "NV Kiểm soát chi phí", null, [AppRoles.CostControl]);
        var pmh = await fix.CreateUserAsync("t5-pmh@test.local", "NV Mua hàng", null, [AppRoles.Procurement]);

        var wf = await SeedWorkflowAsync(db, "QT-CT-T5", [[[approver.Id]]]);

        var pending = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.ChoDuyet,
            "HĐ chờ duyệt", wf.Id, stepIndex: 0, levelOrder: 1);
        var published = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.DaPhatHanh,
            "HĐ đã phát hành", wf.Id, stepIndex: null, levelOrder: null);

        var actorCcm = As(ccm, AppRoles.CostControl);
        var actorPmh = As(pmh, AppRoles.Procurement);

        // Sanity: hai actor này ĐÚNG là 'ngoài quy trình' và không phải người soạn/admin.
        actorCcm.Roles.Should().NotContain(AppRoles.Admin);
        (await db.ApprovalWorkflowLevels.CountAsync(l => l.ApproverUserId == ccm.Id || l.ApproverUserId == pmh.Id))
            .Should().Be(0, "hai actor ca ÂM không được có chân ở bất kỳ Cấp nào — nếu có thì test đo nhầm vế");

        // ---- 403 lớp 1: CostControl (role NẶNG: vừa bản-cứng vừa có eligible-phase legacy).
        var actCcm = async () => await ViewAsync(db, um, actorCcm, pending.Id);
        await actCcm.Should().ThrowAsync<ForbiddenException>(
            "`CostControl` là vai bản-cứng NHƯNG chỉ cho HĐ `DaPhatHanh`; HĐ đang `ChoDuyet` mà mở "
            + "được nghĩa là vế bản-cứng thiếu chặn phase = mở toang theo role");

        // ---- 403 lớp 2: Procurement (không bản-cứng, không eligible ChoDuyet).
        var actPmh = async () => await ViewAsync(db, um, actorPmh, pending.Id);
        await actPmh.Should().ThrowAsync<ForbiddenException>(
            "role ngoài cả hai điều kiện thì phải giữ nguyên 403 như trước W6");

        // ---- 🔴 CHỨNG NHÂN 1: CHÍNH `ccm` mở được HĐ `DaPhatHanh` (đổi đúng 1 biến: phase).
        (await ViewAsync(db, um, actorCcm, published.Id)).Id.Should().Be(published.Id,
            "cùng người, cùng role, chỉ khác phase ⇒ 403 ở trên đến từ ĐIỀU KIỆN PER-HĐ, "
            + "không phải vì guard chặn tất cả mọi thứ");

        // ---- CHỨNG NHÂN 2: approver hợp lệ mở được chính HĐ `ChoDuyet` đó.
        (await ViewAsync(db, um, As(approver), pending.Id)).Id.Should().Be(pending.Id,
            "HĐ đó KHÔNG hỏng — đúng người vẫn mở được; 403 là về danh tính người hỏi");

        // ---- Và `Procurement` vẫn 403 ngay cả với HĐ đã phát hành (không phải bản-cứng).
        var actPmhPublished = async () => await ViewAsync(db, um, actorPmh, published.Id);
        await actPmhPublished.Should().ThrowAsync<ForbiddenException>(
            "danh sách `HardCopyActorRoles` là danh sách ĐÓNG — thêm role vào đó là quyết định "
            + "có chủ đích, không được rơi ra do sửa vặt");
    }

    // ===================================================================
    // (T6 — BONUS, DR-4 / review F-B13) Admin thấy HĐ `ChoDuyet` trong hộp thư.
    //
    // Trước W6 admin lấy `eligiblePhases = PhaseActorRoles.Keys` mà dict đó 100% phase legacy
    // ⇒ ghép `v2InboxIds` xong admin VẪN mù (và admin CỐ Ý có `v2InboxIds` rỗng — `:516-518`).
    // Sửa = liệt kê tường minh `AdminInboxPhases` (`:487-497`).
    //
    // Đo bằng TẬP ĐỦ chứ không chỉ "có chứa": seed đủ 8 phase trong danh sách + 3 phase ngoài.
    // Cách này bắt CẢ HAI chiều — thêm `ChoDuyet` mà đánh rơi một phase legacy cũng ĐỎ.
    // Admin cố ý KHÔNG có chân trong quy trình ⇒ đường duy nhất là nhánh PHASE.
    // ===================================================================
    [Fact]
    public async Task Inbox_Admin_SeesChoDuyetContract()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var (sup, proj) = await SeedMasterAsync(db);

        var drafter = await fix.CreateUserAsync("t6-drafter@test.local", "BCH soạn HĐ", null, []);
        var approver = await fix.CreateUserAsync("t6-ap@test.local", "Approver V2", null, []);
        var adminUser = await fix.CreateUserAsync("t6-admin@test.local", "Quản trị hệ thống", null, [AppRoles.Admin]);

        var wf = await SeedWorkflowAsync(db, "QT-CT-T6", [[[approver.Id]]]);

        ContractPhase[] inboxPhases =
        [
            ContractPhase.DangSoanThao, ContractPhase.DangGopY, ContractPhase.DangDamPhan,
            ContractPhase.DangInKy, ContractPhase.DangKiemTraCCM, ContractPhase.DangTrinhKy,
            ContractPhase.DangDongDau, ContractPhase.ChoDuyet,
        ];
        ContractPhase[] outOfInboxPhases =
        [
            ContractPhase.DaPhatHanh, ContractPhase.TuChoi, ContractPhase.TraLai,
        ];

        var expected = new List<Guid>();
        foreach (var ph in inboxPhases)
        {
            var c = await AddContractAsync(db, sup, proj, drafter.Id, ph, $"HĐ phase {ph}",
                ph == ContractPhase.ChoDuyet ? wf.Id : null,
                stepIndex: ph == ContractPhase.ChoDuyet ? 0 : null,
                levelOrder: ph == ContractPhase.ChoDuyet ? 1 : null);
            expected.Add(c.Id);
        }
        var outIds = new List<Guid>();
        foreach (var ph in outOfInboxPhases)
            outIds.Add((await AddContractAsync(db, sup, proj, drafter.Id, ph, $"HĐ phase {ph}", null)).Id);

        var admin = As(adminUser, AppRoles.Admin);
        (await db.ApprovalWorkflowLevels.CountAsync(l => l.ApproverUserId == adminUser.Id))
            .Should().Be(0, "admin KHÔNG có chân trong quy trình ⇒ chỉ có thể thấy qua nhánh PHASE");

        var inbox = (await InboxAsync(db, admin)).Select(i => i.Id).ToList();

        inbox.Should().BeEquivalentTo(expected,
            "hộp thư admin = ĐÚNG 8 phase liệt tường minh: thiếu `ChoDuyet` là lỗi DR-4 gốc "
            + "(admin mù HĐ chờ duyệt), thừa/thiếu phase legacy là W6 đã lỡ tay gỡ hành-vi cũ");
        inbox.Should().NotIntersectWith(outIds,
            "phase kết thúc (đã phát hành / từ chối / trả lại) không phải việc đang chờ xử lý");
    }

    // ===================================================================
    // (T7 — BONUS) Guard trình: nới đúng vai, và KHÔNG nới nhầm cho actor null.
    //
    // Lỗ W6 #3: NV Phòng Mua hàng (`Procurement`) lập HĐ rồi bị 403 khi bấm Trình.
    // Sửa = `Drafter ∨ DeptManager ∨ Procurement ∨ người-tạo` (`ContractWorkflowService.cs:84-88`).
    //
    // 🔴 Ca 4 là lý do test này tồn tại. `contract.CreatedBy` và `actorUserId` ĐỀU là `Guid?`.
    // Viết vế người-tạo thành `contract.CreatedBy == actorUserId` (cách tự nhiên nhất) thì HĐ
    // có `CreatedBy == null` gặp actor `null` cho ra `null == null` = TRUE ⇒ một lệnh trình
    // KHÔNG DANH TÍNH đi lọt. Chốt `actorUserId is Guid` ở `:82` là thứ duy nhất chặn. Ca này
    // ĐỎ dưới bản viết-tự-nhiên và XANH dưới bản đang có ⇒ lưới có răng thật.
    // `decision` phải là `Approve` (KHÔNG phải `AutoApprove`) để không rơi vào cửa `isSystem`.
    // ===================================================================
    [Fact]
    public async Task Submit_ProcurementAndCreator_Allowed_UnrelatedRoleAndNullActor_Forbidden()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var um = fix.Services.GetRequiredService<UserManager<User>>();
        var (sup, proj) = await SeedMasterAsync(db);

        var pmh = await fix.CreateUserAsync("t7-pmh@test.local", "NV Mua hàng", null, [AppRoles.Procurement]);
        var creator = await fix.CreateUserAsync("t7-creator@test.local", "Người lập HĐ", null, []);
        var ketoan = await fix.CreateUserAsync("t7-kt@test.local", "Kế toán", null, [AppRoles.Finance]);
        var approver = await fix.CreateUserAsync("t7-ap@test.local", "Approver V2", null, []);

        var wf = await SeedWorkflowAsync(db, "QT-CT-T7", [[[approver.Id]]]);

        var dt = new FixedDateTime(Clock2026);
        var currentUser = new TestCurrentUser();      // mutate per ca — mô phỏng scoped-per-request
        var svc = new ContractWorkflowService(
            db, new ContractCodeGenerator(db, dt), dt,
            new NoOpNotificationServiceApp(), new ChangelogService(db, currentUser, um), um);

        async Task<Contract> NewDraftAsync(string ten, Guid drafterId)
            => await AddContractAsync(db, sup, proj, drafterId, ContractPhase.DangSoanThao, ten, wf.Id);

        async Task SubmitAsync(Contract c, Guid? actorId, params string[] roles)
        {
            currentUser.UserId = actorId;
            currentUser.Roles = roles;
            await svc.TransitionAsync(c, ContractPhase.ChoDuyet, actorId, roles,
                ApprovalDecision.Approve, "Trình duyệt HĐ", ct: CancellationToken.None);
        }

        // ---- Ca 1: `Procurement` KHÔNG phải người tạo ⇒ ĐƯỢC trình (chính là lỗ đã vá).
        var c1 = await NewDraftAsync("HĐ do PMH trình", drafterId: creator.Id);
        await SubmitAsync(c1, pmh.Id, AppRoles.Procurement);
        c1.Phase.Should().Be(ContractPhase.ChoDuyet, "role `Procurement` giờ nằm trong vai được trình");
        c1.CurrentWorkflowStepIndex.Should().Be(0);
        c1.CurrentApprovalLevelOrder.Should().Be(1, "HĐ pin V2 ⇒ con-trỏ Cấp khởi tạo = 1");

        // ---- Ca 2: người TẠO nhưng KHÔNG role nào ⇒ vẫn được trình.
        var c2 = await NewDraftAsync("HĐ do chính người lập trình", drafterId: creator.Id);
        await SubmitAsync(c2, creator.Id);
        c2.Phase.Should().Be(ContractPhase.ChoDuyet,
            "vế người-tạo bắt qua `DrafterUserId` — HĐ seed/legacy có thể trống `CreatedBy`");

        // ---- Ca 3: role KHÔNG liên quan + không phải người tạo ⇒ giữ 403.
        var c3 = await NewDraftAsync("HĐ kế toán cố trình", drafterId: creator.Id);
        var act3 = async () => await SubmitAsync(c3, ketoan.Id, AppRoles.Finance);
        await act3.Should().ThrowAsync<ForbiddenException>("nới vai KHÔNG được biến thành bỏ rào");
        c3.Phase.Should().Be(ContractPhase.DangSoanThao, "bị chặn thì HĐ phải bất động");

        // ---- Ca 4: 🔴 actor NULL + `CreatedBy` NULL ⇒ vẫn phải 403.
        var c4 = await NewDraftAsync("HĐ bị trình vô danh", drafterId: creator.Id);
        c4.CreatedBy.Should().BeNull(
            "điều kiện tiên quyết của phép đo: fixture không wire `AuditingInterceptor` nên "
            + "`CreatedBy` để trống. Nếu ô này có giá trị thì bẫy `null == null` không thể xảy ra "
            + "và test đang xanh ở một thế giới KHÁC");
        var act4 = async () => await SubmitAsync(c4, actorId: null, roles: []);
        await act4.Should().ThrowAsync<ForbiddenException>(
            "actor null gặp `CreatedBy` null: viết `contract.CreatedBy == actorUserId` thì "
            + "`null == null` = TRUE và lệnh trình vô danh đi lọt. Chốt `actorUserId is Guid` "
            + "(`ContractWorkflowService.cs:82`) là thứ duy nhất chặn — đây là lưới giữ nó");
        c4.Phase.Should().Be(ContractPhase.DangSoanThao, "và HĐ phải nguyên vẹn sau khi bị chặn");
    }
}
