Lines 97.28% 251 / 258
Methods 86.84% 33 / 38
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 bankDomains 100.00% 1 / 1 100.00% 1 / 1 1
 supports 100.00% 8 / 8 100.00% 1 / 1 6
 parse 100.00% 58 / 58 100.00% 1 / 1 14
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] parse n/a 0 / 0 n/a 0 / 0 0
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] tryParse 100.00% 7 / 7 100.00% 1 / 1 4
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] parseAll 100.00% 1 / 1 100.00% 1 / 1 1
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] withoutByteOrderMark 100.00% 6 / 6 100.00% 1 / 1 3
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] requireUniformLineBreaks 100.00% 1 / 1 100.00% 1 / 1 1
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] normalizeHtmlLineBreaks 100.00% 4 / 4 100.00% 1 / 1 3
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] scrubUtf8 100.00% 3 / 3 100.00% 1 / 1 2
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] supportsQuietly 100.00% 3 / 3 100.00% 1 / 1 2
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] money 100.00% 3 / 3 100.00% 1 / 1 2
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] moneyOrNull 100.00% 5 / 5 100.00% 1 / 1 4
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] matched 100.00% 8 / 8 100.00% 1 / 1 3
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] group 100.00% 4 / 4 100.00% 1 / 1 2
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] optionalGroup 100.00% 4 / 4 100.00% 1 / 1 3
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] normalizeAccount 100.00% 3 / 3 100.00% 1 / 1 2
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] transactionsOrFail 100.00% 7 / 7 100.00% 1 / 1 2
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] senderDomain 100.00% 1 / 1 100.00% 1 / 1 1
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] bankDomains 0.00% 0 / 1 0.00% 0 / 1 2
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] withoutFreeText 100.00% 1 / 1 100.00% 1 / 1 1
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] assertBalanceChain 78.57% 11 / 14 0.00% 0 / 1 10.98
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] balanceAmount 90.00% 9 / 10 0.00% 0 / 1 5.03
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] maskFreeText 100.00% 1 / 1 100.00% 1 / 1 1
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] splitOutsideFreeText 92.85% 13 / 14 0.00% 0 / 1 7.02
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] field 100.00% 2 / 2 100.00% 1 / 1 1
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] lineValue 100.00% 4 / 4 100.00% 1 / 1 3
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] paymentSymbol 100.00% 5 / 5 100.00% 1 / 1 5
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] rewriteFreeText 97.14% 34 / 35 0.00% 0 / 1 20
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] isFreeTextLabel 100.00% 1 / 1 100.00% 1 / 1 1
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] payerKind 100.00% 13 / 13 100.00% 1 / 1 12
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] leadingLabel 100.00% 3 / 3 100.00% 1 / 1 2
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] isBlankLine 100.00% 3 / 3 100.00% 1 / 1 2
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] couldBeBankLine 100.00% 3 / 3 100.00% 1 / 1 4
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] isBankCloser 100.00% 5 / 5 100.00% 1 / 1 6
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] isExactBalanceLine 100.00% 5 / 5 100.00% 1 / 1 1
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] isDigitSymbolLine 100.00% 2 / 2 100.00% 1 / 1 1
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] isExactAccountLine 100.00% 6 / 6 100.00% 1 / 1 1
 [Tomaj\BankMailsParser\Parser\ParsesBankMail] isTatraBalanceLine 100.00% 3 / 3 100.00% 1 / 1 1
