<?php

namespace App\Services\Billing;

use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\PlanVersion;
use App\Models\Subscription;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use RuntimeException;

/**
 * The one place the plan catalogue is read.
 *
 * There is deliberately **no fallback to config**. "Use the DB, and config if
 * the DB has no row" would resurrect a plan the owner had just switched off,
 * and would leave commerce reading one source while provisioning read another —
 * the exact split-brain this whole rebuild exists to close. An empty catalogue
 * is an outage, and it should look like one.
 *
 * Availability is computed here, on every read. Nothing schedules a plan into
 * or out of sale, because a job that fails to run is a plan that silently
 * misbehaves, and "the launch didn't happen because a worker was down" is not
 * something you can tell a customer.
 */
final class PlanCatalogue
{
    /**
     * Every plan on sale right now, keyed by family key, in the array shape the
     * app has always used.
     *
     * @return array<string, array<string, mixed>>
     */
    public function sellable(?Carbon $at = null): array
    {
        return $this->catalogue($at, includeInternal: false);
    }

    /**
     * Everything a package grant can choose from: sellable(), plus the
     * families marked `internal`.
     *
     * A second reader rather than a flag left to sellable()'s many callers:
     * GrantPlan is the one place outside currentVersion() that is meant to
     * see an internal family (that is the entire point of the flag — the
     * operator's test package must stay handable-out even though it left the
     * shop), and naming that here keeps it from leaking into the price sheet,
     * the order page or the cart by an accidental copy-pasted call site.
     *
     * @return array<string, array<string, mixed>>
     */
    public function grantable(?Carbon $at = null): array
    {
        return $this->catalogue($at, includeInternal: true);
    }

    /** @return array<string, array<string, mixed>> */
    private function catalogue(?Carbon $at, bool $includeInternal): array
    {
        $at ??= now();
        $currency = Subscription::catalogueCurrency();

        return PlanFamily::query()
            ->where('sales_enabled', true)
            // Interne Pakete sind kein Angebot für den Laden — Preisblatt,
            // Bestellung und Warenkorb lesen alle sellable() und lassen sie
            // damit aus. grantable() lässt sie bewusst herein, denn genau
            // dafür vergibt der Betreiber sie von Hand.
            ->when(! $includeInternal, fn ($q) => $q->where('internal', false))
            ->with(['versions' => fn ($q) => $q->available($at)->with('prices')])
            ->orderBy('tier')
            ->get()
            ->mapWithKeys(function (PlanFamily $family) use ($currency) {
                // sole() semantics, kept here so a caller listing the shop hits
                // the same loud failure as a caller resolving one plan.
                if ($family->versions->count() > 1) {
                    throw new RuntimeException(
                        "Plan '{$family->key}' has {$family->versions->count()} versions on sale at once. ".
                        'Overlapping availability windows must be fixed before anything can be sold.'
                    );
                }

                $version = $family->versions->first();

                if ($version === null) {
                    return [];
                }

                // Every supported term, or the plan is not shown at all. Listing
                // one that is priced monthly but not yearly would let a
                // customer pick it, pay, and land on a contract that cannot be
                // opened — the shop and the checkout must agree on this.
                $priced = $this->requiredPrices($version, $currency);

                if ($priced === null) {
                    return [];
                }

                $monthly = $priced[Subscription::TERM_MONTHLY];
                $yearly = $priced[Subscription::TERM_YEARLY];

                return [$family->key => array_merge($version->capabilities(), [
                    'name' => $family->name,
                    'price_cents' => $monthly->amount_cents,
                    // The yearly figure travels with the monthly one. Both terms
                    // are already required before a version can be sold at all
                    // (requiredPrices above), so a shop showing one and hiding
                    // the other was hiding a price it had in its hand — and
                    // whoever wanted to show it would have gone looking for a
                    // second source.
                    'yearly_price_cents' => $yearly->amount_cents,
                    'currency' => $monthly->currency,
                    'plan_version_id' => $version->id,
                    // Marketing presentation, not a capability: it lives on the
                    // family (see the migration), and every reader of the shop
                    // gets it from the same place rather than each keeping its
                    // own copy of who a plan is for.
                    'audience' => $family->audience,
                    'note' => $family->note,
                    'recommended' => (bool) $family->is_recommended,
                ])];
            })
            ->all();
    }

