<?php

namespace MintyPHP\Service\Export;

use InvalidArgumentException;

/**
 * Writes CSV rows to a stream.
 *
 * Two flavors:
 * - csv:   comma delimited, UTF-8, no BOM           (RFC-4180-ish)
 * - excel: semicolon delimited, UTF-8 with BOM      (opens natively in Excel on Windows)
 *
 * Values are sanitized against formula injection (=, @, +, -).
 * HTTP headers and output buffering are handled by the caller
 * (see core/Support/helpers/export.php).
 */
final class CsvExportService
{
    public const FLAVOR_CSV = 'csv';
    public const FLAVOR_EXCEL = 'excel';

    private const UTF8_BOM = "\xEF\xBB\xBF";

    /**
     * @param resource $handle
     * @param iterable<array<string,mixed>> $rows
     * @param list<ExportColumn> $columns
     */
    public function writeTo($handle, iterable $rows, array $columns, string $flavor = self::FLAVOR_CSV): void
    {
        if (!is_resource($handle)) {
            throw new InvalidArgumentException('writeTo() expects an open stream resource');
        }
        if ($columns === []) {
            throw new InvalidArgumentException('At least one column is required');
        }

        $delimiter = $flavor === self::FLAVOR_EXCEL ? ';' : ',';

        if ($flavor === self::FLAVOR_EXCEL) {
            fwrite($handle, self::UTF8_BOM);
        }

        $headers = array_map(
            static fn (ExportColumn $c): string => self::escapeValue($c->header),
            $columns
        );
        fputcsv($handle, $headers, $delimiter, '"', '\\');

        foreach ($rows as $row) {
            $line = [];
            foreach ($columns as $column) {
                $value = ($column->extract)($row);
                $line[] = self::escapeValue($value, $column->allowSignedNumeric);
            }
            fputcsv($handle, $line, $delimiter, '"', '\\');
        }
    }

    /**
     * Prefixes a single apostrophe to values that would be interpreted
     * as a formula by spreadsheet apps. Defuses the leading-character
     * set recommended by OWASP CSV Injection: `=`, `@`, `+`, `-`, TAB, CR.
     *
     * When $allowSignedNumeric is true, purely numeric/phone-shaped values
     * starting with + or - are left intact (useful for `+49 …` columns).
     *
     * @api Called from page actions that build rows manually
     */
    public static function escapeValue(mixed $value, bool $allowSignedNumeric = false): string
    {
        if ($value === null) {
            return '';
        }
        if (is_bool($value)) {
            return $value ? '1' : '0';
        }
        $string = (string) $value;
        if ($string === '') {
            return '';
        }
        $first = $string[0];
        if ($first === '=' || $first === '@' || $first === "\t" || $first === "\r") {
            return "'" . $string;
        }
        if ($first === '+' || $first === '-') {
            if ($allowSignedNumeric && preg_match('/^[+\-][0-9\s().\-]+$/', $string) === 1) {
                return $string;
            }
            return "'" . $string;
        }
        return $string;
    }
}
