<?php

namespace CliqThemes\ORM;

use ArrayAccess;
use CliqThemes\ORM\Database\Connection;
use CliqThemes\ORM\Query\Builder;
use CliqThemes\ORM\Relations\BelongsTo;
use CliqThemes\ORM\Relations\BelongsToMany;
use CliqThemes\ORM\Relations\HasMany;
use CliqThemes\ORM\Relations\HasOne;
use CliqThemes\ORM\Relations\Relation;
use JsonSerializable;

/**
 * Base model implementation that powers the lightweight ORM layer.
 *
 * Models manage their own attributes, know how to persist themselves through
 * the shared {@see Connection} instance and expose helpers for common
 * relationships. Concrete models may override protected properties to tailor
 * behaviour for a specific WordPress table.
 */
class Model implements ArrayAccess, JsonSerializable
{
    /**
     * Shared database connection instance used by all models.
     */
    protected static ?Connection $connection = null;

    /**
     * Currently set attributes on the model instance.
     *
     * @var array<string, mixed>
     */
    protected array $attributes = [];

    /**
     * Snapshot of attributes that were last persisted to the database.
     *
     * @var array<string, mixed>
     */
    protected array $original = [];

    /**
     * Explicit table name. If left blank the table is inferred from the class
     * name.
     */
    protected string $table = '';

    /**
     * Name of the primary key column for the model.
     */
    protected string $primaryKey = 'ID';

    /**
     * Attributes that can be mass assigned using {@see fill()}.
     *
     * @var string[]
     */
    protected array $fillable = [];

    /**
     * Whether created/updated timestamps should be automatically managed.
     */
    protected bool $timestamps = true;

    /**
     * Column used for the creation timestamp or null when not in use.
     */
    protected ?string $createdAtColumn = 'created_at';

    /**
     * Column used for the update timestamp or null when not in use.
     */
    protected ?string $updatedAtColumn = 'updated_at';

    /**
     * Whether the model uses soft deletes (delete sets a timestamp instead of
     * removing the row, and queries exclude trashed rows by default).
     */
    protected bool $softDeletes = false;

    /**
     * Column used to flag a soft-deleted record.
     */
    protected string $deletedAtColumn = 'deleted_at';

    /**
     * Create a new model instance optionally seeded with attributes.
     */
    public function __construct(array $attributes = [])
    {
        $this->fill($attributes);
    }

    /**
     * Provide a custom connection instance that should be reused by all models.
     */
    public static function setConnection(Connection $connection): void
    {
        static::$connection = $connection;
    }

    /**
     * Resolve the connection that should be used for all ORM operations.
     */
    public static function connection(): Connection
    {
        if (!static::$connection instanceof Connection) {
            static::$connection = new Connection();
        }

        return static::$connection;
    }

    /**
     * Instantiate a new query builder for the model.
     */
    public static function query(): Builder
    {
        return new Builder(static::class, static::connection());
    }

    /**
     * Retrieve all records for the model from the database.
     *
     * @return static[]
     */
    public static function all(): array
    {
        return static::query()->get();
    }

    /**
     * Find a single model record by its primary key.
     */
    public static function find(int|string $id): ?static
    {
        return static::query()->find($id);
    }

    /**
     * Resolve the table name including the WordPress prefix.
     */
    public function getQualifiedTable(): string
    {
        return static::connection()->table($this->getTable());
    }

    /**
     * Determine the table name that should be queried for the model.
     */
    public function getTable(): string
    {
        return $this->table ?: static::inferTableFromClass();
    }

