using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.Contracts.Dtos;
using SolutionErp.Application.Contracts.Services;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;

namespace SolutionErp.Application.Contracts;

// ========== CREATE draft ==========

public record CreateContractCommand(
    ContractType Type,
    Guid SupplierId,
    Guid ProjectId,
    Guid? DepartmentId,
    Guid? TemplateId,
    decimal GiaTri,
    string? TenHopDong,
    string? NoiDung,
    bool BypassProcurementAndCCM,
    string? DraftData,
    // [S61 Mig 50] BudgetId DROP (module Budget cũ xóa hẳn) — GIỮ BudgetManual*
    // (ngân sách nhập tay HĐ không đổi).
    string? BudgetManualName,
    decimal? BudgetManualAmount,
    // [Plan B S29 2026-05-22 Chunk E1] Drafter pick V2 workflow lúc create —
    // mirror PE pattern Workspace Select dropdown. Nếu null → fallback V1 auto
    // pick activeWfId (7 prod contract giữ behavior). Mutually exclusive với
    // V1: pin V2 + V1 cùng lúc OK schema (cả 2 nullable) — Service ApproveV2Async
    // branch ưu tiên V2 nếu cả 2 set.
    Guid? ApprovalWorkflowId = null) : IRequest<Guid>;

public class CreateContractCommandValidator : AbstractValidator<CreateContractCommand>
{
    public CreateContractCommandValidator()
    {
        RuleFor(x => x.Type).IsInEnum();
        RuleFor(x => x.SupplierId).NotEmpty();
        RuleFor(x => x.ProjectId).NotEmpty();
        RuleFor(x => x.GiaTri).GreaterThanOrEqualTo(0);
        RuleFor(x => x.TenHopDong).MaximumLength(500);
        RuleFor(x => x.NoiDung).MaximumLength(2000);
        RuleFor(x => x.BudgetManualName).MaximumLength(200);
        RuleFor(x => x.BudgetManualAmount).GreaterThanOrEqualTo(0).When(x => x.BudgetManualAmount.HasValue);
    }
}

public class CreateContractCommandHandler(
    IApplicationDbContext db,
    ICurrentUser currentUser,
    IContractWorkflowService workflow,
    IContractCodeGenerator codeGenerator,
    IChangelogService changelog) : IRequestHandler<CreateContractCommand, Guid>
{
    public async Task<Guid> Handle(CreateContractCommand request, CancellationToken ct)
    {
        // Load supplier + project full (cần Code cho gen mã RG-001)
        var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, ct)
            ?? throw new NotFoundException("Supplier", request.SupplierId);
        var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == request.ProjectId, ct)
            ?? throw new NotFoundException("Project", request.ProjectId);

        var activeWfId = await db.WorkflowDefinitions.AsNoTracking()
            .Where(w => w.ContractType == request.Type && w.IsActive)
            .Select(w => (Guid?)w.Id)
            .FirstOrDefaultAsync(ct);

        // ---- [S187 W4] CỬA 1/4 — PIN quy trình V2 lúc tạo.
        //
        // Trước W4 chỗ này là bản inline CHỈ hỏi tồn-tại + đúng-ApplicableType (bản thứ 2 ở cầu K7
        // `CreateContractFromSigningPlanFeatures.cs` hỏi thêm `IsActive` + `IsUserSelectable` ⇒ 2 luật
        // đi lệch nhau, đúng bug-class FLAG-7). Nay CẢ 4 cửa gọi CÙNG `ContractWorkflowGuards`.
        //
        // 2 nhánh theo (86)+(81):
        //   • người dùng pin tay  ⇒ validate (gồm KHOÁ CỨNG loại — không gắn chéo);
        //   • không pin           ⇒ máy TỰ tìm quy trình của đúng loại (PA-A). Không có thì để null
        //     y như hành vi trước W4 (HĐ rơi về V1 `WorkflowDefinitionId`), KHÔNG ném lỗi:
        //     `POST /api/contracts` là cửa CŨ đang chạy, siết nó ở đây là đổi hành vi ngoài phạm vi W4.
        var pinnedApprovalWorkflowId = request.ApprovalWorkflowId;
        if (pinnedApprovalWorkflowId is Guid awId)
            await ContractWorkflowGuards.EnsureValidWorkflowForContractAsync(db, awId, request.Type, ct);
        else
            pinnedApprovalWorkflowId =
                await ContractWorkflowGuards.ResolveDefaultWorkflowAsync(db, request.Type, ct);

        var entity = new Contract
        {
            Type = request.Type,
            Phase = ContractPhase.DangSoanThao,
            SupplierId = request.SupplierId,
            ProjectId = request.ProjectId,
            DepartmentId = request.DepartmentId,
            DrafterUserId = currentUser.UserId,
            TemplateId = request.TemplateId,
            GiaTri = request.GiaTri,
            TenHopDong = request.TenHopDong,
            NoiDung = request.NoiDung,
            BypassProcurementAndCCM = request.BypassProcurementAndCCM,
            DraftData = request.DraftData,
            BudgetManualName = request.BudgetManualName,
            BudgetManualAmount = request.BudgetManualAmount,
            WorkflowDefinitionId = activeWfId,
            // [Plan B S29 2026-05-22 Chunk E1] Pin V2 workflow nếu Drafter pick
            // qua Workspace Select dropdown (Chunk D FE). Cả 2 set ok — Service
            // ApproveV2Async branch dispatch theo ApprovalWorkflowId trước.
            // [S187 W4] Giá trị đã qua cửa 1/4: pin tay (đã validate) HOẶC máy tự chọn theo loại.
            ApprovalWorkflowId = pinnedApprovalWorkflowId,
            SlaDeadline = DateTime.UtcNow.Add(workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)),
        };

        // Gen mã HĐ NGAY tại create (user request) — không đợi DangDongDau như cũ.
        // Trade-off: nếu HĐ bị TuChoi → mã bị "wasted" (gap trong sequence). Acceptable
        // vì user muốn có mã ngay từ đầu để reference vào tài liệu/giấy tờ.
        // GenerateAsync chạy SERIALIZABLE transaction riêng — entity chưa tracked nên
        // không bị save kèm.
        entity.MaHopDong = await codeGenerator.GenerateAsync(entity, project.Code, supplier.Code, ct);

        db.Contracts.Add(entity);

        await changelog.LogContractChangeAsync(
            entity.Id,
            ChangelogAction.Insert,
            summary: $"Tạo HĐ {entity.MaHopDong} — {entity.TenHopDong ?? "(chưa tên)"}",
            phaseAtChange: entity.Phase,
            ct: ct);

        await db.SaveChangesAsync(ct);
        return entity.Id;
    }
}

// ========== UPDATE draft ==========

public record UpdateContractDraftCommand(
    Guid Id,
    decimal GiaTri,
    string? TenHopDong,
    string? NoiDung,
    Guid? TemplateId,
    string? DraftData,
    // [S61 Mig 50] BudgetId DROP — GIỮ BudgetManual* (HĐ nhập tay không đổi).
    string? BudgetManualName,
    decimal? BudgetManualAmount,
    // [S187 W4 — cửa 3/4] RE-PIN quy trình duyệt khi HĐ còn Nháp (§1c-3 plan-review: trước W4
    // KHÔNG có đường sửa quy trình đã pin ⇒ pin nhầm là phải xoá HĐ tạo lại).
    // 🔴 Field ĐẶT CUỐI + default `null` = giữ nguyên hành vi client cũ: FE hiện KHÔNG gửi field này
    //    ⇒ null ⇒ handler KHÔNG đụng tới `ApprovalWorkflowId` đang lưu (null ≠ "gỡ pin").
    //    Không có đường GỠ pin ở đây CÓ CHỦ ĐÍCH: gỡ pin = HĐ rơi về V1 legacy, đó là quyết định
    //    khác (và ngược chiều (86) máy-tự-pin), không lén nhét vào cửa sửa nháp.
    Guid? ApprovalWorkflowId = null) : IRequest;

