<?php

/**
 * @package Dotclear
 * @subpackage Core
 *
 * @copyright Olivier Meunier & Association Dotclear
 * @copyright AGPL-3.0
 */
declare(strict_types=1);

namespace Dotclear\Core;

use Dotclear\Database\Cursor;
use Dotclear\Database\MetaRecord;
use Dotclear\Database\Statement\DeleteStatement;
use Dotclear\Database\Statement\SelectStatement;
use Dotclear\Database\Statement\UpdateStatement;
use Dotclear\Exception\BadRequestException;
use Dotclear\Exception\ProcessException;
use Dotclear\Interface\Core\BlogWorkspaceInterface;
use Throwable;

/**
 * @brief   Blog workspace for settings handler.
 *
 * Handle id,version pairs through database.
 *
 * @since   2.28, container services have been added to constructor
 * @since   2.36, constructor arguments has been replaced by Core instance
 *
 * @phpstan-type TCoreBlogWorkspaceSettings array{
 *      ns:?string,
 *      value:mixed,
 *      type:string,
 *      label:string,
 *      global:bool
 * }
 */
class BlogWorkspace implements BlogWorkspaceInterface
{
    /**
     * Settings table name.
     */
    protected string $table;

    /**
     * Global settings.
     *
     * @var     array<string, TCoreBlogWorkspaceSettings>   $global_settings
     */
    protected array $global_settings = [];

    /**
     * Local settings.
     *
     * @var     array<string, TCoreBlogWorkspaceSettings>   $local_settings
     */
    protected array $local_settings = [];

    /**
     * Blog settings.
     *
     * @var     array<string, TCoreBlogWorkspaceSettings>   $settings
     */
    protected array $settings = [];

    /**
     * Constructs a new instance.
     *
     * @param   Core    $core   The core container
     */
    public function __construct(
        protected Core $core,
        protected ?string $blog_id = null,
        protected ?string $workspace = null,
        ?MetaRecord $rs = null
    ) {
        $this->table = $this->core->db()->con()->prefix() . self::NS_TABLE_NAME;

        if ($workspace !== null) {
            if (!preg_match(self::NS_NAME_SCHEMA, $workspace)) {
                throw new BadRequestException(sprintf(__('Invalid setting namespace: %s.'), $workspace));
            }

            $this->getSettings($rs);
        }
    }

    public function createFromBlog(?string $blog_id, string $workspace, ?MetaRecord $rs = null): BlogWorkspaceInterface
    {
        return new self($this->core, $blog_id, $workspace, $rs);
    }

    public function openBlogWorkspaceCursor(): Cursor
    {
        return $this->core->db()->con()->openCursor($this->table);
    }

    /**
     * Gets the settings.
     *
     * @param   MetaRecord  $rs     The recordset
     */
    private function getSettings(?MetaRecord $rs = null): void
    {
        if (!$rs instanceof MetaRecord) {
            $sql = new SelectStatement();
            $sql
                ->columns([
                    'blog_id',
                    'setting_id',
                    'setting_value',
                    'setting_type',
                    'setting_label',
                    'setting_ns',
                ])
                ->from($this->table)
                ->where($sql->orGroup([
                    'blog_id = ' . $sql->quote((string) $this->blog_id),
                    'blog_id IS NULL',
                ]))
                ->and('setting_ns = ' . $sql->quote((string) $this->workspace))
                ->order('setting_id DESC');

            try {
                $rs = $sql->select();
            } catch (Throwable) {
                throw new ProcessException(sprintf(__('Unable to retrieve settings: %s.'), $this->core->db()->con()->error()));
            }
        }

        if ($rs instanceof MetaRecord) {
            while ($rs->fetch()) {
                if ($rs->strField('setting_ns') !== $this->workspace) {
                    break;
                }

                $id    = trim($rs->strField('setting_id'));
                $value = $rs->field('setting_value');
                $type  = $rs->strField('setting_type');

                if ($type === self::NS_ARRAY) {
                    $value = is_string($value) ? @json_decode($value, true, 512, JSON_THROW_ON_ERROR) : [];
                } elseif ($type === self::NS_FLOAT || $type === self::NS_DOUBLE) {
                    $type = self::NS_FLOAT;
                } elseif ($type !== self::NS_BOOL && $type !== self::NS_INT) {
                    $type = self::NS_STRING;
                }

                settype($value, $type);

                $local = (bool) $rs->strField('blog_id', true);
                $label = $rs->strField('setting_label');

                if ($local) {
                    $this->local_settings[$id] = [
                        'ns'     => $this->workspace,
                        'value'  => $value,
                        'type'   => $type,
                        'label'  => $label,
                        'global' => false,
                    ];
                } else {
                    $this->global_settings[$id] = [
                        'ns'     => $this->workspace,
                        'value'  => $value,
                        'type'   => $type,
                        'label'  => $label,
                        'global' => true,
                    ];
                }
            }
        }

        // Blog settings (local) overwrite global ones
        $this->settings = [...$this->global_settings, ...$this->local_settings];
    }

