<?php

use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\StripeAddonPrice;
use App\Models\StripePlanPrice;
use App\Models\Subscription;
use App\Services\Billing\AddonPrices;
use App\Services\Billing\AdoptStripeProduct;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Log;

/**
 * A run that dies between Stripe creating a PRODUCT and us storing its id leaves
 * an orphan too — and that one is worse than an orphaned Price.
 *
 * A second Product does not merely duplicate something. Every activePricesFor()
 * afterwards is asked about the new Product, so the Price recognition built for
 * exactly this failure goes blind for the whole family, and every orphaned Price
 * on the first Product becomes unreachable. The guard disables itself through
 * the gap beside it.
 *
 * Two things are deliberately unlike the Price side. There is no money gate: a
 * Product carries no amount, so the metadata is the whole of the proof. And a
 * duplicate is REPORTED, never deactivated — an archived Price is provably
 * harmless because Stripe goes on billing subscriptions already on it, but
 * deactivating a Product makes its Prices unsellable, and contracts can be
 * running on those.
 */
beforeEach(function () {
    $this->stripe = new FakeStripeClient;
    app()->instance(StripeClient::class, $this->stripe);
});

it('adopts an orphaned family product instead of minting a second one', function () {
    // A run that got as far as creating the Product and died before storing its
    // id: Stripe has it, plan_families does not.
    //
    // Not simply the lowest tier: since Task 12 that is the internal test
    // package, which stripe:sync-catalogue skips outright and would never
    // adopt anything for.
    $family = PlanFamily::query()->where('internal', false)->orderBy('tier')->firstOrFail();
    $family->update(['stripe_product_id' => null]);

    $this->stripe->plantProduct('prod_orphan_family', $family->name, [
        'plan_family' => $family->key,
        'plan_family_id' => (string) $family->id,
    ]);

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

    // Counting every product would prove nothing — the same run mints one for
    // each of the OTHER families. What must hold is that this family's id is
    // carried by exactly one product, and that it is the planted one.
    expect($family->refresh()->stripe_product_id)->toBe('prod_orphan_family')
        ->and(collect($this->stripe->products)
            ->filter(fn (array $p) => ($p['metadata']['plan_family_id'] ?? null) === (string) $family->id)
            ->keys()->all())->toBe(['prod_orphan_family']);
});

it('adopts an orphaned module product instead of minting a second one', function () {
    $this->stripe->plantProduct('prod_orphan_module', 'Priority Support', [
        'addon' => 'priority_support',
    ]);

    $before = count($this->stripe->products);

    app(AddonPrices::class)->ensure(
        'priority_support', 2900, 'EUR', Subscription::TERM_MONTHLY, TaxTreatment::domestic(),
    );

    expect(StripeAddonPrice::query()
        ->where('addon_key', 'priority_support')
        ->value('stripe_product_id'))->toBe('prod_orphan_module')
        ->and(count($this->stripe->products))->toBe($before);
});

it('adopts nothing a product does not prove about itself', function () {
    // What an operator selling something else through the same Stripe account
    // leaves lying around. Silent, unlike the Price side: we list every product
    // on the account, so warning about each foreign one would fire on every run
    // for as long as it exists.
    Log::spy();

    $this->stripe->plantProduct('prod_stranger', 'Etwas ganz anderes', []);

    expect(app(AdoptStripeProduct::class)(
        ['addon' => 'priority_support'], ['addon'],
    ))->toBeNull();

    Log::shouldNotHaveReceived('warning');
});

it('adopts nothing that contradicts what we would have written', function () {
    $this->stripe->plantProduct('prod_other_module', 'Collabora Pro', ['addon' => 'collabora_pro']);

    expect(app(AdoptStripeProduct::class)(
        ['addon' => 'priority_support'], ['addon'],
    ))->toBeNull();
});

