<?php

declare(strict_types=1);

/*
 * Chill is a software for social workers
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

namespace Chill\MainBundle\Export\Formatter;

use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Export\FormatterInterface;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatorInterface;

class SpreadSheetFormatter implements FormatterInterface
{
    /**
     * an array where keys are the aggregators aliases and
     * values are the data.
     *
     * replaced when `getResponse` is called.
     *
     * @var type
     */
    protected $aggregatorsData;

    /**
     * The export.
     *
     * replaced when `getResponse` is called.
     *
     * @var \Chill\MainBundle\Export\ExportInterface
     */
    protected $export;

    /**
     * replaced when `getResponse` is called.
     *
     * @var type
     */
    // protected $aggregators;

    /**
     * array containing value of export form.
     *
     * replaced when `getResponse` is called.
     *
     * @var array
     */
    protected $exportData;

    /**
     * @var ExportManager
     */
    protected $exportManager;

    /**
     * replaced when `getResponse` is called.
     *
     * @var array
     */
    protected $filtersData;

    /**
     * replaced when `getResponse` is called.
     *
     * @var type
     */
    protected $formatterData;

    /**
     * The result, as returned by the export.
     *
     * replaced when `getResponse` is called.
     *
     * @var type
     */
    protected $result;

    /**
     * replaced when `getResponse` is called.
     *
     * @var array
     */
    // protected $labels;

    /**
     * temporary file to store spreadsheet.
     *
     * @var string
     */
    protected $tempfile;

    /**
     * @var TranslatorInterface
     */
    protected $translator;

    /**
     * cache for displayable result.
     *
     * This cache is reset when `getResponse` is called.
     *
     * The array's keys are the keys in the raw result, and
     * values are the callable which will transform the raw result to
     * displayable result.
     */
    private ?array $cacheDisplayableResult = null;

    /**
     * Whethe `cacheDisplayableResult` is initialized or not.
     */
    private bool $cacheDisplayableResultIsInitialized = false;

    public function __construct(TranslatorInterface $translatorInterface, ExportManager $exportManager)
    {
        $this->translator = $translatorInterface;
        $this->exportManager = $exportManager;
    }

    public function buildForm(
        FormBuilderInterface $builder,
        $exportAlias,
        array $aggregatorAliases,
    ): void {
        // choosing between formats
        $builder->add('format', ChoiceType::class, [
            'choices' => [
                'OpenDocument Format (.ods) (LibreOffice, ...)' => 'ods',
                'Microsoft Excel 2007-2013 XML (.xlsx) (Microsoft Excel, LibreOffice)' => 'xlsx',
                'Comma separated values (.csv)' => 'csv',
            ],
            'placeholder' => 'Choose the format',
        ]);

        // ordering aggregators
        $aggregators = $this->exportManager->getAggregators($aggregatorAliases);
        $nb = \count($aggregatorAliases);

        foreach ($aggregators as $alias => $aggregator) {
            $builderAggregator = $builder->create($alias, FormType::class, [
                'label' => $aggregator->getTitle(),
                'block_name' => '_aggregator_placement_spreadsheet_formatter',
            ]);
            $this->appendAggregatorForm($builderAggregator, $nb);
            $builder->add($builderAggregator);
        }
    }

    public function getFormDefaultData(array $aggregatorAliases): array
    {
        $data = ['format' => 'xlsx'];

        $aggregators = iterator_to_array($this->exportManager->getAggregators($aggregatorAliases));
        foreach (array_keys($aggregators) as $index => $alias) {
            $data[$alias] = ['order' => $index + 1];
        }

        return $data;
    }

    public function getName(): string
    {
        return 'SpreadSheet (xlsx, ods)';
    }

    public function getResponse(
        $result,
        $formatterData,
        $exportAlias,
        array $exportData,
        array $filtersData,
        array $aggregatorsData,
    ): Response {
        // store all data when the process is initiated
        $this->result = $result;
        $this->formatterData = $formatterData;
        $this->export = $this->exportManager->getExport($exportAlias);
        $this->exportData = $exportData;
        $this->filtersData = $filtersData;
        $this->aggregatorsData = $aggregatorsData;

        // reset cache
        $this->cacheDisplayableResult = [];
        $this->cacheDisplayableResultIsInitialized = false;

        $response = new Response();
        $response->headers->set(
            'Content-Type',
            $this->getContentType($this->formatterData['format'])
        );

        $this->tempfile = \tempnam(\sys_get_temp_dir(), '');
        $this->generateContent();

        $f = \fopen($this->tempfile, 'rb');
        $response->setContent(\stream_get_contents($f));
        fclose($f);

        // remove the temp file from disk
        \unlink($this->tempfile);

        return $response;
    }

    public function getType()
    {
        return 'tabular';
    }

    protected function addContentTable(
        Worksheet $worksheet,
        $sortedResults,
        $line,
    ) {
        $worksheet->fromArray(
            $sortedResults,
            null,
            'A'.$line,
            true
        );

        return $line + \count($sortedResults) + 1;
    }

    /**
     * Add filter description since line 3.
     *
     * return the line number after the last description
     *
     * @return int the line number after the last description
     */
    protected function addFiltersDescription(Worksheet &$worksheet)
    {
        $line = 3;

        foreach ($this->filtersData as $alias => $data) {
            $filter = $this->exportManager->getFilter($alias);
            $description = $filter->describeAction($data, 'string');

            if (\is_array($description)) {
                $description = $this->translator
                    ->trans(
                        $description[0],
                        $description[1] ?? []
                    );
            }

            $worksheet->setCellValue('A'.$line, $description);
            ++$line;
        }

        return $line;
    }

    /**
     * add headers to worksheet.
     *
     * return the line number where the next content (i.e. result) should
     * be appended.
     *
     * @param int $line
     *
     * @return int
     */
    protected function addHeaders(
        Worksheet &$worksheet,
        array $globalKeys,
        $line,
    ) {
        // get the displayable form of headers
        $displayables = [];

        foreach ($globalKeys as $key) {
            $displayables[] = $this->translator->trans(
                $this->getDisplayableResult($key, '_header')
            );
        }

        // add headers on worksheet
        $worksheet->fromArray(
            $displayables,
            null,
            'A'.$line
        );

        return $line + 1;
    }

    /**
     * Add the title to the worksheet and merge the cell containing
     * the title.
     */
    protected function addTitleToWorkSheet(Worksheet &$worksheet)
    {
        $worksheet->setCellValue('A1', $this->getTitle());
        $worksheet->mergeCells('A1:G1');
    }

    /**
     * Create a spreadsheet and a working worksheet.
     *
     * @return array where 1st member is spreadsheet, 2nd is worksheet
     */
    protected function createSpreadsheet()
    {
        $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
        $worksheet = $spreadsheet->getActiveSheet();

        // setting the worksheet title and code name
        $worksheet
            ->setTitle($this->getTitle())
            ->setCodeName('result');

        return [$spreadsheet, $worksheet];
    }

    /**
     * Generate the content and write it to php://temp.
     */
    protected function generateContent()
    {
        [$spreadsheet, $worksheet] = $this->createSpreadsheet();

        $this->addTitleToWorkSheet($worksheet);
        $line = $this->addFiltersDescription($worksheet);

        // at this point, we are going to sort retsults for an easier manipulation
        [$sortedResult, $exportKeys, $aggregatorKeys, $globalKeys] =
            $this->sortResult();

        $line = $this->addHeaders($worksheet, $globalKeys, $line);

        $line = $this->addContentTable($worksheet, $sortedResult, $line);

        $writer = match ($this->formatterData['format']) {
            'ods' => \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($spreadsheet, 'Ods'),
            'xlsx' => \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($spreadsheet, 'Xlsx'),
            'csv' => \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($spreadsheet, 'Csv'),
            default => throw new \LogicException(),
        };

        $writer->save($this->tempfile);
    }

    /**
     * get an array of aggregator keys. The keys are sorted as asked
     * by user in the form.
     *
     * @return string[] an array containing the keys of aggregators
     */
    protected function getAggregatorKeysSorted()
    {
        // empty array for aggregators keys
        $keys = [];
        // this association between key and aggregator alias will be used
        // during sorting
        $aggregatorKeyAssociation = [];

        foreach ($this->aggregatorsData as $alias => $data) {
            $aggregator = $this->exportManager->getAggregator($alias);
            $aggregatorsKeys = $aggregator->getQueryKeys($data);
            // append the keys from aggregator to the $keys existing array
            $keys = \array_merge($keys, $aggregatorsKeys);
            // append the key with the alias, which will be use later for sorting
            foreach ($aggregatorsKeys as $key) {
                $aggregatorKeyAssociation[$key] = $alias;
            }
        }

        // sort the result using the form
        usort($keys, function ($a, $b) use ($aggregatorKeyAssociation) {
            $A = $this->formatterData[$aggregatorKeyAssociation[$a]]['order'];
            $B = $this->formatterData[$aggregatorKeyAssociation[$b]]['order'];

            if ($A === $B) {
                return 0;
            }

            if ($A > $B) {
                return 1;
            }

            return -1;
        });

        return $keys;
    }

    protected function getContentType($format)
    {
        switch ($format) {
            case 'csv':
                return 'text/csv';

            case 'ods':
                return 'application/vnd.oasis.opendocument.spreadsheet';

            case 'xlsx':
                return 'application/vnd.openxmlformats-officedocument.'
                .'spreadsheetml.sheet';
        }
    }

    /**
     * Get the displayable result.
     *
     * @param string $key
     *
     * @return string
     */
    protected function getDisplayableResult($key, mixed $value): mixed
    {
        if (false === $this->cacheDisplayableResultIsInitialized) {
            $this->initializeCache($key);
        }

        $value ??= '';

        return \call_user_func($this->cacheDisplayableResult[$key], $value);
    }

    protected function getTitle()
    {
        $title = $this->translator->trans($this->export->getTitle());

        if (30 < strlen($title)) {
            return substr($title, 0, 30).'…';
        }

        return $title;
    }

    protected function initializeCache($key)
    {
        /*
         * this function follows the following steps :
         *
         * 1. associate all keys used in result with their export element
         *    (export or aggregator) and data;
         * 2. associate all keys used in result with all the possible values :
         *    this array will be necessary to call `getLabels`
         * 3. store the `callable` in an associative array, in cache
         */
        // 1. create an associative array with key and export / aggregator
        $keysExportElementAssociation = [];
        // keys for export
        foreach ($this->export->getQueryKeys($this->exportData) as $key) {
            $keysExportElementAssociation[$key] = [$this->export,
                $this->exportData, ];
        }
        // keys for aggregator
        foreach ($this->aggregatorsData as $alias => $data) {
            $aggregator = $this->exportManager->getAggregator($alias);

            foreach ($aggregator->getQueryKeys($data) as $key) {
                $keysExportElementAssociation[$key] = [$aggregator, $data];
            }
        }

        // 2. collect all the keys before iteration
        $keys = array_keys($keysExportElementAssociation);

        $allValues = [];
        // store all the values in an array
        foreach ($this->result as $row) {
            foreach ($keys as $key) {
                $allValues[$key][] = $row[$key];
            }
        }

        // 3. iterate on `keysExportElementAssociation` to store the callable
        // in cache
        foreach ($keysExportElementAssociation as $key => [$element, $data]) {
            // handle the case when there is not results lines (query is empty)
            if ([] === $allValues) {
                $this->cacheDisplayableResult[$key] = $element->getLabels($key, ['_header'], $data);
            } else {
                $this->cacheDisplayableResult[$key] =
                    $element->getLabels($key, \array_unique($allValues[$key]), $data);
            }
        }

        // the cache is initialized !
        $this->cacheDisplayableResultIsInitialized = true;
    }

    /**
     * sort the results, and return an array where :
     *  - 0 => sorted results
     *  - 1 => export keys
     *  - 2 => aggregator keys
     *  - 3 => global keys (aggregator keys and export keys).
     *
     * Example, assuming that the result contains two aggregator keys :
     *
     * array in result :
     *
     * ```
     * array(
     *  array( //row 1
     *      'export_result' => 1,
     *      'aggregator_1'  => 2,
     *      'aggregator_2'  => 3
     *  ),
     *  array( // row 2
     *      'export_result' => 4,
     *      'aggregator_1'  => 5,
     *      'aggregator_2'  => 6
     *  )
     * )
     * ```
     *
     * the sorted result will be :
     *
     * ```
     * array(
     *  array( 2, 3, 1 ),
     *  array( 5, 6, 4 )
     * )
     * ```
     */
    protected function sortResult()
    {
        // get the keys for each row
        $exportKeys = $this->export->getQueryKeys($this->exportData);
        $aggregatorKeys = $this->getAggregatorKeysSorted();

        $globalKeys = \array_merge($aggregatorKeys, $exportKeys);

        $sortedResult = \array_map(function ($row) use ($globalKeys) {
            $newRow = [];

            foreach ($globalKeys as $key) {
                $newRow[] = $this->getDisplayableResult($key, $row[$key]);
            }

            return $newRow;
        }, $this->result);

        \array_multisort($sortedResult);

        return [$sortedResult, $exportKeys, $aggregatorKeys, $globalKeys];
    }

    /**
     * append a form line by aggregator on the formatter form.
     *
     * This form allow to choose the aggregator position (row or column) and
     * the ordering
     */
    private function appendAggregatorForm(FormBuilderInterface $builder, int $nbAggregators): void
    {
        $builder->add('order', ChoiceType::class, [
            'choices' => array_combine(
                range(1, $nbAggregators),
                range(1, $nbAggregators)
            ),
            'multiple' => false,
            'expanded' => false,
        ]);
    }
}
