<?php

namespace Modules\Transport\Services\Ai;

use App\Models\TransportAiSettingsModel;
use App\Models\TransportDistanceCacheModel;
use Throwable;

/**
 * Real-road distance and travel time engine.
 *
 * Primary provider is an OSRM server (table + route services). Every result is
 * cached in transport_distance_cache. When OSRM is unreachable the service
 * falls back to Haversine distance multiplied by a road factor, with duration
 * derived from a configurable average speed, so plan generation never fails
 * because of network issues.
 */
class RoutingService
{
    protected const EARTH_RADIUS_KM  = 6371.0;
    protected const OSRM_TIMEOUT_SEC = 8;
    /** OSRM table service practical URL limit; chunk larger matrices. */
    protected const MAX_TABLE_POINTS = 80;

    protected TransportDistanceCacheModel $cache;
    protected array $settings;
    protected bool $osrmHealthy = true;

    public function __construct(?array $settings = null)
    {
        $this->cache    = new TransportDistanceCacheModel();
        $this->settings = $settings ?? (new TransportAiSettingsModel())->getSettings();
    }

    /**
     * Whether any leg in the last computations used the Haversine fallback.
     */
    public function usedFallback(): bool
    {
        return ! $this->osrmHealthy;
    }

    /**
     * Distance (km) and duration (minutes) between two coordinates.
     *
     * @return array{distance_km: float, duration_minutes: float, provider: string}
     */
    public function leg(float $fromLat, float $fromLng, float $toLat, float $toLng): array
    {
        if (abs($fromLat - $toLat) < 0.00005 && abs($fromLng - $toLng) < 0.00005) {
            return ['distance_km' => 0.0, 'duration_minutes' => 0.0, 'provider' => 'osrm'];
        }

        $cached = $this->cache->lookup($fromLat, $fromLng, $toLat, $toLng);
        // Haversine-sourced cache rows are estimates: retry OSRM while healthy
        // so real road data replaces them as soon as the provider recovers.
        if ($cached && ($cached['provider'] === 'osrm' || ! $this->osrmHealthy)) {
            return [
                'distance_km'      => (float) $cached['distance_km'],
                'duration_minutes' => (float) $cached['duration_minutes'],
                'provider'         => $cached['provider'],
            ];
        }

        if ($this->osrmHealthy) {
            $result = $this->osrmRouteLeg($fromLat, $fromLng, $toLat, $toLng);
            if ($result !== null) {
                $this->cache->store($fromLat, $fromLng, $toLat, $toLng, $result['distance_km'], $result['duration_minutes'], 'osrm');

                return $result + ['provider' => 'osrm'];
            }
            $this->osrmHealthy = false;
        }

        $result = $this->haversineLeg($fromLat, $fromLng, $toLat, $toLng);
        $this->cache->store($fromLat, $fromLng, $toLat, $toLng, $result['distance_km'], $result['duration_minutes'], 'haversine');

        return $result + ['provider' => 'haversine'];
    }

    /**
     * Full pairwise distance/duration matrix for a set of points.
     *
     * @param array<int, array{lat: float, lng: float}> $points
     *
     * @return array{distances: array<int, array<int, float>>, durations: array<int, array<int, float>>, provider: string}
     *         distances in km, durations in minutes, indexed by the input point order.
     */
    public function distanceMatrix(array $points): array
    {
        $n = count($points);
        $distances = array_fill(0, $n, array_fill(0, $n, 0.0));
        $durations = array_fill(0, $n, array_fill(0, $n, 0.0));

        if ($n < 2) {
            return ['distances' => $distances, 'durations' => $durations, 'provider' => 'osrm'];
        }

        if ($this->osrmHealthy && $n <= self::MAX_TABLE_POINTS) {
            $matrix = $this->osrmTable($points);
            if ($matrix !== null) {
                return $matrix + ['provider' => 'osrm'];
            }
            $this->osrmHealthy = false;
        }

        // Pairwise legs (cache-backed); used for fallback or very large sets.
        for ($i = 0; $i < $n; $i++) {
            for ($j = 0; $j < $n; $j++) {
                if ($i === $j) {
                    continue;
                }
                $leg = $this->leg($points[$i]['lat'], $points[$i]['lng'], $points[$j]['lat'], $points[$j]['lng']);
                $distances[$i][$j] = $leg['distance_km'];
                $durations[$i][$j] = $leg['duration_minutes'];
            }
        }

        return [
            'distances' => $distances,
            'durations' => $durations,
            'provider'  => $this->osrmHealthy ? 'osrm' : 'haversine',
        ];
    }