it('takes the oldest of two matching products and leaves the other alone', function () {
    Log::spy();

    $this->stripe->plantProduct('prod_second', 'Priority Support', ['addon' => 'priority_support'], created: 200);
    $this->stripe->plantProduct('prod_first', 'Priority Support', ['addon' => 'priority_support'], created: 100);

    $adopt = app(AdoptStripeProduct::class);

    expect($adopt(['addon' => 'priority_support'], ['addon']))->toBe('prod_first')
        // NOT deactivated. Its prices would become unsellable, and a contract
        // can be running on one of them — the asymmetry with archivePrice().
        ->and($adopt->duplicates)->toBe(['prod_second']);

    Log::shouldHaveReceived('warning')->once();
});

it('says it adopted, not that it created', function () {
    // The one signal a human reads after an incident. The count is taken before
    // ensure() runs, so without this the run reports objects created in Stripe
    // when it made none — and telling those two apart is the whole point of the
    // recognition step.
    app(Kernel::class)->call('stripe:sync-catalogue');

    $row = PlanPrice::query()->firstOrFail();
    $orphan = (string) StripePlanPrice::query()
        ->where('plan_price_id', $row->id)->where('reverse_charge', false)
        ->value('stripe_price_id');
    StripePlanPrice::query()->where('stripe_price_id', $orphan)->delete();
    $this->stripe->keys = [];

    $this->artisan('stripe:sync-catalogue')
        ->expectsOutputToContain('1 adopted')
        ->assertSuccessful();
});

it('names a duplicate product in its report, without touching it', function () {
    // Nothing has been synced yet, same as the orphan test above — the family
    // has no product of its own, and two stray ones claiming to be it are the
    // whole of what Stripe has for it. Not the internal test package, for the
    // same reason as above: sync-catalogue never looks at it at all.
    $family = PlanFamily::query()->where('internal', false)->orderBy('tier')->firstOrFail();
    $family->update(['stripe_product_id' => null]);

    $metadata = ['plan_family' => $family->key, 'plan_family_id' => (string) $family->id];
    $this->stripe->plantProduct('prod_first', $family->name, $metadata, created: 100);
    $this->stripe->plantProduct('prod_second', $family->name, $metadata, created: 200);

    $this->artisan('stripe:sync-catalogue')
        ->expectsOutputToContain('prod_second')
        ->assertSuccessful();

    expect($family->refresh()->stripe_product_id)->toBe('prod_first');
});

it('does not fold a module Product adoption into the created/minted count', function () {
    // A module's prices are all genuinely new, and its Product exists at
    // Stripe already — unlinked from our own register, the same shape as a
    // family's orphaned Product one level up (AddonPrices::product() asks
    // AdoptStripeProduct exactly as the family loop does). The difference that
    // makes this dangerous: syncModules() below counts a module's PRICES as
    // intents, but it has never counted a module's PRODUCT as one — adopted or
    // minted, that Product prints no line and increments nothing — so
    // adopting it here must neither inflate "adopted" nor shrink "created".
    $this->stripe->plantProduct('prod_priority_support_orphan', 'Priority Support', [
        'addon' => 'priority_support',
    ]);

    // Computed from the catalogue rather than hardcoded, the way
    // ReverseChargePriceTest sizes a rate-change sweep: one Product per
    // SELLABLE family, two treatments per priced row, and monthly+yearly at
    // both treatments for every module — all of it newly minted here, nothing
    // pre-existing to adopt except the one Product just planted above.
    // Internal families (Task 12) are skipped by the sync entirely and count
    // for neither the family nor the price half of this sum.
    $modules = count(array_merge(array_keys((array) config('provisioning.addons')), ['storage']));
    $expectedCreated = PlanFamily::query()->where('internal', false)->count()
        + PlanPrice::query()->whereHas('version.family', fn ($q) => $q->where('internal', false))->count() * 2
        + $modules * 4;

    $this->artisan('stripe:sync-catalogue')
        ->expectsOutputToContain("{$expectedCreated} object(s) created, 0 adopted in Stripe.")
        ->assertSuccessful();

    // And the orphan really was taken over rather than merely ignored — the
    // Product this test is about, not a no-op that would pass it by accident.
    expect(StripeAddonPrice::query()
        ->where('addon_key', 'priority_support')
        ->value('stripe_product_id'))->toBe('prod_priority_support_orphan');
});
