<?php

declare(strict_types=1);

/*
 * This file is part of the VitiCore 2.
 *
 * (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\Bridge\Symfony\Bundle\Command;

use CpCreation\VitiCore\Bridge\Symfony\Bundle\Payment\StripePayment;
use CpCreation\VitiCore\Cart\Model\Cart;
use CpCreation\VitiCore\Cart\Model\CartCheckout;
use CpCreation\VitiCore\Cart\Model\PaymentMethod;
use CpCreation\VitiCore\Cart\Repository\CartRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'viti-core:stripe:backfill-transaction-fees',
    description: 'Recharge les frais Stripe reels sur les anciennes commandes payees.'
)]
final class BackfillStripeTransactionFeesCommand extends Command
{
    private const int BATCH_SIZE = 100;

    public function __construct(
        private readonly CartRepository $cartRepository,
        private readonly StripePayment $stripePayment,
        private readonly EntityManagerInterface $em,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Nombre maximal de commandes a mettre a jour. Utiliser 0 pour tout traiter.', '100')
            ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Affiche les commandes modifiables sans ecrire en base.')
            ->addOption('force', null, InputOption::VALUE_NONE, 'Recalcule aussi les commandes qui ont deja un frais Stripe stocke.')
            ->addOption('cart', null, InputOption::VALUE_REQUIRED, 'UUID ou token d\'une commande precise a retraiter.');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $dryRun = (bool) $input->getOption('dry-run');
        $force = (bool) $input->getOption('force');
        $cartFilter = $this->normalizeOptionalString($input->getOption('cart'));
        $limit = $this->parseLimit($input, $io);

        if (null === $limit) {
            return Command::INVALID;
        }

        $io->title('Backfill des frais Stripe');
        $io->definitionList(
            ['Dry run' => $dryRun ? 'oui' : 'non'],
            ['Force' => $force ? 'oui' : 'non'],
            ['Commande ciblee' => $cartFilter ?? 'toutes'],
            ['Limite' => null === $cartFilter ? ($limit > 0 ? (string) $limit : 'aucune') : '1']
        );

        $summary = [
            'inspected' => 0,
            'updated' => 0,
            'skipped' => 0,
            'failed' => 0,
        ];

        if (null !== $cartFilter) {
            $cart = $this->findCartByIdentifier($cartFilter);

            if (!$cart instanceof Cart) {
                $io->error(sprintf('Aucune commande Stripe trouvee pour "%s".', $cartFilter));

                return Command::FAILURE;
            }

            $this->processCart($cart, $io, $dryRun, $force, $summary);

            $this->renderSummary($io, $summary);

            return $summary['failed'] > 0 ? Command::FAILURE : Command::SUCCESS;
        }

        $offset = 0;

        while (true) {
            if ($limit > 0 && $summary['updated'] >= $limit) {
                break;
            }

            $carts = $this->findStripePaidCartsBatch($offset, self::BATCH_SIZE);

            if ([] === $carts) {
                break;
            }

            foreach ($carts as $cart) {
                if ($limit > 0 && $summary['updated'] >= $limit) {
                    break 2;
                }

                $this->processCart($cart, $io, $dryRun, $force, $summary);
            }

            $offset += \count($carts);
        }

        $this->renderSummary($io, $summary);

        return $summary['failed'] > 0 ? Command::FAILURE : Command::SUCCESS;
    }

    /**
     * @param array{inspected: int, updated: int, skipped: int, failed: int} $summary
     */
    private function processCart(
        Cart $cart,
        SymfonyStyle $io,
        bool $dryRun,
        bool $force,
        array &$summary,
    ): void {
        ++$summary['inspected'];

        $checkout = $cart->getCartCheckout();
        $paymentMethod = $cart->getPaymentMethod();

        if (
            !$checkout instanceof CartCheckout
            || !$paymentMethod instanceof PaymentMethod
            || StripePayment::METHOD !== $paymentMethod->getMethod()
            || null === $checkout->getPayedAt()
        ) {
            ++$summary['skipped'];
            $io->comment(sprintf('[skip] %s: commande hors perimetre Stripe paye.', $this->describeCart($cart)));

            return;
        }

        $existingResponse = \is_array($checkout->getResponse()) ? $checkout->getResponse() : [];
        $existingFee = $this->extractStoredTransactionFeeTTC($existingResponse);

        if (null !== $existingFee && !$force) {
            ++$summary['skipped'];
            $io->comment(sprintf(
                '[skip] %s: frais deja presents (%s €).',
                $this->describeCart($cart),
                number_format($existingFee / 100, 2, ',', ' ')
            ));

            return;
        }

        try {
            $patch = $this->buildBackfillPatch($existingResponse, $paymentMethod);
        } catch (\Throwable $exception) {
            ++$summary['failed'];
            $io->warning(sprintf('[fail] %s: %s', $this->describeCart($cart), $exception->getMessage()));

            return;
        }

        $newFee = $this->extractStoredTransactionFeeTTC($patch);
        if (null === $newFee) {
            ++$summary['failed'];
            $io->warning(sprintf('[fail] %s: aucun frais Stripe retourne par l\'API.', $this->describeCart($cart)));

            return;
        }

        if ($dryRun) {
            ++$summary['updated'];
            $io->text(sprintf(
                '[dry-run] %s: %.2f €',
                $this->describeCart($cart),
                $newFee / 100
            ));

            return;
        }

        $checkout->setResponse($this->mergeStripeResponse($existingResponse, $patch));
        $this->em->persist($checkout);
        $this->em->flush();

        ++$summary['updated'];
        $io->success(sprintf(
            '%s: frais Stripe mis a jour a %s €.',
            $this->describeCart($cart),
            number_format($newFee / 100, 2, ',', ' ')
        ));
    }

    /**
     * @param array<string, mixed> $existingResponse
     *
     * @return array<string, mixed>
     */
    private function buildBackfillPatch(array $existingResponse, PaymentMethod $paymentMethod): array
    {
        $sessionId = $this->readStringValue($existingResponse, [
            ['checkout_session_id'],
            ['stripe_checkout_sync', 'checkout_session_id'],
            ['stripe_checkout_init', 'checkout_session_id'],
        ]);

        if (null !== $sessionId && str_starts_with($sessionId, 'cs_')) {
            try {
                $session = $this->stripePayment->retrieveCheckoutSession($sessionId, $paymentMethod);
                $normalizedSession = $this->stripePayment->normalizeCheckoutSession($session);

                if (null !== $this->extractStoredTransactionFeeTTC($normalizedSession)) {
                    return $this->buildStripeSessionPatch($normalizedSession);
                }
            } catch (\Throwable) {
                // Certaines anciennes commandes ont un ancien webhook refund qui a pollué le checkout_session_id.
                // Dans ce cas on bascule sur le payment_intent d'origine.
            }
        }

        $paymentIntentId = $this->stripePayment->extractPaymentIntentId($existingResponse);

        if (null === $paymentIntentId) {
            throw new \RuntimeException('Ni checkout_session_id ni payment_intent Stripe n\'ont ete retrouves.');
        }

        $paymentIntent = $this->stripePayment->retrievePaymentIntent($paymentIntentId, $paymentMethod);
        $normalizedPaymentIntent = $this->stripePayment->normalizePaymentIntent($paymentIntent);

        if (null === $this->extractStoredTransactionFeeTTC($normalizedPaymentIntent)) {
            throw new \RuntimeException('Le payment_intent Stripe ne contient pas de fee exploitable.');
        }

        return $this->buildStripePaymentIntentPatch($normalizedPaymentIntent);
    }

    /**
     * @return list<Cart>
     */
    private function findStripePaidCartsBatch(int $offset, int $batchSize): array
    {
        return $this->cartRepository
            ->createQueryBuilder('cart')
            ->innerJoin(
                'cart.cartCheckout',
                'checkout',
                'WITH',
                'checkout.disabled = FALSE AND checkout.payedAt IS NOT NULL'
            )
            ->addSelect('checkout')
            ->innerJoin('checkout.paymentMethod', 'payment_method')
            ->addSelect('payment_method')
            ->andWhere('payment_method.method = :stripeMethod')
            ->setParameter('stripeMethod', StripePayment::METHOD)
            ->orderBy('checkout.payedAt', 'ASC')
            ->addOrderBy('cart.createdAt', 'ASC')
            ->setFirstResult($offset)
            ->setMaxResults($batchSize)
            ->getQuery()
            ->getResult();
    }

    private function findCartByIdentifier(string $identifier): ?Cart
    {
        $cart = $this->cartRepository->find($identifier);

        if ($cart instanceof Cart) {
            return $cart;
        }

        $cart = $this->cartRepository->findOneBy(['token' => $identifier]);

        return $cart instanceof Cart ? $cart : null;
    }

    /**
     * @param array<string, mixed> $normalizedResponse
     *
     * @return array<string, mixed>
     */
    private function buildStripeSessionPatch(array $normalizedResponse): array
    {
        return $this->mergeStripeResponse($normalizedResponse, [
            'stripe_checkout_sync' => $normalizedResponse,
            'checkout_session_id' => $normalizedResponse['checkout_session_id'] ?? null,
            'payment_intent' => $normalizedResponse['payment_intent'] ?? null,
            'synced_at' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
        ]);
    }

    /**
     * @param array<string, mixed> $normalizedResponse
     *
     * @return array<string, mixed>
     */
    private function buildStripePaymentIntentPatch(array $normalizedResponse): array
    {
        return $this->mergeStripeResponse($normalizedResponse, [
            'stripe_payment_intent_sync' => $normalizedResponse,
            'payment_intent' => $normalizedResponse['payment_intent'] ?? null,
            'synced_at' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
        ]);
    }

    /**
     * @param array<string, mixed> $existing
     * @param array<string, mixed> $patch
     *
     * @return array<string, mixed>
     */
    private function mergeStripeResponse(array $existing, array $patch): array
    {
        return array_replace_recursive($existing, $patch);
    }

    /**
     * @param array<string, mixed> $response
     */
    private function extractStoredTransactionFeeTTC(array $response): ?int
    {
        $candidates = [
            ['transaction_fee_amount'],
            ['stripe_checkout_sync', 'transaction_fee_amount'],
            ['stripe_payment_intent_sync', 'transaction_fee_amount'],
            ['object', 'payment_intent', 'latest_charge', 'balance_transaction', 'fee'],
            ['session', 'payment_intent', 'latest_charge', 'balance_transaction', 'fee'],
            ['raw', 'data', 'object', 'payment_intent', 'latest_charge', 'balance_transaction', 'fee'],
            ['raw', 'data', 'object', 'latest_charge', 'balance_transaction', 'fee'],
            ['raw_payment_intent', 'latest_charge', 'balance_transaction', 'fee'],
        ];

        foreach ($candidates as $path) {
            $value = $this->readNestedValue($response, $path);

            if (\is_int($value)) {
                return $value;
            }

            if (\is_string($value) && '' !== $value && is_numeric($value)) {
                return (int) round((float) $value);
            }
        }

        return null;
    }

    /**
     * @param array<string, mixed> $payload
     * @param list<list<string>> $paths
     */
    private function readStringValue(array $payload, array $paths): ?string
    {
        foreach ($paths as $path) {
            $value = $this->readNestedValue($payload, $path);

            if (\is_string($value) && '' !== $value) {
                return $value;
            }
        }

        return null;
    }

    /**
     * @param array<string, mixed> $payload
     * @param list<string> $path
     */
    private function readNestedValue(array $payload, array $path): mixed
    {
        $value = $payload;

        foreach ($path as $segment) {
            if (!\is_array($value) || !array_key_exists($segment, $value)) {
                return null;
            }

            $value = $value[$segment];
        }

        return $value;
    }

    private function parseLimit(InputInterface $input, SymfonyStyle $io): ?int
    {
        $limit = $input->getOption('limit');

        if (\is_int($limit)) {
            return max(0, $limit);
        }

        if (\is_string($limit) && is_numeric($limit)) {
            $parsedLimit = (int) $limit;

            return $parsedLimit >= 0 ? $parsedLimit : null;
        }

        $io->error('L\'option --limit doit etre un entier positif ou 0.');

        return null;
    }

    private function normalizeOptionalString(mixed $value): ?string
    {
        if (!\is_string($value)) {
            return null;
        }

        $value = trim($value);

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

    private function describeCart(Cart $cart): string
    {
        return sprintf(
            '%s (%s)',
            $cart->getToken() ?: 'commande-sans-token',
            $cart->getId()->toString()
        );
    }

    /**
     * @param array{inspected: int, updated: int, skipped: int, failed: int} $summary
     */
    private function renderSummary(SymfonyStyle $io, array $summary): void
    {
        $io->section('Resume');
        $io->definitionList(
            ['Inspectees' => (string) $summary['inspected']],
            ['Mises a jour' => (string) $summary['updated']],
            ['Ignorees' => (string) $summary['skipped']],
            ['Echecs' => (string) $summary['failed']]
        );
    }
}
