<?php

declare(strict_types=1);

namespace Autogedal\FeedMonitor\Model\Fetch;

/**
 * CSV feed. With a header, rows are keyed by column name and the header must hold the key and required columns;
 * without one, columns are indexes. Rows missing a required column, with a non numeric value where a number is
 * expected or with a value not matching its pattern are rejected; the feed is invalid when no row is left (an error
 * page, another file) or when more than the allowed share of its rows is rejected (a corrupt feed is not used in
 * part). Quoted values may span several lines.
 */
class CsvFormat implements FeedFormatInterface
{
    /**
     * @param string|int $key column with the product code
     * @param array<int, string|int> $required columns each row must have
     * @param array<int, string|int> $numeric columns that must hold a number
     * @param array<string|int, string> $patterns column => regular expression its value must match
     * @param float $maxRejectedShare above this share of rejected rows the whole feed is invalid
     */
    public function __construct(
        private readonly string $separator = ',',
        private readonly bool $header = false,
        private readonly string|int $key = 0,
        private readonly array $required = [],
        private readonly array $numeric = [],
        private readonly array $patterns = [],
        private readonly float $maxRejectedShare = RejectedRows::DEFAULT_MAX_SHARE
    ) {
    }

    public function parse(string $content): ParsedFeed
    {
        $stream = fopen('php://temp', 'r+');
        fwrite($stream, $content);
        rewind($stream);

        try {
            $names = $this->header ? $this->readHeader($stream, $content) : null;
            $rows = [];
            $skipped = 0;
            while (($data = fgetcsv($stream, 0, $this->separator, '"', '\\')) !== false) {
                if ($data === [null]) {
                    continue;
                }
                $row = $names === null ? $data : $this->named($names, $data);
                $code = trim((string)($row[$this->key] ?? ''));
                if ($code === '') {
                    continue;
                }
                if (!$this->matches($row)) {
                    $skipped++;
                    continue;
                }
                $rows[$code] = $row;
            }
        } finally {
            fclose($stream);
        }

        RejectedRows::check(count($rows), $skipped, $this->maxRejectedShare, $content);

        return new ParsedFeed($rows, $skipped);
    }

    /**
     * @param resource $stream
     * @return string[]
     */
    private function readHeader($stream, string $content): array
    {
        $names = fgetcsv($stream, 0, $this->separator, '"', '\\');
        if (!$names || $names === [null]) {
            throw InvalidFeedException::withSample('Antetul CSV nu poate fi citit.', $content);
        }
        $names = array_map(static fn ($name): string => trim((string)$name), $names);
        $names[0] = (string)preg_replace('/^\xEF\xBB\xBF/', '', $names[0]);

        $missing = array_diff(
            array_unique(array_merge([$this->key], $this->required, $this->numeric, array_keys($this->patterns))),
            $names
        );
        if ($missing) {
            throw new InvalidFeedException(sprintf(
                'Coloanele necesare lipsesc din antetul CSV: %s. Antet: %s',
                implode(', ', $missing),
                mb_substr(implode($this->separator, $names), 0, 300)
            ));
        }

        return $names;
    }

    /**
     * @param string[] $names
     * @param array<int, string|null> $data
     * @return array<string, string>
     */
    private function named(array $names, array $data): array
    {
        $row = [];
        foreach ($names as $index => $name) {
            if (array_key_exists($index, $data)) {
                $row[$name] = (string)$data[$index];
            }
        }

        return $row;
    }

    private function matches(array $row): bool
    {
        foreach ($this->required as $column) {
            if (!array_key_exists($column, $row)) {
                return false;
            }
        }
        foreach ($this->numeric as $column) {
            if (!isset($row[$column]) || !is_numeric(trim((string)$row[$column]))) {
                return false;
            }
        }
        foreach ($this->patterns as $column => $pattern) {
            if (!isset($row[$column]) || !preg_match($pattern, (string)$row[$column])) {
                return false;
            }
        }

        return true;
    }
}
