<?php

namespace App\Services\Storage;

use App\Exceptions\StorageNotConfiguredException;
use App\Exceptions\StorageQuotaExceededException;
use App\Models\Central\Tenant;
use App\Services\CurrentContext;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use RuntimeException;

/**
 * The single entry point for tenant file storage.
 *
 * There is exactly ONE storage destination for the whole platform — the
 * Cloudflare R2 bucket resolved by {@see StorageConfig} (Master Control →
 * Storage, falling back to the CLOUDFLARE_R2_* environment values). Tenants are
 * separated by path prefix, "<tenancy.storage_prefix>/<tenantId>/", never by
 * bucket, so per-tenant usage is a simple prefix listing.
 *
 * This replaced a multi-source registry in which every tenant pointed at a row
 * in `storage_sources`. That design is gone: no per-tenant source, no
 * migrations between sources, and no encrypted config read on the upload path
 * that could fail the whole write (see {@see StorageSetting}).
 */
class TenantStorage
{
    /**
     * The built disk, memoised against a fingerprint of the config it was built
     * from. This service is a singleton (AppServiceProvider), so in a queue
     * worker it outlives any number of credential edits — keying on the config
     * means an edited bucket/credential set can never hand back a stale disk.
     *
     * @var array<string, Filesystem>
     */
    private array $disks = [];

    public function __construct(
        private readonly CurrentContext $context,
        private readonly StorageConfig $config,
    ) {}

    /**
     * Store an uploaded file under "<prefix>/<tenantId>/<dir>" with a random name,
     * returning the path relative to the bucket root (persist this, not the URL).
     *
     * @throws StorageNotConfiguredException when no bucket/credentials are set
     *                                       in either Master Control or the environment
     * @throws StorageQuotaExceededException when the write would push the
     *                                       platform over its configured quota_bytes cap
     */
    public function putFile(string $dir, UploadedFile $file): string
    {
        $tenant = $this->requireTenant();

        $this->assertWithinQuota((int) $file->getSize());

        $path = $this->tenantPath($tenant->id, $dir);
        $stored = $this->disk()->putFile($path, $file);

        // Keep the cached counters live rather than only as fresh as the last
        // `storage:recalculate-usage` run — both the quota check above and the
        // tenant list's storage column depend on them being close to real-time.
        $tenant->increment('storage_used_bytes', (int) $file->getSize());
        $tenant->increment('storage_files_count');
        $tenant->forceFill(['storage_calculated_at' => now()])->saveQuietly();

        return $stored;
    }

    /**
     * The platform quota is shared by every tenant on the one bucket, so the
     * usage it is measured against is the sum across all of them.
     *
     * @throws StorageQuotaExceededException
     */
    private function assertWithinQuota(int $incomingBytes): void
    {
        $quota = $this->config->quotaBytes();

        if ($quota === null) {
            return;
        }

        $used = (int) Tenant::query()->sum('storage_used_bytes');

        if ($used + $incomingBytes > $quota) {
            throw new StorageQuotaExceededException(
                'Platform file storage is at capacity. Free up space or raise the quota in Master Control → Storage.'
            );
        }
    }

    public function url(string $path, ?Tenant $tenant = null): string
    {
        return $this->disk()->url($path);
    }

    /**
     * Display URL for a stored path — the absolute bucket URL.
     *
     * Resolve at READ time, never persist the result: it depends on the public
     * host currently configured, and a stored absolute URL would keep pointing
     * at the old host after that changes (see {@see resolveUrl()} for the
     * compatibility path for rows that already hold one).
     *
     * $tenant is accepted for callers on the central domain, where no tenant is
     * bound to the request. It no longer affects the answer — every tenant is on
     * the same bucket — but the parameter is kept so those call sites, and the
     * legacy rows they read, keep working unchanged.
     */
    public function displayUrl(string $path, ?Tenant $tenant = null): string
    {
        return $this->disk()->url($path);
    }

    /**
     * Render a persisted image reference — a storage path, or (for rows written
     * before storage became configurable) an already-resolved URL.
     *
     * Absolute URLs, protocol-relative URLs, data: URIs and root-relative paths
     * are passed through untouched; anything else is treated as a path on the
     * platform bucket. A storage layer that cannot answer degrades to "no image"
     * rather than taking down the page that asked — a missing logo is
     * recoverable, a 500 on an invoice print is not.
     */
    public function resolveUrl(?string $value, ?Tenant $tenant = null): ?string
    {
        $value = is_string($value) ? trim($value) : '';

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

        if (str_starts_with($value, '/')
            || str_starts_with($value, 'data:')
            || preg_match('#^https?://#i', $value) === 1) {
            return $value;
        }

        try {
            return $this->displayUrl($value, $tenant);
        } catch (\Throwable $e) {
            report($e);

            return null;
        }
    }

    public function delete(string $path): bool
    {
        $tenant = $this->requireTenant();
        $disk = $this->disk();
        $size = $disk->exists($path) ? $disk->size($path) : 0;

        $deleted = $disk->delete($path);

        if ($deleted && $size > 0) {
            $tenant->update([
                'storage_used_bytes' => max(0, (int) $tenant->storage_used_bytes - $size),
                'storage_files_count' => max(0, (int) $tenant->storage_files_count - 1),
                'storage_calculated_at' => now(),
            ]);
        }

        return $deleted;
    }

    /**
     * Computed usage (bytes + file count) for a tenant's prefix on the bucket.
     *
     * @return array{bytes: int, files: int}
     */
    public function usage(?Tenant $tenant = null): array
    {
        $tenant ??= $this->requireTenant();
        $disk = $this->disk();
        $prefix = $this->prefix($tenant->id);

        $bytes = 0;
        $files = $disk->allFiles($prefix);
        foreach ($files as $file) {
            $bytes += $disk->size($file);
        }

        return ['bytes' => $bytes, 'files' => count($files)];
    }

    /**
     * The platform's one Filesystem, built from the resolved configuration.
     *
     * @throws StorageNotConfiguredException
     */
    public function disk(): Filesystem
    {
        $config = $this->config->diskConfig();
        $key = md5(serialize($config));

        return $this->disks[$key] ??= Storage::build($config);
    }

    /** Whether storage is configured at all — UI-facing, never throws. */
    public function isConfigured(): bool
    {
        return $this->config->isConfigured();
    }

    /** "<prefix>/<tenantId>" — the root all of a tenant's files live under. */
    public function prefix(int $tenantId): string
    {
        return config('tenancy.storage_prefix', 'tenants')."/{$tenantId}";
    }

    private function tenantPath(int $tenantId, string $dir): string
    {
        $dir = trim($dir, '/');

        return $dir === '' ? $this->prefix($tenantId) : $this->prefix($tenantId)."/{$dir}";
    }

    private function requireTenant(): Tenant
    {
        $tenant = $this->context->tenant();

        if (! $tenant) {
            throw new RuntimeException('TenantStorage requires an active tenant context.');
        }

        return $tenant;
    }
}
