<?php

namespace Kirby\Cms;

use Exception;
use Kirby\Data\Data;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\F;
use Kirby\Form\Field;
use Kirby\Toolkit\A;
use Kirby\Toolkit\I18n;
use Throwable;

/**
 * The Blueprint class normalizes an array from a
 * blueprint file and converts sections, columns, fields
 * etc. into a correct tab layout.
 *
 * @package   Kirby Cms
 * @author    Bastian Allgeier <bastian@getkirby.com>
 * @link      https://getkirby.com
 * @copyright Bastian Allgeier
 * @license   https://getkirby.com/license
 */
class Blueprint
{
    public static $presets = [];
    public static $loaded = [];

    protected $fields = [];
    protected $model;
    protected $props;
    protected $sections = [];
    protected $tabs = [];

    /**
     * Magic getter/caller for any blueprint prop
     *
     * @param string $key
     * @param array|null $arguments
     * @return mixed
     */
    public function __call(string $key, array $arguments = null)
    {
        return $this->props[$key] ?? null;
    }

    /**
     * Creates a new blueprint object with the given props
     *
     * @param array $props
     * @throws \Kirby\Exception\InvalidArgumentException If the blueprint model is missing
     */
    public function __construct(array $props)
    {
        if (empty($props['model']) === true) {
            throw new InvalidArgumentException('A blueprint model is required');
        }

        if (is_a($props['model'], ModelWithContent::class) === false) {
            throw new InvalidArgumentException('Invalid blueprint model');
        }

        $this->model = $props['model'];

        // the model should not be included in the props array
        unset($props['model']);

        // extend the blueprint in general
        $props = $this->extend($props);

        // apply any blueprint preset
        $props = $this->preset($props);

        // normalize the name
        $props['name'] ??= 'default';

        // normalize and translate the title
        $props['title'] = $this->i18n($props['title'] ?? ucfirst($props['name']));

        // convert all shortcuts
        $props = $this->convertFieldsToSections('main', $props);
        $props = $this->convertSectionsToColumns('main', $props);
        $props = $this->convertColumnsToTabs('main', $props);

        // normalize all tabs
        $props['tabs'] = $this->normalizeTabs($props['tabs'] ?? []);

        $this->props = $props;
    }

    /**
     * Improved `var_dump` output
     *
     * @return array
     */
    public function __debugInfo(): array
    {
        return $this->props ?? [];
    }

    /**
     * Converts all column definitions, that
     * are not wrapped in a tab, into a generic tab
     *
     * @param string $tabName
     * @param array $props
     * @return array
     */
    protected function convertColumnsToTabs(string $tabName, array $props): array
    {
        if (isset($props['columns']) === false) {
            return $props;
        }

        // wrap everything in a main tab
        $props['tabs'] = [
            $tabName => [
                'columns' => $props['columns']
            ]
        ];

        unset($props['columns']);

        return $props;
    }

    /**
     * Converts all field definitions, that are not
     * wrapped in a fields section into a generic
     * fields section.
     *
     * @param string $tabName
     * @param array $props
     * @return array
     */
    protected function convertFieldsToSections(string $tabName, array $props): array
    {
        if (isset($props['fields']) === false) {
            return $props;
        }

        // wrap all fields in a section
        $props['sections'] = [
            $tabName . '-fields' => [
                'type'   => 'fields',
                'fields' => $props['fields']
            ]
        ];

        unset($props['fields']);

        return $props;
    }

    /**
     * Converts all sections that are not wrapped in
     * columns, into a single generic column.
     *
     * @param string $tabName
     * @param array $props
     * @return array
     */
    protected function convertSectionsToColumns(string $tabName, array $props): array
    {
        if (isset($props['sections']) === false) {
            return $props;
        }

        // wrap everything in one big column
        $props['columns'] = [
            [
                'width'    => '1/1',
                'sections' => $props['sections']
            ]
        ];

        unset($props['sections']);

        return $props;
    }

    /**
     * Extends the props with props from a given
     * mixin, when an extends key is set or the
     * props is just a string
     *
     * @param array|string $props
     * @return array
     */
    public static function extend($props): array
    {
        if (is_string($props) === true) {
            $props = [
                'extends' => $props
            ];
        }

        $extends = $props['extends'] ?? null;

        if ($extends === null) {
            return $props;
        }

        foreach (A::wrap($extends) as $extend) {
            try {
                $mixin = static::find($extend);
                $mixin = static::extend($mixin);
                $props = A::merge($mixin, $props, A::MERGE_REPLACE);
            } catch (Exception $e) {
                // keep the props unextended if the snippet wasn't found
            }
        }

        // remove the extends flag
        unset($props['extends']);

        return $props;
    }