    /**
     * Road geometry + per-leg stats for an ordered list of waypoints.
     *
     * @param array<int, array{lat: float, lng: float}> $orderedPoints
     *
     * @return array{geometry: ?array, legs: array<int, array{distance_km: float, duration_minutes: float}>, distance_km: float, duration_minutes: float}
     *         geometry is a GeoJSON LineString coordinates array ([lng, lat] pairs) or null on fallback.
     */
    public function routeGeometry(array $orderedPoints): array
    {
        $legs = [];
        $totalKm = 0.0;
        $totalMin = 0.0;

        if (count($orderedPoints) < 2) {
            return ['geometry' => null, 'legs' => [], 'distance_km' => 0.0, 'duration_minutes' => 0.0];
        }

        if ($this->osrmHealthy) {
            $result = $this->osrmRoute($orderedPoints);
            if ($result !== null) {
                return $result;
            }
            $this->osrmHealthy = false;
        }

        for ($i = 1, $n = count($orderedPoints); $i < $n; $i++) {
            $leg = $this->leg(
                $orderedPoints[$i - 1]['lat'],
                $orderedPoints[$i - 1]['lng'],
                $orderedPoints[$i]['lat'],
                $orderedPoints[$i]['lng']
            );
            $legs[] = ['distance_km' => $leg['distance_km'], 'duration_minutes' => $leg['duration_minutes']];
            $totalKm  += $leg['distance_km'];
            $totalMin += $leg['duration_minutes'];
        }

        // Straight-line geometry so the map can still draw the route.
        $geometry = array_map(
            static fn (array $p): array => [(float) $p['lng'], (float) $p['lat']],
            $orderedPoints
        );

        return [
            'geometry'         => $geometry,
            'legs'             => $legs,
            'distance_km'      => round($totalKm, 3),
            'duration_minutes' => round($totalMin, 2),
        ];
    }

    // ------------------------------------------------------------------
    // OSRM
    // ------------------------------------------------------------------

    protected function osrmBaseUrl(): string
    {
        return rtrim((string) ($this->settings['osrm_base_url'] ?? 'https://router.project-osrm.org'), '/');
    }

    /**
     * @param array<int, array{lat: float, lng: float}> $points
     */
    protected function coordinateString(array $points): string
    {
        return implode(';', array_map(
            static fn (array $p): string => sprintf('%.6f,%.6f', $p['lng'], $p['lat']),
            $points
        ));
    }

    protected function osrmRequest(string $url): ?array
    {
        try {
            $client = service('curlrequest', [
                'timeout'         => self::OSRM_TIMEOUT_SEC,
                'connect_timeout' => 5,
                'http_errors'     => false,
            ]);
            $response = $client->get($url, ['headers' => ['Accept' => 'application/json']]);
            if ($response->getStatusCode() !== 200) {
                return null;
            }
            $body = json_decode((string) $response->getBody(), true);
            if (! is_array($body) || ($body['code'] ?? '') !== 'Ok') {
                return null;
            }

            return $body;
        } catch (Throwable $e) {
            log_message('warning', 'Transport AI: OSRM request failed: ' . $e->getMessage());

            return null;
        }
    }

