<?php

namespace App\Domains\Attachable\Services;

use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Exceptions\AttachmentStorageException;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Models\AttachmentCrop;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\Mime\MimeTypes;
use Throwable;

class AttachmentService
{
    /**
     * @param  array<string, array{crop_horizontal: array<string, mixed>, crop_vertical: array<string, mixed>}>  $crops
     */
    public function storeCroppedImageVariants(
        UploadedFile|string $image,
        string $path,
        array $crops,
    ): Attachment {
        $crops = $this->validateCropVariants($crops);
        $storedPaths = [];

        try {
            return DB::transaction(function () use (
                $image,
                $path,
                $crops,
                &$storedPaths,
            ): Attachment {
                $original = $this->store($image, $path);
                $storedPaths[] = $original->path;
                $contents = $this->imageContents($image);

                foreach ($crops as $variant => $crop) {
                    $cropped = $this->storeCropVariant($contents, $path, $crop);
                    $storedPaths[] = $cropped->path;
                    $original->cropVariants()->create([
                        'variant' => $variant,
                        'crop_horizontal' => $crop['crop_horizontal'],
                        'crop_vertical' => $crop['crop_vertical'],
                        'cropped_attachment_id' => $cropped->id,
                    ]);
                }

                return $original->load('cropVariants.croppedAttachment');
            });
        } catch (Throwable $throwable) {
            if ($storedPaths !== []) {
                Storage::disk('s3')->delete(array_values(array_unique($storedPaths)));
            }

            throw $throwable;
        }
    }

    /**
     * @param  array<string, array{crop_horizontal: array<string, mixed>, crop_vertical: array<string, mixed>}>  $crops
     */
    public function updateImageCropVariants(
        Attachment $original,
        array $crops,
    ): Attachment {
        if ($original->type !== AttachmentType::Image) {
            throw new AttachmentStorageException('Only image attachments can be cropped.');
        }

        $crops = $this->validateCropVariants($crops);
        $contents = Storage::disk('s3')->get($original->path);

        if (! is_string($contents) || $contents === '') {
            throw new AttachmentStorageException('The original image could not be read from the s3 disk.');
        }

        $directory = trim(str_replace('\\', '/', dirname($original->path)), './');
        $directory = $directory !== '' ? $directory : 'attachments';
        $previousCrops = $original->cropVariants()->with('croppedAttachment')->get();
        $storedCrops = [];

        try {
            foreach ($crops as $variant => $crop) {
                $storedCrops[$variant] = $this->storeCropVariant($contents, $directory, $crop);
            }

            DB::transaction(function () use ($original, $crops, $storedCrops): void {
                foreach ($crops as $variant => $crop) {
                    $original->cropVariants()->updateOrCreate(
                        ['variant' => $variant],
                        [
                            'crop_horizontal' => $crop['crop_horizontal'],
                            'crop_vertical' => $crop['crop_vertical'],
                            'cropped_attachment_id' => $storedCrops[$variant]->id,
                        ]
                    );
                }
            });
        } catch (Throwable $throwable) {
            foreach ($storedCrops as $storedCrop) {
                $this->delete($storedCrop);
            }

            throw $throwable;
        }

        foreach ($previousCrops as $previousCrop) {
            if ($previousCrop->croppedAttachment !== null) {
                $this->delete($previousCrop->croppedAttachment);
            }
        }

        return $original->refresh()->load('cropVariants.croppedAttachment');
    }

    public function store(
        UploadedFile|string $file,
        string $path,
    ): Attachment {
        $normalizedPath = $this->normalizeDirectory($path);

        if ($normalizedPath === '') {
            throw new AttachmentStorageException('The attachment path cannot be empty.');
        }

        $key = (string) Str::uuid();
        $fileData = $this->resolveFileData($file, $key);
        $storedPath = $this->storeFile(
            $fileData,
            $normalizedPath,
            $this->buildStoredFilename($key, $fileData['extension']),
        );

        if (! is_string($storedPath) || $storedPath === '') {
            throw new AttachmentStorageException('No se pudo subir el archivo al disco s3.');
        }

        try {
            /** @var Attachment $attachment */
            $attachment = Attachment::query()->create([
                'key' => $key,
                'path' => $storedPath,
                'filename' => $this->resolveFilename($file, $key),
                'type' => $this->resolveAttachmentType($fileData['mime_type']),
                'mime_type' => $fileData['mime_type'],
                'extension' => $fileData['extension'],
                'size' => $fileData['size'],
            ]);

            return $attachment;
        } catch (Throwable $throwable) {
            Storage::disk('s3')->delete($storedPath);

            throw $throwable;
        }
    }

