<?php

namespace App\Filament\Central\Pages;

use App\Models\Central\StorageSetting;
use App\Models\Central\Tenant;
use App\Services\Storage\StorageConfig;
use App\Services\Storage\TenantStorage;
use Closure;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Components\Form;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Str;

/**
 * Master Control → Storage.
 *
 * The whole platform writes to ONE Cloudflare R2 bucket; tenants are separated
 * by path prefix. This page is the UI for that single configuration and
 * replaces the old multi-source registry (a table of destinations tenants were
 * individually assigned to, plus a queued job to move them between sources).
 *
 * Every field is optional. A field left blank inherits the matching
 * CLOUDFLARE_R2_* value from `.env`, and the helper text under each input says
 * which layer is currently answering — so an install can be configured entirely
 * from `.env`, entirely from here, or any mix.
 *
 * Per-tenant consumption is NOT shown here; it belongs with the tenants
 * themselves (Master Control → Tenants, "Storage" column).
 */
class StorageSettings extends Page
{
    protected static string|\BackedEnum|null $navigationIcon = Heroicon::OutlinedCircleStack;

    protected static ?string $navigationLabel = 'Storage';

    protected static ?string $title = 'Storage';

    // NOT 'storage': every production release ships a REAL public/storage
    // directory (BuildRelease::scaffoldStorage(), for the local-disk fallback
    // on shared cPanel hosting with no `storage:link`). Apache/LiteSpeed only
    // rewrites to Laravel when the request path is NOT an existing file/dir on
    // disk — so a page at /storage never reaches this app in production at
    // all; the web server serves the real directory first and 403s on the
    // disabled listing before Laravel's router ever runs.
    protected static ?string $slug = 'storage-settings';

    protected static ?int $navigationSort = 2;

    /** @var array<string, mixed> */
    public ?array $data = [];

    public function mount(): void
    {
        $stored = $this->config()->storedConfig();

        $this->form->fill([
            'bucket' => $stored['bucket'] ?? null,
            'region' => $stored['region'] ?? null,
            'endpoint' => $stored['endpoint'] ?? null,
            'url' => $stored['url'] ?? null,
            'key' => $stored['key'] ?? null,
            // Never sent back to the browser; a blank field on save keeps it.
            'secret' => null,
            'quota_gb' => ($quota = $this->config()->quotaBytes()) === null
                ? null
                : round($quota / 1073741824, 2),
        ]);
    }

    public function form(Schema $schema): Schema
    {
        return $schema
            ->statePath('data')
            ->components([
                Section::make('Cloudflare R2')
                    ->description('The single bucket every tenant\'s uploads are written to. Leave a field blank to use the value from .env.')
                    ->schema([
                        TextInput::make('bucket')
                            ->label('Bucket')
                            ->maxLength(255)
                            ->helperText($this->originHint('bucket')),
                        TextInput::make('region')
                            ->label('Region')
                            ->maxLength(100)
                            ->placeholder('auto')
                            ->helperText($this->originHint('region')),
                        TextInput::make('endpoint')
                            ->label('Endpoint')
                            ->maxLength(255)
                            ->placeholder('https://<account>.r2.cloudflarestorage.com')
                            // Filament evaluates rule closures through its own
                            // DI-aware evaluator, which cannot resolve the
                            // validator's $attribute/$value/$fail parameters.
                            // The rule is therefore wrapped in a parameterless
                            // closure that merely RETURNS it; the returned
                            // closure is handed to Laravel's validator verbatim.
                            ->rule(fn (): Closure => static::endpointRule())
                            ->helperText($this->originHint('endpoint').' Must be a public https:// host.'),
                        TextInput::make('url')
                            ->label('Public URL')
                            ->maxLength(255)
                            ->placeholder('https://pub-....r2.dev')
                            ->helperText(
                                $this->originHint('url')
                                .' This is per BUCKET — a public host belonging to a different bucket returns 404 for every'
                                .' object while the API credentials still look healthy.'
                            ),
                        TextInput::make('key')
                            ->label('Access key ID')
                            ->maxLength(255)
                            ->helperText($this->originHint('key')),
                        TextInput::make('secret')
                            ->label('Secret access key')
                            ->password()
                            ->revealable()
                            ->maxLength(255)
                            ->helperText($this->secretHint()),
                    ])
                    ->columns(2),

                Section::make('Quota')
                    ->description('Shared across every tenant on the bucket.')
                    ->schema([
                        TextInput::make('quota_gb')
                            ->label('Platform quota')
                            ->numeric()
                            ->minValue(0)
                            ->suffix('GB')
                            ->helperText('Leave blank for unlimited. Uploads are refused once the total across all tenants exceeds this.'),
                    ]),
            ]);
    }

