<?php

use App\Models\Customer;
use App\Models\PlanPrice;
use App\Models\Subscription;
use App\Models\User;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Facades\DB;

/**
 * A customer buys a package themselves.
 *
 * Every path on the site used to end in "anfragen" — a mailto: link and an
 * afternoon of somebody's time per customer, for a product whose whole point is
 * that the machine does the work. Now: sign in, pick, pay, and provisioning
 * starts on the webhook. The one case that still needs a person is no capacity,
 * and it needs them to buy a server, not to answer a mail.
 */
beforeEach(function () {
    $this->stripe = new FakeStripeClient;
    app()->instance(StripeClient::class, $this->stripe);

    // Every sellable price needs a Stripe id, or there is nothing to check out
    // against. The catalogue sync fills these in production.
    DB::table('plan_prices')->update(['stripe_price_id' => 'price_test']);
});

/**
 * A checkout post, consent and all.
 *
 * The express request that we begin inside the withdrawal window is required
 * (FAGG §16): without it on record a withdrawal on day thirteen takes back the
 * WHOLE amount however long the machine ran, because a seller who cannot prove
 * the consent cannot charge for the days.
 */
function buying(string $plan): array
{
    return ['plan' => $plan, 'terms_accepted' => '1'];
}

/** A signed-in, verified portal user. */
function buyer(): User
{
    return User::factory()->create(['email_verified_at' => now()]);
}

it('opens a Stripe checkout for the package that was chosen', function () {
    $this->actingAs(buyer())
        ->post(route('checkout.start'), buying('start'))
        ->assertRedirect();

    expect($this->stripe->checkouts)->toHaveCount(1)
        ->and($this->stripe->checkouts[0]['price'])->toBe('price_test');
});

it('carries exactly the metadata the webhook reads back', function () {
    // plan, plan_version_id and datacenter are the contract between the
    // checkout and StripeWebhookController. A missing key does not fail — it
    // silently builds the wrong package.
    $this->actingAs(buyer())->post(route('checkout.start'), buying('team'));

    $meta = $this->stripe->checkouts[0]['metadata'];

    expect($meta)->toHaveKeys(['plan', 'plan_version_id', 'datacenter'])
        ->and($meta['plan'])->toBe('team')
        ->and($meta['plan_version_id'])->not->toBeEmpty();
});

it('sends the customer to Stripe with the address their credentials will go to', function () {
    $user = buyer();

    $this->actingAs($user)->post(route('checkout.start'), buying('start'));

    expect($this->stripe->checkouts[0]['email'])->toBe($user->email);
});

it('never lets a request name its own price', function () {
    // The plan key comes from a form, and a form field is a string somebody can
    // retype. What is charged is the catalogue's figure for the version on sale
    // — nothing in the request may decide it.
    $this->actingAs(buyer())->post(route('checkout.start'), buying('start') + [
        'amount_cents' => 1,
        'price' => 'price_of_my_choosing',
    ]);

    expect($this->stripe->checkouts[0]['price'])->toBe('price_test');
});

it('refuses a plan that is not on sale', function () {
    $this->actingAs(buyer())
        ->post(route('checkout.start'), buying('does-not-exist'))
        ->assertSessionHasErrors('plan');

    expect($this->stripe->checkouts)->toBeEmpty();
});

it('refuses a plan that was never synced to Stripe', function () {
    // A price with no Stripe id would open a checkout for nothing.
    PlanPrice::query()->update(['stripe_price_id' => null]);

    $this->actingAs(buyer())
        ->post(route('checkout.start'), buying('start'))
        ->assertSessionHasErrors('plan');

    expect($this->stripe->checkouts)->toBeEmpty();
});

it('says so rather than redirecting into a broken checkout when Stripe is not connected', function () {
    $this->stripe->configured = false;

    $this->actingAs(buyer())
        ->post(route('checkout.start'), buying('start'))
        ->assertSessionHasErrors('plan');

    expect($this->stripe->checkouts)->toBeEmpty();
});

it('sends an existing customer to the plan change instead of selling a second cloud', function () {
    // A second checkout would build a second, empty cloud and bill for both.
    // Moving up is a plan change: it prorates and the data stays where it is.
    $user = buyer();
    $customer = Customer::factory()->create(['email' => $user->email]);
    Subscription::factory()->create(['customer_id' => $customer->id, 'status' => 'active']);

    $this->actingAs($user)
        ->post(route('checkout.start'), buying('team'))
        ->assertRedirect(route('billing'));

    expect($this->stripe->checkouts)->toBeEmpty();
});

