<?php

namespace Tests\Support;

use RuntimeException;

/**
 * A throwaway SMTP server for exactly one connection, run as a real
 * subprocess.
 *
 * MailboxTester opens a real socket through Symfony's EsmtpTransport — the
 * same class production traffic uses — so proving its SUCCESS path (not
 * only the refused-connection failure every other test in this file uses)
 * means something has to answer on the other end of a real TCP connection.
 * PHP is single-threaded: a "server" living in the same process as the test
 * could never accept() a connection while that same process is blocked
 * inside the socket call it is supposed to be answering. Hence a genuine
 * child process (proc_open) rather than a Fake* stub swapped through the
 * container, which is how every other fake in this suite (SSH, Proxmox,
 * WireGuard — see tests/Pest.php) works.
 */
final class FakeSmtpServer
{
    /** @var resource */
    private $process;

    /** @var array<int, resource> */
    private array $pipes;

    public readonly int $port;

    private function __construct($process, array $pipes, int $port)
    {
        $this->process = $process;
        $this->pipes = $pipes;
        $this->port = $port;
    }

    /** Accepts one connection and completes a full, successful send. */
    public static function succeeding(): self
    {
        return self::spawn(['failAt' => null]);
    }

    /**
     * Accepts one connection, advertises AUTH LOGIN in its EHLO response,
     * and completes a full successful send — UNLESS the client attempts to
     * authenticate, which is rejected outright.
     *
     * The point is proving a client deliberately did NOT attempt AUTH, not
     * merely that it could succeed without it: succeeding() never advertises
     * AUTH at all, so Symfony's EsmtpTransport::handleAuth() is never even
     * reachable against it — a mailbox that wrongly called setUsername()
     * with a real value would pass every test that uses succeeding() too.
     * Advertising the capability here is what makes the two cases tell
     * apart: "chose not to authenticate" against a server that offered it,
     * versus "had no opportunity to" against one that never did.
     */
    public static function succeedingUnlessAuthAttempted(): self
    {
        return self::spawn(['failAt' => null, 'advertiseAuth' => true]);
    }

    /**
     * Accepts one connection and rejects it with a real SMTP response at the
     * given stage. 'rcpt' is the simplest choice for a caller that just wants
     * *a* rejection: MAIL FROM has already succeeded by then, so the failure
     * is unambiguously the server's own — 'ehlo' is deliberately unsupported
     * because EsmtpTransport retries a failed EHLO as plain HELO rather than
     * giving up, which this one-shot server does not simulate.
     */
    public static function rejectingAt(string $stage, string $response): self
    {
        return self::spawn(['failAt' => $stage, 'failResponse' => $response]);
    }

    private static function spawn(array $config): self
    {
        $process = proc_open(
            [PHP_BINARY, __DIR__.'/fake_smtp_server_process.php'],
            [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
            $pipes,
        );

        if (! is_resource($process)) {
            throw new RuntimeException('Could not start the fake SMTP server subprocess.');
        }

        fwrite($pipes[0], json_encode($config));
        fclose($pipes[0]);

        // A bound port comes back almost instantly; this timeout only
        // matters if the subprocess never starts at all, so the test suite
        // fails fast instead of hanging.
        stream_set_timeout($pipes[1], 5);
        $port = (int) trim((string) fgets($pipes[1]));

        if ($port <= 0) {
            $stderr = stream_get_contents($pipes[2]);
            proc_terminate($process);
            proc_close($process);

            throw new RuntimeException("Fake SMTP server failed to bind a port. stderr: {$stderr}");
        }

        return new self($process, $pipes, $port);
    }

    public function stop(): void
    {
        foreach ($this->pipes as $pipe) {
            if (is_resource($pipe)) {
                fclose($pipe);
            }
        }

        proc_terminate($this->process);
        proc_close($this->process);
    }
}
