<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Jobs\RouteSupplierLeadJob;
use App\Models\SupplierLead;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;
use Symfony\Component\HttpFoundation\IpUtils;

/**
 * Платформенный endpoint для входящего stream'а лидов от crm.bp-gr.ru.
 *
 * URL: POST /api/webhook/supplier/{secret}
 *
 * Защита (defense-in-depth, spec §5.1):
 *   1. {secret} в URL — platform-wide токен (≥32 chars), хранится в
 *      system_settings.supplier_webhook_secret. hash_equals для сравнения.
 *   2. IP allowlist (system_settings.supplier_ip_allowlist) — JSON array IP/CIDR.
 *      Пустой массив = пропускать всех (DEV mode); на prod заполнить.
 *
 * Несовпадение secret OR IP → 404 (не палим существование endpoint'а).
 *
 * Идемпотентность: UNIQUE INDEX на supplier_leads.vid. При дубле возвращаем
 * 200 OK без re-dispatch (поставщик может ретранслировать одни и те же лиды).
 *
 * Единственный приёмник входящих лидов от crm.bp-gr.ru (legacy per-tenant
 * webhook был удалён вместе с ProcessWebhookJob).
 *
 * Plan 2.6 fix #ii (10.05.2026): пустой `supplier_ip_allowlist = '[]'` на
 * production env теперь fail-closed (`verifyIpAllowlist` возвращает false если
 * env=production AND allowlist пустой). На dev/testing — fail-open для localhost
 * development. Admin ОБЯЗАН заполнить allowlist реальными IP/CIDR поставщика
 * перед production deploy. Дополнительно: hash_equals на уровне `verifySecret`
 * timing-safe только при равной длине; rate-limit per-IP добавится в Plan 3+.
 */
class SupplierWebhookController extends Controller
{
    /** Audit-fix C2: per-IP rate-limit (DoS-guard), запросов в минуту. */
    private const RATE_LIMIT_PER_MINUTE = 600;

    public function receive(Request $request, string $secret = ''): JsonResponse
    {
        // Аутентификация (аддитивно): URL-секрет (backward-compat) ИЛИ HMAC-подпись
        // тела (X-Webhook-Signature = hash_hmac sha256 от raw body на том же
        // supplier_webhook_secret). HMAC позволяет поставщику не слать секрет в URL
        // — тот течёт в access-логи (P2/E4). verifySecret('') всегда false.
        $sig = (string) $request->header('X-Webhook-Signature', '');
        $sig = str_starts_with($sig, 'sha256=') ? substr($sig, 7) : $sig;
        $secretRow = DB::table('system_settings')->where('key', 'supplier_webhook_secret')->first();
        $expectedSecret = $secretRow !== null ? (string) $secretRow->value : '';
        $hmacValid = $sig !== ''
            && $expectedSecret !== '__SET_ON_DEPLOY__'
            && strlen($expectedSecret) >= 32
            && hash_equals(hash_hmac('sha256', $request->getContent(), $expectedSecret), $sig);

        if (! $this->verifySecret($secret) && ! $hmacValid) {
            $this->logSupplierWebhook($request, null, 'rejected_secret');

            return response()->json(['message' => 'Not found.'], 404);
        }

        if (! $this->verifyIpAllowlist($request->ip())) {
            $this->logSupplierWebhook($request, null, 'rejected_ip');

            return response()->json(['message' => 'Not found.'], 404);
        }

        // Audit-fix C2: per-IP rate-limit. Endpoint secret-gated, но защищаем
        // от flood даже с валидным secret (DoS-guard). Лимит с запасом для
        // легитимного stream'а лидов от crm.bp-gr.ru.
        $rateKey = 'supplier-webhook:'.($request->ip() ?? 'unknown');
        if (RateLimiter::tooManyAttempts($rateKey, self::RATE_LIMIT_PER_MINUTE)) {
            $retryAfter = RateLimiter::availableIn($rateKey);
            $this->logSupplierWebhook($request, null, 'rate_limited');

            return response()->json([
                'message' => 'Превышен лимит запросов.',
                'retry_after' => $retryAfter,
            ], 429)->header('Retry-After', (string) $retryAfter);
        }
        RateLimiter::hit($rateKey, 60);

        // Plan 2.6 fix #iii: timestamp partition guard. Партиции deals месячные
        // (deals_2026_MM); time за пределами текущего месяца → INSERT CRASH
        // "no partition of relation deals found for row" в RouteSupplierLeadJob.
        // Окно ±24h защищает от wildly out-of-range значений (старый/будущий
        // дроп от поставщика); покрывает retry-задержки + clock-drift серверов.
        $minTime = now()->subDay()->getTimestamp();
        $maxTime = now()->addDay()->getTimestamp();

        $validated = $request->validate([
            'vid' => 'required|integer|min:1',
            'project' => ['required', 'string', 'max:255'], // Phase 3: regex /^B[123]_.+$/ снят — non-B → platform=DIRECT
            'phone' => ['required', 'string', 'regex:/^7\d{10}$/'],
            'time' => ['required', 'integer', "min:{$minTime}", "max:{$maxTime}"],
            'tag' => 'nullable|string|max:255',
            'phones' => 'nullable|array',
            'phones.*' => 'string|regex:/^7\d{10}$/',
        ]);

        $existing = SupplierLead::query()->where('vid', $validated['vid'])->first();
        if ($existing !== null) {
            return response()->json([
                'status' => 'already_processed',
                'supplier_lead_id' => $existing->id,
            ], 200);
        }

        $platform = $this->parsePlatform($validated['project']);

        $lead = SupplierLead::create([
            'platform' => $platform,
            'raw_payload' => $validated,
            'vid' => $validated['vid'],
            'phone' => $validated['phone'],
            'received_at' => now(),
            'source' => 'webhook',
        ]);

        RouteSupplierLeadJob::dispatch($lead->id);
        $this->logSupplierWebhook($request, $lead->id, 'received');

        return response()->json([
            'status' => 'accepted',
            'supplier_lead_id' => $lead->id,
        ], 202);
    }

