<?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 CallbackFilterIterator;
use Iterator;
use JsonSerializable;
use League\Csv\Polyfill\EmptyEscapeParser;
use SplFileObject;
use function array_combine;
use function array_filter;
use function array_pad;
use function array_slice;
use function array_unique;
use function count;
use function is_array;
use function iterator_count;
use function iterator_to_array;
use function mb_strlen;
use function mb_substr;
use function strlen;
use function substr;
use const PHP_VERSION_ID;
use const STREAM_FILTER_READ;

/**
 * A class to parse and read records from a CSV document.
 */
class Reader extends AbstractCsv implements TabularDataReader, JsonSerializable
{
    protected const STREAM_FILTER_MODE = STREAM_FILTER_READ;

    /**
     * header offset.
     *
     * @var int|null
     */
    protected $header_offset;

    /**
     * header record.
     *
     * @var string[]
     */
    protected $header = [];

    /**
     * records count.
     *
     * @var int
     */
    protected $nb_records = -1;

    /**
     * @var bool
     */
    protected $is_empty_records_included = false;

    /**
     * {@inheritdoc}
     */
    public static function createFromPath(string $path, string $open_mode = 'r', $context = null)
    {
        return parent::createFromPath($path, $open_mode, $context);
    }

    /**
     * {@inheritdoc}
     */
    protected function resetProperties(): void
    {
        $this->nb_records = -1;
        $this->header = [];
    }

    /**
     * Returns the header offset.
     *
     * If no CSV header offset is set this method MUST return null
     *
     */
    public function getHeaderOffset(): ?int
    {
        return $this->header_offset;
    }

    /**
     * {@inheritDoc}
     */
    public function getHeader(): array
    {
        if (null === $this->header_offset) {
            return $this->header;
        }

        if ([] !== $this->header) {
            return $this->header;
        }

        $this->header = $this->setHeader($this->header_offset);

        return $this->header;
    }

    /**
     * Determine the CSV record header.
     *
     * @throws Exception If the header offset is set and no record is found or is the empty array
     *
     * @return string[]
     */
    protected function setHeader(int $offset): array
    {
        $header = $this->seekRow($offset);
        if (in_array($header, [[], [null]], true)) {
            throw SyntaxError::dueToHeaderNotFound($offset);
        }

        if (0 !== $offset) {
            return $header;
        }

        $header = $this->removeBOM($header, mb_strlen($this->getInputBOM()), $this->enclosure);
        if ([''] === $header) {
            throw SyntaxError::dueToHeaderNotFound($offset);
        }

        return $header;
    }

    /**
     * Returns the row at a given offset.
     */
    protected function seekRow(int $offset): array
    {
        foreach ($this->getDocument() as $index => $record) {
            if ($offset === $index) {
                return $record;
            }
        }

        return [];
    }

    /**
     * Returns the document as an Iterator.
     */
    protected function getDocument(): Iterator
    {
        if (70400 > PHP_VERSION_ID && '' === $this->escape) {
            $this->document->setCsvControl($this->delimiter, $this->enclosure);

            return EmptyEscapeParser::parse($this->document);
        }

        $this->document->setFlags(SplFileObject::READ_CSV | SplFileObject::READ_AHEAD);
        $this->document->setCsvControl($this->delimiter, $this->enclosure, $this->escape);
        $this->document->rewind();

        return $this->document;
    }

    /**
     * Strip the BOM sequence from a record.
     *
     * @param string[] $record
     *
     * @return string[]
     */
    protected function removeBOM(array $record, int $bom_length, string $enclosure): array
    {
        if (0 === $bom_length) {
            return $record;
        }

        $record[0] = mb_substr($record[0], $bom_length);
        if ($enclosure.$enclosure != substr($record[0].$record[0], strlen($record[0]) - 1, 2)) {
            return $record;
        }

        $record[0] = substr($record[0], 1, -1);

        return $record;
    }

    /**
     * {@inheritdoc}
     */
    public function fetchColumn($index = 0): Iterator
    {
        return ResultSet::createFromTabularDataReader($this)->fetchColumn($index);
    }

    /**
     * {@inheritdoc}
     */
    public function fetchOne(int $nth_record = 0): array
    {
        return ResultSet::createFromTabularDataReader($this)->fetchOne($nth_record);
    }

    /**
     * {@inheritdoc}
     */
    public function fetchPairs($offset_index = 0, $value_index = 1): Iterator
    {
        return ResultSet::createFromTabularDataReader($this)->fetchPairs($offset_index, $value_index);
    }

    /**
     * {@inheritdoc}
     */
    public function count(): int
    {
        if (-1 === $this->nb_records) {
            $this->nb_records = iterator_count($this->getRecords());
        }

        return $this->nb_records;
    }

