<?php

namespace App\Support;

use App\Models\CrmModule;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;
use ZipArchive;

class CrmModuleManager
{
    private const ARCHIVE_DIRECTORY = 'modules/archives';

    private const EXTRACT_DIRECTORY = 'modules/extracted';

    private const SCAFFOLD_DIRECTORY = 'modules/scaffold';

    /**
     * @return list<string>
     */
    public static function supportedHooks(): array
    {
        return [
            'activities.store',
            'activities.update',
            'companies.store',
            'companies.update',
            'contacts.store',
            'contacts.update',
            'deals.store',
            'deals.update',
            'disks.folders.store',
            'disks.store',
            'disks.update',
            'forms.store',
            'forms.update',
            'hr.requests.store',
            'hr.requests.update',
            'news.store',
            'pipelines.store',
            'pipelines.update',
            'products.store',
            'products.update',
            'projects.store',
            'projects.update',
            'tasks.store',
            'tasks.update',
            'onec.exchanges.store',
            'onec.exchanges.update',
            'telephony.store',
            'telephony.update',
            'messengers.channels.store',
            'messengers.channels.update',
            'messengers.conversations.store',
            'messengers.conversations.update',
            'messengers.messages.store',
            'messengers.messages.update',
            'mail.mailboxes.store',
            'mail.mailboxes.update',
            'warehouses.store',
            'warehouses.update',
            'warehouses.map.update',
            'warehouses.addresses.store',
            'warehouses.addresses.update',
        ];
    }

    /**
     * @param  array<string, mixed>  $payload
     * @param  array<string, mixed>  $context
     * @param  list<string>|null  $allowedKeys
     * @return array<string, mixed>
     */
    public function applyPayloadHooks(string $hook, array $payload, array $context = [], ?array $allowedKeys = null): array
    {
        if ($payload === []) {
            return $payload;
        }

        $modules = CrmModule::query()
            ->where('is_enabled', true)
            ->orderBy('id')
            ->get();

        foreach ($modules as $module) {
            $hooks = is_array($module->hooks) ? $module->hooks : [];
            $hookPath = $hooks[$hook] ?? null;
            if (! is_string($hookPath) || trim($hookPath) === '') {
                continue;
            }

            $scriptPath = $this->resolveModuleScriptPath($module, $hookPath);
            if ($scriptPath === null) {
                continue;
            }

            try {
                $result = include $scriptPath;

                if (is_callable($result)) {
                    $result = $result($payload, $context, $module->manifest ?? []);
                }

                if (! is_array($result)) {
                    continue;
                }

                if ($allowedKeys !== null) {
                    $result = Arr::only($result, $allowedKeys);
                }

                if ($result === []) {
                    continue;
                }

                $payload = array_replace($payload, $result);
            } catch (Throwable $exception) {
                Log::warning('CRM module hook failed.', [
                    'module_id' => $module->id,
                    'module_slug' => $module->slug,
                    'hook' => $hook,
                    'message' => $exception->getMessage(),
                ]);
            }
        }

        return $payload;
    }

    /**
     * @param  array{name:string,slug:string,version?:string,description?:string}  $input
     */
    public function createScaffoldArchive(array $input): string
    {
        $manifest = [
            'name' => trim((string) $input['name']),
            'slug' => $this->normalizeSlug((string) $input['slug']),
            'version' => trim((string) ($input['version'] ?? '1.0.0')) ?: '1.0.0',
            'description' => trim((string) ($input['description'] ?? '')),
            'hooks' => [
                'tasks.store' => 'hooks/tasks-store.php',
                'deals.store' => 'hooks/deals-store.php',
                'companies.store' => 'hooks/companies-store.php',
            ],
        ];

        $this->assertValidManifest($manifest);

        $scaffoldStamp = now()->format('YmdHis');
        $scaffoldRelativeDir = self::SCAFFOLD_DIRECTORY.'/'.$manifest['slug'].'-'.$scaffoldStamp;
        $scaffoldAbsoluteDir = storage_path('app/'.$scaffoldRelativeDir);
        File::ensureDirectoryExists($scaffoldAbsoluteDir);
        File::ensureDirectoryExists($scaffoldAbsoluteDir.'/hooks');

        File::put(
            $scaffoldAbsoluteDir.'/module.json',
            json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES).PHP_EOL
        );

        File::put($scaffoldAbsoluteDir.'/hooks/tasks-store.php', <<<'PHP'
<?php

return static function (array $payload, array $context, array $manifest): array {
    // Example: force default task priority for all newly created tasks.
    if (($payload['priority'] ?? '') === 'low') {
        $payload['priority'] = 'high';
    }

    return $payload;
};
PHP
        );

        File::put($scaffoldAbsoluteDir.'/hooks/deals-store.php', <<<'PHP'
<?php

return static function (array $payload, array $context, array $manifest): array {
    // Example: set a default source for newly created deals.
    if (empty($payload['source'])) {
        $payload['source'] = 'module-default';
    }

    return $payload;
};
PHP
        );

