<?php

namespace App\Services\Storage;

use App\Exceptions\StorageNotConfiguredException;
use App\Models\Central\StorageSetting;
use Closure;

/**
 * Resolves the platform's ONE storage configuration.
 *
 * Precedence, highest first:
 *   1. The `storage_settings` row saved from Master Control → Storage.
 *   2. The `.env` values (config/filesystems.php "r2" disk).
 *   3. Nothing — uploads fail loudly (see {@see StorageNotConfiguredException}).
 *
 * The layering is per FIELD, not all-or-nothing: an operator who fills in only
 * a bucket in the UI keeps the endpoint and credentials from `.env`. A field
 * left blank in the UI is not an instruction to blank it out, it is an
 * instruction to defer to the environment.
 *
 * Resolution must never be allowed to go stale across a credential edit: this
 * service is a singleton, so in a queue worker a cached config would otherwise
 * outlive any number of edits. It is therefore memoised for the life of ONE
 * request/job only — {@see flush()} is called by StorageSetting's own model
 * events and by the queue's JobProcessing hook (AppServiceProvider), so no
 * call site can forget to invalidate it.
 *
 * The memo is not an optimisation of last resort, it is load-bearing: every
 * `$product->image_display_url` read resolves through here, so an uncached
 * resolve() meant ONE `storage_settings` query plus ONE AES decrypt per
 * product card on the POS grid and the sales-order picker — hundreds of
 * queries to render one screen.
 */
class StorageConfig
{
    /**
     * The fields that make up a storage configuration, and where each one comes
     * from in the `.env`-backed disk config.
     */
    private const FIELDS = ['bucket', 'region', 'endpoint', 'url', 'key', 'secret'];

    /**
     * Memoised {@see resolve()} result for this request/job. Wrapped in a
     * one-element array so a legitimately-null resolution (nothing configured)
     * is still a cache hit rather than a re-resolve on every call.
     *
     * @var array{0: array<string, mixed>|null}|null
     */
    private ?array $resolved = null;

    /**
     * Memoised {@see storedConfig()} result — the `storage_settings` row read
     * and its AES payload decrypted, both of which are per-call costs.
     *
     * @var array<string, mixed>|null
     */
    private ?array $storedConfigCache = null;

    private bool $storedConfigResolved = false;

    private ?StorageSetting $settingRowCache = null;

    private bool $settingRowResolved = false;

    /**
     * Drop every memo. Called whenever the saved configuration changes, and at
     * the start of each queued job.
     */
    public function flush(): void
    {
        $this->resolved = null;
        $this->storedConfigCache = null;
        $this->storedConfigResolved = false;
        $this->settingRowCache = null;
        $this->settingRowResolved = false;
    }

    /**
     * The effective configuration, or null when neither layer supplies enough
     * to build a disk.
     *
     * @return array<string, mixed>|null
     */
    public function resolve(): ?array
    {
        if ($this->resolved !== null) {
            return $this->resolved[0];
        }

        return ($this->resolved = [$this->resolveUncached()])[0];
    }

    /**
     * @return array<string, mixed>|null
     */
    private function resolveUncached(): ?array
    {
        $stored = $this->storedConfig();
        $env = $this->envConfig();

        $merged = [];
        foreach (self::FIELDS as $field) {
            // filled() rather than isset(): a blank string in the UI means
            // "defer to .env", which is what makes partial UI config work.
            $merged[$field] = filled($stored[$field] ?? null)
                ? $stored[$field]
                : ($env[$field] ?? null);
        }

        // A bucket and a key are the minimum that could possibly work; without
        // them there is nothing to talk to.
        if (blank($merged['bucket']) || blank($merged['key'])) {
            return null;
        }

        $merged['region'] = $merged['region'] ?: 'auto';
        $merged['use_path_style_endpoint'] = true;

        return $merged;
    }

    /**
     * The effective config, or a clear failure.
     *
     * @return array<string, mixed>
     *
     * @throws StorageNotConfiguredException
     */
    public function resolveOrFail(): array
    {
        $config = $this->resolve();

        if ($config === null) {
            throw new StorageNotConfiguredException(
                'File storage is not configured. Add the Cloudflare R2 bucket and credentials in '
                .'Master Control → Storage, or set the CLOUDFLARE_R2_* values in .env.'
            );
        }

        return $config;
    }

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

    /**
     * Flysystem config for `Storage::build()`. R2 is S3-compatible, pinned to
     * its account endpoint with path-style addressing.
     *
     * `throw => true` so a failed write raises instead of returning false — the
     * upload path depends on an exception to know it must not record the file.
     *
     * @return array<string, mixed>
     *
     * @throws StorageNotConfiguredException
     */
    public function diskConfig(): array
    {
        $c = $this->resolveOrFail();

        return [
            'driver' => 's3',
            'key' => $c['key'],
            'secret' => $c['secret'],
            'region' => $c['region'],
            'bucket' => $c['bucket'],
            'url' => $c['url'],
            'endpoint' => $c['endpoint'],
            'use_path_style_endpoint' => true,
            'visibility' => 'public',
            'throw' => true,
        ];
    }

