<?php

namespace App\Services\Settings;

use App\Models\PlatformSetting;
use App\Models\SettingAuditLog;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema as DatabaseSchema;
use Illuminate\Support\Str;

class SettingsService
{
    public function get(string $key, mixed $default = null, ?string $group = null): mixed
    {
        [$group, $key] = $this->normalizeKey($key, $group);

        $record = $this->allSettings()->get($this->fullKey($group, $key));
        $definition = $this->definition($group, $key);

        if ($record instanceof PlatformSetting) {
            return $this->readRecordValue($record, $definition, $default);
        }

        return $this->fallbackValue($definition, $default);
    }

    public function set(
        string $group,
        string $key,
        mixed $value,
        string $type = 'string',
        bool $encrypted = false,
        bool $public = false
    ): PlatformSetting {
        [$group, $key] = $this->normalizeKey($key, $group);

        $definition = $this->definition($group, $key);
        $type = $definition['type'] ?? $type;
        $encrypted = (bool) ($definition['encrypted'] ?? $encrypted);
        $public = (bool) ($definition['public'] ?? $public);
        $description = $definition['helper'] ?? $definition['description'] ?? null;

        $existing = PlatformSetting::query()
            ->where('group', $group)
            ->where('key', $key)
            ->first();

        $oldAuditValue = $this->auditValue($existing, $definition);
        $storedValue = $encrypted
            ? $this->encryptForStorage($value)
            : $this->castValue($value, $type);

        $setting = PlatformSetting::query()->updateOrCreate(
            ['group' => $group, 'key' => $key],
            [
                'value' => $storedValue,
                'type' => $type,
                'is_encrypted' => $encrypted,
                'is_public' => $public && ! $encrypted,
                'description' => $description,
            ]
        );

        $this->writeAuditLog(
            group: $group,
            key: $key,
            oldValue: $oldAuditValue,
            newValue: $encrypted ? 'secret updated' : $this->castValue($value, $type),
            isSecret: $encrypted,
        );

        $this->clearCache();

        return $setting;
    }

    /**
     * @return array<string, mixed>
     */
    public function getGroup(string $group): array
    {
        $definitions = $this->groupDefinitions($group);
        $records = $this->allSettings()
            ->filter(fn (PlatformSetting $setting): bool => $setting->group === $group);

        $values = [];

        foreach ($definitions as $key => $definition) {
            $record = $records->get($this->fullKey($group, $key));

            $values[$key] = $record instanceof PlatformSetting
                ? $this->readRecordValue($record, $definition)
                : $this->fallbackValue($definition);
        }

        foreach ($records as $record) {
            $values[$record->key] ??= $this->readRecordValue($record, []);
        }

        return $values;
    }

    public function isEnabled(string $key, ?string $group = null): bool
    {
        return filter_var($this->get($key, null, $group), FILTER_VALIDATE_BOOL);
    }

    public function clearCache(): void
    {
        Cache::forget($this->cacheKey());
        Cache::forget($this->publicCacheKey());
    }

    public function maskSecret(?string $value): string
    {
        if (! filled($value)) {
            return '';
        }

        $value = (string) $value;
        $prefixLength = match (true) {
            Str::startsWith($value, ['rzp_live_', 'rzp_test_']) => 9,
            Str::startsWith($value, 'sk-') => 3,
            default => min(4, max(1, (int) floor(strlen($value) / 4))),
        };

        $suffixLength = min(4, max(2, (int) floor(strlen($value) / 5)));

        if (strlen($value) <= ($prefixLength + $suffixLength + 4)) {
            return str_repeat('*', min(12, max(8, strlen($value))));
        }

        return substr($value, 0, $prefixLength).'********'.substr($value, -$suffixLength);
    }

    /**
     * @return array<string, mixed>
     */
    public function publicSettings(): array
    {
        $cachedSettings = Cache::get($this->publicCacheKey());

        if (is_array($cachedSettings)) {
            return $cachedSettings;
        }

        if ($cachedSettings !== null) {
            Cache::forget($this->publicCacheKey());
        }

        $settings = $this->buildPublicSettings();

        Cache::forever($this->publicCacheKey(), $settings);

        return $settings;
    }