        File::put($scaffoldAbsoluteDir.'/hooks/companies-store.php', <<<'PHP'
<?php

return static function (array $payload, array $context, array $manifest): array {
    // Example: apply default lead source for newly created companies.
    if (empty($payload['source'])) {
        $payload['source'] = 'module-default';
    }

    return $payload;
};
PHP
        );

        File::put($scaffoldAbsoluteDir.'/README.md', <<<'MD'
# CRM Module Scaffold

1. Edit `module.json`.
2. Add or remove hooks.
3. Write hook scripts in the `hooks/` directory.
4. Archive this folder to ZIP and upload it in CRM settings.

Hook script format:

```php
<?php
return static function (array $payload, array $context, array $manifest): array {
    // Return changed fields.
    return ['priority' => 'urgent'];
};
```

All supported hooks are listed in CRM settings and in `docs/modules.md`.
MD
        );

        $zipRelativePath = self::SCAFFOLD_DIRECTORY.'/'.$manifest['slug'].'-'.$scaffoldStamp.'-scaffold.zip';
        $zipAbsolutePath = storage_path('app/'.$zipRelativePath);
        File::ensureDirectoryExists(dirname($zipAbsolutePath));
        $this->zipDirectory($scaffoldAbsoluteDir, $zipAbsolutePath);
        File::deleteDirectory($scaffoldAbsoluteDir);

