<?php
declare(strict_types=1);

namespace FCA\StockApi\Collection;

use FCA\StockApi\Api;
use FCA\StockApi\Collection\Filter\Builder\Fields;
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'],
        ];

        if ($sort === ['random' => true]) {
            if ($limit === null) {
                throw new ApiException('Limit must be defined in random find!');
            }
            $pipeline = [
                [
                    '$match' => (object)$filters
                ],
                [
                    '$sample' => [
                        'size' => $limit
                    ]
                ],
            ];

            $docs = $this->offerCollection->aggregate($pipeline, $options);
        } else {
            $pipeline = [
                [
                    '$match' => (object)$filters
                ]
            ];

            if ($sort) {
                // `isInvenduto` is a field that states if vehicle belongs to certain dealer or is available from
                // central stock. "Invenduto" offers should always be placed after "in place" offers, and hence the
                // need of additional field to sort by.
                if (array_key_exists('isInvenduto', (array) $sort)) {
                    $pipeline[] = [
                        '$addFields' => [
                            'isInvenduto' => [
                                '$cond' => [
                                    'if' => '$dealer',
                                    'then' => false,
                                    'else' => true,
                                ]
                            ]
                        ]
                    ];
                }
                // Hello again. The `distance` sort is tricky. Giving it as a parameter will make additional field
                // called `distance` that will contain simple calculated (as on the plain surface, not Earth-alike)
                // distance from the point given in filters, under `dealer.coordinates`.
                if (array_key_exists('distance', (array) $sort)) {
                    // Find if there is coordinates filter available:
                    if ($distance_base = $this->getDistanceBaseCoordinates($filters)) {
                        $pipeline[] = [
                            '$addFields' => [
                                'distance' => [
                                    '$sqrt' => [
                                        '$add' => [
                                            [
                                                '$pow' => [
                                                    [
                                                        '$subtract' => [
                                                            $distance_base['latitude'],
                                                            ['$arrayElemAt' => ['$dealer.coordinates.coordinates', 1]]
                                                        ]
                                                    ],
                                                    2
                                                ]
                                            ],
                                            [
                                                '$pow' => [
                                                    [
                                                        '$subtract' => [
                                                            $distance_base['longitude'],
                                                            ['$arrayElemAt' => ['$dealer.coordinates.coordinates', 0]]
                                                        ]
                                                    ],
                                                    2
                                                ]
                                            ],
                                        ],
                                    ],
                                ]
                            ]
                        ];
//                        $sort['distance'] = \FCA\StockApi\Collection\Sort\Sort::ASC;
                    } else {
                        unset($sort['distance']);
                    }
                }

                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);

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

        $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);

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

        $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;
    }

    /**
     * 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;
    }

    /**
     * Tricky helper to extract from used filters geo-coordinates of point that is being used as a base for search (like
     * 'Bytom' or 'Rawa Mazowiecka').
     *
     * @param array $filters
     * @return array|null
     */
    private function getDistanceBaseCoordinates(array $filters = []): ?array
    {
        foreach ($filters as $operator => $filter_set) {
            foreach ($filter_set as $filter) {
                foreach ($filter as $field => $conditions) {
                    if ($field === Fields::DEALER_COORDINATES) {
                        // `$geoWithin` is just one (however the only currently used) of many ways to limit records
                        // based on the geo-region (here: sphere). There may be others used in the future, like `$near`
                        // or `$geoNear`, and they will have other structure that should be then added here.
                        if ($conditions['$geoWithin']['$centerSphere'][0] ?? false) {
                            $coordinates = $conditions['$geoWithin']['$centerSphere'][0];
                            if (isset($coordinates[0], $coordinates[1])) {
                                return [
                                    'longitude' => $conditions['$geoWithin']['$centerSphere'][0][0],
                                    'latitude' => $conditions['$geoWithin']['$centerSphere'][0][1],
                                ];
                            }
                        }
                    }
                }
            }
        }

        return null;
    }
}