    /**
     * @return Collection<string, PlatformSetting>
     */
    private function allSettings(): Collection
    {
        if (! $this->settingsTableExists()) {
            return collect();
        }

        if (app()->environment('testing')) {
            return $this->querySettings();
        }

        $cachedRows = Cache::get($this->cacheKey());

        if (is_array($cachedRows)) {
            return $this->hydrateSettings($cachedRows);
        }

        if ($cachedRows !== null) {
            Cache::forget($this->cacheKey());

            Log::warning('Discarded invalid platform settings cache payload.', [
                'type' => get_debug_type($cachedRows),
            ]);
        }

        $settings = $this->querySettings();

        Cache::forever($this->cacheKey(), $this->settingsCachePayload($settings));

        return $settings;
    }

    /**
     * @return Collection<string, PlatformSetting>
     */
    private function querySettings(): Collection
    {
        return PlatformSetting::query()
            ->get()
            ->keyBy(fn (PlatformSetting $setting): string => $this->fullKey($setting->group, $setting->key));
    }

    /**
     * @param  array<int, array<string, mixed>>  $rows
     * @return Collection<string, PlatformSetting>
     */
    private function hydrateSettings(array $rows): Collection
    {
        if ($rows === []) {
            return collect();
        }

        if (collect($rows)->contains(fn (mixed $row): bool => ! is_array($row))) {
            Cache::forget($this->cacheKey());

            return $this->querySettings();
        }

        return PlatformSetting::hydrate(array_values($rows))
            ->keyBy(fn (PlatformSetting $setting): string => $this->fullKey($setting->group, $setting->key));
    }

    /**
     * @param  Collection<string, PlatformSetting>  $settings
     * @return array<int, array<string, mixed>>
     */
    private function settingsCachePayload(Collection $settings): array
    {
        return $settings
            ->map(fn (PlatformSetting $setting): array => $setting->getAttributes())
            ->values()
            ->all();
    }

    private function settingsTableExists(): bool
    {
        try {
            return DatabaseSchema::hasTable('platform_settings');
        } catch (\Throwable $exception) {
            Log::warning('Unable to inspect platform settings table.', [
                'exception' => $exception::class,
            ]);

            return false;
        }
    }

    /**
     * @return array<string, mixed>
     */
    private function buildPublicSettings(): array
    {
        $settings = [];

        foreach ($this->allSettings() as $setting) {
            if (! $setting->is_public || $setting->is_encrypted) {
                continue;
            }

            $settings[$this->fullKey($setting->group, $setting->key)] = $this->readRecordValue(
                $setting,
                $this->definition($setting->group, $setting->key)
            );
        }

        foreach (config('settings.groups', []) as $group => $groupDefinition) {
            foreach (($groupDefinition['settings'] ?? []) as $key => $definition) {
                if (! ($definition['public'] ?? false) || ($definition['encrypted'] ?? false)) {
                    continue;
                }

                $settings[$this->fullKey($group, $key)] ??= $this->fallbackValue($definition);
            }
        }

        return $settings;
    }

    /**
     * @return array{0: string, 1: string}
     */
    private function normalizeKey(string $key, ?string $group): array
    {
        $fullKey = filled($group) ? "{$group}.{$key}" : $key;
        $fullKey = config("settings.aliases.{$fullKey}", $fullKey);

        if (str_contains($fullKey, '.')) {
            [$group, $key] = explode('.', $fullKey, 2);

            return [(string) $group, (string) $key];
        }

        return [(string) ($group ?: 'general'), $fullKey];
    }

    private function fullKey(string $group, string $key): string
    {
        return "{$group}.{$key}";
    }

    /**
     * @return array<string, mixed>
     */
    private function definition(string $group, string $key): array
    {
        return (array) config("settings.groups.{$group}.settings.{$key}", []);
    }

    /**
     * @return array<string, array<string, mixed>>
     */
    private function groupDefinitions(string $group): array
    {
        return (array) config("settings.groups.{$group}.settings", []);
    }

