<?php

/**
 * League.Csv (https://csv.thephpleague.com)
 *
 * (c) Ignace Nyamagana Butera <nyamsprod@gmail.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

declare(strict_types=1);

namespace League\Csv;

use Deprecated;

use function array_filter;
use function array_map;
use function array_reduce;
use function count;
use function explode;
use function filter_var;
use function preg_match;
use function range;

use const FILTER_VALIDATE_INT;

/**
 * EXPERIMENTAL WARNING! This class implementation will change in the next major point release.
 *
 * @phpstan-type selection array{selection:string, start:int<-1, max>, end:?int, length:int, columns:array<int>}
 *
 * @experimental
 */
class FragmentFinder
{
    private const REGEXP_URI_FRAGMENT = ',^(?<type>row|cell|col)=(?<selections>.*)$,i';
    private const REGEXP_ROWS_COLUMNS_SELECTION = '/^(?<start>\d+)(-(?<end>\d+|\*))?$/';
    private const REGEXP_CELLS_SELECTION = '/^(?<csr>\d+),(?<csc>\d+)(-(?<end>((?<cer>\d+),(?<cec>\d+))|\*))?$/';
    private const TYPE_ROW = 'row';
    private const TYPE_COLUMN = 'col';
    private const TYPE_UNKNOWN = 'unknown';

    /**
     * EXPERIMENTAL WARNING! This method implementation will change in the next major point release.
     *
     * Extract all found fragment identifiers for the specifield tabular data
     *
     * @experimental since version 9.12.0
     *
     * @throws SyntaxError
     * @return iterable<int, TabularDataReader>
     */
    public function findAll(string $expression, TabularData|TabularDataProvider $tabularData): iterable
    {
        $tabularData = self::tabularData($tabularData);

        return $this->find($this->parseExpression($expression, $tabularData), $tabularData);
    }

    private static function tabularData(TabularData|TabularDataProvider $tabularData): TabularData
    {
        if ($tabularData instanceof TabularData) {
            return $tabularData;
        }

        return $tabularData->getTabularData();
    }

    /**
     * EXPERIMENTAL WARNING! This method implementation will change in the next major point release.
     *
     * Extract the first found fragment identifier of the tabular data or returns null
     *
     * @experimental since version 9.12.0
     *
     * @throws SyntaxError
     */
    public function findFirst(string $expression, TabularData|TabularDataProvider $tabularData): ?TabularDataReader
    {
        $tabularData = self::tabularData($tabularData);
        $fragment = $this->find($this->parseExpression($expression, $tabularData), $tabularData)[0];

        return match ([]) {
            $fragment->first() => null,
            default => $fragment,
        };
    }

    /**
     * EXPERIMENTAL WARNING! This method implementation will change in the next major point release.
     *
     * Extract the first found fragment identifier of the tabular data or fail
     *
     * @experimental since version 9.12.0
     *
     * @throws SyntaxError
     * @throws FragmentNotFound if the expression can not be parsed
     */
    public function findFirstOrFail(string $expression, TabularData|TabularDataProvider $tabularData): TabularDataReader
    {
        $tabularData = self::tabularData($tabularData);
        $parsedExpression = $this->parseExpression($expression, $tabularData);
        if ([] !== array_filter($parsedExpression['selections'], fn (array $selection) => -1 === $selection['start'])) {
            throw new FragmentNotFound('The expression `'.$expression.'` contains an invalid or an unsupported selection for the tabular data.');
        }

        $fragment = $this->find($parsedExpression, $tabularData)[0];

        return match ([]) {
            $fragment->first() => throw new FragmentNotFound('No fragment found in the tabular data with the expression `'.$expression.'`.'),
            default => $fragment,
        };
    }