    public function settingExists(string $name, bool $global = false): bool
    {
        $array = ($global ? 'global' : 'local') . '_settings';

        return isset($this->{$array}[$name]);
    }

    public function get($name)
    {
        if (isset($this->settings[$name]) && isset($this->settings[$name]['value'])) {
            return $this->settings[$name]['value'];
        }

        return null;
    }

    public function getStr(string $name, bool $null_allowed = true): ?string
    {
        return is_string($value = $this->get($name)) ? $value : ($null_allowed ? null : '');
    }

    public function getInt(string $name, bool $null_allowed = true): ?int
    {
        return is_numeric($value = $this->get($name)) ? (int) $value : ($null_allowed ? null : 0);
    }

    public function getBool(string $name, bool $null_allowed = true): ?bool
    {
        return is_scalar($value = $this->get($name)) ? (bool) $value : ($null_allowed ? null : false);
    }

    public function getFloat(string $name, bool $null_allowed = true): ?float
    {
        return is_numeric($value = $this->get($name)) ? (float) $value : ($null_allowed ? null : 0.0);
    }

    public function getGlobal($name)
    {
        if (isset($this->global_settings[$name]) && isset($this->global_settings[$name]['value'])) {
            return $this->global_settings[$name]['value'];
        }

        return null;
    }

    public function getLocal($name)
    {
        if (isset($this->local_settings[$name]) && isset($this->local_settings[$name]['value'])) {
            return $this->local_settings[$name]['value'];
        }

        return null;
    }

    public function __get($name)
    {
        return $this->get($name);
    }

    public function set($name, $value): void
    {
        if (isset($this->settings[$name])) {
            $this->settings[$name]['value'] = $value;
        }
    }

    public function __set($name, $value): void
    {
        $this->set($name, $value);
    }

    public function put(string $name, $value, ?string $type = null, ?string $label = null, bool $ignore_value = true, bool $global = false): void
    {
        if (!preg_match(self::NS_ID_SCHEMA, $name)) {
            throw new BadRequestException(sprintf(__('%s is not a valid setting id.'), $name));
        }

        # We don't want to change setting value
        if (!$ignore_value) {
            if (!$global && $this->settingExists($name, false)) {
                $value = $this->local_settings[$name]['value'];
            } elseif ($this->settingExists($name, true)) {
                $value = $this->global_settings[$name]['value'];
            }
        }

        # Setting type
        if ($type === self::NS_DOUBLE) {
            $type = self::NS_FLOAT;
        } elseif ($type === null) {
            if (!$global && $this->settingExists($name, false)) {
                $type = $this->local_settings[$name]['type'];
            } elseif ($this->settingExists($name, true)) {
                $type = $this->global_settings[$name]['type'];
            } else {
                // Try to detect type from given value
                $type = match (gettype($value)) {
                    'array'   => self::NS_ARRAY,
                    'boolean' => self::NS_BOOL,
                    'integer' => self::NS_INT,
                    'double'  => self::NS_FLOAT,
                    default   => self::NS_STRING,
                };
            }
        } elseif (!in_array($type, [self::NS_BOOL, self::NS_INT, self::NS_FLOAT, self::NS_ARRAY], true)) {
            $type = self::NS_STRING;
        }

        # We don't change label
        if (!$label) {
            if (!$global && $this->settingExists($name, false)) {
                $label = $this->local_settings[$name]['label'];
            } elseif ($this->settingExists($name, true)) {
                $label = $this->global_settings[$name]['label'];
            }
        }

        if ($type !== self::NS_ARRAY) {
            settype($value, $type);
            $stored_value = match (gettype($value)) {
                'boolean' => (string) (int) $value,     // Convert bool to int (1 or 0)
                'integer' => (string) $value,
                'double'  => (string) $value,
                default   => is_string($value) ? $value : '',
            };
        } else {
            $value        = json_encode($value, JSON_THROW_ON_ERROR);
            $stored_value = (string) $value;
        }

        $cur = $this->core->db()->con()->openCursor($this->table);

        $cur->setStrField('setting_value', $stored_value);
        $cur->setStrField('setting_type', $type);
        $cur->setStrField('setting_label', $label);

        // If it's a local setting and if a global setting exists, compare local value to global value
        if (!$global && $this->settingExists($name, true)) {
            $g    = $this->global_settings[$name];
            $same = ($g['ns'] === $this->workspace && $g['value'] === $value && $g['type'] === $type && $g['label'] === $label);

            // The local value is the same as global value, remove local setting if exists
            if ($same) {
                if ($this->settingExists($name, false)) {
                    $this->drop($name);
                }

                return;
            }
        }

        if ($this->settingExists($name, $global) && $this->workspace == $this->settings[$name]['ns']) {
            $sql = new UpdateStatement();

            if ($global) {
                $sql->where('blog_id IS NULL');
            } else {
                $sql->where('blog_id = ' . $sql->quote((string) $this->blog_id));
            }

            $sql
                ->and('setting_id = ' . $sql->quote($name))
                ->and('setting_ns = ' . $sql->quote((string) $this->workspace));

            $sql->update($cur);
        } else {
            $cur->setStrField('setting_id', $name);
            $cur->setStrField('blog_id', $global ? null : $this->blog_id);
            $cur->setStrField('setting_ns', $this->workspace);

            $cur->insert();
        }
    }

