using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.ContractSigningPlans;
using SolutionErp.Application.Contracts;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans;
using SolutionErp.Domain.Contracts;
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;

namespace SolutionErp.Infrastructure.Tests.Application;

// [K7 — S167 2026-08-01] CẦU GĐ2 → GĐ3: `CreateContractFromSigningPlanCommandHandler`
// (`Application/ContractSigningPlans/CreateContractFromSigningPlanFeatures.cs`, file MỚI của
// lane BE cùng đợt). Test-after: BE land trước, đây là lưới khoá hành-vi.
//
// ── Đo cái gì (map bảng T1-T8 `sub-invest-fable-b2-cum3.md:75-82` + 4 vá spec-cụm-3 §②) ──
//   T1 happy 1 dòng (trọn field-map + 2 changelog + write-site)   T2 gộp 2 dòng CÙNG NCC
//   T3 chưa có giá chốt → chặn, KHÔNG đốt mã                       T4 phiếu chưa duyệt xong
//   T5 dòng đã có HĐ (double-bridge)                               T6 2 dòng KHÁC NCC
//   T7 quy trình pin sai — 4 nhánh + ĐỐI CHỨNG DƯƠNG cùng lượt     T8 mối nối cầu → đường-ống V2
//   T9 dòng-của-phiếu-khác + rào tập LineIds                       T10 bridge lần 2 dòng khác (`??=` CÓ CHIỀU)
//   T11 double-click cùng tập dòng (idempotency)
//
// ── 3 quyết định hạ tầng (điều kiện để phép đo có nghĩa) ──────────────────────────────
//
// (1) `IdentityFixture` chứ không phải fixture KHKK riêng: `ContractWorkflowService` (deps của
//     handler cầu, và là máy chạy T8) BẮT BUỘC `UserManager<User>` trong ctor. Khuôn sao y
//     bridge PE cũ `CreateContractFromEvaluationMultiWinnerTests.BuildHandler`.
// (2) DÙNG DEPS THẬT (`ContractCodeGenerator` + `ContractWorkflowService` + service KHKK thật ở
//     T9-B6), KHÔNG stub: mã HĐ RG-001 và `GetPhaseSla` là hai thứ vá-6 yêu cầu — stub chúng thì
//     phép đo tự trả lời chính mình. `BeginTransactionAsync(Serializable)` chạy sạch trên SQLite
//     (tiền lệ `SqliteDbFixture.cs:36-42` + codegen tests) — KHÔNG try/skip.
// (3) `ChangeTracker.Clear()` giữa 2 lần bấm (T10/T11): prod là 2 request ⇒ 2 DbContext scoped.
//     Giữ nguyên context đã track dòng = một đường đi KHÔNG có thật, và nó CHE mất câu hỏi
//     "đọc lại từ DB có thấy `ContractId` đã ghi không".
//
// ── 🔴 LỆCH SPEC ĐÃ ĐO, test theo CODE (single source of truth) ────────────────────────
//   Bảng invest T1 (`:75`) ghi kỳ vọng `WorkflowDefinitionId null`. **Vá-6** của spec-cụm-3
//   (`spec-cum3-chi-tiet-31-07-2026.md:16`) ĐẢO lại: pin V1 như 2 tiền lệ, và spec `:4` chốt
//   "16 vá THẮNG khi xung đột với nền". Mã đang theo vá-6 (`:163-166` + `:201`).
//   ⇒ T1 assert **pin ĐÚNG bản active của loại HĐ đó**, và assert luôn vế PHỦ ĐỊNH (không phải
//   bản inactive / không phải bản của loại khác) — nếu chỉ assert `!= null` thì `activeWfId` lấy
//   nhầm bản nào cũng xanh.
public class CreateContractFromSigningPlanTests
{
    private static readonly DateTime Clock = new(2026, 8, 1, 3, 0, 0, DateTimeKind.Utc);

    // ===================================================================
    // Handler dưới test + deps THẬT (mirror wiring prod ContractSigningPlansController → MediatR)
    // ===================================================================
    private static CreateContractFromSigningPlanCommandHandler BuildHandler(
        TestApplicationDbContext db, IServiceProvider sp, User actor, params string[] roles)
    {
        var um = sp.GetRequiredService<UserManager<User>>();
        var dt = new FixedDateTime(Clock);
        var currentUser = new TestCurrentUser(actor.Id, actor.FullName, actor.Email,
            roles.Length > 0 ? roles : [AppRoles.Drafter]);
        var changelog = new ChangelogService(db, currentUser, um);
        var codeGen = new ContractCodeGenerator(db, dt);
        var workflowSvc = new ContractWorkflowService(
            db, codeGen, dt, new NoOpNotificationServiceApp(), changelog, um);
        return new CreateContractFromSigningPlanCommandHandler(db, currentUser, workflowSvc, codeGen);
    }

    private const string ProjectCode = "FLOCK01";
    private const string SupplierCode = "PVL";

    private sealed record Seeded(
        ContractSigningPlan Plan,
        PurchaseEvaluation Pe,
        Project Project,
        List<Supplier> Suppliers,
        Guid ContractWorkflowId,      // V2 `ApplicableType = Contract(3)` — hợp lệ
        Guid ActiveWorkflowDefId,     // V1 active ĐÚNG loại HĐ dùng trong test
        User Actor);

    /// Mỗi phần tử = 1 dòng của phiếu. `SupplierIndex` trỏ vào `Suppliers` đã seed.
    private sealed record LineSeed(int SupplierIndex, decimal PeReference, decimal Proposed, decimal? Approved);

    private const ContractType TestType = ContractType.HopDongThauPhu;