        return $zipAbsolutePath;
    }

    public function installFromArchive(UploadedFile $archive, User $actor): CrmModule
    {
        $zip = new ZipArchive();
        if ($zip->open($archive->getRealPath()) !== true) {
            throw new RuntimeException('Unable to open module archive.');
        }

        try {
            $manifestRaw = $zip->getFromName('module.json');
            if (! is_string($manifestRaw) || trim($manifestRaw) === '') {
                throw new RuntimeException('module.json was not found in archive root.');
            }

            $manifestDecoded = json_decode($manifestRaw, true);
            if (! is_array($manifestDecoded)) {
                throw new RuntimeException('module.json has invalid JSON format.');
            }

            $manifest = $this->normalizeManifest($manifestDecoded);

            $slug = $manifest['slug'];
            $archiveRelativePath = self::ARCHIVE_DIRECTORY.'/'.$slug.'-'.now()->format('YmdHis').'.zip';
            $extractRelativePath = self::EXTRACT_DIRECTORY.'/'.$slug;
            $archiveAbsolutePath = storage_path('app/'.$archiveRelativePath);
            $extractAbsolutePath = storage_path('app/'.$extractRelativePath);

            File::ensureDirectoryExists(dirname($archiveAbsolutePath));
            File::ensureDirectoryExists(dirname($extractAbsolutePath));

            $existing = CrmModule::query()->where('slug', $slug)->first();
            if ($existing) {
                $this->deleteModuleFiles($existing);
            }

            File::copy($archive->getRealPath(), $archiveAbsolutePath);

            File::deleteDirectory($extractAbsolutePath);
            File::ensureDirectoryExists($extractAbsolutePath);
            $this->safeExtractArchive($zip, $extractAbsolutePath);

            $module = CrmModule::query()->updateOrCreate(
                ['slug' => $slug],
                [
                    'name' => $manifest['name'],
                    'version' => $manifest['version'],
                    'description' => $manifest['description'] ?: null,
                    'archive_path' => $archiveRelativePath,
                    'extract_path' => $extractRelativePath,
                    'hooks' => $manifest['hooks'],
                    'manifest' => $manifest,
                    'is_enabled' => true,
                    'created_by' => $actor->id,
                ]
            );
        } finally {
            $zip->close();
        }

        return $module;
    }

    public function setEnabled(CrmModule $module, bool $enabled): CrmModule
    {
        $module->forceFill([
            'is_enabled' => $enabled,
        ])->save();

        return $module->refresh();
    }

    public function delete(CrmModule $module): void
    {
        $this->deleteModuleFiles($module);
        $module->delete();
    }

    /**
     * @return Collection<int, CrmModule>
     */
    public function all(): Collection
    {
        return CrmModule::query()
            ->with('author:id,name')
            ->latest('id')
            ->get();
    }

    /**
     * @param  array<string, mixed>  $manifest
     * @return array{name:string,slug:string,version:string,description:string,hooks:array<string,string>}
     */
    private function normalizeManifest(array $manifest): array
    {
        $normalized = [
            'name' => trim((string) ($manifest['name'] ?? '')),
            'slug' => $this->normalizeSlug((string) ($manifest['slug'] ?? '')),
            'version' => trim((string) ($manifest['version'] ?? '1.0.0')) ?: '1.0.0',
            'description' => trim((string) ($manifest['description'] ?? '')),
            'hooks' => is_array($manifest['hooks'] ?? null) ? $manifest['hooks'] : [],
        ];

        $hooks = [];
        foreach ($normalized['hooks'] as $hook => $path) {
            if (! is_string($hook) || ! is_string($path)) {
                continue;
            }

            $hook = trim($hook);
            $path = trim(str_replace('\\', '/', $path));
            if ($hook === '' || $path === '') {
                continue;
            }

            $hooks[$hook] = $path;
        }
        $normalized['hooks'] = $hooks;

        $this->assertValidManifest($normalized);

        return $normalized;
    }

    /**
     * @param  array{name:string,slug:string,version:string,description:string,hooks:array<string,string>}  $manifest
     */
    private function assertValidManifest(array $manifest): void
    {
        if ($manifest['name'] === '') {
            throw new RuntimeException('module.json: "name" is required.');
        }

        if (! preg_match('/^[a-z0-9]+(?:[a-z0-9._-]*[a-z0-9]+)?$/', $manifest['slug'])) {
            throw new RuntimeException('module.json: "slug" must contain lowercase latin letters, numbers, dots, underscores and dashes.');
        }

        if ($manifest['version'] === '') {
            throw new RuntimeException('module.json: "version" is required.');
        }

        $supportedHooks = array_flip(self::supportedHooks());
        foreach ($manifest['hooks'] as $hook => $path) {
            if (! isset($supportedHooks[$hook])) {
                throw new RuntimeException(sprintf('module.json: unsupported hook "%s".', $hook));
            }

            if (! str_ends_with($path, '.php')) {
                throw new RuntimeException(sprintf('module.json: hook "%s" path must point to a PHP file.', $hook));
            }

            if (! $this->isSafeRelativePath($path)) {
                throw new RuntimeException(sprintf('module.json: hook "%s" path is unsafe.', $hook));
            }
        }
    }

    private function normalizeSlug(string $value): string
    {
        return trim(Str::of($value)->lower()->replace(' ', '-')->toString());
    }

    private function isSafeRelativePath(string $path): bool
    {
        $path = str_replace('\\', '/', trim($path));

        if ($path === '' || str_starts_with($path, '/')) {
            return false;
        }

        if (preg_match('/^[A-Za-z]:/', $path)) {
            return false;
        }

        if (str_contains($path, '../') || str_contains($path, '/..')) {
            return false;
        }

        return true;
    }

    private function resolveModuleScriptPath(CrmModule $module, string $relativePath): ?string
    {
        if (! $this->isSafeRelativePath($relativePath)) {
            return null;
        }

        $root = storage_path('app/'.trim($module->extract_path, '/'));
        $rootReal = realpath($root);
        if (! is_string($rootReal) || $rootReal === '') {
            return null;
        }

        $candidate = $rootReal.'/'.ltrim(str_replace('\\', '/', $relativePath), '/');
        $candidateReal = realpath($candidate);

        if (! is_string($candidateReal) || ! str_starts_with($candidateReal, $rootReal.DIRECTORY_SEPARATOR)) {
            return null;
        }

        if (! is_file($candidateReal) || ! is_readable($candidateReal)) {
            return null;
        }

        return $candidateReal;
    }

    private function deleteModuleFiles(CrmModule $module): void
    {
        if (trim((string) $module->archive_path) !== '') {
            File::delete(storage_path('app/'.$module->archive_path));
        }

        if (trim((string) $module->extract_path) !== '') {
            File::deleteDirectory(storage_path('app/'.$module->extract_path));
        }
    }

    private function safeExtractArchive(ZipArchive $zip, string $destinationRoot): void
    {
        for ($index = 0; $index < $zip->numFiles; $index++) {
            $entryName = $zip->getNameIndex($index);
            if (! is_string($entryName)) {
                continue;
            }

            $entryName = str_replace('\\', '/', $entryName);
            if ($entryName === '' || ! $this->isSafeRelativePath($entryName)) {
                throw new RuntimeException('Archive contains unsafe file paths.');
            }

            $targetPath = $destinationRoot.'/'.ltrim($entryName, '/');
            $targetDirectory = str_ends_with($entryName, '/') ? $targetPath : dirname($targetPath);
            File::ensureDirectoryExists($targetDirectory);

            if (str_ends_with($entryName, '/')) {
                continue;
            }

            $stream = $zip->getStream($entryName);
            if ($stream === false) {
                throw new RuntimeException(sprintf('Unable to read file "%s" from archive.', $entryName));
            }

            $content = stream_get_contents($stream);
            fclose($stream);

            if ($content === false) {
                throw new RuntimeException(sprintf('Unable to extract file "%s" from archive.', $entryName));
            }

            File::put($targetPath, $content);
        }
    }

    /**
     */
    private function zipDirectory(string $directory, string $zipPath): void
    {
        $zip = new ZipArchive();
        if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
            throw new RuntimeException('Unable to create module scaffold archive.');
        }

        try {
            $files = new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
                \RecursiveIteratorIterator::SELF_FIRST
            );

            foreach ($files as $file) {
                $absolutePath = $file->getPathname();
                $relativePath = trim(str_replace($directory, '', $absolutePath), DIRECTORY_SEPARATOR);
                $relativePath = str_replace('\\', '/', $relativePath);

                if ($relativePath === '') {
                    continue;
                }

                if ($file->isDir()) {
                    $zip->addEmptyDir($relativePath);
                } else {
                    $zip->addFile($absolutePath, $relativePath);
                }
            }
        } finally {
            $zip->close();
        }
    }
}
