<?php
declare(strict_types=1);

namespace App\Core;

final class QueryBuilder
{
    private Database $db;
    private string $table;
    private array $selects = ['*'];
    private array $wheres = [];
    private array $bindings = [];
    private array $joins = [];
    private array $orderBys = [];
    private array $groupBys = [];
    private ?string $having = null;
    private ?int $limitVal = null;
    private ?int $offsetVal = null;
    private bool $includeArchived = false;
    private bool $onlyArchivedFlag = false;
    private bool $hasSoftDelete = true;

    public function __construct(?Database $db = null)
    {
        $this->db = $db ?? App::getInstance()->db();
    }

    public function table(string $name): self
    {
        $clone = clone $this;
        $clone->table = $name;
        return $clone;
    }

    public function select(string|array $columns = '*'): self
    {
        $clone = clone $this;
        $clone->selects = is_array($columns) ? $columns : [$columns];
        return $clone;
    }

    public function where(string $col, string $op, $val): self
    {
        $clone = clone $this;
        $clone->wheres[] = ['AND', "`{$col}` {$op} ?"];
        $clone->bindings[] = $val;
        return $clone;
    }

    public function andWhere(string $col, string $op, $val): self
    {
        return $this->where($col, $op, $val);
    }

    public function orWhere(string $col, string $op, $val): self
    {
        $clone = clone $this;
        $clone->wheres[] = ['OR', "`{$col}` {$op} ?"];
        $clone->bindings[] = $val;
        return $clone;
    }

    public function whereIn(string $col, array $values): self
    {
        if (empty($values)) {
            return $this->whereRaw('1 = 0');
        }
        $clone = clone $this;
        $placeholders = implode(', ', array_fill(0, count($values), '?'));
        $clone->wheres[] = ['AND', "`{$col}` IN ({$placeholders})"];
        $clone->bindings = array_merge($clone->bindings, array_values($values));
        return $clone;
    }

    public function whereNull(string $col): self
    {
        $clone = clone $this;
        $clone->wheres[] = ['AND', "`{$col}` IS NULL"];
        return $clone;
    }

    public function whereNotNull(string $col): self
    {
        $clone = clone $this;
        $clone->wheres[] = ['AND', "`{$col}` IS NOT NULL"];
        return $clone;
    }

    public function whereBetween(string $col, $min, $max): self
    {
        $clone = clone $this;
        $clone->wheres[] = ['AND', "`{$col}` BETWEEN ? AND ?"];
        $clone->bindings[] = $min;
        $clone->bindings[] = $max;
        return $clone;
    }

    public function whereRaw(string $raw, array $bindings = []): self
    {
        $clone = clone $this;
        $clone->wheres[] = ['AND', $raw];
        $clone->bindings = array_merge($clone->bindings, $bindings);
        return $clone;
    }

    public function join(string $table, string $on, string $type = 'INNER'): self
    {
        $clone = clone $this;
        $clone->joins[] = "{$type} JOIN `{$table}` ON {$on}";
        return $clone;
    }

    public function leftJoin(string $table, string $on): self
    {
        return $this->join($table, $on, 'LEFT');
    }

    public function orderBy(string $col, string $dir = 'ASC'): self
    {
        $clone = clone $this;
        $dir = strtoupper($dir) === 'DESC' ? 'DESC' : 'ASC';
        if (str_contains($col, '.')) {
            [$tbl, $c] = explode('.', $col, 2);
            $clone->orderBys[] = "`{$tbl}`.`{$c}` {$dir}";
        } else {
            $clone->orderBys[] = "`{$col}` {$dir}";
        }
        return $clone;
    }

    public function groupBy(string $col): self
    {
        $clone = clone $this;
        if (str_contains($col, '.')) {
            [$tbl, $c] = explode('.', $col, 2);
            $clone->groupBys[] = "`{$tbl}`.`{$c}`";
        } else {
            $clone->groupBys[] = "`{$col}`";
        }
        return $clone;
    }

    public function having(string $raw): self
    {
        $clone = clone $this;
        $clone->having = $raw;
        return $clone;
    }

    public function limit(int $n): self
    {
        $clone = clone $this;
        $clone->limitVal = $n;
        return $clone;
    }

    public function offset(int $n): self
    {
        $clone = clone $this;
        $clone->offsetVal = $n;
        return $clone;
    }

    public function withoutArchived(): self
    {
        $clone = clone $this;
        $clone->includeArchived = false;
        $clone->onlyArchivedFlag = false;
        return $clone;
    }

    public function withArchived(): self
    {
        $clone = clone $this;
        $clone->includeArchived = true;
        $clone->onlyArchivedFlag = false;
        return $clone;
    }

