<?php
namespace App\Controllers;

use App\Services\DealService;
use App\Services\VirtualStageService;
use App\Repositories\PersonRepository;
use App\Repositories\DealRepository;
use App\Services\AuthService;
use App\Services\AuditService;
use App\Utils\Sanitizer;
use App\Utils\Logger;

class DealController extends ApiController
{
    private $dealService;
    private $virtualStageService;
    private $personRepo;
    private $dealRepo;
    private $authService;
    private $auditService;
    private $db;

    private function canRbac(string $capabilityKey): bool
    {
        global $app;
        if (isset($app->services->rbac) && $app->services->rbac) {
            return (bool)$app->services->rbac->can($capabilityKey);
        }
        return false;
    }

    private function requireRbac(string $capabilityKey, string $message = 'دسترسی محدود'): void
    {
        if (!$this->canRbac($capabilityKey)) {
            $this->errorResponse($message, 403);
        }
    }

    private function getRbacScopeForResource(string $resourceKey): ?array
    {
        global $app;
        if (!isset($app->services->rbac) || !$app->services->rbac) {
            return null;
        }
        return (array)$app->services->rbac->getDataScope($resourceKey);
    }

    private function mapSelectedUserIdsToDidarIds(array $selectedUserIds): array
    {
        global $app;
        if (!isset($app->repositories->user) || !$app->repositories->user) {
            return [];
        }
        $didarIds = [];
        foreach ($selectedUserIds as $uid) {
            $userId = (int)$uid;
            if ($userId <= 0) continue;
            try {
                $user = $app->repositories->user->findById($userId);
                $didar = trim((string)($user->didar_user_id ?? ''));
                if ($didar !== '') {
                    $didarIds[$didar] = true;
                }
            } catch (\Throwable $e) {
                // Ignore invalid selected users.
            }
        }
        return array_keys($didarIds);
    }

    private function applyOwnerScopeToFilters(array &$filters, string $resourceKey, string $requestedOwnerDidarId, string $selfDidarId): void
    {
        global $app;
        if (isset($app->services->rbac) && $app->services->rbac) {
            $filters = $app->services->rbac->applyOwnerScope($filters, $resourceKey, $requestedOwnerDidarId);
        }
    }

    private function assertDealInScope($deal): void
    {
        $ownerDidarId = trim((string)($deal->owner_didar_id ?? ''));
        global $app;
        if (!isset($app->services->rbac) || !$app->services->rbac || !$app->services->rbac->isOwnerInScope('deals', $ownerDidarId)) {
            $this->errorResponse('دسترسی به این معامله در محدوده مجاز شما نیست', 403);
        }
    }

    private function assertOwnerInDealScopeByDidar(string $ownerDidarId): void
    {
        global $app;
        if (!isset($app->services->rbac) || !$app->services->rbac || !$app->services->rbac->isOwnerInScope('deals', $ownerDidarId)) {
            $this->errorResponse('دسترسی به این معامله در محدوده مجاز شما نیست', 403);
        }
    }

    private function normalizePaymentForPermissionCompare(array $payment): array
    {
        return [
            'amount' => (float)($payment['amount'] ?? 0),
            'date' => trim((string)($payment['date'] ?? '')),
            'date_persian' => trim((string)($payment['date_persian'] ?? '')),
            'method' => trim((string)($payment['method'] ?? $payment['payment_method'] ?? '')),
            'description' => trim((string)($payment['description'] ?? '')),
            'card_last4' => trim((string)($payment['card_last4'] ?? $payment['card_last_4'] ?? '')),
            'destination_bank' => trim((string)($payment['destination_bank'] ?? $payment['bank_destination'] ?? '')),
            'tracking_code_last4' => trim((string)($payment['tracking_code_last4'] ?? $payment['tracking_code'] ?? '')),
            'tracking_code' => trim((string)($payment['tracking_code'] ?? '')),
            'number' => trim((string)($payment['number'] ?? '')),
            'check_number' => trim((string)($payment['check_number'] ?? '')),
            'check_bank_name' => trim((string)($payment['check_bank_name'] ?? '')),
            'check_owner_name' => trim((string)($payment['check_owner_name'] ?? '')),
        ];
    }

    private function hasPositivePayments(array $payments): bool
    {
        foreach ($payments as $payment) {
            if (!empty($payment['deleted']) || !empty($payment['is_deleted'])) {
                continue;
            }
            if ((float)($payment['amount'] ?? 0) > 0) {
                return true;
            }
        }
        return false;
    }

    private function getPaidDealAutoPipelineStage(float $payableAmount, float $paymentAmount): string
    {
        if ($paymentAmount <= 0) {
            return '';
        }
        if ($payableAmount > 0 && $paymentAmount >= $payableAmount) {
            return 'پرداخت';
        }
        return 'پیگیری مانده حساب';
    }

    private function moveContactOutOfKarizOnSuccessfulDeal(string $contactId, string $dealId = ''): void
    {
        if ($contactId === '') {
            return;
        }

        try {
            $this->virtualStageService->update($contactId, 'with_purchase', true);
            Logger::logInfo('Moved contact out of kariz after successful deal', [
                'contact_id' => $contactId,
                'deal_id' => $dealId,
                'target_stage' => 'with_purchase',
            ]);
        } catch (\Throwable $e) {
            Logger::logWarning('Failed to move contact out of kariz after successful deal', [
                'contact_id' => $contactId,
                'deal_id' => $dealId,
                'error' => $e->getMessage(),
            ]);
        }
    }

    private function syncSuccessfulDealSideEffects(string $dealId, string $contactId): void
    {
        if ($contactId === '') {
            return;
        }

        try {
            $this->dealService->updatePipelineStage($dealId, 'اتمام');
        } catch (\Throwable $e) {
            Logger::logWarning('Failed to sync pipeline_stage for successful deal', [
                'deal_id' => $dealId,
                'contact_id' => $contactId,
                'error' => $e->getMessage(),
            ]);
        }

        try {
            $person = $this->personRepo->findByDidarId($contactId);
            if ($person) {
                $this->personRepo->updateSaleStatus($contactId, 1);
            }
        } catch (\Throwable $e) {
            Logger::logWarning('Failed to sync sale_status for successful deal', [
                'deal_id' => $dealId,
                'contact_id' => $contactId,
                'error' => $e->getMessage(),
            ]);
        }

        try {
            $products = $this->dealRepo->getProductsByDealDidarId($dealId);
            $productIds = array_values(array_filter(array_map(static function ($p) {
                return $p['product_id'] ?? null;
            }, $products)));
            if (!empty($productIds)) {
                $this->personRepo->appendProducts($contactId, $productIds);
            }
        } catch (\Throwable $e) {
            Logger::logWarning('Failed to sync successful deal products', [
                'deal_id' => $dealId,
                'contact_id' => $contactId,
                'error' => $e->getMessage(),
            ]);
        }

        $this->moveContactOutOfKarizOnSuccessfulDeal($contactId, $dealId);
    }

    private function syncFailedDealSideEffects(string $dealId, string $contactId): void
    {
        if ($contactId === '') {
            return;
        }

        try {
            $this->dealService->updatePipelineStage($dealId, 'اتمام');
        } catch (\Throwable $e) {
            Logger::logWarning('Failed to sync pipeline_stage for failed deal', [
                'deal_id' => $dealId,
                'contact_id' => $contactId,
                'error' => $e->getMessage(),
            ]);
        }

        try {
            $this->virtualStageService->update($contactId, 'without_purchase', true);
            Logger::logInfo('Moved contact out of kariz after failed deal', [
                'contact_id' => $contactId,
                'deal_id' => $dealId,
                'target_stage' => 'without_purchase',
            ]);
        } catch (\Throwable $e) {
            Logger::logWarning('Failed to move contact out of kariz after failed deal', [
                'contact_id' => $contactId,
                'deal_id' => $dealId,
                'error' => $e->getMessage(),
            ]);
        }
    }

    private function calculateOutstandingFromDeal($deal): float
    {
        if (!$deal) {
            return 0.0;
        }

        $payableAmount = (float)($deal->payable_amount ?? 0);
        $paidAmount = 0.0;

        $payments = json_decode((string)($deal->payments ?? ''), true);
        if (is_array($payments)) {
            foreach ($payments as $payment) {
                $paidAmount += (float)($payment['amount'] ?? 0);
            }
        } else {
            $paidAmount = (float)($deal->payment_amount ?? 0);
        }

        $outstanding = $payableAmount - $paidAmount;
        return $outstanding > 0 ? $outstanding : 0.0;
    }

    private function calculatePaymentTotalFromDeal($deal): float
    {
        if (!$deal) {
            return 0.0;
        }

        $paidAmount = 0.0;
        $payments = json_decode((string)($deal->payments ?? ''), true);
        if (is_array($payments)) {
            foreach ($payments as $payment) {
                $paidAmount += (float)($payment['amount'] ?? 0);
            }
            return $paidAmount;
        }

        return (float)($deal->payment_amount ?? 0);
    }

    private function getSuccessRequiredPaymentAmount($deal): float
    {
        if (!$deal) {
            return 0.0;
        }

        if (property_exists($deal, 'payable_amount') && $deal->payable_amount !== null) {
            return max(0.0, (float)$deal->payable_amount);
        }

        return max(0.0, (float)($deal->service_cost ?? 0));
    }

    private function assertDealCanBeMarkedWon($deal): void
    {
        if (!$deal) {
            $this->errorResponse('معامله یافت نشد');
        }

        $requiredAmount = $this->getSuccessRequiredPaymentAmount($deal);
        $paidAmount = $this->calculatePaymentTotalFromDeal($deal);
        if ($requiredAmount > 0 && ($paidAmount + 0.0001) < $requiredAmount) {
            $this->errorResponse('برای موفق شدن معامله، مجموع پرداخت‌ها باید حداقل برابر مبلغ قابل پرداخت باشد');
        }
    }

    private function resolveAllowedServicePricing(string $serviceTitle): array
    {
        $serviceTitle = trim($serviceTitle);
        if ($serviceTitle === '') {
            return ['service_cost' => null, 'price_list_code' => ''];
        }

        try {
            $items = $this->dealService->getLookupItems('allowed_services', true);
        } catch (\Throwable $e) {
            $items = [];
        }

        foreach ((array)$items as $item) {
            $value = trim((string)($item['value'] ?? $item['title'] ?? ''));
            $title = trim((string)($item['title'] ?? $item['value'] ?? ''));
            if ($serviceTitle !== $value && $serviceTitle !== $title) {
                continue;
            }

            $meta = [];
            if (!empty($item['meta_json'])) {
                try {
                    $meta = json_decode((string)$item['meta_json'], true, 512, JSON_THROW_ON_ERROR);
                } catch (\Throwable $e) {
                    $meta = [];
                }
            }

            $rawPrice = $meta['price'] ?? null;
            $serviceCost = null;
            if ($rawPrice !== null && $rawPrice !== '') {
                $normalized = preg_replace('/[^\d.]/', '', str_replace(',', '', (string)$rawPrice));
                $serviceCost = (float)($normalized !== '' ? $normalized : 0);
            }

            return [
                'service_cost' => ($serviceCost !== null && $serviceCost > 0) ? $serviceCost : null,
                'price_list_code' => trim((string)($meta['price_list_code'] ?? '')),
            ];
        }

        return ['service_cost' => null, 'price_list_code' => ''];
    }

    private function isBootstrapDealMissingService($deal): bool
    {
        if (!$deal) {
            return false;
        }

        if ($this->dealHasPayments($deal)) {
            return false;
        }

        if (!empty($this->normalizeRequestedServicesForBootstrapCheck($deal->requested_services ?? null))) {
            return false;
        }

        $title = trim((string)($deal->title ?? ''));
        if ($title !== '' && $this->isAllowedServiceTitle($title)) {
            return false;
        }

        return true;
    }

    private function dealHasPayments($deal): bool
    {
        $payments = $deal->payments ?? null;
        if (is_string($payments)) {
            $decoded = json_decode($payments, true);
            $payments = is_array($decoded) ? $decoded : [];
        }
        if (!is_array($payments)) {
            return false;
        }

        foreach ($payments as $payment) {
            if (!empty($payment['deleted'])) {
                continue;
            }
            $amount = preg_replace('/[^\d.]/', '', str_replace(',', '', (string)($payment['amount'] ?? 0)));
            if ((float)($amount !== '' ? $amount : 0) > 0) {
                return true;
            }
        }

        return false;
    }

    private function normalizeRequestedServicesForBootstrapCheck($raw): array
    {
        if (is_array($raw)) {
            return array_values(array_filter(array_map(static function ($value) {
                return trim((string)$value);
            }, $raw)));
        }

        $rawString = trim((string)($raw ?? ''));
        if ($rawString === '') {
            return [];
        }

        $decoded = json_decode($rawString, true);
        if (is_array($decoded)) {
            return array_values(array_filter(array_map(static function ($value) {
                return trim((string)$value);
            }, $decoded)));
        }

        return array_values(array_filter(array_map('trim', preg_split('/[،,]+/u', $rawString) ?: [])));
    }

    private function isAllowedServiceTitle(string $serviceTitle): bool
    {
        $serviceTitle = trim($serviceTitle);
        if ($serviceTitle === '') {
            return false;
        }

        try {
            $items = $this->dealService->getLookupItems('allowed_services', true);
        } catch (\Throwable $e) {
            $items = [];
        }

        foreach ((array)$items as $item) {
            $value = trim((string)($item['value'] ?? $item['title'] ?? ''));
            $title = trim((string)($item['title'] ?? $item['value'] ?? ''));
            if ($serviceTitle === $value || $serviceTitle === $title) {
                return true;
            }
        }

        return false;
    }

    private function validatePipelineStageForAssignment(string $stage, ?string $currentStage = null, bool $allowCurrentInactive = false): void
    {
        try {
            $this->dealService->validatePipelineStageForAssignment($stage, $currentStage, $allowCurrentInactive);
        } catch (\Throwable $e) {
            $this->errorResponse($e->getMessage());
        }
    }

    private function assertAdminOnly(string $message = 'فقط مدیر سیستم به این عملیات دسترسی دارد'): void
    {
        if (!$this->authService->isAdmin()) {
            $this->errorResponse($message, 403);
        }
    }

    private function restorePaidDealLockedFields(array &$data, $existingDeal, array $existingRequestedServices, array $existingProducts): void
    {
        if (!$existingDeal) {
            return;
        }

        $data['title'] = (string)($existingDeal->title ?? $data['title'] ?? '');
        $data['requested_services'] = $existingRequestedServices;
        $data['products'] = $existingProducts;
        $data['service_cost'] = (float)($existingDeal->service_cost ?? 0);
        $data['estimated_price'] = (float)($existingDeal->estimated_price ?? ($data['service_cost'] ?? 0));
        $data['final_price'] = (float)($existingDeal->final_price ?? ($existingDeal->payable_amount ?? 0));
        $data['price_list_code'] = (string)($existingDeal->price_list_code ?? '');
        $data['has_discount'] = (int)($existingDeal->has_discount ?? 0);
        $data['discount_type'] = (string)($existingDeal->discount_type ?? '');
        $data['discount_occasion'] = (string)($existingDeal->discount_occasion ?? '');
        $data['discount_amount'] = (float)($existingDeal->discount_amount ?? 0);
        $data['refund_amount'] = (float)($existingDeal->refund_amount ?? 0);
        $data['refund_description'] = (string)($existingDeal->refund_description ?? '');
        $data['payable_amount'] = (float)($existingDeal->payable_amount ?? 0);
    }

    private function detectPaymentPermissionChanges(array $existingPayments, array $validatedPayments): array
    {
        $changes = ['create' => false, 'edit' => false, 'delete' => false];
        $existingCount = count($existingPayments);
        $newCount = count($validatedPayments);
        if ($newCount > $existingCount) $changes['create'] = true;
        if ($newCount < $existingCount) $changes['delete'] = true;

        if ($changes['delete'] && !$changes['create']) {
            $normalizedExisting = array_map(function ($payment) {
                return $this->normalizePaymentForPermissionCompare((array)$payment);
            }, $existingPayments);
            $normalizedNew = array_map(function ($payment) {
                return $this->normalizePaymentForPermissionCompare((array)$payment);
            }, $validatedPayments);
            $cursor = 0;
            foreach ($normalizedExisting as $oldPayment) {
                if ($cursor < $newCount && $oldPayment === $normalizedNew[$cursor]) {
                    $cursor++;
                }
            }
            if ($cursor === $newCount) {
                return $changes;
            }
        }

        $compareCount = min($existingCount, $newCount);
        for ($i = 0; $i < $compareCount; $i++) {
            $oldRaw = (array)($existingPayments[$i] ?? []);
            $newRaw = (array)($validatedPayments[$i] ?? []);
            if ((empty($oldRaw['deleted']) && empty($oldRaw['is_deleted'])) && (!empty($newRaw['deleted']) || !empty($newRaw['is_deleted']))) {
                $changes['delete'] = true;
                continue;
            }
            $old = $this->normalizePaymentForPermissionCompare($oldRaw);
            $new = $this->normalizePaymentForPermissionCompare($newRaw);
            if ($old !== $new) {
                $changes['edit'] = true;
                break;
            }
        }
        return $changes;
    }

    /** Allowed upload MIME types → file extension. */
    private const PAYMENT_UPLOAD_ALLOWED = [
        'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp',
        'image/gif' => 'gif', 'application/pdf' => 'pdf',
    ];
    /** Browser/OS MIME aliases for the same allowed types (these vary across desktops). */
    private const PAYMENT_UPLOAD_MIME_ALIASES = [
        'image/jpg' => 'jpg', 'image/pjpeg' => 'jpg', 'image/x-citrix-jpeg' => 'jpg',
        'image/x-png' => 'png', 'image/x-citrix-png' => 'png', 'image/apng' => 'png',
        'application/x-pdf' => 'pdf', 'application/acrobat' => 'pdf',
        'applications/vnd.pdf' => 'pdf', 'text/pdf' => 'pdf',
    ];
    /** Allowed filename extensions → canonical extension (fallback when MIME is unreliable). */
    private const PAYMENT_UPLOAD_EXT_ALLOWED = [
        'jpg' => 'jpg', 'jpeg' => 'jpg', 'png' => 'png', 'webp' => 'webp', 'gif' => 'gif', 'pdf' => 'pdf',
    ];
    private const PAYMENT_UPLOAD_MAX_BYTES = 5242880; // 5MB

    /**
     * Pure validation of an uploaded payment file (size + real MIME).
     * Returns ['ok'=>bool, 'ext'=>string, 'mime'=>string, 'error'=>string].
     * Extracted from uploadPaymentFile() so it can be unit-tested without a real
     * HTTP upload (the PHP built-in dev server cannot process multipart bodies).
     */
    public function inspectPaymentUpload(array $file): array
    {
        $uploadError = (int)($file['error'] ?? UPLOAD_ERR_NO_FILE);
        if ($uploadError !== UPLOAD_ERR_OK) {
            $message = 'فایلی دریافت نشد';
            if ($uploadError === UPLOAD_ERR_INI_SIZE || $uploadError === UPLOAD_ERR_FORM_SIZE) {
                $message = 'حجم فایل از سقف مجاز سرور بیشتر است. سرور لوکال را با upload_max_filesize=5M اجرا کنید';
            } elseif ($uploadError === UPLOAD_ERR_PARTIAL) {
                $message = 'فایل به‌صورت کامل بارگذاری نشد؛ دوباره تلاش کنید';
            }
            return ['ok' => false, 'ext' => '', 'mime' => '', 'error' => $message];
        }
        $size = (int)($file['size'] ?? 0);
        if ($size <= 0 || $size > self::PAYMENT_UPLOAD_MAX_BYTES) {
            return ['ok' => false, 'ext' => '', 'mime' => '', 'error' => 'حجم فایل باید بین ۱ بایت تا ۵ مگابایت باشد'];
        }
        $mime = '';
        $tmp = (string)($file['tmp_name'] ?? '');
        if ($tmp !== '' && is_file($tmp) && function_exists('finfo_open')) {
            $finfo = finfo_open(FILEINFO_MIME_TYPE);
            if ($finfo) {
                $mime = (string)(finfo_file($finfo, $tmp) ?: '');
                if (PHP_VERSION_ID < 80500) {
                    finfo_close($finfo);
                }
            }
        }
        if ($mime === '') {
            $mime = (string)($file['type'] ?? '');
        }
        $mimeLower = strtolower(trim($mime));

        // 1) Exact allowed MIME, or a known browser/OS alias of an allowed type.
        $ext = self::PAYMENT_UPLOAD_ALLOWED[$mimeLower]
            ?? self::PAYMENT_UPLOAD_MIME_ALIASES[$mimeLower]
            ?? '';

        // 2) Fallback to the filename extension when the reported MIME is empty or generic
        //    (some desktops/browsers send '', 'application/octet-stream', etc.). Stays safe:
        //    a concrete non-image/non-pdf MIME (e.g. text/html) still blocks the file.
        if ($ext === '') {
            $nameExt = strtolower(pathinfo((string)($file['name'] ?? ''), PATHINFO_EXTENSION));
            if (isset(self::PAYMENT_UPLOAD_EXT_ALLOWED[$nameExt])
                && ($mimeLower === ''
                    || $mimeLower === 'application/octet-stream'
                    || strpos($mimeLower, 'image/') === 0
                    || $mimeLower === 'application/pdf')) {
                $ext = self::PAYMENT_UPLOAD_EXT_ALLOWED[$nameExt];
            }
        }

        if ($ext === '') {
            return ['ok' => false, 'ext' => '', 'mime' => $mime, 'error' => 'فقط تصویر (JPG/PNG/WEBP/GIF) یا PDF مجاز است'];
        }
        return ['ok' => true, 'ext' => $ext, 'mime' => $mime, 'error' => ''];
    }