public class UpdateContractDraftCommandHandler(
    IApplicationDbContext db,
    IChangelogService changelog) : IRequestHandler<UpdateContractDraftCommand>
{
    public async Task Handle(UpdateContractDraftCommand request, CancellationToken ct)
    {
        var entity = await db.Contracts.FirstOrDefaultAsync(c => c.Id == request.Id, ct)
            ?? throw new NotFoundException("Contract", request.Id);

        if (entity.Phase != ContractPhase.DangSoanThao)
            throw new ConflictException("Chỉ được sửa HĐ khi ở phase Đang soạn thảo.");

        // Capture diff trước update để log
        var changes = new List<object>();
        if (entity.GiaTri != request.GiaTri)
            changes.Add(new { Field = "GiaTri", Old = entity.GiaTri, New = request.GiaTri });
        if (entity.TenHopDong != request.TenHopDong)
            changes.Add(new { Field = "TenHopDong", Old = entity.TenHopDong, New = request.TenHopDong });
        if (entity.NoiDung != request.NoiDung)
            changes.Add(new { Field = "NoiDung", Old = entity.NoiDung, New = request.NoiDung });
        if (entity.TemplateId != request.TemplateId)
            changes.Add(new { Field = "TemplateId", Old = entity.TemplateId, New = request.TemplateId });
        if (entity.BudgetManualName != request.BudgetManualName)
            changes.Add(new { Field = "BudgetManualName", Old = entity.BudgetManualName, New = request.BudgetManualName });
        if (entity.BudgetManualAmount != request.BudgetManualAmount)
            changes.Add(new { Field = "BudgetManualAmount", Old = entity.BudgetManualAmount, New = request.BudgetManualAmount });

        // ---- [S187 W4] CỬA 3/4 — RE-PIN quy trình duyệt V2 khi HĐ còn Nháp.
        //
        // 3 tầng bảo toàn hành-vi cũ, theo đúng thứ tự:
        //   (1) `null` (client cũ KHÔNG gửi field) ⇒ không vào khối này ⇒ ô đang lưu bất động.
        //       `null` ở đây nghĩa là "không nói gì", KHÔNG phải "gỡ pin".
        //   (2) gửi ĐÚNG giá trị đang lưu ⇒ cũng không validate lại. Cố ý: nếu quy trình đã pin
        //       sau đó bị Admin cho nghỉ, thì một lần sửa GIÁ TRỊ HĐ (client echo nguyên payload)
        //       sẽ ăn 409 vì một lý do chẳng liên quan tới thứ người dùng vừa sửa.
        //   (3) chỉ khi ĐỔI sang quy trình khác mới đi qua nhà chung — và đi qua ĐỦ 5 vế
        //       (gồm KHOÁ CỨNG loại (86)), y hệt cửa 1/4 và 2/4: 3 cửa ghi không được lệch nhau,
        //       đó là toàn bộ lý do `ContractWorkflowGuards` tồn tại.
        if (request.ApprovalWorkflowId is Guid rePinId && rePinId != entity.ApprovalWorkflowId)
        {
            await ContractWorkflowGuards.EnsureValidWorkflowForContractAsync(db, rePinId, entity.Type, ct);
            changes.Add(new { Field = "ApprovalWorkflowId", Old = entity.ApprovalWorkflowId, New = (Guid?)rePinId });
            entity.ApprovalWorkflowId = rePinId;
        }

        entity.GiaTri = request.GiaTri;
        entity.TenHopDong = request.TenHopDong;
        entity.NoiDung = request.NoiDung;
        entity.TemplateId = request.TemplateId;
        entity.DraftData = request.DraftData;
        entity.BudgetManualName = request.BudgetManualName;
        entity.BudgetManualAmount = request.BudgetManualAmount;

        if (changes.Count > 0)
        {
            await changelog.LogContractChangeAsync(
                entity.Id,
                ChangelogAction.Update,
                summary: $"Cập nhật HĐ ({changes.Count} field)",
                fieldChangesJson: System.Text.Json.JsonSerializer.Serialize(changes),
                phaseAtChange: entity.Phase,
                ct: ct);
        }

        await db.SaveChangesAsync(ct);
    }
}

// ========== TRANSITION phase ==========

// [WQ3-BE S190 · YC-030 Q3] +2 field append-CUỐI, đều có default (positional record — chèn giữa
// là vỡ mọi call-site truyền theo vị trí). `ApplyLevelFinalize` ĐÃ là `bool` dứt khoát: cửa
// `?? true` nằm ở controller, nơi duy nhất phân biệt được "không gửi" ⟂ "gửi false".
public record TransitionContractCommand(
    Guid Id,
    ContractPhase TargetPhase,
    ApprovalDecision Decision,
    string? Comment,
    bool ApplyLevelFinalize = true,
    bool SkipToFinal = false) : IRequest;

public class TransitionContractCommandValidator : AbstractValidator<TransitionContractCommand>
{
    public TransitionContractCommandValidator()
    {
        RuleFor(x => x.Id).NotEmpty();
        RuleFor(x => x.TargetPhase).IsInEnum();
        RuleFor(x => x.Decision).IsInEnum();
        RuleFor(x => x.Comment).MaximumLength(1000);
    }
}

public class TransitionContractCommandHandler(
    IApplicationDbContext db,
    ICurrentUser currentUser,
    IContractWorkflowService workflow) : IRequestHandler<TransitionContractCommand>
{
    public async Task Handle(TransitionContractCommand request, CancellationToken ct)
    {
        if (!currentUser.IsAuthenticated || currentUser.UserId is null)
            throw new UnauthorizedException();

        var entity = await db.Contracts.FirstOrDefaultAsync(c => c.Id == request.Id, ct)
            ?? throw new NotFoundException("Contract", request.Id);

        await workflow.TransitionAsync(
            entity,
            request.TargetPhase,
            currentUser.UserId,
            currentUser.Roles,
            request.Decision,
            request.Comment,
            skipToFinal: request.SkipToFinal,
            applyLevelFinalize: request.ApplyLevelFinalize,
            ct: ct);
    }
}

// ========== ADD comment ==========

public record AddCommentCommand(Guid ContractId, string Content) : IRequest<Guid>;

public class AddCommentCommandValidator : AbstractValidator<AddCommentCommand>
{
    public AddCommentCommandValidator()
    {
        RuleFor(x => x.ContractId).NotEmpty();
        RuleFor(x => x.Content).NotEmpty().MaximumLength(2000);
    }
}