    /// Seed đủ 1 thế giới nhỏ nhưng THẬT: phiếu KHKK + PE nguồn + Project/Supplier CÓ `Code`
    /// (cầu nạp 2 bảng này cho codegen, thiếu = `NotFoundException`) + 1 quy trình V2 loại HĐ
    /// hợp lệ + 1 `WorkflowDefinition` V1 active của `TestType`.
    ///
    /// 🔴 MỖI dòng 1 `CatalogEntryId` RIÊNG: UNIQUE `(PlanId, SupplierId, CatalogEntryId)` filtered
    /// (`ContractSigningPlanLineConfiguration.cs:39-41`, K2 Mig 71). Chính vì Mig 71 đổi unique từ
    /// (Plan, Supplier) sang bộ-3 mà **T2 gộp 2 dòng cùng NCC mới tồn tại được** (vá-5 spec-cụm-3).
    private static async Task<Seeded> SeedAsync(
        IdentityFixture fix,
        TestApplicationDbContext db,
        LineSeed[] lines,
        int supplierCount = 1,
        ContractSigningPlanPhase phase = ContractSigningPlanPhase.DaDuyet,
        string maKeHoach = "KHKK/2026/001")
    {
        // Hậu tố ngẫu nhiên cho MỌI mã: `Projects.Code` / `Suppliers.Code` / `ApprovalWorkflows`
        // (Code,Version) / `WorkflowDefinitions` (Code,Version) đều UNIQUE ⇒ T9 gọi `SeedAsync`
        // HAI LẦN trên cùng DB sẽ nổ SQLite 19 nếu mã trùng.
        var tag = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();

        var actor = await fix.CreateUserAsync(
            $"drafter-k7-{Guid.NewGuid():N}@test.local", "Người soạn K7",
            departmentId: null, roles: [AppRoles.Drafter]);

        var project = new Project
        {
            Id = Guid.NewGuid(), Code = $"{ProjectCode}{tag}", Name = "Dự án FLOCK 01",
        };
        db.Projects.Add(project);

        var suppliers = new List<Supplier>();
        for (var i = 0; i < supplierCount; i++)
        {
            var s = new Supplier
            {
                Id = Guid.NewGuid(),
                // NCC[0] mang mã dùng để soi mã HĐ (T1); các NCC sau khác mã để không lẫn.
                Code = i == 0 ? $"{SupplierCode}{tag}" : $"NCC{i}{tag}",
                Name = $"Nhà cung cấp {i + 1}",
                Type = SupplierType.NhaThauPhu,
            };
            suppliers.Add(s);
            db.Suppliers.Add(s);
        }

        var pe = new PurchaseEvaluation
        {
            Id = Guid.NewGuid(),
            MaPhieu = $"PE-2026-K7-{tag}",
            Type = PurchaseEvaluationType.DuyetNccPhuongAn,
            Phase = PurchaseEvaluationPhase.DaDuyet,
            TenGoiThau = "Gói thầu K7",
            ProjectId = project.Id,
            DrafterUserId = actor.Id,
            MoTa = "Mô tả gói thầu K7",
        };
        db.PurchaseEvaluations.Add(pe);

        var plan = new ContractSigningPlan
        {
            Id = Guid.NewGuid(),
            MaKeHoach = maKeHoach,
            PurchaseEvaluationId = pe.Id,
            ProjectId = project.Id,
            DepartmentId = Guid.NewGuid(),
            DrafterUserId = actor.Id,
            Phase = phase,
            ApprovalGroup = 1,
        };
        foreach (var l in lines)
        {
            var catalog = new ContractCatalogEntry
            {
                Id = Guid.NewGuid(),
                Code = "K7-" + Guid.NewGuid().ToString("N")[..8],
                TenVi = $"Hạng mục K7 #{plan.Lines.Count + 1}",
                GroupCode = "B1",
                ApprovalGroup = 1,
                SignerRole = "TP.PRO",
                IsActive = true,
                SortOrder = 1,
            };
            db.ContractCatalogEntries.Add(catalog);
            plan.Lines.Add(new ContractSigningPlanLine
            {
                Id = Guid.NewGuid(),
                ContractSigningPlanId = plan.Id,
                SupplierId = suppliers[l.SupplierIndex].Id,
                CatalogEntryId = catalog.Id,
                TenHangMuc = catalog.TenVi,
                PeReferenceAmount = l.PeReference,
                ProposedAmount = l.Proposed,
                ApprovedAmount = l.Approved,
            });
        }
        db.ContractSigningPlans.Add(plan);

        var aw = NewContractWorkflow($"QT-HD-V2-K7-{tag}");
        db.ApprovalWorkflows.Add(aw);

        // V1 active của ĐÚNG `TestType` + 1 bản NHIỄU (loại khác) để vế pin của T1 không thể
        // xanh nhờ "chỉ có mỗi một bản trong bảng".
        var activeDef = new WorkflowDefinition
        {
            Id = Guid.NewGuid(), Code = $"QT-TP-{tag}", Version = 1,
            ContractType = TestType, Name = "QT Thầu phụ v01", IsActive = true,
        };
        db.WorkflowDefinitions.Add(activeDef);
        db.WorkflowDefinitions.Add(new WorkflowDefinition
        {
            Id = Guid.NewGuid(), Code = $"QT-MB-{tag}", Version = 1,
            ContractType = ContractType.HopDongMuaBan, Name = "QT Mua bán v01", IsActive = true,
        });

        await db.SaveChangesAsync(CancellationToken.None);
        return new Seeded(plan, pe, project, suppliers, aw.Id, activeDef.Id, actor);
    }

