<?php

declare(strict_types=1);

namespace App\Jobs\Supplier;

use App\Jobs\RouteSupplierLeadJob;
use App\Mail\CsvDriftAlertMail;
use App\Mail\SupplierCriticalAlertMail;
use App\Mail\TenantBusinessDriftAlertMail;
use App\Models\SupplierLead;
use App\Services\Supplier\SupplierCsvParser;
use App\Services\Supplier\SupplierPortalClient;
use Carbon\CarbonInterface;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Cache\LockProvider;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\QueryException;
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 Throwable;

/**
 * Резервный CSV-канал (Путь 2): сверка отчёта поставщика «Запрос номеров»
 * с принятыми webhook-лидами; recovery пропущенного + drift-детект.
 *
 * Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md
 *
 * Алгоритм:
 *   1. Cache::lock — overlap-защита.
 *   2. INSERT supplier_csv_reconcile_log (status='running').
 *   3. Заказать отчёт «Запрос номеров» за окно (2 кал. дня) → дождаться → скачать.
 *   4. Parse CSV (Name;Tag;Phone).
 *   5. Дедуп по (phone, project): SELECT existing supplier_leads за окно.
 *   6. Diff = missing → INSERT supplier_leads (vid=NULL, source='csv_recovery') + RouteJob.
 *   7. UPDATE log + drift; drift > 5% → CsvDriftAlertMail.
 *   8. На exception — status='failed', throw (cron повторит через 30 мин).
 */
final class CsvReconcileJob implements ShouldQueue
{
    use FoundationQueueable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public int $tries = 1;

    public int $timeout = 300;

    private const DB_CONNECTION = 'pgsql_supplier';

    private const DRIFT_THRESHOLD = 0.05;

    private const WINDOW_DAYS = 2;

    private const LOCK_NAME = 'supplier:csv_reconcile';

    private const LOCK_TTL_SECONDS = 600;

    /** UI-аудит 21.06: не чаще 1 алерта о падении сверки за это окно (анти-спам). */
    private const FAILURE_ALERT_THROTTLE_HOURS = 6;

