using FluentValidation;                                 // IValidator<T> — kiểu tham số của ValidationBehavior
using MediatR;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Behaviors;         // ValidationBehavior — đường mà 2001 ký tự đi qua trong prod
using SolutionErp.Application.Common.Exceptions;
// Cùng alias với chính `ValidationBehavior.cs`: `ValidationException` tồn tại ở CẢ FluentValidation
// lẫn Application.Common.Exceptions. T-12 phải bắt ĐÚNG cái của app (cái map ra HTTP 400).
using ValidationException = SolutionErp.Application.Common.Exceptions.ValidationException;
using SolutionErp.Application.Common.Interfaces;        // ICurrentUser — bơm cho AuditingInterceptor
using SolutionErp.Application.Contracts;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;                      // AppRoles
using SolutionErp.Infrastructure.Persistence;
using SolutionErp.Infrastructure.Persistence.Interceptors;
using SolutionErp.Infrastructure.Tests.Common;

namespace SolutionErp.Infrastructure.Tests.Application;

// [W5a YC-029 — S190 2026-08-12] Đường XOÁ Hợp đồng đồng-khuôn KHKK — **TEST-BEFORE**.
//
// Nguồn ca DUY NHẤT: `runs/2026-08-12-S190-soltuon-dieu-chinh-hd/style-gate-v2.md` §C T-MATRIX
// (13 ca T-1..T-13). Chi tiết bẫy per-ca: `sub-b5-plan-final.md` §W5 việc 1.
//
// ── LOẠI ĐỎ MONG ĐỢI (đọc trước khi hoảng) ───────────────────────────────────────
// File này viết theo HÀNH VI ĐÍCH sau W5, trong khi mã production hiện tại
// (`ContractFeatures.cs:885-902`) mới chỉ có:
//     record DeleteContractCommand(Guid Id)                       // CHƯA có Reason
//     DeleteContractCommandHandler(IApplicationDbContext db)      // CHƯA nhận ICurrentUser
//     (KHÔNG có DeleteContractCommandValidator)
// ⇒ đỏ ở đây là **ĐỎ COMPILE**, không phải đỏ assert. Đúng bản chất test-before: hợp đồng API
// của handler được CHỐT bằng test trước khi ai gõ dòng production nào. BE worker làm xanh bằng
// cách nở đúng 3 thứ trên — **KHÔNG được sửa test cho vừa mã** (Đ-3).
//
// 🔴 3 TÊN BỊ PIN bởi file này (BE phải dùng ĐÚNG, sai tên = vẫn đỏ compile):
//     (1) `DeleteContractCommand(Guid Id, string? Reason = null)`
//     (2) `DeleteContractCommandHandler(IApplicationDbContext db, ICurrentUser currentUser)`
//     (3) `DeleteContractCommandValidator : AbstractValidator<DeleteContractCommand>`
//         — khuôn `DeleteContractSigningPlanCommandValidator` (`ContractSigningPlanFeatures.cs:1743`).
//
// ── 2 quyết định hạ tầng, cả hai là ĐIỀU KIỆN để phép đo có nghĩa ────────────────
//
// (1) Fixture RIÊNG có `AuditingInterceptor` (KHÔNG dùng `SqliteDbFixture`). `TestApplicationDbContext`
//     mặc định KHÔNG wire interceptor ⇒ `Remove()` là xoá **CỨNG** (bài S155). Đo "xoá mềm +
//     DeletedBy + row còn trong DB" trên xoá-cứng là đo NHẦM cơ chế: mọi assert dạng
//     `IgnoreQueryFilters().Single(...)` sẽ nổ vì row bốc hơi, còn assert `Count()==0` thì XANH
//     kể cả khi soft-delete chưa từng tồn tại. Nếp fixture private-per-file theo họ Contract
//     (`ContractSigningPlanCrudTests.cs:50` `KhkkFixture` · `ContractCatalogTests.cs:83`).
//
// (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. Tái dùng 1 context cho cả seed lẫn lệnh thì các con
//     (Changelogs) đang được TRACK sẽ bị EF cascade client-side ngay tại `Remove()` — đường đi
//     KHÔNG xảy ra trong prod (ở đó context mới chỉ nạp mỗi header). Chính sai lệch này đẻ ra
//     bug T26 (S155) mà T-1 dưới đây canh.
//
// ── Ghi chú SPEC vs CODE ─────────────────────────────────────────────────────────
// Allow-list phase của HĐ **RỘNG HƠN** KHKK: `Phase < DangInKy(5)` ∪ {TraLai(98), TuChoi(99)}
// — tức gồm cả legacy 1/3/4, KHÔNG phải bộ 3 {DangSoanThao, TuChoi, TraLai} của KHKK. T-4 là ca
// canh đúng chỗ này (chép nguyên hình pattern KHKK = 409 oan cho HĐ legacy).
// T-14 (phản-ca) CỐ Ý KHÔNG có mặt: không viết test reflection-authz cho DELETE — chiều khẳng
// định sai với shape hiện tại (`[Authorize]` trần), chiều phủ định PIN LỖ và sẽ đỏ oan khi W8 mở.
public class ContractDeleteGuardTests
{
    private static readonly DateTime Clock2026 = new(2026, 8, 12, 3, 0, 0, DateTimeKind.Utc);

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

