<?php

namespace GeminiLabs\SiteReviews\Database;

use GeminiLabs\SiteReviews\Helper;
use GeminiLabs\SiteReviews\Helpers\Arr;
use GeminiLabs\SiteReviews\Helpers\Str;

trait Sql
{
    /** @var array */
    public $args;

    public \wpdb $db;

    protected array $joinStatements = [
        'assigned_posts' => "INNER JOIN table|assigned_posts AS apt ON (apt.rating_id = r.ID)",
        'assigned_terms' => "INNER JOIN table|assigned_terms AS att ON (att.rating_id = r.ID)",
        'assigned_users' => "INNER JOIN table|assigned_users AS aut ON (aut.rating_id = r.ID)",
        'postmeta' => "INNER JOIN table|postmeta AS pm ON (pm.post_id = r.review_id)",
        'posts' => "INNER JOIN table|posts AS p ON (p.ID = r.review_id)",
    ];

    public function clauses(string $clause, array $values = []): array
    {
        $prefix = Str::restrictTo('and,join', $clause);
        foreach ($this->args as $key => $value) {
            $method = Helper::buildMethodName('clause', $prefix, $key);
            if (!method_exists($this, $method) || Helper::isEmpty($value)) {
                continue;
            }
            if ($statement = call_user_func([$this, $method])) {
                $values[$key] = $statement;
            }
        }
        return $values;
    }

    public function escFieldsForInsert(array $fields): string
    {
        return sprintf('(`%s`)', implode('`,`', $fields));
    }

    public function escValuesForInsert(array $values): string
    {
        $values = array_values(
            array_map('\GeminiLabs\SiteReviews\Helpers\Cast::toString', array_map('esc_sql', $values))
        );
        return sprintf("('%s')", implode("','", $values));
    }

    /**
     * This method allows the following SQL syntax:
     *  - ALTER TABLE table|<unprefixed_table_name>
     *  - FROM table|<unprefixed_table_name>
     *  - JOIN table|<unprefixed_table_name>
     *  - TRUNCATE TABLE table|<unprefixed_table_name>
     *  - UPDATE table|<unprefixed_table_name>
     *
     * @param string|int ...$args Additional parameters will be passed to $wpdb->prepare()
     */
    public function sql(string $statement, ...$args): string
    {
        $handle = $this->sqlHandle();
        $statement = preg_replace('/ {12}/', '', $statement);
        $statement = glsr()->filterString("database/sql/{$handle}", $statement);
        $statement = preg_replace_callback('/(ALTER TABLE|FROM|JOIN|TRUNCATE TABLE|UPDATE)(\s+)(table\|)([^\s]+)/',
            fn ($m) => $m[1].$m[2].glsr(Tables::class)->table($m[4]),
            $statement
        );
        if (!empty($args)) {
            $statement = $this->db->prepare($statement, ...$args);
        }
        glsr()->action("database/sql/{$handle}", $statement);
        glsr()->action('database/sql', $statement, $handle);
        return $statement;
    }

    public function sqlJoin(array $additional = []): string
    {
        $joins = $this->clauses('join');
        foreach ($additional as $join) {
            array_unshift($joins, ($this->joinStatements[$join] ?? ''));
        }
        $joins = glsr()->filterArrayUnique('query/sql/join', $joins, $this->sqlHandle(), $this);
        return implode(' ', $joins);
    }

    public function sqlLimit(): string
    {
        $limit = Helper::ifTrue($this->args['per_page'] > 0,
            $this->db->prepare('LIMIT %d', $this->args['per_page'])
        );
        return glsr()->filterString('query/sql/limit', $limit, $this->sqlHandle(), $this);
    }

    public function sqlOffset(): string
    {
        if ($this->args['per_page'] < 1) {
            $offsetBy = $this->args['offset'];
        } else {
            $page = $this->args['page'] - 1;
            $offsetBy = ($page * $this->args['per_page']) + $this->args['offset'];
        }
        $offset = Helper::ifTrue($offsetBy > 0,
            $this->db->prepare('OFFSET %d', $offsetBy)
        );
        return glsr()->filterString('query/sql/offset', $offset, $this->sqlHandle(), $this);
    }

    public function sqlOrderBy(): string
    {
        $values = [
            // An unseeded RAND() re-randomizes on every request, so
            // paginated random results overlap and skip across pages.
            // Seeding per hour keeps the ordering stable within the
            // hour (page N always agrees with page 1) while still
            // rotating the "random" order over time.
            'random' => $this->db->prepare('RAND(%d)', (int) floor(time() / HOUR_IN_SECONDS)),
        ];
        $order = $this->args['order'];
        $orderby = $this->args['orderby'];
        $orderedby = [];
        if (Str::startsWith($orderby, ['p.', 'r.'])) {
            $orderedby[] = "r.is_pinned {$order}";
            $orderedby[] = "{$orderby} {$order}";
        } elseif (array_key_exists($orderby, $values)) {
            $orderedby[] = $values[$orderby];
        }
        $orderedby = glsr()->filterArrayUnique('query/sql/order-by', $orderedby, $this->sqlHandle(), $this);
        if (empty($orderedby)) {
            return '';
        }
        return 'ORDER BY '.implode(', ', $orderedby);
    }

