Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.64% covered (success)
96.64%
230 / 238
85.71% covered (warning)
85.71%
18 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
AmmonlyMapApiClient
96.62% covered (success)
96.62%
229 / 237
85.71% covered (warning)
85.71%
18 / 21
78
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 geocode
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 queryNominatim
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
8
 fetchFromDbCache
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 loadCachedPointFromDb
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 hydrateCachedGeoPoint
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 saveToDbCache
82.61% covered (warning)
82.61%
19 / 23
0.00% covered (danger)
0.00%
0 / 1
4.08
 reverseGeocode
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 calculateRoute
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
6
 optimizeTrip
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
9
 extractTripSteps
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 parseSteps
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 createRouteStep
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 getTileUrlTemplate
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getDarkTileUrlTemplate
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getApiUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getApiKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 requestJson
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 executeHttpRequest
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
6.00
 fetchHttpPayload
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
3.00
 fetchHttpViaStream
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Map\Infrastructure\Client;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Map\Domain\Model\GeoPoint;
12use App\Modules\Map\Domain\Model\RouteResultDto;
13use App\Modules\Map\Domain\Model\RouteStepDto;
14use App\Modules\Map\Domain\Repository\MapApiClientInterface;
15use PDO;
16use Throwable;
17
18/**
19 * Ammonly Maps Engine HTTP Client Implementation.
20 *
21 * Communicates with private Ammonly Maps server endpoints with API Key authentication.
22 *
23 * @package App\Modules\Map\Infrastructure\Client
24 */
25final class AmmonlyMapApiClient implements MapApiClientInterface
26{
27    private string $apiUrl;
28    private string $apiKey;
29    /** @var (callable(string): mixed)|null */
30    private mixed $httpRequester;
31
32    /**
33     * AmmonlyMapApiClient constructor.
34     *
35     * @param string        $apiUrl        Base server URL (e.g. "https://map.ammonly.com").
36     * @param string        $apiKey        Secret API Key (e.g. "ak_live_...").
37     * @param callable|null $httpRequester Optional HTTP requester callback for testing.
38     * @param PDO|null      $pdo           Optional PDO instance for persistent local cache.
39     */
40    public function __construct(
41        string $apiUrl,
42        string $apiKey,
43        ?callable $httpRequester = null,
44        private readonly ?PDO $pdo = null
45    ) {
46        $this->apiUrl = rtrim($apiUrl, '/');
47        $this->apiKey = trim($apiKey);
48        $this->httpRequester = $httpRequester;
49    }
50
51    /**
52     * {@inheritdoc}
53     */
54    public function geocode(string $query, int $limit = 5): array
55    {
56        $trimmed = trim($query);
57        if ($trimmed === '') {
58            return [];
59        }
60
61        $points = $this->queryNominatim($trimmed, $limit);
62        if ($points === []) {
63            $stripped = (string) preg_replace('/^(ul\.|ulica|al\.|aleja|pl\.|plac)\s+/iu', '', $trimmed);
64            if ($stripped !== $trimmed && trim($stripped) !== '') {
65                $points = $this->queryNominatim(trim($stripped), $limit);
66            }
67        }
68
69        return $points;
70    }
71
72    /**
73     * Executes Nominatim search query with local persistent database caching.
74     *
75     * @param string $query Query string.
76     * @param int    $limit Max candidates.
77     * @return array<int, GeoPoint>
78     */
79    private function queryNominatim(string $query, int $limit): array
80    {
81        $cached = $this->fetchFromDbCache($query);
82        if ($cached !== null) {
83            return [$cached];
84        }
85
86        $url = sprintf(
87            '%s/nominatim/search?format=json&q=%s&countrycodes=pl&limit=%d&addressdetails=1',
88            $this->apiUrl,
89            rawurlencode($query),
90            max(1, min($limit, 20))
91        );
92
93        $data = $this->requestJson($url);
94        if (!is_array($data)) {
95            return [];
96        }
97
98        $points = [];
99        foreach ($data as $item) {
100            if (!is_array($item) || !isset($item['lat'], $item['lon'])) {
101                continue;
102            }
103
104            $point = new GeoPoint(
105                latitude: (float) $item['lat'],
106                longitude: (float) $item['lon'],
107                label: (string) ($item['name'] ?? $item['display_name'] ?? $query),
108                address: (string) ($item['display_name'] ?? ''),
109                metadata: is_array($item['address'] ?? null) ? $item['address'] : []
110            );
111            $points[] = $point;
112        }
113
114        if ($points !== []) {
115            $this->saveToDbCache($query, $points[0]);
116        }
117
118        return $points;
119    }
120
121    /**
122     * Attempts to fetch cached coordinates from local database cache.
123     */
124    private function fetchFromDbCache(string $query): ?GeoPoint
125    {
126        if ($this->pdo === null) {
127            return null;
128        }
129
130        return $this->loadCachedPointFromDb($query);
131    }
132
133    private function loadCachedPointFromDb(string $query): ?GeoPoint
134    {
135        try {
136            $hash = hash('sha256', mb_strtolower(trim($query)));
137            $stmt = $this->pdo->prepare(
138                'SELECT latitude, longitude, display_name, raw_payload ' .
139                'FROM a_core_geocode_cache WHERE query_hash = :hash'
140            );
141            $stmt->execute([':hash' => $hash]);
142            $row = $stmt->fetch(PDO::FETCH_ASSOC);
143            if (!is_array($row) || !isset($row['latitude'], $row['longitude'])) {
144                return null;
145            }
146
147            $this->pdo->prepare(
148                'UPDATE a_core_geocode_cache SET hits = hits + 1 WHERE query_hash = :hash'
149            )->execute([':hash' => $hash]);
150
151            return $this->hydrateCachedGeoPoint($row, $query);
152        } catch (Throwable) {
153            return null;
154        }
155    }
156
157    /**
158     * @param array<string, mixed> $row
159     */
160    private function hydrateCachedGeoPoint(array $row, string $query): GeoPoint
161    {
162        $rawPayload = [];
163        if (!empty($row['raw_payload'])) {
164            $decoded = json_decode((string) $row['raw_payload'], true);
165            if (is_array($decoded)) {
166                $rawPayload = $decoded;
167            }
168        }
169
170        return new GeoPoint(
171            latitude: (float) $row['latitude'],
172            longitude: (float) $row['longitude'],
173            label: (string) ($row['display_name'] ?? $query),
174            address: (string) ($row['display_name'] ?? ''),
175            metadata: $rawPayload
176        );
177    }
178
179    /**
180     * Persists resolved geocoding point to local database cache.
181     */
182    private function saveToDbCache(string $query, GeoPoint $point): void
183    {
184        if ($this->pdo === null) {
185            return;
186        }
187
188        try {
189            $hash = hash('sha256', mb_strtolower(trim($query)));
190            $rawJson = json_encode($point->metadata, JSON_UNESCAPED_UNICODE);
191            $driver = (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
192
193            if ($driver === 'sqlite') {
194                $sql = 'INSERT OR REPLACE INTO a_core_geocode_cache ' .
195                       '(query_hash, query_text, latitude, longitude, display_name, raw_payload, hits) ' .
196                       'VALUES (:hash, :text, :lat, :lon, :display_name, :raw, 1)';
197            } else {
198                $sql = 'INSERT INTO a_core_geocode_cache ' .
199                       '(query_hash, query_text, latitude, longitude, display_name, raw_payload, hits) ' .
200                       'VALUES (:hash, :text, :lat, :lon, :display_name, :raw, 1) ' .
201                       'ON DUPLICATE KEY UPDATE hits = hits + 1, updated_at = CURRENT_TIMESTAMP(6)';
202            }
203
204            $stmt = $this->pdo->prepare($sql);
205            $stmt->execute([
206                ':hash'         => $hash,
207                ':text'         => mb_substr(trim($query), 0, 255),
208                ':lat'          => $point->latitude,
209                ':lon'          => $point->longitude,
210                ':display_name' => mb_substr($point->address, 0, 255),
211                ':raw'          => $rawJson,
212            ]);
213        } catch (Throwable) {
214            // Silently ignore cache write failures to ensure geocoding continues
215        }
216    }
217
218    /**
219     * {@inheritdoc}
220     */
221    public function reverseGeocode(float $latitude, float $longitude): ?GeoPoint
222    {
223        $url = sprintf(
224            '%s/nominatim/reverse?format=json&lat=%.6f&lon=%.6f&addressdetails=1',
225            $this->apiUrl,
226            $latitude,
227            $longitude
228        );
229
230        $item = $this->requestJson($url);
231        if (!is_array($item) || !isset($item['lat'], $item['lon'])) {
232            return null;
233        }
234
235        $label = (string) ($item['name'] ?? ($item['address']['road'] ?? 'Location'));
236        $address = (string) ($item['display_name'] ?? '');
237
238        return new GeoPoint(
239            latitude: (float) $item['lat'],
240            longitude: (float) $item['lon'],
241            label: $label,
242            address: $address,
243            metadata: is_array($item['address'] ?? null) ? $item['address'] : []
244        );
245    }
246
247    /**
248     * {@inheritdoc}
249     */
250    public function calculateRoute(GeoPoint $origin, GeoPoint $destination): ?RouteResultDto
251    {
252        $url = sprintf(
253            '%s/route/v1/driving/%.6f,%.6f;%.6f,%.6f?overview=full&geometries=geojson&steps=true',
254            $this->apiUrl,
255            $origin->longitude,
256            $origin->latitude,
257            $destination->longitude,
258            $destination->latitude
259        );
260
261        $data = $this->requestJson($url);
262        if (!is_array($data) || ($data['code'] ?? '') !== 'Ok' || empty($data['routes'][0])) {
263            return null;
264        }
265
266        $route = $data['routes'][0];
267        $distance = (float) ($route['distance'] ?? 0.0);
268        $duration = (float) ($route['duration'] ?? 0.0);
269        $geometry = is_array($route['geometry'] ?? null) ? $route['geometry'] : [];
270        $rawSteps = is_array($route['legs'][0]['steps'] ?? null) ? $route['legs'][0]['steps'] : [];
271
272        return new RouteResultDto(
273            distanceMeters: $distance,
274            durationSeconds: $duration,
275            geometryGeoJson: $geometry,
276            steps: $this->parseSteps($rawSteps),
277            summary: (string) ($route['summary'] ?? '')
278        );
279    }
280
281    /**
282     * {@inheritdoc}
283     */
284    public function optimizeTrip(array $points, bool $roundtrip = true): ?RouteResultDto
285    {
286        if (count($points) < 2) {
287            return null;
288        }
289
290        $coords = array_map(
291            static fn(GeoPoint $p): string => sprintf('%.6f,%.6f', $p->longitude, $p->latitude),
292            $points
293        );
294        $url = sprintf(
295            '%s/trip/v1/driving/%s?overview=full&geometries=geojson&steps=true&roundtrip=%s',
296            $this->apiUrl,
297            implode(';', $coords),
298            $roundtrip ? 'true' : 'false'
299        );
300
301        $data = $this->requestJson($url);
302        if (!is_array($data) || ($data['code'] ?? '') !== 'Ok' || empty($data['trips'][0])) {
303            return null;
304        }
305
306        $trip = $data['trips'][0];
307        $distance = (float) ($trip['distance'] ?? 0.0);
308        $duration = (float) ($trip['duration'] ?? 0.0);
309        $geometry = is_array($trip['geometry'] ?? null) ? $trip['geometry'] : [];
310        $legs = is_array($trip['legs'] ?? null) ? $trip['legs'] : [];
311        $waypoints = is_array($data['waypoints'] ?? null) ? $data['waypoints'] : [];
312
313        return new RouteResultDto(
314            distanceMeters: $distance,
315            durationSeconds: $duration,
316            geometryGeoJson: $geometry,
317            steps: $this->extractTripSteps($legs),
318            summary: (string) ($trip['summary'] ?? ''),
319            waypoints: $waypoints
320        );
321    }
322
323    /**
324     * @param array<int, mixed> $legs
325     * @return list<RouteStepDto>
326     */
327    private function extractTripSteps(array $legs): array
328    {
329        $steps = [];
330        foreach ($legs as $leg) {
331            if (is_array($leg) && !empty($leg['steps']) && is_array($leg['steps'])) {
332                $steps = array_merge($steps, $this->parseSteps($leg['steps']));
333            }
334        }
335        return $steps;
336    }
337
338    /**
339     * @param array<int, mixed> $rawSteps
340     * @return list<RouteStepDto>
341     */
342    private function parseSteps(array $rawSteps): array
343    {
344        $steps = [];
345        foreach ($rawSteps as $step) {
346            if (!is_array($step)) {
347                continue;
348            }
349            $steps[] = $this->createRouteStep($step);
350        }
351        return $steps;
352    }
353
354    /**
355     * @param array<string, mixed> $step
356     */
357    private function createRouteStep(array $step): RouteStepDto
358    {
359        $maneuver = is_array($step['maneuver'] ?? null) ? $step['maneuver'] : [];
360        $type = (string) ($maneuver['type'] ?? 'turn');
361        $name = (string) ($step['name'] ?? '');
362        $instruction = trim($type . ($name !== '' ? ' on ' . $name : ''));
363
364        return new RouteStepDto(
365            instruction: $instruction !== '' ? $instruction : 'Continue',
366            distanceMeters: (float) ($step['distance'] ?? 0.0),
367            durationSeconds: (float) ($step['duration'] ?? 0.0),
368            maneuverType: $type
369        );
370    }
371
372    /**
373     * {@inheritdoc}
374     */
375    public function getTileUrlTemplate(): string
376    {
377        if ($this->apiKey !== '') {
378            return sprintf('%s/tile/{z}/{x}/{y}.png?key=%s', $this->apiUrl, urlencode($this->apiKey));
379        }
380
381        return sprintf('%s/tile/{z}/{x}/{y}.png', $this->apiUrl);
382    }
383
384    /**
385     * {@inheritdoc}
386     */
387    public function getDarkTileUrlTemplate(): string
388    {
389        if ($this->apiKey !== '') {
390            return sprintf('%s/tile-dark/{z}/{x}/{y}.png?key=%s', $this->apiUrl, urlencode($this->apiKey));
391        }
392
393        return sprintf('%s/tile-dark/{z}/{x}/{y}.png', $this->apiUrl);
394    }
395
396    /**
397     * {@inheritdoc}
398     */
399    public function getApiUrl(): string
400    {
401        return $this->apiUrl;
402    }
403
404    /**
405     * {@inheritdoc}
406     */
407    public function getApiKey(): string
408    {
409        return $this->apiKey;
410    }
411
412    /**
413     * Performs an HTTP GET request with API Key header and parses JSON.
414     */
415    private function requestJson(string $url): mixed
416    {
417        if ($this->httpRequester !== null) {
418            return ($this->httpRequester)($url);
419        }
420
421        return $this->executeHttpRequest($url);
422    }
423
424    private function executeHttpRequest(string $url): mixed
425    {
426        try {
427            $raw = $this->fetchHttpPayload($url);
428            if (!is_string($raw) || $raw === '') {
429                return null;
430            }
431
432            return json_decode($raw, true);
433        } catch (Throwable) {
434            return null;
435        }
436    }
437
438    /**
439     * Performs network transport via cURL or stream wrapper with gzip decoding.
440     */
441    private function fetchHttpPayload(string $url): ?string
442    {
443        if (function_exists('curl_init')) {
444            $ch = curl_init($url);
445            curl_setopt_array($ch, [
446                CURLOPT_RETURNTRANSFER => true,
447                CURLOPT_HTTPHEADER     => [
448                    "X-API-Key: {$this->apiKey}",
449                    'User-Agent: Ammonly-Maps-Client/1.0',
450                    'Accept: application/json',
451                ],
452                CURLOPT_ENCODING       => '',
453                CURLOPT_TIMEOUT        => 3,
454                CURLOPT_SSL_VERIFYPEER => false,
455                CURLOPT_SSL_VERIFYHOST => 0,
456            ]);
457            $raw = curl_exec($ch);
458            curl_close($ch);
459
460            return is_string($raw) ? $raw : null;
461        }
462
463        return $this->fetchHttpViaStream($url);
464    }
465
466    /**
467     * Fallback stream wrapper GET request with optional gzip decompression.
468     */
469    private function fetchHttpViaStream(string $url): ?string
470    {
471        $context = stream_context_create([
472            'http' => [
473                'method'  => 'GET',
474                'header'  => "X-API-Key: {$this->apiKey}\r\n" .
475                             "User-Agent: Ammonly-Maps-Client/1.0\r\n" .
476                             "Accept: application/json\r\n",
477                'timeout' => 3,
478            ],
479            'ssl' => [
480                'verify_peer'      => false,
481                'verify_peer_name' => false,
482            ],
483        ]);
484
485        $raw = @file_get_contents($url, false, $context);
486        if ($raw === false || $raw === '') {
487            return null;
488        }
489
490        if (str_starts_with($raw, "\x1f\x8b")) {
491            $decompressed = @gzdecode($raw);
492            if ($decompressed !== false) {
493                return $decompressed;
494            }
495        }
496
497        return $raw;
498    }
499}