Lines 98.27% 57 / 58
Functions and Methods 80.00% 4 / 5
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
AgentStatCsvImporter 98.27% 57 / 58 80.00% 4 / 5 26 0.00% 0 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 import 100.00% 16 / 16 100.00% 1 / 1 7
 defaults 88.88% 8 / 9 0.00% 0 / 1 4.02
 importRow 100.00% 24 / 24 100.00% 1 / 1 8
 values 100.00% 8 / 8 100.00% 1 / 1 6
1<?php
2
3declare(strict_types=1);
4
5namespace App\Import;
6
7use App\Dto\AgentStatInput;
8use App\Entity\User;
9use App\Exception\AgentNameException;
10use App\Exception\StatsAlreadyAddedException;
11use App\Service\AgentStatService;
12use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
13use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
14use Symfony\Component\Validator\ConstraintViolationInterface;
15use Symfony\Component\Validator\Validator\ValidatorInterface;
16
17/**
18 * Imports agent stats from a CSV file with a header row of agent_stat column names, as
19 * 4e-website exports them (Migrate → Export CSV). Each row is saved like a POST /api/agent-stats
20 * (AgentStatInput, validated, AgentStatService): stats already imported are skipped, invalid
21 * rows and stats of another agent are reported. Columns AgentTools does not know are ignored;
22 * rows without nickname or faction (4e-website's before May 2020) get them from the file.
23 */
24final readonly class AgentStatCsvImporter
25{
26    /** Stay text: the agent, the faction and the datetime. */
27    private const array TEXT_COLUMNS = ['datetime', 'nickname', 'faction'];
28
29    public function __construct(
30        private DenormalizerInterface $denormalizer,
31        private ValidatorInterface $validator,
32        private AgentStatService $agentStatService,
33    ) {
34    }
35
36    /**
37     * @param resource $csv
38     */
39    public function import(User $owner, $csv): CsvImportResult
40    {
41        $result = new CsvImportResult();
42
43        $header = \fgetcsv($csv, escape: '');
44        if (false === $header || [] === \array_filter($header, static fn (?string $column): bool => null !== $column && '' !== \trim($column))) {
45            $result->errors[] = '1: no header row';
46
47            return $result;
48        }
49
50        $columns = \array_map(static fn (?string $column): string => \trim((string) $column), $header);
51        $rows = [];
52        $line = 1;
53        while (false !== ($row = \fgetcsv($csv, escape: ''))) {
54            ++$line;
55            // Empty lines
56            if ([null] !== $row) {
57                $rows[$line] = $row;
58            }
59        }
60
61        $defaults = $this->defaults($owner, $columns, $rows);
62        foreach ($rows as $number => $row) {
63            $this->importRow($owner, $columns, $row, $number, $defaults, $result);
64        }
65
66        return $result;
67    }
68
69    /**
70     * For rows without them: 4e-website's rows before May 2020 have no nickname and faction. The
71     * owner's agent name (else the file's first nickname) and the file's first faction.
72     *
73     * @param list<string>                  $columns
74     * @param array<int, list<string|null>> $rows
75     *
76     * @return array{nickname: string, faction: string}
77     */
78    private function defaults(User $owner, array $columns, array $rows): array
79    {
80        $first = static function (string $column) use ($columns, $rows): string {
81            $index = \array_search($column, $columns, true);
82            foreach ($rows as $row) {
83                $value = false === $index ? '' : \trim((string) ($row[$index] ?? ''));
84                if ('' !== $value) {
85                    return $value;
86                }
87            }
88
89            return '';
90        };
91
92        return ['nickname' => $owner->getAgentName() ?? $first('nickname'), 'faction' => $first('faction')];
93    }
94
95    /**
96     * @param list<string>                             $columns
97     * @param list<string|null>                        $row
98     * @param array{nickname: string, faction: string} $defaults
99     */
100    private function importRow(User $owner, array $columns, array $row, int $line, array $defaults, CsvImportResult $result): void
101    {
102        if (\count($row) !== \count($columns)) {
103            $result->errors[] = \sprintf('%d: %d values for %d columns', $line, \count($row), \count($columns));
104
105            return;
106        }
107
108        try {
109            $values = $this->values(\array_combine($columns, $row));
110            foreach ($defaults as $column => $default) {
111                if ('' === ($values[$column] ?? '')) {
112                    $values[$column] = $default;
113                }
114            }
115
116            $input = $this->denormalizer->denormalize($values, AgentStatInput::class);
117        } catch (NotNormalizableValueException $notNormalizableValueException) {
118            // E.g. text for a stat
119            $result->errors[] = \sprintf('%d: %s: %s', $line, $notNormalizableValueException->getPath() ?? '', $notNormalizableValueException->getMessage());
120
121            return;
122        }
123
124        $violations = $this->validator->validate($input);
125        if (\count($violations) > 0) {
126            $result->errors[] = $line.': '.\implode(' ', \array_map(
127                static fn (ConstraintViolationInterface $violation): string => \trim($violation->getPropertyPath().': '.$violation->getMessage(), ': '),
128                \iterator_to_array($violations),
129            ));
130
131            return;
132        }
133
134        try {
135            $this->agentStatService->create($owner, $input);
136            ++$result->imported;
137        } catch (StatsAlreadyAddedException) {
138            ++$result->skipped;
139        } catch (AgentNameException $agentNameException) {
140            $result->errors[] = $line.': '.$agentNameException->getMessage();
141        }
142    }
143
144    /**
145     * The values as AgentStatInput takes them: numbers for the stats, null for empty ones.
146     *
147     * @param array<string, string|null> $row
148     *
149     * @return array<string, int|string|null>
150     */
151    private function values(array $row): array
152    {
153        $values = [];
154        foreach ($row as $column => $value) {
155            $value = \trim((string) $value);
156            $values[$column] = match (true) {
157                \in_array($column, self::TEXT_COLUMNS, true) => $value,
158                '' === $value => null,
159                // Not a number: left as it is, the denormalizer reports it
160                1 === \preg_match('/^\d+$/', $value) => (int) $value,
161                default => $value,
162            };
163        }
164
165        return $values;
166    }
167}