using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;     // ICurrentUser — bơm cho AuditingInterceptor
using SolutionErp.Application.Common.Models;         // PagedResult<T> — kết quả màn "Đã xoá"
using SolutionErp.Application.ContractSigningPlans;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans;
using SolutionErp.Domain.Contracts;                 // ChangelogAction (enum dùng chung 3 module)
using SolutionErp.Domain.Identity;                  // AppRoles
using SolutionErp.Domain.Master;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Persistence;
using SolutionErp.Infrastructure.Persistence.Interceptors;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;

namespace SolutionErp.Infrastructure.Tests.Application;

// [W2 KHKK — S161 2026-07-29] CRUD phiếu "Kế hoạch ký kết HĐ" (GĐ2) — test-AFTER
// (feature mới, `docs/rules.md §7`), chốt acceptance spec W2 §③-B.
//
// Code under test: `Application/ContractSigningPlans/ContractSigningPlanFeatures.cs`
//   Create  :292-400   (4 rào theo thứ tự + codegen + auto-sinh Lines)
//   Delete  :1098-1142 (owner ∥ Admin + allow-list phase + thứ tự Remove/Add)
//
// ── 2 quyết định hạ tầng, cả hai đều là ĐIỀU KIỆN để phép đo có nghĩa ──────────
//
// (1) Fixture RIÊNG có `AuditingInterceptor` (KHÔNG dùng SqliteDbFixture/IdentityFixture).
//     `TestApplicationDbContext` mặc định KHÔNG wire interceptor ⇒ `Remove()` là xoá
//     **CỨNG** (bài S155). Đo "xoá xong lập lại được" trên xoá-cứng là đo NHẦM cơ chế:
//     rào (iii) của Create dựa vào GLOBAL QUERY FILTER che row `IsDeleted=1`
//     (`:323-325`), chứ không phải row biến mất khỏi bảng. Không có interceptor thì
//     test vẫn XANH kể cả khi filter bị gỡ — đúng kiểu vacuous.
//     Không cần Identity stack: 2 handler dưới test chỉ nhận `db + currentUser (+ codeGen)`.
//
// (2) MỖI lệnh chạy trên MỘT DbContext MỚI (`NewDb()`), dùng chung 1 connection —
//     mô phỏng scoped-per-request của prod. Nếu tái dùng 1 context cho cả seed lẫn
//     lệnh, các con (Lines/Changelogs) đang được TRACK sẽ bị EF cascade client-side
//     ngay tại `Remove()` — một đường đi KHÔNG hề xảy ra trong prod (ở đó context mới
//     chỉ nạp mỗi header). Sai lệch này chính là thứ đẻ ra bug T26 S155, nên nó phải
//     được mô phỏng cho ĐÚNG chứ không được làm cho tiện.
public class ContractSigningPlanCrudTests
{
    private static readonly DateTime Clock2026 = new(2026, 7, 29, 3, 0, 0, DateTimeKind.Utc);

    // ===================================================================
    // Fixture: SQLite in-memory + AuditingInterceptor (xoá mềm THẬT).
    // ===================================================================
    private sealed class KhkkFixture : IDisposable
    {
        private readonly SqliteConnection _conn;
        public FixedDateTime Clock { get; } = new(Clock2026);

        /// Người đang thao tác — đổi được giữa chừng để đóng vai user khác.
        public TestCurrentUser Actor { get; } = new(Guid.NewGuid(), "BCH lập kế hoạch", "bch@test.local");

        public KhkkFixture()
        {
            _conn = new SqliteConnection("DataSource=:memory:");
            _conn.Open();
            using var boot = NewDb();
            boot.Database.EnsureCreated();
        }

        /// Context MỚI cho mỗi "request" (xem ghi chú (2) đầu file).
        /// 🔴 `actor` phải là ĐÚNG cái `ICurrentUser` mà handler nhận: trong prod cả
        /// interceptor lẫn handler đều resolve CÙNG một `ICurrentUser` scoped. Nếu để
        /// interceptor dính cứng một người còn handler chạy bằng người khác thì
        /// `DeletedBy`/`UpdatedBy` đo ra người sai — và tệ hơn, nó sai theo hướng
        /// TRÔNG NHƯ ĐÚNG (vẫn có giá trị, vẫn là một Guid user thật).
        public TestApplicationDbContext NewDb(ICurrentUser? actor = null)
        {
            var options = new DbContextOptionsBuilder<ApplicationDbContext>()
                .UseSqlite(_conn)
                .EnableSensitiveDataLogging()
                .AddInterceptors(new AuditingInterceptor(actor ?? Actor, Clock))
                .Options;
            return new TestApplicationDbContext(options);
        }

        public void Dispose() => _conn.Dispose();
    }

    // ===================================================================
    // Seed: 1 PE + N nhà cung cấp + M hạng mục báo giá + 1 quy trình duyệt.
    // ===================================================================
    private sealed record SeededPe(
        Guid PeId,
        Guid ProjectId,
        Guid DepartmentId,
        Guid PeDrafterId,
        Guid WorkflowId,
        List<Guid> MasterSupplierIds,      // Id ở bảng Suppliers (master)
        List<Guid> PeSupplierRowIds);      // Id ở bảng PurchaseEvaluationSuppliers (ROW của phiếu)