public class AddCommentCommandHandler(
    IApplicationDbContext db,
    ICurrentUser currentUser,
    IChangelogService changelog) : IRequestHandler<AddCommentCommand, Guid>
{
    public async Task<Guid> Handle(AddCommentCommand request, CancellationToken ct)
    {
        if (!currentUser.IsAuthenticated || currentUser.UserId is null)
            throw new UnauthorizedException();

        var contract = await db.Contracts.FirstOrDefaultAsync(c => c.Id == request.ContractId, ct)
            ?? throw new NotFoundException("Contract", request.ContractId);

        var comment = new ContractComment
        {
            ContractId = request.ContractId,
            UserId = currentUser.UserId.Value,
            Phase = contract.Phase,
            Content = request.Content,
        };
        db.ContractComments.Add(comment);
        await changelog.LogCommentAddedAsync(request.ContractId, request.Content, contract.Phase, ct);
        await db.SaveChangesAsync(ct);
        return comment.Id;
    }
}

// ========== LIST contracts (admin view) ==========

// [W7 S161] `Type` append-CUỐI (positional) — call-site controller dựng bằng vị-trí
// `new ListContractsQuery(phase, supplierId, projectId, type)`, chèn giữa là đổi nghĩa
// 3 tham số cũ. Khuôn lấy nguyên từ `ListDeletedContractsQuery(:408)` đã có sẵn.
public record ListContractsQuery(
    ContractPhase? Phase = null,
    Guid? SupplierId = null,
    Guid? ProjectId = null,
    ContractType? Type = null) : PagedRequest, IRequest<PagedResult<ContractListItemDto>>;

public class ListContractsQueryHandler(IApplicationDbContext db, ICurrentUser currentUser)
    : IRequestHandler<ListContractsQuery, PagedResult<ContractListItemDto>>
{
    public async Task<PagedResult<ContractListItemDto>> Handle(ListContractsQuery request, CancellationToken ct)
    {
        var q = from c in db.Contracts.AsNoTracking()
                join s in db.Suppliers.AsNoTracking() on c.SupplierId equals s.Id
                join p in db.Projects.AsNoTracking() on c.ProjectId equals p.Id
                select new { c, s, p };

        // IDOR: non-admin chỉ thấy HĐ mình là Drafter hoặc role eligible phase hiện tại
        if (!currentUser.Roles.Contains(AppRoles.Admin))
        {
            var userId = currentUser.UserId;
            var eligiblePhases = GetEligiblePhases(currentUser.Roles);
            // [W6 S161] +2 vế cho đường ống V2 (spec §②-3). Vế V2 là THEO-TỪNG-HĐ
            // (workflow pin của CHÍNH HĐ đó), KHÔNG phải role-blanket — xem chú thích
            // khối helper dưới `GetEligiblePhases`.
            var myWorkflowIds = await ResolveUserApprovalWorkflowIdsAsync(db, userId, ct);
            var isHardCopyActor = HasHardCopyActorRole(currentUser.Roles);
            q = q.Where(x => x.c.DrafterUserId == userId
                || eligiblePhases.Contains(x.c.Phase)
                // (a) có chân duyệt trong workflow ĐÃ PIN của HĐ này + HĐ đã rời Nháp (S89:
                //     nháp = riêng tư người soạn, approver chỉ thấy khi đã gửi duyệt).
                || (x.c.Phase != ContractPhase.DangSoanThao
                    && myWorkflowIds.Contains(x.c.ApprovalWorkflowId))
                // (b) vai bản-cứng khâu ký/đóng dấu → HĐ ĐÃ PHÁT HÀNH (GĐ4/W7).
                || (isHardCopyActor && x.c.Phase == ContractPhase.DaPhatHanh));
        }

        if (request.Phase is not null) q = q.Where(x => x.c.Phase == request.Phase);
        if (request.SupplierId is not null) q = q.Where(x => x.c.SupplierId == request.SupplierId);
        if (request.ProjectId is not null) q = q.Where(x => x.c.ProjectId == request.ProjectId);
        // [W7 S161] Lọc theo loại HĐ — server-side, ĐẶT TRONG khối filter (SAU khối IDOR
        // `:298-315`) ⇒ thu hẹp tập đã-được-phép, KHÔNG mở thêm gì. FE 7 leaf `Hdc_*` gọi
        // `?type={1..7}&phase=9`. Mirror `ListDeletedContractsQueryHandler:434`.
        if (request.Type is not null) q = q.Where(x => x.c.Type == request.Type);

        if (!string.IsNullOrWhiteSpace(request.Search))
        {
            var s = request.Search.Trim();
            q = q.Where(x =>
                (x.c.MaHopDong != null && x.c.MaHopDong.Contains(s)) ||
                (x.c.TenHopDong != null && x.c.TenHopDong.Contains(s)) ||
                x.s.Name.Contains(s) || x.p.Name.Contains(s));
        }

        q = request.SortDesc ? q.OrderByDescending(x => x.c.CreatedAt) : q.OrderBy(x => x.c.CreatedAt);

        var total = await q.CountAsync(ct);
        var items = await q
            .Skip((request.Page - 1) * request.PageSize).Take(request.PageSize)
            .Select(x => new ContractListItemDto(
                x.c.Id, x.c.MaHopDong, x.c.TenHopDong, x.c.Type, x.c.Phase,
                x.c.SupplierId, x.s.Name,
                x.c.ProjectId, x.p.Name,
                x.c.GiaTri, x.c.SlaDeadline, x.c.CreatedAt,
                // [W7 S161] Cờ "đã có bản cứng" — dịch thành EXISTS(...) trên
                // `ContractAttachments`, 0 round-trip thêm.
                // 🔴 CỐ Ý KHÔNG có vế `!a.IsDeleted`: `ContractAttachment : BaseEntity`
                // (ContractAttachment.cs:13) — BaseEntity KHÔNG có `IsDeleted`, và
                // `ContractAttachmentConfiguration` (ContractConfiguration.cs:78-93) KHÔNG
                // có `HasQueryFilter`. Xoá đính kèm là HARD delete
                // (`ContractAttachmentFeatures.cs:143` `.Remove(att)`) ⇒ row còn = file còn.
                x.c.Attachments.Any(a => a.Purpose == AttachmentPurpose.SealedCopy)))
            .ToListAsync(ct);

        return new PagedResult<ContractListItemDto>(items, total, request.Page, request.PageSize);
    }

    // Mirror GetMyInboxQueryHandler.PhaseActorRoles — shared qua internal static
    internal static List<ContractPhase> GetEligiblePhases(IReadOnlyList<string> userRoles)
    {
        var phases = new HashSet<ContractPhase>();
        void AddIfAny(string[] required, params ContractPhase[] toAdd)
        {
            if (userRoles.Any(r => required.Contains(r)))
                foreach (var p in toAdd) phases.Add(p);
        }
        AddIfAny([AppRoles.Drafter, AppRoles.DeptManager],
            ContractPhase.DangSoanThao, ContractPhase.DangDamPhan, ContractPhase.DangInKy);
        AddIfAny([AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl,
                  AppRoles.Finance, AppRoles.Accounting, AppRoles.Equipment],
            ContractPhase.DangGopY);
        AddIfAny([AppRoles.CostControl], ContractPhase.DangKiemTraCCM);
        AddIfAny([AppRoles.Director, AppRoles.AuthorizedSigner], ContractPhase.DangTrinhKy);
        AddIfAny([AppRoles.HrAdmin], ContractPhase.DangDongDau);
        return phases.ToList();
    }

    // ===== [W6 S161] Vế V2 per-HĐ — CỐ Ý TÁCH KHỎI `GetEligiblePhases` =====
    // 🔴 KHÔNG được nhét `ChoDuyet`/`DaPhatHanh` vào `GetEligiblePhases`: hàm đó chỉ nhận
    // ROLE (không biết đang xét HĐ nào) ⇒ thêm phase vào đó = MỌI user mang role đó thấy
    // MỌI HĐ ở phase đó. Nặng hơn: nó là hàm DÙNG CHUNG với `:381` ListDeleted ⇒ HĐ
    // `ChoDuyet` đã XÓA MỀM sẽ rò sang màn "Đã xóa" (acceptance §③-B cấm đúng ca này).
    // Vì vậy 2 vế dưới chỉ gắn ở 2 call-site `:301` List + Detail (`GetContractQueryHandler`),
    // KHÔNG gắn ở ListDeleted — quyết định có chủ đích, xem chú thích tại chỗ `:381`.

    /// Vai bản-cứng khâu ký / đóng dấu / phát hành (spec §②-3 — actor b.19/20/21): được xem
    /// HĐ **đã phát hành** (`DaPhatHanh`) dù không có chân trong workflow V2 nào. Cho GĐ4/W7.
    internal static readonly string[] HardCopyActorRoles =
        [AppRoles.Director, AppRoles.AuthorizedSigner, AppRoles.HrAdmin, AppRoles.CostControl];

    internal static bool HasHardCopyActorRole(IReadOnlyList<string> userRoles)
        => userRoles.Any(r => HardCopyActorRoles.Contains(r));

    /// Các workflow V2 mà `userId` có chân duyệt (BẤT KỲ Bước/Cấp) — bản-set cho query LIST,
    /// mirror `ContractSigningPlanFeatures.cs:275-283`; bản 1-phiếu là `AnyAsync` kiểu PE
    /// `PurchaseEvaluationFeatures.cs:889-891`.
    /// 🔴 Trả `List&lt;Guid?&gt;` CÓ CHỦ ĐÍCH: cột `Contract.ApprovalWorkflowId` là `Guid?` ⇒
    /// `myWorkflowIds.Contains(x.c.ApprovalWorkflowId)` dịch thẳng `IN (...)` không cần
    /// `.Value` (NULL không bao giờ khớp `IN` ⇒ HĐ V1 legacy tự rớt khỏi vế này).
    internal static async Task<List<Guid?>> ResolveUserApprovalWorkflowIdsAsync(
        IApplicationDbContext db, Guid? userId, CancellationToken ct)
        => userId is null
            ? []
            : await db.ApprovalWorkflowLevels.AsNoTracking()
                .Where(l => l.ApproverUserId == userId.Value)
                .Select(l => (Guid?)l.Step!.ApprovalWorkflowId)
                .Distinct()
                .ToListAsync(ct);
}

