<?php

/**
 * Abstract Repository Base Class
 *
 * Provides common data access patterns for all repositories.
 *
 * @package     WPUltimateSecurity\UltimateSecurity\Infrastructure\Persistence
 * @since       2.0.0
 */

declare(strict_types=1);

namespace WPUltimateSecurity\UltimateSecurity\Infrastructure\Persistence;

use WPUltimateSecurity\UltimateSecurity\Contracts\RepositoryInterface;
use WPUltimateSecurity\UltimateSecurity\Infrastructure\Cache\CacheManager;
use WPUltimateSecurity\UltimateSecurity\Services\EventBus;

/**
 * Abstract base class for data repositories.
 *
 * Provides:
 * - Common CRUD operations with prepared statements
 * - Column type casting
 * - Fillable column filtering
 * - Pagination support
 * - Automatic timestamp management
 *
 * @since 2.0.0
 */
abstract class AbstractRepository implements RepositoryInterface
{
    /**
     * WordPress database instance.
     *
     * @var \wpdb
     */
    protected \wpdb $db;

    /**
     * Full table name with prefix.
     *
     * @var string
     */
    protected string $table;

    /**
     * Primary key column name.
     *
     * @var string
     */
    protected string $primaryKey = 'id';

    /**
     * Cache TTL in seconds.
     *
     * @var int
     */
    protected int $cacheTtl = 3600;

    /**
     * Constructor.
     *
     * @param \wpdb|null $db WordPress database instance.
     */
    public function __construct(?\wpdb $db = null)
    {
        global $wpdb;
        $this->db = $db ?? $wpdb;
        $this->table = $this->db->prefix . 'ultimate_security_' . $this->getTable();
    }

    /**
     * Get the table name without prefix.
     *
     * @return string
     */
    abstract protected function getTable(): string;

    /**
     * Get fillable columns for mass assignment.
     *
     * @return string[]
     */
    protected function getFillable(): array
    {
        return [];
    }

    /**
     * Get column type casts.
     *
     * @return array<string, string>
     */
    protected function getCasts(): array
    {
        return [
            'id' => 'int',
            'created_at' => 'datetime',
            'updated_at' => 'datetime',
        ];
    }

    /**
     * Get the full table name with prefix.
     *
     * @return string
     */
    public function getTableName(): string
    {
        return $this->table;
    }

    /**
     * Create a new QueryBuilder instance for fluent queries.
     *
     * @since 2.0.0
     *
     * @return QueryBuilder
     */
    public function query(): QueryBuilder
    {
        return new QueryBuilder($this->db, $this->table, $this->getCasts());
    }

    /**
     * Find a single record by ID.
     *
     * @param int $id The record ID.
     *
     * @return object|null
     */
    public function find(int $id): ?object
    {
        $result = $this->db->get_row(
            $this->db->prepare(
                "SELECT * FROM {$this->table} WHERE {$this->primaryKey} = %d",
                $id
            )
        );

        return $result ? $this->castAttributes($result) : null;
    }

    /**
     * Find a single record by ID or throw an exception.
     *
     * @since 2.0.0
     *
     * @param int $id The record ID.
     *
     * @return object
     *
     * @throws \RuntimeException If record is not found.
     */
    public function findOrFail(int $id): object
    {
        $result = $this->find($id);

        if ($result === null) {
            throw new \RuntimeException(
                sprintf('Record not found in %s: %d', esc_html($this->getTable()), esc_html($id))
            );
        }

        return $result;
    }

    /**
     * Find all records matching criteria.
     *
     * @param array<string, mixed> $criteria Filter criteria.
     *
     * @return array<int, object>
     */
    public function findAll(array $criteria = []): array
    {
        $where = '1=1';
        $values = [];

        if (!empty($criteria['where'])) {
            foreach ($criteria['where'] as $column => $value) {
                // Validate column name to prevent SQL injection.
                if (!$this->isValidColumnName($column)) {
                    continue;
                }
                $where .= " AND {$column} = %s";
                $values[] = $value;
            }
        }

        $orderBy = $criteria['orderby'] ?? $this->primaryKey;
        $order = strtoupper($criteria['order'] ?? 'DESC');
        $order = in_array($order, ['ASC', 'DESC'], true) ? $order : 'DESC';

        // Validate orderBy column name to prevent SQL injection.
        if (!$this->isValidColumnName($orderBy)) {
            $orderBy = $this->primaryKey;
        }

        $limit = isset($criteria['limit']) ? (int) $criteria['limit'] : 50;
        $offset = isset($criteria['offset']) ? (int) $criteria['offset'] : 0;

        $sql = "SELECT * FROM {$this->table} WHERE {$where} ORDER BY {$orderBy} {$order} LIMIT %d OFFSET %d";
        $values[] = $limit;
        $values[] = $offset;

        $results = $this->db->get_results(
            $this->db->prepare($sql, ...$values)
        );

        return array_map([$this, 'castAttributes'], $results ?: []);
    }

