<?php
declare(strict_types=1);

/**
 * YachtMemory - Anchorage geographic enrichment, Phase 2C.2b.5
 *
 * READ ONLY: this class never writes geocoding results to the database.
 *
 * Phase 2C.2b.5 adds an acquisition policy so background workers can distinguish
 * cheap cache work from deliberately rate-limited external evidence acquisition.
 * The decision rules themselves remain unchanged.
 *
 * Evidence acquisition is spatially clustered:
 * - one Overpass settlement request per geographic cluster
 * - one compact maritime request per geographic cluster
 * - successful responses are cached by spatial query, not anchorage_number
 * - candidate distances are calculated locally for every anchorage
 *
 * Decision rules are intentionally unchanged from 2C.2b.
 */
final class AnchorageGeocoder
{
    /**
     * Stable implementation identifier persisted with Stay Geography results.
     *
     * This version identifies the geographic evidence/decision semantics,
     * independently of Stay revisions or worker implementation versions.
     */
    public const VERSION = '2C.2b.5';

    private const USER_AGENT = 'YachtMemory/1.0 (https://yachtmemory.net)';
    private const NOMINATIM_LOOKUP_URL = 'https://nominatim.openstreetmap.org/lookup';
    private const OVERPASS_ENDPOINTS = [
        'https://overpass-api.de/api/interpreter',
        'https://overpass.kumi.systems/api/interpreter',
    ];
    private const CACHE_DIR = '/var/www/app.yachtmemory.net/storage/cache/geography';
    private const LEGACY_SETTLEMENT_CACHE_DIR = '/tmp/yachtmemory_overpass_cache';
    private const LEGACY_MARITIME_CACHE_DIR = '/tmp/yachtmemory_overpass_maritime_cache_v4';
    private const MAX_SETTLEMENT_CANDIDATES = 10;

    public const ACQUISITION_NETWORK_ALLOWED = 'network_allowed';
    public const ACQUISITION_CACHE_ONLY = 'cache_only';

    private array $settings;
    private string $acquisitionPolicy;
    /** @var callable|null */
    private $progressCallback;

    /**
     * Initializes the geocoder with runtime settings and an optional progress callback.
     */
    public function __construct(
        ?array $settings = null,
        ?callable $progressCallback = null,
        string $acquisitionPolicy = self::ACQUISITION_NETWORK_ALLOWED
    ) {
        if (!in_array(
            $acquisitionPolicy,
            [self::ACQUISITION_NETWORK_ALLOWED, self::ACQUISITION_CACHE_ONLY],
            true
        )) {
            throw new InvalidArgumentException('Unknown geocoder acquisition policy: ' . $acquisitionPolicy);
        }

        $this->settings = $settings ?? SystemSettingsService::getGeocodingConfig();
        $this->progressCallback = $progressCallback;
        $this->acquisitionPolicy = $acquisitionPolicy;
        $this->ensureCacheDirectory();
    }

    /**
     * Returns the effective runtime settings used by this geocoder instance.
     */
    public function getSettings(): array
    {
        return $this->settings;
    }

    /**
     * Analyze a complete batch. This is the preferred 2C.2b.1 entry point,
     * because acquisition is shared across nearby anchorages.
     */
    public function analyzeBatch(array $anchorages): array
    {
        if ($anchorages === []) {
            return [];
        }

        $clusters = $this->buildClusters($anchorages);
        $traces = [];

        $clusterCount = count($clusters);
        foreach ($clusters as $clusterIndex => $cluster) {
            $this->progress(sprintf(
                'Cluster %d/%d: %d anchorage(s)',
                $clusterIndex + 1,
                $clusterCount,
                count($cluster['anchorages'])
            ));
            $evidence = $this->collectClusterEvidence($cluster);

            foreach ($cluster['anchorages'] as $anchorage) {
                $trace = $this->createBaseTrace($anchorage, $cluster, $evidence);

                $lat = $this->nullableFloat($anchorage['lat'] ?? null);
                $lon = $this->nullableFloat($anchorage['lon'] ?? null);

                if ($lat === null || $lon === null) {
                    $trace['rules_evaluated'][] = $this->rule(
                        'valid_position',
                        false,
                        'Anchorage has no usable latitude/longitude.'
                    );
                    $trace['explanation'] = 'No geographic decision is possible without a valid position.';
                    $traces[] = $trace;
                    continue;
                }

                $trace['rules_evaluated'][] = $this->rule(
                    'valid_position',
                    true,
                    sprintf('Position %.7f, %.7f is available.', $lat, $lon)
                );

                if ($evidence['settlement']['status'] !== 'FAILED') {
                    $trace['settlement_candidates'] = $this->settlementCandidatesForAnchorage(
                        $lat,
                        $lon,
                        $evidence['settlement']['features']
                    );
                }

                if ($evidence['maritime']['status'] !== 'FAILED') {
                    $trace['maritime_candidates'] = $this->maritimeCandidatesForAnchorage(
                        $lat,
                        $lon,
                        $evidence['maritime']['features']
                    );
                }

                $trace = $this->decide($trace);
                $traces[] = $trace;
            }
        }

        usort(
            $traces,
            static fn(array $a, array $b): int =>
                ((int)$a['input']['anchorage_number']) <=> ((int)$b['input']['anchorage_number'])
        );

        return $traces;
    }

    /**
     * Analyzes a single anchorage by delegating to the batch pipeline.
     */
    public function analyze(array $anchorage): array
    {
        $traces = $this->analyzeBatch([$anchorage]);
        return $traces[0] ?? $this->createBaseTrace($anchorage, null, null);
    }

