using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Contracts.Services;
// [S187 W4] Alias thay vì `using SolutionErp.Application.Contracts;`: file này đã mở
// `SolutionErp.Domain.Contracts` (Contract, ContractPhase, WorkflowDefinition…) — kéo trọn
// namespace Application cùng tên vào đây là mời một va-chạm tên trong tương lai mà lỗi sẽ hiện ra
// ở một file chẳng liên quan. Cần đúng 1 lớp thì trỏ đúng 1 lớp.
using ContractWorkflowGuards = SolutionErp.Application.Contracts.ContractWorkflowGuards;
using SolutionErp.Application.Notifications;
using SolutionErp.Domain.Common;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Notifications;

namespace SolutionErp.Infrastructure.Services;

// Contract Workflow Service — Session 17 spec mới (state machine 5 trạng thái):
//   Nháp (DangSoanThao) ──trình──► Đã gửi duyệt (ChoDuyet) ─approve cấp cuối─► Đã phát hành (DaPhatHanh, terminal + gen mã)
//                                                          ├─ Trả lại ────────► Trả lại (TraLai)
//                                                          └─ Từ chối ────────► Từ chối (TuChoi, terminal)
//   Trả lại ──Drafter sửa+gửi lại──► Đã gửi duyệt (chạy LẠI từ đầu, KHÔNG jump-back)
//
// Khác Mig 21 (Session 16): bỏ smart-reject jump-back. Trả lại = Phase RIÊNG
// (TraLai=98), không revert về DangSoanThao. Drafter từ TraLai gửi lại như
// case Nháp — workflow chạy lại từ Cấp 1 Bước 1.
public class ContractWorkflowService(
    IApplicationDbContext db,
    IContractCodeGenerator codeGenerator,
    IDateTime dateTime,
    INotificationService notifications,
    IChangelogService changelog,
    UserManager<User> userManager) : IContractWorkflowService
{
    public TimeSpan? GetPhaseSla(ContractPhase phase) =>
        phase == ContractPhase.ChoDuyet ? TimeSpan.FromDays(7) : null;

    public async Task TransitionAsync(
        Contract contract,
        ContractPhase targetPhase,
        Guid? actorUserId,
        IReadOnlyList<string> actorRoles,
        ApprovalDecision decision,
        string? comment,
        bool skipToFinal = false,
        bool applyLevelFinalize = true,
        CancellationToken ct = default)
    {
        var fromPhase = contract.Phase;
        var isAdmin = actorRoles.Contains(AppRoles.Admin);
        var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;

        // ===== REJECT BRANCH =====
        if (decision == ApprovalDecision.Reject)
        {
            // ---- [S187 W6] CỬA ĐÚNG-TRẠM cho HĐ đã pin quy trình V2 — vá lỗ gotcha #82-S156.
            //
            // Trước đợt này thân nhánh dưới chạy TRƯỚC MỌI rào: bất kỳ ai đăng nhập gửi
            // `decision=Reject` là lật được HĐ BẤT KỲ sang TraLai/TuChoi — kể cả HĐ đã
            // `DaPhatHanh` (đã gen mã, đã phát hành). Màn duyệt V2 (W6-FE) chỉ tắt 2 nút ở UI;
            // API còn hở thì phần tắt đó là trang trí, forge 1 request qua DevTools là xong.
            //
            // 🔴 PHẠM VI CỐ Ý HẸP — chỉ HĐ pin V2 (`ApprovalWorkflowId != null`). HĐ V1 legacy đi
            //    tiếp y nguyên từng byte (owner: "chỗ hợp đồng cứ từ từ" cho V1). Đo Dev
            //    2026-08-10 (W4): 7 HĐ, 0 HĐ pin V2 ⇒ đợt này KHÔNG đổi hành vi của bất kỳ HĐ nào
            //    đang sống, chỉ dựng rào sẵn cho luồng V2 sắp mở. Siết V1 là việc riêng, rủi ro
            //    riêng, cần owner chốt riêng.
            //
            // 🔴 ĐẶT Ở ĐÂY chứ KHÔNG nhân bản thân nhánh thành bản V2 thứ hai: phần thân dưới
            //    (set Phase + xoá con-trỏ + log) đúng cho CẢ V1 lẫn V2. Chép ra bản thứ hai là đẻ
            //    hai luật rồi để chúng đi lệch nhau — đúng cái bệnh mà
            //    `ContractWorkflowGuards.cs:14-21` vừa gỡ ở đường pin quy trình.
            if (contract.ApprovalWorkflowId is Guid rejectAwId)
                await EnsureCanRejectV2Async(contract, rejectAwId, targetPhase, actorUserId, isAdmin, ct);

            if (targetPhase == ContractPhase.TuChoi)
            {
                contract.Phase = ContractPhase.TuChoi;
            }
            else
            {
                // Trả lại — Phase=TraLai RIÊNG (không revert về DangSoanThao).
                // Drafter sửa từ TraLai rồi gửi lại sẽ chạy lại từ Cấp 1 Bước 1.
                contract.Phase = ContractPhase.TraLai;
                contract.CurrentWorkflowStepIndex = null;
            }
            contract.SlaDeadline = null;
            await LogTransitionAsync(contract, fromPhase, contract.Phase, actorUserId, decision, comment, ct);
            await db.SaveChangesAsync(ct);
            return;
        }

        // ===== DRAFTER TRÌNH/GỬI LẠI (Nháp HOẶC Trả lại → ChoDuyet) =====
        // Cả 2 entry point cùng logic: chạy lại từ đầu (CurrentWorkflowStepIndex=0).
        if ((fromPhase == ContractPhase.DangSoanThao || fromPhase == ContractPhase.TraLai)
            && (targetPhase == ContractPhase.ChoDuyet || (!isAdmin && !isSystem)))
        {
            // [W6 S161] Nới vai được TRÌNH: `Drafter ∨ DeptManager ∨ người-tạo ∨ Procurement`.
            // Lỗ đo 07-29: nhân sự Phòng Mua hàng (role `Procurement`) bị 403 khi trình HĐ do
            // chính mình lập. Vẫn giữ 403 cho role NGOÀI 4 vế này.
            // 🔴 `actorUserId is Guid` là BẮT BUỘC, không được viết thẳng
            // `contract.CreatedBy == actorUserId`: cả hai đều `Guid?` ⇒ HĐ seed/legacy có
            // `CreatedBy == null` gặp actor null sẽ ra `null == null` = TRUE và lọt guard.
            // Xét cả `DrafterUserId` vì đó là field handler tạo HĐ gán tường minh
            // (`ContractFeatures.cs:96`), còn `CreatedBy` do `AuditingInterceptor.cs:45` gán —
            // HĐ nạp từ seed có thể trống ô sau.
            var isCreator = actorUserId is Guid actorId
                && (contract.CreatedBy == actorId || contract.DrafterUserId == actorId);
            if (!isAdmin && !isSystem
                && !actorRoles.Contains(AppRoles.Drafter)
                && !actorRoles.Contains(AppRoles.DeptManager)
                && !actorRoles.Contains(AppRoles.Procurement)
                && !isCreator)
            {
                throw new ForbiddenException(
                    $"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt HĐ.");
            }

            // ---- [S187 W4] CỬA 4/4 — quy trình ĐÃ PIN còn hợp lệ không?
            //
            // 3 cửa GHI (Create · cầu K7 · sửa nháp) chỉ chặn ĐÚNG LÚC GHI. HĐ pin từ trước khi có
            // rào — hoặc pin xong rồi Admin đổi mã quy trình đó trong Designer — vẫn nằm sẵn trong
            // DB với loại lệch. Đây là cửa duy nhất bắt được lớp đó.
            //
            // 🔴 ĐẶT SAU rào vai (403 đứng trước 409): sai người thì lý do đầu tiên người dùng cần
            //    nghe là "không phải việc của bạn", không phải chuyện cấu hình quy trình.
            //
            // 🔴 CỐ Ý KHÔNG cắm thêm vào nhánh APPROVE dù cùng là "đường duyệt V2": HĐ đã sang
            //    `ChoDuyet` thì KHÔNG còn đường sửa quy trình (`UpdateContractDraftCommandHandler`
            //    chỉ mở ở `DangSoanThao`) ⇒ chặn ở đó là tự tay giam phiếu, không ai gỡ được kể cả
            //    Admin. Rào chỉ được đứng ở cửa mà người dùng còn lối đi tiếp: ở ĐÂY, HĐ đang Nháp,
            //    người lập sửa lại quy trình qua cửa 3/4 rồi trình lại.
            //    ⚠️ Dư-lượng đã biết, KHÔNG vá trong W4: HĐ ở `TraLai` cũng không sửa được
            //    (`UpdateContractDraft` chỉ nhận `DangSoanThao`) ⇒ nếu một HĐ `TraLai` mang pin
            //    lệch thì nó kẹt. Hiện KHÔNG có HĐ nào rơi vào ca đó (đo Dev 2026-08-10: 7 HĐ,
            //    0 HĐ pin V2) và nới phase được-sửa là đổi hành-vi nằm ngoài phạm vi W4.
            //
            // 🔴 KHÔNG hỏi `IsActive`/`IsUserSelectable` — xem lý do trong
            //    `ContractWorkflowGuards.EnsurePinnedWorkflowStillValidAsync`: hai cờ đó thuộc về
            //    khả-năng-CHỌN, không phải hiệu-lực; hỏi ở đây thì một thao tác Designer chẳng liên
            //    quan sẽ làm HĐ nháp đang chạy hết trình được.
            if (contract.ApprovalWorkflowId is Guid pinnedAwId)
                await ContractWorkflowGuards.EnsurePinnedWorkflowStillValidAsync(
                    db, pinnedAwId, contract.Type, ct);

            contract.Phase = ContractPhase.ChoDuyet;
            contract.CurrentWorkflowStepIndex = 0;
            // [Plan B S29 2026-05-22] V2 pointer init — mirror PE line 153.
            // Chỉ init levelOrder=1 nếu pin schema V2 (ApprovalWorkflowId set).
            contract.CurrentApprovalLevelOrder = contract.ApprovalWorkflowId is not null ? 1 : null;
            contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
            await LogTransitionAsync(contract, fromPhase, ContractPhase.ChoDuyet, actorUserId, decision, comment, ct);
            await db.SaveChangesAsync(ct);
            return;
        }

        // ===== APPROVE STEP =====
        if (fromPhase == ContractPhase.ChoDuyet && decision == ApprovalDecision.Approve)
        {
            // [Plan B S29 2026-05-22] Branch V2 schema mới (ApprovalWorkflowId pin)
            // vs V1 legacy (WorkflowDefinitionId pin Mig 21). Mirror PE
            // PurchaseEvaluationWorkflowService.cs line 161-180 pattern.
            // V1 legacy giữ behavior cũ — 7 prod contract chạy nhánh này.
            if (contract.ApprovalWorkflowId is Guid awId)
            {
                await ApproveV2Async(contract, awId, actorUserId, actorRoles, isAdmin, isSystem, comment, skipToFinal, applyLevelFinalize, ct);
                await db.SaveChangesAsync(ct);
                return;
            }
            if (skipToFinal && !isAdmin && !isSystem)
                throw new ConflictException(
                    "skipToFinal chỉ hỗ trợ HĐ V2 (ApprovalWorkflowsV2). HĐ V1 legacy không có per-Approver-slot flag.");

            var def = contract.WorkflowDefinitionId is Guid wfId
                ? await db.WorkflowDefinitions.AsNoTracking()
                    .Include(d => d.Steps.OrderBy(s => s.Order))
                        .ThenInclude(s => s.Approvers)
                    .FirstOrDefaultAsync(d => d.Id == wfId, ct)
                : null;

            if (def == null || def.Steps.Count == 0)
                throw new ConflictException("HĐ chưa pin workflow definition hoặc workflow không có step.");

            var steps = def.Steps.OrderBy(s => s.Order).ToList();
            var currentIdx = contract.CurrentWorkflowStepIndex ?? 0;
            if (currentIdx < 0 || currentIdx >= steps.Count)
                throw new ConflictException($"CurrentWorkflowStepIndex={currentIdx} không hợp lệ.");

            var currentStep = steps[currentIdx];

            if (!isAdmin && !isSystem)
            {
                var actor = actorUserId is Guid uid ? await userManager.FindByIdAsync(uid.ToString()) : null;
                if (actor == null)
                    throw new ForbiddenException("Không xác định được approver.");

                var matchByDeptLevel = currentStep.DepartmentId != null
                    && currentStep.PositionLevel != null
                    && actor.DepartmentId == currentStep.DepartmentId
                    && actor.PositionLevel != null
                    && (int)actor.PositionLevel >= (int)currentStep.PositionLevel;

                var matchByExplicitUser = currentStep.Approvers.Any(a =>
                    a.Kind == WorkflowApproverKind.User
                    && Guid.TryParse(a.AssignmentValue, out var auid)
                    && auid == actor.Id);

                var matchByRole = currentStep.Approvers.Any(a =>
                    a.Kind == WorkflowApproverKind.Role
                    && actorRoles.Contains(a.AssignmentValue));

                if (!matchByDeptLevel && !matchByExplicitUser && !matchByRole)
                    throw new ForbiddenException(
                        $"Step {currentIdx + 1} ({currentStep.Name}) yêu cầu phòng={currentStep.DepartmentId}, cấp={currentStep.PositionLevel}. Bạn không khớp.");
            }

            db.ContractApprovals.Add(new ContractApproval
            {
                ContractId = contract.Id,
                FromPhase = fromPhase,
                ToPhase = fromPhase,
                ApproverUserId = actorUserId,
                Decision = decision,
                Comment = $"[Step {currentIdx + 1}] {comment ?? ""}",
                ApprovedAt = dateTime.UtcNow,
            });

            var nextIdx = currentIdx + 1;
            if (nextIdx >= steps.Count)
            {
                // All steps done — gen mã HĐ + DaPhatHanh
                if (string.IsNullOrEmpty(contract.MaHopDong))
                {
                    var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == contract.SupplierId, ct)
                        ?? throw new NotFoundException("Supplier", contract.SupplierId);
                    var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == contract.ProjectId, ct)
                        ?? throw new NotFoundException("Project", contract.ProjectId);
                    contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct);
                }
                contract.Phase = ContractPhase.DaPhatHanh;
                contract.CurrentWorkflowStepIndex = null;
                contract.SlaDeadline = null;
                await LogTransitionAsync(contract, fromPhase, ContractPhase.DaPhatHanh, actorUserId, decision, comment, ct);
            }
            else
            {
                contract.CurrentWorkflowStepIndex = nextIdx;
                contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
                await LogTransitionAsync(contract, fromPhase, fromPhase, actorUserId, decision,
                    $"Hoàn tất step {currentIdx + 1}/{steps.Count}, sang step {nextIdx + 1}", ct);
            }
            await db.SaveChangesAsync(ct);
            return;
        }

        // Admin manual override
        if (isAdmin)
        {
            contract.Phase = targetPhase;
            contract.SlaDeadline = targetPhase == ContractPhase.ChoDuyet
                ? dateTime.UtcNow.AddDays(7) : null;
            await LogTransitionAsync(contract, fromPhase, targetPhase, actorUserId, decision, comment, ct);
            await db.SaveChangesAsync(ct);
            return;
        }

        throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ.");
    }

    // ===== [S187 W6] CỬA ĐÚNG-TRẠM CHO TRẢ LẠI / TỪ CHỐI — CHỈ HĐ PIN V2 =====
    // Vá gotcha #82-S156. Cùng vế quyền với nhánh Duyệt (`ApproveV2Async` :301-312) và đọc
    // con-trỏ bằng CÙNG một phép (`?? 0` / `?? 1`, xem :284/:288) — hai đường phải hiểu "ai đang
    // tới lượt" y hệt nhau, lệch một nước là phiếu vừa duyệt được vừa trả lại được bởi hai người
    // khác nhau. Khuôn: KHKK `ContractSigningPlanWorkflowService.ReturnOrRejectAsync:374-390`
    // (cửa trạng-thái đứng trước cửa người) + PE
    // `PurchaseEvaluationWorkflowService.EnsureCanRejectV2Async:336`.
    //
    // 🔴 KHÁC PE/KHKK Ở KIỂU NGOẠI LỆ — CỐ Ý, đừng "sửa" cho đồng bộ:
    //    PE `:359` và KHKK `:477` ném `ForbiddenException` (403) cho cùng vị từ này; ở đây là
    //    `ConflictException` (409) theo lệnh W6. Lý do đứng được: dưới OR-of-N thì "sai người" và
    //    "chưa tới lượt" là MỘT vị từ không tách được, mà "chưa tới lượt" là xung đột TRẠNG THÁI
    //    (lát nữa chính người đó lại duyệt được) chứ không phải thiếu quyền vĩnh viễn.
    //    ⚠️ Dư-lượng đã biết, KHAI thẳng: trong CÙNG panel W6-FE, nút Duyệt sai người trả 403
    //    (`:309`) còn nút Trả lại sai người trả 409. Muốn 2 nút cùng mã thì đổi Ở ĐÂY, KHÔNG đổi
    //    `:309` — đường Duyệt đang bị lưới hành-vi neo 403
    //    (`ContractWorkflowServiceApproveV2Tests.cs:456`).
    //
    // 🔴 Admin MIỄN TRỌN cửa này (kể cả vế trạng-thái) — giữ nguyên tiền lệ owner đã chốt ở PE
    //    EDGE-5 (`PurchaseEvaluationWorkflowService.cs:58-72`: "ADMIN + SYSTEM MIỄN … Admin đầy
    //    đủ nhất", giữ cả khả năng un-terminal). Admin cũng là đường gỡ kẹt DUY NHẤT khi quy
    //    trình pin hỏng/mất row; chặn admin ở đây là tự tay giam HĐ mà không ai mở được.
    //
    // 🔴 `actorUserId == null` ⇒ CHẶN, không thả. Hôm nay KHÔNG caller nào rơi vào đây: caller
    //    hệ-thống duy nhất là `SlaExpiryJob.cs:100-107` và nó gửi `AutoApprove` chứ không bao giờ
    //    `Reject`; đường người dùng `ContractFeatures.cs:252` đã chặn thiếu UserId từ trước. Mai
    //    có cron tự-trả-lại thì phải khai tường minh, KHÔNG mượn lỗ null đi qua — chỗ thả null ở
    //    PE `:342` chính là phần hở còn lại của khuôn nguồn, không phải thứ đáng chép.
    private async Task EnsureCanRejectV2Async(
        Contract contract,
        Guid awId,
        ContractPhase targetPhase,
        Guid? actorUserId,
        bool isAdmin,
        CancellationToken ct)
    {
        if (isAdmin) return;

        var actionLabel = targetPhase == ContractPhase.TuChoi ? "từ chối" : "trả lại";

        // (1) CỬA TRẠNG THÁI — đứng TRƯỚC cửa người: HĐ không ở "Đã gửi duyệt" thì không có Cấp
        //     nào đang chờ để mà xét tới lượt ai. Đây chính là vế khoá `DaPhatHanh` của S156.
        if (contract.Phase != ContractPhase.ChoDuyet)
            throw new ConflictException(
                $"Chỉ {actionLabel} được HĐ đang ở trạng thái Đã gửi duyệt."
                + (contract.Phase == ContractPhase.DangSoanThao
                    ? " HĐ còn Bản nháp thì dùng Xoá HĐ."
                    : string.Empty));

        // (2) CỬA NGƯỜI — actor phải có tên trong Cấp ĐANG CHỜ (OR-of-N: một trong N là đủ).
        if (actorUserId is not Guid actorId)
            throw new ConflictException(
                $"Không xác định được người {actionLabel} HĐ — thao tác này cần đăng nhập.");

        var aw = await db.ApprovalWorkflows.AsNoTracking()
            .Include(w => w.Steps.OrderBy(s => s.Order))
                .ThenInclude(s => s.Levels.OrderBy(l => l.Order))
            .FirstOrDefaultAsync(w => w.Id == awId, ct)
            ?? throw new ConflictException($"ApprovalWorkflow {awId} không tồn tại.");

        var steps = aw.Steps.OrderBy(s => s.Order).ToList();
        if (steps.Count == 0)
            throw new ConflictException("Quy trình chưa có bước nào.");

        var currentIdx = contract.CurrentWorkflowStepIndex ?? 0;
        if (currentIdx < 0 || currentIdx >= steps.Count)
            throw new ConflictException($"CurrentWorkflowStepIndex={currentIdx} không hợp lệ (max={steps.Count - 1}).");

        var currentStep = steps[currentIdx];
        var currentLevelOrder = contract.CurrentApprovalLevelOrder ?? 1;

        var levelGroups = currentStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
        var maxLevelOrder = levelGroups.Count == 0 ? 0 : levelGroups.Max(g => g.Key);
        if (currentLevelOrder < 1 || currentLevelOrder > maxLevelOrder)
            throw new ConflictException($"CurrentApprovalLevelOrder={currentLevelOrder} không hợp lệ (max={maxLevelOrder}).");

        var pendingLevelGroup = levelGroups.FirstOrDefault(g => g.Key == currentLevelOrder)
            ?? throw new ConflictException($"Bước {currentIdx + 1} không có cấp {currentLevelOrder}.");

        if (!pendingLevelGroup.Any(l => l.ApproverUserId == actorId))
            throw new ConflictException(
                $"Không đến lượt bạn — HĐ đang chờ Bước {currentIdx + 1} ({currentStep.Name}) Cấp {currentLevelOrder}; " +
                $"bạn không thuộc cấp duyệt hiện tại nên không {actionLabel} được HĐ này.");
    }

    // ===== V2 APPROVE (Mig 32+33 — Plan B S29 2026-05-22) =====
    // Mirror PurchaseEvaluationWorkflowService.cs:ApproveV2Async (line 446-634).
    // Khác PE: terminal hoàn tất → gen mã HĐ + Phase=DaPhatHanh (PE chỉ
    // Phase=DaDuyet, không gen mã). V1 legacy giữ behavior cũ.
    //
    // skipToFinal F2 (Mig 31 Plan K S23): Approver tick "Duyệt thẳng Cấp cuối"
    // + admin opt-in per slot tại matchingLevel.AllowApproverSkipToFinal → bỏ
    // qua mọi Bước/Cấp trung gian, advance pointer tới Bước cuối + Cấp cuối.
    // Phase giữ ChoDuyet — NV cuối vẫn duyệt thật để tiến DaPhatHanh.
    //
    // TODO Chunk C: UPSERT ContractLevelOpinion (table chưa tồn tại — Mig 33
    // sẽ scaffold + entity + EF config). Sau Chunk C done, em main add block
    // UPSERT mirror PE line 512-546.
    private async Task ApproveV2Async(
        Contract contract,
        Guid awId,
        Guid? actorUserId,
        IReadOnlyList<string> actorRoles,
        bool isAdmin,
        bool isSystem,
        string? comment,
        bool skipToFinal,
        bool applyLevelFinalize,
        CancellationToken ct)
    {
        var aw = await db.ApprovalWorkflows.AsNoTracking()
            .Include(w => w.Steps.OrderBy(s => s.Order))
                .ThenInclude(s => s.Levels.OrderBy(l => l.Order))
            .FirstOrDefaultAsync(w => w.Id == awId, ct)
            ?? throw new ConflictException($"ApprovalWorkflow {awId} không tồn tại.");

        var steps = aw.Steps.OrderBy(s => s.Order).ToList();
        if (steps.Count == 0)
            throw new ConflictException("Quy trình chưa có bước nào.");

        var currentIdx = contract.CurrentWorkflowStepIndex ?? 0;
        if (currentIdx < 0 || currentIdx >= steps.Count)
            throw new ConflictException($"CurrentWorkflowStepIndex={currentIdx} không hợp lệ (max={steps.Count - 1}).");

        var currentLevelOrder = contract.CurrentApprovalLevelOrder ?? 1;
        var currentStep = steps[currentIdx];

        // Group levels by Order = Cấp. Mỗi Cấp có N approvers (OR-of-N).
        var levelGroups = currentStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
        var maxLevelOrder = levelGroups.Count == 0 ? 0 : levelGroups.Max(g => g.Key);
        if (currentLevelOrder < 1 || currentLevelOrder > maxLevelOrder)
            throw new ConflictException($"CurrentApprovalLevelOrder={currentLevelOrder} không hợp lệ (max={maxLevelOrder}).");

        var pendingLevelGroup = levelGroups.FirstOrDefault(g => g.Key == currentLevelOrder)
            ?? throw new ConflictException($"Bước {currentIdx + 1} không có cấp {currentLevelOrder}.");

        // Match approver: actor.Id ∈ pendingLevelGroup.ApproverUserId. Admin bypass.
        if (!isAdmin && !isSystem)
        {
            if (actorUserId is null)
                throw new ForbiddenException("Không xác định được approver.");
            var allowedUserIds = pendingLevelGroup.Select(l => l.ApproverUserId).ToHashSet();
            if (!allowedUserIds.Contains(actorUserId.Value))
            {
                var names = string.Join(", ", allowedUserIds);
                throw new ForbiddenException(
                    $"Bước {currentIdx + 1} ({currentStep.Name}) — Cấp {currentLevelOrder}: bạn không có trong danh sách NV duyệt ({names}).");
            }
        }

        // Log approval. Enrich comment với prefix "[Duyệt vượt cấp]" khi
        // skipToFinal=true (mirror PE Plan AC S25 Bug 3b).
        var skipPrefix = skipToFinal ? "[Duyệt vượt cấp tới Cấp cuối] " : "";
        db.ContractApprovals.Add(new ContractApproval
        {
            ContractId = contract.Id,
            FromPhase = contract.Phase,
            ToPhase = contract.Phase,
            ApproverUserId = actorUserId,
            Decision = ApprovalDecision.Approve,
            Comment = $"{skipPrefix}[Bước {currentIdx + 1} — Cấp {currentLevelOrder}] {comment ?? ""}".Trim(),
            ApprovedAt = dateTime.UtcNow,
        });

        // [Plan B Chunk B2 S29 2026-05-22] UPSERT ContractLevelOpinion vào row
        // Level chính chủ (mirror PE Mig 26 line 512-546). Section 5 FE render
        // dynamic theo flow.steps[].levels[]. Comment khi duyệt auto sync sang
        // Section 5 (read-only summary). Empty comment → "(duyệt — không ý kiến)"
        // placeholder. Multi-NV cùng Cấp (OR-of-N): match level theo
        // ApproverUserId. Admin override → fallback first level group; FE detect
        // SignedByUserId !== Level.ApproverUserId → banner "Admin duyệt thay".
        var matchingLevel = pendingLevelGroup.FirstOrDefault(l => actorUserId.HasValue && l.ApproverUserId == actorUserId.Value)
                            ?? pendingLevelGroup.First();
        var actorFullName = await ResolveActorFullNameAsync(actorUserId, isSystem, ct);
        var existingOpinion = await db.ContractLevelOpinions
            .FirstOrDefaultAsync(o => o.ContractId == contract.Id
                && o.ApprovalWorkflowLevelId == matchingLevel.Id, ct);
        var normalizedComment = string.IsNullOrWhiteSpace(comment)
            ? "(duyệt — không ý kiến)"
            : comment.Trim();
        if (existingOpinion is null)
        {
            db.ContractLevelOpinions.Add(new ContractLevelOpinion
            {
                ContractId = contract.Id,
                ApprovalWorkflowLevelId = matchingLevel.Id,
                Comment = normalizedComment,
                SignedAt = dateTime.UtcNow,
                SignedByUserId = actorUserId ?? Guid.Empty,
                SignedByFullName = actorFullName,
            });
        }
        else
        {
            existingOpinion.Comment = normalizedComment;
            existingOpinion.SignedAt = dateTime.UtcNow;
            existingOpinion.SignedByUserId = actorUserId ?? Guid.Empty;
            existingOpinion.SignedByFullName = actorFullName;
        }

        // skipToFinal F2 (Mig 31 Plan K S23) — Approver scope ChoDuyet skip
        // thẳng Cấp cuối. Admin opt-in per slot tại AllowApproverSkipToFinal.
        if (skipToFinal)
        {
            if (!isAdmin && !isSystem && !matchingLevel.AllowApproverSkipToFinal)
            {
                throw new ConflictException(
                    $"Cấp Approver hiện tại (Bước {currentIdx + 1} Cấp {currentLevelOrder}) " +
                    "chưa được phép duyệt thẳng Cấp cuối. Admin phải tick checkbox " +
                    "'Duyệt thẳng Cấp cuối' trong Workflow Designer cho slot này.");
            }

            var lastStepIdx = steps.Count - 1;
            var lastStep = steps[lastStepIdx];
            var lastLevelGroups = lastStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
            var lastLevelMaxOrder = lastLevelGroups.Count == 0 ? 1 : lastLevelGroups.Max(g => g.Key);

            // Guard: actor đã ở Cấp cuối Bước cuối → fall through normal advance
            // (sẽ hit branch nextIdx >= steps.Count → DaPhatHanh đúng).
            if (!(currentIdx == lastStepIdx && currentLevelOrder == lastLevelMaxOrder))
            {
                contract.CurrentWorkflowStepIndex = lastStepIdx;
                contract.CurrentApprovalLevelOrder = lastLevelMaxOrder;
                contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
                await LogTransitionAsync(
                    contract,
                    ContractPhase.ChoDuyet,
                    ContractPhase.ChoDuyet,
                    actorUserId,
                    ApprovalDecision.Approve,
                    $"[Approver skip thẳng tới Bước {lastStepIdx + 1} Cấp {lastLevelMaxOrder} (NV cuối) — bỏ qua các Bước/Cấp trung gian] {comment ?? ""}".Trim(),
                    ct);
                return;
            }
        }

        // ---- [WQ3-BE S190 · YC-030 Q3] KẾT THÚC TẠI CẤP (level-finalize) — port khuôn KHKK
        // `ContractSigningPlanWorkflowService.cs:286-299`. Cấp bật `AllowApproverFinalize` bấm Duyệt
        // ⇒ HĐ PHÁT HÀNH luôn tại đây, không trình tiếp các Bước/Cấp còn lại.
        //
        // 🔴 OPT-OUT, không phải bắt buộc (khuôn PE S96 → KHKK K3): `applyLevelFinalize=false` =
        //    người duyệt BỎ tick "Cấp này kết thúc" ⇒ rơi xuống advance thường. Nên điều kiện là
        //    HAI vế (cờ cấu hình ∧ ý người duyệt), không phải một.
        //
        // 🔴 ĐỨNG SAU `skipToFinal` CÓ CHỦ ĐÍCH: hai cờ nói hai việc ngược nhau — skip là "nhảy TỚI
        //    Cấp cuối để cấp đó duyệt thật", finalize là "dừng NGAY, khỏi cần cấp sau". Ca hiếm
        //    người duyệt bật cả hai: khối skip ở trên đã `return` trước ⇒ skip thắng, HĐ đi tới Cấp
        //    cuối rồi cấp cuối mới kết thúc — an toàn hơn (còn một người nữa nhìn) so với thứ tự ngược.
        //    Ngoại lệ: actor ĐANG ở Cấp cuối Bước cuối thì khối skip fall-through xuống đây, và
        //    finalize ở ca đó ra CÙNG kết quả với terminal thường (cả hai đều `DaPhatHanh` + gen mã),
        //    chỉ khác dòng changelog — không đẻ trạng thái lạ.
        //
        // 🔴 TÁI DÙNG `FinalizeToDaPhatHanhAsync` (đường terminal DUY NHẤT) chứ không chép tay:
        //    gen mã RG-001 + xoá cặp con-trỏ + xoá SLA phải giống hệt nhánh terminal thường, nếu
        //    không thì HĐ kết thúc sớm ra đời KHÔNG CÓ MÃ (mirror luật choke-point KHKK
        //    `ContractSigningPlanWorkflowService.cs:342-347`).
        if (matchingLevel.AllowApproverFinalize && applyLevelFinalize)
        {
            contract.EndedByLevelFinalize = true;   // cờ RUNTIME (Mig 73) — write-site DUY NHẤT
            await FinalizeToDaPhatHanhAsync(
                contract, actorUserId,
                $"[Duyệt Bước {currentIdx + 1} Cấp {currentLevelOrder} — KẾT THÚC TẠI CẤP (level-finalize), HĐ ĐÃ PHÁT HÀNH] {comment ?? ""}".Trim(),
                ct);
            return;
        }

        // Advance: nếu còn cấp tiếp trong Step → levelOrder++; else → next Step + level 1
        if (currentLevelOrder < maxLevelOrder)
        {
            contract.CurrentApprovalLevelOrder = currentLevelOrder + 1;
            contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
            await LogTransitionAsync(contract, contract.Phase, contract.Phase, actorUserId, ApprovalDecision.Approve,
                $"Hoàn tất Cấp {currentLevelOrder}, sang Cấp {currentLevelOrder + 1} cùng Bước {currentIdx + 1}", ct);
            return;
        }

        // Hết cấp trong Step — sang Step kế (Cấp 1)
        var nextIdx = currentIdx + 1;
        if (nextIdx >= steps.Count)
        {
            // All Steps done — terminal DaPhatHanh (thân nằm ở `FinalizeToDaPhatHanhAsync`).
            await FinalizeToDaPhatHanhAsync(contract, actorUserId, comment, ct);
        }
        else
        {
            contract.CurrentWorkflowStepIndex = nextIdx;
            contract.CurrentApprovalLevelOrder = 1;
            contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
            await LogTransitionAsync(contract, contract.Phase, contract.Phase, actorUserId, ApprovalDecision.Approve,
                $"Hoàn tất Bước {currentIdx + 1}/{steps.Count}, sang Bước {nextIdx + 1} (Cấp 1)", ct);
        }
    }

    /// [WQ3-BE S190 · YC-030 Q3] ĐƯỜNG TERMINAL DUY NHẤT của nhánh duyệt V2 — gen mã RG-001 (nếu
    /// chưa có) → `DaPhatHanh` → xoá cặp con-trỏ + SLA → log.
    ///
    /// 🔴 LUẬT (khuôn `ApplyApprovedValuesOnFinalize` bên KHKK `ContractSigningPlanWorkflowService.cs:342`,
    ///    KHÔNG đếm số vì số tự lão-hoá): MỌI nhánh đưa HĐ V2 sang `DaPhatHanh` BẮT BUỘC đi qua đây.
    ///    Bỏ qua = HĐ "Đã phát hành" mà `MaHopDong` rỗng, hoặc còn con-trỏ Bước/Cấp ⇒ nằm mãi trong
    ///    hộp thư của một cấp duyệt đã xong việc. Tự kiểm 2 lệnh (phải khớp nhau trên nhánh V2):
    ///      grep -n "Phase = ContractPhase.DaPhatHanh" src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs
    ///      grep -n "FinalizeToDaPhatHanhAsync"        src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs
    ///    (Nhánh V1 legacy `:229-244` CỐ Ý đứng ngoài — giữ nguyên từng byte cho 7 HĐ prod đang chạy.)
    ///
    /// `MaHopDong` đã có ⇒ KHÔNG gen lại (HĐ bị trả lại rồi duyệt lại giữ nguyên mã cũ).
    private async Task FinalizeToDaPhatHanhAsync(
        Contract contract,
        Guid? actorUserId,
        string? logComment,
        CancellationToken ct)
    {
        // Gen mã HĐ theo RG-001 (mirror V1 `:232-239`). FE sau khi nhận DaPhatHanh refresh MaHopDong.
        if (string.IsNullOrEmpty(contract.MaHopDong))
        {
            var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == contract.SupplierId, ct)
                ?? throw new NotFoundException("Supplier", contract.SupplierId);
            var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == contract.ProjectId, ct)
                ?? throw new NotFoundException("Project", contract.ProjectId);
            contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct);
        }
        contract.Phase = ContractPhase.DaPhatHanh;
        contract.CurrentWorkflowStepIndex = null;
        contract.CurrentApprovalLevelOrder = null;
        contract.SlaDeadline = null;
        await LogTransitionAsync(contract, ContractPhase.ChoDuyet, ContractPhase.DaPhatHanh,
            actorUserId, ApprovalDecision.Approve, logComment, ct);
    }

    private async Task LogTransitionAsync(
        Contract contract,
        ContractPhase fromPhase,
        ContractPhase toPhase,
        Guid? actorUserId,
        ApprovalDecision decision,
        string? comment,
        CancellationToken ct)
    {
        await changelog.LogWorkflowTransitionAsync(contract.Id, fromPhase, toPhase, comment);

        if (contract.DrafterUserId is Guid drafterId && drafterId != actorUserId)
        {
            var (title, type) = toPhase switch
            {
                ContractPhase.DaPhatHanh => ($"HĐ {contract.MaHopDong ?? contract.TenHopDong} đã phát hành",
                                              NotificationType.ContractPublished),
                ContractPhase.TuChoi => ($"HĐ {contract.TenHopDong ?? "của bạn"} bị từ chối",
                                          NotificationType.ContractRejected),
                ContractPhase.TraLai when fromPhase == ContractPhase.ChoDuyet =>
                    ($"HĐ {contract.TenHopDong ?? "của bạn"} bị trả lại — vui lòng sửa và trình lại",
                     NotificationType.ContractRejected),
                _ => ($"HĐ {contract.TenHopDong ?? contract.MaHopDong ?? ""} chuyển phase mới",
                      NotificationType.ContractPhaseTransition),
            };
            await notifications.NotifyAsync(
                drafterId, type, title,
                description: $"{fromPhase} → {toPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
                href: $"/contracts/{contract.Id}",
                refId: contract.Id,
                ct: ct);
        }
    }

    // [Plan B Chunk B2 S29 2026-05-22] Resolve actor full name for
    // ContractLevelOpinion.SignedByFullName denormalized field (Section 5 FE
    // display). Mirror PE PurchaseEvaluationWorkflowService.cs:774-783.
    private async Task<string> ResolveActorFullNameAsync(Guid? actorUserId, bool isSystem, CancellationToken ct)
    {
        if (isSystem || actorUserId is null) return "(System)";
        var user = await db.Users.AsNoTracking()
            .Where(u => u.Id == actorUserId.Value)
            .Select(u => new { u.FullName, u.UserName })
            .FirstOrDefaultAsync(ct);
        if (user is null) return "(unknown)";
        return !string.IsNullOrWhiteSpace(user.FullName) ? user.FullName : (user.UserName ?? "(unknown)");
    }
}
