<?php

declare(strict_types=1);

namespace App\Services;

use App\Models\Project;
use App\Models\SupplierProject;
use App\Support\SystemSettings;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Random\Randomizer;

/**
 * Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6) с
 * каскадной маршрутизацией по региону (lead region resolution §3.9).
 *
 * Eligibility — структурно через snapshot `project_routing_snapshots` за активную
 * дату слепка (slepok-инвариант): до 21:00 МСК активен snapshot сегодняшней даты,
 * с 21:00 МСК — завтрашней. Все эффективные параметры маршрутизации берутся из
 * snapshot; из live `projects` — только `delivered_today` (остаток лимита),
 * из `tenants` — `balance_rub` + `frozen_by_balance_at` (live auto-pause).
 *
 * Каскад (§3.9): один SQL оборачивается тремя фазами по убыванию точности региона:
 *   1) точное совпадение субъекта (`?::int = ANY(snap.regions)`);
 *   2) «вся РФ» (`snap.regions = '{}'`), добор недостающих слотов;
 *   3) запасной канал (без фильтра региона) — только если первые две пусты;
 *      сделкам в этой фазе подменяется subject_code (RouteSupplierLeadJob §3.10).
 * Каждый Project помечается атрибутом `routing_step` (1/2/3).
 *
 * Отбор внутри фазы при кандидатах > cap — **взвешенный жребий по остатку лимита**
 * (вариант D1=В): шанс ∝ остатку, но у каждого кандидата шанс > 0 (вес ≥ 1) —
 * маленькие клиенты не отрезаются. cap = LeadDistributor::CAP (лид продаётся ≤3 раз).
 * Жребий через инъектируемый \Random\Randomizer (тесты сидируют Mt19937).
 *
 * Запрос через connection pgsql_supplier (BYPASSRLS crm_supplier_worker) — в
 * sharing-flow tenant ещё не определён, SELECT видит проекты всех tenant'ов.
 *
 * Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.3
 *     + docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md §3.9.
 */
class LeadRouter
{
    /** Кэш флага матча по слепку на время жизни инстанса (один лид) — горячий путь. */
    private ?bool $matchBySnapshot = null;

    public function __construct(
        private readonly Randomizer $randomizer = new Randomizer,
    ) {}

    /**
     * Флаг отката (ADR Task 2.0, Путь A): true → матч источника поставщиковых проектов
     * идёт по signal-полям слепка; false → старый путь через живой pivot.
     * Дефолт false — безопасно, новый путь включается явно на проде.
     */
    private function matchBySnapshotEnabled(): bool
    {
        return $this->matchBySnapshot ??= SystemSettings::bool('routing_match_by_snapshot', false);
    }

    /**
     * Возвращает ≤ cap проектов (по одному на tenant), отобранных каскадом
     * по региону + взвешенным жребием. Каждый Project несёт `routing_step`.
     *
     * @return Collection<int, Project>
     */
    public function matchEligibleProjects(SupplierProject $supplierProject, ?int $resolvedSubjectCode = null): Collection
    {
        $activeDate = $this->activeSnapshotDate();
        $cap = LeadDistributor::CAP;

        // Фаза 1: точное совпадение региона (только если резолвер дал subject_code).
        $exact = $resolvedSubjectCode !== null
            ? $this->queryCandidates($activeDate, $supplierProject, 'exact', $resolvedSubjectCode, [])
            : collect();
        $selected = $this->weightedPick($exact, $cap);
        $this->tagStep($selected, 1);

        if ($selected->count() >= $cap) {
            return $selected->take($cap)->values();
        }

        // Фаза 2: «вся РФ», добор недостающих слотов (исключая уже выбранных tenant'ов).
        $allRu = $this->queryCandidates(
            $activeDate, $supplierProject, 'all_ru', null,
            $selected->pluck('tenant_id')->all(),
        );
        $pickedRu = $this->weightedPick($allRu, $cap - $selected->count());
        $this->tagStep($pickedRu, 2);
        $combined = $selected->concat($pickedRu);

        if ($combined->isNotEmpty()) {
            return $combined->take($cap)->values();
        }

        // Фаза 3: запасной канал (никто не подписан на регион и нет «вся РФ»).
        $fallback = $this->weightedPick(
            $this->queryCandidates($activeDate, $supplierProject, 'any', null, []),
            $cap,
        );
        $this->tagStep($fallback, 3);

        $this->logIfNoSnapshot($fallback->all(), $supplierProject, $activeDate);

        return $fallback->take($cap)->values();
    }

