<?php

declare(strict_types=1);

namespace CliqThemes\StoreLocator\Geocoding;

defined('ABSPATH') || exit;

use CliqThemes\StoreLocator\Models\Location;
use CliqThemes\StoreLocator\Support\SettingsService;

/**
 * High-level geocoding facade. Resolves the configured provider, caches lookups
 * in transients, and powers the bulk "fill missing coordinates" tool used by
 * import and the admin.
 */
final class GeocodingService
{
    private const CACHE_TTL = MONTH_IN_SECONDS;
    /** Short TTL for negative (miss) results so a bad address isn't re-hit every run. */
    private const MISS_TTL = 900; // 15 minutes
    private const CACHE_PREFIX = 'cliq_sl_geo_';

    private GeocoderInterface $geocoder;

    /** Timestamp (microtime) of the last live Nominatim request, for throttling. */
    private static ?float $lastNominatimCall = null;

    public function __construct(?GeocoderInterface $geocoder = null)
    {
        $this->geocoder = $geocoder ?? self::resolveFromSettings();
    }

    /**
     * Build the geocoder configured in settings.
     */
    public static function resolveFromSettings(): GeocoderInterface
    {
        $provider = (string) SettingsService::get('geocoder', 'nominatim');

        /**
         * Short-circuit geocoder selection with a custom GeocoderInterface.
         * Return a GeocoderInterface to use it; return null to fall through to the
         * built-in providers. Lets integrators register their own provider without
         * editing the match below.
         *
         * @param GeocoderInterface|null $geocoder
         * @param string                 $provider Configured provider slug.
         */
        $custom = apply_filters('cliq_sl_geocoder', null, $provider);
        if ($custom instanceof GeocoderInterface) {
            return $custom;
        }

        return match ($provider) {
            // Prefer the dedicated server key (IP-restricted, server-side only);
            // fall back to the browser Maps key only if no server key is set.
            'google' => new GoogleGeocoder(self::googleServerKey()),
            default => new NominatimGeocoder(),
        };
    }

    private static function googleServerKey(): string
    {
        $server = (string) SettingsService::get('google_server_api_key', '');

        return $server !== '' ? $server : (string) SettingsService::get('google_maps_api_key', '');
    }

    public function provider(): string
    {
        return $this->geocoder->id();
    }

    public function geocode(string $address): ?GeocodeResult
    {
        $address = trim($address);
        if ($address === '') {
            return null;
        }

        $key = self::CACHE_PREFIX . $this->geocoder->id() . '_' . md5(strtolower($address));

        return $this->cached($key, fn (): ?GeocodeResult => $this->geocoder->geocode($address));
    }

    public function reverse(float $latitude, float $longitude): ?GeocodeResult
    {
        $key = self::CACHE_PREFIX . 'r_' . $this->geocoder->id() . '_' . md5($latitude . ',' . $longitude);

        return $this->cached($key, fn (): ?GeocodeResult => $this->geocoder->reverse($latitude, $longitude));
    }

    /**
     * Serve a geocode/reverse lookup from the transient cache, throttling and
     * caching live calls. Both hits and misses are cached (misses for a short
     * TTL) so a repeated bad address or an offline provider isn't hammered.
     *
     * @param callable():?GeocodeResult $lookup
     */
    private function cached(string $key, callable $lookup): ?GeocodeResult
    {
        $cached = get_transient($key);
        if (is_array($cached)) {
            if (isset($cached['__miss'])) {
                return null;
            }

            return new GeocodeResult(
                (float) $cached['latitude'],
                (float) $cached['longitude'],
                (string) ($cached['formatted'] ?? ''),
                (array) ($cached['components'] ?? [])
            );
        }

        $this->throttle();
        $result = $lookup();

        if ($result instanceof GeocodeResult) {
            set_transient($key, $result->toArray(), self::CACHE_TTL);
        } else {
            set_transient($key, ['__miss' => 1], self::MISS_TTL);
        }

        return $result;
    }

    /**
     * Respect Nominatim's usage policy of at most one request per second by
     * spacing out consecutive live calls. No-op for other providers. The
     * interval is filterable (e.g. for a self-hosted Nominatim instance).
     */
    private function throttle(): void
    {
        if ($this->geocoder->id() !== 'nominatim') {
            return;
        }

        $minGap = (float) apply_filters('cliq_sl_nominatim_min_interval', 1.0);
        if (self::$lastNominatimCall !== null) {
            $elapsed = microtime(true) - self::$lastNominatimCall;
            if ($elapsed < $minGap) {
                usleep((int) (($minGap - $elapsed) * 1_000_000));
            }
        }

        self::$lastNominatimCall = microtime(true);
    }

    /**
     * Compose a single-line address from location-style parts.
     *
     * @param array<string, mixed> $parts
     */
    public static function composeAddress(array $parts): string
    {
        $segments = array_filter([
            $parts['address_line1'] ?? '',
            $parts['address_line2'] ?? '',
            $parts['city'] ?? '',
            $parts['state'] ?? '',
            $parts['postal_code'] ?? '',
            $parts['country'] ?? '',
        ], static fn ($value): bool => is_string($value) && trim($value) !== '');

        return implode(', ', $segments);
    }

    /**
     * Geocode locations that have no coordinates (lat/lng 0), manually
     * triggered from the admin "Fill missing coordinates" tool (no cron —
     * see Platform.php). Returns a summary of how many were
     * processed/updated/failed, plus how many still remain so the admin UI
     * can prompt "click again" for a backlog bigger than one batch.
     *
     * Never-attempted rows are always selected before previously-failed ones
     * (oldest failure first) — otherwise a handful of permanently-unresolvable
     * addresses (blank/garbage address, provider can't find it) would occupy
     * every batch forever and starve every other row behind them, since a
     * failed row is never removed from the "missing coordinates" set (it can
     * only leave by actually succeeding).
     *
     * @return array{processed:int, updated:int, failed:int, remaining:int}
     */
    public function fillMissingCoordinates(int $limit = 50): array
    {
        $summary = ['processed' => 0, 'updated' => 0, 'failed' => 0, 'remaining' => 0];

        $missing = static fn () => Location::query()->where('latitude', '=', 0)->where('longitude', '=', 0);

        $locations = $missing()->whereNull('geocode_failed_at')->orderBy('id', 'ASC')->limit($limit)->get();
        if (count($locations) < $limit) {
            $retryLimit = $limit - count($locations);
            $retries = $missing()->whereNotNull('geocode_failed_at')->orderBy('geocode_failed_at', 'ASC')->limit($retryLimit)->get();
            $locations = [...$locations, ...$retries];
        }

        foreach ($locations as $location) {
            $summary['processed']++;

            $address = self::composeAddress($location->toArray());
            if ($address === '') {
                $location->setAttribute('geocode_failed_at', current_time('mysql'));
                $location->save();
                $summary['failed']++;
                continue;
            }

            $result = $this->geocode($address);
            if (! $result instanceof GeocodeResult) {
                $location->setAttribute('geocode_failed_at', current_time('mysql'));
                $location->save();
                $summary['failed']++;
                continue;
            }

            $location->setAttribute('latitude', $result->latitude);
            $location->setAttribute('longitude', $result->longitude);
            $location->save();
            $summary['updated']++;
        }

        $summary['remaining'] = $missing()->count();

        return $summary;
    }
}
