<?php

use App\Livewire\Admin\Inbox;
use App\Models\Customer;
use App\Models\InboundMail;
use App\Models\SupportRequest;
use App\Services\Mail\FakeMailbox;
use App\Services\Mail\FetchedMail;
use App\Services\Mail\IngestInboundMail;
use App\Services\Mail\InboundMailbox;
use App\Services\Mail\MailParser;
use Illuminate\Support\Facades\File;
use Livewire\Livewire;

/**
 * The mail a customer writes, read where it can be answered.
 *
 * The socket half cannot be exercised without a mail server, and a test that
 * needs one is a test nobody runs. What IS exercised here is everything that
 * decides anything: the parsing, against messages in the shape real mail
 * arrives in, and every judgement the ingest makes afterwards.
 */
beforeEach(function () {
    $this->mailbox = new FakeMailbox;
    app()->instance(InboundMailbox::class, $this->mailbox);
});

/**
 * A message as a mail server hands it over.
 *
 * Overrides REPLACE a default header rather than being appended after it. The
 * parser keeps the first occurrence of a name on purpose — a message with two
 * Subject headers is malformed, and the second one is the interesting one to
 * somebody forging mail — so appending would silently test nothing.
 */
function rawMail(string $body = "Guten Tag,\r\n\r\nkönnen Sie unsere Daten übernehmen?\r\n", array $headers = []): string
{
    $lines = [
        'Return-Path: <kunde@example.test>',
        'Message-ID: <abc123@example.test>',
        'From: Kanzlei Berger <kunde@example.test>',
        'To: support@clupilot.com',
        'Subject: Frage zur Migration',
        'Date: Tue, 29 Jul 2026 09:15:00 +0200',
        'Content-Type: text/plain; charset=UTF-8',
    ];

    foreach ($headers as $override) {
        $name = strtolower(strtok($override, ':'));
        $lines = array_values(array_filter(
            $lines,
            fn (string $line) => strtolower(strtok($line, ':')) !== $name,
        ));
        $lines[] = $override;
    }

    return implode("\r\n", $lines)."\r\n\r\n".$body;
}

/** One fetched message, ready for the ingest. */
function fetched(string $email = 'kunde@example.test', string $messageId = 'm-1'): FetchedMail
{
    return new FetchedMail(
        uid: '1',
        messageId: $messageId,
        fromEmail: $email,
        fromName: 'Kanzlei Berger',
        subject: 'Frage zur Migration',
        body: 'Können Sie unsere Daten übernehmen?',
        receivedAt: now(),
    );
}

// ---- Reading a real message ----

it('reads sender, subject and the words out of an ordinary mail', function () {
    $mail = MailParser::parse('7', rawMail());

    expect($mail->fromEmail)->toBe('kunde@example.test')
        ->and($mail->fromName)->toBe('Kanzlei Berger')
        ->and($mail->subject)->toBe('Frage zur Migration')
        ->and($mail->body)->toContain('können Sie unsere Daten übernehmen?')
        ->and($mail->messageId)->toBe('<abc123@example.test>');
});

it('decodes a subject with an umlaut in it, which is most German subjects', function () {
    // RFC 2047. Without this the console shows "=?UTF-8?B?…?=" where the
    // question should be.
    $raw = rawMail(headers: ['Subject: =?UTF-8?B?RsO8ciBTaWU6IEt1bmRlbmFuZnJhZ2U=?=']);

    expect(MailParser::parse('7', $raw)->subject)->toBe('Für Sie: Kundenanfrage');
});

it('decodes a quoted-printable body, and a Windows charset with it', function () {
    // What Outlook sends. Stored raw, "können" is a broken character for ever,
    // because the column is UTF-8.
    $raw = rawMail(
        "Guten Tag,\r\n\r\nk=F6nnen Sie das =FCbernehmen?\r\n",
        [
            'Content-Type: text/plain; charset=ISO-8859-1',
            'Content-Transfer-Encoding: quoted-printable',
        ],
    );

    expect(MailParser::parse('7', $raw)->body)->toContain('können Sie das übernehmen?');
});