    /**
     * Audit-fix: log all supplier webhook outcomes to webhook_log.
     * Covers 4 exit points: received / rejected_secret / rejected_ip / rate_limited.
     * Silently skips if table does not exist (safe for migrations in progress).
     */
    private function logSupplierWebhook(Request $request, ?int $leadId, string $status): void
    {
        if (! \Schema::hasTable('webhook_log')) {
            return;
        }

        try {
            DB::table('webhook_log')->insert([
                'tenant_id' => null,
                'raw_payload' => '{}',
                'source' => 'supplier',
                'status' => $status,
                'lead_id' => $leadId,
                'ip_address' => $request->ip(),
                'created_at' => now(),
            ]);
        } catch (\Throwable) {
            // Never let logging failure break the primary response
        }
    }

    private function verifySecret(string $providedSecret): bool
    {
        $row = DB::table('system_settings')->where('key', 'supplier_webhook_secret')->first();
        if ($row === null) {
            return false;
        }
        $expected = (string) $row->value;
        if ($expected === '__SET_ON_DEPLOY__' || strlen($expected) < 32) {
            return false;
        }

        return hash_equals($expected, $providedSecret);
    }

    private function verifyIpAllowlist(?string $clientIp): bool
    {
        if ($clientIp === null) {
            return false;
        }
        $row = DB::table('system_settings')->where('key', 'supplier_ip_allowlist')->first();
        if ($row === null) {
            return true;
        }
        $list = json_decode((string) $row->value, true) ?: [];
        if ($list === []) {
            // Plan 2.6 fix #ii: production env — пустой allowlist fail-closed (защита
            // от забытого override schema seed); dev/testing — fail-open для localhost
            // development. CV.11 audit WARN #5: inline-warning lines 34-39 признавал
            // проблему, теперь enforced на уровне env.
            return ! app()->environment('production');
        }

        return IpUtils::checkIp($clientIp, $list);
    }

    private function parsePlatform(string $project): string
    {
        // Phase 3: проекты без B-префикса → DIRECT (раньше silent fallback на 'B1'
        // приводил к неверной маршрутизации).
        if (preg_match('/^(B[123])_/', $project, $m) === 1) {
            return $m[1];
        }

        return 'DIRECT';
    }
}
