<?php
/**
 * @brief Dotclear metadata class.
 *
 * Dotclear metadata class instance is provided by dcCore $meta property.
 *
 * @package Dotclear
 * @subpackage Core
 *
 * @copyright Olivier Meunier & Association Dotclear
 * @copyright GPL-2.0-only
 */
if (!defined('DC_RC_PATH')) {
    return;
}

class dcMeta
{
    private $core;  ///< <b>dcCore</b> dcCore instance
    private $con;   ///< <b>connection</b>    Database connection object
    private $table; ///< <b>string</b> Media table name

    /**
     * Constructs a new instance.
     *
     * @param      dcCore  $core   The core
     */
    public function __construct(dcCore $core)
    {
        $this->core  = &$core;
        $this->con   = &$this->core->con;
        $this->table = $this->core->prefix . 'meta';
    }

    /**
     * Splits up comma-separated values into an array of
     * unique, URL-proof metadata values.
     *
     * @param      string  $str    Comma-separated metadata
     *
     * @return     array  The array of sanitized metadata
     */
    public function splitMetaValues($str)
    {
        $res = [];
        foreach (explode(',', $str) as $i => $tag) {
            $tag = trim((string) $tag);
            $tag = self::sanitizeMetaID($tag);

            if ($tag != false) {
                $res[$i] = $tag;
            }
        }

        return array_unique($res);
    }

    /**
     * Make a metadata ID URL-proof.
     *
     * @param      string  $str    The metadata ID
     *
     * @return     string
     */
    public static function sanitizeMetaID($str)
    {
        return text::tidyURL($str, false, true);
    }

    /**
     * Converts serialized metadata (for instance in dc_post post_meta)
     * into a meta array.
     *
     * @param      string  $str    The serialized metadata
     *
     * @return     array   The meta array.
     */
    public function getMetaArray($str)
    {
        $meta = @unserialize((string) $str);

        if (!is_array($meta)) {
            return [];
        }

        return $meta;
    }

    /**
     * Converts serialized metadata (for instance in dc_post post_meta)
     * into a comma-separated meta list for a given type.
     *
     * @param      string  $str    The serialized metadata
     * @param      string  $type   The meta type to retrieve metaIDs from
     *
     * @return     string  The comma-separated list of meta.
     */
    public function getMetaStr($str, $type)
    {
        $meta = $this->getMetaArray($str);

        if (!isset($meta[$type])) {
            return '';
        }

        return implode(', ', $meta[$type]);
    }

    /**
     * Converts serialized metadata (for instance in dc_post post_meta)
     * into a "fetchable" metadata record.
     *
     * @param      string  $str    The serialized metadata
     * @param      string  $type   The meta type to retrieve metaIDs from
     *
     * @return     staticRecord  The meta recordset.
     */
    public function getMetaRecordset($str, $type)
    {
        $meta = $this->getMetaArray($str);
        $data = [];

        if (isset($meta[$type])) {
            foreach ($meta[$type] as $v) {
                $data[] = [
                    'meta_id'       => $v,
                    'meta_type'     => $type,
                    'meta_id_lower' => mb_strtolower($v),
                    'count'         => 0,
                    'percent'       => 0,
                    'roundpercent'  => 0,
                ];
            }
        }

        return staticRecord::newFromArray($data);
    }

    /**
     * Checks whether the current user is allowed to change post meta
     * An exception is thrown if user is not allowed.
     *
     * @param      mixed     $post_id  The post identifier
     *
     * @throws     Exception
     */
    private function checkPermissionsOnPost($post_id)
    {
        $post_id = (int) $post_id;

        if (!$this->core->auth->check('usage,contentadmin', $this->core->blog->id)) {
            throw new Exception(__('You are not allowed to change this entry status'));
        }

        #�If user can only publish, we need to check the post's owner
        if (!$this->core->auth->check('contentadmin', $this->core->blog->id)) {
            $sql = new dcSelectStatement($this->core, 'dcMetaCheckPermissionsOnPost');
            $sql
                ->from($this->table)
                ->column('post_id')
                ->where('post_id = ' . $post_id)
                ->and('user_id = ' . $sql->quote($this->core->auth->userID(), true));

            $rs = $sql->select();

            if ($rs->isEmpty()) {
                throw new Exception(__('You are not allowed to change this entry status'));
            }
        }
    }