    /**
     * Allowlist a stored upload path: only safe relative paths under uploads/payments/.
     * Blocks traversal and absolute/scheme paths. Returns '' if not acceptable.
     */
    public function sanitizePaymentUploadPath($p): string
    {
        $p = trim((string)$p);
        if ($p === '') {
            return '';
        }
        if (strpos($p, '..') !== false) {
            return '';
        }
        return preg_match('#^uploads/payments/[A-Za-z0-9_/\.\-]+$#', $p) === 1 ? $p : '';
    }

    /**
     * C2: upload a deposit receipt or cheque image for a payment row.
     * Returns a relative path stored on the payment object.
     */
    public function uploadPaymentFile(): void
    {
        if (!$this->canRbac('action.payments.create') && !$this->canRbac('action.payments.edit')) {
            $this->errorResponse('دسترسی محدود - شما مجاز به بارگذاری فایل پرداخت نیستید', 403);
        }

        $kind = strtolower(trim((string)($_POST['kind'] ?? 'receipt')));
        if (!in_array($kind, ['receipt', 'check', 'check_sayadi'], true)) {
            $kind = 'receipt';
        }

        if (!isset($_FILES['file']) || !is_array($_FILES['file'])) {
            $this->errorResponse('فایلی دریافت نشد');
        }
        $file = $_FILES['file'];
        $check = $this->inspectPaymentUpload($file);
        if (!$check['ok']) {
            $this->errorResponse($check['error']);
        }
        $ext = $check['ext'];

        $relativeDir = 'uploads/payments/' . date('Y') . '/' . date('m');
        $baseDir = dirname(__DIR__, 2) . '/public/' . $relativeDir;
        if (!is_dir($baseDir) && !@mkdir($baseDir, 0775, true) && !is_dir($baseDir)) {
            $this->errorResponse('عدم امکان ایجاد مسیر ذخیره‌سازی');
        }
        try {
            $rand = bin2hex(random_bytes(16));
        } catch (\Exception $e) {
            $rand = str_replace('.', '', uniqid('pay', true));
        }
        $filename = $kind . '_' . $rand . '.' . $ext;
        $target = $baseDir . '/' . $filename;
        if (!@move_uploaded_file($file['tmp_name'], $target)) {
            $this->errorResponse('بارگذاری فایل ناموفق بود');
        }
        @chmod($target, 0644);

        $relativePath = $relativeDir . '/' . $filename;
        \App\Utils\Logger::logInfo('PAYMENT_FILE_UPLOADED', ['kind' => $kind, 'path' => $relativePath, 'mime' => $check['mime']]);
        $this->successResponse(['path' => $relativePath, 'kind' => $kind]);
    }

    /**
     * Stream a stored payment receipt/cheque image (or PDF) back to the browser.
     * Docroot-independent (reads from the filesystem, not a guessed /public/ URL), auth-gated,
     * sets the correct Content-Type, and supports ?download=1 for the download button.
     * Outputs binary — never JSON — so errors use plain HTTP status + text.
     */
    public function servePaymentFile(): void
    {
        $fail = function (int $code, string $msg): void {
            http_response_code($code);
            header('Content-Type: text/plain; charset=utf-8');
            echo $msg;
        };

        if (!$this->authService || !$this->authService->isAuthenticated()) {
            $fail(403, 'دسترسی غیرمجاز');
            return;
        }

        $rel = $this->sanitizePaymentUploadPath($_GET['path'] ?? '');
        if ($rel === '') {
            $fail(404, 'فایل یافت نشد');
            return;
        }

        $baseDir = realpath(dirname(__DIR__, 2) . '/public/uploads/payments');
        $full = realpath(dirname(__DIR__, 2) . '/public/' . $rel);
        // Containment check: resolved path must live under uploads/payments and exist.
        if ($baseDir === false || $full === false || strpos($full, $baseDir) !== 0 || !is_file($full)) {
            $fail(404, 'فایل یافت نشد');
            return;
        }

        // Resolve Content-Type from the real bytes, falling back to the extension.
        $mime = '';
        if (function_exists('finfo_open')) {
            $finfo = finfo_open(FILEINFO_MIME_TYPE);
            if ($finfo) {
                $mime = (string)(finfo_file($finfo, $full) ?: '');
                if (PHP_VERSION_ID < 80500) {
                    finfo_close($finfo);
                }
            }
        }
        $ext = strtolower(pathinfo($full, PATHINFO_EXTENSION));
        if ($mime === '' || $mime === 'application/octet-stream') {
            $extToMime = [
                'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
                'webp' => 'image/webp', 'gif' => 'image/gif', 'pdf' => 'application/pdf',
            ];
            $mime = $extToMime[$ext] ?? 'application/octet-stream';
        }

        $download = in_array(strtolower((string)($_GET['download'] ?? '')), ['1', 'true', 'yes'], true);
        $downloadName = 'receipt_' . date('Ymd_His') . '.' . ($ext !== '' ? $ext : 'bin');

        // Discard any active output buffering (gzip is skipped for this action, but be safe)
        // so binary bytes and Content-Length are not corrupted.
        while (ob_get_level() > 0) {
            ob_end_clean();
        }

        header('Content-Type: ' . $mime);
        header('Content-Length: ' . (string)filesize($full));
        header('X-Content-Type-Options: nosniff');
        header('Cache-Control: private, max-age=300');
        header('Content-Disposition: ' . ($download ? 'attachment' : 'inline') . '; filename="' . $downloadName . '"');
        readfile($full);
    }

    private function normalizeDealPermissionValue(string $key, $value)
    {
        if (in_array($key, ['service_cost', 'payable_amount', 'discount_amount', 'refund_amount'], true)) {
            return round((float)$value, 2);
        }
        if ($key === 'status') {
            $s = trim((string)$value);
            if ($s === '') return '';
            return strtolower($s);
        }
        if (in_array($key, ['has_discount', 'has_previous_purchase'], true)) {
            if ($value === null || $value === '') {
                return null;
            }
            return (int)$value;
        }
        if ($key === 'products') {
            $arr = is_array($value) ? $value : [];
            $arr = array_values(array_filter(array_map(static function ($v) {
                $s = trim((string)$v);
                return $s === '' ? null : $s;
            }, $arr)));
            sort($arr, SORT_NATURAL);
            return $arr;
        }
        if ($key === 'requested_services') {
            $arr = is_array($value) ? $value : [];
            $arr = array_values(array_filter(array_map(static function ($v) {
                $s = preg_replace('/\s+/', ' ', trim((string)$v));
                return $s === '' ? null : $s;
            }, $arr)));
            sort($arr, SORT_NATURAL);
            return $arr;
        }
        if ($value === null) return null;
        return trim((string)$value);
    }

    private function hasNonPaymentDealChanges($existingDeal, array $data, array $ignoreKeys = []): bool
    {
        if (!$existingDeal) return true;

        $existingRequested = [];
        if (!empty($existingDeal->requested_services)) {
            $decoded = json_decode((string)$existingDeal->requested_services, true);
            if (is_array($decoded)) {
                $existingRequested = $decoded;
            }
        }

        $existingProducts = [];
        try {
            $rows = $this->dealRepo->getProductsByDealDidarId((string)$existingDeal->didar_deal_id);
            foreach ((array)$rows as $r) {
                if (isset($r['product_id'])) {
                    $existingProducts[] = (string)$r['product_id'];
                }
            }
        } catch (\Throwable $e) {
            return true;
        }

        $compareMap = [
            'title' => $existingDeal->title ?? null,
            'status' => $existingDeal->status ?? null,
            'pipeline_stage' => $existingDeal->pipeline_stage ?? null,
            'service_cost' => $existingDeal->service_cost ?? null,
            'price_list_code' => $existingDeal->price_list_code ?? null,
            'payable_amount' => $existingDeal->payable_amount ?? null,
            'payment_conditions' => $existingDeal->payment_conditions ?? null,
            'has_discount' => $existingDeal->has_discount ?? null,
            'discount_type' => $existingDeal->discount_type ?? null,
            'discount_occasion' => $existingDeal->discount_occasion ?? null,
            'discount_amount' => $existingDeal->discount_amount ?? null,
            'refund_amount' => $existingDeal->refund_amount ?? null,
            'refund_description' => $existingDeal->refund_description ?? null,
            'financial_level' => $existingDeal->financial_level ?? null,
            'asset_estimation' => $existingDeal->asset_estimation ?? null,
            'income_estimation' => $existingDeal->income_estimation ?? null,
            'has_previous_purchase' => $existingDeal->has_previous_purchase ?? null,
            'failure_reason_code' => $existingDeal->failure_reason_code ?? null,
            'failure_reason_text' => $existingDeal->failure_reason_text ?? null,
            'failure_reason_description' => $existingDeal->failure_reason_description ?? null,
            'acquaintance_source' => $existingDeal->acquaintance_source ?? null,
            'acquaintance_detail' => $existingDeal->acquaintance_detail ?? null,
            'content_topic' => $existingDeal->content_topic ?? null,
            'requested_services' => $existingRequested,
            'products' => $existingProducts,
        ];

        foreach ($compareMap as $key => $oldVal) {
            if (in_array($key, $ignoreKeys, true)) {
                continue;
            }
            $newVal = $data[$key] ?? ($key === 'products' ? [] : null);
            if ($this->normalizeDealPermissionValue($key, $oldVal) !== $this->normalizeDealPermissionValue($key, $newVal)) {
                return true;
            }
        }
        return false;
    }

    public function __construct(
        DealService $dealService,
        VirtualStageService $virtualStageService,
        PersonRepository $personRepo,
        DealRepository $dealRepo,
        AuthService $authService,
        AuditService $auditService,
        $db = null
    ) {
        $this->dealService = $dealService;
        $this->virtualStageService = $virtualStageService;
        $this->personRepo = $personRepo;
        $this->dealRepo = $dealRepo;
        $this->authService = $authService;
        $this->auditService = $auditService;
        $this->db = $db;
    }

    public function getDeals()
    {
        $this->requireRbac('deals.view', 'دسترسی محدود - شما مجاز به مشاهده معاملات نیستید');
        $isAdmin = $this->authService->isAdmin();
        $isAdminOrCrm = $this->authService->isAdminOrCrmSpecialist();
        $didarId = $_SESSION['didar_id'] ?? '';
        $requestedOwnerId = Sanitizer::sanitize($_POST['owner_id'] ?? '');
        
        $filters = [];
        if (!$isAdminOrCrm && !empty($didarId)) {
            $filters['owner_didar_id'] = $didarId;
        }

        // Admin/CRM can filter by owner
        if ($isAdminOrCrm && $requestedOwnerId) {
            $filters['owner_didar_id'] = $requestedOwnerId;
        }
        
        $status = Sanitizer::sanitize($_POST['status'] ?? 'all');
        $search = Sanitizer::sanitize($_POST['search'] ?? '');
        $customerCodeSearch = Sanitizer::sanitize($_POST['customer_code_search'] ?? '');
        $pipelineStage = Sanitizer::sanitize($_POST['pipeline_stage'] ?? 'all');
        $pipelineBucket = Sanitizer::sanitize($_POST['pipeline_bucket'] ?? '');
        $requestedService = Sanitizer::sanitize($_POST['requested_service'] ?? '');
        $registerFrom = Sanitizer::sanitize($_POST['register_date_from'] ?? '');
        $registerTo = Sanitizer::sanitize($_POST['register_date_to'] ?? '');
        $activityFrom = Sanitizer::sanitize($_POST['activity_date_from'] ?? '');
        $activityTo = Sanitizer::sanitize($_POST['activity_date_to'] ?? '');
        $legacy = $_POST['legacy'] ?? '';
        $limit = isset($_POST['limit']) ? (int)$_POST['limit'] : null;
        $offset = isset($_POST['offset']) ? (int)$_POST['offset'] : null;
        $cursorTime = Sanitizer::sanitize($_POST['cursor_time'] ?? '');
        $cursorId = Sanitizer::sanitize($_POST['cursor_id'] ?? '');
        $useCursor = isset($_POST['use_cursor']) ? (int)$_POST['use_cursor'] === 1 : false;
        
        if ($status !== 'all') {
            $filters['status'] = $status;
        }
        
        if ($pipelineStage !== 'all') {
            $filters['pipeline_stage'] = $pipelineStage;
        }
        if ($pipelineBucket !== '' && $pipelineBucket !== 'all') {
            $filters['pipeline_bucket'] = $pipelineBucket;
        }
        if ($requestedService !== '') {
            $filters['requested_service'] = $requestedService;
        }

        if ($registerFrom) {
            $filters['register_date_from'] = $registerFrom;
        }
        if ($registerTo) {
            $filters['register_date_to'] = $registerTo;
        }

        if ($activityFrom) {
            $filters['activity_date_from'] = $activityFrom;
        }
        if ($activityTo) {
            $filters['activity_date_to'] = $activityTo;
        }
        
        if ($search) {
            $filters['search'] = $search;
        }
        if ($customerCodeSearch) {
            $filters['customer_code_search'] = $customerCodeSearch;
        }

        if ($legacy !== '') {
            $filters['legacy'] = Sanitizer::sanitize($legacy);
        }
        if ($limit !== null) {
            $filters['limit'] = $limit;
        }
        if ($offset !== null) {
            $filters['offset'] = $offset;
        }
        if ($cursorTime) {
            $filters['cursor_time'] = $cursorTime;
        }
        if ($cursorId) {
            $filters['cursor_id'] = $cursorId;
        }
        if ($useCursor && empty($filters['cursor_time']) && $offset === null) {
            // Cursor mode can be explicitly requested without a cursor for first page.
            // Repository will simply return first page ordered by register_time.
            $filters['use_cursor'] = true;
        }

        // RBAC data scope (self/all/selected_users) overrides legacy owner visibility logic.
        $this->applyOwnerScopeToFilters($filters, 'deals', $requestedOwnerId, (string)$didarId);
        
        $deals = $this->dealService->getDeals($filters);
        // Attach products in batch (avoid N+1)
        $dealDidarIds = array_values(array_filter(array_map(function ($d) {
            return (string)($d['didar_deal_id'] ?? '');
        }, $deals)));
        $productsMap = $this->dealRepo->getProductsByDealDidarIds($dealDidarIds);
        $deals = array_map(function($d) use ($productsMap) {
            $key = (string)($d['didar_deal_id'] ?? '');
            $d['products'] = $productsMap[$key] ?? [];
            return $d;
        }, $deals);

        $pagination = [
            'mode' => ($cursorTime || $useCursor) ? 'cursor' : 'offset',
            'limit' => (int)($filters['limit'] ?? 100),
        ];
        if (!empty($deals)) {
            $last = $deals[count($deals) - 1];
            $pagination['next_cursor_time'] = substr((string)($last['register_time'] ?? ''), 0, 19);
            $pagination['next_cursor_id'] = (string)($last['didar_deal_id'] ?? '');
            $pagination['last_row_id'] = (int)($last['id'] ?? 0);
        } else {
            $pagination['next_cursor_time'] = '';
            $pagination['next_cursor_id'] = '';
            $pagination['last_row_id'] = 0;
        }
        $pagination['rows_count'] = count($deals);

        $this->successResponse(['deals' => $deals, 'pagination' => $pagination]);
    }

    private function logFieldChanges(string $entityType, string $entityId, array $oldData, array $newData): void
    {
        if (!$this->db) {
            \App\Utils\Logger::logError("DealController.logFieldChanges: DB connection not initialized");
            return;
        }
        $actorId = $_SESSION['didar_id'] ?? 'system';
        $actorName = $_SESSION['name'] ?? 'System';
        $pdo = $this->db->getPdo();
        
        $sql = "INSERT INTO field_audit_logs (entity_type, entity_id, field_name, old_value, new_value, actor_didar_id, actor_name) 
                VALUES (?, ?, ?, ?, ?, ?, ?)";
        $stmt = $pdo->prepare($sql);
        
        foreach ($newData as $field => $newValue) {
            $oldValue = $oldData[$field] ?? null;
            
            // Normalize values for comparison
            $normOld = is_array($oldValue) ? json_encode($oldValue) : (string)$oldValue;
            $normNew = is_array($newValue) ? json_encode($newValue) : (string)$newValue;
            
            if ($normOld !== $normNew) {
                $stmt->execute([
                    $entityType,
                    $entityId,
                    $field,
                    $normOld,
                    $normNew,
                    $actorId,
                    $actorName
                ]);
            }
        }
    }

    public function getLegacyDeals()
    {
        $this->requireRbac('deals.view', 'دسترسی محدود - شما مجاز به مشاهده معاملات نیستید');
        $contactId = Sanitizer::sanitize($_POST['contact_id'] ?? '');
        if (empty($contactId)) {
            $this->errorResponse('O\'U+OO3UØ O\'OrOæ OU,OýOU.UO OO3O¦');
        }

        $person = $this->personRepo->findByIdOrDidarId($contactId);
        if (!$person) {
            $this->errorResponse('O\'OrOæ UOOU?O¦ U+O\'O_');
        }
        $this->assertOwnerInDealScopeByDidar((string)($person->owner_didar_id ?? ''));

        $legacyDeals = $this->dealRepo->findByContactIdLegacy($person->didar_contact_id, true);
        $legacyArray = array_map(function($deal) {
            return $deal->toArray();
        }, $legacyDeals);

        $this->successResponse(['deals' => $legacyArray]);
    }