    /**
     * Platform-wide quota in bytes, or null for unlimited.
     */
    public function quotaBytes(): ?int
    {
        // settingRow() rather than StorageSetting::current(): the same memoised
        // row every other read here uses, so a quota check on the upload path
        // costs no extra query.
        $quota = $this->settingRow()?->quota_bytes;

        return $quota === null ? null : (int) $quota;
    }

    /**
     * Where each field's effective value comes from — 'database', 'env', or
     * 'unset'. Drives the "inherited from .env" hints in the settings UI.
     *
     * @return array<string, string>
     */
    public function fieldOrigins(): array
    {
        $stored = $this->storedConfig();
        $env = $this->envConfig();

        $origins = [];
        foreach (self::FIELDS as $field) {
            $origins[$field] = match (true) {
                filled($stored[$field] ?? null) => 'database',
                filled($env[$field] ?? null) => 'env',
                default => 'unset',
            };
        }

        return $origins;
    }

    /**
     * The saved credentials, or an empty array when nothing is saved or the
     * payload cannot be decrypted under the current APP_KEY.
     *
     * @return array<string, mixed>
     */
    public function storedConfig(): array
    {
        if ($this->storedConfigResolved) {
            return $this->storedConfigCache ?? [];
        }

        $this->storedConfigResolved = true;

        return $this->storedConfigCache = ($this->settingRow()?->decodedConfig() ?? []);
    }

    /**
     * @return array<string, mixed>
     */
    public function envConfig(): array
    {
        $disk = (array) config('filesystems.disks.r2', []);

        return [
            'bucket' => $disk['bucket'] ?? null,
            'region' => $disk['region'] ?? null,
            'endpoint' => $disk['endpoint'] ?? null,
            'url' => $disk['url'] ?? null,
            'key' => $disk['key'] ?? null,
            'secret' => $disk['secret'] ?? null,
        ];
    }

    /**
     * True when credentials are saved in the database but unreadable with the
     * current APP_KEY — the app has silently fallen back to `.env`.
     */
    public function hasUnreadableStoredConfig(): bool
    {
        return $this->settingRow()?->hasUnreadableConfig() ?? false;
    }

    public function settingRow(): ?StorageSetting
    {
        if ($this->settingRowResolved) {
            return $this->settingRowCache;
        }

        $this->settingRowResolved = true;

        // A missing table (fresh clone, migration not yet run) must not take the
        // whole panel down — behave as "nothing saved" and let .env answer.
        return $this->settingRowCache = $this->quietly(fn () => StorageSetting::current());
    }

    /**
     * @template T
     *
     * @param  Closure(): T  $callback
     * @return T|null
     */
    private function quietly(Closure $callback)
    {
        try {
            return $callback();
        } catch (\Throwable) {
            return null;
        }
    }

    /**
     * Reject a host that is (or resolves to) a loopback/private/link-local/
     * reserved address — the cloud metadata endpoint (169.254.169.254) that
     * every major provider exposes lives in this range. A hostname that fails
     * to resolve is let through here; the actual S3 client will simply fail
     * to connect, which is a functional problem for the operator to fix, not
     * a security one.
     */
    public static function resolvesToDisallowedHost(string $host): bool
    {
        $host = strtolower($host);

        if ($host === 'localhost' || str_ends_with($host, '.local')) {
            return true;
        }

        $isPublicIp = fn (string $ip): bool => filter_var(
            $ip,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
        ) !== false;

        if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
            return ! $isPublicIp($host);
        }

        $ips = gethostbynamel($host);
        if ($ips === false) {
            return false;
        }

        foreach ($ips as $ip) {
            if (! $isPublicIp($ip)) {
                return true;
            }
        }

        return false;
    }

    /**
     * The endpoint validation closure, as a reusable rule for the settings form.
     *
     * This host is used server-side for every upload/download/usage scan — an
     * internal-network or loopback endpoint here is SSRF with attacker-chosen
     * destination, not just path, control.
     */
    public static function endpointRule(): Closure
    {
        return function (string $attribute, mixed $value, Closure $fail): void {
            if (blank($value)) {
                return;
            }

            $host = parse_url($value, PHP_URL_HOST);
            $scheme = parse_url($value, PHP_URL_SCHEME);

            if ($scheme !== 'https' || ! is_string($host) || static::resolvesToDisallowedHost($host)) {
                $fail('The endpoint must be a public https:// host.');
            }
        };
    }
}
