<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Exceptions\Billing\InsufficientBalanceException;
use App\Models\ActivityLog;
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\DuplicateDetector;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\SupplierProjects\SupplierProjectResolver;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable as FoundationQueueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;

/**
 * Routing входящего supplier_lead к eligible Лидерра-проектам (sharing-model).
 *
 * Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §5–§6.
 *       docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §3 (Plan 4 Task 4).
 *
 * Алгоритм:
 *   1. Загрузить SupplierLead.
 *   2. Распарсить raw_payload['project'] → (platform, signal_type, identifier).
 *   3. Резолв SupplierProject (resolveOrStub) — persist в supplier_lead.supplier_project_id.
 *   4. LeadRouter::matchEligibleProjects → Collection<Project>.
 *   5. Для каждого Project — DB::transaction с SET LOCAL app.current_tenant_id:
 *      - lockForUpdate Tenant.
 *      - Создать Deal (source_crm_id=vid).
 *      - DuplicateDetector::findMaster — если найден master !== deal, mark
 *        duplicate_of_id (без charge/counter/notify, ActivityLog с duplicate_of).
 *      - Иначе: LedgerService::chargeForDelivery(tenant, deal, lead) — dual-balance
 *        списание (prepaid balance_leads-- ИЛИ rub balance_rub-=tier_price), INSERT
 *        lead_charges + balance_transactions + supplier_lead_costs внутри той же
 *        транзакции. На InsufficientBalanceException — Log::warning + rethrow
 *        (auto-pause flow приходит в Task 6). delivered_today/month++ на проекте,
 *        ActivityLog (EVENT_DEAL_CREATED), NotificationService::notifyNewLead.
 *   6. Обновить SupplierLead.processed_at=now() + deals_created_count.
 */
class RouteSupplierLeadJob implements ShouldQueue
{
    use FoundationQueueable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public int $tries = 3;

    public int $backoff = 60;

    public int $timeout = 60;

    /**
     * Plan 3 Task 3: имя DB-connection (BYPASSRLS-роль crm_supplier_worker), через который
     * supplier-flow обходит RLS для sharing-операций (failed_webhook_jobs с tenant_id=NULL).
     *
     * NB: это НЕ $this->connection из Bus\Queueable — то управляет очередью, не БД.
     * Job's queue connection остаётся default (sync/database), а DB-операции в failed()
     * явно идут через DB::connection(self::DB_CONNECTION). Tenant-scoped транзакции в
     * handle() (createDealCopyForProject) продолжают использовать default `pgsql`
     * с SET LOCAL app.current_tenant_id — там RLS нужна.
     *
     * См. docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1.
     */
    public const DB_CONNECTION = 'pgsql_supplier';

    public function __construct(public int $supplierLeadId) {}

