<?php

use App\Actions\ApplyStripeBillingEvent;
use App\Actions\BookAddon;
use App\Actions\RecordCommercialEvent;
use App\Livewire\Billing;
use App\Models\Customer;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\SubscriptionRecord;
use App\Models\User;
use App\Services\Billing\AddonCatalogue;
use Illuminate\Support\Facades\Queue;

/**
 * The register answers, years later, what a customer was sold and for how much
 * — without reconstructing it from tables that have moved on since.
 */

/** A business in another member state, its VAT ID verified — reverse charge. */
function reverseChargeCustomer(): Customer
{
    return Customer::factory()->create([
        'vat_id' => 'DE123456789',
        'vat_id_verified_at' => now(),
        'vat_id_verified_value' => 'DE123456789',
    ]);
}

it('records the sale the moment a contract is opened', function () {
    Queue::fake();

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_reg',
        'type' => 'checkout.session.completed',
        'data' => ['object' => [
            'id' => 'cs_reg',
            'payment_status' => 'paid',
            'customer_details' => ['email' => 'register@example.com', 'name' => 'Kanzlei Berger'],
            // Stripe's amount_total is what was charged: net plus 20 % VAT.
            'amount_total' => 9480,
            'currency' => 'eur',
            'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
        ]],
    ])->assertOk();

    $record = SubscriptionRecord::query()->sole();

    // Flat columns, because these are what gets searched and relied on.
    expect($record->event)->toBe('purchase')
        ->and($record->plan_key)->toBe('team')
        // Die zweite Fassung: die Umschaltung auf die neue Leiter hat die erste
        // beendet und diese nachgeschoben.
        ->and($record->plan_version)->toBe(2)
        ->and($record->term)->toBe('monthly')
        ->and($record->net_cents)->toBe(7900)          // what was agreed
        ->and($record->gross_cents)->toBe(9480)        // what was charged
        ->and($record->tax_cents)->toBe(1580)
        ->and($record->snapshot['amounts']['matches_catalogue'])->toBeTrue()
        ->and($record->currency)->toBe('EUR')
        ->and($record->reverse_charge)->toBeFalse()
        ->and($record->customer_name)->toBe('Kanzlei Berger')
        ->and($record->stripe_event_id)->toBe('cs_reg');

    // Plus the whole snapshot, for the questions nobody has asked yet.
    expect($record->snapshot['plan']['capabilities']['ram_mb'])->toBe(8192)
        ->and($record->snapshot['customer']['email'])->toBe('register@example.com')
        ->and($record->snapshot_version)->toBe(1);
});

it('shows the customer their whole monthly bill, modules included', function () {
    $subscription = Subscription::factory()->plan('team')->create();
    $instance = Instance::factory()->create([
        'customer_id' => $subscription->customer_id,
        'host_id' => Host::factory()->active()->create()->id,
        'plan' => 'team', 'status' => 'active',
    ]);
    $subscription->update(['instance_id' => $instance->id]);

    app(BookAddon::class)($subscription, 'priority_support');

    $user = User::factory()->create(['email' => $subscription->customer->email]);
    $page = Livewire\Livewire::actingAs($user)->test(Billing::class);

    // The plan alone is not what they pay once a module is booked.
    expect($page->viewData('totalMonthlyCents'))->toBe(7900 + 2900);

    // And it reaches the page, not only the component.
    expect($page->html())->toContain('Gesamt inkl. Module')->toContain('108,00');
});

it('refuses to be edited or deleted, in bulk as well as one at a time', function () {
    $subscription = Subscription::factory()->plan('team')->create();
    $record = app(RecordCommercialEvent::class)('purchase', $subscription, 7900);

    // A careless data fix takes exactly this shape, and model events do not
    // fire for it.
    expect(fn () => SubscriptionRecord::query()->where('id', $record->id)->update(['net_cents' => 1]))
        ->toThrow(RuntimeException::class, 'append-only');

    expect(fn () => SubscriptionRecord::query()->where('id', $record->id)->delete())
        ->toThrow(RuntimeException::class, 'append-only');

    // A register that can be rewritten proves nothing; corrections are recorded,
    // not applied in place.
    expect(fn () => $record->update(['net_cents' => 1]))
        ->toThrow(RuntimeException::class, 'append-only');

    expect(fn () => $record->delete())->toThrow(RuntimeException::class, 'append-only')
        ->and(SubscriptionRecord::query()->count())->toBe(1)
        ->and($record->fresh()->net_cents)->toBe(7900);
});

