<?php
declare(strict_types=1);

namespace Celena\Plugin\Analytics\Controllers;

use Celena\Core\Database\Connection;
use Celena\Core\Http\Request;
use Celena\Core\Http\Response;
use Celena\Plugin\Analytics\Support\Url;
use Celena\Plugin\Analytics\Support\Geo;
use Celena\Plugin\Analytics\Support\GeoCity;

/**
 * Первопартийный приёмник аналитики. POST /api/track (beacon/fetch, без CSRF — публичный).
 * Сохраняет события визитов в cl_analytics_events и определяет источник перехода.
 */
final class TrackController
{
    public function __construct(private readonly Connection $conn)
    {
    }

    public function ingest(Request $req): Response
    {
        $type = $this->s($req->input('type', 'pageview'), 16);
        if (!in_array($type, ['pageview', 'event', 'leave'], true)) {
            return Response::json(['ok' => false], 400);
        }

        $vid = $this->s($req->input('vid', ''), 40);
        $sid = $this->s($req->input('sid', ''), 40);
        if ($vid === '' || $sid === '') {
            return Response::json(['ok' => false], 400);
        }

        $ref       = $this->s($req->input('ref', ''), 500);
        $utmSource = $this->s($req->input('utm_source', ''), 120);
        $utmMedium = $this->s($req->input('utm_medium', ''), 120);
        $gclid     = $this->s($req->input('gclid', ''), 200);

        $source = $this->detectSource($gclid, $utmSource, $utmMedium, $ref);

        $ua  = (string) ($req->header('user-agent') ?? '');
        $ip  = (string) $req->ip();
        $dev = $this->s($req->input('device', ''), 10);
        if (!in_array($dev, ['mobile', 'desktop', 'tablet'], true)) {
            $dev = preg_match('/Mobi|Android|iPhone/i', $ua) ? 'mobile' : 'desktop';
        }

        $dur = (int) $req->input('dur', 0);
        if ($dur < 0 || $dur > 7200) $dur = 0;

        // Один резолв по IP из mmdb (страна+город+координаты). IP не сохраняется.
        $geo = $ip !== '' ? GeoCity::lookup($ip) : null;

        try {
            $this->conn->builder('analytics_events')->insert([
                'visitor_id'   => $vid,
                'session_id'   => $sid,
                'type'         => $type,
                'event_name'   => $type === 'event' ? $this->s($req->input('name', ''), 60) : null,
                'url'          => $this->s($req->input('url', ''), 500),
                'referrer'     => $ref ?: null,
                'source'       => $source,
                'utm_source'   => $utmSource ?: null,
                'utm_medium'   => $utmMedium ?: null,
                'utm_campaign' => $this->s($req->input('utm_campaign', ''), 150) ?: null,
                'utm_term'     => $this->s($req->input('utm_term', ''), 150) ?: null,
                'gclid'        => $gclid ?: null,
                'device'       => $dev,
                'duration'     => $type === 'leave' ? $dur : null,
                'country'      => $this->country($req, $geo),
                'city'         => $this->city($req, $geo),
                'lat'          => $this->coord($req, 'x-geo-lat', $geo['lat'] ?? null, -90.0, 90.0),
                'lon'          => $this->coord($req, 'x-geo-lon', $geo['lon'] ?? null, -180.0, 180.0),
                'ip_hash'      => $ip !== '' ? substr(hash('sha256', $ip . '|' . $this->ipSalt()), 0, 64) : null,
                'created_at'   => date('Y-m-d H:i:s'),
            ]);
        } catch (\Throwable) {
            // не мешаем посетителю при любой ошибке записи
        }

        return Response::json(['ok' => true], 204);
    }

    /**
     * Соль для хэша IP. Берём APP_KEY (уникален на каждой установке) — так хэши
     * не обратимы по радужной таблице и не переносимы между сайтами. Если ключа
     * почему-то нет, откатываемся на константу (лучше слабый хэш, чем сырой IP).
     */
    private function ipSalt(): string
    {
        $key = (string) (function_exists('env') ? env('APP_KEY', '') : (getenv('APP_KEY') ?: ''));
        return $key !== '' ? $key : 'celena-fallback-salt';
    }

    /** Источник: реклама (gclid/cpc) / соцсети / поиск / переход по ссылке / прямой. */
    private function detectSource(string $gclid, string $utmSource, string $utmMedium, string $ref): string
    {
        $m = strtolower($utmMedium);
        if ($gclid !== '' || in_array($m, ['cpc', 'ppc', 'paid', 'paidsearch'], true)
            || (str_contains(strtolower($utmSource), 'google') && $m !== '' && $m !== 'organic')) {
            return 'ads';
        }
        if ($m === 'social' || $this->hostIn($ref, ['facebook.', 'instagram.', 't.me', 'telegram.', 'twitter.', 'x.com', 'linkedin.', 'youtube.', 'tiktok.'])) {
            return 'social';
        }
        if ($this->hostIn($ref, ['google.', 'bing.', 'yahoo.', 'duckduckgo.', 'yandex.'])) {
            return 'organic';
        }
        if ($ref !== '' && !Url::isInternal($ref)) {
            return 'referral';
        }
        return 'direct';
    }

    /**
     * Страна: upstream-заголовок (Cloudflare CF-IPCountry / nginx X-Geo-Country) →
     * mmdb (DB-IP City, в т.ч. IPv6) → локальная база IP→страна (IPv4). Иначе null.
     * @param array{cc:?string,city:?string,lat:?float,lon:?float}|null $geo
     */
    private function country(Request $req, ?array $geo): ?string
    {
        $c = strtoupper(trim((string) ($req->header('cf-ipcountry') ?? $req->header('x-geo-country') ?? '')));
        if (preg_match('/^[A-Z]{2}$/', $c) && !in_array($c, ['XX', 'T1'], true)) {
            return $c;
        }
        if (!empty($geo['cc'])) {
            return $geo['cc'];
        }
        return Geo::lookup($this->conn, (string) $req->ip());
    }

    /**
     * Город: upstream-заголовок фронта (X-Geo-City / CF-IPCity) → mmdb. Иначе null.
     * @param array{cc:?string,city:?string,lat:?float,lon:?float}|null $geo
     */
    private function city(Request $req, ?array $geo): ?string
    {
        $raw = (string) ($req->header('x-geo-city') ?? $req->header('cf-ipcity') ?? '');
        if ($raw !== '') {
            // nginx может URL-кодировать не-ASCII; убираем управляющие символы
            $c = trim(preg_replace('/[\x00-\x1F\x7F]/u', '', rawurldecode($raw)) ?? '');
            if ($c !== '') return mb_substr($c, 0, 120);
        }
        return $geo['city'] ?? null;
    }

    /** Координата: заголовок фронта (число) → fallback из mmdb. Вне диапазона → null. */
    private function coord(Request $req, string $header, ?float $fallback, float $min, float $max): ?float
    {
        $raw = trim((string) ($req->header($header) ?? ''));
        if ($raw !== '' && is_numeric($raw)) {
            $v = (float) $raw;
            if ($v >= $min && $v <= $max) return $v;
        }
        return ($fallback !== null && $fallback >= $min && $fallback <= $max) ? $fallback : null;
    }

    private function hostIn(string $url, array $needles): bool
    {
        $h = strtolower((string) parse_url($url, PHP_URL_HOST));
        foreach ($needles as $n) if (str_contains($h, $n)) return true;
        return false;
    }

    private function s(mixed $v, int $max): string
    {
        return mb_substr(trim((string) $v), 0, $max);
    }
}
