using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.PurchaseEvaluations;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Domain.Master.Catalogs;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
using SolutionErp.Infrastructure.Tests.Services;   // NoOpNotificationService (reuse internal helper)

namespace SolutionErp.Infrastructure.Tests.Application;

// [S61 Mig 50] "Ngân sách PE theo Excel anh Kiệt" — thay module Budget cũ (5 bảng
// drop cùng Mig 50). Entity mới PeWorkItemBudget = 1 record/cặp (ProjectId,
// WorkItemId) loose-Guid, UNIQUE composite FILTERED [IsDeleted]=0 (gotcha #57 day-1).
//
// Test guard theo CODE đã land (single source of truth — S34 rule). 6 nhóm:
//   1. Auto-create on PE create (CreatePurchaseEvaluationCommandHandler ~:96-129)
//   2. EnsureTrackedAsync helper (idempotent + soft-deleted slot → tạo MỚI được)
//   3. UpdatePeBudgetPro authz matrix (Procurement|Admin fail-closed TRƯỚC side-effect)
//   4. UpdatePeBudgetCcm authz matrix (CostControl|Admin, Adjustment cho phép ÂM)
//   5. budgetSummary aggregates (GetPurchaseEvaluationQueryHandler ~:762-829)
//   6. AdjustBudget mới (absolute-set 2 field + validator >0 / -≥0 when HasValue)
//
// Pattern reuse: IdentityFixture + TestApplicationDbContext (SQLite) +
// FixedDateTime + FakeCurrentUser (mirror PeWorkItemGuardTests S57bis +
// ItTicketReassignAuthzTests S54). GUARD-FIRST seed: test fail đúng lý do.
//
// LƯU Ý SOFT-DELETE TRONG TEST: AuditingInterceptor (prod soft-delete) KHÔNG wire
// trong TestApplicationDbContext → Remove = HARD delete. Để test slot soft-deleted
// (test nhóm 2) SEED row IsDeleted=true thủ công. Filtered index [IsDeleted]=0 đã
// có day-1 (PeWorkItemBudgetConfiguration.cs:24-26) nên reuse slot hoạt động —
// KHÁC gotcha #57 RED của MasterCatalog (bare .IsUnique()).
public class PeWorkItemBudgetTests
{
    // ICurrentUser fake với Roles configurable (matrix PRO / CCM / Admin / none).
    private sealed class FakeCurrentUser : ICurrentUser
    {
        public Guid? UserId { get; init; } = Guid.NewGuid();
        public string? Email { get; init; } = "actor@test.local";
        public string? FullName { get; init; } = "Actor Test";
        public IReadOnlyList<string> Roles { get; init; } = Array.Empty<string>();
        public bool IsAuthenticated => UserId is not null;
    }

    private static FakeCurrentUser AsRoles(params string[] roles)
        => new() { Roles = roles };

    // ---- shared seed helpers ----

    private static async Task<Project> SeedProjectAsync(TestApplicationDbContext db, string code = "PRJ-BG")
    {
        var p = new Project { Id = Guid.NewGuid(), Code = code, Name = "Dự án ngân sách " + code };
        db.Projects.Add(p);
        await db.SaveChangesAsync(CancellationToken.None);
        return p;
    }

    private static async Task<WorkItem> SeedWorkItemAsync(
        TestApplicationDbContext db, string code, bool isActive = true)
    {
        var wi = new WorkItem
        {
            Id = Guid.NewGuid(),
            Code = code,
            Name = "Hạng mục " + code,
            IsActive = isActive,
        };
        db.WorkItems.Add(wi);
        await db.SaveChangesAsync(CancellationToken.None);
        return wi;
    }

    // Create handler stack đầy đủ (db + ICurrentUser + workflow svc + codeGen).
    private static CreatePurchaseEvaluationCommandHandler BuildCreateHandler(
        TestApplicationDbContext db, UserManager<User> um, ICurrentUser currentUser)
    {
        var dt = new FixedDateTime(new DateTime(2026, 6, 13, 0, 0, 0, DateTimeKind.Utc));
        var notify = new NoOpNotificationService();
        var workflow = new PurchaseEvaluationWorkflowService(db, dt, notify, um);
        var codeGen = new PurchaseEvaluationCodeGenerator(db, dt);
        return new CreatePurchaseEvaluationCommandHandler(db, currentUser, workflow, codeGen);
    }

    private static CreatePurchaseEvaluationCommand BuildCreateCommand(Guid projectId, Guid? workItemId)
        => new(
            Type: PurchaseEvaluationType.DuyetNcc,
            TenGoiThau: "Gói thầu test",
            ProjectId: projectId,
            DepartmentId: null,
            DiaDiem: null,
            MoTa: null,
            PaymentTerms: null,
            BudgetPeriodAmount: null,
            ApprovalWorkflowId: null,
            WorkItemId: workItemId);

    // Phiếu Nháp có gắn WorkItemId — dựng thủ công (cho test Pro/Ccm/Adjust/Summary).
    private static async Task<PurchaseEvaluation> SeedPeAsync(
        TestApplicationDbContext db,
        Guid projectId,
        Guid? workItemId,
        PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.DangSoanThao,
        Guid? drafterUserId = null,
        decimal? budgetPeriodAmount = null,
        Guid? selectedSupplierId = null,
        DateTime? createdAt = null,
        string code = "PE-BG")
    {
        var pe = new PurchaseEvaluation
        {
            Id = Guid.NewGuid(),
            Type = PurchaseEvaluationType.DuyetNcc,
            Phase = phase,
            MaPhieu = code,
            TenGoiThau = "Gói thầu gốc",
            ProjectId = projectId,
            WorkItemId = workItemId,
            DrafterUserId = drafterUserId ?? Guid.NewGuid(),
            BudgetPeriodAmount = budgetPeriodAmount,
            SelectedSupplierId = selectedSupplierId,
        };
        db.PurchaseEvaluations.Add(pe);
        await db.SaveChangesAsync(CancellationToken.None);

        // CreatedAt do BaseEntity/DB default set — test summary cần kiểm soát thứ tự.
        // TestApplicationDbContext KHÔNG wire AuditingInterceptor → set sau + Save lại
        // để override giá trị default (lũy kế dùng CreatedAt < this).
        if (createdAt is DateTime ca)
        {
            pe.CreatedAt = ca;
            await db.SaveChangesAsync(CancellationToken.None);
        }
        return pe;
    }