        /// Người soạn HĐ mặc định (chủ phiếu) — đổi actor giữa chừng để đóng vai người khác.
        public TestCurrentUser Owner { get; } = new(Guid.NewGuid(), "Nguyễn Văn Soạn", "soan@test.local");

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

        /// Context MỚI cho mỗi "request" (xem ghi chú hạ tầng (2)).
        /// 🔴 `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. Để interceptor dính cứng một
        /// người còn handler chạy bằng người khác thì `DeletedBy` đo ra người sai — và sai theo
        /// hướng TRÔNG NHƯ ĐÚNG (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 ?? Owner, Clock))
                .Options;
            return new TestApplicationDbContext(options);
        }

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

    // ===================================================================
    // Seed: 1 HĐ + 1 dòng nhật ký CÓ SẴN.
    //
    // `Contract.SupplierId/ProjectId` là Guid LOOSE (không FK vật lý —
    // `ContractConfiguration.cs:29-31` chỉ có index) ⇒ không cần seed master.
    //
    // Dòng nhật ký `Insert` có sẵn KHÔNG phải trang trí: nó biến assert changelog thành phép đo
    // PHÂN BIỆT — "đúng 1 dòng Delete" trên nền đã có 1 dòng khác chứng minh test đọc theo
    // Action chứ không đếm bừa, đồng thời canh vết cũ không bị lượt xoá cuốn theo.
    // ===================================================================
    private static async Task<Guid> SeedContractAsync(
        ContractDeleteFixture f,
        ContractPhase phase,
        Guid? drafterUserId,
        string? maHopDong = null)
    {
        await using var db = f.NewDb();
        var id = Guid.NewGuid();

        db.Contracts.Add(new Contract
        {
            Id = id,
            Type = ContractType.HopDongThauPhu,
            Phase = phase,
            SupplierId = Guid.NewGuid(),
            ProjectId = Guid.NewGuid(),
            DrafterUserId = drafterUserId,
            GiaTri = 1_500_000_000m,
            TenHopDong = "HĐ thầu phụ phần thô",
            MaHopDong = maHopDong,
        });

        db.ContractChangelogs.Add(new ContractChangelog
        {
            ContractId = id,
            EntityType = ChangelogEntityType.Contract,
            Action = ChangelogAction.Insert,
            PhaseAtChange = ContractPhase.DangSoanThao,
            UserId = drafterUserId,
            UserName = "Nguyễn Văn Soạn",
            Summary = "Tạo hợp đồng",
        });

        await db.SaveChangesAsync(CancellationToken.None);
        return id;
    }

    /// Chạy lệnh xoá qua handler THẬT, 1 context mới, interceptor + handler CÙNG actor (như prod).
    private static async Task DeleteAsync(
        ContractDeleteFixture f, Guid contractId, string? reason, TestCurrentUser actor)
    {
        await using var db = f.NewDb(actor);
        var handler = new DeleteContractCommandHandler(db, actor);
        await handler.Handle(new DeleteContractCommand(contractId, reason), CancellationToken.None);
    }

    private static TestCurrentUser Stranger()
        => new(Guid.NewGuid(), "Người lạ", "la@test.local");

    private static TestCurrentUser Admin()
        => new(Guid.NewGuid(), "Quản trị", "admin@test.local", AppRoles.Admin);

    // ===================================================================
    // T-1 — ca gốc: 3 phép đo trong 1 lượt, mỗi phép canh một tầng khác nhau.
    //   (a) khuất khỏi query thường   → global filter còn hiệu lực
    //   (b) IgnoreQueryFilters        → row VẪN NẰM trong DB, IsDeleted/DeletedBy đúng người
    //   (c) changelog đúng 1 dòng     → thứ tự Remove-trước/Add-sau (bug T26 S155)
    // Thiếu (b) thì xoá cứng cũng xanh; thiếu (c) thì lý do xoá bốc hơi im lặng, không lỗi.
    // ===================================================================
    [Fact]
    // T-1: HĐ nháp của chính mình → xoá MỀM + để lại đúng 1 vết nhật ký có lý do.
    public async Task DeleteContract_DraftByOwner_SoftDeletes_AndWritesOneChangelogRow()
    {
        using var f = new ContractDeleteFixture();
        var id = await SeedContractAsync(f, ContractPhase.DangSoanThao, f.Owner.UserId);

        await DeleteAsync(f, id, "Lập nhầm nhà cung cấp", f.Owner);

        await using var db = f.NewDb();

        // (a) khuất khỏi mọi màn hình thường.
        db.Contracts.Count(c => c.Id == id)
            .Should().Be(0, "HĐ đã xoá không được lọt vào danh sách/đếm của màn hình thường");

        // (b) nhưng row vẫn còn để truy — xoá MỀM, không phải bốc hơi.
        var raw = db.Contracts.IgnoreQueryFilters().AsNoTracking().Single(c => c.Id == id);
        raw.IsDeleted.Should().BeTrue("xoá mềm — vết phải còn để truy");
        raw.DeletedBy.Should().Be(f.Owner.UserId, "người xoá thật phải được ghi lại");

        // (c) nhật ký: dòng cũ còn sống + đúng 1 dòng Delete mang đủ ngữ cảnh.
        var logs = db.ContractChangelogs.AsNoTracking().Where(l => l.ContractId == id).ToList();
        logs.Should().HaveCount(2, "1 dòng lúc tạo + 1 dòng lúc xoá");

        var del = logs.Single(l => l.Action == ChangelogAction.Delete);
        del.EntityType.Should().Be(ChangelogEntityType.Contract, "xoá HEADER, không phải con");
        del.EntityId.Should().BeNull("header thì EntityId để trống (khuôn 2 site ghi tay)");
        del.PhaseAtChange.Should().Be(ContractPhase.DangSoanThao, "snapshot phase lúc xoá");
        del.UserId.Should().Be(f.Owner.UserId);
        del.UserName.Should().Be(f.Owner.FullName);
        del.ContextNote.Should().Be("Lập nhầm nhà cung cấp",
            "LÝ DO chỉ sống ở changelog — DeletedBy/DeletedAt chỉ trả lời ai + lúc nào");
    }

    // ===================================================================
    // T-2 — `TraLai` (98). Dev 0 row ⇒ seed THẲNG Phase (style-gate §D dữ-liệu đo tay).
    //   Ý nghĩa: phiếu bị trả về tay người soạn thì KHÔNG chiếm lượt duyệt của ai
    //   ⇒ cùng vị thế với nháp. `PhaseAtChange` assert theo phase THẬT (98) để chứng
    //   snapshot đọc từ entity chứ không hardcode DangSoanThao.
    // ===================================================================
    [Fact]
    // T-2: HĐ ở Trả lại (98) → chủ HĐ xoá được.
    public async Task DeleteContract_TraLai_ByOwner_Succeeds()
    {
        using var f = new ContractDeleteFixture();
        var id = await SeedContractAsync(f, ContractPhase.TraLai, f.Owner.UserId);

        await DeleteAsync(f, id, "Bị trả lại, soạn lại từ đầu", f.Owner);

        await using var db = f.NewDb();
        db.Contracts.Count(c => c.Id == id).Should().Be(0);
        db.Contracts.IgnoreQueryFilters().AsNoTracking().Single(c => c.Id == id)
            .IsDeleted.Should().BeTrue();
        db.ContractChangelogs.AsNoTracking()
            .Single(l => l.ContractId == id && l.Action == ChangelogAction.Delete)
            .PhaseAtChange.Should().Be(ContractPhase.TraLai,
                "snapshot phải là phase THẬT lúc xoá, không phải hằng số");
    }

    // ===================================================================
    // T-3 — `TuChoi` (99). Terminal khoá, nhưng khoá là khoá SỬA, không phải khoá DỌN.
    // ===================================================================
    [Fact]
    // T-3: HĐ ở Từ chối (99) → chủ HĐ xoá được.
    public async Task DeleteContract_TuChoi_ByOwner_Succeeds()
    {
        using var f = new ContractDeleteFixture();
        var id = await SeedContractAsync(f, ContractPhase.TuChoi, f.Owner.UserId);

        await DeleteAsync(f, id, "Dọn phiếu bị từ chối", f.Owner);

        await using var db = f.NewDb();
        db.Contracts.Count(c => c.Id == id).Should().Be(0);
        db.ContractChangelogs.AsNoTracking()
            .Single(l => l.ContractId == id && l.Action == ChangelogAction.Delete)
            .PhaseAtChange.Should().Be(ContractPhase.TuChoi);
    }

    // ===================================================================
    // T-4 — CHỐNG VÁ-QUÁ-TAY (style-gate be2-R7). Allow-list HĐ là `Phase < DangInKy(5)`, tức
    //   gồm CẢ legacy 1/3/4 — rộng hơn bộ 3 của KHKK. Ai chép nguyên hình
    //   `is not (DangSoanThao or TuChoi or TraLai)` sẽ 409 oan đúng 3 phase này (Dev đang có
    //   HĐ sống ở `DangGopY`). Đo cả 1/3/4 trong 1 Fact vì chúng CÙNG một lớp ca — thêm phase
    //   là thêm điểm đo, không phải thêm ca.
    // ===================================================================
    [Fact]
    // T-4: HĐ legacy phase 1/3/4 (< DangInKy) → chủ HĐ vẫn xoá được.
    public async Task DeleteContract_LegacyPhaseBelowDangInKy_ByOwner_Succeeds()
    {
        using var f = new ContractDeleteFixture();

        foreach (var phase in new[] { ContractPhase.DangChon, ContractPhase.DangGopY, ContractPhase.DangDamPhan })
        {
            var id = await SeedContractAsync(f, phase, f.Owner.UserId);

            await DeleteAsync(f, id, $"Dọn HĐ legacy {phase}", f.Owner);

            await using var db = f.NewDb();
            db.Contracts.Count(c => c.Id == id)
                .Should().Be(0, $"phase {phase} nằm dưới DangInKy nên vẫn thuộc quyền dọn của người soạn");
            db.ContractChangelogs.AsNoTracking()
                .Single(l => l.ContractId == id && l.Action == ChangelogAction.Delete)
                .PhaseAtChange.Should().Be(phase);
        }
    }

    // ===================================================================
    // T-5 — `ChoDuyet` (10): CHỦ HĐ cũng không được rút êm. Người đang chờ duyệt mất lượt.
    //   (Đường xoá-khi-đang-duyệt dành cho approver là Q4, KHÔNG thuộc đợt này.)
    //   Vế "còn nguyên" bắt buộc: ném xong mà vẫn ghi thì tệ hơn không rào.
    // ===================================================================
    [Fact]
    // T-5: HĐ đang chờ duyệt + chính chủ → 409 Conflict, HĐ còn nguyên.
    public async Task DeleteContract_ChoDuyet_ByOwner_ThrowsConflict()
    {
        using var f = new ContractDeleteFixture();
        var id = await SeedContractAsync(f, ContractPhase.ChoDuyet, f.Owner.UserId);

        var act = async () => await DeleteAsync(f, id, null, f.Owner);

        await act.Should().ThrowAsync<ConflictException>(
            "HĐ đang chờ duyệt thì chủ HĐ cũng không được rút êm");

        await using var db = f.NewDb();
        db.Contracts.Count(c => c.Id == id)
            .Should().Be(1, "chặn xong HĐ phải còn NGUYÊN trong danh sách thường");
        db.ContractChangelogs.AsNoTracking()
            .Count(l => l.ContractId == id && l.Action == ChangelogAction.Delete)
            .Should().Be(0, "chặn rồi thì KHÔNG được để lại vết xoá ma");
    }

    // ===================================================================
    // T-6 — `DaPhatHanh` (9) + **Admin**: đã gen mã HĐ thì không ai xoá, kể cả Admin.
    //   Actor Admin là cố ý: nó chứng rào phase đứng ĐỘC LẬP với rào chủ-sở-hữu, không phải
    //   hệ quả của việc Admin bị chặn ở tầng khác.
    //   +Biên `DangInKy(5)`: allow-list là `< 5` STRICT ⇒ chính phase 5 phải bị chặn. Không có
    //   điểm đo tại biên thì `<=` và `<` không phân biệt được (bài S69b).
    // ===================================================================
    [Fact]
    // T-6: HĐ đã phát hành (9) + Admin → 409 Conflict; biên DangInKy(5) cũng bị chặn.
    public async Task DeleteContract_DaPhatHanh_ByAdmin_ThrowsConflict()
    {
        using var f = new ContractDeleteFixture();
        var admin = Admin();

        var released = await SeedContractAsync(
            f, ContractPhase.DaPhatHanh, f.Owner.UserId, maHopDong: "FLOCK01/HĐTP/SOL&PVL/01");
        var actReleased = async () => await DeleteAsync(f, released, "dọn dẹp", admin);
        await actReleased.Should().ThrowAsync<ConflictException>(
            "HĐ đã phát hành đã mang mã chính thức — Admin cũng không xoá");

        var atBoundary = await SeedContractAsync(f, ContractPhase.DangInKy, f.Owner.UserId);
        var actBoundary = async () => await DeleteAsync(f, atBoundary, "dọn dẹp", admin);
        await actBoundary.Should().ThrowAsync<ConflictException>(
            "biên: allow-list là Phase < DangInKy, nên chính DangInKy phải bị chặn");

        await using var db = f.NewDb();
        db.Contracts.Count(c => c.Id == released || c.Id == atBoundary)
            .Should().Be(2, "cả hai HĐ phải còn nguyên sau khi bị chặn");
    }

    // ===================================================================
    // T-7 — người lạ (không phải người soạn, không Admin) đụng HĐ NHÁP của người khác.
    //   Đây là lỗ SẴN CÓ đang vá (gotcha #82): trước W5 mọi user đăng nhập đều xoá được
    //   HĐ của người khác vì handler không hề hỏi actor là ai.
    // ===================================================================
    [Fact]
    // T-7: HĐ nháp của người khác + người lạ → 403 Forbidden.
    public async Task DeleteContract_DraftOfAnotherUser_ByStranger_ThrowsForbidden()
    {
        using var f = new ContractDeleteFixture();
        var id = await SeedContractAsync(f, ContractPhase.DangSoanThao, f.Owner.UserId);

        var act = async () => await DeleteAsync(f, id, null, Stranger());

        await act.Should().ThrowAsync<ForbiddenException>(
            "chỉ người soạn hoặc Admin được xoá HĐ này");

        await using var db = f.NewDb();
        db.Contracts.Count(c => c.Id == id).Should().Be(1, "chặn xong HĐ phải còn NGUYÊN");
    }

    // ===================================================================
    // T-8 — VẾ DƯƠNG của `|| isAdmin`. Không có ca này thì T-7/T-5/T-6/T-9/T-10/T-11 vẫn xanh
    //   trong cái thế giới mà handler ném với MỌI đầu vào. `DeletedBy` phải là ADMIN, không
    //   phải chủ HĐ — canh luôn chuyện interceptor và handler chạy cùng một actor.
    // ===================================================================
    [Fact]
    // T-8: HĐ nháp của người khác + Admin → xoá được, DeletedBy ghi đúng Admin.
    public async Task DeleteContract_DraftOfAnotherUser_ByAdmin_Succeeds()
    {
        using var f = new ContractDeleteFixture();
        var admin = Admin();
        var id = await SeedContractAsync(f, ContractPhase.DangSoanThao, f.Owner.UserId);

        await DeleteAsync(f, id, "Admin dọn HĐ nháp bỏ quên", admin);

        await using var db = f.NewDb();
        db.Contracts.Count(c => c.Id == id).Should().Be(0);
        db.Contracts.IgnoreQueryFilters().AsNoTracking().Single(c => c.Id == id)
            .DeletedBy.Should().Be(admin.UserId,
                "người xoá thật là Admin, không phải chủ HĐ");
        db.ContractChangelogs.AsNoTracking()
            .Single(l => l.ContractId == id && l.Action == ChangelogAction.Delete)
            .UserId.Should().Be(admin.UserId);
    }

    // ===================================================================
    // T-9 — NẤC 0 (khuôn `PeSoftDeleteFeatures.cs:56-58`). Khuôn KHKK `:1763` KHÔNG có nấc này:
    //   nó so thẳng `DrafterUserId == currentUser.UserId`, nên HĐ `DrafterUserId = null` gặp
    //   actor chưa đăng nhập ⇒ `null == null` ⇒ isOwner = TRUE ⇒ xoá LỌT.
    //   Ca này là lý do "đừng chép nguyên hình KHKK": nếu BE chép, test không ném gì cả và HĐ
    //   biến mất — chính xác cái ta sợ. Kỳ vọng `UnauthorizedException` (chưa đăng nhập),
    //   KHÔNG phải Forbidden (đã đăng nhập nhưng không đủ quyền) — hai mã HTTP khác nhau.
    // ===================================================================
    [Fact]
    // T-9: HĐ DrafterUserId=null + actor UserId=null → 401 Unauthorized, HĐ còn nguyên.
    public async Task DeleteContract_NullDrafter_ByAnonymousActor_ThrowsUnauthorized()
    {
        using var f = new ContractDeleteFixture();
        var id = await SeedContractAsync(f, ContractPhase.DangSoanThao, drafterUserId: null);

        var anonymous = TestCurrentUser.System();   // UserId = null ⇒ IsAuthenticated = false
        var act = async () => await DeleteAsync(f, id, null, anonymous);

        await act.Should().ThrowAsync<UnauthorizedException>(
            "chưa đăng nhập thì dừng ở nấc 0 — không được để null == null thành 'chủ sở hữu'");

        await using var db = f.NewDb();
        db.Contracts.Count(c => c.Id == id)
            .Should().Be(1, "HĐ mồ côi người soạn KHÔNG được thành HĐ ai cũng xoá");
    }

    // ===================================================================
    // T-10 — THỨ TỰ ném: NotFound TRƯỚC Forbidden. Actor cố ý là người lạ để hai khả năng đều
    //   mở; handler kiểm quyền trước khi nạp sẽ trả 403 và làm lộ "id này có tồn tại hay không"
    //   theo chiều ngược (403 = có, 404 = không). Chỉ đúng thứ tự mới cho 404 ở đây.
    // ===================================================================
    [Fact]
    // T-10: id không tồn tại + người lạ → 404 NotFound (không phải 403).
    public async Task DeleteContract_UnknownId_ByStranger_ThrowsNotFoundBeforeForbidden()
    {
        using var f = new ContractDeleteFixture();
        await SeedContractAsync(f, ContractPhase.DangSoanThao, f.Owner.UserId);   // thế giới KHÔNG rỗng

        var act = async () => await DeleteAsync(f, Guid.NewGuid(), null, Stranger());

        await act.Should().ThrowAsync<NotFoundException>(
            "không tìm thấy thì báo không tìm thấy — kiểm quyền trước khi nạp là sai thứ tự");
    }

    // ===================================================================
    // T-11 — THỨ TỰ ném: Forbidden TRƯỚC Conflict. Ca này có ĐỦ điều kiện cho cả hai (người lạ
    //   + phase cấm) nên nó phân biệt được thứ tự rào; T-5 (chính chủ + ChoDuyet) và T-7
    //   (người lạ + nháp) mỗi cái chỉ chạm một rào nên KHÔNG phân biệt được.
    //   Lý do nghiệp vụ: người ngoài không đáng được biết HĐ đó đang ở bước nào.
    // ===================================================================
    [Fact]
    // T-11: HĐ chờ duyệt của người khác + người lạ → 403 Forbidden (không phải 409).
    public async Task DeleteContract_ChoDuyetOfAnotherUser_ByStranger_ThrowsForbiddenBeforeConflict()
    {
        using var f = new ContractDeleteFixture();
        var id = await SeedContractAsync(f, ContractPhase.ChoDuyet, f.Owner.UserId);

        var act = async () => await DeleteAsync(f, id, null, Stranger());

        await act.Should().ThrowAsync<ForbiddenException>(
            "rào chủ-sở-hữu đứng trước rào phase — người ngoài không được biết HĐ đang ở bước nào");

        await using var db = f.NewDb();
        db.Contracts.Count(c => c.Id == id).Should().Be(1);
    }

    // ===================================================================
    // T-12 — Reason 2001 ký tự. Đo qua `ValidationBehavior` THẬT (đường mà request đi trong
    //   prod) chứ không chỉ `validator.Validate(...)`: nó chứng luôn rằng lỗi nổi lên đúng
    //   `Application.Common.Exceptions.ValidationException` (→ 400) chứ không phải
    //   `DbUpdateException` khi EF cắt ở `HasMaxLength(2000)`
    //   (`ContractChangelogConfiguration.cs:20` — validator PHẢI match EF, không tự chế số).
    //   Control dương ĐÚNG 2000 ký tự: thiếu nó thì một validator ném với mọi Reason cũng xanh.
    // ===================================================================
    [Fact]
    // T-12: Reason 2001 ký tự → ValidationException; đúng 2000 ký tự thì lọt (biên).
    public async Task DeleteContract_ReasonOver2000Chars_FailsValidation()
    {
        var behavior = new ValidationBehavior<DeleteContractCommand, Unit>(
            new IValidator<DeleteContractCommand>[] { new DeleteContractCommandValidator() });

        var reached = false;
        RequestHandlerDelegate<Unit> next = () => { reached = true; return Task.FromResult(Unit.Value); };

        var tooLong = new DeleteContractCommand(Guid.NewGuid(), new string('x', 2001));
        var act = async () => await behavior.Handle(tooLong, next, CancellationToken.None);

        var ex = await act.Should().ThrowAsync<ValidationException>(
            "2001 ký tự vượt trần 2000 của cột ContextNote — phải chặn ở tầng validate, không để EF nổ");
        ex.Which.Errors.Should().ContainKey(nameof(DeleteContractCommand.Reason));
        reached.Should().BeFalse("chặn ở validate thì handler không được chạy");

        // Control dương: đúng trần thì đi tiếp.
        var atLimit = new DeleteContractCommand(Guid.NewGuid(), new string('x', 2000));
        await behavior.Handle(atLimit, next, CancellationToken.None);
        reached.Should().BeTrue("đúng 2000 ký tự là hợp lệ — rào là ĐÚNG BIÊN, không phải cấm tuốt");
    }

    // ===================================================================
    // T-13 — Reason toàn khoảng trắng ⇒ vẫn xoá được, nhưng ContextNote lưu NULL (trim-to-null,
    //   khuôn KHKK `:1785`). Không có ca này thì "   " chui xuống DB thành một lý do rỗng trông
    //   như có — đọc log tưởng người ta đã ghi lý do.
    //   Viết RIÊNG một `[Fact]` theo X-9 (gộp vào T-1 làm vỡ ô đếm `[Fact]` ≥ 13).
    // ===================================================================
    [Fact]
    // T-13: Reason toàn khoảng trắng → xoá OK + ContextNote lưu null.
    public async Task DeleteContract_WhitespaceReason_Succeeds_AndStoresNullContextNote()
    {
        using var f = new ContractDeleteFixture();
        var id = await SeedContractAsync(f, ContractPhase.DangSoanThao, f.Owner.UserId);

        await DeleteAsync(f, id, "   ", f.Owner);

        await using var db = f.NewDb();
        db.Contracts.Count(c => c.Id == id).Should().Be(0, "khoảng trắng không phải lý do để chặn xoá");

        var del = db.ContractChangelogs.AsNoTracking()
            .Single(l => l.ContractId == id && l.Action == ChangelogAction.Delete);
        del.ContextNote.Should().BeNull(
            "trim-to-null: chuỗi trắng phải thành NULL, đừng để log trông như có lý do");
    }
}