    /**
     * {@inheritdoc}
     */
    public function getIterator(): Iterator
    {
        return $this->getRecords();
    }

    /**
     * {@inheritdoc}
     */
    public function jsonSerialize(): array
    {
        return iterator_to_array($this->getRecords(), false);
    }

    /**
     * {@inheritDoc}
     */
    public function getRecords(array $header = []): Iterator
    {
        $header = $this->computeHeader($header);
        $normalized = function ($record): bool {
            return is_array($record) && ($this->is_empty_records_included || $record != [null]);
        };

        $bom = '';
        if (!$this->is_input_bom_included) {
            $bom = $this->getInputBOM();
        }

        $document = $this->getDocument();
        $records = $this->stripBOM(new CallbackFilterIterator($document, $normalized), $bom);
        if (null !== $this->header_offset) {
            $records = new CallbackFilterIterator($records, function (array $record, int $offset): bool {
                return $offset !== $this->header_offset;
            });
        }

        if ($this->is_empty_records_included) {
            $normalized_empty_records = static function (array $record): array {
                if ([null] === $record) {
                    return [];
                }

                return $record;
            };

            return $this->combineHeader(new MapIterator($records, $normalized_empty_records), $header);
        }

        return $this->combineHeader($records, $header);
    }

    /**
     * Returns the header to be used for iteration.
     *
     * @param string[] $header
     *
     * @throws Exception If the header contains non unique column name
     *
     * @return string[]
     */
    protected function computeHeader(array $header)
    {
        if ([] === $header) {
            $header = $this->getHeader();
        }

        if ($header !== ($filtered_header = array_filter($header, 'is_string'))) {
            throw SyntaxError::dueToInvalidHeaderColumnNames();
        }

        if ($header !== array_unique($filtered_header)) {
            throw SyntaxError::dueToDuplicateHeaderColumnNames($header);
        }

        return $header;
    }

    /**
     * Combine the CSV header to each record if present.
     *
     * @param string[] $header
     */
    protected function combineHeader(Iterator $iterator, array $header): Iterator
    {
        if ([] === $header) {
            return $iterator;
        }

        $field_count = count($header);
        $mapper = static function (array $record) use ($header, $field_count): array {
            if (count($record) != $field_count) {
                $record = array_slice(array_pad($record, $field_count, null), 0, $field_count);
            }

            /** @var array<string|null> $assocRecord */
            $assocRecord = array_combine($header, $record);

            return $assocRecord;
        };

        return new MapIterator($iterator, $mapper);
    }

    /**
     * Strip the BOM sequence from the returned records if necessary.
     */
    protected function stripBOM(Iterator $iterator, string $bom): Iterator
    {
        if ('' === $bom) {
            return $iterator;
        }

        $bom_length = mb_strlen($bom);
        $mapper = function (array $record, int $index) use ($bom_length): array {
            if (0 !== $index) {
                return $record;
            }

            $record = $this->removeBOM($record, $bom_length, $this->enclosure);
            if ([''] === $record) {
                return [null];
            }

            return $record;
        };

        $filter = function (array $record): bool {
            return $this->is_empty_records_included || $record != [null];
        };

        return new CallbackFilterIterator(new MapIterator($iterator, $mapper), $filter);
    }

    /**
     * Selects the record to be used as the CSV header.
     *
     * Because the header is represented as an array, to be valid
     * a header MUST contain only unique string value.
     *
     * @param int|null $offset the header record offset
     *
     * @throws Exception if the offset is a negative integer
     *
     * @return static
     */
    public function setHeaderOffset(?int $offset): self
    {
        if ($offset === $this->header_offset) {
            return $this;
        }

        if (null !== $offset && 0 > $offset) {
            throw InvalidArgument::dueToInvalidHeaderOffset($offset, __METHOD__);
        }

        $this->header_offset = $offset;
        $this->resetProperties();

        return $this;
    }

    /**
     * Enable skipping empty records.
     */
    public function skipEmptyRecords(): self
    {
        if ($this->is_empty_records_included) {
            $this->is_empty_records_included = false;
            $this->nb_records = -1;
        }

        return $this;
    }

    /**
     * Disable skipping empty records.
     */
    public function includeEmptyRecords(): self
    {
        if (!$this->is_empty_records_included) {
            $this->is_empty_records_included = true;
            $this->nb_records = -1;
        }

        return $this;
    }

    /**
     * Tells whether empty records are skipped by the instance.
     */
    public function isEmptyRecordsIncluded(): bool
    {
        return $this->is_empty_records_included;
    }
}