    /**
     * Create a new blueprint for a model
     *
     * @param string $name
     * @param string|null $fallback
     * @param \Kirby\Cms\Model $model
     * @return static|null
     */
    public static function factory(string $name, string $fallback = null, Model $model)
    {
        try {
            $props = static::load($name);
        } catch (Exception $e) {
            $props = $fallback !== null ? static::load($fallback) : null;
        }

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

        // inject the parent model
        $props['model'] = $model;

        return new static($props);
    }

    /**
     * Returns a single field definition by name
     *
     * @param string $name
     * @return array|null
     */
    public function field(string $name): ?array
    {
        return $this->fields[$name] ?? null;
    }

    /**
     * Returns all field definitions
     *
     * @return array
     */
    public function fields(): array
    {
        return $this->fields;
    }

    /**
     * Find a blueprint by name
     *
     * @param string $name
     * @return array
     * @throws \Kirby\Exception\NotFoundException If the blueprint cannot be found
     */
    public static function find(string $name): array
    {
        if (isset(static::$loaded[$name]) === true) {
            return static::$loaded[$name];
        }

        $kirby = App::instance();
        $root  = $kirby->root('blueprints');
        $file  = $root . '/' . $name . '.yml';

        // first try to find a site blueprint,
        // then check in the plugin extensions
        if (F::exists($file, $root) !== true) {
            $file = $kirby->extension('blueprints', $name);
        }

        // callback option can be return array or blueprint file path
        if (is_callable($file) === true) {
            $file = $file($kirby);
        }

        // now ensure that we always return the data array
        if (is_string($file) === true && F::exists($file) === true) {
            return static::$loaded[$name] = Data::read($file);
        } elseif (is_array($file) === true) {
            return static::$loaded[$name] = $file;
        }

        // neither a valid file nor array data
        throw new NotFoundException([
            'key'  => 'blueprint.notFound',
            'data' => ['name' => $name]
        ]);
    }

    /**
     * Used to translate any label, heading, etc.
     *
     * @param mixed $value
     * @param mixed $fallback
     * @return mixed
     */
    protected function i18n($value, $fallback = null)
    {
        return I18n::translate($value, $fallback ?? $value);
    }

    /**
     * Checks if this is the default blueprint
     *
     * @return bool
     */
    public function isDefault(): bool
    {
        return $this->name() === 'default';
    }

    /**
     * Loads a blueprint from file or array
     *
     * @param string $name
     * @return array
     */
    public static function load(string $name): array
    {
        $props = static::find($name);

        $normalize = function ($props) use ($name) {
            // inject the filename as name if no name is set
            $props['name'] ??= $name;

            // normalize the title
            $title = $props['title'] ?? ucfirst($props['name']);

            // translate the title
            $props['title'] = I18n::translate($title, $title);

            return $props;
        };

        return $normalize($props);
    }

    /**
     * Returns the parent model
     *
     * @return \Kirby\Cms\Model
     */
    public function model()
    {
        return $this->model;
    }

    /**
     * Returns the blueprint name
     *
     * @return string
     */
    public function name(): string
    {
        return $this->props['name'];
    }

    /**
     * Normalizes all required props in a column setup
     *
     * @param string $tabName
     * @param array $columns
     * @return array
     */
    protected function normalizeColumns(string $tabName, array $columns): array
    {
        foreach ($columns as $columnKey => $columnProps) {
            // unset/remove column if its property is not array
            if (is_array($columnProps) === false) {
                unset($columns[$columnKey]);
                continue;
            }

            $columnProps = $this->convertFieldsToSections($tabName . '-col-' . $columnKey, $columnProps);

            // inject getting started info, if the sections are empty
            if (empty($columnProps['sections']) === true) {
                $columnProps['sections'] = [
                    $tabName . '-info-' . $columnKey => [
                        'headline' => 'Column (' . ($columnProps['width'] ?? '1/1') . ')',
                        'type'     => 'info',
                        'text'     => 'No sections yet'
                    ]
                ];
            }

            $columns[$columnKey] = array_merge($columnProps, [
                'width'    => $columnProps['width'] ?? '1/1',
                'sections' => $this->normalizeSections($tabName, $columnProps['sections'] ?? [])
            ]);
        }

        return $columns;
    }

    /**
     * @param array $items
     * @return string
     */
    public static function helpList(array $items): string
    {
        $md = [];

        foreach ($items as $item) {
            $md[] = '- *' . $item . '*';
        }

        return PHP_EOL . implode(PHP_EOL, $md);
    }