14final readonly class TatraBankaMailParser implements ParserInterface
15{
16    use ParsesBankMail;
17
18    private const string TRANSACTION = '/^' . MailWhitespace::WS . '(\d{1,2}\.[ \t\x{00A0}\x{2009}\x{202F}]*\d{1,2}\.[ \t\x{00A0}\x{2009}\x{202F}]*\d{2,4}(?:[ \t\x{00A0}\x{2009}\x{202F}]+\d{1,2}:\d{2}(?::\d{2})?)?|\d{4}-\d{2}-\d{2}(?:[ \t\x{00A0}\x{2009}\x{202F}]+\d{1,2}:\d{2}(?::\d{2})?)?|\d{1,2}[ \t\x{00A0}\x{2009}\x{202F}]+[A-Za-z]+[ \t\x{00A0}\x{2009}\x{202F}]+\d{4}(?:[ \t\x{00A0}\x{2009}\x{202F}]+\d{1,2}:\d{2}(?::\d{2})?)?)[ \t\x{00A0}\x{2009}\x{202F}]+bol zostatok Vasho uctu ([A-Za-z0-9]+) (zvyseny|znizeny) o ([0-9 \x{00A0}\x{2009}\x{202F}\t]+,[0-9]+) ([A-Za-z]+)/mu';
19
20    /**
21     * @return list<string>
22     */
23    #[\Override]
24    public function bankDomains(): array
25    {
26        return ['tatrabanka.sk'];
27    }
28
29    #[\Override]
30    public function supports(string $content, ?string $sender = null, ?string $subject = null): bool
31    {
32        return $this->supportsQuietly(function () use ($content, $sender, $subject): bool {
33            $content = $this->withoutFreeText($this->scrubUtf8($this->withoutByteOrderMark($content)));
34            if ($this->matched(self::TRANSACTION, $content)) {
35                return true;
36            }
37
38            $senderMatches = $sender !== null && $this->senderDomain($sender, 'tatrabanka.sk');
39            $subjectMatches = $subject !== null && str_contains(strtolower($subject), 'tatra');
40
41            return ($senderMatches || $subjectMatches) && str_contains($content, 'zostatok Vasho uctu');
42        });
43    }
44
45    public function parse(string $content): MailContent
46    {
47        $original = $this->scrubUtf8($this->requireUniformLineBreaks($content));
48        $description = $this->lineValue($original, 'Popis transakcie');
49        $receiverMessage = $this->lineValue($original, 'Informacia pre prijemcu');
50        $sourceAccountNumber = null;
51        if ($description !== null) {
52            $descriptionParts = explode(' ', $description, 2);
53            $sourceAccountNumber = count($descriptionParts) === 2 ? $descriptionParts[1] : $descriptionParts[0];
54        }
55
56        $content = $this->withoutFreeText($original);
57        if (!$this->matched(self::TRANSACTION, $content, $matches)) {
58            throw InvalidEmailFormatException::malformedContent(
59                self::class,
60                'Could not find required transaction pattern',
61            );
62        }
63
64        $rawDate = trim($this->group($matches, 1));
65        $transactionDate = BankDate::parseFirst($rawDate, [
66            '!j. n. Y G:i:s',
67            '!j.n.Y G:i:s',
68            '!j. n. Y G:i',
69            '!j.n.Y G:i',
70            '!Y-m-d G:i:s',
71            '!Y-m-d H:i:s',
72            '!Y-m-d G:i',
73            '!Y-m-d H:i',
74            '!j. n. Y',
75            '!j.n.Y',
76            '!Y-m-d',
77        ], BankDate::bratislava());
78        if ($transactionDate === null && preg_match('/[A-Za-z]/', $rawDate) === 1) {
79            $transactionDate = BankDate::parseEnglish($rawDate, BankDate::bratislava());
80        }
81
82        $accountNumber = $this->group($matches, 2);
83        $amount = $this->group($matches, 4);
84        if ($this->group($matches, 3) === 'znizeny') {
85            $amount = '-' . $amount;
86        }
87
88        $money = $this->money($amount, $this->group($matches, 5), ',');
89
90        $vs = null;
91        $ss = null;
92        $ks = null;
93        if ($this->matched($this->field('Referencia platitela:', '\/VS(\d*)\/SS(\d*)\/KS(\d*)'), $original, $refMatches)) {
94            $vs = $this->paymentSymbol($this->group($refMatches, 1), null);
95            $ss = $this->paymentSymbol($this->group($refMatches, 2), null);
96            $ks = $this->paymentSymbol($this->group($refMatches, 3), null);
97        }
98
99        // 4.1.0 fallbacks. They read payer text and never the amount, account, or date.
100        if ($vs === null && $this->matched('/vs([0-9]{1,10})(?!\d)/i', $original, $prefixed)) {
101            $vs = $this->group($prefixed, 1);
102        }
103        if ($vs === null && $this->matched('/Informacia pre prijemcu:.*\b([0-9]{1,10})\b.*/i', $original, $fromMessage)) {
104            $vs = $this->group($fromMessage, 1);
105        }
106        if ($vs === null && $this->matched('/Referencia platitela:.*\b([0-9]{1,10})\b.*/i', $original, $fromReference)) {
107            $vs = $this->group($fromReference, 1);
108        }
109
110        return new MailContent(
111            money: $money,
112            transactionDate: $transactionDate,
113            accountNumber: $accountNumber,
114            sourceAccountNumber: $sourceAccountNumber,
115            vs: $vs,
116            ss: $ss,
117            ks: $ks,
118            receiverMessage: $receiverMessage,
119            description: $description,
120        );
121    }
122}