    public function onlyArchived(): self
    {
        $clone = clone $this;
        $clone->onlyArchivedFlag = true;
        $clone->includeArchived = true;
        return $clone;
    }

    public function noSoftDelete(): self
    {
        $clone = clone $this;
        $clone->hasSoftDelete = false;
        return $clone;
    }

    public function when(bool $condition, callable $fn): self
    {
        if ($condition) {
            return $fn($this);
        }
        return $this;
    }

    private function buildSql(?string $selectOverride = null): array
    {
        $select = $selectOverride ?? implode(', ', $this->selects);
        $sql = "SELECT {$select} FROM `{$this->table}`";

        foreach ($this->joins as $join) {
            $sql .= " {$join}";
        }

        $allWheres = $this->wheres;

        if ($this->hasSoftDelete && !$this->includeArchived) {
            $allWheres[] = ['AND', "`{$this->table}`.`is_archived` = 0"];
        }

        if ($this->hasSoftDelete && $this->onlyArchivedFlag) {
            $allWheres[] = ['AND', "`{$this->table}`.`is_archived` = 1"];
        }

        $bindings = $this->bindings;

        if (!empty($allWheres)) {
            $sql .= ' WHERE ';
            $parts = [];
            foreach ($allWheres as $i => [$connector, $clause]) {
                if ($i === 0) {
                    $parts[] = $clause;
                } else {
                    $parts[] = $connector . ' ' . $clause;
                }
            }
            $sql .= implode(' ', $parts);
        }

        if (!empty($this->groupBys)) {
            $sql .= ' GROUP BY ' . implode(', ', $this->groupBys);
        }

        if ($this->having !== null) {
            $sql .= ' HAVING ' . $this->having;
        }

        if (!empty($this->orderBys)) {
            $sql .= ' ORDER BY ' . implode(', ', $this->orderBys);
        }

        if ($this->limitVal !== null) {
            $sql .= ' LIMIT ' . $this->limitVal;
        }

        if ($this->offsetVal !== null) {
            $sql .= ' OFFSET ' . $this->offsetVal;
        }

        return [$sql, $bindings];
    }

    public function get(): array
    {
        [$sql, $bindings] = $this->buildSql();
        return $this->db->select($sql, $bindings);
    }

    public function first(): ?array
    {
        $clone = clone $this;
        $clone->limitVal = 1;
        [$sql, $bindings] = $clone->buildSql();
        return $this->db->selectOne($sql, $bindings);
    }

    public function count(): int
    {
        [$sql, $bindings] = $this->buildSql('COUNT(*) as _cnt');
        $result = $this->db->selectOne($sql, $bindings);
        return (int) ($result['_cnt'] ?? 0);
    }

    public function sum(string $col): string
    {
        [$sql, $bindings] = $this->buildSql("COALESCE(SUM(`{$col}`), 0) as _sum");
        $result = $this->db->selectOne($sql, $bindings);
        return (string) ($result['_sum'] ?? '0');
    }

    public function paginate(int $perPage, int $currentPage): array
    {
        $totalItems = $this->count();
        $pagination = Pagination::paginate($totalItems, $perPage, $currentPage);

        $data = $this->limit($perPage)->offset(($currentPage - 1) * $perPage)->get();

        return [
            'data'       => $data,
            'pagination' => $pagination,
        ];
    }

    public function insertGetId(array $data): int
    {
        return $this->db->insert($this->table, $data);
    }

    public function insertRow(array $data): int
    {
        return $this->db->insert($this->table, $data);
    }

    public function updateRows(array $data): int
    {
        if (empty($this->wheres)) {
            throw new \RuntimeException('Cannot update without WHERE clause');
        }

        $set = implode(', ', array_map(fn($c) => "`{$c}` = ?", array_keys($data)));
        $sql = "UPDATE `{$this->table}` SET {$set}";

        $bindings = array_values($data);

        $sql .= ' WHERE ';
        $parts = [];
        foreach ($this->wheres as $i => [$connector, $clause]) {
            if ($i === 0) {
                $parts[] = $clause;
            } else {
                $parts[] = $connector . ' ' . $clause;
            }
        }
        $sql .= implode(' ', $parts);
        $bindings = array_merge($bindings, $this->bindings);

        $stmt = $this->db->query($sql, $bindings);
        return $stmt->rowCount();
    }

    public function softDelete(): int
    {
        return $this->updateRows([
            'is_archived' => 1,
            'archived_at' => date('Y-m-d H:i:s'),
        ]);
    }

    public function toSql(): string
    {
        [$sql,] = $this->buildSql();
        return $sql;
    }
}