    /**
     * Один SQL-запрос фазы каскада: DISTINCT ON (tenant_id) с фильтром региона.
     * regionFilter ∈ exact|all_ru|any. Возвращает всех eligible (по одному на tenant),
     * упорядоченных по остатку лимита DESC, created_at, id; жребий — поверх в PHP.
     *
     * @param  list<int>  $excludeTenantIds
     * @return Collection<int, Project>
     */
    private function queryCandidates(string $activeDate, SupplierProject $sp, string $regionFilter, ?int $code, array $excludeTenantIds): Collection
    {
        $bindings = [$activeDate];

        if ($sp->platform === 'DIRECT' || $this->matchBySnapshotEnabled()) {
            // Матч по signal-полям СЛЕПКА (Путь A, ADR 2026-06-25): DIRECT — всегда (нет pivot),
            // B1/B2/B3 — под флагом routing_match_by_snapshot. Слепок переживает смену/удаление
            // источника, поэтому хвостовой лид по старому источнику доезжает.
            if ($sp->signal_type === 'sms') {
                // Agnostic-ключ sms = sms_senders[0][+keyword] — реконструируем из слепка
                // (зеркало SupplierProjectGrouping::buildUniqueKeyAgnostic). Матч по ПЕРВОМУ
                // отправителю (->> 0) + keyword, не по «любому элементу» массива.
                $sourceWhere = "snap.signal_type = 'sms' AND (
                      (snap.sms_senders ->> 0)
                      || CASE WHEN COALESCE(snap.sms_keyword, '') <> '' THEN '+' || snap.sms_keyword ELSE '' END
                  ) = ?";
                $bindings[] = (string) $sp->unique_key;
            } else {
                // site / call — точное совпадение signal_identifier (case-insensitive).
                $sourceWhere = 'snap.signal_type = ? AND LOWER(snap.signal_identifier) = LOWER(?)';
                $bindings[] = $sp->signal_type;
                $bindings[] = (string) $sp->unique_key;
                if ($sp->signal_type === 'site') {
                    // Root-domain: лид по корню (carmoney.ru) доходит до подписчиков на субдомены
                    // (*.carmoney.ru), однонаправленно — сохраняем семантику auto-link.
                    $sourceWhere = '('.$sourceWhere
                        ." OR (snap.signal_type = ? AND LOWER(snap.signal_identifier) LIKE '%.' || LOWER(?)))";
                    $bindings[] = $sp->signal_type;
                    $bindings[] = (string) $sp->unique_key;
                }
            }
        } else {
            $sourceWhere = 'EXISTS (SELECT 1 FROM project_supplier_links psl
                  WHERE psl.project_id = snap.project_id AND psl.supplier_project_id = ?)';
            $bindings[] = $sp->id;
        }

        $regionWhere = '';
        if ($regionFilter === 'exact') {
            $regionWhere = 'AND ?::int = ANY(snap.regions)';
            $bindings[] = $code;
        } elseif ($regionFilter === 'all_ru') {
            $regionWhere = "AND snap.regions = '{}'::int[]";
        }

        $excludeWhere = '';
        if ($excludeTenantIds !== []) {
            $placeholders = implode(',', array_fill(0, count($excludeTenantIds), '?'));
            $excludeWhere = "AND snap.tenant_id NOT IN ($placeholders)";
            foreach ($excludeTenantIds as $tid) {
                $bindings[] = $tid;
            }
        }

        $sql = <<<SQL
            SELECT DISTINCT ON (snap.tenant_id)
                projects.*,
                snap.daily_limit AS snapshot_daily_limit,
                snap.regions AS snapshot_regions
            FROM project_routing_snapshots snap
            INNER JOIN projects ON projects.id = snap.project_id
            WHERE snap.snapshot_date = ?::date
              AND $sourceWhere
              AND projects.delivered_today < snap.daily_limit
              AND EXISTS (
                  SELECT 1 FROM tenants
                  WHERE tenants.id = snap.tenant_id
                    AND tenants.balance_rub > 0
                    AND tenants.frozen_by_balance_at IS NULL
              )
              $regionWhere
              $excludeWhere
            ORDER BY snap.tenant_id,
                     (snap.daily_limit - projects.delivered_today) DESC,
                     projects.created_at,
                     projects.id
        SQL;

        return Project::hydrate(DB::connection('pgsql_supplier')->select($sql, $bindings));
    }

    /**
     * Взвешенный жребий без возврата (вариант D1=В): отбирает ≤ $n кандидатов,
     * вероятность ∝ остатку лимита, вес ≥ 1 у каждого (мелкие не отрезаются).
     * При кандидатах ≤ $n — возвращает всех в исходном SQL-порядке (детерминизм).
     *
     * @param  Collection<int, Project>  $candidates
     * @return Collection<int, Project>
     */
    private function weightedPick(Collection $candidates, int $n): Collection
    {
        if ($n <= 0) {
            return collect();
        }

        $pool = $candidates->values()->all();
        if (count($pool) <= $n) {
            return collect($pool);
        }

        $picked = [];
        for ($i = 0; $i < $n && $pool !== []; $i++) {
            $total = 0;
            foreach ($pool as $p) {
                $total += $this->weightOf($p);
            }

            $roll = $this->randomizer->getInt(1, $total);
            $acc = 0;
            $winner = 0;
            foreach ($pool as $idx => $p) {
                $acc += $this->weightOf($p);
                if ($roll <= $acc) {
                    $winner = $idx;
                    break;
                }
            }

            $picked[] = $pool[$winner];
            array_splice($pool, $winner, 1);
        }

        return collect($picked);
    }

    private function weightOf(Project $project): int
    {
        $remaining = (int) $project->snapshot_daily_limit - (int) $project->delivered_today;

        return max(1, $remaining);
    }

    /**
     * @param  Collection<int, Project>  $projects
     */
    private function tagStep(Collection $projects, int $step): void
    {
        foreach ($projects as $project) {
            $project->setAttribute('routing_step', $step);
        }
    }

    /**
     * Активная дата слепка: до 21:00 МСК — сегодня, с 21:00 МСК — завтра (§4.2.3).
     */
    private function activeSnapshotDate(): string
    {
        $msk = Carbon::now('Europe/Moscow');

        return $msk->hour >= 21
            ? $msk->copy()->addDay()->toDateString()
            : $msk->toDateString();
    }

    /**
     * Fail-loud: пишет в лог, если по активной дате слепка вообще нет ни одной строки
     * snapshot'а (cron SnapshotProjectRoutingJob не отработал). Пустой валидный
     * результат при наличии snapshot'ов — не алерт.
     *
     * @param  array<int, mixed>  $rows
     */
    private function logIfNoSnapshot(array $rows, SupplierProject $supplierProject, string $activeDate): void
    {
        if ($rows !== []) {
            return;
        }

        $snapshotEmpty = DB::connection('pgsql_supplier')
            ->table('project_routing_snapshots')
            ->where('snapshot_date', $activeDate)
            ->doesntExist();

        if ($snapshotEmpty) {
            Log::error('lead_router.no_snapshot_for_active_date', [
                'active_date' => $activeDate,
                'supplier_project_id' => $supplierProject->id,
                'platform' => $supplierProject->platform,
            ]);
        }
    }
}
