<?php

namespace App\Services\Mail;

use Illuminate\Support\Carbon;
use RuntimeException;

/**
 * A raw RFC 822 message, reduced to the four things the console shows.
 *
 * Sender, subject, the text a person wrote, and the NAMES of anything attached.
 * Not a general MIME library and not trying to be one: everything it cannot
 * make sense of is reported as unparseable so the caller can skip the message
 * and leave it in the mailbox, where it can still be read by a mail client.
 *
 * What it handles, because real mail from real people contains it:
 *
 *  - `=?UTF-8?B?…?=` and `=?…?Q?…?=` encoded headers (every German subject
 *    line with an umlaut in it);
 *  - quoted-printable and base64 bodies;
 *  - multipart/alternative — the text part wins over the HTML one, because a
 *    console is not a mail client and a person's own words are in the text;
 *  - multipart/mixed with attachments, whose headers are read for a name and a
 *    size while the content itself is thrown away unread.
 *
 * Charsets are converted to UTF-8, since that is what the database column is
 * and a Windows-1252 umlaut stored raw is a broken character for ever.
 */
final class MailParser
{
    /** How deep a nested multipart is followed before giving up. */
    private const MAX_DEPTH = 4;

    public static function parse(string $uid, string $raw): FetchedMail
    {
        [$headerBlock, $body] = self::split($raw);
        $headers = self::headers($headerBlock);

        $from = self::address($headers['from'] ?? '');

        if ($from['email'] === '') {
            throw new RuntimeException('A message with no sender is not one we can answer.');
        }

        $parts = self::parts($headers, $body);

        return new FetchedMail(
            uid: $uid,
            // Falling back to the UID is deliberate: Message-ID is required by
            // the standard and missing in practice often enough that dropping
            // the mail would lose real questions. The UID is unique per
            // mailbox, which is the same guarantee for our purposes.
            messageId: trim($headers['message-id'] ?? '') !== ''
                ? trim($headers['message-id'])
                : 'uid-'.$uid,
            fromEmail: $from['email'],
            fromName: $from['name'],
            subject: self::decodeHeader($headers['subject'] ?? ''),
            body: $parts['text'],
            receivedAt: self::date($headers['date'] ?? null),
            attachments: $parts['attachments'],
        );
    }

    /** @return array{0: string, 1: string} */
    private static function split(string $raw): array
    {
        $break = strpos($raw, "\r\n\r\n");

        if ($break === false) {
            $break = strpos($raw, "\n\n");

            if ($break === false) {
                throw new RuntimeException('No header/body boundary.');
            }

            return [substr($raw, 0, $break), substr($raw, $break + 2)];
        }

        return [substr($raw, 0, $break), substr($raw, $break + 4)];
    }

    /**
     * Header lines, unfolded and lower-cased by name.
     *
     * @return array<string, string>
     */
    private static function headers(string $block): array
    {
        // Unfold first: a long Subject is legally split across lines with the
        // continuation indented, and reading it line by line would cut it.
        $unfolded = preg_replace('/\r?\n[ \t]+/', ' ', $block) ?? $block;
        $headers = [];

        foreach (preg_split('/\r?\n/', $unfolded) ?: [] as $line) {
            $colon = strpos($line, ':');

            if ($colon === false) {
                continue;
            }

            $name = strtolower(trim(substr($line, 0, $colon)));

            // First wins. A message with two Subject headers is malformed, and
            // the second one is the interesting one to an attacker.
            if (! array_key_exists($name, $headers)) {
                $headers[$name] = trim(substr($line, $colon + 1));
            }
        }

        return $headers;
    }

    /** @return array{email: string, name: ?string} */
    private static function address(string $value): array
    {
        $value = self::decodeHeader($value);

        if (preg_match('/<([^>]+)>/', $value, $m) === 1) {
            $name = trim(str_replace('"', '', substr($value, 0, strpos($value, '<'))));

            return ['email' => strtolower(trim($m[1])), 'name' => $name === '' ? null : $name];
        }

        return ['email' => strtolower(trim($value)), 'name' => null];
    }

    /** RFC 2047: `=?charset?B|Q?text?=`, possibly several times in one line. */
    private static function decodeHeader(string $value): string
    {
        $decoded = preg_replace_callback(
            '/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/',
            function (array $m): string {
                $text = strtoupper($m[2]) === 'B'
                    ? (base64_decode($m[3], true) ?: '')
                    // Underscore is a space in Q encoding — the one way it
                    // differs from quoted-printable, and the reason a decoded
                    // subject otherwise reads "Ihre_Frage".
                    : quoted_printable_decode(str_replace('_', ' ', $m[3]));

                return self::toUtf8($text, $m[1]);
            },
            $value,
        );

        return trim($decoded ?? $value);
    }