    /**
     * Creates the structured, UI-independent Decision Trace for one anchorage.
     */
    private function createBaseTrace(array $anchorage, ?array $cluster, ?array $evidence): array
    {
        $lat = $this->nullableFloat($anchorage['lat'] ?? null);
        $lon = $this->nullableFloat($anchorage['lon'] ?? null);

        $sourceStatus = [
            'settlement' => $evidence['settlement'] ?? [
                'status' => 'NOT_RUN',
                'source' => null,
                'error' => null,
            ],
            'maritime' => $evidence['maritime'] ?? [
                'status' => 'NOT_RUN',
                'source' => null,
                'error' => null,
            ],
        ];

        foreach ($sourceStatus as &$item) {
            unset($item['features']);
        }
        unset($item);

        $complete = $sourceStatus['settlement']['status'] !== 'FAILED'
            && $sourceStatus['maritime']['status'] !== 'FAILED';

        return [
            'trace_version' => 2,
            'mode' => 'dry-run',
            'input' => [
                'vessel_id' => (int)($anchorage['vessel_id'] ?? 0),
                'anchorage_number' => (int)($anchorage['anchorage_number'] ?? 0),
                'stored_name' => $this->nullableString($anchorage['anchorage_name'] ?? null),
                'stored_country' => $this->nullableString($anchorage['country'] ?? null),
                'stored_region' => $this->nullableString($anchorage['region'] ?? null),
                'stored_geocode_status' => isset($anchorage['geocode_status'])
                    ? (int)$anchorage['geocode_status']
                    : null,
                'start_time' => $this->nullableString($anchorage['start_time'] ?? null),
                'end_time' => $this->nullableString($anchorage['end_time'] ?? null),
                'lat' => $lat,
                'lon' => $lon,
            ],
            'cluster' => $cluster === null ? null : [
                'cluster_id' => $cluster['cluster_id'],
                'anchorage_count' => count($cluster['anchorages']),
                'min_lat' => $cluster['min_lat'],
                'max_lat' => $cluster['max_lat'],
                'min_lon' => $cluster['min_lon'],
                'max_lon' => $cluster['max_lon'],
            ],
            'settings' => $this->settings,
            'evidence_sources' => $sourceStatus,
            'decision_completeness' => $complete ? 'COMPLETE' : 'INCOMPLETE',
            'settlement_candidates' => [],
            'maritime_candidates' => [],
            'rules_evaluated' => [],
            'selected_name' => null,
            'selected_source' => null,
            'decision_rule' => 'no_decision',
            'confidence' => 'none',
            'explanation' => 'No decision was made.',
            'warnings' => [],
        ];
    }

    /**
     * Build connected geographic clusters. Two anchorages are connected when
     * they are at most the larger configured search radius apart.
     *
     * This preserves the earlier successful analysis principle: nearby stays
     * share one area acquisition instead of independently hitting Overpass.
     */
    private function buildClusters(array $anchorages): array
    {
        $valid = [];
        $invalid = [];

        foreach ($anchorages as $anchorage) {
            $lat = $this->nullableFloat($anchorage['lat'] ?? null);
            $lon = $this->nullableFloat($anchorage['lon'] ?? null);
            if ($lat === null || $lon === null) {
                $invalid[] = $anchorage;
            } else {
                $valid[] = $anchorage;
            }
        }

        $linkDistanceM = max(
            (int)$this->settings['settlement_search_radius_m'],
            (int)$this->settings['maritime_search_radius_m']
        );

        $visited = [];
        $clusters = [];
        $clusterNo = 0;

        foreach ($valid as $startIndex => $start) {
            if (isset($visited[$startIndex])) {
                continue;
            }

            $clusterNo++;
            $queue = [$startIndex];
            $visited[$startIndex] = true;
            $members = [];

            while ($queue !== []) {
                $index = array_shift($queue);
                $current = $valid[$index];
                $members[] = $current;

                for ($j = 0, $n = count($valid); $j < $n; $j++) {
                    if (isset($visited[$j])) {
                        continue;
                    }

                    $distance = $this->haversineMeters(
                        (float)$current['lat'],
                        (float)$current['lon'],
                        (float)$valid[$j]['lat'],
                        (float)$valid[$j]['lon']
                    );

                    if ($distance <= $linkDistanceM) {
                        $visited[$j] = true;
                        $queue[] = $j;
                    }
                }
            }

            $clusters[] = $this->finalizeCluster($clusterNo, $members);
        }

        foreach ($invalid as $anchorage) {
            $clusterNo++;
            $clusters[] = [
                'cluster_id' => $clusterNo,
                'anchorages' => [$anchorage],
                'min_lat' => null,
                'max_lat' => null,
                'min_lon' => null,
                'max_lon' => null,
            ];
        }

        return $clusters;
    }

    /**
     * Calculates the bounding coordinates and identifier of one completed cluster.
     */
    private function finalizeCluster(int $clusterId, array $members): array
    {
        $lats = array_map(static fn(array $a): float => (float)$a['lat'], $members);
        $lons = array_map(static fn(array $a): float => (float)$a['lon'], $members);

        return [
            'cluster_id' => $clusterId,
            'anchorages' => $members,
            'min_lat' => min($lats),
            'max_lat' => max($lats),
            'min_lon' => min($lons),
            'max_lon' => max($lons),
        ];
    }

    /**
     * Collects settlement and maritime evidence for one geographic cluster.
     */
    private function collectClusterEvidence(array $cluster): array
    {
        if ($cluster['min_lat'] === null) {
            return [
                'settlement' => [
                    'status' => 'FAILED',
                    'source' => null,
                    'error' => 'Cluster contains no valid position.',
                    'features' => [],
                ],
                'maritime' => [
                    'status' => 'FAILED',
                    'source' => null,
                    'error' => 'Cluster contains no valid position.',
                    'features' => [],
                ],
            ];
        }

        $settlement = $this->collectClusterSettlementEvidence($cluster);
        $maritime = $this->collectClusterMaritimeEvidence($cluster);

        return [
            'settlement' => $settlement,
            'maritime' => $maritime,
        ];
    }

    /**
     * Loads settlement evidence for a cluster using cache-first acquisition.
     */
    private function collectClusterSettlementEvidence(array $cluster): array
    {
        $radiusM = (int)$this->settings['settlement_search_radius_m'];
        $bbox = $this->paddedBoundingBox($cluster, $radiusM);
        $query = $this->buildSettlementBoxQuery($bbox);

        $this->progress('  Settlement: metadata cache -> proven legacy cache -> network');

        try {
            [$json, $source, $status] = $this->loadOverpassData(
                'settlement',
                $query,
                $bbox,
                self::LEGACY_SETTLEMENT_CACHE_DIR,
                true
            );
            $this->progress(sprintf('  Settlement: %s | %s', $status, $source));
            return [
                'status' => $status,
                'source' => $source,
                'error' => null,
                'features' => $this->extractSettlementFeatures($json),
            ];
        } catch (Throwable $e) {
            $this->progress('  Settlement: FAILED | ' . $e->getMessage());
            return [
                'status' => 'FAILED',
                'source' => null,
                'error' => $e->getMessage(),
                'features' => [],
            ];
        }
    }