    public function delete(Attachment $attachment): void
    {
        $croppedAttachments = $attachment->cropVariants()
            ->with('croppedAttachment')
            ->get()
            ->pluck('croppedAttachment')
            ->filter();
        $paths = $croppedAttachments
            ->pluck('path')
            ->prepend($attachment->path)
            ->filter()
            ->unique()
            ->values()
            ->all();
        $deleted = Storage::disk('s3')->delete(
            count($paths) === 1 ? $paths[0] : $paths
        );

        if (! $deleted) {
            throw new AttachmentStorageException('No se pudieron eliminar los archivos del disco s3.');
        }

        DB::transaction(function () use ($attachment, $croppedAttachments): void {
            $attachment->delete();
            Attachment::query()->whereKey($croppedAttachments->pluck('id'))->delete();
        });
    }

    public function copy(Attachment $source, string $path): Attachment
    {
        $normalizedPath = $this->normalizeDirectory($path);

        if ($normalizedPath === '') {
            throw new AttachmentStorageException('The attachment path cannot be empty.');
        }

        $key = (string) Str::uuid();
        $storedPath = $normalizedPath.'/'.$this->buildStoredFilename($key, (string) $source->extension);
        $copied = Storage::disk('s3')->copy($source->path, $storedPath);

        if (! $copied) {
            throw new AttachmentStorageException('No se pudo copiar el archivo en el disco s3.');
        }

        try {
            /** @var Attachment $attachment */
            $attachment = Attachment::query()->create([
                'key' => $key,
                'path' => $storedPath,
                'filename' => $source->filename,
                'type' => $source->type,
                'mime_type' => $source->mime_type,
                'extension' => $source->extension,
                'size' => $source->size,
            ]);

            return $attachment;
        } catch (Throwable $throwable) {
            Storage::disk('s3')->delete($storedPath);

            throw $throwable;
        }
    }

    protected function normalizeDirectory(string $path): string
    {
        return trim($path, '/');
    }

    /**
     * @param  array<string, array{crop_horizontal?: mixed, crop_vertical?: mixed}>  $crops
     * @return array<string, array{crop_horizontal: array{start_percentage: float, end_percentage: float}, crop_vertical: array{start_percentage: float, end_percentage: float}}>
     */
    protected function validateCropVariants(array $crops): array
    {
        $validated = [];

        foreach (AttachmentCrop::VARIANTS as $variant) {
            $crop = $crops[$variant] ?? null;

            if (! is_array($crop)) {
                throw new AttachmentStorageException("The {$variant} crop is required.");
            }

            $horizontal = $crop['crop_horizontal'] ?? null;
            $vertical = $crop['crop_vertical'] ?? null;

            if (! is_array($horizontal) || ! is_array($vertical)) {
                throw new AttachmentStorageException(
                    "The {$variant} crop must contain crop_horizontal and crop_vertical ranges."
                );
            }

            $validated[$variant] = [
                'crop_horizontal' => $this->validateCropRange($horizontal, "{$variant} horizontal"),
                'crop_vertical' => $this->validateCropRange($vertical, "{$variant} vertical"),
            ];
        }

        return $validated;
    }

    /**
     * @param  array{crop_horizontal: array<string, mixed>, crop_vertical: array<string, mixed>}  $crop
     */
    protected function storeCropVariant(string $contents, string $path, array $crop): Attachment
    {
        $croppedContents = $this->cropImage(
            $contents,
            $crop['crop_horizontal'],
            $crop['crop_vertical'],
        );

        return $this->store(
            'data:'.$croppedContents['mime_type'].';base64,'.base64_encode($croppedContents['contents']),
            $path,
        );
    }