    /**
     * The version a purchase of this family right now would be sold under.
     *
     * Fails closed and loudly: an unknown or unsold plan throws, and so does an
     * overlap. Picking one of two overlapping versions would decide a customer's
     * terms by row order.
     */
    public function currentVersion(string $familyKey, ?Carbon $at = null): PlanVersion
    {
        $family = PlanFamily::query()->where('key', $familyKey)->first();

        if ($family === null) {
            throw new RuntimeException("Unknown plan: {$familyKey}");
        }

        if (! $family->sales_enabled) {
            throw new RuntimeException("Plan '{$familyKey}' is not on sale.");
        }

        try {
            $version = $family->versions()->available($at)->sole();
        } catch (ModelNotFoundException) {
            throw new RuntimeException("Plan '{$familyKey}' has no version on sale.");
        }

        $version->setRelation('family', $family);

        return $version;
    }

    /**
     * Whether a purchase of this family could actually be completed right now.
     *
     * Pricing is part of the question, not a detail left to the checkout. A
     * version whose price row has been deleted is still inside its window, and
     * answering "yes" here would send an already-paid webhook into a contract
     * it cannot open.
     */
    public function isSellable(string $familyKey, ?Carbon $at = null): bool
    {
        try {
            $version = $this->currentVersion($familyKey, $at);
        } catch (RuntimeException) {
            return false;
        }

        return $this->requiredPrices($version, Subscription::catalogueCurrency()) !== null;
    }

    /**
     * Every term we sell on, priced in this currency — or null if any is
     * missing.
     *
     * The single definition of "this version can actually be bought", so the
     * shop, the checkout and the consistency command can never disagree about
     * which plans are real.
     *
     * @return array<string, PlanPrice>|null
     */
    private function requiredPrices(PlanVersion $version, string $currency): ?array
    {
        $found = [];

        foreach ([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY] as $term) {
            // relationLoaded() so a listing that eager-loaded prices does not
            // fire a query per plan per term.
            $price = $version->relationLoaded('prices')
                ? $version->prices->first(fn ($p) => $p->term === $term && $p->currency === $currency)
                : $version->priceFor($term, $currency);

            if ($price === null) {
                return null;
            }

            $found[$term] = $price;
        }

        return $found;
    }

    /**
     * The exact version a customer was sold, checked against the plan they
     * bought.
     *
     * Used when the purchase carried its own version — a checkout that started
     * before a scheduled transition and finished after it. A version that has
     * closed is still honoured, because the customer saw and paid for it; one
     * that was never published is not, because nothing was ever promised.
     */
    public function soldVersion(string $familyKey, int $versionId): PlanVersion
    {
        $version = $this->version($versionId);

        if ($version->family->key !== $familyKey) {
            throw new RuntimeException(
                "Version {$versionId} belongs to '{$version->family->key}', not to '{$familyKey}'."
            );
        }

        if (! $version->isPublished()) {
            throw new RuntimeException("Version {$versionId} was never published.");
        }

        return $version;
    }

    /**
     * Whether a contract can still be opened on a version a customer was quoted.
     *
     * Deliberately NOT the same question as "is this plan on sale". A checkout
     * that began while the version was available is owed that version, even if
     * the window has closed or the owner has withdrawn the plan since — they
     * paid for what they were shown. Only realness and pricing matter here.
     */
    public function isDeliverable(string $familyKey, int $versionId): bool
    {
        try {
            $version = $this->soldVersion($familyKey, $versionId);
        } catch (RuntimeException|ModelNotFoundException) {
            return false;
        }

        return $this->requiredPrices($version, Subscription::catalogueCurrency()) !== null;
    }

    /**
     * A historical reference, resolved by version id — never by family key.
     *
     * Looking a past contract up by name would hand back today's terms, which
     * is the same mistake the snapshot exists to prevent, one level up.
     */
    public function version(int $id): PlanVersion
    {
        return PlanVersion::query()->with('family')->findOrFail($id);
    }

    /**
     * Write a new draft version of a family.
     *
     * The version number is allocated under a lock on the family: two admins
     * drafting at the same moment would otherwise both read the same maximum,
     * and the unique index would turn one perfectly valid action into a
     * database error — after the other had already succeeded.
     *
     * @param  array<string, mixed>  $capabilities
     * @param  array<string, int>  $pricesByTerm
     */
    public function draft(PlanFamily $family, array $capabilities, array $pricesByTerm): PlanVersion
    {
        $currency = Subscription::catalogueCurrency();

        return DB::transaction(function () use ($family, $capabilities, $pricesByTerm, $currency) {
            PlanFamily::query()->whereKey($family->getKey())->lockForUpdate()->firstOrFail();

            $version = $family->versions()->create([
                ...$capabilities,
                'version' => (int) $family->versions()->max('version') + 1,
                // A placeholder: publication sets the real window, and until
                // then the version is not available at any time anyway.
                'available_from' => now(),
            ]);

            foreach ($pricesByTerm as $term => $amount) {
                $version->prices()->create([
                    'term' => $term,
                    'amount_cents' => $amount,
                    'currency' => $currency,
                ]);
            }

            return $version;
        });
    }