    public function save()
    {
        // A non-admin must not change the deal's service ("خدمات قابل ارائه" = the deal title)
        // once the deal exists / has been submitted. Force-preserve the stored title so every
        // downstream step (requested_services, pricing, persistence) uses the immutable value.
        // Admins/system are unaffected; new deals (no deal_id) are unaffected.
        if (!empty($_POST['deal_id'])) {
            $actorRole = (string)($_SESSION['role'] ?? '');
            if (!in_array($actorRole, ['admin', 'system'], true)) {
                $existingForService = $this->dealRepo->findByDidarId(Sanitizer::sanitize($_POST['deal_id']));
                $storedTitle = $existingForService ? (string)($existingForService->title ?? '') : '';
                $incomingTitle = Sanitizer::sanitize($_POST['title'] ?? '');
                if ($storedTitle !== '' && $storedTitle !== $incomingTitle && !$this->isBootstrapDealMissingService($existingForService)) {
                    \App\Utils\Logger::logWarning('Blocked non-admin attempt to change deal service (title) on existing deal', [
                        'deal_id' => $_POST['deal_id'],
                        'role' => $actorRole,
                        'from' => $storedTitle,
                        'to' => $incomingTitle,
                        'user_id' => $_SESSION['didar_id'] ?? null,
                    ]);
                    $_POST['title'] = $storedTitle;
                }
            }
        }

        $products = [];
        if (!empty($_POST['product_ids'])) {
            if (is_array($_POST['product_ids'])) {
                $products = $_POST['product_ids'];
            } else {
                # comma separated
                $products = array_filter(array_map('trim', explode(',', $_POST['product_ids'])));
            }
        }

        # Requested services (can be array, JSON string, or comma separated)
        $requestedServices = [];
        if (isset($_POST['requested_services'])) {
            if (is_array($_POST['requested_services'])) {
                $requestedServices = $_POST['requested_services'];
            } else {
                $raw = trim((string)$_POST['requested_services']);
                if ($raw != '') {
                    $decoded = json_decode($raw, true);
                    if (is_array($decoded)) {
                        $requestedServices = $decoded;
                    } else {
                        $requestedServices = array_filter(array_map('trim', explode(',', $raw)));
                    }
                }
            }
        }
        // اگر هنوز خالی است ولی عنوان خدمات قابل ارائه موجود است، آن را هم به عنوان خدمت درخواستی اضافه کن
        if (empty($requestedServices) && !empty($_POST['title'])) {
            $requestedServices = [Sanitizer::sanitize($_POST['title'])];
        }

        $isNewDeal = empty($_POST['deal_id']);
        if ($isNewDeal) {
            $this->requireRbac('deals.create', 'دسترسی محدود - شما مجاز به ایجاد معامله نیستید');
        } else {
            $this->requireRbac('deals.edit', 'دسترسی محدود - شما مجاز به ویرایش معامله نیستید');
        }
        \App\Utils\Logger::logInfo('DealController.save start', [
            'is_new' => $isNewDeal,
            'incoming_title' => $_POST['title'] ?? '',
            'contact_id' => $_POST['contact_id'] ?? '',
            'deal_id' => $_POST['deal_id'] ?? '',
            'user_id' => $_SESSION['didar_id'] ?? 'unknown',
            'user_name' => $_SESSION['name'] ?? 'unknown',
            'form_data_keys' => array_keys($_POST),
            'server_time' => date('Y-m-d H:i:s'),
            'session_id' => session_id()
        ]);
        $title = Sanitizer::sanitize($_POST['title'] ?? '');
        $rawContactId = Sanitizer::sanitize($_POST['contact_id'] ?? '');
        $resolvedPerson = $this->resolvePersonByContactId($rawContactId);
        $normalizedContactId = $resolvedPerson ? $resolvedPerson->didar_contact_id : $rawContactId;

        if ($resolvedPerson && empty(trim((string)($resolvedPerson->customer_code ?? ''))) && $this->personRepo) {
            $this->personRepo->assignCustomerCodeIfMissing($resolvedPerson->didar_contact_id);
            $resolvedPerson = $this->resolvePersonByContactId($rawContactId);
        }

        // For new deals, generate title automatically if not provided
        if ($isNewDeal && empty($title)) {
            try {
                if (!empty($normalizedContactId)) {
                    $title = $this->dealService->generateDealTitle($normalizedContactId);
                }
            } catch (\Exception $e) {
                \App\Utils\Logger::logError('Failed to generate deal title', $e, ['contact_id' => $_POST['contact_id'] ?? '']);
                // Continue with empty title - will be caught by validation
            }
        }

        if ($isNewDeal && $this->isGeneratedDealTitle($title)) {
            Logger::logWarning('Deal save blocked: generated title used as service', [
                'deal_title' => $title,
                'contact_id' => $_POST['contact_id'] ?? '',
                'deal_id' => $_POST['deal_id'] ?? '',
                'user_id' => $_SESSION['didar_id'] ?? 'unknown',
                'user_name' => $_SESSION['name'] ?? 'unknown',
                'server_time' => date('Y-m-d H:i:s')
            ]);
            $this->errorResponse("\u{0644}\u{0637}\u{0641}\u{0627}\u{064b} \u{062e}\u{062f}\u{0645}\u{0627}\u{062a} \u{0642}\u{0627}\u{0628}\u{0644} \u{0627}\u{0631}\u{0627}\u{0626}\u{0647} \u{0631}\u{0627} \u{0627}\u{0646}\u{062a}\u{062e}\u{0627}\u{0628} \u{06a9}\u{0646}\u{06cc}\u{062f}.");
        }

        // VALIDATION: Ensure deal phone matches contact's primary phone
        if (!empty($title) && !empty($_POST['contact_id'])) {
            Logger::logInfo('DEAL_PHONE_VALIDATION_START', [
                'deal_title' => $title,
                'contact_id' => $_POST['contact_id'],
                'is_new_deal' => $isNewDeal
            ]);

            $dealPhone = $this->extractPhoneFromDealTitle($title);
            $contactPrimaryPhone = $this->getContactPrimaryPhone($_POST['contact_id']);

            Logger::logInfo('DEAL_PHONE_VALIDATION_EXTRACTED', [
                'deal_phone' => $dealPhone,
                'contact_primary_phone' => $contactPrimaryPhone
            ]);

            if (!empty($dealPhone) && !empty($contactPrimaryPhone)) {
                // Normalize both phones for comparison
                $normalizedDealPhone = $this->normalizePhoneForComparison($dealPhone);
                $normalizedContactPhone = $this->normalizePhoneForComparison($contactPrimaryPhone);

                Logger::logInfo('DEAL_PHONE_VALIDATION_NORMALIZED', [
                    'normalized_deal_phone' => $normalizedDealPhone,
                    'normalized_contact_phone' => $normalizedContactPhone,
                    'phones_match' => $normalizedDealPhone === $normalizedContactPhone
                ]);

                if ($normalizedDealPhone !== $normalizedContactPhone) {
                    Logger::logWarning('DEAL_PHONE_VALIDATION_FAILED: Deal phone does not match contact primary phone', [
                        'deal_title' => $title,
                        'extracted_deal_phone' => $dealPhone,
                        'normalized_deal_phone' => $normalizedDealPhone,
                        'contact_primary_phone' => $contactPrimaryPhone,
                        'normalized_contact_phone' => $normalizedContactPhone,
                        'contact_id' => $_POST['contact_id'],
                        'user_id' => $_SESSION['didar_id'] ?? 'unknown',
                        'user_name' => $_SESSION['name'] ?? 'unknown',
                        'server_time' => date('Y-m-d H:i:s')
                    ]);
                    $this->errorResponse('شماره تماس معامله با شماره تماس اصلی لید مطابقت ندارد. لطفاً از شماره صحیح استفاده کنید.');
                }

                Logger::logInfo('DEAL_PHONE_VALIDATION_PASSED', [
                    'deal_title' => $title,
                    'deal_phone' => $dealPhone,
                    'contact_phone' => $contactPrimaryPhone,
                    'contact_id' => $_POST['contact_id'],
                    'validation' => 'PASSED'
                ]);
            } else {
                Logger::logInfo('DEAL_PHONE_VALIDATION_SKIPPED', [
                    'reason' => 'missing_deal_phone_or_contact_phone',
                    'deal_phone' => $dealPhone,
                    'contact_primary_phone' => $contactPrimaryPhone,
                    'contact_id' => $_POST['contact_id'] ?? '',
                    'deal_id' => $_POST['deal_id'] ?? '',
                    'is_new_deal' => $isNewDeal,
                    'incoming_title' => $title,
                    'user_id' => $_SESSION['didar_id'] ?? 'unknown',
                    'user_name' => $_SESSION['name'] ?? 'unknown',
                    'server_time' => date('Y-m-d H:i:s')
                ]);
            }
        }

        // Payments and financial fields (optional for new deals)
        $paymentsRaw = $_POST['payments'] ?? '';
        if (is_array($paymentsRaw)) {
            $paymentsRaw = json_encode($paymentsRaw, JSON_UNESCAPED_UNICODE);
        }
        $replacePayments = (int)($_POST['replace_payments'] ?? 0) === 1;
        $paymentsPayload = [];
        if (!empty($paymentsRaw)) {
            $decodedPayments = json_decode($paymentsRaw, true);
            if (is_array($decodedPayments)) {
                // Normalize payment keys to standard format before processing
                $paymentsPayload = array_map(function($payment) {
                    // Normalize card_last4 (support both card_last4 and card_last_4, use card_last4 as standard)
                    if (isset($payment['card_last_4']) && !isset($payment['card_last4'])) {
                        $payment['card_last4'] = $payment['card_last_4'];
                    }
                    // Normalize destination_bank (support both destination_bank and bank_destination, use destination_bank as standard)
                    if (isset($payment['bank_destination']) && !isset($payment['destination_bank'])) {
                        $payment['destination_bank'] = $payment['bank_destination'];
                    }
                    // Normalize method (support both method and payment_method, use method as standard)
                    if (isset($payment['payment_method']) && !isset($payment['method'])) {
                        $payment['method'] = $payment['payment_method'];
                    }
                    return $payment;
                }, $decodedPayments);

                // ✅ Validate payment fields based on field configuration
                // This respects field permissions and conditional requirements
                global $app;
                if (isset($app->services->fieldPermission)) {
                    $userRole = $_SESSION['role'] ?? 'agent';
                    $isExisting = !$isNewDeal;
                    
                    // Load existing deal for comparison if updating
                    $existingPayments = [];
                    $existingDealData = null;
                    if (!$isNewDeal) {
                        $existingDealData = $this->dealRepo->findByDidarId(Sanitizer::sanitize($_POST['deal_id']));
                        if ($existingDealData && !empty($existingDealData->payments)) {
                            $existingPaymentsRaw = json_decode($existingDealData->payments, true) ?: [];
                            // Normalize existing payments keys to standard format
                            $existingPayments = array_map(function($payment) {
                                // Normalize card_last4 (support both card_last4 and card_last_4, use card_last4 as standard)
                                if (isset($payment['card_last_4']) && !isset($payment['card_last4'])) {
                                    $payment['card_last4'] = $payment['card_last_4'];
                                }
                                // Normalize destination_bank (support both destination_bank and bank_destination, use destination_bank as standard)
                                if (isset($payment['bank_destination']) && !isset($payment['destination_bank'])) {
                                    $payment['destination_bank'] = $payment['bank_destination'];
                                }
                                // Normalize method (support both method and payment_method, use method as standard)
                                if (isset($payment['payment_method']) && !isset($payment['method'])) {
                                    $payment['method'] = $payment['payment_method'];
                                }
                                return $payment;
                            }, $existingPaymentsRaw);
                        }
                    }

                    // Check each payment record for field-level constraints
                    // Note: paymentsPayload and existingPayments are already normalized at this point
                    foreach ($paymentsPayload as $idx => $payment) {
                        $existingPayment = $existingPayments[$idx] ?? null;
                        $isNewPayment = ($existingPayment === null);

                        // Helper to check if a field has actually changed
                        // Since payments are normalized, we only need to check the standard key
                        $hasChanged = function($fieldName, $newValue) use ($existingPayment) {
                            if (!$existingPayment) return true; // New record
                            
                            // Get old value using standard key (payments are normalized)
                            $oldValue = $existingPayment[$fieldName] ?? null;
                            
                            // Normalize: treat null, empty string, and '0' as empty
                            $oldNormalized = ($oldValue === null || $oldValue === '' || $oldValue === '0') ? '' : trim((string)$oldValue);
                            $newNormalized = ($newValue === null || $newValue === '' || $newValue === '0') ? '' : trim((string)$newValue);
                            
                            return $oldNormalized !== $newNormalized;
                        };

                        // At this point, payment keys are already normalized, so use standard keys directly
                        $methodValue = $payment['method'] ?? '';
                        $cardValue   = $payment['card_last4'] ?? '';
                        $bankValue   = $payment['destination_bank'] ?? '';

                        // 1. Validate payment_method with editable_after_create support
                        if ($hasChanged('method', $methodValue) && !empty($methodValue)) {
                            if (!$app->services->fieldPermission->canUserEditField('payment_method', $userRole, !$isNewPayment)) {
                                \App\Utils\Logger::logWarning('DEAL_PAYMENT_METHOD_EDIT_DENIED', [
                                    'deal_id' => $_POST['deal_id'] ?? '',
                                    'payment_idx' => $idx,
                                    'user_role' => $userRole,
                                    'method_value' => $methodValue,
                                    'is_new_payment' => $isNewPayment,
                                    'old_value' => $existingPayment['method'] ?? null,
                                    'new_value' => $methodValue
                                ]);
                                $this->errorResponse("شما مجاز به تغییر نحوه پرداخت نیستید (ردیف " . ($idx + 1) . ")");
                            }
                        }

                        // 2. Validate conditional requirements using FieldPermissionService
                        // This replaces hardcoded card_last4 logic
                        $paymentDataForLogic = [
                            'payment_method' => $methodValue,
                            'method' => $methodValue,
                            'destination_bank' => $bankValue,
                            'card_last4' => $cardValue,
                            // Add keys that might be used in conditional logic strings
                            'payment_methods' => $methodValue,
                            'payment_destination_banks' => $bankValue
                        ];

                        // Check if card_last4 is required based on conditional logic in settings
                        if ($app->services->fieldPermission->checkConditionalVisibility('payment_card_last4', $paymentDataForLogic)) {
                            if ($app->services->fieldPermission->isFieldRequired('payment_card_last4') && empty($cardValue)) {
                                $this->errorResponse("4 رقم آخر کارت الزامی است (ردیف " . ($idx + 1) . ")");
                            }
                        }

                        // Check if destination_bank is required based on conditional logic in settings
                        if ($app->services->fieldPermission->checkConditionalVisibility('destination_bank', $paymentDataForLogic)) {
                            if ($app->services->fieldPermission->isFieldRequired('destination_bank') && empty($bankValue)) {
                                $this->errorResponse("انتخاب بانک مقصد الزامی است (ردیف " . ($idx + 1) . ")");
                            }
                        }
                        
                        // 3. Validate card_last4 with editable_after_create support
                        if ($hasChanged('card_last4', $cardValue) && !empty($cardValue)) {
                            if (!$app->services->fieldPermission->canUserEditField('payment_card_last4', $userRole, !$isNewPayment)) {
                                \App\Utils\Logger::logWarning('DEAL_PAYMENT_CARD_EDIT_DENIED', [
                                    'deal_id' => $_POST['deal_id'] ?? '',
                                    'payment_idx' => $idx,
                                    'user_role' => $userRole,
                                    'card_value' => $cardValue,
                                    'is_new_payment' => $isNewPayment,
                                    'old_value' => $existingPayment['card_last4'] ?? null,
                                    'new_value' => $cardValue
                                ]);
                                $this->errorResponse("شما مجاز به تغییر 4 رقم کارت نیستید (ردیف " . ($idx + 1) . ")");
                            }
                            
                            // Apply validation pattern from config if set, otherwise use default 4-digit regex
                            $config = $app->services->fieldPermission->getFieldConfig('payment_card_last4');
                            $pattern = $config['validation_pattern'] ?? '^\d{4}$';
                            if (!empty($pattern)) {
                                if (@preg_match('/' . str_replace('/', '\/', $pattern) . '/', $cardValue) === 0) {
                                    $this->errorResponse($config['validation_message'] ?? "قالب 4 رقم آخر کارت صحیح نیست (ردیف " . ($idx + 1) . ")");
                                }
                            }
                        }
                        
                        // 4. Validate destination_bank with editable_after_create support
                        if ($hasChanged('destination_bank', $bankValue) && !empty($bankValue)) {
                            if (!$app->services->fieldPermission->canUserEditField('destination_bank', $userRole, !$isNewPayment)) {
                                \App\Utils\Logger::logWarning('DEAL_PAYMENT_BANK_EDIT_DENIED', [
                                    'deal_id' => $_POST['deal_id'] ?? '',
                                    'payment_idx' => $idx,
                                    'user_role' => $userRole,
                                    'bank_value' => $bankValue,
                                    'is_new_payment' => $isNewPayment,
                                    'old_value' => $existingPayment['destination_bank'] ?? null,
                                    'new_value' => $bankValue,
                                    'config_found' => $app->services->fieldPermission->getFieldConfig('destination_bank')
                                ]);
                                $this->errorResponse("شما مجاز به تغییر بانک مقصد نیستید (ردیف " . ($idx + 1) . ")");
                            }
                        }
                    }

                    // Prevent deletion of payments without permission (use canUserDeleteField)
                    if (count($existingPayments) > count($paymentsPayload)) {
                        if (!$app->services->fieldPermission->canUserDeleteField('payment_delete', $userRole)) {
                            $this->errorResponse('شما مجاز به حذف پرداخت‌ها نیستید');
                        }
                    }
                }
            }
        }

        $statusInput = Sanitizer::sanitize($_POST['deal_status'] ?? '');
        $pipelineStageInput = Sanitizer::sanitize($_POST['pipeline_stage'] ?? '');
        $stageMeansSuccess = in_array($pipelineStageInput, ['deal_success', 'اتمام']);
        $stageMeansFailed = in_array($pipelineStageInput, ['deal_failed', 'معامله ناموفق']);
        if ($stageMeansSuccess) {
            $statusInput = 'won';
            $pipelineStageInput = 'اتمام';
        } elseif ($stageMeansFailed) {
            $statusInput = 'lost';
            $pipelineStageInput = 'اتمام';
        }
        
        // Lost deals are closed and removed from the active kariz.
        if ($statusInput === 'lost' && empty($pipelineStageInput)) {
            $pipelineStageInput = 'اتمام';
        }
        
        $serviceCostInput = $_POST['service_cost'] ?? null;
        $priceListCodeInput = Sanitizer::sanitize($_POST['price_list_code'] ?? '');
        $payableAmountInput = $_POST['payable_amount'] ?? null;
        $paymentConditionsInput = Sanitizer::sanitize($_POST['payment_conditions'] ?? '');
        $dealAcquaintanceSourceInput = Sanitizer::sanitize($_POST['acquaintance_source'] ?? '');
        $dealAcquaintanceDetailInput = Sanitizer::sanitize($_POST['acquaintance_detail'] ?? '');
        $dealContentTopicInput = Sanitizer::sanitize($_POST['content_topic'] ?? '');
        $fromDuplicateLeadModal = !empty($_POST['from_duplicate_lead_modal']);

        $normalizeMoney = function ($val): float {
            $s = is_string($val) ? $val : (string)($val ?? '');
            $s = str_replace(',', '', $s);
            $s = preg_replace('/[^\d.]/', '', $s);
            return floatval($s ?: 0);
        };

        $refundAmountInput = $normalizeMoney($_POST['refund_amount'] ?? 0);
        $refundDescriptionInput = Sanitizer::sanitize($_POST['refund_description'] ?? '');
        $hasDiscountInput = intval($_POST['has_discount'] ?? 0);
        $discountTypeInput = Sanitizer::sanitize($_POST['discount_type'] ?? '');
        $discountOccasionInput = Sanitizer::sanitize($_POST['discount_occasion'] ?? '');
        $discountAmountInput = $normalizeMoney($_POST['discount_amount'] ?? 0);

        $existingDeal = null;
        $incomingDealId = Sanitizer::sanitize($_POST['deal_id'] ?? '');
        if (!$isNewDeal && $incomingDealId !== '') {
            $existingDeal = $this->dealRepo->findByDidarId($incomingDealId);
        }
        if (!$isNewDeal && $existingDeal) {
            if (!array_key_exists('acquaintance_source', $_POST)) {
                $dealAcquaintanceSourceInput = (string)($existingDeal->acquaintance_source ?? '');
            }
            if (!array_key_exists('acquaintance_detail', $_POST)) {
                $dealAcquaintanceDetailInput = (string)($existingDeal->acquaintance_detail ?? '');
            }
            if (!array_key_exists('content_topic', $_POST)) {
                $dealContentTopicInput = (string)($existingDeal->content_topic ?? '');
            }
        }
        if ($isNewDeal && $fromDuplicateLeadModal && $resolvedPerson) {
            if ($dealAcquaintanceSourceInput === '') {
                $dealAcquaintanceSourceInput = (string)($resolvedPerson->acquaintance_source ?? '');
            }
            if ($dealAcquaintanceDetailInput === '') {
                $dealAcquaintanceDetailInput = (string)($resolvedPerson->acquaintance_detail ?? '');
            }
            if ($dealContentTopicInput === '') {
                $dealContentTopicInput = (string)($resolvedPerson->content_topic ?? '');
            }
        }

        $normalizeServiceForPricing = function($value): string {
            $s = preg_replace('/\s+/', ' ', trim((string)$value));
            return $s ?? '';
        };
        $existingServiceForPricing = '';
        if ($existingDeal && !empty($existingDeal->requested_services)) {
            $decodedExistingServices = json_decode((string)$existingDeal->requested_services, true);
            if (is_array($decodedExistingServices) && !empty($decodedExistingServices)) {
                $existingServiceForPricing = $normalizeServiceForPricing($decodedExistingServices[0] ?? '');
            }
        }
        if ($existingServiceForPricing === '' && $existingDeal && !empty($existingDeal->title)) {
            $existingTitleForPricing = (string)$existingDeal->title;
            $existingServiceForPricing = strpos($existingTitleForPricing, '|') !== false
                ? $normalizeServiceForPricing(substr($existingTitleForPricing, strrpos($existingTitleForPricing, '|') + 1))
                : $normalizeServiceForPricing($existingTitleForPricing);
        }
        $incomingServiceForPricing = $normalizeServiceForPricing($title);
        $serviceChangedForPricing = $existingDeal && $existingServiceForPricing !== '' && $incomingServiceForPricing !== ''
            ? $existingServiceForPricing !== $incomingServiceForPricing
            : false;

        $resolvedPricing = ($isNewDeal || $serviceChangedForPricing)
            ? $this->resolveAllowedServicePricing($title)
            : ['service_cost' => null, 'price_list_code' => ''];
        $resolvedServiceCost = $resolvedPricing['service_cost'];
        $resolvedPriceListCode = (string)($resolvedPricing['price_list_code'] ?? '');
        if ($resolvedServiceCost !== null) {
            $serviceCostInput = $resolvedServiceCost;
        }
        if ($resolvedPriceListCode !== '') {
            $priceListCodeInput = $resolvedPriceListCode;
        }
        $effectiveDiscountAmount = ($hasDiscountInput === 1 && $discountTypeInput !== 'refund') ? $discountAmountInput : 0;
        if ($resolvedServiceCost !== null) {
            $payableAmountInput = max(0, $resolvedServiceCost - $effectiveDiscountAmount);
        }
        if (!$isNewDeal && $existingDeal && $resolvedServiceCost === null) {
            $incomingServiceCost = $normalizeMoney($serviceCostInput);
            $incomingPayableAmount = $normalizeMoney($payableAmountInput);
            $existingServiceCost = (float)($existingDeal->service_cost ?? 0);
            $existingPayableAmount = (float)($existingDeal->payable_amount ?? 0);
            if ($incomingServiceCost <= 0 && $existingServiceCost > 0) {
                $serviceCostInput = $existingServiceCost;
            }
            if ($incomingPayableAmount <= 0 && $existingPayableAmount > 0) {
                $payableAmountInput = $existingPayableAmount;
            }
            if ($priceListCodeInput === '' && trim((string)($existingDeal->price_list_code ?? '')) !== '') {
                $priceListCodeInput = (string)$existingDeal->price_list_code;
            }
            if ($incomingServiceCost <= 0 || $incomingPayableAmount <= 0 || $resolvedPriceListCode === '') {
                \App\Utils\Logger::logWarning('Preserved existing deal pricing because lookup pricing was unresolved', [
                    'deal_id' => $incomingDealId,
                    'title' => $title,
                    'incoming_service_cost' => $incomingServiceCost,
                    'incoming_payable_amount' => $incomingPayableAmount,
                    'existing_service_cost' => $existingServiceCost,
                    'existing_payable_amount' => $existingPayableAmount,
                    'incoming_price_list_code_empty' => $priceListCodeInput === '',
                    'role' => $_SESSION['role'] ?? null,
                    'user_id' => $_SESSION['user_id'] ?? null,
                ]);
            }
        }

        $failureReasonCodeInput = Sanitizer::sanitize($_POST['failure_reason'] ?? '');
        $failureReasonDescriptionInput = Sanitizer::sanitize($_POST['failure_other_description'] ?? '');
        $failureReasonTextInput = Sanitizer::sanitize($_POST['failure_reason_text'] ?? '');

        $failureReasonTextFromCodeMap = [
            'no_financial_ability' => 'عدم توان مالی',
            'no_purchase_intent' => 'فعلا قصد خرید ندارد',
            'competitor_purchase' => 'خرید از رقبا',
            'wrong_number' => 'شماره اشتباه',
            'irrelevant_lead' => 'لید نامرتبط',
            'other' => 'سایر',
        ];

        $failureReasonTextFromCode = function(string $code) use ($failureReasonTextFromCodeMap): string {
            $map = [
                'no_financial_ability' => 'عدم توان مالی',
                'no_purchase_intent' => 'فعلا قصد خرید ندارد',
                'competitor_purchase' => 'خرید از رقبا',
                'wrong_number' => 'شماره اشتباه',
                'irrelevant_lead' => 'لید نامرتبط',
                'other' => 'سایر',
            ];
            return $map[$code] ?? '';
        };

        $normalizedFailureCode = $failureReasonCodeInput !== '' ? $failureReasonCodeInput : null;
        $normalizedFailureDesc = $failureReasonDescriptionInput !== '' ? $failureReasonDescriptionInput : null;
        $normalizedFailureText = $failureReasonTextInput !== '' ? $failureReasonTextInput : null;

        if ($statusInput === 'lost') {
            // Keep backend in sync with frontend: for lost deals, reason and description are required.
            if (empty($normalizedFailureCode)) {
                if (!empty($normalizedFailureText)) {
                    $reverse = array_flip($failureReasonTextFromCodeMap ?? []);
                    if (isset($reverse[$normalizedFailureText])) {
                        $normalizedFailureCode = $reverse[$normalizedFailureText];
                    }
                }
                if (empty($normalizedFailureCode)) {
                    $this->errorResponse('دلیل ناموفق بودن معامله الزامی است');
                }
            }
            if (empty($normalizedFailureDesc)) {
                $this->errorResponse('توضیحات دلیل ناموفق بودن معامله الزامی است');
            }

            if (empty($normalizedFailureText)) {
                $base = $failureReasonTextFromCode($normalizedFailureCode);
                $normalizedFailureText = $base !== '' ? $base : $normalizedFailureCode;
            }
            if (empty($normalizedFailureText) && !empty($normalizedFailureDesc)) {
                $normalizedFailureText = $normalizedFailureDesc;
            }
        } else {
            // For non-lost deals, clear any sent failure reason data.
            $normalizedFailureCode = null;
            $normalizedFailureDesc = null;
            $normalizedFailureText = null;
        }

        $data = [
            'didar_deal_id' => Sanitizer::sanitize($_POST['deal_id'] ?? ''),
            'contact_didar_id' => $normalizedContactId,
            'title' => $title,
            'status' => $statusInput,
            'pipeline_stage' => $pipelineStageInput,
            'estimated_price' => isset($serviceCostInput) ? $normalizeMoney($serviceCostInput) : null,
            'final_price' => isset($payableAmountInput) ? $normalizeMoney($payableAmountInput) : null,
            'service_cost' => isset($serviceCostInput) ? $normalizeMoney($serviceCostInput) : null,
            'price_list_code' => $priceListCodeInput,
            'has_discount' => $hasDiscountInput,
            'discount_type' => $discountTypeInput,
            'discount_occasion' => $discountOccasionInput,
            'discount_amount' => $discountAmountInput,
            'refund_amount' => $refundAmountInput,
            'refund_description' => $refundDescriptionInput,
            'payable_amount' => isset($payableAmountInput) ? $normalizeMoney($payableAmountInput) : null,
            'payment_conditions' => $paymentConditionsInput,
            'payment_amount' => floatval($_POST['payment_amount'] ?? 0),
            'payments' => '',
            'acquaintance_source' => $dealAcquaintanceSourceInput,
            'acquaintance_detail' => $dealAcquaintanceDetailInput,
            'content_topic' => $dealContentTopicInput,
            'financial_level' => Sanitizer::sanitize($_POST['financial_level'] ?? ''),
            'asset_estimation' => Sanitizer::sanitize($_POST['asset_estimation'] ?? ''),
            'income_estimation' => Sanitizer::sanitize($_POST['income_estimation'] ?? ''),
            'has_previous_purchase' => isset($_POST['has_previous_purchase']) && $_POST['has_previous_purchase'] !== '' ? (int)$_POST['has_previous_purchase'] : null,
            'owner_didar_id' => $_SESSION['didar_id'] ?? '',
            'created_by_didar_id' => ($isNewDeal || !$existingDeal) ? ($_SESSION['didar_id'] ?? '') : ($existingDeal->created_by_didar_id ?? ''),
            'created_by_name' => ($isNewDeal || !$existingDeal)
                ? (($_SESSION['display_name'] ?? '') !== '' ? ($_SESSION['display_name'] ?? '') : ($_SESSION['name'] ?? ''))
                : ($existingDeal->created_by_name ?? ''),
            'created_via' => ($isNewDeal || !$existingDeal) ? 'manual' : ($existingDeal->created_via ?? 'manual'),
            'register_time' => ($isNewDeal || !$existingDeal)
                ? date('Y-m-d H:i:s')
                : ($existingDeal->register_time ?? date('Y-m-d H:i:s')),
            'last_sync' => date('Y-m-d H:i:s'),
            'products' => $products,
            'requested_services' => $requestedServices
        ];

        if ($isNewDeal && !$fromDuplicateLeadModal) {
            if ($data['acquaintance_source'] === '') {
                $this->errorResponse('فیلد شیوه آشنایی معامله الزامی است');
            }
        }

        if (!empty($title)) {
            $data['requested_services'] = [$title];
        }

        // Use the robust repository method to find the person
        $person = $resolvedPerson ?: $this->resolvePersonByContactId($data['contact_didar_id']);

        if (!$person) {
            $this->errorResponse('لید با این شناسه یافت نشد');
        }

        // Standardize to the real Didar GUID for ALL internal processing
        $data['contact_didar_id'] = $person->didar_contact_id;
        // --- END STRICT LOOKUP ---

        // Failure reason (stored for reporting)
        $data['failure_reason_code'] = $normalizedFailureCode;
        $data['failure_reason_text'] = $normalizedFailureText;
        $data['failure_reason_description'] = $normalizedFailureDesc;

        // ✅ Validation based on field configurations
        global $app;
        if (isset($app->repositories->fieldConfig)) {
            $configs = $app->repositories->fieldConfig->getByEntity('deal');
            
            // Map field_configurations field names to actual POST field names
            $fieldNameMapping = [
                'deal_title' => 'title',  // deal_title in config maps to title in POST
                'deal_acquaintance_source' => 'acquaintance_source',
                'deal_acquaintance_detail' => 'acquaintance_detail',
                'deal_content_topic' => 'content_topic',
            ];
            $dealMarketingConfigFields = ['deal_acquaintance_source', 'deal_acquaintance_detail', 'deal_content_topic'];
            
            foreach ($configs as $config) {
                $fieldName = $config['field_name'];
                $fieldLabel = !empty($config['field_label']) ? $config['field_label'] : $fieldName;

                // Skip payment sub-fields; آنها در بخش پرداخت جداگانه اعتبارسنجی می‌شوند
                // Also skip fields that are actually lookup group names or reference payment-related lookup groups
                $paymentRelatedFields = [
                    'payment_card_last4', 'payment_card_last_4', 'card_last4', 'card_last_4',
                    'destination_bank', 'bank_destination', 'destination_banks', 'payment_destination_banks',
                    'payment_method', 'payment_methods', 
                    'payment_delete'
                ];
                $paymentRelatedLookups = ['payment_methods', 'payment_destination_banks'];
                
                if (in_array($fieldName, $paymentRelatedFields) || 
                    in_array($config['lookup_group_code'] ?? '', $paymentRelatedLookups)) {
                    continue;
                }

                // Skip lookup groups - they don't exist as direct fields in $_POST for deals
                if (!empty($config['lookup_group_code']) && $fieldName === $config['lookup_group_code']) {
                    continue;
                }

                // Skip inactive fields
                if (empty($config['is_active'])) {
                    continue;
                }

                // موضوع محتوا معامله از فرم معامله حذف شده است؛ هیچ اعتبارسنجی روی آن انجام نشود
                if ($fieldName === 'deal_content_topic') {
                    continue;
                }

                // Map field name to actual POST field name
                $postFieldName = $fieldNameMapping[$fieldName] ?? $fieldName;
                $val = $_POST[$postFieldName] ?? null;
                if (!$isNewDeal && in_array($fieldName, $dealMarketingConfigFields, true) && !array_key_exists($postFieldName, $_POST)) {
                    continue;
                }
                
                // Conditional Visibility Check: Only validate if field should be visible
                // Pass $_POST as formData for condition evaluation
                if (!$app->services->fieldPermission->checkConditionalVisibility($fieldName, $_POST)) {
                    continue;
                }

                // Required check - only validate if field exists in POST or is a new deal
                if ($config['is_required'] && in_array($fieldName, $dealMarketingConfigFields, true) && (!$isNewDeal || $fromDuplicateLeadModal)) {
                    continue;
                }
                if ($config['is_required'] && (is_null($val) || $val === '')) {
                    \App\Utils\Logger::logWarning('DEAL_FIELD_REQUIRED_VALIDATION_FAILED', [
                        'deal_id' => $_POST['deal_id'] ?? '',
                        'field_name' => $fieldName,
                        'post_field_name' => $postFieldName,
                        'field_label' => $fieldLabel,
                        'is_required' => $config['is_required'],
                        'value' => $val,
                        'is_new_deal' => $isNewDeal,
                        'all_post_keys' => array_keys($_POST)
                    ]);
                    $this->errorResponse("فیلد {$fieldLabel} الزامی است");
                }
                
                // Regex Pattern check
                if (!empty($config['validation_pattern']) && !empty($val)) {
                    // Safety: use @ to suppress errors if regex is invalid
                    if (@preg_match('/' . str_replace('/', '\/', $config['validation_pattern']) . '/', $val) === 0) {
                        $this->errorResponse("قالب فیلد {$fieldLabel} صحیح نیست");
                    }
                }
            }
        }

        // Refund validation (as a discount type)
        if ((int)($data['has_discount'] ?? 0) === 1 && ($data['discount_type'] ?? '') === 'refund') {
            if (($data['refund_amount'] ?? 0) <= 0) {
                $this->errorResponse('مبلغ مرجوعی الزامی است');
            }
            if (empty(trim((string)($data['refund_description'] ?? '')))) {
                $this->errorResponse('توضیحات مرجوعی الزامی است');
            }
        } else {
            $data['refund_amount'] = 0;
            $data['refund_description'] = '';
        }

        if (!$isNewDeal && !empty($data['didar_deal_id'])) {
            \App\Utils\Logger::logInfo('DealController.save existing deal lookup', [
                'deal_id' => $data['didar_deal_id'],
                'contact_id' => $data['contact_didar_id'],
                'existing_deal_found' => (bool) $existingDeal,
                'existing_contact_id' => $existingDeal ? $existingDeal->contact_didar_id : null,
                'existing_title' => $existingDeal ? $existingDeal->title : null,
                'user_id' => $_SESSION['didar_id'] ?? 'unknown',
                'user_name' => $_SESSION['name'] ?? 'unknown',
                'server_time' => date('Y-m-d H:i:s')
            ]);
        }
        if ($existingDeal && !empty($existingDeal->contact_didar_id) && $existingDeal->contact_didar_id !== $data['contact_didar_id']) {
            Logger::logWarning('Deal save blocked: deal_id does not belong to contact', [
                'deal_id' => $data['didar_deal_id'],
                'incoming_contact_id' => $data['contact_didar_id'],
                'existing_contact_id' => $existingDeal->contact_didar_id,
                'incoming_title' => $data['title'] ?? '',
                'requested_services' => $data['requested_services'] ?? [],
                'user_id' => $_SESSION['didar_id'] ?? 'unknown',
                'user_name' => $_SESSION['name'] ?? 'unknown',
                'server_time' => date('Y-m-d H:i:s')
            ]);
            $this->errorResponse('شناسه معامله با لید انتخاب‌شده مطابقت ندارد. لطفا معامله درست را انتخاب کنید.');
        }

        // Preserve پایه عنوان معامله (شماره و ترتیب) و فقط خدمات را به انتهای آن اضافه کن
        $baseTitle = $data['title'];
        if ($isNewDeal) {
            try {
                $baseTitle = $this->dealService->generateDealTitle($data['contact_didar_id']);
            } catch (\Exception $e) {
                \App\Utils\Logger::logWarning('Failed to generate base title for new deal', [
                    'contact_id' => $data['contact_didar_id'],
                    'error' => $e->getMessage()
                ]);
            }
        } elseif ($existingDeal && !empty($existingDeal->title)) {
            $parts = explode('|', $existingDeal->title, 2);
            $baseTitle = trim($parts[0]);
        }
        if (empty($baseTitle) && !empty($data['contact_didar_id'])) {
            try {
                $baseTitle = $this->dealService->generateDealTitle($data['contact_didar_id']);
            } catch (\Exception $e) {
                \App\Utils\Logger::logWarning('Failed to generate base title for existing deal', [
                    'contact_id' => $data['contact_didar_id'],
                    'error' => $e->getMessage()
                ]);
            }
        }
        $servicesText = '';
        $existingRequestedServices = [];
        if ($existingDeal && !empty($existingDeal->requested_services)) {
            $decodedRequested = json_decode($existingDeal->requested_services, true);
            if (is_array($decodedRequested)) {
                $existingRequestedServices = $decodedRequested;
            }
        }
        $normalizeService = function($value): string {
            $s = trim((string)$value);
            $s = preg_replace('/\s+/', ' ', $s);
            return trim($s);
        };
        $baseTitleNormalized = $normalizeService($baseTitle);
        $filterServices = function(array $services) use ($normalizeService, $baseTitleNormalized) {
            $out = [];
            foreach ($services as $service) {
                $s = $normalizeService($service);
                if ($s === '') {
                    continue;
                }
                if ($baseTitleNormalized !== '' && $s === $baseTitleNormalized) {
                    continue;
                }
                if ($this->isGeneratedDealTitle($s)) {
                    continue;
                }
                $out[] = $s;
            }
            return array_values(array_unique($out));
        };

        if (!empty($existingRequestedServices)) {
            $existingRequestedServices = $filterServices((array)$existingRequestedServices);
        }
        if (!empty($requestedServices)) {
            $requestedServices = $filterServices((array)$requestedServices);
        }
        // Replace requested services on edit when client submits them.
        // Only fallback to existing data if nothing is submitted.
        if (empty($requestedServices) && !empty($existingRequestedServices)) {
            $requestedServices = $existingRequestedServices;
        }
        if (!empty($requestedServices)) {
            $requestedServices = array_values(array_filter(array_map('trim', (array)$requestedServices)));
        }
        if (!empty($requestedServices)) {
            $servicesText = is_array($requestedServices) ? implode('، ', array_unique(array_map('trim', $requestedServices))) : trim((string)$requestedServices);
        } elseif ($existingDeal && !empty($existingDeal->title) && strpos($existingDeal->title, '|') !== false) {
            $servicesText = trim(substr($existingDeal->title, strpos($existingDeal->title, '|') + 1));
        }
        $data['title'] = $servicesText ? trim($baseTitle . ' | ' . $servicesText) : $baseTitle;
        $data['requested_services'] = $requestedServices;

        if (empty($data['contact_didar_id'])) {
            $this->errorResponse('شناسه لید الزامی است');
        }

        // For new deals, title is generated automatically
        if (empty($data['title'])) {
            $this->errorResponse('عنوان معامله الزامی است');
        }

        if (empty($data['status'])) {
            $this->errorResponse('انتخاب وضعیت معامله الزامی است');
        }
        
        // Pipeline stage is only required if status is NOT lost (lost deals automatically get 'معامله ناموفق')
        if (empty($data['pipeline_stage']) && $data['status'] !== 'lost') {
            $this->errorResponse('انتخاب مرحله کاریز معامله الزامی است');
        }
        if (!empty($data['pipeline_stage'])) {
            $this->validatePipelineStageForAssignment(
                (string)$data['pipeline_stage'],
                $existingDeal ? (string)($existingDeal->pipeline_stage ?? '') : null,
                !$isNewDeal
            );
        }

        $requiredFieldMap = [
            'service_cost' => ['value' => $serviceCostInput, 'label' => 'هزینه خدمات'],
            'price_list_code' => ['value' => $priceListCodeInput, 'label' => 'کد لیست قیمت'],
            'payable_amount' => ['value' => $payableAmountInput, 'label' => 'مبلغ قابل پرداخت']
        ];
        foreach ($requiredFieldMap as $key => $item) {
            // Only validate if key exists in POST (manual submission). Automated flows may skip these.
            if (array_key_exists($key, $_POST)) {
                $value = $item['value'];
                if ($value === null || $value === '' || (is_string($value) && trim($value) === '')) {
                    $this->errorResponse($item['label'] . ' الزامی است');
                }
            }
        }

        $existingPaymentsDecoded = [];
        if ($existingDeal && !empty($existingDeal->payments)) {
            $decodedExisting = json_decode($existingDeal->payments, true);
            if (is_array($decodedExisting)) {
                $existingPaymentsDecoded = $decodedExisting;
            }
        }
        $existingPaymentsCount = count($existingPaymentsDecoded);
        $nextPaymentSequence = 0;
        foreach ($existingPaymentsDecoded as $existingPaymentForSequence) {
            $nextPaymentSequence = max($nextPaymentSequence, (int)($existingPaymentForSequence['payment_sequence'] ?? $existingPaymentForSequence['sequence'] ?? 0));
        }
        if (!$replacePayments && empty($paymentsPayload) && !empty($existingPaymentsDecoded)) {
            $paymentsPayload = $existingPaymentsDecoded;
        }
        $existingProducts = [];
        if ($existingDeal && !empty($existingDeal->didar_deal_id)) {
            try {
                foreach ((array)$this->dealRepo->getProductsByDealDidarId((string)$existingDeal->didar_deal_id) as $row) {
                    if (isset($row['product_id'])) {
                        $existingProducts[] = (string)$row['product_id'];
                    }
                }
            } catch (\Throwable $e) {
                $existingProducts = [];
            }
        }

        $rulesMap = $this->buildFieldRulesMap();
        $userRole = $_SESSION['role'] ?? '';

        $dateRule = $this->resolveFieldRule($rulesMap, 'deal.payments.date', ['required' => false]);
        $amountRule = $this->resolveFieldRule($rulesMap, 'deal.payments.amount', ['required' => false]);
        $methodRule = $this->resolveFieldRule($rulesMap, 'deal.payments.method', ['required' => false]);
        $descriptionRule = $this->resolveFieldRule($rulesMap, 'deal.payments.description', ['required' => false]);
        $cardRule = $this->resolveFieldRule($rulesMap, 'deal.payments.card_last4', ['required' => false]);
        $bankRule = $this->resolveFieldRule($rulesMap, 'deal.payments.bank_destination', ['required' => false]);

        $normalizedStatus = strtolower((string)$data['status']);
        $successPaymentRequirement = array_key_exists('payable_amount', $data) && $data['payable_amount'] !== null
            ? max(0.0, floatval($data['payable_amount']))
            : max(0.0, floatval($data['service_cost'] ?? 0));
        $paymentsRequired = ($normalizedStatus === 'won' || $stageMeansSuccess) && $successPaymentRequirement > 0;
        // Admin override that unlocks otherwise-locked fields on paid/submitted deals.
        $adminUnlockPaidDealFields = $this->authService->isAdmin() && (int)($_POST['admin_unlock_paid_deal_fields'] ?? 0) === 1;
        $validatedPayments = [];
        foreach ($paymentsPayload as $idx => $payment) {
            $isEntryNew = $isNewDeal || $idx >= $existingPaymentsCount;
            $existingPayment = $existingPaymentsDecoded[$idx] ?? [];
            $paymentSequence = (int)($payment['payment_sequence'] ?? $payment['sequence'] ?? 0);
            if ($paymentSequence <= 0 && isset($existingPayment['payment_sequence'])) {
                $paymentSequence = (int)$existingPayment['payment_sequence'];
            }
            if ($paymentSequence <= 0 && isset($existingPayment['sequence'])) {
                $paymentSequence = (int)$existingPayment['sequence'];
            }
            if ($paymentSequence <= 0) {
                $nextPaymentSequence++;
                $paymentSequence = $nextPaymentSequence;
            } else {
                $nextPaymentSequence = max($nextPaymentSequence, $paymentSequence);
            }

            $date = trim((string)($payment['date'] ?? ''));
            $datePersian = trim((string)($payment['date_persian'] ?? ''));
            $amount = isset($payment['amount']) ? floatval($payment['amount']) : 0;
            // Normalize method key
            $method = Sanitizer::sanitize($payment['method'] ?? $payment['payment_method'] ?? '');
            $description = Sanitizer::sanitize($payment['description'] ?? '');
            // Normalize bank key (support both destination_bank and bank_destination, use destination_bank as standard)
            $bankDestination = Sanitizer::sanitize($payment['destination_bank'] ?? $payment['bank_destination'] ?? '');
            $bankDestinationOther = Sanitizer::sanitize($payment['destination_bank_other'] ?? '');
            $accountOwnerName = Sanitizer::sanitize($payment['account_owner_name'] ?? '');
            $depositorName = Sanitizer::sanitize($payment['depositor_name'] ?? '');
            $destinationCardNumber = preg_replace('/\D+/', '', (string)Sanitizer::sanitize($payment['destination_card_number'] ?? ''));
            if (strlen($destinationCardNumber) > 4) {
                $destinationCardNumber = substr($destinationCardNumber, -4);
            }
            $destinationCardNumberOther = Sanitizer::sanitize($payment['destination_card_number_other'] ?? '');
            $destinationBankTitle = Sanitizer::sanitize($payment['destination_bank_title'] ?? '');
            $destinationCardFull = preg_replace('/\D+/', '', (string)Sanitizer::sanitize($payment['destination_card_full'] ?? ''));
            $destinationAccountNumber = preg_replace('/\s+/', '', (string)Sanitizer::sanitize($payment['destination_account_number'] ?? ''));
            $destinationSheba = strtoupper(preg_replace('/\s+/', '', (string)Sanitizer::sanitize($payment['destination_sheba'] ?? '')));
            $destinationAccountOwner = trim((string)Sanitizer::sanitize($payment['destination_account_owner'] ?? ''));
            $paymentTime = Sanitizer::sanitize($payment['payment_time'] ?? '');
            $cardReaderDevice = Sanitizer::sanitize($payment['card_reader_device'] ?? '');
            $currency = Sanitizer::sanitize($payment['currency'] ?? '');
            $currencyOther = Sanitizer::sanitize($payment['currency_other'] ?? '');
            $currencyAmount = isset($payment['currency_amount']) ? floatval($payment['currency_amount']) : 0;
            $checkBankName = Sanitizer::sanitize($payment['check_bank_name'] ?? '');
            $checkDate = Sanitizer::sanitize($payment['check_date'] ?? '');
            $checkNumber = preg_replace('/\D+/', '', $this->normalizeDigits((string)Sanitizer::sanitize($payment['check_number'] ?? '')));
            $checkOwnerName = Sanitizer::sanitize($payment['check_owner_name'] ?? '');
            // Sprint fields (C3/C4/C7/C8)
            $registeredAt = trim((string)($payment['registered_at'] ?? ''));
            $depositDate = trim((string)($payment['deposit_date'] ?? ''));
            $depositDatePersian = trim((string)($payment['deposit_date_persian'] ?? ''));
            $checkDueDate = trim((string)($payment['check_due_date'] ?? ''));
            $checkDueDatePersian = trim((string)($payment['check_due_date_persian'] ?? ''));
            $sayadiDate = Sanitizer::sanitize($payment['sayadi_date'] ?? '');
            $checkDeliveryDate = trim((string)($payment['check_delivery_date'] ?? ''));
            $checkDeliveryDatePersian = trim((string)($payment['check_delivery_date_persian'] ?? ''));
            $checkRecipientName = Sanitizer::sanitize($payment['check_recipient_name'] ?? '');
            // C2: uploaded file paths (validated/created by uploadPaymentFile). Keep only safe
            // relative paths under uploads/payments/ to avoid storing arbitrary client strings.
            $receiptImagePath = $this->sanitizePaymentUploadPath($payment['receipt_image_path'] ?? '');
            $checkImagePath = $this->sanitizePaymentUploadPath($payment['check_image_path'] ?? '');
            $checkSayadiImagePath = $this->sanitizePaymentUploadPath($payment['check_sayadi_image_path'] ?? '');

            if (!empty($payment['deleted']) || !empty($payment['is_deleted'])) {
                $validatedPayments[] = [
                    'date' => $date,
                    'date_persian' => $datePersian,
                    'amount' => 0,
                    'method' => $method,
                    'description' => '',
                    'registered_at' => $registeredAt !== '' ? $registeredAt : (string)($existingPayment['registered_at'] ?? ''),
                    'payment_sequence' => $paymentSequence,
                    'deleted' => true,
                    'deleted_at' => trim((string)($payment['deleted_at'] ?? date('Y-m-d H:i:s'))),
                ];
                continue;
            }

            $canEditDate = $this->canEditField($dateRule, $isEntryNew, $userRole);
            $canEditAmount = $this->canEditField($amountRule, $isEntryNew, $userRole);
            $canEditMethod = $this->canEditField($methodRule, $isEntryNew, $userRole);
            $canEditDescription = $this->canEditField($descriptionRule, $isEntryNew, $userRole);
            $canEditCard = $this->canEditField($cardRule, $isEntryNew, $userRole);
            $canEditBank = $this->canEditField($bankRule, $isEntryNew, $userRole);

            if (!$canEditDate) {
                if (isset($existingPayment['date'])) {
                    $date = trim((string)$existingPayment['date']);
                }
                if (isset($existingPayment['date_persian'])) {
                    $datePersian = trim((string)$existingPayment['date_persian']);
                }
            }
            if (!$canEditAmount && isset($existingPayment['amount'])) {
                $amount = floatval($existingPayment['amount']);
            }
            if (!$canEditMethod && isset($existingPayment['method'])) {
                $method = Sanitizer::sanitize($existingPayment['method']);
            }
            if (!$canEditDescription && isset($existingPayment['description'])) {
                $description = Sanitizer::sanitize($existingPayment['description']);
            }
            // Normalize existing payment bank key
            if (!$canEditBank) {
                $existingBank = $existingPayment['destination_bank'] ?? $existingPayment['bank_destination'] ?? '';
                if ($existingBank) {
                    $bankDestination = Sanitizer::sanitize($existingBank);
                }
            }

            // Normalize card_last4 key for hasAnyValue check
            $cardLast4ForCheck = $payment['card_last4'] ?? $payment['card_last_4'] ?? '';
            $hasAnyValue = (
                $date !== '' || $datePersian !== '' || $amount > 0 || $method !== '' || $description !== '' ||
                $bankDestination !== '' || trim((string)$cardLast4ForCheck) !== '' || $destinationCardNumber !== '' ||
                $paymentTime !== '' || $cardReaderDevice !== '' || $currency !== '' || $currencyAmount > 0 ||
                $checkBankName !== '' || $checkDate !== '' || $checkNumber !== '' || $checkOwnerName !== '' ||
                $depositorName !== ''
            );
            if (!$hasAnyValue) {
                continue;
            }

            $isCardTransfer = $this->isCardTransferMethod($method);
            $isCardReader = $this->isCardReaderMethod($method);
            $isCash = $this->isCashMethod($method);
            $isCheck = $this->isCheckMethod($method);
            $requiresCardLast4 = $isCardTransfer || $isCardReader;
            $requiresBankDestination = $isCardTransfer;

            // For NEW payment entries, enforce the method-appropriate mandatory fields.
            // Gated on $isEntryNew so re-saving an existing deal never retroactively
            // blocks legacy payments that predate these rules.
            $requireDate = $isEntryNew && ($isCardTransfer || $isCardReader || $isCash);
            $requireAmount = $isEntryNew && ($isCardTransfer || $isCardReader || $isCash);
            $requireMethod = $isEntryNew;
            $requireDescription = false;
            $requireCardLast4 = $isEntryNew && ($isCardTransfer || $isCardReader);
            $requireBank = $isEntryNew && $isCardTransfer;

            if ($requireDate && $date === '' && $datePersian === '') {
                $this->errorResponse('تاریخ پرداخت شماره ' . ($idx + 1) . ' الزامی است');
            }
            if ($requireAmount && $amount <= 0) {
                $this->errorResponse('مبلغ پرداخت شماره ' . ($idx + 1) . ' الزامی است');
            }
            if ($requireMethod && $method === '') {
                $this->errorResponse('نحوه پرداخت شماره ' . ($idx + 1) . ' الزامی است');
            }
            if ($requireDescription && $description === '') {
                $this->errorResponse('توضیح پرداخت شماره ' . ($idx + 1) . ' الزامی است');
            }

            // Normalize card_last4 key (support both card_last4 and card_last_4)
            $cardLast4Raw = $payment['card_last4'] ?? $payment['card_last_4'] ?? '';
            $cardLast4 = preg_replace('/\D+/', '', (string)$cardLast4Raw);
            if (!$canEditCard) {
                $existingCardLast4 = $existingPayment['card_last4'] ?? $existingPayment['card_last_4'] ?? '';
                if ($existingCardLast4) {
                    $cardLast4 = preg_replace('/\D+/', '', (string)$existingCardLast4);
                }
            }
            if (strlen($cardLast4) > 4) {
                $cardLast4 = substr($cardLast4, -4);
            }
            if ($requireCardLast4 && !preg_match('/^\d{4}$/', $cardLast4)) {
                $this->errorResponse('وارد کردن ۴ رقم آخر کارت مبدا الزامی است (ردیف ' . ($idx + 1) . ')');
            }
            if ($requireBank && $bankDestination === '') {
                $this->errorResponse('انتخاب بانک مقصد الزامی است (ردیف ' . ($idx + 1) . ')');
            }
            if ($isEntryNew && $isCardReader && trim((string)$cardReaderDevice) === '') {
                $this->errorResponse('انتخاب دستگاه کارتخوان الزامی است (ردیف ' . ($idx + 1) . ')');
            }
            if ($isEntryNew && $isCash && trim((string)$currency) === '') {
                $this->errorResponse('انتخاب نوع ارز برای پرداخت نقد الزامی است (ردیف ' . ($idx + 1) . ')');
            }
            $bankIsOther = ($bankDestination === '__other__');
            $destinationSnapshot = $this->getPaymentDestinationSnapshot($bankDestination);
            if ($isCardTransfer && !$bankIsOther && $destinationSnapshot) {
                $destinationBankTitle = $destinationSnapshot['destination_bank_title'];
                $destinationCardFull = $destinationSnapshot['destination_card_full'];
                $destinationAccountNumber = $destinationSnapshot['destination_account_number'];
                $destinationSheba = $destinationSnapshot['destination_sheba'];
                $destinationAccountOwner = $destinationSnapshot['destination_account_owner'];
                if ($destinationSnapshot['destination_card_number'] !== '') {
                    $destinationCardNumber = $destinationSnapshot['destination_card_number'];
                }
            }
            // account_owner_name is used by cheques and by card-to-card "سایر" (account holder).
            if (!$isCheck && !($isCardTransfer && $bankIsOther)) {
                $accountOwnerName = '';
            }
            if (!$isCardTransfer || $bankIsOther) {
                $destinationBankTitle = '';
                $destinationCardFull = '';
                $destinationAccountNumber = '';
                $destinationSheba = '';
                $destinationAccountOwner = '';
            }
            // نام واریز‌کننده فقط برای روش‌های کارتی (کارت به کارت/کارتخوان/شبا) نگه داشته می‌شود.
            if (!$isCardTransfer && !$isCardReader) {
                $depositorName = '';
            }

            // C6: card-to-card/sheba "سایر" requires destination number + account holder.
            if ($isCardTransfer && $bankIsOther) {
                if ($bankDestinationOther === '' || $accountOwnerName === '') {
                    $this->errorResponse('برای انتخاب «سایر» در بانک مقصد، شماره بانک مقصد و نام صاحب حساب الزامی است (ردیف ' . ($idx + 1) . ')');
                }
            }
            // C7: new cheques require source bank, owner, number, Sayadi date and amount. Existing
            // cheques may predate these rules and must not block cache-only operations like delete.
            if ($isCheck) {
                if ($isEntryNew && ($checkBankName === '' || $checkOwnerName === '' || !preg_match('/^\d{6}$/', $checkNumber) || $sayadiDate === '' || ($datePersian === '' && $date === ''))) {
                    $this->errorResponse('برای چک، نام بانک مبدا، نام صاحب چک، شماره چک ۶ رقمی، تاریخ صیادی و تاریخ سررسید چک الزامی است (ردیف ' . ($idx + 1) . ')');
                }
                if ($isEntryNew && $amount <= 0) {
                    $this->errorResponse('مبلغ چک الزامی است (ردیف ' . ($idx + 1) . ')');
                }
                if ($isEntryNew && $checkImagePath === '') {
                    $this->errorResponse('تصویر چک الزامی است (ردیف ' . ($idx + 1) . ')');
                }
                if ($isEntryNew && $checkSayadiImagePath === '') {
                    $this->errorResponse('تصویر ثبت چک صیادی الزامی است (ردیف ' . ($idx + 1) . ')');
                }
                // The main payment date doubles as the cheque due date when not explicitly set.
                if ($checkDueDatePersian === '' && $datePersian !== '') {
                    $checkDueDatePersian = $datePersian;
                }
                if ($checkDueDate === '' && $date !== '') {
                    $checkDueDate = $date;
                }
            } else {
                // Cheque-only fields are cleared for non-cheque methods.
                $checkBankName = $checkNumber = $checkOwnerName = $sayadiDate = '';
                $checkDate = $checkDueDate = $checkDueDatePersian = '';
                $checkDeliveryDate = $checkDeliveryDatePersian = $checkRecipientName = '';
                $checkImagePath = $checkSayadiImagePath = '';
            }
            // Cash payments carry no deposit receipt (no transfer slip).
            if ($isCash) {
                $receiptImagePath = '';
            }

            // C3: registration timestamp is immutable. Keep the stored value for existing
            // payments; stamp now for new ones (never trust a client-supplied change).
            if (!$isEntryNew && isset($existingPayment['registered_at']) && trim((string)$existingPayment['registered_at']) !== '') {
                $registeredAt = trim((string)$existingPayment['registered_at']);
            } elseif ($registeredAt === '') {
                $registeredAt = date('Y-m-d H:i:s');
            }

            // C7/C8: cheque identity fields and the optional delivery fields lock after the
            // payment is registered. For existing payments, preserve the stored values
            // (sales experts cannot change them; admins edit via the dedicated unlock path).
            if (!$isEntryNew && !$adminUnlockPaidDealFields) {
                $lockKeys = ['check_bank_name' => &$checkBankName, 'check_number' => &$checkNumber,
                    'check_owner_name' => &$checkOwnerName, 'sayadi_date' => &$sayadiDate,
                    'check_delivery_date' => &$checkDeliveryDate, 'check_delivery_date_persian' => &$checkDeliveryDatePersian,
                    'check_recipient_name' => &$checkRecipientName, 'check_image_path' => &$checkImagePath,
                    'check_sayadi_image_path' => &$checkSayadiImagePath];
                foreach ($lockKeys as $k => &$ref) {
                    // Write-once for the sales expert: a cheque field that ALREADY holds a stored
                    // value is locked (restore it so it can't be changed). A field that was empty
                    // stays fillable on this save, then locks on the next edit (it is now non-empty).
                    if (array_key_exists($k, $existingPayment) && trim((string)$existingPayment[$k]) !== '') {
                        $ref = (string)$existingPayment[$k];
                    }
                }
                unset($ref);
            }

            // Store with standard keys (card_last4 and destination_bank)
            $validatedPayments[] = [
                'date' => $date,
                'date_persian' => $datePersian,
                'amount' => $amount,
                'method' => $method,
                'description' => $description,
                'card_last4' => $cardLast4,  // Standard key (without underscore)
                'destination_bank' => $bankDestination,  // Standard key
                'destination_bank_other' => $bankDestinationOther,
                'account_owner_name' => $accountOwnerName,
                'depositor_name' => $depositorName,
                'destination_card_number' => $destinationCardNumber,
                'destination_card_number_other' => $destinationCardNumberOther,
                'destination_bank_title' => $destinationBankTitle,
                'destination_card_full' => $destinationCardFull,
                'destination_account_number' => $destinationAccountNumber,
                'destination_sheba' => $destinationSheba,
                'destination_account_owner' => $destinationAccountOwner,
                'payment_time' => $paymentTime,
                'card_reader_device' => $cardReaderDevice,
                'currency' => $currency,
                'currency_other' => $currencyOther,
                'currency_amount' => $currencyAmount,
                'check_bank_name' => $checkBankName,
                'check_date' => $checkDate,
                'check_number' => $checkNumber,
                'check_owner_name' => $checkOwnerName,
                // Sprint fields
                'registered_at' => $registeredAt,
                'deposit_date' => $depositDate,
                'deposit_date_persian' => $depositDatePersian,
                'check_due_date' => $checkDueDate,
                'check_due_date_persian' => $checkDueDatePersian,
                'sayadi_date' => $sayadiDate,
                'check_delivery_date' => $checkDeliveryDate,
                'check_delivery_date_persian' => $checkDeliveryDatePersian,
                'check_recipient_name' => $checkRecipientName,
                'receipt_image_path' => $receiptImagePath,
                'check_image_path' => $checkImagePath,
                'check_sayadi_image_path' => $checkSayadiImagePath,
                'payment_sequence' => $paymentSequence
            ];
        }

        $activeValidatedPayments = array_values(array_filter($validatedPayments, function($payment) {
            return empty($payment['deleted']) && empty($payment['is_deleted']);
        }));
        $isFirstPaymentNow = (!$this->hasPositivePayments($existingPaymentsDecoded) && $this->hasPositivePayments($activeValidatedPayments));
        $paymentChanges = $this->detectPaymentPermissionChanges($existingPaymentsDecoded, $validatedPayments);
        if ($paymentChanges['create']) {
            $this->requireRbac('action.payments.create', 'دسترسی محدود - شما مجاز به ثبت پرداخت نیستید');
        }
        if ($paymentChanges['edit']) {
            $this->requireRbac('action.payments.edit', 'دسترسی محدود - شما مجاز به ویرایش پرداخت نیستید');
        }
        if ($paymentChanges['delete']) {
            $this->requireRbac('action.payments.delete', 'دسترسی محدود - شما مجاز به حذف پرداخت نیستید');
        }

        if ($paymentsRequired && empty($activeValidatedPayments)) {
            $this->errorResponse('ثبت حداقل یک پرداخت کامل برای معامله الزامی است');
        }
        if (!$paymentsRequired && empty($validatedPayments) && !empty($paymentsPayload)) {
            $this->errorResponse('اطلاعات پرداخت ناقص است');
        }

        if (!$replacePayments && empty($validatedPayments) && $existingDeal && !empty($existingDeal->payments)) {
            $storedPayments = json_decode($existingDeal->payments, true);
            if (is_array($storedPayments) && !empty($storedPayments)) {
                $validatedPayments = $storedPayments;
            }
        }
        $activeValidatedPayments = array_values(array_filter($validatedPayments, function($payment) {
            return empty($payment['deleted']) && empty($payment['is_deleted']);
        }));

        $data['payments'] = ($replacePayments || !empty($validatedPayments))
            ? json_encode(array_values($validatedPayments), JSON_UNESCAPED_UNICODE)
            : '';
        $data['payment_amount'] = array_reduce($activeValidatedPayments, function($sum, $p) {
            return $sum + (isset($p['amount']) ? floatval($p['amount']) : 0);
        }, 0);
        $payableAmount = isset($data['payable_amount']) ? floatval($data['payable_amount']) : 0;
        $paymentAmount = floatval($data['payment_amount']);
        if ($paymentAmount > 0 && $payableAmount <= 0) {
            $this->errorResponse('ثبت پرداخت بدون مبلغ قرارداد مجاز نیست');
        }

        $adminUnlockPaidDealFields = $this->authService->isAdmin() && (int)($_POST['admin_unlock_paid_deal_fields'] ?? 0) === 1;
        $dealHadPaymentsBefore = $this->hasPositivePayments($existingPaymentsDecoded);
        $dealHasPaymentsAfterSave = $this->hasPositivePayments($validatedPayments);
        if (!$isNewDeal && $existingDeal && $dealHadPaymentsBefore && !$adminUnlockPaidDealFields) {
            $this->restorePaidDealLockedFields($data, $existingDeal, $existingRequestedServices, $existingProducts);
        }
        if ($dealHasPaymentsAfterSave && !$adminUnlockPaidDealFields) {
            if (empty($data['status'])) {
                $data['status'] = 'Pending';
            }
            $data['pipeline_stage'] = $this->getPaidDealAutoPipelineStage((float)($data['payable_amount'] ?? 0), $paymentAmount);
        }
        $successRequiredAmount = array_key_exists('payable_amount', $data) && $data['payable_amount'] !== null
            ? max(0.0, (float)$data['payable_amount'])
            : max(0.0, (float)($data['service_cost'] ?? 0));
        if (strtolower((string)($data['status'] ?? '')) === 'won' && $successRequiredAmount > 0 && ($paymentAmount + 0.0001) < $successRequiredAmount) {
            $this->errorResponse('برای موفق شدن معامله، مجموع پرداخت‌ها باید حداقل برابر مبلغ قابل پرداخت باشد');
        }
        if (!$isNewDeal) {
            $ignoreKeys = (!$adminUnlockPaidDealFields && $dealHadPaymentsBefore) ? ['payment_conditions', 'pipeline_stage'] : [];
            $hasNonPaymentChanges = $this->hasNonPaymentDealChanges($existingDeal, $data, $ignoreKeys);
            if ($hasNonPaymentChanges) {
                $this->requireRbac('action.deals.edit', 'دسترسی محدود - شما مجاز به ویرایش معامله نیستید');
            }
        }

        \App\Utils\Logger::logInfo('DealController.save validated payments', [
            'deal_id' => $data['didar_deal_id'],
            'contact_id' => $data['contact_didar_id'],
            'payments_count' => count($validatedPayments),
            'status' => $data['status'],
            'pipeline_stage' => $data['pipeline_stage']
        ]);
        
        # The person has already been loaded and validated earlier
        # and $data['contact_didar_id'] is already standardized to a GUID.
        
        // Enforce owner consistency and permission
        $currentUserId = $_SESSION['didar_id'] ?? '';
        \App\Utils\Logger::logInfo('DealController.save validating permissions', [
            'current_user_id' => $currentUserId,
            'contact_owner_id' => $person->owner_didar_id,
            'contact_id' => $person->didar_contact_id,
            'rbac_scope_check' => 'deals'
        ]);

        $this->assertOwnerInDealScopeByDidar((string)($person->owner_didar_id ?? ''));
        // Force deal owner to match lead owner
        $data['owner_didar_id'] = $person->owner_didar_id;
        
        // Extra logging for troubleshooting
        \App\Utils\Logger::logInfo('DealController.save payload - DETAILED', [
            'didar_deal_id' => $data['didar_deal_id'],
            'contact_didar_id' => $data['contact_didar_id'],
            'title' => $data['title'],
            'base_title_used' => $baseTitle,
            'services_text' => $servicesText,
            'requested_services_count' => count($requestedServices),
            'pipeline_stage' => $data['pipeline_stage'],
            'status' => $data['status'],
            'payable_amount' => $data['payable_amount'],
            'payment_amount' => $data['payment_amount'],
            'payments_present' => !empty($data['payments']),
            'requested_services' => $data['requested_services'],
            'user_id' => $_SESSION['didar_id'] ?? 'unknown',
            'user_name' => $_SESSION['name'] ?? 'unknown',
            'contact_owner_id' => $person->owner_didar_id,
            'contact_name' => trim(($person->first_name ?? '') . ' ' . ($person->last_name ?? '')),
            'existing_deal' => $existingDeal ? 'YES' : 'NO',
            'existing_deal_id' => $existingDeal ? $existingDeal->didar_deal_id : null,
            'existing_deal_status' => $existingDeal ? $existingDeal->status : null,
            'server_time' => date('Y-m-d H:i:s'),
            'session_data' => [
                'didar_id' => $_SESSION['didar_id'] ?? null,
                'name' => $_SESSION['name'] ?? null,
                'user_id' => $_SESSION['user_id'] ?? null
            ]
        ]);

        try {
            $pdo = $this->db->getPdo();
            if (!$pdo->inTransaction()) {
                $pdo->beginTransaction();
            }

            // Log changes before saving if updating
            if ($existingDeal) {
                $this->logFieldChanges('deal', $existingDeal->didar_deal_id, $existingDeal->toArray(), $data);
            }

            $savedDeal = $this->dealService->saveDeal($data);

            if ($replacePayments || !empty($validatedPayments)) {
                $syncDealId = $savedDeal->didar_deal_id ?? $data['didar_deal_id'];
                if (!empty($validatedPayments)) {
                    $invoiceCode = $this->dealService->ensureInvoiceCodeForDeal((string)$syncDealId, $validatedPayments);
                    if ($invoiceCode) {
                        $data['invoice_code'] = $invoiceCode;
                    }
                }
                $this->dealService->syncTransactionsFromPayments((string)$syncDealId, $validatedPayments);
            }

            if ($pdo->inTransaction()) {
                $pdo->commit();
            }

            if (!empty($requestedServices) || !empty($data['title'])) {
                try {
                    $this->dealService->ensureDealProductsFromText(
                        $data['didar_deal_id'],
                        $requestedServices,
                        $data['title']
                    );
                } catch (\Exception $e) {
                    \App\Utils\Logger::logWarning('ensureDealProductsFromText failed', [
                        'deal_id' => $data['didar_deal_id'],
                        'error' => $e->getMessage()
                    ]);
                }
            }

            // AUDIT TRAIL: Log deal creation/update for data integrity tracking
            \App\Utils\Logger::logInfo('AUDIT: Deal saved successfully', [
                'action' => $isNewDeal ? 'CREATE_DEAL' : 'UPDATE_DEAL',
                'deal_id' => $data['didar_deal_id'],
                'contact_id' => $data['contact_didar_id'],
                'contact_owner_id' => $data['owner_didar_id'],
                'user_id' => $_SESSION['didar_id'] ?? 'unknown',
                'user_name' => $_SESSION['name'] ?? 'unknown',
                'deal_title' => $data['title'],
                'deal_status' => $data['status'],
                'pipeline_stage' => $data['pipeline_stage'],
                'payable_amount' => $data['payable_amount'],
                'payment_amount' => $data['payment_amount'],
                'server_time' => date('Y-m-d H:i:s'),
                'ip_address' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
                'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown'
            ]);
        } catch (\Exception $e) {
            try {
                $pdo = $this->db->getPdo();
                if ($pdo->inTransaction()) {
                    $pdo->rollBack();
                }
            } catch (\Throwable $rollbackError) {
                \App\Utils\Logger::logWarning('Deal save rollback failed', [
                    'deal_id' => $data['didar_deal_id'] ?? '',
                    'error' => $rollbackError->getMessage()
                ]);
            }
            \App\Utils\Logger::logError('Failed to save deal', $e, [
                'deal_id' => $data['didar_deal_id'],
                'contact_id' => $data['contact_didar_id'],
                'user_id' => $_SESSION['didar_id'] ?? 'unknown'
            ]);
            $this->errorResponse($e->getMessage() ?: 'خطا در ذخیره معامله');
        }

        // Update person financial fields so profile modal shows them
        $finFields = [];
        if (!empty($data['financial_level'])) $finFields['financial_level'] = $data['financial_level'];
        if (!empty($data['asset_estimation'])) $finFields['asset_estimation'] = $data['asset_estimation'];
        if (!empty($data['income_estimation'])) $finFields['income_estimation'] = $data['income_estimation'];
        if ($data['has_previous_purchase'] !== null) $finFields['has_previous_purchase'] = $data['has_previous_purchase'];
        if (!empty($finFields)) {
            try {
                $pdo = $this->personRepo->getDb()->getPdo();
                $setParts = [];
                $params = [];
                foreach ($finFields as $k => $v) {
                    $setParts[] = "$k = ?";
                    $params[] = $v;
                }
                $params[] = $data['contact_didar_id'];
                $stmt = $pdo->prepare("UPDATE persons SET " . implode(', ', $setParts) . " WHERE didar_contact_id = ?");
                $stmt->execute($params);
            } catch (\Exception $e) {
                \App\Utils\Logger::logWarning('Failed to update person financial fields on deal save', [
                    'contact_id' => $data['contact_didar_id'],
                    'error' => $e->getMessage()
                ]);
            }
        }

        if (strtolower((string)($data['status'] ?? '')) === 'won') {
            $this->syncSuccessfulDealSideEffects((string)$data['didar_deal_id'], (string)$data['contact_didar_id']);
        } elseif (strtolower((string)($data['status'] ?? '')) === 'lost') {
            $this->syncFailedDealSideEffects((string)$data['didar_deal_id'], (string)$data['contact_didar_id']);
        }

        $this->successResponse([], 'معامله با موفقیت ثبت شد');
    }