it('books a module at today\'s price and freezes it there', function () {
    $subscription = Subscription::factory()->plan('team')->create();

    app(BookAddon::class)($subscription, 'priority_support');

    $addon = SubscriptionAddon::query()->sole();

    expect($addon->price_cents)->toBe(2900)
        ->and($addon->addon_key)->toBe('priority_support')
        ->and($addon->isActive())->toBeTrue();

    // The catalogue moves; this customer does not.
    config()->set('provisioning.addons.priority_support.price_cents', 4900);

    $offered = app(AddonCatalogue::class)->forSubscription($subscription->fresh());

    expect($offered['priority_support']['price_cents'])->toBe(2900)
        ->and($offered['priority_support']['booked'])->toBeTrue()
        // One they have NOT booked is a sale still to be made, at today's price.
        ->and($offered['collabora_pro']['price_cents'])->toBe(1900)
        ->and($offered['collabora_pro']['booked'])->toBeFalse();
});

it('adds booked modules to the monthly total, all of it frozen', function () {
    $subscription = Subscription::factory()->plan('team')->create();

    app(BookAddon::class)($subscription, 'priority_support');   // 29,00
    app(BookAddon::class)($subscription, 'extra_backups');      // 5,00

    // Everything moves in the catalogue afterwards.
    config()->set('provisioning.addons.priority_support.price_cents', 9900);
    config()->set('provisioning.addons.extra_backups.price_cents', 9900);

    expect($subscription->fresh()->totalMonthlyCents())->toBe(7900 + 2900 + 500);
});

it('counts a yearly contract and its modules per month', function () {
    $subscription = Subscription::factory()->plan('team', 'yearly')->create();

    app(BookAddon::class)($subscription, 'priority_support');

    // The plan is stored for the whole year — 790,00 € for ten paid months of
    // twelve — and the page divides it back down: 65,83 a month, plus the
    // module's own monthly figure beside it.
    expect($subscription->fresh()->totalMonthlyCents())->toBe(6583 + 2900);
});

it('refuses to reprice a module that is already booked', function () {
    $subscription = Subscription::factory()->plan('team')->create();
    $addon = app(BookAddon::class)($subscription, 'priority_support');

    expect(fn () => $addon->update(['price_cents' => 9900]))
        ->toThrow(RuntimeException::class, 'frozen');
});

it('keeps a cancelled module on record, and off the bill', function () {
    $subscription = Subscription::factory()->plan('team')->create();
    $addon = app(BookAddon::class)($subscription, 'priority_support');

    app(BookAddon::class)->cancel($addon);
    // A second request on a stale instance must not enter the event twice.
    app(BookAddon::class)->cancel($subscription->addons()->sole());

    expect(SubscriptionRecord::query()->where('event', 'addon_cancelled')->count())->toBe(1)
        ->and($subscription->fresh()->totalMonthlyCents())->toBe(7900)
        // What they were paying, and until when, is evidence.
        ->and(SubscriptionAddon::query()->sole()->cancelled_at)->not->toBeNull()
        ->and(app(AddonCatalogue::class)->forSubscription($subscription->fresh())['priority_support']['booked'])->toBeFalse();

    $events = SubscriptionRecord::query()->pluck('event')->all();
    expect($events)->toContain('addon_booked')->toContain('addon_cancelled');
});

it('books a module once when the same order arrives twice', function () {
    $subscription = Subscription::factory()->plan('team')->create();
    $order = Order::factory()->create([
        'customer_id' => $subscription->customer_id, 'type' => 'addon', 'addon_key' => 'collabora_pro',
    ]);

    app(BookAddon::class)($subscription, 'collabora_pro', 1, $order);
    app(BookAddon::class)($subscription, 'collabora_pro', 1, $order);

    $record = SubscriptionRecord::query()->where('event', 'addon_booked')->sole();

    expect(SubscriptionAddon::query()->count())->toBe(1)
        // Filed under the add-on's own order, not the plan purchase that
        // opened the contract — otherwise nothing reconciles.
        ->and($record->order_id)->toBe($order->id)
        ->and($record->order_id)->not->toBe($subscription->order_id);
});

