<?php

namespace AppBundle\Entity;

use AppBundle\Util;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\Query\Expr;

class TravelPeriodRepository extends \Doctrine\ORM\EntityRepository
{
    private $departureRepository;

    function __construct(EntityManager $em, ClassMetadata $class)
    {
        parent::__construct($em, $class);
        $this->departureRepository = $this->getEntityManager()->getRepository('AppBundle:TravelDeparturePoint');
    }

    /**
     * @param \DateTime $startDate
     * @param \DateTime $endDate
     * @param null $destinationIds
     * @param bool $combi
     *
     * @return TravelProgram[]|array
     * @throws \Exception
     * @internal param null $destination
     */
    public function getTravelProgramsWithTravelDatesForTimePeriod($startDate, $endDate, $destinationIds = null,
      $combi = false)
    {
        // Idea for natural sort problem:
        // Add new column sortable_name

        $now = new \DateTime();
        if ($startDate < $now)
        {
            $startDate = $now;
        }
        $startDateStr = $startDate->format('Y-m-d');
        if ($endDate)
        {
            $endDateStr = $endDate->format('Y-m-d');
        }

        $qb = $this->getEntityManager()->createQueryBuilder()
          ->from('AppBundle:TravelProgram', 'tp', 'tp.id')
          ->addSelect('tp')
          ->where('tp.status > 0');

        // Limit time period for seasons and travel dates
        $qb->innerJoin('tp.periods', 'p');
        $qb->addSelect('p');
        $qb->innerJoin('p.dates', 'd');
        $qb->addSelect('d');
        $qb->where("((p.isSeason = 0 AND d.startDate >= '$startDateStr' AND d.endDate <= '$endDateStr') OR".
          " (p.isSeason = 1 AND d.endDate >= '$startDateStr' AND".
          " DATE_ADD(d.startDate, tp.programDuration, 'DAY') <= '$endDateStr' AND p.status > 0))");

        // Prices
        // Instead of a single join to prices we add one join per price type. This reduces the execution time by
        //  150ms on the development system
        $priceTypes = $this->getEntityManager()->getRepository('AppBundle:TravelPeriodPriceType')->findAll();
        foreach ($priceTypes as $priceType)
        {
            $priceTypeKey = 'price_'. $priceType->getId();
            $qb->leftJoin('p.prices', $priceTypeKey, Expr\Join::WITH,
              $priceTypeKey .'.priceType = '. $priceType->getId(), $priceTypeKey .'.priceTypeId');
            $qb->addSelect($priceTypeKey);
        }
        $qb->leftJoin('p.discounts', 'discount');
        $qb->addSelect('discount');

        $qb->leftJoin('p.departures', 'p_dep', Expr\Join::WITH, 'tp.programType = '.
          TravelProgram::MEDIATED_PROGRAM_TYPE);
        $qb->addSelect('p_dep');

        // Destinations
        if (!empty($destinationIds) && is_array($destinationIds))
        {
            //$qb->innerJoin('AppBundle:TravelProgramCountry', 'tpc',  Expr\Join::WITH,
            //  'tpc.program = tp AND IDENTITY(tpc.country) IN ('. implode(', ', $destinationIds) .')');
            $qb->innerJoin('tp.countries', 'tc', Expr\Join::WITH,
                'tc.id IN ('. implode(', ', $destinationIds) .')');
            if ($combi)
            {
                //$qb->having('COUNT(DISTINCT tpc.country) = '. count($destinationIds));
                $qb->having('COUNT(DISTINCT tc) = '. count($destinationIds));
            }
        }
        $qb->groupBy('p.id, p_dep.id, d.id');

        // Travel class
        $qb->innerJoin('p.class', 'cls', Expr\Join::WITH, 'cls.standard = 1');

        // Image
        $qb->leftJoin('tp.images', 'tp_image', Expr\Join::WITH, 'tp_image.type = 2')
          ->addSelect('tp_image');

        // Sort travel programs
        $qb->addSelect('COALESCE(tp.position, 0) as HIDDEN position_sort_key');
        $qb->orderBy('position_sort_key');
        //$qb->addOrderBy('LENGTH(tp.title)'); // Emulate natural sort
        $qb->addOrderBy('tp.title');
        $qb->addOrderBy('tp.id');
        // Sort: Real travel dates have higher priority than virtual travel dates. Sort travel programs
        $qb->addOrderBy('p.isSeason, p.id');

        /** @var TravelProgram[]|array $travelPrograms */
        $travelPrograms = $qb->getQuery()->getResult();

        if (empty($travelPrograms))
        {
            return $travelPrograms;
        }

        // Collect arrival point IDs for non mediated travel programs
        $isUsedArrivalPointById = [];
        foreach ($travelPrograms as $travelProgram)
        {
            $isUsedTravelProgramById[$travelProgram->getId()] = true;
            if (!$travelProgram->getIsMediated())
            {
                $isUsedArrivalPointById[$travelProgram->getTravelArrivalPoint()->getId()] = true;
            }
        }
        $usedArrivalPointIds = array_keys($isUsedArrivalPointById);

        // Find flight periods and related departures
        $flightPeriods = empty($isUsedArrivalPointById) ? []
            : $this->getEntityManager()->getRepository('AppBundle:FlightPeriod')
                ->getIndexedFlightPeriodsForTimePeriod($startDate, $endDate, $usedArrivalPointIds);

        // Find default departures and classify by-program or by-arrival-point
        // We could've simply left joined them to get an equal result. But we're reducing the number of rows returned
        //  in the first travel program query above drastically with this solution. This doesn't hurt performance.
        // Of course, we have to link departures of travel programs manually later.
        $defDepsByArrivalPointId = [];
        $qb = $this->getEntityManager()->createQueryBuilder()
          ->from('AppBundle:TravelDeparturePoint', 'dep')
          ->addSelect('dep')
          ->where($qb->expr()->in('IDENTITY(dep.program)', array_keys($travelPrograms)));
        if (!empty($isUsedArrivalPointById))
        {
            $qb->orWhere($qb->expr()->in('IDENTITY(dep.travelArrivalPoint)', $usedArrivalPointIds));
        }
        /** @var TravelDeparturePoint[]|array $defaultDepartures */
        $defaultDepartures = $qb->getQuery()->execute();
        foreach ($defaultDepartures as $defaultDeparture)
        {
            if ($defaultDeparture->getProgram())
            {
                $travelProgram = $travelPrograms[$defaultDeparture->getProgram()->getId()];
                if ($travelProgram->getDepartures() instanceof PersistentCollection)
                {
                    Util::reAttachRelatedCollection($travelProgram, 'departures', $travelProgram->getDepartures()->unwrap());
                }
                $travelProgram->addDeparture($defaultDeparture);
            }
            elseif ($defaultDeparture->getTravelArrivalPoint())
            {
                if (!isset($defDepsByArrivalPointId[$defaultDeparture->getTravelArrivalPoint()->getId()]))
                {
                    $defDepsByArrivalPointId[$defaultDeparture->getTravelArrivalPoint()->getId()] = [];
                }
                $defDepsByArrivalPointId[$defaultDeparture->getTravelArrivalPoint()->getId()][] = $defaultDeparture;
            }
        }

        foreach ($travelPrograms as $k => $travelProgram)
        {
            $flightPeriod = null;
            if (!$travelProgram->getIsMediated())
            {
                $arrivalPointId = $travelProgram->getTravelArrivalPoint()->getId();
                // Manually link separately fetched default departures
                $travelProgram->getTravelArrivalPoint()->__setDepartures(
                  isset($defDepsByArrivalPointId[$arrivalPointId])
                    ? $defDepsByArrivalPointId[$arrivalPointId]
                    : []
                );
            }

            $this->addTravelDatesToProgram($travelProgram, $travelProgram->getPeriods(), $flightPeriods,
              $startDate, $endDate);

            if (!$travelProgram->hasTravelDates())
            {
                unset($travelPrograms[$k]);
            }
        }

        return $travelPrograms;
    }