    /**
     * @param  array{start_percentage?: mixed, end_percentage?: mixed}  $range
     * @return array{start_percentage: float, end_percentage: float}
     */
    protected function validateCropRange(array $range, string $axis): array
    {
        $start = $range['start_percentage'] ?? null;
        $end = $range['end_percentage'] ?? null;

        if (! is_numeric($start) || ! is_numeric($end)) {
            throw new AttachmentStorageException(
                "The {$axis} crop range must contain numeric start_percentage and end_percentage values."
            );
        }

        $start = (float) $start;
        $end = (float) $end;

        if (! is_finite($start) || ! is_finite($end) || $start < 0 || $end > 100 || $start >= $end) {
            throw new AttachmentStorageException(
                "The {$axis} crop range must satisfy 0 <= start_percentage < end_percentage <= 100."
            );
        }

        return [
            'start_percentage' => $start,
            'end_percentage' => $end,
        ];
    }

    protected function imageContents(UploadedFile|string $image): string
    {
        if (is_string($image)) {
            return $this->decodeBase64File($image)['data'];
        }

        $contents = file_get_contents($image->getRealPath());

        if (! is_string($contents) || $contents === '') {
            throw new AttachmentStorageException('The image content cannot be empty.');
        }

        return $contents;
    }

    /**
     * @return array{contents: string, mime_type: string}
     */
    protected function cropImage(
        string $contents,
        array $cropHorizontal,
        array $cropVertical,
    ): array {
        $source = @imagecreatefromstring($contents);

        if ($source === false) {
            throw new AttachmentStorageException('The attachment must contain a valid image.');
        }

        $width = imagesx($source);
        $height = imagesy($source);
        $x = min($width - 1, (int) floor($width * $cropHorizontal['start_percentage'] / 100));
        $right = min($width, (int) ceil($width * $cropHorizontal['end_percentage'] / 100));
        $y = min($height - 1, (int) floor($height * $cropVertical['start_percentage'] / 100));
        $bottom = min($height, (int) ceil($height * $cropVertical['end_percentage'] / 100));
        $cropped = imagecrop($source, [
            'x' => $x,
            'y' => $y,
            'width' => $right - $x,
            'height' => $bottom - $y,
        ]);

        if ($cropped === false) {
            throw new AttachmentStorageException('The image could not be cropped.');
        }

        return $this->encodeImage($cropped, $contents);
    }

    /**
     * @return array{contents: string, mime_type: string}
     */
    protected function encodeImage(\GdImage $image, string $originalContents): array
    {
        $mimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($originalContents);
        $mimeType = is_string($mimeType) ? strtolower($mimeType) : '';

        ob_start();

        try {
            $encoded = match ($mimeType) {
                'image/jpeg' => imagejpeg($image, null, 90),
                'image/gif' => imagegif($image),
                'image/webp' => imagewebp($image, null, 90),
                'image/avif' => function_exists('imageavif') && imageavif($image, null, 90),
                default => imagepng($image),
            };
            $output = ob_get_contents();
        } finally {
            ob_end_clean();
        }

        if (! $encoded || ! is_string($output) || $output === '') {
            throw new AttachmentStorageException('The cropped image could not be encoded.');
        }

        return [
            'contents' => $output,
            'mime_type' => match ($mimeType) {
                'image/jpeg', 'image/gif', 'image/webp', 'image/avif' => $mimeType,
                default => 'image/png',
            },
        ];
    }

    protected function resolveFilename(UploadedFile|string $file, string $key): string
    {
        if ($file instanceof UploadedFile) {
            $originalName = trim($file->getClientOriginalName());

            if ($originalName !== '') {
                return $originalName;
            }
        }

        return $key;
    }

    /**
     * @return array{contents: string|null, extension: string, file: UploadedFile|null, mime_type: string, size: int}
     */
    protected function resolveFileData(UploadedFile|string $file, string $filename): array
    {
        if ($file instanceof UploadedFile) {
            return $this->fileDataFromUploadedFile($file);
        }

        return $this->fileDataFromBase64($file, $filename);
    }

