using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.ApprovalWorkflowsV2;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Contracts;              // ApprovalDecision + ChangelogAction (shared HĐ/PE)
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Office;                 // Proposal + ProposalLevelOpinion (F-16 blast radius)
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
using SolutionErp.Infrastructure.Tests.Services; // NoOpNotificationService

namespace SolutionErp.Infrastructure.Tests.Application;

// ============================================================================
// S155 hạng mục F (2026-07-27) — lệnh UPDATE quy trình duyệt TẠI CHỖ.
// Test-before-merge: đụng CẢ security (tự thêm mình vào Cấp = duyệt phiếu người
// khác) LẪN financial (phiếu đang chạy đổi cấu hình giữa chừng) ⇒ hạng critical
// theo docs/rules.md §7.
//
// Đối tượng đo: `UpdateAwDefinitionCommandHandler`
//   (src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs:501-930)
//
// 🔴 MỆNH ĐỀ TRUNG TÂM (spec §F.0) mà F-12/F-15 tồn tại để chứng minh:
//   POST tạo version mới sinh **Id MỚI**, phiếu đang treo pin **Id CŨ** và đọc
//   workflow **SỐNG** (`PurchaseEvaluationWorkflowService.cs:671-675` — chỉ
//   `w.Id == awId`, không snapshot) ⇒ cấu hình tick hôm nay KHÔNG BAO GIỜ tới
//   được phiếu cần cứu. Chỉ sửa-TẠI-CHỖ (giữ nguyên Id) mới tới.
//   ⇒ mọi assert "phiếu thấy thay đổi" ở đây đều đi qua `ResolveLikeRunningPeAsync`
//     — bản sao Y HỆT đường resolve của service, không phải một query tiện tay.
//
// Ranh giới ĐỢT: file này KHÔNG test hành vi XOÁ phiếu khi đang duyệt (nút +
// endpoint xoá-khi-duyệt = ĐỢT 2, chưa tồn tại trên đĩa). F-15 chỉ đo tới mức
// "cờ F6 đọc được từ phía phiếu đang treo".
//
// Harness: IdentityFixture (BẮT BUỘC — `ApprovalWorkflowLevel.ApproverUserId` có
// FK Restrict sang User, `ApprovalWorkflowConfiguration.cs:70-74`, nên approver
// phải là user THẬT chứ không phải Guid ngẫu nhiên).
// ============================================================================
public class UpdateAwDefinitionTests
{
    private sealed record Harness(
        IdentityFixture Fix,
        TestApplicationDbContext Db,
        UserManager<User> Um,
        FixedDateTime Clock);

    private static Harness NewHarness()
    {
        var fix = new IdentityFixture();
        return new Harness(
            fix,
            fix.Services.GetRequiredService<TestApplicationDbContext>(),
            fix.Services.GetRequiredService<UserManager<User>>(),
            new FixedDateTime(new DateTime(2026, 7, 27, 3, 0, 0, DateTimeKind.Utc)));
    }

    private static UpdateAwDefinitionCommandHandler NewHandler(Harness h, User actor)
        => new(h.Db, new TestCurrentUser(actor.Id, actor.FullName, actor.Email), h.Clock, h.Um);