    /**
     * Create a new record.
     *
     * @param array<string, mixed> $data Record data.
     *
     * @return int|false The new record ID, or false on failure.
     */
    public function create(array $data): int|false
    {
        $data = $this->filterFillable($data);
        $data['created_at'] = current_time('mysql');
        $data['updated_at'] = current_time('mysql');

        $result = $this->db->insert($this->table, $data);

        if ($result !== false) {
            $insertId = (int) $this->db->insert_id;
            $this->clearCache();
            $this->dispatchEntityEvent('created', $insertId, $data);
            return $insertId;
        }

        return false;
    }

    /**
     * Update an existing record.
     *
     * @param int                  $id   Record ID.
     * @param array<string, mixed> $data Data to update.
     *
     * @return bool True on success.
     */
    public function update(int $id, array $data): bool
    {
        $data = $this->filterFillable($data);
        $data['updated_at'] = current_time('mysql');

        $result = $this->db->update(
            $this->table,
            $data,
            [$this->primaryKey => $id]
        );

        if ($result !== false) {
            $this->clearCache();
            $this->dispatchEntityEvent('updated', $id, $data);
            return true;
        }

        return false;
    }

    /**
     * Delete a record by ID.
     *
     * @param int $id Record ID.
     *
     * @return bool True on success.
     */
    public function delete(int $id): bool
    {
        $result = $this->db->delete(
            $this->table,
            [$this->primaryKey => $id]
        );

        if ($result !== false) {
            $this->clearCache();
            $this->dispatchEntityEvent('deleted', $id);
            return true;
        }

        return false;
    }

    /**
     * Count records matching criteria.
     *
     * @param array<string, mixed> $criteria Filter criteria.
     *
     * @return int
     */
    public function count(array $criteria = []): int
    {
        $where = '1=1';
        $values = [];

        if (!empty($criteria['where'])) {
            foreach ($criteria['where'] as $column => $value) {
                // Validate column name to prevent SQL injection.
                if (!$this->isValidColumnName($column)) {
                    continue;
                }
                $where .= " AND {$column} = %s";
                $values[] = $value;
            }
        }

        $sql = "SELECT COUNT(*) FROM {$this->table} WHERE {$where}";

        if (!empty($values)) {
            $sql = $this->db->prepare($sql, ...$values);
        }

        return (int) $this->db->get_var($sql);
    }

    /**
     * Delete records matching criteria.
     *
     * @param array<string, mixed> $criteria Filter criteria.
     *
     * @return int Number of records deleted.
     */
    public function deleteWhere(array $criteria): int
    {
        if (empty($criteria)) {
            return 0;
        }

        $result = $this->db->delete($this->table, $criteria);

        if ($result !== false) {
            $this->clearCache();
            return (int) $result;
        }

        return 0;
    }

    /**
     * Truncate the table.
     *
     * @return bool
     */
    public function truncate(): bool
    {
        $result = (bool) $this->db->query("TRUNCATE TABLE {$this->table}");

        if ($result) {
            $this->clearCache();
        }

        return $result;
    }

    /**
     * Get paginated results.
     *
     * @since 2.0.0
     *
     * @param int                  $perPage  Items per page.
     * @param int                  $page     Current page (1-indexed).
     * @param array<string, mixed> $criteria Filter criteria.
     *
     * @return PaginatedResult
     */
    public function paginate(int $perPage = 20, int $page = 1, array $criteria = []): PaginatedResult
    {
        $criteria['limit'] = $perPage;
        $criteria['offset'] = ($page - 1) * $perPage;

        $items = $this->findAll($criteria);
        $total = $this->count($criteria);

        return new PaginatedResult(
            items: $items,
            total: $total,
            perPage: $perPage,
            currentPage: $page
        );
    }