it('takes the text half of a mail that was sent as both text and HTML', function () {
    // A console is not a mail client: what the person wrote is in the text
    // part, and the HTML one is the same words wrapped in markup.
    $raw = implode("\r\n", [
        'Message-ID: <mixed@example.test>',
        'From: kunde@example.test',
        'Subject: Zwei Teile',
        'Content-Type: multipart/alternative; boundary="B1"',
    ])."\r\n\r\n".implode("\r\n", [
        '--B1',
        'Content-Type: text/plain; charset=UTF-8',
        '',
        'Der einfache Text.',
        '--B1',
        'Content-Type: text/html; charset=UTF-8',
        '',
        '<p>Der <b>ausgezeichnete</b> Text.</p>',
        '--B1--',
    ]);

    expect(MailParser::parse('7', $raw)->body)->toBe('Der einfache Text.');
});

it('strips the markup when HTML is all there is', function () {
    $raw = rawMail('<p>Bitte um <b>R&uuml;ckruf</b>.</p>', ['Content-Type: text/html; charset=UTF-8']);

    expect(MailParser::parse('7', $raw)->body)->toBe('Bitte um Rückruf.');
});

it('names an attachment and refuses to keep it', function () {
    // The whole attachment policy in one assertion: the operator learns that
    // something was attached and what it was called, and the bytes are not
    // stored anywhere. Keeping whatever a stranger sends would make this a
    // malware store with a console in front of it.
    $raw = implode("\r\n", [
        'Message-ID: <att@example.test>',
        'From: kunde@example.test',
        'Subject: Mit Anhang',
        'Content-Type: multipart/mixed; boundary="B2"',
    ])."\r\n\r\n".implode("\r\n", [
        '--B2',
        'Content-Type: text/plain; charset=UTF-8',
        '',
        'Anbei die Liste.',
        '--B2',
        'Content-Type: application/pdf; name="rechnung.pdf"',
        'Content-Disposition: attachment; filename="rechnung.pdf"',
        'Content-Transfer-Encoding: base64',
        '',
        base64_encode(str_repeat('X', 2048)),
        '--B2--',
    ]);

    $mail = MailParser::parse('7', $raw);

    expect($mail->body)->toBe('Anbei die Liste.')
        ->and($mail->attachments)->toHaveCount(1)
        ->and($mail->attachments[0]['name'])->toBe('rechnung.pdf')
        ->and($mail->attachments[0]['bytes'])->toBeGreaterThan(0);

    // And nothing anywhere in the value object carries the file itself.
    expect(json_encode($mail))->not->toContain(base64_encode(str_repeat('X', 64)));
});

it('refuses a message with no sender rather than filing it under nobody', function () {
    $raw = implode("\r\n", ['Message-ID: <x@y>', 'Subject: Ohne Absender'])."\r\n\r\nText";

    expect(fn () => MailParser::parse('7', $raw))->toThrow(RuntimeException::class);
});

it('falls back to the UID when a message carries no Message-ID', function () {
    // Required by the standard and missing in practice often enough that
    // dropping the mail would lose real questions.
    $raw = implode("\r\n", ['From: kunde@example.test', 'Subject: Ohne ID'])."\r\n\r\nText";

    expect(MailParser::parse('42', $raw)->messageId)->toBe('uid-42');
});

// ---- What the ingest decides ----

it('files a mail under the customer whose address it came from', function () {
    $customer = Customer::factory()->create(['email' => 'kunde@example.test']);
    $this->mailbox->mails = [fetched()];

    app(IngestInboundMail::class)();

    expect(InboundMail::query()->first()?->customer_id)->toBe($customer->id);
});

it('keeps a mail from an address nobody knows, unassigned rather than dropped', function () {
    // The mail an operator must not miss: a new enquiry starts exactly this
    // way, and hiding it because it matches no customer would lose the sale.
    $this->mailbox->mails = [fetched('fremder@example.test')];

    app(IngestInboundMail::class)();

    $mail = InboundMail::query()->first();

    expect($mail)->not->toBeNull()
        ->and($mail->customer_id)->toBeNull()
        ->and($mail->isUnassigned())->toBeTrue();
});

it('decides whose it is by the address and by nothing else', function () {
    // Not by a name in the subject and not by anything in the body: a mail is
    // easy to write, and this decision attaches a stranger's words to a real
    // customer's file.
    Customer::factory()->create(['email' => 'echt@example.test', 'name' => 'Kanzlei Berger']);

    $this->mailbox->mails = [new FetchedMail(
        uid: '1', messageId: 'm-forge', fromEmail: 'betrueger@example.test',
        fromName: 'Kanzlei Berger', subject: 'Kanzlei Berger — dringend',
        body: 'Bitte senden Sie die Zugangsdaten an diese Adresse.', receivedAt: now(),
    )];

    app(IngestInboundMail::class)();

    expect(InboundMail::query()->first()?->customer_id)->toBeNull();
});

