<?php

namespace App\Services\Mail;

use App\Services\Secrets\SecretVault;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;

/**
 * IMAP over a TLS socket, by hand.
 *
 * ## Why by hand
 *
 * Because the alternative is a dependency, and this needs four IMAP verbs:
 * LOGIN, SELECT, SEARCH UNSEEN, FETCH. The parsing that follows is the real
 * work either way, and it is bounded here by what the console actually shows: a
 * sender, a subject, the text of the message, and the NAMES of any attachments.
 *
 * ## What it deliberately does not do
 *
 * It does not download attachments — only the part headers are read, so a name
 * and a size are known and the file is left on the server. It does not render
 * HTML mail: where a message has both, the text part wins, and where it has
 * only HTML the tags are stripped. It does not delete anything.
 *
 * A message it cannot parse is skipped and logged, never guessed at. A garbled
 * question in front of an operator is worse than a missing one they go and read
 * in the mailbox — the original is still there either way.
 *
 * ## The connection
 *
 * IMAPS on 993 by default. Plain 143 is possible but not the default and not
 * recommended: this carries a password and the customers' own words.
 */
class ImapMailbox implements InboundMailbox
{
    /** @var resource|null */
    private $socket = null;

    private int $sequence = 0;

    /**
     * Configured means: somebody entered a host, a user and a password.
     *
     * The first two come from the console (ProvisioningSettings), the password
     * from the VAULT — read here, at the point of use. It used to read
     * config('services.inbound_mail.password'), which is only ever the .env
     * value: this project deliberately does not overlay stored secrets onto
     * config at boot (SecretVault's docblock, rule 3). So a password saved in
     * the console counted for nothing, isConfigured() stayed false, and the
     * inbox went on saying no mailbox was set up while the password sat right
     * there on the settings page. Reported exactly that way.
     *
     * SecretVault::get() falls back to the configured value when nothing is
     * stored, so an installation carrying INBOUND_MAIL_PASSWORD in .env keeps
     * working unchanged.
     *
     * No separate "enabled" switch to forget: a mailbox with credentials IS the
     * switch, and one without them cannot be polled anyway.
     */
    public function isConfigured(): bool
    {
        $mailbox = ProvisioningSettings::inboundMailbox();

        return filled($mailbox['host'])
            && filled($mailbox['username'])
            && filled($this->password());
    }

    /** The mailbox password, from the vault, falling back to .env. */
    private function password(): string
    {
        return (string) app(SecretVault::class)->get('inbound_mail.password');
    }

    /**
     * Reach the mailbox and count what is waiting.
     *
     * Deliberately the same four verbs the fetch uses — LOGIN, SELECT, SEARCH —
     * so a green check means the thing that actually runs would work. A check
     * that proves something easier than the real job is worse than none: it was
     * exactly that mistake (a test computing its expectation with the code under
     * test) the timezone rule was written about.
     *
     * @return array{ok: bool, message: string, unseen: int|null}
     */
    public function check(): array
    {
        if (! $this->isConfigured()) {
            return ['ok' => false, 'message' => 'incomplete', 'unseen' => null];
        }

        try {
            $this->connect();
            $unseen = count($this->search());

            return ['ok' => true, 'message' => '', 'unseen' => $unseen];
        } catch (Throwable $e) {
            // The message, not the trace, and never the command: LOGIN carries
            // the password and this string is shown on a page.
            return ['ok' => false, 'message' => $this->reason($e), 'unseen' => null];
        } finally {
            $this->disconnect();
        }
    }

    /**
     * One line an operator can act on, with nothing secret in it.
     *
     * IMAP servers answer a failed LOGIN with their own prose, and this class
     * puts the command in the exception message — which for LOGIN would be the
     * password. So the text is matched, never passed through.
     */
    private function reason(Throwable $e): string
    {
        $text = strtolower($e->getMessage());

        return match (true) {
            str_contains($text, 'could not reach') => 'unreachable',
            str_contains($text, 'authenticationfailed') || str_contains($text, 'login') => 'credentials',
            str_contains($text, 'nonexistent') || str_contains($text, 'select') => 'folder',
            default => 'failed',
        };
    }

    /** @return array<int, FetchedMail> */
    public function fetch(int $limit = 50): array
    {
        if (! $this->isConfigured()) {
            return [];
        }

        try {
            $this->connect();

            $uids = $this->search();
            $mails = [];

            foreach (array_slice($uids, 0, $limit) as $uid) {
                $raw = $this->fetchRaw($uid);

                if ($raw === null) {
                    continue;
                }

                try {
                    $mails[] = MailParser::parse($uid, $raw);
                } catch (Throwable $e) {
                    // Skipped, never guessed at: a garbled question in front of
                    // an operator is worse than one they go and read in the
                    // mailbox, where it still is.
                    Log::warning('Could not parse an inbound mail', ['uid' => $uid, 'exception' => $e]);
                }
            }

            return $mails;
        } catch (Throwable $e) {
            // A mailbox that cannot be reached is not an outage of this
            // application. The console still shows every mail already fetched;
            // the next run tries again.
            Log::error('Could not read the support mailbox', ['exception' => $e]);

            return [];
        } finally {
            $this->disconnect();
        }
    }