    public function sqlWhere(): string
    {
        $and = $this->clauses('and');
        $and = glsr()->filterArrayUnique('query/sql/and', $and, $this->sqlHandle(), $this);
        $and = $this->normalizeAndClauses($and);
        return trim('WHERE 1=1 '.implode(' ', $and));
    }

    protected function clauseAndAssignedPosts(): string
    {
        return $this->clauseIfValueNotEmpty('(apt.post_id IN (%s) AND apt.is_published = 1)', $this->args['assigned_posts']);
    }

    protected function clauseAndAssignedPostsTypes(): string
    {
        return $this->clauseIfValueNotEmpty('apt.is_published = 1', $this->args['assigned_posts_types']);
    }

    protected function clauseAndAssignedTerms(): string
    {
        return $this->clauseIfValueNotEmpty('(att.term_id IN (%s))', $this->args['assigned_terms']);
    }

    protected function clauseAndAssignedUsers(): string
    {
        return $this->clauseIfValueNotEmpty('(aut.user_id IN (%s))', $this->args['assigned_users']);
    }

    protected function clauseAndContent(): string
    {
        return $this->clauseIfValueNotEmpty('AND p.post_content = %s', $this->args['content']);
    }

    protected function clauseAndDate(): string
    {
        $clauses = [];
        $date = $this->args['date'];
        if (!empty($date['after'])) {
            $clauses[] = $this->db->prepare("(p.post_date >{$date['inclusive']} %s)", $date['after']);
        }
        if (!empty($date['before'])) {
            $clauses[] = $this->db->prepare("(p.post_date <{$date['inclusive']} %s)", $date['before']);
        }
        if (!empty($date['year'])) {
            $clauses[] = $this->db->prepare('(YEAR(p.post_date) = %d AND MONTH(p.post_date) = %d AND DAYOFMONTH(p.post_date) = %d)',
                $date['year'], $date['month'], $date['day']
            );
        }
        if ($clauses = implode(' AND ', $clauses)) {
            return sprintf('AND (%s)', $clauses);
        }
        return '';
    }

    protected function clauseAndEmail(): string
    {
        return $this->clauseIfValueNotEmpty('AND r.email = %s', $this->args['email']);
    }

    protected function clauseAndIpAddress(): string
    {
        return $this->clauseIfValueNotEmpty('AND r.ip_address = %s', $this->args['ip_address']);
    }

    protected function clauseAndPostIn(): string
    {
        return $this->clauseIfValueNotEmpty('AND r.review_id IN (%s)', $this->args['post__in']);
    }

    protected function clauseAndPostNotIn(): string
    {
        return $this->clauseIfValueNotEmpty('AND r.review_id NOT IN (%s)', $this->args['post__not_in']);
    }

    protected function clauseAndRating(): string
    {
        $column = $this->isCustomRatingField() ? 'pm.meta_value' : 'r.rating';
        return (string) Helper::ifTrue($this->args['rating'] > 0,
            $this->db->prepare("AND {$column} > %d", --$this->args['rating'])
        );
    }

    protected function clauseAndRatingField(): string
    {
        return (string) Helper::ifTrue($this->isCustomRatingField(),
            $this->db->prepare('AND pm.meta_key = %s', sprintf('_custom_%s', $this->args['rating_field']))
        );
    }

    protected function clauseAndStatus(): string
    {
        if (-1 === $this->args['status']) {
            return "AND p.post_status IN ('pending','publish')";
        }
        return $this->clauseIfValueNotEmpty('AND r.is_approved = %d', $this->args['status']);
    }

    protected function clauseAndTerms(): string
    {
        if (-1 !== $this->args['terms']) {
            return $this->clauseIfValueNotEmpty('AND r.terms = %d', $this->args['terms']);
        }
        return '';
    }

    protected function clauseAndType(): string
    {
        return $this->clauseIfValueNotEmpty('AND r.type = %s', $this->args['type']);
    }

    protected function clauseAndUserIn(): string
    {
        return $this->clauseIfValueNotEmpty('AND p.post_author IN (%s)', $this->args['user__in']);
    }

    protected function clauseAndUserNotIn(): string
    {
        return $this->clauseIfValueNotEmpty('AND p.post_author NOT IN (%s)', $this->args['user__not_in']);
    }

    protected function clauseAndVerified(): string
    {
        if (-1 !== $this->args['verified']) {
            return $this->clauseIfValueNotEmpty('AND r.is_verified = %d', $this->args['verified']);
        }
        return '';
    }

