<?php
declare(strict_types=1);

namespace FCA\StockApi\Collection;

use FCA\StockApi\Api;
use FCA\StockApi\Collection\Filter\Builder\Validator;
use FCA\StockApi\Document\DistinctValue;
use FCA\StockApi\Exception\ApiException;

class Offer
{
    /**
     * @var \MongoDB\Collection
     */
    protected $offerCollection;

    /**
     * Offer constructor.
     * @param Api $api
     * @throws ApiException
     */
    public function __construct(Api $api)
    {
        if ($api->getCollection() === null) {
            throw new ApiException('Database collection is missing!');
        }

        $this->offerCollection = $api->getCollection();
    }

    /**
     * @param array $filters
     * @param array|null $sort
     * @param int|null $limit
     * @param int|null $skip
     * @return \FCA\StockApi\Document\Offer[]
     * @throws ApiException
     */
    public function find(array $filters = [], ?array $sort = null, ?int $limit = null, ?int $skip = null): array
    {
        Validator::validateFilter($filters);

        $options = [
            'collation' => ['locale' => 'pl'],
        ];

        // When using `aggregate`, the `$near` condition must be moved out from filters to aggregation stage:
        list($dealer_coordinates_filter, $filters) = self::extractDealerCoordinatesFilter($filters);

        if ($sort === ['random' => true]) {
            if ($limit === null) {
                throw new ApiException('Limit must be defined in random find!');
            }
            $pipeline = [
                [
                    '$match' => (object)$filters
                ],
                [
                    '$sample' => [
                        'size' => $limit
                    ]
                ],
            ];
            if ($dealer_coordinates_filter) {
                array_unshift($pipeline, [
                    '$geoNear' => [
                        'near' => $dealer_coordinates_filter['$geometry'],
                        'maxDistance' => $dealer_coordinates_filter['$maxDistance'],
                        'distanceField' => 'dealer.distance',
                        'spherical' => true,
                    ],
                ]);
            }

            $docs = $this->offerCollection->aggregate($pipeline, $options);
        } else {
            $pipeline = [
                [
                    '$match' => (object)$filters
                ]
            ];
            if ($dealer_coordinates_filter) {
                array_unshift($pipeline, [
                    '$geoNear' => [
                        'near' => $dealer_coordinates_filter['$geometry'],
                        'maxDistance' => $dealer_coordinates_filter['$maxDistance'],
                        'distanceField' => 'dealer.distance',
                        'spherical' => true,
                    ],
                ]);
            }

            if (array_key_exists('isInvenduto', (array) $sort)) {
                $pipeline[] = [
                    '$addFields' => [
                        'isInvenduto' => [
                            '$cond' => [
                                'if' => '$dealer',
                                'then' => false,
                                'else' => true,
                            ]
                        ]
                    ]
                ];
            }
            if ($sort) {
                if (!Validator::isValidSortArray($sort)) {
                    throw new ApiException('Sort array is invalid!');
                }
                $pipeline[] = [
                    '$sort' => $sort
                ];
            }
            if ($skip) {
                $pipeline[] = [
                    '$skip' => $skip
                ];
            }
            if ($limit) {
                $pipeline[] = [
                    '$limit' => $limit
                ];
            }

            $docs = $this->offerCollection->aggregate(
                $pipeline,
                $options
            );
        }

        $offers = [];

        foreach ($docs as $doc) {
            $offers[] = new \FCA\StockApi\Document\Offer((array)$doc);
        }

        $docs = null;
        unset($docs);

        return $offers;
    }

    public function findOne(array $filters = [], ?array $sort = null, ?int $skip = null): ?\FCA\StockApi\Document\Offer
    {
        Validator::validateFilter($filters);

        $options = [];
        if ($sort != [] and $sort !== null) {
            if (!Validator::isValidSortArray($sort)) {
                throw new ApiException('Sort array is invalid!');
            }

            $options['collation'] = ['locale' => 'pl'];
            $options['sort'] = $sort;
        }

        if ($skip !== null) {
            if (is_int($skip)) {
                $options['skip'] = $skip;
            } else {
                throw new ApiException('Skip must be integer!');
            }
        }

        if ($sort === ['random' => true]) {
            $doc = $this->offerCollection->aggregate(
                [
                    [
                        '$match' => (object) $filters
                    ],
                    [
                        '$sample' => [
                            'size' => 1
                        ]
                    ],
                ]
            );

            if (isset($doc[0])) {
                $doc = $doc[0];
            }
        } else {
            $doc = $this->offerCollection->findOne($filters, $options);
        }

        if (!$doc) {
            return null;
        }

        $offer = new \FCA\StockApi\Document\Offer((array)$doc);

        $doc = null;
        unset($doc);

        return $offer;
    }

