<?php

namespace App\Services\Deployment;

use App\Support\Settings;
use Illuminate\Support\Carbon;

/**
 * When the installation is allowed to update itself.
 *
 * The button stays: manual is always possible, at any hour. This is the other
 * option — tick it, name a window, and the installation takes the update in
 * that window on its own. An update is one to three minutes of maintenance
 * mode, which is short enough not to announce and long enough that nobody
 * wants it at eleven in the morning.
 *
 * There is nothing to guard against having already updated by hand: an
 * installation that is current has nothing available, and the window finds
 * nothing to do. The condition is "is there something newer", not "has today's
 * update run".
 */
class UpdateWindow
{
    public const KEY_ENABLED = 'deploy.auto_update';

    public const KEY_DAYS = 'deploy.auto_days';

    public const KEY_FROM = 'deploy.auto_from';

    public const KEY_TO = 'deploy.auto_to';

    /**
     * Tuesday to Thursday by default.
     *
     * Not Monday — the week starts and every fault costs double. Not Friday —
     * nobody wants to repair a deployment on a Friday afternoon. ISO weekdays,
     * so 1 is Monday.
     */
    public const DEFAULT_DAYS = [2, 3, 4];

    /**
     * Half past five by default.
     *
     * Not three in the morning: if it goes wrong, the person who has to fix it
     * is asleep, and an outage nobody is awake for is an outage that lasts
     * until somebody wakes up.
     */
    public const DEFAULT_FROM = '05:00';

    public const DEFAULT_TO = '06:00';

    public function enabled(): bool
    {
        return Settings::bool(self::KEY_ENABLED, false);
    }

    /** @return array<int, int> ISO weekdays */
    public function days(): array
    {
        $days = Settings::get(self::KEY_DAYS, self::DEFAULT_DAYS);

        if (! is_array($days)) {
            return self::DEFAULT_DAYS;
        }

        // Filtered rather than trusted: this is read on a schedule, and a
        // malformed setting must not be able to mean "every day" by accident.
        $days = array_values(array_unique(array_filter(
            array_map('intval', $days),
            fn (int $day) => $day >= 1 && $day <= 7,
        )));

        return $days === [] ? self::DEFAULT_DAYS : $days;
    }

    public function from(): string
    {
        return $this->time(self::KEY_FROM, self::DEFAULT_FROM);
    }

    public function to(): string
    {
        return $this->time(self::KEY_TO, self::DEFAULT_TO);
    }

    private function time(string $key, string $default): string
    {
        $value = (string) Settings::get($key, $default);

        return preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $value) === 1 ? $value : $default;
    }

    /**
     * Is now inside the window?
     *
     * On the wall clock, not UTC (R19): an operator who writes 05:00 means five
     * in the morning where they live, and a window that drifts by an hour twice
     * a year is a window nobody trusts.
     *
     * A window that ends before it starts wraps past midnight — 23:00 to 02:00
     * is a perfectly reasonable thing to ask for, and reading it as an empty
     * range would silently mean "never".
     */
    public function isOpen(?Carbon $at = null): bool
    {
        $now = ($at ?? Carbon::now())->local();

        if (! in_array((int) $now->isoWeekday(), $this->days(), true)) {
            // …unless the window wraps and we are in the tail of yesterday's.
            return $this->wraps()
                && in_array((int) $now->copy()->subDay()->isoWeekday(), $this->days(), true)
                && $now->format('H:i') < $this->to();
        }

        $clock = $now->format('H:i');
        $from = $this->from();
        $to = $this->to();

        return $this->wraps()
            ? ($clock >= $from || $clock < $to)
            : ($clock >= $from && $clock < $to);
    }

    private function wraps(): bool
    {
        return $this->to() <= $this->from();
    }
}