    /**
     * @param array{type:string, selections:non-empty-array<selection>} $parsedExpression
     *
     * @throws SyntaxError
     *
     * @return array<int, TabularDataReader>
     */
    private function find(array $parsedExpression, TabularData $tabularData): array
    {
        ['type' => $type, 'selections' => $selections] = $parsedExpression;

        $selections = array_filter($selections, fn (array $selection) => -1 !== $selection['start']);
        if ([] === $selections) {
            return [new ResultSet()];
        }

        if (self::TYPE_ROW === $type) {
            $rowFilter = fn (array $record, int $offset): bool => [] !== array_filter(
                $selections,
                fn (array $selection) =>
                        $offset >= $selection['start'] &&
                        (null === $selection['end'] || $offset <= $selection['end'])
            );

            return [
                (new Statement())
                    ->where($rowFilter)
                    ->process($tabularData),
            ];
        }

        if (self::TYPE_COLUMN === $type) {
            $columns = array_reduce(
                $selections,
                fn (array $columns, array $selection) => [...$columns, ...$selection['columns']],
                []
            );

            return [match ([]) {
                $columns => new ResultSet(),
                default => (new Statement())->select(...$columns)->process($tabularData),
            }];
        }

        return array_map(
            fn (array $selection) => (new Statement())
                ->offset($selection['start'])
                ->limit($selection['length'])
                ->select(...$selection['columns'])
                ->process($tabularData),
            $selections
        );
    }

    /**
     * @return array{type:string, selections:non-empty-array<selection>}
     */
    private function parseExpression(string $expression, TabularData $tabularData): array
    {
        if (1 !== preg_match(self::REGEXP_URI_FRAGMENT, $expression, $matches)) {
            return [
                'type' => self::TYPE_UNKNOWN,
                'selections' => [
                    [
                        'selection' => $expression,
                        'start' => -1,
                        'end' => null,
                        'length' => -1,
                        'columns' => [],
                    ],
                ],
            ];
        }

        $type = strtolower($matches['type']);

        /** @var non-empty-array<selection> $res */
        $res = array_reduce(
            explode(';', $matches['selections']),
            fn (array $selections, string $selection): array => [...$selections, match ($type) {
                self::TYPE_ROW => $this->parseRowSelection($selection),
                self::TYPE_COLUMN => $this->parseColumnSelection($selection, $tabularData),
                default => $this->parseCellSelection($selection, $tabularData),
            }],
            []
        );

        return [
            'type' => $type,
            'selections' => $res,
        ];
    }

    /**
     * @return selection
     */
    private function parseRowSelection(string $selection): array
    {
        [$start, $end] = $this->parseRowColumnSelection($selection);

        return match (true) {
            -1 === $start,
            null === $end => [
            'selection' => $selection,
            'start' => $start,
            'end' => $start,
            'length' => 1,
            'columns' => [],
            ],
            '*' === $end => [
                'selection' => $selection,
                'start' => $start,
                'end' => null,
                'length' => -1,
                'columns' => [],
            ],
            default => [
                'selection' => $selection,
                'start' => $start,
                'end' => $end,
                'length' => $end - $start + 1,
                'columns' => [],
            ],
        };
    }

    /**
     * @return selection
     */
    private function parseColumnSelection(string $selection, TabularData $tabularData): array
    {
        [$start, $end] = $this->parseRowColumnSelection($selection);
        $header = $tabularData->getHeader();
        if ([] === $header) {
            $header = $tabularData->nth(0);
        }

        $nbColumns = count($header);

        return match (true) {
            -1 === $start,
            $start >= $nbColumns => [
            'selection' => $selection,
            'start' => -1,
            'end' => null,
            'length' => -1,
            'columns' => [],
            ],
            null === $end => [
                'selection' => $selection,
                'start' => 0,
                'end' => null,
                'length' => -1,
                'columns' => [$start],
            ],
            '*' === $end,
            $end > ($nbColumns - 1) => [
            'selection' => $selection,
            'start' => 0,
            'end' => null,
            'length' => -1,
            'columns' => range($start, $nbColumns - 1),
            ],
            default => [
                'selection' => $selection,
                'start' => 0,
                'end' => $end,
                'length' => -1,
                'columns' => range($start, $end),
            ],
        };
    }