    protected function osrmRouteLeg(float $fromLat, float $fromLng, float $toLat, float $toLng): ?array
    {
        $coords = $this->coordinateString([
            ['lat' => $fromLat, 'lng' => $fromLng],
            ['lat' => $toLat, 'lng' => $toLng],
        ]);
        $body = $this->osrmRequest($this->osrmBaseUrl() . "/route/v1/driving/{$coords}?overview=false");
        if ($body === null || empty($body['routes'][0])) {
            return null;
        }

        $route = $body['routes'][0];

        return [
            'distance_km'      => round(((float) $route['distance']) / 1000, 3),
            'duration_minutes' => round(((float) $route['duration']) / 60, 2),
        ];
    }

    /**
     * @param array<int, array{lat: float, lng: float}> $points
     */
    protected function osrmTable(array $points): ?array
    {
        $coords = $this->coordinateString($points);
        $body   = $this->osrmRequest($this->osrmBaseUrl() . "/table/v1/driving/{$coords}?annotations=distance,duration");
        if ($body === null || ! isset($body['distances'], $body['durations'])) {
            return null;
        }

        $n = count($points);
        $distances = [];
        $durations = [];
        for ($i = 0; $i < $n; $i++) {
            for ($j = 0; $j < $n; $j++) {
                $d = $body['distances'][$i][$j] ?? null;
                $t = $body['durations'][$i][$j] ?? null;
                if ($d === null || $t === null) {
                    // Unroutable pair (e.g. point snapped nowhere): use fallback estimate.
                    $leg = $this->haversineLeg($points[$i]['lat'], $points[$i]['lng'], $points[$j]['lat'], $points[$j]['lng']);
                    $distances[$i][$j] = $leg['distance_km'];
                    $durations[$i][$j] = $leg['duration_minutes'];

                    continue;
                }
                $distances[$i][$j] = round(((float) $d) / 1000, 3);
                $durations[$i][$j] = round(((float) $t) / 60, 2);
            }
        }

        return ['distances' => $distances, 'durations' => $durations];
    }

    /**
     * @param array<int, array{lat: float, lng: float}> $orderedPoints
     */
    protected function osrmRoute(array $orderedPoints): ?array
    {
        $coords = $this->coordinateString($orderedPoints);
        $body   = $this->osrmRequest($this->osrmBaseUrl() . "/route/v1/driving/{$coords}?overview=full&geometries=geojson&steps=false");
        if ($body === null || empty($body['routes'][0])) {
            return null;
        }

        $route = $body['routes'][0];
        $legs  = [];
        foreach ($route['legs'] ?? [] as $leg) {
            $legs[] = [
                'distance_km'      => round(((float) $leg['distance']) / 1000, 3),
                'duration_minutes' => round(((float) $leg['duration']) / 60, 2),
            ];
        }

        return [
            'geometry'         => $route['geometry']['coordinates'] ?? null,
            'legs'             => $legs,
            'distance_km'      => round(((float) $route['distance']) / 1000, 3),
            'duration_minutes' => round(((float) $route['duration']) / 60, 2),
        ];
    }

    // ------------------------------------------------------------------
    // Haversine fallback
    // ------------------------------------------------------------------

    public function haversineKm(float $fromLat, float $fromLng, float $toLat, float $toLng): float
    {
        $latFrom = deg2rad($fromLat);
        $latTo   = deg2rad($toLat);
        $dLat    = deg2rad($toLat - $fromLat);
        $dLng    = deg2rad($toLng - $fromLng);

        $a = sin($dLat / 2) ** 2 + cos($latFrom) * cos($latTo) * sin($dLng / 2) ** 2;

        return self::EARTH_RADIUS_KM * 2 * atan2(sqrt($a), sqrt(1 - $a));
    }

    protected function haversineLeg(float $fromLat, float $fromLng, float $toLat, float $toLng): array
    {
        $roadFactor = max(1.0, (float) ($this->settings['road_factor'] ?? 1.35));
        $speedKmh   = max(5.0, (float) ($this->settings['average_speed_kmh'] ?? 40.0));

        $distanceKm = $this->haversineKm($fromLat, $fromLng, $toLat, $toLng) * $roadFactor;

        return [
            'distance_km'      => round($distanceKm, 3),
            'duration_minutes' => round(($distanceKm / $speedKmh) * 60, 2),
        ];
    }
}
