<?php
declare(strict_types=1);

/**
 * BEdita, API-first content management framework
 * Copyright 2021 ChannelWeb Srl, Chialab Srl
 *
 * This file is part of BEdita: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published
 * by the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details.
 */
namespace BEdita\Core\Command;

use BEdita\Core\Utility\ProjectModel;
use BEdita\Core\Utility\Resources;
use Cake\Cache\Cache;
use Cake\Command\Command;
use Cake\Console\Arguments;
use Cake\Console\ConsoleIo;
use Cake\Console\ConsoleOptionParser;
use Cake\Core\ConventionsTrait;
use Cake\Utility\Hash;

/**
 * Command to apply project model from input file.
 *
 * @since 4.5.0
 */
class ProjectModelCommand extends Command
{
    use ConventionsTrait;

    /**
     * Project model default file name
     *
     * @var string
     */
    public const PROJECT_MODEL_FILE = 'project_model.json';

    /**
     * @inheritDoc
     */
    protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
    {
        return $parser->addOption('file', [
                'help' => 'Path of JSON file containing project model to apply',
                'short' => 'f',
                'required' => false,
            ])
            ->addOption('cache-clear', [
                'help' => 'Perform cache clear after resources change',
                'short' => 'c',
                'boolean' => true,
                'required' => false,
                'default' => true,
            ])
            ->addOption('plugin', [
                'help' => 'Plugin to use for loading default `project_model.json` file',
                'short' => 'p',
                'required' => false,
            ])
            ->addOption('delete', [
                'help' => 'Delete resources not present in project model',
                'short' => 'd',
                'boolean' => true,
                'required' => false,
            ]);
    }

    /**
     * @inheritDoc
     */
    public function execute(Arguments $args, ConsoleIo $io): ?int
    {
        $file = $this->modelFileFromFolder();
        if (empty($file)) {
            $file = $this->modelFilePath($args);
        }
        if (!file_exists($file)) {
            $io->error(sprintf('File not found %s', $file));

            return self::CODE_ERROR;
        }

        $project = (array)json_decode(file_get_contents($file), true);
        if (empty($project)) {
            $io->error(sprintf('Bad file content in %s', $file));

            return self::CODE_ERROR;
        }
        // adjust project config content, if needed
        if (isset($project['config'])) {
            $project['config'] = $this->prepareContent($project['config']);
        }

        if ($args->getOption('cache-clear')) {
            Cache::clearAll();
            $io->success('Cache cleared');
        }

        $diff = ProjectModel::diff($project);
        if (!empty($diff['remove'])) {
            $io->warning('Items to remove: ' . json_encode($diff['remove']));
            if ($args->getOption('delete')) {
                $io->warning('Removing items');
                $keys = array_reverse(array_keys($diff['remove']));
                foreach ($keys as $key) {
                    $this->remove(
                        $key,
                        (array)Hash::extract($diff, sprintf('remove.%s', $key)),
                        $io
                    );
                }
            }
            unset($diff['remove']);
        }
        if (empty($diff)) {
            $io->success('Project model in sync, exiting.');

            return self::CODE_SUCCESS;
        }

        Resources::save($diff);
        $io->success('Project model updated');

        if ($args->getOption('cache-clear')) {
            Cache::clearAll();
            $io->success('Cache cleared');
        }

        return null;
    }

    /**
     * Set content to string if is an array, in model data.
     *
     * @param array $data Data to prepare.
     * @return array
     */
    protected function prepareContent(array $data): array
    {
        return array_map(
            function ($item) {
                if (is_array($item['content'])) {
                    $item['content'] = json_encode($item['content']);
                }

                return $item;
            },
            $data
        );
    }

    /**
     * Build model file from `project-model` folder, putting together all JSON files.
     * If the folder does not exist or is empty, returns null.
     *
     * @return string|null
     */
    protected function modelFileFromFolder(): ?string
    {
        if (!is_dir(CONFIG . DS . 'project-model')) {
            return null;
        }
        $files = glob(CONFIG . DS . 'project-model' . DS . '*.json');
        if (empty($files)) {
            return null;
        }
        $projectModelFile = null;
        $json = [];
        foreach ($files as $file) {
            $name = str_replace('.json', '', basename($file));
            $content = file_get_contents($file);
            if ($content) {
                $json[$name] = (array)json_decode($content, true);
            }
        }
        if (!empty($json)) {
            $projectModelFile = TMP . DS . 'project-model.json';
            file_put_contents($projectModelFile, json_encode($json, JSON_PRETTY_PRINT));
        }

        return $projectModelFile;
    }

    /**
     * Retrieve model file path using CLI options and defaults.
     *  - if `--file/-f` is used, this path is used
     *  - if `--plugin/-p` is used default model file is searched in a plugin
     *  - if no option is passed default path is used
     *
     * @param \Cake\Console\Arguments $args Console arguments
     * @return string
     */
    protected function modelFilePath(Arguments $args): string
    {
        $file = (string)$args->getOption('file');
        if (!empty($file)) {
            return $file;
        }
        $plugin = (string)$args->getOption('plugin');
        if (!empty($plugin)) {
            return $this->_pluginPath($plugin) . 'config' . DS . self::PROJECT_MODEL_FILE;
        }

        return CONFIG . self::PROJECT_MODEL_FILE;
    }

    /**
     * Remove items not found in project model but found in system.
     *
     * @param string $resourceType The resource type (can be 'categories', 'properties', etc.).
     * @param array $items The items to remove.
     * @param \Cake\Console\ConsoleIo $io Console IO.
     * @return void
     */
    protected function remove(string $resourceType, array $items, ConsoleIo $io): void
    {
        foreach ($items as $item) {
            $name = (string)Hash::get($item, 'name');
            $message = sprintf('Remove %s %s', $resourceType, $name);
            $fromModel = sprintf(' from model %s', (string)Hash::get($item, 'object'));
            $message = !empty($item['object']) ? $message . $fromModel : $message;
            $message .= '. Are you sure?';
            $choice = $io->askChoice($message, ['y', 'n'], 'n');
            if ($choice === 'y') {
                Resources::remove($resourceType, [$item]);
            }
        }
    }
}