it('sums several bookings of one module rather than showing an arbitrary one', function () {
    $subscription = Subscription::factory()->plan('team')->create();

    // Two storage packs, bought at different times and different prices.
    // Beide Preise ausdrücklich gesetzt (Task 5 änderte den Katalogpreis):
    // der Test prüft, dass zwei UNTERSCHIEDLICHE Preise summiert werden, nicht
    // was der Katalog gerade zufällig als Standard mitbringt.
    config()->set('provisioning.storage_addon.price_cents', 1000);
    app(BookAddon::class)($subscription, AddonCatalogue::STORAGE);
    config()->set('provisioning.storage_addon.price_cents', 1500);
    app(BookAddon::class)($subscription, AddonCatalogue::STORAGE);

    $storage = app(AddonCatalogue::class)->forSubscription($subscription->fresh())[AddonCatalogue::STORAGE];

    expect($storage['quantity'])->toBe(2)
        ->and($storage['monthly_cents'])->toBe(2500)      // 10,00 + 15,00
        // No single honest figure when the bookings disagree, so none is shown.
        ->and($storage['price_cents'])->toBeNull()
        ->and($storage['bookings'])->toHaveCount(2)
        // And the page cannot disagree with the bill.
        ->and($subscription->fresh()->totalMonthlyCents())->toBe(7900 + 2500);
});

it('records what was actually charged, not only what the catalogue says', function () {
    Queue::fake();

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_coupon',
        'type' => 'checkout.session.completed',
        'data' => ['object' => [
            'id' => 'cs_coupon',
            'payment_status' => 'paid',
            'customer_details' => ['email' => 'gutschein@example.com'],
            // A discount was applied: less than the catalogue price plus VAT.
            'amount_total' => 5900,
            'currency' => 'eur',
            'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
        ]],
    ])->assertOk();

    $record = SubscriptionRecord::query()->sole();

    // The transaction, split by the rate that applied: a discount lowers the
    // taxable amount, it does not produce negative VAT.
    expect($record->gross_cents)->toBe(5900)           // what the bank saw
        ->and($record->net_cents)->toBe(4917)
        ->and($record->tax_cents)->toBe(983)
        ->and($record->net_cents + $record->tax_cents)->toBe($record->gross_cents)
        // And what was agreed is kept beside it, rather than reconciled away —
        // it is exactly the thing someone asks about later.
        ->and($record->snapshot['amounts']['agreed_net_cents'])->toBe(7900)
        ->and($record->snapshot['amounts']['expected_gross_cents'])->toBe(9480)
        ->and($record->snapshot['amounts']['matches_catalogue'])->toBeFalse();
});

it('records a free checkout as free, not as paid in full', function () {
    Queue::fake();

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_free',
        'type' => 'checkout.session.completed',
        'data' => ['object' => [
            'id' => 'cs_free',
            'payment_status' => 'paid',
            'customer_details' => ['email' => 'gratis@example.com'],
            'amount_total' => 0,          // fully discounted
            'currency' => 'eur',
            'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
        ]],
    ])->assertOk();

    $record = SubscriptionRecord::query()->sole();

    expect($record->gross_cents)->toBe(0)
        ->and($record->net_cents)->toBe(0)
        ->and($record->tax_cents)->toBe(0)
        ->and($record->snapshot['amounts']['agreed_net_cents'])->toBe(7900)
        ->and($record->snapshot['amounts']['matches_catalogue'])->toBeFalse();
});

it('carries the booked modules into the snapshot of a later event', function () {
    $subscription = Subscription::factory()->plan('team')->create();
    app(BookAddon::class)($subscription, 'priority_support');

    $record = app(RecordCommercialEvent::class)('upgrade', $subscription->fresh(), 5000);

    expect($record->snapshot['addons'])->toHaveCount(1)
        ->and($record->snapshot['addons'][0]['key'])->toBe('priority_support')
        ->and($record->snapshot['addons'][0]['price_cents'])->toBe(2900);
});

it('records a customer name that later disappears', function () {
    $subscription = Subscription::factory()->plan('team')->create();
    $customer = $subscription->customer;

    $record = app(RecordCommercialEvent::class)('purchase', $subscription, 7900);

    $customer->delete();

    // The link goes; what happened does not.
    expect($record->fresh()->customer_id)->toBeNull()
        ->and($record->fresh()->customer_name)->toBe($customer->name)
        ->and($record->fresh()->net_cents)->toBe(7900)
        ->and($record->fresh()->plan_key)->toBe('team');
});