    /**
     * @param array|int|string $value
     */
    protected function clauseIfValueNotEmpty(string $clause, $value, bool $prepare = true): string
    {
        if (Helper::isEmpty($value)) {
            return '';
        }
        if (!$prepare) {
            return $clause;
        }
        if (is_array($value)) {
            $value = implode(',', Arr::uniqueInt($value));
            return sprintf($clause, $value); // this clause uses IN(%s) so we need to bypass db->prepare
        }
        return $this->db->prepare($clause, $value);
    }

    protected function clauseJoinAssignedPosts(): string
    {
        return $this->clauseIfValueNotEmpty(
            $this->join('assigned_posts', $this->joinKeyword()),
            $this->args['assigned_posts'],
            $prepare = false
        );
    }

    protected function clauseJoinAssignedPostsTypes(): string
    {
        $clause1 = $this->join('assigned_posts', $this->joinKeyword());
        $clause2 = "INNER JOIN table|posts AS pt ON (pt.ID = apt.post_id AND pt.post_type IN ('%s'))";
        $values = Arr::unique($this->args['assigned_posts_types']);
        $values = array_map('esc_sql', $values);
        $values = array_filter($values, 'is_string'); // for phpstan
        $values = implode("','", $values);
        return sprintf(sprintf('%s %s', $clause1, $clause2), $values);
    }

    protected function clauseJoinAssignedTerms(): string
    {
        return $this->clauseIfValueNotEmpty(
            $this->join('assigned_terms', $this->joinKeyword()),
            $this->args['assigned_terms'],
            $prepare = false
        );
    }

    protected function clauseJoinAssignedUsers(): string
    {
        return $this->clauseIfValueNotEmpty(
            $this->join('assigned_users', $this->joinKeyword()),
            $this->args['assigned_users'],
            $prepare = false
        );
    }

    protected function clauseJoinContent(): string
    {
        return $this->clauseIfValueNotEmpty(
            $this->join('posts', 'INNER JOIN'),
            $this->args['content'],
            $prepare = false
        );
    }

    protected function clauseJoinDate(): string
    {
        return $this->clauseIfValueNotEmpty(
            $this->join('posts', 'INNER JOIN'),
            array_filter($this->args['date']),
            $prepare = false
        );
    }

    protected function clauseJoinUserIn(): string
    {
        return $this->clauseIfValueNotEmpty(
            $this->join('posts', 'INNER JOIN'),
            $this->args['user__in'],
            $prepare = false
        );
    }

    protected function clauseJoinUserNotIn(): string
    {
        return $this->clauseIfValueNotEmpty(
            $this->join('posts', 'INNER JOIN'),
            $this->args['user__not_in'],
            $prepare = false
        );
    }

    protected function clauseJoinOrderBy(): string
    {
        return (string) Helper::ifTrue(
            str_starts_with($this->args['orderby'], 'p.'),
            $this->join('posts', 'INNER JOIN')
        );
    }

    protected function clauseJoinRatingField(): string
    {
        return (string) Helper::ifTrue(
            $this->isCustomRatingField(),
            $this->join('postmeta', 'INNER JOIN')
        );
    }

    protected function clauseJoinStatus(): string
    {
        return (string) Helper::ifTrue(
            $this->args['status'] === -1,
            $this->join('posts', 'INNER JOIN')
        );
    }

    protected function isCustomRatingField(): bool
    {
        return 'rating' !== $this->args['rating_field'] && !empty($this->args['rating_field']);
    }

    protected function join(string $join, string $joinKeyword): string
    {
        $joinKeywords = [
            'CROSS JOIN', 'INNER JOIN', 'LEFT JOIN', 'NATURAL JOIN', 'RIGHT JOIN',
        ];
        if (!in_array($joinKeyword, $joinKeywords)) {
            $joinKeyword = 'INNER JOIN';
        }
        $statement = $this->joinStatements[$join] ?? '';
        $statement = str_replace('INNER JOIN', $joinKeyword, $statement);
        return $statement;
    }

    protected function joinKeyword(): string
    {
        return 'loose' === glsr_get_option('reviews.assignment') ? 'LEFT JOIN' : 'INNER JOIN';
    }

    protected function normalizeAndClauses(array $and): array
    {
        $clauses = [];
        foreach ($and as $key => $value) {
            if (str_starts_with($key, 'assigned_')) {
                $clauses[] = $value;
                unset($and[$key]);
            }
        }
        $operator = glsr()->filterString('query/sql/clause/operator', 'OR', $clauses, $this->args);
        $operator = strtoupper($operator);
        $operator = Helper::ifTrue(in_array($operator, ['AND', 'OR']), $operator, 'OR');
        if ($clauses = implode(" {$operator} ", $clauses)) {
            $and['assigned'] = "AND ($clauses)";
        }
        return $and;
    }

    protected function ratingColumn(): string
    {
        return Helper::ifTrue($this->isCustomRatingField(), 'pm.meta_value', 'r.rating');
    }

    protected function sqlHandle(int $depth = 2): string
    {
        return Str::dashCase(Arr::get((new \Exception())->getTrace(), $depth.'.function'));
    }
}