    /**
     * 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 that range.
     *
     * This host is used server-side for every upload/download/usage scan, so an
     * internal-network or loopback endpoint here is SSRF with attacker-chosen
     * destination, not just path, control. Carried over unchanged from the
     * previous storage UI.
     */
    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.');
            }
        };
    }

    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;
    }

    public function save(): void
    {
        $state = $this->form->getState();

        $config = [
            'bucket' => $state['bucket'] ?? null,
            'region' => $state['region'] ?? null,
            'endpoint' => $state['endpoint'] ?? null,
            'url' => $state['url'] ?? null,
            'key' => $state['key'] ?? null,
            'secret' => $state['secret'] ?? null,
        ];

        // A blank secret means "keep the stored one" — so it never has to be
        // re-typed to change an unrelated field. It is intentionally not sent to
        // the browser, so a blank box cannot be distinguished from an untouched
        // one any other way.
        if (blank($config['secret'])) {
            $config['secret'] = $this->config()->storedConfig()['secret'] ?? null;
        }

        $config = array_filter($config, fn ($value): bool => filled($value));

        $quotaGb = $state['quota_gb'] ?? null;

        StorageSetting::writeConfig(
            $config === [] ? null : $config,
            blank($quotaGb) ? null : (int) round(((float) $quotaGb) * 1073741824),
        );

        Notification::make()
            ->title('Storage settings saved')
            ->body($this->config()->isConfigured()
                ? 'Uploads will use this configuration.'
                : 'Storage is still not fully configured — a bucket and access key are required.')
            ->success()
            ->send();

        $this->mount();
    }

    /**
     * A real round-trip against the bucket: write a small object, read it back,
     * confirm the public URL, then delete it.
     *
     * This is the only way to tell a genuinely working configuration from one
     * that merely looks complete — wrong credentials, a bucket that does not
     * exist, and a public host belonging to a DIFFERENT bucket all present as
     * a perfectly plausible-looking settings page.
     */
    public function testConnection(): void
    {
        $storage = app(TenantStorage::class);

        try {
            $disk = $storage->disk();
        } catch (\Throwable $e) {
            Notification::make()
                ->title('Storage is not configured')
                ->body($e->getMessage())
                ->danger()
                ->persistent()
                ->send();

            return;
        }

        $path = 'diagnostics/connection-test-'.Str::random(12).'.txt';
        $body = 'vellix-point storage check '.now()->toIso8601String();

        try {
            $disk->put($path, $body);

            $readBack = $disk->get($path);
            if ($readBack !== $body) {
                throw new \RuntimeException('The object read back from the bucket did not match what was written.');
            }

            $url = $disk->url($path);
            $publicStatus = $this->probe($url);

            $disk->delete($path);

            $publicNote = match (true) {
                $publicStatus === 200 => 'Public URL serves objects correctly.',
                $publicStatus === null => 'Could not reach the public URL from this server (the write/read/delete round-trip still succeeded).',
                default => 'WARNING: the public URL returned HTTP '.$publicStatus.'. Uploads will succeed but images will not load in the browser —'
                    .' the Public URL almost certainly belongs to a different bucket.',
            };

            Notification::make()
                ->title($publicStatus === 200 ? 'Storage is working' : 'Storage is writable, but check the public URL')
                ->body('Wrote, read and deleted a test object successfully. '.$publicNote)
                ->color($publicStatus === 200 ? 'success' : 'warning')
                ->persistent()
                ->send();
        } catch (\Throwable $e) {
            // Best-effort cleanup — a partial write should not leave litter.
            try {
                $disk->delete($path);
            } catch (\Throwable) {
                // Ignore: the original failure is the one worth reporting.
            }

            Notification::make()
                ->title('Storage test failed')
                ->body($e->getMessage())
                ->danger()
                ->persistent()
                ->send();
        }
    }

    /**
     * HTTP status of a public object URL, or null when the server cannot make
     * outbound requests at all (in which case the check is inconclusive rather
     * than failed).
     */
    private function probe(string $url): ?int
    {
        try {
            $context = stream_context_create([
                'http' => ['method' => 'HEAD', 'timeout' => 8, 'ignore_errors' => true],
                'https' => ['method' => 'HEAD', 'timeout' => 8, 'ignore_errors' => true],
            ]);

            $headers = @get_headers($url, false, $context);

            if ($headers === false || ! isset($headers[0])) {
                return null;
            }

            preg_match('#HTTP/\S+\s+(\d{3})#', $headers[0], $matches);

            return isset($matches[1]) ? (int) $matches[1] : null;
        } catch (\Throwable) {
            return null;
        }
    }

    public function recalculateUsage(): void
    {
        Artisan::call('storage:recalculate-usage');

        Notification::make()
            ->title('Storage usage recalculated')
            ->body('Per-tenant totals are up to date — see Master Control → Tenants.')
            ->success()
            ->send();
    }

    public function content(Schema $schema): Schema
    {
        return $schema->components([$this->getFormContentComponent()]);
    }

    public function getFormContentComponent(): Component
    {
        return Form::make([EmbeddedSchema::make('form')])
            ->id('form')
            ->livewireSubmitHandler('save')
            ->footer([
                Actions::make($this->getFormActions())->key('form-actions'),
            ]);
    }

    /**
     * @return array<Action>
     */
    protected function getFormActions(): array
    {
        return [
            Action::make('save')
                ->label('Save settings')
                ->submit('save'),

            Action::make('test')
                ->label('Test connection')
                ->color('gray')
                ->icon(Heroicon::OutlinedBolt)
                ->action('testConnection'),

            Action::make('recalculate')
                ->label('Recalculate usage')
                ->color('gray')
                ->icon(Heroicon::OutlinedArrowPath)
                ->requiresConfirmation()
                ->modalDescription('Re-scans the bucket and refreshes each tenant\'s cached usage figures. On a large bucket this can take a while.')
                ->action('recalculateUsage'),
        ];
    }

    /**
     * The banner above the form: what is answering right now, and the two
     * conditions worth interrupting the operator about.
     *
     * @return array<string, mixed>
     */
    public function getSubheading(): ?string
    {
        $config = $this->config();

        if ($config->hasUnreadableStoredConfig()) {
            return 'Saved credentials exist but cannot be decrypted with this APP_KEY — they were saved under a different one. '
                .'The .env values are being used instead. Re-enter the credentials below and save to repair this.';
        }

        if (! $config->isConfigured()) {
            return 'Storage is NOT configured — uploads will fail. Set a bucket and access key below, or in .env.';
        }

        $origins = $config->fieldOrigins();
        $fromDb = count(array_filter($origins, fn (string $o): bool => $o === 'database'));

        return match (true) {
            $fromDb === 0 => 'Configured entirely from .env. Saving any field here overrides the matching .env value.',
            $fromDb === count($origins) => 'Configured entirely from these settings.',
            default => 'Configured from these settings, with the remaining values inherited from .env.',
        };
    }

    private function originHint(string $field): string
    {
        $origin = $this->config()->fieldOrigins()[$field] ?? 'unset';
        $env = $this->config()->envConfig()[$field] ?? null;

        return match ($origin) {
            'database' => 'Set here.',
            'env' => 'Currently inherited from .env'.(filled($env) && $field !== 'secret' ? ' ("'.Str::limit((string) $env, 48).'")' : '').'.',
            default => 'Not set here or in .env.',
        };
    }

    private function secretHint(): string
    {
        $origins = $this->config()->fieldOrigins();

        return match ($origins['secret'] ?? 'unset') {
            'database' => 'A secret is saved. Leave blank to keep it; type a new one to replace it.',
            'env' => 'Currently inherited from .env. Leave blank to keep using it.',
            default => 'Not set here or in .env.',
        };
    }

    private function config(): StorageConfig
    {
        return app(StorageConfig::class);
    }

    /**
     * Total bytes across every tenant — the figure the platform quota is
     * measured against, shown on the tenants list.
     */
    public static function totalUsedBytes(): int
    {
        return (int) Tenant::query()->sum('storage_used_bytes');
    }
}
