<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use RuntimeException;

/**
 * What a plan WAS at a point in time.
 *
 * A customer buys a version, not a name. Once a version is published it is
 * fixed: publication is a promise made in public, and it is made whether or not
 * anyone has bought yet — which is why the lock is on publication and not on
 * the first sale. Selling different terms means publishing a new version, and
 * the old one keeps describing what its customers are owed.
 *
 * The availability WINDOW is not frozen. Rescheduling when a version is sold
 * changes nothing about what it promises, and the owner has to be able to move
 * a launch date without rewriting the plan.
 */
class PlanVersion extends Model
{
    use HasFactory;
    use HasUuids;

    /** Everything publication nails down. */
    public const FROZEN = [
        'plan_family_id', 'version', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb',
        'cores', 'disk_gb', 'performance', 'template_vmid', 'features', 'published_at',
        // How many of the twelve months a year costs nothing. Frozen with the
        // rest: it is not presentation, it is what the customer bought — and the
        // yearly amount in `plan_prices` was derived from it.
        'free_months',
    ];

    protected $guarded = [];

    protected static function booted(): void
    {
        static::updating(function (self $version) {
            // A draft is still a draft: nothing has been promised to anyone.
            if ($version->getOriginal('published_at') === null) {
                return;
            }

            $frozen = array_intersect(array_keys($version->getDirty()), self::FROZEN);

            if ($frozen !== []) {
                throw new RuntimeException(
                    'A published plan version is immutable; tried to change: '.implode(', ', $frozen).
                    '. Publish a new version instead — the customers on this one bought these terms.'
                );
            }
        });

        static::deleting(function (self $version) {
            // Deleting is the loophole that would undo the rest. Contracts and
            // orders point here to record what was sold, and the foreign keys
            // null out on delete — so removing a published version quietly
            // erases the provenance of every customer on it, and can leave an
            // in-flight checkout resolving against whatever replaced it.
            if ($version->isPublished()) {
                throw new RuntimeException(
                    'A published plan version cannot be deleted; customers are contracted to it. '.
                    'Close its availability window instead — it stops being sold and stays on record.'
                );
            }
        });
    }

    protected function casts(): array
    {
        return [
            'version' => 'integer',
            'quota_gb' => 'integer',
            'traffic_gb' => 'integer',
            'seats' => 'integer',
            'ram_mb' => 'integer',
            'cores' => 'integer',
            'disk_gb' => 'integer',
            'template_vmid' => 'integer',
            'features' => 'array',
            'available_from' => 'datetime',
            'available_until' => 'datetime',
            'published_at' => 'datetime',
        ];
    }

    public function uniqueIds(): array
    {
        return ['uuid'];
    }

    public function family(): BelongsTo
    {
        return $this->belongsTo(PlanFamily::class, 'plan_family_id');
    }

    public function prices(): HasMany
    {
        return $this->hasMany(PlanPrice::class);
    }

    /**
     * Published, and inside its window at `$at`.
     *
     * Half-open [from, until): `until` is the first moment the version is no
     * longer sold, so a successor starting exactly then leaves neither a gap
     * nor an overlap.
     */
    public function scopeAvailable(Builder $query, ?Carbon $at = null): Builder
    {
        $at ??= now();

        return $query
            ->whereNotNull('published_at')
            ->where('available_from', '<=', $at)
            ->where(fn (Builder $open) => $open
                ->whereNull('available_until')
                ->orWhere('available_until', '>', $at));
    }

    public function isAvailableAt(?Carbon $at = null): bool
    {
        $at ??= now();

        return $this->published_at !== null
            && $this->available_from->lessThanOrEqualTo($at)
            && ($this->available_until === null || $this->available_until->greaterThan($at));
    }

    public function isPublished(): bool
    {
        return $this->published_at !== null;
    }

    public function priceFor(string $term, ?string $currency = null): ?PlanPrice
    {
        return $this->prices()
            ->where('term', $term)
            ->where('currency', $currency ?? Subscription::catalogueCurrency())
            ->first();
    }

    /**
     * The capabilities, in the shape the rest of the app already speaks.
     *
     * @return array<string, mixed>
     */
    public function capabilities(): array
    {
        return [
            'tier' => (int) $this->family->tier,
            'quota_gb' => $this->quota_gb,
            'traffic_gb' => $this->traffic_gb,
            'seats' => $this->seats,
            // Part of the terms, so it travels with them: every reader of the
            // catalogue — the shop, the order page, the console — gets the
            // figure from the version rather than dividing two prices and
            // hoping the answer is whole.
            'free_months' => (int) $this->free_months,
            'ram_mb' => $this->ram_mb,
            'cores' => $this->cores,
            'disk_gb' => $this->disk_gb,
            'performance' => $this->performance,
            'template_vmid' => $this->template_vmid,
            'features' => $this->features ?? [],
        ];
    }

    /**
     * Every VM template a currently sellable version points at.
     *
     * Published versions whose window has not CLOSED — deliberately not
     * scopeAvailable(): a version scheduled to launch next week is not
     * "available" yet, and leaving its template out would move the gap to its
     * first order, which is the one place nobody is watching. A closed window
     * is different: nobody can buy it any more, so its template is nobody's
     * problem.
     *
     * Lives here because three callers ask exactly this — BuildVmTemplate
     * builds them, VerifyVmTemplate refuses to finish a host without them, and
     * VmTemplateCheck answers the same question before a purchase. Three copies
     * of the window logic would drift, and the one that drifted would be found
     * by a customer.
     *
     * @return array<int, int>
     */
    public static function requiredTemplateVmids(): array
    {
        return static::query()
            ->whereNotNull('published_at')
            ->whereNotNull('template_vmid')
            ->where(fn ($q) => $q->whereNull('available_until')->orWhere('available_until', '>', now()))
            ->pluck('template_vmid')
            ->map(fn ($vmid) => (int) $vmid)
            ->unique()
            ->sort()
            ->values()
            ->all();
    }
}
