using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.PurchaseEvaluations;
using SolutionErp.Domain.Contracts;   // ChangelogAction enum (reuse — PurchaseEvaluationChangelog dùng chung)
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Tests.Common;

namespace SolutionErp.Infrastructure.Tests.Application;

// [multi-NCC 2026-07-12 — anh Kiệt LOCKED — cardinality-change test-before] SelectPurchaseEvaluationWinnerCommand
// đổi `(Guid PurchaseEvaluationId, IReadOnlyList<Guid> SupplierIds)` → thêm DetailId:
// `(Guid PurchaseEvaluationId, Guid DetailId, IReadOnlyList<Guid> SupplierIds)`. Winner NAY chọn
// theo TỪNG HẠNG MỤC (Detail) — mỗi hạng mục ≥2 NCC/TP có thể cùng trúng, mỗi nhà 1 giá riêng.
//
// Handler (PurchaseEvaluationDetailFeatures.cs ~410-480):
//   • Load PE (NotFound) → Load Detail thuộc PE (NotFound) → Load MỌI supplier-row của phiếu.
//   • Validate mỗi selected SupplierId ∈ participating-list → Conflict "NCC chưa được thêm vào phiếu".
//   • Load MỌI quote của phiếu → set Quote.IsSelected cho quotes CỦA DetailId NÀY: true nếu
//     supplier-row thắng, false còn lại. NGUỒN-SỰ-THẬT = Quote.IsSelected (per Detail×Supplier).
//   • Derive Supplier.IsWinner (INVARIANT): winner ⟺ có ÍT NHẤT 1 quote IsSelected TOÀN PHIẾU.
//   • Sync SelectedSupplierId: đúng 1 winner toàn phiếu → đơn vị đó; ≥2 HOẶC 0 → null.
//   • Changelog Header/Update Summary: count==0 → "Bỏ chọn NCC/TP trúng thầu cho hạng mục {GroupCode}";
//     else "Chọn {n} NCC/TP trúng thầu cho hạng mục {GroupCode}".
//
// ⚠️ CARDINALITY-CHANGE (khác test cũ): IsWinner KHÔNG còn set-thẳng — nó DERIVE từ Quote.IsSelected.
//   Muốn supplier thành winner qua SelectWinner thì (Detail × Supplier) PHẢI có 1 quote để handler
//   flip IsSelected → derive IsWinner. Test seed 1 detail + 1 quote/participating-row (helper
//   AddParticipatingWithQuoteAsync). Test dùng SQLite EnsureCreated — KHÔNG replay migration
//   backfill (chỉ SQL-Server) → seed IsSelected/IsWinner trực tiếp cho pre-state.
//
// Handler 2-dep (db + ICurrentUser) → SqliteDbFixture đủ nhẹ. ICurrentUser fake để Changelog ghi.
public class PeSelectWinnerClearTests
{
    private const string DetailGroupCode = "A.I";

    private sealed class FakeCurrentUser : ICurrentUser
    {
        public Guid? UserId { get; init; } = Guid.NewGuid();
        public string? Email { get; init; } = "procurement@test.local";
        public string? FullName { get; init; } = "Procurement Test";
        public IReadOnlyList<string> Roles { get; init; } = new[] { AppRoles.Procurement };
        public bool IsAuthenticated => UserId is not null;
    }

    private static SelectPurchaseEvaluationWinnerCommandHandler BuildHandler(
        TestApplicationDbContext db)
        => new(db, new FakeCurrentUser());

    private static UpsertPurchaseEvaluationQuoteCommandHandler BuildUpsertQuoteHandler(
        TestApplicationDbContext db)
        => new(db, new FakeCurrentUser());

    private static async Task<Supplier> SeedSupplierAsync(
        TestApplicationDbContext db, string code)
    {
        var s = new Supplier { Id = Guid.NewGuid(), Code = code, Name = "NCC " + code };
        db.Suppliers.Add(s);
        await db.SaveChangesAsync(CancellationToken.None);
        return s;
    }