    public function rename(string $old_name, string $new_name): bool
    {
        if (!$this->workspace) {
            throw new BadRequestException(__('No namespace specified.'));
        }

        if (!array_key_exists($old_name, $this->settings) || array_key_exists($new_name, $this->settings)) {
            return false;
        }

        if (!preg_match(self::NS_ID_SCHEMA, $new_name)) {
            throw new BadRequestException(sprintf(__('%s is not a valid setting id.'), $new_name));
        }

        // Rename the setting in the settings array
        $this->settings[$new_name] = $this->settings[$old_name];
        unset($this->settings[$old_name]);

        if (isset($this->global_settings[$old_name])) {
            $this->global_settings[$new_name] = $this->global_settings[$old_name];
            unset($this->global_settings[$old_name]);
        }

        if (isset($this->local_settings[$old_name])) {
            $this->local_settings[$new_name] = $this->local_settings[$old_name];
            unset($this->local_settings[$old_name]);
        }

        // Rename the setting in the database
        $sql = new UpdateStatement();
        $sql
            ->ref($this->table)
            ->set('setting_id = ' . $sql->quote($new_name))
            ->where('setting_ns = ' . $sql->quote($this->workspace))
            ->and('setting_id = ' . $sql->quote($old_name));

        $sql->update();

        return true;
    }

    public function drop(string $name): void
    {
        if (!$this->workspace) {
            throw new BadRequestException(__('No namespace specified.'));
        }

        $sql = new DeleteStatement();
        $sql
            ->from($this->table);

        if ($this->blog_id === null) {
            $sql->where('blog_id IS NULL');
        } else {
            $sql->where('blog_id = ' . $sql->quote($this->blog_id));
        }

        $sql
            ->and('setting_id = ' . $sql->quote($name))
            ->and('setting_ns = ' . $sql->quote($this->workspace));

        $sql->delete();
    }

    public function dropEvery(string $name, bool $global = false): void
    {
        if (!$this->workspace) {
            throw new BadRequestException(__('No namespace specified.'));
        }

        $sql = new DeleteStatement();
        $sql
            ->from($this->table);

        if (!$global) {
            $sql->where('blog_id IS NOT NULL');
        }

        $sql
            ->and('setting_id = ' . $sql->quote($name))
            ->and('setting_ns = ' . $sql->quote($this->workspace));

        $sql->delete();
    }

    public function dropAll(bool $force_global = false): void
    {
        if (!$this->workspace) {
            throw new BadRequestException(__('No namespace specified.'));
        }

        $sql = new DeleteStatement();
        $sql
            ->from($this->table);

        if (($force_global) || ($this->blog_id === null)) {
            $sql->where('blog_id IS NULL');
            $global = true;
        } else {
            $sql->where('blog_id = ' . $sql->quote($this->blog_id));
            $global = false;
        }

        $sql->and('setting_ns = ' . $sql->quote($this->workspace));

        $sql->delete();

        $array          = ($global ? 'global' : 'local') . '_settings';
        $this->{$array} = [];

        // Blog settings (local) overwrite global ones
        $this->settings = [...$this->global_settings, ...$this->local_settings];
    }

    public function dumpWorkspace(): string
    {
        return (string) $this->workspace;
    }

    public function dumpSettings(): array
    {
        return $this->settings;
    }

    public function dumpLocalSettings(): array
    {
        return $this->local_settings;
    }

    public function dumpGlobalSettings(): array
    {
        return $this->global_settings;
    }

    public function dumpNamespace(): string
    {
        $this->core->deprecated()->set(self::class . '::dumpWorkspace()', '2.28');

        return (string) $this->workspace;
    }
}
