<?php

namespace App\Service\Prices;

use App\Service\Adapter\Orm;
use App\Service\Adapter\RedisClientService;
use Psr\Log\LoggerInterface;
use Random\RandomException;
use Redis;
use RuntimeException;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Stopwatch\Stopwatch;
use Throwable;

class RedisImportService
{
    private ?Redis $redis = null;
    private const LOCK_TTL = 30;
    private const  MAX_TTL_PRICE_KEY = 300;
    private const MAX_VERSIONS = 2;
    public const PRICE_DATA_FEED = 'price_data_feed.json';
    private string $priceDataImportDir;
    private string $randomFilePrefix;
    private ?OutputInterface $consoleOutput = null;

    /**
     * RedisImportService constructor.
     *
     * @param RedisClientService $redisClientService The service to manage Redis client connections.
     * @param Orm $ormAdapter The ORM adapter to fetch price data.
     * @param LoggerInterface $logger The logger for logging messages and errors.
     * @param array $calledShopIds The shop IDs to call for fetching data.
     * @param int $cashPriceStep The step index for cash prices.
     * @param KernelInterface $kernel The kernel interface for accessing project directories.
     * @param Stopwatch $stopwatch The stopwatch for measuring execution time.
     */
    public function __construct(
        private readonly RedisClientService $redisClientService,
        private readonly Orm                $ormAdapter,
        private readonly LoggerInterface    $logger,
        #[Autowire('%mitho.import.orm.shopIds%')]
        private readonly array              $calledShopIds,
        #[Autowire('%mitho.import.orm.cashPriceStep%')]
        private readonly int                $cashPriceStep,
        private readonly KernelInterface    $kernel,
        private readonly Stopwatch          $stopwatch
    )
    {
        $this->priceDataImportDir = $this->getDir();
    }

