<?php

namespace MintyPHP\Service\Import;

class CsvReaderService
{
    private const DELIMITERS = [',', ';'];

    /**
     * @return array{ok:bool, delimiter?:string, headers?:array<int,string>, rows_total?:int, code?:string}
     */
    public function analyze(string $path, int $maxRows): array
    {
        if (!is_file($path) || !is_readable($path)) {
            return ['ok' => false, 'code' => 'invalid_file_type'];
        }

        $delimiter = $this->detectDelimiter($path);
        $headerMeta = $this->readHeader($path, $delimiter);
        if (!$headerMeta['ok']) {
            $code = isset($headerMeta['code']) ? (string) $headerMeta['code'] : 'invalid_csv_header';
            return ['ok' => false, 'code' => $code];
        }

        $headers = $headerMeta['headers'] ?? [];
        $headerLine = (int) ($headerMeta['header_line'] ?? 1);
        if (!$headers) {
            return ['ok' => false, 'code' => 'invalid_csv_header'];
        }

        $rowsTotal = $this->countRows($path, $delimiter, $headers, $headerLine);
        if ($rowsTotal <= 0) {
            return ['ok' => false, 'code' => 'empty_csv'];
        }
        if ($rowsTotal > $maxRows) {
            return ['ok' => false, 'code' => 'max_rows_exceeded'];
        }

        return [
            'ok' => true,
            'delimiter' => $delimiter,
            'headers' => $headers,
            'rows_total' => $rowsTotal,
        ];
    }

    /**
     * @param array<int, string> $headers
     */
    public function streamRows(string $path, string $delimiter, array $headers, callable $callback): void
    {
        $headerMeta = $this->readHeader($path, $delimiter);
        if (!$headerMeta['ok']) {
            return;
        }
        $headerLine = (int) ($headerMeta['header_line'] ?? 1);

        $handle = @fopen($path, 'rb');
        if (!$handle) {
            return;
        }

        $lineNumber = 0;
        $rowIndex = 0;
        while (($row = fgetcsv($handle, 0, $delimiter, '"', '\\')) !== false) {
            $lineNumber++;
            if ($lineNumber <= $headerLine) {
                continue;
            }

            $row = $this->normalizeRowColumns($row, count($headers));
            if ($this->isEmptyRow($row)) {
                continue;
            }

            $rowIndex++;
            $assoc = [];
            foreach ($headers as $index => $header) {
                $assoc[$header] = $this->cleanCell($row[$index] ?? '');
            }
            $callback($assoc, $lineNumber, $rowIndex);
        }

        fclose($handle);
    }

    // Sample-based detection — reading 5 lines is enough to identify the delimiter without scanning the full file.
    private function detectDelimiter(string $path): string
    {
        $sample = '';
        $handle = @fopen($path, 'rb');
        if ($handle) {
            for ($i = 0; $i < 5; $i++) {
                $line = fgets($handle);
                if ($line === false) {
                    break;
                }
                $sample .= $line;
            }
            fclose($handle);
        }

        $bestDelimiter = ',';
        $bestScore = -1;
        foreach (self::DELIMITERS as $delimiter) {
            $score = substr_count($sample, $delimiter);
            if ($score > $bestScore) {
                $bestScore = $score;
                $bestDelimiter = $delimiter;
            }
        }

        return $bestDelimiter;
    }

    /**
     * @return array{ok:bool, headers?:array<int,string>, header_line?:int, code?:string}
     */
    private function readHeader(string $path, string $delimiter): array
    {
        $handle = @fopen($path, 'rb');
        if (!$handle) {
            return ['ok' => false, 'code' => 'invalid_csv_header'];
        }

        $lineNumber = 0;
        while (($row = fgetcsv($handle, 0, $delimiter, '"', '\\')) !== false) {
            $lineNumber++;
            if (!is_array($row)) {
                continue;
            }
            $row = $this->normalizeRowColumns($row, count($row));
            if ($this->isEmptyRow($row)) {
                continue;
            }

            $headers = [];
            foreach ($row as $index => $cell) {
                $value = $this->cleanCell((string) $cell);
                if ($index === 0) {
                    $value = $this->stripBom($value);
                }
                $headers[] = trim($value);
            }

            fclose($handle);

            if (!$headers) {
                return ['ok' => false, 'code' => 'invalid_csv_header'];
            }

            foreach ($headers as $headerValue) {
                if ($headerValue === '') {
                    return ['ok' => false, 'code' => 'invalid_csv_header'];
                }
            }

            // Case-insensitive duplicate check — "Email" and "email" in the same header row are ambiguous.
            $normalizedKeys = array_map([$this, 'lower'], $headers);
            if (count(array_unique($normalizedKeys)) !== count($normalizedKeys)) {
                return ['ok' => false, 'code' => 'invalid_csv_header'];
            }

            return ['ok' => true, 'headers' => $headers, 'header_line' => $lineNumber];
        }

        fclose($handle);
        return ['ok' => false, 'code' => 'empty_csv'];
    }

    /**
     * @param array<int, mixed> $row
     * @return array<int, string>
     */
    private function normalizeRowColumns(array $row, int $size): array
    {
        $normalized = [];
        foreach ($row as $cell) {
            $normalized[] = $this->cleanCell((string) $cell);
        }

        // Pad short rows and trim over-long rows so every row has exactly as many cells as headers.
        $current = count($normalized);
        if ($current < $size) {
            $normalized = array_pad($normalized, $size, '');
        } elseif ($current > $size) {
            $normalized = array_slice($normalized, 0, $size);
        }

        return $normalized;
    }

    /**
     * @param array<int, string> $headers
     */
    private function countRows(string $path, string $delimiter, array $headers, int $headerLine): int
    {
        $count = 0;
        $handle = @fopen($path, 'rb');
        if (!$handle) {
            return 0;
        }

        $lineNumber = 0;
        while (($row = fgetcsv($handle, 0, $delimiter, '"', '\\')) !== false) {
            $lineNumber++;
            if ($lineNumber <= $headerLine) {
                continue;
            }
            if (!is_array($row)) {
                continue;
            }
            $row = $this->normalizeRowColumns($row, count($headers));
            if ($this->isEmptyRow($row)) {
                continue;
            }
            $count++;
        }

        fclose($handle);
        return $count;
    }

    private function isEmptyRow(array $row): bool
    {
        foreach ($row as $cell) {
            if (trim((string) $cell) !== '') {
                return false;
            }
        }
        return true;
    }

    private function stripBom(string $value): string
    {
        if (str_starts_with($value, "\xEF\xBB\xBF")) {
            return substr($value, 3);
        }
        return $value;
    }

    // Remove null bytes (defense against null byte injection) and UTF-8 BOM from each cell.
    private function cleanCell(string $value): string
    {
        $value = str_replace("\0", '', $value);
        $value = $this->stripBom($value);
        return trim($value);
    }

    private function lower(string $value): string
    {
        if (function_exists('mb_strtolower')) {
            return mb_strtolower($value, 'UTF-8');
        }
        return strtolower($value);
    }
}