// ========== LIST DELETED contracts — màn "Đã xóa" (chỉ-xem) ==========
// [S159-đợt5 — owner "y chang Duyệt NCC"] Cookie-cutter `ListDeletedPurchaseEvaluationsQuery`
// (PeSoftDeleteFeatures.cs). 🔴 LUẬT (không đếm số — reviewer FLAG-10: "chỗ THỨ HAI" là
// con số tự-lan, ApprovalWorkflowV2AdminFeatures cũng dùng Ignore ở lớp KHÁC): mọi nơi
// dùng `IgnoreQueryFilters` để LIỆT KÊ bản ghi đã xóa BẮT BUỘC kèm `.Where(x => x.IsDeleted)`
// vì Ignore GỠ filter chứ KHÔNG ĐẢO (bài 8a S155). Tự kiểm:
//   grep -rn "IgnoreQueryFilters" src/Backend --include=*.cs
// IDOR scope MIRROR ListContractsQueryHandler (Drafter ∪ eligible-phase; phase ở đây =
// phase tại thời điểm xóa).

public record ListDeletedContractsQuery(ContractType? Type = null)
    : PagedRequest, IRequest<PagedResult<ContractListItemDto>>;

public class ListDeletedContractsQueryHandler(IApplicationDbContext db, ICurrentUser currentUser)
    : IRequestHandler<ListDeletedContractsQuery, PagedResult<ContractListItemDto>>
{
    public async Task<PagedResult<ContractListItemDto>> Handle(ListDeletedContractsQuery request, CancellationToken ct)
    {
        var q = from c in db.Contracts.AsNoTracking().IgnoreQueryFilters()
                    .Where(x => x.IsDeleted)
                join s in db.Suppliers.AsNoTracking() on c.SupplierId equals s.Id
                join p in db.Projects.AsNoTracking() on c.ProjectId equals p.Id
                select new { c, s, p };

        if (!currentUser.Roles.Contains(AppRoles.Admin))
        {
            var userId = currentUser.UserId;
            var eligiblePhases = ListContractsQueryHandler.GetEligiblePhases(currentUser.Roles);
            // 🔴 [W6 S161] CALL-SITE THỨ 3 — CỐ Ý **KHÔNG** ghép 2 vế V2 (`myWorkflowIds` /
            // `HardCopyActorRoles`) đã thêm ở List `:301` + Detail. Đây là màn "Đã xóa":
            // acceptance W6 §③-B đòi HĐ `ChoDuyet` xóa mềm KHÔNG được rò sang user V2.
            // Scope giữ NGUYÊN (Drafter ∪ eligible-phase-role) = 0 dòng thay đổi hành vi.
            // Đụng vào đây là mở lỗ, không phải "làm cho đủ 3 chỗ".
            q = q.Where(x => x.c.DrafterUserId == userId || eligiblePhases.Contains(x.c.Phase));
        }

        if (request.Type is not null) q = q.Where(x => x.c.Type == request.Type);

        if (!string.IsNullOrWhiteSpace(request.Search))
        {
            var kw = request.Search.Trim();
            q = q.Where(x =>
                (x.c.MaHopDong != null && x.c.MaHopDong.Contains(kw)) ||
                (x.c.TenHopDong != null && x.c.TenHopDong.Contains(kw)) ||
                x.s.Name.Contains(kw) || x.p.Name.Contains(kw));
        }

        q = request.SortDesc ? q.OrderByDescending(x => x.c.DeletedAt) : q.OrderBy(x => x.c.DeletedAt);

        var total = await q.CountAsync(ct);
        var items = await q
            .Skip((request.Page - 1) * request.PageSize).Take(request.PageSize)
            .Select(x => new ContractListItemDto(
                x.c.Id, x.c.MaHopDong, x.c.TenHopDong, x.c.Type, x.c.Phase,
                x.c.SupplierId, x.s.Name,
                x.c.ProjectId, x.p.Name,
                x.c.GiaTri, x.c.SlaDeadline, x.c.CreatedAt))
            .ToListAsync(ct);

        return new PagedResult<ContractListItemDto>(items, total, request.Page, request.PageSize);
    }
}

// ========== INBOX — HĐ chờ role/tôi xử lý ==========

public record GetMyInboxQuery : IRequest<List<ContractListItemDto>>;