    private static ApprovalWorkflow NewContractWorkflow(
        string code, bool isActive = true, bool isUserSelectable = true,
        ApprovalWorkflowApplicableType type = ApprovalWorkflowApplicableType.Contract)
        => new()
        {
            Id = Guid.NewGuid(),
            Code = code,
            Version = 1,
            Name = $"Quy trình {code}",
            ApplicableType = type,
            IsActive = isActive,
            IsUserSelectable = isUserSelectable,
        };

    private static CreateContractFromSigningPlanCommand Cmd(
        Seeded s, IEnumerable<Guid> lineIds, Guid? workflowId = null, string? ten = null)
        => new(s.Plan.Id, lineIds.ToList(), TestType, workflowId ?? s.ContractWorkflowId, ten);

    private static Guid LineId(Seeded s, int index) => s.Plan.Lines[index].Id;

    // ===================================================================
    // T1 — Happy 1 dòng: trọn field-map + write-site + 2 changelog.
    // ===================================================================
    [Fact]
    public async Task T1_MotDong_TaoDungMotHopDong_VaGhiNguocContractId()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var s = await SeedAsync(fix, db, [new LineSeed(0, PeReference: 900m, Proposed: 999m, Approved: 750m)]);
        var handler = BuildHandler(db, fix.Services, s.Actor);

