<?php

use App\Actions\WithdrawContract;
use App\Livewire\Admin\RecordWithdrawal;
use App\Livewire\Settings;
use App\Models\Customer;
use App\Models\DnsRecord;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Invoice;
use App\Models\InvoiceSeries;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Models\User;
use App\Services\Billing\IssueInvoice;
use App\Services\Billing\WithdrawalRight;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use App\Services\Traefik\FakeTraefikWriter;
use App\Services\Traefik\TraefikWriter;
use App\Support\CompanyProfile;
use Illuminate\Support\Carbon;
use Livewire\Livewire;

/**
 * The fourteen-day right of withdrawal — who has it, what it gives back, and
 * what it leaves behind.
 *
 * The concrete example these tests are built on, once, so the arithmetic can be
 * checked by hand rather than recomputed from the implementation:
 *
 *   contract concluded  1 March 2026, 09:00
 *   term                1 March → 1 April = 31 days
 *   price               49,00 € net; Stripe charges the gross, 58,80 €
 *   withdrawal          8 March 2026, 09:00 — 7 days of service delivered
 *
 *   refund      = 5880, the whole of it                (58,80 €)
 *
 * FAGG §16 would let us keep the pro-rata value of those seven days — 13,27 €
 * gross. The owner has decided not to, so there is no arithmetic left: whatever
 * the customer paid comes back, and the Storno is for the whole document.
 */
beforeEach(function () {
    Carbon::setTestNow('2026-03-01 09:00:00');

    // A withdrawal takes an address down, and taking one down is SSH and DNS.
    // Without the fakes bound the teardown dials a random public IP on port 22
    // and sits there — no test in this suite may reach the network.
    fakeServices();

    CompanyProfile::put([
        'name' => 'CluPilot Cloud e.U.',
        'address' => 'Dreherstraße 66/1/8',
        'postcode' => '1110',
        'city' => 'Wien',
        'vat_id' => 'ATU00000000',
    ]);
});

afterEach(function () {
    Carbon::setTestNow();
});

/**
 * A paid contract, invoiced, with a running machine behind it.
 *
 * @return array{0: Subscription, 1: Invoice, 2: Instance}
 */
function withdrawableContract(array $customerState = []): array
{
    $customer = Customer::factory()->create($customerState + [
        'name' => 'Frau Berger',
        'email' => 'berger@example.test',
    ]);

    $order = Order::factory()->create([
        'customer_id' => $customer->id,
        'plan' => 'team',
        // What the payment provider took, and what the document is written from:
        // the GROSS of the 49,00 € catalogue figure, because that is what the
        // Stripe Price charges.
        'amount_cents' => 5880,
        'currency' => 'EUR',
        'status' => 'paid',
        'stripe_event_id' => 'cs_withdraw',
        'stripe_payment_intent_id' => 'pi_withdraw',
    ]);

    $contract = Subscription::factory()->create([
        'customer_id' => $customer->id,
        'order_id' => $order->id,
        'price_cents' => 4900,
        'currency' => 'EUR',
        'started_at' => now(),
        'current_period_start' => now(),
        'current_period_end' => now()->addMonth(),
        'withdrawal_ends_at' => now()->addDays(WithdrawalRight::WINDOW_DAYS),
    ]);

    $host = Host::factory()->create();

    $instance = Instance::factory()->create([
        'customer_id' => $customer->id,
        'order_id' => $order->id,
        'host_id' => $host->id,
        'plan' => 'team',
        'status' => 'active',
        'route_written' => true,
    ]);

    $contract->update(['instance_id' => $instance->id]);

    // The router and the record that would leak if a withdrawal left them
    // standing — the whole point of EndInstanceService.
    app(TraefikWriter::class)->write(
        (string) ($host->wg_ip ?? $host->public_ip),
        (string) $instance->subdomain,
        [$instance->subdomain.'.clupilot.cloud'],
        'http://10.0.0.2:80',
    );

    DnsRecord::query()->create([
        'instance_id' => $instance->id,
        'provider' => 'hetzner',
        // Ein RRSet wird über {name}/{typ} adressiert; eine erfundene Record-ID
        // aus der alten API würde beim Abbau werfen, statt aufzuräumen.
        'record_id' => $instance->subdomain.'/A',
        'fqdn' => $instance->subdomain.'.clupilot.cloud',
        'type' => 'A',
        'value' => '203.0.113.9',
    ]);

    $invoice = app(IssueInvoice::class)->forOrders($customer, collect([$order]));

    return [$contract->refresh(), $invoice, $instance->refresh()];
}

