<?php

namespace App\Services\Billing;

use App\Actions\BookAddon;
use App\Actions\ReapplyInstanceAddress;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\PlanFamily;
use App\Models\Subscription;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Number;

/**
 * Whether a customer may have their own domain — asked and answered once.
 *
 * The owner's rule has three steps, and until now not one of them existed
 * anywhere in the code: `/domain` shipped with no access control at all, and
 * the `custom_domain` plan feature was checked nowhere, so every customer on
 * every package could point a domain at their instance.
 *
 *  - **Start**: impossible. Not bookable, not upgradable, not even offered.
 *  - **Team**: optional. The `custom_domain` module buys it.
 *  - **Business and Enterprise**: included, because the plan version carries
 *    the `custom_domain` feature. The module buys them nothing.
 *
 * Everything asks this class — the booking action, the portal's module list,
 * the downgrade warning, the plan change that carries it out, and the public
 * price sheet. Nothing re-derives it. A rule written out in five places is five
 * rules, and the fifth is the one nobody remembers to change.
 *
 * Read against the FROZEN plan version, never against today's catalogue: what a
 * customer is owed is what they bought, and a feature added to or taken off a
 * plan later must not reach into a contract that was signed before it.
 */
final class CustomDomainAccess
{
    /** The catalogue feature a package carries when an own domain is part of it. */
    public const FEATURE = 'custom_domain';

    /** The module that buys one where the package does not include it. */
    public const ADDON = 'custom_domain';

    /**
     * The customer's live contract — the same resolution the console's grant
     * screen uses.
     *
     * Deliberately not read through the instance: `subscriptions.instance_id`
     * is written during provisioning, so a customer whose machine is still
     * being built has a package but no link, and answering "no contract" there
     * would take a right away from someone who has paid for it.
     */
    public function contractOf(?Customer $customer): ?Subscription
    {
        if ($customer === null) {
            return null;
        }

        return Subscription::query()
            ->where('customer_id', $customer->id)
            ->where('status', 'active')
            ->latest('id')
            ->first();
    }

    /** The guard every page and route asks: may this customer use one right now? */
    public function allowsCustomer(?Customer $customer): bool
    {
        return $this->allows($this->contractOf($customer));
    }

    /**
     * May this contract present a custom domain right now?
     *
     * Included by the package, or bought as a module — and a module only counts
     * where the package could have booked it. Without that second half a module
     * booked on Team would survive a move to Start and keep serving a domain
     * the customer has just been told they cannot have; enforce() cancels such
     * a booking, and this is the same sentence read the other way round.
     */
    public function allows(?Subscription $subscription): bool
    {
        if ($subscription === null) {
            return false;
        }

        return $this->includedInPlan($subscription)
            || ($this->bookable($subscription) && $this->hasBooked($subscription));
    }

    /** Does the package itself carry it, so nothing needs booking? */
    public function includedInPlan(?Subscription $subscription): bool
    {
        return $subscription !== null && $this->includedInFeatures($this->planFeatures($subscription));
    }

    /**
     * May this contract book the module?
     *
     * Only where a domain is possible and the package does not already include
     * it — which is Team today. Never Start, and pointless on Business and
     * Enterprise, who would be paying nine euros a month for something they
     * already have.
     */
    public function bookable(?Subscription $subscription): bool
    {
        return $subscription !== null
            && $this->bookableOnPlan((string) $subscription->plan, $this->planFeatures($subscription));
    }

    /** Is the module already booked and still running on this contract? */
    public function hasBooked(?Subscription $subscription): bool
    {
        return $subscription !== null
            && $subscription->addons()->active()->where('addon_key', self::ADDON)->exists();
    }

    /**
     * The same two questions for a package rather than a contract, because the
     * public price sheet has no customer to ask about.
     *
     * @param  array<int, string>  $features  the version's feature keys
     */
    public function includedInFeatures(array $features): bool
    {
        return in_array(self::FEATURE, $features, true);
    }