    /**
     * @return array{int<-1, max>, int|null|'*'}
     */
    private function parseRowColumnSelection(string $selection): array
    {
        if (1 !== preg_match(self::REGEXP_ROWS_COLUMNS_SELECTION, $selection, $found)) {
            return [-1, 0];
        }

        $start = $found['start'];
        $end = $found['end'] ?? null;
        $start = filter_var($start, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
        if (false === $start) {
            return [-1, 0];
        }
        --$start;

        if (null === $end || '*' === $end) {
            return [$start, $end];
        }

        $end = filter_var($end, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
        if (false === $end) {
            return [-1, 0];
        }
        --$end;

        if ($end <= $start) {
            return [-1, 0];
        }

        return [$start, $end];
    }

    /**
     * @return selection
     */
    private function parseCellSelection(string $selection, TabularData $tabularData): array
    {
        if (1 !== preg_match(self::REGEXP_CELLS_SELECTION, $selection, $found)) {
            return [
                'selection' => $selection,
                'start' => -1,
                'end' => null,
                'length' => 1,
                'columns' => [],
            ];
        }

        $cellStartRow = filter_var($found['csr'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
        $cellStartCol = filter_var($found['csc'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
        if (false === $cellStartRow || false === $cellStartCol) {
            return [
                'selection' => $selection,
                'start' => -1,
                'end' => null,
                'length' => 1,
                'columns' => [],
            ];
        }

        --$cellStartRow;
        --$cellStartCol;

        $header = $tabularData->getHeader();
        if ([] === $header) {
            $header = $tabularData->nth(0);
        }

        $nbColumns = count($header);

        if ($cellStartCol > $nbColumns - 1) {
            return [
                'selection' => $selection,
                'start' => -1,
                'end' => null,
                'length' => 1,
                'columns' => [],
            ];
        }

        $cellEnd = $found['end'] ?? null;
        if (null === $cellEnd) {
            return [
                'selection' => $selection,
                'start' => $cellStartRow,
                'end' => null,
                'length' => 1,
                'columns' => [$cellStartCol],
            ];
        }

        if ('*' === $cellEnd) {
            return [
                'selection' => $selection,
                'start' => $cellStartRow,
                'end' => null,
                'length' => -1,
                'columns' => range($cellStartCol, $nbColumns - 1),
            ];
        }

        $cellEndRow = filter_var($found['cer'] ?? '', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
        $cellEndCol = filter_var($found['cec'] ?? '', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);

        if (false === $cellEndRow || false === $cellEndCol) {
            return [
                'selection' => $selection,
                'start' => -1,
                'end' => null,
                'length' => 1,
                'columns' => [],
            ];
        }

        --$cellEndRow;
        --$cellEndCol;

        if ($cellEndRow < $cellStartRow || $cellEndCol < $cellStartCol) {
            return [
                'selection' => $selection,
                'start' => -1,
                'end' => null,
                'length' => 1,
                'columns' => [],
            ];
        }

        return [
            'selection' => $selection,
            'start' => $cellStartRow,
            'end' => $cellEndRow,
            'length' => $cellEndRow - $cellStartRow + 1,
            'columns' => range($cellStartCol, ($cellEndCol > $nbColumns - 1) ? $nbColumns - 1 : $cellEndCol),
        ];
    }

    /**
     * DEPRECATION WARNING! This method will be removed in the next major point release.
     *
     * @see FragmentFinder::__construct()
     * @deprecated Since version 9.22.0
     * @codeCoverageIgnore
     *
     * Returns a new instance.
     */
    #[Deprecated(message:'use League\Csv\FragmentFinder::__construct()', since:'league/csv:9.22.0')]
    public static function create(): self
    {
        return new self();
    }
}
