<?php

use App\Actions\BookAddon;
use App\Actions\GrantSubscription;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\InvoiceSeries;
use App\Models\PlanPrice;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Services\Billing\AddonPrices;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use App\Support\CompanyProfile;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;

/**
 * A booked module, billed every month, on one document with the package.
 *
 * Two faults, one cause. Modules were not items on the Stripe subscription at
 * all, so a customer who booked storage in March paid for it in March and never
 * again — and because the renewal document was written from our own contract
 * snapshot, it would not have said so even if they had been charged. Both are
 * closed here: the module becomes an item, and the document is built from the
 * lines Stripe actually charged.
 *
 * The owner's rule, in their own example: two months of package, storage booked
 * in the middle of month three, and that is four invoices with four numbers —
 * three for the package, one for the module the day it was booked — after which
 * ONE invoice a month carries both.
 */
beforeEach(function () {
    // Fixed, because a proration is a number of days and a document states a
    // service period. Both are the point of these tests.
    Carbon::setTestNow('2026-03-01 09:00:00');

    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 Stripe that answers, with the catalogue — packages and modules — mirrored into it. */
function addonStripe(): FakeStripeClient
{
    $fake = new FakeStripeClient;
    app()->instance(StripeClient::class, $fake);

    app(Kernel::class)->call('stripe:sync-catalogue');

    return $fake;
}

/** A paying contract with somebody on it to send a document to. */
function addonContract(string $plan = 'team'): Subscription
{
    $customer = Customer::factory()->create([
        'name' => 'Berger GmbH',
        'email' => 'kundin@example.com',
    ]);

    return Subscription::factory()->plan($plan)->create([
        'customer_id' => $customer->id,
        'stripe_subscription_id' => 'sub_mod',
        'stripe_item_id' => 'si_plan',
        'current_period_start' => now(),
        'current_period_end' => now()->addMonth(),
    ]);
}

/** The Stripe Price a package is sold at on this term. */
function addonPlanPrice(Subscription $contract): string
{
    return (string) PlanPrice::query()
        ->where('plan_version_id', $contract->plan_version_id)
        ->where('term', $contract->term)
        ->value('stripe_price_id');
}

/**
 * The Stripe Price a module is sold at, monthly, at today's catalogue figure —
 * to a customer who is charged VAT.
 *
 * There are two Prices per module and interval now: this one and the bare net a
 * verified business in another member state is charged. The contracts in this
 * file are domestic, so the domestic treatment is the one their items bill on.
 */
function addonModulePrice(string $key, int $monthlyCents): string
{
    return (string) app(AddonPrices::class)->liveFor(
        $key,
        $monthlyCents,
        'EUR',
        Subscription::TERM_MONTHLY,
        TaxTreatment::domestic(),
    );
}

/**
 * What Stripe actually takes for a net catalogue figure from an ordinary,
 * domestic customer.
 *
 * Their Price carries the GROSS, so every amount on a Stripe invoice line for them
 * already contains the VAT. The fixtures below state the net figure a reader
 * recognises from the price sheet and put it through the same call the sync does,
 * rather than writing 21480 and leaving the next reader to work out where it came
 * from. A reverse-charge business is charged the bare net instead, and that is
 * tested where it belongs, in ReverseChargePriceTest.
 */
function atTheTill(int $netCents): int
{
    return TaxTreatment::advertisedCents($netCents);
}

/**
 * One line as Stripe puts it on an invoice.
 *
 * @return array<string, mixed>
 */
function stripeLine(string $priceId, int $amount, Carbon $from, Carbon $to, int $quantity = 1, bool $proration = false): array
{
    return [
        'id' => 'il_'.substr(sha1($priceId.$amount.$from->timestamp), 0, 8),
        'amount' => $amount,
        'quantity' => $quantity,
        'proration' => $proration,
        'period' => ['start' => $from->timestamp, 'end' => $to->timestamp],
        'price' => ['id' => $priceId, 'metadata' => []],
        'description' => 'Stripe’s own wording, which no customer should have to read',
    ];
}

/**
 * The `invoice.paid` event, carrying the lines Stripe charged for.
 *
 * @param  array<int, array<string, mixed>>  $lines
 * @return array<string, mixed>
 */
function paidInvoice(string $id, array $lines, string $reason, Carbon $from, Carbon $to): array
{
    // What Stripe took IS the sum of its lines. The catalogue is pushed gross,
    // so there is nothing to add on top — and the document has to total to this
    // figure exactly, which is what these tests are checking.
    $charged = array_sum(array_column($lines, 'amount'));

    return [
        'id' => 'evt_'.$id, 'type' => 'invoice.paid',
        'data' => ['object' => [
            'id' => $id,
            'subscription' => 'sub_mod',
            'billing_reason' => $reason,
            'currency' => 'eur',
            'amount_paid' => $charged,
            'total' => $charged,
            'period_start' => $from->timestamp,
            'period_end' => $to->timestamp,
            'lines' => ['data' => $lines, 'has_more' => false],
        ]],
    ];
}

it('gives the owner’s example four invoices, and puts package and module on the fourth', function () {
    Mail::fake();
    Queue::fake();

    $stripe = addonStripe();
    $contract = addonContract();
    $planPrice = addonPlanPrice($contract);

    $month1 = now()->copy();
    $month2 = $month1->copy()->addMonth();
    $month3 = $month2->copy()->addMonth();
    $month4 = $month3->copy()->addMonth();

    // Month one and month two: the package on its own, one document each.
    $this->postJson(route('webhooks.stripe'), paidInvoice(
        'in_m1', [stripeLine($planPrice, atTheTill(17900), $month1, $month2)], 'subscription_cycle', $month1, $month2,
    ))->assertOk();

    Carbon::setTestNow($month2);
    $this->postJson(route('webhooks.stripe'), paidInvoice(
        'in_m2', [stripeLine($planPrice, atTheTill(17900), $month2, $month3)], 'subscription_cycle', $month2, $month3,
    ))->assertOk();

    expect(Invoice::query()->count())->toBe(2);

    // The middle of month three: the customer books a storage pack. The item
    // goes onto the subscription and Stripe settles it there and then —
    // always_invoice, which prorates by days AND charges immediately, so the
    // booking produces its own invoice rather than a credit line a month later.
    Carbon::setTestNow($month3->copy()->addDays(14));
    Subscription::query()->whereKey($contract->id)->update([
        'current_period_start' => $month3, 'current_period_end' => $month4,
    ]);

    app(BookAddon::class)($contract->fresh(), 'storage', 1);

    expect($stripe->itemCalls)->toHaveCount(1)
        ->and($stripe->itemCalls[0]['call'])->toBe('add')
        ->and($stripe->itemCalls[0]['proration'])->toBe(StripeClient::PRORATE_IMMEDIATELY)
        // 1500, nicht 1000: der Katalogpreis eines Blocks seit Task 5.
        ->and($stripe->itemCalls[0]['price'])->toBe(addonModulePrice('storage', 1500));

    // Which is the invoice Stripe then raises: half a month of the module, on
    // its own, in the middle of a term. The owner's fourth document.
    $storagePrice = addonModulePrice('storage', 1500);
    $this->postJson(route('webhooks.stripe'), paidInvoice(
        'in_m3_storage',
        [stripeLine($storagePrice, atTheTill(484), now(), $month4, proration: true)],
        'subscription_update',
        now(),
        $month4,
    ))->assertOk();

    // From month four the package and the module are charged together, so they
    // are ONE document — which is exactly what the owner asked for, and what
    // separate invoices per line would not have been.
    Carbon::setTestNow($month4);
    $this->postJson(route('webhooks.stripe'), paidInvoice(
        'in_m4',
        [
            stripeLine($planPrice, atTheTill(17900), $month4, $month4->copy()->addMonth()),
            stripeLine($storagePrice, atTheTill(1500), $month4, $month4->copy()->addMonth()),
        ],
        'subscription_cycle',
        $month4,
        $month4->copy()->addMonth(),
    ))->assertOk();

    $invoices = Invoice::query()->orderBy('id')->get();

    // Four documents, four numbers, in one unbroken series.
    expect($invoices)->toHaveCount(4)
        ->and($invoices->pluck('number')->all())->toBe([
            'RE-2026-0001', 'RE-2026-0002', 'RE-2026-0003', 'RE-2026-0004',
        ]);

    // The first three carry one line each: package, package, module.
    expect($invoices[0]->snapshot['lines'])->toHaveCount(1)
        ->and($invoices[1]->snapshot['lines'])->toHaveCount(1)
        ->and($invoices[2]->snapshot['lines'])->toHaveCount(1)
        ->and($invoices[2]->snapshot['lines'][0]['description'])->toBe(__('billing.storage_title'))
        // Prorated by days, and it says so — a figure that is not the monthly
        // price the customer knows has to explain itself.
        ->and($invoices[2]->net_cents)->toBe(484)
        ->and($invoices[2]->snapshot['lines'][0]['details'])->toContain(__('invoice.line_prorated'));

    // And the fourth carries both, on one document with one number.
    $fourth = $invoices[3];

    expect($fourth->snapshot['lines'])->toHaveCount(2)
        ->and(array_column($fourth->snapshot['lines'], 'description'))
        ->toBe([__('billing.cart.plan', ['plan' => 'Team']), __('billing.storage_title')])
        // Stripe took 226,80 € and the document says 226,80 €, split into the
        // net and the VAT that were inside it. The catalogue is pushed gross, so
        // the total is not something the document adds up to — it is the figure
        // it starts from and divides.
        ->and($fourth->gross_cents)->toBe(atTheTill(17900) + atTheTill(1500))
        ->and($fourth->gross_cents)->toBe(23280)
        ->and($fourth->net_cents)->toBe(19400)
        ->and($fourth->tax_cents)->toBe(3880);

    // One mail per document, never two for one.
    Mail::assertQueuedCount(4);
});

it('issues one document and consumes one number however often Stripe redelivers a module invoice', function () {
    Mail::fake();
    Queue::fake();

    addonStripe();
    $contract = addonContract();

    $event = paidInvoice(
        'in_twice',
        [stripeLine(addonPlanPrice($contract), atTheTill(17900), now(), now()->addMonth())],
        'subscription_cycle',
        now(),
        now()->addMonth(),
    );

    $this->postJson(route('webhooks.stripe'), $event)->assertOk();
    $this->postJson(route('webhooks.stripe'), $event)->assertOk();

    // An invoice number comes out of a gapless series and can never be handed
    // out twice — so the second delivery must not take one, not even one it
    // then throws away.
    expect(Invoice::query()->count())->toBe(1)
        ->and(InvoiceSeries::query()->where('kind', 'invoice')->value('next_number'))->toBe(2);

    Mail::assertQueuedCount(1);
});

it('keeps a cancelled module until the period end and leaves it off the next cycle invoice', function () {
    Mail::fake();
    Queue::fake();

    $stripe = addonStripe();
    $contract = addonContract();

    $addon = app(BookAddon::class)($contract, 'storage', 1);
    $item = $addon->fresh()->stripe_item_id;

    expect($item)->not->toBeNull()
        ->and($stripe->itemsOn('sub_mod'))->toHaveCount(1);

    // Cancelled in the middle of the term the customer has already paid for.
    Carbon::setTestNow(now()->addDays(10));
    app(BookAddon::class)->cancelAtPeriodEnd($addon->fresh());

    $addon->refresh();

    // They keep it: the booking is still running, with an appointment for the
    // end of the term. Nothing was credited — the item simply came off, so the
    // NEXT cycle is smaller and this one is untouched.
    expect($addon->cancelled_at)->toBeNull()
        ->and($addon->cancels_at->timestamp)->toBe($contract->current_period_end->timestamp)
        ->and($stripe->itemsOn('sub_mod'))->toHaveCount(0)
        ->and(collect($stripe->itemCalls)->last())
        ->toBe(['call' => 'remove', 'item' => $item, 'proration' => StripeClient::PRORATE_NONE]);

    // The cycle turns. Stripe bills the package alone, and the appointment is
    // kept: the module stops being held as well as being billed.
    Carbon::setTestNow($contract->current_period_end->copy()->addMinute());
    $this->artisan('clupilot:end-cancelled-addons')->assertSuccessful();

    expect($addon->fresh()->cancelled_at)->not->toBeNull();

    $this->postJson(route('webhooks.stripe'), paidInvoice(
        'in_after_cancel',
        [stripeLine(addonPlanPrice($contract), atTheTill(17900), now(), now()->addMonth())],
        'subscription_cycle',
        now(),
        now()->addMonth(),
    ))->assertOk();

    $invoice = Invoice::query()->latest('id')->sole();

    expect($invoice->snapshot['lines'])->toHaveCount(1)
        ->and($invoice->net_cents)->toBe(17900);
});

it('books a second storage pack as a quantity rather than a second item', function () {
    Mail::fake();
    Queue::fake();

    $stripe = addonStripe();
    $contract = addonContract();

    app(BookAddon::class)($contract, 'storage', 1);
    app(BookAddon::class)($contract->fresh(), 'storage', 1);

    // Two packs are twice as much storage on one item, not two items at the
    // same price — Stripe allows the latter and nothing could ever tell the two
    // apart afterwards.
    $items = $stripe->itemsOn('sub_mod');

    expect($items)->toHaveCount(1)
        ->and(reset($items)['quantity'])->toBe(2)
        ->and(collect($stripe->itemCalls)->last()['call'])->toBe('quantity')
        // More of a pack is had now and paid for the days that are left.
        ->and(collect($stripe->itemCalls)->last()['proration'])->toBe(StripeClient::PRORATE_IMMEDIATELY);

    // Both bookings point at the one item, so a third would join it too.
    expect(SubscriptionAddon::query()->pluck('stripe_item_id')->unique())->toHaveCount(1);

    // And the document says two, at the unit price, so the reader can multiply.
    $this->postJson(route('webhooks.stripe'), paidInvoice(
        'in_packs',
        [
            stripeLine(addonPlanPrice($contract), atTheTill(17900), now(), now()->addMonth()),
            stripeLine(addonModulePrice('storage', 1500), atTheTill(3000), now(), now()->addMonth(), quantity: 2),
        ],
        'subscription_cycle',
        now(),
        now()->addMonth(),
    ))->assertOk();

    $storage = collect(Invoice::query()->sole()->snapshot['lines'])
        ->firstWhere('description', __('billing.storage_title'));

    expect($storage['quantity_milli'])->toBe(2000)
        ->and($storage['unit_net_cents'])->toBe(1500);
});

it('books a module onto a granted contract without asking Stripe anything', function () {
    fakeServices();
    Queue::fake();

    $stripe = addonStripe();

    // Any call at all would throw and be parked as a failure, so a clean
    // contract afterwards is proof that none was made.
    $stripe->failWith = 'Stripe must not be called for a granted contract.';

    $customer = Customer::factory()->create();
    $granted = app(GrantSubscription::class)($customer, admin(), 'team', 'monthly', 'fsn', 0);

    $addon = app(BookAddon::class)($granted->fresh(), 'storage', 1);

    expect($addon->exists)->toBeTrue()
        ->and($addon->fresh()->stripe_item_id)->toBeNull()
        ->and($stripe->itemCalls)->toBeEmpty()
        ->and($granted->fresh()->stripe_addon_sync)->toBeNull();

    // And the sweep leaves it alone too: a contract Stripe never sold is not
    // out of step with Stripe.
    $this->artisan('clupilot:sync-stripe-subscriptions')->assertSuccessful();

    expect($stripe->itemCalls)->toBeEmpty();
});

it('does not undo a booking Stripe could not be told about, and finishes it on the sweep', function () {
    Mail::fake();
    Queue::fake();

    $stripe = addonStripe();
    $contract = addonContract();

    $stripe->failWith = 'Connection timed out';

    $addon = app(BookAddon::class)($contract, 'storage', 1);

    // The pack is booked and, on a running machine, already delivered. Rolling
    // that back because an API was away would take storage off a customer who
    // is holding it.
    expect($addon->fresh()->isActive())->toBeTrue()
        ->and($addon->fresh()->stripe_item_id)->toBeNull();

    // And what did not happen is on the contract, not only in a log line.
    expect($contract->fresh()->stripe_addon_sync['error'])->toContain('Connection timed out')
        ->and($contract->fresh()->stripe_addon_sync['attempts'])->toBe(1);

    // The hourly sweep finishes it once Stripe answers again — the same one
    // that retries a plan change that never landed.
    $stripe->failWith = null;
    $this->artisan('clupilot:sync-stripe-subscriptions')->assertSuccessful();

    expect($stripe->itemsOn('sub_mod'))->toHaveCount(1)
        ->and($addon->fresh()->stripe_item_id)->not->toBeNull()
        ->and($contract->fresh()->stripe_addon_sync)->toBeNull();
});

it('gives an upgrade’s proration a document for the amount Stripe actually charged', function () {
    Mail::fake();
    Queue::fake();

    addonStripe();
    $contract = addonContract();

    $business = (string) PlanPrice::query()
        ->where('plan_version_id', Subscription::snapshotFrom('business')['plan_version_id'])
        ->where('term', 'monthly')
        ->value('stripe_price_id');

    // Stripe worked this out against ITS boundaries: the days left on the small
    // package credited, the days left on the big one charged. A document written
    // from our contract snapshot would state a sum nobody was charged, which is
    // why this used to get no document at all — and why that was wrong.
    $this->postJson(route('webhooks.stripe'), paidInvoice(
        'in_upgrade',
        [
            stripeLine(addonPlanPrice($contract), atTheTill(-8950), now(), now()->addDays(15), proration: true),
            stripeLine($business, atTheTill(17450), now(), now()->addDays(15), proration: true),
        ],
        'subscription_update',
        now(),
        now()->addDays(15),
    ))->assertOk();

    $invoice = Invoice::query()->sole();

    expect($invoice->snapshot['lines'])->toHaveCount(2)
        ->and($invoice->net_cents)->toBe(8500)
        ->and($invoice->tax_cents)->toBe(1700)
        ->and($invoice->gross_cents)->toBe(10200)
        ->and(array_column($invoice->snapshot['lines'], 'description'))->toBe([
            __('billing.cart.plan', ['plan' => 'Team']),
            __('billing.cart.plan', ['plan' => 'Business']),
        ]);

    Mail::assertQueuedCount(1);
});

it('keeps a line it cannot name rather than dropping it from the document', function () {
    Mail::fake();
    Queue::fake();

    addonStripe();
    $contract = addonContract();

    // A Price created by hand in Stripe's dashboard: money was taken for
    // something this catalogue has never heard of. Leaving it off would give the
    // customer a document whose total they cannot account for.
    $this->postJson(route('webhooks.stripe'), paidInvoice(
        'in_unknown',
        [
            stripeLine(addonPlanPrice($contract), atTheTill(17900), now(), now()->addMonth()),
            stripeLine('price_typed_by_hand', atTheTill(5000), now(), now()->addMonth()),
        ],
        'subscription_cycle',
        now(),
        now()->addMonth(),
    ))->assertOk();

    $invoice = Invoice::query()->sole();

    expect($invoice->snapshot['lines'])->toHaveCount(2)
        ->and($invoice->net_cents)->toBe(22900)
        // Under Stripe's own description, which is at least true, rather than
        // under a guess or not at all.
        ->and($invoice->snapshot['lines'][1]['description'])
        ->toBe('Stripe’s own wording, which no customer should have to read');
});