    /**
     * The readable text, and what was attached.
     *
     * @param  array<string, string>  $headers
     * @return array{text: string, attachments: array<int, array{name: string, bytes: int}>}
     */
    private static function parts(array $headers, string $body, int $depth = 0): array
    {
        $contentType = strtolower($headers['content-type'] ?? 'text/plain');

        if (str_starts_with($contentType, 'multipart/') && $depth < self::MAX_DEPTH) {
            return self::multipart($headers, $body, $contentType, $depth);
        }

        // An attachment at the top level: a bare file mailed on its own.
        $name = self::attachmentName($headers);

        if ($name !== null) {
            return ['text' => '', 'attachments' => [['name' => $name, 'bytes' => strlen($body)]]];
        }

        return ['text' => self::text($headers, $body, $contentType), 'attachments' => []];
    }

    /**
     * @param  array<string, string>  $headers
     * @return array{text: string, attachments: array<int, array{name: string, bytes: int}>}
     */
    private static function multipart(array $headers, string $body, string $contentType, int $depth): array
    {
        if (preg_match('/boundary="?([^";]+)"?/i', $headers['content-type'] ?? '', $m) !== 1) {
            throw new RuntimeException('A multipart message with no boundary.');
        }

        $chunks = explode('--'.$m[1], $body);
        $text = '';
        $html = '';
        $attachments = [];

        foreach ($chunks as $chunk) {
            $chunk = ltrim($chunk, "\r\n");

            if ($chunk === '' || str_starts_with($chunk, '--')) {
                continue;   // the preamble and the closing delimiter
            }

            try {
                [$partHeaderBlock, $partBody] = self::split($chunk);
            } catch (RuntimeException) {
                continue;
            }

            $partHeaders = self::headers($partHeaderBlock);
            $partType = strtolower($partHeaders['content-type'] ?? 'text/plain');
            $name = self::attachmentName($partHeaders);

            if ($name !== null) {
                // The name and the size, and then we stop reading. Keeping the
                // file itself would make this application a store of whatever
                // strangers choose to send it.
                $attachments[] = ['name' => $name, 'bytes' => strlen($partBody)];

                continue;
            }

            if (str_starts_with($partType, 'multipart/')) {
                $nested = self::parts($partHeaders, $partBody, $depth + 1);
                $text = $text !== '' ? $text : $nested['text'];
                $attachments = array_merge($attachments, $nested['attachments']);

                continue;
            }

            $decoded = self::text($partHeaders, $partBody, $partType);

            if (str_starts_with($partType, 'text/plain') && $text === '') {
                $text = $decoded;
            } elseif (str_starts_with($partType, 'text/html') && $html === '') {
                $html = $decoded;
            }
        }

        // Plain wins. A console is not a mail client, and what a person wrote is
        // in the text part; the HTML one is the same words wrapped in markup.
        return ['text' => $text !== '' ? $text : $html, 'attachments' => $attachments];
    }

    /** @param  array<string, string>  $headers */
    private static function attachmentName(array $headers): ?string
    {
        $disposition = $headers['content-disposition'] ?? '';

        if (preg_match('/filename="?([^";]+)"?/i', $disposition, $m) === 1) {
            return self::decodeHeader($m[1]);
        }

        // Some senders put the name only on the type, with no disposition.
        if (preg_match('/name="?([^";]+)"?/i', $headers['content-type'] ?? '', $m) === 1) {
            return self::decodeHeader($m[1]);
        }

        return str_starts_with(strtolower($disposition), 'attachment') ? 'unbenannt' : null;
    }

    /** @param  array<string, string>  $headers */
    private static function text(array $headers, string $body, string $contentType): string
    {
        $encoding = strtolower(trim($headers['content-transfer-encoding'] ?? '7bit'));

        $decoded = match ($encoding) {
            'base64' => base64_decode(preg_replace('/\s+/', '', $body) ?? '', true) ?: '',
            'quoted-printable' => quoted_printable_decode($body),
            default => $body,
        };

        $charset = preg_match('/charset="?([^";]+)"?/i', $contentType, $m) === 1 ? $m[1] : 'UTF-8';
        $decoded = self::toUtf8($decoded, $charset);

        if (str_starts_with($contentType, 'text/html')) {
            // Not a renderer: the tags come out, the words stay. The console
            // shows this as text and escapes it on the way to the page.
            $decoded = html_entity_decode(strip_tags($decoded), ENT_QUOTES | ENT_HTML5, 'UTF-8');
        }

        return trim($decoded);
    }

    private static function toUtf8(string $text, string $charset): string
    {
        $charset = strtoupper(trim($charset));

        if ($charset === '' || $charset === 'UTF-8' || $charset === 'US-ASCII') {
            return $text;
        }

        $converted = @mb_convert_encoding($text, 'UTF-8', $charset);

        return $converted === false ? $text : $converted;
    }

    private static function date(?string $value): Carbon
    {
        if ($value === null || trim($value) === '') {
            return Carbon::now();
        }

        try {
            return Carbon::parse($value);
        } catch (\Throwable) {
            // An unreadable Date header is not a reason to lose the message —
            // and "when we read it" is closer to the truth than a guess.
            return Carbon::now();
        }
    }
}
