Lines 95.34% 41 / 43
Methods 85.71% 6 / 7
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 parse 100.00% 5 / 5 100.00% 1 / 1 2
 toColumns 100.00% 6 / 6 100.00% 1 / 1 4
 toValue 100.00% 5 / 5 100.00% 1 / 1 3
 createInput 77.77% 7 / 9 0.00% 0 / 1 4.18
 parseRow 100.00% 13 / 13 100.00% 1 / 1 6
 cells 100.00% 4 / 4 100.00% 1 / 1 3
18final readonly class IngressStatsParser
19{
20    /** "Time Span" values of all-time stats, per game language. */
21    private const array ALL_TIME_SPANS = ['ALL TIME', 'GESAMT', 'SIEMPRE'];
22
23    private const array TEXT_COLUMNS = ['nickname', 'faction'];
24
25    /**
26     * Ingress stats header → agent_stat column (4e-website's MedalChecker::$primeHeaders).
27     * Headers not listed here are not stored.
28     */
29    public const array COLUMNS = [
30        'Agent Name' => 'nickname',
31        'Agent Faction' => 'faction',
32        'Lifetime AP' => 'ap',
33        'Unique Portals Visited' => 'explorer',
34        'Portals Discovered' => 'portals_discovered',
35        'Seer Points' => 'seer',
36        'OPR Agreements' => 'recon',
37        'Distance Walked' => 'trekker',
38        'Resonators Deployed' => 'builder',
39        'Links Created' => 'connector',
40        'Control Fields Created' => 'mind_controller',
41        'Mind Units Captured' => 'illuminator',
42        'Longest Link Ever Created' => 'longest_link',
43        'Largest Control Field' => 'largest_field',
44        'XM Recharged' => 'recharger',
45        'Portals Captured' => 'liberator',
46        'Unique Portals Captured' => 'pioneer',
47        'Mods Deployed' => 'engineer',
48        'Resonators Destroyed' => 'purifier',
49        'Unique Missions Completed' => 'specops',
50        'Hacks' => 'hacker',
51        'Glyph Hack Points' => 'translator',
52        // Old and new name
53        'Longest Hacking Streak' => 'sojourner',
54        'Longest Sojourner Streak' => 'sojourner',
55        'Completed Hackstreaks' => 'epoch',
56        // Old and new name
57        'Agents Successfully Recruited' => 'recruiter',
58        'Agents Recruited' => 'recruiter',
59        'Mission Day(s) Attended' => 'missionday',
60        'NL-1331 Meetup(s) Attended' => 'nl1331_meetups',
61        'First Saturday Events' => 'ifs',
62        'Second Sunday Events' => 'second_sunday',
63        'Portal Scans Uploaded' => 'scout',
64        // Old and new name
65        'Scout Controller on Unique Portals' => 'scout_controller',
66        'Uniques Scout Controlled' => 'scout_controller',
67        'Drone Hacks' => 'maverick',
68        'Unique Portals Drone Visited' => 'drone_portals_visited',
69        'Furthest Drone Distance' => 'drone_flight_distance',
70        'Forced Drone Recalls' => 'drone_forced_recalls',
71        'Drones Returned' => 'drones_returned',
72        'Level' => 'level',
73        'Recursions' => 'recursions',
74        'Kinetic Capsules Completed' => 'kinetic_capsules_completed',
75        'Months Subscribed' => 'months_subscribed',
76        // Events recorded by 4e-website
77        'Umbra: Unique Resonator Slots Deployed' => 'current_challenge',
78        'Didact Fields Created' => 'current_challenge',
79        'Operation Chronos Points' => 'current_challenge',
80        'Machina Portals Reclaimed' => 'reclaimer',
81    ];
82
83    public function __construct(
84        private DenormalizerInterface $denormalizer,
85        private ValidatorInterface $validator,
86    ) {
87    }
88
89    /**
90     * @throws InvalidStatsException
91     */
92    public function parse(string $text): AgentStatInput
93    {
94        $row = $this->parseRow($text);
95
96        $timeSpan = $row['Time Span'] ?? '';
97        if (!\in_array($timeSpan, self::ALL_TIME_SPANS, true)) {
98            throw new InvalidStatsException([\sprintf('Only all-time stats can be imported, not "%s".', $timeSpan)]);
99        }
100
101        return $this->createInput($this->toColumns($row));
102    }
103
104    /**
105     * @param array<string, string> $row
106     *
107     * @return array<string, string|int> agent_stat column => value
108     *
109     * @throws InvalidStatsException
110     */
111    private function toColumns(array $row): array
112    {
113        $values = ['datetime' => \trim(($row['Date (yyyy-mm-dd)'] ?? '').' '.($row['Time (hh:mm:ss)'] ?? ''))];
114
115        foreach ($row as $name => $value) {
116            $column = self::COLUMNS[$name] ?? null;
117            if (null !== $column && '' !== $value) {
118                $values[$column] = $this->toValue($column, $name, $value);
119            }
120        }
121
122        return $values;
123    }
124
125    /**
126     * @throws InvalidStatsException
127     */
128    private function toValue(string $column, string $name, string $value): string|int
129    {
130        if (\in_array($column, self::TEXT_COLUMNS, true)) {
131            return $value;
132        }
133
134        if (1 !== \preg_match('/^\d+$/', $value)) {
135            throw new InvalidStatsException([\sprintf('"%s" is not a number: "%s".', $name, $value)]);
136        }
137
138        return (int) $value;
139    }
140
141    /**
142     * @param array<string, string|int> $values
143     *
144     * @throws InvalidStatsException
145     */
146    private function createInput(array $values): AgentStatInput
147    {
148        try {
149            $input = $this->denormalizer->denormalize($values, AgentStatInput::class);
150        } catch (ExceptionInterface $exception) {
151            throw new InvalidStatsException([$exception->getMessage()]);
152        }
153
154        $errors = [];
155        foreach ($this->validator->validate($input) as $violation) {
156            $errors[] = $violation->getPropertyPath().': '.$violation->getMessage();
157        }
158
159        if ([] !== $errors) {
160            throw new InvalidStatsException($errors);
161        }
162
163        return $input;
164    }
165
166    /**
167     * @return array<string, string> header => cell of the single data line
168     *
169     * @throws InvalidStatsException
170     */
171    private function parseRow(string $text): array
172    {
173        $lines = \array_values(\array_filter(
174            \preg_split('/\R/', $text) ?: [],
175            static fn (string $line): bool => '' !== \trim($line),
176        ));
177
178        if (\count($lines) < 2 || !\str_contains($lines[0], "\t")) {
179            throw new InvalidStatsException(['Paste the stats as copied from Ingress: a header line and a data line, separated by tabs.']);
180        }
181
182        if (2 !== \count($lines)) {
183            throw new InvalidStatsException(['Paste exactly one line of stats.']);
184        }
185
186        $header = $this->cells($lines[0]);
187        $cells = $this->cells($lines[1]);
188
189        if (\count($cells) > \count($header)) {
190            throw new InvalidStatsException(['The data line has more fields than the header line.']);
191        }
192
193        return \array_combine($header, \array_pad($cells, \count($header), ''));
194    }
195
196    /**
197     * Ingress ends every line with a tab: trailing empty cells are dropped.
198     *
199     * @return list<string>
200     */
201    private function cells(string $line): array
202    {
203        $cells = \array_map(trim(...), \explode("\t", $line));
204        while ([] !== $cells && '' === \end($cells)) {
205            \array_pop($cells);
206        }
207
208        return $cells;
209    }
210}