    /**
     * Normalize field props for a single field
     *
     * @param array|string $props
     * @return array
     * @throws \Kirby\Exception\InvalidArgumentException If the filed name is missing or the field type is invalid
     */
    public static function fieldProps($props): array
    {
        $props = static::extend($props);

        if (isset($props['name']) === false) {
            throw new InvalidArgumentException('The field name is missing');
        }

        $name = $props['name'];
        $type = $props['type'] ?? $name;

        if ($type !== 'group' && isset(Field::$types[$type]) === false) {
            throw new InvalidArgumentException('Invalid field type ("' . $type . '")');
        }

        // support for nested fields
        if (isset($props['fields']) === true) {
            $props['fields'] = static::fieldsProps($props['fields']);
        }

        // groups don't need all the crap
        if ($type === 'group') {
            return [
                'fields' => $props['fields'],
                'name'   => $name,
                'type'   => $type,
            ];
        }

        // add some useful defaults
        return array_merge($props, [
            'label' => $props['label'] ?? ucfirst($name),
            'name'  => $name,
            'type'  => $type,
            'width' => $props['width'] ?? '1/1',
        ]);
    }

    /**
     * Creates an error field with the given error message
     *
     * @param string $name
     * @param string $message
     * @return array
     */
    public static function fieldError(string $name, string $message): array
    {
        return [
            'label' => 'Error',
            'name'  => $name,
            'text'  => strip_tags($message),
            'theme' => 'negative',
            'type'  => 'info',
        ];
    }

    /**
     * Normalizes all fields and adds automatic labels,
     * types and widths.
     *
     * @param array $fields
     * @return array
     */
    public static function fieldsProps($fields): array
    {
        if (is_array($fields) === false) {
            $fields = [];
        }

        foreach ($fields as $fieldName => $fieldProps) {

            // extend field from string
            if (is_string($fieldProps) === true) {
                $fieldProps = [
                    'extends' => $fieldProps,
                    'name'    => $fieldName
                ];
            }

            // use the name as type definition
            if ($fieldProps === true) {
                $fieldProps = [];
            }

            // unset / remove field if its property is false
            if ($fieldProps === false) {
                unset($fields[$fieldName]);
                continue;
            }

            // inject the name
            $fieldProps['name'] = $fieldName;

            // create all props
            try {
                $fieldProps = static::fieldProps($fieldProps);
            } catch (Throwable $e) {
                $fieldProps = static::fieldError($fieldName, $e->getMessage());
            }

            // resolve field groups
            if ($fieldProps['type'] === 'group') {
                if (empty($fieldProps['fields']) === false && is_array($fieldProps['fields']) === true) {
                    $index  = array_search($fieldName, array_keys($fields));
                    $before = array_slice($fields, 0, $index);
                    $after  = array_slice($fields, $index + 1);
                    $fields = array_merge($before, $fieldProps['fields'] ?? [], $after);
                } else {
                    unset($fields[$fieldName]);
                }
            } else {
                $fields[$fieldName] = $fieldProps;
            }
        }

        return $fields;
    }

    /**
     * Normalizes blueprint options. This must be used in the
     * constructor of an extended class, if you want to make use of it.
     *
     * @param array|true|false|null|string $options
     * @param array $defaults
     * @param array $aliases
     * @return array
     */
    protected function normalizeOptions($options, array $defaults, array $aliases = []): array
    {
        // return defaults when options are not defined or set to true
        if ($options === true) {
            return $defaults;
        }

        // set all options to false
        if ($options === false) {
            return array_map(fn () => false, $defaults);
        }

        // extend options if possible
        $options = $this->extend($options);

        foreach ($options as $key => $value) {
            $alias = $aliases[$key] ?? null;

            if ($alias !== null) {
                $options[$alias] ??= $value;
                unset($options[$key]);
            }
        }

        return array_merge($defaults, $options);
    }