    public function setStatus()
    {
        $this->requireRbac('deals.status.edit', 'دسترسی محدود - شما مجاز به تغییر وضعیت معامله نیستید');
        $dealId = Sanitizer::sanitize($_POST['deal_id'] ?? '');
        $status = Sanitizer::sanitize($_POST['status'] ?? '');
        $contactId = Sanitizer::sanitize($_POST['contact_id'] ?? '');
        $pipelineStage = Sanitizer::sanitize($_POST['pipeline_stage'] ?? '');
        $failureReasonCode = Sanitizer::sanitize($_POST['failure_reason'] ?? '');
        $failureReasonDescription = Sanitizer::sanitize($_POST['failure_other_description'] ?? '');
        $failureReasonText = Sanitizer::sanitize($_POST['failure_reason_text'] ?? '');
        
        if (empty($dealId) || empty($status)) {
            $this->errorResponse('شناسه معامله و وضعیت الزامی است');
        }
        $scopeDeal = $this->dealRepo->findByDidarId($dealId);
        if (!$scopeDeal) {
            $this->errorResponse('معامله یافت نشد', 404);
        }
        $this->assertDealInScope($scopeDeal);
        \App\Utils\Logger::logInfo('setStatus called', [
            'deal_id' => $dealId,
            'status' => $status,
            'contact_id' => $contactId,
            'pipeline_stage' => $pipelineStage
        ]);
        
        $statusText = $status === '1' ? 'Won' : 'Lost';
        $timeField = $status === '1' ? 'won_time' : 'lost_time';
        $isParkedDeal = $this->dealService->isDealParked($scopeDeal);

        if ($isParkedDeal && !in_array($statusText, ['Won', 'Lost'], true)) {
            $this->errorResponse('معامله پارک‌شده فقط می‌تواند موفق یا ناموفق شود');
        }

        // Ensure payments exist for successful status
        if ($status === '1') {
            $deal = $this->dealRepo->findByDidarId($dealId);
            $this->assertDealCanBeMarkedWon($deal);
            $requiredAmount = $this->getSuccessRequiredPaymentAmount($deal);
            $paymentsDecoded = [];
            if ($deal && !empty($deal->payments)) {
                $paymentsDecoded = json_decode($deal->payments, true) ?: [];
            }
            $hasValidPayment = false;
            foreach ($paymentsDecoded as $p) {
                $amount = isset($p['amount']) ? floatval($p['amount']) : 0;
                $date = trim((string)($p['date'] ?? ''));
                $datePersian = trim((string)($p['date_persian'] ?? ''));
                $method = trim((string)($p['method'] ?? ''));
                if ($amount > 0 && ($date !== '' || $datePersian !== '') && $method !== '') {
                    $hasValidPayment = true;
                    break;
                }
            }
            if ($requiredAmount > 0 && !$hasValidPayment) {
                \App\Utils\Logger::logWarning('setStatus blocked: no valid payments for Won', ['deal_id' => $dealId, 'contact_id' => $contactId, 'payments_count' => count($paymentsDecoded)]);
                $this->errorResponse('برای ثبت معامله موفق، پرداخت کامل ثبت نشده است');
            }
        }
        
        $this->dealService->updateStatus($dealId, $statusText, $timeField);
        if ($statusText === 'Lost') {
            $this->dealService->updatePipelineStage($dealId, 'اتمام');
        } elseif (!empty($pipelineStage)) {
            if ($isParkedDeal) {
                $this->errorResponse('مرحله کاریز معامله پارک‌شده قابل تغییر نیست');
            }
            $this->validatePipelineStageForAssignment($pipelineStage, (string)($scopeDeal->pipeline_stage ?? ''), false);
            $this->dealService->updatePipelineStage($dealId, $pipelineStage);
        }

        if ($isParkedDeal) {
            try {
                $this->dealService->moveDealOutOfParked($dealId, 'اتمام');
            } catch (\Throwable $e) {
                Logger::logWarning('Failed to clear parked state after closing parked deal', [
                    'deal_id' => $dealId,
                    'status' => $statusText,
                    'error' => $e->getMessage(),
                ]);
            }
        }

        // Persist failure reason for reporting (only for Lost)
        if ($statusText === 'Lost') {
            $map = [
                'no_financial_ability' => 'عدم توان مالی',
                'no_purchase_intent' => 'فعلا قصد خرید ندارد',
                'competitor_purchase' => 'خرید از رقبا',
                'wrong_number' => 'شماره اشتباه',
                'irrelevant_lead' => 'لید نامرتبط',
                'other' => 'سایر',
            ];
            $code = $failureReasonCode !== '' ? $failureReasonCode : null;
            $desc = $failureReasonDescription !== '' ? $failureReasonDescription : null;
            $text = $failureReasonText !== '' ? $failureReasonText : null;
            if (!$code && $text) {
                $reverse = array_flip($map);
                if (isset($reverse[$text])) {
                    $code = $reverse[$text];
                }
            }
            if (empty($code)) {
                $this->errorResponse('دلیل ناموفق بودن معامله الزامی است');
            }
            if ($text === null && $code) {
                $text = $map[$code] ?? $code;
            }
            // Note: older clients might send Persian text instead of code in failure_reason
            if ($code && !isset($map[$code]) && $text === null) {
                $text = $code;
                $code = null;
            }
            $this->dealRepo->updateFailureReason($dealId, $code, $text, $desc);
        } else {
            // Clearing reason when marking as Won
            $this->dealRepo->updateFailureReason($dealId, null, null, null);
        }

        // Sync lead sale_status with successful deal
        if (!empty($contactId)) {
            $saleStatus = $status === '1' ? 1 : 0;
            try {
                $person = $this->personRepo->findByDidarId($contactId);
                $currentSaleStatus = $person ? (int) $person->sale_status : null;
                $shouldUpdate = true;
                // اگر یک بار موفق شده، با معامله ناموفق، لید را ناموفق نکن
                if ($saleStatus === 0 && $currentSaleStatus === 1) {
                    $shouldUpdate = false;
                }
                if ($shouldUpdate) {
                    $this->personRepo->updateSaleStatus($contactId, $saleStatus);
                }
            } catch (\Exception $e) {
                \App\Utils\Logger::logWarning('Failed to update sale_status for contact', [
                    'contact_id' => $contactId,
                    'status' => $saleStatus,
                    'error' => $e->getMessage()
                ]);
            }
        }
        if ($status === '1' && !empty($contactId)) {
            $this->syncSuccessfulDealSideEffects($dealId, $contactId);
        } elseif (!empty($contactId)) {
            $this->syncFailedDealSideEffects($dealId, $contactId);
        }
        $this->successResponse([], 'وضعیت معامله با موفقیت تغییر یافت');
    }