    private function createTravelDateKey(\DateTime $startDate, \DateTime $endDate)
    {
        return $startDate->format('Y-m-d') . $endDate->format('Y-m-d');
    }

    const TD_QUERY_NON_VIRTUAL = 0x1;
    const TD_QUERY_VIRTUAL = 0x2;
    const TD_QUERY_INACTIVE = 0x4;
    const TD_QUERY_OUTDATED = 0x8;
    const TD_QUERY_ACP = self::TD_QUERY_INACTIVE | self::TD_QUERY_OUTDATED;
    const TD_QUERY_VIRTUAL_AND_NON_VIRTUAL = self::TD_QUERY_VIRTUAL | self::TD_QUERY_NON_VIRTUAL;

    /**
     * @param TravelProgram $program
     * @param bool $class
     * @param int $flags
     *
     * @return TravelDate[]|array
     * @todo Find a more appropriate name for this method
     */
    public function getTrueTravelPeriods(TravelProgram $program, $class = false, $flags =
        self::TD_QUERY_VIRTUAL_AND_NON_VIRTUAL)
    {
        if (!($flags & self::TD_QUERY_VIRTUAL_AND_NON_VIRTUAL))
        {
            return [];
        }
        $doQueryVirtualAndNonVirtual = $flags & self::TD_QUERY_VIRTUAL_AND_NON_VIRTUAL ==
          self::TD_QUERY_VIRTUAL_AND_NON_VIRTUAL;

        /** @var TravelPeriod[] $periods */
        $qb = $this->createQueryBuilder('p');
        $qb
          //->from('AppBundle:TravelPeriod', 'tpp', 'key')
          ->leftJoin('p.dates', 'd')->addSelect('d')
          ->leftJoin('p.prices', 'price', null, null, 'price.priceTypeId')
          //->innerJoin('price.priceType', 'price_type_')
          ->addSelect('price')
          ->leftJoin('p.discounts', 'discount', Expr\Join::WITH,
            'discount.start <= CURRENT_TIMESTAMP() AND discount.end >= CURRENT_TIMESTAMP()')->addSelect('discount')
          ->where('IDENTITY(p.program) = '. $program->getId())
        ;

        if ($program->getIsMediated())
        {
            // Only mediated travel programs define departures in travelPeriods
            $qb->leftJoin('p.departures', 'p_dep')->addSelect('p_dep');
        }
        elseif (!($flags & self::TD_QUERY_VIRTUAL))
        {
            // Retrieving all flight periods by join is only possible, if virtual entries are excluded
            $qb->leftJoin('AppBundle:FlightPeriod', 'fp', Expr\Join::WITH, 'IDENTITY(fp.travelArrivalPoint) = '.
                ':travelArrivalPointId AND d.startDate = fp.startDate AND d.endDate = fp.endDate');
            $qb->setParameter('travelArrivalPointId', $program->getTravelArrivalPoint()->getId());
            $qb->addSelect('fp');
            $qb->leftJoin('fp.departures', 'fp_dep')->addSelect('fp_dep');
        }

        $startDate = null;
        if (!($flags & self::TD_QUERY_OUTDATED))
        {
            $startDate = new \DateTime('tomorrow');
            $qb->andWhere("((p.isSeason = 0 AND d.startDate >= CURRENT_TIMESTAMP()) OR".
              " (p.isSeason = 1 AND d.endDate >= CURRENT_TIMESTAMP()))");
        }
        if ($class)
        {
            $qb->andWhere($qb->expr()->eq('IDENTITY(tpp.class)', $class));
        }

        if ($doQueryVirtualAndNonVirtual)
        {
            $qb->addOrderBy('p.isSeason');
        }
        else
        {
            $qb->andWhere($qb->expr()->eq('p.isSeason', $flags & self::TD_QUERY_VIRTUAL));

            if(!($flags & self::TD_QUERY_INACTIVE))
            {
                // If both, non virtual and virtual entries are selected, we cannot exclude inactive entries, because
                // there may be a non virtual travel date with inactive status and a matching virtual travel date with
                // active status. In this case, the inactive status would get lost and the virtual travel date would
                // wrongly be included in the result. Therefore we are removing inactive travel dates later in the
                // TD_QUERY_VIRTUAL_AND_NON_VIRTUAL case.
                $qb->andWhere($qb->expr()->gt('tpp.status', 0));
            }
        }

        $qb
          ->addOrderBy('p.order', 'ASC')
          ->addOrderBy('d.startDate', 'ASC')
          ->addOrderBy('p.name', 'ASC')
        ;

        $entities = $qb->getQuery()->execute();

        $flightPeriodByKey = null;
        if (!$program->getIsMediated())
        {
            if (!$program->getTravelArrivalPoint())
            {
                return [];
            }
            if ($flags & self::TD_QUERY_VIRTUAL)
            {
                // If virtual entries are included, we have to fetch all flight periods, because we don't know
                // the actual dates yet
                $flightPeriodByKey = $this->getEntityManager()->getRepository('AppBundle:FlightPeriod')
                    ->getIndexedFlightPeriodsForTimePeriod($startDate, null, $program->getTravelArrivalPoint()->getId());
            }

            foreach ($entities as $key => $entity)
            {
                if ($entity == null)
                {
                    unset($entities[$key]);
                }
                else if ($entity instanceof FlightPeriod)
                {
                    // We are joining to flight period with multiple keys. Doctrine cannot handle this and returns a
                    // mixed list containing both, travel and flight periods at the same level. We fix that here.
                    $flightPeriodByKey[$entity->getStartDate()->format('Y-m-d') .'_'.
                        $entity->getEndDate()->format('Y-m-d')] = $entity;
                    unset($entities[$key]);
                }
            }
            /** @var TravelPeriod $period */
            foreach ($entities as &$period)
            {
                foreach ($period->getDates() as &$date)
                {
                    $fpKey = $date->getStartDate()->format('Y-m-d') . $date->getEndDate()->format('Y-m-d') .
                        $program->getTravelArrivalPoint()->getId();
                    if (isset($flightPeriodByKey[$fpKey]))
                    {
                        // #TODO Does this cause performance problems?
                        $date->setFlightPeriod($flightPeriodByKey[$fpKey]);
                    }
                }
            }
        }

        $this->addTravelDatesToProgram($program, $entities, $flightPeriodByKey, $startDate, null);
        return $program->getTravelDates();
    }