    /**
     * Discard a draft — but only if it is still a draft.
     *
     * One statement, conditional on `published_at` still being null. Checking
     * in PHP and deleting afterwards can delete a version that was published in
     * between, and a published version must never be removed: contracts point
     * at it. Returns false when the row was published in the meantime.
     */
    public function discardDraft(PlanVersion $version): bool
    {
        return PlanVersion::query()
            ->whereKey($version->getKey())
            ->whereNull('published_at')
            ->delete() > 0;
    }

    /**
     * Move a version's availability window.
     *
     * Under a lock on the family, because two admins scheduling at the same
     * moment would each see a clean check and both commit — leaving two
     * versions on sale and every read of that family throwing.
     */
    public function schedule(PlanVersion $version, Carbon $from, ?Carbon $until = null): PlanVersion
    {
        if ($until !== null && $until->lessThanOrEqualTo($from)) {
            throw new RuntimeException('A plan cannot stop being sold before it starts.');
        }

        return DB::transaction(function () use ($version, $from, $until) {
            PlanFamily::query()->whereKey($version->plan_family_id)->lockForUpdate()->firstOrFail();

            $clash = PlanVersion::query()
                ->where('plan_family_id', $version->plan_family_id)
                ->whereKeyNot($version->getKey())
                // Only published versions can clash: a draft is not on sale, so
                // its provisional window must not block the owner from
                // rescheduling the version that customers can actually buy.
                ->whereNotNull('published_at')
                // Half-open overlap: a starts before b ends AND b starts before
                // a ends. A null end is "never ends".
                ->where(fn ($q) => $q
                    ->whereNull('available_until')
                    ->orWhere('available_until', '>', $from))
                ->when($until !== null, fn ($q) => $q->where('available_from', '<', $until))
                ->exists();

            if ($clash) {
                throw new RuntimeException(
                    'That window overlaps another version of this plan. Two versions on sale at once '.
                    'would leave the price a customer pays decided by row order.'
                );
            }

            // Written by query, not by save(): a model handed to us may carry
            // unsaved edits, and rescheduling a window must never be the thing
            // that quietly persists a change to what the plan promises.
            PlanVersion::query()->whereKey($version->getKey())->update([
                'available_from' => $from,
                'available_until' => $until,
                'updated_at' => now(),
            ]);

            return $version->refresh();
        });
    }

    /**
     * Put a published version back on open-ended sale.
     *
     * The inverse of closing a window, and the reason it has to exist: closing
     * was one-way. An owner who took a version off sale before its replacement
     * was ready had no way back, and the plan stayed dark until someone edited
     * the table by hand.
     *
     * Expressed through schedule() rather than as its own update, so it passes
     * the same family lock and the same overlap check as every other window
     * change. That check is the point, not an obstacle: a version whose
     * successor is already selling must not come back and leave two on sale at
     * once.
     */
    public function reopen(PlanVersion $version): PlanVersion
    {
        // Re-read first: a stale object could carry a start date the owner has
        // since moved, and reopening would then quietly restore the old one.
        $version->refresh();

        if (! $version->isPublished()) {
            throw new RuntimeException(
                'That version was never published, so it was never on sale and there is nothing to reopen. '.
                'Publish it instead.'
            );
        }

        return $this->schedule($version, $version->available_from, null);
    }

    /**
     * The other published version of this family that is on sale at `$at`.
     *
     * Public because the console announces a handover before the owner commits
     * to it, and an announcement computed from its own idea of "currently
     * running" would sooner or later name a different version than the one
     * publish() actually closes.
     *
     * Null when two are running at once: that is the data fault schedule()
     * exists to prevent, and picking one of them to close would decide by row
     * order which of the owner's plans quietly stops selling.
     */
    public function predecessorAt(PlanVersion $version, Carbon $at): ?PlanVersion
    {
        $running = PlanVersion::query()
            ->where('plan_family_id', $version->plan_family_id)
            ->whereKeyNot($version->getKey())
            ->whereNotNull('published_at')
            // Running at `$at`, not merely clashing with it. A version that
            // only starts later clashes too, but closing it at `$at` would end
            // it before it began — that one still belongs to the overlap check.
            ->where('available_from', '<=', $at)
            ->where(fn ($q) => $q
                ->whereNull('available_until')
                ->orWhere('available_until', '>', $at))
            ->get();

        return $running->count() === 1 ? $running->first() : null;
    }

