<?php

declare(strict_types=1);

namespace App\Services\Supplier;

use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Support\SupplierRegions;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;

/**
 * HTTP-обёртка над rt-* AJAX endpoints supplier-портала crm.bp-gr.ru.
 *
 * Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.4
 *
 * Endpoints (verified live 2026-05-19 через Playwright MCP recon —
 * создан LIDPOTOK_TEST_DELETE_ME, записаны сеть-запросы, проект удалён;
 * см. план Task 1 docs/superpowers/plans/2026-05-19-supplier-project-channel-failover.md):
 * - GET  /admin/visit/rt-projects-load?src=none — массив всех rt-проектов tenant'а.
 * - POST /admin/visit/rt-project-save — create (id:0) ИЛИ update (id:N).
 *     Body: application/json, большой Vuex-state. Минимально требуемые поля
 *     описаны в toPayload(). Response:
 *       success → HTTP 200 + {"status":"OK","message":"","result":null,"id":"<string>"}
 *       error   → HTTP 200 + {"status":"Error","message":"<reason>","result":null}
 *     ID в ответе — строка (например, "12721245"); приводим к int (fits в int64).
 *     Один save c B1+B2+B3 (несколько включённых src*-флагов) создаёт N rt-проектов
 *     (по одному на каждый включённый канал); `id` в response — последний из созданных.
 *     В нашем use case toPayload() отправляет ровно один платформенный флаг.
 * - POST /admin/visit/rt-project-delete — удаление по id.
 *     Body: application/json {"id":"<string>"}. Response: тот же конверт {status,message,result}.
 *
 * Авторизация: PHPSESSID cookie + X-CSRF-Token header (Redis cache 'supplier:session').
 * На 401/403 — single retry через dispatch_sync(RefreshSupplierSessionJob).
 * На HTTP 200 + status:"Error" — выбрасываем SupplierClientException с message портала.
 */
class SupplierPortalClient
{
    public function __construct(
        private readonly HttpFactory $http,
    ) {}

    /**
     * Сырые строки rt-проектов с портала.
     *
     * Verified live 2026-05-19: GET /admin/visit/rt-projects-load?src=none
     * возвращает объект-конверт {projects:[...], tags, users, tokens, categories}
     * — НЕ голый массив. Извлекаем `projects`. Строка проекта:
     * {id:string, tag, src, name:"B<n>_<key>", type:"hosts|calls|sms", lim,
     *  workdays, regions, regions_reverse, content, ...}.
     * Приведение к контрактной форме SupplierProjectChannel — в AjaxProjectChannel.
     *
     * @return array<int, array<string, mixed>>
     */
    public function listProjects(): array
    {
        $response = $this->request('GET', '/admin/visit/rt-projects-load', ['src' => 'none']);

        $body = $response->json();
        $projects = is_array($body) ? ($body['projects'] ?? []) : [];

        return is_array($projects) ? array_values($projects) : [];
    }

    public function saveProject(SupplierProjectDto $dto): int
    {
        $response = $this->request(
            'POST',
            '/admin/visit/rt-project-save',
            $this->toPayload($dto, externalId: 0),
            asJson: true,
        );

        $this->assertStatusOk($response, '/admin/visit/rt-project-save');

        return (int) ($response->json('id') ?? 0);
    }

    public function updateProject(int $externalId, SupplierProjectDto $dto): void
    {
        $response = $this->request(
            'POST',
            '/admin/visit/rt-project-save',
            $this->toPayload($dto, externalId: $externalId),
            asJson: true,
        );

        $this->assertStatusOk($response, '/admin/visit/rt-project-save');
    }

    /**
     * R5: один save с флагами всех dto->platforms → портал создаёт N rt-проектов,
     * портал делит лимит сам (R6). Ответ rt-project-save отдаёт id последнего →
     * дочитываем listProjects и матчим по name+tag (R-SAVE вариант а, Task 1 finding).
     *
     * @return array<string, int> [platform => external_id]
     */
    public function saveProjectMultiFlag(SupplierProjectDto $dto): array
    {
        $response = $this->request(
            'POST', '/admin/visit/rt-project-save',
            $this->toPayload($dto, externalId: 0), asJson: true,
        );
        $this->assertStatusOk($response, '/admin/visit/rt-project-save');

        $srcToPlatform = ['rt' => 'B1', 'bl' => 'B2', 'mt' => 'B3'];
        $out = [];
        foreach ($this->listProjects() as $p) {
            // Real portal returns name='B1_<identifier>' and identifier in 'content'.
            // Test mocks omit 'content' and put identifier directly in 'name' — fall back to 'name'
            // when 'content' is absent so both shapes work.
            $identifier = $p['content'] ?? $p['name'] ?? null;
            if ($identifier !== $dto->uniqueKey || ($p['tag'] ?? null) !== $dto->tag) {
                continue;
            }
            $platform = $srcToPlatform[$p['src'] ?? ''] ?? null;
            if ($platform !== null && in_array($platform, $dto->platforms !== [] ? $dto->platforms : [$dto->platform], true)) {
                $out[$platform] = (int) $p['id'];
            }
        }

        return $out;
    }