    public function forceSuccessAndExitKariz()
    {
        $this->requireRbac('deals.force_success', 'دسترسی محدود - شما مجاز به نهایی‌سازی اجباری معامله نیستید');
        $dealId = Sanitizer::sanitize($_POST['deal_id'] ?? '');
        if ($dealId === '') {
            $this->errorResponse('شناسه معامله الزامی است');
        }

        $deal = $this->dealRepo->findByDidarId($dealId);
        if (!$deal) {
            $this->errorResponse('معامله یافت نشد', 404);
        }
        $this->assertDealInScope($deal);

        $contactId = trim((string)($deal->contact_didar_id ?? ''));
        if ($contactId === '') {
            $this->errorResponse('شناسه لید معامله نامعتبر است');
        }

        if (trim((string)($deal->status ?? '')) !== 'Won') {
            $this->errorResponse('این عملیات فقط برای معاملات موفق مجاز است');
        }

        $this->assertDealCanBeMarkedWon($deal);

        $this->dealService->updateStatus($dealId, 'Won', 'won_time');
        $this->dealService->updatePipelineStage($dealId, 'اتمام');
        $this->dealRepo->updateFailureReason($dealId, null, null, null);
        $this->syncSuccessfulDealSideEffects($dealId, $contactId);

        if ($this->auditService) {
            $this->auditService->log('force_success_exit_kariz', 'deal', $dealId, [
                'contact_id' => $contactId,
                'pipeline_stage' => 'اتمام',
                'status' => 'Won',
            ]);
        }

        $this->successResponse([], 'معامله با موفقیت نهایی شد و از کاریز خارج شد');
    }

