using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.PurchaseEvaluations.Services;   // WorkflowReturnMode
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Contracts;   // ApprovalDecision enum (shared HĐ/PE)
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;

namespace SolutionErp.Infrastructure.Tests.Services;

// ===== UAT S60 (anh Kiệt) — 2 feature mới trong submit branch =====
// File mirror pattern PurchaseEvaluationWorkflowServiceGuardTests.cs cùng folder
// (helper dựng PE + AwV2 + IdentityFixture SQLite). KHÔNG touch production code —
// test theo CODE hiện tại trong PurchaseEvaluationWorkflowService.cs.
//
// Feature 1 — Section 3 completeness guard (submit branch DangSoanThao/TraLai →
//   ChoDuyet, line 158-198 prod): chặn ConflictException khi thiếu 1 trong 4:
//     (1) SelectedSupplierId null → "chưa chọn Đơn vị NCC/TP"
//     (2) winner quote sum (map PES.SupplierId==winner → row Ids → quotes) ≤ 0
//         → "chưa có giá chào thầu"
//     (3) BudgetId null && (BudgetManualAmount null || ≤0) → "chưa nhập Ngân sách"
//     (4) KHÔNG có attachment PES_Id==null → "chưa đính kèm Bảng so sánh"
//   Message gộp MỌI mục thiếu 1 lần. Áp CẢ Admin (data-quality ≠ authz).
//
// Feature 2 — Drafter-in-chain bypass khi submit (ApplyDrafterBypassOnSubmitAsync,
//   V2-only). [A1 — close-review wf_7df56b89-7bb, owner anh Kiệt] ĐỔI hành vi cũ (auto qua
//   Cấp 1..k + auto-sign slot chính chủ + auto nhảy Bước / terminal). NAY: người soạn
//   (DrafterUserId — KHÔNG phải actor gửi; Admin gửi hộ vẫn tính theo người soạn) KÝ TAY ô
//   cấp mình. CHỈ bypass các cấp NV DƯỚI THUẦN (Order < minOwn, minOwn = cấp THẤP NHẤT người
//   soạn có slot ở BƯỚC ĐẦU). Pointer DỪNG tại minOwn → người soạn tự ký Cấp minOwn qua
//   ApproveV2Async (KHÔNG auto-sign opinion chính chủ, KHÔNG auto nhảy Bước sau, KHÔNG
//   auto-terminal DaDuyet khi gửi). Mỗi cấp bypass → 1 Approval row (Decision=AutoApprove);
//   opinion Section 5 = 0 NGAY SAU submit (chỉ sinh khi người soạn ký THẬT). minOwn==1 (người
//   soạn là Cấp 1, hoặc non-contiguous Cấp 1 + Cấp 3): bypass RỖNG → toàn bộ chuỗi ký tay
//   đúng thứ tự (KHÔNG auto qua Cấp 2 của người khác).
//
// [EDGE-5 — close-review wf_7df56b89-7bb, owner anh Kiệt oQ2] TransitionAsync top-guard
//   (:57-71 prod): phiếu ĐÃ KẾT THÚC (DaDuyet / TuChoi) → user THƯỜNG (non-admin) KHÔNG được
//   trả lại / từ chối / chuyển trạng thái (ConflictException "...trạng thái kết thúc..."). Chặn
//   exploit drafter forge Reject lật phiếu DaDuyet của mình về "đang duyệt". ADMIN + SYSTEM
//   (actorUserId null + decision=AutoApprove) MIỄN (un-terminal hợp lệ + cron/SLA auto-job).
//
// [Opinion history — close-review CHANGE 4] ApproveV2Async: re-sign ĐÈ 1 opinion CŨ non-empty
//   → ghi 1 PurchaseEvaluationChangelog (EntityType=Workflow, Action=Update, EntityId=level.Id,
//   ContextNote chứa comment cũ) TRƯỚC khi overwrite (bảo toàn lịch sử, no migration). Lần ký
//   ĐẦU (existingOpinion null) HOẶC đè opinion rỗng/whitespace → KHÔNG ghi history row.
//
// LƯU Ý GUARD-FIRST: submit guard chạy TRƯỚC bypass → mọi test bypass phải dựng
//   PE ĐỦ 4 điều kiện Section 3 (winner + quote>0 + manual budget + comparison file).
//
// [S83 spec change] Thêm guard require-workflow (SAU Section-3, TRƯỚC mutate phase):
//   phiếu KHÔNG pin ApprovalWorkflowId KHÔNG được gửi duyệt (đóng lỗ hổng "quy trình
//   cũ" — phiếu null-workflow kẹt nhánh V1-legacy). Test (7) + (13) cập nhật từ
//   "submit OK no-workflow" → "throws". V1-submit deprecated (data V1 cũ wipe S59,
//   mọi phiếu mới = V2). Bypass tests (9-12,14) pin workflow → KHÔNG ảnh hưởng.
public class PeSubmitGuardAndBypassTests
{
    private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix,
        TestApplicationDbContext db, FixedDateTime clock) CreateService()
    {
        var fix = new IdentityFixture();
        var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
        var um = fix.Services.GetRequiredService<UserManager<User>>();
        var clock = new FixedDateTime(new DateTime(2026, 6, 12, 0, 0, 0, DateTimeKind.Utc));
        var notify = new NoOpNotificationService();
        var svc = new PurchaseEvaluationWorkflowService(db, clock, notify, um);
        return (svc, fix, db, clock);
    }

    // PE ở Nháp (DangSoanThao) — entry point submit branch. Drafter mặc định
    // random (test guard không cần drafter trong chuỗi). V2 nếu awId set.
    private static PurchaseEvaluation BuildPeNhap(
        Guid? selectedSupplierId = null,
        decimal? budgetPeriodAmount = null,
        Guid? approvalWorkflowId = null,
        Guid? drafterUserId = null,
        string code = "PE-S60-001")
    {
        return new PurchaseEvaluation
        {
            Type = PurchaseEvaluationType.DuyetNcc,
            Phase = PurchaseEvaluationPhase.DangSoanThao,
            MaPhieu = code,
            TenGoiThau = "Test S60 submit guard + bypass",
            ProjectId = Guid.NewGuid(),
            DrafterUserId = drafterUserId ?? Guid.NewGuid(),
            SelectedSupplierId = selectedSupplierId,
            BudgetPeriodAmount = budgetPeriodAmount,   // [S61 Mig 50] thay BudgetId/BudgetManualAmount
            ApprovalWorkflowId = approvalWorkflowId,
        };
    }

    // Seed 1 NCC tham gia (PurchaseEvaluationSupplier) cho winner + 1 detail + 1
    // quote ThanhTien=amount để winner quote sum > 0. Return supplierId (master ref).
    // [multi-NCC 2026-07-12 cardinality-change] Submit guard NAY đọc SUM theo
    // Quote.IsSelected (nguồn-sự-thật per Detail×Supplier — PurchaseEvaluationWorkflowService
    // :196-201 `where q.IsSelected`), KHÔNG còn IsWinner/SelectedSupplierId. Winner quote PHẢI
    // IsSelected=true thì mới lọt selectedThanhTien (nếu không → "chưa chọn Đơn vị NCC/TP" bắn
    // sai). Set CẢ quote.IsSelected=true + pes.IsWinner=true (invariant IsWinner==Any(IsSelected))
    // để mirror handler select-winner. Test (3) ThanhTien=0 vẫn đúng: IsSelected=true → lọt
    // list nhưng Sum≤0 → "chưa có giá chào thầu".
    private static async Task<Guid> SeedWinnerWithQuoteAsync(
        TestApplicationDbContext db, PurchaseEvaluation pe, decimal quoteThanhTien)
    {
        var supplierId = Guid.NewGuid();
        var pes = new PurchaseEvaluationSupplier
        {
            PurchaseEvaluationId = pe.Id,
            SupplierId = supplierId,
            Order = 0,
            IsWinner = true,   // derived == Any(quote.IsSelected) — giữ đúng invariant
        };
        var detail = new PurchaseEvaluationDetail
        {
            PurchaseEvaluationId = pe.Id,
            GroupCode = "A.I",
            GroupName = "Bê tông",
            NoiDung = "Concrete M100",
            Order = 0,
        };
        db.PurchaseEvaluationSuppliers.Add(pes);
        db.PurchaseEvaluationDetails.Add(detail);
        db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
        {
            PurchaseEvaluationDetailId = detail.Id,
            PurchaseEvaluationSupplierId = pes.Id,
            ThanhTien = quoteThanhTien,
            IsSelected = true,   // [multi-NCC] báo giá ĐƯỢC CHỌN → lọt SUM submit-guard/DTO/threshold
        });
        await db.SaveChangesAsync(CancellationToken.None);
        return supplierId;
    }

    // Attachment "Bảng so sánh" = PurchaseEvaluationSupplierId NULL (chung phiếu).
    private static void SeedComparisonAttachment(TestApplicationDbContext db, PurchaseEvaluation pe)
    {
        db.PurchaseEvaluationAttachments.Add(new PurchaseEvaluationAttachment
        {
            PurchaseEvaluationId = pe.Id,
            PurchaseEvaluationSupplierId = null,   // chung phiếu = bảng so sánh
            FileName = "bang-so-sanh.xlsx",
            StoragePath = "/uploads/bang-so-sanh.xlsx",
            FileSize = 1024,
            ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            Purpose = PurchaseEvaluationAttachmentPurpose.ComparisonTable,
        });
    }

    // Seed workflow V2: 1 bước (1 phòng) với N cấp (Order 1..levelApprovers.Length),
    // mỗi cấp 1 NV theo approverUserIds[i]. Return ApprovalWorkflow đã persist.
    private static async Task<ApprovalWorkflow> SeedWorkflowSingleStepAsync(
        TestApplicationDbContext db, params Guid[] approverUserIds)
    {
        return await SeedWorkflowAsync(db, new[] { approverUserIds });
    }

    // Seed workflow V2 multi-step: stepApprovers[s] = mảng NV cho từng cấp (Order
    // 1-based) của bước s (Order s+1). 1 NV / cấp (V2 OR-of-N nhưng test dùng 1).
    private static async Task<ApprovalWorkflow> SeedWorkflowAsync(
        TestApplicationDbContext db, Guid[][] stepApprovers)
    {
        var wf = new ApprovalWorkflow
        {
            Code = "QT-S60-V2",
            Version = 1,
            ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
            Name = "QT test S60 bypass",
            IsActive = true,
            IsUserSelectable = true,
        };
        for (int s = 0; s < stepApprovers.Length; s++)
        {
            var step = new ApprovalWorkflowStep
            {
                ApprovalWorkflowId = wf.Id,
                Order = s + 1,
                Name = $"Bước {s + 1}",
            };
            for (int lvl = 0; lvl < stepApprovers[s].Length; lvl++)
            {
                step.Levels.Add(new ApprovalWorkflowLevel
                {
                    ApprovalWorkflowStepId = step.Id,
                    Order = lvl + 1,
                    Name = $"Cấp {lvl + 1}",
                    ApproverUserId = stepApprovers[s][lvl],
                });
            }
            wf.Steps.Add(step);
        }
        db.ApprovalWorkflows.Add(wf);
        await db.SaveChangesAsync(CancellationToken.None);
        return wf;
    }

    private static Task SubmitAsync(
        PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId,
        params string[] roles) =>
        svc.TransitionAsync(
            evaluation: pe,
            targetPhase: PurchaseEvaluationPhase.ChoDuyet,
            actorUserId: actorUserId,
            actorRoles: roles.Length > 0 ? roles : new[] { AppRoles.Drafter },
            decision: ApprovalDecision.Approve,
            comment: null,
            ct: CancellationToken.None);

    // Approver ký Cấp hiện tại (advance pointer trong ChoDuyet qua ApproveV2Async).
    // approvedPrice* CHỈ cần khi cú ký NÀY chốt phiếu (Cấp cuối Bước cuối → DaDuyet):
    // người duyệt cuối BẮT BUỘC chọn 1 giá (ApplyApprovedPriceOnFinalize). Ký cấp
    // trung gian → để null. Match approver V2 = theo ApproverUserId (role không xét
    // trừ Admin) → default roles [Drafter] vô hại.
    private static Task ApproveAsync(
        PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId,
        string? comment = null,
        decimal? approvedPriceAmount = null,
        string? approvedPriceSource = null,
        string[]? roles = null) =>
        svc.TransitionAsync(
            evaluation: pe,
            targetPhase: PurchaseEvaluationPhase.ChoDuyet,
            actorUserId: actorUserId,
            actorRoles: roles ?? new[] { AppRoles.Drafter },
            decision: ApprovalDecision.Approve,
            comment: comment,
            approvedPriceAmount: approvedPriceAmount,
            approvedPriceSource: approvedPriceSource,
            ct: CancellationToken.None);

    // Approver Cấp hiện tại Trả lại về Người soạn (mode Drafter — Phase=TraLai, clear
    // pointer). AllowReturnToDrafter mặc định TRUE trên Level nên không cần set flag.
    private static Task ReturnToDrafterAsync(
        PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId) =>
        svc.TransitionAsync(
            evaluation: pe,
            targetPhase: PurchaseEvaluationPhase.TraLai,
            actorUserId: actorUserId,
            actorRoles: Array.Empty<string>(),
            decision: ApprovalDecision.Reject,
            comment: "Trả lại để người soạn sửa",
            returnMode: WorkflowReturnMode.Drafter,
            ct: CancellationToken.None);

    // =====================================================================
    // FEATURE 1 — Section 3 completeness guard
    // =====================================================================

    [Fact]
    public async Task Submit_MissingAllFour_ThrowsConflict_MessageContainsAllFourClauses()
    {
        // (1) Thiếu CẢ 4 → message gộp đủ 4 cụm. SelectedSupplier null → branch (1)
        // chạy + KHÔNG seed winner quote, budget, comparison.
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var pe = BuildPeNhap(); // SelectedSupplierId null, budget null, no attach
            db.PurchaseEvaluations.Add(pe);
            await db.SaveChangesAsync(CancellationToken.None);

            var act = () => SubmitAsync(svc, pe, Guid.NewGuid());

            // Khi winner null → branch winner-quote KHÔNG chạy (chỉ add "chưa chọn
            // Đơn vị NCC/TP"). Còn lại 3 mục: ngân sách + bảng so sánh. Tổng 3 cụm.
            var ex = await act.Should().ThrowAsync<ConflictException>();
            ex.Which.Message.Should().Contain("chưa chọn Đơn vị NCC/TP");
            ex.Which.Message.Should().Contain("chưa nhập Ngân sách");
            ex.Which.Message.Should().Contain("chưa đính kèm Bảng so sánh");
            // Guard chặn trước mutate
            pe.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao);
        }
    }

    [Fact]
    public async Task Submit_MissingWinnerOnly_ThrowsConflict()
    {
        // (2) Đủ budget + comparison, CHỈ thiếu winner (SelectedSupplierId null).
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var pe = BuildPeNhap(budgetPeriodAmount: 500_000m);
            db.PurchaseEvaluations.Add(pe);
            await db.SaveChangesAsync(CancellationToken.None);
            SeedComparisonAttachment(db, pe);
            await db.SaveChangesAsync(CancellationToken.None);

            var act = () => SubmitAsync(svc, pe, Guid.NewGuid());

            var ex = await act.Should().ThrowAsync<ConflictException>();
            ex.Which.Message.Should().Contain("chưa chọn Đơn vị NCC/TP");
            ex.Which.Message.Should().NotContain("chưa nhập Ngân sách");
            ex.Which.Message.Should().NotContain("chưa đính kèm Bảng so sánh");
        }
    }

    [Fact]
    public async Task Submit_WinnerWithZeroQuote_ThrowsConflict_NoBidPrice()
    {
        // (3) Winner CÓ (PES row tồn tại) nhưng sum quote = 0 → "chưa có giá chào thầu".
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var pe = BuildPeNhap(budgetPeriodAmount: 500_000m);
            db.PurchaseEvaluations.Add(pe);
            await db.SaveChangesAsync(CancellationToken.None);
            var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 0m); // quote = 0
            pe.SelectedSupplierId = supplierId;
            SeedComparisonAttachment(db, pe);
            await db.SaveChangesAsync(CancellationToken.None);

            var act = () => SubmitAsync(svc, pe, Guid.NewGuid());

            var ex = await act.Should().ThrowAsync<ConflictException>();
            ex.Which.Message.Should().Contain("chưa có giá chào thầu");
        }
    }

    [Fact]
    public async Task Submit_MissingBudget_NullBlocks_ThrowsConflict()
    {
        // (4) [Mig 58 item 2] Đủ winner + quote + comparison. Ngân sách = NULL (chưa nhập,
        // và phiếu KHÔNG có WorkItemId fallback ProInitial) → "chưa nhập Ngân sách kỳ này".
        // ĐỔI từ trước (block khi null||<=0): NAY chỉ NULL block, 0 hợp lệ (test 4b). Phiếu
        // KHÔNG pin WorkItemId → effectiveBudget=null sau khi không fallback được.
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var pe = BuildPeNhap(budgetPeriodAmount: null);   // null = chưa nhập → block
            db.PurchaseEvaluations.Add(pe);
            await db.SaveChangesAsync(CancellationToken.None);
            var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
            pe.SelectedSupplierId = supplierId;
            SeedComparisonAttachment(db, pe);
            await db.SaveChangesAsync(CancellationToken.None);

            var act = () => SubmitAsync(svc, pe, Guid.NewGuid());

            var ex = await act.Should().ThrowAsync<ConflictException>();
            ex.Which.Message.Should().Contain("chưa nhập Ngân sách");
            ex.Which.Message.Should().NotContain("chưa chọn Đơn vị NCC/TP");
            ex.Which.Message.Should().NotContain("chưa có giá chào thầu");
            ex.Which.Message.Should().NotContain("chưa đính kèm Bảng so sánh");
        }
    }

    [Fact]
    public async Task Submit_BudgetZero_IsValid_PassesBudgetGuard_ThenWorkflowRequired()
    {
        // (4b) [Mig 58 item 2] Ngân sách = 0 HỢP LỆ (SSG ngân sách 0 vẫn cho gửi duyệt).
        // ĐỔI hành vi: trước `is null || <= 0` block; NAY chỉ `is null` block → 0 đi qua.
        // Đủ winner + quote>0 + comparison + budget=0 → guard Section-3 PASS, KHÔNG còn
        // "chưa nhập Ngân sách". Phiếu KHÔNG pin workflow → dừng ở require-workflow guard
        // (chứng minh đã qua budget-guard: message NHẮC workflow chứ KHÔNG nhắc ngân sách).
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var pe = BuildPeNhap(budgetPeriodAmount: 0m);   // 0 = cố ý → hợp lệ
            db.PurchaseEvaluations.Add(pe);
            await db.SaveChangesAsync(CancellationToken.None);
            var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
            pe.SelectedSupplierId = supplierId;
            SeedComparisonAttachment(db, pe);
            await db.SaveChangesAsync(CancellationToken.None);

            var act = () => SubmitAsync(svc, pe, Guid.NewGuid());

            var ex = await act.Should().ThrowAsync<ConflictException>();
            ex.Which.Message.Should().NotContain("Ngân sách", "ngân sách = 0 hợp lệ → qua budget-guard");
            ex.Which.Message.Should().Contain("quy trình duyệt", "dừng ở require-workflow (sau budget-guard)");
            pe.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao);
        }
    }

    [Fact]
    public async Task Submit_BudgetZero_WithWorkflow_SucceedsToChoDuyet()
    {
        // (4c) [Mig 58 item 2] END-TO-END ngân sách 0 hợp lệ: đủ 4 Section-3 (winner +
        // quote>0 + comparison + budget=0) + pin workflow V2 → submit THÀNH CÔNG, Phase
        // ChoDuyet (KHÔNG ConflictException "chưa nhập Ngân sách"). Đây là trục item 2 cốt
        // lõi: ngân sách 0 không cản gửi duyệt. Drafter ngoài chuỗi → KHÔNG bypass, start
        // pointer Bước 1 Cấp 1.
        var (svc, fix, db, clock) = CreateService();
        using (fix)
        {
            var approver = (await fix.CreateUserAsync("appr0@s60.test", "Approver", null, Array.Empty<string>())).Id;
            var wf = await SeedWorkflowSingleStepAsync(db, approver);

            var pe = BuildPeNhap(budgetPeriodAmount: 0m, approvalWorkflowId: wf.Id, code: "PE-S60-B0");
            db.PurchaseEvaluations.Add(pe);
            await db.SaveChangesAsync(CancellationToken.None);
            var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
            pe.SelectedSupplierId = supplierId;
            SeedComparisonAttachment(db, pe);
            await db.SaveChangesAsync(CancellationToken.None);

            await SubmitAsync(svc, pe, Guid.NewGuid());   // drafter random (ngoài chuỗi)

            pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "ngân sách 0 + đủ ĐK + workflow → gửi duyệt OK");
            pe.CurrentWorkflowStepIndex.Should().Be(0);
            pe.CurrentApprovalLevelOrder.Should().Be(1);
            pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7));
        }
    }

    [Fact]
    public async Task Submit_MissingComparisonTable_ThrowsConflict()
    {
        // (5) Đủ winner + quote + budget. KHÔNG seed attachment chung → thiếu bảng so sánh.
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var pe = BuildPeNhap(budgetPeriodAmount: 500_000m);
            db.PurchaseEvaluations.Add(pe);
            await db.SaveChangesAsync(CancellationToken.None);
            var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
            pe.SelectedSupplierId = supplierId;
            await db.SaveChangesAsync(CancellationToken.None);

            var act = () => SubmitAsync(svc, pe, Guid.NewGuid());

            var ex = await act.Should().ThrowAsync<ConflictException>();
            ex.Which.Message.Should().Contain("chưa đính kèm Bảng so sánh");
        }
    }

    [Fact]
    public async Task Submit_AttachmentBoundToSupplier_DoesNotCountAsComparison_StillConflict()
    {
        // (6) Có attachment NHƯNG gắn NCC cụ thể (PurchaseEvaluationSupplierId != null)
        // → KHÔNG đếm là bảng so sánh (predicate PES_Id==null) → vẫn Conflict thiếu
        // bảng so sánh. Boundary: chỉ attachment chung phiếu mới qua.
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var pe = BuildPeNhap(budgetPeriodAmount: 500_000m);
            db.PurchaseEvaluations.Add(pe);
            await db.SaveChangesAsync(CancellationToken.None);
            var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
            pe.SelectedSupplierId = supplierId;

            // Lấy PES row Id của winner để gắn attachment vào NCC cụ thể.
            var winnerRowId = await db.PurchaseEvaluationSuppliers
                .Where(s => s.PurchaseEvaluationId == pe.Id && s.SupplierId == supplierId)
                .Select(s => s.Id).FirstAsync();
            db.PurchaseEvaluationAttachments.Add(new PurchaseEvaluationAttachment
            {
                PurchaseEvaluationId = pe.Id,
                PurchaseEvaluationSupplierId = winnerRowId,   // gắn NCC → KHÔNG phải bảng so sánh
                FileName = "bao-gia-ncc.pdf",
                StoragePath = "/uploads/bao-gia-ncc.pdf",
                FileSize = 2048,
                ContentType = "application/pdf",
                Purpose = PurchaseEvaluationAttachmentPurpose.QuoteDocument,
            });
            await db.SaveChangesAsync(CancellationToken.None);

            var act = () => SubmitAsync(svc, pe, Guid.NewGuid());

            var ex = await act.Should().ThrowAsync<ConflictException>();
            ex.Which.Message.Should().Contain("chưa đính kèm Bảng so sánh");
        }
    }

    [Fact]
    public async Task Submit_AllFourMet_NoWorkflow_ThrowsWorkflowRequired_S83()
    {
        // (7) [S83 spec change] Đủ 4 Section 3 NHƯNG KHÔNG pin ApprovalWorkflowId →
        // submit BỊ CHẶN. Trước S83: set ChoDuyet với level pointer null (V1-legacy);
        // từ S83: require-workflow guard (SAU Section-3, TRƯỚC mutate) → Conflict,
        // phiếu giữ Nháp. Đóng lỗ hổng "quy trình cũ" (phiếu null-workflow kẹt V1).
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var pe = BuildPeNhap(budgetPeriodAmount: 750_000m);
            db.PurchaseEvaluations.Add(pe);
            await db.SaveChangesAsync(CancellationToken.None);
            var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
            pe.SelectedSupplierId = supplierId;
            SeedComparisonAttachment(db, pe);
            await db.SaveChangesAsync(CancellationToken.None);

            var act = () => SubmitAsync(svc, pe, Guid.NewGuid());

            var ex = await act.Should().ThrowAsync<ConflictException>();
            ex.Which.Message.Should().Contain("quy trình duyệt");
            pe.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao,
                "guard require-workflow chặn TRƯỚC mutate phase");
        }
    }

    // [S61 Mig 50] Test (8) "đủ 4 qua BudgetId" XÓA — nhánh BudgetId không còn
    // tồn tại (module Budget cũ drop, predicate (3) chỉ còn BudgetPeriodAmount).
    // Nhánh thoả-mãn duy nhất đã cover bởi test (7) budgetPeriodAmount > 0.

    // =====================================================================
    // FEATURE 2 — Drafter-in-chain bypass khi submit (V2-only)
    // Mọi test dưới đây PHẢI dựng PE đủ 4 điều kiện Section 3 (guard chạy trước).
    // =====================================================================

    // Helper: dựng PE V2 đủ 4 điều kiện Section 3 + pin workflow. Drafter =
    // drafterUserId. Return PE đã persist (Nháp).
    private static async Task<PurchaseEvaluation> BuildV2PeReadyToSubmitAsync(
        TestApplicationDbContext db, Guid workflowId, Guid drafterUserId, string code)
    {
        var pe = BuildPeNhap(
            budgetPeriodAmount: 1_000_000m,
            approvalWorkflowId: workflowId,
            drafterUserId: drafterUserId,
            code: code);
        db.PurchaseEvaluations.Add(pe);
        await db.SaveChangesAsync(CancellationToken.None);
        var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_500_000m);
        pe.SelectedSupplierId = supplierId;
        SeedComparisonAttachment(db, pe);
        await db.SaveChangesAsync(CancellationToken.None);
        return pe;
    }

    [Fact]
    public async Task Submit_DrafterIsTopLevelOfFirstStep_BypassesSubordinatesOnly_StopsAtOwnLevel()
    {
        // (9) [A1] Drafter = TP (cấp 2/2 bước 1), workflow 2 bước (bước 1 có Cấp 1=NV +
        // Cấp 2=TP-drafter; bước 2 có 1 cấp = sếp). minOwn=2 → CHỈ bypass Cấp 1 (NV dưới
        // thuần). Pointer DỪNG tại (StepIdx=0, Level=2) — KHÔNG advance sang Bước 2; người
        // soạn TP phải tự ký Cấp 2 qua ApproveV2Async sau đó. Opinion count=0 NGAY SAU submit
        // (KHÔNG auto-sign slot chính chủ). 1 approval AutoApprove (chỉ Cấp 1 bypass).
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var nvLevel1 = (await fix.CreateUserAsync("nv1@s60.test", "NV Cap 1", null, Array.Empty<string>())).Id;
            var drafterTp = (await fix.CreateUserAsync("tp@s60.test", "TP Drafter", null, new[] { AppRoles.Drafter })).Id;
            var step2Boss = (await fix.CreateUserAsync("boss@s60.test", "Boss Step2", null, Array.Empty<string>())).Id;

            // bước 1: Cấp 1 = NV, Cấp 2 = TP(drafter) · bước 2: Cấp 1 = boss
            var wf = await SeedWorkflowAsync(db, new[]
            {
                new[] { nvLevel1, drafterTp },
                new[] { step2Boss },
            });
            var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterTp, "PE-S60-009");

            await SubmitAsync(svc, pe, drafterTp);

            pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
            pe.CurrentWorkflowStepIndex.Should().Be(0, "[A1] DỪNG tại bước đầu — người soạn ký Cấp mình, KHÔNG nhảy Bước 2");
            pe.CurrentApprovalLevelOrder.Should().Be(2, "[A1] pointer dừng tại minOwn=2 (cấp thấp nhất của người soạn)");

            // [A1] KHÔNG auto-sign opinion chính chủ → 0 opinion sau submit.
            var opinions = await db.PurchaseEvaluationLevelOpinions
                .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
            opinions.Should().BeEmpty("[A1] KHÔNG tự ký hộ opinion — người soạn ký tay Cấp 2 sau");

            // Chỉ Cấp 1 (NV dưới thuần) bypass → 1 approval AutoApprove.
            var autoApprovals = await db.PurchaseEvaluationApprovals
                .Where(a => a.PurchaseEvaluationId == pe.Id
                    && a.Decision == ApprovalDecision.AutoApprove).ToListAsync();
            autoApprovals.Should().HaveCount(1, "[A1] chỉ bypass Cấp 1 NV dưới = 1 vết AutoApprove");
        }
    }

    [Fact]
    public async Task Submit_DrafterIsLevel1OfFirstStep_BypassesNothing_StaysAtLevel1()
    {
        // (10) [A1] Drafter = NV cấp 1/2 bước 1 (workflow 1 bước có 2 cấp). minOwn=1 →
        // KHÔNG có cấp nào Order < 1 → bypass RỖNG. Pointer giữ init (StepIdx=0, Level=1) —
        // người soạn tự ký Cấp 1 qua ApproveV2Async. Opinion=0, AutoApprove=0 sau submit.
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var drafterNv = (await fix.CreateUserAsync("nv@s60.test", "NV Drafter", null, new[] { AppRoles.Drafter })).Id;
            var level2Boss = (await fix.CreateUserAsync("boss2@s60.test", "Boss Cap 2", null, Array.Empty<string>())).Id;

            var wf = await SeedWorkflowSingleStepAsync(db, drafterNv, level2Boss); // Cấp1=drafter, Cấp2=boss
            var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterNv, "PE-S60-010");

            await SubmitAsync(svc, pe, drafterNv);

            pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
            pe.CurrentWorkflowStepIndex.Should().Be(0, "cùng bước");
            pe.CurrentApprovalLevelOrder.Should().Be(1, "[A1] minOwn=1 → bypass rỗng → giữ Cấp 1 (người soạn tự ký)");

            var opinions = await db.PurchaseEvaluationLevelOpinions
                .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
            opinions.Should().BeEmpty("[A1] người soạn Cấp 1 → không bypass, không auto-sign");

            var autoApprovals = await db.PurchaseEvaluationApprovals
                .Where(a => a.PurchaseEvaluationId == pe.Id
                    && a.Decision == ApprovalDecision.AutoApprove).ToListAsync();
            autoApprovals.Should().BeEmpty("[A1] 0 cấp dưới → 0 bypass");
        }
    }

    [Fact]
    public async Task Submit_DrafterNotInFirstStep_NoBypass_StartsAtStep1Level1()
    {
        // (11) Drafter KHÔNG ở bước đầu (chỉ ở bước 2). drafterSlots bước 1 empty →
        // return sớm KHÔNG bypass. Pointer giữ init Step 0 Level 1. 0 approval auto.
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var step1Nv = (await fix.CreateUserAsync("s1nv@s60.test", "NV Buoc 1", null, Array.Empty<string>())).Id;
            var drafterStep2 = (await fix.CreateUserAsync("d2@s60.test", "Drafter Buoc 2", null, new[] { AppRoles.Drafter })).Id;

            var wf = await SeedWorkflowAsync(db, new[]
            {
                new[] { step1Nv },         // bước 1: NV khác (KHÔNG phải drafter)
                new[] { drafterStep2 },    // bước 2: drafter — nhưng bypass chỉ xét bước đầu
            });
            var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterStep2, "PE-S60-011");

            await SubmitAsync(svc, pe, drafterStep2);

            pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
            pe.CurrentWorkflowStepIndex.Should().Be(0, "không bypass → start Bước 1");
            pe.CurrentApprovalLevelOrder.Should().Be(1);

            var opinions = await db.PurchaseEvaluationLevelOpinions
                .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
            opinions.Should().BeEmpty("drafter ngoài bước đầu → KHÔNG ghi opinion bypass");

            var autoApprovals = await db.PurchaseEvaluationApprovals
                .Where(a => a.PurchaseEvaluationId == pe.Id
                    && a.Decision == ApprovalDecision.AutoApprove).ToListAsync();
            autoApprovals.Should().BeEmpty("0 cấp bypass");
        }
    }

    [Fact]
    public async Task Submit_OneStepWorkflow_DrafterIsLastLevel_StopsAtOwnLevel_ThenSignsToDaDuyet()
    {
        // (12) [A1] Workflow 1 bước (2 cấp) + drafter = cấp cuối (Cấp 2). minOwn=2 → bypass
        // Cấp 1 (NV dưới) NHƯNG KHÔNG auto-terminal khi gửi: pointer DỪNG tại (0, 2) với
        // Phase=ChoDuyet. Người soạn phải TỰ KÝ Cấp 2 qua ApproveV2Async để "bám đúng quy
        // trình" → chỉ khi đó mới DaDuyet. Chốt: submit KHÔNG còn tự-duyệt-hộ phiếu của mình.
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var level1Nv = (await fix.CreateUserAsync("l1@s60.test", "NV Cap 1", null, Array.Empty<string>())).Id;
            var drafterLast = (await fix.CreateUserAsync("last@s60.test", "Drafter Cap cuoi", null, new[] { AppRoles.Drafter })).Id;

            var wf = await SeedWorkflowSingleStepAsync(db, level1Nv, drafterLast); // Cấp1=NV, Cấp2=drafter(cuối)
            var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterLast, "PE-S60-012");

            // --- Bước 1: submit → dừng tại Cấp 2, CHƯA terminal ---
            await SubmitAsync(svc, pe, drafterLast);

            pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "[A1] submit KHÔNG auto-terminal — dừng chờ người soạn ký");
            pe.CurrentWorkflowStepIndex.Should().Be(0);
            pe.CurrentApprovalLevelOrder.Should().Be(2, "[A1] dừng tại minOwn=2");

            (await db.PurchaseEvaluationLevelOpinions
                .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync())
                .Should().BeEmpty("[A1] chưa ký → chưa có opinion");

            (await db.PurchaseEvaluationApprovals
                .Where(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove).ToListAsync())
                .Should().HaveCount(1, "[A1] chỉ Cấp 1 NV dưới bypass");

            // --- Bước 2: người soạn TỰ KÝ Cấp 2 (cấp cuối bước cuối) → chốt giá → DaDuyet ---
            await ApproveAsync(svc, pe, drafterLast, comment: "Người soạn ký chốt Cấp 2",
                approvedPriceAmount: 1_500_000m, approvedPriceSource: "Ncc");

            pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "người soạn ký tay Cấp cuối → terminal");
            pe.CurrentWorkflowStepIndex.Should().BeNull();
            pe.CurrentApprovalLevelOrder.Should().BeNull();
            pe.SlaDeadline.Should().BeNull();

            // Opinion CHỈ slot drafter (Cấp 2) — sinh khi ký THẬT, comment thật (không canned).
            var opinions = await db.PurchaseEvaluationLevelOpinions
                .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
            opinions.Should().HaveCount(1, "[A1] chỉ 1 opinion do người soạn ký tay Cấp 2");
            var drafterLevelId = wf.Steps.First().Levels.First(l => l.Order == 2).Id;
            opinions[0].ApprovalWorkflowLevelId.Should().Be(drafterLevelId);
            opinions[0].SignedByUserId.Should().Be(drafterLast);
            opinions[0].Comment.Should().Be("Người soạn ký chốt Cấp 2");
        }
    }

    [Fact]
    public async Task Submit_V1Phieu_NoApprovalWorkflowId_NowBlocked_S83()
    {
        // (13) [S83 spec change] Trước S83: phiếu V1 (ApprovalWorkflowId null) submit OK
        // (V1-legacy). Từ S83: require-workflow guard CHẶN — V1-submit deprecated (data
        // V1 cũ đã wipe S59, mọi phiếu mới = V2). Đủ 4 Section 3 vẫn bị chặn ở bước
        // workflow. Để gửi duyệt: mở Sửa chọn quy trình V2.
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var drafter = (await fix.CreateUserAsync("v1d@s60.test", "V1 Drafter", null, new[] { AppRoles.Drafter })).Id;
            var pe = BuildPeNhap(budgetPeriodAmount: 1_000_000m, approvalWorkflowId: null, drafterUserId: drafter, code: "PE-S60-013");
            db.PurchaseEvaluations.Add(pe);
            await db.SaveChangesAsync(CancellationToken.None);
            var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
            pe.SelectedSupplierId = supplierId;
            SeedComparisonAttachment(db, pe);
            await db.SaveChangesAsync(CancellationToken.None);

            var act = () => SubmitAsync(svc, pe, drafter);

            var ex = await act.Should().ThrowAsync<ConflictException>();
            ex.Which.Message.Should().Contain("quy trình duyệt");
            pe.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao,
                "V1-submit deprecated S83 → giữ Nháp");
        }
    }

    [Fact]
    public async Task Resubmit_FromTraLai_ReRunsBypass_DrafterSoleLevel_NoBypassNoAutoTerminal()
    {
        // (14) [A1] Workflow 1 bước 1 cấp = drafter (người soạn LÀ cấp duy nhất). minOwn=1 →
        // bypass RỖNG. ĐỔI hành vi cũ (auto-terminal DaDuyet + auto-sign khi gửi): NAY submit
        // chỉ đưa phiếu tới ChoDuyet tại (0, 1), opinion=0, AutoApprove=0 — người soạn phải tự
        // ký. Resubmit từ TraLai chạy LẠI bypass idempotent: vẫn 0 bypass row, KHÔNG cộng dồn,
        // KHÔNG duplicate (vì không có gì để cộng/nhân bản khi bypass rỗng).
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var drafterSolo = (await fix.CreateUserAsync("solo@s60.test", "Drafter Solo", null, new[] { AppRoles.Drafter })).Id;
            var wf = await SeedWorkflowSingleStepAsync(db, drafterSolo); // 1 bước, 1 cấp = drafter
            var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterSolo, "PE-S60-014");

            // Lần gửi 1 → ChoDuyet (0,1), KHÔNG terminal, 0 opinion + 0 AutoApprove.
            await SubmitAsync(svc, pe, drafterSolo);
            pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "[A1] auto-terminal-on-submit đã bỏ");
            pe.CurrentWorkflowStepIndex.Should().Be(0);
            pe.CurrentApprovalLevelOrder.Should().Be(1, "[A1] người soạn Cấp 1 → giữ chờ ký");

            (await db.PurchaseEvaluationLevelOpinions
                .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync())
                .Should().BeEmpty("[A1] chưa ký → 0 opinion");
            (await db.PurchaseEvaluationApprovals
                .CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove))
                .Should().Be(0, "[A1] minOwn=1 → bypass rỗng");

            // Mô phỏng Trả lại: reset về TraLai (như Reject branch Drafter mode làm).
            pe.Phase = PurchaseEvaluationPhase.TraLai;
            pe.CurrentWorkflowStepIndex = null;
            pe.CurrentApprovalLevelOrder = null;
            pe.SlaDeadline = null;
            await db.SaveChangesAsync(CancellationToken.None);

            // Lần gửi 2 (resubmit từ TraLai) → bypass chạy lại nhưng vẫn RỖNG (idempotent).
            await SubmitAsync(svc, pe, drafterSolo);
            pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "[A1] resubmit → ChoDuyet (0,1), vẫn KHÔNG terminal");
            pe.CurrentWorkflowStepIndex.Should().Be(0);
            pe.CurrentApprovalLevelOrder.Should().Be(1);

            (await db.PurchaseEvaluationLevelOpinions
                .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync())
                .Should().BeEmpty("[A1] vẫn 0 opinion sau resubmit — không có auto-sign");
            (await db.PurchaseEvaluationApprovals
                .CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove))
                .Should().Be(0, "[A1] bypass rỗng cả 2 lần gửi → KHÔNG cộng dồn AutoApprove");
        }
    }

    // =====================================================================
    // A1 NEW COVERAGE — happy-path người soạn ký tay + non-contiguous manual chain
    // =====================================================================

    [Fact]
    public async Task Submit_DrafterAtLevel2_ThenSignsOwnLevelWithRealComment_OpinionIsRealNotCanned()
    {
        // [A1 happy-path] Workflow 2 bước (Bước 1: Cấp 1=NV dưới, Cấp 2=drafter · Bước 2:
        // Cấp 1=boss). Submit → bypass Cấp 1, pointer DỪNG (0, 2). Người soạn TỰ KÝ Cấp 2
        // qua ApproveV2Async với ý kiến THẬT → advance sang Bước 2. Opinion phải là comment
        // THẬT của người soạn + SignedByUserId=drafter, TUYỆT ĐỐI KHÔNG phải canned
        // "(duyệt tự động…)" (chuỗi này đã bị gỡ khỏi prod trong A1 — không còn auto-sign).
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var nvSub = (await fix.CreateUserAsync("nvsub@s60.test", "NV Duoi", null, Array.Empty<string>())).Id;
            var drafter = (await fix.CreateUserAsync("drafter2@s60.test", "TP Drafter", null, new[] { AppRoles.Drafter })).Id;
            var boss = (await fix.CreateUserAsync("bossb@s60.test", "Boss Buoc 2", null, Array.Empty<string>())).Id;

            var wf = await SeedWorkflowAsync(db, new[]
            {
                new[] { nvSub, drafter },   // Bước 1: Cấp 1 = NV dưới, Cấp 2 = drafter
                new[] { boss },             // Bước 2: Cấp 1 = boss
            });
            var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafter, "PE-A1-HAPPY");

            await SubmitAsync(svc, pe, drafter);
            pe.CurrentApprovalLevelOrder.Should().Be(2, "bypass Cấp 1 → dừng tại Cấp 2 người soạn");
            (await db.PurchaseEvaluationLevelOpinions.CountAsync(o => o.PurchaseEvaluationId == pe.Id))
                .Should().Be(0, "chưa ký → 0 opinion");

            const string realComment = "Tôi đồng ý chọn nhà cung cấp này — giá hợp lý";
            await ApproveAsync(svc, pe, drafter, comment: realComment);

            // Advance sang Bước 2 (KHÔNG terminal — còn boss).
            pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
            pe.CurrentWorkflowStepIndex.Should().Be(1, "ký xong Cấp 2 Bước 1 → sang Bước 2");
            pe.CurrentApprovalLevelOrder.Should().Be(1);

            var opinions = await db.PurchaseEvaluationLevelOpinions
                .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
            opinions.Should().HaveCount(1, "chỉ 1 opinion — do người soạn ký tay (Cấp 1 bypass KHÔNG có opinion)");
            var drafterLevelId = wf.Steps.First(s => s.Order == 1).Levels.First(l => l.Order == 2).Id;
            opinions[0].ApprovalWorkflowLevelId.Should().Be(drafterLevelId);
            opinions[0].SignedByUserId.Should().Be(drafter, "người soạn ký THẬT — không phải ký hộ");
            opinions[0].Comment.Should().Be(realComment, "opinion = ý kiến thật của người soạn");
            opinions[0].Comment.Should().NotContain("duyệt tự động", "A1 gỡ canned auto-sign");
            opinions[0].Comment.Should().NotContain("bỏ qua");
        }
    }

    [Fact]
    public async Task Submit_DrafterNonContiguousLevel1And3_NoBypass_FullManualChainInOrder()
    {
        // [A1 non-contiguous] Bước 1 có 3 cấp: Cấp 1=drafter, Cấp 2=NV khác, Cấp 3=drafter.
        // minOwn=1 → bypass RỖNG (KHÔNG auto qua Cấp 2 của người khác). Toàn bộ chuỗi ký TAY
        // đúng thứ tự: drafter ký Cấp 1 → NV khác ký Cấp 2 → drafter ký Cấp 3 (chốt giá →
        // DaDuyet). 3 opinion đúng người ký, 0 AutoApprove, 3 Approve.
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var drafter = (await fix.CreateUserAsync("dnc@s60.test", "Drafter NC", null, new[] { AppRoles.Drafter })).Id;
            var otherNv = (await fix.CreateUserAsync("otherc2@s60.test", "NV Cap 2 khac", null, Array.Empty<string>())).Id;

            // 1 bước, 3 cấp: Cấp1=drafter, Cấp2=otherNv, Cấp3=drafter (non-contiguous).
            var wf = await SeedWorkflowSingleStepAsync(db, drafter, otherNv, drafter);
            var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafter, "PE-A1-NONCONTIG");

            await SubmitAsync(svc, pe, drafter);
            pe.CurrentWorkflowStepIndex.Should().Be(0);
            pe.CurrentApprovalLevelOrder.Should().Be(1, "minOwn=1 → bypass rỗng → bắt đầu Cấp 1");
            (await db.PurchaseEvaluationApprovals
                .CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove))
                .Should().Be(0, "non-contiguous minOwn=1 → KHÔNG auto qua Cấp 2 người khác");

            // Chuỗi ký tay đúng thứ tự.
            await ApproveAsync(svc, pe, drafter, comment: "Cấp 1 — người soạn");
            pe.CurrentApprovalLevelOrder.Should().Be(2, "sang Cấp 2");

            await ApproveAsync(svc, pe, otherNv, comment: "Cấp 2 — NV khác", roles: Array.Empty<string>());
            pe.CurrentApprovalLevelOrder.Should().Be(3, "sang Cấp 3");

            await ApproveAsync(svc, pe, drafter, comment: "Cấp 3 — người soạn chốt",
                approvedPriceAmount: 1_500_000m, approvedPriceSource: "Ncc");
            pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "ký hết 3 cấp → terminal");

            // Map level Id → Order từ wf in-memory (đã có Id sau SaveChanges) — tránh Join SQLite.
            var levelByOrder = wf.Steps.First().Levels.ToDictionary(l => l.Order, l => l.Id);
            var opinions = await db.PurchaseEvaluationLevelOpinions
                .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
            opinions.Should().HaveCount(3, "cả 3 cấp ký tay → 3 opinion");
            opinions.Single(o => o.ApprovalWorkflowLevelId == levelByOrder[1]).SignedByUserId
                .Should().Be(drafter, "Cấp 1 do người soạn ký");
            opinions.Single(o => o.ApprovalWorkflowLevelId == levelByOrder[2]).SignedByUserId
                .Should().Be(otherNv, "Cấp 2 do NV khác ký");
            opinions.Single(o => o.ApprovalWorkflowLevelId == levelByOrder[3]).SignedByUserId
                .Should().Be(drafter, "Cấp 3 do người soạn ký");
            opinions.Should().OnlyContain(o => !o.Comment!.Contains("bỏ qua"), "không có auto-sign hộ");

            (await db.PurchaseEvaluationApprovals
                .CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove))
                .Should().Be(0, "toàn bộ chuỗi ký tay → 0 AutoApprove");
            (await db.PurchaseEvaluationApprovals
                .CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.Approve))
                .Should().Be(3, "3 lượt ký tay = 3 Approve");
        }
    }

    // =====================================================================
    // EDGE-5 (security) — top-guard chặn non-admin đổi trạng thái phiếu ĐÃ KẾT THÚC
    // =====================================================================

    // Dựng phiếu ở phase kết thúc cho test EDGE-5 (không cần workflow — guard đứng TRƯỚC).
    private static async Task<PurchaseEvaluation> BuildTerminalPeAsync(
        TestApplicationDbContext db, PurchaseEvaluationPhase terminalPhase, string code)
    {
        var pe = BuildPeNhap(code: code);
        pe.Phase = terminalPhase;
        db.PurchaseEvaluations.Add(pe);
        await db.SaveChangesAsync(CancellationToken.None);
        return pe;
    }

    [Fact]
    public async Task Transition_NonAdminReject_FromDaDuyet_ThrowsTerminalLock_Edge5()
    {
        // [EDGE-5] non-admin forge Reject (target=TraLai) trên phiếu DaDuyet → ConflictException
        // "trạng thái kết thúc". Chặn lật phiếu đã duyệt về "đang duyệt". Phiếu giữ DaDuyet.
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var pe = await BuildTerminalPeAsync(db, PurchaseEvaluationPhase.DaDuyet, "PE-EDGE5-DADUYET");

            var act = () => svc.TransitionAsync(pe, PurchaseEvaluationPhase.TraLai,
                actorUserId: Guid.NewGuid(), actorRoles: new[] { AppRoles.Drafter },
                decision: ApprovalDecision.Reject, comment: "forge trả lại", ct: CancellationToken.None);

            var ex = await act.Should().ThrowAsync<ConflictException>();
            ex.Which.Message.Should().Contain("trạng thái kết thúc");
            pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "guard chặn TRƯỚC mutate — phiếu giữ DaDuyet");
        }
    }

    [Fact]
    public async Task Transition_NonAdminReject_FromTuChoi_ThrowsTerminalLock_Edge5()
    {
        // [EDGE-5] Tương tự từ TuChoi (phase kết thúc thứ 2) → cùng ConflictException.
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var pe = await BuildTerminalPeAsync(db, PurchaseEvaluationPhase.TuChoi, "PE-EDGE5-TUCHOI");

            var act = () => svc.TransitionAsync(pe, PurchaseEvaluationPhase.TraLai,
                actorUserId: Guid.NewGuid(), actorRoles: new[] { AppRoles.Drafter },
                decision: ApprovalDecision.Reject, comment: "forge trả lại", ct: CancellationToken.None);

            var ex = await act.Should().ThrowAsync<ConflictException>();
            ex.Which.Message.Should().Contain("trạng thái kết thúc");
            pe.Phase.Should().Be(PurchaseEvaluationPhase.TuChoi);
        }
    }

    [Fact]
    public async Task Transition_AdminReject_FromDaDuyet_NotBlockedByTerminalLock_Edge5()
    {
        // [EDGE-5] ADMIN MIỄN guard → qua top-guard, un-terminal phiếu DaDuyet về TraLai
        // (owner oQ2 "Admin đầy đủ nhất"). KHÔNG ném "trạng thái kết thúc".
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var pe = await BuildTerminalPeAsync(db, PurchaseEvaluationPhase.DaDuyet, "PE-EDGE5-ADMIN");

            var act = () => svc.TransitionAsync(pe, PurchaseEvaluationPhase.TraLai,
                actorUserId: Guid.NewGuid(), actorRoles: new[] { AppRoles.Admin },
                decision: ApprovalDecision.Reject, comment: "admin un-terminal",
                returnMode: WorkflowReturnMode.Drafter, ct: CancellationToken.None);

            await act.Should().NotThrowAsync("Admin được MIỄN EDGE-5 guard");
            pe.Phase.Should().Be(PurchaseEvaluationPhase.TraLai, "Admin qua guard + un-terminal thành công");
        }
    }

    [Fact]
    public async Task Transition_SystemAutoApprove_FromDaDuyet_NotBlockedByTerminalLock_Edge5()
    {
        // [EDGE-5] SYSTEM (actorUserId=null + decision=AutoApprove, vd cron/SLA) MIỄN guard.
        // Từ DaDuyet không có transition system hợp lệ → rơi xuống fall-through "không hỗ trợ"
        // (KHÁC "trạng thái kết thúc") → chứng minh đã QUA EDGE-5, không bị guard này chặn.
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var pe = await BuildTerminalPeAsync(db, PurchaseEvaluationPhase.DaDuyet, "PE-EDGE5-SYSTEM");

            var act = () => svc.TransitionAsync(pe, PurchaseEvaluationPhase.ChoDuyet,
                actorUserId: null, actorRoles: Array.Empty<string>(),
                decision: ApprovalDecision.AutoApprove, comment: "cron", ct: CancellationToken.None);

            var ex = await act.Should().ThrowAsync<ConflictException>();
            ex.Which.Message.Should().NotContain("trạng thái kết thúc", "system MIỄN EDGE-5 guard");
            ex.Which.Message.Should().Contain("không hỗ trợ", "qua EDGE-5 → fall-through vì DaDuyet không có transition system");
        }
    }

    // =====================================================================
    // Opinion history (CHANGE 4) — re-sign đè opinion cũ → 1 Changelog Workflow/Update
    // =====================================================================

    [Fact]
    public async Task ReSign_OverExistingNonEmptyOpinion_LogsExactlyOneHistoryRow_FirstSignLogsNone()
    {
        // [CHANGE 4] Workflow 1 bước 2 cấp (Cấp 1=approverA, Cấp 2=approverB). Drafter ngoài
        // chuỗi → không bypass. approverA ký Cấp 1 (lần ĐẦU) → KHÔNG history row. approverB
        // Trả lại → drafter resubmit → approverA ký Cấp 1 LẦN 2 → đúng 1 Changelog
        // (EntityType=Workflow, Action=Update, EntityId=Cấp1.Id, ContextNote chứa comment cũ)
        // + opinion.Comment cập nhật bản mới.
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var approverA = (await fix.CreateUserAsync("appA@s60.test", "Approver A", null, Array.Empty<string>())).Id;
            var approverB = (await fix.CreateUserAsync("appB@s60.test", "Approver B", null, Array.Empty<string>())).Id;
            var drafterX = (await fix.CreateUserAsync("dx@s60.test", "Drafter Ngoai", null, new[] { AppRoles.Drafter })).Id;

            var wf = await SeedWorkflowSingleStepAsync(db, approverA, approverB); // Cấp1=A, Cấp2=B
            var cap1Id = wf.Steps.First().Levels.First(l => l.Order == 1).Id;
            var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterX, "PE-HIST-1");

            await SubmitAsync(svc, pe, drafterX);   // drafter ngoài chuỗi → pointer (0,1), no bypass

            const string round1 = "Round 1: đồng ý sơ bộ";
            await ApproveAsync(svc, pe, approverA, comment: round1, roles: Array.Empty<string>());

            // Lần ký ĐẦU → KHÔNG có history row cho Cấp 1.
            (await db.PurchaseEvaluationChangelogs.CountAsync(c =>
                c.PurchaseEvaluationId == pe.Id
                && c.EntityId == cap1Id
                && c.Action == ChangelogAction.Update))
                .Should().Be(0, "[CHANGE 4] lần ký đầu (existingOpinion null) → KHÔNG log history");

            // approverB Trả lại về người soạn → resubmit → về lại Cấp 1.
            await ReturnToDrafterAsync(svc, pe, approverB);
            pe.Phase.Should().Be(PurchaseEvaluationPhase.TraLai);
            await SubmitAsync(svc, pe, drafterX);
            pe.CurrentApprovalLevelOrder.Should().Be(1, "resubmit → chạy lại từ Cấp 1");

            // approverA ký Cấp 1 LẦN 2 (đè opinion cũ non-empty).
            const string round2 = "Round 2: đã xem lại, vẫn đồng ý";
            await ApproveAsync(svc, pe, approverA, comment: round2, roles: Array.Empty<string>());

            var histRows = await db.PurchaseEvaluationChangelogs
                .Where(c => c.PurchaseEvaluationId == pe.Id
                    && c.EntityId == cap1Id
                    && c.Action == ChangelogAction.Update)
                .ToListAsync();
            histRows.Should().HaveCount(1, "[CHANGE 4] re-sign đè opinion non-empty → đúng 1 history row");
            histRows[0].EntityType.Should().Be(PurchaseEvaluationEntityType.Workflow);
            histRows[0].ContextNote.Should().Contain(round1, "history lưu snapshot ý kiến CŨ");

            var cap1Opinion = await db.PurchaseEvaluationLevelOpinions
                .SingleAsync(o => o.PurchaseEvaluationId == pe.Id && o.ApprovalWorkflowLevelId == cap1Id);
            cap1Opinion.Comment.Should().Be(round2, "opinion overwrite bằng bản mới");
        }
    }

    [Fact]
    public async Task ReSign_OverEmptyOpinion_LogsNoHistoryRow()
    {
        // [CHANGE 4 boundary] Opinion cũ Comment rỗng (chỉ tồn tại qua data legacy / seed trực
        // tiếp — service không bao giờ tạo rỗng vì có placeholder "(duyệt — không ý kiến)").
        // Guard `!IsNullOrWhiteSpace(existingOpinion.Comment)` → re-sign đè opinion RỖNG KHÔNG
        // ghi history row. Pin defensive-guard.
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var approverA = (await fix.CreateUserAsync("appAe@s60.test", "Approver A", null, Array.Empty<string>())).Id;
            var approverB = (await fix.CreateUserAsync("appBe@s60.test", "Approver B", null, Array.Empty<string>())).Id;
            var drafterX = (await fix.CreateUserAsync("dxe@s60.test", "Drafter Ngoai", null, new[] { AppRoles.Drafter })).Id;

            var wf = await SeedWorkflowSingleStepAsync(db, approverA, approverB); // Cấp1=A, Cấp2=B
            var cap1Id = wf.Steps.First().Levels.First(l => l.Order == 1).Id;
            var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterX, "PE-HIST-2");

            await SubmitAsync(svc, pe, drafterX);   // pointer (0,1)

            // Seed opinion Cấp 1 với Comment RỖNG (mô phỏng data legacy).
            db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
            {
                PurchaseEvaluationId = pe.Id,
                ApprovalWorkflowLevelId = cap1Id,
                Comment = "",   // rỗng — chỉ đạt được qua seed trực tiếp
                SignedAt = DateTime.UtcNow,
                SignedByUserId = approverA,
                SignedByFullName = "Approver A",
            });
            await db.SaveChangesAsync(CancellationToken.None);

            // approverA ký Cấp 1 → đè opinion rỗng.
            await ApproveAsync(svc, pe, approverA, comment: "Ý kiến mới sau bản rỗng", roles: Array.Empty<string>());

            (await db.PurchaseEvaluationChangelogs.CountAsync(c =>
                c.PurchaseEvaluationId == pe.Id
                && c.EntityId == cap1Id
                && c.Action == ChangelogAction.Update))
                .Should().Be(0, "[CHANGE 4] đè opinion rỗng → KHÔNG log history");

            var cap1Opinion = await db.PurchaseEvaluationLevelOpinions
                .SingleAsync(o => o.PurchaseEvaluationId == pe.Id && o.ApprovalWorkflowLevelId == cap1Id);
            cap1Opinion.Comment.Should().Be("Ý kiến mới sau bản rỗng", "opinion overwrite bình thường");
        }
    }

    // =====================================================================
    // [S116 anh Kiệt — SỐ ÂM báo giá / R2-revised submit-guard] Báo giá ÂM
    // (phát-sinh-giảm / hoàn tiền NCC = giá-trị THẬT) → gửi duyệt được.
    // Guard ĐỔI (prod :202-208): `Sum() <= 0` (cũ) → `All(x => x == 0)` (mới):
    //   • CHƯA chọn gì (0 quote IsSelected)  → "chưa chọn Đơn vị NCC/TP"  (chặn)
    //   • Có chọn nhưng TẤT CẢ = 0           → "chưa có giá chào thầu"    (chặn)
    //   • Có ≥1 báo giá KHÁC 0 (âm HOẶC dương) → QUA guard (gửi được)
    // Regression-lock mạnh nhất = mixed net==0 nhưng từng dòng ≠0: dưới guard CŨ
    // (Sum<=0) bị CHẶN, dưới guard MỚI (All==0) cho QUA. Revert về Sum()<=0 →
    // 2 test net≤0 dưới đây RED ngay.
    // =====================================================================

    // Seed 1 winner (PES) mang N báo giá ĐƯỢC CHỌN (mỗi giá 1 detail) — cho phép âm /
    // trái dấu. Mở rộng SeedWinnerWithQuoteAsync (:106, 1 quote/winner) → N quote/winner.
    private static async Task<Guid> SeedWinnerWithQuotesAsync(
        TestApplicationDbContext db, PurchaseEvaluation pe, params decimal[] quoteThanhTien)
    {
        var supplierId = Guid.NewGuid();
        var pes = new PurchaseEvaluationSupplier
        {
            PurchaseEvaluationId = pe.Id,
            SupplierId = supplierId,
            Order = 0,
            IsWinner = true,
        };
        db.PurchaseEvaluationSuppliers.Add(pes);
        var order = 0;
        foreach (var tt in quoteThanhTien)
        {
            var detail = new PurchaseEvaluationDetail
            {
                PurchaseEvaluationId = pe.Id,
                GroupCode = "A.I",
                GroupName = "Bê tông",
                NoiDung = $"Dòng {order}",
                Order = order,
            };
            db.PurchaseEvaluationDetails.Add(detail);
            db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
            {
                PurchaseEvaluationDetailId = detail.Id,
                PurchaseEvaluationSupplierId = pes.Id,
                ThanhTien = tt,
                IsSelected = true,
            });
            order++;
        }
        await db.SaveChangesAsync(CancellationToken.None);
        return supplierId;
    }

    // Dựng PE V2 đủ 4 ĐK Section-3 NHƯNG winner mang N báo giá tuỳ ý (âm / mixed).
    // budget=1tr (>0 qua budget-guard) + comparison + pin workflow. Drafter ngoài
    // chuỗi → submit start pointer (0,1) KHÔNG bypass.
    private static async Task<PurchaseEvaluation> BuildV2PeReadyToSubmitWithQuotesAsync(
        TestApplicationDbContext db, Guid workflowId, Guid drafterUserId, string code,
        params decimal[] selectedQuoteThanhTien)
    {
        var pe = BuildPeNhap(
            budgetPeriodAmount: 1_000_000m,
            approvalWorkflowId: workflowId,
            drafterUserId: drafterUserId,
            code: code);
        db.PurchaseEvaluations.Add(pe);
        await db.SaveChangesAsync(CancellationToken.None);
        var supplierId = await SeedWinnerWithQuotesAsync(db, pe, selectedQuoteThanhTien);
        pe.SelectedSupplierId = supplierId;
        SeedComparisonAttachment(db, pe);
        await db.SaveChangesAsync(CancellationToken.None);
        return pe;
    }

    [Fact]
    public async Task Submit_AllSelectedQuotesZero_ThrowsConflict_NoBidPrice_R2Revised()
    {
        // all-zero: winner có 2 báo giá ĐƯỢC CHỌN NHƯNG cả 2 = 0 → All(==0) → chặn
        // "chưa có giá chào thầu" (số ÂM mới hợp lệ; số 0 vẫn = chưa nhập giá). Đủ
        // budget + comparison → message CHỈ nhắc giá. Bổ sung test (3) single-zero:
        // ở đây .All() chạy trên list NHIỀU phần tử (đều 0).
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var pe = BuildPeNhap(budgetPeriodAmount: 500_000m, code: "PE-NEG-ZERO");
            db.PurchaseEvaluations.Add(pe);
            await db.SaveChangesAsync(CancellationToken.None);
            var supplierId = await SeedWinnerWithQuotesAsync(db, pe, 0m, 0m);
            pe.SelectedSupplierId = supplierId;
            SeedComparisonAttachment(db, pe);
            await db.SaveChangesAsync(CancellationToken.None);

            var act = () => SubmitAsync(svc, pe, Guid.NewGuid());

            var ex = await act.Should().ThrowAsync<ConflictException>();
            ex.Which.Message.Should().Contain("chưa có giá chào thầu");
            ex.Which.Message.Should().NotContain("chưa chọn Đơn vị NCC/TP");
            ex.Which.Message.Should().NotContain("Ngân sách");
            ex.Which.Message.Should().NotContain("Bảng so sánh");
            pe.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao, "guard chặn TRƯỚC mutate");
        }
    }

    [Fact]
    public async Task Submit_PureNegativeQuote_PassesBidPriceGuard_SucceedsToChoDuyet_R2Revised()
    {
        // ⭐ TRỤC CỐT LÕI: 1 winner, 1 báo giá ÂM −500k (hoàn tiền / phát-sinh-giảm =
        // giá THẬT) → All(==0) false → QUA guard → gửi duyệt OK (ChoDuyet 0,1). Dưới
        // guard CŨ (Sum<=0) case này BỊ CHẶN → đây là hành-vi mới R2-revised.
        var (svc, fix, db, clock) = CreateService();
        using (fix)
        {
            var approver = (await fix.CreateUserAsync("apprneg@s116.test", "Approver", null, Array.Empty<string>())).Id;
            var wf = await SeedWorkflowSingleStepAsync(db, approver);
            var pe = await BuildV2PeReadyToSubmitWithQuotesAsync(
                db, wf.Id, drafterUserId: Guid.NewGuid(), code: "PE-NEG-PURE", -500_000m);

            await SubmitAsync(svc, pe, Guid.NewGuid());   // actor ngoài chuỗi

            pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "báo giá âm là giá THẬT → gửi duyệt được");
            pe.CurrentWorkflowStepIndex.Should().Be(0);
            pe.CurrentApprovalLevelOrder.Should().Be(1);
            pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7));
        }
    }

    [Fact]
    public async Task Submit_MixedSignQuotes_NetPositive_SucceedsToChoDuyet_R2Revised()
    {
        // mixed net>0: +1tr vật tư + (−300k) giảm trừ → net +700k, từng dòng ≠0 → qua guard.
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var approver = (await fix.CreateUserAsync("apprmix@s116.test", "Approver", null, Array.Empty<string>())).Id;
            var wf = await SeedWorkflowSingleStepAsync(db, approver);
            var pe = await BuildV2PeReadyToSubmitWithQuotesAsync(
                db, wf.Id, drafterUserId: Guid.NewGuid(), code: "PE-NEG-MIXPOS", 1_000_000m, -300_000m);

            await SubmitAsync(svc, pe, Guid.NewGuid());

            pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "net dương + từng dòng ≠0 → gửi duyệt OK");
            pe.CurrentApprovalLevelOrder.Should().Be(1);
        }
    }

    [Fact]
    public async Task Submit_MixedSignQuotes_NetZeroButIndividualNonzero_SucceedsToChoDuyet_R2Revised()
    {
        // 🔒 STRONGEST regression-lock cho R2-revised: +500k + (−500k) → net == 0 NHƯNG
        // từng dòng ≠0 → All(==0) FALSE → QUA guard. Dưới guard CŨ (Sum()<=0) net==0 sẽ
        // BỊ CHẶN → nếu code revert về Sum()<=0, test này RED ngay. Đây chính là ranh
        // giới phân biệt All(==0) vs Sum()<=0.
        var (svc, fix, db, _) = CreateService();
        using (fix)
        {
            var approver = (await fix.CreateUserAsync("apprz@s116.test", "Approver", null, Array.Empty<string>())).Id;
            var wf = await SeedWorkflowSingleStepAsync(db, approver);
            var pe = await BuildV2PeReadyToSubmitWithQuotesAsync(
                db, wf.Id, drafterUserId: Guid.NewGuid(), code: "PE-NEG-NET0", 500_000m, -500_000m);

            await SubmitAsync(svc, pe, Guid.NewGuid());

            pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
                "net==0 nhưng từng báo giá ≠0 → All(==0) false → gửi được (phân biệt All==0 vs Sum<=0)");
            pe.CurrentApprovalLevelOrder.Should().Be(1);
        }
    }
}