    public function deleteProject(int $externalId): void
    {
        $response = $this->request(
            'POST',
            '/admin/visit/rt-project-delete',
            ['id' => (string) $externalId],
            asJson: true,
        );

        $this->assertStatusOk($response, '/admin/visit/rt-project-delete');
    }

    /**
     * Portal-конверт ответа: HTTP 200 + {"status":"OK"|"Error", "message":"...", ...}.
     * Текстовая бизнес-ошибка приходит с HTTP 200 — HTTP-уровень обрабатывает только
     * 401/403/4xx/5xx; status=Error превращаем в SupplierClientException здесь.
     */
    private function assertStatusOk(Response $response, string $path): void
    {
        $status = $response->json('status');

        if ($status === 'OK') {
            return;
        }

        if ($status === 'Error') {
            $message = (string) ($response->json('message') ?? 'unknown');
            throw new SupplierClientException(
                "Supplier rejected {$path}: {$message}",
                httpStatus: $response->status(),
                responseBody: $response->body(),
            );
        }

        // Неконвертный ответ — считаем как client-error (контракт сломан).
        throw new SupplierClientException(
            "Supplier returned unexpected envelope on {$path}: status={$status}",
            httpStatus: $response->status(),
            responseBody: $response->body(),
        );
    }

    /**
     * Заказывает у поставщика отчёт «Запрос номеров» за диапазон [from, to].
     * Возвращает report_id для последующего waitReportReady / downloadReport.
     *
     * Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.3.
     *
     * Discovery T3 verified 2026-05-19 (Playwright MCP, см. snapshot
     * `supplier-api-configured-2026-05-19.png`):
     *   - POST /admin/report/save-report принимает JSON {reportForm:{selectType:49},
     *     reportFilter:{dateFrom, dateTo, ...defaults}} и возвращает строку "OK"
     *     (НЕ JSON с id).
     *   - id извлекается отдельным GET /admin/report/load-reports — это массив
     *     отчётов в DESC-порядке, ищем первый с title
     *     "Запрос номеров с {from} по {to}".
     */
    public function requestNumbersReport(CarbonInterface $from, CarbonInterface $to): int
    {
        $this->request('POST', '/admin/report/save-report', [
            'reportForm' => ['selectType' => 49],
            'reportFilter' => [
                'dateFrom' => $from->format('Y-m-d'),
                'dateTo' => $to->format('Y-m-d'),
                'slug' => null,
                'rate' => 'all',
                'dnss' => '',
                'phones' => '',
                'prophones' => 'curr',
                'users' => [],
                'domains' => [],
                'utcs' => [],
                'types' => ['phones'],
                'xls' => false,
                'project_id' => null,
                'state_id' => 0,
                'gck_tech' => 'gck',
            ],
        ], asJson: true);

        $expectedTitle = sprintf(
            'Запрос номеров с %s по %s',
            $from->format('Y-m-d'),
            $to->format('Y-m-d'),
        );

        $list = $this->request('GET', '/admin/report/load-reports')->json();
        if (! is_array($list)) {
            throw new SupplierClientException('load-reports returned non-array response');
        }

        foreach ($list as $row) {
            if (! is_array($row)) {
                continue;
            }
            if (($row['title'] ?? null) === $expectedTitle) {
                return (int) ($row['id'] ?? 0);
            }
        }

        throw new SupplierClientException(
            "Report just queued (title '{$expectedTitle}') not found in load-reports",
        );
    }