    public function handle(
        SupplierPortalClient $portal,
        SupplierCsvParser $parser,
        Mailer $mailer,
    ): void {
        /** @var LockProvider $lockStore */
        $lockStore = Cache::store('redis');
        $lock = $lockStore->lock(self::LOCK_NAME, self::LOCK_TTL_SECONDS);
        if (! $lock->get()) {
            Log::info('csv_reconcile.skipped_overlap');

            return;
        }

        // Окно: начало (сегодня − (WINDOW_DAYS−1) дней) 00:00 .. сейчас.
        $windowEnd = Carbon::now();
        $windowStart = Carbon::today()->subDays(self::WINDOW_DAYS - 1);

        // $logId инициализируется внутри try: если сам insertGetId упадёт (БД недоступна),
        // catch обязан НЕ обращаться к неинициализированному $logId, а finally — освободить
        // lock (иначе lock висит LOCK_TTL_SECONDS и пропускает следующие запуски).
        $logId = null;

        try {
            $logId = DB::connection(self::DB_CONNECTION)
                ->table('supplier_csv_reconcile_log')
                ->insertGetId([
                    'started_at' => now(),
                    'window_start' => $windowStart,
                    'window_end' => $windowEnd,
                    'status' => 'running',
                    'created_at' => now(),
                ]);

            $reportId = $portal->requestNumbersReport($windowStart, $windowEnd);
            $portal->waitReportReady($reportId);
            $csv = $portal->downloadReport($reportId);

            // CSV-строки по ключу phone|project (последняя строка с тем же ключом перетирает).
            /** @var array<string, array{project: string, tag: string, phone: string}> $csvByKey */
            $csvByKey = [];
            foreach ($parser->parse($csv) as $row) {
                $csvByKey[$this->dedupKey((string) $row['phone'], (string) $row['project'])] = $row;
            }
            $totalCsvRows = count($csvByKey);

            // Существующие лиды за окно → set ключей phone|project.
            $existingKeys = [];
            DB::connection(self::DB_CONNECTION)
                ->table('supplier_leads')
                ->where('received_at', '>=', $windowStart)
                ->select('phone', 'raw_payload')
                ->orderBy('id')
                ->chunk(500, function ($leads) use (&$existingKeys): void {
                    foreach ($leads as $lead) {
                        $payload = is_string($lead->raw_payload)
                            ? json_decode($lead->raw_payload, true)
                            : (array) $lead->raw_payload;
                        $project = (string) ($payload['project'] ?? '');
                        $existingKeys[$this->dedupKey((string) $lead->phone, $project)] = true;
                    }
                });

            $missing = array_diff_key($csvByKey, $existingKeys);

            $recoveredCount = 0;
            $unparseableCount = 0;
            foreach ($missing as $row) {
                $platform = $this->extractPlatform((string) $row['project']);
                if ($platform === null) {
                    // Поставщик иногда кладёт в `project` нестандартные имена (телефон, URL).
                    // Не warning — это не наш баг, processing продолжается, paper-trail на info уровне.
                    // Считаем такие строки отдельно, чтобы исключить из формулы drift'а
                    // (иначе ~40-50% мусора каждый запуск стабильно даёт false-positive drift_alert).
                    $unparseableCount++;
                    Log::info('csv_reconcile.unparseable_project_skipped', [
                        'project' => $row['project'],
                    ]);

                    continue;
                }

                try {
                    $lead = SupplierLead::create([
                        'vid' => null,
                        'platform' => $platform,
                        'phone' => (string) $row['phone'],
                        'raw_payload' => $row,
                        'received_at' => now(),
                        'recovered_from_csv_at' => now(),
                        'source' => 'csv_recovery',
                        'supplier_project_id' => null,
                    ]);
                    RouteSupplierLeadJob::dispatch($lead->id);
                    $recoveredCount++;
                } catch (QueryException $e) {
                    Log::warning('csv_reconcile.lead_insert_failed', [
                        'phone' => $row['phone'],
                        'project' => $row['project'],
                        'error' => $e->getMessage(),
                    ]);
                }
            }

            $matchedCount = $totalCsvRows - count($missing);
            // drift считается только по «реальным» пропускам (parseable, не junk):
            //   real_missing  = count(missing) - unparseable  (всегда ≥ 0)
            //   parseable_tot = total_csv_rows - unparseable
            // Это убирает класс «поставщик кладёт телефон/URL в поле project →
            // строки скипаются → drift искусственно завышен» (см. ПИЛОТ 22.05, 25.05).
            $realMissing = max(0, count($missing) - $unparseableCount);
            $parseableTotal = max(0, $totalCsvRows - $unparseableCount);
            $driftRatio = $parseableTotal > 0 ? $realMissing / $parseableTotal : 0.0;
            $status = $driftRatio > self::DRIFT_THRESHOLD ? 'drift_alert' : 'ok';

            $update = [
                'finished_at' => now(),
                'total_csv_rows' => $totalCsvRows,
                'matched_count' => $matchedCount,
                'recovered_count' => $recoveredCount,
                'unparseable_count' => $unparseableCount,
                'drift_ratio' => $driftRatio,
                'status' => $status,
            ];

            if ($status === 'drift_alert') {
                $mailer->to((string) config('services.supplier.alert_email'))
                    ->send(new CsvDriftAlertMail(
                        reconcileLogId: $logId,
                        totalCsvRows: $totalCsvRows,
                        missingCount: count($missing),
                        recoveredCount: $recoveredCount,
                        driftRatio: $driftRatio,
                        windowStart: $windowStart,
                        windowEnd: $windowEnd,
                    ));
                $update['alert_email_sent_at'] = now();
            }

            DB::connection(self::DB_CONNECTION)
                ->table('supplier_csv_reconcile_log')
                ->where('id', $logId)
                ->update($update);

            // R-05 / §4.4.4 second pass — business-drift on project_routing_snapshots.
            // Detects tenants where supplier under-delivered against the slepok plan
            // (shortfall = (expected - delivered) / expected > 20%). Orthogonal to
            // webhook-loss drift above — same lead can be missing from CSV AND from
            // delivered_count (compounding R-05.1 + R-05.2).
            $this->detectAndAlertBusinessDrift($mailer, $windowStart, $windowEnd);

        } catch (Throwable $e) {
            // UI-аудит 21.06: раньше падение сверки писалось в лог status=failed,
            // но НИКОГО не уведомляло (алерт слался только на drift) — а heartbeat
            // показывал «OK» (Schedule::job меряет постановку в очередь, не результат).
            // Из-за этого заход к поставщику падал каждые 30 мин ~3 недели незаметно.
            // Теперь: при падении шлём critical-алерт на ops-email, троттл 6ч.
            $alertSent = false;
            if (! $this->failureAlertRecentlySent()) {
                try {
                    $mailer->to((string) config('services.supplier.alert_email'))
                        ->send(new SupplierCriticalAlertMail(
                            alertType: 'csv_reconcile_failed',
                            details: 'Сверка с поставщиком (CsvReconcileJob) падает. Ошибка: '
                                .substr($e->getMessage(), 0, 500),
                        ));
                    $alertSent = true;
                } catch (Throwable $mailError) {
                    Log::error('csv_reconcile.failure_alert_send_failed', ['error' => $mailError->getMessage()]);
                }
            }

            // $logId === null — упал сам insertGetId, log-строки нет, обновлять нечего.
            if ($logId !== null) {
                DB::connection(self::DB_CONNECTION)
                    ->table('supplier_csv_reconcile_log')
                    ->where('id', $logId)
                    ->update([
                        'finished_at' => now(),
                        'status' => 'failed',
                        'error_message' => substr($e->getMessage(), 0, 1000),
                        'alert_email_sent_at' => $alertSent ? now() : null,
                    ]);
            }
            throw $e;
        } finally {
            $lock->release();
        }
    }