    /**
     * Updates serialized post_meta information with dc_meta table information.
     *
     * @param      mixed  $post_id  The post identifier
     */
    private function updatePostMeta($post_id)
    {
        $post_id = (int) $post_id;

        $sql = new dcSelectStatement($this->core, 'dcMetaUpdatePostMeta');
        $sql
            ->from($this->table)
            ->columns([
                'meta_id',
                'meta_type',
            ])
            ->where('post_id = ' . $post_id);

        $rs = $sql->select();

        $meta = [];
        while ($rs->fetch()) {
            $meta[$rs->meta_type][] = $rs->meta_id;
        }

        $post_meta = serialize($meta);

        $cur            = $this->con->openCursor($this->core->prefix . 'post');
        $cur->post_meta = $post_meta;

        $sql = new dcUpdateStatement($this->core, 'dcMetaUpdatePostMeta');
        $sql->where('post_id = ' . $post_id);

        $sql->update($cur);

        $this->core->blog->triggerBlog();
    }

    /**
     * Retrieves posts corresponding to given meta criteria.
     * <b>$params</b> is an array taking the following optional parameters:
     * - meta_id : get posts having meta id
     * - meta_type : get posts having meta type
     *
     * @param      array                    $params      The parameters
     * @param      bool                     $count_only  Only count results
     * @param      dcSelectStatement|null   $sql         Optional dcSqlStatement instance
     *
     * @return     mixed   The resulting posts record.
     */
    public function getPostsByMeta($params = [], $count_only = false, ?dcSelectStatement $sql = null)
    {
        if (!isset($params['meta_id'])) {
            return;
        }

        if (!$sql) {
            $sql = new dcSelectStatement($this->core, 'dcMetaGetPostsByMeta');
        }

        $sql
            ->from($this->table . ' META')
            ->and('META.post_id = P.post_id')
            ->and('META.meta_id = ' . $sql->quote($params['meta_id'], true));

//        $params['from'] = $this->table . ' META ';
//        $params['sql']  = 'AND META.post_id = P.post_id ';
//        $params['sql'] .= "AND META.meta_id = '" . $this->con->escape($params['meta_id']) . "' ";

        if (!empty($params['meta_type'])) {
            $sql->and('META.meta_type = ' . $sql->quote($params['meta_type'], true));

//            $params['sql'] .= "AND META.meta_type = '" . $this->con->escape($params['meta_type']) . "' ";
            unset($params['meta_type']);
        }

        unset($params['meta_id']);

        return $this->core->blog->getPosts($params, $count_only, $sql);
    }

    /**
     * Retrieves comments corresponding to given meta criteria.
     * <b>$params</b> is an array taking the following optional parameters:
     * - meta_id : get posts having meta id
     * - meta_type : get posts having meta type
     *
     * @param      array                    $params      The parameters
     * @param      bool                     $count_only  Only count results
     * @param      dcSelectStatement|null   $sql         Optional dcSqlStatement instance
     *
     * @return     mixed   The resulting comments record.
     */
    public function getCommentsByMeta($params = [], $count_only = false, ?dcSelectStatement $sql = null)
    {
        if (!isset($params['meta_id'])) {
            return;
        }

        if (!$sql) {
            $sql = new dcSelectStatement($this->core, 'dcMetaGetPostsByMeta');
        }

        $sql
            ->from($this->table . ' META')
            ->and('META.post_id = P.post_id')
            ->and('META.meta_id = ' . $sql->quote($params['meta_id'], true));

//        $params['from'] = ', ' . $this->table . ' META ';
//        $params['sql']  = 'AND META.post_id = P.post_id ';
//        $params['sql'] .= "AND META.meta_id = '" . $this->con->escape($params['meta_id']) . "' ";

        if (!empty($params['meta_type'])) {
            $sql->and('META.meta_type = ' . $sql->quote($params['meta_type'], true));

//            $params['sql'] .= "AND META.meta_type = '" . $this->con->escape($params['meta_type']) . "' ";
            unset($params['meta_type']);
        }

        return $this->core->blog->getComments($params, $count_only, $sql);
    }