From Tomaj\BankMailsParser\Parser\ParsesBankMail

14trait ParsesBankMail
15{
16    abstract public function parse(string $content): MailContent;
17
18    /**
19     * Invalid input returns null. ConfigurationException is a setup error and is rethrown.
20     * TypeError and Error are programmer errors and are not caught.
21     */
22    public function tryParse(string $content): ?MailContent
23    {
24        try {
25            return $this->parse($content);
26        } catch (ConfigurationException $exception) {
27            throw $exception;
28        } catch (ParseException) {
29            return null;
30        } catch (\InvalidArgumentException|\ValueError|\DateMalformedStringException) {
31            return null;
32        }
33    }
34
35    /**
36     * @return list<MailContent>
37     */
38    public function parseAll(string $content): array
39    {
40        return [$this->parse($content)];
41    }
42
43    /**
44     * Drop a leading BOM. CRLF becomes LF. Lone CR, VT, FF, U+0085, U+2028 and U+2029
45     * become LF only when the mail has no LF at all. A mail that already has LF keeps
46     * those characters as ordinary text: they do not split a line and they do not open
47     * a new transaction. supports() uses this and does not throw.
48     *
49     * The `/m` flag is required: a BOM after a header is still a BOM.
50     *
51     * @internal
52     */
53    protected function withoutByteOrderMark(string $content): string
54    {
55        $stripped = preg_replace('/^\x{FEFF}/mu', '', $content);
56        $content = is_string($stripped) ? $stripped : $content;
57        $content = str_replace("\r\n", "\n", $content);
58        if (str_contains($content, "\n")) {
59            return $content;
60        }
61
62        return str_replace(["\r", "\v", "\f", "\u{0085}", "\u{2028}", "\u{2029}"], "\n", $content);
63    }
64
65    /**
66     * Normalize line breaks. Exotic separators stay in a mail that already has LF.
67     * They are ordinary characters there and cannot open a transaction.
68     *
69     * @internal
70     */
71    protected function requireUniformLineBreaks(string $content): string
72    {
73        return $this->withoutByteOrderMark($content);
74    }
75
76    /**
77     * A single-line HTML ČSOB body uses `<br>`, `</p>` and `</div>` as line breaks.
78     * Turn those into newlines, then drop the remaining tags, so a header can still
79     * start a line. `<br>` does not stay inside an account number.
80     *
81     * @internal
82     */
83    protected function normalizeHtmlLineBreaks(string $content): string
84    {
85        $broken = preg_replace('/<br\s*\/?>|<\/(?:p|div|tr)\s*>|<(?:p|div|tr)\b[^>]*>/iu', "\n", $content);
86        $content = is_string($broken) ? $broken : $content;
87        $stripped = preg_replace('/<[^>\n]*>/', '', $content);
88
89        return is_string($stripped) ? $stripped : $content;
90    }
91
92    /**
93     * Tatra and CardPay patterns use `/u`. Invalid bytes are replaced so a
94     * Windows-1250 body still parses, the way 4.x did without `/u`.
95     *
96     * @internal
97     */
98    protected function scrubUtf8(string $content): string
99    {
100        if (mb_check_encoding($content, 'UTF-8')) {
101            return $content;
102        }
103
104        return mb_scrub($content, 'UTF-8');
105    }
106
107    /**
108     * supports() never throws. A PCRE limit on a huge mail is "not this bank".
109     *
110     * @param \Closure(): bool $probe
111     *
112     * @internal
113     */
114    protected function supportsQuietly(\Closure $probe): bool
115    {
116        try {
117            return $probe();
118        } catch (\Throwable) {
119            return false;
120        }
121    }
122
123    /**
124     * Turn a decimal string into Money. Invalid amounts become InvalidEmailFormatException.
125     */
126    protected function money(string $amount, Currency|string $currency, ?string $decimalSeparator = null): Money
127    {
128        try {
129            return Money::fromDecimalString($amount, $currency, $decimalSeparator);
130        } catch (\InvalidArgumentException $exception) {
131            throw InvalidEmailFormatException::malformedContent(static::class, $exception->getMessage(), $exception);
132        }
133    }
134
135    /**
136     * Same as money(), except a missing, unreadable, or unknown-currency amount is null.
137     * The rest of the transaction is still returned, as in 4.x.
138     */
139    protected function moneyOrNull(string $amount, Currency|string $currency, ?string $decimalSeparator = null): ?Money
140    {
141        if (is_string($currency) && Currency::tryFromCode($currency) === null) {
142            return null;
143        }
144
145        try {
146            return $this->money($amount, $currency, $decimalSeparator);
147        } catch (InvalidEmailFormatException) {
148            return null;
149        }
150    }
151
152    /**
153     * @param array<int|string, string>|null $matches
154     *
155     * @internal
156     */
157    protected function matched(string $pattern, string $subject, ?array &$matches = null): bool
158    {
159        $captured = [];
160        $result = preg_match($pattern, $subject, $captured);
161        if ($result === false) {
162            throw InvalidEmailFormatException::malformedContent(static::class, 'Regular expression failed: ' . preg_last_error_msg());
163        }
164
165        if ($result === 1) {
166            /** @var array<int|string, string> $captured */
167            $matches = $captured;
168
169            return true;
170        }
171
172        return false;
173    }
174
175    /**
176     * @param array<int|string, string>|null $matches
177     *
178     * @internal
179     */
180    protected function group(?array $matches, int $index): string
181    {
182        $value = $matches[$index] ?? null;
183        if (!is_string($value)) {
184            throw InvalidEmailFormatException::malformedContent(static::class, 'Missing regular expression capture');
185        }
186
187        return $value;
188    }
189
190    /**
191     * @param array<int|string, string>|null $matches
192     *
193     * @internal
194     */
195    private function optionalGroup(?array $matches, int $index): ?string
196    {
197        $value = $matches[$index] ?? null;
198        if (!is_string($value) || $value === '') {
199            return null;
200        }
201
202        return $value;
203    }
204
205    /** @internal */
206    private function normalizeAccount(string $value): string
207    {
208        $spaced = str_replace(["\u{00A0}", "\u{2009}", "\u{202F}", "\t"], ' ', $value);
209        $collapsed = preg_replace('/ {2,}/', ' ', trim($spaced));
210
211        return is_string($collapsed) ? $collapsed : trim($spaced);
212    }
213
214    /**
215     * @param list<MailContent> $parsed
216     * @param list<string> $errors
217     *
218     * @return list<MailContent>
219     *
220     * @internal
221     */
222    private function transactionsOrFail(array $parsed, array $errors, ?\Throwable $previous = null): array
223    {
224        if ($errors !== []) {
225            throw InvalidEmailFormatException::malformedContent(
226                static::class,
227                'Could not parse every transaction: ' . implode('; ', $errors),
228                $previous,
229            );
230        }
231
232        return $parsed;
233    }
234
235    /** @internal */
236    protected function senderDomain(string $sender, string $domain): bool
237    {
238        return SenderHost::matches($sender, $domain);
239    }
240
241    /**
242     * @return list<string>
243     */
244    public function bankDomains(): array
245    {
246        return [];
247    }
248
249    /**
250     * Payer-controlled text. Amount, currency, account and date are never read from it.
251     *
252     * @internal
253     */
254    protected function withoutFreeText(string $content): string
255    {
256        return $this->rewriteFreeText($content, false);
257    }
258
259    /**
260     * When a ČSOB mail has more than one transaction and at least one balance line,
261     * each transaction must carry a balance and balance[n-1] + amount[n] = balance[n].
262     *
263     * @param list<?Money> $balances
264     * @param list<MailContent> $parsed
265     *
266     * @internal
267     */
268    private function assertBalanceChain(array $balances, array $parsed): void
269    {
270        $count = count($parsed);
271        for ($index = 0; $index < $count; ++$index) {
272            if (!$balances[$index] instanceof Money) {
273                throw InvalidEmailFormatException::malformedContent(static::class, 'Transaction is missing a balance line');
274            }
275        }
276
277        for ($index = 1; $index < $count; ++$index) {
278            $previous = $balances[$index - 1];
279            $current = $balances[$index];
280            $amount = $parsed[$index]->money;
281            if (!$previous instanceof Money || !$current instanceof Money || !$amount instanceof Money) {
282                throw InvalidEmailFormatException::malformedContent(static::class, 'Balance does not match the previous transaction');
283            }
284            if (!$previous->currency->equals($current->currency) || !$previous->currency->equals($amount->currency)) {
285                throw InvalidEmailFormatException::malformedContent(static::class, 'Balance does not match the previous transaction');
286            }
287            if ($previous->amount + $amount->amount !== $current->amount) {
288                throw InvalidEmailFormatException::malformedContent(static::class, 'Balance does not match the previous transaction');
289            }
290        }
291    }
292
293    /**
294     * The bank prints one balance at the end of the transaction. A second exact
295     * balance line in the same chunk is rejected.
296     *
297     * @internal
298     */
299    private function balanceAmount(string $chunk, string $pattern): ?Money
300    {
301        $found = preg_match_all($pattern, $chunk, $matches, PREG_SET_ORDER);
302        if ($found === false) {
303            throw InvalidEmailFormatException::malformedContent(static::class, 'Regular expression failed: ' . preg_last_error_msg());
304        }
305        if ($found > 1) {
306            throw InvalidEmailFormatException::malformedContent(static::class, 'Transaction has more than one balance line');
307        }
308        if ($found !== 1) {
309            return null;
310        }
311
312        $row = $matches[0];
313        $signed = (($row[1] ?? '') === '-' ? '-' : '') . ($row[2] ?? '');
314
315        return $this->money($signed, $row[3] ?? '', ',');
316    }
317
318    /**
319     * Same byte length as $content. Free-text lines become spaces so a later match offset stays valid.
320     *
321     * @internal
322     */
323    private function maskFreeText(string $content): string
324    {
325        return $this->rewriteFreeText($content, true);
326    }
327
328    /**
329     * @return list<string>
330     *
331     * @internal
332     */
333    private function splitOutsideFreeText(string $content, string $pattern): array
334    {
335        $masked = $this->maskFreeText($content);
336        $found = preg_match_all($pattern, $masked, $matches, PREG_OFFSET_CAPTURE);
337        if ($found === false || $found === 0) {
338            return [$content];
339        }
340
341        $chunks = [];
342        $total = count($matches[0]);
343        for ($index = 0; $index < $total; ++$index) {
344            $start = $matches[0][$index][1] ?? null;
345            $next = $matches[0][$index + 1][1] ?? null;
346            if (!is_int($start)) {
347                continue;
348            }
349            $end = is_int($next) ? $next : strlen($content);
350            $chunks[] = substr($content, $start, $end - $start);
351        }
352
353        return $chunks === [] ? [$content] : $chunks;
354    }
355
356    /**
357     * A label at the start of a line, with the shared whitespace class on both sides.
358     * One builder, so a duplicated `*` class is not copied into every parser.
359     *
360     * @internal
361     */
362    private function field(string $label, string $value): string
363    {
364        $space = MailWhitespace::WS;
365
366        return '/^' . $space . $label . $space . $value . '/mu';
367    }
368
369    /** @internal */
370    private function lineValue(string $content, string $labelPattern): ?string
371    {
372        if (!$this->matched($this->field($labelPattern . ':', '(.*)$'), $content, $matches)) {
373            return null;
374        }
375
376        $value = mb_trim($this->group($matches, 1), " \t\n\r\0\x0B\u{00A0}\u{2009}\u{202F}");
377
378        return $value === '' ? null : $value;
379    }
380
381    /** @internal */
382    private function paymentSymbol(string $digits, ?int $maxDigits = 10): ?string
383    {
384        if ($digits === '' || preg_match('/^\d+$/', $digits) !== 1) {
385            return null;
386        }
387        if ($maxDigits !== null && strlen($digits) > $maxDigits) {
388            return null;
389        }
390
391        return $digits;
392    }
393
394    /**
395     * Payer-written fields.
396     *
397     * ČSOB CZ: Název protistrany, Adresa protistrany, Adresa plátce, Zpráva příjemci,
398     * Účel platby, Identifikace, Reference plátce.
399     * ČSOB SK: názov protiúčtu, meno protiúčtu, informácia pre príjemcu, referencia platiteľa.
400     * Tatra: Informacia pre prijemcu, Popis transakcie, popis, Referencia platitela.
401     * VÚB fixtures have no payer prose field.
402     *
403     * A blank line, or a line of spaces, tabs or NBSP, ends the field.
404     * An arbitrary "Word:" does not. A closing line has to be the bank's own shape:
405     * the full balance sentence, a digits-only symbol line, an account line, or a Tatra balance line.
406     * Lines inside the field are blanked when bank data is read. They do not reject the mail.
407     */
408    private function rewriteFreeText(string $content, bool $mask): string
409    {
410        $parts = preg_split('/(\n)/', $content, -1, PREG_SPLIT_DELIM_CAPTURE);
411        if ($parts === false) {
412            return $content;
413        }
414
415        $active = null;
416        $out = '';
417        foreach ($parts as $part) {
418            if ($part === "\n") {
419                if ($mask || $active === null) {
420                    $out .= $part;
421                }
422
423                continue;
424            }
425
426            if ($active !== null && $this->isBlankLine($part)) {
427                $active = null;
428                $out .= $part;
429
430                continue;
431            }
432
433            // A continuation such as "a" cannot be a label, a closer, or a forged bank line.
434            // Skipping the regexes keeps a long purpose field from scanning every pattern per line.
435            if ($active !== null && !$this->couldBeBankLine($part)) {
436                $out .= $mask ? str_repeat(' ', strlen($part)) : '';
437
438                continue;
439            }
440
441            $next = $this->payerKind($part);
442            if ($active !== null && $next !== null) {
443                $active = $next;
444                $out .= $mask ? str_repeat(' ', strlen($part)) : '';
445
446                continue;
447            }
448
449            if ($active !== null && $this->isBankCloser($part, $active)) {
450                $active = null;
451                $out .= $part;
452
453                continue;
454            }
455
456            if ($next !== null) {
457                $active = $next;
458                $out .= $mask ? str_repeat(' ', strlen($part)) : '';
459
460                continue;
461            }
462
463            if ($active !== null) {
464                $out .= $mask ? str_repeat(' ', strlen($part)) : '';
465
466                continue;
467            }
468
469            $out .= $part;
470        }
471
472        return $out;
473    }
474
475    /** @internal */
476    private function isFreeTextLabel(string $line): bool
477    {
478        return $this->payerKind($line) !== null;
479    }
480
481    private function payerKind(string $line): ?string
482    {
483        $label = $this->leadingLabel($line);
484        if ($label === null) {
485            return null;
486        }
487
488        return match (true) {
489            preg_match('/^(?:název protistrany|adresa plátce|adresa platcu|názov protiúčtu|meno protiúčtu)$/ui', $label) === 1 => 'name',
490            preg_match('/^adresa protistrany$/ui', $label) === 1 => 'address',
491            preg_match('/^zpráva příjemci$/ui', $label) === 1 => 'message',
492            preg_match('/^účel platby$/ui', $label) === 1 => 'purpose',
493            preg_match('/^identifikace$/ui', $label) === 1 => 'identification',
494            preg_match('/^informácia pre príjemcu$/ui', $label) === 1 => 'sk-message',
495            preg_match('/^informacia pre prijemcu$/ui', $label) === 1 => 'tatra-message',
496            preg_match('/^(?:popis transakcie|popis)$/ui', $label) === 1 => 'tatra-description',
497            preg_match('/^(?:reference plátce|referencia platiteľa|referencia platitela)$/ui', $label) === 1 => 'reference',
498            default => null,
499        };
500    }
501
502    /**
503     * Possessive leading whitespace: a multi-megabyte line of spaces must not backtrack.
504     */
505    private function leadingLabel(string $line): ?string
506    {
507        if (preg_match('/^[ \t\x{00A0}\x{2009}\x{202F}\x{FEFF}]*+([^\n:]{1,80})[ \t\x{00A0}\x{2009}\x{202F}]*:/u', $line, $match) !== 1) {
508            return null;
509        }
510
511        return $match[1];
512    }
513
514    private function isBlankLine(string $line): bool
515    {
516        if ($line === '') {
517            return true;
518        }
519
520        return preg_match('/^[ \t\x{00A0}\x{2009}\x{202F}\x{FEFF}]*$/u', $line) === 1;
521    }
522
523    /**
524     * A line with no colon and no date or "Dne"/"dňa" cannot be a bank closer or a forged header.
525     */
526    private function couldBeBankLine(string $line): bool
527    {
528        if (str_contains($line, ':') || str_contains($line, 'Dne') || str_contains($line, 'dňa')) {
529            return true;
530        }
531
532        return preg_match('/\d/u', $line) === 1;
533    }
534
535    /**
536     * A closing line is the bank's own line, not a word the payer can type.
537     * Bare `Zůstatek`, `zostatok`, or `banka:` do not close the field.
538     * Tatra `uctovny zostatok` / `aktualny zostatok` / `disponibilny zostatok` do.
539     */
540    private function isBankCloser(string $line, string $kind): bool
541    {
542        if ($this->isExactBalanceLine($line) || $this->isDigitSymbolLine($line) || $this->isExactAccountLine($line)) {
543            return true;
544        }
545
546        if ($kind === 'tatra-message' || $kind === 'tatra-description') {
547            return $this->isTatraBalanceLine($line);
548        }
549
550        return false;
551    }
552
553    private function isExactBalanceLine(string $line): bool
554    {
555        $ws = MailWhitespace::WS;
556        $amount = '[+-]' . $ws . '(?:\d{1,3}(?:[ \t\x{00A0}\x{2009}\x{202F}]\d{3})*|\d+),\d{2}';
557        $cz = 'zůstatek na účtu po zaúčtování transakce:' . $ws . $amount . $ws . '[A-Z]{3}\.';
558        $sk = 'zostatok na účte po zaúčtovaní sumy platobnej operácie:' . $ws . $amount . $ws . '[A-Z]{3}\.?';
559
560        return preg_match('/^' . $ws . '(?:' . $cz . '|' . $sk . ')' . $ws . '$/ui', $line) === 1;
561    }
562
563    private function isDigitSymbolLine(string $line): bool
564    {
565        $ws = MailWhitespace::WS;
566
567        return preg_match('/^' . $ws . '(?:variabilní symbol|konstantní symbol|specifický symbol)' . $ws . ':' . $ws . '\d+' . $ws . '$/ui', $line) === 1;
568    }
569
570    private function isExactAccountLine(string $line): bool
571    {
572        $ws = MailWhitespace::WS;
573        $domestic = '\d{1,10}(?:-\d{1,10})?\/\d{4}';
574        $iban = '[A-Z]{2}\d{2}(?:[ \t\x{00A0}\x{2009}\x{202F}]?[A-Z0-9]{1,4})+';
575        $account = '(?:' . $iban . '|' . $domestic . ')';
576        $labels = 'účet protistrany(?:\/iban)?|protiúčet|z účtu';
577
578        return preg_match('/^' . $ws . '(?:' . $labels . ')' . $ws . ':' . $ws . $account . $ws . '$/ui', $line) === 1;
579    }
580
581    /**
582     * Tatra prints the balance without a sign: `aktualny zostatok: 142,11 EUR`.
583     * Deleting this rule leaves a later transaction sentence inside the payer message.
584     */
585    private function isTatraBalanceLine(string $line): bool
586    {
587        $ws = MailWhitespace::WS;
588        $amount = '[+-]?' . $ws . '(?:\d{1,3}(?:[ \t\x{00A0}\x{2009}\x{202F}]\d{3})*|\d+),\d{2}';
589
590        return preg_match('/^' . $ws . '(?:uctovny zostatok|aktualny zostatok|disponibilny zostatok)' . $ws . ':' . $ws . $amount . $ws . '[A-Za-z]{3}' . $ws . '$/u', $line) === 1;
591    }
592}