        var result = await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);

        var c = await db.Contracts.AsNoTracking().SingleAsync(x => x.Id == result.ContractId);

        // ---- Tiền + trạng thái.
        c.GiaTri.Should().Be(750m,
            "`GiaTri` = Σ ApprovedAmount của ĐÚNG tập dòng chọn — KHÔNG phải `ProposedAmount` (999) " +
            "cũng không phải `PeReferenceAmount` (900); 3 số cố ý khác nhau để chỉ một cái đúng");
        c.Phase.Should().Be(ContractPhase.DangSoanThao);
        c.Type.Should().Be(TestType);
        c.SupplierId.Should().Be(s.Suppliers[0].Id, "NCC của HĐ lấy từ DÒNG, không phải từ phiếu");
        c.ProjectId.Should().Be(s.Project.Id);
        c.DepartmentId.Should().Be(s.Plan.DepartmentId);
        c.DrafterUserId.Should().Be(s.Actor.Id);

        // ---- Pin quy trình: V2 (mới) + V1 (vá-6). Xem khối "LỆCH SPEC" đầu file.
        c.ApprovalWorkflowId.Should().Be(s.ContractWorkflowId);
        c.WorkflowDefinitionId.Should().Be(s.ActiveWorkflowDefId,
            "[vá-6] pin bản V1 ĐANG ACTIVE của ĐÚNG loại HĐ — để null thì HĐ rơi về policy hardcode; " +
            "pin nhầm bản của loại khác (bảng có sẵn 1 bản nhiễu `HopDongMuaBan`) cũng sai");

        // ---- [vá-6] SLA: `GetPhaseSla(DangSoanThao)` trả null ⇒ nhánh dự phòng 7 ngày.
        //      Thiếu dòng này thì HĐ sinh từ cầu là HĐ DUY NHẤT trong hệ không có hạn phase.
        c.SlaDeadline.Should().NotBeNull("[vá-6] cầu phải set hạn phase như 2 tiền lệ");
        c.SlaDeadline!.Value.Should().BeCloseTo(DateTime.UtcNow.AddDays(7), TimeSpan.FromMinutes(5));

        // ---- [vá-6] Mã HĐ: soi RUỘT chứ không chỉ non-null — mã có `Project.Code` + `Supplier.Code`
        //      CHÍNH LÀ bằng chứng cầu đã nạp 2 bảng đó (nếu quên nạp thì `GenerateAsync` NRE/mã rỗng).
        c.MaHopDong.Should().Be($"{s.Project.Code}/HĐTP/SOL&{s.Suppliers[0].Code}/01",
            "RG-001 `{ProjectCode}/HĐTP/SOL&{SupplierCode}/{Seq:D2}` — gen NGAY lúc bắc cầu; " +
            "mã CHỨA mã dự án + mã NCC chính là bằng chứng cầu đã nạp 2 bảng đó [vá-6]");
        result.MaHopDong.Should().Be(c.MaHopDong, "kết quả trả về phải khớp DB (FE toast đọc số này)");

        // ---- NS tham chiếu = Σ `PeReferenceAmount` (số PE), KHÁC `GiaTri` (số đã chốt).
        c.BudgetManualAmount.Should().Be(900m);
        c.BudgetManualName.Should().Contain(s.Plan.MaKeHoach!);

        // ---- [vá-4a] WRITE-SITE: `Line.ContractId` = nguồn THẬT của đường KHKK → HĐ.
        var line = await db.ContractSigningPlanLines.AsNoTracking().SingleAsync(l => l.Id == LineId(s, 0));
        line.ContractId.Should().Be(c.Id, "[vá-4a] write-site ĐẦU TIÊN toàn codebase của cột này");

        // ---- [vá-4a] `pe.ContractId` = fallback hiển thị cho cây 4-folder (ô còn trống ⇒ điền).
        var pe = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(p => p.Id == s.Pe.Id);
        pe.ContractId.Should().Be(c.Id);

        // ---- 2 changelog, mỗi bên 1 hàng, ĐÚNG bảng (không cùng bảng, không thiếu bên nào).
        var ctLog = await db.ContractChangelogs.AsNoTracking().Where(x => x.ContractId == c.Id).ToListAsync();
        ctLog.Should().HaveCount(1);
        ctLog[0].Action.Should().Be(ChangelogAction.Insert);
        ctLog[0].EntityType.Should().Be(ChangelogEntityType.Contract);
        ctLog[0].UserId.Should().Be(s.Actor.Id);
        ctLog[0].Summary.Should().Contain(c.MaHopDong!).And.Contain(s.Plan.MaKeHoach!);

        var planLog = await db.ContractSigningPlanChangelogs.AsNoTracking()
            .Where(x => x.ContractSigningPlanId == s.Plan.Id).ToListAsync();
        planLog.Should().HaveCount(1);
        planLog[0].Action.Should().Be(ChangelogAction.Update);
        planLog[0].EntityType.Should().Be(ContractSigningPlanEntityType.Line);
        planLog[0].ContextNote.Should().Contain(c.Id.ToString());

        (await db.Contracts.AsNoTracking().CountAsync()).Should().Be(1, "1 lần bấm = 1 HĐ");
    }

    // ===================================================================
    // T2 — Gộp 2 dòng CÙNG NCC (máy nhận N, UI hướng 1-1).
    // 🔴 Case này CHỈ TỒN TẠI ĐƯỢC nhờ K2 Mig 71 đổi UNIQUE (Plan, Supplier) → bộ-3 có
    //    `CatalogEntryId` (vá-5): trước đó 2 dòng cùng NCC trên 1 phiếu là bất khả về schema.
    // ===================================================================
    [Fact]
    public async Task T2_HaiDongCungNcc_GopVaoMotHopDong_GiaTriLaTong()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var s = await SeedAsync(fix, db,
        [
            new LineSeed(0, 100m, 500m, Approved: 300m),
            new LineSeed(0, 200m, 500m, Approved: 450m),
        ]);
        var handler = BuildHandler(db, fix.Services, s.Actor);

        var result = await handler.Handle(Cmd(s, [LineId(s, 0), LineId(s, 1)]), CancellationToken.None);

        var c = await db.Contracts.AsNoTracking().SingleAsync();
        c.Id.Should().Be(result.ContractId);
        c.GiaTri.Should().Be(750m, "Σ 300 + 450 của ĐÚNG 2 dòng gửi lên");
        c.BudgetManualAmount.Should().Be(300m, "NS tham chiếu = Σ PeReferenceAmount (100 + 200)");

        var lines = await db.ContractSigningPlanLines.AsNoTracking()
            .Where(l => l.ContractSigningPlanId == s.Plan.Id).ToListAsync();
        lines.Should().HaveCount(2);
        lines.Should().OnlyContain(l => l.ContractId == c.Id, "CẢ HAI dòng cùng trỏ 1 HĐ");
    }

    // ===================================================================
    // T3 — 1 dòng chưa có giá chốt ⇒ chặn, message NÊU TÊN hạng mục, KHÔNG đốt mã.
    // "Không đốt mã" = phép đo side-effect của sequence: rào (4) phải đứng TRƯỚC codegen
    // (`:119-130` vs `:212`). Nếu ai đảo thứ tự, HĐ không sinh ra nhưng số RG-001 đã cháy
    // — hỏng câm lặng, chỉ lộ khi soi bảng sequence.
    // ===================================================================
    [Fact]
    public async Task T3_DongChuaCoGiaChot_ThiChan_KhongTaoHd_VaKhongDotMa()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var s = await SeedAsync(fix, db,
        [
            new LineSeed(0, 100m, 500m, Approved: 300m),   // dòng lành
            new LineSeed(0, 100m, 700m, Approved: null),   // dòng thiếu giá chốt
        ]);
        var handler = BuildHandler(db, fix.Services, s.Actor);
        var tenHangMucThieu = s.Plan.Lines[1].TenHangMuc!;

        var act = async () => await handler.Handle(
            Cmd(s, [LineId(s, 0), LineId(s, 1)]), CancellationToken.None);

        (await act.Should().ThrowAsync<ConflictException>())
            .WithMessage($"*{tenHangMucThieu}*",
                "message phải NÊU TÊN hạng mục — dialog chỉ hiện tên, không hiện id");

        (await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0, "atomic: 0 HĐ dở dang");
        (await db.ContractSigningPlanLines.AsNoTracking()
            .CountAsync(l => l.ContractId != null)).Should().Be(0, "kể cả dòng LÀNH cũng không bị ghi");
        (await db.ContractCodeSequences.AsNoTracking().CountAsync()).Should().Be(0,
            "rào phải đứng TRƯỚC codegen — chặn xong mà số RG-001 đã cháy là hỏng câm lặng");
    }

    // ===================================================================
    // T4 — Phiếu chưa duyệt xong (`ChoDuyet`) ⇒ chặn.
    // ===================================================================
    [Fact]
    public async Task T4_PhieuChuaDuyetXong_ThiChan()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        // Dòng CÓ đủ giá chốt: nếu bỏ rào phase, call này sẽ THÀNH CÔNG ⇒ test phân biệt được
        // đúng một biến (phase), không phải xanh nhờ vướng rào khác.
        var s = await SeedAsync(fix, db,
            [new LineSeed(0, 100m, 500m, Approved: 300m)],
            phase: ContractSigningPlanPhase.ChoDuyet);
        var handler = BuildHandler(db, fix.Services, s.Actor);

        var act = async () => await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);

        (await act.Should().ThrowAsync<ConflictException>()).WithMessage("*Đã duyệt*");
        (await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0);
    }

    // ===================================================================
    // T5 — Dòng đã có HĐ ⇒ chặn double-bridge PER-DÒNG (không phải per-phiếu như cầu PE cũ).
    // ===================================================================
    [Fact]
    public async Task T5_DongDaCoHopDong_ThiChan()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var s = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 300m)]);

        // Đặt sẵn dấu "đã bắc cầu" bằng chính cột write-site.
        var seededContractId = Guid.NewGuid();
        var line = await db.ContractSigningPlanLines.SingleAsync(l => l.Id == LineId(s, 0));
        line.ContractId = seededContractId;
        await db.SaveChangesAsync(CancellationToken.None);
        db.ChangeTracker.Clear();

        var handler = BuildHandler(db, fix.Services, s.Actor);
        var act = async () => await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);

        (await act.Should().ThrowAsync<ConflictException>())
            .WithMessage($"*{s.Plan.Lines[0].TenHangMuc}*đã có Hợp đồng*");

        (await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0);
        (await db.ContractSigningPlanLines.AsNoTracking().SingleAsync(l => l.Id == LineId(s, 0)))
            .ContractId.Should().Be(seededContractId, "HĐ cũ KHÔNG bị ghi đè");
    }

    // ===================================================================
    // T6 — 2 dòng KHÁC NCC trong 1 lần bấm ⇒ chặn (1 HĐ = 1 NCC; liên-danh = N lần bấm).
    // ===================================================================
    [Fact]
    public async Task T6_HaiDongKhacNcc_ThiChan()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var s = await SeedAsync(fix, db,
        [
            new LineSeed(0, 100m, 500m, Approved: 300m),
            new LineSeed(1, 100m, 500m, Approved: 300m),   // NCC KHÁC
        ], supplierCount: 2);
        var handler = BuildHandler(db, fix.Services, s.Actor);

        var act = async () => await handler.Handle(
            Cmd(s, [LineId(s, 0), LineId(s, 1)]), CancellationToken.None);

        (await act.Should().ThrowAsync<ConflictException>()).WithMessage("*nhiều NCC khác nhau*");
        (await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0);

        // ĐỐI CHỨNG DƯƠNG cùng lượt: tách ra bấm từng dòng thì QUA — chứng minh 2 dòng này
        // hợp lệ về mọi mặt khác, cái chặn ở trên đúng là vế "khác NCC".
        var r1 = await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
        db.ChangeTracker.Clear();
        var r2 = await handler.Handle(Cmd(s, [LineId(s, 1)]), CancellationToken.None);
        r1.ContractId.Should().NotBe(r2.ContractId);
        (await db.Contracts.AsNoTracking().CountAsync()).Should().Be(2, "liên-danh 2 NCC = 2 HĐ");
    }

    // ===================================================================
    // T7 — Rào quy trình pin: 4 nhánh SAI + 1 ĐỐI CHỨNG DƯƠNG, tất cả trên CÙNG một phiếu.
    // [vá-3] 2 vế `IsActive`/`IsUserSelectable` là phần VÁ (khuôn gốc `ContractFeatures.cs:79-87`
    // chỉ hỏi tồn-tại + đúng-loại ⇒ forge POST pin được quy trình đã nghỉ / chưa ghim).
    // Ma-trận cùng-ruột: mọi thứ khác giữ y nguyên, đổi ĐÚNG 1 biến là `ApprovalWorkflowId`.
    // Message TÁCH RIÊNG từng nguyên nhân — gộp `||` thì admin không biết bấm nút nào.
    // ===================================================================
    [Fact]
    public async Task T7_QuyTrinhPinSai_Chan4Nhanh_VaQuyTrinhDungThiQua()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var s = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 300m)]);

        var wrongType = NewContractWorkflow("QT-KHKK-V2",
            type: ApprovalWorkflowApplicableType.ContractSigningPlan);   // loại 10, không phải 3
        var retired = NewContractWorkflow("QT-HD-NGHI", isActive: false);
        var unpinned = NewContractWorkflow("QT-HD-CHUA-GHIM", isUserSelectable: false);
        db.ApprovalWorkflows.AddRange(wrongType, retired, unpinned);
        await db.SaveChangesAsync(CancellationToken.None);

        var handler = BuildHandler(db, fix.Services, s.Actor);
        var lineIds = new[] { LineId(s, 0) };

        // (a) không tồn tại → NotFound (khác Conflict: id rác là lỗi tham chiếu, không phải trạng thái).
        await ((Func<Task>)(async () => await handler.Handle(
                Cmd(s, lineIds, Guid.NewGuid()), CancellationToken.None)))
            .Should().ThrowAsync<NotFoundException>();

        // (b) đúng tồn tại nhưng SAI LOẠI (quy trình của chính module KHKK).
        (await ((Func<Task>)(async () => await handler.Handle(
                Cmd(s, lineIds, wrongType.Id), CancellationToken.None)))
            .Should().ThrowAsync<ConflictException>())
            .WithMessage("*không khớp với Hợp đồng*");

        // (c) [vá-3] đã ngừng hoạt động.
        (await ((Func<Task>)(async () => await handler.Handle(
                Cmd(s, lineIds, retired.Id), CancellationToken.None)))
            .Should().ThrowAsync<ConflictException>())
            .WithMessage("*ngừng hoạt động*");

        // (d) [vá-3] chưa được ghim cho người dùng chọn — message PHẢI khác (c).
        (await ((Func<Task>)(async () => await handler.Handle(
                Cmd(s, lineIds, unpinned.Id), CancellationToken.None)))
            .Should().ThrowAsync<ConflictException>())
            .WithMessage("*chưa được ghim*");

        (await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0, "4 nhánh sai = 0 HĐ");
        (await db.ContractCodeSequences.AsNoTracking().CountAsync()).Should().Be(0,
            "rào quy trình cũng phải đứng TRƯỚC codegen");

        // ---- ĐỐI CHỨNG DƯƠNG: đổi ĐÚNG 1 biến (quy trình hợp lệ) → QUA.
        //      Thiếu vế này thì 4 khẳng-định trên vẫn xanh trong một thế giới mà cầu chặn TẤT CẢ.
        var ok = await handler.Handle(Cmd(s, lineIds, s.ContractWorkflowId), CancellationToken.None);
        ok.ContractId.Should().NotBeEmpty();
        (await db.Contracts.AsNoTracking().CountAsync()).Should().Be(1);
    }

    // ===================================================================
    // T8 (khoá V3) — MỐI NỐI cầu → đường ống V2.
    //
    // `ContractV2PipelineTests` (W6-S161) đã khoá đường ống, NHƯNG nó **seed thẳng** HĐ ở
    // `ChoDuyet` + `ApprovalWorkflowId` + `stepIndex` — tức cho sẵn thứ mà cầu phải tự sinh.
    // Test này đo phần KHÔNG ai đo: HĐ do CẦU đẻ có đi vào máy V2 được không.
    // Thế giới hỏng phân biệt được: cầu quên pin `ApprovalWorkflowId` ⇒ `TransitionAsync:97`
    // để `CurrentApprovalLevelOrder = null` ⇒ `ResolveV2InboxIdsAsync` không khớp ⇒ hộp thư
    // của ĐÚNG người phải duyệt rỗng, mà HĐ thì vẫn "đã trình" — treo vô thời hạn, im lặng.
    // ===================================================================
    [Fact]
    public async Task T8_HdSinhTuCau_TrinhDuyet_ThiVaoDungHopThuCapDangCho()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var s = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 300m)]);

        // 2 Cấp trong 1 Bước: cấp 1 = a, cấp 2 = b (OR-of-N không cần thiết ở đây).
        var a = await fix.CreateUserAsync($"a-{Guid.NewGuid():N}@test.local", "Duyệt cấp 1", null, []);
        var b = await fix.CreateUserAsync($"b-{Guid.NewGuid():N}@test.local", "Duyệt cấp 2", null, []);
        var step = new ApprovalWorkflowStep
        {
            Id = Guid.NewGuid(), ApprovalWorkflowId = s.ContractWorkflowId,
            Order = 1, Name = "Bước 1", DepartmentId = null,
        };
        db.ApprovalWorkflowSteps.Add(step);
        db.ApprovalWorkflowLevels.AddRange(
            new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = step.Id, Order = 1, Name = "Cấp 1", ApproverUserId = a.Id },
            new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = step.Id, Order = 2, Name = "Cấp 2", ApproverUserId = b.Id });
        await db.SaveChangesAsync(CancellationToken.None);

        var handler = BuildHandler(db, fix.Services, s.Actor);
        var result = await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);

        // ---- Trình duyệt QUA MÁY THẬT (không set Phase bằng tay — seed thẳng là đo nhầm cơ chế).
        var dt = new FixedDateTime(Clock);
        var drafterUser = new TestCurrentUser(s.Actor.Id, s.Actor.FullName, s.Actor.Email, AppRoles.Drafter);
        var svc = new ContractWorkflowService(
            db, new ContractCodeGenerator(db, dt), dt, new NoOpNotificationServiceApp(),
            new ChangelogService(db, drafterUser, fix.Services.GetRequiredService<UserManager<User>>()),
            fix.Services.GetRequiredService<UserManager<User>>());
        var contract = await db.Contracts.SingleAsync(x => x.Id == result.ContractId);
        await svc.TransitionAsync(contract, ContractPhase.ChoDuyet, s.Actor.Id,
            [AppRoles.Drafter], ApprovalDecision.Approve, "trình", ct: CancellationToken.None);

        var after = await db.Contracts.AsNoTracking().SingleAsync(x => x.Id == result.ContractId);
        after.Phase.Should().Be(ContractPhase.ChoDuyet);
        after.CurrentWorkflowStepIndex.Should().Be(0);
        after.CurrentApprovalLevelOrder.Should().Be(1,
            "con-trỏ Cấp CHỈ được khởi tạo khi HĐ pin V2 (`ContractWorkflowService.cs:97`) — " +
            "null ở đây nghĩa là cầu quên pin `ApprovalWorkflowId`");

        // ---- Hộp thư: người của Cấp ĐANG CHỜ thấy; người Cấp SAU chưa thấy.
        //      Cả 2 user KHÔNG mang role legacy nào ⇒ chỉ có chân V2, đo đúng vế V2.
        var inboxA = await new GetMyInboxQueryHandler(db, new TestCurrentUser(a.Id, a.FullName, a.Email))
            .Handle(new GetMyInboxQuery(), CancellationToken.None);
        inboxA.Select(i => i.Id).Should().BeEquivalentTo(new[] { result.ContractId },
            "HĐ sinh từ cầu phải nằm trong hộp thư của Cấp 1 — đây là toàn bộ lý do cầu tồn tại");

        var inboxB = await new GetMyInboxQueryHandler(db, new TestCurrentUser(b.Id, b.FullName, b.Email))
            .Handle(new GetMyInboxQuery(), CancellationToken.None);
        inboxB.Should().BeEmpty("Cấp 2 chưa tới lượt");
    }

    // ===================================================================
    // T9 — Rào tập `LineIds`: id của phiếu KHÁC (hoặc id rác) không được lọt.
    // Đây là rào duy nhất chặn "đưa dòng của phiếu người khác vào HĐ của mình".
    // ===================================================================
    [Fact]
    public async Task T9_DongCuaPhieuKhac_ThiChan()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var s1 = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 300m)]);
        var s2 = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 999m)],
            maKeHoach: "KHKK/2026/002");
        var handler = BuildHandler(db, fix.Services, s1.Actor);

        // Trộn 1 dòng của phiếu 1 + 1 dòng của phiếu 2 → chặn.
        var act = async () => await handler.Handle(
            Cmd(s1, [LineId(s1, 0), LineId(s2, 0)]), CancellationToken.None);
        (await act.Should().ThrowAsync<ConflictException>()).WithMessage("*không thuộc kế hoạch này*");

        // Id rác cũng rơi vào cùng rào (đếm sau Distinct).
        var act2 = async () => await handler.Handle(
            Cmd(s1, [LineId(s1, 0), Guid.NewGuid()]), CancellationToken.None);
        (await act2.Should().ThrowAsync<ConflictException>()).WithMessage("*không thuộc kế hoạch này*");

        (await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0);
        (await db.ContractSigningPlanLines.AsNoTracking()
            .CountAsync(l => l.ContractId != null)).Should().Be(0, "dòng của phiếu hàng xóm không bị đụng");
    }

    // ===================================================================
    // T10 — Bấm lần 2 trên DÒNG KHÁC của cùng phiếu ⇒ HĐ thứ 2 sinh ra,
    //       nhưng `pe.ContractId` GIỮ HĐ ĐẦU. Đây là phép đo CHIỀU của `??=` (vá-4a):
    //       viết `=` thay `??=` thì mọi assert khác vẫn xanh, chỉ vế này ĐỎ.
    //       Đồng thời là phép đo cardinality của DoD vá-15 (N lần bấm = N ContractId).
    // ===================================================================
    [Fact]
    public async Task T10_BacCauLanHai_TaoHdThuHai_NhungPeGiuHdDau()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var s = await SeedAsync(fix, db,
        [
            new LineSeed(0, 100m, 500m, Approved: 300m),
            new LineSeed(0, 100m, 500m, Approved: 450m),
        ]);
        var handler = BuildHandler(db, fix.Services, s.Actor);

        var first = await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
        db.ChangeTracker.Clear();
        var second = await handler.Handle(Cmd(s, [LineId(s, 1)]), CancellationToken.None);

        second.ContractId.Should().NotBe(first.ContractId);
        (await db.Contracts.AsNoTracking().CountAsync()).Should().Be(2);

        var lines = await db.ContractSigningPlanLines.AsNoTracking()
            .Where(l => l.ContractSigningPlanId == s.Plan.Id).OrderBy(l => l.ApprovedAmount).ToListAsync();
        lines[0].ContractId.Should().Be(first.ContractId);
        lines[1].ContractId.Should().Be(second.ContractId);

        var pe = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(p => p.Id == s.Pe.Id);
        pe.ContractId.Should().Be(first.ContractId,
            "[vá-4a] `??=` CÓ CHIỀU: HĐ đầu giữ ô đơn của PE; HĐ sau KHÔNG cướp ô " +
            "(chúng vẫn nhìn thấy được qua `Line.ContractId` — chính là lý do vá-4a tồn tại)");

        // Mã HĐ thứ 2 tăng seq trên CÙNG prefix (cùng dự án + cùng NCC) — chứng codegen chạy thật.
        var codes = await db.Contracts.AsNoTracking().Select(c => c.MaHopDong).ToListAsync();
        codes.Should().BeEquivalentTo(new[]
        {
            $"{s.Project.Code}/HĐTP/SOL&{s.Suppliers[0].Code}/01",
            $"{s.Project.Code}/HĐTP/SOL&{s.Suppliers[0].Code}/02",
        });
    }

    // ===================================================================
    // T11 — DOUBLE-CLICK: bấm 2 lần liên tiếp CÙNG tập dòng.
    // Hành vi ĐANG CÓ của BE (đọc `:128-129`): lần 2 rơi vào rào per-dòng "đã có Hợp đồng"
    // ⇒ `ConflictException`. Test khoá đúng hành vi đó, KHÔNG chế kỳ vọng "no-op im lặng".
    // `ChangeTracker.Clear()` mô phỏng request thứ 2 (context mới đọc lại từ DB).
    // ===================================================================
    [Fact]
    public async Task T11_BamHaiLanCungTapDong_ThiLanHaiBiChan_KhongTaoHdThua()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var s = await SeedAsync(fix, db, [new LineSeed(0, 100m, 500m, Approved: 300m)]);
        var handler = BuildHandler(db, fix.Services, s.Actor);

        var first = await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
        db.ChangeTracker.Clear();

        var act = async () => await handler.Handle(Cmd(s, [LineId(s, 0)]), CancellationToken.None);
        (await act.Should().ThrowAsync<ConflictException>()).WithMessage("*đã có Hợp đồng*");

        (await db.Contracts.AsNoTracking().CountAsync()).Should().Be(1, "double-click không đẻ HĐ thứ 2");
        (await db.ContractSigningPlanLines.AsNoTracking().SingleAsync(l => l.Id == LineId(s, 0)))
            .ContractId.Should().Be(first.ContractId, "dòng vẫn trỏ HĐ của lần bấm đầu");
        var seq = await db.ContractCodeSequences.AsNoTracking().SingleAsync();
        seq.LastSeq.Should().Be(1, "lần bấm 2 bị chặn TRƯỚC codegen ⇒ số RG-001 không cháy oan");
    }

    // ===================================================================
    // T12 — [vá-7 spec-cụm-3 §②-7] NEO ACCEPTANCE B6, đo END-TO-END trên máy THẬT.
    //
    // Vì sao phải có dù K3 đã có test finalize: các test kia dừng ở "finalize ghi
    // `ApprovedAmount`". Cầu K7 thì ĐỌC cột đó. Mối nối giữa hai vế chưa ai đo — mà chính nó
    // là điều kiện sống của toàn bộ happy-path (invest `:154`: "không có K3 thì `ApprovedAmount`
    // NULL 100%" ⇒ cầu chặn mọi phiếu). Ở đây phiếu đi TRỌN đường qua
    // `ContractSigningPlanWorkflowService` THẬT (trình → duyệt → terminal chốt giá), KHÔNG seed
    // thẳng Phase/giá — seed thẳng là tự đưa cho mình đáp án rồi chấm.
    //
    // Tương phản để phép đo không rỗng:
    //   dòng A: `ApprovedAmount` = 111 CÓ SẴN (người có thẩm quyền chốt tay), `Proposed` = 999
    //   dòng B: `ApprovedAmount` = null      → finalize `??=` điền = `Proposed` = 250
    //   ⇒ `GiaTri` phải = 361. Σ `Proposed` = 1249. Nếu cầu (hoặc finalize) lấy nhầm nguồn thì
    //     ra 1249 / 1109 / 500 — mọi cách sai đều KHÁC 361.
    //
    // Vế cuối = yêu cầu chữ của vá-7 ("`Line.ContractId` không đổi sau finalize"): sau khi bắc
    // cầu, phiếu ĐÃ terminal nên không còn đường finalize lần nữa — chứng bằng cách BẤM THẬT
    // (`approve` → Conflict) rồi đọc lại cả `ContractId` lẫn `ApprovedAmount`.
    // ===================================================================
    [Fact]
    public async Task T12_DiTronDuongFinalizeRoiBacCau_GiaTriLaSoDaCHOT_VaKhongDoiVeSau()
    {
        using var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var s = await SeedAsync(fix, db,
        [
            new LineSeed(0, 100m, Proposed: 999m, Approved: 111m),   // đã chốt tay
            new LineSeed(0, 100m, Proposed: 250m, Approved: null),   // để finalize điền
        ], phase: ContractSigningPlanPhase.DangSoanThao);

        // Quy trình KHKK (loại 10) 1 Bước × 1 Cấp — đường ngắn nhất tới terminal.
        var approver = await fix.CreateUserAsync(
            $"tp-{Guid.NewGuid():N}@test.local", "TP duyệt", null, []);
        var khkkWf = NewContractWorkflow($"QT-KHKK-{Guid.NewGuid().ToString("N")[..6]}",
            type: ApprovalWorkflowApplicableType.ContractSigningPlan);
        db.ApprovalWorkflows.Add(khkkWf);
        var step = new ApprovalWorkflowStep
        {
            Id = Guid.NewGuid(), ApprovalWorkflowId = khkkWf.Id,
            Order = 1, Name = "Bước 1", DepartmentId = null,
        };
        db.ApprovalWorkflowSteps.Add(step);
        db.ApprovalWorkflowLevels.Add(new ApprovalWorkflowLevel
        {
            Id = Guid.NewGuid(), ApprovalWorkflowStepId = step.Id,
            Order = 1, Name = "Cấp 1", ApproverUserId = approver.Id,
        });
        var plan = await db.ContractSigningPlans.SingleAsync(p => p.Id == s.Plan.Id);
        plan.ApprovalWorkflowId = khkkWf.Id;
        await db.SaveChangesAsync(CancellationToken.None);
        db.ChangeTracker.Clear();

        var dt = new FixedDateTime(Clock);
        ContractSigningPlanWorkflowService KhkkSvc(User u, params string[] roles)
            => new(db, new TestCurrentUser(u.Id, u.FullName, u.Email, roles), dt,
                new NoOpNotificationServiceApp());

        // ---- Trình → duyệt (Cấp cuối của Bước cuối ⇒ terminal + chốt giá).
        await KhkkSvc(s.Actor, AppRoles.Drafter)
            .TransitionAsync(s.Plan.Id, "submit", null, ct: CancellationToken.None);
        var afterApprove = await KhkkSvc(approver)
            .TransitionAsync(s.Plan.Id, "approve", "ok", ct: CancellationToken.None);

        afterApprove.Phase.Should().Be((int)ContractSigningPlanPhase.DaDuyet,
            "B6 neo (a): phiếu phải TỰ đi tới Đã duyệt qua máy, không phải do test gán");
        db.ChangeTracker.Clear();

        var frozen = await db.ContractSigningPlanLines.AsNoTracking()
            .Where(l => l.ContractSigningPlanId == s.Plan.Id).OrderBy(l => l.ProposedAmount).ToListAsync();
        frozen.Select(l => l.ApprovedAmount).Should().BeEquivalentTo(new decimal?[] { 250m, 111m },
            "B6 neo (b): finalize `??=` — dòng chốt tay GIỮ 111 (không bị 999 đè), dòng trống lấy 250");

        // ---- Bây giờ mới bắc cầu, trên đúng những con số vừa đông cứng.
        var handler = BuildHandler(db, fix.Services, s.Actor);
        var result = await handler.Handle(
            Cmd(s, frozen.Select(l => l.Id)), CancellationToken.None);

        var c = await db.Contracts.AsNoTracking().SingleAsync(x => x.Id == result.ContractId);
        c.GiaTri.Should().Be(361m,
            "= 111 + 250 (số ĐÃ CHỐT). Σ ProposedAmount = 1249 — mọi cách lấy nhầm nguồn đều ra số khác");

        // ---- Vế cuối vá-7: sau khi bắc cầu, phiếu terminal ⇒ không còn đường chốt lại giá.
        db.ChangeTracker.Clear();
        var act = async () => await KhkkSvc(approver)
            .TransitionAsync(s.Plan.Id, "approve", "duyệt lại", ct: CancellationToken.None);
        await act.Should().ThrowAsync<ConflictException>();

        var after = await db.ContractSigningPlanLines.AsNoTracking()
            .Where(l => l.ContractSigningPlanId == s.Plan.Id).OrderBy(l => l.ProposedAmount).ToListAsync();
        after.Should().OnlyContain(l => l.ContractId == result.ContractId,
            "`Line.ContractId` KHÔNG đổi sau khi phiếu đã terminal");
        after.Select(l => l.ApprovedAmount).Should().BeEquivalentTo(new decimal?[] { 250m, 111m },
            "giá đã chốt cũng không đổi — HĐ và phiếu kể cùng một câu chuyện tiền");
    }
}