/** A Stripe that answers, so the refund has somewhere to go. */
function withdrawalStripe(): FakeStripeClient
{
    $fake = new FakeStripeClient;
    app()->instance(StripeClient::class, $fake);

    return $fake;
}

it('lets a consumer withdraw inside fourteen days', function () {
    $stripe = withdrawalStripe();
    [$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);

    Carbon::setTestNow('2026-03-08 09:00:00');

    app(WithdrawContract::class)($contract);

    expect($contract->refresh()->withdrawn_at)->not->toBeNull()
        ->and($contract->withdrawal_channel)->toBe(WithdrawContract::CHANNEL_PORTAL)
        ->and($contract->withdrawal_refund_error)->toBeNull()
        ->and($stripe->refunds)->toHaveCount(1);
});

it('refuses a business customer, at the action and at the portal alike', function () {
    withdrawalStripe();
    [$contract] = withdrawableContract(['customer_type' => Customer::TYPE_BUSINESS]);

    Carbon::setTestNow('2026-03-08 09:00:00');

    // The door the console and any future caller comes through.
    expect(fn () => app(WithdrawContract::class)($contract))
        ->toThrow(RuntimeException::class, __('withdrawal.refusal_business'));

    // And the door a browser comes through. A Livewire action is reachable by
    // anybody who can POST to /livewire/update, so hiding the card proves
    // nothing — this calls the method directly, exactly as that request would.
    $user = User::factory()->create(['email' => $contract->customer->email]);
    $contract->customer->update(['user_id' => $user->id]);

    Livewire::actingAs($user)
        ->test(Settings::class)
        ->call('withdraw')
        ->assertDispatched('notify', message: __('withdrawal.refusal_business'));

    expect($contract->refresh()->withdrawn_at)->toBeNull();
});

it('treats a customer whose type was never recorded as a consumer', function () {
    $stripe = withdrawalStripe();

    // No `customer_type` at all: the state every customer created from a Stripe
    // event arrives in. NULL is not "business" and must never be read as one.
    [$contract] = withdrawableContract();

    expect($contract->customer->customer_type)->toBeNull()
        ->and($contract->customer->hasRecordedType())->toBeFalse();

    Carbon::setTestNow('2026-03-08 09:00:00');

    app(WithdrawContract::class)($contract);

    expect($contract->refresh()->withdrawn_at)->not->toBeNull()
        ->and($stripe->refunds)->toHaveCount(1);
});

it('refuses a withdrawal once the fourteen days are up', function () {
    $stripe = withdrawalStripe();
    [$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);

    // One second past the deadline. The window closed at 15 March 09:00.
    Carbon::setTestNow('2026-03-15 09:00:01');

    expect(WithdrawalRight::for($contract->refresh())->open)->toBeFalse();

    expect(fn () => app(WithdrawContract::class)($contract))
        ->toThrow(RuntimeException::class, __('withdrawal.refusal_expired'));

    expect($contract->refresh()->withdrawn_at)->toBeNull()
        ->and($stripe->refunds)->toBeEmpty();
});

it('refunds a consumer withdrawing on day thirteen the whole of what they paid', function () {
    $stripe = withdrawalStripe();
    [$contract, $invoice] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);

    // The document the customer actually has: 58,80 € was charged, and the
    // document divides it — 49,00 € net and 9,80 € VAT.
    expect($invoice->gross_cents)->toBe(5880)
        ->and($invoice->net_cents)->toBe(4900)
        ->and($invoice->tax_cents)->toBe(980);

    // Day thirteen: deep inside the window, and thirteen days of cloud really
    // did run. None of it is charged for.
    Carbon::setTestNow('2026-03-13 09:00:00');

    $right = WithdrawalRight::for($contract->refresh());

    expect($right->open)->toBeTrue()
        ->and(WithdrawalRight::deliveredDays($contract))->toBe(12)
        ->and(WithdrawalRight::termDays($contract))->toBe(31);

    app(WithdrawContract::class)($contract);

    // Nothing is invoiced for the days that were delivered — there is the
    // original and its Storno, and no third document.
    expect(Invoice::query()->where('customer_id', $contract->customer_id)->count())->toBe(2);

    // And the money is the whole of what the cancelled document said.
    expect($stripe->refunds)->toHaveCount(1)
        ->and($stripe->refunds[0]['amount'])->toBe(5880)
        ->and($stripe->refunds[0]['payment'])->toBe('pi_withdraw')
        ->and($contract->refresh()->withdrawal_refund_cents)->toBe(5880);
});