    /**
     * Loads maritime evidence for a cluster using cache-first acquisition.
     */
    private function collectClusterMaritimeEvidence(array $cluster): array
    {
        $radiusM = (int)$this->settings['maritime_search_radius_m'];
        $bbox = $this->paddedBoundingBox($cluster, $radiusM);
        $query = $this->buildMaritimeBoxQuery($bbox);

        $this->progress('  Maritime: metadata cache -> proven legacy cache -> network');

        try {
            [$json, $source, $status] = $this->loadOverpassData(
                'maritime',
                $query,
                $bbox,
                self::LEGACY_MARITIME_CACHE_DIR,
                false
            );
            $this->progress(sprintf('  Maritime: %s | %s', $status, $source));
            return [
                'status' => $status,
                'source' => $source,
                'error' => null,
                'features' => $this->extractMaritimeFeatures($json),
            ];
        } catch (Throwable $e) {
            $this->progress('  Maritime: FAILED | ' . $e->getMessage());
            return [
                'status' => 'FAILED',
                'source' => null,
                'error' => $e->getMessage(),
                'features' => [],
            ];
        }
    }

    /**
     * Pad cluster bounds so every anchorage has at least radiusM coverage.
     * Longitude padding is latitude-adjusted.
     */
    private function paddedBoundingBox(array $cluster, int $radiusM): array
    {
        $centerLat = ((float)$cluster['min_lat'] + (float)$cluster['max_lat']) / 2.0;
        $latPad = $radiusM / 111320.0;
        $cos = max(0.2, cos(deg2rad($centerLat)));
        $lonPad = $radiusM / (111320.0 * $cos);

        return [
            'south' => max(-90.0, (float)$cluster['min_lat'] - $latPad),
            'west' => max(-180.0, (float)$cluster['min_lon'] - $lonPad),
            'north' => min(90.0, (float)$cluster['max_lat'] + $latPad),
            'east' => min(180.0, (float)$cluster['max_lon'] + $lonPad),
        ];
    }

    /**
     * Builds the Overpass query for named city, town, village and hamlet nodes.
     */
    private function buildSettlementBoxQuery(array $bbox): string
    {
        $timeout = max(1, min(300, (int)$this->settings['overpass_timeout_s']));
        $box = $this->formatBox($bbox);

        return <<<OVERPASS
[out:json][timeout:{$timeout}];
node["place"~"^(city|town|village|hamlet)$"]["name"]({$box});
out body;
OVERPASS;
    }

    /**
     * Builds the compact Overpass query for relevant named maritime objects.
     */
    private function buildMaritimeBoxQuery(array $bbox): string
    {
        $timeout = max(1, min(300, (int)$this->settings['overpass_timeout_s']));
        $box = $this->formatBox($bbox);

        return <<<OVERPASS
[out:json][timeout:{$timeout}];
(
  nwr["leisure"="marina"]({$box});
  nwr["harbour"]({$box});
  nwr["seamark:type"="harbour"]({$box});
  nwr["seamark:type"="anchorage"]({$box});
  nwr["seamark:harbour:category"]({$box});
  nwr["natural"="bay"]({$box});
);
out center tags;
OVERPASS;
    }

    /**
     * Formats a bbox in the south,west,north,east syntax expected by Overpass.
     */
    private function formatBox(array $bbox): string
    {
        return implode(',', [
            number_format((float)$bbox['south'], 7, '.', ''),
            number_format((float)$bbox['west'], 7, '.', ''),
            number_format((float)$bbox['north'], 7, '.', ''),
            number_format((float)$bbox['east'], 7, '.', ''),
        ]);
    }

    /**
     * Normalizes settlement objects from a raw Overpass response.
     */
    private function extractSettlementFeatures(array $json): array
    {
        $features = [];

        foreach ($json['elements'] as $element) {
            if (
                !is_array($element)
                || ($element['type'] ?? '') !== 'node'
                || !isset($element['id'], $element['lat'], $element['lon'])
                || !is_numeric($element['lat'])
                || !is_numeric($element['lon'])
            ) {
                continue;
            }

            $tags = is_array($element['tags'] ?? null) ? $element['tags'] : [];
            $name = $this->extractName($tags);
            $placeType = trim((string)($tags['place'] ?? ''));

            if (
                $name === null
                || !in_array($placeType, ['city', 'town', 'village', 'hamlet'], true)
            ) {
                continue;
            }

            $osmRef = 'N' . (int)$element['id'];
            $features[$osmRef] = [
                'name' => $name,
                'place_type' => $placeType,
                'lat' => (float)$element['lat'],
                'lon' => (float)$element['lon'],
                'osm_ref' => $osmRef,
                'osm_type' => 'node',
                'osm_id' => (int)$element['id'],
                'population' => $this->nullableString($tags['population'] ?? null),
                'importance' => null,
                'wikidata' => $this->nullableString($tags['wikidata'] ?? null),
                'wikipedia' => $this->nullableString($tags['wikipedia'] ?? null),
                'display_name' => null,
            ];
        }

        return array_values($features);
    }

    /**
     * Normalizes relevant maritime objects from a raw Overpass response.
     */
    private function extractMaritimeFeatures(array $json): array
    {
        $features = [];

        foreach ($json['elements'] as $element) {
            if (!is_array($element)) {
                continue;
            }

            $position = $this->extractElementPosition($element);
            if ($position === null) {
                continue;
            }

            $tags = is_array($element['tags'] ?? null) ? $element['tags'] : [];
            $name = $this->extractName($tags);
            $feature = $this->classifyMaritimeFeature($tags);

            if ($name === null || $feature === 'OTHER') {
                continue;
            }

            $osmType = (string)($element['type'] ?? '');
            $osmId = (string)($element['id'] ?? '');
            if ($osmType === '' || $osmId === '') {
                continue;
            }

            $key = $osmType . ':' . $osmId;
            $features[$key] = [
                'name' => $name,
                'feature' => $feature,
                'lat' => $position[0],
                'lon' => $position[1],
                'osm_type' => $osmType,
                'osm_id' => $osmId,
                'osm_ref' => strtoupper(substr($osmType, 0, 1)) . $osmId,
                'tags' => $this->extractRelevantMaritimeTags($tags),
            ];
        }

        return array_values($features);
    }