it('lets nobody buy anything without signing in first', function () {
    // The credentials for the finished cloud are mailed to the account's
    // address, so the account has to exist and be confirmed before the money
    // moves — not afterwards.
    $this->post(route('checkout.start'), buying('start'))->assertRedirect(route('login'));
    $this->get(route('order'))->assertRedirect(route('login'));

    expect($this->stripe->checkouts)->toBeEmpty();
});

it('does not take an unverified address through checkout', function () {
    $unverified = User::factory()->create(['email_verified_at' => null]);

    $this->actingAs($unverified)->post(route('checkout.start'), buying('start'))
        ->assertRedirect(route('verification.notice'));

    expect($this->stripe->checkouts)->toBeEmpty();
});

it('shows the packages with the same gross prices the public sheet quotes', function () {
    // A customer quoted 46,80 € out there must not meet 39 € on the page with
    // the button on it.
    $this->actingAs(buyer())
        ->get(route('order'))
        ->assertOk()
        ->assertSee('46,80')
        // "Weiter", not "buy": the order page chooses, the checkout page buys.
        ->assertSee(__('order.choose'));
});

it('promises delivery on the booking page from the estate, like everywhere else', function () {
    $this->actingAs(buyer())
        ->get(route('order'))
        ->assertOk()
        // No host is onboarded in this test, so nothing can be immediate.
        ->assertSee(__('delivery.scheduled'))
        ->assertDontSee(__('delivery.immediate'));
});

it('points the website buttons at the sign-in rather than an inbox', function () {
    // Booking happens in the panel, where everything already works. The website
    // does not need a second checkout — it needs to get somebody to the door.
    $content = $this->get('/')->assertOk()->getContent();

    expect($content)->toContain(route('login'))
        // Not straight at /order: that is behind auth, so an anonymous visitor
        // would be bounced by a middleware redirect from a page they never
        // asked for.
        ->and($content)->not->toContain('href="'.route('order').'"')
        // And not back to the old shape: every card ending in a jump to a
        // mailto: block.
        ->and($content)->not->toContain('anfragen</x-ui.button>');
});

it('does not promise a migration it has not looked at', function () {
    // We do not know where a visitor's data is, what shape it is in, or whether
    // it can be moved at all. "danach wissen Sie … wie Ihre Daten sicher
    // umziehen" was a promise made before seeing the thing it is about.
    expect($this->get('/')->getContent())
        ->not->toContain('wie Ihre Daten sicher umziehen');
});

it('refuses to open a checkout without the terms being accepted', function () {
    // The gap this closes: the webhook has always read `immediate_start` off the
    // session metadata, and the checkout never put it there. So every contract
    // was concluded WITHOUT recorded consent, and every withdrawal — even on day
    // thirteen of a running cloud — had to refund the full amount.
    $this->actingAs(buyer())
        ->post(route('checkout.start'), ['plan' => 'start'])
        ->assertSessionHasErrors('terms_accepted');

    expect($this->stripe->checkouts)->toBeEmpty();
});

it('does not take a box that was merely present as a yes', function () {
    // "accepted", not "present": an empty field is what an unticked checkbox
    // sends, and a consent that can be produced by leaving something blank is
    // not a consent.
    foreach (['', '0', 'nein'] as $value) {
        $this->actingAs(buyer())
            ->post(route('checkout.start'), ['plan' => 'start', 'terms_accepted' => $value])
            ->assertSessionHasErrors('terms_accepted');
    }

    expect($this->stripe->checkouts)->toBeEmpty();
});

it('carries the consent to Stripe as exactly the string the webhook compares against', function () {
    // StripeWebhookController tests `=== '1'` on purpose: a consent that can be
    // produced by a typo is not a consent. Anything else here silently becomes
    // "no consent" months later, at the one moment it matters.
    $this->actingAs(buyer())->post(route('checkout.start'), buying('start'));

    expect($this->stripe->checkouts[0]['metadata']['immediate_start'])->toBe('1');
});

it('names what is being accepted, and links to it', function () {
    // Not a box saying "I agree" with nothing behind it: the label says what,
    // links to the document, and summarises beside it what a reader most needs to
    // know before ticking — that delivery starts at once and the withdrawal right
    // survives it.
    //
    // On the CHECKOUT page. It used to sit at the top of the package list as a
    // condition of looking at prices; it is a condition of buying.
    $this->actingAs(buyer())
        ->get(route('checkout', ['plan' => 'start']))
        ->assertOk()
        ->assertSee(__('order.terms_link'))
        ->assertSee(__('order.terms_why'))
        ->assertSee(route('legal.agb'), false)
        ->assertSee('terms_accepted', false);
});

it('keeps the package list free of forms and consents', function () {
    // Choosing is not agreeing. The page arrived with every button dead until a
    // box at the top was ticked, which reads as broken before anybody has done
    // anything wrong.
    $page = $this->actingAs(buyer())->get(route('order'))->assertOk()->getContent();

    expect($page)->not->toContain('name="terms_accepted"')
        ->and($page)->not->toContain('action="'.route('checkout.start').'"');
});