    /**
     * @return array{contents: string|null, extension: string, file: UploadedFile, mime_type: string, size: int}
     */
    protected function fileDataFromUploadedFile(UploadedFile $file): array
    {
        $mimeType = strtolower($file->getClientMimeType() ?? $file->getMimeType() ?? 'application/octet-stream');

        return [
            'contents' => null,
            'extension' => strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: ''),
            'file' => $file,
            'mime_type' => $mimeType,
            'size' => $file->getSize() ?? 0,
        ];
    }

    /**
     * @return array{contents: string, extension: string, file: null, mime_type: string, size: int}
     */
    protected function fileDataFromBase64(string $file, string $filename): array
    {
        ['data' => $contents, 'mime_type' => $declaredMimeType] = $this->decodeBase64File($file);
        $mimeType = $this->detectMimeType($contents, $declaredMimeType);

        return [
            'contents' => $contents,
            'extension' => $this->resolveExtension($filename, $mimeType),
            'file' => null,
            'mime_type' => $mimeType,
            'size' => strlen($contents),
        ];
    }

    /**
     * @return array{data: string, mime_type: string|null}
     */
    protected function decodeBase64File(string $file): array
    {
        $payload = trim($file);

        if ($payload === '') {
            throw new AttachmentStorageException('The base64 attachment content cannot be empty.');
        }

        $declaredMimeType = null;

        if (preg_match('/^data:(?<mime>[-\w.+\/]+);base64,(?<data>.+)$/s', $payload, $matches) === 1) {
            $declaredMimeType = strtolower($matches['mime']);
            $payload = $matches['data'];
        }

        $decoded = base64_decode(preg_replace('/\s+/', '', $payload), true);

        if ($decoded === false || $decoded === '') {
            throw new AttachmentStorageException('The attachment base64 payload is invalid.');
        }

        return [
            'data' => $decoded,
            'mime_type' => $declaredMimeType,
        ];
    }

    protected function detectMimeType(string $contents, ?string $fallback = null): string
    {
        if (is_string($fallback) && $fallback !== '') {
            return strtolower($fallback);
        }

        $detectedMimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($contents);

        if (is_string($detectedMimeType) && $detectedMimeType !== '') {
            return strtolower($detectedMimeType);
        }

        return strtolower($fallback ?? 'application/octet-stream');
    }

    protected function resolveExtension(string $filename, string $mimeType): string
    {
        $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));

        if ($extension !== '') {
            return $extension;
        }

        return strtolower(MimeTypes::getDefault()->getExtensions($mimeType)[0] ?? '');
    }

    /**
     * @param  array{contents: string|null, extension: string, file: UploadedFile|null, mime_type: string, size: int}  $fileData
     */
    protected function storeFile(array $fileData, string $directory, string $storedFilename): string
    {
        if ($fileData['file'] instanceof UploadedFile) {
            return Storage::disk('s3')->putFileAs($directory, $fileData['file'], $storedFilename);
        }

        $storedPath = $directory !== ''
            ? $directory.'/'.$storedFilename
            : $storedFilename;

        $stored = Storage::disk('s3')->put($storedPath, $fileData['contents'] ?? '');

        if (! $stored) {
            return '';
        }

        return $storedPath;
    }

    protected function resolveAttachmentType(string $mimeType): AttachmentType
    {
        $mimeType = strtolower($mimeType);

        if (str_starts_with($mimeType, 'image/')) {
            return AttachmentType::Image;
        }

        if (str_starts_with($mimeType, 'video/')) {
            return AttachmentType::Video;
        }

        if ($mimeType === 'application/pdf') {
            return AttachmentType::Pdf;
        }

        if (str_starts_with($mimeType, 'audio/')) {
            return AttachmentType::Audio;
        }

        if (
            str_starts_with($mimeType, 'text/')
            || str_contains($mimeType, 'document')
            || str_contains($mimeType, 'word')
            || str_contains($mimeType, 'excel')
            || str_contains($mimeType, 'spreadsheet')
            || str_contains($mimeType, 'presentation')
            || str_contains($mimeType, 'officedocument')
        ) {
            return AttachmentType::Document;
        }

        return AttachmentType::Other;
    }

    protected function buildStoredFilename(string $key, string $extension): string
    {
        if ($extension === '') {
            return $key;
        }

        return $key.'.'.$extension;
    }
}