    public function setPipelineStage()
    {
        $this->requireRbac('deals.pipeline.edit', 'دسترسی محدود - شما مجاز به ویرایش مرحله معامله نیستید');
        $dealId = Sanitizer::sanitize($_POST['deal_id'] ?? '');
        $stage = Sanitizer::sanitize($_POST['pipeline_stage'] ?? '');
        if (empty($dealId) || empty($stage)) {
            $this->errorResponse('شناسه معامله و مرحله الزامی است');
        }
        $scopeDeal = $this->dealRepo->findByDidarId($dealId);
        if (!$scopeDeal) {
            $this->errorResponse('معامله یافت نشد', 404);
        }
        $this->assertDealInScope($scopeDeal);
        if ($this->dealService->isDealParked($scopeDeal)) {
            $this->errorResponse('معامله پارک‌شده قابل جابه‌جایی بین مراحل کاریز نیست');
        }
        $stageNormalized = $stage;
        $isSuccessStage = in_array($stage, ['deal_success', 'اتمام', 'موفق', 'معامله موفق']);
        $isFailedStage = in_array($stage, ['deal_failed', 'معامله ناموفق', 'ناموفق']);
        if ($isSuccessStage) {
            $deal = $this->dealRepo->findByDidarId($dealId);
            $this->assertDealCanBeMarkedWon($deal);
            $stageNormalized = 'اتمام';
            $this->dealService->updateStatus($dealId, 'Won', 'won_time');
        } elseif ($isFailedStage) {
            $stageNormalized = 'اتمام';
            $this->dealService->updateStatus($dealId, 'Lost', 'lost_time');
        } else {
            $this->validatePipelineStageForAssignment($stageNormalized, (string)($scopeDeal->pipeline_stage ?? ''), false);
        }
        $this->dealService->updatePipelineStage($dealId, $stageNormalized);
        $deal = $this->dealRepo->findByDidarId($dealId);
        $contactId = trim((string)($deal->contact_didar_id ?? ''));
        if ($isSuccessStage && $contactId !== '') {
            $this->syncSuccessfulDealSideEffects($dealId, $contactId);
        } elseif ($isFailedStage && $contactId !== '') {
            $this->syncFailedDealSideEffects($dealId, $contactId);
        } elseif ($contactId !== '') {
            try {
                $this->virtualStageService->update($contactId);
            } catch (\Throwable $e) {
                Logger::logWarning('Failed to refresh virtual stage after pipeline stage change', [
                    'deal_id' => $dealId,
                    'contact_id' => $contactId,
                    'error' => $e->getMessage(),
                ]);
            }
        }
        $this->successResponse([], 'مرحله کاریز معامله به‌روزرسانی شد');
    }