    private $currencyFactor = null;

    public function getCurrencyFactor()
    {
        if ($this->currencyFactor == null)
        {
            // #TODO Enable doctrine 2nd level cache instead of implementing own caching mechanism
            $dollar = $this->getEntityManager()->getRepository('AppBundle:TravelSetting')->findOneBy(['key' => 'dollar']);
            if (!$dollar)
            {
                throw new \Exception('Missing currency factor setting "dollar" in table travel_setting');
            }
            $this->currencyFactor = $dollar->getValue() ?? 1;
        }
        return $this->currencyFactor;
    }

    /**
     * @param TravelProgram $travelProgram
     * @param array|TravelPeriod[] $travelPeriods Represent seasons and travel dates
     * @param array|FlightPeriod[] $flightPeriods For performance reasons, $flightPeriods must be pre-fetched
     * @param \DateTime|null $startDate If not null, only add travel dates later than this value
     * @param \DateTime|null $endDate If not null, only add travel dates earlier than this value
     *
     * @throws \Exception
     */
    public function addTravelDatesToProgram(TravelProgram &$travelProgram, $travelPeriods, $flightPeriods,
        \DateTime $startDate = null, \DateTime $endDate = null)
    {
        $currencyFactor = $travelProgram->getNettoPricesInEuro() ? 1 : $this->getCurrencyFactor();

        // #TODO Consider adding travelPeriods to travelProgram in the search algorithm
        //foreach ($travelProgram->getPeriods() as $travelPeriod)
        foreach ($travelPeriods as $travelPeriod)
        {
            if ($travelPeriod->getIsSeason())
            {
                $i = 1;
                foreach ($travelPeriod->getDates() as $travelPeriodDate)
                {
                    $seasonEndDate = clone $travelPeriodDate->getEndDate();
                    $seasonEndDate->modify('+'.$travelProgram->getProgramDuration().' day');
                    if ($endDate != null && $seasonEndDate > $endDate)
                    {
                        // Limit end date to requested latest travel end date
                        $seasonEndDate = clone $endDate;
                    }
                    // Subtract temporarily added days which were added above. (Also subtract if date was limited!)
                    // Add one day to include season end date in $dates array below (would be excluded otherwise)
                    $seasonEndDate->modify('-'. ($travelProgram->getProgramDuration() - 1) .' day');

                    $dates = new \DatePeriod($travelPeriodDate->getStartDate(),
                      \DateInterval::createFromDateString('1 day'), $seasonEndDate);

                    $doTestStartDate = $startDate != null;
                    /** @var \DateTime $date */
                    foreach ($dates as $date)
                    {
                        $isPossibleDate = $travelProgram->getIsAvailWeekday($date->format('w'));

                        // $doTestStartDate helps to improve performance by avoiding unnecessary (more expensive)
                        // "$date < $startDate"  checks: As soon as $date >= $startDate the first time, it will stay
                        // this way until the foreach iteration has finished.
                        if (!($doTestStartDate && $date < $startDate))
                        {
                            $doTestStartDate = false;
                            if ($isPossibleDate)
                            {
                                // #TODO Do we need the travel date key?
                                $travelDateEnd = (clone $date)->modify('+'.$travelProgram->getProgramDuration().' day');
                                $travelDateKey = $this->createTravelDateKey($date, $travelDateEnd);
                                if (!$travelProgram->hasTravelDate($travelDateKey))
                                {
                                    $flightPeriod = null;
                                    if (!$travelProgram->getIsMediated())
                                    {
                                        $flightPeriodKey = $travelDateKey .
                                          $travelProgram->getTravelArrivalPoint()->getId();
                                        $flightPeriod = $flightPeriods[$flightPeriodKey] ?? null;
                                    }
                                    $travelProgram->addTravelDateFromSeasonTravelPeriod(
                                      $travelDateKey,
                                      $travelPeriod,
                                      $travelPeriodDate->getId() . $travelPeriod->getName() . $i,
                                      $date,
                                      $travelDateEnd,
                                      $flightPeriod,
                                      $currencyFactor
                                    );
                                }
                            }
                        }
                        // Also increment $i if the date is theoretically possible but excluded from the search request
                        if ($isPossibleDate)
                        {
                            ++$i;
                        }
                    }
                }
            }
            elseif ($travelProgram->getIsPossibleStartDate($travelPeriod->getStartDate()))
            {
                $travelDateKey = $this->createTravelDateKey($travelPeriod->getStartDate(), $travelPeriod->getEndDate());
                $flightPeriod = null;
                if (!$travelProgram->getIsMediated())
                {
                    $flightPeriod = $flightPeriods[$travelDateKey . $travelProgram->getTravelArrivalPoint()->getId()]
                      ?? null;
                }

                // #TODO There is an error in the old backend which causes duplicates
                if ($travelProgram->hasTravelDate($travelDateKey) &&
                  $travelProgram->getTravelDate($travelDateKey)->__getTravelPeriod()->getId() != $travelPeriod->getId())
                {
                    global $kernel;
                    if($kernel instanceOf \AppCache) $kernel = $kernel->getKernel();
                    $kernel->getContainer()->get('logger')->warn('Duplicate travel period found with name "'.
                      $travelPeriod->getName() .'"');
                }
                else
                {
                    $travelProgram->addTravelDateFromNonSeasonTravelPeriod($travelDateKey, $travelPeriod, $flightPeriod,
                      $currencyFactor);
                }
            }
        }
    }

    /**
     * "Default departures" are taken if a travel period date has no departure itself. For mediated travel programs
     * default departures are defined at travel program level. For self-organized travel programs they are defined
     * at travel-arrival-point (airport) level.
     *
     * @param TravelProgram $program
     * @param bool $acp
     *
     * @return TravelDeparturePoint[]|array|\Doctrine\Common\Collections\Collection|mixed
     */
    public function getDefaultDeparturesByProgram(TravelProgram $program, $acp = false)
    {
        if ($program->getIsMediated())
        {
            return $program->getDepartures();
        }
        if ($program->getTravelArrivalPoint() == null)
        {
            return [];
        }
        $defaultDepartures = $program->getTravelArrivalPoint()->getDepartures();
        if (!$acp)
        {
            $defaultDepartures = $this->departureRepository->limitIndividualArrivalPriceInDepartures(
              $defaultDepartures, $program->getDefaultFlightPrice());
        }
        return $defaultDepartures;
    }
}