<?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\ExportGenerationContext;
use Chill\MainBundle\Export\ExportInterface;
use Chill\MainBundle\Export\ExportManagerAwareInterface;
use Chill\MainBundle\Export\FormattedExportGeneration;
use Chill\MainBundle\Export\FormatterInterface;
use Chill\MainBundle\Export\Helper\ExportManagerAwareTrait;
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\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;

final class SpreadSheetFormatter implements FormatterInterface, ExportManagerAwareInterface
{
    use ExportManagerAwareTrait;

    public function __construct(private readonly TranslatorInterface $translator) {}

    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->getExportManager()->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 getNormalizationVersion(): int
    {
        return 1;
    }

    public function normalizeFormData(array $formData): array
    {
        return $formData;
    }

    public function denormalizeFormData(array $formData, int $fromVersion): array
    {
        return $formData;
    }

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

        $aggregators = iterator_to_array($this->getExportManager()->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 generate(
        $result,
        $formatterData,
        string $exportAlias,
        array $exportData,
        array $filtersData,
        array $aggregatorsData,
        ExportGenerationContext $context,
    ) {
        // Initialize local variables instead of class properties
        /** @var ExportInterface $export */
        $export = $this->getExportManager()->getExport($exportAlias);

        // Initialize cache variables
        $cacheDisplayableResult = $this->initializeDisplayable($result, $export, $exportData, $aggregatorsData);

        $tempfile = \tempnam(\sys_get_temp_dir(), '');

        if (false === $tempfile) {
            throw new \RuntimeException('Unable to create temporary file');
        }

        $this->generateContent(
            $context,
            $tempfile,
            $result,
            $formatterData,
            $export,
            $exportData,
            $filtersData,
            $aggregatorsData,
            $cacheDisplayableResult,
        );

        $result = new FormattedExportGeneration(
            file_get_contents($tempfile),
            $this->getContentType($formatterData['format']),
        );

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

        return $result;
    }

    public function getResponse(
        $result,
        $formatterData,
        $exportAlias,
        array $exportData,
        array $filtersData,
        array $aggregatorsData,
        ExportGenerationContext $context,
    ): Response {
        $formattedResult = $this->generate($result, $formatterData, $exportAlias, $exportData, $filtersData, $aggregatorsData, $context);

        $response = new BinaryFileResponse($formattedResult->content);
        $response->headers->set('Content-Type', $formattedResult->contentType);

        return $response;
    }

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

    private 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
     */
    private function addFiltersDescription(Worksheet &$worksheet, ExportGenerationContext $context, array $filtersData)
    {
        $line = 3;

        foreach ($filtersData as $alias => $data) {
            $filter = $this->getExportManager()->getFilter($alias);
            $description = $filter->describeAction($data, $context);
            if (\is_array($description)) {
                $description = $this->translator
                    ->trans(
                        $description[0],
                        $description[1] ?? [],
                    );
            } elseif ($description instanceof TranslatableInterface) {
                $description = $description->trans($this->translator, $this->translator->getLocale());
            }

            $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.
     */
    private function addHeaders(
        Worksheet &$worksheet,
        array $globalKeys,
        int $line,
        array $cacheDisplayableResult = [],
    ): int {
        // get the displayable form of headers
        $displayables = [];
        foreach ($globalKeys as $key) {
            $displayable = $this->getDisplayableResult($key, '_header', $cacheDisplayableResult);

            if ($displayable instanceof TranslatableInterface) {
                $displayables[] = $displayable->trans($this->translator, $this->translator->getLocale());
            } else {
                $displayables[] = $this->translator->trans($this->getDisplayableResult($key, '_header', $cacheDisplayableResult));
            }
        }

        // 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.
     */
    private function addTitleToWorkSheet(Worksheet &$worksheet, $export)
    {
        $worksheet->setCellValue('A1', $this->getTitle($export));
        $worksheet->mergeCells('A1:G1');
    }

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

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

        return [$spreadsheet, $worksheet];
    }

    /**
     * Generate the content and write it to php://temp.
     */
    private function generateContent(
        ExportGenerationContext $context,
        string $tempfile,
        $result,
        $formatterData,
        $export,
        array $exportData,
        array $filtersData,
        array $aggregatorsData,
        array $cacheDisplayableResult,
    ) {
        [$spreadsheet, $worksheet] = $this->createSpreadsheet($export);

        $this->addTitleToWorkSheet($worksheet, $export);
        $line = $this->addFiltersDescription($worksheet, $context, $filtersData);

        // at this point, we are going to sort results for an easier manipulation
        [$sortedResult, $exportKeys, $aggregatorKeys, $globalKeys] =
            $this->sortResult($result, $export, $exportData, $aggregatorsData, $formatterData, $cacheDisplayableResult);

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

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

        $writer = match ($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($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
     */
    private function getAggregatorKeysSorted(array $aggregatorsData, array $formatterData)
    {
        // empty array for aggregators keys
        $keys = [];
        // this association between key and aggregator alias will be used
        // during sorting
        $aggregatorKeyAssociation = [];

        foreach ($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, $formatterData) {
            $A = $formatterData[$aggregatorKeyAssociation[$a]]['order'];
            $B = $formatterData[$aggregatorKeyAssociation[$b]]['order'];

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

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

            return -1;
        });

        return $keys;
    }

    private 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.
     */
    private function getDisplayableResult(
        string $key,
        mixed $value,
        array $cacheDisplayableResult,
    ): string|TranslatableInterface|\DateTimeInterface|int|float|bool {
        $value ??= '';

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

    private function getTitle($export): string
    {
        $original = $export->getTitle();

        if ($original instanceof TranslatableInterface) {
            $title = $original->trans($this->translator, $this->translator->getLocale());
        } else {
            $title = $this->translator->trans($original);
        }

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

        return $title;
    }

    private function initializeDisplayable(
        $result,
        ExportInterface $export,
        array $exportData,
        array $aggregatorsData,
    ): array {
        $cacheDisplayableResult = [];
        /*
         * 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 ($export->getQueryKeys($exportData) as $key) {
            $keysExportElementAssociation[$key] = [$export, $exportData];
        }
        // keys for aggregator
        foreach ($aggregatorsData as $alias => $data) {
            $aggregator = $this->getExportManager()->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 ($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) {
                $cacheDisplayableResult[$key] = $element->getLabels($key, ['_header'], $data);
            } else {
                $cacheDisplayableResult[$key] =
                    $element->getLabels($key, \array_unique($allValues[$key]), $data);
            }
        }

        return $cacheDisplayableResult;
    }

    /**
     * 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 )
     * )
     * ```
     */
    private function sortResult(
        $result,
        ExportInterface $export,
        array $exportData,
        array $aggregatorsData,
        array $formatterData,
        array $cacheDisplayableResult,
    ) {
        // get the keys for each row
        $exportKeys = $export->getQueryKeys($exportData);
        $aggregatorKeys = $this->getAggregatorKeysSorted($aggregatorsData, $formatterData);
        $globalKeys = \array_merge($aggregatorKeys, $exportKeys);

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

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

            return $newRow;
        }, $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,
        ]);
    }
}