    /**
     * Ranks settlement candidates by local distance and enriches them with Nominatim data.
     */
    private function settlementCandidatesForAnchorage(
        float $lat,
        float $lon,
        array $features
    ): array {
        $radiusM = (int)$this->settings['settlement_search_radius_m'];
        $candidates = [];

        foreach ($features as $feature) {
            $distanceM = $this->haversineMeters(
                $lat,
                $lon,
                (float)$feature['lat'],
                (float)$feature['lon']
            );

            if ($distanceM > $radiusM) {
                continue;
            }

            $candidate = $feature;
            unset($candidate['lat'], $candidate['lon']);
            $candidate['distance_m'] = (int)round($distanceM);
            $candidate['rank'] = null;
            $candidates[] = $candidate;
        }

        usort(
            $candidates,
            static fn(array $a, array $b): int => $a['distance_m'] <=> $b['distance_m']
        );
        $candidates = array_slice($candidates, 0, self::MAX_SETTLEMENT_CANDIDATES);

        if ($candidates !== []) {
            try {
                $lookup = $this->lookupNominatim(array_column($candidates, 'osm_ref'));
                foreach ($candidates as &$candidate) {
                    $nom = $lookup[$candidate['osm_ref']] ?? null;
                    if (!is_array($nom)) {
                        continue;
                    }

                    $extra = is_array($nom['extratags'] ?? null) ? $nom['extratags'] : [];
                    if (isset($nom['importance']) && is_numeric($nom['importance'])) {
                        $candidate['importance'] = (float)$nom['importance'];
                    }
                    $candidate['population'] = $this->firstNonEmpty([
                        $extra['population'] ?? null,
                        $candidate['population'],
                    ]);
                    $candidate['wikidata'] = $this->firstNonEmpty([
                        $extra['wikidata'] ?? null,
                        $candidate['wikidata'],
                    ]);
                    $candidate['wikipedia'] = $this->firstNonEmpty([
                        $extra['wikipedia'] ?? null,
                        $candidate['wikipedia'],
                    ]);
                    $candidate['display_name'] = $this->nullableString($nom['display_name'] ?? null);
                }
                unset($candidate);
            } catch (Throwable) {
                // Nominatim enrichment is supplemental. OSM settlement evidence
                // remains usable; missing population/importance stays explicit.
            }
        }

        foreach ($candidates as $index => &$candidate) {
            $candidate['rank'] = $index + 1;
            $candidate['population'] = $this->normalizePopulation($candidate['population']);
        }
        unset($candidate);

        return $candidates;
    }

    /**
     * Ranks maritime candidates by distance from the anchorage position.
     */
    private function maritimeCandidatesForAnchorage(
        float $lat,
        float $lon,
        array $features
    ): array {
        $radiusM = (int)$this->settings['maritime_search_radius_m'];
        $candidates = [];

        foreach ($features as $feature) {
            $distanceM = $this->haversineMeters(
                $lat,
                $lon,
                (float)$feature['lat'],
                (float)$feature['lon']
            );

            if ($distanceM > $radiusM) {
                continue;
            }

            $candidate = $feature;
            unset($candidate['lat'], $candidate['lon']);
            $candidate['distance_m'] = (int)round($distanceM);
            $candidate['distance_kind'] = 'center';
            $candidate['rank'] = null;
            $candidates[] = $candidate;
        }

        usort(
            $candidates,
            static fn(array $a, array $b): int => $a['distance_m'] <=> $b['distance_m']
        );

        foreach ($candidates as $index => &$candidate) {
            $candidate['rank'] = $index + 1;
        }
        unset($candidate);

        return $candidates;
    }

    /**
     * Decision logic intentionally kept equivalent to 2C.2b.
     */
    private function decide(array $trace): array
    {
        $settlements = $trace['settlement_candidates'];
        $maritime = $trace['maritime_candidates'];

        if ($trace['decision_completeness'] === 'INCOMPLETE') {
            $failed = [];
            foreach ($trace['evidence_sources'] as $name => $source) {
                if (($source['status'] ?? '') === 'FAILED') {
                    $failed[] = $name . ': ' . ($source['error'] ?? 'unknown error');
                }
            }
            $trace['warnings'][] = 'Evidence acquisition incomplete: ' . implode(' | ', $failed);
        }

        if ($settlements === [] && $maritime === []) {
            $trace['rules_evaluated'][] = $this->rule(
                'evidence_available',
                false,
                $trace['decision_completeness'] === 'COMPLETE'
                    ? 'Both evidence sources completed, but no candidates were found.'
                    : 'No candidates are available because evidence acquisition is incomplete.'
            );
            $trace['explanation'] = $trace['decision_completeness'] === 'COMPLETE'
                ? 'No usable geographic evidence was found.'
                : 'No decision: geographic evidence acquisition is incomplete.';
            return $trace;
        }

        $trace['rules_evaluated'][] = $this->rule(
            'evidence_available',
            true,
            sprintf(
                '%d settlement and %d maritime candidates are available; evidence is %s.',
                count($settlements),
                count($maritime),
                $trace['decision_completeness']
            )
        );

        /*
         * Do not finalize a name from a partial evidence set. We still expose
         * candidates and rule diagnostics, but an outage must not silently
         * change the decision.
         */
        if ($trace['decision_completeness'] === 'INCOMPLETE') {
            $trace['rules_evaluated'][] = $this->rule(
                'complete_evidence_required',
                false,
                'Final name selection is suppressed because at least one evidence source failed.'
            );
            $trace['explanation'] = 'Evidence is visible, but no name is selected from an incomplete evidence set.';
            return $trace;
        }

        $trace['rules_evaluated'][] = $this->rule(
            'complete_evidence_required',
            true,
            'Settlement and maritime evidence acquisition both completed.'
        );

        $bayThreshold = $this->settings['near_bay_threshold_m'];
        $nearestBay = $this->nearestMaritimeOfType($maritime, 'BAY');

        if ($bayThreshold === null) {
            $trace['rules_evaluated'][] = $this->rule(
                'near_named_bay',
                false,
                'Rule disabled because near_bay_threshold_m is not configured.',
                ['candidate' => $nearestBay]
            );
        } elseif ($nearestBay === null) {
            $trace['rules_evaluated'][] = $this->rule(
                'near_named_bay',
                false,
                'No named BAY candidate is available.'
            );
        } elseif ($nearestBay['distance_m'] <= (float)$bayThreshold) {
            $trace['rules_evaluated'][] = $this->rule(
                'near_named_bay',
                true,
                sprintf(
                    '%s is a named BAY at %d m, within the configured %d m threshold.',
                    $nearestBay['name'],
                    $nearestBay['distance_m'],
                    (int)$bayThreshold
                ),
                ['candidate' => $nearestBay]
            );

            return $this->select(
                $trace,
                $nearestBay['name'],
                'maritime:BAY',
                'near_named_bay',
                'strong',
                sprintf(
                    'Selected named bay "%s" because it lies within the explicitly configured near-bay threshold.',
                    $nearestBay['name']
                )
            );
        } else {
            $trace['rules_evaluated'][] = $this->rule(
                'near_named_bay',
                false,
                sprintf(
                    '%s is %d m away and outside the configured %d m threshold.',
                    $nearestBay['name'],
                    $nearestBay['distance_m'],
                    (int)$bayThreshold
                ),
                ['candidate' => $nearestBay]
            );
        }

        $correlationThreshold = $this->settings['maritime_correlation_distance_m'];
        $correlation = $this->findNameCorrelation(
            $settlements,
            $maritime,
            $correlationThreshold
        );

        if ($correlationThreshold === null) {
            $trace['rules_evaluated'][] = $this->rule(
                'settlement_maritime_name_correlation',
                false,
                'Rule disabled because maritime_correlation_distance_m is not configured.'
            );
        } elseif ($correlation !== null) {
            $trace['rules_evaluated'][] = $this->rule(
                'settlement_maritime_name_correlation',
                true,
                sprintf(
                    'Settlement "%s" is independently reinforced by nearby maritime feature "%s".',
                    $correlation['settlement']['name'],
                    $correlation['maritime']['name']
                ),
                $correlation
            );

            return $this->select(
                $trace,
                $correlation['settlement']['name'],
                'correlated:settlement+maritime',
                'settlement_maritime_name_correlation',
                'strong',
                sprintf(
                    'Selected "%s" because settlement and maritime evidence independently support the same geographic name.',
                    $correlation['settlement']['name']
                )
            );
        } else {
            $trace['rules_evaluated'][] = $this->rule(
                'settlement_maritime_name_correlation',
                false,
                'No settlement/maritime name correlation satisfies the configured distance.'
            );
        }

        if ($settlements !== []) {
            $nearest = $settlements[0];
            $window = (int)$this->settings['settlement_competition_window_m'];
            $maxDistance = $nearest['distance_m'] + $window;
            $eligible = array_values(array_filter(
                $settlements,
                static fn(array $c): bool => $c['distance_m'] <= $maxDistance
            ));

            $selected = $nearest;
            $reason = sprintf(
                'Nearest settlement "%s" at %d m is the starting candidate.',
                $nearest['name'],
                $nearest['distance_m']
            );

            foreach ($eligible as $candidate) {
                if ($candidate['osm_ref'] === $nearest['osm_ref']) {
                    continue;
                }

                if ($this->isClearlyMoreSignificant($candidate, $selected)) {
                    $selected = $candidate;
                    $reason = sprintf(
                        '"%s" at %d m displaces the nearer settlement because it is clearly more significant within the %d m competition window.',
                        $candidate['name'],
                        $candidate['distance_m'],
                        $window
                    );
                }
            }

            $trace['rules_evaluated'][] = $this->rule(
                'settlement_competition',
                true,
                $reason,
                [
                    'nearest' => $nearest,
                    'eligible_count' => count($eligible),
                    'competition_window_m' => $window,
                    'selected' => $selected,
                ]
            );

            return $this->select(
                $trace,
                $selected['name'],
                'settlement',
                'settlement_competition',
                $selected['osm_ref'] === $nearest['osm_ref'] ? 'moderate' : 'strong',
                $reason
            );
        }

        $trace['rules_evaluated'][] = $this->rule(
            'maritime_only_fallback',
            false,
            'Maritime evidence exists, but no enabled rule authorizes it as the primary geographic name.'
        );
        $trace['explanation'] = 'Evidence was found, but the current explicit rules do not justify a name.';
        return $trace;
    }

