<?php

declare(strict_types=1);

namespace App\Services;

use App\Models\Setting;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;

/**
 * Cached read/write access to the `settings` table.
 *
 * Named for what it is — a settings accessor — rather than a "repository",
 * because it is not one: there is no interface, no swappable implementation and
 * no abstraction over Eloquent. It exists purely so the whole table is read
 * once, cached, and cast to real PHP types.
 *
 * Registered as a singleton, so within a request the values are loaded at most
 * once even before the cache is consulted.
 */
class AppSettings
{
    public const CACHE_KEY = 'ams.settings';

    /**
     * Values for this request, keyed by setting key. Null until first load.
     *
     * @var array<string, mixed>|null
     */
    private ?array $items = null;

    /**
     * Every setting, cast to its declared type.
     *
     * @return array<string, mixed>
     */
    public function all(): array
    {
        if ($this->items !== null) {
            return $this->items;
        }

        return $this->items = $this->load();
    }

    /**
     * One setting value, or $default when it is absent or null.
     */
    public function get(string $key, mixed $default = null): mixed
    {
        $value = $this->all()[$key] ?? null;

        return $value ?? $default;
    }

    public function has(string $key): bool
    {
        return array_key_exists($key, $this->all());
    }

    /**
     * Update one setting and invalidate the cache.
     *
     * Only existing keys can be written: settings are declared by the
     * SettingSeeder so that every key has a label, a type and a group for the
     * Settings UI. A typo in a key name fails loudly instead of silently
     * creating an orphan row.
     */
    public function set(string $key, mixed $value): void
    {
        $setting = Setting::query()->where('key', $key)->first();

        if ($setting === null) {
            throw new InvalidArgumentException(
                sprintf('Unknown application setting [%s]. Declare it in SettingSeeder first.', $key),
            );
        }

        $setting->fill([
            'value' => $this->serialise($setting->type, $value),
            'updated_by' => Auth::id(),
        ])->save();

        $this->flush();
    }

    /**
     * Update several settings in one pass, invalidating the cache once.
     *
     * @param  array<string, mixed>  $values
     */
    public function setMany(array $values): void
    {
        foreach ($values as $key => $value) {
            $this->set($key, $value);
        }

        $this->flush();
    }

    /**
     * Drop both the request-local and the shared cache.
     */
    public function flush(): void
    {
        $this->items = null;
        Cache::forget(self::CACHE_KEY);
    }

    /**
     * Read every setting from cache, falling back to the database.
     *
     * The database may legitimately be unreachable or unmigrated — during the
     * very first `migrate`, or when a console command runs before setup. In
     * that case return an empty set so callers fall back to their defaults,
     * and do not cache the emptiness.
     *
     * @return array<string, mixed>
     */
    private function load(): array
    {
        try {
            /** @var array<string, mixed> $values */
            $values = Cache::rememberForever(self::CACHE_KEY, function (): array {
                $result = [];

                foreach (Setting::query()->get(['key', 'value', 'type']) as $setting) {
                    $result[$setting->key] = $this->cast($setting->type, $setting->value);
                }

                return $result;
            });

            return $values;
        } catch (QueryException) {
            return [];
        }
    }

    /**
     * Convert a stored string into the PHP type the setting declares.
     */
    private function cast(string $type, ?string $value): mixed
    {
        if ($value === null) {
            return null;
        }

        return match ($type) {
            Setting::TYPE_INTEGER => (int) $value,
            Setting::TYPE_BOOLEAN => filter_var($value, FILTER_VALIDATE_BOOLEAN),
            Setting::TYPE_JSON => json_decode($value, associative: true) ?? [],
            default => $value,
        };
    }

    /**
     * Convert a PHP value into its stored string form.
     */
    private function serialise(string $type, mixed $value): ?string
    {
        if ($value === null) {
            return null;
        }

        return match ($type) {
            Setting::TYPE_BOOLEAN => $value ? '1' : '0',
            Setting::TYPE_JSON => json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
            default => (string) $value,
        };
    }
}