public class GetMyInboxQueryHandler(
    IApplicationDbContext db,
    ICurrentUser currentUser) : IRequestHandler<GetMyInboxQuery, List<ContractListItemDto>>
{
    // Map phase → role nào được xử lý (xem workflow-contract.md)
    private static readonly Dictionary<ContractPhase, string[]> PhaseActorRoles = new()
    {
        [ContractPhase.DangSoanThao] = [AppRoles.Drafter, AppRoles.DeptManager],
        [ContractPhase.DangGopY] = [AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl, AppRoles.Finance, AppRoles.Accounting, AppRoles.Equipment],
        [ContractPhase.DangDamPhan] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
        [ContractPhase.DangInKy] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
        [ContractPhase.DangKiemTraCCM] = [AppRoles.CostControl],
        [ContractPhase.DangTrinhKy] = [AppRoles.Director, AppRoles.AuthorizedSigner],
        [ContractPhase.DangDongDau] = [AppRoles.HrAdmin],
    };

    /// 🔴 [W6 S161 — DR-4] Phase Admin thấy trong Inbox, LIỆT KÊ TƯỜNG MINH (mirror
    /// `PurchaseEvaluationFeatures.cs:731-742`). Trước W6 admin lấy `PhaseActorRoles.Keys`,
    /// mà dict đó 100% phase legacy (0 key `ChoDuyet`) ⇒ ghép `v2InboxIds` xong admin VẪN
    /// mù HĐ chờ duyệt. Danh sách này = 7 phase legacy CŨ **+ `ChoDuyet`** ⇒ THUẦN CỘNG
    /// THÊM, không bỏ phase nào admin đang thấy (cố ý KHÔNG bê luật S89 "bỏ Nháp khỏi Inbox"
    /// của PE sang: đó là gỡ hành-vi, ngoài phạm vi W6).
    internal static readonly ContractPhase[] AdminInboxPhases =
    [
        ContractPhase.DangSoanThao,
        ContractPhase.DangGopY,
        ContractPhase.DangDamPhan,
        ContractPhase.DangInKy,
        ContractPhase.DangKiemTraCCM,
        ContractPhase.DangTrinhKy,
        ContractPhase.DangDongDau,
        ContractPhase.ChoDuyet,
    ];

    public async Task<List<ContractListItemDto>> Handle(GetMyInboxQuery request, CancellationToken ct)
    {
        if (!currentUser.IsAuthenticated) throw new UnauthorizedException();

        var userRoles = currentUser.Roles;
        var isAdmin = userRoles.Contains(AppRoles.Admin);

        // Phase phù hợp với role hiện tại (Admin thấy tất cả phase chưa kết thúc)
        var eligiblePhases = isAdmin
            ? AdminInboxPhases.ToList()
            : PhaseActorRoles
                .Where(kv => kv.Value.Any(r => userRoles.Contains(r)))
                .Select(kv => kv.Key)
                .ToList();

        // [W6 S161] V2-aware: HĐ pin V2 + Phase=ChoDuyet + actor là approver CẤP ĐANG CHỜ.
        // Admin đi bằng nhánh PHASE (`ChoDuyet` ở trên) nên để rỗng — mirror PE `:746-748`.
        var v2InboxIds = isAdmin
            ? new HashSet<Guid>()
            : await ResolveV2InboxIdsAsync(currentUser.UserId, ct);

        // 🔴 Điều kiện thoát sớm phải xét CẢ 2 nguồn: user chỉ có chân duyệt V2 mà không mang
        // role legacy nào (eligiblePhases rỗng) vẫn PHẢI thấy hộp thư của mình.
        if (eligiblePhases.Count == 0 && v2InboxIds.Count == 0) return [];

        var q = from c in db.Contracts.AsNoTracking()
                join s in db.Suppliers.AsNoTracking() on c.SupplierId equals s.Id
                join p in db.Projects.AsNoTracking() on c.ProjectId equals p.Id
                where eligiblePhases.Contains(c.Phase) || v2InboxIds.Contains(c.Id)
                orderby c.SlaDeadline ?? DateTime.MaxValue
                select new ContractListItemDto(
                    c.Id, c.MaHopDong, c.TenHopDong, c.Type, c.Phase,
                    c.SupplierId, s.Name, c.ProjectId, p.Name,
                    c.GiaTri, c.SlaDeadline, c.CreatedAt);

        return await q.Take(100).ToListAsync(ct);
    }

    /// Precompute ID các HĐ mà `userId` là approver của Cấp ĐANG CHỜ — mirror
    /// `PurchaseEvaluationFeatures.cs:817-852` (và bản KHKK `ContractSigningPlanFeatures.cs:229-264`).
    /// 🔴 In-memory join CÓ CHỦ ĐÍCH: `CurrentWorkflowStepIndex` là INDEX 0-based vào list Steps
    /// đã sort theo `Order`, KHÔNG phải chính `Order` ⇒ không dịch thẳng sang SQL được.
    /// So Cấp bằng GIÁ TRỊ `Level.Order` + lấy `Any(...)` ⇒ giữ nguyên OR-of-N cùng Cấp.
    /// HĐ xóa mềm tự rớt: `ContractConfiguration.cs:44` có global filter `!IsDeleted`.
    private async Task<HashSet<Guid>> ResolveV2InboxIdsAsync(Guid? userId, CancellationToken ct)
    {
        if (userId is null) return new HashSet<Guid>();

        var candidates = await (
            from c in db.Contracts.AsNoTracking()
            where c.Phase == ContractPhase.ChoDuyet
                && c.ApprovalWorkflowId != null
                && c.CurrentWorkflowStepIndex != null
                && c.CurrentApprovalLevelOrder != null
            select new { c.Id, c.ApprovalWorkflowId, c.CurrentWorkflowStepIndex, c.CurrentApprovalLevelOrder }
        ).ToListAsync(ct);

        if (candidates.Count == 0) return new HashSet<Guid>();

        var wfIds = candidates.Select(c => c.ApprovalWorkflowId!.Value).Distinct().ToList();
        var workflows = await db.ApprovalWorkflows.AsNoTracking()
            .Where(w => wfIds.Contains(w.Id))
            .Include(w => w.Steps.OrderBy(s => s.Order))
                .ThenInclude(s => s.Levels.OrderBy(l => l.Order))
            .ToDictionaryAsync(w => w.Id, ct);

        var result = new HashSet<Guid>();
        foreach (var c in candidates)
        {
            if (!workflows.TryGetValue(c.ApprovalWorkflowId!.Value, out var wf)) continue;
            var steps = wf.Steps.OrderBy(s => s.Order).ToList();
            var idx = c.CurrentWorkflowStepIndex!.Value;
            if (idx < 0 || idx >= steps.Count) continue;
            var match = steps[idx].Levels.Any(l =>
                l.Order == c.CurrentApprovalLevelOrder!.Value && l.ApproverUserId == userId.Value);
            if (match) result.Add(c.Id);
        }
        return result;
    }
}

// ========== GET detail ==========

public record GetContractQuery(Guid Id) : IRequest<ContractDetailDto>;