    public function findOneById(int $id): ?\FCA\StockApi\Document\Offer
    {
        $doc = $this->offerCollection->findOne(['vehicle_id' => $id]);

        if (!$doc) {
            return null;
        }

        $offer = new \FCA\StockApi\Document\Offer((array)$doc);

        $doc = null;
        unset($doc);

        return $offer;
    }

    /**
     * @return DistinctValue[]
     */
    public function findDistinct(string $field, array $filters = []): array
    {
        Validator::validateFilter($filters);

        // When using `aggregate`, the `$near` condition must be moved out from filters to aggregation stage:
        list($dealer_coordinates_filter, $filters) = self::extractDealerCoordinatesFilter($filters);

        $pipeline = [
            [
                '$match' => (object)$filters
            ],
            [
                '$unwind' => '$' . $field
            ],
            [
                '$group' => [
                    '_id' => '$' . $field,
                    'count' => [
                        '$sum' => 1
                    ]
                ]
            ]
        ];

        if ($dealer_coordinates_filter) {
            array_unshift($pipeline, [
                '$geoNear' => [
                    'near' => $dealer_coordinates_filter['$geometry'],
                    'maxDistance' => $dealer_coordinates_filter['$maxDistance'],
                    'distanceField' => 'dealer.distance',
                    'spherical' => true,
                ],
            ]);
        }

        $differentValues = $this->offerCollection->aggregate($pipeline);

        /**
         * @var $distinctValues DistinctValue[]
         */
        $distinctValues = [];

        foreach ($differentValues as $doc) {
            $distinctValues[] = new DistinctValue($doc['_id'], $doc['count']);
        }

        return $distinctValues;
    }

    public function count(array $filters = []): int
    {
        Validator::validateFilter($filters);

        // Deprecated due to "$near is not allowed inside of a $match aggregation expression" error
//        return $this->offerCollection->countDocuments($filters);

        // When using `aggregate`, the `$near` condition must be moved out from filters to aggregation stage:
        list($dealer_coordinates_filter, $filters) = self::extractDealerCoordinatesFilter($filters);

        $pipeline = [
            [
                '$match' => (object)$filters
            ],
            [
                '$count' => 'the_count'
            ],
        ];

        if ($dealer_coordinates_filter) {
            array_unshift($pipeline, [
                '$geoNear' => [
                    'near' => $dealer_coordinates_filter['$geometry'],
                    'maxDistance' => $dealer_coordinates_filter['$maxDistance'],
                    'distanceField' => 'dealer.distance',
                    'spherical' => true,
                ],
            ]);
        }

        $count = 0;

        /** @var \MongoDB\Driver\Cursor $result */
        foreach ($this->offerCollection->aggregate($pipeline) as $record) {
            /** @var \MongoDB\Model\BSONDocument $record */
            $count = (int) $record->the_count;
        }


        return $count;
    }

    public static function extractDealerCoordinatesFilter(array $filters): array
    {
        if (is_iterable($filters)) {
            foreach ($filters as $operator_level_0 => &$filters_level_0) {
                if (is_iterable($filters_level_0)) {
                    foreach ($filters_level_0 as $index => $condition_object) {
                        if (array_key_exists('dealer.coordinates', $condition_object)) {
                            $found = $index;
                            break;
                        }
                    }
                }
                if (isset($found)) {
                    $near = $filters_level_0[$found]['dealer.coordinates']['$near'];
                    unset($filters_level_0[$found]);
                }
            }
        }

        return [
            $near ?? [],
            $filters,
        ];
    }

    /**
     * Returns lowest available FINAL NETTO price for different models, where model is the "generic" name used in
     * `model.group.name`. One can put additional filters as parameter, but basically filtering by brand should be
     * sufficient.
     *
     * Returned array follows schema:
     *
     *   [
     *     MODEL_1_NAME => LOWEST_PRICE_FOR_MODEL_1,
     *     MODEL_2_NAME => LOWEST_PRICE_FOR_MODEL_2,
     *     ...
     *   ]
     *
     * It is basically used to determine if offer should be labelled (tagged) as "lowest price".
     *
     * @param array $filters
     * @return array
     * @throws ApiException
     */
    public function getModelLowestPrices(array $filters = []): array
    {
        Validator::validateFilter($filters);

        $pipeline = [
            [
                '$match' => (object)$filters
            ],
            [
                '$group' => [
                    '_id' => '$model.group.name',
                    'lowest_price' => [
                        '$min' => '$price.final.netto'
                    ]
                ]
            ]
        ];

        $lowest_model_prices = [];

        foreach ($this->offerCollection->aggregate($pipeline) as $record) {
            $lowest_model_prices[$record->_id] = $record->lowest_price;
        }

        return $lowest_model_prices;
    }
}