    /** @param  array<int, string>  $features */
    public function bookableOnPlan(string $planKey, array $features): bool
    {
        return $this->possibleOn($planKey) && ! $this->includedInFeatures($features);
    }

    /**
     * Is an own domain possible on this package at all?
     *
     * The list of packages where it is not lives in config/provisioning.php,
     * beside the module's price — see the comment there for why the catalogue
     * cannot answer this and a derived rule would be a guess.
     */
    public function possibleOn(string $planKey): bool
    {
        $excluded = (array) config('provisioning.addons.'.self::ADDON.'.unavailable_on', []);

        return ! in_array($planKey, $excluded, true);
    }

    /**
     * Why the customer may not use one, in words they can be shown.
     *
     * Null when they may. Every caller that would otherwise write its own
     * sentence — the guard on the domain page, a support answer — takes it from
     * here, so the customer is never told two different things.
     */
    public function refusal(?Subscription $subscription): ?string
    {
        if ($this->allows($subscription)) {
            return null;
        }

        if ($subscription === null) {
            return __('billing.custom_domain.no_contract');
        }

        if (! $this->possibleOn((string) $subscription->plan)) {
            return __('billing.custom_domain.not_in_plan', ['plan' => $this->planName((string) $subscription->plan)]);
        }

        return __('billing.custom_domain.needs_addon', ['price' => $this->modulePrice()]);
    }

    /** Why the module may not be booked. Null when it may. */
    public function bookingRefusal(?Subscription $subscription): ?string
    {
        if ($subscription === null) {
            return __('billing.custom_domain.no_contract');
        }

        if ($this->includedInPlan($subscription)) {
            return __('billing.custom_domain.already_included');
        }

        if (! $this->possibleOn((string) $subscription->plan)) {
            return __('billing.custom_domain.not_in_plan', ['plan' => $this->planName((string) $subscription->plan)]);
        }

        return null;
    }

    /**
     * What moving to another package would do to a domain the customer HAS, so
     * they are asked before they confirm rather than told afterwards.
     *
     * Two answers, and they are different decisions: on a package where a
     * domain is impossible it simply goes, and on one where it is a module the
     * customer chooses — book it and nothing changes for them, leave it and the
     * domain is switched off with the change.
     *
     * Nothing is reported when nothing happens: no domain set, none in use, the
     * target includes it anyway, or the module is already booked and stays
     * valid on the target.
     *
     * @param  array<int, string>  $targetFeatures  the target version's feature keys
     * @return array<int, array{key: string, replace: array<string, string>}>
     */
    public function consequencesOfMovingTo(?Subscription $current, ?Instance $instance, string $targetPlan, array $targetFeatures): array
    {
        if ($instance === null || blank($instance->custom_domain) || ! $this->allows($current)) {
            return [];
        }

        if ($this->includedInFeatures($targetFeatures)) {
            return [];
        }

        if (! $this->possibleOn($targetPlan)) {
            return [[
                'key' => 'custom_domain_lost',
                'replace' => [
                    'domain' => (string) $instance->custom_domain,
                    'plan' => $this->planName($targetPlan),
                    // The address it falls back to, so "abgeschaltet" is a
                    // change of address rather than a threat of darkness.
                    'address' => $instance->subdomain.'.'.ProvisioningSettings::dnsZone(),
                ],
            ]];
        }

        // Already paying for the module, and it stays valid where they are
        // going: for them this move changes nothing about the domain.
        if ($this->hasBooked($current)) {
            return [];
        }

        return [[
            'key' => 'custom_domain_addon',
            'replace' => [
                'domain' => (string) $instance->custom_domain,
                'plan' => $this->planName($targetPlan),
                'price' => $this->modulePrice(),
            ],
        ]];
    }