public class GetContractQueryHandler(
    IApplicationDbContext db,
    UserManager<User> userManager,
    ICurrentUser currentUser) : IRequestHandler<GetContractQuery, ContractDetailDto>
{
    public async Task<ContractDetailDto> Handle(GetContractQuery request, CancellationToken ct)
    {
        var c = await db.Contracts.AsNoTracking()
            .Include(x => x.Approvals)
            .Include(x => x.Comments)
            .Include(x => x.Attachments)
            .FirstOrDefaultAsync(x => x.Id == request.Id, ct)
            ?? throw new NotFoundException("Contract", request.Id);

        // IDOR guard: non-admin chỉ xem được HĐ mình là Drafter hoặc role eligible phase hiện tại
        var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
        if (!isAdmin)
        {
            var isDrafter = c.DrafterUserId == currentUser.UserId;
            var eligiblePhases = ListContractsQueryHandler.GetEligiblePhases(currentUser.Roles);
            var isEligibleByRole = eligiblePhases.Contains(c.Phase);
            // [W6 S161] Vế V2 THEO-TỪNG-HĐ — mirror PE `PurchaseEvaluationFeatures.cs:886-898`:
            // actor có chân `ApproverUserId` ở BẤT KỲ Cấp nào của workflow ĐÃ PIN CỦA HĐ NÀY.
            // Hỏi theo `c.ApprovalWorkflowId` (không phải theo role) ⇒ user ngoài workflow vẫn 403.
            var isV2Approver = false;
            if (c.ApprovalWorkflowId is Guid awIdForCheck && currentUser.UserId is Guid uidForCheck)
            {
                isV2Approver = await db.ApprovalWorkflowLevels.AsNoTracking()
                    .AnyAsync(l => l.Step!.ApprovalWorkflowId == awIdForCheck
                        && l.ApproverUserId == uidForCheck, ct);
            }
            // [S89 mirror PE] Nháp = RIÊNG TƯ người soạn: V2-approver KHÔNG mở được HĐ còn Nháp.
            var isDraftPhase = c.Phase == ContractPhase.DangSoanThao;
            // Vai bản-cứng ký/đóng dấu → xem được HĐ ĐÃ PHÁT HÀNH (GĐ4/W7).
            var isHardCopyActor = ListContractsQueryHandler.HasHardCopyActorRole(currentUser.Roles)
                && c.Phase == ContractPhase.DaPhatHanh;
            if (!isDrafter && !isEligibleByRole && !isHardCopyActor && (isDraftPhase || !isV2Approver))
                throw new ForbiddenException("Bạn không có quyền xem HĐ này.");
        }

        var supplier = await db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == c.SupplierId, ct);
        var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == c.ProjectId, ct);
        var department = c.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == c.DepartmentId, ct);


        // Resolve workflow: pinned WorkflowDefinition > overrides > hardcoded
        WorkflowPolicy workflowPolicy;
        if (c.WorkflowDefinitionId is Guid wfId)
        {
            var def = await db.WorkflowDefinitions.AsNoTracking()
                .Include(d => d.Steps.OrderBy(s => s.Order))
                    .ThenInclude(s => s.Approvers)
                .FirstOrDefaultAsync(d => d.Id == wfId, ct);
            workflowPolicy = def is not null
                ? WorkflowPolicyRegistry.FromDefinition(def)
                : WorkflowPolicyRegistry.ForContract(c);
        }
        else
        {
            var workflowOverrides = await db.WorkflowTypeAssignments.AsNoTracking()
                .ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
            workflowPolicy = WorkflowPolicyRegistry.ForContractWithOverrides(c, workflowOverrides);
        }

        // [Plan B S29 2026-05-22 Chunk E2] Load V2 LevelOpinions nếu pin ApprovalWorkflowId.
        // JOIN ApprovalWorkflowLevel + Step + Approver User để build dynamic DTO
        // cho FE Section 5 render dynamic theo flow shape. V1 legacy → empty list.
        List<ContractLevelOpinionDto>? levelOpinionsDto = null;
        if (c.ApprovalWorkflowId is Guid awIdLoad)
        {
            var opinions = await db.ContractLevelOpinions.AsNoTracking()
                .Where(o => o.ContractId == c.Id)
                .ToListAsync(ct);
            if (opinions.Count > 0)
            {
                var levelIds = opinions.Select(o => o.ApprovalWorkflowLevelId).ToHashSet();
                var levels = await db.ApprovalWorkflowLevels.AsNoTracking()
                    .Include(l => l.Step)
                    .Where(l => levelIds.Contains(l.Id))
                    .ToListAsync(ct);
                var approverUserIds = levels.Select(l => l.ApproverUserId).ToHashSet();
                var approverNames = await userManager.Users.AsNoTracking()
                    .Where(u => approverUserIds.Contains(u.Id))
                    .ToDictionaryAsync(u => u.Id, u => u.FullName, ct);
                levelOpinionsDto = opinions
                    .Select(o =>
                    {
                        var level = levels.FirstOrDefault(l => l.Id == o.ApprovalWorkflowLevelId);
                        var step = level?.Step;
                        return new ContractLevelOpinionDto(
                            o.Id,
                            o.ApprovalWorkflowLevelId,
                            StepOrder: step?.Order ?? 0,
                            StepName: step?.Name ?? "",
                            LevelOrder: level?.Order ?? 0,
                            LevelName: level?.Name,
                            ApproverUserId: level?.ApproverUserId ?? Guid.Empty,
                            ApproverFullName: level != null && approverNames.TryGetValue(level.ApproverUserId, out var afn) ? afn : null,
                            o.Comment ?? "",
                            o.SignedAt,
                            o.SignedByUserId,
                            o.SignedByFullName);
                    })
                    .OrderBy(d => d.StepOrder).ThenBy(d => d.LevelOrder)
                    .ToList();
            }
            else
            {
                levelOpinionsDto = new List<ContractLevelOpinionDto>();
            }
        }

        // ---- [W2 S187 — YC-023 GĐ3] (a) NGUỒN GỐC KHKK: reverse-join qua DÒNG kế hoạch.
        // `Contract` không mang con-trỏ ngược sang GĐ2; con-trỏ nằm ở `Line.ContractId`
        // (`ContractSigningPlanLine.cs:46`), write-site DUY NHẤT = bridge K7
        // (`CreateContractFromSigningPlanFeatures.cs:238` — gán cho TỪNG dòng được chọn).
        // 0 dòng ⇒ `null` ⇒ FE không render card (HĐ tạo tay / HĐ cũ trước K7).
        // 🔴 Cả `ContractSigningPlanLines` lẫn `ContractSigningPlans` CÓ global query filter
        // `!IsDeleted` (`ContractSigningPlanLineConfiguration.cs:52` + `...PlanConfiguration.cs:55`)
        // ⇒ KHÔNG cần `.Where(!IsDeleted)` thủ công như họ HRM.
        ContractSourceDto? sourceDto = null;
        var planLines = await db.ContractSigningPlanLines.AsNoTracking()
            .Where(l => l.ContractId == c.Id)
            .OrderBy(l => l.CreatedAt).ThenBy(l => l.Id)
            .Select(l => new { l.ContractSigningPlanId, l.TenHangMuc, l.ApprovedAmount })
            .ToListAsync(ct);
        if (planLines.Count > 0)
        {
            // "N dòng nối cùng 1 phiếu" là bất biến do rào (2) của bridge
            // (`CreateContractFromSigningPlanFeatures.cs:101-109`). Vẫn chốt phiếu theo dòng
            // ĐẦU (thứ tự tất định) rồi lọc lại: nếu có ai INSERT SQL thô (họ #84) làm lệch,
            // card ra số của ĐÚNG 1 phiếu thay vì trộn tiền của 2 phiếu.
            var planId = planLines[0].ContractSigningPlanId;
            var linesOfPlan = planLines.Where(l => l.ContractSigningPlanId == planId).ToList();
            var plan = await db.ContractSigningPlans.AsNoTracking()
                .Where(p => p.Id == planId)
                .Select(p => new { p.MaKeHoach, p.ApprovalGroup, p.PurchaseEvaluationId })
                .FirstOrDefaultAsync(ct);
            sourceDto = new ContractSourceDto(
                PlanId: planId,
                MaKeHoach: plan?.MaKeHoach,
                ApprovalGroup: plan?.ApprovalGroup,
                PurchaseEvaluationId: plan?.PurchaseEvaluationId,
                TenHangMucs: linesOfPlan
                    .Select(l => string.IsNullOrWhiteSpace(l.TenHangMuc) ? "(chưa gán hạng mục)" : l.TenHangMuc!)
                    .ToList(),
                // Σ in-memory, KHÔNG `Sum()` dịch xuống SQL: provider SQLite của test-fixture
                // không cộng được `decimal` (lưu TEXT) — cộng ở đây cho cả 2 provider ra CÙNG số.
                ApprovedAmountTotal: linesOfPlan.Sum(l => l.ApprovedAmount ?? 0));
        }

        // ---- [W2 S187] (b) CÂY THÔ workflow V2 (Bước → Cấp → người) — khuôn KHKK
        // `ContractSigningPlanFeatures.cs:1014-1057`. Chỉ nạp khi HĐ ĐÃ pin V2; HĐ V1 legacy
        // ⇒ null (FE giữ nhánh render cũ). KHÔNG precompute status — panel tự suy từ cặp
        // con-trỏ (`CurrentWorkflowStepIndex` INDEX 0-based + `CurrentApprovalLevelOrder`).
        List<ContractWorkflowStepDto>? workflowStepsDto = null;
        // [WQ3-BE S190] Meta quy trình cho panel duyệt — lấy TỪ CHÍNH bản `wf` đang nạp cây thô,
        // KHÔNG mở query thứ hai (2 query = 2 cơ hội đọc lệch nhau khi admin sửa giữa chừng).
        string? workflowCode = null;
        int? workflowVersion = null;
        if (c.ApprovalWorkflowId is Guid awIdTree)
        {
            var wf = await db.ApprovalWorkflows.AsNoTracking()
                .Include(w => w.Steps.OrderBy(s => s.Order))
                    .ThenInclude(s => s.Levels.OrderBy(l => l.Order))
                .FirstOrDefaultAsync(w => w.Id == awIdTree, ct);
            if (wf is not null)
            {
                workflowCode = wf.Code;
                workflowVersion = wf.Version;
                var approverIds = wf.Steps.SelectMany(s => s.Levels)
                    .Select(l => l.ApproverUserId).Distinct().ToList();
                var approverNames = await userManager.Users.AsNoTracking()
                    .Where(u => approverIds.Contains(u.Id))
                    .ToDictionaryAsync(u => u.Id, u => u.FullName, ct);

                // `Step.DepartmentId` loose-Guid (không nav) ⇒ gom 1 lượt, KHÔNG N+1 trong vòng lặp.
                var stepDeptIds = wf.Steps.Where(s => s.DepartmentId.HasValue)
                    .Select(s => s.DepartmentId!.Value).Distinct().ToList();
                var stepDeptNames = stepDeptIds.Count == 0
                    ? new Dictionary<Guid, string>()
                    : await db.Departments.AsNoTracking()
                        .Where(d => stepDeptIds.Contains(d.Id))
                        .Select(d => new { d.Id, d.Name })
                        .ToDictionaryAsync(d => d.Id, d => d.Name, ct);

                workflowStepsDto = wf.Steps.OrderBy(s => s.Order)
                    .Select(s => new ContractWorkflowStepDto(
                        s.Id, s.Order, s.Name, s.DepartmentId,
                        s.Levels.OrderBy(l => l.Order)
                            .Select(l => new ContractWorkflowLevelDto(
                                l.Id, l.Order, l.Name, l.ApproverUserId,
                                approverNames.TryGetValue(l.ApproverUserId, out var afn2) ? afn2 : null,
                                l.AllowApproverFinalize))
                            .ToList(),
                        s.DepartmentId is Guid sdId && stepDeptNames.TryGetValue(sdId, out var sdn) ? sdn : null))
                    .ToList();
            }
        }

        // Resolve user names
        var userIds = new HashSet<Guid>();
        if (c.DrafterUserId is Guid did) userIds.Add(did);
        foreach (var a in c.Approvals) if (a.ApproverUserId is Guid aid) userIds.Add(aid);
        foreach (var cm in c.Comments) userIds.Add(cm.UserId);
        var users = await userManager.Users.AsNoTracking()
            .Where(u => userIds.Contains(u.Id))
            .ToDictionaryAsync(u => u.Id, u => u.FullName, ct);

        return new ContractDetailDto(
            c.Id, c.MaHopDong, c.TenHopDong, c.NoiDung, c.Type, c.Phase,
            c.SupplierId, supplier?.Name ?? "",
            c.ProjectId, project?.Name ?? "",
            c.DepartmentId, department?.Name,
            c.DrafterUserId, c.DrafterUserId is Guid d && users.TryGetValue(d, out var dn) ? dn : null,
            c.TemplateId, c.GiaTri, c.BypassProcurementAndCCM, c.SlaDeadline, c.DraftData,
            c.CreatedAt, c.UpdatedAt,
            c.BudgetManualName, c.BudgetManualAmount,
            c.Approvals
                .OrderBy(a => a.ApprovedAt)
                .Select(a => new ContractApprovalDto(
                    a.Id, a.FromPhase, a.ToPhase, a.ApproverUserId,
                    a.ApproverUserId is Guid id && users.TryGetValue(id, out var n) ? n : null,
                    a.Decision, a.Comment, a.ApprovedAt))
                .ToList(),
            c.Comments
                .OrderBy(cm => cm.CreatedAt)
                .Select(cm => new ContractCommentDto(
                    cm.Id, cm.UserId,
                    users.TryGetValue(cm.UserId, out var cn) ? cn : "",
                    cm.Phase, cm.Content, cm.CreatedAt))
                .ToList(),
            c.Attachments
                .OrderBy(att => att.CreatedAt)
                .Select(att => new ContractAttachmentDto(
                    att.Id, att.FileName, att.StoragePath, att.FileSize,
                    att.ContentType, att.Purpose, att.Note, att.CreatedAt))
                .ToList(),
            BuildWorkflowSummary(c, workflowPolicy),
            // [Plan B S29 2026-05-22 Chunk E2] V2 fields
            ApprovalWorkflowId: c.ApprovalWorkflowId,
            CurrentApprovalLevelOrder: c.CurrentApprovalLevelOrder,
            LevelOpinions: levelOpinionsDto,
            // [W2 S187] Nền hiển thị GĐ3 — nguồn gốc KHKK + đủ cặp con-trỏ + cây thô.
            // `CurrentWorkflowStepIndex` đọc THẲNG cột đã có (`Contract.cs:40`) ⇒ 0-migration.
            Source: sourceDto,
            CurrentWorkflowStepIndex: c.CurrentWorkflowStepIndex,
            WorkflowSteps: workflowStepsDto,
            // [WQ3-BE S190 · YC-030 Q3] meta quy trình + cờ RUNTIME kết-thúc-sớm (Mig 73).
            WorkflowCode: workflowCode,
            WorkflowVersion: workflowVersion,
            EndedByLevelFinalize: c.EndedByLevelFinalize);
    }

    // FE uses this to render next-phase buttons dynamically — no more hardcoded
    // NEXT_PHASES map that silently drifts from the BE policy.
    private static WorkflowSummaryDto BuildWorkflowSummary(Contract c, WorkflowPolicy policy)
    {
        return new WorkflowSummaryDto(
            PolicyName: policy.Name,
            PolicyDescription: policy.Description,
            ActivePhases: policy.ActivePhases.ToList(),
            NextPhases: policy.NextPhasesFrom(c.Phase).ToList());
    }
}