    /**
     * Mark exactly one plan family as recommended, clearing every other one.
     *
     * Singular by nature: a second recommendation is not two recommendations,
     * it is none. Every family row is locked for the length of the
     * transaction, so two operators recommending two different plans at the
     * same moment cannot both win and leave two marked rows instead of one.
     */
    public function recommend(PlanFamily $family): void
    {
        DB::transaction(function () use ($family) {
            PlanFamily::query()->lockForUpdate()->get();

            PlanFamily::query()->whereKeyNot($family->getKey())->update(['is_recommended' => false]);
            PlanFamily::query()->whereKey($family->getKey())->update(['is_recommended' => true]);
        });
    }

    /**
     * Publish a draft: lock its capabilities and put it on sale.
     *
     * Publication is the promise. From here the version describes what its
     * customers are owed, and changing it would rewrite their contract.
     */
    public function publish(PlanVersion $version, ?Carbon $from = null, ?Carbon $until = null): PlanVersion
    {
        // Re-read before deciding anything. A previous attempt that was rolled
        // back leaves this object claiming a publication the database never
        // kept, and the owner would then be told a draft they can still see is
        // already published.
        $version->refresh();

        if ($version->isPublished()) {
            throw new RuntimeException('That version is already published.');
        }

        // Nothing goes on sale that provisioning cannot build. Without a
        // blueprint the pipeline fails at CloneVirtualMachine with
        // `template_missing` — after the customer has paid.
        if ($version->template_vmid === null) {
            throw new RuntimeException(
                'A version cannot go on sale without a VM template; provisioning would have nothing to clone.'
            );
        }

        $currency = Subscription::catalogueCurrency();

        // Both terms, or neither. A version priced only yearly passes as
        // sellable and then fails at the checkout of anyone who picks monthly —
        // and once published its capabilities are frozen, so the mistake cannot
        // simply be edited away.
        foreach ([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY] as $term) {
            $priced = $version->prices()->where('term', $term)->where('currency', $currency)->exists();

            if (! $priced) {
                throw new RuntimeException("A version cannot go on sale without a {$term} price in {$currency}.");
            }
        }

        // Publication, the handover and the new window together, or not at all.
        // Publishing first and then failing the overlap check would leave the
        // version frozen but unscheduled — and publish() refuses it from then
        // on, so nothing short of a manual repair could rescue it. The same
        // goes the other way: a predecessor closed for a successor that never
        // arrived is a plan taken off sale by an action that failed.
        return DB::transaction(function () use ($version, $from, $until) {
            $from ??= now();

            // The lock schedule() would take anyway, taken here instead: it has
            // to be held from before the predecessor is chosen until after the
            // successor's window is written, or a publish running alongside
            // this one could hand the same version over twice.
            PlanFamily::query()->whereKey($version->plan_family_id)->lockForUpdate()->firstOrFail();

            // Claim the publication conditionally, so of two simultaneous
            // attempts exactly one proceeds. Unconditional, the second would
            // sail past and overwrite the window the first had just set.
            $claimed = PlanVersion::query()
                ->whereKey($version->getKey())
                ->whereNull('published_at')
                ->update(['published_at' => now(), 'updated_at' => now()]);

            if ($claimed === 0) {
                throw new RuntimeException('That version is already published.');
            }

            $predecessor = $this->predecessorAt($version, $from);

            if ($predecessor !== null) {
                // A handover, not an overlap: the version that was running
                // stops at the moment its successor starts, which is neither a
                // gap nor two versions on sale. Refusing here — as this used to
                // — forced the owner to close the running version first, and a
                // replacement that then failed to publish left the plan off
                // sale with no way back. That dead end is what this removes.
                //
                // Written by query for the same reason schedule() is: the
                // version being handed over is published and immutable, and
                // save() would offer every attribute on the object to that
                // guard.
                PlanVersion::query()->whereKey($predecessor->getKey())->update([
                    'available_until' => $from,
                    'updated_at' => now(),
                ]);
            }

            return $this->schedule($version->refresh(), $from, $until);
        });
    }
}
