<?php

/*
 * This file is part of the VITICORE2 package.
 *
 * (c) CP Creation <web@cpcreation.fr>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CpCreation\VitiCore\Cart\LegacyOrderImport;

use Doctrine\DBAL\Connection;
use Ramsey\Uuid\Uuid;

final readonly class LegacyOrderImportMappingGenerator
{
    public function __construct(
        private Connection $target,
        private LegacyOrderSourceReader $sourceReader,
    ) {
    }

    /**
     * @return array<string, mixed>
     */
    public function generate(
        Connection $source,
        string $scope,
        string $sourceId,
        string $sourceKey,
    ): array {
        if (!\in_array($scope, ['all', 'paid'], true)) {
            throw new \InvalidArgumentException('Le perimetre doit etre "all" ou "paid".');
        }

        $sourceErrors = $this->sourceReader->validate($source);
        if ([] !== $sourceErrors) {
            throw new \RuntimeException(implode("\n", $sourceErrors));
        }

        if (!$source->isTransactionActive()) {
            throw new \RuntimeException('La source doit etre lue dans une transaction coherente en lecture seule.');
        }

        $sourceLines = $this->sourceReader->linesByCart($source, $scope);
        $sourceOrders = $this->sourceReader->orders($source, $scope);
        $targetVariants = $this->targetVariants();
        $targetPaymentMethods = $this->targetPaymentMethods();
        $targetDeliveryMethods = $this->targetDeliveryMethods();
        [$sourceVariantContext, $variantMappings] = $this->sourceVariantInventory($sourceLines, $targetVariants);

        $sourcePaymentContext = [];
        $sourceDeliveryContext = [];
        $paymentMappings = [];
        $deliveryMappings = [];

        foreach ($sourceOrders as $order) {
            $paid = null !== ($order['payed_at'] ?? null);
            $sourcePaymentMethod = mb_strtolower(mb_trim((string) ($order['source_payment_method'] ?? '')));

            if ('' !== $sourcePaymentMethod) {
                $sourcePaymentContext[$sourcePaymentMethod] ??= ['paid_orders' => 0, 'unpaid_orders' => 0];
                ++$sourcePaymentContext[$sourcePaymentMethod][$paid ? 'paid_orders' : 'unpaid_orders'];
                $matchingMethods = array_filter(
                    $targetPaymentMethods,
                    static fn (array $method): bool => mb_strtolower($method['method']) === $sourcePaymentMethod,
                );
                $paymentMappings[$sourcePaymentMethod] = LegacyOrderImportMapping::UNRESOLVED;

                if (1 === \count($matchingMethods)) {
                    $matchingMethod = array_values($matchingMethods)[0];
                    $paymentMappings[$sourcePaymentMethod] = $matchingMethod['id'];
                }
            }

            $sourceDeliveryMethodId = mb_strtolower(mb_trim((string) ($order['source_delivery_method_id'] ?? '')));
            if ('' === $sourceDeliveryMethodId) {
                continue;
            }

            $sourceDeliveryContext[$sourceDeliveryMethodId] ??= [
                'label' => $this->nullableString($order['shipping_name'] ?? null),
                'orders' => 0,
            ];
            ++$sourceDeliveryContext[$sourceDeliveryMethodId]['orders'];
            $deliveryMappings[$sourceDeliveryMethodId] = isset($targetDeliveryMethods[$sourceDeliveryMethodId])
                ? $sourceDeliveryMethodId
                : LegacyOrderImportMapping::UNRESOLVED;
        }

        ksort($sourceVariantContext);
        ksort($variantMappings);
        ksort($sourcePaymentContext);
        ksort($paymentMappings);
        ksort($sourceDeliveryContext);
        ksort($deliveryMappings);

        return [
            'schema_version' => 1,
            'source' => [
                'source_id' => $sourceId,
                'source_key' => $sourceKey,
                'scope' => $scope,
            ],
            '_instructions' => [
                'Remplacez chaque __A_COMPLETER__ par l UUID cible, par sku:CODE, ou par null pour un detachement historique volontaire.',
                'Les libelles sont informatifs : aucune correspondance n est decidee automatiquement a partir d un nom.',
                'Tant qu une valeur __A_COMPLETER__ subsiste, le preflight et l import sont refuses.',
            ],
            'variants' => $variantMappings,
            'payment_methods' => $paymentMappings,
            'delivery_methods' => $deliveryMappings,
            '_source_variants' => $sourceVariantContext,
            '_target_variants' => $targetVariants,
            '_source_payment_methods' => $sourcePaymentContext,
            '_target_payment_methods' => $targetPaymentMethods,
            '_source_delivery_methods' => $sourceDeliveryContext,
            '_target_delivery_methods' => $targetDeliveryMethods,
        ];
    }

    /**
     * Enrichit un fichier deja genere avec l inventaire courant des variantes et leurs prix,
     * sans modifier les correspondances deja choisies.
     *
     * @param array<string, mixed> $draft
     *
     * @return array<string, mixed>
     */
    public function refreshVariantContext(Connection $source, string $scope, array $draft): array
    {
        if (!\in_array($scope, ['all', 'paid'], true)) {
            throw new \InvalidArgumentException('Le perimetre doit etre "all" ou "paid".');
        }

        if (!$source->isTransactionActive()) {
            throw new \RuntimeException('La source doit etre lue dans une transaction coherente en lecture seule.');
        }

        $targetVariants = $this->targetVariants();
        [$sourceVariantContext, $suggestedMappings] = $this->sourceVariantInventory(
            $this->sourceReader->linesByCart($source, $scope),
            $targetVariants,
        );
        $mappings = $draft['variants'] ?? null;
        if (!\is_array($mappings)) {
            throw new \RuntimeException('La section "variants" du fichier genere est invalide.');
        }

        foreach ($suggestedMappings as $sourceVariantId => $suggestedMapping) {
            if (!\array_key_exists($sourceVariantId, $mappings)) {
                $mappings[$sourceVariantId] = $suggestedMapping;
            }
        }

        $existingSourceContext = $draft['_source_variants'] ?? [];
        $existingSourceContext = \is_array($existingSourceContext) ? $existingSourceContext : [];
        $draft['variants'] = $mappings;
        $draft['_source_variants'] = array_replace($existingSourceContext, $sourceVariantContext);
        $draft['_target_variants'] = $targetVariants;
        ksort($draft['variants']);
        ksort($draft['_source_variants']);

        return $draft;
    }

    /**
     * @param array<string, mixed> $draft
     */
    public function write(string $path, array $draft): void
    {
        if (file_exists($path) || is_link($path)) {
            throw new \RuntimeException(\sprintf('Le fichier de correspondance "%s" existe deja et ne sera pas ecrase.', $path));
        }

        $directory = \dirname($path);
        if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
            throw new \RuntimeException(\sprintf('Impossible de creer le dossier "%s".', $directory));
        }

        if (!is_writable($directory)) {
            throw new \RuntimeException(\sprintf('Le dossier "%s" n est pas accessible en ecriture.', $directory));
        }

        $temporaryPath = $this->writeTemporaryFile($directory, $draft);

        try {
            if (!@link($temporaryPath, $path)) {
                if (file_exists($path)) {
                    throw new \RuntimeException(\sprintf('Le fichier de correspondance "%s" existe deja et ne sera pas ecrase.', $path));
                }

                throw new \RuntimeException(\sprintf('Impossible de finaliser le fichier de correspondance "%s".', $path));
            }
        } finally {
            if (file_exists($temporaryPath)) {
                unlink($temporaryPath);
            }
        }
    }

    /**
     * @return array<string, mixed>
     */
    public function readDraft(string $path): array
    {
        if (!is_file($path) || !is_readable($path)) {
            throw new \RuntimeException(\sprintf('Le fichier de correspondance "%s" est introuvable ou illisible.', $path));
        }

        $contents = file_get_contents($path);
        if (false === $contents) {
            throw new \RuntimeException(\sprintf('Impossible de lire le fichier de correspondance "%s".', $path));
        }

        try {
            $draft = json_decode($contents, true, 512, \JSON_THROW_ON_ERROR);
        } catch (\JsonException $exception) {
            throw new \RuntimeException(\sprintf('Le fichier de correspondance n est pas un JSON valide : %s', $exception->getMessage()), previous: $exception);
        }

        if (!\is_array($draft)) {
            throw new \RuntimeException('Le fichier de correspondance doit contenir un objet JSON.');
        }

        return $draft;
    }

    /**
     * Remplace explicitement un fichier genere pendant une session interactive.
     *
     * @param array<string, mixed> $draft
     */
    public function replace(string $path, array $draft): void
    {
        if (!is_file($path) || is_link($path)) {
            throw new \RuntimeException(\sprintf('Le fichier de correspondance "%s" doit etre un fichier ordinaire existant.', $path));
        }

        $directory = \dirname($path);
        if (!is_writable($directory)) {
            throw new \RuntimeException(\sprintf('Le dossier "%s" n est pas accessible en ecriture.', $directory));
        }

        $temporaryPath = $this->writeTemporaryFile($directory, $draft);

        try {
            if (!@rename($temporaryPath, $path)) {
                throw new \RuntimeException(\sprintf('Impossible de mettre a jour le fichier de correspondance "%s".', $path));
            }
        } finally {
            if (file_exists($temporaryPath)) {
                unlink($temporaryPath);
            }
        }
    }

    /**
     * @param array<string, mixed> $draft
     */
    private function writeTemporaryFile(string $directory, array $draft): string
    {
        $json = json_encode(
            $draft,
            \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR,
        )."\n";
        $temporaryPath = tempnam($directory, '.legacy-order-map-');

        if (false === $temporaryPath) {
            throw new \RuntimeException(\sprintf('Impossible de preparer le fichier dans "%s".', $directory));
        }

        try {
            if (false === file_put_contents($temporaryPath, $json, \LOCK_EX)) {
                throw new \RuntimeException('Impossible d ecrire le fichier de correspondance temporaire.');
            }

            if (!chmod($temporaryPath, 0640)) {
                throw new \RuntimeException('Impossible de proteger le fichier de correspondance temporaire.');
            }

            return $temporaryPath;
        } catch (\Throwable $exception) {
            if (file_exists($temporaryPath)) {
                unlink($temporaryPath);
            }

            throw $exception;
        }
    }

    /**
     * @return array<string, array{label: string, sku: ?string, price: ?int, price_discount: ?int}>
     */
    private function targetVariants(): array
    {
        $rows = $this->target->fetchAllAssociative(<<<'SQL'
            SELECT
                variant.id,
                COALESCE(variant.sku, product.sku) AS sku,
                COALESCE(
                    (SELECT translation.name FROM product_product_translation translation
                        WHERE translation.translatable_id = product.id AND translation.locale = 'fr' LIMIT 1),
                    (SELECT translation.name FROM product_product_translation translation
                        WHERE translation.translatable_id = product.id ORDER BY translation.locale LIMIT 1),
                    'Produit sans nom'
                ) AS product_name,
                COALESCE(
                    (SELECT translation.quality FROM product_product_translation translation
                        WHERE translation.translatable_id = product.id AND translation.locale = 'fr' LIMIT 1),
                    (SELECT translation.quality FROM product_product_translation translation
                        WHERE translation.translatable_id = product.id ORDER BY translation.locale LIMIT 1)
                ) AS product_quality,
                COALESCE(
                    (SELECT translation.name FROM product_type_translation translation
                        WHERE translation.translatable_id = product_type.id AND translation.locale = 'fr' LIMIT 1),
                    (SELECT translation.name FROM product_type_translation translation
                        WHERE translation.translatable_id = product_type.id ORDER BY translation.locale LIMIT 1)
                ) AS type_name,
                product_type.volume,
                product_type.unity,
                current_price.price,
                current_price.price_discount
            FROM product_variant variant
            INNER JOIN product_product product ON product.id = variant.product_id
            LEFT JOIN product_type product_type ON product_type.id = variant.product_type_id
            LEFT JOIN product_price current_price ON current_price.id = (
                SELECT selected_price.id
                FROM product_price selected_price
                INNER JOIN product_price_group price_group ON price_group.id = selected_price.group_id
                WHERE selected_price.variant_id = variant.id
                    AND selected_price.deleted_at IS NULL
                    AND price_group.by_default = TRUE
                    AND price_group.deleted_at IS NULL
                ORDER BY price_group.id, selected_price.id
                LIMIT 1
            )
            WHERE variant.deleted_at IS NULL AND product.deleted_at IS NULL
            ORDER BY variant.id
            SQL);
        $variants = [];

        foreach ($rows as $row) {
            $id = mb_strtolower((string) $row['id']);
            $variants[$id] = [
                'label' => $this->variantLabel($row),
                'sku' => $this->nullableString($row['sku'] ?? null),
                'price' => $this->nullableMoney($row['price'] ?? null),
                'price_discount' => $this->nullableMoney($row['price_discount'] ?? null),
            ];
        }

        ksort($variants);

        return $variants;
    }

    /**
     * @param array<string, list<array<string, mixed>>>                                            $sourceLines
     * @param array<string, array{label: string, sku: ?string, price: ?int, price_discount: ?int}> $targetVariants
     *
     * @return array{
     *     array<string, array{label: string, orders: int, lines: int, quantity: int, prices: list<int>}>,
     *     array<string, string>
     * }
     */
    private function sourceVariantInventory(array $sourceLines, array $targetVariants): array
    {
        $contexts = [];
        $mappings = [];

        foreach ($sourceLines as $sourceCartId => $lines) {
            foreach ($lines as $line) {
                $sourceVariantId = mb_strtolower(mb_trim((string) ($line['source_variant_id'] ?? '')));
                if (!Uuid::isValid($sourceVariantId)) {
                    throw new \RuntimeException('Une ligne source ne contient pas un UUID de variante valide.');
                }

                $contexts[$sourceVariantId] ??= [
                    'label' => $this->sourceVariantLabel($line),
                    'orders' => [],
                    'lines' => 0,
                    'quantity' => 0,
                    'prices' => [],
                ];
                $contexts[$sourceVariantId]['orders'][$sourceCartId] = true;
                ++$contexts[$sourceVariantId]['lines'];
                $contexts[$sourceVariantId]['quantity'] += (int) ($line['quantity'] ?? 0);
                $price = $this->nullableMoney($line['price'] ?? null);
                if (null !== $price) {
                    $contexts[$sourceVariantId]['prices'][$price] = $price;
                }
                $mappings[$sourceVariantId] = isset($targetVariants[$sourceVariantId])
                    ? $sourceVariantId
                    : LegacyOrderImportMapping::UNRESOLVED;
            }
        }

        foreach ($contexts as &$context) {
            $context['orders'] = \count($context['orders']);
            sort($context['prices'], \SORT_NUMERIC);
        }
        unset($context);

        ksort($contexts);
        ksort($mappings);

        return [$contexts, $mappings];
    }

    /**
     * @return list<array{id: string, method: string}>
     */
    private function targetPaymentMethods(): array
    {
        $rows = $this->target->fetchAllAssociative(<<<'SQL'
            SELECT id, method
            FROM cart_payment_method
            WHERE disabled = FALSE AND deleted_at IS NULL
            ORDER BY method, id
            SQL);

        return array_map(static fn (array $row): array => [
            'id' => (string) $row['id'],
            'method' => (string) $row['method'],
        ], $rows);
    }

    /**
     * @return array<string, array{label: ?string}>
     */
    private function targetDeliveryMethods(): array
    {
        $rows = $this->target->fetchAllAssociative(<<<'SQL'
            SELECT
                delivery_method.id,
                COALESCE(
                    (SELECT translation.name FROM shop_delivery_method_translation translation
                        WHERE translation.translatable_id = delivery_method.id AND translation.locale = 'fr' LIMIT 1),
                    (SELECT translation.name FROM shop_delivery_method_translation translation
                        WHERE translation.translatable_id = delivery_method.id ORDER BY translation.locale LIMIT 1)
                ) AS name
            FROM shop_delivery_method delivery_method
            WHERE delivery_method.deleted_at IS NULL
            ORDER BY delivery_method.id
            SQL);
        $methods = [];

        foreach ($rows as $row) {
            $methods[mb_strtolower((string) $row['id'])] = [
                'label' => $this->nullableString($row['name'] ?? null),
            ];
        }

        ksort($methods);

        return $methods;
    }

    /**
     * @param array<string, mixed> $row
     */
    private function sourceVariantLabel(array $row): string
    {
        return $this->variantLabel([
            'product_name' => $row['product_name'] ?? null,
            'product_quality' => $row['product_quality'] ?? null,
            'type_name' => $row['type_name'] ?? null,
            'volume' => $row['volume'] ?? null,
            'unity' => $row['unity'] ?? null,
        ]);
    }

    /**
     * @param array<string, mixed> $row
     */
    private function variantLabel(array $row): string
    {
        $product = mb_trim(implode(' ', array_filter([
            $this->nullableString($row['product_name'] ?? null),
            $this->nullableString($row['product_quality'] ?? null),
        ])));
        $type = $this->nullableString($row['type_name'] ?? null);
        $volume = $this->nullableString($row['volume'] ?? null);
        $unity = $this->nullableString($row['unity'] ?? null);
        $format = mb_trim(implode(' ', array_filter([$volume, $unity])));
        $suffix = mb_trim(implode(' ', array_filter([$type, '' === $format ? null : '('.$format.')'])));

        if ('' === $product) {
            $product = 'Produit sans nom';
        }

        return '' === $suffix ? $product : $product.' - '.$suffix;
    }

    private function nullableString(mixed $value): ?string
    {
        if (null === $value || !\is_scalar($value)) {
            return null;
        }

        $value = mb_trim((string) $value);

        return '' === $value ? null : $value;
    }

    private function nullableMoney(mixed $value): ?int
    {
        if (null === $value || '' === $value) {
            return null;
        }

        if (!is_numeric($value)) {
            throw new \RuntimeException('Un prix de variante doit etre un montant entier en centimes.');
        }

        $number = (float) $value;
        $amount = (int) $number;
        if (!is_finite($number) || $number < 0.0 || (float) $amount !== $number) {
            throw new \RuntimeException('Un prix de variante doit etre un montant entier positif en centimes.');
        }

        return $amount;
    }
}