    /**
     * Normalizes all required keys in sections
     *
     * @param string $tabName
     * @param array $sections
     * @return array
     */
    protected function normalizeSections(string $tabName, array $sections): array
    {
        foreach ($sections as $sectionName => $sectionProps) {

            // unset / remove section if its property is false
            if ($sectionProps === false) {
                unset($sections[$sectionName]);
                continue;
            }

            // fallback to default props when true is passed
            if ($sectionProps === true) {
                $sectionProps = [];
            }

            // inject all section extensions
            $sectionProps = $this->extend($sectionProps);

            $sections[$sectionName] = $sectionProps = array_merge($sectionProps, [
                'name' => $sectionName,
                'type' => $type = $sectionProps['type'] ?? $sectionName
            ]);

            if (empty($type) === true || is_string($type) === false) {
                $sections[$sectionName] = [
                    'name' => $sectionName,
                    'headline' => 'Invalid section type for section "' . $sectionName . '"',
                    'type' => 'info',
                    'text' => 'The following section types are available: ' . $this->helpList(array_keys(Section::$types))
                ];
            } elseif (isset(Section::$types[$type]) === false) {
                $sections[$sectionName] = [
                    'name' => $sectionName,
                    'headline' => 'Invalid section type ("' . $type . '")',
                    'type' => 'info',
                    'text' => 'The following section types are available: ' . $this->helpList(array_keys(Section::$types))
                ];
            }

            if ($sectionProps['type'] === 'fields') {
                $fields = Blueprint::fieldsProps($sectionProps['fields'] ?? []);

                // inject guide fields guide
                if (empty($fields) === true) {
                    $fields = [
                        $tabName . '-info' => [
                            'label' => 'Fields',
                            'text'  => 'No fields yet',
                            'type'  => 'info'
                        ]
                    ];
                } else {
                    foreach ($fields as $fieldName => $fieldProps) {
                        if (isset($this->fields[$fieldName]) === true) {
                            $this->fields[$fieldName] = $fields[$fieldName] = [
                                'type'  => 'info',
                                'label' => $fieldProps['label'] ?? 'Error',
                                'text'  => 'The field name <strong>"' . $fieldName . '"</strong> already exists in your blueprint.',
                                'theme' => 'negative'
                            ];
                        } else {
                            $this->fields[$fieldName] = $fieldProps;
                        }
                    }
                }

                $sections[$sectionName]['fields'] = $fields;
            }
        }

        // store all normalized sections
        $this->sections = array_merge($this->sections, $sections);

        return $sections;
    }

    /**
     * Normalizes all required keys in tabs
     *
     * @param array $tabs
     * @return array
     */
    protected function normalizeTabs($tabs): array
    {
        if (is_array($tabs) === false) {
            $tabs = [];
        }

        foreach ($tabs as $tabName => $tabProps) {

            // unset / remove tab if its property is false
            if ($tabProps === false) {
                unset($tabs[$tabName]);
                continue;
            }

            // inject all tab extensions
            $tabProps = $this->extend($tabProps);

            // inject a preset if available
            $tabProps = $this->preset($tabProps);

            $tabProps = $this->convertFieldsToSections($tabName, $tabProps);
            $tabProps = $this->convertSectionsToColumns($tabName, $tabProps);

            $tabs[$tabName] = array_merge($tabProps, [
                'columns' => $this->normalizeColumns($tabName, $tabProps['columns'] ?? []),
                'icon'    => $tabProps['icon']  ?? null,
                'label'   => $this->i18n($tabProps['label'] ?? ucfirst($tabName)),
                'link'    => $this->model->panel()->url(true) . '/?tab=' . $tabName,
                'name'    => $tabName,
            ]);
        }

        return $this->tabs = $tabs;
    }

    /**
     * Injects a blueprint preset
     *
     * @param array $props
     * @return array
     */
    protected function preset(array $props): array
    {
        if (isset($props['preset']) === false) {
            return $props;
        }

        if (isset(static::$presets[$props['preset']]) === false) {
            return $props;
        }

        $preset = static::$presets[$props['preset']];

        if (is_string($preset) === true) {
            $preset = require $preset;
        }

        return $preset($props);
    }

    /**
     * Returns a single section by name
     *
     * @param string $name
     * @return \Kirby\Cms\Section|null
     */
    public function section(string $name)
    {
        if (empty($this->sections[$name]) === true) {
            return null;
        }

        // get all props
        $props = $this->sections[$name];

        // inject the blueprint model
        $props['model'] = $this->model();

        // create a new section object
        return new Section($props['type'], $props);
    }

    /**
     * Returns all sections
     *
     * @return array
     */
    public function sections(): array
    {
        return A::map(
            $this->sections,
            fn ($section) => $this->section($section['name'])
        );
    }

    /**
     * Returns a single tab by name
     *
     * @param string|null $name
     * @return array|null
     */
    public function tab(?string $name = null): ?array
    {
        if ($name === null) {
            return A::first($this->tabs);
        }

        return $this->tabs[$name] ?? null;
    }

    /**
     * Returns all tabs
     *
     * @return array
     */
    public function tabs(): array
    {
        return array_values($this->tabs);
    }

    /**
     * Returns the blueprint title
     *
     * @return string
     */
    public function title(): string
    {
        return $this->props['title'];
    }

    /**
     * Converts the blueprint object to a plain array
     *
     * @return array
     */
    public function toArray(): array
    {
        return $this->props;
    }
}