    /**
     * Make the state match the rule, after a change has landed.
     *
     * Called by PlanChange when a plan change takes effect. Two things happen,
     * in this order: the module stops being charged for, and the domain stops
     * being presented. Charging first, because a customer who has lost a
     * feature and is still billed for it has a complaint we would deserve.
     *
     * Returns whether anything was actually taken away.
     */
    public function enforce(Subscription $subscription): bool
    {
        if ($this->allows($subscription)) {
            return false;
        }

        $changed = false;

        // Resolved lazily rather than through the constructor: BookAddon asks
        // this class whether a booking is allowed, and two constructors
        // pointing at each other cannot both be built.
        $bookAddon = app(BookAddon::class);

        foreach ($subscription->addons()->active()->where('addon_key', self::ADDON)->get() as $addon) {
            $bookAddon->cancel($addon);
            $changed = true;
        }

        return $this->deactivate($this->instanceOf($subscription)) || $changed;
    }

    /**
     * Take the domain off an instance, so it answers on its platform address
     * again.
     *
     * The whole domain, not merely its verification flag. The nightly re-check
     * (clupilot:verify-domains) reads every instance that still has a domain
     * AND a token, and it would put the verification straight back the next
     * night — the domain would switch itself on again days after the customer
     * was told it was gone. Clearing the field is also exactly what the
     * customer's own "remove" does on the domain page, so what they see
     * afterwards is a state this product already knows how to draw.
     *
     * Neither the verification nor the serving logic is reimplemented here:
     * Instance::address() already falls back to the subdomain the moment the
     * domain is gone, and that is the whole of the fallback in this codebase.
     *
     * Clearing the row is only half of it, though. A domain that was verified
     * is a domain the proxy is routing and Nextcloud is trusting, and neither
     * of those reads this table — so the address is re-applied, which is what
     * actually stops the customer's old domain from answering. Without it a
     * downgrade would take the domain off every screen while the machine
     * carried on serving it.
     */
    public function deactivate(?Instance $instance): bool
    {
        if ($instance === null || blank($instance->custom_domain)) {
            return false;
        }

        $wasVerified = $instance->domainIsVerified();

        $instance->forceFill([
            'custom_domain' => null,
            'domain_token' => null,
            'domain_verified_at' => null,
            'domain_cert_ok' => false,
            'domain_checked_at' => null,
            'domain_error' => null,
            'domain_failures' => 0,
        ])->save();

        if ($wasVerified) {
            app(ReapplyInstanceAddress::class)($instance);
        }

        return true;
    }

    /**
     * The machine this contract pays for.
     *
     * The link when provisioning has written it, and otherwise the customer's
     * newest instance — the same fallback the portal makes for contracts opened
     * before that link existed.
     */
    private function instanceOf(Subscription $subscription): ?Instance
    {
        return $subscription->instance
            ?? $subscription->customer?->instances()->latest('id')->first();
    }

    /**
     * What this contract's package promises — from the version it was sold
     * under, not from the catalogue.
     *
     * The empty fallback is for a contract whose version row has gone (a
     * discarded draft nulls the reference); such a package promises nothing
     * this class can stand on, and inventing today's answer for it would hand
     * out a right the contract may never have carried.
     *
     * @return array<int, string>
     */
    private function planFeatures(Subscription $subscription): array
    {
        return array_values((array) ($subscription->planVersion?->features ?? []));
    }

    /**
     * A package by the name the owner gave it, never by its key.
     *
     * The key is a handle that has not changed since day one; the name is what
     * the customer sees on the price sheet, and renaming a package must not
     * leave one sentence in the product still calling it something else.
     */
    private function planName(string $planKey): string
    {
        return (string) (PlanFamily::query()->where('key', $planKey)->value('name') ?? ucfirst($planKey));
    }

    /** Today's price for the module, in the same shape the portal prints it. */
    private function modulePrice(): string
    {
        $cents = (int) app(AddonCatalogue::class)->priceCents(self::ADDON);

        return Number::currency($cents / 100, in: Subscription::catalogueCurrency(), locale: app()->getLocale());
    }
}