    /// <param name="quotes">quotes[hạng mục][NCC] = ThanhTien.</param>
    /// <param name="selected">selected[hạng mục][NCC] = IsSelected; null ⇒ mặc định "quote của winner được chọn".</param>
    private static async Task<SeededPe> SeedPeAsync(
        KhkkFixture f,
        int supplierCount,
        int[] winnerIndexes,
        decimal[][] quotes,
        bool[][]? selected = null,
        PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.DaDuyet,
        ApprovalWorkflowApplicableType wfType = ApprovalWorkflowApplicableType.ContractSigningPlan)
    {
        await using var db = f.NewDb();

        // Quy trình duyệt V2 — BẮT BUỘC là row thật: `ContractSigningPlan.ApprovalWorkflowId`
        // là FK Restrict (`ContractSigningPlanConfiguration.cs:39-42`), Guid rác = SQLite lỗi 19.
        // KHÔNG cần Step/Level (Level.ApproverUserId mới là FK sang Users) — W2 chưa duyệt.
        var wf = new ApprovalWorkflow
        {
            Code = "QT-KHKK-V2",
            Version = 1,
            ApplicableType = wfType,
            Name = "Quy trình duyệt kế hoạch ký kết",
            IsActive = true,
            IsUserSelectable = true,
        };
        db.ApprovalWorkflows.Add(wf);

        var peId = Guid.NewGuid();
        var masterIds = new List<Guid>();
        var rowIds = new List<Guid>();
        var peSuppliers = new List<PurchaseEvaluationSupplier>();

        for (var i = 0; i < supplierCount; i++)
        {
            var master = new Supplier
            {
                Id = Guid.NewGuid(),
                Code = $"NCC{i + 1}",
                Name = $"Nhà cung cấp {i + 1}",
                Type = SupplierType.NhaThauPhu,
            };
            db.Suppliers.Add(master);

            var row = new PurchaseEvaluationSupplier
            {
                Id = Guid.NewGuid(),                     // ≠ master.Id — chính là cái bẫy w.Id vs w.SupplierId
                PurchaseEvaluationId = peId,
                SupplierId = master.Id,
                Order = i,
                IsWinner = winnerIndexes.Contains(i),
            };
            masterIds.Add(master.Id);
            rowIds.Add(row.Id);
            peSuppliers.Add(row);
        }

        // Người soạn PE (QS) CỐ Ý khác người lập kế hoạch (BCH) — dùng để chứng D2.
        var peDrafterId = Guid.NewGuid();
        var projectId = Guid.NewGuid();
        var departmentId = Guid.NewGuid();

        var pe = new PurchaseEvaluation
        {
            Id = peId,
            MaPhieu = "PE-KHKK-001",
            Type = PurchaseEvaluationType.DuyetNcc,
            Phase = phase,
            TenGoiThau = "Cung cấp và lắp dựng cốp pha",
            ProjectId = projectId,
            DepartmentId = departmentId,
            DrafterUserId = peDrafterId,
            ApprovalWorkflowId = wf.Id,
        };
        pe.Suppliers.AddRange(peSuppliers);

        for (var d = 0; d < quotes.Length; d++)
        {
            var detail = new PurchaseEvaluationDetail
            {
                Id = Guid.NewGuid(),
                PurchaseEvaluationId = peId,
                GroupCode = "A.I",
                GroupName = "Cốp pha",
                NoiDung = $"Hạng mục {d + 1}",
                Order = d,
                ThanhTienNganSach = 1_000_000_000m,   // ngân sách CỐ Ý lệch hẳn báo giá
            };
            for (var s = 0; s < supplierCount; s++)
            {
                detail.Quotes.Add(new PurchaseEvaluationQuote
                {
                    Id = Guid.NewGuid(),
                    PurchaseEvaluationDetailId = detail.Id,
                    PurchaseEvaluationSupplierId = peSuppliers[s].Id,
                    ThanhTien = quotes[d][s],
                    IsSelected = selected is not null ? selected[d][s] : winnerIndexes.Contains(s),
                });
            }
            pe.Details.Add(detail);
        }

        db.PurchaseEvaluations.Add(pe);
        await db.SaveChangesAsync(CancellationToken.None);

        return new SeededPe(peId, projectId, departmentId, peDrafterId, wf.Id, masterIds, rowIds);
    }

    // ---- Chạy lệnh qua handler THẬT, mỗi lệnh 1 context mới ----

    /// <param name="approvalGroup">
    /// [K4b S166] null ⇒ handler tự lấy nhóm mặc định 1 (`ContractSigningPlanScope.DefaultApprovalGroup`).
    /// Truyền số để lập phiếu ở nhóm duyệt khác — dùng cho test lọc nhóm màn "Đã xoá".
    /// </param>
    private static async Task<CreateContractSigningPlanResult> CreateAsync(
        KhkkFixture f, Guid peId, Guid workflowId, string? ghiChu = null, int? approvalGroup = null)
    {
        await using var db = f.NewDb();
        var handler = new CreateContractSigningPlanCommandHandler(
            db, f.Actor, new ContractSigningPlanCodeGenerator(db, f.Clock));
        return await handler.Handle(
            new CreateContractSigningPlanCommand(peId, workflowId, ghiChu, approvalGroup),
            CancellationToken.None);
    }