    // Phiếu PE Nháp + (tùy chọn) SelectedSupplierId pre-set. KHÔNG tự thêm participating
    // row — caller quyết định để cô lập nhánh clear vs select.
    private static async Task<PurchaseEvaluation> SeedPeAsync(
        TestApplicationDbContext db, Guid? selectedSupplierId, string code = "PE-SW")
    {
        var pe = new PurchaseEvaluation
        {
            Id = Guid.NewGuid(),
            Type = PurchaseEvaluationType.DuyetNcc,
            Phase = PurchaseEvaluationPhase.DangSoanThao,
            MaPhieu = code,
            TenGoiThau = "Gói thầu chọn NCC",
            DrafterUserId = Guid.NewGuid(),
            SelectedSupplierId = selectedSupplierId,
        };
        db.PurchaseEvaluations.Add(pe);
        await db.SaveChangesAsync(CancellationToken.None);
        return pe;
    }

    // 1 hạng mục (Detail) cho phiếu. Winner chọn theo hạng mục → mọi command cần DetailId này.
    private static async Task<Guid> SeedDetailAsync(
        TestApplicationDbContext db, Guid peId, string groupCode = DetailGroupCode)
    {
        var detail = new PurchaseEvaluationDetail
        {
            Id = Guid.NewGuid(),
            PurchaseEvaluationId = peId,
            GroupCode = groupCode,
            GroupName = "Bê tông",
            NoiDung = "Concrete M100",
            Order = 0,
        };
        db.PurchaseEvaluationDetails.Add(detail);
        await db.SaveChangesAsync(CancellationToken.None);
        return detail.Id;
    }