it('takes the same message once, however often the mailbox offers it', function () {
    // The fetch runs every couple of minutes, and a flag that did not stick on
    // the server costs one re-read — not a second copy of the same question.
    $this->mailbox->mails = [fetched()];

    app(IngestInboundMail::class)();
    app(IngestInboundMail::class)();

    expect(InboundMail::query()->count())->toBe(1);
});

it('flags a message on the server only once its row exists here', function () {
    // The other order loses the message with nothing to show for it, and there
    // is no second copy anywhere.
    $this->mailbox->mails = [fetched()];

    app(IngestInboundMail::class)();

    expect($this->mailbox->seen)->toBe(['1']);
});

it('attaches the mail to the question the customer already has open', function () {
    $customer = Customer::factory()->create(['email' => 'kunde@example.test']);
    $request = SupportRequest::create([
        'customer_id' => $customer->id, 'subject' => 'Migration', 'category' => 'other',
        'body' => 'Erste Frage', 'status' => 'open', 'reported_by' => $customer->email,
    ]);

    $this->mailbox->mails = [fetched()];
    app(IngestInboundMail::class)();

    expect(InboundMail::query()->first()?->support_request_id)->toBe($request->id);
});

it('fetches nothing at all while no mailbox is configured', function () {
    $this->mailbox->configured = false;
    $this->mailbox->mails = [fetched()];

    expect(app(IngestInboundMail::class)())->toBe(0)
        ->and(InboundMail::query()->count())->toBe(0);
});

// ---- The page an operator works in ----

it('puts what nobody could place at the top, where it cannot be missed', function () {
    $customer = Customer::factory()->create(['email' => 'kunde@example.test', 'name' => 'Kanzlei Berger']);

    InboundMail::create([
        'customer_id' => $customer->id, 'message_id' => 'm-known', 'from_email' => $customer->email,
        'subject' => 'Von einem Kunden', 'body' => 'Text', 'received_at' => now(),
    ]);
    InboundMail::create([
        'message_id' => 'm-unknown', 'from_email' => 'fremder@example.test',
        'subject' => 'Von einem Fremden', 'body' => 'Text', 'received_at' => now()->subHour(),
    ]);

    $page = Livewire::actingAs(operator('Owner'), 'operator')->test(Inbox::class);

    $page->assertViewHas('mails', fn ($mails) => $mails->first()->message_id === 'm-unknown')
        ->assertSee(__('inbox.unassigned'));
});

it('lets an operator place a mail the matcher never could', function () {
    // The customer writing from their private account: no matcher will
    // recognise them, and an operator recognises them at once.
    $customer = Customer::factory()->create(['name' => 'Kanzlei Berger']);
    $mail = InboundMail::create([
        'message_id' => 'm-private', 'from_email' => 'bea.berger@gmx.at',
        'subject' => 'Kurze Frage', 'body' => 'Text', 'received_at' => now(),
    ]);

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(Inbox::class)
        ->call('assign', $mail->uuid, $customer->uuid);

    expect($mail->fresh()->customer_id)->toBe($customer->id);
});

it('files a mail away without touching the copy on the mail server', function () {
    $mail = InboundMail::create([
        'message_id' => 'm-file', 'from_email' => 'kunde@example.test',
        'subject' => 'Erledigt', 'body' => 'Text', 'received_at' => now(),
    ]);

    $page = Livewire::actingAs(operator('Owner'), 'operator')->test(Inbox::class);
    $page->call('archive', $mail->uuid);

    expect($mail->fresh()->archived_at)->not->toBeNull()
        // Nothing was asked of the mailbox: an application that deletes
        // somebody's mail on their own server is one nobody hands a password.
        ->and($this->mailbox->seen)->toBe([]);

    $page->assertViewHas('mails', fn ($mails) => $mails->isEmpty());
});

it('says that no mailbox is set up, rather than showing an empty list nobody can explain', function () {
    $this->mailbox->configured = false;

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(Inbox::class)
        ->assertSee(__('inbox.not_configured'));
});