    // =====================================================================
    // 1. AUTO-CREATE ON PE CREATE
    // =====================================================================

    [Fact]
    public async Task Create_WithWorkItem_AutoCreatesExactlyOnePeWorkItemBudget()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var um = fix.Services.GetRequiredService<UserManager<User>>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-AC1");
        var handler = BuildCreateHandler(db, um, AsRoles(AppRoles.Drafter));

        await handler.Handle(BuildCreateCommand(project.Id, wi.Id), CancellationToken.None);

        var recs = await db.PeWorkItemBudgets
            .Where(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id).ToListAsync();
        recs.Should().HaveCount(1, "tạo phiếu đầu của cặp → auto-create đúng 1 record");
    }

    [Fact]
    public async Task Create_SecondPeSamePair_StillOnlyOneBudgetRecord()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var um = fix.Services.GetRequiredService<UserManager<User>>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-AC2");
        var handler = BuildCreateHandler(db, um, AsRoles(AppRoles.Drafter));

        await handler.Handle(BuildCreateCommand(project.Id, wi.Id), CancellationToken.None);
        // Phiếu THỨ 2 cùng cặp (ProjectId, WorkItemId) → pre-check exists → KHÔNG thêm record.
        await handler.Handle(BuildCreateCommand(project.Id, wi.Id), CancellationToken.None);

        var recs = await db.PeWorkItemBudgets
            .Where(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id).ToListAsync();
        recs.Should().HaveCount(1, "1 record DUY NHẤT per cặp dùng chung mọi phiếu cùng gói thầu");
    }

    [Fact]
    public async Task Create_TwoDistinctPairs_CreatesTwoBudgetRecords()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var um = fix.Services.GetRequiredService<UserManager<User>>();
        var project = await SeedProjectAsync(db);
        var wiA = await SeedWorkItemAsync(db, "WI-ACa");
        var wiB = await SeedWorkItemAsync(db, "WI-ACb");
        var handler = BuildCreateHandler(db, um, AsRoles(AppRoles.Drafter));

        await handler.Handle(BuildCreateCommand(project.Id, wiA.Id), CancellationToken.None);
        await handler.Handle(BuildCreateCommand(project.Id, wiB.Id), CancellationToken.None);

        (await db.PeWorkItemBudgets.CountAsync(b => b.ProjectId == project.Id))
            .Should().Be(2, "2 cặp khác hạng mục → 2 record độc lập");
    }

    // =====================================================================
    // 2. EnsureTrackedAsync helper (internal — gọi qua handler PUT pro)
    //    Idempotent + soft-deleted slot → tạo record MỚI được (filtered index).
    // =====================================================================

    [Fact]
    public async Task EnsurePair_CalledTwiceSamePair_ReturnsSameRecordId()
    {
        // EnsureTrackedAsync internal → drive qua UpdatePeBudgetProCommandHandler 2 lần
        // (Admin role). Lần 2 PHẢI tái dùng record lần 1 (cùng Id), KHÔNG tạo thêm.
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-ENS1");
        var pe = await SeedPeAsync(db, project.Id, wi.Id);
        var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Admin));

        await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 100m, null, "lần 1"), CancellationToken.None);
        var firstId = (await db.PeWorkItemBudgets
            .SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id)).Id;

        await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 200m, null, "lần 2"), CancellationToken.None);

        var recs = await db.PeWorkItemBudgets
            .Where(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id).ToListAsync();
        recs.Should().ContainSingle("EnsureTrackedAsync idempotent — 2 lần cùng cặp KHÔNG nhân đôi record");
        recs[0].Id.Should().Be(firstId, "cùng record Id qua 2 lần gọi");
        recs[0].ProInitialAmount.Should().Be(200m, "lần 2 absolute-set đè giá trị mới (handler set ProInitialAmount)");
    }

    [Fact]
    public async Task EnsurePair_SoftDeletedRecordExists_CreatesNewActiveRecord()
    {
        // Slot (Project, WorkItem) đang bị 1 record SOFT-DELETED (IsDeleted=true) chiếm.
        // Filtered unique [IsDeleted]=0 + HasQueryFilter(!IsDeleted) → exists-check bỏ
        // qua deleted → EnsureTrackedAsync tạo record MỚI active được (KHÔNG đụng record cũ).
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-ENS2");

        db.PeWorkItemBudgets.Add(new PeWorkItemBudget
        {
            Id = Guid.NewGuid(),
            ProjectId = project.Id,
            WorkItemId = wi.Id,
            ProInitialAmount = 999m,
            IsDeleted = true,
        });
        await db.SaveChangesAsync(CancellationToken.None);

        var pe = await SeedPeAsync(db, project.Id, wi.Id);
        var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Admin));

        var act = async () => await handler.Handle(
            new UpdatePeBudgetProCommand(pe.Id, 50m, null, "mới"), CancellationToken.None);
        await act.Should().NotThrowAsync(
            "filtered index cho phép tạo record active mới khi slot cũ đã soft-delete");

        (await db.PeWorkItemBudgets.CountAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
            .Should().Be(1, "query filter !IsDeleted chỉ thấy 1 record active");
        (await db.PeWorkItemBudgets.IgnoreQueryFilters()
                .CountAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
            .Should().Be(2, "record soft-deleted gốc giữ lại cho audit + active mới");
        (await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
            .ProInitialAmount.Should().Be(50m, "record active mới mang giá trị set, KHÔNG kế thừa 999 cũ");
    }

    // =====================================================================
    // 3. UpdatePeBudgetPro authz matrix
    // =====================================================================

    [Fact]
    public async Task UpdatePro_ProcurementRole_SetsInitialAndNote()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-PRO1");
        var pe = await SeedPeAsync(db, project.Id, wi.Id);
        var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Procurement));

        await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 1_500_000m, null, "Dự trù theo đơn giá Q2"),
            CancellationToken.None);

        var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
        rec.ProInitialAmount.Should().Be(1_500_000m, "handler set ProInitialAmount (arg2 = ProInitial)");
        rec.ProNote.Should().Be("Dự trù theo đơn giá Q2");
    }

    [Fact]
    public async Task UpdatePro_CostControlRole_ThrowsForbidden_AndDoesNotMutateRecord()
    {
        // CCM KHÔNG có quyền nhập PRO → ForbiddenException + record KHÔNG đổi (side-effect assert).
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-PRO2");
        var pe = await SeedPeAsync(db, project.Id, wi.Id);

        // Pre-seed record với giá trị PRO sẵn để chứng minh KHÔNG bị mutate khi Forbidden.
        db.PeWorkItemBudgets.Add(new PeWorkItemBudget
        {
            Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
            ProInitialAmount = 700m, ProAdjustmentAmount = -10m, ProNote = "giữ nguyên",
        });
        await db.SaveChangesAsync(CancellationToken.None);

        var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.CostControl));

        await FluentActions.Awaiting(() => handler.Handle(
                new UpdatePeBudgetProCommand(pe.Id, 9_999m, 1m, "không được set"), CancellationToken.None))
            .Should().ThrowAsync<ForbiddenException>("chỉ Procurement | Admin được nhập dự trù PRO");

        var rec = await db.PeWorkItemBudgets.AsNoTracking()
            .SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
        rec.ProInitialAmount.Should().Be(700m, "Forbidden TRƯỚC side-effect → record giữ nguyên");
        rec.ProAdjustmentAmount.Should().Be(-10m, "không field PRO nào bị mutate khi Forbidden");
        rec.ProNote.Should().Be("giữ nguyên");
    }

    [Fact]
    public async Task UpdatePro_AdminRole_Succeeds()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-PRO3");
        var pe = await SeedPeAsync(db, project.Id, wi.Id);
        var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Admin));

        await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 42m, null, "admin set"), CancellationToken.None);

        (await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
            .ProInitialAmount.Should().Be(42m, "Admin được nhập PRO");
    }

    [Fact]
    public async Task UpdatePro_PeWorkItemIdNull_ThrowsConflict()
    {
        // Phiếu cũ chưa gắn Hạng mục (WorkItemId null) → Conflict (resolve record qua PE.WorkItemId).
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var pe = await SeedPeAsync(db, project.Id, workItemId: null);
        var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Procurement));

        await FluentActions.Awaiting(() => handler.Handle(
                new UpdatePeBudgetProCommand(pe.Id, 10m, null, null), CancellationToken.None))
            .Should().ThrowAsync<ConflictException>()
            .WithMessage("*chưa gắn Hạng mục công việc*");
    }

    [Fact]
    public async Task UpdatePro_RecordMissing_AutoCreatesThenSets()
    {
        // Cặp chưa có record (phiếu seed thủ công KHÔNG đi qua auto-create của Create handler)
        // → handler auto-create qua EnsureTrackedAsync rồi set. budgetId non-null sau call.
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-PRO4");
        var pe = await SeedPeAsync(db, project.Id, wi.Id);

        (await db.PeWorkItemBudgets.AnyAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
            .Should().BeFalse("tiền điều kiện: chưa có record cho cặp");

        var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Procurement));
        await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 333m, null, "auto"), CancellationToken.None);

        var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
        rec.Id.Should().NotBe(Guid.Empty, "auto-create record có Id thật");
        rec.ProInitialAmount.Should().Be(333m);
    }

    [Fact]
    public async Task UpdatePro_SetsBothInitialAndNegativeAdjustment_PersistTogether()
    {
        // [S76] PRO column split — set CẢ ProInitial + ProAdjust (gồm ÂM) trong 1 lệnh.
        // ProAdjustmentAmount cho phép ÂM ("V0/hiệu chỉnh tăng giảm", validator KHÔNG ràng dấu —
        // mirror CCM AdjustmentAmount). Cả 2 field + note persist đủ.
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-PRO5");
        var pe = await SeedPeAsync(db, project.Id, wi.Id);
        var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Procurement));

        await handler.Handle(
            new UpdatePeBudgetProCommand(pe.Id, 100_000_000m, -20_000_000m, "ban hành + V0 giảm 20tr"),
            CancellationToken.None);

        var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
        rec.ProInitialAmount.Should().Be(100_000_000m, "ban hành lần đầu PRO");
        rec.ProAdjustmentAmount.Should().Be(-20_000_000m, "V0/hiệu chỉnh PRO ÂM được chấp nhận");
        rec.ProNote.Should().Be("ban hành + V0 giảm 20tr", "cả 3 field PRO persist trong 1 lệnh");
    }

    [Fact]
    public void UpdatePro_Validator_NegativeInitial_FailsValidation()
    {
        // ProInitialAmount >= 0 when HasValue → ÂM không hợp lệ (khác ProAdjustment).
        var validator = new UpdatePeBudgetProCommandValidator();
        var result = validator.Validate(new UpdatePeBudgetProCommand(Guid.NewGuid(), -1m, null, null));

        result.IsValid.Should().BeFalse();
        result.Errors.Should().Contain(e =>
            e.PropertyName == nameof(UpdatePeBudgetProCommand.ProInitialAmount));
    }

    [Fact]
    public void UpdatePro_Validator_NegativeAdjustment_PassesValidation()
    {
        // ProAdjustmentAmount KHÔNG ràng dấu — "hiệu chỉnh tăng giảm" cho phép ÂM (mirror CCM).
        var validator = new UpdatePeBudgetProCommandValidator();
        var result = validator.Validate(new UpdatePeBudgetProCommand(Guid.NewGuid(), 50m, -999m, null));

        result.IsValid.Should().BeTrue();
    }

    // =====================================================================
    // 4. UpdatePeBudgetCcm authz matrix
    // =====================================================================

    [Fact]
    public async Task UpdateCcm_CostControlRole_SetsInitialAndNegativeAdjustment()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-CCM1");
        var pe = await SeedPeAsync(db, project.Id, wi.Id);
        var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.CostControl));

        // Adjustment ÂM −5tr — "hiệu chỉnh tăng giảm" cho phép ÂM (validator KHÔNG ràng dấu).
        await handler.Handle(new UpdatePeBudgetCcmCommand(pe.Id, 80_000_000m, -5_000_000m, null),
            CancellationToken.None);

        var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
        rec.InitialAmount.Should().Be(80_000_000m);
        rec.AdjustmentAmount.Should().Be(-5_000_000m, "Adjustment ÂM được chấp nhận");
    }

    [Fact]
    public async Task UpdateCcm_ProcurementRole_ThrowsForbidden()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-CCM2");
        var pe = await SeedPeAsync(db, project.Id, wi.Id);
        var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.Procurement));

        await FluentActions.Awaiting(() => handler.Handle(
                new UpdatePeBudgetCcmCommand(pe.Id, 10m, 0m, null), CancellationToken.None))
            .Should().ThrowAsync<ForbiddenException>("chỉ CostControl | Admin được nhập ban hành/hiệu chỉnh");
    }

    [Fact]
    public async Task UpdateCcm_AdminRole_Succeeds()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-CCM3");
        var pe = await SeedPeAsync(db, project.Id, wi.Id);
        var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.Admin));

        await handler.Handle(new UpdatePeBudgetCcmCommand(pe.Id, 1m, 2m, null), CancellationToken.None);

        var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
        rec.InitialAmount.Should().Be(1m);
        rec.AdjustmentAmount.Should().Be(2m);
    }

    // =====================================================================
    // 4b. CcmNote [Mig 55] — mirror ProNote: absolute-set null=clear,
    //     role-gate CostControl|Admin fail-closed TRƯỚC side-effect.
    // =====================================================================

    [Fact]
    public async Task UpdateCcm_CostControlRole_SetsCcmNote()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-CCMN1");
        var pe = await SeedPeAsync(db, project.Id, wi.Id);
        var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.CostControl));

        await handler.Handle(
            new UpdatePeBudgetCcmCommand(pe.Id, 80_000_000m, -5_000_000m, "Theo NS ban hành Q2 — nguồn dự toán CCM"),
            CancellationToken.None);

        var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
        rec.CcmNote.Should().Be("Theo NS ban hành Q2 — nguồn dự toán CCM", "CostControl được ghi chú CCM");
    }

    [Fact]
    public async Task UpdateCcm_AdminRole_SetsCcmNote()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-CCMN2");
        var pe = await SeedPeAsync(db, project.Id, wi.Id);
        var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.Admin));

        await handler.Handle(new UpdatePeBudgetCcmCommand(pe.Id, 1m, 2m, "admin ghi chú"), CancellationToken.None);

        (await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
            .CcmNote.Should().Be("admin ghi chú", "Admin được ghi chú CCM");
    }

    [Fact]
    public async Task UpdateCcm_NullCcmNote_ClearsField_AbsoluteSet()
    {
        // Absolute-set (mirror ProNote): gửi CcmNote=null → CLEAR về null, KHÔNG partial-keep.
        // Bắt đầu từ giá trị có sẵn để chứng minh bị xoá (không phải vốn-dĩ-null).
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-CCMN3");
        var pe = await SeedPeAsync(db, project.Id, wi.Id);

        db.PeWorkItemBudgets.Add(new PeWorkItemBudget
        {
            Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
            InitialAmount = 500m, AdjustmentAmount = 10m, CcmNote = "ghi chú cũ cần xoá",
        });
        await db.SaveChangesAsync(CancellationToken.None);

        var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.CostControl));
        await handler.Handle(new UpdatePeBudgetCcmCommand(pe.Id, 500m, 10m, null), CancellationToken.None);

        var rec = await db.PeWorkItemBudgets.AsNoTracking()
            .SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
        rec.CcmNote.Should().BeNull("absolute-set: CcmNote=null CLEAR field, KHÔNG giữ giá trị cũ");
    }

    [Fact]
    public async Task UpdateCcm_NonPrivilegedRole_WithCcmNote_ThrowsForbidden_AndDoesNotMutateRecord()
    {
        // Procurement (KHÔNG CostControl/Admin) set CcmNote → ForbiddenException +
        // record giữ nguyên (fail-closed: role-gate TRƯỚC EnsureTrackedAsync + side-effect).
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-CCMN4");
        var pe = await SeedPeAsync(db, project.Id, wi.Id);

        // Pre-seed record để chứng minh KHÔNG bị mutate khi Forbidden.
        db.PeWorkItemBudgets.Add(new PeWorkItemBudget
        {
            Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
            InitialAmount = 700m, AdjustmentAmount = -3m, CcmNote = "giữ nguyên",
        });
        await db.SaveChangesAsync(CancellationToken.None);

        var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.Procurement));

        await FluentActions.Awaiting(() => handler.Handle(
                new UpdatePeBudgetCcmCommand(pe.Id, 9_999m, 1m, "không được ghi"), CancellationToken.None))
            .Should().ThrowAsync<ForbiddenException>("chỉ CostControl | Admin được nhập ban hành/hiệu chỉnh + ghi chú CCM");

        var rec = await db.PeWorkItemBudgets.AsNoTracking()
            .SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
        rec.CcmNote.Should().Be("giữ nguyên", "Forbidden TRƯỚC side-effect → CcmNote giữ nguyên");
        rec.InitialAmount.Should().Be(700m, "không field nào bị mutate khi Forbidden");
        rec.AdjustmentAmount.Should().Be(-3m);
    }

    [Fact]
    public async Task UpdateCcm_InitialAdjustmentAndCcmNote_AllPersistTogether()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-CCMN5");
        var pe = await SeedPeAsync(db, project.Id, wi.Id);
        var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.CostControl));

        await handler.Handle(
            new UpdatePeBudgetCcmCommand(pe.Id, 120_000_000m, -8_000_000m, "ban hành + hiệu chỉnh + lý do"),
            CancellationToken.None);

        var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
        rec.InitialAmount.Should().Be(120_000_000m);
        rec.AdjustmentAmount.Should().Be(-8_000_000m);
        rec.CcmNote.Should().Be("ban hành + hiệu chỉnh + lý do", "cả 3 field persist trong 1 lệnh");
    }

    // =====================================================================
    // 5. budgetSummary aggregates (GetPurchaseEvaluationQueryHandler)
    // =====================================================================

    // Seed 1 NCC thắng + 1 detail + 1 quote ThanhTien cho phiếu (winner tổng thể).
    // [multi-NCC 2026-07-12 cardinality-change] budgetSummary PreviousSelectedTotal +
    // CurrentProposalTotal NAY = SUM ThanhTien quote ĐƯỢC CHỌN (Quote.IsSelected —
    // PurchaseEvaluationFeatures.cs:911-926 `where q.IsSelected`), KHÔNG còn build từ IsWinner.
    // PreviousSelectedCount vẫn dùng IsWinner (existence). Helper set CẢ quote.IsSelected=true +
    // pes.IsWinner=true (invariant) → tổng tiền lọt đúng + count đúng. SelectedSupplierId sync caller.
    private static async Task SeedWinnerWithQuoteAsync(
        TestApplicationDbContext db, PurchaseEvaluation pe, Guid supplierId, decimal thanhTien)
    {
        var pes = new PurchaseEvaluationSupplier
        {
            Id = Guid.NewGuid(),
            PurchaseEvaluationId = pe.Id,
            SupplierId = supplierId,
            Order = 0,
            IsWinner = true,   // existence (PreviousSelectedCount) — derived == Any(IsSelected)
        };
        var detail = new PurchaseEvaluationDetail
        {
            Id = Guid.NewGuid(),
            PurchaseEvaluationId = pe.Id,
            GroupCode = "A.I",
            GroupName = "Bê tông",
            NoiDung = "Concrete",
            Order = 0,
        };
        db.PurchaseEvaluationSuppliers.Add(pes);
        db.PurchaseEvaluationDetails.Add(detail);
        db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
        {
            Id = Guid.NewGuid(),
            PurchaseEvaluationDetailId = detail.Id,
            PurchaseEvaluationSupplierId = pes.Id,
            ThanhTien = thanhTien,
            IsSelected = true,   // [multi-NCC] báo giá được chọn → vào PrevSelectedTotal/CurrentProposalTotal
        });
        await db.SaveChangesAsync(CancellationToken.None);
    }

    // [Mig 58 multi-winner — FIX 2] Seed N đơn vị IsWinner=true cho 1 phiếu, mỗi đơn vị 1
    // quote ThanhTien (1 detail dùng chung). SelectedSupplierId = NULL (liên-danh ≥2 → không
    // có 1 winner duy nhất). Đây là biến thể đa-thắng của SeedWinnerWithQuoteAsync — chứng
    // minh code đọc IsWinner (multi) thay SelectedSupplierId (single, null khi ≥2).
    private static async Task SeedMultiWinnersWithQuotesAsync(
        TestApplicationDbContext db, PurchaseEvaluation pe, params decimal[] winnerQuotes)
    {
        var detail = new PurchaseEvaluationDetail
        {
            Id = Guid.NewGuid(),
            PurchaseEvaluationId = pe.Id,
            GroupCode = "A.I",
            GroupName = "Bê tông",
            NoiDung = "Concrete",
            Order = 0,
        };
        db.PurchaseEvaluationDetails.Add(detail);

        var order = 0;
        foreach (var thanhTien in winnerQuotes)
        {
            var pes = new PurchaseEvaluationSupplier
            {
                Id = Guid.NewGuid(),
                PurchaseEvaluationId = pe.Id,
                SupplierId = Guid.NewGuid(),
                Order = order++,
                IsWinner = true,   // ≥2 đơn vị cùng trúng (multi-winner / liên-danh)
            };
            db.PurchaseEvaluationSuppliers.Add(pes);
            db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
            {
                Id = Guid.NewGuid(),
                PurchaseEvaluationDetailId = detail.Id,
                PurchaseEvaluationSupplierId = pes.Id,
                ThanhTien = thanhTien,
                IsSelected = true,   // [multi-NCC] mỗi winner có báo giá được chọn → vào SUM IsSelected
            });
        }
        // pe.SelectedSupplierId GIỮ null (đã null từ SeedPeAsync) — đặc trưng ≥2 winner.
        await db.SaveChangesAsync(CancellationToken.None);
    }

    private static GetPurchaseEvaluationQueryHandler BuildQueryHandler(
        IdentityFixture fix, TestApplicationDbContext db, ICurrentUser currentUser)
        => new(db, fix.Services.GetRequiredService<UserManager<User>>(), currentUser);

    [Fact]
    public async Task BudgetSummary_AccumulatesPreviousSubmittedAndSelected_IgnoresTraLaiAndOtherPairs()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-SUM1");
        var otherWi = await SeedWorkItemAsync(db, "WI-SUM-OTHER");

        var baseT = new DateTime(2026, 6, 13, 8, 0, 0, DateTimeKind.Utc);

        // P1 DaDuyet — BudgetPeriod 100, winner quote 90, CreatedAt -3d.
        var p1Winner = Guid.NewGuid();
        var p1 = await SeedPeAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.DaDuyet,
            budgetPeriodAmount: 100m, selectedSupplierId: p1Winner,
            createdAt: baseT.AddDays(-3), code: "PE-SUM-1");
        await SeedWinnerWithQuoteAsync(db, p1, p1Winner, 90m);

        // P2 ChoDuyet — BudgetPeriod 50, CreatedAt -2d (tính submitted, KHÔNG tính selected).
        await SeedPeAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.ChoDuyet,
            budgetPeriodAmount: 50m, createdAt: baseT.AddDays(-2), code: "PE-SUM-2");

        // P3 TraLai — BudgetPeriod 999, CreatedAt -1d → KHÔNG tính (quay về soạn).
        await SeedPeAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.TraLai,
            budgetPeriodAmount: 999m, createdAt: baseT.AddDays(-1), code: "PE-SUM-3");

        // Phiếu KHÁC CẶP (cùng project, hạng mục khác) DaDuyet 777 → KHÔNG lẫn.
        var otherWinner = Guid.NewGuid();
        var pOther = await SeedPeAsync(db, project.Id, otherWi.Id, PurchaseEvaluationPhase.DaDuyet,
            budgetPeriodAmount: 777m, selectedSupplierId: otherWinner,
            createdAt: baseT.AddDays(-2), code: "PE-SUM-OTHER");
        await SeedWinnerWithQuoteAsync(db, pOther, otherWinner, 777m);

        // P_this DangSoanThao — BudgetPeriod 70, CreatedAt = baseT (mới nhất).
        var pThis = await SeedPeAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.DangSoanThao,
            budgetPeriodAmount: 70m, createdAt: baseT, code: "PE-SUM-THIS");

        // GET P_this bằng Admin (bỏ qua authz scope) → đọc peBudgetSummary.
        var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
        var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pThis.Id), CancellationToken.None);

        bundle.BudgetSummary.Should().NotBeNull("phiếu có WorkItemId → summary build");
        var s = bundle.BudgetSummary!;
        s.PreviousSubmittedTotal.Should().Be(150m, "P1(100,DaDuyet)+P2(50,ChoDuyet); P3 TraLai loại");
        s.PreviousSubmittedCount.Should().Be(2);
        s.PreviousSelectedTotal.Should().Be(90m, "chỉ P1 DaDuyet + có winner quote (IsWinner=true)");
        s.PreviousSelectedCount.Should().Be(1);
    }

    // =====================================================================
    // [FIX 2 — Mig 58 multi-winner re-key IsWinner] Block B financials khi ≥2 đơn vị
    // cùng trúng (SelectedSupplierId = null). TRƯỚC FIX 2: CurrentProposalTotal +
    // PreviousSelected* gate trên SelectedSupplierId (single) → null khi ≥2 winner →
    // báo 0 / bỏ sót phiếu liên-danh. SAU FIX: dùng Suppliers.Where(IsWinner) (multi).
    // =====================================================================

    // (a) Phiếu HIỆN TẠI có ≥2 đơn vị IsWinner, mỗi đơn vị 1 báo giá → CurrentProposalTotal
    //     = TỔNG báo giá của TẤT CẢ winner (KHÔNG phải 0). Pre-fix = 0 vì SelectedSupplierId
    //     null khi ≥2 winner → curWinnerRowIds rỗng → 0. Đây là bug đã sửa.
    [Fact]
    public async Task BudgetSummary_CurrentProposalTotal_SumsAllWinnerQuotes_WhenMultipleWinners()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-MW1");

        // Phiếu này đang xét — SelectedSupplierId null (mặc định), 2 đơn vị IsWinner: 120 + 80.
        var pe = await SeedPeAsync(db, project.Id, wi.Id,
            PurchaseEvaluationPhase.ChoDuyet, code: "PE-MW-CURR");
        await SeedMultiWinnersWithQuotesAsync(db, pe, 120m, 80m);

        var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
        var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);

        var s = bundle.BudgetSummary!;
        s.CurrentProposalTotal.Should().Be(200m,
            "≥2 winner → tổng MỌI báo giá winner (120+80); pre-fix báo 0 vì SelectedSupplierId null khi liên-danh");
        // Sanity: phiếu thật sự ở trạng thái multi-winner (SelectedSupplierId null).
        var reloadPe = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloadPe.SelectedSupplierId.Should().BeNull("đặc trưng ≥2 đồng-trúng — không có 1 winner duy nhất");
    }

    // (b) Phiếu DaDuyet TRƯỚC (peer) trao cho ≥2 đơn vị liên-danh (SelectedSupplierId null,
    //     IsWinner=true trên ≥2 row) → VẪN tính vào PreviousSelectedCount + báo giá winner
    //     cộng vào PreviousSelectedTotal. Pre-fix: peer bị loại vì SelectedSupplierId null.
    [Fact]
    public async Task BudgetSummary_PreviousSelected_CountsMultiWinnerPeer_AndSumsWinnerQuotes()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-MW2");

        var baseT = new DateTime(2026, 6, 24, 8, 0, 0, DateTimeKind.Utc);

        // Peer DaDuyet TRƯỚC (CreatedAt -2d), liên-danh ≥2 winner: 60 + 40 = 100. SelectedSupplierId null.
        var peer = await SeedPeAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.DaDuyet,
            createdAt: baseT.AddDays(-2), code: "PE-MW-PEER");
        await SeedMultiWinnersWithQuotesAsync(db, peer, 60m, 40m);

        // Phiếu đang xét (mới nhất) — chỉ để trigger build summary.
        var pThis = await SeedPeAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.DangSoanThao,
            createdAt: baseT, code: "PE-MW-THIS");

        var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
        var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pThis.Id), CancellationToken.None);

        var s = bundle.BudgetSummary!;
        s.PreviousSelectedCount.Should().Be(1,
            "peer DaDuyet liên-danh (≥2 IsWinner, SelectedSupplierId null) VẪN được đếm; pre-fix bỏ sót");
        s.PreviousSelectedTotal.Should().Be(100m,
            "tổng báo giá MỌI winner của peer (60+40); pre-fix = 0 vì gate SelectedSupplierId null");
    }

    [Fact]
    public async Task BudgetSummary_FullAmount_FallsBackToProFull_WhenCcmEmpty()
    {
        // [S76] CCM (Initial+Adjustment) cả null → fallback proFull = ProInitial(500)+ProAdjust(0)
        // = 500, FullIsEstimate=true. KHÔNG còn dùng ProEstimateAmount (legacy).
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-SUM2");
        var pe = await SeedPeAsync(db, project.Id, wi.Id);

        db.PeWorkItemBudgets.Add(new PeWorkItemBudget
        {
            Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
            ProInitialAmount = 500m,   // CCM Initial + Adjustment đều null
        });
        await db.SaveChangesAsync(CancellationToken.None);

        var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
        var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);

        var s = bundle.BudgetSummary!;
        s.FullAmount.Should().Be(500m, "CCM trống → full = ngân sách PRO (ProInitial + ProAdjust)");
        s.FullIsEstimate.Should().BeTrue("cờ FE badge 'ngân sách PRO'");
    }

    [Fact]
    public async Task BudgetSummary_FullAmount_UsesCcmInitialPlusAdjustment_WhenCcmPresent()
    {
        // CCM Initial=400 + Adjustment=-50 → full=350, FullIsEstimate=false.
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-SUM3");
        var pe = await SeedPeAsync(db, project.Id, wi.Id);

        db.PeWorkItemBudgets.Add(new PeWorkItemBudget
        {
            Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
            ProInitialAmount = 500m, ProAdjustmentAmount = 20m,    // PRO có nhưng KHÔNG dùng khi CCM present
            InitialAmount = 400m,
            AdjustmentAmount = -50m,
        });
        await db.SaveChangesAsync(CancellationToken.None);

        var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
        var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);

        var s = bundle.BudgetSummary!;
        s.FullAmount.Should().Be(350m, "CCM present → full = Initial + Adjustment (400 - 50), PRO bị bỏ qua");
        s.FullIsEstimate.Should().BeFalse("không phải dự trù — CCM đã nhập");
    }

    [Fact]
    public async Task BudgetSummary_FullAmount_ProFull_SumsProInitialAndAdjustment_WhenCcmEmpty()
    {
        // [S76] CCM trống → proFull = ProInitial(100) + ProAdjust(50) = 150. FullIsEstimate=true.
        // DTO cũng surface ProInitial/ProAdjust riêng (2 field cuối record) cho FE render từng cột.
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-SUM5");
        var pe = await SeedPeAsync(db, project.Id, wi.Id);

        db.PeWorkItemBudgets.Add(new PeWorkItemBudget
        {
            Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
            ProInitialAmount = 100m, ProAdjustmentAmount = 50m,   // CCM Initial + Adjustment đều null
        });
        await db.SaveChangesAsync(CancellationToken.None);

        var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
        var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);

        var s = bundle.BudgetSummary!;
        s.FullAmount.Should().Be(150m, "CCM trống → full = proFull = ProInitial + ProAdjust (100 + 50)");
        s.FullIsEstimate.Should().BeTrue("PRO nhập + CCM trống → cờ dự trù PRO");
        s.ProInitialAmount.Should().Be(100m, "DTO surface ProInitial riêng cho FE");
        s.ProAdjustmentAmount.Should().Be(50m, "DTO surface ProAdjust riêng cho FE");
    }

    [Fact]
    public async Task BudgetSummary_FullAmount_NegativeProAdjustment_ReducesProFull_WhenCcmEmpty()
    {
        // proFull cho phép ProAdjust ÂM → 100 + (-30) = 70. Chứng minh full = tổng đại số (không clamp).
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-SUM6");
        var pe = await SeedPeAsync(db, project.Id, wi.Id);

        db.PeWorkItemBudgets.Add(new PeWorkItemBudget
        {
            Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
            ProInitialAmount = 100m, ProAdjustmentAmount = -30m,
        });
        await db.SaveChangesAsync(CancellationToken.None);

        var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
        var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);

        var s = bundle.BudgetSummary!;
        s.FullAmount.Should().Be(70m, "proFull = ProInitial + ProAdjust ÂM (100 - 30)");
        s.FullIsEstimate.Should().BeTrue();
    }

    [Fact]
    public async Task BudgetSummary_CanEditFlags_FollowRole()
    {
        // canEditPro theo Procurement|Admin; canEditCcm theo CostControl|Admin.
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-SUM4");
        var pe = await SeedPeAsync(db, project.Id, wi.Id);
        db.PeWorkItemBudgets.Add(new PeWorkItemBudget
        {
            Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id, ProInitialAmount = 10m,
        });
        await db.SaveChangesAsync(CancellationToken.None);

        // PRO user (drafter của phiếu để pass authz scope) → canEditPro true, canEditCcm false.
        var proUser = new FakeCurrentUser { UserId = pe.DrafterUserId, Roles = new[] { AppRoles.Procurement } };
        var bundlePro = await BuildQueryHandler(fix, db, proUser)
            .Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
        bundlePro.BudgetSummary!.CanEditPro.Should().BeTrue();
        bundlePro.BudgetSummary!.CanEditCcm.Should().BeFalse("Procurement không sửa CCM");

        var ccmUser = new FakeCurrentUser { UserId = pe.DrafterUserId, Roles = new[] { AppRoles.CostControl } };
        var bundleCcm = await BuildQueryHandler(fix, db, ccmUser)
            .Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
        bundleCcm.BudgetSummary!.CanEditPro.Should().BeFalse("CostControl không sửa PRO");
        bundleCcm.BudgetSummary!.CanEditCcm.Should().BeTrue();
    }

    [Fact]
    public async Task BudgetSummary_Null_WhenPeHasNoWorkItem()
    {
        // Phiếu cũ chưa gắn Hạng mục (WorkItemId null) → summary null (FE banner nhắc gắn).
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var pe = await SeedPeAsync(db, project.Id, workItemId: null);

        var bundle = await BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin))
            .Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);

        bundle.BudgetSummary.Should().BeNull("WorkItemId null → KHÔNG build summary");
    }

    // =====================================================================
    // 6. AdjustBudget mới (absolute-set 2 field + validator)
    // =====================================================================

    [Fact]
    public async Task AdjustBudget_DrafterDraft_SetsBothFields()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-ADJ1");
        var drafterId = Guid.NewGuid();
        var pe = await SeedPeAsync(db, project.Id, wi.Id,
            PurchaseEvaluationPhase.DangSoanThao, drafterUserId: drafterId);

        // Actor = chính Drafter của phiếu (scope Drafter khi Nháp).
        var drafter = new FakeCurrentUser { UserId = drafterId, Roles = new[] { AppRoles.Drafter } };
        var handler = new AdjustPurchaseEvaluationBudgetCommandHandler(db, drafter);

        await handler.Handle(new AdjustPurchaseEvaluationBudgetCommand(pe.Id, 80m, 30m),
            CancellationToken.None);

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == pe.Id);
        reloaded.BudgetPeriodAmount.Should().Be(80m);
        reloaded.ExpectedRemainingAmount.Should().Be(30m);
    }

    [Fact]
    public void AdjustBudget_Validator_BudgetPeriodZero_PassesValidation()
    {
        // [Mig 58 item 2 — SPEC CHANGE] GreaterThanOrEqualTo(0) when HasValue → 0 HỢP LỆ
        // (trước GreaterThan(0) chặn 0). SSG ngân sách 0 vẫn lưu/gửi duyệt được (anh Kiệt FDC).
        var validator = new AdjustPurchaseEvaluationBudgetCommandValidator();
        var result = validator.Validate(new AdjustPurchaseEvaluationBudgetCommand(Guid.NewGuid(), 0m, null));

        result.IsValid.Should().BeTrue("ngân sách kỳ này = 0 hợp lệ sau Mig 58");
    }

    [Fact]
    public void AdjustBudget_Validator_BudgetPeriodNegative_FailsValidation()
    {
        // [Mig 58 item 2] Boundary: 0 hợp lệ NHƯNG ÂM vẫn KHÔNG (GreaterThanOrEqualTo(0)
        // = >= 0, loại số âm). Chứng minh nới chỉ tới 0, không mở cho âm.
        var validator = new AdjustPurchaseEvaluationBudgetCommandValidator();
        var result = validator.Validate(new AdjustPurchaseEvaluationBudgetCommand(Guid.NewGuid(), -1m, null));

        result.IsValid.Should().BeFalse("ngân sách kỳ này ÂM không hợp lệ");
        result.Errors.Should().Contain(e =>
            e.PropertyName == nameof(AdjustPurchaseEvaluationBudgetCommand.BudgetPeriodAmount));
    }

    [Fact]
    public void AdjustBudget_Validator_ExpectedRemainingNegative_PassesValidation()
    {
        // [S62] SPEC CHANGE — "Giá trị thực hiện dự kiến còn lại" (row 8) ÂM giờ HỢP LỆ.
        // Vượt ngân sách → số dư còn lại ra âm; nghiệp vụ (anh Kiệt FDC) cho lưu, chỉ
        // cảnh báo. Rule GreaterThanOrEqualTo(0) cũ đã gỡ. BudgetPeriodAmount=80 > 0 hợp lệ.
        var validator = new AdjustPurchaseEvaluationBudgetCommandValidator();
        var result = validator.Validate(new AdjustPurchaseEvaluationBudgetCommand(Guid.NewGuid(), 80m, -1m));

        result.IsValid.Should().BeTrue();
    }

    [Fact]
    public async Task AdjustBudget_AbsoluteSet_NullExpectedRemaining_ClearsField()
    {
        // Absolute-set: gửi (BudgetPeriod=80, ExpectedRemaining=null) → ExpectedRemaining
        // bị CLEAR về null (đúng thiết kế — KHÔNG partial-keep). Bắt đầu từ giá trị có sẵn.
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-ADJ2");
        var drafterId = Guid.NewGuid();
        var pe = await SeedPeAsync(db, project.Id, wi.Id,
            PurchaseEvaluationPhase.DangSoanThao, drafterUserId: drafterId);

        // Set ExpectedRemaining sẵn = 999 trước khi adjust với null.
        var tracked = await db.PurchaseEvaluations.SingleAsync(x => x.Id == pe.Id);
        tracked.ExpectedRemainingAmount = 999m;
        await db.SaveChangesAsync(CancellationToken.None);

        var drafter = new FakeCurrentUser { UserId = drafterId, Roles = new[] { AppRoles.Drafter } };
        var handler = new AdjustPurchaseEvaluationBudgetCommandHandler(db, drafter);

        await handler.Handle(new AdjustPurchaseEvaluationBudgetCommand(pe.Id, 80m, null),
            CancellationToken.None);

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == pe.Id);
        reloaded.BudgetPeriodAmount.Should().Be(80m, "field này được set");
        reloaded.ExpectedRemainingAmount.Should().BeNull("absolute-set: null request CLEAR field, KHÔNG giữ 999");
    }

    // =====================================================================
    // 7. [S116 anh Kiệt — SỐ ÂM báo giá] Regression-lock: báo giá ÂM (phát-sinh-giảm /
    //    hoàn tiền NCC = giá-trị THẬT) KHÔNG rò rỉ vào ngân sách. "Ngân sách giữ nguyên"
    //    là quyết định KIẾN-TRÚC (0 code nối quote→budget). Test tautological NHƯNG khoá
    //    cứng: nếu ai sau này wire "auto-trừ ngân sách theo báo giá" thì dòng âm sẽ phá
    //    ngân sách — test này bắt regression đó. Đồng thời chứng minh TÁCH BIỆT: dòng âm
    //    CHẢY vào CurrentProposalTotal (cột đề xuất) chứ KHÔNG vào FullAmount (cột ngân
    //    sách) + record PeWorkItemBudget bất biến.
    // =====================================================================

    [Fact]
    public async Task NegativeSelectedQuote_DoesNotMutateBudgetRecord_ReflectsInProposalOnly()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var project = await SeedProjectAsync(db);
        var wi = await SeedWorkItemAsync(db, "WI-NEG1");
        var pe = await SeedPeAsync(db, project.Id, wi.Id,
            PurchaseEvaluationPhase.DangSoanThao, code: "PE-NEG1");

        // Ngân sách CCM ban hành: Initial 80tr, Adjustment −5tr → FullAmount 75tr.
        db.PeWorkItemBudgets.Add(new PeWorkItemBudget
        {
            Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
            ProInitialAmount = 100_000_000m, ProAdjustmentAmount = 0m,
            InitialAmount = 80_000_000m, AdjustmentAmount = -5_000_000m,
        });
        await db.SaveChangesAsync(CancellationToken.None);

        // Báo giá ÂM −20tr được chọn (phát-sinh-giảm / hoàn tiền) cho phiếu này.
        await SeedWinnerWithQuoteAsync(db, pe, Guid.NewGuid(), -20_000_000m);

        var bundle = await BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin))
            .Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);

        var s = bundle.BudgetSummary!;
        // Cột NGÂN SÁCH bất biến — dòng âm KHÔNG kéo giảm ngân sách.
        s.FullAmount.Should().Be(75_000_000m, "ngân sách = CCM Initial+Adjustment (80−5), KHÔNG bị báo giá âm chạm");
        s.FullIsEstimate.Should().BeFalse();
        // Cột ĐỀ XUẤT phản ánh dòng âm (SUM có dấu) — chứng minh âm chảy đúng chỗ.
        s.CurrentProposalTotal.Should().Be(-20_000_000m, "báo giá âm phản ánh ở đề xuất, KHÔNG phải ngân sách");

        // Record PeWorkItemBudget persistent BẤT BIẾN sau khi thêm quote âm + GET.
        var rec = await db.PeWorkItemBudgets.AsNoTracking()
            .SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
        rec.InitialAmount.Should().Be(80_000_000m, "record ngân sách KHÔNG đổi vì báo giá âm");
        rec.AdjustmentAmount.Should().Be(-5_000_000m);
        rec.ProInitialAmount.Should().Be(100_000_000m);
        rec.ProAdjustmentAmount.Should().Be(0m);
    }
}