// ========== DELETE (soft) ==========
//
// [W5 S190 · YC-029] Đường xoá HĐ dựng lại đồng-khuôn KHKK (`ContractSigningPlanFeatures.cs`
// Region 7 `:1737-1807`). 5 nấc, THỨ TỰ là một phần của đặc tả — không phải sắp cho đẹp:
//     (0) chưa đăng nhập        → 401
//     (1) không tìm thấy        → 404
//     (2) không phải chủ/Admin  → 403
//     (3) phase ngoài allow-list→ 409
//     (4) xoá mềm + ghi 1 vết nhật ký mang LÝ DO
// Nấc (1) đứng TRƯỚC (2) để 403/404 không thành kênh dò "id này có tồn tại không"; nấc (2)
// đứng TRƯỚC (3) để người ngoài không đọc được HĐ đang ở bước nào.
//
// 🔴 3 bẫy đã né — ghi lại vì cả ba đều trông như "chỗ có thể gọn hơn":
//
//  a. NẤC-0 KHÔNG CÓ trong khuôn KHKK (`:1763` so thẳng người soạn với actor). HĐ có
//     `DrafterUserId` NULL (data legacy) gặp actor chưa đăng nhập (UserId null) ⇒ `null == null`
//     ⇒ hoá "chủ sở hữu" ⇒ xoá LỌT. Nấc-0 lấy từ `PeSoftDeleteFeatures.cs:56-58`. Ai thấy hai
//     khuôn "lệch nhau" thì sửa KHKK cho có nấc-0, ĐỪNG xoá nấc-0 ở đây cho giống.
//
//  b. ALLOW-LIST PHASE CỦA HĐ RỘNG HƠN KHKK. KHKK là bộ 3 {Nháp, Từ chối, Trả lại}; HĐ là
//     `Phase < DangInKy(5)` ∪ {TraLai(98), TuChoi(99)} — gồm cả legacy 1/3/4 (Dev còn HĐ sống
//     ở `DangGopY`). Chép nguyên hình `is not (…)` của KHKK = 409 oan đúng 3 phase đó.
//
//  c. THỨ TỰ `Remove` / `Add(changelog)` — chú thích tại chỗ (bug T26 S155).
//
// 🔶 LÝ DO xoá chỉ sống ở changelog: `IsDeleted/DeletedAt/DeletedBy` (do `AuditingInterceptor`
//    đặt khi `Remove`) chỉ trả lời "ai + lúc nào".
// 🔶 Endpoint DELETE CỐ Ý chưa gắn policy per-action — lý do đo được ghi tại
//    `ContractsController.Delete`  [carry:w8-delete-policy].
// Test canh: `tests/SolutionErp.Infrastructure.Tests/Application/ContractDeleteGuardTests.cs`
// (13 ca T-1..T-13, viết TRƯỚC mã này).