    /**
     * Guess the table name from the class name when no explicit table is set.
     */
    protected static function inferTableFromClass(): string
    {
        $segments = explode('\\', static::class);
        $class = end($segments);

        return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $class)) . 's';
    }

    /**
     * Get the primary key column for the model.
     */
    public function getPrimaryKey(): string
    {
        return $this->primaryKey;
    }

    /**
     * Fetch the primary key value for the current model instance.
     */
    public function getKey(): mixed
    {
        return $this->attributes[$this->primaryKey] ?? null;
    }

    /**
     * Mass assign attributes using the fillable whitelist.
     *
     * @return $this
     */
    public function fill(array $attributes): static
    {
        foreach ($attributes as $key => $value) {
            if (empty($this->fillable) || in_array($key, $this->fillable, true)) {
                $this->attributes[$key] = $value;
            }
        }

        return $this;
    }

    /**
     * Force assign attributes bypassing the fillable whitelist.
     *
     * @return $this
     */
    public function forceFill(array $attributes): static
    {
        foreach ($attributes as $key => $value) {
            $this->attributes[$key] = $value;
        }

        return $this;
    }

    /**
     * Synchronise the original attribute snapshot with the current state.
     */
    public function syncOriginal(): void
    {
        $this->original = $this->attributes;
    }

    /**
     * Determine if any attributes have been modified since the last sync.
     */
    public function isDirty(): bool
    {
        return $this->attributes !== $this->original;
    }

    /**
     * Persist the model to the database, performing an insert or update.
     */
    public function save(): bool
    {
        $connection = static::connection();
        $table = $this->getTable();

        $timestampColumns = $this->getTimestampColumns();

        if ($this->timestamps && $timestampColumns['created'] && !$this->getAttribute($timestampColumns['created'])) {
            $this->attributes[$timestampColumns['created']] = $this->currentTimestamp();
        }

        if ($this->timestamps && $timestampColumns['updated']) {
            $this->attributes[$timestampColumns['updated']] = $this->currentTimestamp();
        }

        if ($this->exists()) {
            $dirty = $this->getDirty();

            if (empty($dirty)) {
                return true;
            }

            $connection->update($table, $dirty, [$this->primaryKey => $this->getKey()]);
        } else {
            $id = $connection->insert($table, $this->attributes);

            if (!isset($this->attributes[$this->primaryKey])) {
                $this->attributes[$this->primaryKey] = $id;
            }
        }

        $this->syncOriginal();

        return true;
    }

    /**
     * Remove the model record. When the model uses soft deletes this sets the
     * deleted_at timestamp instead of physically removing the row.
     */
    public function delete(): bool
    {
        if (!$this->exists()) {
            return false;
        }

        if ($this->softDeletes) {
            $timestamp = $this->currentTimestamp();
            $this->attributes[$this->deletedAtColumn] = $timestamp;

            static::connection()->update(
                $this->getTable(),
                [$this->deletedAtColumn => $timestamp],
                [$this->primaryKey => $this->getKey()]
            );

            $this->syncOriginal();

            return true;
        }

        static::connection()->delete($this->getTable(), [$this->primaryKey => $this->getKey()]);

        return true;
    }

    /**
     * Permanently delete the record, ignoring soft deletes.
     */
    public function forceDelete(): bool
    {
        if (!$this->exists()) {
            return false;
        }

        static::connection()->delete($this->getTable(), [$this->primaryKey => $this->getKey()]);

        return true;
    }

    /**
     * Restore a soft-deleted record by clearing its deleted_at timestamp.
     */
    public function restore(): bool
    {
        if (!$this->softDeletes || !$this->exists()) {
            return false;
        }

        $this->attributes[$this->deletedAtColumn] = null;

        static::connection()->update(
            $this->getTable(),
            [$this->deletedAtColumn => null],
            [$this->primaryKey => $this->getKey()]
        );

        $this->syncOriginal();

        return true;
    }

    /**
     * Whether the model opts into soft deletes.
     */
    public function usesSoftDeletes(): bool
    {
        return $this->softDeletes;
    }

    /**
     * The column used to track soft deletion.
     */
    public function getDeletedAtColumn(): string
    {
        return $this->deletedAtColumn;
    }

    /**
     * Whether the current instance has been soft deleted.
     */
    public function trashed(): bool
    {
        return $this->softDeletes && !empty($this->attributes[$this->deletedAtColumn]);
    }

    /**
     * Determine if the model has already been persisted.
     */
    public function exists(): bool
    {
        return $this->getKey() !== null;
    }

    /**
     * Get the attributes that have been modified since the last sync.
     *
     * @return array<string, mixed>
     */
    public function getDirty(): array
    {
        return array_diff_assoc($this->attributes, $this->original);
    }

    /**
     * Retrieve an attribute value from the model.
     */
    public function getAttribute(string $key): mixed
    {
        return $this->attributes[$key] ?? null;
    }

    /**
     * Set an attribute value on the model.
     */
    public function setAttribute(string $key, mixed $value): void
    {
        $this->attributes[$key] = $value;
    }

    /**
     * Create and persist a new model using the provided attributes.
     */
    public static function create(array $attributes): static
    {
        $model = new static($attributes);
        $model->save();

        return $model;
    }

    /**
     * Convert the model instance into a plain array of attributes.
     *
     * @return array<string, mixed>
     */
    public function toArray(): array
    {
        return $this->attributes;
    }

    /**
     * Specify data that can be serialised by {@see json_encode()}.
     */
    public function jsonSerialize(): array
    {
        return $this->toArray();
    }

    /**
     * Determine whether an attribute exists for ArrayAccess support.
     */
    public function offsetExists(mixed $offset): bool
    {
        return isset($this->attributes[$offset]);
    }

    /**
     * Retrieve an attribute via ArrayAccess.
     */
    public function offsetGet(mixed $offset): mixed
    {
        return $this->attributes[$offset] ?? null;
    }

    /**
     * Set an attribute via ArrayAccess.
     */
    public function offsetSet(mixed $offset, mixed $value): void
    {
        $this->attributes[$offset] = $value;
    }

    /**
     * Remove an attribute via ArrayAccess.
     */
    public function offsetUnset(mixed $offset): void
    {
        unset($this->attributes[$offset]);
    }

    /**
     * Magic getter that resolves relationship methods or raw attributes.
     */
    public function __get(string $key): mixed
    {
        if (method_exists($this, $key)) {
            $relation = $this->{$key}();

            if ($relation instanceof Relation) {
                return $relation->getResults();
            }
        }

        return $this->getAttribute($key);
    }

    /**
     * Magic setter forwarding to {@see setAttribute()}.
     */
    public function __set(string $key, mixed $value): void
    {
        $this->setAttribute($key, $value);
    }

    /**
     * Define a one-to-one relationship.
     */
    protected function hasOne(string $related, ?string $foreignKey = null, ?string $localKey = null): HasOne
    {
        $instance = new $related();
        $foreignKey = $foreignKey ?? $instance->getForeignKey();
        $localKey = $localKey ?? $this->primaryKey;

        return new HasOne($this, $instance, $foreignKey, $localKey);
    }

    /**
     * Define a one-to-many relationship.
     */
    protected function hasMany(string $related, ?string $foreignKey = null, ?string $localKey = null): HasMany
    {
        $instance = new $related();
        $foreignKey = $foreignKey ?? $instance->getForeignKey();
        $localKey = $localKey ?? $this->primaryKey;

        return new HasMany($this, $instance, $foreignKey, $localKey);
    }

    /**
     * Define an inverse one-to-one or one-to-many relationship.
     */
    protected function belongsTo(string $related, ?string $foreignKey = null, ?string $ownerKey = null): BelongsTo
    {
        $instance = new $related();
        $foreignKey = $foreignKey ?? $this->guessForeignKey($related);
        $ownerKey = $ownerKey ?? $instance->getPrimaryKey();

        return new BelongsTo($this, $instance, $foreignKey, $ownerKey);
    }

    /**
     * Define a many-to-many relationship.
     */
    protected function belongsToMany(string $related, ?string $pivotTable = null, ?string $foreignPivotKey = null, ?string $relatedPivotKey = null): BelongsToMany
    {
        $instance = new $related();
        $pivotTable = $pivotTable ?? $this->inferPivotTableName($instance);
        $foreignPivotKey = $foreignPivotKey ?? $this->getForeignKey();
        $relatedPivotKey = $relatedPivotKey ?? $instance->getForeignKey();

        return new BelongsToMany($this, $instance, $pivotTable, $foreignPivotKey, $relatedPivotKey);
    }

    /**
     * Guess the pivot table name for a many-to-many relationship.
     */
    protected function inferPivotTableName(Model $related): string
    {
        $tables = [strtolower($this->getTable()), strtolower($related->getTable())];
        sort($tables);

        return implode('_', $tables);
    }

    /**
     * Resolve the timestamp columns that should be maintained.
     *
     * @return array{created: string|null, updated: string|null}
     */
    protected function getTimestampColumns(): array
    {
        return [
            'created' => $this->timestamps ? $this->createdAtColumn : null,
            'updated' => $this->timestamps ? $this->updatedAtColumn : null,
        ];
    }

    /**
     * Guess the default foreign key column for the model.
     */
    public function getForeignKey(): string
    {
        return $this->guessForeignKey(static::class);
    }

    /**
     * Guess the foreign key column name for a related model.
     */
    protected function guessForeignKey(string $related): string
    {
        $segments = explode('\\', $related);
        $class = end($segments);
        $snake = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $class));

        return $snake . '_id';
    }

    /**
     * Get the current timestamp formatted for database storage.
     */
    protected function currentTimestamp(): string
    {
        if (function_exists('current_time')) {
            return current_time('mysql');
        }

        return gmdate('Y-m-d H:i:s');
    }
}