    // stepSpecs[s] = các dòng Cấp của Bước (Order s+1). Mỗi phần tử = (LevelOrder, ApproverUserId).
    // N dòng cùng LevelOrder = 1 Cấp có N người (OR-of-N) — đúng hình dạng prod.
    private static async Task<ApprovalWorkflow> SeedWorkflowAsync(
        TestApplicationDbContext db,
        ApprovalWorkflowApplicableType type,
        (int LevelOrder, Guid User)[][] stepSpecs,
        decimal? ceoThreshold = null,
        string code = "QT-UPD-V2")
    {
        var wf = new ApprovalWorkflow
        {
            Code = code,
            Version = 1,
            ApplicableType = type,
            Name = "QT test sửa tại chỗ",
            IsActive = true,
            IsUserSelectable = true,
            CeoApprovalThreshold = ceoThreshold,
            ActivatedAt = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc),
        };
        for (int s = 0; s < stepSpecs.Length; s++)
        {
            var step = new ApprovalWorkflowStep
            {
                ApprovalWorkflowId = wf.Id,
                Order = s + 1,
                Name = $"Bước {s + 1}",
            };
            foreach (var (levelOrder, user) in stepSpecs[s])
            {
                step.Levels.Add(new ApprovalWorkflowLevel
                {
                    ApprovalWorkflowStepId = step.Id,
                    Order = levelOrder,
                    Name = $"Cấp {levelOrder}",
                    ApproverUserId = user,
                });
            }
            wf.Steps.Add(step);
        }
        db.ApprovalWorkflows.Add(wf);
        await db.SaveChangesAsync(CancellationToken.None);
        return wf;
    }

    private static async Task<PurchaseEvaluation> SeedPeAsync(
        TestApplicationDbContext db,
        Guid workflowId,
        Guid drafterId,
        string code,
        int stepIdx = 0,
        int levelOrder = 1,
        PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.ChoDuyet,
        bool isDeleted = false)
    {
        var pe = new PurchaseEvaluation
        {
            Id = Guid.NewGuid(),
            Type = PurchaseEvaluationType.DuyetNcc,
            Phase = phase,
            MaPhieu = code,
            TenGoiThau = "Gói thầu test sửa quy trình tại chỗ",
            ProjectId = Guid.NewGuid(),
            DrafterUserId = drafterId,
            ApprovalWorkflowId = workflowId,
            CurrentWorkflowStepIndex = phase == PurchaseEvaluationPhase.ChoDuyet ? stepIdx : null,
            CurrentApprovalLevelOrder = phase == PurchaseEvaluationPhase.ChoDuyet ? levelOrder : null,
            IsDeleted = isDeleted,
            DeletedAt = isDeleted ? new DateTime(2026, 7, 26, 0, 0, 0, DateTimeKind.Utc) : null,
        };
        db.PurchaseEvaluations.Add(pe);
        await db.SaveChangesAsync(CancellationToken.None);
        return pe;
    }

    // 🔴 BẢN SAO Y HỆT đường resolve của phiếu đang chạy
    // (`PurchaseEvaluationWorkflowService.cs:671-675`): chỉ lọc theo Id đã pin,
    // Include Steps/Levels đã sắp xếp, AsNoTracking. Dùng nó (chứ không phải một
    // query tiện tay) để assert "phiếu THẤY thay đổi" mới là bằng chứng thật.
    private static Task<ApprovalWorkflow> ResolveLikeRunningPeAsync(TestApplicationDbContext db, Guid pinnedId)
        => db.ApprovalWorkflows.AsNoTracking()
            .Include(w => w.Steps.OrderBy(s => s.Order))
                .ThenInclude(s => s.Levels.OrderBy(l => l.Order))
            .FirstAsync(w => w.Id == pinnedId, CancellationToken.None);

    // Lệnh Update "trung thực": echo NGUYÊN TRẠNG mọi thứ đang lưu. Mọi test chỉ
    // đổi ĐÚNG MỘT biến trên nền này (kỹ thuật cặp cùng-ruột-khác-1-biến) — nếu
    // test đỏ thì biết chắc do biến đó chứ không do payload lệch chỗ khác.
    private static UpdateAwDefinitionCommand BuildEchoCommand(ApprovalWorkflow wf, string? reason = null)
        => new(
            Id: wf.Id,
            Name: wf.Name,
            Description: wf.Description,
            Steps: wf.Steps.OrderBy(s => s.Order).Select(s => new UpdateAwStepInput(
                Id: s.Id,
                Order: s.Order,
                Name: s.Name,
                DepartmentId: s.DepartmentId,
                Levels: s.Levels.OrderBy(l => l.Order).Select(EchoLevel).ToList())).ToList(),
            Reason: reason,
            Code: null,
            ApplicableType: null,
            CeoApprovalThreshold: wf.CeoApprovalThreshold);

    private static UpdateAwLevelInput EchoLevel(ApprovalWorkflowLevel l) => new(
        Id: l.Id,
        Order: l.Order,
        Name: l.Name,
        ApproverUserId: l.ApproverUserId,
        AllowReturnOneLevel: l.AllowReturnOneLevel,
        AllowReturnOneStep: l.AllowReturnOneStep,
        AllowReturnToAssignee: l.AllowReturnToAssignee,
        AllowReturnToDrafter: l.AllowReturnToDrafter,
        AllowApproverEditDetails: l.AllowApproverEditDetails,
        AllowApproverEditBudget: l.AllowApproverEditBudget,
        AllowApproverDelete: l.AllowApproverDelete,
        AllowApproverSkipToFinal: null,     // ECHO-only, null = không khai báo
        AllowApproverFinalize: null);

    private static UpdateAwLevelInput NewLevel(int order, Guid approverUserId, bool allowDelete = false)
        => new(Id: null, Order: order, Name: $"Cấp {order}", ApproverUserId: approverUserId,
               AllowApproverDelete: allowDelete);

    // Thay danh sách Cấp của Bước thứ `stepIdx` trong lệnh (giữ nguyên phần còn lại).
    private static UpdateAwDefinitionCommand WithLevels(
        UpdateAwDefinitionCommand cmd, int stepIdx, List<UpdateAwLevelInput> levels)
    {
        var steps = cmd.Steps.ToList();
        steps[stepIdx] = steps[stepIdx] with { Levels = levels };
        return cmd with { Steps = steps };
    }

    private static PurchaseEvaluationWorkflowService NewPeService(Harness h)
        => new(h.Db, h.Clock, new NoOpNotificationService(), h.Um);

    private static Task ApproveAsync(PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorId)
        => svc.TransitionAsync(
            evaluation: pe,
            targetPhase: PurchaseEvaluationPhase.ChoDuyet,     // approve-in-place
            actorUserId: actorId,
            actorRoles: Array.Empty<string>(),                 // KHÔNG Admin — phải qua guard thật
            decision: ApprovalDecision.Approve,
            comment: null,
            ct: CancellationToken.None);

    // =====================================================================
    // F-12 ⭐ BÀI KIỂM CHỨNG TRUNG TÂM (spec §F.0) — sửa hạng AN TOÀN trên
    // workflow ĐANG CÓ PHIẾU CHẠY ⇒ phiếu nhận thay đổi NGAY.
    //
    // Đo bằng HÀNH VI THẬT chứ không bằng một cột trong bảng: cùng một phiếu,
    // cùng một lời gọi duyệt của cùng một người — chỉ khác nhau ở chỗ lệnh Update
    // đã chạy hay chưa. Trước: Forbidden. Sau: duyệt được + con trỏ tiến.
    // (Nếu chỉ assert "DB có row mới" thì test vẫn xanh kể cả khi phiếu đọc
    //  snapshot cũ — tức mù đúng chỗ spec sợ nhất.)
    // Kèm luôn F-4 (Id/Version/IsActive bất biến) và F-6 (403 in TÊN, không GUID).
    // =====================================================================
    [Fact]
    public async Task F12_SafeUpdate_AddsPersonToExistingLevel_RunningPeAcceptsNewApproverImmediately()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "Trần Thị An", null, []);
            var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Lê Văn Bình", null, []);
            var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Phạm Thị Cúc", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);

            // Bước 1: Cấp 1 = alice · Cấp 2 = carol. Phiếu treo tại (Bước 1, Cấp 1).
            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id), (2, carol.Id)]]);
            var pe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F12-001");
            var svc = NewPeService(h);

            // --- TRƯỚC: bob KHÔNG duyệt được (nửa contrast — chống vacuous pass) ---
            var beforeAct = async () => await ApproveAsync(svc, pe, bob.Id);
            var forbidden = (await beforeAct.Should().ThrowAsync<ForbiddenException>(
                "bob chưa nằm trong Cấp 1 nên phải bị chặn TRƯỚC khi sửa quy trình")).Which;
            // F-6 (lead tự vá, chưa ai soi): message phải in TÊN người duyệt, không phải GUID trần.
            forbidden.Message.Should().Contain("Trần Thị An",
                "F-6: thông điệp 403 phải resolve GUID sang tên thật để người dùng biết nhờ ai duyệt");
            forbidden.Message.Should().NotContain(alice.Id.ToString(),
                "F-6: không được rơi lại về in GUID trần khi tra được tên");

            // --- ACT: thêm bob vào CẤP CÓ SẴN (row trùng Order 1) ---
            var step = wf.Steps.Single();
            var lvlAlice = step.Levels.Single(l => l.ApproverUserId == alice.Id);
            var lvlCarol = step.Levels.Single(l => l.ApproverUserId == carol.Id);
            var cmd = WithLevels(BuildEchoCommand(wf), 0,
                [EchoLevel(lvlAlice), NewLevel(1, bob.Id), EchoLevel(lvlCarol)]);

            await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            // --- F-4: giữ Id / Version / IsActive — giữ Id là TOÀN BỘ mục đích ---
            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Id.Should().Be(wf.Id);
            live.Version.Should().Be(1, "sửa tại chỗ KHÔNG được tăng Version");
            live.IsActive.Should().BeTrue("sửa tại chỗ KHÔNG được đụng IsActive");
            live.Steps.Should().HaveCount(1);
            live.Steps[0].Levels.Where(l => l.Order == 1).Select(l => l.ApproverUserId)
                .Should().BeEquivalentTo(new[] { alice.Id, bob.Id });

            var peRow = await h.Db.PurchaseEvaluations.AsNoTracking().FirstAsync(p => p.Id == pe.Id);
            peRow.ApprovalWorkflowId.Should().Be(wf.Id, "phiếu vẫn pin ĐÚNG Id cũ — không có đường re-pin");

            // --- SAU: cùng phiếu, cùng người, cùng lời gọi ⇒ nay duyệt ĐƯỢC ---
            await ApproveAsync(svc, pe, bob.Id);

            var peAfter = await h.Db.PurchaseEvaluations.AsNoTracking().FirstAsync(p => p.Id == pe.Id);
            peAfter.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
            peAfter.CurrentApprovalLevelOrder.Should().Be(2,
                "bob duyệt xong Cấp 1 ⇒ con trỏ tiến sang Cấp 2 — phiếu chạy trên cấu hình MỚI");

            // Chữ ký gắn vào ĐÚNG dòng Cấp mới của bob (không dạt sang row alice).
            var newLevelId = live.Steps[0].Levels.Single(l => l.ApproverUserId == bob.Id).Id;
            var opinion = await h.Db.PurchaseEvaluationLevelOpinions.AsNoTracking()
                .SingleAsync(o => o.PurchaseEvaluationId == pe.Id);
            opinion.ApprovalWorkflowLevelId.Should().Be(newLevelId);
            opinion.SignedByUserId.Should().Be(bob.Id);
        }
    }

    // =====================================================================
    // F-13 — đụng tập Order ⇒ 409 VÀ workflow trong DB KHÔNG đổi.
    // Cả 2 vế: chỉ assert "có ném" là mù trước ca handler ném SAU khi đã ghi.
    // =====================================================================
    [Fact]
    public async Task F13_AddNewLevelOrder_Rejected409_AndWorkflowUnchanged()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Bình", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id)]]);
            var lvlAlice = wf.Steps.Single().Levels.Single();

            // Thêm Cấp 2 (Order chưa từng có) = chèn chặng cho phiếu đang chạy.
            var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice), NewLevel(2, bob.Id)]);
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            await act.Should().ThrowAsync<ConflictException>().WithMessage("*không được thêm Cấp mới*");

            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Steps.Single().Levels.Should().HaveCount(1, "409 rồi thì DB phải y nguyên");
            live.Steps.Single().Levels.Single().ApproverUserId.Should().Be(alice.Id);
        }
    }

    [Fact]
    public async Task F13_RemoveWholeLevel_Rejected409_AndWorkflowUnchanged()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id), (2, carol.Id)]]);
            var lvlAlice = wf.Steps.Single().Levels.Single(l => l.Order == 1);

            // Bỏ TOÀN BỘ Cấp 2 ⇒ phiếu đang chờ ở Cấp 2 sẽ KẸT.
            var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            await act.Should().ThrowAsync<ConflictException>().WithMessage("*KHÔNG CÒN người duyệt*");

            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Steps.Single().Levels.Select(l => l.Order).Should().BeEquivalentTo(new[] { 1, 2 });
        }
    }

    [Fact]
    public async Task F13_MovePersonBetweenLevelOrders_Rejected409_AndWorkflowUnchanged()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id), (2, carol.Id)]]);
            var step = wf.Steps.Single();
            var lvlAlice = step.Levels.Single(l => l.Order == 1);
            var lvlCarol = step.Levels.Single(l => l.Order == 2);

            // Hoán vị Cấp giữa 2 dòng: TẬP {1,2} GIỮ NGUYÊN nên set-equality một
            // mình sẽ cho qua — chốt chặn thật là "Order của TỪNG dòng bất biến".
            var cmd = WithLevels(BuildEchoCommand(wf), 0,
                [EchoLevel(lvlAlice) with { Order = 2 }, EchoLevel(lvlCarol) with { Order = 1 }]);
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            await act.Should().ThrowAsync<ConflictException>().WithMessage("*không được chuyển người duyệt từ Cấp*");

            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id).Order.Should().Be(1);
            live.Steps.Single().Levels.Single(l => l.ApproverUserId == carol.Id).Order.Should().Be(2);
        }
    }

    [Fact]
    public async Task F13_SwapStepOrders_KeepsOrderSet_ButStillRejected409()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id)], [(1, carol.Id)]]);

            // Đảo Order 2 Bước: tập {Step.Order} = {1,2} KHÔNG đổi ⇒ phép thử
            // set-equality "khung không đổi" theo nghĩa yếu sẽ cho lọt, nhưng con
            // trỏ phiếu là INDEX vào list đã sort nên nó trỏ sang Bước KHÁC, ÂM THẦM.
            var echo = BuildEchoCommand(wf);
            var swapped = new List<UpdateAwStepInput>
            {
                echo.Steps[0] with { Order = 2 },
                echo.Steps[1] with { Order = 1 },
            };
            var act = async () => await NewHandler(h, admin).Handle(echo with { Steps = swapped }, CancellationToken.None);

            await act.Should().ThrowAsync<ConflictException>().WithMessage("*không được đổi thứ tự Bước*");

            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Steps.Single(s => s.Levels.Any(l => l.ApproverUserId == alice.Id)).Order.Should().Be(1);
            live.Steps.Single(s => s.Levels.Any(l => l.ApproverUserId == carol.Id)).Order.Should().Be(2);
        }
    }

    // =====================================================================
    // F-13b (T2) — 2 trường owner xếp PHÁ VỠ: CeoApprovalThreshold + F5.
    // =====================================================================
    [Fact]
    public async Task F13b_ChangeCeoApprovalThreshold_Rejected409_AndValueUnchanged()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id)]], ceoThreshold: 500_000_000m);

            var cmd = BuildEchoCommand(wf) with { CeoApprovalThreshold = 900_000_000m };
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            await act.Should().ThrowAsync<ConflictException>().WithMessage("*Ngưỡng giá trị gói CEO*");

            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.CeoApprovalThreshold.Should().Be(500_000_000m);
        }
    }

    // Vế IM LẶNG của cùng luật: client KHÔNG khai báo ngưỡng (null) trong khi DB
    // đang có ngưỡng. Nếu handler đọc null là "bỏ qua" thì admin xoá trắng ô ngưỡng
    // sẽ nhận 204 mà số không đổi — sai im lặng, đúng lớp lỗi spec đang đi chữa.
    [Fact]
    public async Task F13b_OmitCeoApprovalThreshold_WhenStored_Rejected409_NoSilentNoop()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id)]], ceoThreshold: 500_000_000m);

            var cmd = BuildEchoCommand(wf) with { CeoApprovalThreshold = null, Name = "Tên mới" };
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            await act.Should().ThrowAsync<ConflictException>().WithMessage("*Ngưỡng giá trị gói CEO*");

            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.CeoApprovalThreshold.Should().Be(500_000_000m);
            live.Name.Should().Be("QT test sửa tại chỗ", "409 thì KHÔNG phần nào của lệnh được ghi");
        }
    }

    [Fact]
    public async Task F13b_ToggleAllowApproverFinalize_Rejected409_AndFlagUnchanged()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id)]]);
            var lvl = wf.Steps.Single().Levels.Single();

            // F5 = ĐỔI ĐIỂM KẾT THÚC của phiếu (Phase=DaDuyet + đóng băng số tiền
            // ngay lúc duyệt) ⇒ không phải "quyền tại chỗ".
            var cmd = WithLevels(BuildEchoCommand(wf), 0,
                [EchoLevel(lvl) with { AllowApproverFinalize = true }]);
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            await act.Should().ThrowAsync<ConflictException>().WithMessage("*Duyệt là kết thúc*");

            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Steps.Single().Levels.Single().AllowApproverFinalize.Should().BeFalse();
        }
    }

    // Nửa còn lại của "ECHO-only, KHÔNG BAO GIỜ ghi": khi client bỏ trống 2 cờ
    // đổi-đường-đi thì giá trị đang lưu phải GIỮ NGUYÊN. Đây mới là chốt chặn hồi
    // quy thật — nếu ai đó sau này viết `db.X = req.X ?? false` thì F5 của người
    // đang giữ quyền kết-thúc bị TẮT âm thầm qua một lần đổi tên vô hại.
    [Fact]
    public async Task F13b_OmittedFinalizeEcho_PreservesStoredFlags_NotSilentlyCleared()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id)]]);
            var lvl = wf.Steps.Single().Levels.Single();
            lvl.AllowApproverFinalize = true;
            lvl.AllowApproverSkipToFinal = true;
            await h.Db.SaveChangesAsync(CancellationToken.None);

            // Lệnh hợp lệ, chỉ đổi tên — 2 cờ để null (không khai báo).
            var cmd = BuildEchoCommand(wf) with { Name = "Tên quy trình đã đổi" };
            await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Name.Should().Be("Tên quy trình đã đổi");
            var liveLevel = live.Steps.Single().Levels.Single();
            liveLevel.AllowApproverFinalize.Should().BeTrue("cờ đổi-đường-đi là echo-only, không được ghi đè bằng null");
            liveLevel.AllowApproverSkipToFinal.Should().BeTrue();
        }
    }

    // =====================================================================
    // F-14 — bớt người khỏi Cấp mà dòng đó ĐÃ CÓ chữ ký ⇒ chặn LỊCH SỰ (409),
    // KHÔNG để nổ FK 547 → 500.
    // Kèm CHỨNG-NHÂN: cuối test tự tay xoá dòng đó ⇒ DB nổ thật. Không có đoạn
    // này thì assert "409" chỉ chứng minh handler biết ném, chứ không chứng minh
    // nó đang đỡ một quả nổ có thật.
    // =====================================================================
    [Fact]
    public async Task F14_RemoveApproverWithLiveOpinion_BlockedPolitely_NotFkViolation()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Lê Văn Bình", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);

            // Cấp 1 có 2 người ⇒ bỏ bob KHÔNG làm Cấp rỗng (tách bạch với F-13).
            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id), (1, bob.Id)]]);
            var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
            var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);

            var pe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F14-001");
            h.Db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
            {
                PurchaseEvaluationId = pe.Id,
                ApprovalWorkflowLevelId = lvlBob.Id,
                Comment = "Đồng ý",
                SignedAt = h.Clock.UtcNow,
                SignedByUserId = bob.Id,
                SignedByFullName = "Lê Văn Bình",
            });
            await h.Db.SaveChangesAsync(CancellationToken.None);

            var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            var ex = (await act.Should().ThrowAsync<ConflictException>(
                "chữ ký trên phiếu CÒN SỐNG phải chặn việc bỏ người, và chặn LỊCH SỰ")).Which;
            ex.Message.Should().Contain("chữ ký");

            // DB nguyên vẹn: cả dòng Cấp lẫn chữ ký đều còn.
            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Steps.Single().Levels.Should().HaveCount(2);
            (await h.Db.PurchaseEvaluationLevelOpinions.AsNoTracking()
                .CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id)).Should().Be(1);

            // CHỨNG-NHÂN: nếu không có guard thì đây là thứ người dùng nhận (FK Restrict).
            // 🔴 Phải dọn change-tracker TRƯỚC: chữ ký ở trên do CHÍNH test `Add` vào cùng
            // context nên nó vẫn đang được theo dõi. Khi dependent còn tracked, `Remove(level)`
            // kích cascade-check CLIENT-SIDE của EF (CascadeDeleteTiming.Immediate = mặc định)
            // và ném InvalidOperationException("...has been severed...") NGAY TẠI `Remove` —
            // chưa từng chạm DB ⇒ dòng assert bên dưới KHÔNG BAO GIỜ được chấm. Dọn tracker
            // để chứng-nhân đo ĐÚNG cái tầng nó khai: ràng buộc FK ở DB.
            // (Prod không dính: context mỗi request không nạp opinion nào, guard đọc chữ ký
            //  bằng AsNoTracking ⇒ tới `Remove` là tracker vốn đã sạch.)
            h.Db.ChangeTracker.Clear();
            h.Db.ApprovalWorkflowLevels.Remove(lvlBob);
            var raw = async () => await h.Db.SaveChangesAsync(CancellationToken.None);
            await raw.Should().ThrowAsync<DbUpdateException>(
                "FK Restrict LevelOpinion→Level là thật — guard 409 đang đỡ đúng quả nổ này");
        }
    }

    // 🔴 F-2 (reviewer đợt 1) — TIÊU CHÍ NGHIỆM THU #1. Cùng RUỘT với test trên,
    // khác ĐÚNG MỘT BIT: phiếu mang chữ ký đó đã bị xoá MỀM.
    //
    // ĐẢO SO VỚI BẢN TRƯỚC (bản trước tên `..._RemovalAllowed_AndOrphanPurged`, khẳng
    // định "cho gỡ + xoá cứng chữ ký"). Vì sao đảo — không phải nới/siết cho hợp code:
    //   xoá mềm là KHÔI PHỤC ĐƯỢC và §A spec S155 có màn "Đã xoá" phải đọc được lịch
    //   sử ký. Xoá cứng chữ ký của phiếu xoá mềm = phiếu khôi phục lên mất trắng lịch
    //   sử duyệt, IM LẶNG, không dấu vết (changelog cũ chỉ ghi một con SỐ ĐẾM, không
    //   ghi phiếu nào). Đó là đúng class lỗi mà cả spec S155 đang đi chữa.
    // "Bị chặn OAN" (lý lẽ cũ) không phải là mất mát: admin còn 2 đường thoát rõ ràng
    // (khôi phục phiếu rồi xử lý, hoặc xoá HẲN phiếu) — cả hai đều hoàn tác được.
    [Fact]
    public async Task F2_OpinionBelongsToSoftDeletedPe_BlockedPolitely_AndSignatureKept()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Bình", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);

            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id), (1, bob.Id)]]);
            var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
            var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);

            var deletedPe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F14b-001", isDeleted: true);
            h.Db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
            {
                PurchaseEvaluationId = deletedPe.Id,
                ApprovalWorkflowLevelId = lvlBob.Id,
                Comment = "Đồng ý",
                SignedAt = h.Clock.UtcNow,
                SignedByUserId = bob.Id,
                SignedByFullName = "Bình",
            });
            await h.Db.SaveChangesAsync(CancellationToken.None);

            var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            var ex = (await act.Should().ThrowAsync<ConflictException>(
                "chữ ký của phiếu XOÁ MỀM vẫn là lịch sử duyệt khôi phục được")).Which;
            // Thông điệp phải NÊU TÊN phiếu đang giữ chữ ký — không thì admin không biết gỡ gì.
            ex.Message.Should().Contain("PE-F14b-001").And.Contain("đã xoá");

            // 🔴 Điều F-2 đòi: chữ ký VẪN CÒN. Đọc bằng IgnoreQueryFilters để không bị
            // chính global filter che mất row (đọc thiếu sẽ trông y hệt "đã bị xoá").
            (await h.Db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
                .CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id)).Should().Be(1);

            // Và quy trình cũng không bị sứt: cả 2 dòng Cấp còn nguyên.
            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Steps.Single().Levels.Should().HaveCount(2);
        }
    }

    // 🔴 F-2 — TIÊU CHÍ NGHIỆM THU #2, vế thứ HAI của điều kiện purge cũ (`o.IsDeleted`):
    // ý kiến TỰ NÓ xoá mềm, trên phiếu CÒN SỐNG. Đó là bản ghi KIỂM TOÁN của tài liệu
    // đang hiệu lực — bản trước đem xoá CỨNG luôn. Cặp cùng-ruột-khác-1-bit với test
    // trên: ở đó cha xoá mềm / ý kiến sống, ở đây cha sống / ý kiến xoá mềm.
    [Fact]
    public async Task F2_SoftDeletedOpinionOnLivePe_NotHardPurged_BlockedAndKept()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Bình", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);

            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id), (1, bob.Id)]]);
            var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
            var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);

            // Phiếu CÒN SỐNG (không isDeleted) — chỉ ý kiến bị thu hồi (xoá mềm).
            var livePe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F2b-001");
            h.Db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
            {
                PurchaseEvaluationId = livePe.Id,
                ApprovalWorkflowLevelId = lvlBob.Id,
                Comment = "Đồng ý (sau đó thu hồi)",
                SignedAt = h.Clock.UtcNow,
                SignedByUserId = bob.Id,
                SignedByFullName = "Bình",
                IsDeleted = true,
                DeletedAt = new DateTime(2026, 7, 26, 0, 0, 0, DateTimeKind.Utc),
            });
            await h.Db.SaveChangesAsync(CancellationToken.None);

            // Tầng 1 (`CountLiveOpinionsAsync`) CỐ TÌNH bỏ qua row này (`!o.IsDeleted`)
            // ⇒ nếu tầng 2 không tồn tại thì đây rơi thẳng vào purge và mất vĩnh viễn.
            var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            var ex = (await act.Should().ThrowAsync<ConflictException>(
                "ý kiến đã thu hồi trên phiếu đang hiệu lực là bản ghi kiểm toán")).Which;
            ex.Message.Should().Contain("PE-F2b-001").And.Contain("thu hồi");

            (await h.Db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
                .CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id)).Should().Be(1);
        }
    }

    // 🔴 F-1 (reviewer đợt 1) — TIÊU CHÍ NGHIỆM THU: purge (`ExecuteDelete`) auto-commit
    // NGAY khi không có transaction ambient, mà `SaveChangesAsync` thì mãi cuối method.
    // Ném ở khoảng GIỮA ⇒ mọi thứ sau rollback, chữ ký đã xoá cứng thì KHÔNG quay lại.
    //
    // Dựng lại đúng hình đó, KHÔNG mock:
    //   (1) chữ ký MỒ CÔI THẬT — phiếu cha đã không còn row. Không dựng được bằng đường
    //       ứng dụng (FK cha→opinion là Cascade: xoá cứng cha thì chữ ký đi theo), nên
    //       tắt `PRAGMA foreign_keys` đúng một câu DELETE để mô phỏng dữ liệu lệch sinh
    //       ngoài luồng — chính thứ mà purge tồn tại để dọn.
    //   (2) BẪY ném SAU purge: một changelog trỏ vào phiếu không tồn tại, gài sẵn trong
    //       change-tracker ⇒ `SaveChangesAsync` cuối method nổ FK. Hiện thực đúng luồng
    //       thật: context là scoped-theo-request, handler khác trong cùng request có thể
    //       để lại thay đổi đang chờ.
    // Nếu rào giao dịch KHÔNG có: purge đã commit ⇒ assert dưới thấy 0 ⇒ đỏ.
    [Fact]
    public async Task F1_SaveChangesThrowsAfterPurge_RollsBackWholeCommand_OrphanSignaturesKept()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Bình", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);

            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id), (1, bob.Id)]]);
            var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
            var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);

            // Phiếu SỐNG pin quy trình này ⇒ nhánh ghi changelog (F-1c) có việc để làm,
            // nên `SaveChanges` cuối method thật sự được gọi.
            await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1-LIVE");

            var ghostPe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1-GHOST");
            h.Db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
            {
                PurchaseEvaluationId = ghostPe.Id,
                ApprovalWorkflowLevelId = lvlBob.Id,
                Comment = "Đồng ý",
                SignedAt = h.Clock.UtcNow,
                SignedByUserId = bob.Id,
                SignedByFullName = "Bình",
            });
            await h.Db.SaveChangesAsync(CancellationToken.None);

            // (1) Xoá HẲN phiếu cha nhưng chặn cascade ⇒ còn lại chữ ký MỒ CÔI THẬT.
            await h.Db.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF;");
            await h.Db.Database.ExecuteSqlRawAsync(
                "DELETE FROM PurchaseEvaluations WHERE Id = {0}", ghostPe.Id);
            await h.Db.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = ON;");
            h.Db.ChangeTracker.Clear();     // tracker vẫn giữ bản sao — dọn cho khớp DB

            (await h.Db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
                .CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id))
                .Should().Be(1, "tiền đề: phải có đúng 1 chữ ký mồ côi cho purge nhắm tới");

            // (2) BẪY — sẽ nổ FK tại `SaveChangesAsync`, tức SAU khi purge đã chạy.
            h.Db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
            {
                PurchaseEvaluationId = Guid.NewGuid(),      // phiếu KHÔNG tồn tại
                EntityType = PurchaseEvaluationEntityType.Workflow,
                Action = ChangelogAction.Update,
                UserId = admin.Id,
                UserName = "Quản trị",
                Summary = "Bẫy: ép SaveChanges ném SAU khi purge đã chạy",
            });

            var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
            await act.Should().ThrowAsync<DbUpdateException>(
                "bẫy phải nổ ở SaveChanges — nếu không thì test này không đo được cái nó khai");

            // 🔴 Điều F-1 đòi: số row chữ ký KHÔNG ĐỔI sau lệnh thất bại.
            h.Db.ChangeTracker.Clear();
            (await h.Db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
                .CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id))
                .Should().Be(1, "purge phải cùng rollback với SaveChanges, không auto-commit riêng");

            // Và phần còn lại của lệnh cũng rollback trọn: dòng Cấp chưa bị gỡ.
            (await h.Db.ApprovalWorkflowLevels.AsNoTracking()
                .CountAsync(l => l.Id == lvlBob.Id)).Should().Be(1);
            (await h.Db.PurchaseEvaluationChangelogs.AsNoTracking().CountAsync()).Should().Be(0);
        }
    }

    // =====================================================================
    // F-15 (NỬA TRƯỚC) — bật cờ F6 `AllowApproverDelete` bằng Update trên
    // workflow đang có phiếu ChoDuyet treo ⇒ cờ ĐỌC ĐƯỢC từ phía phiếu đang chạy.
    //
    // 🔴 GIỚI HẠN CỐ Ý: KHÔNG test hành vi XOÁ phiếu. Nút xoá ở màn duyệt +
    // endpoint xoá-khi-đang-duyệt thuộc ĐỢT 2 và CHƯA TỒN TẠI trên đĩa lúc viết
    // test này. Ở đây chỉ chứng minh mắt xích §F.0: cờ tick hôm nay TỚI ĐƯỢC
    // phiếu treo từ hôm qua (điều mà POST-tạo-version-mới không làm được).
    // =====================================================================
    [Fact]
    public async Task F15_EnableAllowApproverDeleteViaUpdate_FlagReachesAlreadyPendingPe_DeleteBehaviorIsWave2()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
            var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);

            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id), (2, carol.Id)]]);
            // Phiếu treo TỪ TRƯỚC khi admin tick cờ.
            var pe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F15-001");

            var before = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            before.Steps[0].Levels.Single(l => l.ApproverUserId == alice.Id)
                .AllowApproverDelete.Should().BeFalse("nền: cờ F6 mặc định tắt");

            var step = wf.Steps.Single();
            var lvlAlice = step.Levels.Single(l => l.ApproverUserId == alice.Id);
            var lvlCarol = step.Levels.Single(l => l.ApproverUserId == carol.Id);
            var cmd = WithLevels(BuildEchoCommand(wf, reason: "Bấm sai gói thầu — cần cho phép xoá"), 0,
                [EchoLevel(lvlAlice) with { AllowApproverDelete = true }, EchoLevel(lvlCarol)]);

            await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            // Đọc ĐÚNG như phiếu đang chạy đọc: theo Id đã pin + con trỏ (Bước, Cấp).
            var peRow = await h.Db.PurchaseEvaluations.AsNoTracking().FirstAsync(p => p.Id == pe.Id);
            var live = await ResolveLikeRunningPeAsync(h.Db, peRow.ApprovalWorkflowId!.Value);
            var currentStep = live.Steps.OrderBy(s => s.Order).ToList()[peRow.CurrentWorkflowStepIndex!.Value];
            var matchingRow = currentStep.Levels
                .Single(l => l.Order == peRow.CurrentApprovalLevelOrder && l.ApproverUserId == alice.Id);

            matchingRow.AllowApproverDelete.Should().BeTrue(
                "§F.0: chỉ sửa-tại-chỗ mới đưa được cờ tới phiếu đang treo — POST version mới sinh Id khác");
            live.Version.Should().Be(1, "phiếu vẫn đọc ĐÚNG bản đang pin, không phải bản mới");

            // Per-ROW chứ không per-Cấp: người khác trong quy trình KHÔNG tự có cờ.
            live.Steps[0].Levels.Single(l => l.ApproverUserId == carol.Id)
                .AllowApproverDelete.Should().BeFalse();
        }
    }

    // =====================================================================
    // F-16 — blast radius: `ApprovalWorkflow` V2 dùng chung ≥6 module. Nếu guard
    // chữ ký chỉ soi bảng PE thì quy trình của Văn phòng số vẫn nổ FK 547 → 500.
    // =====================================================================
    [Fact]
    public async Task F16_ProposalWorkflow_RemoveApproverWithLiveProposalOpinion_Blocked409()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Bình", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);

            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.ProposalGeneral,
                [[(1, alice.Id), (1, bob.Id)]], code: "QT-DX-V2");
            var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
            var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);

            var proposal = new Proposal
            {
                Id = Guid.NewGuid(),
                Title = "Đề xuất mua máy tính",
                Status = ProposalStatus.DaGuiDuyet,
                DrafterUserId = Guid.NewGuid(),
                ApprovalWorkflowId = wf.Id,
                CurrentApprovalLevelOrder = 1,
            };
            h.Db.Proposals.Add(proposal);
            h.Db.ProposalLevelOpinions.Add(new ProposalLevelOpinion
            {
                ProposalId = proposal.Id,
                ApprovalWorkflowLevelId = lvlBob.Id,
                Comment = "Đồng ý",
                SignedAt = h.Clock.UtcNow,
                SignedByUserId = bob.Id,
                SignedByFullName = "Bình",
            });
            await h.Db.SaveChangesAsync(CancellationToken.None);

            var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
            var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            await act.Should().ThrowAsync<ConflictException>(
                "chữ ký của module Đề xuất cũng Restrict FK y như PE — guard phải quét CẢ 7 bảng opinion");

            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Steps.Single().Levels.Should().HaveCount(2);
        }
    }

    [Fact]
    public async Task F16_ProposalWorkflow_SafeUpdate_Succeeds_AndWritesNoPeChangelog()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);

            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.ProposalGeneral,
                [[(1, alice.Id)]], code: "QT-DX-V2");
            var proposal = new Proposal
            {
                Id = Guid.NewGuid(),
                Title = "Đề xuất tăng ngân sách",
                Status = ProposalStatus.DaGuiDuyet,
                DrafterUserId = Guid.NewGuid(),
                ApprovalWorkflowId = wf.Id,
                CurrentApprovalLevelOrder = 1,
            };
            h.Db.Proposals.Add(proposal);
            await h.Db.SaveChangesAsync(CancellationToken.None);

            // 🔴 CHỨNG-NHÂN cho cổng module (vá F11 — reviewer bắt ca này XANH GIẢ).
            // Nếu KHÔNG ghim phiếu PE nào vào workflow này thì `affected` rỗng ở CẢ HAI
            // thế giới — còn cổng `def.ApplicableType is DuyetNcc or DuyetNccPhuongAn`
            // hay gỡ cổng đi — nên assert `Count == 0` bên dưới luôn đúng và KHÔNG đo gì.
            // Ghim 1 phiếu PE (phase ChoDuyet = "chưa xong" ⇒ lọt truy vấn `affected`)
            // vào đúng workflow module Đề xuất này thì phép đo mới phân biệt được:
            // gỡ cổng ⇒ handler ghi changelog PE ⇒ ca này ĐỎ.
            var peGateWitness = await SeedPeAsync(h.Db, wf.Id, admin.Id, "PE-F16-GATE");

            var lvlAlice = wf.Steps.Single().Levels.Single();
            var cmd = WithLevels(BuildEchoCommand(wf, reason: "Bổ sung người duyệt"), 0,
                [EchoLevel(lvlAlice), NewLevel(1, carol.Id)]);

            await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
            live.Steps.Single().Levels.Select(l => l.ApproverUserId)
                .Should().BeEquivalentTo(new[] { alice.Id, carol.Id });
            live.ApplicableType.Should().Be(ApprovalWorkflowApplicableType.ProposalGeneral);

            // Phép đo nay CÓ RĂNG: có đúng 1 phiếu PE ChoDuyet ghim workflow này, nên
            // nếu cổng module bị gỡ thì handler sẽ ghi vết cho nó ⇒ count = 1 ⇒ ĐỎ.
            (await h.Db.PurchaseEvaluations.AsNoTracking()
                .CountAsync(p => p.ApprovalWorkflowId == wf.Id && p.Phase == PurchaseEvaluationPhase.ChoDuyet))
                .Should().Be(1, "chứng-nhân phải tồn tại, nếu không assert dưới là rỗng nghĩa");
            (await h.Db.PurchaseEvaluationChangelogs.AsNoTracking().CountAsync())
                .Should().Be(0, "quy trình module Đề xuất KHÔNG được đẻ vết vào changelog của PE");
            peGateWitness.Should().NotBeNull();

            var proposalRow = await h.Db.Proposals.AsNoTracking().FirstAsync(p => p.Id == proposal.Id);
            proposalRow.ApprovalWorkflowId.Should().Be(wf.Id);
        }
    }

    // =====================================================================
    // F-1c — GHI VẾT. Spec đòi changelog khi xoá phiếu (§B4) nhưng ban đầu quên
    // đòi vết cho việc SỬA QUYỀN — bất đối xứng, review bắt. Vết phải rơi đúng
    // phiếu ĐANG CHỊU ẢNH HƯỞNG (chưa kết thúc), không rải cho phiếu đã đóng.
    // =====================================================================
    [Fact]
    public async Task F1c_SafeUpdate_LogsChangelogWithReason_OnlyToUnfinishedPes()
    {
        var h = NewHarness();
        using (h.Fix)
        {
            var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
            var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Lê Văn Bình", null, []);
            var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị hệ thống", null, [AppRoles.Admin]);
            var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);

            var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
                [[(1, alice.Id)]]);
            var pending = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1c-PENDING");
            var approved = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1c-DADUYET",
                phase: PurchaseEvaluationPhase.DaDuyet);
            var rejected = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1c-TUCHOI",
                phase: PurchaseEvaluationPhase.TuChoi);

            var lvlAlice = wf.Steps.Single().Levels.Single();
            var cmd = WithLevels(BuildEchoCommand(wf, reason: "Bấm sai gói thầu"), 0,
                [EchoLevel(lvlAlice), NewLevel(1, bob.Id)]);

            await NewHandler(h, admin).Handle(cmd, CancellationToken.None);

            var logs = await h.Db.PurchaseEvaluationChangelogs.AsNoTracking().ToListAsync();
            logs.Select(l => l.PurchaseEvaluationId).Should().BeEquivalentTo(new[] { pending.Id },
                $"chỉ phiếu chưa kết thúc mới chịu ảnh hưởng (DaDuyet {approved.MaPhieu} + TuChoi {rejected.MaPhieu} phải bị loại)");

            var log = logs.Single();
            log.Action.Should().Be(ChangelogAction.Update);
            log.EntityType.Should().Be(PurchaseEvaluationEntityType.Workflow);
            log.EntityId.Should().Be(wf.Id);
            log.PhaseAtChange.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
            log.UserId.Should().Be(admin.Id);
            log.UserName.Should().Be("Quản trị hệ thống");
            log.Summary.Should().Contain("giữ nguyên phiên bản");
            log.ContextNote.Should().Contain("Lý do: Bấm sai gói thầu");
            log.ContextNote.Should().Contain("THÊM NV duyệt Lê Văn Bình",
                "vết phải đọc được bằng TÊN, không phải GUID");
        }
    }
}