    private static Task<PagedResult<ContractSigningPlanListItemDto>> ListDeletedAsync(
        KhkkFixture f, ICurrentUser actor, int? approvalGroup)
    {
        var db = f.NewDb(actor);
        var handler = new ListDeletedContractSigningPlansQueryHandler(db, actor);
        return handler.Handle(
            new ListDeletedContractSigningPlansQuery(approvalGroup), CancellationToken.None);
    }

    private static async Task DeleteAsync(KhkkFixture f, Guid planId, string? reason, TestCurrentUser actor)
    {
        await using var db = f.NewDb(actor);            // interceptor + handler CÙNG một actor (như prod)
        var handler = new DeleteContractSigningPlanCommandHandler(db, actor);
        await handler.Handle(new DeleteContractSigningPlanCommand(planId, reason), CancellationToken.None);
    }

    // ===================================================================
    // (1) Tên PIN theo spec §③-B — PE chưa duyệt xong thì KHÔNG lập được kế hoạch.
    //
    // Rào này là rào ĐẦU TIÊN (`:312-313`) nên nó cũng gác luôn 3 rào sau: sai ở đây
    // là phiếu GĐ2 chốt giá trên một phiếu Duyệt NCC còn đang tranh luận.
    // ===================================================================
    [Fact]
    public async Task CreateContractSigningPlan_FromNonApprovedPe_Throws()
    {
        using var f = new KhkkFixture();

        // PE ChoDuyet — MỌI thứ khác đã hợp lệ (có winner, có báo giá được chọn, workflow
        // đúng loại) ⇒ nếu ném thì chỉ có thể vì phase.
        var seeded = await SeedPeAsync(f,
            supplierCount: 2,
            winnerIndexes: [0],
            quotes: [[100m, 7m]],
            phase: PurchaseEvaluationPhase.ChoDuyet);

        var act = async () => await CreateAsync(f, seeded.PeId, seeded.WorkflowId);

        (await act.Should().ThrowAsync<ConflictException>(
                "phiếu Duyệt NCC chưa ở trạng thái Đã duyệt thì chưa có giá chốt để lập kế hoạch"))
            .WithMessage("*Đã duyệt*");

        await using (var db = f.NewDb())
        {
            db.ContractSigningPlans.IgnoreQueryFilters().Count()
                .Should().Be(0, "ném xong KHÔNG được để lại phiếu nửa vời");

            // Rào phase đứng TRƯỚC codegen (`:312` vs `:339`) ⇒ không đốt số thứ tự.
            // Nếu ai đổi thứ tự, mã KHKK sẽ có lỗ mà chẳng phiếu nào mang.
            db.WorkflowAppCodeSequences.Any(s => s.Prefix == "KHKK/2026")
                .Should().BeFalse("lỗi ở rào đầu thì chưa được cấp phát mã nào");
        }

        // ---- VẾ ĐỐI CHỨNG (bắt buộc): cùng bộ seed, ĐỔI ĐÚNG 1 BIẾN = phase → phải CHẠY.
        // Thiếu vế này, test trên vẫn xanh trong cái thế giới mà seed hỏng sẵn (FK/required
        // thiếu) — lúc đó nó đo lỗi của chính mình chứ không đo rào.
        using var ok = new KhkkFixture();
        var okSeed = await SeedPeAsync(ok,
            supplierCount: 2,
            winnerIndexes: [0],
            quotes: [[100m, 7m]],
            phase: PurchaseEvaluationPhase.DaDuyet);

        var result = await CreateAsync(ok, okSeed.PeId, okSeed.WorkflowId);
        result.LineCount.Should().Be(1, "cùng seed đó với PE Đã duyệt thì lập được kế hoạch bình thường");
    }