    /**
     * @param  array<string, mixed>  $definition
     */
    private function readRecordValue(PlatformSetting $setting, array $definition, mixed $default = null): mixed
    {
        $type = $setting->type ?: ($definition['type'] ?? 'string');

        if ($setting->is_encrypted) {
            $value = $this->decryptValue($setting);

            return $value === null ? $this->fallbackValue($definition, $default) : $this->castValue($value, $type);
        }

        return $this->castValue($setting->value, $type);
    }

    private function decryptValue(PlatformSetting $setting): ?string
    {
        if (! filled($setting->value) || ! is_string($setting->value)) {
            return null;
        }

        try {
            return Crypt::decryptString($setting->value);
        } catch (DecryptException $exception) {
            Log::warning('Unable to decrypt platform setting.', [
                'group' => $setting->group,
                'key' => $setting->key,
                'exception' => $exception::class,
            ]);

            return null;
        }
    }

    /**
     * @param  array<string, mixed>  $definition
     */
    private function fallbackValue(array $definition, mixed $default = null): mixed
    {
        $fallback = null;

        if (isset($definition['config'])) {
            $fallback = config((string) $definition['config']);
        }

        if (! $this->hasUsableFallback($fallback) && isset($definition['env'])) {
            $fallback = env((string) $definition['env']);
        }

        if (! $this->hasUsableFallback($fallback)) {
            $fallback = $default ?? Arr::get($definition, 'default');
        }

        return $this->castValue($fallback, (string) ($definition['type'] ?? 'string'));
    }

    private function hasUsableFallback(mixed $value): bool
    {
        return ! ($value === null || $value === '');
    }

    private function encryptForStorage(mixed $value): ?string
    {
        if ($value === null || $value === '') {
            return null;
        }

        return Crypt::encryptString((string) $value);
    }

    private function castValue(mixed $value, string $type): mixed
    {
        if ($value === '') {
            return null;
        }

        return match ($type) {
            'boolean' => filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false,
            'integer' => $value === null ? null : (int) $value,
            'decimal' => $value === null ? null : (float) $value,
            'tags', 'array' => $this->castArray($value),
            'file' => $this->castFilePath($value),
            default => $value === null ? null : (string) $value,
        };
    }

    /**
     * @return array<int, string>
     */
    private function castArray(mixed $value): array
    {
        if (is_array($value)) {
            return array_values(array_filter(array_map(
                fn (mixed $item): string => trim((string) $item),
                $value
            ), fn (string $item): bool => $item !== ''));
        }

        if ($value === null || $value === '') {
            return [];
        }

        return array_values(array_filter(array_map(
            'trim',
            preg_split('/[\n,]+/', (string) $value) ?: []
        ), fn (string $item): bool => $item !== ''));
    }

    private function castFilePath(mixed $value): ?string
    {
        if (is_array($value)) {
            $firstValue = Arr::first($value);

            return filled($firstValue) ? (string) $firstValue : null;
        }

        return filled($value) ? (string) $value : null;
    }

    /**
     * @param  array<string, mixed>  $definition
     */
    private function auditValue(?PlatformSetting $setting, array $definition): mixed
    {
        if (! $setting instanceof PlatformSetting) {
            return null;
        }

        if ($setting->is_encrypted || ($definition['encrypted'] ?? false)) {
            return 'secret updated';
        }

        return $this->readRecordValue($setting, $definition);
    }

    private function writeAuditLog(string $group, string $key, mixed $oldValue, mixed $newValue, bool $isSecret): void
    {
        SettingAuditLog::query()->create([
            'user_id' => auth('admin')->id() ?? auth('web')->id(),
            'group' => $group,
            'key' => $key,
            'old_value' => $oldValue,
            'new_value' => $newValue,
            'is_secret' => $isSecret,
            'ip_address' => request()?->ip(),
            'user_agent' => request()?->userAgent(),
        ]);
    }

    private function cacheKey(): string
    {
        return (string) config('settings.cache_key', 'platform_settings.repository');
    }

    private function publicCacheKey(): string
    {
        return $this->cacheKey().'.public';
    }
}