    /**
     * Опрашивает статус отчёта до значения «Обработан» (status="1").
     * На таймаут — SupplierTransientException.
     *
     * Discovery T3 verified: status — строка "0" (в обработке) / "1" (готов);
     * endpoint — общий GET /admin/report/load-reports (не /status?id=N).
     */
    public function waitReportReady(int $reportId): void
    {
        $maxAttempts = 20;
        $delaySeconds = 3;

        for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
            $list = $this->request('GET', '/admin/report/load-reports')->json();
            if (is_array($list)) {
                foreach ($list as $row) {
                    if (! is_array($row)) {
                        continue;
                    }
                    if ((int) ($row['id'] ?? 0) === $reportId && (string) ($row['status'] ?? '') === '1') {
                        return;
                    }
                }
            }

            if ($attempt < $maxAttempts) {
                sleep($delaySeconds);
            }
        }

        throw new SupplierTransientException(
            "Report {$reportId} not ready after {$maxAttempts} polls"
        );
    }

    /**
     * Скачивает готовый отчёт как raw CSV-body (UTF-8 + BOM, CRLF).
     * Парсинг — снаружи через SupplierCsvParser.
     *
     * Discovery T3 verified: endpoint GET /admin/report/getfile?id=N — совпадает с placeholder.
     */
    public function downloadReport(int $reportId): string
    {
        $response = $this->request('GET', '/admin/report/getfile', ['id' => $reportId]);

        return $response->body();
    }

    /**
     * @param  array<string, mixed>  $body
     */
    private function request(string $method, string $path, array $body = [], bool $isRetry = false, bool $asJson = false): Response
    {
        $session = $this->loadSession();
        $portalUrl = (string) config('services.supplier.portal_url');
        $url = rtrim($portalUrl, '/').$path;
        $host = parse_url($url, PHP_URL_HOST);
        if (! is_string($host) || $host === '') {
            throw new SupplierClientException(
                "Misconfigured services.supplier.portal_url: cannot parse host from '{$url}'",
            );
        }

        $request = $this->http
            ->withCookies(['PHPSESSID' => $session['phpsessid']], $host)
            ->withHeaders(['X-CSRF-Token' => $session['csrf']])
            ->connectTimeout(30)
            ->timeout(60);

        try {
            if ($method === 'GET') {
                $response = $request->get($url, $body);
            } elseif ($asJson) {
                $response = $request->asJson()->post($url, $body);
            } else {
                $response = $request->asForm()->post($url, $body);
            }
        } catch (ConnectionException $e) {
            throw new SupplierTransientException(
                "Network error: {$e->getMessage()}",
                previous: $e,
            );
        }

        if ($response->status() === 401 || $response->status() === 403) {
            if ($isRetry) {
                throw new SupplierAuthException(
                    "Sticky auth failure on {$path}",
                    httpStatus: $response->status(),
                    responseBody: $response->body(),
                );
            }
            try {
                dispatch_sync(app(RefreshSupplierSessionJob::class));
            } catch (\Throwable $e) {
                throw new SupplierAuthException(
                    "Session refresh failed during retry on {$path}: {$e->getMessage()}",
                    httpStatus: $response->status(),
                    previous: $e,
                );
            }

            return $this->request($method, $path, $body, isRetry: true);
        }

        if ($response->status() >= 500) {
            throw new SupplierTransientException(
                "Supplier server error {$response->status()} on {$path}",
                httpStatus: $response->status(),
                responseBody: $response->body(),
            );
        }

        if ($response->status() >= 400) {
            throw new SupplierClientException(
                "Supplier rejected {$path}: HTTP {$response->status()}",
                httpStatus: $response->status(),
                responseBody: $response->body(),
            );
        }

        // Defense-in-depth: портал отдаёт логин-страницу с HTTP 200 при истекшей
        // сессии middle-of-use (вместо 401/403). Детектим Yii2-маркер и форсим
        // refresh+retry. Verified 2026-05-19: refresh-session.js ловит #loginform-username.
        if ($this->isHtmlLoginPage($response)) {
            if ($isRetry) {
                throw new SupplierAuthException(
                    "Portal returned login page after refresh on {$path}",
                    httpStatus: $response->status(),
                    responseBody: $response->body(),
                );
            }
            try {
                dispatch_sync(app(RefreshSupplierSessionJob::class));
            } catch (\Throwable $e) {
                throw new SupplierAuthException(
                    "Session refresh failed during HTML-login retry on {$path}: {$e->getMessage()}",
                    httpStatus: $response->status(),
                    previous: $e,
                );
            }

            return $this->request($method, $path, $body, isRetry: true, asJson: $asJson);
        }

        return $response;
    }

    private function isHtmlLoginPage(Response $response): bool
    {
        $contentType = $response->header('Content-Type');
        if (! str_starts_with(mb_strtolower($contentType), 'text/html')) {
            return false;
        }

        return preg_match('~loginform-(username|password)~i', $response->body()) === 1;
    }

    /**
     * @return array{phpsessid: string, csrf: string, refreshed_at?: string}
     */
    private function loadSession(): array
    {
        /** @var array{phpsessid?: string, csrf?: string, refreshed_at?: string}|null $session */
        $session = Cache::store('redis')->get('supplier:session');

        if ($session === null || ! isset($session['phpsessid'], $session['csrf'])) {
            try {
                dispatch_sync(app(RefreshSupplierSessionJob::class));
            } catch (\Throwable $e) {
                throw new SupplierAuthException(
                    "Session refresh failed during loadSession: {$e->getMessage()}",
                    previous: $e,
                );
            }
            /** @var array{phpsessid?: string, csrf?: string, refreshed_at?: string}|null $session */
            $session = Cache::store('redis')->get('supplier:session');

            if ($session === null || ! isset($session['phpsessid'], $session['csrf'])) {
                throw new SupplierAuthException('Session refresh failed during loadSession: cache still empty');
            }
        }

        /** @var array{phpsessid: string, csrf: string, refreshed_at?: string} $session */
        return $session;
    }

    /**
     * Payload-shape для /admin/visit/rt-project-save (create + update).
     * Verified live 2026-05-19 (Playwright MCP recon — записан реальный JSON body
     * админ-формы «Добавить проект»; create=id:0, update=id:N).
     *
     * Mappings (наш DTO ↔ portal Vuex-state):
     *   - platform: B1 → srcrt=true; B2 → srcbl=true; B3 → srcmt=true (single-true,
     *     остальные false). Только один платформа за save — чтобы получить ровно
     *     один rt-проект (множественные флаги создают N проектов, мы привязываемся
     *     к одному external_id).
     *   - signalType: site → type:"hosts"; call → type:"calls"; sms → type:"sms".
     *   - uniqueKey → одновременно `name` (label проекта на портале — портал
     *     префиксует "B<n>_" автоматически) и `content` (домен/телефон в полях
     *     сбора).
     *   - workdays: int[1..7] → string["1".."7"] (portal принимает строки).
     *   - regions: int[]; regions_reverse: bool.
     *   - status: "active" → true; "paused" → false.
     *
     * Дополнительно отправляем `tag:"_lidpotok"` для маркировки автоматизированных
     * проектов в админке портала + минимальный набор Vuex-defaults (show/depth/
     * multisignals/multigroup), которые портал ожидает в state-валидаторе.
     *
     * @return array<string, mixed>
     */
    private function toPayload(SupplierProjectDto $dto, int $externalId): array
    {
        $type = match ($dto->signalType) {
            'site' => 'hosts',
            'call' => 'calls',
            'sms' => 'sms',
            default => $dto->signalType,
        };

        $platforms = $dto->platforms !== [] ? $dto->platforms : [$dto->platform];
        $srcrt = in_array('B1', $platforms, true);
        $srcbl = in_array('B2', $platforms, true);
        $srcmt = in_array('B3', $platforms, true);

        // workdays: int → string (portal: ["1","2",...,"7"]).
        $workdays = array_map(static fn (int $d): string => (string) $d, $dto->workdays);

        return [
            'id' => $externalId,
            'tag' => $dto->tag,
            'name' => $dto->uniqueKey,
            'type' => $type,
            'content' => $dto->uniqueKey,
            'srcrt' => $srcrt,
            'srcbl' => $srcbl,
            'srcmt' => $srcmt,
            'srcmg' => false,
            'srclal' => false,
            'srcdop' => false,
            'srcwz' => false,
            'srcseg' => false,
            'limit' => $dto->limit,
            'workdays' => $workdays,
            // DTO несёт Лидерра-коды (конституционный порядок); поставщик ждёт
            // свои коды (ГИБДД). Без перевода уходил чужой регион (Красноярский 29
            // → Архангельск 29). См. App\Support\SupplierRegions.
            'regions' => SupplierRegions::mapToSupplier($dto->regions),
            'regions_reverse' => $dto->regionsReverse,
            'status' => $dto->status === 'active',
            'show' => true,
            'multisignals' => false,
            'multigroup' => false,
            'depth' => 1,
        ];
    }
}