it('refunds in full on day seven as well, whatever the express-start consent said', function () {
    $stripe = withdrawalStripe();

    // FAGG §16 would let us keep the pro-rata share where the consumer asked for
    // the service to begin inside the window. The owner has decided not to, so
    // the consent decides nothing: the contract carries it and the refund is
    // still everything.
    [$contract, $invoice] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);

    $contract->update(['immediate_start_consent_at' => now()]);

    Carbon::setTestNow('2026-03-08 09:00:00');

    app(WithdrawContract::class)($contract->refresh());

    expect($stripe->refunds[0]['amount'])->toBe($invoice->gross_cents)
        ->and($stripe->refunds[0]['amount'])->toBe(5880);
});

it('ends the service through the existing machinery, and the instance stops being served', function () {
    withdrawalStripe();
    [$contract, , $instance] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);

    /** @var FakeTraefikWriter $traefik */
    $traefik = app(TraefikWriter::class);

    expect($traefik->routes)->toHaveKey($instance->subdomain);

    Carbon::setTestNow('2026-03-08 09:00:00');

    app(WithdrawContract::class)($contract);

    $instance->refresh();

    // Marked ended by EndInstanceService, through `cancellation_scheduled` and
    // a service end of NOW — a withdrawal leaves no paid-up term to run out.
    expect($instance->status)->toBe('ended')
        ->and($instance->route_written)->toBeFalse()
        ->and($instance->service_ends_at?->toDateTimeString())->toBe('2026-03-08 09:00:00');

    // The address really is gone: no router, no DNS record.
    expect($traefik->routes)->not->toHaveKey($instance->subdomain)
        ->and(DnsRecord::query()->where('instance_id', $instance->id)->exists())->toBeFalse();

    expect($contract->refresh()->status)->toBe('cancelled');
});

it('cancels the invoice with its own document and never reuses a number', function () {
    withdrawalStripe();
    [$contract, $invoice] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);

    $originalNumber = $invoice->number;

    Carbon::setTestNow('2026-03-08 09:00:00');

    app(WithdrawContract::class)($contract);

    // The original is untouched. Not edited, not deleted, same number.
    $invoice->refresh();
    expect($invoice->exists)->toBeTrue()
        ->and($invoice->number)->toBe($originalNumber)
        ->and($invoice->gross_cents)->toBe(5880);

    // A cancellation in its own series, pointing at what it takes back and
    // stating the same figures with the opposite sign.
    $storno = Invoice::query()->where('cancels_invoice_id', $invoice->id)->firstOrFail();
    $series = InvoiceSeries::query()->where('kind', 'cancellation')->firstOrFail();

    expect($storno->invoice_series_id)->toBe($series->id)
        ->and($storno->number)->toStartWith($series->prefix)
        ->and($storno->net_cents)->toBe(-4900)
        ->and($storno->tax_cents)->toBe(-980)
        ->and($storno->gross_cents)->toBe(-5880)
        ->and($storno->snapshot['meta']['title'])->toBe(__('invoice.cancellation_title'));

    // Every number issued is distinct, and each series only ever went forwards.
    $numbers = Invoice::query()->pluck('number');
    expect($numbers->unique()->count())->toBe($numbers->count());

    // A second Storno of the same invoice would take the same money back twice
    // on paper, so it is refused outright.
    expect(fn () => app(IssueInvoice::class)->cancelling($invoice))
        ->toThrow(RuntimeException::class);
});