    /**
     * Tests whether one settlement is clearly more significant without using an opaque score.
     */
    private function isClearlyMoreSignificant(array $candidate, array $current): bool
    {
        $candidatePopulation = $candidate['population'];
        $currentPopulation = $current['population'];
        $significant = (int)$this->settings['population_significant'];
        $local = (int)$this->settings['population_local'];

        if (
            $candidatePopulation !== null
            && $candidatePopulation >= $significant
            && ($currentPopulation === null || $currentPopulation < $local)
        ) {
            return true;
        }

        $candidateClass = $this->placeClassRank($candidate['place_type']);
        $currentClass = $this->placeClassRank($current['place_type']);

        return $candidateClass >= 3
            && $currentClass <= 1
            && $candidatePopulation !== null
            && $candidatePopulation >= $local;
    }

    /**
     * Maps OSM place classes to a simple semantic rank used by explicit comparison rules.
     */
    private function placeClassRank(string $type): int
    {
        return match ($type) {
            'city' => 4,
            'town' => 3,
            'village' => 2,
            'hamlet' => 1,
            default => 0,
        };
    }

    /**
     * Finds a nearby maritime object whose normalized name correlates with a settlement name.
     */
    private function findNameCorrelation(
        array $settlements,
        array $maritime,
        mixed $threshold
    ): ?array {
        if ($threshold === null) {
            return null;
        }

        $threshold = (float)$threshold;

        foreach ($settlements as $settlement) {
            foreach ($maritime as $feature) {
                if ($feature['distance_m'] > $threshold) {
                    continue;
                }
                if ($this->namesCorrelate($settlement['name'], $feature['name'])) {
                    return [
                        'settlement' => $settlement,
                        'maritime' => $feature,
                        'threshold_m' => $threshold,
                    ];
                }
            }
        }

        return null;
    }

    /**
     * Checks whether two normalized geographic names materially refer to the same wording.
     */
    private function namesCorrelate(string $a, string $b): bool
    {
        $a = $this->normalizeName($a);
        $b = $this->normalizeName($b);

        if ($a === '' || $b === '') {
            return false;
        }
        if ($a === $b) {
            return true;
        }

        return str_contains(' ' . $a . ' ', ' ' . $b . ' ')
            || str_contains(' ' . $b . ' ', ' ' . $a . ' ');
    }