    /**
     * Ключ дедупа: нормализованный phone + project.
     */
    private function dedupKey(string $phone, string $project): string
    {
        return trim($phone).'|'.trim($project);
    }

    /** Был ли алерт о падении сверки за последнее окно троттла (анти-спам). */
    private function failureAlertRecentlySent(): bool
    {
        return DB::connection(self::DB_CONNECTION)
            ->table('supplier_csv_reconcile_log')
            ->whereNotNull('alert_email_sent_at')
            ->where('alert_email_sent_at', '>=', now()->subHours(self::FAILURE_ALERT_THROTTLE_HOURS))
            ->exists();
    }

    /**
     * Извлекает platform из имени проекта:
     *   - `B[123]_<rest>` → 'B1' / 'B2' / 'B3';
     *   - Phase 3: иначе, если строка непустая и состоит из identifier-символов
     *     (домены / телефоны / SMS-отправители) → 'DIRECT';
     *   - откровенный мусор (только спец-символы, пусто) → null (unparseable).
     */
    private function extractPlatform(string $project): ?string
    {
        if (preg_match('/^(B[123])_/', $project, $m) === 1) {
            return $m[1];
        }
        // Phase 3: всё что выглядит как разумный identifier (домен / телефон / SMS-sender) → DIRECT.
        // unparseable_count теперь только для откровенного мусора (пустые / только спец-символы).
        $trimmed = trim($project);
        if ($trimmed !== '' && preg_match('/^[\w\-.а-яА-Я0-9\/() +]+$/u', $trimmed) === 1) {
            return 'DIRECT';
        }

        return null;
    }

    /**
     * R-05 (Stage 4 §4.4.4) — business-drift second pass.
     *
     * Поверх существующего webhook-loss drift (R-05.1: «лид прилетел, мы webhook'а не
     * получили») ищем business-drift (R-05.2: «лид прилетел, мы доставили не тому/никому»):
     * для каждой пары (snapshot_date, tenant_id) считаем SUM(expected_volume) и
     * SUM(delivered_count) по `project_routing_snapshots`, при shortfall > 20% шлём
     * `TenantBusinessDriftAlertMail` админу.
     *
     * Окно — то же что у текущего CSV-reconcile run. Один email на тенанта на дату.
     */
    private const BUSINESS_DRIFT_THRESHOLD = 0.20;

    private function detectAndAlertBusinessDrift(
        Mailer $mailer,
        CarbonInterface $windowStart,
        CarbonInterface $windowEnd,
    ): void {
        $from = $windowStart->toDateString();
        $to = $windowEnd->toDateString();

        $rows = DB::connection(self::DB_CONNECTION)
            ->table('project_routing_snapshots')
            ->whereBetween('snapshot_date', [$from, $to])
            ->groupBy('snapshot_date', 'tenant_id')
            ->selectRaw('snapshot_date, tenant_id, SUM(expected_volume) AS expected, SUM(delivered_count) AS delivered')
            ->havingRaw('SUM(expected_volume) > 0')
            ->get();

        foreach ($rows as $row) {
            $expected = (int) $row->expected;
            $delivered = (int) $row->delivered;
            if ($expected <= 0) {
                continue;
            }
            $shortfall = ($expected - $delivered) / $expected;
            if ($shortfall <= self::BUSINESS_DRIFT_THRESHOLD) {
                continue;
            }

            $mailer->to((string) config('services.supplier.alert_email'))
                ->send(new TenantBusinessDriftAlertMail(
                    tenantId: (int) $row->tenant_id,
                    snapshotDate: (string) $row->snapshot_date,
                    expected: $expected,
                    delivered: $delivered,
                    shortfallRatio: $shortfall,
                    windowStart: $windowStart,
                    windowEnd: $windowEnd,
                ));

            Log::warning('csv_reconcile.business_drift_alert', [
                'tenant_id' => (int) $row->tenant_id,
                'snapshot_date' => (string) $row->snapshot_date,
                'expected' => $expected,
                'delivered' => $delivered,
                'shortfall' => $shortfall,
            ]);
        }
    }
}