    /**
     * Imports prices from ORM and saves them to a JSON file.
     *
     * This method calls the ORM adapter to fetch price data and saves it to a JSON file
     * in the specified directory. It returns true if the import was successful, false otherwise.
     *
     * @return bool True if the import was successful, false otherwise.
     */
    public function importPricesFromOrm(): bool
    {
        $fileName = $this->getPriceDataFeedPath();
        $ormResult = $this->ormAdapter->call($this->calledShopIds);
        if ($ormResult) {
            file_put_contents($fileName, json_encode($ormResult, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
        } else {
            return false;
        }
        return true;
    }

    /**
     * Writes prices to Redis from the imported data.
     *
     * This method reads the price data from the JSON file created by `importPricesFromOrm()`
     * and writes it to Redis, ensuring that the data is properly formatted and stored.
     *
     * @param OutputInterface|null $output Optional output interface for logging messages.
     * @throws RuntimeException|RandomException|Throwable If there is an error during the import process.
     */
    public function writePrices(?OutputInterface $output = null): void
    {
        $updateStartTimestamp = time();

        if ($output) {
            $this->consoleOutput = $output;
        }

        //Set global lock to prevent concurrent imports
        if (!$this->acquireGlobalLock()) {
            throw new RuntimeException('An import is already in progress...');
        }

        // Check if Redis is available before proceeding
        if (!$this->redis()->ping()) {
            throw new RuntimeException('Redis is not available');
        }

        // Create a random file prefix to avoid conflicts
        $this->randomFilePrefix = bin2hex(random_bytes(8));

        $this->__WCO('Call ORM', 'comment');

        //**********************************************************
        //* Load data from ORM
        //**********************************************************
        try {
            $this->stopwatch->start('call_orm');
            $this->importPricesFromOrm();
            $time = $this->stopwatch->stop('call_orm')?->getDuration() / 1000;
            $this->__WCO("Time ORM call: {$time}s");
        } catch (Throwable $e) {
            $this->handleException('Import from ORM failed', $e);
        }

        //**********************************************************
        //* Load data from saved feed with data from ORM
        //**********************************************************
        try {
            $this->__WCO('Start save prices', 'comment');

            $this->stopwatch->start('get_price_data_feed');
            $payload = $this->getPriceDataFromFeed();
            $time = $this->stopwatch->stop('get_price_data_feed')?->getDuration() / 1000;
            $this->__WCO("Time load data: {$time}s");
        } catch (Throwable $e) {
            $this->handleException('Failed to read price data feed', $e);
        }

        if (!isset($payload['data']['shops']) || !is_array($payload['data']['shops'])) {
            throw new RuntimeException('Invalid payload format: "shops" data is missing or not an array.');
        }


        $timestamp = $payload['data']['real_pricedate'] ?? date('Y-m-d H:i:s');
        $unixTimestamp = strtotime($timestamp);
        $version = $unixTimestamp;

        //**********************************************************
        //* Write into redis from payload data from ORM
        //**********************************************************
        foreach ($payload['data']['shops'] as $shopId => $shopData) {
            $shopId = (int)$shopId;
            $currentVersion = $this->redis()->get("current_cache_version:$shopId");

            //Set shop redis lock
            if (!$this->acquireLock($shopId)) {
                $this->__WCO("[INFO] Shop $shopId locked – skipping import.", 'error');
                continue;
            }

            // Check if the current version matches the version to be imported
            if($currentVersion == $version){
                $this->logger->info("Version: $version for Shop $shopId already exists, skipping import.");
                continue;
            }

            $watcherName = "write_shop_data_$shopId";
            $this->stopwatch->start($watcherName);

            try {
                $this->importShopData($shopId, $shopData, $version, $timestamp, $unixTimestamp, $updateStartTimestamp);
                $this->updateCurrentVersion($shopId, $version);
                $this->manageVersionHistory($shopId, $version);
                $this->__WCO("[SUCCESS] Imported Shop $shopId (Version $version).", 'fg=white');
            } catch (Throwable $e) {
                // Don't stop the import for other shops, only log the error
                $this->logger->error("Import failed for Shop $shopId: " . $e->getMessage(), ['trace' => $e->getTraceAsString()]);
                $this->__WCO("[ERROR] Import failed for Shop $shopId: " . $e->getMessage(), 'error');
            } finally {

                // Reset lock for the shop
                $this->releaseLock($shopId);

                $steps = count($shopData['items'][0]['steps'] ?? []);
                $savedDataCount = count($shopData['items']) * $steps;
                $time = $this->stopwatch->stop($watcherName)?->getDuration() / 1000;
                $this->__WCO("Time write data ($savedDataCount keys): {$time}s");
            }
        }

        // Clean up the JSON file after import
        if (file_exists($this->getPriceDataFeedPath())) {
            unlink($this->getPriceDataFeedPath());
        }

        $this->releaseGlobalLock();
    }

    //**********************************************************
    //* HELPER METHODS
    //**********************************************************

    /**
     * Logs an informational message to the output interface.
     *
     * @param string $message The message to log.
     * @param string|null $type The type of message (e.g., 'info', 'comment'). Defaults to 'info'.
     */
    private function __WCO(string $message, ?string $type = 'info'): void
    {
        if ($this->consoleOutput instanceof OutputInterface) {
            $this->consoleOutput?->writeln("<{$type}>$message</{$type}>");
        }

    }

    /**
     * Handles exceptions by logging the error and throwing a RuntimeException.
     *
     * @param string $context The context in which the exception occurred.
     * @param Throwable $e The exception to handle.
     * @throws RuntimeException
     */
    private function handleException(string $context, Throwable $e): void
    {
        $this->logger->error("$context: " . $e->getMessage(), ['trace' => $e->getTraceAsString()]);
        throw new RuntimeException("$context: " . $e->getMessage(), 0, $e);
    }

    /**
     * Imports shop data into Redis.
     *
     * This method processes the shop data and writes it to Redis with a specific key format.
     * It calculates gross prices based on the net prices and tax rates provided in the data.
     *
     * @param int $shopId The ID of the shop to import data for.
     * @param array $shopData The data for the shop, including items and their prices.
     * @param string $version The version of the data being imported.
     * @param string $timestamp The timestamp of the data import.
     * @param int $unixTimestamp The Unix timestamp of the data import.
     * @throws RuntimeException|Throwable If there is an error writing to Redis.
     */
    private function importShopData(int $shopId, array $shopData, string $version, string $timestamp, int $unixTimestamp, int $updateStartTimestamp): void
    {
        $errors = [];
        foreach ($shopData['items'] as $item) {
            $sku = $item['itemid'];
            $taxrate = isset($item['taxrate']) ? ($item['taxrate'] / 100) : 0.0;
            $cashPrice = $item['steps'][$this->cashPriceStep]['vk_price'] ?? 0.0;
            $updateEndTimestamp = time();

            foreach ($item['steps'] as $step => $prices) {
                $key = "v{$version}:{$step}:{$shopId}:{$sku}";

                $data = [
                    'sku' => $sku,
                    'vk_price' => $prices['vk_price'],
                    'vk_price_gross' => $this->calcGross($prices['vk_price'], $taxrate),
                    'vk_g_price' => $prices['vk_price_g_net'],
                    'vk_g_price_gross' => $this->calcGross($prices['vk_price_g_net'], $taxrate),
                    'ak_price' => $prices['ak_price'],
                    'ak_price_gross' => $this->calcGross($prices['ak_price'], $taxrate),
                    'ak_g_price' => $prices['ak_price_g_net'],
                    'ak_g_price_gross' => $this->calcGross($prices['ak_price_g_net'], $taxrate),
                    'cash_price' => $cashPrice,
                    'cash_price_gross' => $this->calcGross($cashPrice, $taxrate),
                    'taxrate' => $taxrate,
                    'last_update' => $timestamp,
                    'timestamp' => $unixTimestamp,
                    'update_start_timestamp' => $updateStartTimestamp,
                    'update_end_timestamp' => $updateEndTimestamp
                ];

                // Write to Redis with a key that includes the version, step, shop ID, and SKU
                try {
                    $this->redis()->set($key, json_encode($data), ['NX', 'EX' => self:: MAX_TTL_PRICE_KEY]);
                } catch (\RedisException $e) {
                    $errors [] = "Failed to write Redis key: {$key}. Error: " . $e->getMessage() . "\n";
                }

            }
        }
        if ($errors) {
            $this->logger->error(implode("", $errors));
        }

    }

    /**
     * Returns the directory for price imports.
     *
     * This method checks if the directory exists and creates it if it does not.
     *
     * @return string The path to the price imports directory.
     * @throws RuntimeException If the directory cannot be created.
     */
    private function getDir(): string
    {
        $dir = $this->kernel->getProjectDir() . '/var/price_imports/';
        if (!is_dir($dir)) {
            if (!mkdir($dir, 0777, true) && !is_dir($dir)) {
                throw new RuntimeException(sprintf('Directory "%s" was not created', $dir));
            }
        }
        return $dir;
    }

    /**
     * Returns the path to the price data feed file.
     *
     * This method checks if the price data feed file exists and is readable.
     * If not, it attempts to create the file.
     *
     * @return string The path to the price data feed file.
     * @throws RuntimeException If the file cannot be created or is not readable.
     */
    private function getPriceDataFeedPath(): string
    {
        $file = $this->priceDataImportDir . '/' . $this->randomFilePrefix . '_' . self::PRICE_DATA_FEED;
        if (!is_readable($file)) {
            if (!touch($file)) {
                $msg = 'Input file not readable';
                $this->logger->error($msg, ['path' => $file]);
                throw new RuntimeException($msg);
            }
        }

        return $file;
    }

    /**
     * Reads the price data feed from the JSON file.
     *
     * This method reads the JSON file containing price data and decodes it into an array.
     * It throws an exception if the file is not readable or if the JSON format is invalid.
     *
     * @return array The decoded price data.
     * @throws RuntimeException If the file is not readable or contains invalid JSON.
     */
    private function getPriceDataFromFeed(): array
    {
        $file = $this->getPriceDataFeedPath();
        $json = file_get_contents($file);
        $data = json_decode($json, true);

        if (!is_array($data)) {
            $msg = 'Invalid JSON format in file';
            $this->logger->error($msg, ['path' => $file]);
            throw new RuntimeException($msg);
        }

        return $data;
    }

    /**
     * Calculates the gross price from the net price and tax rate.
     *
     * @param float $net The net price.
     * @param float $taxRate The tax rate as a decimal (e.g., 0.19 for 19%).
     * @return float The calculated gross price, rounded to two decimal places.
     */
    private function calcGross(float $net, float $taxRate): float
    {
        return round($net * (1 + $taxRate), 2);
    }

    /**
     * Acquires a global lock to prevent concurrent imports.
     *
     * @return bool True if the lock was acquired, false if it was already held.
     * @throws Throwable
     */
    private function acquireGlobalLock(): bool
    {
        return $this->redis()->set('lock:import:global', time(), ['NX', 'EX' => self::LOCK_TTL]);
    }

    /**
     * Releases the global lock after import is complete.
     *
     * @return void
     * @throws Throwable
     */
    private function releaseGlobalLock(): void
    {
        $this->redis()->del('lock:import:global');
    }

    /**
     * Acquires a lock for a specific shop to prevent concurrent imports.
     *
     * @param int $shopId The ID of the shop to lock.
     * @return bool True if the lock was acquired, false if it was already held.
     * @throws RuntimeException If there is an error acquiring the lock.
     */
    private function acquireLock(int $shopId): bool
    {
        try {
            return $this->redis()->set("lock:$shopId", time(), ['NX', 'EX' => self::LOCK_TTL]);
        } catch (Throwable $e) {
            throw new RuntimeException("Redis lock error for shop $shopId: {$e->getMessage()}");
        }
    }

    /**
     * Releases the lock for a specific shop.
     *
     * @param int $shopId The ID of the shop to release the lock for.
     * @return void
     */
    private function releaseLock(int $shopId): void
    {
        try {
            $this->redis()->del("lock:$shopId");
        } catch (Throwable $e) {
            $this->logger->warning("[WARN] Failed to release lock for Shop $shopId: {$e->getMessage()}");
        }
    }

    /**
     * Updates the current cache version for a specific shop.
     *
     * @param int $shopId The ID of the shop.
     * @param string $version The new version to set.
     * @throws RuntimeException|Throwable If there is an error updating the version.
     */
    private function updateCurrentVersion(int $shopId, string $version): void
    {
        if (!$this->redis()->set("current_cache_version:$shopId", $version)) {
            //Don't stop the import if one shop key fails, just only log the error
            $this->logger->error("Failed to update current version for Shop {$shopId}: {$version}");
        }
    }

    /**
     * Manages the version history for a specific shop, keeping only the latest versions.
     *
     * @param int $shopId The ID of the shop.
     * @param string $newVersion The new version to add to the history.
     * @throws Throwable
     */
    private function manageVersionHistory(int $shopId, string $newVersion): void
    {
        $versionListKey = "price_versions:$shopId";

        $this->redis()->lPush($versionListKey, $newVersion);
        $allVersions = $this->redis()->lRange($versionListKey, 0, -1);
        $this->redis()->lTrim($versionListKey, 0, self::MAX_VERSIONS - 1);
        $versionsToDelete = array_slice($allVersions, self::MAX_VERSIONS);

        foreach ($versionsToDelete as $oldVersion) {
            $this->deleteVersionKeys($oldVersion, $shopId);
        }
    }

    /**
     * Deletes all keys associated with a specific version for a shop.
     *
     * @param string $version The version to delete.
     * @param int $shopId The ID of the shop.
     * @throws Throwable
     */
    private function deleteVersionKeys(string $version, int $shopId): void
    {
        $pattern = "v{$version}:*:$shopId:*";
        $cursor = null;
        do {
            $batch = $this->redis()->scan($cursor, $pattern, 500);
            if ($batch) {
                $this->redis()->del(...$batch);
            }
        } while ($cursor !== 0);
    }

    /**
     * Returns the Redis client instance.
     *
     * This method initializes the Redis client if it has not been created yet.
     *
     * @return Redis The Redis client instance.
     * @throws Throwable
     */
    private function redis(): Redis
    {
        if ($this->redis === null) {
            $this->redis = $this->redisClientService->get();
        }
        return $this->redis;
    }
}
