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.PurchaseEvaluations;
using SolutionErp.Infrastructure.Tests.Common;

namespace SolutionErp.Infrastructure.Tests.Application;

// ===== NEW (Mig 54 2026-06-18 anh Kiệt FDC) — ③ 2 setter giá-đề-xuất role-gate =====
// PeSuggestedPriceFeatures.cs: UpdatePeSuggestedPricePro/CcmCommand(+Handler+Validator).
// Test theo CODE đã land (S34 rule — KHÔNG touch production). Mirror harness
// PeWorkItemGuardTests (validator plain .Validate() API + handler 2-dep nhẹ
// db+ICurrentUser instantiate trực tiếp).
//
// Authz (mirror UpdatePeBudgetPro/Ccm S61):
//   - PRO command: role Procurement HOẶC Admin set ProSuggestedMin/Max; role khác →
//     ForbiddenException (fail-closed TRƯỚC mọi side-effect — guard sau NotFound check).
//   - CCM command: role CostControl HOẶC Admin set CcmSuggestedPrice; role khác → Forbidden.
//   - Validator PRO: Min/Max >= 0; cả 2 có → Min <= Max (invalid nếu Min > Max).
//   - Validator CCM: CcmPrice >= 0.
//
// ⚠️ Handler check PE-existence (NotFound) TRƯỚC authz gate (PeSuggestedPriceFeatures
//   line 49-50 rồi 53). Nên unknown-PE → NotFound bất kể role; Forbidden cần PE tồn tại.
public class PeSuggestedPriceSetterAuthzTests
{
    private sealed class FakeCurrentUser(params string[] roles) : ICurrentUser
    {
        public Guid? UserId { get; } = Guid.NewGuid();
        public string? Email { get; } = "actor@test.local";
        public string? FullName { get; } = "Actor Test";
        public IReadOnlyList<string> Roles { get; } = roles ?? Array.Empty<string>();
        public bool IsAuthenticated => UserId is not null;
    }

    private static async Task<PurchaseEvaluation> SeedPeAsync(
        TestApplicationDbContext db, string code = "PE-SP-001",
        // [S96 2026-07-01] Default DangSoanThao (Nháp) = phase editable cho setter giá đề xuất
        // sau khi thêm phase-guard "duyệt là fix". Guard tests truyền ChoDuyet/DaDuyet để chặn.
        PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.DangSoanThao)
    {
        var pe = new PurchaseEvaluation
        {
            Id = Guid.NewGuid(),
            Type = PurchaseEvaluationType.DuyetNcc,
            Phase = phase,
            MaPhieu = code,
            TenGoiThau = "Gói thầu test giá đề xuất",
            ProjectId = Guid.NewGuid(),
            DrafterUserId = Guid.NewGuid(),
        };
        db.PurchaseEvaluations.Add(pe);
        await db.SaveChangesAsync(CancellationToken.None);
        return pe;
    }

    // ====================================================================
    // ===== PRO setter (ProSuggestedMinPrice / ProSuggestedMaxPrice) =====
    // ====================================================================