    // ===================================================================
    // (2) Happy path — acceptance §③-B: N Lines = số winner, `PeReferenceAmount` =
    //     SUM báo giá ĐƯỢC CHỌN của ĐÚNG NCC đó, mã khớp `^KHKK/\d{4}/\d{3}$`.
    //
    // Seed cài sẵn 3 cái bẫy, mỗi cái bắt một lỗi khác nhau:
    //   • NCC thua (idx 2) có báo giá `IsSelected = true` và số TO   → lọt = quên lọc winner;
    //   • winner 1 có 1 hạng mục `IsSelected = false`                → cộng = quên lọc IsSelected;
    //   • Id ROW ≠ Id master supplier                                → dùng nhầm = sai NCC hoàn toàn
    //     (bẫy `w.Id` vs `w.SupplierId` mà lane BE tự khai ở sub-md §1).
    // ===================================================================
    [Fact]
    public async Task CreateContractSigningPlan_HappyPath_LinesMatchWinners_AndPeReferenceAmountIsSelectedQuoteSum()
    {
        using var f = new KhkkFixture();

        // 3 NCC: 0 + 1 trúng, 2 thua. 2 hạng mục.
        //   winner0: 100 + 40   = 140  (cả 2 được chọn)
        //   winner1: 200 + [60] = 200  (hạng mục 2 KHÔNG được chọn)
        //   loser2 : 999 + 888         (được chọn hết — mồi nhử)
        var seeded = await SeedPeAsync(f,
            supplierCount: 3,
            winnerIndexes: [0, 1],
            quotes:
            [
                [100m, 200m, 999m],
                [40m, 60m, 888m],
            ],
            selected:
            [
                [true, true, true],
                [true, false, true],
            ]);

        var result = await CreateAsync(f, seeded.PeId, seeded.WorkflowId, "Kế hoạch ký kết đợt 1");

        // ---- Kết quả trả về (đúng thứ FE đọc để điều hướng sau khi POST).
        result.LineCount.Should().Be(2, "2 NCC trúng thầu ⇒ 2 dòng giá");
        result.MaKeHoach.Should().MatchRegex(@"^KHKK/\d{4}/\d{3}$", "acceptance §③-B");
        result.MaKeHoach.Should().Be("KHKK/2026/001",
            "regex một mình vẫn xanh khi năm bị hardcode hoặc seq đứng yên — chốt luôn giá trị tuyệt đối");

        await using var db = f.NewDb();
        var plan = await db.ContractSigningPlans.AsNoTracking()
            .Include(p => p.Lines)
            .SingleAsync(p => p.Id == result.Id);

        plan.Phase.Should().Be(ContractSigningPlanPhase.DangSoanThao, "lập xong là NHÁP, chưa trình");
        plan.MaKeHoach.Should().Be(result.MaKeHoach, "mã trong DB phải khớp mã trả về");
        plan.ApprovalWorkflowId.Should().Be(seeded.WorkflowId, "quy trình pin ngay lúc tạo");
        plan.GhiChu.Should().Be("Kế hoạch ký kết đợt 1");

        // ---- Denorm từ PE.
        plan.PurchaseEvaluationId.Should().Be(seeded.PeId);
        plan.ProjectId.Should().Be(seeded.ProjectId, "dự án lấy từ phiếu nguồn");
        plan.DepartmentId.Should().Be(seeded.DepartmentId, "phòng lấy từ phiếu nguồn");

        // ---- D2: người soạn kế hoạch = NGƯỜI ĐANG TẠO, không phải người soạn PE.
        // 2 vế: bằng người tạo ∧ KHÁC người soạn PE. Chỉ có vế đầu thì trong thế giới
        // "copy pe.DrafterUserId" test vẫn có thể xanh nếu hai người trùng nhau.
        plan.DrafterUserId.Should().Be(f.Actor.UserId!.Value, "BCH lập kế hoạch là chủ phiếu GĐ2");
        plan.DrafterUserId.Should().NotBe(seeded.PeDrafterId,
            "người soạn phiếu Duyệt NCC (QS) là vai KHÁC — copy sang là trao nhầm quyền sửa/xoá");

        // ---- Lines: đúng 2 NCC trúng, trỏ Id MASTER (không phải Id row của phiếu PE).
        var lines = plan.Lines.OrderBy(l => l.PeReferenceAmount).ToList();
        lines.Select(l => l.SupplierId).Should().BeEquivalentTo(
            new[] { seeded.MasterSupplierIds[0], seeded.MasterSupplierIds[1] },
            "dòng giá phải trỏ NCC ở danh mục master");
        lines.Select(l => l.SupplierId).Should().NotContain(seeded.PeSupplierRowIds[0],
            "SupplierId là `w.SupplierId` (master) chứ KHÔNG phải `w.Id` (row của phiếu PE) — "
            + "nhầm 2 cái này thì dòng giá trỏ vào một NCC không tồn tại, tên hiện rỗng ở FE");
        lines.Should().NotContain(l => l.SupplierId == seeded.MasterSupplierIds[2],
            "NCC thua thầu KHÔNG được có dòng giá dù báo giá của nó vẫn IsSelected");

        // ---- Số tiền chốt: từng đồng, không dùng biểu thức lặp lại logic sản xuất.
        var bySupplier = plan.Lines.ToDictionary(l => l.SupplierId);
        bySupplier[seeded.MasterSupplierIds[0]].PeReferenceAmount.Should().Be(140m,
            "winner0 = 100 + 40 (cả 2 hạng mục được chọn)");
        bySupplier[seeded.MasterSupplierIds[1]].PeReferenceAmount.Should().Be(200m,
            "winner1 = 200; hạng mục 60 KHÔNG được chọn nên KHÔNG cộng — nếu ra 260 là quên lọc IsSelected");

        // Ngân sách hạng mục là 1 tỷ/dòng: nếu ai lỡ lấy ThanhTienNganSach thì con số sẽ
        // nhảy sang hàng tỷ, không thể trùng 140/200 do ngẫu nhiên.
        plan.Lines.Sum(l => l.PeReferenceAmount).Should().Be(340m);

        // ---- D4: mồi ProposedAmount = số tham chiếu (W2 chưa có đường sửa dòng giá).
        plan.Lines.Should().OnlyContain(l => l.ProposedAmount == l.PeReferenceAmount,
            "đề xuất mồi bằng giá đã duyệt ở PE — để 0 thì cả phiếu là 0 mà W2 không ai sửa được");
        plan.Lines.Should().OnlyContain(l => l.ApprovedAmount == null, "giá chốt chỉ có ở W3");

        // ---- Vết audit: 1 dòng nhật ký Insert, có trỏ ngược về phiếu nguồn.
        var logs = await db.ContractSigningPlanChangelogs.AsNoTracking()
            .Where(c => c.ContractSigningPlanId == plan.Id).ToListAsync();
        logs.Should().ContainSingle();
        logs[0].Action.Should().Be(ChangelogAction.Insert);
        logs[0].EntityType.Should().Be(ContractSigningPlanEntityType.Header);
        logs[0].PhaseAtChange.Should().Be(ContractSigningPlanPhase.DangSoanThao);
        logs[0].UserId.Should().Be(f.Actor.UserId);
        logs[0].ContextNote.Should().Contain(seeded.PeId.ToString(),
            "nhật ký phải truy ngược được về phiếu Duyệt NCC nguồn");
    }