    /**
     * Generic-purpose metadata retrieval : gets metadatas according to given
     * criteria. <b>$params</b> is an array taking the following
     * optionnal parameters:
     *
     * - type: get metas having the given type
     * - meta_id: if not null, get metas having the given id
     * - post_id: get metas for the given post id
     * - limit: number of max fetched metas
     * - order: results order (default : posts count DESC)
     *
     * @param      array                    $params      The parameters
     * @param      bool                     $count_only  Only counts results
     * @param      dcSelectStatement|null   $sql         Optional dcSqlStatement instance
     *
     * @return     record  The metadata.
     */
    public function getMetadata($params = [], $count_only = false, ?dcSelectStatement $sql = null)
    {
        if (!$sql) {
            $sql = new dcSelectStatement($this->core, 'dcMetaGetMetadata');
        }

        if ($count_only) {
            $sql->column($sql->count($sql->unique('M.meta_id')));
        } else {
            $sql->columns([
                'M.meta_id',
                'M.meta_type',
                $sql->count('M.post_id', 'count'),
                $sql->max('P.post_dt', 'latest'),
                $sql->min('P.post_dt', 'oldest'),
            ]);
        }

        $sql
            ->from($this->table . ' M')
            ->join(
                (new dcJoinStatement($this->core, 'dcMetaGetMetadata'))
                ->type('LEFT')
                ->from($sql->core->prefix . 'post P')
                ->on('M.post_id = P.post_id')
                ->statement()
            )
            ->where('P.blog_id = ' . $sql->quote($this->core->blog->id, true));

        if (isset($params['meta_type'])) {
            $sql->and('meta_type = ' . $sql->quote($params['meta_type'], true));
        }

        if (isset($params['meta_id'])) {
            $sql->and('meta_id = ' . $sql->quote($params['meta_id'], true));
        }

        if (isset($params['post_id'])) {
            $sql->and('P.post_id' . $sql->in($params['post_id']));
        }

        if (!$this->core->auth->check('contentadmin', $this->core->blog->id)) {
            $user_id = $this->core->auth->userID();

            $and = ['post_status = 1'];
            if ($this->core->blog->without_password) {
                $and[] = 'post_password IS NULL';
            }

            $or = [$sql->andGroup($and)];
            if ($user_id) {
                $or[] = 'P.user_id = ' . $sql->quote($user_id, true);
            }
            $sql->and($sql->orGroup($or));
        }

        if (!$count_only) {
            if (!isset($params['order'])) {
                $params['order'] = 'count DESC';
            }

            $sql
                ->group([
                    'meta_id',
                    'meta_type',
                    'P.blog_id',
                ])
                ->order($params['order']);

            if (isset($params['limit'])) {
                $sql->limit($params['limit']);
            }
        }

        $rs = $sql->select();

        return $rs;
    }

    /**
     * Computes statistics from a metadata recordset.
     * Each record gets enriched with lowercase name, percent and roundpercent columns
     *
     * @param      record  $rs     The metadata recordset
     *
     * @return     staticRecord  The meta statistics.
     */
    public function computeMetaStats($rs)
    {
        $rs_static = $rs->toStatic();

        $max = [];
        while ($rs_static->fetch()) {
            $type = $rs_static->meta_type;
            if (!isset($max[$type])) {
                $max[$type] = $rs_static->count;
            } else {
                if ($rs_static->count > $max[$type]) {
                    $max[$type] = $rs_static->count;
                }
            }
        }

        $rs_static->moveStart();
        while ($rs_static->fetch()) {   // @phpstan-ignore-line
            $rs_static->set('meta_id_lower', dcUtils::removeDiacritics(mb_strtolower($rs_static->meta_id)));

            $percent = ((int) $rs_static->count) * 100 / $max[$rs_static->meta_type];

            $rs_static->set('percent', (int) round($percent));
            $rs_static->set('roundpercent', round($percent / 10) * 10);
        }

        return $rs_static;
    }

    /**
     * Adds a metadata to a post.
     *
     * @param      mixed   $post_id  The post identifier
     * @param      mixed   $type     The type
     * @param      mixed   $value    The value
     */
    public function setPostMeta($post_id, $type, $value)
    {
        $this->checkPermissionsOnPost($post_id);

        $value = trim((string) $value);
        if ($value === '') {
            return;
        }

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

        $cur->post_id   = (int) $post_id;
        $cur->meta_id   = (string) $value;
        $cur->meta_type = (string) $type;

        $cur->insert();
        $this->updatePostMeta((int) $post_id);
    }

    /**
     * Removes metadata from a post.
     *
     * @param      mixed   $post_id  The post identifier
     * @param      mixed   $type     The meta type (if null, delete all types)
     * @param      mixed   $meta_id  The meta identifier (if null, delete all values)
     */
    public function delPostMeta($post_id, $type = null, $meta_id = null)
    {
        $post_id = (int) $post_id;

        $this->checkPermissionsOnPost($post_id);

        $sql = new dcDeleteStatement($this->core, 'dcMetaDelPostMeta');
        $sql
            ->from($this->table)
            ->where('post_id = ' . $post_id);

        if ($type !== null) {
            $sql->and('meta_type = ' . $sql->quote($type, true));
        }

        if ($meta_id !== null) {
            $sql->and('meta_id = ' . $sql->quote($meta_id, true));
        }

        $sql->delete();

        $this->updatePostMeta((int) $post_id);
    }