    public function markSeen(string $uid): void
    {
        if (! $this->isConfigured()) {
            return;
        }

        try {
            $this->connect();
            $this->command('UID STORE '.$uid.' +FLAGS (\\Seen)');
        } catch (Throwable $e) {
            // Worth logging and not worth failing over: the ingest deduplicates
            // on Message-ID, so a flag that did not stick costs one re-read,
            // not a duplicate row.
            Log::warning('Could not flag an inbound mail as seen', ['uid' => $uid, 'exception' => $e]);
        } finally {
            $this->disconnect();
        }
    }

    // ---- the socket ----

    private function connect(): void
    {
        if ($this->socket !== null) {
            return;
        }

        $mailbox = ProvisioningSettings::inboundMailbox();
        $host = $mailbox['host'];
        $port = $mailbox['port'];
        // 993 is implicit TLS. Anything else is assumed to be plain, which the
        // configuration documents as the discouraged case.
        $endpoint = ($port === 993 ? 'ssl://' : 'tcp://').$host.':'.$port;

        $socket = @stream_socket_client($endpoint, $errno, $error, 15);

        if ($socket === false) {
            throw new RuntimeException("Could not reach {$host}:{$port} — {$error}");
        }

        stream_set_timeout($socket, 20);
        $this->socket = $socket;
        $this->readLine();  // the server's greeting

        $this->command(sprintf(
            'LOGIN %s %s',
            $this->quote($mailbox['username']),
            $this->quote($this->password()),
        ));

        $this->command('SELECT '.$this->quote($mailbox['folder']));
    }

    private function disconnect(): void
    {
        if ($this->socket === null) {
            return;
        }

        try {
            $this->command('LOGOUT');
        } catch (Throwable) {
            // Closing politely is a courtesy, not a requirement.
        }

        fclose($this->socket);
        $this->socket = null;
    }

    /** UIDs of everything unread, oldest first. */
    private function search(): array
    {
        $lines = $this->command('UID SEARCH UNSEEN');

        foreach ($lines as $line) {
            if (str_starts_with($line, '* SEARCH')) {
                $uids = preg_split('/\s+/', trim(substr($line, 8))) ?: [];

                return array_values(array_filter($uids, fn ($uid) => $uid !== '' && ctype_digit($uid)));
            }
        }

        return [];
    }

    /**
     * The message itself, capped.
     *
     * PEEK, so reading it does not mark it seen — that is the ingest's decision
     * to make once the row is safely written, not a side effect of looking.
     */
    private function fetchRaw(string $uid): ?string
    {
        $max = (int) config('services.inbound_mail.max_bytes', 262144);
        $lines = $this->command('UID FETCH '.$uid.' (BODY.PEEK[]<0.'.$max.'>)');
        $body = [];
        $collecting = false;

        foreach ($lines as $line) {
            if (! $collecting && preg_match('/\{\d+\}$/', $line) === 1) {
                $collecting = true;

                continue;
            }

            if ($collecting) {
                $body[] = $line;
            }
        }

        $raw = implode("\r\n", $body);

        return trim($raw) === '' ? null : $raw;
    }

    // ---- the protocol ----

    /**
     * Send one command and read until its tagged response.
     *
     * @return array<int, string>
     */
    private function command(string $command): array
    {
        if ($this->socket === null) {
            throw new RuntimeException('Not connected.');
        }

        $tag = sprintf('A%03d', ++$this->sequence);
        fwrite($this->socket, $tag.' '.$command."\r\n");

        $lines = [];

        while (true) {
            $line = $this->readLine();

            if ($line === null) {
                throw new RuntimeException('The server stopped answering.');
            }

            if (str_starts_with($line, $tag.' ')) {
                if (! str_starts_with($line, $tag.' OK')) {
                    // Never with the command in the message: LOGIN carries the
                    // password, and an exception is written to a log file.
                    throw new RuntimeException('IMAP refused a command: '.substr($line, strlen($tag) + 1));
                }

                return $lines;
            }

            $lines[] = $line;
        }
    }

    private function readLine(): ?string
    {
        if ($this->socket === null) {
            return null;
        }

        $line = fgets($this->socket);

        return $line === false ? null : rtrim($line, "\r\n");
    }

    /** IMAP quoting: a literal string in double quotes, backslash-escaped. */
    private function quote(string $value): string
    {
        return '"'.str_replace(['\\', '"'], ['\\\\', '\\"'], $value).'"';
    }
}