    public function setCourseStatus()
    {
        $this->requireRbac('deals.course.edit', 'دسترسی محدود - شما مجاز به ویرایش وضعیت دوره نیستید');
        $data = [
            'deal_id' => Sanitizer::sanitize($_POST['deal_id'] ?? ''),
            'contact_id' => Sanitizer::sanitize($_POST['contact_id'] ?? ''),
            'course_virtual' => Sanitizer::sanitize($_POST['course_virtual'] ?? ''),
            'course_online' => Sanitizer::sanitize($_POST['course_online'] ?? ''),
            'access_created' => Sanitizer::sanitize($_POST['access_created'] ?? ''),
            'link_sent' => Sanitizer::sanitize($_POST['link_sent'] ?? ''),
            'course_date' => Sanitizer::sanitize($_POST['course_date'] ?? ''),
            'next_stage' => Sanitizer::sanitize($_POST['next_stage'] ?? 'برگزاری دوره'),
            'refund' => Sanitizer::sanitize($_POST['refund'] ?? ''),
            'refund_reason' => Sanitizer::sanitize($_POST['refund_reason'] ?? '')
        ];

        if (empty($data['deal_id'])) {
            $this->errorResponse('شناسه معامله الزامی است');
        }
        $scopeDeal = $this->dealRepo->findByDidarId($data['deal_id']);
        if (!$scopeDeal) {
            $this->errorResponse('معامله یافت نشد', 404);
        }
        $this->assertDealInScope($scopeDeal);

        $this->dealService->updateCourseStatus($data);
        $this->successResponse([], 'وضعیت دوره/پشتیبانی معامله به‌روز شد');
    }

    public function delete()
    {
        $this->requireRbac('deals.delete', 'دسترسی محدود - شما مجاز به حذف معامله نیستید');
        
        $dealId = Sanitizer::sanitize($_POST['deal_id'] ?? '');
        
        if (empty($dealId)) {
            $this->errorResponse('شناسه معامله الزامی است');
        }

        $deal = $this->dealRepo->findByDidarId($dealId);
        if (!$deal) {
            $this->errorResponse('معامله یافت نشد', 404);
        }
        $this->assertDealInScope($deal);
        if ($this->auditService) {
            $this->auditService->log('delete', 'deal', $dealId, [
                'deal' => $deal ? $deal->toArray() : null,
                'deal_title' => $deal ? ($deal->title ?? '') : '',
            ]);
        }

        $this->dealService->deleteDeal($dealId);
        $this->successResponse([], 'معامله با موفقیت حذف شد');
    }

    public function getDeal()
    {
        $this->requireRbac('deals.detail.view', 'دسترسی محدود - شما مجاز به مشاهده جزئیات معامله نیستید');
        $dealId = Sanitizer::sanitize($_POST['deal_id'] ?? '');
        if (empty($dealId)) {
            $this->errorResponse('شناسه معامله الزامی است');
        }
        
        $deal = $this->dealService->getDeal($dealId);
        if (!$deal) {
            $this->errorResponse('معامله یافت نشد');
        }
        $this->assertDealInScope($deal);
        
        // Parse payments JSON
        $payments = [];
        if (!empty($deal->payments)) {
            $payments = json_decode($deal->payments, true) ?: [];
        }
        
        $dealArray = $deal->toArray();
        $dealArray['payments'] = $payments;
        $dealArray['has_registered_payments'] = $this->hasPositivePayments($payments);
        $dealArray['registered_payments_count'] = count(array_filter($payments, static function ($payment) {
            return (float)($payment['amount'] ?? 0) > 0;
        }));
        
        // Add explicit contact info to prevent frontend from doing a wrong lookup
        if (isset($deal->contact_name)) {
            $dealArray['contact_name'] = $deal->contact_name;
        }
        if (isset($deal->contact_mobile)) {
            $dealArray['contact_mobile'] = $deal->contact_mobile;
        }
        if (isset($deal->local_person_id)) {
            $dealArray['local_person_id'] = $deal->local_person_id;
        }

        // Load products
        $products = $this->dealRepo->getProductsByDealDidarId($deal->didar_deal_id);
        $dealArray['products'] = $products;
        
        $this->successResponse(['deal' => $dealArray]);
    }

    public function reactivate()
    {
        $this->requireRbac('deals.reactivate', 'دسترسی محدود - شما مجاز به فعال‌سازی مجدد معامله نیستید');

        $dealId = Sanitizer::sanitize($_POST['deal_id'] ?? '');
        $newPipelineStage = Sanitizer::sanitize($_POST['new_pipeline_stage'] ?? '');
        $reactivationReason = Sanitizer::sanitize($_POST['reactivation_reason'] ?? '');

        if (empty($dealId)) {
            $this->errorResponse('شناسه معامله الزامی است');
        }

        if (empty($newPipelineStage)) {
            $this->errorResponse('مرحله کاریز جدید الزامی است');
        }

        // بررسی وجود معامله
        $deal = $this->dealRepo->findByDidarId($dealId);
        if (!$deal) {
            $this->errorResponse('معامله یافت نشد');
        }
        $this->assertDealInScope($deal);

        // بررسی اینکه معامله بسته شده باشد
        if ($deal->status === 'Pending') {
            $this->errorResponse('این معامله هم اکنون فعال است');
        }

        if ($deal->status !== 'Won' && $deal->status !== 'Lost') {
            $this->errorResponse('فقط معاملات بسته شده (موفق یا ناموفق) قابل فعال‌سازی مجدد هستند');
        }

        $this->validatePipelineStageForAssignment($newPipelineStage);

        \App\Utils\Logger::logInfo('Deal reactivation requested', [
            'deal_id' => $dealId,
            'old_status' => $deal->status,
            'old_stage' => $deal->pipeline_stage,
            'new_stage' => $newPipelineStage,
            'reason' => $reactivationReason
        ]);

        try {
            $this->dealService->reactivateDeal($dealId, $newPipelineStage, $reactivationReason);

            // Audit logging
            if ($this->auditService) {
                $this->auditService->log('reactivate', 'deal', $dealId, [
                    'old_status' => $deal->status,
                    'old_stage' => $deal->pipeline_stage,
                    'new_stage' => $newPipelineStage,
                    'reason' => $reactivationReason,
                    'user_id' => $_SESSION['user_id'] ?? null,
                    'user_name' => $_SESSION['name'] ?? 'Unknown'
                ]);
            }

        $this->successResponse([], 'معامله با موفقیت فعال شد');

        } catch (\Exception $e) {
            \App\Utils\Logger::logError('Failed to reactivate deal', $e, [
                'deal_id' => $dealId,
                'new_stage' => $newPipelineStage
            ]);
            $this->errorResponse('خطا در فعال‌سازی مجدد معامله: ' . $e->getMessage());
        }
    }

    public function moveToParked()
    {
        $this->assertAdminOnly();
        $dealId = Sanitizer::sanitize($_POST['deal_id'] ?? '');
        $reason = Sanitizer::sanitize($_POST['parked_reason'] ?? '');
        if ($dealId === '') {
            $this->errorResponse('شناسه معامله الزامی است');
        }

        $deal = $this->dealRepo->findByDidarId($dealId);
        if (!$deal) {
            $this->errorResponse('معامله یافت نشد', 404);
        }
        $this->assertDealInScope($deal);

        try {
            $this->dealService->moveDealToParked($dealId, (string)($_SESSION['didar_id'] ?? ''), $reason);
            if ($this->auditService) {
                $this->auditService->log('move_to_parked', 'deal', $dealId, [
                    'pipeline_stage' => $deal->pipeline_stage ?? '',
                    'parked_reason' => $reason,
                ]);
            }
            $this->successResponse([], 'معامله به بخش پارک‌شده منتقل شد');
        } catch (\Throwable $e) {
            $this->errorResponse($e->getMessage());
        }
    }

    public function moveOutOfParked()
    {
        $this->assertAdminOnly();
        $dealId = Sanitizer::sanitize($_POST['deal_id'] ?? '');
        $targetStage = Sanitizer::sanitize($_POST['target_stage'] ?? '');
        if ($dealId === '') {
            $this->errorResponse('شناسه معامله الزامی است');
        }

        $deal = $this->dealRepo->findByDidarId($dealId);
        if (!$deal) {
            $this->errorResponse('معامله یافت نشد', 404);
        }
        $this->assertDealInScope($deal);

        try {
            $this->dealService->moveDealOutOfParked($dealId, $targetStage !== '' ? $targetStage : null);
            if ($this->auditService) {
                $this->auditService->log('move_out_of_parked', 'deal', $dealId, [
                    'target_stage' => $targetStage !== '' ? $targetStage : ($deal->parked_from_stage ?? $deal->pipeline_stage ?? ''),
                ]);
            }
            $this->successResponse([], 'معامله به مسیر کاریز بازگردانده شد');
        } catch (\Throwable $e) {
            $this->errorResponse($e->getMessage());
        }
    }

    public function moveStageToParked()
    {
        $this->assertAdminOnly();

        $stage = Sanitizer::sanitize($_POST['pipeline_stage'] ?? '');
        $reason = Sanitizer::sanitize($_POST['parked_reason'] ?? '');
        $requestedOwnerId = Sanitizer::sanitize($_POST['owner_didar_id'] ?? '');
        $dryRun = in_array(strtolower((string)($_POST['dry_run'] ?? '')), ['1', 'true', 'yes'], true);

        if ($stage === '') {
            $this->errorResponse('مرحله کاریز الزامی است');
        }

        $filters = [];
        $didarId = (string)($_SESSION['didar_id'] ?? '');
        $this->applyOwnerScopeToFilters($filters, 'deals', $requestedOwnerId, $didarId);
        $filters['legacy'] = '0';

        if ($requestedOwnerId !== '' && empty($filters['owner_didar_id']) && empty($filters['owner_didar_ids'])) {
            $filters['owner_didar_id'] = $requestedOwnerId;
        }

        try {
            $beforeSummary = $this->dealRepo->getStageParkingSummary($stage, $filters);
            if ($dryRun) {
                $this->successResponse([
                    'summary' => [
                        'before' => $beforeSummary,
                        'moved_count' => 0,
                        'skipped_closed' => (int)($beforeSummary['skipped_closed'] ?? 0),
                        'skipped_legacy_pending' => (int)($beforeSummary['skipped_legacy_pending'] ?? 0),
                        'skipped_legacy_closed' => (int)($beforeSummary['skipped_legacy_closed'] ?? 0),
                        'already_parked_pending' => (int)($beforeSummary['already_parked_pending'] ?? 0),
                        'remaining_eligible' => (int)($beforeSummary['eligible_pending'] ?? 0),
                    ],
                ], 'پیش‌نمایش انتقال گروهی آماده است');
            }
            $movedCount = $this->dealService->moveStageDealsToParked($stage, $didarId, $reason, $filters);
            $afterSummary = $this->dealRepo->getStageParkingSummary($stage, $filters);
            $summary = [
                'before' => $beforeSummary,
                'after' => $afterSummary,
                'moved_count' => $movedCount,
                'skipped_closed' => (int)($beforeSummary['skipped_closed'] ?? 0),
                'skipped_legacy_pending' => (int)($beforeSummary['skipped_legacy_pending'] ?? 0),
                'skipped_legacy_closed' => (int)($beforeSummary['skipped_legacy_closed'] ?? 0),
                'already_parked_pending' => (int)($beforeSummary['already_parked_pending'] ?? 0),
                'remaining_eligible' => (int)($afterSummary['eligible_pending'] ?? 0),
            ];
            Logger::logInfo('Bulk stage parking completed', [
                'pipeline_stage' => $stage,
                'owner_didar_id' => $requestedOwnerId,
                'actor_didar_id' => $didarId,
                'summary' => $summary,
            ]);
            if ($this->auditService) {
                $this->auditService->log('move_stage_to_parked', 'deal', $stage, [
                    'pipeline_stage' => $stage,
                    'owner_didar_id' => $requestedOwnerId,
                    'parked_reason' => $reason,
                    'moved_count' => $movedCount,
                    'summary' => $summary,
                ]);
            }
            $this->successResponse(['moved_count' => $movedCount, 'summary' => $summary], $movedCount > 0 ? 'معاملات مرحله به بخش پارک‌شده منتقل شدند' : 'معامله جاری جدیدی برای انتقال پیدا نشد');
        } catch (\Throwable $e) {
            $this->errorResponse($e->getMessage());
        }
    }

    public function moveStageOutOfParked()
    {
        $this->assertAdminOnly();

        $stage = Sanitizer::sanitize($_POST['pipeline_stage'] ?? '');
        $requestedOwnerId = Sanitizer::sanitize($_POST['owner_didar_id'] ?? '');
        $dryRun = in_array(strtolower((string)($_POST['dry_run'] ?? '')), ['1', 'true', 'yes'], true);

        if ($stage === '') {
            $this->errorResponse('مرحله پارک‌شده الزامی است');
        }

        $filters = [];
        $didarId = (string)($_SESSION['didar_id'] ?? '');
        $this->applyOwnerScopeToFilters($filters, 'deals', $requestedOwnerId, $didarId);
        $filters['legacy'] = '0';

        if ($requestedOwnerId !== '' && empty($filters['owner_didar_id']) && empty($filters['owner_didar_ids'])) {
            $filters['owner_didar_id'] = $requestedOwnerId;
        }

        try {
            $beforeSummary = $this->dealRepo->getStageUnparkingSummary($stage, $filters);
            if ($dryRun) {
                $this->successResponse([
                    'summary' => [
                        'before' => $beforeSummary,
                        'moved_count' => 0,
                        'skipped_closed_parked' => (int)($beforeSummary['skipped_closed_parked'] ?? 0),
                        'skipped_legacy_parked' => (int)($beforeSummary['skipped_legacy_parked'] ?? 0),
                        'already_unparked' => (int)($beforeSummary['already_unparked'] ?? 0),
                        'remaining_eligible' => (int)($beforeSummary['eligible_parked_pending'] ?? 0),
                    ],
                ], 'پیش‌نمایش بازگردانی گروهی آماده است');
            }

            $movedCount = $this->dealService->moveStageDealsOutOfParked($stage, $filters);
            $afterSummary = $this->dealRepo->getStageUnparkingSummary($stage, $filters);
            $summary = [
                'before' => $beforeSummary,
                'after' => $afterSummary,
                'moved_count' => $movedCount,
                'skipped_closed_parked' => (int)($beforeSummary['skipped_closed_parked'] ?? 0),
                'skipped_legacy_parked' => (int)($beforeSummary['skipped_legacy_parked'] ?? 0),
                'already_unparked' => (int)($beforeSummary['already_unparked'] ?? 0),
                'remaining_eligible' => (int)($afterSummary['eligible_parked_pending'] ?? 0),
            ];
            Logger::logInfo('Bulk stage unparking completed', [
                'pipeline_stage' => $stage,
                'owner_didar_id' => $requestedOwnerId,
                'actor_didar_id' => $didarId,
                'summary' => $summary,
            ]);
            if ($this->auditService) {
                $this->auditService->log('move_stage_out_of_parked', 'deal', $stage, [
                    'pipeline_stage' => $stage,
                    'owner_didar_id' => $requestedOwnerId,
                    'summary' => $summary,
                ]);
            }
            $this->successResponse(['moved_count' => $movedCount, 'summary' => $summary], $movedCount > 0 ? 'معاملات پارک‌شده به کاریز بازگردانده شدند' : 'معامله پارک‌شده جاری جدیدی برای بازگردانی پیدا نشد');
        } catch (\Throwable $e) {
            $this->errorResponse($e->getMessage());
        }
    }

    public function unparkAll()
    {
        $this->assertAdminOnly();

        $requestedOwnerId = Sanitizer::sanitize($_POST['owner_didar_id'] ?? '');
        $dryRun = in_array(strtolower((string)($_POST['dry_run'] ?? '')), ['1', 'true', 'yes'], true);

        $filters = ['legacy' => '0'];
        $didarId = (string)($_SESSION['didar_id'] ?? '');
        $this->applyOwnerScopeToFilters($filters, 'deals', $requestedOwnerId, $didarId);

        if ($requestedOwnerId !== '' && empty($filters['owner_didar_id']) && empty($filters['owner_didar_ids'])) {
            $filters['owner_didar_id'] = $requestedOwnerId;
        }

        try {
            $beforeSummary = $this->dealRepo->getAllParkedSummary($filters);
            if ($dryRun) {
                $this->successResponse([
                    'eligible_count' => (int)($beforeSummary['eligible_parked_pending'] ?? 0),
                    'moved_count' => 0,
                ], 'پیش‌نمایش آماده است');
            }

            $movedCount = $this->dealRepo->unparkAllDeals($filters);
            Logger::logInfo('Bulk unpark-all completed', [
                'owner_didar_id' => $requestedOwnerId,
                'actor_didar_id' => $didarId,
                'moved_count' => $movedCount,
            ]);
            if ($this->auditService) {
                $this->auditService->log('unpark_all', 'deal', null, [
                    'owner_didar_id' => $requestedOwnerId ?: 'all',
                    'moved_count' => $movedCount,
                ]);
            }
            $this->successResponse(
                ['moved_count' => $movedCount],
                $movedCount > 0
                    ? 'معاملات پارک‌شده به کاریز بازگردانده شدند'
                    : 'معامله پارک‌شده جاری جدیدی برای بازگردانی پیدا نشد'
            );
        } catch (\Throwable $e) {
            $this->errorResponse($e->getMessage());
        }
    }