    /**
     * Mass updates metadata for a given post_type.
     *
     * @param      string  $meta_id      The old meta value
     * @param      string  $new_meta_id  The new meta value
     * @param      mixed   $type         The type (if null, select all types)
     * @param      mixed   $post_type    The post type (if null, select all types)
     *
     * @return     bool   true if at least 1 post has been impacted
     */
    public function updateMeta($meta_id, $new_meta_id, $type = null, $post_type = null)
    {
        $new_meta_id = self::sanitizeMetaID($new_meta_id);

        if ($new_meta_id == $meta_id) {
            return true;
        }

        $sql = new dcSelectStatement($this->core, 'dcMetaUpdateMeta');
        $sql
            ->from([
                $this->table . ' M',
                $sql->core->prefix . 'post P',
            ])
            ->column('M.post_id')
            ->where('P.post_id = M.post_id')
            ->and('P.blog_id = ' . $sql->quote($this->core->blog->id, true));

        if (!$this->core->auth->check('contentadmin', $this->core->blog->id)) {
            $sql->and('P.user_id = ' . $sql->quote($this->core->auth->userID(), true));
        }
        if ($post_type !== null) {
            $sql->and('P.post_type = ' . $sql->quote($post_type, true));
        }

        if ($type !== null) {
            $sql->and('meta_type = ' . $sql->quote($type, true));
        }

        $to_update = $to_remove = [];

        // Clone $sql object in order to do the same select query but with another meta_id
        $sqlNew = clone $sql;

        $sql->and('meta_id = ' . $sql->quote($meta_id, true));

        $rs = $sql->select();

        while ($rs->fetch()) {
            $to_update[] = $rs->post_id;
        }

        if (empty($to_update)) {
            return false;
        }

        $sqlNew->and('meta_id = ' . $sqlNew->quote($new_meta_id, true));

        $rs = $sqlNew->select();

        while ($rs->fetch()) {
            if (in_array($rs->post_id, $to_update)) {
                $to_remove[] = $rs->post_id;
                unset($to_update[array_search($rs->post_id, $to_update)]);
            }
        }

        # Delete duplicate meta
        if (!empty($to_remove)) {
            $sqlDel = new dcDeleteStatement($this->core, 'dcMetaUpdateMeta');
            $sqlDel
                ->from($this->table)
                ->where('post_id' . $sqlDel->in($to_remove, 'int'))      // Note: will cast all values to integer
                ->and('meta_id = ' . $sqlDel->quote($meta_id, true));

            if ($type !== null) {
                $sqlDel->and('meta_type = ' . $sqlDel->quote($type, true));
            }

            $sqlDel->delete();

            foreach ($to_remove as $post_id) {
                $this->updatePostMeta($post_id);
            }
        }

        # Update meta
        if (!empty($to_update)) {
            $sqlUpd = new dcUpdateStatement($this->core, 'dcMetaUpdateMeta');
            $sqlUpd
                ->from($this->table)
                ->set('meta_id = ' . $sqlUpd->quote($new_meta_id, true))
                ->where('post_id' . $sqlUpd->in($to_update, 'int'))     // Note: will cast all values to integer
                ->and('meta_id = ' . $sqlUpd->quote($meta_id, true));

            if ($type !== null) {
                $sqlUpd->and('meta_type = ' . $sqlUpd->quote($type, true));
            }

            $sqlUpd->update();

            foreach ($to_update as $post_id) {
                $this->updatePostMeta($post_id);
            }
        }

        return true;
    }

    /**
     * Mass delete metadata for a given post_type.
     *
     * @param      string  $meta_id    The meta identifier
     * @param      mixed   $type       The meta type (if null, select all types)
     * @param      mixed   $post_type  The post type (if null, select all types)
     *
     * @return     array   The list of impacted post_ids
     */
    public function delMeta($meta_id, $type = null, $post_type = null)
    {
        $sql = new dcSelectStatement($this->core, 'dcMetaDelMeta');
        $sql
            ->column('M.post_id')
            ->from([
                $this->table . ' M',
                $sql->core->prefix . 'post P',
            ])
            ->where('P.post_id = M.post_id')
            ->and('P.blog_id = ' . $sql->quote($this->core->blog->id, true))
            ->and('meta_id = ' . $sql->quote($meta_id, true));

        if ($type !== null) {
            $sql->and('meta_type = ' . $sql->quote($type, true));
        }

        if ($post_type !== null) {
            $sql->and('P.post_type = ' . $sql->quote($post_type, true));
        }

        $rs = $sql->select();

        if ($rs->isEmpty()) {
            return [];
        }

        $ids = [];
        while ($rs->fetch()) {
            $ids[] = $rs->post_id;
        }

        $sql = new dcDeleteStatement($this->core, 'dcMetaDelMeta');
        $sql
            ->from($this->table)
            ->where('post_id' . $sql->in($ids, 'int'))
            ->and('meta_id = ' . $sql->quote($meta_id, true));

        if ($type !== null) {
            $sql->and('meta_type = ' . $sql->quote($type, true));
        }

        $sql->delete();

        foreach ($ids as $post_id) {
            $this->updatePostMeta($post_id);
        }

        return $ids;
    }
}
