<?php
/**
 * Copyright since 2025 InPost S.A.
 *
 * For the full license information, please view the LICENSE file bundled with the module.
 *
 * @author InPost S.A.
 * @copyright since 2025 InPost S.A.
 * @license MIT
 */

declare(strict_types=1);

namespace InPost\International\Delivery\Point;

use InPost\International\Api\Point\Model\PointCapability;
use InPost\International\Delivery\Address;
use InPost\International\Delivery\Point\Cache\ClosestPointCache;
use InPost\International\Serializer\SafeDeserializerTrait;
use InPost\International\Storage\StorageInterface;
use Psr\Clock\ClockInterface;
use Symfony\Component\Serializer\SerializerInterface;

if (!defined('_PS_VERSION_')) {
    exit;
}

final class CachedClosestPointFinder implements ClosestPointFinderInterface
{
    use SafeDeserializerTrait;

    private const TTL_DEFAULT = 'P1D';
    private const STORAGE_KEY = 'closest_point';

    /**
     * @var ClosestPointFinderInterface
     */
    private $finder;

    /**
     * @var StorageInterface
     */
    private $storage;

    /**
     * @var ClockInterface
     */
    private $clock;

    /**
     * @var \DateInterval
     */
    private $ttl;

    /**
     * @var array<string, RelativePointInterface|null>
     */
    private $cache = [];

    /**
     * @param string|\DateInterval|null $ttl
     */
    public function __construct(ClosestPointFinderInterface $finder, StorageInterface $storage, SerializerInterface $serializer, ClockInterface $clock, $ttl = null)
    {
        $this->finder = $finder;
        $this->storage = $storage;
        $this->serializer = $serializer;
        $this->clock = $clock;
        $this->ttl = $this->resolveTtl($ttl);
    }

    public function findClosestPoint(Address $address, PointCapability ...$capabilities): ?RelativePointInterface
    {
        $checksum = $this->generateChecksum($address);

        if (array_key_exists($checksum, $this->cache)) {
            return $this->cache[$checksum];
        }

        if (null !== $cacheItem = $this->getCacheItem($checksum)) {
            $point = $cacheItem->getPoint();

            if (null === $point || $this->hasCapabilities($point, $capabilities)) {
                return $this->cache[$checksum] = $point;
            }
        }

        $point = $this->finder->findClosestPoint($address, ...$capabilities);
        $this->store($point, $checksum);

        return $this->cache[$checksum] = $point;
    }

    private function getCacheItem(string $checksum): ?ClosestPointCache
    {
        if (null === $value = $this->storage->get(self::STORAGE_KEY)) {
            return null;
        }

        if (null === $item = $this->deserialize($value, ClosestPointCache::class)) {
            return null;
        }

        if ($item->getChecksum() !== $checksum) {
            return null;
        }

        if ($this->clock->now() >= $item->getCreatedAt()->add($this->ttl)) {
            return null;
        }

        return $item;
    }

    private function store(?RelativePointInterface $point, string $checksum): void
    {
        $item = ClosestPointCache::create($point, $checksum, $this->clock->now());
        $value = $this->serializer->serialize($item, 'json');

        $this->storage->set(self::STORAGE_KEY, $value);
    }

    private function generateChecksum(Address $address): string
    {
        return hash('md5', implode('|', [
            $address->getAddress(),
            $address->getPostcode(),
            $address->getCity(),
            $address->getCountry()->value,
        ]));
    }

    /**
     * @param PointCapability[] $capabilities
     */
    private function hasCapabilities(RelativePointInterface $point, array $capabilities): bool
    {
        foreach ($capabilities as $capability) {
            if (!$point->hasCapability($capability)) {
                return false;
            }
        }

        return true;
    }

    private function resolveTtl($ttl): \DateInterval
    {
        if ($ttl instanceof \DateInterval) {
            return $ttl;
        }

        if (null === $ttl) {
            $ttl = self::TTL_DEFAULT;
        }

        if (!is_string($ttl)) {
            throw new \InvalidArgumentException(sprintf('Expected TTL to be a string or DateInterval, "%s" given.', get_debug_type($ttl)));
        }

        return new \DateInterval($ttl);
    }
}
