<?php

use App\Models\Mailbox;
use App\Services\Mail\MailPurpose;
use App\Services\Secrets\SecretCipher;
use App\Support\Settings;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\ConfigurationUrlParser;
use Illuminate\Support\Facades\DB;

/**
 * Takes the single configured SMTP account over into the new mailbox table,
 * and puts the other four addresses on the page as empty rows.
 *
 * Empty rather than absent: a page that lists what is expected tells the owner
 * what is still missing. An empty list would only say "nothing here".
 *
 * Safe to run more than once: `Mailbox::firstOrNew()` rather than `new
 * Mailbox()`, because a migration that fails partway leaves the migrations
 * table not recording it, and a retry must update the rows already written
 * rather than collide with them on the unique key. `up()` and `down()` are
 * each wrapped in their own transaction for the same reason — MariaDB does
 * not get one automatically the way Postgres does.
 */
return new class extends Migration
{
    /** @var array<string, array{no_reply: bool, purposes: array<int, string>}> */
    private const SEED = [
        'no-reply' => ['no_reply' => true, 'purposes' => [MailPurpose::MAINTENANCE, MailPurpose::PROVISIONING, MailPurpose::SYSTEM]],
        'support' => ['no_reply' => false, 'purposes' => [MailPurpose::SUPPORT]],
        'billing' => ['no_reply' => false, 'purposes' => [MailPurpose::BILLING]],
        'office' => ['no_reply' => false, 'purposes' => []],
        'info' => ['no_reply' => false, 'purposes' => []],
    ];

    /**
     * config/mail.php's own fallbacks when MAIL_HOST/MAIL_PORT are unset.
     *
     * config('mail.mailers.smtp.host') cannot tell "genuinely unset" apart
     * from "deliberately set to 127.0.0.1" on its own — config/mail.php's own
     * env('MAIL_HOST', '127.0.0.1') has already substituted by the time this
     * migration reads it, and 127.0.0.1 is not '', so it would sail straight
     * past MailboxTransport's "host is not configured" guard (Task 4) and
     * quietly dial localhost instead of refusing loudly. Comparing the
     * resolved value against these two placeholders is what tells the two
     * apart — but only as a FALLBACK now, not the whole answer: an install
     * genuinely relaying through 127.0.0.1:2525 (a sidecar relay, commonly
     * paired with MAIL_URL) is exactly as real as one on any other
     * host:port, and this comparison alone cannot tell that apart from
     * "nothing configured" either — it would zero out a working setup the
     * moment this migration ran, and MailboxTransport would then refuse
     * every send with "port is not configured" (Codex R15#8, P1). See
     * isExplicitlySupplied() below for the fix, and for why this sentinel is
     * still needed alongside it rather than replaced outright.
     *
     * Why keep a sentinel at all, instead of only ever reading raw env()?
     * env('MAIL_HOST') correctly returns null for "genuinely unset" — but
     * ONLY as long as .env was actually loaded, which Laravel skips entirely
     * once `config:cache` has run (LoadEnvironmentVariables checks
     * app()->configurationIsCached() first, before ever calling Dotenv — see
     * that class directly, not guessed). This migration cannot control that:
     * deploy/update.sh always migrates before it rebuilds the cache, but
     * nothing enforces that order for a hand-run `migrate` on a server
     * someone already cached. Under that ordering, isExplicitlySupplied()
     * below collapses to exactly this sentinel comparison alone — env()
     * returns null for every key, so it never overrides it — which is this
     * migration's behaviour from BEFORE R15#8, not a regression past it.
     *
     * Checked, not assumed: app()->configurationIsCached() only reports that
     * Dotenv was skipped, not that MAIL_HOST/MAIL_PORT are therefore
     * unreadable by env() — a deployment that also exported them as real
     * container-level environment variables (docker-compose's own
     * `environment:` block, systemd, etc.) would keep seeing them through
     * env() regardless of the config cache, and isExplicitlySupplied() would
     * keep working correctly even then. What makes "collapses to the
     * sentinel" a COMPLETE answer here, specifically, is this repository's
     * own docker-compose.yml: the app service deliberately does not inject
     * .env as real process environment ("Do NOT inject it as real env vars
     * (env_file): that would override the test env... Only vite needs these
     * at process level") — so once cached, MAIL_HOST/MAIL_PORT genuinely have
     * no remaining source for env() to read on THIS install. A fork of that
     * compose file which changed that would want to re-check this comment.
     */
    private const UNCONFIGURED_HOST = '127.0.0.1';

    private const UNCONFIGURED_PORT = 2525;

    /**
     * config/mail.php's own fallback when MAIL_FROM_ADDRESS is unset.
     *
     * Same masking problem as UNCONFIGURED_HOST above, and the same fix:
     * env('MAIL_FROM_ADDRESS', 'hello@example.com') has already substituted
     * by the time this migration reads config('mail.from.address'), and
     * 'hello@example.com' is not '', so without this check it would be
     * seeded as a real From address instead of recognised as "nothing
     * configured, fall back to the SMTP login". Same trade-off as the host
     * placeholder too: an install that deliberately wants to send from the
     * literal address hello@example.com reads as unset — nobody does that.
     */
    private const UNCONFIGURED_FROM_ADDRESS = 'hello@example.com';

    public function up(): void
    {
        DB::transaction(function () {
            $this->seed();
        });
    }

    /**
     * host/port/username/password the way MailManager actually builds the
     * SMTP transport from them — not necessarily what config('mail.mailers.
     * smtp.host') and its three siblings hold.
     *
     * config/mail.php carries 'url' => env('MAIL_URL') on the smtp mailer:
     * Laravel's OTHER supported way to configure SMTP, a single DSN instead
     * of four separate variables. An install using it never sets MAIL_HOST/
     * MAIL_USERNAME/MAIL_PASSWORD at all, so the plain config() calls this
     * migration used to make read config/mail.php's own placeholder
     * defaults (127.0.0.1, 2525, null, null) — indistinguishable from
     * UNCONFIGURED_HOST/UNCONFIGURED_PORT below, even though the install
     * sends mail successfully today. The substitution that would have shown
     * the real values only happens inside Illuminate\Mail\MailManager::
     * getConfig(), which is protected — not called here, reflected into, or
     * re-implemented by hand (a second copy of ITS merge order is what would
     * rot). Illuminate\Support\ConfigurationUrlParser is the public class
     * getConfig() itself delegates to for exactly this, so calling it here
     * directly is the same resolution, read from the one place it lives.
     *
     * Precedence — verified against the real MailManager::getConfig() via
     * reflection, not guessed (see the task report): array_merge() puts
     * parseConfiguration()'s result SECOND, and that result already has the
     * URL's host/port/username/password merged in over top of the individual
     * keys (Illuminate\Support\ConfigurationUrlParser::getPrimaryOptions()
     * array_filters out only what the URL left null). So the URL wins for
     * every field it actually supplies, and an unset MAIL_URL — isset() on a
     * null config value is false, same as MailManager's own check — leaves
     * this a no-op, returning the individual keys exactly as before.
     *
     * Also returns 'suppliedByUrl': isExplicitlySupplied() (Codex R15#8, P1)
     * needs to know whether host/port SPECIFICALLY came from the URL, and the
     * merged 'config' above cannot answer that — it always has a 'host' and a
     * 'port' key regardless, falling back to $config's own already-defaulted
     * values whenever the URL did not supply one. A second, isolated call to
     * the same PUBLIC parseConfiguration() below — given only the url, none
     * of $config's other keys riding along — is what actually answers it.
     *
     * @return array{config: array<string, mixed>, suppliedByUrl: array<string, mixed>}
     */
    private function resolveSmtpConfig(): array
    {
        $config = (array) config('mail.mailers.smtp', []);

        if (! isset($config['url'])) {
            return ['config' => $config, 'suppliedByUrl' => []];
        }

        $parser = new ConfigurationUrlParser;

        return [
            'config' => array_merge($config, $parser->parseConfiguration($config)),
            'suppliedByUrl' => $parser->parseConfiguration(['url' => $config['url']]),
        ];
    }

    /**
     * Whether an operator actually stated $field — through .env directly, or
     * through MAIL_URL — as distinct from it merely sitting at config/
     * mail.php's own substituted default. This is the fix for Codex R15#8,
     * P1 (see UNCONFIGURED_HOST's docblock for the bug it closes).
     *
     * env($envKey) is checked first and is the PRIMARY signal: ANY value it
     * returns — even one that happens to equal the placeholder — is
     * something an operator or their tooling actually wrote, and must be
     * trusted over the sentinel rather than second-guessed by it.
     * $suppliedByUrl (see resolveSmtpConfig()) carries the same standing for
     * MAIL_URL: a URL's host/port is exactly as deliberate as the individual
     * env var, and resolveSmtpConfig() already makes it win in the merged
     * config — second-guessing it here with the sentinel would undo that.
     *
     * Only when NEITHER says anything does UNCONFIGURED_HOST/UNCONFIGURED_
     * PORT's own sentinel comparison in seed() still apply — exactly as it
     * did before this fix. That fallback is deliberate, not a leftover: see
     * UNCONFIGURED_HOST's own docblock for why env() cannot simply replace it
     * outright (config:cache).
     *
     * @param  array<string, mixed>  $suppliedByUrl
     */
    private function isExplicitlySupplied(string $envKey, string $field, array $suppliedByUrl): bool
    {
        return env($envKey) !== null || array_key_exists($field, $suppliedByUrl);
    }

    /**
     * The scheme an operator actually stated, or null if nobody did.
     *
     * Two different fields can carry this, and they are not interchangeable:
     *
     * - $smtp['scheme'] is config/mail.php's 'scheme' => env('MAIL_SCHEME'),
     *   Laravel's own single-purpose encryption knob. createSmtpTransport()
     *   reads it directly (MailManager.php:196) with no other meaning
     *   possible — an operator who set it MEANT it.
     * - $smtp['driver'] exists only after resolveSmtpConfig() has run the URL
     *   through ConfigurationUrlParser, and only when MAIL_URL was set. It is
     *   NOT the same slot: getPrimaryOptions() maps a URL's scheme into
     *   'driver' (verified directly against the installed class), because
     *   Laravel's MailManager::getConfig() reuses that same field to pick
     *   WHICH TRANSPORT to build ($config['transport'] = Arr::pull($config,
     *   'driver')) — the URL scheme is doing double duty as a transport
     *   selector, not purely an encryption statement.
     *
     * That difference is why 'smtps' is trusted from either field but 'smtp'
     * only from the first: 'smtps://' in a MAIL_URL is unambiguous — nothing
     * else lets an operator say implicit TLS through a URL alone. A plain
     * 'smtp://' is simply the ordinary, only real-world way to write this
     * mailer's URL and says nothing about TLS either way; the port heuristic
     * below already carries that intent, so a plain 'smtp://' is
     * deliberately left resolving to null (no stated scheme) here, unlike an
     * explicit MAIL_SCHEME=smtp. See the match in seed() for what each of
     * 'smtp', 'smtps' and null actually maps to, and why (Codex R15#7, P1).
     *
     * Anything else — including this machine's own MAIL_SCHEME=tls, which
     * Symfony's EsmtpTransportFactory rejects outright (it supports only
     * 'smtp' and 'smtps', see getSupportedSchemes()) — is not a real scheme
     * and must not silently steer the result toward either 'ssl' or 'none'.
     * Returning null for it here is what sends seed() to the port heuristic
     * instead, the same place a genuinely absent MAIL_SCHEME already goes.
     */
    private function resolveScheme(array $smtp): ?string
    {
        $scheme = $smtp['scheme'] ?? null;

        if (in_array($scheme, ['smtp', 'smtps'], true)) {
            return $scheme;
        }

        return ($smtp['driver'] ?? null) === 'smtps' ? 'smtps' : null;
    }

    private function seed(): void
    {
        $resolved = $this->resolveSmtpConfig();
        $smtp = $resolved['config'];
        $suppliedByUrl = $resolved['suppliedByUrl'];

        // The server, once. Whatever .env (or MAIL_URL) already proved
        // workable — but not config/mail.php's own placeholder (see
        // UNCONFIGURED_HOST above), UNLESS an operator can be shown to have
        // actually written this value themselves. isExplicitlySupplied()
        // checks raw env() and the parsed URL before the sentinel is allowed
        // to zero anything out (Codex R15#8, P1).
        $configuredHost = (string) ($smtp['host'] ?? '');
        $hostSupplied = $this->isExplicitlySupplied('MAIL_HOST', 'host', $suppliedByUrl);
        $host = (! $hostSupplied && $configuredHost === self::UNCONFIGURED_HOST) ? '' : $configuredHost;
        Settings::set('mail.host', $host);

        $configuredPort = (int) ($smtp['port'] ?? 0);
        $portSupplied = $this->isExplicitlySupplied('MAIL_PORT', 'port', $suppliedByUrl);
        $port = (! $portSupplied && $configuredPort === self::UNCONFIGURED_PORT) ? 0 : $configuredPort;
        Settings::set('mail.port', $port);

        // Codex R15#4, P1a: a scheme actually STATED (MAIL_SCHEME=smtps, or an
        // smtps:// MAIL_URL) must be believed over the port — an implicit-TLS
        // relay on a non-465 port must not be downgraded to STARTTLS just
        // because this migration guessed from the port instead of the scheme
        // that was already sitting there. Only when nobody stated one
        // (resolveScheme() returns null — including for this machine's own
        // invalid MAIL_SCHEME=tls, which is not a real Symfony scheme under
        // either spelling) does the port heuristic from before still apply:
        // 465 means implicit TLS, everything else means STARTTLS.
        $scheme = $this->resolveScheme($smtp);

        // Codex R15#7, P1: 'smtp' => 'tls', not 'none'. MAIL_SCHEME=smtp is
        // Symfony's OPPORTUNISTIC scheme — it starts the connection in the
        // clear and upgrades to STARTTLS only if the server's EHLO happens
        // to offer it, never failing if it does not (see MailTlsPolicy's own
        // docblock on autoTls vs. requireTls). It is NOT a "no encryption"
        // statement. Round 4 of this review mapped it to 'none' on the wrong
        // theory that 'smtp' was a plain, non-TLS scheme — every existing
        // installation with MAIL_SCHEME=smtp (including the .env.example
        // value) would have had this migration silently turn a working,
        // encrypted-when-possible connection into a fully plaintext one,
        // exposing credentials and message contents with no warning.
        //
        // 'tls' (STARTTLS required) is deliberately STRICTER than smtp's own
        // opportunistic default, not a faithful reproduction of it: a relay
        // that genuinely does not offer STARTTLS now fails LOUDLY on the
        // first send instead of handing a mailbox password over in the
        // clear. That is the correct failure direction — the operator can
        // then choose 'none' in the console deliberately, an explicit act
        // rather than a silent migration default.
        //
        // Deliberately no fourth, "opportunistic" stored value: round 1 of
        // this review is exactly what closed that hole (a network attacker
        // who strips STARTTLS from the EHLO reply gets a plaintext
        // connection — the same credential-exposure path). Making
        // opportunistic a first-class option here would reopen it by
        // design.
        //
        // Worth flagging rather than leaving implicit: this match can no
        // longer produce 'none' AT ALL. resolveScheme() only ever returns
        // 'smtp', 'smtps', or null (see its own docblock), and all three
        // arms below now resolve to 'ssl' or 'tls'. That is intentional,
        // not an oversight — nothing this migration can read (a stated
        // scheme, a MAIL_URL scheme, or the absence of either) is a genuine
        // "no encryption" statement, so this migration must never
        // manufacture one on an operator's behalf. 'none' remains a real,
        // selectable choice in the console; it is just not a migration
        // output.
        Settings::set('mail.encryption', match ($scheme) {
            'smtps' => 'ssl',
            'smtp' => 'tls',
            default => $port === 465 ? 'ssl' : 'tls',
        });

        $configuredUser = (string) ($smtp['username'] ?? '');

        // Placeholder domain for the four boxes below comes from the SMTP
        // LOGIN's domain, not from mail.from.address — even though address
        // itself (below) now prefers mail.from.address for the no-reply box.
        // The login and the From address can point at different domains (an
        // API-key login on a shared relay next to a From address on the
        // install's own domain, or the reverse), and only the login's domain
        // is proven to accept mail: it is the one .env's MAIL_HOST/
        // MAIL_USERNAME pair has actually been sending through. Building
        // support@/billing@/etc. from mail.from.address's domain instead
        // could seed a placeholder on a domain nobody could ever turn into a
        // working mailbox.
        $domain = str_contains($configuredUser, '@')
            ? substr($configuredUser, strpos($configuredUser, '@') + 1)
            : 'clupilot.com';

        // The From address actually in force today may not be the SMTP
        // login — a relay's own account name (an API-key username, a shared
        // login) is routinely not the address recipients see and can reply
        // to. A takeover migration's job is to reproduce what the system
        // already sends, not to improve on it, so address and username are
        // read and assigned separately rather than collapsing the login into
        // both.
        $configuredFromAddress = (string) config('mail.from.address', '');
        $fromAddress = $configuredFromAddress === self::UNCONFIGURED_FROM_ADDRESS ? '' : $configuredFromAddress;

        // An install with no MAIL_FROM_ADDRESS has nothing better than the
        // SMTP login to send From — which is what Laravel's own mailer was
        // already falling back to before this migration, so this reproduces
        // that install's mail rather than changing it.
        $resolvedAddress = $fromAddress !== '' ? $fromAddress : $configuredUser;

        $configuredFromName = (string) config('mail.from.name', '');
        $displayName = $configuredFromName !== '' ? $configuredFromName : 'CluPilot';

        // A password may already be stored in the vault. It is CARRIED, not
        // moved: the app_secrets row is deliberately left in place below
        // rather than deleted, so this block only ever reads it.
        $storedPassword = DB::table('app_secrets')->where('key', 'mail.password')->value('value');
        $envPassword = (string) ($smtp['password'] ?? '');

        // A fresh install may not have generated SECRETS_KEY yet — encrypting
        // is what needs it, not migrating. Without this check, an install with
        // MAIL_PASSWORD set in .env but no SECRETS_KEY would fail to migrate
        // at all: setPasswordAttribute() throws through SecretCipher rather
        // than leave the row unwritten. The mailbox still gets created, just
        // without a password, exactly like the four addresses below it.
        $canEncrypt = app(SecretCipher::class)->isUsable();

        // Codex R15#4, P1b: "neither a username nor a password" is what
        // Laravel's SMTP mailer has always accepted as a legitimate
        // unauthenticated relay — a trusted local or private-network server
        // that authenticates by IP rather than by login. Read from the
        // resolved SMTP config only (not from $storedPassword, the vault's
        // own separate, legacy carry-over mechanism a few lines below this
        // one — folding that in would mean a leftover vault row from a much
        // older round could flip this even though today's .env states no
        // credentials at all, a scope this fix deliberately leaves alone).
        // A username ALONE, with no password anywhere, stays authenticates =
        // true: that is an operator who started typing, not one who chose no
        // auth, and isConfigured() must keep asking for a password rather
        // than reading it as a deliberately open relay.
        $authenticates = $configuredUser !== '' || $envPassword !== '';

        foreach (self::SEED as $key => $meta) {
            // A real account exists when the takeover found somewhere to
            // actually send through (the host — MailboxTransport's own first
            // guard refuses to send without one too) AND someone to name it
            // from ($resolvedAddress, which itself falls back through
            // mail.from.address to the SMTP login). A username is
            // deliberately NOT required here the way it used to be: gating
            // on it exclusively is exactly what left an unauthenticated
            // relay seeded as a placeholder, unable to send, until this fix.
            $isConfigured = $key === 'no-reply' && $host !== '' && $resolvedAddress !== '';

            $box = Mailbox::firstOrNew(['key' => $key]);
            $box->fill([
                'address' => $isConfigured ? $resolvedAddress : $key.'@'.$domain,
                'display_name' => $displayName,
                // Null when the login and address match, OR when there is no
                // login at all (an unauthenticated relay): Mailbox::
                // smtpUsername() already falls back to address in both
                // cases, so writing '' into the column would just be a
                // redundant, misleading copy of "no separate login".
                'username' => $isConfigured && $configuredUser !== '' && $configuredUser !== $resolvedAddress
                    ? $configuredUser : null,
                'no_reply' => $meta['no_reply'],
                'active' => true,
                // Placeholders default true (the column's own default) the
                // same way active does above — a fabricated support@/
                // billing@/etc. address must keep asking for a password, not
                // silently inherit "no auth needed" from the real account it
                // sits next to in this loop.
                'authenticates' => $isConfigured ? $authenticates : true,
            ]);

            // Only the account that actually exists gets credentials.
            if ($isConfigured && $envPassword !== '') {
                if ($canEncrypt) {
                    $box->password = $envPassword;
                } elseif ($storedPassword !== null) {
                    // Skipping the .env password beats crashing the migration
                    // (that is what $canEncrypt guards above) — but this box
                    // is NOT left without a password: the vault row carried
                    // below (see the unconditional block after $box->save())
                    // runs regardless of $canEncrypt, because it is already
                    // ciphertext and needs no new encryption. Saying "was NOT
                    // copied" here would be true only of the .env value and
                    // false of the outcome — an operator reading this while
                    // debugging a live mail outage would be sent to re-enter
                    // a password that is already sitting in the column.
                    echo '  ! SECRETS_KEY is not set - the SMTP password configured in .env for '
                        .$configuredUser.' was not copied from .env, but a password already stored '
                        ."for this mailbox was carried over and should still work.\n";
                } else {
                    // Skipping beats crashing the migration (that is what
                    // $canEncrypt guards above) — but a skip that LOOKS like
                    // success is its own failure mode: migrate prints DONE,
                    // MAIL_MAILER is still log today, and the first real send
                    // after it flips to smtp throws for every purpose,
                    // discovered by a customer rather than by this line.
                    // There is no console page for mailboxes yet, so stdout
                    // during migrate is the only place an operator can see
                    // this. echo, not fwrite(STDOUT, ...): both reach the
                    // real terminal on a plain `php artisan migrate`, but
                    // only echo goes through PHP's output buffer, which is
                    // what makes it possible to assert on in a test.
                    echo '  ! SECRETS_KEY is not set - the SMTP password configured in .env for '
                        .$configuredUser.' was NOT copied into the mailbox table. Generate SECRETS_KEY, '
                        .'then either set mailboxes.password directly or migrate:rollback and re-run '
                        ."this migration.\n";
                }
            }

            $box->save();

            // The ciphertext from the vault is already under SECRETS_KEY, so it
            // is carried across as-is rather than decrypted and re-encrypted.
            if ($isConfigured && $storedPassword !== null) {
                DB::table('mailboxes')->where('id', $box->id)->update(['password' => $storedPassword]);
            }

            foreach ($meta['purposes'] as $purpose) {
                Settings::set(MailPurpose::settingKey($purpose), $key);
            }
        }

        // mail.password's app_secrets row is deliberately left alone here —
        // not deleted. Its SecretVault::REGISTRY entry is gone (this task's
        // other half), so nothing can read or write it through the vault
        // anymore, but destroying the only remaining copy of a real
        // credential in the SAME migration that first introduces its new,
        // less-proven home is backwards for what a stored SMTP password is.
        // Two ways that unconditional delete used to lose it for real,
        // reproduced against MariaDB (see the task report):
        //   - MAIL_USERNAME empty makes $isConfigured false for every key
        //     above, so the carry-over above never runs — deleting here
        //     regardless would destroy a rotated password with no copy left
        //     anywhere.
        //   - migrate, then migrate:rollback, then migrate again: down()
        //     deletes the mailbox rows, which would be the ONLY remaining
        //     copy once this row was already gone.
        // Left in place, a rollback can never lose the credential — at worst
        // it is an inert leftover row nothing can reach through
        // SecretVault::assertKnown() any more. A later migration can remove
        // it once the mailbox takeover has been proven on a live install.
    }

    public function down(): void
    {
        DB::transaction(function () {
            DB::table('mailboxes')->whereIn('key', array_keys(self::SEED))->delete();

            // Settings::forget(), not a raw DB::table(...)->delete(): the
            // cache is Cache::forever()'d with no TTL, so bypassing it here
            // would leave Settings::get() returning what this loop just
            // deleted until something else happens to write the same key.
            // Reproduced: immediately after a raw-delete rollback,
            // Settings::get('mail.purpose.system') still answered 'no-reply'.
            foreach (MailPurpose::ALL as $purpose) {
                Settings::forget(MailPurpose::settingKey($purpose));
            }

            foreach (['mail.host', 'mail.port', 'mail.encryption'] as $key) {
                Settings::forget($key);
            }
        });
    }
};