    // Gắn supplier vào participating-list + 1 quote thuộc detailId. isSelected pin pre-state
    // (quote.IsSelected + pes.IsWinner giữ invariant). SelectWinner sẽ flip IsSelected của
    // quote NÀY → derive IsWinner. Return PES row Id.
    private static async Task<Guid> AddParticipatingWithQuoteAsync(
        TestApplicationDbContext db, Guid peId, Guid supplierId, Guid detailId,
        bool isSelected = false, decimal thanhTien = 100m, int order = 0)
    {
        var row = new PurchaseEvaluationSupplier
        {
            Id = Guid.NewGuid(),
            PurchaseEvaluationId = peId,
            SupplierId = supplierId,
            Order = order,
            IsWinner = isSelected,   // derived == Any(quote.IsSelected)
        };
        db.PurchaseEvaluationSuppliers.Add(row);
        db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
        {
            Id = Guid.NewGuid(),
            PurchaseEvaluationDetailId = detailId,
            PurchaseEvaluationSupplierId = row.Id,
            ThanhTien = thanhTien,
            IsSelected = isSelected,
        });
        await db.SaveChangesAsync(CancellationToken.None);
        return row.Id;
    }

    // Participating-row THUẦN (không quote) — cho test derive-invariant qua UpsertQuote.
    private static async Task<Guid> AddParticipatingNoQuoteAsync(
        TestApplicationDbContext db, Guid peId, Guid supplierId, int order = 0)
    {
        var row = new PurchaseEvaluationSupplier
        {
            Id = Guid.NewGuid(),
            PurchaseEvaluationId = peId,
            SupplierId = supplierId,
            Order = order,
        };
        db.PurchaseEvaluationSuppliers.Add(row);
        await db.SaveChangesAsync(CancellationToken.None);
        return row.Id;
    }

    // Re-read IsWinner cho 1 supplier-row (theo SupplierId) — AsNoTracking để né cache.
    private static async Task<bool> IsWinnerAsync(
        TestApplicationDbContext db, Guid peId, Guid supplierId)
        => await db.PurchaseEvaluationSuppliers.AsNoTracking()
            .Where(s => s.PurchaseEvaluationId == peId && s.SupplierId == supplierId)
            .Select(s => s.IsWinner)
            .FirstAsync();

    // ============================================================
    // 1. CLEAR branch ([] = bỏ chọn cho hạng mục — trục quan trọng nhất)
    // ============================================================

    [Fact]
    public async Task Clear_PhieuHasSelectedWinner_SetsSelectedSupplierIdNull_AllRowsNotWinner()
    {
        // ⭐ Phiếu đang có 1 winner (s1, quote IsSelected=true → IsWinner=true) + s1 trong
        // participating-list → gọi SupplierIds=[] cho hạng mục → clear: quote IsSelected=false,
        // IsWinner derive=false, SelectedSupplierId=null.
        using var fix = new SqliteDbFixture();
        var db = fix.Db;
        var s1 = await SeedSupplierAsync(db, "NCC-A");
        var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
        var detailId = await SeedDetailAsync(db, pe.Id);
        await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true);
        var handler = BuildHandler(db);

        var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: Array.Empty<Guid>());
        await handler.Handle(cmd, CancellationToken.None);

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.SelectedSupplierId.Should().BeNull("bỏ chọn = clear winner về none");
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("clear → quote IsSelected=false → IsWinner=false");
    }

    [Fact]
    public async Task Clear_EmptyList_AllRowsNotWinner_NoConflict()
    {
        // Trục cốt lõi nhánh clear: SKIP validate participating-list (list rỗng = không có id
        // cần kiểm). Phiếu 2 NCC tham gia, s1 winner (quote sel=true) → clear [] cho hạng mục →
        // KHÔNG ném + cả 2 IsWinner=false + SelectedSupplierId=null.
        using var fix = new SqliteDbFixture();
        var db = fix.Db;
        var s1 = await SeedSupplierAsync(db, "NCC-1");
        var s2 = await SeedSupplierAsync(db, "NCC-2");
        var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
        var detailId = await SeedDetailAsync(db, pe.Id);
        await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true, order: 0);
        await AddParticipatingWithQuoteAsync(db, pe.Id, s2.Id, detailId, isSelected: false, order: 1);
        var handler = BuildHandler(db);

        var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: Array.Empty<Guid>());
        var act = async () => await handler.Handle(cmd, CancellationToken.None);

        await act.Should().NotThrowAsync("clear [] KHÔNG validate participating-list");
        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.SelectedSupplierId.Should().BeNull();
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse();
        (await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeFalse();
    }

    [Fact]
    public async Task Clear_PhieuAlreadyNull_IsIdempotent_StaysNull_NoThrow()
    {
        // Phiếu vốn-dĩ chưa chọn winner (SelectedSupplierId=null, không quote IsSelected) →
        // clear [] cho hạng mục lần nữa = idempotent, vẫn null, không ném. Cần seed detail
        // (handler load Detail thuộc phiếu). Không có supplier/quote.
        using var fix = new SqliteDbFixture();
        var db = fix.Db;
        var pe = await SeedPeAsync(db, selectedSupplierId: null);
        var detailId = await SeedDetailAsync(db, pe.Id);
        var handler = BuildHandler(db);

        var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: Array.Empty<Guid>());
        var act = async () => await handler.Handle(cmd, CancellationToken.None);

        await act.Should().NotThrowAsync();
        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.SelectedSupplierId.Should().BeNull();
    }

    [Fact]
    public async Task Clear_WritesChangelog_WithBoChonSummary()
    {
        // Nhánh clear ghi 1 Changelog Header/Update Summary "Bỏ chọn NCC/TP trúng thầu cho
        // hạng mục {GroupCode}" (phân biệt với "Chọn {n}..." của nhánh select).
        using var fix = new SqliteDbFixture();
        var db = fix.Db;
        var s1 = await SeedSupplierAsync(db, "NCC-A");
        var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
        var detailId = await SeedDetailAsync(db, pe.Id);
        await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true);
        var handler = BuildHandler(db);

        var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: Array.Empty<Guid>());
        await handler.Handle(cmd, CancellationToken.None);

        var log = await db.PurchaseEvaluationChangelogs.AsNoTracking()
            .Where(c => c.PurchaseEvaluationId == pe.Id)
            .SingleAsync();
        log.Summary.Should().Be($"Bỏ chọn NCC/TP trúng thầu cho hạng mục {DetailGroupCode}");
        log.EntityType.Should().Be(PurchaseEvaluationEntityType.Header);
        log.Action.Should().Be(ChangelogAction.Update);
    }

    // ============================================================
    // 2. SELECT branch — 1 winner (backward compat single-winner)
    // ============================================================

    [Fact]
    public async Task Select_OneSupplierInList_SetsSelectedSupplierId_RowIsWinner()
    {
        // Happy-path single-winner: 1 supplier hợp lệ (trong list, có quote cho hạng mục) →
        // quote IsSelected=true → IsWinner=true + SelectedSupplierId = đúng id (count==1 sync).
        using var fix = new SqliteDbFixture();
        var db = fix.Db;
        var s1 = await SeedSupplierAsync(db, "NCC-WIN");
        var pe = await SeedPeAsync(db, selectedSupplierId: null);
        var detailId = await SeedDetailAsync(db, pe.Id);
        await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false);
        var handler = BuildHandler(db);

        var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s1.Id });
        await handler.Handle(cmd, CancellationToken.None);

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.SelectedSupplierId.Should().Be(s1.Id, "đúng 1 winner → SelectedSupplierId sync");
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue();
    }

    [Fact]
    public async Task Select_ChangeWinner_FromS1ToS2_BothInList_UpdatesToS2_OnlyS2IsWinner()
    {
        // Đổi winner: phiếu đang chọn s1 (quote sel=true) → chọn [s2] cho hạng mục → s2 quote
        // IsSelected=true + s1 quote IsSelected=false → SelectedSupplierId=s2 + s2.IsWinner=true
        // + s1.IsWinner=false.
        using var fix = new SqliteDbFixture();
        var db = fix.Db;
        var s1 = await SeedSupplierAsync(db, "NCC-1");
        var s2 = await SeedSupplierAsync(db, "NCC-2");
        var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
        var detailId = await SeedDetailAsync(db, pe.Id);
        await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true, order: 0);
        await AddParticipatingWithQuoteAsync(db, pe.Id, s2.Id, detailId, isSelected: false, order: 1);
        var handler = BuildHandler(db);

        var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s2.Id });
        await handler.Handle(cmd, CancellationToken.None);

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.SelectedSupplierId.Should().Be(s2.Id);
        (await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue("s2 là winner mới");
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("winner cũ s1 bị set false");
    }

    [Fact]
    public async Task Select_SupplierNotInParticipatingList_ThrowsConflict_NoMutate()
    {
        // Supplier không thuộc participating-list → Conflict. IsWinner + SelectedSupplierId giữ
        // nguyên (throw TRƯỚC khi flip IsSelected + SaveChanges).
        using var fix = new SqliteDbFixture();
        var db = fix.Db;
        var s1 = await SeedSupplierAsync(db, "NCC-IN");      // trong list (winner)
        var outsider = await SeedSupplierAsync(db, "NCC-OUT"); // không trong list
        var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
        var detailId = await SeedDetailAsync(db, pe.Id);
        await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true);
        var handler = BuildHandler(db);

        var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { outsider.Id });
        var act = async () => await handler.Handle(cmd, CancellationToken.None);

        await act.Should().ThrowAsync<ConflictException>()
            .WithMessage("*NCC chưa được thêm vào phiếu*");

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.SelectedSupplierId.Should().Be(s1.Id, "Conflict TRƯỚC mutate → winner cũ s1 giữ nguyên");
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("Conflict → IsWinner s1 không bị set false");
    }

    [Fact]
    public async Task Select_OneIdInListOneOutside_ThrowsConflict_NoPartialMutate()
    {
        // Multi-select với 1 id ngoài list (s1 trong, outsider ngoài) → Conflict. KHÔNG
        // partial-write: s1.IsWinner KHÔNG bị set true dù hợp lệ (validate TRƯỚC khi flip IsSelected).
        using var fix = new SqliteDbFixture();
        var db = fix.Db;
        var s1 = await SeedSupplierAsync(db, "NCC-OK");
        var outsider = await SeedSupplierAsync(db, "NCC-FORGE");
        var pe = await SeedPeAsync(db, selectedSupplierId: null);
        var detailId = await SeedDetailAsync(db, pe.Id);
        await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false);
        var handler = BuildHandler(db);

        var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s1.Id, outsider.Id });
        var act = async () => await handler.Handle(cmd, CancellationToken.None);

        await act.Should().ThrowAsync<ConflictException>()
            .WithMessage("*NCC chưa được thêm vào phiếu*");
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("Conflict → KHÔNG set winner cho id hợp lệ trong cùng lệnh");
    }

    // ============================================================
    // 3. SELECT branch — MULTI winner CÙNG HẠNG MỤC (≥2 → joint, SelectedSupplierId=null)
    //    [multi-NCC] ≥2 NCC/TP trúng CÙNG 1 hạng mục.
    // ============================================================

    [Fact]
    public async Task Select_TwoSuppliers_BothIsWinner_SelectedSupplierIdNull()
    {
        // Chọn 2 đơn vị cùng trúng 1 hạng mục → cả 2 quote IsSelected=true → cả 2 IsWinner=true,
        // SelectedSupplierId=null (≥2 → không "1 winner" duy nhất). Trục multi-NCC cốt lõi.
        using var fix = new SqliteDbFixture();
        var db = fix.Db;
        var s1 = await SeedSupplierAsync(db, "NCC-J1");
        var s2 = await SeedSupplierAsync(db, "NCC-J2");
        var pe = await SeedPeAsync(db, selectedSupplierId: null);
        var detailId = await SeedDetailAsync(db, pe.Id);
        await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false, order: 0);
        await AddParticipatingWithQuoteAsync(db, pe.Id, s2.Id, detailId, isSelected: false, order: 1);
        var handler = BuildHandler(db);

        var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s1.Id, s2.Id });
        await handler.Handle(cmd, CancellationToken.None);

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.SelectedSupplierId.Should().BeNull("≥2 winner → không sync 1 SelectedSupplierId");
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue();
        (await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue();
    }

    [Fact]
    public async Task Select_ThreeOfFour_ThreeIsWinner_NonSelectedIsFalse_SelectedSupplierIdNull()
    {
        // 4 NCC chào cùng hạng mục, chọn 3 → đúng 3 quote IsSelected=true (3 IsWinner), NCC thứ 4
        // quote IsSelected=false (IsWinner=false), SelectedSupplierId=null.
        using var fix = new SqliteDbFixture();
        var db = fix.Db;
        var s1 = await SeedSupplierAsync(db, "NCC-Q1");
        var s2 = await SeedSupplierAsync(db, "NCC-Q2");
        var s3 = await SeedSupplierAsync(db, "NCC-Q3");
        var s4 = await SeedSupplierAsync(db, "NCC-Q4");
        var pe = await SeedPeAsync(db, selectedSupplierId: null);
        var detailId = await SeedDetailAsync(db, pe.Id);
        await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false, order: 0);
        await AddParticipatingWithQuoteAsync(db, pe.Id, s2.Id, detailId, isSelected: false, order: 1);
        await AddParticipatingWithQuoteAsync(db, pe.Id, s3.Id, detailId, isSelected: false, order: 2);
        await AddParticipatingWithQuoteAsync(db, pe.Id, s4.Id, detailId, isSelected: false, order: 3);
        var handler = BuildHandler(db);

        var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s1.Id, s2.Id, s3.Id });
        await handler.Handle(cmd, CancellationToken.None);

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.SelectedSupplierId.Should().BeNull();
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue();
        (await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue();
        (await IsWinnerAsync(db, pe.Id, s3.Id)).Should().BeTrue();
        (await IsWinnerAsync(db, pe.Id, s4.Id)).Should().BeFalse("NCC không được chọn → IsWinner=false");
    }

    [Fact]
    public async Task Select_DuplicateIds_Deduped_TreatedAsSingleWinner_SelectedSupplierIdSet()
    {
        // FE gửi trùng id [s1, s1] → Distinct → count==1 → single-winner: s1.IsWinner=true +
        // SelectedSupplierId=s1 (KHÔNG bị coi là ≥2 do trùng).
        using var fix = new SqliteDbFixture();
        var db = fix.Db;
        var s1 = await SeedSupplierAsync(db, "NCC-DUP");
        var pe = await SeedPeAsync(db, selectedSupplierId: null);
        var detailId = await SeedDetailAsync(db, pe.Id);
        await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false);
        var handler = BuildHandler(db);

        var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s1.Id, s1.Id });
        await handler.Handle(cmd, CancellationToken.None);

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.SelectedSupplierId.Should().Be(s1.Id, "trùng id Distinct → count==1 → sync SelectedSupplierId");
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue();
    }

    [Fact]
    public async Task Select_MultiThenClear_RoundTrip_AllWinnersReset()
    {
        // Round-trip: chọn 2 (joint) → rồi clear [] → cả 2 IsWinner về false + SelectedSupplierId=null.
        using var fix = new SqliteDbFixture();
        var db = fix.Db;
        var s1 = await SeedSupplierAsync(db, "NCC-R1");
        var s2 = await SeedSupplierAsync(db, "NCC-R2");
        var pe = await SeedPeAsync(db, selectedSupplierId: null);
        var detailId = await SeedDetailAsync(db, pe.Id);
        await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false, order: 0);
        await AddParticipatingWithQuoteAsync(db, pe.Id, s2.Id, detailId, isSelected: false, order: 1);
        var handler = BuildHandler(db);

        await handler.Handle(
            new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s1.Id, s2.Id }),
            CancellationToken.None);
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("sau chọn 2");
        (await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue();

        await handler.Handle(
            new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: Array.Empty<Guid>()),
            CancellationToken.None);

        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.SelectedSupplierId.Should().BeNull();
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("clear sau multi → false hết");
        (await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeFalse();
    }

    [Fact]
    public async Task Select_MultiWinner_WritesChangelog_WithChonNSummary()
    {
        // Multi-winner ghi Changelog Summary "Chọn {n} NCC/TP trúng thầu cho hạng mục {GroupCode}".
        using var fix = new SqliteDbFixture();
        var db = fix.Db;
        var s1 = await SeedSupplierAsync(db, "NCC-CL1");
        var s2 = await SeedSupplierAsync(db, "NCC-CL2");
        var pe = await SeedPeAsync(db, selectedSupplierId: null);
        var detailId = await SeedDetailAsync(db, pe.Id);
        await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false, order: 0);
        await AddParticipatingWithQuoteAsync(db, pe.Id, s2.Id, detailId, isSelected: false, order: 1);
        var handler = BuildHandler(db);

        await handler.Handle(
            new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s1.Id, s2.Id }),
            CancellationToken.None);

        var log = await db.PurchaseEvaluationChangelogs.AsNoTracking()
            .Where(c => c.PurchaseEvaluationId == pe.Id)
            .SingleAsync();
        log.Summary.Should().Be($"Chọn 2 NCC/TP trúng thầu cho hạng mục {DetailGroupCode}");
        log.EntityType.Should().Be(PurchaseEvaluationEntityType.Header);
        log.Action.Should().Be(ChangelogAction.Update);
    }

    // ============================================================
    // 4. Existence guards — PE + Detail (check TRƯỚC khi rẽ nhánh SupplierIds)
    // ============================================================

    [Fact]
    public async Task UnknownPe_Clear_ThrowsNotFound_BeforeBranch()
    {
        // PE không tồn tại → NotFound("PurchaseEvaluation") ngay đầu handler (TRƯỚC Detail + SupplierIds).
        using var fix = new SqliteDbFixture();
        var db = fix.Db;
        var handler = BuildHandler(db);

        var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), Guid.NewGuid(), SupplierIds: Array.Empty<Guid>());
        var act = async () => await handler.Handle(cmd, CancellationToken.None);

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

    [Fact]
    public async Task UnknownPe_Select_ThrowsNotFound_BeforeBranch()
    {
        using var fix = new SqliteDbFixture();
        var db = fix.Db;
        var s1 = await SeedSupplierAsync(db, "NCC-A");
        var handler = BuildHandler(db);

        var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), Guid.NewGuid(), SupplierIds: new[] { s1.Id });
        var act = async () => await handler.Handle(cmd, CancellationToken.None);

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

    [Fact]
    public async Task UnknownDetail_NotBelongingToPhieu_ThrowsNotFound()
    {
        // [multi-NCC] Detail phải thuộc phiếu — DetailId lạ (random Guid) → NotFound("PurchaseEvaluationDetail").
        // Chặn cross-phiếu / stale detail. Kiểm SAU PE-existence, TRƯỚC supplier validate.
        using var fix = new SqliteDbFixture();
        var db = fix.Db;
        var s1 = await SeedSupplierAsync(db, "NCC-A");
        var pe = await SeedPeAsync(db, selectedSupplierId: null);
        var validDetailId = await SeedDetailAsync(db, pe.Id);   // phiếu CÓ 1 detail hợp lệ
        await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, validDetailId);
        var handler = BuildHandler(db);

        // cmd truyền DetailId LẠ (không thuộc phiếu) → NotFound TRƯỚC supplier validate.
        var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, Guid.NewGuid(), SupplierIds: new[] { s1.Id });
        var act = async () => await handler.Handle(cmd, CancellationToken.None);

        await act.Should().ThrowAsync<NotFoundException>()
            .WithMessage("*PurchaseEvaluationDetail*");
    }

    // ============================================================
    // 5. [multi-NCC NEW test (d)] DERIVE INVARIANT: Supplier.IsWinner == Any(Quote.IsSelected)
    //    duy trì qua UpsertQuote + SelectWinner + seed. Nguồn-sự-thật = Quote.IsSelected.
    // ============================================================

    [Fact]
    public async Task DeriveInvariant_IsWinnerEqualsAnySelectedQuote_AfterUpsertQuoteAndSelectWinner()
    {
        using var fix = new SqliteDbFixture();
        var db = fix.Db;
        var s1 = await SeedSupplierAsync(db, "NCC-INV1");
        var s2 = await SeedSupplierAsync(db, "NCC-INV2");
        var pe = await SeedPeAsync(db, selectedSupplierId: null);
        var detailId = await SeedDetailAsync(db, pe.Id);
        var s1Row = await AddParticipatingNoQuoteAsync(db, pe.Id, s1.Id, order: 0);
        var s2Row = await AddParticipatingNoQuoteAsync(db, pe.Id, s2.Id, order: 1);

        var upsert = BuildUpsertQuoteHandler(db);
        var select = BuildHandler(db);

        // Step 1 — UpsertQuote (Detail × s1) IsSelected=true → handler set s1.IsWinner=true.
        await upsert.Handle(new UpsertPurchaseEvaluationQuoteCommand(
            pe.Id, detailId, s1Row, BgVat: 0m, ChuaVat: 0m, ThanhTien: 100m, IsSelected: true, Note: null),
            CancellationToken.None);
        await AssertWinnerInvariantAsync(db, pe.Id);
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("UpsertQuote IsSelected=true → derive IsWinner=true");

        // Step 2 — UpsertQuote (Detail × s2) IsSelected=false → s2.IsWinner=false.
        await upsert.Handle(new UpsertPurchaseEvaluationQuoteCommand(
            pe.Id, detailId, s2Row, BgVat: 0m, ChuaVat: 0m, ThanhTien: 200m, IsSelected: false, Note: null),
            CancellationToken.None);
        await AssertWinnerInvariantAsync(db, pe.Id);
        (await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeFalse("UpsertQuote IsSelected=false → IsWinner=false");

        // Step 3 — SelectWinner [s2] cho hạng mục → s2 quote IsSelected=true, s1 quote IsSelected=false.
        await select.Handle(
            new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s2.Id }),
            CancellationToken.None);
        await AssertWinnerInvariantAsync(db, pe.Id);
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("SelectWinner đổi sang s2 → s1 quote IsSelected=false → IsWinner=false");
        (await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue("s2 quote IsSelected=true → IsWinner=true");

        // Step 4 — SelectWinner [] clear → cả 2 quote IsSelected=false → cả 2 IsWinner=false.
        await select.Handle(
            new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: Array.Empty<Guid>()),
            CancellationToken.None);
        await AssertWinnerInvariantAsync(db, pe.Id);
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse();
        (await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeFalse();
    }

    // ============================================================
    // [S114 Bước 4 verify — MUST/SHOULD] Delete-path re-derive IsWinner (writer bị sót ở implement)
    // ============================================================

    [Fact]
    public async Task DeleteQuote_LastSelectedQuoteOfSupplier_ClearsIsWinner_NoPhantom()
    {
        // ⭐ BUG (Bước 4): DeleteQuoteHandler Remove(quote) KHÔNG re-derive IsWinner → xóa quote
        // IsSelected CUỐI của 1 NCC để IsWinner kẹt=true trong khi Any(IsSelected)=false → phantom
        // winner → CreateContract sinh HĐ giaTri=0. Test-before: assert IsWinner về false + invariant.
        using var fix = new SqliteDbFixture();
        var db = fix.Db;
        var s1 = await SeedSupplierAsync(db, "NCC-DQ");
        var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
        var detailId = await SeedDetailAsync(db, pe.Id);
        var rowId = await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true);
        var quoteId = await db.PurchaseEvaluationQuotes.AsNoTracking()
            .Where(q => q.PurchaseEvaluationSupplierId == rowId).Select(q => q.Id).FirstAsync();
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("pre-state: quote IsSelected=true → IsWinner=true");

        var del = new DeletePurchaseEvaluationQuoteCommandHandler(db, new FakeCurrentUser());
        await del.Handle(new DeletePurchaseEvaluationQuoteCommand(pe.Id, quoteId), CancellationToken.None);

        await AssertWinnerInvariantAsync(db, pe.Id);
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("xóa quote-selected cuối → IsWinner về false");
        var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
        reloaded.SelectedSupplierId.Should().BeNull("0 winner → SelectedSupplierId=null");
    }

    [Fact]
    public async Task DeleteDetail_WithSupplierLastSelectedQuote_ClearsIsWinner()
    {
        // ⭐ BUG (Bước 4): DeleteDetailHandler Remove(entity) cascade xóa quotes NHƯNG không re-derive
        // IsWinner → NCC mất quote-selected cuối bị kẹt IsWinner=true. Test-before.
        using var fix = new SqliteDbFixture();
        var db = fix.Db;
        var s1 = await SeedSupplierAsync(db, "NCC-DD");
        var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
        var detailId = await SeedDetailAsync(db, pe.Id);
        await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true);
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("pre-state IsWinner=true");

        var del = new DeletePurchaseEvaluationDetailCommandHandler(db, new FakeCurrentUser());
        await del.Handle(new DeletePurchaseEvaluationDetailCommand(pe.Id, detailId), CancellationToken.None);

        await AssertWinnerInvariantAsync(db, pe.Id);
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("xóa hạng mục chứa quote-selected cuối → IsWinner=false");
    }

    [Fact]
    public async Task DeleteDetail_SupplierStillWinsAnotherDetail_KeepsIsWinner()
    {
        // Guard: re-derive per-PHIẾU (không over-clear). Xóa 1 hạng mục nhưng NCC còn thắng hạng
        // mục KHÁC → IsWinner GIỮ true.
        using var fix = new SqliteDbFixture();
        var db = fix.Db;
        var s1 = await SeedSupplierAsync(db, "NCC-2D");
        var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
        var detailA = await SeedDetailAsync(db, pe.Id, "A.I");
        var detailB = await SeedDetailAsync(db, pe.Id, "B.I");
        var rowId = await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailA, isSelected: true);
        var up = BuildUpsertQuoteHandler(db);
        await up.Handle(new UpsertPurchaseEvaluationQuoteCommand(pe.Id, detailB, rowId, 0, 0, 50m, true, null), CancellationToken.None);
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue();

        var del = new DeletePurchaseEvaluationDetailCommandHandler(db, new FakeCurrentUser());
        await del.Handle(new DeletePurchaseEvaluationDetailCommand(pe.Id, detailA), CancellationToken.None);

        await AssertWinnerInvariantAsync(db, pe.Id);
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("vẫn thắng detailB → IsWinner giữ true");
    }

    [Fact]
    public async Task UpsertQuote_UpdateBranch_TogglesIsWinnerCorrectly()
    {
        // [Bước 4 SHOULD] Coverage-gap: UPDATE-path (quote đã tồn tại) re-derive dùng loại-trừ
        // q.Id != existing.Id (né stale-DB-read). Test-before đã có, giữ để cover UPDATE branch.
        using var fix = new SqliteDbFixture();
        var db = fix.Db;
        var s1 = await SeedSupplierAsync(db, "NCC-UP");
        var pe = await SeedPeAsync(db, selectedSupplierId: null);
        var detailId = await SeedDetailAsync(db, pe.Id);
        var rowId = await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false);
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("quote tồn tại nhưng chưa selected");
        var up = BuildUpsertQuoteHandler(db);

        await up.Handle(new UpsertPurchaseEvaluationQuoteCommand(pe.Id, detailId, rowId, 0, 0, 100m, true, null), CancellationToken.None);
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("UPDATE IsSelected=true → IsWinner=true");
        await AssertWinnerInvariantAsync(db, pe.Id);

        await up.Handle(new UpsertPurchaseEvaluationQuoteCommand(pe.Id, detailId, rowId, 0, 0, 100m, false, null), CancellationToken.None);
        (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("UPDATE IsSelected=false (loại chính nó) → IsWinner=false");
        await AssertWinnerInvariantAsync(db, pe.Id);
    }

    // Invariant checker: MỌI supplier-row → IsWinner == (tồn tại ÍT NHẤT 1 quote IsSelected của row).
    private static async Task AssertWinnerInvariantAsync(TestApplicationDbContext db, Guid peId)
    {
        var rows = await db.PurchaseEvaluationSuppliers.AsNoTracking()
            .Where(s => s.PurchaseEvaluationId == peId).ToListAsync();
        foreach (var r in rows)
        {
            var anySelected = await db.PurchaseEvaluationQuotes.AsNoTracking()
                .AnyAsync(q => q.PurchaseEvaluationSupplierId == r.Id && q.IsSelected);
            r.IsWinner.Should().Be(anySelected,
                $"invariant Supplier.IsWinner == Any(Quote.IsSelected) cho row SupplierId={r.SupplierId}");
        }
    }
}