it('keeps the inbox to operators who may see customers', function () {
    Livewire::actingAs(operator('Read-only'), 'operator')
        ->test(Inbox::class)
        ->assertForbidden();
});

// ---- Configured means configured, and the check that says so ----

it('counts a password from the vault as configured', function () {
    // The bug this closes: isConfigured() 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. So a password saved in
    // the console counted for nothing, and the inbox went on saying no mailbox
    // was set up while the password sat right there on the settings page.
    App\Support\Settings::set('inbound_mail.host', 'mail.example.test');
    App\Support\Settings::set('inbound_mail.username', 'support@example.test');
    config()->set('services.inbound_mail.password', null);

    $mailbox = new App\Services\Mail\ImapMailbox;

    expect($mailbox->isConfigured())->toBeFalse();

    app(App\Services\Secrets\SecretVault::class)->put('inbound_mail.password', 'geheim', operator('Owner'));

    expect($mailbox->isConfigured())->toBeTrue();
});

it('still takes the password out of the environment where that is where it is', function () {
    // An installation carrying INBOUND_MAIL_PASSWORD keeps working: the vault
    // falls back to the configured value when nothing is stored.
    App\Support\Settings::set('inbound_mail.host', 'mail.example.test');
    App\Support\Settings::set('inbound_mail.username', 'support@example.test');
    config()->set('services.inbound_mail.password', 'aus-der-env');

    expect((new App\Services\Mail\ImapMailbox)->isConfigured())->toBeTrue();
});

it('records when the mailbox was last reached, and what came back', function () {
    $status = app(App\Services\Mail\InboundMailStatus::class);

    expect($status->last())->toBeNull();

    $status->record(true, '', 3);
    $last = $status->last();

    expect($last['ok'])->toBeTrue()
        ->and($last['unseen'])->toBe(3)
        // A Carbon, so the view can put it on the wall clock rather than
        // printing an ISO string at somebody (R19).
        ->and($last['at'])->toBeInstanceOf(Illuminate\Support\Carbon::class);
});

it('records the scheduled run too, not only the button', function () {
    // "Last checked" has to mean the last time the mailbox was actually reached.
    // A mailbox that stopped answering at three in the morning is the one worth
    // seeing, and nobody presses a button at three in the morning.
    $this->mailbox->mails = [fetched()];

    app(IngestInboundMail::class)();

    expect(app(App\Services\Mail\InboundMailStatus::class)->last())->not->toBeNull();
});

it('takes nothing in when the mailbox cannot be reached, and says so', function () {
    // Fetching against a mailbox that just refused the login would report
    // "nothing new", which is true and useless.
    $this->mailbox->mails = [fetched()];
    $this->mailbox->checkAnswer = ['ok' => false, 'message' => 'credentials', 'unseen' => null];

    expect(app(IngestInboundMail::class)())->toBe(0)
        ->and(InboundMail::query()->count())->toBe(0);

    $last = app(App\Services\Mail\InboundMailStatus::class)->last();

    expect($last['ok'])->toBeFalse()
        ->and($last['message'])->toBe('credentials');
});

it('tests the connection from the settings page and shows when it last answered', function () {
    $page = Livewire::actingAs(operator('Owner'), 'operator')
        ->test(App\Livewire\Admin\Integrations::class)
        ->set('inboundHost', 'mail.example.test')
        ->set('inboundUsername', 'support@example.test')
        ->call('testInbound');

    $page->assertDispatched('notify');

    // And the page now carries the line, whichever way it went.
    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(App\Livewire\Admin\Integrations::class)
        ->assertViewHas('inboundStatus', fn ($status) => $status !== null);
});

it('never puts the password or the command into the message it shows', function () {
    // IMAP servers answer a failed LOGIN with their own prose, and this class
    // puts the failing command in the exception — which for LOGIN is the
    // password. The reasons are matched, never passed through.
    $reasons = ['incomplete', 'unreachable', 'credentials', 'folder', 'failed'];

    foreach ($reasons as $reason) {
        expect(__('integrations.inbound_failed_'.$reason))->not->toBe('integrations.inbound_failed_'.$reason);
    }

    $code = File::get(app_path('Services/Mail/ImapMailbox.php'));

    // The reason() method returns one of the fixed words above, and nothing
    // derived from the exception text.
    expect($code)->toContain("return match (true) {")
        ->and($code)->not->toContain('$e->getMessage()]');
});