it('serves the terms it asks a customer to accept', function () {
    // A link to a page that says "Dieser Inhalt wird noch ergänzt" is not a
    // contract, and accepting it would be accepting nothing.
    $page = $this->get(route('legal.agb'))->assertOk()->getContent();

    expect($page)->toContain('Widerrufsrecht')
        ->and($page)->toContain('Löschfristen')
        ->and($page)->not->toContain('Dieser Inhalt wird noch ergänzt');
});

it('lands as a timestamp on the order, which is what a withdrawal is measured against', function () {
    // The whole chain, end to end: the checkout puts `immediate_start` on the
    // session, Stripe hands it back on the webhook, and the order carries the
    // moment the consumer asked us to begin. Without that timestamp
    // WithdrawalRight refunds everything — the two ends were wired to each
    // other through a field nobody was sending.
    Illuminate\Support\Facades\Queue::fake();

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_consent',
        'type' => 'checkout.session.completed',
        'data' => ['object' => [
            'id' => 'cs_evt_consent',
            'payment_status' => 'paid',
            'customer_details' => ['email' => 'kunde@example.test', 'name' => 'Kanzlei Berger'],
            'amount_total' => 4900,
            'currency' => 'eur',
            // Exactly what CheckoutController::start() sends.
            'metadata' => ['plan' => 'start', 'datacenter' => 'fsn', 'immediate_start' => '1'],
        ]],
    ])->assertOk();

    $order = App\Models\Order::query()->where('stripe_event_id', 'cs_evt_consent')->first();

    expect($order)->not->toBeNull()
        ->and($order->immediate_start_consent_at)->not->toBeNull();
});

it('records no consent for a session that never carried one', function () {
    // The other direction, and the one that must stay safe: an old session, or
    // a checkout opened outside this application, records nothing — and a
    // withdrawal against it refunds in full.
    Illuminate\Support\Facades\Queue::fake();

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_no_consent',
        'type' => 'checkout.session.completed',
        'data' => ['object' => [
            'id' => 'cs_evt_no_consent',
            'payment_status' => 'paid',
            'customer_details' => ['email' => 'zweiter@example.test', 'name' => 'Zweite Kanzlei'],
            'amount_total' => 4900,
            'currency' => 'eur',
            'metadata' => ['plan' => 'start', 'datacenter' => 'fsn'],
        ]],
    ])->assertOk();

    expect(App\Models\Order::query()->where('stripe_event_id', 'cs_evt_no_consent')->first()?->immediate_start_consent_at)
        ->toBeNull();
});

it('asks for the terms where the money is spent, not where prices are read', function () {
    // On the checkout page, at the bottom of the summary, directly above the
    // button that pays — after the total, not before the prices.
    $page = Illuminate\Support\Facades\File::get(resource_path('views/livewire/checkout.blade.php'));

    expect(strpos($page, "__('checkout.total_today')"))->toBeLessThan(strpos($page, "__('order.terms_label'"))
        ->and(strpos($page, "__('order.terms_label'"))->toBeLessThan(strpos($page, "__('checkout.pay_cta')"))
        // No order without the terms — and the button says why rather than
        // leaving somebody to guess at a control that does nothing.
        ->and($page)->toContain('x-bind:disabled="!terms"')
        ->and($page)->toContain("__('order.terms_first')");
});

it('lays five packages out as three and two', function () {
    // A fifth card alone on a row of four reads as a mistake.
    expect(Illuminate\Support\Facades\File::get(resource_path('views/livewire/order.blade.php')))
        ->toContain('lg:grid-cols-3')
        ->not->toContain('lg:grid-cols-4');
});

it('still refuses the checkout when the box was not ticked', function () {
    // The layout changed, the wording changed, the rule did not: the server is
    // the gate. Disabling a button in a browser refuses nothing.
    $this->actingAs(buyer())
        ->post(route('checkout.start'), ['plan' => 'start'])
        ->assertSessionHasErrors('terms_accepted');

    expect($this->stripe->checkouts)->toBeEmpty();
});

it('renders inside the app shell, not the bare sign-in one', function () {
    // layouts.portal is what the sign-in pages use: no navigation, no page
    // padding. In it, this page sat flush against the top and bottom of the
    // window with no way back to anything.
    expect(Illuminate\Support\Facades\File::get(app_path('Livewire/Order.php')))
        ->toContain("#[Layout('layouts.portal-app')]");

    // And the shell is actually there: the navigation proves it.
    $this->actingAs(buyer())
        ->get(route('order'))
        ->assertOk()
        ->assertSee(__('dashboard.nav.overview'));
});