    /**
     * Extract phone number from deal title
     * Expected format: "معامله اول 9127103488 | خدمات"
     */
    private function isGeneratedDealTitle(string $title): bool
    {
        $t = trim($title);
        if ($t === '') {
            return false;
        }
        if (strpos($t, '|') !== false) {
            return false;
        }
        if ($t === "\u{0645}\u{0639}\u{0627}\u{0645}\u{0644}\u{0647} \u{062c}\u{062f}\u{06cc}\u{062f}") {
            return true;
        }
        return preg_match('/^\x{0645}\x{0639}\x{0627}\x{0645}\x{0644}\x{0647}\s+\S+\s+\d{10,12}$/u', $t) === 1;
    }

    private function extractPhoneFromDealTitle(string $title): string
    {
        Logger::logInfo('extractPhoneFromDealTitle called', [
            'input_title' => $title
        ]);

        // Remove any content after " | " separator
        $titlePart = explode(' | ', $title, 2)[0];

        Logger::logInfo('extractPhoneFromDealTitle processing', [
            'title_part' => $titlePart
        ]);

        // Look for phone number pattern in the title part
        // Pattern: "معامله [ordinal] [phone_number]"
        if (preg_match('/معامله\s+\S+\s+(\d{10,12})/u', $titlePart, $matches)) {
            Logger::logInfo('extractPhoneFromDealTitle found match', [
                'matched_phone' => $matches[1],
                'pattern' => 'معامله_ordinal_phone'
            ]);
            return $matches[1];
        }

        // Fallback: try to find any 10-12 digit number
        if (preg_match('/(\d{10,12})/', $titlePart, $matches)) {
            Logger::logInfo('extractPhoneFromDealTitle found fallback match', [
                'matched_phone' => $matches[1],
                'pattern' => 'any_10-12_digits'
            ]);
            return $matches[1];
        }

        Logger::logInfo('extractPhoneFromDealTitle no match found', [
            'title_part' => $titlePart
        ]);
        return '';
    }

    /**
     * Get contact's primary phone number
     */
    private function getContactPrimaryPhone(string $contactId): string
    {
        Logger::logInfo('getContactPrimaryPhone called', [
            'contact_id' => $contactId
        ]);

        // Use the robust repository method to find the person
        $person = $this->resolvePersonByContactId($contactId);
        
        $primaryPhone = $person ? ($person->mobile_phone ?? '') : '';

        Logger::logInfo('getContactPrimaryPhone result', [
            'contact_id' => $contactId,
            'person_found' => (bool) $person,
            'primary_phone' => $primaryPhone
        ]);

        return $primaryPhone;
    }

    /**
     * Normalize phone number for comparison
     * Remove country code, spaces, and standardize format
     */
    private function normalizePhoneForComparison(string $phone): string
    {
        Logger::logInfo('normalizePhoneForComparison called', [
            'input_phone' => $phone
        ]);

        // Remove all non-digit characters
        $cleaned = preg_replace('/\D/', '', $phone);

        Logger::logInfo('normalizePhoneForComparison cleaned', [
            'original' => $phone,
            'cleaned' => $cleaned
        ]);

        // Remove country code if present (98 or +98)
        if (str_starts_with($cleaned, '98') && strlen($cleaned) > 10) {
            $cleaned = substr($cleaned, 2);
            Logger::logInfo('normalizePhoneForComparison removed 98 prefix', ['cleaned' => $cleaned]);
        }
        if (str_starts_with($cleaned, '+98') && strlen($cleaned) > 10) {
            $cleaned = substr($cleaned, 3);
            Logger::logInfo('normalizePhoneForComparison removed +98 prefix', ['cleaned' => $cleaned]);
        }

        // Ensure it's a valid mobile number (10 digits starting with 9)
        if (strlen($cleaned) === 10 && str_starts_with($cleaned, '9')) {
            Logger::logInfo('normalizePhoneForComparison valid 10-digit mobile', ['final' => $cleaned]);
            return $cleaned;
        }

        // If it's 11 digits starting with 09, remove the 0
        if (strlen($cleaned) === 11 && str_starts_with($cleaned, '09')) {
            $final = substr($cleaned, 1);
            Logger::logInfo('normalizePhoneForComparison converted 11-digit to 10-digit', ['original' => $cleaned, 'final' => $final]);
            return $final;
        }

        Logger::logInfo('normalizePhoneForComparison final result', ['final' => $cleaned]);
        return $cleaned;
    }

    private function parseLookupMeta(?string $metaJson): array
    {
        if ($metaJson === null || $metaJson === '') {
            return [];
        }
        $decoded = json_decode($metaJson, true);
        return is_array($decoded) ? $decoded : [];
    }

    private function getLookupItemsSafe(string $groupCode, bool $includeInactive = false): array
    {
        if (!method_exists($this->dealService, 'getLookupItems')) {
            return [];
        }
        try {
            return $this->dealService->getLookupItems($groupCode, $includeInactive);
        } catch (\Throwable $e) {
            return [];
        }
    }

    private function getPaymentDestinationSnapshot(string $destinationValue): ?array
    {
        $destinationValue = trim($destinationValue);
        if ($destinationValue === '' || $destinationValue === '__other__') {
            return null;
        }
        foreach ($this->getLookupItemsSafe('payment_destination_banks', true) as $item) {
            $candidates = [
                trim((string)($item['value'] ?? '')),
                trim((string)($item['code'] ?? '')),
                trim((string)($item['title'] ?? '')),
            ];
            if (!in_array($destinationValue, $candidates, true)) {
                continue;
            }
            $meta = $this->parseLookupMeta($item['meta_json'] ?? null);
            $cardFull = preg_replace('/\D+/', '', (string)($meta['card_number'] ?? ''));
            $accountNumber = preg_replace('/\s+/', '', (string)($meta['account_number'] ?? ''));
            $sheba = strtoupper(preg_replace('/\s+/', '', (string)($meta['sheba'] ?? '')));
            $accountOwner = trim((string)($meta['account_owner'] ?? ''));
            return [
                'destination_bank_title' => trim((string)($item['title'] ?? '')),
                'destination_card_full' => $cardFull,
                'destination_card_number' => $cardFull !== '' ? substr($cardFull, -4) : '',
                'destination_account_number' => $accountNumber,
                'destination_sheba' => $sheba,
                'destination_account_owner' => $accountOwner,
            ];
        }
        return null;
    }

    private function buildFieldRulesMap(): array
    {
        $items = $this->getLookupItemsSafe('field_rules', false);
        $map = [];
        foreach ($items as $item) {
            $key = $item['value'] ?? $item['title'] ?? '';
            if ($key === '') {
                continue;
            }
            $meta = $this->parseLookupMeta($item['meta_json'] ?? null);
            $map[$key] = [
                'required' => array_key_exists('required', $meta) ? (bool)$meta['required'] : null,
                'editable' => array_key_exists('editable', $meta) ? (bool)$meta['editable'] : null,
                'editable_after_create' => array_key_exists('editable_after_create', $meta) ? (bool)$meta['editable_after_create'] : null,
                'editable_roles' => $meta['editable_roles'] ?? [],
            ];
        }
        return $map;
    }

    private function resolveFieldRule(array $rulesMap, string $key, array $defaults = []): array
    {
        $rule = $rulesMap[$key] ?? [];
        $roles = $rule['editable_roles'] ?? ($defaults['editable_roles'] ?? []);
        if (is_string($roles)) {
            $roles = array_values(array_filter(array_map('trim', explode(',', $roles))));
        }
        if (!is_array($roles)) {
            $roles = [];
        }
        return [
            'required' => array_key_exists('required', $rule) ? (bool)$rule['required'] : (bool)($defaults['required'] ?? false),
            'editable' => array_key_exists('editable', $rule) ? (bool)$rule['editable'] : (bool)($defaults['editable'] ?? true),
            'editable_after_create' => array_key_exists('editable_after_create', $rule) ? (bool)$rule['editable_after_create'] : (bool)($defaults['editable_after_create'] ?? true),
            'editable_roles' => $roles,
        ];
    }

    private function canEditField(array $rule, bool $isNewEntry, string $role): bool
    {
        if (array_key_exists('editable', $rule) && !$rule['editable']) {
            return false;
        }
        if (!$isNewEntry && array_key_exists('editable_after_create', $rule) && !$rule['editable_after_create']) {
            return false;
        }
        $roles = $rule['editable_roles'] ?? [];
        if (!empty($roles) && !in_array($role, $roles, true)) {
            return false;
        }
        return true;
    }

    private function shouldRequireField(array $rule, bool $isNewEntry, bool $canEdit): bool
    {
        if (empty($rule['required'])) {
            return false;
        }
        return $isNewEntry ? true : $canEdit;
    }

    private function buildPaymentMethodMetaMap(): array
    {
        $items = $this->getLookupItemsSafe('payment_methods', true);
        $map = [];
        foreach ($items as $item) {
            $key = $item['value'] ?? $item['title'] ?? '';
            if ($key === '') {
                continue;
            }
            $meta = $this->parseLookupMeta($item['meta_json'] ?? null);
            $map[$key] = [
                'requires_card_last4' => !empty($meta['requires_card_last4']),
                'requires_bank_destination' => !empty($meta['requires_bank_destination']),
            ];
        }
        if (empty($map)) {
            $map = [
                'card_reader' => ['requires_card_last4' => true, 'requires_bank_destination' => true],
                'card_to_card' => ['requires_card_last4' => true, 'requires_bank_destination' => true],
                'check' => ['requires_card_last4' => false, 'requires_bank_destination' => false],
            ];
        }
        return $map;
    }

    private function normalizeMethodCode(string $method): string
    {
        $method = strtolower(trim($method));
        return preg_replace('/[\s\-\/]+/', '_', $method);
    }

    private function normalizeDigits(string $value): string
    {
        return strtr($value, [
            '۰' => '0', '۱' => '1', '۲' => '2', '۳' => '3', '۴' => '4',
            '۵' => '5', '۶' => '6', '۷' => '7', '۸' => '8', '۹' => '9',
            '٠' => '0', '١' => '1', '٢' => '2', '٣' => '3', '٤' => '4',
            '٥' => '5', '٦' => '6', '٧' => '7', '٨' => '8', '٩' => '9',
        ]);
    }

    private function isCardTransferMethod(string $method): bool
    {
        $code = $this->normalizeMethodCode($method);
        return in_array($code, ['card_to_card', 'card_to_card_lookup', 'card_to_card_transfer'], true)
            || strpos($code, 'sheba') !== false
            || strpos($code, 'shaba') !== false
            || strpos($code, 'pol') !== false
            || strpos($code, 'pool') !== false;
    }

    private function isCardReaderMethod(string $method): bool
    {
        $code = $this->normalizeMethodCode($method);
        return in_array($code, ['card_reader', 'pos', 'pos_machine'], true);
    }

    private function isCashMethod(string $method): bool
    {
        $code = $this->normalizeMethodCode($method);
        return in_array($code, ['cash', 'نقد', 'وجه_نقد'], true);
    }

    private function isCheckMethod(string $method): bool
    {
        $code = $this->normalizeMethodCode($method);
        return in_array($code, ['check', 'cheque', 'چک'], true);
    }

    private function resolvePersonByContactId(string $contactId)
    {
        if ($contactId === '') {
            return null;
        }

        $person = $this->personRepo->findByDidarId($contactId);
        if ($person) {
            return $person;
        }

        return $this->personRepo->findByIdOrDidarId($contactId);
    }

    private function getWonDealsNeedingKarizReconcile(int $limit = 3000): array
    {
        if (!$this->db) {
            return [];
        }

        $pdo = $this->db->getPdo();
        $sql = "
            SELECT
                d.didar_deal_id,
                d.title,
                d.contact_didar_id,
                d.pipeline_stage,
                d.register_time,
                CASE WHEN COALESCE(d.pipeline_stage, '') <> 'اتمام' THEN 1 ELSE 0 END AS needs_pipeline_fix,
                CASE
                    WHEN d.contact_didar_id IS NOT NULL
                     AND d.contact_didar_id <> ''
                     AND NOT EXISTS (
                        SELECT 1
                        FROM virtual_stages vs
                        WHERE vs.contact_didar_id = d.contact_didar_id
                          AND vs.stage_name = 'with_purchase'
                     )
                    THEN 1 ELSE 0
                END AS needs_virtual_fix
            FROM deals d
            WHERE d.status = 'Won'
              AND (
                    COALESCE(d.pipeline_stage, '') <> 'اتمام'
                    OR (
                        d.contact_didar_id IS NOT NULL
                        AND d.contact_didar_id <> ''
                        AND NOT EXISTS (
                            SELECT 1
                            FROM virtual_stages vs
                            WHERE vs.contact_didar_id = d.contact_didar_id
                              AND vs.stage_name = 'with_purchase'
                        )
                    )
                  )
            ORDER BY d.register_time DESC, d.id DESC
            LIMIT " . max(1, (int)$limit);

        $stmt = $pdo->query($sql);
        return $stmt ? (array)$stmt->fetchAll(\PDO::FETCH_ASSOC) : [];
    }

    private function getPipelineStageValuesForReconcile(): array
    {
        $items = $this->dealService->getLookupItems('pipeline_stages', true);
        $values = [];
        foreach ($items as $item) {
            $value = trim((string)($item['value'] ?? $item['title'] ?? ''));
            if ($value !== '') {
                $values[$value] = true;
            }
        }

        foreach (['بلاتکلیف', 'معامله جدید', 'تماس عدم پاسخ', 'تماس پیگیری', 'ارسال لینک پرداخت', 'پرداخت', 'پیگیری مانده حساب', 'برگزاری دوره', 'پشتیبانی', 'ارجاع به CRM'] as $fallback) {
            $values[$fallback] = true;
        }

        unset($values['اتمام'], $values['معامله ناموفق'], $values['ناموفق'], $values['deal_failed']);
        return array_keys($values);
    }

    private function getLostDealsNeedingKarizReconcile(int $limit = 5000): array
    {
        if (!$this->db) {
            return [];
        }

        $stages = $this->getPipelineStageValuesForReconcile();
        if (empty($stages)) {
            return [];
        }

        $pdo = $this->db->getPdo();
        $placeholders = implode(',', array_fill(0, count($stages), '?'));
        $sql = "
            SELECT
                d.didar_deal_id,
                d.title,
                d.contact_didar_id,
                d.pipeline_stage,
                d.pipeline_bucket,
                d.register_time,
                d.owner_didar_id
            FROM deals d
            WHERE d.status = 'Lost'
              AND COALESCE(d.pipeline_stage, '') IN ($placeholders)
            ORDER BY d.register_time DESC, d.id DESC
            LIMIT " . max(1, (int)$limit);

        $stmt = $pdo->prepare($sql);
        $stmt->execute($stages);
        return (array)$stmt->fetchAll(\PDO::FETCH_ASSOC);
    }

    public function previewWonKarizReconcile()
    {
        $this->requireRbac('deals.reconcile', 'دسترسی محدود - شما مجاز به پیش‌نمایش ترمیم معاملات نیستید');

        $rows = $this->getWonDealsNeedingKarizReconcile(5000);
        $pipelineCount = 0;
        $virtualCount = 0;
        foreach ($rows as $row) {
            if ((int)($row['needs_pipeline_fix'] ?? 0) === 1) {
                $pipelineCount++;
            }
            if ((int)($row['needs_virtual_fix'] ?? 0) === 1) {
                $virtualCount++;
            }
        }

        $this->successResponse([
            'report' => [
                'total_candidates' => count($rows),
                'needs_pipeline_fix' => $pipelineCount,
                'needs_virtual_fix' => $virtualCount,
                'preview' => array_slice($rows, 0, 120),
            ],
        ]);
    }

    public function runWonKarizReconcile()
    {
        $this->requireRbac('deals.reconcile', 'دسترسی محدود - شما مجاز به اجرای ترمیم معاملات نیستید');

        $rows = $this->getWonDealsNeedingKarizReconcile(5000);
        $fixedPipeline = 0;
        $fixedVirtual = 0;
        $errors = [];

        foreach ($rows as $row) {
            $dealId = trim((string)($row['didar_deal_id'] ?? ''));
            $contactId = trim((string)($row['contact_didar_id'] ?? ''));
            if ($dealId === '') {
                continue;
            }

            try {
                if ((int)($row['needs_pipeline_fix'] ?? 0) === 1) {
                    $this->dealService->updatePipelineStage($dealId, 'اتمام');
                    $fixedPipeline++;
                }

                if ((int)($row['needs_virtual_fix'] ?? 0) === 1 && $contactId !== '') {
                    try {
                        $this->personRepo->updateSaleStatus($contactId, 1);
                    } catch (\Throwable $ignored) {
                        // Sale-status sync failure should not block virtual-stage repair.
                    }
                    $this->virtualStageService->update($contactId, 'with_purchase', true);
                    $fixedVirtual++;
                }
            } catch (\Throwable $e) {
                $errors[] = [
                    'deal_id' => $dealId,
                    'contact_id' => $contactId,
                    'error' => $e->getMessage(),
                ];
            }
        }

        if ($this->auditService) {
            $this->auditService->log('reconcile_won_kariz', 'deal', null, [
                'total_candidates' => count($rows),
                'fixed_pipeline' => $fixedPipeline,
                'fixed_virtual' => $fixedVirtual,
                'errors_count' => count($errors),
            ]);
        }

        $this->successResponse([
            'report' => [
                'total_candidates' => count($rows),
                'fixed_pipeline' => $fixedPipeline,
                'fixed_virtual' => $fixedVirtual,
                'errors_count' => count($errors),
                'errors' => array_slice($errors, 0, 100),
            ],
        ], 'ترمیم گروهی معاملات موفق انجام شد');
    }

    public function previewLostKarizReconcile()
    {
        $this->requireRbac('deals.reconcile', 'دسترسی محدود - شما مجاز به پیش‌نمایش ترمیم معاملات نیستید');

        $rows = $this->getLostDealsNeedingKarizReconcile(5000);
        $byStage = [];
        foreach ($rows as $row) {
            $stage = trim((string)($row['pipeline_stage'] ?? 'نامشخص'));
            if ($stage === '') {
                $stage = 'نامشخص';
            }
            $byStage[$stage] = ($byStage[$stage] ?? 0) + 1;
        }

        $this->successResponse([
            'report' => [
                'total_candidates' => count($rows),
                'target_stage' => 'معامله ناموفق',
                'by_stage' => $byStage,
                'preview' => array_slice($rows, 0, 120),
            ],
        ]);
    }

    public function runLostKarizReconcile()
    {
        $this->requireRbac('deals.reconcile', 'دسترسی محدود - شما مجاز به اجرای ترمیم معاملات نیستید');

        $rows = $this->getLostDealsNeedingKarizReconcile(5000);
        $fixedPipeline = 0;
        $fixedVirtual = 0;
        $errors = [];

        foreach ($rows as $row) {
            $dealId = trim((string)($row['didar_deal_id'] ?? ''));
            $contactId = trim((string)($row['contact_didar_id'] ?? ''));
            if ($dealId === '') {
                continue;
            }

            try {
                $this->dealService->updateStatus($dealId, 'Lost', 'lost_time');
                $this->dealService->updatePipelineStage($dealId, 'معامله ناموفق');
                $this->dealRepo->restoreFromParked($dealId, 'معامله ناموفق');
                $fixedPipeline++;

                if ($contactId !== '') {
                    $this->syncFailedDealSideEffects($dealId, $contactId);
                    $fixedVirtual++;
                }
            } catch (\Throwable $e) {
                $errors[] = [
                    'deal_id' => $dealId,
                    'contact_id' => $contactId,
                    'stage' => $row['pipeline_stage'] ?? '',
                    'error' => $e->getMessage(),
                ];
            }
        }

        Logger::logInfo('Lost Kariz reconcile completed', [
            'total_candidates' => count($rows),
            'fixed_pipeline' => $fixedPipeline,
            'fixed_virtual' => $fixedVirtual,
            'errors_count' => count($errors),
        ]);

        if ($this->auditService) {
            $this->auditService->log('reconcile_lost_kariz', 'deal', null, [
                'total_candidates' => count($rows),
                'fixed_pipeline' => $fixedPipeline,
                'fixed_virtual' => $fixedVirtual,
                'errors_count' => count($errors),
            ]);
        }

        $this->successResponse([
            'report' => [
                'total_candidates' => count($rows),
                'fixed_pipeline' => $fixedPipeline,
                'fixed_virtual' => $fixedVirtual,
                'errors_count' => count($errors),
                'errors' => array_slice($errors, 0, 100),
            ],
        ], 'ترمیم گروهی معاملات ناموفق انجام شد');
    }
}