/// <param name="Reason">Lý do xoá (tuỳ chọn) — chảy vào `ContractChangelog.ContextNote`.</param>
public record DeleteContractCommand(Guid Id, string? Reason = null) : IRequest;

public class DeleteContractCommandValidator : AbstractValidator<DeleteContractCommand>
{
    public DeleteContractCommandValidator()
    {
        RuleFor(x => x.Id).NotEmpty();
        // Trần MATCH EF, không lấy theo spec: `ContractChangelogConfiguration.cs:20`
        // `ContextNote` HasMaxLength(2000). Lệch số ở đây = EF nổ `DbUpdateException` (500)
        // thay vì 400 có thông báo đọc được.
        RuleFor(x => x.Reason).MaximumLength(2000);
    }
}

public class DeleteContractCommandHandler(
    IApplicationDbContext db,
    ICurrentUser currentUser) : IRequestHandler<DeleteContractCommand>
{
    public async Task Handle(DeleteContractCommand request, CancellationToken ct)
    {
        // (0) Chặn actor vô danh TRƯỚC mọi phép so người — bẫy (a) ở đầu khối.
        if (!currentUser.IsAuthenticated || currentUser.UserId is null)
            throw new UnauthorizedException();

        var entity = await db.Contracts.FirstOrDefaultAsync(c => c.Id == request.Id, ct)
            ?? throw new NotFoundException("Contract", request.Id);

        // (2) Chủ HĐ hoặc Admin. Trước W5 khối này KHÔNG hỏi actor là ai ⇒ mọi user đăng nhập
        // xoá được HĐ của người khác (lỗ #82 — rào hiển thị FE và rào API là 2 tầng độc lập;
        // FE ẩn nút không phải là rào). An toàn khi so vì nấc (0) đã loại UserId null.
        var isOwner = entity.DrafterUserId == currentUser.UserId;
        var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
        if (!isOwner && !isAdmin)
            throw new ForbiddenException("Chỉ người soạn hợp đồng hoặc Admin được xoá hợp đồng này.");

        // (3) Phase — bẫy (b). `ChoDuyet(10)` cấm KỂ CẢ chủ HĐ (rút êm là cướp lượt của người
        // đang duyệt; đường xoá-khi-đang-duyệt dành cho approver là việc khác, chưa mở).
        // Mọi phase ≥ `DangInKy(5)` cấm mọi đường, kể cả Admin — tới đó HĐ đã mang mã chính thức.
        var phaseAllowed = entity.Phase < ContractPhase.DangInKy
            || entity.Phase == ContractPhase.TraLai
            || entity.Phase == ContractPhase.TuChoi;
        if (!phaseAllowed)
            throw new ConflictException(
                "Chỉ xoá được hợp đồng ở trạng thái soạn thảo, Trả lại hoặc Từ chối. " +
                "Hợp đồng Đã gửi duyệt phải Trả lại/Từ chối trước; hợp đồng từ 'Đang in ký' " +
                "trở đi (trình ký, đóng dấu, đã phát hành) thì không xoá được.");

        var reason = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim();
        var phaseAtChange = entity.Phase;

        // 🔴 THỨ TỰ: `Remove()` TRƯỚC, `Add(changelog)` SAU (bug T26 S155). EF chạy cascade
        // client-side NGAY tại `Remove()` (`CascadeDeleteTiming.Immediate`); dòng nhật ký đã ở
        // state `Added` trước đó sẽ bị DETACH ⇒ lưu xong 0 row, im lặng, không lỗi. Thêm SAU thì
        // lượt cascade đã chạy xong lúc chưa có con nào để gỡ ⇒ vết sống.
        db.Contracts.Remove(entity);

        db.ContractChangelogs.Add(new ContractChangelog
        {
            ContractId = entity.Id,
            EntityType = ChangelogEntityType.Contract,
            EntityId = null,                        // header thì để trống (khuôn 2 site ghi tay)
            Action = ChangelogAction.Delete,
            PhaseAtChange = phaseAtChange,
            UserId = currentUser.UserId,
            UserName = currentUser.FullName ?? currentUser.Email,
            Summary = $"Xoá hợp đồng {entity.MaHopDong ?? entity.TenHopDong ?? "(chưa có mã)"}",
            ContextNote = reason,
        });

        await db.SaveChangesAsync(ct);
    }
}