it('tells Stripe to stop charging immediately', function () {
    $stripe = withdrawalStripe();
    [$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);

    // The call nothing in this application used to be able to make. Without it a
    // withdrawal ended the service, sent the money back — and left the card being
    // charged every month for a machine that had been switched off.
    $contract->update(['stripe_subscription_id' => 'sub_withdraw']);

    Carbon::setTestNow('2026-03-08 09:00:00');

    app(WithdrawContract::class)($contract->refresh());

    expect($stripe->cancellations)->toHaveCount(1)
        ->and($stripe->cancellations[0]['subscription'])->toBe('sub_withdraw')
        // IMMEDIATELY, which is the whole difference from a customer's own
        // cancellation: a withdrawal unwinds the contract and the entire amount
        // has just gone back, so there is no paid-up term left to use up.
        ->and($stripe->cancellations[0]['when'])->toBe(StripeClient::CANCEL_IMMEDIATELY)
        ->and($contract->refresh()->withdrawal_refund_error)->toBeNull();
});

it('refunds and cancels every charge inside the window, not only the opening one', function () {
    $stripe = withdrawalStripe();
    [$contract, $opening] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);

    $contract->update(['stripe_subscription_id' => 'sub_withdraw']);

    // Day three: the customer books a storage pack, Stripe prorates it and takes
    // 12,00 € there and then. Its document points at the CONTRACT and carries no
    // `order_id` — which is exactly why the old code, which looked the document up
    // by `invoices.order_id`, could not see it at all. It was neither cancelled
    // nor refunded, and the owner's rule is that a withdrawing consumer gets
    // everything back.
    Carbon::setTestNow('2026-03-04 09:00:00');

    $module = app(IssueInvoice::class)->forStripeInvoice(
        subscription: $contract->refresh(),
        stripeInvoiceId: 'in_module',
        lines: [[
            'id' => 'il_module',
            'amount' => 1200,
            'quantity' => 1,
            'price' => ['id' => 'price_storage', 'metadata' => ['addon' => 'storage']],
        ]],
    );

    // A different charge is a different payment, and Stripe refunds a PAYMENT.
    $stripe->invoicePayments['in_module'] = 'pi_module';

    expect($module->order_id)->toBeNull()
        ->and($module->subscription_id)->toBe($contract->id)
        ->and($module->gross_cents)->toBe(1200);

    Carbon::setTestNow('2026-03-08 09:00:00');

    app(WithdrawContract::class)($contract->refresh());

    // A Storno per document, because that is what a Storno is: it mirrors ONE
    // invoice and carries its number. Both originals keep theirs for ever.
    $stornos = Invoice::query()->whereNotNull('cancels_invoice_id')->orderBy('id')->get();

    expect($stornos)->toHaveCount(2)
        ->and($stornos->pluck('cancels_invoice_id')->all())->toBe([$opening->id, $module->id])
        ->and($stornos->pluck('gross_cents')->all())->toBe([-5880, -1200]);

    // And the money: one refund per payment, each against the payment that took
    // it. One refund of 70,80 € against the opening PaymentIntent is what Stripe
    // would have refused for exceeding the charge.
    expect($stripe->refunds)->toHaveCount(2)
        ->and($stripe->refunds[0]['payment'])->toBe('pi_withdraw')
        ->and($stripe->refunds[0]['amount'])->toBe(5880)
        ->and($stripe->refunds[1]['payment'])->toBe('pi_module')
        ->and($stripe->refunds[1]['amount'])->toBe(1200)
        // Distinct idempotency keys. One key for the whole withdrawal would have
        // Stripe replay the first refund's answer for the second, and the module
        // charge would silently never go back.
        ->and($stripe->refunds[0]['key'])->not->toBe($stripe->refunds[1]['key']);

    expect($contract->refresh()->withdrawal_refund_cents)->toBe(7080)
        ->and($contract->withdrawal_refund_error)->toBeNull();

    // The register states what actually went back, so it agrees with the two
    // cancellation documents rather than with the opening one alone.
    $entry = SubscriptionRecord::query()
        ->where('subscription_id', $contract->id)
        ->where('event', SubscriptionRecord::EVENT_WITHDRAWAL)
        ->firstOrFail();

    expect($entry->snapshot['withdrawal']['refunded_gross_cents'])->toBe(7080);
});