    // 1. Procurement set được Min/Max.
    [Fact]
    public async Task ProSetter_Procurement_SetsMinMax()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var pe = await SeedPeAsync(db);
        var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));

        await handler.Handle(
            new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 100_000_000m, MaxPrice: 200_000_000m),
            CancellationToken.None);

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.ProSuggestedMinPrice.Should().Be(100_000_000m);
        reloaded.ProSuggestedMaxPrice.Should().Be(200_000_000m);
    }

    // 2. Admin set được Min/Max (allow-list thứ 2).
    [Fact]
    public async Task ProSetter_Admin_SetsMinMax()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var pe = await SeedPeAsync(db, "PE-SP-002");
        var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Admin));

        await handler.Handle(
            new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 50_000_000m, MaxPrice: null),
            CancellationToken.None);

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.ProSuggestedMinPrice.Should().Be(50_000_000m);
        reloaded.ProSuggestedMaxPrice.Should().BeNull("chỉ 1 trong 2 = giá chốt đó, Max để trống OK");
    }

    // 3. Role khác (CostControl) → ForbiddenException + KHÔNG set giá (fail-closed).
    [Fact]
    public async Task ProSetter_CostControl_ThrowsForbidden_NoSet()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var pe = await SeedPeAsync(db, "PE-SP-003");
        var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));

        var act = async () => await handler.Handle(
            new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 1m, MaxPrice: 2m),
            CancellationToken.None);

        await act.Should().ThrowAsync<ForbiddenException>()
            .WithMessage("*PRO*");

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.ProSuggestedMinPrice.Should().BeNull("CCM bị chặn → không set giá PRO");
        reloaded.ProSuggestedMaxPrice.Should().BeNull();
    }

    // 3b. Role thường khác (Drafter) → cũng Forbidden (chắc chắn không chỉ CCM bị chặn).
    [Fact]
    public async Task ProSetter_Drafter_ThrowsForbidden()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var pe = await SeedPeAsync(db, "PE-SP-003B");
        var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Drafter));

        var act = async () => await handler.Handle(
            new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 1m, MaxPrice: null),
            CancellationToken.None);

        await act.Should().ThrowAsync<ForbiddenException>();
    }

    // 4. Validator PRO: Min > Max → invalid (rule WithMessage "Giá Min phải ≤ Giá Max").
    [Fact]
    public void ProValidator_MinGreaterThanMax_Invalid()
    {
        var validator = new UpdatePeSuggestedPriceProCommandValidator();

        var result = validator.Validate(
            new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), MinPrice: 500m, MaxPrice: 100m));

        result.IsValid.Should().BeFalse("Min > Max vi phạm rule");
        result.Errors.Should().Contain(e => e.ErrorMessage.Contains("Min phải ≤"));
    }

    // 4b. Validator PRO: Min <= Max → valid. Chỉ 1 trong 2 (Max null) → cũng valid
    //     (rule Min<=Max chỉ When cả 2 HasValue).
    [Fact]
    public void ProValidator_MinLeMax_AndSingleValue_Valid()
    {
        var validator = new UpdatePeSuggestedPriceProCommandValidator();

        validator.Validate(new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), 100m, 500m))
            .IsValid.Should().BeTrue("Min <= Max hợp lệ");
        validator.Validate(new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), 100m, null))
            .IsValid.Should().BeTrue("chỉ Min (Max null) hợp lệ — không ràng Min<=Max");
        validator.Validate(new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), null, null))
            .IsValid.Should().BeTrue("cả 2 null (clear) hợp lệ");
    }

    // 4c. Validator PRO: giá trị âm → invalid (GreaterThanOrEqualTo(0)).
    [Fact]
    public void ProValidator_NegativeValue_Invalid()
    {
        var validator = new UpdatePeSuggestedPriceProCommandValidator();

        var result = validator.Validate(
            new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), MinPrice: -1m, MaxPrice: null));

        result.IsValid.Should().BeFalse("giá âm vi phạm GreaterThanOrEqualTo(0)");
    }

    // 5. PRO handler unknown PE → NotFound (existence check TRƯỚC authz).
    [Fact]
    public async Task ProSetter_UnknownPe_ThrowsNotFound()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));

        var act = async () => await handler.Handle(
            new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), 1m, 2m), CancellationToken.None);

        await act.Should().ThrowAsync<NotFoundException>();
    }

    // ====================================================================
    // ===== CCM setter (CcmSuggestedPrice) =====
    // ====================================================================

    // 6. CostControl set được CcmSuggestedPrice.
    [Fact]
    public async Task CcmSetter_CostControl_SetsPrice()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var pe = await SeedPeAsync(db, "PE-SP-006");
        var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));

        await handler.Handle(
            new UpdatePeSuggestedPriceCcmCommand(pe.Id, CcmPrice: 333_000_000m), CancellationToken.None);

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.CcmSuggestedPrice.Should().Be(333_000_000m);
    }

    // 7. Admin set được CcmSuggestedPrice.
    [Fact]
    public async Task CcmSetter_Admin_SetsPrice()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var pe = await SeedPeAsync(db, "PE-SP-007");
        var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.Admin));

        await handler.Handle(
            new UpdatePeSuggestedPriceCcmCommand(pe.Id, CcmPrice: 1m), CancellationToken.None);

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.CcmSuggestedPrice.Should().Be(1m);
    }

    // 8. Role khác (Procurement) → ForbiddenException + KHÔNG set giá.
    [Fact]
    public async Task CcmSetter_Procurement_ThrowsForbidden_NoSet()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var pe = await SeedPeAsync(db, "PE-SP-008");
        var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));

        var act = async () => await handler.Handle(
            new UpdatePeSuggestedPriceCcmCommand(pe.Id, CcmPrice: 9m), CancellationToken.None);

        await act.Should().ThrowAsync<ForbiddenException>()
            .WithMessage("*CCM*");

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.CcmSuggestedPrice.Should().BeNull("PRO bị chặn → không set giá CCM");
    }

    // 9. Validator CCM: giá âm → invalid.
    [Fact]
    public void CcmValidator_NegativeValue_Invalid()
    {
        var validator = new UpdatePeSuggestedPriceCcmCommandValidator();

        validator.Validate(new UpdatePeSuggestedPriceCcmCommand(Guid.NewGuid(), CcmPrice: -5m))
            .IsValid.Should().BeFalse("giá âm vi phạm GreaterThanOrEqualTo(0)");
        validator.Validate(new UpdatePeSuggestedPriceCcmCommand(Guid.NewGuid(), CcmPrice: 0m))
            .IsValid.Should().BeTrue("0 hợp lệ");
        validator.Validate(new UpdatePeSuggestedPriceCcmCommand(Guid.NewGuid(), CcmPrice: null))
            .IsValid.Should().BeTrue("null (clear) hợp lệ");
    }

    // 10. CCM handler unknown PE → NotFound.
    [Fact]
    public async Task CcmSetter_UnknownPe_ThrowsNotFound()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));

        var act = async () => await handler.Handle(
            new UpdatePeSuggestedPriceCcmCommand(Guid.NewGuid(), 1m), CancellationToken.None);

        await act.Should().ThrowAsync<NotFoundException>();
    }

    // ====================================================================
    // ===== NEW [Mig 57 2026-06-19] suggested-price NOTE (ProSuggestedPriceNote /
    //       CcmSuggestedPriceNote) — mirror PeWorkItemBudgetTests §4b CcmNote shape.
    //   - PRO/CCM command +Note (string?) absolute-set (overwrite always, null = clear).
    //   - Note rides the SAME role-gate as the price (PRO/Admin; CCM/Admin) fail-closed.
    //   - Validator: Note MaximumLength(1000).
    // ====================================================================

    // 11. PRO note set + persist — note rides alongside Min/Max (doesn't clobber price).
    [Fact]
    public async Task ProSetter_Procurement_SetsNote_AlongsideMinMax()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var pe = await SeedPeAsync(db, "PE-SPN-001");
        var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));

        await handler.Handle(
            new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 100_000_000m, MaxPrice: 200_000_000m,
                Note: "Min theo NCC A, Max theo NCC B (đã gồm VAT)"),
            CancellationToken.None);

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.ProSuggestedPriceNote.Should().Be("Min theo NCC A, Max theo NCC B (đã gồm VAT)",
            "PRO ghi chú giải thích Min vs Max");
        reloaded.ProSuggestedMinPrice.Should().Be(100_000_000m, "note đi kèm KHÔNG đè giá Min");
        reloaded.ProSuggestedMaxPrice.Should().Be(200_000_000m, "note đi kèm KHÔNG đè giá Max");
    }

    // 11b. Admin set PRO note (allow-list thứ 2 của PRO command).
    [Fact]
    public async Task ProSetter_Admin_SetsNote()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var pe = await SeedPeAsync(db, "PE-SPN-001B");
        var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Admin));

        await handler.Handle(
            new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 50_000_000m, MaxPrice: null, Note: "admin ghi chú giá PRO"),
            CancellationToken.None);

        (await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id))
            .ProSuggestedPriceNote.Should().Be("admin ghi chú giá PRO", "Admin được ghi chú giá PRO");
    }

    // 12. CCM note set + persist — note rides alongside CcmPrice.
    [Fact]
    public async Task CcmSetter_CostControl_SetsNote_AlongsidePrice()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var pe = await SeedPeAsync(db, "PE-SPN-002");
        var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));

        await handler.Handle(
            new UpdatePeSuggestedPriceCcmCommand(pe.Id, CcmPrice: 333_000_000m, Note: "giá CCM khuyến nghị cho CEO duyệt"),
            CancellationToken.None);

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.CcmSuggestedPriceNote.Should().Be("giá CCM khuyến nghị cho CEO duyệt", "CCM ghi chú giá CCM");
        reloaded.CcmSuggestedPrice.Should().Be(333_000_000m, "note đi kèm KHÔNG đè giá CCM");
    }

    // 13. Absolute-set null-clear — bắt đầu từ note CÓ SẴN rồi gửi Note=null → CLEAR
    //     (chứng minh absolute-set, KHÔNG skip-if-null giữ giá trị cũ). Cả PRO + CCM.
    [Fact]
    public async Task ProSetter_NullNote_ClearsExistingNote_AbsoluteSet()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var pe = await SeedPeAsync(db, "PE-SPN-003");

        // Pre-seed note có sẵn để chứng minh bị xoá (không phải vốn-dĩ-null).
        pe.ProSuggestedPriceNote = "ghi chú PRO cũ cần xoá";
        pe.ProSuggestedMinPrice = 10_000_000m;
        await db.SaveChangesAsync(CancellationToken.None);

        var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));
        await handler.Handle(
            new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 10_000_000m, MaxPrice: null, Note: null),
            CancellationToken.None);

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.ProSuggestedPriceNote.Should().BeNull("absolute-set: Note=null CLEAR field, KHÔNG giữ ghi chú cũ");
    }

    [Fact]
    public async Task CcmSetter_NullNote_ClearsExistingNote_AbsoluteSet()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var pe = await SeedPeAsync(db, "PE-SPN-003C");

        pe.CcmSuggestedPriceNote = "ghi chú CCM cũ cần xoá";
        pe.CcmSuggestedPrice = 20_000_000m;
        await db.SaveChangesAsync(CancellationToken.None);

        var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
        await handler.Handle(
            new UpdatePeSuggestedPriceCcmCommand(pe.Id, CcmPrice: 20_000_000m, Note: null),
            CancellationToken.None);

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.CcmSuggestedPriceNote.Should().BeNull("absolute-set: Note=null CLEAR field, KHÔNG giữ ghi chú cũ");
    }

    // 14. Non-privileged role → ForbiddenException + note (và giá) GIỮ NGUYÊN trong DB.
    //     Fail-closed: role-gate TRƯỚC mọi side-effect (NotFound → gate → mutate), nên PE
    //     tồn tại + role sai = Forbidden trước khi ghi → KHÔNG partial-write.
    [Fact]
    public async Task ProSetter_NonPrivilegedRole_WithNote_ThrowsForbidden_AndDoesNotMutate()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var pe = await SeedPeAsync(db, "PE-SPN-004");

        // Pre-seed note + giá để chứng minh KHÔNG bị mutate khi Forbidden.
        pe.ProSuggestedPriceNote = "ghi chú PRO giữ nguyên";
        pe.ProSuggestedMinPrice = 700_000_000m;
        await db.SaveChangesAsync(CancellationToken.None);

        var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));

        await FluentActions.Awaiting(() => handler.Handle(
                new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 1m, MaxPrice: 2m, Note: "không được ghi"),
                CancellationToken.None))
            .Should().ThrowAsync<ForbiddenException>("chỉ PRO | Admin được nhập giá đề xuất PRO + ghi chú");

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.ProSuggestedPriceNote.Should().Be("ghi chú PRO giữ nguyên",
            "Forbidden TRƯỚC side-effect → note KHÔNG bị mutate");
        reloaded.ProSuggestedMinPrice.Should().Be(700_000_000m, "giá cũng giữ nguyên (no partial-write)");
    }

    // 15. Độc lập PRO note ↔ CCM note — set CCM note KHÔNG đụng PRO note (và ngược lại
    //     ngầm định: 2 lệnh, 2 field riêng). Cũng kiểm Note MaximumLength(1000) ở validator.
    [Fact]
    public async Task ProAndCcmNotes_AreIndependent_AndValidatorEnforcesMaxLength()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var pe = await SeedPeAsync(db, "PE-SPN-005");

        // Đặt PRO note trước (Admin set được cả 2 command).
        await new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Admin))
            .Handle(new UpdatePeSuggestedPriceProCommand(pe.Id, 1m, 2m, Note: "PRO note"), CancellationToken.None);
        // Rồi đặt CCM note — phải KHÔNG làm mất PRO note.
        await new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.Admin))
            .Handle(new UpdatePeSuggestedPriceCcmCommand(pe.Id, 3m, Note: "CCM note"), CancellationToken.None);

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.ProSuggestedPriceNote.Should().Be("PRO note", "set CCM note KHÔNG đụng PRO note (field độc lập)");
        reloaded.CcmSuggestedPriceNote.Should().Be("CCM note");

        // Validator: Note > 1000 ký tự → invalid; đúng 1000 → valid.
        var proValidator = new UpdatePeSuggestedPriceProCommandValidator();
        proValidator.Validate(new UpdatePeSuggestedPriceProCommand(pe.Id, null, null, Note: new string('x', 1001)))
            .IsValid.Should().BeFalse("Note > 1000 ký tự vi phạm MaximumLength(1000)");
        proValidator.Validate(new UpdatePeSuggestedPriceProCommand(pe.Id, null, null, Note: new string('x', 1000)))
            .IsValid.Should().BeTrue("Note đúng 1000 ký tự hợp lệ");

        var ccmValidator = new UpdatePeSuggestedPriceCcmCommandValidator();
        ccmValidator.Validate(new UpdatePeSuggestedPriceCcmCommand(pe.Id, null, Note: new string('x', 1001)))
            .IsValid.Should().BeFalse("Note > 1000 ký tự vi phạm MaximumLength(1000)");
    }

    // ====================================================================
    // ===== NEW [S96 2026-07-01 — anh Kiệt FDC "duyệt là fix"] PHASE-GUARD =====
    //   Giá đề xuất PRO/CCM CHỈ sửa khi phiếu Nháp (DangSoanThao) / Trả lại (TraLai).
    //   Phiếu ĐÃ GỬI DUYỆT (ChoDuyet) / ĐÃ DUYỆT (DaDuyet) / TỪ CHỐI → ConflictException.
    //   Admin override (sửa-sai). Đảo Mig 54 comment "KHÔNG ràng Phase". Role-gate chạy
    //   TRƯỚC phase-guard (wrong-role → Forbidden; right-role + non-draft → Conflict).
    // ====================================================================

    // 16. PRO Procurement + phiếu ĐÃ GỬI DUYỆT (ChoDuyet) → Conflict + no mutate. Đây ĐÚNG
    //     bug anh Kiệt: drafter=PRO điền Min/Max SAU khi cấp trên (chị Trà) đã duyệt.
    [Fact]
    public async Task ProSetter_SubmittedPhase_ThrowsConflict_NoMutate()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var pe = await SeedPeAsync(db, "PE-SP-LOCK-1", PurchaseEvaluationPhase.ChoDuyet);
        var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));

        var act = async () => await handler.Handle(
            new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 111m, MaxPrice: 111m),
            CancellationToken.None);

        await act.Should().ThrowAsync<ConflictException>().WithMessage("*đã gửi duyệt*");
        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.ProSuggestedMinPrice.Should().BeNull("phiếu đã gửi duyệt → KHÔNG set giá (no mutate)");
    }

    // 17. PRO Procurement + phiếu ĐÃ DUYỆT (DaDuyet) → Conflict.
    [Fact]
    public async Task ProSetter_ApprovedPhase_ThrowsConflict()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var pe = await SeedPeAsync(db, "PE-SP-LOCK-2", PurchaseEvaluationPhase.DaDuyet);
        var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));

        await FluentActions.Awaiting(() => handler.Handle(
                new UpdatePeSuggestedPriceProCommand(pe.Id, 1m, 2m), CancellationToken.None))
            .Should().ThrowAsync<ConflictException>();
    }

    // 18. Admin OVERRIDE — sửa giá được kể cả phiếu ĐÃ DUYỆT (sửa-sai). Guard chỉ chặn non-admin.
    [Fact]
    public async Task ProSetter_Admin_ApprovedPhase_StillSets()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var pe = await SeedPeAsync(db, "PE-SP-LOCK-3", PurchaseEvaluationPhase.DaDuyet);
        var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Admin));

        await handler.Handle(
            new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 42m, MaxPrice: null), CancellationToken.None);

        (await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id))
            .ProSuggestedMinPrice.Should().Be(42m, "Admin override phase-guard (sửa-sai sau duyệt)");
    }

    // 19. CCM CostControl + phiếu ĐÃ DUYỆT → Conflict (mirror PRO).
    [Fact]
    public async Task CcmSetter_ApprovedPhase_ThrowsConflict()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var pe = await SeedPeAsync(db, "PE-SP-LOCK-4", PurchaseEvaluationPhase.DaDuyet);
        var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));

        await FluentActions.Awaiting(() => handler.Handle(
                new UpdatePeSuggestedPriceCcmCommand(pe.Id, 5m), CancellationToken.None))
            .Should().ThrowAsync<ConflictException>().WithMessage("*đã gửi duyệt*");
    }
}