    public function handle(
        LeadRouter $router,
        SupplierProjectResolver $resolver,
        DuplicateDetector $duplicateDetector,
        NotificationService $notifier,
        LedgerService $ledger,
    ): void {
        $lead = SupplierLead::findOrFail($this->supplierLeadId);

        // Idempotency guard для retry-сценария ($tries = 3).
        // Если лид уже обработан — выходим, не создаём ghost duplicate'ы deal'ов.
        // CV.11 audit BLOCKER #3 (Plan 2.5 fix).
        if ($lead->processed_at !== null) {
            Log::info('supplier_lead.skipped_already_processed', [
                'supplier_lead_id' => $lead->id,
                'processed_at' => $lead->processed_at->toIso8601String(),
                'deals_created_count' => $lead->deals_created_count,
            ]);

            return;
        }

        $projectField = (string) ($lead->raw_payload['project'] ?? '');
        [$platform, $signalType, $identifier] = $this->parseProjectField($projectField);

        $supplier = $resolver->resolveOrStub($platform, $signalType, $identifier);
        $lead->update(['supplier_project_id' => $supplier->id]);

        $matched = $router->matchEligibleProjects($supplier, (string) $lead->phone);

        $createdCount = 0;
        $failures = [];
        foreach ($matched as $project) {
            try {
                if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger)) {
                    $createdCount++;
                }
            } catch (Throwable $e) {
                // Per-Project failure isolation (Plan 2 code-review Important).
                // Sharing-model: один сбой проекта не должен абортить routing других tenant'ов.
                // Логируем и продолжаем; final failed() callback зафиксирует общий проблемный лид
                // только если ВСЕ Projects упали (через handle() rethrow ниже).
                $failures[] = ['project_id' => $project->id, 'tenant_id' => $project->tenant_id, 'error' => $e->getMessage()];
                Log::warning('supplier_lead.per_project_routing_failed', [
                    'supplier_lead_id' => $lead->id,
                    'project_id' => $project->id,
                    'tenant_id' => $project->tenant_id,
                    'exception' => $e->getMessage(),
                ]);
            }
        }

        // Если ВСЕ Projects упали (а matched был непустой) — пробрасываем последнюю ошибку,
        // чтобы failed() callback сработал и проблема ушла в failed_webhook_jobs.
        if ($matched->isNotEmpty() && $createdCount === 0 && count($failures) === $matched->count()) {
            throw new RuntimeException(
                'All eligible projects failed routing for supplier_lead='.$lead->id.
                '; last error: '.($failures[array_key_last($failures)]['error'] ?? 'unknown')
            );
        }

        $lead->update([
            'processed_at' => now(),
            'deals_created_count' => $createdCount,
        ]);
    }

    /**
     * Парсит поле raw_payload['project'] (формат `B[123]_<rest>`):
     *   - rest вида `7\d{10}` → call (телефон-номер для звонка-сигнала);
     *   - rest вида `^[a-z0-9-]+(\.[a-z0-9-]+)+$` → site (домен сайта-сигнала);
     *   - иначе → sms (короткое имя отправителя SMS-шлюза).
     *
     * @return array{0: string, 1: string, 2: string} [platform, signal_type, identifier]
     */
    private function parseProjectField(string $project): array
    {
        if (preg_match('/^(B[123])_(.+)$/', $project, $m) !== 1) {
            throw new RuntimeException("Cannot parse supplier project field: '{$project}'");
        }
        $platform = $m[1];
        $rest = $m[2];

        if (preg_match('/^7\d{10}$/', $rest) === 1) {
            $signalType = 'call';
        } elseif (preg_match('/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i', $rest) === 1) {
            $signalType = 'site';
        } else {
            $signalType = 'sms';
        }

        return [$platform, $signalType, $rest];
    }

    /**
     * Создаёт deal-копию в одной транзакции для конкретного Project.
     * Возвращает true — если копия не дубль (баланс списан, счётчики выросли).
     * false — если копия помечена дублем (без списания).
     */
    private function createDealCopyForProject(
        SupplierLead $lead,
        Project $project,
        DuplicateDetector $duplicateDetector,
        NotificationService $notifier,
        LedgerService $ledger,
    ): bool {
        try {
            return DB::transaction(function () use ($lead, $project, $duplicateDetector, $notifier, $ledger): bool {
                DB::statement("SET LOCAL app.current_tenant_id = '{$project->tenant_id}'");

                /** @var Tenant $tenant */
                $tenant = Tenant::query()
                    ->whereKey($project->tenant_id)
                    ->lockForUpdate()
                    ->firstOrFail();

                // Concurrency recheck: lockForUpdate(Project) + recheck delivered_today
                // против лимита под блокировкой. Closes CV.11 audit BLOCKER #2 (Plan 2.5).
                // matchEligibleProjects делал SELECT без lock'а — между snapshot'ом и
                // этой транзакцией concurrent webhook мог инкрементить счётчик до limit.
                // Если лимит уже исчерпан — return false (deal не создаём, баланс не списываем).
                /** @var Project $lockedProject */
                $lockedProject = Project::query()
                    ->whereKey($project->id)
                    ->lockForUpdate()
                    ->firstOrFail();
                $effectiveLimit = $lockedProject->effective_daily_limit_today ?? $lockedProject->daily_limit_target;
                if ($lockedProject->delivered_today >= $effectiveLimit) {
                    Log::info('supplier_lead.project_at_limit_skipped', [
                        'supplier_lead_id' => $lead->id,
                        'project_id' => $lockedProject->id,
                        'tenant_id' => $tenant->id,
                        'delivered_today' => $lockedProject->delivered_today,
                        'effective_limit' => $effectiveLimit,
                    ]);

                    return false;
                }
                $project = $lockedProject;

                $payload = $lead->raw_payload ?? [];
                $receivedAt = isset($payload['time'])
                    ? Carbon::createFromTimestamp((int) $payload['time'])
                    : ($lead->received_at ?? Carbon::now());

                /** @var array<int, string> $phones */
                $phones = isset($payload['phones']) && is_array($payload['phones'])
                    ? array_values(array_map('strval', $payload['phones']))
                    : [(string) $lead->phone];

                $deal = Deal::create([
                    'tenant_id' => $tenant->id,
                    'source_crm_id' => $lead->vid,
                    'project_id' => $project->id,
                    'phone' => (string) $lead->phone,
                    'phones' => $phones,
                    'status' => 'new',
                    'received_at' => $receivedAt,
                ]);

                $master = $duplicateDetector->findMaster(
                    tenantId: (int) $tenant->id,
                    phone: (string) $lead->phone,
                    now: $receivedAt,
                );

                // Только что созданный $deal сам попадает в выборку DuplicateDetector
                // (он уже в БД к моменту lookup'а), поэтому master может ===$deal.
                // Дубль — только если master найден И это НЕ сам deal.
                if ($master !== null && $master->id !== $deal->id) {
                    $deal->update(['duplicate_of_id' => $master->id]);

                    ActivityLog::create([
                        'tenant_id' => $tenant->id,
                        'user_id' => null,
                        'deal_id' => $deal->id,
                        'event' => ActivityLog::EVENT_DEAL_CREATED,
                        'context' => [
                            'source' => 'supplier_webhook',
                            'duplicate_of' => $master->id,
                            'supplier_lead_id' => $lead->id,
                        ],
                        'created_at' => now(),
                    ]);

                    return false;
                }

                // Task 6: $ledger->chargeForDelivery бросит InsufficientBalanceException —
                // транзакция откатится, и outer catch ниже отловит для auto-pause flow.
                $ledger->chargeForDelivery($tenant, $deal, $lead);

                $project->increment('delivered_today');
                $project->increment('delivered_in_month');

                ActivityLog::create([
                    'tenant_id' => $tenant->id,
                    'user_id' => null,
                    'deal_id' => $deal->id,
                    'event' => ActivityLog::EVENT_DEAL_CREATED,
                    'context' => [
                        'source' => 'supplier_webhook',
                        'supplier_lead_id' => $lead->id,
                    ],
                    'created_at' => now(),
                ]);

                // ProcessWebhookJob-pattern: setRelation чтобы NotificationService
                // мог подтянуть deal->project без N+1 lookup'а под RLS.
                $deal->setRelation('project', $project);
                $notifier->notifyNewLead($tenant, $deal);

                return true;
            });
        } catch (InsufficientBalanceException $e) {
            // Транзакция уже rolled back — Deal не создан, balance не тронут.
            // Запускаем auto-pause flow (Plan 4 Task 6 §4.4) и возвращаем false,
            // чтобы handle()-loop продолжил routing к остальным tenant'ам без rethrow.
            $this->handleInsufficientBalance($lead, $project, $e);

            return false;
        }
    }

    /**
     * Auto-pause flow при недостатке баланса (Plan 4 Task 6 §4.4):
     *
     *   1. UPDATE projects.is_active=false через pgsql_supplier (BYPASSRLS), потому
     *      что текущая транзакция уже rolled back — её SET LOCAL app.current_tenant_id
     *      отвалился и обычный pgsql connection не сможет апдейтить чужой tenant'овый
     *      row под политикой tenant_isolation.
     *   2. Email-алерт ZeroBalancePausedMail с rate-limit 1/час/tenant — через
     *      Redis SETNX (Cache::add вернёт true только при первой попытке за час).
     *   3. Log::warning с диагностикой суммы баланса и цены тарифа.
     */
    private function handleInsufficientBalance(
        SupplierLead $lead,
        Project $project,
        InsufficientBalanceException $e,
    ): void {
        // 1) UPDATE projects.is_active=false через pgsql_supplier (BYPASSRLS).
        DB::connection(self::DB_CONNECTION)
            ->update('UPDATE projects SET is_active = false WHERE id = ?', [$project->id]);

        // 2) Email-алерт с rate-limit 1/час/tenant через Redis SETNX (Cache::add).
        $cacheKey = "billing:zero_balance_alert:{$project->tenant_id}";
        if (Cache::store('redis')->add($cacheKey, true, now()->addHour())) {
            $project->loadMissing('tenant');
            app(NotificationService::class)->notifyZeroBalancePaused(
                $project->tenant,
                $project,
                $e->priceKopecks,
            );
        }

        Log::warning('billing.project_paused_insufficient_balance', [
            'tenant_id' => $project->tenant_id,
            'project_id' => $project->id,
            'supplier_lead_id' => $lead->id,
            'price_kopecks' => $e->priceKopecks,
            'balance_rub' => $e->balanceRub,
            'balance_leads' => $e->balanceLeads,
        ]);
    }

    /**
     * Финальный callback после исчерпания всех ретраев ($tries=3).
     *
     * Сохраняет упавший job в `failed_webhook_jobs` (tenant_id=null — sharing-flow,
     * tenant ещё не определён на момент routing'а) для ручного разбора.
     * supplier_lead.error апдейтится текстом исключения.
     *
     * INSERT с tenant_id=NULL проходит благодаря DB_CONNECTION='pgsql_supplier'
     * (BYPASSRLS-роль crm_supplier_worker — обходит политику tenant_isolation,
     * которая под обычной ролью отвергла бы NULL). Закрыто Plan 3 Task 3.
     */
    public function failed(Throwable $e): void
    {
        DB::connection(self::DB_CONNECTION)->table('failed_webhook_jobs')->insert([
            'tenant_id' => null,
            'webhook_log_id' => null,
            'raw_payload' => json_encode([
                'supplier_lead_id' => $this->supplierLeadId,
            ], JSON_UNESCAPED_UNICODE),
            'exception' => $e->getMessage(),
            'retry_count' => $this->tries,
            'failed_at' => now(),
        ]);

        SupplierLead::query()
            ->whereKey($this->supplierLeadId)
            ->update(['error' => $e->getMessage()]);

        Log::error('supplier_lead.routing_failed_permanently', [
            'supplier_lead_id' => $this->supplierLeadId,
            'exception' => $e->getMessage(),
        ]);
    }
}
