using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.ContractSigningPlans;          // ContractSigningPlanWorkflowGuards (rào nhóm dùng chung 3 site)
using SolutionErp.Application.ContractSigningPlans.Services;
using SolutionErp.Application.Notifications;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.ContractSigningPlans;
using SolutionErp.Domain.Contracts;          // ApprovalDecision + ChangelogAction (enum dùng chung)
using SolutionErp.Domain.Identity;           // AppRoles
using SolutionErp.Domain.Notifications;      // NotificationType

namespace SolutionErp.Infrastructure.Services;

// [W3 ĐỢT-3 — S161 2026-07-29] Workflow phiếu "Kế hoạch ký kết HĐ" (GĐ2).
//
// Đợt-1 để skeleton ném NotImplementedException cho 6 test sàn ĐỎ THẬT; đợt này điền
// logic để chúng TỰ xanh. Hợp-đồng đợt-1 (4 GIỮ + 3 ADAPT + 2 CẤM) chép lại rút gọn
// bên dưới kèm ĐỊA CHỈ, và ghi rõ chỗ nào đã thi hành / chỗ nào CỐ Ý không.
//
// NGUỒN COPY: `ContractWorkflowService.ApproveV2Async` (`ContractWorkflowService.cs:217-394`).
// 🔴 CẤM copy `ProposalFeatures` — Proposal FLATTEN Level (SelectMany) nên OR-of-N biến
//    thành AND ÂM THẦM, và nó chỉ có 1 con-trỏ LevelOrder ⇒ không diễn tả được "Bước mới
//    reset Cấp về 1". Ở đây giữ đúng khuôn nguồn: `GroupBy(l => l.Order)` + `Contains(actor)`.
//
// 4 ĐIỂM GIỮ (mỗi điểm = 1 acceptance, đều đã thi hành):
//   1. Con-trỏ ĐÔI `CurrentWorkflowStepIndex` (0-based INDEX vào list Steps đã sort theo
//      Order — KHÔNG phải giá trị Order) + `CurrentApprovalLevelOrder` (1-based).
//      Sang Bước mới ⇒ Cấp reset về 1 (`AdvancePointer` bên dưới).
//   2. GroupBy Order = Cấp (khuôn `ContractWorkflowService.cs:246`).
//   3. OR-of-N: MỘT người trong Cấp duyệt là Cấp đó xong (khuôn `:255-266`) —
//      `ResolveActingLevel` khớp theo tập, KHÔNG đòi đủ chữ ký cả nhóm.
//   4. UPSERT ý kiến vào `ContractSigningPlanLevelOpinions` (khuôn `:292-316`), 1 row /
//      cặp (Plan × Level), `SignedByUserId` = người ký THẬT (Admin duyệt thay vẫn ghi Admin).
//
// 3 ĐIỂM ADAPT:
//   (a) Finalize choke-point: helper `ApplyApprovedValuesOnFinalize` gọi ở MỌI nhánh set
//       `DaDuyet` — xem khối LUẬT ngay trên helper. 🔄 [K3 S165] service NAY có **HAI** nhánh
//       set DaDuyet (đường-thường Cấp-cuối + level-finalize dưới đây), CẢ HAI qua choke-point;
//       vẫn KHÔNG có site gán-qua-BIẾN kiểu nguồn copy (`ContractWorkflowService.cs:190-193`):
//       hợp-đồng transition KHKK chỉ nhận 4 action cố định, KHÔNG có tham số `targetPhase`
//       ⇒ Admin kết thúc phiếu vẫn đi đúng đường `approve` (miễn vế "có tên trong Cấp",
//       KHÔNG miễn choke-point). Tự-kiểm 2 lệnh grep ở khối LUẬT phải cùng khớp.
//   (b) 🔄 [K3 S165 — OG-9 SUPERSEDE "PHÁ VỠ" @S155] Owner @S164 đảo phán quyết (verbatim:
//       "chỗ budget chốt chặn là a Chương… bài toán trần budget xem như đóng") ⇒ ĐÃ PORT
//       nhánh `AllowApproverFinalize` (level-finalize, OPT-OUT khuôn PE S96 — cờ seed cho
//       TP.CCM Phan Văn Chương @K3). VẪN KHÔNG port `CeoApprovalThreshold` (ngưỡng máy —
//       NĐUQ = human-gate vĩnh viễn, OG-3) và `AllowApproverSkipToFinal` giữ nguyên không port.
//   (c) Trả lại = `TraLai` TOÀN PHẦN (về người soạn, xoá con-trỏ; gửi lại chạy LẠI từ Bước 1
//       Cấp 1). 4 return-mode per-level (`ApprovalWorkflow.cs:116-129`) CỐ Ý chưa đọc —
//       mở thêm mode là đổi hành vi duyệt, thuộc quyền lead.
//
// LỊCH SỬ: mỗi transition ghi `ContractSigningPlanApprovals` + `ContractSigningPlanChangelogs`
//   qua `LogSigningPlanTransitionAsync` (method KHKK-RIÊNG, bên dưới).
// 🔴 CẤM `IChangelogService.LogWorkflowTransitionAsync(...)`: nó nhận `Guid contractId` và
//   ghi `ContractChangelogs` — bảng có FK VẬT LÝ `ContractId` Cascade
//   (`ContractChangelogConfiguration.cs:22-26`) ⇒ truyền `plan.Id` = FK violation 547 LÚC CHẠY,
//   compiler KHÔNG chặn. Rào bằng THIẾT KẾ: service CỐ Ý không inject `IChangelogService`
//   (không có sẵn thì không lỡ tay gọi) — giữ nguyên từ đợt-1.
//
// ĐIỂM LƯU (khai để lead phân xử): ĐÚNG MỘT `SaveChangesAsync` ở CUỐI `TransitionAsync`.
//   Khác nguồn copy (`ContractWorkflowService.cs:537` TransitionAsync KHÔNG tự save, caller
//   save) vì ở đây service LÀ điểm vào (controller gọi thẳng, không qua MediatR) ⇒ không có
//   "caller downstream" nào để save hộ. 1 transition = 1 unit-of-work: con-trỏ + hàng
//   Approvals + Changelogs + LevelOpinion + giá chốt + Notifications cùng sống hoặc cùng chết
//   (`NotificationService.cs:7-8` cố ý KHÔNG tự SaveChanges để caller giữ atomicity).
public class ContractSigningPlanWorkflowService(
    IApplicationDbContext db,
    ICurrentUser currentUser,
    IDateTime dateTime,
    INotificationService notifications) : IContractSigningPlanWorkflowService
{
    // Tập action khoá theo hợp-đồng literal của lead. So khớp KHÔNG phân biệt hoa/thường
    // (client gửi "Submit" vẫn hiểu), nhưng nhánh xử lý luôn chạy trên bản đã chuẩn hoá.
    private static readonly HashSet<string> AllowedActions =
        new(StringComparer.OrdinalIgnoreCase) { "submit", "approve", "return", "reject" };

    // Ý kiến rỗng → chữ thay thế (khuôn nguồn copy `ContractWorkflowService.cs:295-297`):
    // ô câm không phân biệt được "đã ký, không ý kiến" với "chưa ký".
    private const string NoOpinionPlaceholder = "(duyệt — không ý kiến)";

    // Trần độ dài ý kiến = cột CHẶT NHẤT trong các nơi chứa nguyên văn comment:
    // `ContractSigningPlanApprovals.Comment` = 1000 (`ContractSigningPlanApprovalConfiguration.cs:26`).
    // Chặn TẠI CỔNG thay vì cắt ngầm: đường này KHÔNG đi qua MediatR nên KHÔNG có
    // FluentValidation nào đỡ; để lọt thì SQL Server ném 2628 (chuỗi bị cắt) = 500 khó hiểu.
    private const int MaxCommentLength = 1000;

    public async Task<ContractSigningPlanTransitionResult> TransitionAsync(
        Guid planId,
        string action,
        string? comment,
        bool applyLevelFinalize = true,
        CancellationToken ct = default)
    {
        // Rào action đứng TRƯỚC mọi truy vấn: gõ sai tên hành động là lỗi dữ liệu gửi lên,
        // không phải "không tìm thấy phiếu" — trả 409 để FE hiện đúng nguyên nhân.
        var normalized = (action ?? string.Empty).Trim().ToLowerInvariant();
        if (!AllowedActions.Contains(normalized))
            throw new ConflictException("Hành động không hợp lệ.");

        var normalizedComment = string.IsNullOrWhiteSpace(comment) ? null : comment.Trim();
        if (normalizedComment is not null && normalizedComment.Length > MaxCommentLength)
            throw new ConflictException($"Ý kiến duyệt tối đa {MaxCommentLength} ký tự.");

        if (!currentUser.IsAuthenticated || currentUser.UserId is null)
            throw new UnauthorizedException();
        var actorId = currentUser.UserId.Value;
        var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);

        // Include Lines NGAY: nhánh finalize cần chốt giá, và nạp kèm ở đây thì phép lọc
        // "chỉ dòng của phiếu này" là do CHÍNH quan hệ cha-con bảo đảm (xem LUẬT ở
        // `ApplyApprovedValuesOnFinalize`), không phải do nhớ viết `Where(PlanId == ...)`.
        var plan = await db.ContractSigningPlans
            .Include(p => p.Lines)
            .FirstOrDefaultAsync(p => p.Id == planId, ct)
            ?? throw new NotFoundException("ContractSigningPlan", planId);

        // switch TÁCH RIÊNG từng nhánh (không gộp) — mỗi hành động có guard riêng; gộp lại
        // là mất ranh giới giữa "ai được trình" và "ai được duyệt".
        switch (normalized)
        {
            case "submit":
                await SubmitAsync(plan, actorId, isAdmin, normalizedComment, ct);
                break;

            case "approve":
                await ApproveV2Async(plan, actorId, isAdmin, normalizedComment, applyLevelFinalize, ct);
                break;

            case "return":
                await ReturnOrRejectAsync(
                    plan, actorId, isAdmin, normalizedComment, ContractSigningPlanPhase.TraLai, ct);
                break;

            case "reject":
                await ReturnOrRejectAsync(
                    plan, actorId, isAdmin, normalizedComment, ContractSigningPlanPhase.TuChoi, ct);
                break;

            default:
                // Không tới được (đã lọc ở rào trên) — giữ để switch đóng kín.
                throw new ConflictException("Hành động không hợp lệ.");
        }

        await db.SaveChangesAsync(ct);

        // Trả trạng thái SAU transition, đọc từ chính entity vừa lưu ⇒ FE vẽ lại panel không
        // cần refetch mà vẫn không thể lệch với DB.
        return new ContractSigningPlanTransitionResult(
            (int)plan.Phase, plan.CurrentWorkflowStepIndex, plan.CurrentApprovalLevelOrder);
    }

    // =====================================================================
    // submit — người soạn trình phiếu
    // =====================================================================
    private async Task SubmitAsync(
        ContractSigningPlan plan, Guid actorId, bool isAdmin, string? comment, CancellationToken ct)
    {
        if (plan.Phase is not (ContractSigningPlanPhase.DangSoanThao or ContractSigningPlanPhase.TraLai))
            throw new ConflictException("Chỉ trình duyệt được phiếu đang ở trạng thái Nháp hoặc Trả lại.");

        // Nói RÕ nguyên nhân: phiếu chưa gắn quy trình thì không có Bước/Cấp nào để chờ,
        // trình đại = phiếu treo ở "Đã gửi duyệt" mà không nằm trong hộp thư của ai.
        if (plan.ApprovalWorkflowId is not Guid awId)
            throw new ConflictException(
                "Phiếu chưa gắn quy trình duyệt — chọn quy trình trong phiếu nháp trước khi trình.");

        await EnsureCanSubmitAsync(plan, actorId, isAdmin, ct);

        // ── [K2 S164 — vá-5 spec-cụm1, THẮNG nền K2.d vế null] Rào HẠNG MỤC trước khi trình:
        // (i) mọi dòng phải đã gán hạng mục — auto-gen mồi CatalogEntryId=null chỉ hợp lệ trong
        //     nháp; nền `:126` định CHO-QUA null-legacy nhưng vá-5 chốt CHẶN cho NHẤT QUÁN.
        //     Ghost-window K2→K4b khai chủ-đích: phiếu cũ bị Trả-lại sẽ kẹt trình tới khi
        //     Lines-editor (K4b) land — message chỉ rõ việc cần làm.
        // (ii) hạng mục phải CÙNG nhóm duyệt với phiếu (code mẫu nền K2.d) — chốt CUỐI chống
        //     data cũ/lách API; validator đường-gán K4b là fail-fast tầng trước.
        if (plan.Lines.Any(l => l.CatalogEntryId == null))
            throw new ConflictException(
                "Còn dòng chưa gán hạng mục công việc (danh mục SP-002) — gán hạng mục cho mọi dòng trước khi trình.");
        // [gate-K2 F-1] Hỏi câu ĐÚNG: "MỌI id resolve tới hạng mục CÒN SỐNG + ĐÚNG nhóm" — không
        // phải "có id nào SAI nhóm". Query filter !IsDeleted nuốt hạng mục xoá mềm ⇒ Any(sai nhóm)
        // = false cho cả Guid rác lẫn hạng mục đã xoá (vắng-mặt đọc thành sạch). Đếm-so-khớp bịt cả hai.
        var catalogIds = plan.Lines.Select(l => l.CatalogEntryId!.Value).Distinct().ToList();
        var okCount = await db.ContractCatalogEntries
            .CountAsync(c => catalogIds.Contains(c.Id) && c.ApprovalGroup == plan.ApprovalGroup, ct);
        if (okCount != catalogIds.Count)
            throw new ConflictException(
                $"Có hạng mục không hợp lệ (đã xoá / không tồn tại) hoặc không thuộc nhóm duyệt của phiếu — mọi dòng phải cùng nhóm N{plan.ApprovalGroup}.");

        // ── [S182 W1b] Rào NHÓM ⟷ QUY TRÌNH, site 3/3 (2 site kia ở đường tạo/sửa nháp).
        // Cửa này là chốt CUỐI và là cửa DUY NHẤT bắt được phiếu pin lệch có TỪ TRƯỚC: hai
        // rào kia chỉ chặn lúc GHI, nên phiếu tạo trước khi có rào (hoặc trước khi admin đổi
        // mã quy trình) vẫn nằm sẵn trong DB với nhóm lệch. Đứng ngay trên `LoadStepsAsync`
        // để phiếu lệch không kịp bước vào ChoDuyet rồi mới lộ.
        // Thân luật + lý do "cho qua code ngoài họ": `ContractSigningPlanWorkflowGuards`.
        await ContractSigningPlanWorkflowGuards.EnsureWorkflowGroupMatchAsync(
            db, awId, plan.ApprovalGroup, ct);

        var steps = await LoadStepsAsync(awId, ct);
        var firstLevelGroup = steps[0].Levels.Where(l => l.Order == 1).ToList();
        if (firstLevelGroup.Count == 0)
            throw new ConflictException(
                $"Quy trình duyệt chưa cấu hình Cấp 1 cho Bước 1 ({steps[0].Name}) — chưa trình được.");

        var fromPhase = plan.Phase;
        plan.Phase = ContractSigningPlanPhase.ChoDuyet;

        // 🔴 RESET về đầu quy trình cho CẢ hai lối vào (Nháp lần đầu VÀ Trả lại gửi lại):
        // nối tiếp chỗ đang dở thì bản sửa của người soạn không được các Bước trước xem lại
        // lần nào (acceptance #6).
        plan.CurrentWorkflowStepIndex = 0;
        plan.CurrentApprovalLevelOrder = 1;

        await LogSigningPlanTransitionAsync(
            plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Pending, comment,
            actingLevelId: null,
            summary: fromPhase == ContractSigningPlanPhase.TraLai
                ? "Trình duyệt lại kế hoạch ký kết (chạy lại từ Bước 1 Cấp 1)"
                : "Trình duyệt kế hoạch ký kết",
            ct: ct);

        await NotifyPendingApproversAsync(plan, firstLevelGroup, actorId, ct);
    }

    /// Guard trình — CỐ Ý khác khuôn role-based của Contract (né lớp 403-PMH): phiếu là của
    /// người soạn + phòng của người soạn, không phải của một vai trong quy trình.
    /// 🔴 KHAI RÕ nới 1 vế so với chữ của spec ("CreatedBy == actor"): chấp nhận CẢ
    /// `DrafterUserId == actor`. Hai cột này chỉ đường tới CÙNG một người ở đường tạo phiếu
    /// W2 (`ContractSigningPlanFeatures.cs:372` gán `DrafterUserId = actorId`, còn `CreatedBy`
    /// do `AuditingInterceptor` ghi từ cùng `ICurrentUser`) ⇒ nới KHÔNG mở thêm ai. Nhưng
    /// phiếu sinh ngoài đường CQRS (seed/import chạy dưới danh nghĩa hệ thống) sẽ có
    /// `CreatedBy = null` — đọc mỗi cột đó thì chính người soạn bị chặn trình phiếu của mình.
    private async Task EnsureCanSubmitAsync(
        ContractSigningPlan plan, Guid actorId, bool isAdmin, CancellationToken ct)
    {
        if (isAdmin) return;
        if (plan.CreatedBy == actorId || plan.DrafterUserId == actorId) return;

        // Trưởng phòng CÙNG phòng ban CỦA PHIẾU. So với `plan.DepartmentId` (phòng của phiếu),
        // KHÔNG phải `Step.DepartmentId` — cột kia chỉ là hint của quy trình
        // (`ContractSigningPlan.cs:26-28`).
        if (currentUser.Roles.Contains(AppRoles.DeptManager) && plan.DepartmentId is Guid deptId)
        {
            var sameDept = await db.Users.AsNoTracking()
                .AnyAsync(u => u.Id == actorId && u.DepartmentId == deptId, ct);
            if (sameDept) return;
        }

        throw new ForbiddenException(
            "Chỉ người soạn kế hoạch, trưởng phòng cùng phòng ban của phiếu hoặc Admin được trình duyệt.");
    }

    // =====================================================================
    // approve — approver Cấp đang chờ duyệt (OR-of-N)
    // =====================================================================
    private async Task ApproveV2Async(
        ContractSigningPlan plan, Guid actorId, bool isAdmin, string? comment,
        bool applyLevelFinalize, CancellationToken ct)
    {
        if (plan.Phase != ContractSigningPlanPhase.ChoDuyet)
            throw new ConflictException("Chỉ duyệt được phiếu đang ở trạng thái Đã gửi duyệt.");
        if (plan.ApprovalWorkflowId is not Guid awId)
            throw new ConflictException("Phiếu chưa gắn quy trình duyệt.");

        var steps = await LoadStepsAsync(awId, ct);
        var (currentIdx, currentStep, currentLevelOrder, levelGroups, maxLevelOrder) =
            ResolvePointer(plan, steps);

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

        var actingLevel = ResolveActingLevel(
            pendingLevelGroup, currentStep, currentIdx, currentLevelOrder, actorId, isAdmin);

        // Ý kiến sync ngay lúc duyệt (Q1=1B — KHÔNG endpoint ý kiến rời).
        await UpsertLevelOpinionAsync(plan, actingLevel, actorId, comment, ct);

        var fromPhase = plan.Phase;

        // ---- [K3 S165 — OG-3/OG-9 port level-finalize, mirror PE `:866-876`] Cấp có cờ
        // `AllowApproverFinalize` (seed K3: TP.CCM Phan Văn Chương, Bước 2 Cấp 2) được KẾT THÚC
        // quy trình TẠI ĐÂY thay vì trình tiếp CEO. OPT-OUT khuôn PE S96: client gửi
        // `applyLevelFinalize=false` (người duyệt BỎ tick "Cấp này KẾT THÚC") ⇒ rơi xuống
        // advance thường. Supersede "PHÁ VỠ" @S155 theo lời owner @S164 (xem 3-ĐIỂM-ADAPT (b)).
        // Cờ RUNTIME `EndedByLevelFinalize` = Mig 71 K2 đẻ — K3 tiêu thụ, HẾT ghost-window.
        if (actingLevel.AllowApproverFinalize && applyLevelFinalize)
        {
            plan.EndedByLevelFinalize = true;
            ApplyApprovedValuesOnFinalize(plan);  // LUẬT choke-point: TRƯỚC khi gán Phase
            plan.Phase = ContractSigningPlanPhase.DaDuyet;
            plan.CurrentWorkflowStepIndex = null;
            plan.CurrentApprovalLevelOrder = null;
            await LogSigningPlanTransitionAsync(
                plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Approve, comment,
                actingLevel.Id,
                $"Duyệt Bước {currentIdx + 1} Cấp {currentLevelOrder} — KẾT THÚC TẠI CẤP (level-finalize), kế hoạch ĐÃ DUYỆT, chốt giá",
                ct);
            return;
        }

        // ---- Advance con-trỏ ĐÔI: còn Cấp trong Bước → Cấp++; hết Cấp → Bước kế (Cấp 1);
        //      hết Bước → FINALIZE.
        if (currentLevelOrder < maxLevelOrder)
        {
            plan.CurrentApprovalLevelOrder = currentLevelOrder + 1;
            await LogSigningPlanTransitionAsync(
                plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Approve, comment,
                actingLevel.Id,
                $"Duyệt Bước {currentIdx + 1} Cấp {currentLevelOrder} — chuyển Cấp {currentLevelOrder + 1}",
                ct);
            await NotifyPendingApproversAsync(
                plan, LevelsOf(currentStep, currentLevelOrder + 1), actorId, ct);
            return;
        }

        var nextIdx = currentIdx + 1;
        if (nextIdx < steps.Count)
        {
            plan.CurrentWorkflowStepIndex = nextIdx;
            plan.CurrentApprovalLevelOrder = 1;   // 🔴 Bước mới ⇒ Cấp RESET về 1
            await LogSigningPlanTransitionAsync(
                plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Approve, comment,
                actingLevel.Id,
                $"Hoàn tất Bước {currentIdx + 1}/{steps.Count} — chuyển Bước {nextIdx + 1} (Cấp 1)",
                ct);
            await NotifyPendingApproversAsync(plan, LevelsOf(steps[nextIdx], 1), actorId, ct);
            return;
        }

        // ---- Cấp cuối của Bước cuối ⇒ terminal.
        ApplyApprovedValuesOnFinalize(plan);
        plan.Phase = ContractSigningPlanPhase.DaDuyet;
        plan.CurrentWorkflowStepIndex = null;     // terminal ⇒ không nằm trong hộp thư ai nữa
        plan.CurrentApprovalLevelOrder = null;
        await LogSigningPlanTransitionAsync(
            plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Approve, comment,
            actingLevel.Id,
            $"Duyệt Cấp cuối (Bước {currentIdx + 1}/{steps.Count}) — kế hoạch ký kết ĐÃ DUYỆT, chốt giá",
            ct);
    }

    /// 🔴 LUẬT (không đếm số — số-đếm tự lão-hoá, bài `PurchaseEvaluationWorkflowService.cs:1008`):
    /// MỌI nhánh đưa phiếu sang `DaDuyet` BẮT BUỘC gọi helper này TRƯỚC khi gán Phase.
    /// Bỏ sót một nhánh = phiếu "Đã duyệt" mà `ApprovedAmount` rỗng ⇒ GĐ3 sinh hợp đồng 0đ.
    /// Tự kiểm 2 lệnh (chạy cả hai, cùng phải khớp nhau):
    ///   grep -rn "Phase = ContractSigningPlanPhase.DaDuyet" src/Backend --include=*.cs
    ///   grep -rn "ApplyApprovedValuesOnFinalize" src/Backend --include=*.cs
    ///
    /// Vế "CHỈ dòng của phiếu này" (acceptance `AND PlanId = @id`) do QUAN HỆ bảo đảm:
    /// `plan.Lines` nạp bằng `Include` trên đúng một phiếu, nên không có đường nào chạm dòng
    /// của phiếu hàng xóm — cố ý KHÔNG viết `db.ContractSigningPlanLines.Where(...)` để không
    /// tồn tại chỗ cho phép quên vế lọc.
    ///
    /// `??=` — GIỮ giá trị đã chốt, KHÔNG đè: số do người có thẩm quyền sửa tay phải thắng.
    private static void ApplyApprovedValuesOnFinalize(ContractSigningPlan plan)
    {
        foreach (var line in plan.Lines)
            line.ApprovedAmount ??= line.ProposedAmount;
    }

    // =====================================================================
    // return / reject — trả lại người soạn hoặc từ chối hẳn
    // =====================================================================
    private async Task ReturnOrRejectAsync(
        ContractSigningPlan plan,
        Guid actorId,
        bool isAdmin,
        string? comment,
        ContractSigningPlanPhase targetPhase,
        CancellationToken ct)
    {
        var isReturn = targetPhase == ContractSigningPlanPhase.TraLai;

        if (plan.Phase != ContractSigningPlanPhase.ChoDuyet)
            throw new ConflictException(isReturn
                ? "Chỉ trả lại được phiếu đang ở trạng thái Đã gửi duyệt."
                : "Chỉ từ chối được phiếu đang ở trạng thái Đã gửi duyệt.");
        if (plan.ApprovalWorkflowId is not Guid awId)
            throw new ConflictException("Phiếu chưa gắn quy trình duyệt.");

        var steps = await LoadStepsAsync(awId, ct);
        var (currentIdx, currentStep, currentLevelOrder, levelGroups, _) = ResolvePointer(plan, steps);

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

        // Cùng vế quyền với `approve`: chỉ NV của Cấp ĐANG CHỜ (OR-of-N) hoặc Admin.
        // Rào bằng CHÍNH hàm của approve ⇒ không thể lệch nhau về sau.
        var actingLevel = ResolveActingLevel(
            pendingLevelGroup, currentStep, currentIdx, currentLevelOrder, actorId, isAdmin);

        // 🔴 KHÔNG UPSERT LevelOpinion ở đây: bảng ý kiến là chữ ký DUYỆT
        // (`ContractSigningPlanLevelOpinion.cs:11` "Reject (TraLai/TuChoi) KHÔNG sync").
        // Lý do trả lại đã nằm ở Approvals + Changelogs ngay dưới.
        var fromPhase = plan.Phase;
        plan.Phase = targetPhase;

        // Trả lại TOÀN PHẦN / từ chối: xoá con-trỏ. Để sót thì phiếu vừa nằm ở tay người soạn
        // vừa nằm trong hộp thư người duyệt (`ResolveV2InboxIdsAsync` lọc theo con-trỏ).
        plan.CurrentWorkflowStepIndex = null;
        plan.CurrentApprovalLevelOrder = null;

        await LogSigningPlanTransitionAsync(
            plan, fromPhase, targetPhase, actorId, ApprovalDecision.Reject, comment,
            actingLevel.Id,
            isReturn
                ? $"Trả lại người soạn tại Bước {currentIdx + 1} Cấp {currentLevelOrder}"
                : $"Từ chối tại Bước {currentIdx + 1} Cấp {currentLevelOrder}",
            ct);
    }

    // =====================================================================
    // Helpers dùng chung
    // =====================================================================

    /// Nạp cây quy trình đã SORT sẵn (Bước theo Order, Cấp theo Order) — mọi nhánh đọc CÙNG
    /// một thứ tự, nên INDEX con-trỏ luôn hiểu như nhau (khuôn `ContractWorkflowService.cs:228-236`).
    private async Task<List<ApprovalWorkflowStep>> LoadStepsAsync(Guid awId, 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($"Quy trình duyệt {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 duyệt chưa có Bước nào.");
        return steps;
    }

    /// Đọc con-trỏ ĐÔI + kiểm biên. 🔴 `CurrentWorkflowStepIndex` là INDEX vào list đã sort,
    /// KHÔNG phải `Step.Order` — đọc nhầm thì phiếu nhảy sang Bước của người khác trong im lặng
    /// (W2 đã chốt semantic INDEX ở `ContractSigningPlanFeatures.cs:256-260`).
    private static (int Idx,
                    ApprovalWorkflowStep Step,
                    int LevelOrder,
                    List<IGrouping<int, ApprovalWorkflowLevel>> LevelGroups,
                    int MaxLevelOrder)
        ResolvePointer(ContractSigningPlan plan, List<ApprovalWorkflowStep> steps)
    {
        var idx = plan.CurrentWorkflowStepIndex ?? 0;
        if (idx < 0 || idx >= steps.Count)
            throw new ConflictException(
                $"CurrentWorkflowStepIndex={idx} không hợp lệ (max={steps.Count - 1}).");

        var step = steps[idx];
        var levelOrder = plan.CurrentApprovalLevelOrder ?? 1;

        // Cấp = nhóm Level CÙNG Order (khuôn `ContractWorkflowService.cs:246`). Giữ GroupBy —
        // flatten kiểu Proposal sẽ biến OR-of-N thành AND mà build vẫn sạch.
        var levelGroups = step.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
        var maxLevelOrder = levelGroups.Count == 0 ? 0 : levelGroups.Max(g => g.Key);
        if (levelOrder < 1 || levelOrder > maxLevelOrder)
            throw new ConflictException(
                $"CurrentApprovalLevelOrder={levelOrder} không hợp lệ (max={maxLevelOrder}).");

        return (idx, step, levelOrder, levelGroups, maxLevelOrder);
    }

    /// OR-of-N (khuôn `ContractWorkflowService.cs:255-266`): BẤT KỲ ai trong Cấp đang chờ là
    /// duyệt được — trả về ĐÚNG row Level của người ký để ý kiến treo dưới đúng tên.
    /// Admin duyệt thay: miễn vế "có tên trong Cấp", ý kiến rơi vào row đầu Cấp và
    /// `SignedByUserId` vẫn là Admin ⇒ FE phân biệt được (`SignedByUserId != ApproverUserId`).
    private static ApprovalWorkflowLevel ResolveActingLevel(
        IGrouping<int, ApprovalWorkflowLevel> pendingLevelGroup,
        ApprovalWorkflowStep step,
        int stepIdx,
        int levelOrder,
        Guid actorId,
        bool isAdmin)
    {
        var own = pendingLevelGroup.FirstOrDefault(l => l.ApproverUserId == actorId);
        if (own is not null) return own;
        if (isAdmin) return pendingLevelGroup.First();

        throw new ForbiddenException(
            $"Bước {stepIdx + 1} ({step.Name}) — Cấp {levelOrder}: bạn không có trong danh sách NV duyệt của cấp này.");
    }

    private static List<ApprovalWorkflowLevel> LevelsOf(ApprovalWorkflowStep step, int levelOrder)
        => step.Levels.Where(l => l.Order == levelOrder).ToList();

    /// UPSERT 1 row / cặp (Plan × Level) — UNIQUE `(PlanId, LevelId)`
    /// (`ContractSigningPlanLevelOpinionConfiguration.cs:36`). Duyệt lại cùng Cấp (sau khi bị
    /// trả lại) = CẬP NHẬT dòng cũ; `Add` thẳng sẽ nổ UNIQUE hoặc đẻ dòng thứ hai.
    private async Task UpsertLevelOpinionAsync(
        ContractSigningPlan plan,
        ApprovalWorkflowLevel actingLevel,
        Guid actorId,
        string? comment,
        CancellationToken ct)
    {
        var text = string.IsNullOrWhiteSpace(comment) ? NoOpinionPlaceholder : comment;
        var signedByFullName = await ResolveActorFullNameAsync(actorId, ct);
        var now = dateTime.UtcNow;

        var existing = await db.ContractSigningPlanLevelOpinions
            .FirstOrDefaultAsync(o => o.ContractSigningPlanId == plan.Id
                && o.ApprovalWorkflowLevelId == actingLevel.Id, ct);

        if (existing is null)
        {
            db.ContractSigningPlanLevelOpinions.Add(new ContractSigningPlanLevelOpinion
            {
                ContractSigningPlanId = plan.Id,
                ApprovalWorkflowLevelId = actingLevel.Id,
                Comment = text,
                SignedAt = now,
                SignedByUserId = actorId,
                SignedByFullName = signedByFullName,
            });
        }
        else
        {
            // latest-write-wins TRỌN BỘ 4 field: giữ lại chữ/người/giờ cũ = hồ sơ nói sai
            // sự thật về lần ký mới.
            existing.Comment = text;
            existing.SignedAt = now;
            existing.SignedByUserId = actorId;
            existing.SignedByFullName = signedByFullName;
        }
    }

    /// Method ghi lịch sử KHKK-RIÊNG (spec §②-4). Ghi CẢ HAI bảng trong cùng unit-of-work:
    ///   • `ContractSigningPlanApprovals`  — 1 row / transition (ai, Cấp nào, quyết định gì)
    ///   • `ContractSigningPlanChangelogs` — 1 row / transition (nhật ký đọc-được cho người dùng)
    /// 🔴 KHÔNG gọi `IChangelogService.LogWorkflowTransitionAsync` — xem lý do FK-547 đầu file.
    /// Kèm notify NGƯỜI SOẠN (mirror `ContractWorkflowService.cs:407-427`); notify approver
    /// Cấp kế nằm riêng ở `NotifyPendingApproversAsync` (vế THÊM của spec §②-6).
    private async Task LogSigningPlanTransitionAsync(
        ContractSigningPlan plan,
        ContractSigningPlanPhase fromPhase,
        ContractSigningPlanPhase toPhase,
        Guid actorId,
        ApprovalDecision decision,
        string? comment,
        Guid? actingLevelId,
        string summary,
        CancellationToken ct)
    {
        var now = dateTime.UtcNow;

        db.ContractSigningPlanApprovals.Add(new ContractSigningPlanApproval
        {
            ContractSigningPlanId = plan.Id,
            FromPhase = fromPhase,
            ToPhase = toPhase,
            Decision = decision,
            Comment = comment,
            ApprovalWorkflowLevelId = actingLevelId,
            ApprovedByUserId = actorId,
            ApprovedAt = now,
        });

        db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
        {
            ContractSigningPlanId = plan.Id,
            EntityType = ContractSigningPlanEntityType.Workflow,
            EntityId = actingLevelId,
            Action = ChangelogAction.Transition,
            PhaseAtChange = toPhase,
            UserId = actorId,
            UserName = Truncate(await ResolveActorFullNameAsync(actorId, ct), 200),
            Summary = Truncate(summary, 1000),
            ContextNote = comment,
        });

        // Notify NGƯỜI SOẠN — trừ khi chính họ vừa bấm (khỏi tự báo cho mình).
        if (plan.DrafterUserId != Guid.Empty && plan.DrafterUserId != actorId)
        {
            var label = plan.MaKeHoach ?? "Kế hoạch ký kết";
            var (title, type) = toPhase switch
            {
                ContractSigningPlanPhase.DaDuyet =>
                    ($"Kế hoạch ký kết {label} đã được duyệt", NotificationType.ContractPublished),
                ContractSigningPlanPhase.TuChoi =>
                    ($"Kế hoạch ký kết {label} bị từ chối", NotificationType.ContractRejected),
                ContractSigningPlanPhase.TraLai =>
                    ($"Kế hoạch ký kết {label} bị trả lại — vui lòng sửa và trình lại",
                     NotificationType.ContractRejected),
                _ => ($"Kế hoạch ký kết {label} chuyển trạng thái", NotificationType.ContractPhaseTransition),
            };
            await notifications.NotifyAsync(
                plan.DrafterUserId, type, title,
                description: summary + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
                href: $"/khkk/{plan.Id}",
                refId: plan.Id,
                ct: ct);
        }
    }

    /// Vế THÊM của spec §②-6 — báo ĐÍCH DANH NV của Cấp đang chờ (khuôn đã chạy thật ở PE
    /// `PurchaseEvaluationWorkflowService.cs:1243-1289`), vá lỗ "chỉ người soạn được báo".
    /// Bỏ qua: chính người vừa bấm, và NGƯỜI SOẠN — họ đã thấy phiếu ở màn "phiếu của tôi"
    /// và nhận chuông KẾT QUẢ ở trên (bài UAT S86).
    private async Task NotifyPendingApproversAsync(
        ContractSigningPlan plan,
        IEnumerable<ApprovalWorkflowLevel> pendingLevels,
        Guid actorId,
        CancellationToken ct)
    {
        var approverIds = pendingLevels
            .Select(l => l.ApproverUserId)
            .Where(id => id != Guid.Empty && id != actorId && id != plan.DrafterUserId)
            .Distinct()
            .ToList();
        if (approverIds.Count == 0) return;

        await notifications.NotifyManyAsync(
            approverIds,
            NotificationType.Generic,
            $"Kế hoạch ký kết cần bạn duyệt: {plan.MaKeHoach ?? "(chưa có mã)"}",
            "Có kế hoạch ký kết HĐ đang chờ bạn duyệt.",
            $"/khkk/{plan.Id}",
            plan.Id,
            ct);
    }

    /// Snapshot tên người ký (`SignedByFullName` là cột NOT NULL 200 ký tự —
    /// `ContractSigningPlanLevelOpinionConfiguration.cs:24`). Khuôn
    /// `ContractWorkflowService.cs:433-442`; thêm vế cuối lấy từ token vì service này lấy
    /// actor qua `ICurrentUser` chứ không qua `UserManager`.
    private async Task<string> ResolveActorFullNameAsync(Guid actorId, CancellationToken ct)
    {
        var user = await db.Users.AsNoTracking()
            .Where(u => u.Id == actorId)
            .Select(u => new { u.FullName, u.UserName })
            .FirstOrDefaultAsync(ct);

        if (user is not null && !string.IsNullOrWhiteSpace(user.FullName)) return Truncate(user.FullName, 200);
        if (user is not null && !string.IsNullOrWhiteSpace(user.UserName)) return Truncate(user.UserName, 200);
        if (!string.IsNullOrWhiteSpace(currentUser.FullName)) return Truncate(currentUser.FullName!, 200);
        if (!string.IsNullOrWhiteSpace(currentUser.Email)) return Truncate(currentUser.Email!, 200);
        return "(unknown)";
    }

    /// Cắt AN TOÀN cho các chuỗi service TỰ DỰNG (tên Bước do admin đặt có thể dài).
    /// Ý kiến của người dùng KHÔNG đi qua đây — chuỗi đó bị chặn ở cổng
    /// (`MaxCommentLength`) để không bao giờ bị cắt ngầm mất chữ.
    private static string Truncate(string value, int max)
        => value.Length <= max ? value : value[..max];
}