    /**
     * Filter data to only include fillable columns.
     *
     * @param array<string, mixed> $data Input data.
     *
     * @return array<string, mixed>
     */
    protected function filterFillable(array $data): array
    {
        $fillable = $this->getFillable();

        if (empty($fillable)) {
            return $data;
        }

        return array_intersect_key($data, array_flip($fillable));
    }

    /**
     * Cast attributes to their proper types.
     *
     * @param object $row Database row.
     *
     * @return object
     */
    protected function castAttributes(object $row): object
    {
        $casts = $this->getCasts();

        foreach ($casts as $key => $type) {
            if (!isset($row->$key)) {
                continue;
            }

            $row->$key = match ($type) {
                'int', 'integer' => (int) $row->$key,
                'float', 'double' => (float) $row->$key,
                'bool', 'boolean' => (bool) $row->$key,
                'string' => (string) $row->$key,
                'array', 'json' => is_string($row->$key)
                    ? json_decode($row->$key, true)
                    : $row->$key,
                default => $row->$key,
            };
        }

        return $row;
    }

    /**
     * Get a cached value or compute and cache it.
     *
     * @since 2.0.0
     *
     * @param string   $key      Cache key (will be prefixed with repository prefix).
     * @param callable $callback Callback to compute value if not cached.
     * @param int|null $ttl      Time to live in seconds. Null uses repository default.
     *
     * @return mixed Cached or computed value.
     */
    protected function getCached(string $key, callable $callback, ?int $ttl = null): mixed
    {
        return CacheManager::getInstance()->remember(
            $this->getCacheKey($key),
            $ttl ?? $this->cacheTtl,
            $callback
        );
    }

    /**
     * Clear cache for this repository.
     *
     * @since 2.0.0
     *
     * @param string|null $key Specific cache key to clear, or null to clear all.
     *
     * @return void
     */
    protected function clearCache(?string $key = null): void
    {
        $cache = CacheManager::getInstance();

        if ($key !== null) {
            $cache->forget($this->getCacheKey($key));
        } else {
            $cache->forgetByPrefix($this->getCachePrefix());
        }
    }

    /**
     * Generate a full cache key for this repository.
     *
     * @since 2.0.0
     *
     * @param string $key The key suffix.
     *
     * @return string Full cache key.
     */
    protected function getCacheKey(string $key): string
    {
        return $this->getCachePrefix() . $key;
    }

    /**
     * Get the cache key prefix for this repository.
     *
     * @since 2.0.0
     *
     * @return string Cache key prefix.
     */
    protected function getCachePrefix(): string
    {
        return 'repo_' . $this->getTable() . '_';
    }

    /**
     * Dispatch an entity lifecycle event.
     *
     * @since 2.0.0
     *
     * @param string                    $action Entity action (created, updated, deleted).
     * @param int                       $id     Entity ID.
     * @param array<string, mixed>|null $data   Entity data (optional).
     *
     * @return void
     */
    protected function dispatchEntityEvent(string $action, int $id, ?array $data = null): void
    {
        $eventName = 'repository.' . $this->getTable() . '.' . $action;

        /**
         * Fires when a repository entity changes.
         *
         * @since 2.0.0
         *
         * @param int                       $id     The entity ID.
         * @param string                    $table  The table name.
         * @param string                    $action The action (created, updated, deleted).
         * @param array<string, mixed>|null $data   The entity data.
         */
        do_action(
            'ultimate_security_entity_' . $action,
            $id,
            $this->getTable(),
            $action,
            $data
        );

        // Also dispatch via EventBus for subscribers.
        do_action(
            $eventName,
            $id,
            $data
        );
    }

    /**
     * Get the last database error.
     *
     * @return string
     */
    public function getLastError(): string
    {
        return $this->db->last_error;
    }

    /**
     * Validate that a column name is safe for use in SQL queries.
     *
     * Prevents SQL injection by ensuring column names only contain
     * alphanumeric characters and underscores, and start with a letter
     * or underscore.
     *
     * @since 2.0.0
     *
     * @param string $column The column name to validate.
     *
     * @return bool True if the column name is valid, false otherwise.
     */
    protected function isValidColumnName(string $column): bool
    {
        return preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $column) === 1;
    }
}
