<?php

namespace AppBundle\Command;

use AppBundle\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Geocoder\Collection;
use Geocoder\Model\Coordinates;
use Geocoder\Query\GeocodeQuery;
use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Stopwatch\Stopwatch;
use Symfony\Component\Validator\Validator\ValidatorInterface;

/**
 * Geocode the user's personal location, using BazingaGeocoderBundle.
 * 
 * @author Bastien Gatellier <contact@bgatellier.fr>
 */
class GeocodeUserLocationCommand extends ContainerAwareCommand
{
    /**
     * 
     */
    protected function configure()
    {
        $this
            ->setName('cgenial:user:geocode')
            ->setDescription("
                Update the user personal coordinates, according to their location, using Google Maps.
                Only users who do not have their coordinates not set will be updated. You can force this behavior.")
            ->addArgument('provider', InputArgument::REQUIRED, 'BazingaGeocoderBundle provider name.')
            ->addArgument('max', InputArgument::REQUIRED, 'Maximum number of users that you want to geocode. Set it to 0 if you want to  include every users.')
            ->addOption('force', null, InputOption::VALUE_NONE, 'Force the geocoding of users who already have their coordinates set.')
        ;
    }

    /**
     *
     * @param InputInterface $input
     * @param OutputInterface $output
     * @throws \Doctrine\ORM\ORMException
     * @throws \Doctrine\ORM\OptimisticLockException
     */
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $container = $this->getContainer();

        $em = $container->get('doctrine.orm.entity_manager');
        $geocoder = $container->get('bazinga_geocoder.provider.' . $input->getArgument('provider'));
        $qtyUpdated = 0;
        $stopwatch = new Stopwatch();
        $validator = $container->get('validator');

        $users = $this->findUsers($em, $validator, $input->getArgument('max'), $input->getOption('force'));

        $progress = new ProgressBar($output, count($users));

        // Prevent Google Maps request usage limits for free accounts (50 every 1 second)
        // @see https://developers.google.com/maps/documentation/geocoding/usage-limits
        // This is purely theorical, because it assumes that the code takes 0 seconds to execute.
        $chunks = array_chunk($users, 50);

        $progress->start();

        foreach ($chunks as $chunk) {
            $stopwatch->start('chunk');

            foreach ($chunk as $user) {
                $geocodeQuery = $this->createGeocodeQuery($user);
        
                $adresses = $geocoder->geocodeQuery($geocodeQuery);

                $isUserUpdated = $this->updateUserCoordinates($user, $adresses);

                if ($isUserUpdated) {
                    $qtyUpdated = $qtyUpdated + 1;
                    $em->persist($user);
                }

                $progress->advance();
            }
            
            $event = $stopwatch->stop('chunk');

            if ($duration = $event->getDuration() < 1000) {
                usleep((1000 - $duration) * 1000);
            }
        }

        $progress->finish();
        $output->writeln('');

        if ($qtyUpdated > 0) {
            $output->writeln('Saving to database...');
            $em->flush();
        }

        $output->writeln(count($users) . ' user(s) geocoded. ' . $qtyUpdated . ' user(s) updated.');
    }
    
    /**
     * Find geocodable users from the DB, where the personalAddress and personalZipcode properties
     * has been validated using the Symfony validator.
     * The property validation is needed because the validation was absent
     * from the beginning. It means that some user accounts could have bad formatted data.
     * 
     * @param EntityManagerInterface $em
     * @param ValidatorInterface $validator
     * @param int $max
     * @param bool $force
     * @return array List of users
     */
    private function findUsers(
        EntityManagerInterface $em,
        ValidatorInterface $validator,
        int $max = 0,
        bool $force = false
    ): array {
        $qb = $em->createQueryBuilder();

        // Find users from the database where the personal address or zipcode is set
        $qb->select('u')
            ->from(User::class, 'u')
            ->where(
                $qb->expr()->orX(
                    $qb->expr()->isNotNull('u.personalAddress'),
                    $qb->expr()->isNotNull('u.personalZipcode')
                )
            );

        if (!$force) {
            $qb->andWhere($qb->expr()->isNull('u.personalLatitude'))
                ->andWhere($qb->expr()->isNull('u.personalLongitude'));
        }

        if ($max > 0) {
            $qb->setMaxResults($max);
        }

        $users = $qb->getQuery()->getResult();
        
        // Keep the users were the Symfony validation is ok for the 2 fields
        return array_filter($users, function (User $user) use ($validator) {
            $addressViolations = $validator->validateProperty($user, 'personalAddress', 'profile');
            $zipcodeViolations = $validator->validateProperty($user, 'personalZipcode', 'profile');

            return 0 === count($addressViolations) && 0 === count($zipcodeViolations);
        });
    }

    /**
     * Format the query for the geocoding service
     *
     * @param User $user
     * @return GeocodeQuery 
     */
    private function createGeocodeQuery(User $user): GeocodeQuery
    {
        $address = $user->getPersonalAddress();

        $location = $address . ($address ? ', ' : '') . $user->getPersonalZipcode();

        return GeocodeQuery::create($location)->withLocale('fr');
    }

    /**
     * Update the user personal coordinates, from a list of geocoded adresses
     *
     * @param User $user
     * @param Collection $adresses List of geocoded adresses
     * @return bool Indicates if the user has been updated
     */
    private function updateUserCoordinates(User &$user, Collection $adresses): bool
    {
        $isUpdated = false;

        if (!$adresses->isEmpty()) {
            // Assume the first address is the one we need
            $coordinates = $adresses->first()->getCoordinates();
            
            // Update the user with the latitude and longitude found
            if ($coordinates instanceof Coordinates) {
                $user->setPersonalLatitude($coordinates->getLatitude());
                $user->setPersonalLongitude($coordinates->getLongitude());

                $isUpdated = true;
            }
        }

        return $isUpdated;
    }
}