it('states net, tax and gross rather than leaving them to be recomputed', function () {
    $subscription = Subscription::factory()->plan('team')->create(['customer_id' => reverseChargeCustomer()->id]);

    // What a real reverse-charge purchase records: OpenSubscription always passes
    // the charged figure, and for this customer that figure is the BARE NET —
    // their checkout used the net Stripe Price, because reverse charge means no
    // VAT is owed to us at all.
    $record = app(RecordCommercialEvent::class)('purchase', $subscription, 7900, chargedGrossCents: 7900);

    // The whole of it is net at 0 %. Recorded as it stood on the day — a rate
    // that changes later must not change the answer for this sale.
    expect($record->net_cents)->toBe(7900)
        ->and($record->tax_cents)->toBe(0)
        ->and($record->gross_cents)->toBe(7900)
        ->and($record->reverse_charge)->toBeTrue();
});

it('calls a correctly charged reverse-charge sale a match, and an overcharged one a mismatch', function () {
    $subscription = Subscription::factory()->plan('team')->create(['customer_id' => reverseChargeCustomer()->id]);

    // 79,00 net is the WHOLE of what this customer owes: no VAT is charged to
    // them, so there is none in the price either, and their checkout used the net
    // Stripe Price. The register expects that figure, and finding it is a match.
    $record = app(RecordCommercialEvent::class)('purchase', $subscription, 7900, chargedGrossCents: 7900);

    expect($record->snapshot['amounts']['expected_gross_cents'])->toBe(7900)
        ->and($record->snapshot['amounts']['charged_gross_cents'])->toBe(7900)
        ->and($record->snapshot['amounts']['matches_catalogue'])->toBeTrue();

    // And now it catches the thing it is for, in the direction that actually cost
    // somebody money: the domestic gross taken from a reverse-charge business is a
    // flat 20 % surcharge with no VAT line anywhere for them to reclaim. That is
    // the defect this whole change ends, and the flag whose job is "somebody must
    // look at this" has to be the thing that would have found it.
    $overcharged = app(RecordCommercialEvent::class)('purchase', $subscription, 7900, chargedGrossCents: 9480);

    expect($overcharged->snapshot['amounts']['matches_catalogue'])->toBeFalse();

    // A short charge is still a mismatch, in the other direction.
    $short = app(RecordCommercialEvent::class)('purchase', $subscription, 7900, chargedGrossCents: 5900);

    expect($short->snapshot['amounts']['matches_catalogue'])->toBeFalse();
});

it('says it does not know whether the right amount was taken when nothing was taken', function () {
    $subscription = Subscription::factory()->plan('team')->create();

    // A plan change moves terms; Stripe raises the proration invoice afterwards
    // and that arrives as its own event with the real money on it. This row used
    // to be stamped with the expected figure as though it had been charged, and
    // then to agree with itself — "yes, the right amount was taken" about an
    // event where nothing was.
    $record = app(RecordCommercialEvent::class)('upgrade', $subscription, 5000);

    expect($record->snapshot['amounts']['charged_gross_cents'])->toBeNull()
        ->and($record->snapshot['amounts']['matches_catalogue'])->toBeNull()
        // What was agreed is still stated, which is the whole point of keeping it.
        ->and($record->snapshot['amounts']['agreed_net_cents'])->toBe(5000)
        ->and($record->snapshot['amounts']['expected_gross_cents'])->toBe(6000);
});

it('holds a renewal against the whole term, modules included', function () {
    $subscription = Subscription::factory()->plan('team')->create();
    $subscription->update(['stripe_subscription_id' => 'sub_renewal_modules']);

    app(BookAddon::class)($subscription, 'priority_support');   // 29,00 net / month

    $start = now()->startOfDay();

    // What Stripe bills for the cycle: the package item AND the module item, each
    // at the gross of its net figure. Held against `price_cents` alone, every
    // renewal on a contract with a single module was filed as charged at the
    // wrong amount — the one thing this flag exists to point at, false for a
    // customer whose bill was correct to the cent.
    app(ApplyStripeBillingEvent::class)->invoicePaid([
        'id' => 'in_renewal_modules',
        'subscription' => 'sub_renewal_modules',
        'billing_reason' => 'subscription_cycle',
        'period_start' => $start->getTimestamp(),
        'period_end' => $start->copy()->addMonth()->getTimestamp(),
        'amount_paid' => 12960,      // (79,00 + 29,00) + 20 %
    ]);

    $record = SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_RENEWAL)->sole();

    expect($record->snapshot['amounts']['agreed_net_cents'])->toBe(7900 + 2900)
        ->and($record->snapshot['amounts']['expected_gross_cents'])->toBe(12960)
        ->and($record->snapshot['amounts']['matches_catalogue'])->toBeTrue();
});