    // ===================================================================
    // (3) 1 phiếu PE = 1 kế hoạch SỐNG (`:323-328`) — acceptance "tạo lần 2 → 409".
    // ===================================================================
    [Fact]
    public async Task CreateContractSigningPlan_SecondTimeForSamePe_Throws_AndDoesNotBurnCode()
    {
        using var f = new KhkkFixture();
        var seeded = await SeedPeAsync(f, supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]]);

        var first = await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
        first.MaKeHoach.Should().Be("KHKK/2026/001");

        var act = async () => await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
        await act.Should().ThrowAsync<ConflictException>(
            "1 phiếu Duyệt NCC chỉ được có 1 kế hoạch ký kết đang sống");

        await using var db = f.NewDb();
        db.ContractSigningPlans.Count(p => p.PurchaseEvaluationId == seeded.PeId)
            .Should().Be(1, "lần 2 không được đẻ thêm phiếu");

        // Rào (iii) đứng TRƯỚC codegen ⇒ lần thất bại không nuốt mất số 002.
        db.WorkflowAppCodeSequences.Single(s => s.Prefix == "KHKK/2026").LastSeq
            .Should().Be(1, "lần tạo hỏng KHÔNG được tiêu một số thứ tự (mã sẽ thủng lỗ chỗ)");
    }

    // ===================================================================
    // (4) Rào workflow-type — TRỌN 2 nhánh (`:197-200`).
    //
    // Đây là chỗ dễ "copy nửa guard" nhất: bản gốc Proposal có 2 nhánh, chép thiếu nhánh
    // NotFound thì một Guid quy trình rác đi thẳng vào phiếu, và chỉ vỡ ở W3 lúc duyệt.
    // ===================================================================
    [Fact]
    public async Task CreateContractSigningPlan_WorkflowGuard_WrongType_Conflict_And_Unknown_NotFound()
    {
        // (a) Quy trình CÓ THẬT nhưng thuộc module khác (Duyệt NCC) → Conflict.
        using var wrong = new KhkkFixture();
        var wrongSeed = await SeedPeAsync(wrong,
            supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]],
            wfType: ApprovalWorkflowApplicableType.DuyetNcc);

        var actWrong = async () => await CreateAsync(wrong, wrongSeed.PeId, wrongSeed.WorkflowId);
        (await actWrong.Should().ThrowAsync<ConflictException>(
                "quy trình của module khác không được pin vào phiếu KHKK"))
            .WithMessage("*Kế hoạch ký kết*");

        // (b) Quy trình KHÔNG tồn tại → NotFound (KHÔNG được im lặng cho qua).
        using var missing = new KhkkFixture();
        var okSeed = await SeedPeAsync(missing, supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]]);

        var actMissing = async () => await CreateAsync(missing, okSeed.PeId, Guid.NewGuid());
        await actMissing.Should().ThrowAsync<NotFoundException>(
            "Guid quy trình rác phải chặn NGAY — lọt vào phiếu thì tới W3 mới vỡ, lúc đó phiếu đã có mã");

        await using var db = missing.NewDb();
        db.ContractSigningPlans.IgnoreQueryFilters().Count().Should().Be(0);
    }

    // ===================================================================
    // (5) Chưa chọn NCC trúng thầu (`:317-318`) — phiếu 0 dòng giá là phiếu vô nghĩa.
    // ===================================================================
    [Fact]
    public async Task CreateContractSigningPlan_NoWinner_Throws()
    {
        using var f = new KhkkFixture();
        var seeded = await SeedPeAsync(f,
            supplierCount: 3,
            winnerIndexes: [],                       // PE đã duyệt nhưng chưa tick NCC nào trúng
            quotes: [[100m, 200m, 300m]],
            selected: [[true, true, true]]);         // có báo giá được chọn — vẫn KHÔNG đủ

        var act = async () => await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
        (await act.Should().ThrowAsync<ConflictException>(
                "không có NCC trúng thầu thì không có dòng giá nào để chốt"))
            .WithMessage("*trúng thầu*");

        await using var db = f.NewDb();
        db.ContractSigningPlans.IgnoreQueryFilters().Count()
            .Should().Be(0, "KHÔNG được tạo phiếu rỗng 0 dòng");
    }

    // ===================================================================
    // (6) Acceptance §③-B: "DELETE ở Nháp → 204; tạo lại cho CÙNG phiếu PE → 201".
    //
    // Đo 3 điều CÙNG LÚC, vì chỉ đo "tạo lại được" là đo yếu nhất trong ba:
    //   (a) xoá là xoá MỀM (row còn, `IsDeleted=1`) — nếu là xoá cứng thì màn "Đã xoá" rỗng;
    //   (b) rào "1 PE = 1 kế hoạch sống" thoát được nhờ GLOBAL QUERY FILTER, không phải
    //       nhờ row biến mất ⇒ đây mới là cửa thoát khoá 409 vĩnh viễn (M6);
    //   (c) 🔴 dòng nhật ký "xoá" PHẢI tồn tại — bug T26 (S155) là: `Add(changelog)` đặt
    //       TRƯỚC `Remove(cha)` thì EF cascade client-side tại `Remove()` DETACH luôn
    //       dòng đang `Added` ⇒ SaveChanges ghi 0 row, IM LẶNG, không lỗi. Bản W2 đã đảo
    //       đúng thứ tự (`:1126-1128`); vế (c) khoá lại để không ai "dọn cho gọn" ngược về.
    // ===================================================================
    [Fact]
    public async Task DeleteContractSigningPlan_SoftDeletes_KeepsChangelog_ThenCreateAgainSucceeds()
    {
        using var f = new KhkkFixture();
        var seeded = await SeedPeAsync(f, supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]]);

        var first = await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
        await DeleteAsync(f, first.Id, "Lập nhầm phiếu nguồn", f.Actor);

        await using (var db = f.NewDb())
        {
            // (a) xoá MỀM: khuất khỏi mọi query thường, nhưng row vẫn nằm đó.
            db.ContractSigningPlans.Count(p => p.Id == first.Id)
                .Should().Be(0, "phiếu đã xoá không được hiện ở danh sách");
            var raw = db.ContractSigningPlans.IgnoreQueryFilters().AsNoTracking()
                .Single(p => p.Id == first.Id);
            raw.IsDeleted.Should().BeTrue("`Remove()` + AuditingInterceptor = xoá MỀM, không xoá cứng");
            raw.DeletedAt.Should().NotBeNull();
            raw.DeletedBy.Should().Be(f.Actor.UserId);
            raw.MaKeHoach.Should().Be("KHKK/2026/001", "mã cũ vẫn giữ trong row đã xoá");

            // (c) vết xoá phải SỐNG SÓT qua lượt cascade — regression bug T26 S155.
            var logs = db.ContractSigningPlanChangelogs.AsNoTracking()
                .Where(c => c.ContractSigningPlanId == first.Id).ToList();
            logs.Should().HaveCount(2, "1 dòng lúc lập + 1 dòng lúc xoá");
            var del = logs.Single(l => l.Action == ChangelogAction.Delete);
            del.ContextNote.Should().Be("Lập nhầm phiếu nguồn", "lý do xoá nằm ở nhật ký");
            del.PhaseAtChange.Should().Be(ContractSigningPlanPhase.DangSoanThao);
            del.UserId.Should().Be(f.Actor.UserId);
        }

        // (b) Cửa thoát: lập LẠI cho CHÍNH phiếu PE đó.
        var second = await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
        second.Id.Should().NotBe(first.Id);
        second.MaKeHoach.Should().Be("KHKK/2026/002", "phiếu mới lấy số tiếp theo, không tái dùng mã cũ");
        second.LineCount.Should().Be(1);

        await using var check = f.NewDb();
        check.ContractSigningPlans.Count(p => p.PurchaseEvaluationId == seeded.PeId)
            .Should().Be(1, "chỉ 1 kế hoạch SỐNG cho phiếu PE này");
        check.ContractSigningPlans.IgnoreQueryFilters().Count(p => p.PurchaseEvaluationId == seeded.PeId)
            .Should().Be(2, "cộng cả phiếu đã xoá mềm là 2 row vật lý");
    }

    // ===================================================================
    // (7) Rào xoá: chủ phiếu ∥ Admin, và CHỈ ở {Nháp, Từ chối} (`:1107-1118`).
    //     Phiếu đang duyệt phải Trả lại/Từ chối trước — xoá giữa chừng là mất lượt
    //     của người đang chờ duyệt (đường xoá-khi-đang-duyệt là việc của W3).
    // ===================================================================
    [Fact]
    public async Task DeleteContractSigningPlan_GuardedByOwnerAndPhase()
    {
        using var f = new KhkkFixture();
        var seeded = await SeedPeAsync(f, supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]]);
        var created = await CreateAsync(f, seeded.PeId, seeded.WorkflowId);

        // (a) người lạ (không phải chủ phiếu, không Admin) → Forbidden.
        var stranger = new TestCurrentUser(Guid.NewGuid(), "Người lạ", "la@test.local");
        var actStranger = async () => await DeleteAsync(f, created.Id, null, stranger);
        await actStranger.Should().ThrowAsync<ForbiddenException>(
            "chỉ người soạn kế hoạch hoặc Admin được xoá");

        await using (var db = f.NewDb())
        {
            db.ContractSigningPlans.Count(p => p.Id == created.Id)
                .Should().Be(1, "chặn xong phiếu phải còn NGUYÊN (ném rồi mà vẫn ghi = tệ hơn không rào)");

            // (b) đẩy phiếu sang Đã gửi duyệt (W2 chưa có transition ⇒ set thẳng DB).
            var plan = db.ContractSigningPlans.Single(p => p.Id == created.Id);
            plan.Phase = ContractSigningPlanPhase.ChoDuyet;
            db.SaveChanges();
        }

        var actPhase = async () => await DeleteAsync(f, created.Id, null, f.Actor);
        (await actPhase.Should().ThrowAsync<ConflictException>(
                "phiếu đang chờ duyệt thì chủ phiếu cũng không được rút êm"))
            .WithMessage("*Nháp*");

        // (c) VẾ THUẬN: Admin không-phải-chủ-phiếu + đưa phiếu về Từ chối (nằm trong
        //     allow-list) → xoá ĐƯỢC. Không có vế này thì 2 vế trên vẫn xanh trong thế
        //     giới mà handler ném với MỌI đầu vào.
        await using (var db = f.NewDb())
        {
            var plan = db.ContractSigningPlans.Single(p => p.Id == created.Id);
            plan.Phase = ContractSigningPlanPhase.TuChoi;
            db.SaveChanges();
        }

        var admin = new TestCurrentUser(Guid.NewGuid(), "Quản trị", "admin@test.local", AppRoles.Admin);
        await DeleteAsync(f, created.Id, "Dọn phiếu bị từ chối", admin);

        await using var final = f.NewDb();
        final.ContractSigningPlans.Count(p => p.Id == created.Id).Should().Be(0);
        final.ContractSigningPlans.IgnoreQueryFilters().AsNoTracking()
            .Single(p => p.Id == created.Id).DeletedBy
            .Should().Be(admin.UserId, "người xoá thật được ghi lại là Admin, không phải chủ phiếu");
    }

    // ===================================================================
    // (8-bis) [S177 — anh: "Trạng thái trả lại cho xóa đc luôn nhé"] `TraLai` vào allow-list xoá.
    //
    //     Đây là ĐỔI SPEC (allow-list {Nháp, Từ chối} → {Nháp, Từ chối, Trả lại}) nên test đi
    //     CÙNG commit, không để test-after.
    //
    //     🔴 Vì sao test cũ (8) KHÔNG bắt được thiếu sót này, dù nó có cả vế thuận lẫn vế
    //        nghịch: vế nghịch của nó dùng `ChoDuyet` — vẫn bị cấm ĐÚNG cả trước lẫn sau bản
    //        vá; vế thuận dùng `TuChoi` — được phép ĐÚNG cả trước lẫn sau. `TraLai` là phase
    //        DUY NHẤT đổi verdict, và nó không có mặt trong test nào ⇒ allow-list thiếu một
    //        phase mà toàn bộ hàng test vẫn xanh. Trục nào KHÔNG có ca thì trục đó không được đo.
    //
    //     🔸 Nền của bản vá là LỆCH 2 TẦNG chứ không phải nới quyền: FE đã hiện nút Xoá cho
    //        phiếu `TraLai` từ trước (`canEdit` gồm `TraLai`), BE thì 409 ⇒ bấm được mà không
    //        xoá được. Test này neo tầng BE — tầng có răng thật.
    [Fact]
    public async Task DeleteContractSigningPlan_Allows_TraLai()
    {
        using var f = new KhkkFixture();
        var seeded = await SeedPeAsync(f, supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]]);
        var created = await CreateAsync(f, seeded.PeId, seeded.WorkflowId);

        await using (var db = f.NewDb())
        {
            var plan = db.ContractSigningPlans.Single(p => p.Id == created.Id);
            plan.Phase = ContractSigningPlanPhase.TraLai;
            db.SaveChanges();
        }

        await DeleteAsync(f, created.Id, "Phiếu bị trả lại, soạn lại từ đầu", f.Actor);

        await using var final = f.NewDb();
        final.ContractSigningPlans.Count(p => p.Id == created.Id)
            .Should().Be(0, "phiếu Trả lại đã nằm trong tay người soạn, không chiếm lượt duyệt của ai");
        final.ContractSigningPlans.IgnoreQueryFilters().AsNoTracking()
            .Single(p => p.Id == created.Id).IsDeleted
            .Should().BeTrue("xoá MỀM — vết phải còn để truy, không phải bốc hơi khỏi DB");
    }

    // ===================================================================
    // (9) [gate-K4b MAJOR-A @S166] Màn "Đã xoá" phải LỌC NHÓM ở SERVER
    //     (`ListDeletedContractSigningPlansQueryHandler` :896-897, vá @K4b).
    //
    //     Vì sao đáng 1 test riêng: sidebar KHKK có 8 leaf "Đã xóa" per-nhóm, mỗi leaf trỏ
    //     `?group=n`. Trước bản vá, endpoint KHÔNG đọc param ⇒ 8 URL khác nhau trả CÙNG một
    //     tập mọi-nhóm — hỏng IM LẶNG (màn vẫn có dữ liệu, chỉ là dữ liệu của nhóm khác).
    //
    //     Đo 4 điều, và 3 trong 4 là để phép đo KHÔNG rỗng:
    //       (a) `Total` chứ không chỉ `Items` — `Total` là DẤU VÂN TAY của lọc-server:
    //           `CountAsync` chạy SAU mọi `Where`. Ai "lọc" bằng cách lấy trang to rồi lọc
    //           trong bộ nhớ sẽ cho `Items` Y HỆT nhưng `Total` = cả thế giới ⇒ số trang nói dối.
    //       (b) group=1 ⇒ phiếu nhóm 1 · group=3 ⇒ phiếu nhóm 3 (KHÔNG phải "luôn trả cái đầu");
    //       (c) group=7 (không có phiếu) ⇒ RỖNG — lọc không khớp phải ra rỗng, không phải
    //           "bỏ qua lọc" (đây chính là hình dạng của lỗi trước bản vá);
    //       (d) đo bằng CẢ actor thường LẪN Admin: vế lọc nhóm nằm NGOÀI khối IDOR
    //           (`:884-893`), nên chỉ đo 1 vai thì đẩy vế lọc vào trong `if (!admin)` vẫn xanh.
    //
    //     🔴 CHỨNG CÓ RĂNG (không cần chạm production): ở thế giới KHÔNG có vế
    //     `.Where(p => p.ApprovalGroup == request.ApprovalGroup)`, tham số bị bỏ qua hoàn toàn
    //     ⇒ `Handle(group:1)` chạy Y HỆT `Handle(group:null)`. Mà `Handle(null)` được đo NGAY
    //     TRONG test này = 2 phiếu ⇒ vế `group:1` phải là 1 sẽ ĐỎ. Nói cách khác cặp
    //     (null ⇒ 2) ∧ (1 ⇒ 1) chính là 2 thế giới đặt cạnh nhau, đo trên MÃ THẬT.
    // ===================================================================
    [Fact]
    public async Task ListDeletedContractSigningPlans_FiltersByApprovalGroup_ServerSide()
    {
        using var f = new KhkkFixture();
        var seeded = await SeedPeAsync(f, supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]]);

        // Cùng 1 phiếu PE nhưng 2 NHÓM DUYỆT khác nhau — rào "1 PE = 1 kế hoạch sống" khoá
        // theo cặp (PeId, ApprovalGroup) (`:401-404`) nên đây là đường hợp lệ.
        // Workflow seed có Code "QT-KHKK-V2" (ngoài họ `KHKK-N{n}`) ⇒ rào (v) `:420-424` cho qua.
        var planG1 = await CreateAsync(f, seeded.PeId, seeded.WorkflowId, approvalGroup: 1);
        var planG3 = await CreateAsync(f, seeded.PeId, seeded.WorkflowId, approvalGroup: 3);

        await DeleteAsync(f, planG1.Id, "xoá phiếu nhóm 1", f.Actor);
        await DeleteAsync(f, planG3.Id, "xoá phiếu nhóm 3", f.Actor);

        // ── Sanity TRƯỚC phép đo chính: 2 row THẬT SỰ đã xoá mềm và THẬT SỰ khác nhóm.
        // Thiếu 2 vế này thì "lọc ra 1 phiếu" có thể đúng vì lý do hoàn toàn khác.
        await using (var db = f.NewDb())
        {
            var raws = db.ContractSigningPlans.IgnoreQueryFilters().AsNoTracking()
                .Where(p => p.PurchaseEvaluationId == seeded.PeId)
                .Select(p => new { p.Id, p.ApprovalGroup, p.IsDeleted }).ToList();
            raws.Should().HaveCount(2);
            raws.Should().OnlyContain(r => r.IsDeleted, "cả 2 phải là xoá MỀM, nếu không màn Đã xoá rỗng");
            raws.Single(r => r.Id == planG1.Id).ApprovalGroup.Should().Be(1);
            raws.Single(r => r.Id == planG3.Id).ApprovalGroup.Should().Be(3, "2 phiếu phải KHÁC nhóm — cùng nhóm thì phép lọc vô nghĩa");
        }

        // ── (1) KHÔNG truyền nhóm ⇒ thấy CẢ HAI. Đây cũng chính là ảnh của thế-giới-hỏng.
        var all = await ListDeletedAsync(f, f.Actor, approvalGroup: null);
        all.Items.Should().HaveCount(2);
        all.Total.Should().Be(2, "không lọc thì tổng là cả 2 phiếu đã xoá");

        // ── (2) group=1 ⇒ ĐÚNG 1 phiếu nhóm 1.
        var g1 = await ListDeletedAsync(f, f.Actor, approvalGroup: 1);
        g1.Items.Should().HaveCount(1);
        g1.Total.Should().Be(1,
            "Total đếm SAU vế Where — nếu vẫn là 2 thì handler đang trả trang đã lọc trên tập chưa lọc (số trang nói dối)");
        g1.Items[0].Id.Should().Be(planG1.Id);
        g1.Items[0].ApprovalGroup.Should().Be(1);

        // ── (3) group=3 ⇒ ĐÚNG phiếu nhóm 3 (chứng không phải "luôn trả phiếu đầu tiên").
        var g3 = await ListDeletedAsync(f, f.Actor, approvalGroup: 3);
        g3.Items.Should().HaveCount(1);
        g3.Total.Should().Be(1);
        g3.Items[0].Id.Should().Be(planG3.Id);
        g3.Items[0].ApprovalGroup.Should().Be(3);

        // ── (4) group=7 (không phiếu nào) ⇒ RỖNG, không phải "bỏ qua lọc".
        var g7 = await ListDeletedAsync(f, f.Actor, approvalGroup: 7);
        g7.Items.Should().BeEmpty("nhóm không có phiếu nào thì màn Đã xoá của nhóm đó phải trống");
        g7.Total.Should().Be(0);

        // ── (5) NHÁNH ADMIN: lọc nhóm nằm NGOÀI khối IDOR nên phải đúng ở cả 2 vai.
        var admin = new TestCurrentUser(Guid.NewGuid(), "Quản trị", "admin@test.local", AppRoles.Admin);
        var adminAll = await ListDeletedAsync(f, admin, approvalGroup: null);
        adminAll.Total.Should().Be(2, "Admin bỏ qua IDOR nên thấy cả 2 phiếu đã xoá");

        var adminG3 = await ListDeletedAsync(f, admin, approvalGroup: 3);
        adminG3.Items.Should().HaveCount(1);
        adminG3.Total.Should().Be(1,
            "đẩy vế lọc nhóm vào trong `if (!admin)` sẽ làm vế này ĐỎ trong khi 4 vế trên vẫn xanh");
        adminG3.Items[0].Id.Should().Be(planG3.Id);
    }
}