it('records the withdrawal in the proof register', function () {
    withdrawalStripe();
    [$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);

    Carbon::setTestNow('2026-03-08 09:00:00');

    app(WithdrawContract::class)($contract);

    $entry = SubscriptionRecord::query()
        ->where('subscription_id', $contract->id)
        ->where('event', SubscriptionRecord::EVENT_WITHDRAWAL)
        ->firstOrFail();

    // Revenue leaving, with what actually went back — and the days the service
    // ran beside it, which nothing is charged for but which is still a fact
    // about this contract.
    expect($entry->gross_cents)->toBe(-5880)
        ->and($entry->snapshot['withdrawal']['delivered_days'])->toBe(7)
        ->and($entry->snapshot['withdrawal']['term_days'])->toBe(31)
        ->and($entry->snapshot['withdrawal']['refunded_gross_cents'])->toBe(5880);
});

it('withdraws once however many times it is asked', function () {
    $stripe = withdrawalStripe();
    [$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);

    Carbon::setTestNow('2026-03-08 09:00:00');

    app(WithdrawContract::class)($contract);

    expect(fn () => app(WithdrawContract::class)($contract->refresh()))
        ->toThrow(RuntimeException::class, __('withdrawal.refusal_already'));

    // One refund, one cancellation document. A second of either would be money
    // and a number we can never get back.
    expect($stripe->refunds)->toHaveCount(1)
        ->and(Invoice::query()->whereNotNull('cancels_invoice_id')->count())->toBe(1);
});

it('lets a consumer withdraw from the portal', function () {
    $stripe = withdrawalStripe();
    [$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);

    $user = User::factory()->create(['email' => $contract->customer->email]);
    $contract->customer->update(['user_id' => $user->id]);

    Carbon::setTestNow('2026-03-08 09:00:00');

    Livewire::actingAs($user)
        ->test(Settings::class)
        ->call('withdraw')
        ->assertDispatched('notify', message: __('withdrawal.done'));

    expect($contract->refresh()->withdrawn_at)->not->toBeNull()
        ->and($contract->withdrawal_channel)->toBe(WithdrawContract::CHANNEL_PORTAL)
        ->and($stripe->refunds)->toHaveCount(1);
});

it('lets an operator record a withdrawal that came in by telephone', function () {
    $stripe = withdrawalStripe();
    [$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);

    $support = operator('Support');

    Carbon::setTestNow('2026-03-08 09:00:00');

    Livewire::actingAs($support, 'operator')
        ->test(RecordWithdrawal::class, ['uuid' => $contract->customer->uuid])
        ->call('record')
        ->assertDispatched('notify');

    $contract->refresh();

    // The same action, the same arithmetic — with the channel and the name of
    // whoever took the declaration on it.
    expect($contract->withdrawn_at)->not->toBeNull()
        ->and($contract->withdrawal_channel)->toBe(WithdrawContract::CHANNEL_OPERATOR)
        ->and($contract->withdrawal_recorded_by)->toBe($support->id)
        ->and($stripe->refunds[0]['amount'])->toBe(5880);
});

it('will not let an operator record a withdrawal a business customer does not have', function () {
    $stripe = withdrawalStripe();
    [$contract] = withdrawableContract(['customer_type' => Customer::TYPE_BUSINESS]);

    Carbon::setTestNow('2026-03-08 09:00:00');

    Livewire::actingAs(operator('Support'), 'operator')
        ->test(RecordWithdrawal::class, ['uuid' => $contract->customer->uuid])
        ->call('record')
        ->assertSet('refusal', __('withdrawal.refusal_business'));

    expect($contract->refresh()->withdrawn_at)->toBeNull()
        ->and($stripe->refunds)->toBeEmpty();
});

it('keeps the console withdrawal behind a permission', function () {
    withdrawalStripe();
    [$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);

    // A modal is reachable without the route middleware of the page that opens
    // it, so the check lives on the component itself.
    Livewire::actingAs(operator('Read-only'), 'operator')
        ->test(RecordWithdrawal::class, ['uuid' => $contract->customer->uuid])
        ->assertForbidden();
});

it('shows the card to a consumer and never to a business', function () {
    withdrawalStripe();
    [$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);

    $user = User::factory()->create(['email' => $contract->customer->email]);
    $contract->customer->update(['user_id' => $user->id]);

    // On the contract tab: the settings page is four tabs now, and a withdrawal
    // belongs with the contract rather than with the company address.
    Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract'])
        ->assertSee(__('withdrawal.cta'));

    $contract->customer->update(['customer_type' => Customer::TYPE_BUSINESS]);

    Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract'])
        ->assertDontSee(__('withdrawal.cta'));
});