    /**
     * Normalizes a place name for deterministic comparison.
     */
    private function normalizeName(string $name): string
    {
        $name = trim($name);
        $name = function_exists('mb_strtolower')
            ? mb_strtolower($name, 'UTF-8')
            : strtolower($name);

        $name = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $name) ?? $name;
        return trim(preg_replace('/\s+/u', ' ', $name) ?? $name);
    }

    /**
     * Returns the nearest maritime candidate of a requested feature type.
     */
    private function nearestMaritimeOfType(array $features, string $type): ?array
    {
        foreach ($features as $feature) {
            if ($feature['feature'] === $type) {
                return $feature;
            }
        }
        return null;
    }

    /**
     * Writes the selected name, source, rule and confidence into a Decision Trace.
     */
    private function select(
        array $trace,
        string $name,
        string $source,
        string $rule,
        string $confidence,
        string $explanation
    ): array {
        $trace['selected_name'] = $name;
        $trace['selected_source'] = $source;
        $trace['decision_rule'] = $rule;
        $trace['confidence'] = $confidence;
        $trace['explanation'] = $explanation;
        return $trace;
    }

    /**
     * Builds one deterministic rule-evaluation record for the Decision Trace.
     */
    private function rule(
        string $ruleId,
        bool $matched,
        string $reason,
        array $evidence = []
    ): array {
        return [
            'rule_id' => $ruleId,
            'matched' => $matched,
            'reason' => $reason,
            'evidence' => $evidence,
        ];
    }

    /**
     * Loads one Overpass evidence set using explicit cache provenance.
     *
     * Acquisition order:
     * 1. exact current cache whose sidecar metadata proves the requested bbox,
     * 2. legacy cache only when a compatible metadata sidecar exists,
     * 3. live Overpass request.
     *
     * A raw legacy JSON file without provenance metadata is deliberately not
     * treated as complete evidence. Feature coordinates cannot prove the
     * bounding box that was used for the original Overpass request.
     *
     * @return array{0: array, 1: string, 2: string}
     */
    private function loadOverpassData(
        string $kind,
        string $query,
        array $requiredBbox,
        string $legacyDir,
        bool $settlementOnly
    ): array {
        $queryHash = sha1($query);
        $cacheFile = self::CACHE_DIR . '/overpass_' . $kind . '_' . $queryHash . '.json';

        // Current cache entries are accepted only together with matching
        // provenance metadata. This prevents an old raw JSON response from
        // accidentally being interpreted as spatially complete.
        $cached = $this->loadCacheWithMetadata(
            $cacheFile,
            $kind,
            $requiredBbox,
            $queryHash
        );
        if ($cached !== null) {
            if ($kind === 'settlement') {
                try {
                    $this->assertUsableSettlementResponse($cached['json']);
                    return [$cached['json'], $cacheFile, 'CACHE'];
                } catch (Throwable $e) {
                    $this->progress('    Current settlement cache rejected: ' . $e->getMessage());
                }
            } else {
                return [$cached['json'], $cacheFile, 'CACHE'];
            }
        }

        // Legacy caches may be reused, but only if a sidecar explicitly states
        // the original query coverage. We no longer infer coverage from the
        // locations of returned OSM objects.
        $legacy = $this->findCoveringLegacyCache(
            $legacyDir,
            $kind,
            $requiredBbox
        );
        if ($legacy !== null && $kind === 'settlement') {
            try {
                $this->assertUsableSettlementResponse($legacy['json']);
            } catch (Throwable $e) {
                $this->progress('    Legacy settlement cache rejected: ' . $e->getMessage());
                $legacy = null;
            }
        }

        if ($legacy !== null) {
            $this->writeOverpassCache(
                $cacheFile,
                $legacy['json'],
                $kind,
                $requiredBbox,
                $queryHash,
                'legacy_import',
                $legacy['file']
            );
            return [$legacy['json'], $legacy['file'], 'LEGACY_CACHE'];
        }

        if ($this->acquisitionPolicy === self::ACQUISITION_CACHE_ONLY) {
            $this->progress('    No cache with proven spatial coverage found; network disabled by acquisition policy.');
            throw new RuntimeException('NETWORK_REQUIRED: ' . $kind . ' evidence is not available from proven cache.');
        }

        $this->progress('    No cache with proven spatial coverage found; network fallback required.');

        $errors = [];
        foreach (self::OVERPASS_ENDPOINTS as $endpoint) {
            $this->progress('    Overpass: ' . $endpoint . ' ...');

            try {
                $decoded = $this->requestOverpass($endpoint, $query);

                // HTTP success alone is not enough for settlement evidence:
                // distance calculation requires coordinates on the place nodes.
                if ($kind === 'settlement') {
                    $this->assertUsableSettlementResponse($decoded);
                }

                // Every newly acquired response is stored together with the
                // exact bbox and query hash that produced it.
                $this->writeOverpassCache(
                    $cacheFile,
                    $decoded,
                    $kind,
                    $requiredBbox,
                    $queryHash,
                    'network',
                    $endpoint
                );

                return [$decoded, $endpoint, 'NETWORK'];
            } catch (Throwable $e) {
                $errors[] = $endpoint . ': ' . $e->getMessage();
                $this->progress('    Overpass failed: ' . $e->getMessage());
            }
        }

        throw new RuntimeException(
            'All Overpass endpoints failed: ' . implode(' | ', $errors)
        );
    }

    /**
     * Searches legacy cache files for an entry with explicit coverage metadata.
     *
     * The expected sidecar filename is "<cache-file>.meta.json". Raw legacy
     * responses remain usable as historical data, but they are not sufficient
     * to establish COMPLETE evidence without such provenance.
     */
    private function findCoveringLegacyCache(
        string $dir,
        string $kind,
        array $requiredBbox
    ): ?array {
        if (!is_dir($dir)) {
            return null;
        }

        foreach (glob(rtrim($dir, '/') . '/*.json') ?: [] as $file) {
            // Metadata files themselves are not evidence payloads.
            if (str_ends_with($file, '.meta.json')) {
                continue;
            }

            $json = $this->loadValidJsonCache($file, true);
            $meta = $this->loadCacheMetadata($file . '.meta.json');

            if ($json === null || $meta === null) {
                continue;
            }

            if (($meta['evidence_type'] ?? null) !== $kind) {
                continue;
            }

            $bbox = is_array($meta['bbox'] ?? null) ? $meta['bbox'] : null;
            if ($bbox === null || !$this->bboxContains($bbox, $requiredBbox)) {
                continue;
            }

            if (($meta['complete'] ?? false) !== true) {
                continue;
            }

            return ['file' => $file, 'json' => $json, 'meta' => $meta];
        }

        return null;
    }

    /**
     * Loads a current cache entry and verifies its provenance sidecar.
     *
     * Exact query hashes are checked in addition to bbox coverage. This makes
     * the cache safe against later changes to the Overpass query semantics.
     */
    private function loadCacheWithMetadata(
        string $cacheFile,
        string $kind,
        array $requiredBbox,
        string $queryHash
    ): ?array {
        $json = $this->loadValidJsonCache($cacheFile, true);
        $meta = $this->loadCacheMetadata($cacheFile . '.meta.json');

        if ($json === null || $meta === null) {
            return null;
        }

        if (
            ($meta['schema_version'] ?? null) !== 1
            || ($meta['evidence_type'] ?? null) !== $kind
            || ($meta['query_hash'] ?? null) !== $queryHash
            || ($meta['complete'] ?? false) !== true
        ) {
            return null;
        }

        $bbox = is_array($meta['bbox'] ?? null) ? $meta['bbox'] : null;
        if ($bbox === null || !$this->bboxContains($bbox, $requiredBbox)) {
            return null;
        }

        return ['json' => $json, 'meta' => $meta];
    }

    /**
     * Reads and validates a cache metadata sidecar.
     */
    private function loadCacheMetadata(string $file): ?array
    {
        $meta = $this->loadValidJsonCache($file, false);

        if (
            $meta === null
            || !isset($meta['schema_version'], $meta['evidence_type'], $meta['bbox'])
            || !is_array($meta['bbox'])
        ) {
            return null;
        }

        foreach (['south', 'west', 'north', 'east'] as $key) {
            if (!isset($meta['bbox'][$key]) || !is_numeric($meta['bbox'][$key])) {
                return null;
            }
        }

        return $meta;
    }

    /**
     * Stores an Overpass response and a separate provenance sidecar.
     *
     * The payload remains plain Overpass JSON. Metadata is intentionally kept
     * outside it so diagnostic tools can continue to consume the raw response.
     */
    private function writeOverpassCache(
        string $cacheFile,
        array $json,
        string $kind,
        array $bbox,
        string $queryHash,
        string $sourceType,
        string $source
    ): void {
        $this->writeJsonCache($cacheFile, $json);

        $meta = [
            'schema_version' => 1,
            'evidence_type' => $kind,
            'bbox' => [
                'south' => (float)$bbox['south'],
                'west' => (float)$bbox['west'],
                'north' => (float)$bbox['north'],
                'east' => (float)$bbox['east'],
            ],
            'query_hash' => $queryHash,
            'source_type' => $sourceType,
            'source' => $source,
            'complete' => true,
            'created_at' => gmdate('c'),
        ];

        $this->writeJsonCache($cacheFile . '.meta.json', $meta);
    }

    /**
     * Returns true when the outer bbox completely contains the inner bbox.
     */
    private function bboxContains(array $outer, array $inner): bool
    {
        return (float)$outer['south'] <= (float)$inner['south']
            && (float)$outer['west'] <= (float)$inner['west']
            && (float)$outer['north'] >= (float)$inner['north']
            && (float)$outer['east'] >= (float)$inner['east'];
    }

    /**
     * Verifies that settlement evidence contains positions usable for distance calculation.
     *
     * A successful Overpass HTTP response can still be semantically unusable.
     * At least one returned named place node must therefore contain numeric
     * latitude and longitude before the response is accepted as evidence.
     *
     * @throws RuntimeException if settlement nodes exist but none are positioned.
     */
    private function assertUsableSettlementResponse(array $json): void
    {
        $settlementCount = 0;
        $positionedCount = 0;

        foreach ($json['elements'] ?? [] as $element) {
            if (!is_array($element) || ($element['type'] ?? '') !== 'node') {
                continue;
            }

            $tags = is_array($element['tags'] ?? null) ? $element['tags'] : [];
            $place = trim((string)($tags['place'] ?? ''));
            $name = trim((string)($tags['name'] ?? ''));

            if ($name === '' || !in_array($place, ['city', 'town', 'village', 'hamlet'], true)) {
                continue;
            }

            $settlementCount++;

            if (
                isset($element['lat'], $element['lon'])
                && is_numeric($element['lat'])
                && is_numeric($element['lon'])
            ) {
                $positionedCount++;
            }
        }

        if ($settlementCount > 0 && $positionedCount === 0) {
            throw new RuntimeException(
                'Overpass returned settlement nodes, but none contain usable coordinates.'
            );
        }
    }

    /**
     * Emits a progress message when the caller supplied a callback.
     */
    private function progress(string $message): void
    {
        if ($this->progressCallback !== null) {
            ($this->progressCallback)($message);
        }
    }

    /**
     * Executes one Overpass POST request and validates the returned JSON payload.
     */
    private function requestOverpass(string $endpoint, string $query): array
    {
        $postData = http_build_query(['data' => $query], '', '&', PHP_QUERY_RFC3986);

        $context = stream_context_create([
            'http' => [
                'method' => 'POST',
                'header' =>
                    "Content-Type: application/x-www-form-urlencoded\r\n" .
                    'User-Agent: ' . self::USER_AGENT . "\r\n" .
                    "Accept: application/json\r\n",
                'content' => $postData,
                'timeout' => (int)$this->settings['overpass_timeout_s'],
                'ignore_errors' => true,
            ],
        ]);

        $response = @file_get_contents($endpoint, false, $context);
        $status = $this->extractHttpStatusCode($http_response_header ?? []);

        if ($response === false) {
            throw new RuntimeException('HTTP request failed.');
        }
        if ($status !== null && ($status < 200 || $status >= 300)) {
            throw new RuntimeException('HTTP ' . $status);
        }

        $decoded = json_decode($response, true);
        if (
            !is_array($decoded)
            || !isset($decoded['elements'])
            || !is_array($decoded['elements'])
        ) {
            throw new RuntimeException('Invalid Overpass JSON response.');
        }

        return $decoded;
    }

    /**
     * Enriches unique OSM settlement references through batched Nominatim lookup with caching.
     */
    private function lookupNominatim(array $refs): array
    {
        $refs = array_values(array_unique(array_filter($refs)));
        if ($refs === []) {
            return [];
        }

        $results = [];

        foreach (array_chunk($refs, 50) as $chunk) {
            $params = [
                'osm_ids' => implode(',', $chunk),
                'format' => 'jsonv2',
                'addressdetails' => 1,
                'extratags' => 1,
                'namedetails' => 1,
                'accept-language' => 'en',
            ];

            $url = self::NOMINATIM_LOOKUP_URL . '?'
                . http_build_query($params, '', '&', PHP_QUERY_RFC3986);
            $cacheFile = self::CACHE_DIR . '/nominatim_' . sha1($url) . '.json';
            $data = $this->loadValidJsonCache($cacheFile, false);

            if ($data === null) {
                if ($this->acquisitionPolicy === self::ACQUISITION_CACHE_ONLY) {
                    // Nominatim is supplemental enrichment. In cache-only mode a
                    // missing lookup cache must never trigger hidden network I/O.
                    continue;
                }

                $data = $this->httpGetJson($url);
                $this->writeJsonCache($cacheFile, $data);
                sleep(1);
            }

            foreach ($data as $item) {
                if (
                    !is_array($item)
                    || ($item['osm_type'] ?? '') !== 'node'
                    || !isset($item['osm_id'])
                ) {
                    continue;
                }
                $results['N' . (int)$item['osm_id']] = $item;
            }
        }

        return $results;
    }

    /**
     * Executes a JSON GET request with YachtMemory identification and timeout handling.
     */
    private function httpGetJson(string $url): array
    {
        $context = stream_context_create([
            'http' => [
                'method' => 'GET',
                'header' =>
                    'User-Agent: ' . self::USER_AGENT . "\r\n" .
                    "Accept: application/json\r\n",
                'timeout' => (int)$this->settings['nominatim_timeout_s'],
                'ignore_errors' => true,
            ],
        ]);

        $raw = @file_get_contents($url, false, $context);
        $status = $this->extractHttpStatusCode($http_response_header ?? []);

        if ($raw === false) {
            throw new RuntimeException('Nominatim request failed.');
        }
        if ($status !== null && ($status < 200 || $status >= 300)) {
            throw new RuntimeException('Nominatim HTTP ' . $status);
        }

        $decoded = json_decode($raw, true);
        if (!is_array($decoded)) {
            throw new RuntimeException('Invalid Nominatim JSON response.');
        }

        return $decoded;
    }

    /**
     * Loads a JSON cache file and optionally requires an Overpass elements array.
     */
    private function loadValidJsonCache(string $file, bool $requireElements): ?array
    {
        if (!is_file($file)) {
            return null;
        }

        $raw = file_get_contents($file);
        if ($raw === false) {
            return null;
        }

        $decoded = json_decode($raw, true);
        if (!is_array($decoded)) {
            return null;
        }

        if ($requireElements && (!isset($decoded['elements']) || !is_array($decoded['elements']))) {
            return null;
        }

        return $decoded;
    }

    /**
     * Writes JSON atomically enough for the current single-process diagnostic workflow.
     */
    private function writeJsonCache(string $file, array $data): void
    {
        $encoded = json_encode(
            $data,
            JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
        );

        if ($encoded === false || file_put_contents($file, $encoded) === false) {
            throw new RuntimeException('Could not write cache file: ' . $file);
        }
    }

    /**
     * Creates the geocoder cache directory when it does not yet exist.
     */
    private function ensureCacheDirectory(): void
    {
        if (is_dir(self::CACHE_DIR)) {
            return;
        }

        if (!mkdir(self::CACHE_DIR, 0775, true) && !is_dir(self::CACHE_DIR)) {
            throw new RuntimeException('Could not create cache directory: ' . self::CACHE_DIR);
        }
    }

    /**
     * Extracts a representative latitude/longitude from an OSM node or center-bearing object.
     */
    private function extractElementPosition(array $element): ?array
    {
        if (
            isset($element['lat'], $element['lon'])
            && is_numeric($element['lat'])
            && is_numeric($element['lon'])
        ) {
            return [(float)$element['lat'], (float)$element['lon']];
        }

        if (
            isset($element['center']['lat'], $element['center']['lon'])
            && is_numeric($element['center']['lat'])
            && is_numeric($element['center']['lon'])
        ) {
            return [(float)$element['center']['lat'], (float)$element['center']['lon']];
        }

        return null;
    }

    /**
     * Classifies OSM tags into the maritime evidence categories used by the naming logic.
     */
    private function classifyMaritimeFeature(array $tags): string
    {
        if (($tags['leisure'] ?? null) === 'marina') {
            return 'MARINA';
        }
        if (($tags['seamark:type'] ?? null) === 'anchorage') {
            return 'ANCHORAGE';
        }
        if (($tags['seamark:type'] ?? null) === 'harbour') {
            return 'HARBOUR';
        }
        if (array_key_exists('seamark:harbour:category', $tags)) {
            return 'HARBOUR_CATEGORY';
        }
        if (array_key_exists('harbour', $tags)) {
            return 'HARBOUR';
        }
        if (($tags['natural'] ?? null) === 'bay') {
            return 'BAY';
        }
        return 'OTHER';
    }

    /**
     * Keeps only maritime OSM tags that are useful for diagnostics and Decision Trace output.
     */
    private function extractRelevantMaritimeTags(array $tags): array
    {
        $wanted = [
            'name', 'name:en', 'leisure', 'harbour', 'natural',
            'seamark:type', 'seamark:name', 'seamark:harbour:category',
            'seamark:anchorage:category',
        ];

        $result = [];
        foreach ($wanted as $key) {
            if (isset($tags[$key]) && trim((string)$tags[$key]) !== '') {
                $result[$key] = (string)$tags[$key];
            }
        }
        return $result;
    }

    /**
     * Returns the best available OSM name from the supported name tags.
     */
    private function extractName(array $tags): ?string
    {
        foreach (['name', 'seamark:name', 'name:en'] as $key) {
            if (isset($tags[$key]) && trim((string)$tags[$key]) !== '') {
                return trim((string)$tags[$key]);
            }
        }
        return null;
    }

    /**
     * Converts an OSM/Nominatim population value to an integer when possible.
     */
    private function normalizePopulation(mixed $value): ?int
    {
        if ($value === null) {
            return null;
        }

        $text = preg_replace('/[^\d]/', '', (string)$value);
        return ($text === null || $text === '') ? null : (int)$text;
    }

    /**
     * Returns the first non-empty scalar value from a list.
     */
    private function firstNonEmpty(array $values): ?string
    {
        foreach ($values as $value) {
            if ($value !== null && trim((string)$value) !== '') {
                return trim((string)$value);
            }
        }
        return null;
    }

    /**
     * Normalizes an optional scalar value to a trimmed string or null.
     */
    private function nullableString(mixed $value): ?string
    {
        if ($value === null) {
            return null;
        }
        $value = trim((string)$value);
        return $value === '' ? null : $value;
    }

    /**
     * Normalizes an optional numeric value to float or null.
     */
    private function nullableFloat(mixed $value): ?float
    {
        return is_numeric($value) ? (float)$value : null;
    }

    /**
     * Extracts the HTTP status code from PHP response headers.
     */
    private function extractHttpStatusCode(array $headers): ?int
    {
        foreach ($headers as $header) {
            if (preg_match('~^HTTP/\S+\s+(\d{3})~', (string)$header, $m)) {
                return (int)$m[1];
            }
        }
        return null;
    }

    /**
     * Calculates great-circle distance between two WGS84 positions in metres.
     */
    private function haversineMeters(
        float $lat1,
        float $lon1,
        float $lat2,
        float $lon2
    ): float {
        $earthRadiusM = 6371000.0;
        $lat1Rad = deg2rad($lat1);
        $lat2Rad = deg2rad($lat2);
        $deltaLat = deg2rad($lat2 - $lat1);
        $deltaLon = deg2rad($lon2 - $lon1);

        $a = sin($deltaLat / 2) ** 2
            + cos($lat1Rad) * cos($lat2Rad) * sin($deltaLon / 2) ** 2;

        return $earthRadiusM * 2 * atan2(sqrt($a), sqrt(1 - $a));
    }
}
