<?php

/**
 * Created by Cristian.
 * Date: 11/09/16 12:11 PM.
 */

namespace Reliese\Coders\Model;

use Illuminate\Support\Str;
use Reliese\Meta\Blueprint;
use Illuminate\Support\Fluent;
use Illuminate\Database\Eloquent\SoftDeletes;
use Reliese\Coders\Model\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Model as Eloquent;
use Reliese\Coders\Model\Relations\ReferenceFactory;

class Model
{
    /**
     * @var \Reliese\Meta\Blueprint
     */
    private $blueprint;

    /**
     * @var \Reliese\Coders\Model\Factory
     */
    private $factory;

    /**
     * @var array
     */
    protected $properties = [];

    /**
     * @var Relation[]
     */
    protected $relations = [];

    /**
     * @var \Reliese\Meta\Blueprint[]
     */
    protected $references = [];

    /**
     * @var array
     */
    protected $hidden = [];

    /**
     * @var array
     */
    protected $fillable = [];

    /**
     * @var array
     */
    protected $casts = [];

    /**
     * @var \Reliese\Coders\Model\Mutator[]
     */
    protected $mutators = [];

    /**
     * @var \Reliese\Coders\Model\Mutation[]
     */
    protected $mutations = [];

    /**
     * @var array
     */
    protected $hints = [];

    /**
     * @var string
     */
    protected $namespace;

    /**
     * @var string
     */
    protected $parentClass;

    /**
     * @var bool
     */
    protected $timestamps = true;

    /**
     * @var string
     */
    protected $CREATED_AT;

    /**
     * @var string
     */
    protected $UPDATED_AT;

    /**
     * @var bool
     */
    protected $softDeletes = false;

    /**
     * @var string
     */
    protected $DELETED_AT;

    /**
     * @var bool
     */
    protected $showConnection = false;

    /**
     * @var string
     */
    protected $connection;

    /**
     * @var \Illuminate\Support\Fluent
     */
    protected $primaryKeys;

    /**
     * @var \Illuminate\Support\Fluent
     */
    protected $primaryKeyColumn;

    /**
     * @var int
     */
    protected $perPage;

    /**
     * @var string
     */
    protected $dateFormat;

    /**
     * @var bool
     */
    protected $loadRelations;

    /**
     * @var bool
     */
    protected $hasCrossDatabaseRelationships = false;

    /**
     * @var string
     */
    protected $tablePrefix = '';

    /**
     * @var string
     */
    protected $relationNameStrategy = '';

    /**
     * @var bool
     */
    protected $definesReturnTypes = false;

    /**
     * ModelClass constructor.
     *
     * @param \Reliese\Meta\Blueprint $blueprint
     * @param \Reliese\Coders\Model\Factory $factory
     * @param \Reliese\Coders\Model\Mutator[] $mutators
     * @param bool $loadRelations
     */
    public function __construct(Blueprint $blueprint, Factory $factory, $mutators = [], $loadRelations = true)
    {
        $this->blueprint = $blueprint;
        $this->factory = $factory;
        $this->loadRelations = $loadRelations;
        $this->mutators = $mutators;
        $this->configure();
        $this->fill();
    }

    protected function configure()
    {
        $this->withNamespace($this->config('namespace'));
        $this->withParentClass($this->config('parent'));

        // Timestamps settings
        $this->withTimestamps($this->config('timestamps.enabled', $this->config('timestamps', true)));
        $this->withCreatedAtField($this->config('timestamps.fields.CREATED_AT', $this->getDefaultCreatedAtField()));
        $this->withUpdatedAtField($this->config('timestamps.fields.UPDATED_AT', $this->getDefaultUpdatedAtField()));

        // Soft deletes settings
        $this->withSoftDeletes($this->config('soft_deletes.enabled', $this->config('soft_deletes', false)));
        $this->withDeletedAtField($this->config('soft_deletes.field', $this->getDefaultDeletedAtField()));

        // Connection settings
        $this->withConnection($this->config('connection', false));
        $this->withConnectionName($this->blueprint->connection());

        // Pagination settings
        $this->withPerPage($this->config('per_page', $this->getDefaultPerPage()));

        // Dates settings
        $this->withDateFormat($this->config('date_format', $this->getDefaultDateFormat()));

        // Table Prefix settings
        $this->withTablePrefix($this->config('table_prefix', $this->getDefaultTablePrefix()));

        // Relation name settings
        $this->withRelationNameStrategy($this->config('relation_name_strategy', $this->getDefaultRelationNameStrategy()));

        $this->definesReturnTypes = $this->config('enable_return_types', false);

        return $this;
    }

    /**
     * Parses the model information.
     */
    protected function fill()
    {
        $this->primaryKeys = $this->blueprint->primaryKey();

        // Process columns
        foreach ($this->blueprint->columns() as $column) {
            $this->parseColumn($column);
        }

        if (! $this->loadRelations) {
            return;
        }

        foreach ($this->blueprint->relations() as $relation) {
            $model = $this->makeRelationModel($relation);
            $belongsTo = new BelongsTo($relation, $this, $model);
            $this->relations[$belongsTo->name()] = $belongsTo;
        }

        foreach ($this->factory->referencing($this) as $related) {
            $factory = new ReferenceFactory($related, $this);
            $references = $factory->make();
            foreach ($references as $reference) {
                $this->relations[$reference->name()] = $reference;
            }
        }
    }

    /**
     * @param \Illuminate\Support\Fluent $column
     */
    protected function parseColumn(Fluent $column)
    {
        // TODO: Check type cast is OK
        $cast = $column->type;

        $propertyName = $this->usesPropertyConstants() ? 'self::'.strtoupper($column->name) : $column->name;

        // Due to some casting problems when converting null to a Carbon instance,
        // we are going to treat Soft Deletes field as string.
        if ($column->name == $this->getDeletedAtField()) {
            $cast = 'string';
        }

        // Track attribute casts, ignoring timestamps
        if ($cast != 'string' && !in_array($propertyName, [$this->CREATED_AT, $this->UPDATED_AT])) {
            $this->casts[$propertyName] = $cast;
        }

        foreach ($this->config('casts', []) as $pattern => $casting) {
            if (Str::is($pattern, $column->name)) {
                $this->casts[$propertyName] = $cast = $casting;
                break;
            }
        }

        if ($this->isHidden($column->name)) {
            $this->hidden[] = $propertyName;
        }

        if ($this->isFillable($column->name)) {
            $this->fillable[] = $propertyName;
        }

        $this->mutate($column->name);

        // Track comment hints
        if (! empty($column->comment)) {
            $this->hints[$column->name] = $column->comment;
        }

        // Track PHP type hints
        $hint = $this->phpTypeHint($cast, $column->nullable);
        $this->properties[$column->name] = $hint;

        if ($column->name == $this->getPrimaryKey()) {
            $this->primaryKeyColumn = $column;
        }
    }

    /**
     * @param string $column
     */
    protected function mutate($column)
    {
        foreach ($this->mutators as $mutator) {
            if ($mutator->applies($column, $this->getBlueprint())) {
                $this->mutations[] = new Mutation(
                    $mutator->getName($column, $this),
                    $mutator->getBody($column, $this)
                );
            }
        }
    }

    /**
     * @param \Illuminate\Support\Fluent $relation
     *
     * @return $this|\Reliese\Coders\Model\Model
     */
    public function makeRelationModel(Fluent $relation)
    {
        list($database, $table) = array_values($relation->on);

        if ($this->blueprint->is($database, $table)) {
            return $this;
        }

        return $this->factory->makeModel($database, $table, false);
    }

    /**
     * @param string $castType
     * @param bool $nullable
     *
     * @todo Make tests
     *
     * @return string
     */
    public function phpTypeHint($castType, $nullable)
    {
        $type = $castType;

        switch ($castType) {
            case 'object':
                $type = '\stdClass';
                break;
            case 'array':
            case 'json':
                $type = 'array';
                break;
            case 'collection':
                $type = '\Illuminate\Support\Collection';
                break;
            case 'datetime':
                $type = '\Carbon\Carbon';
                break;
            case 'binary':
                $type = 'string';
                break;
        }

        if ($nullable) {
            return $type.'|null';
        }

        return $type;
    }

    /**
     * @return string
     */
    public function getSchema()
    {
        return $this->blueprint->schema();
    }

    /**
     * @return string
     */
    public function getTable($andRemovePrefix = false)
    {
        if ($andRemovePrefix) {
            return $this->removeTablePrefix($this->blueprint->table());
        }

        return $this->blueprint->table();
    }

    /**
     * @return string
     */
    public function getQualifiedTable()
    {
        return $this->blueprint->qualifiedTable();
    }

    /**
     * @return string
     */
    public function getTableForQuery()
    {
        return $this->shouldQualifyTableName()
            ? $this->getQualifiedTable()
            : $this->getTable();
    }

    /**
     * @return bool
     */
    public function shouldQualifyTableName()
    {
        return $this->config('qualified_tables', false);
    }

    /**
     * @return bool
     */
    public function shouldPluralizeTableName()
    {
        $pluralize = (bool) $this->config('pluralize', true);

        $overridePluralizeFor = $this->config('override_pluralize_for', []);
        if (count($overridePluralizeFor) > 0) {
            foreach ($overridePluralizeFor as $except) {
                if ($except == $this->getTable()) {
                    return ! $pluralize;
                }
            }
        }

        return $pluralize;
    }

    /**
     * @return bool
     */
    public function shouldLowerCaseTableName()
    {
        return (bool) $this->config('lower_table_name_first', false);
    }

    /**
     * @param \Reliese\Meta\Blueprint[] $references
     */
    public function withReferences($references)
    {
        $this->references = $references;
    }

    /**
     * @param string $namespace
     *
     * @return $this
     */
    public function withNamespace($namespace)
    {
        $this->namespace = $namespace;

        return $this;
    }

    /**
     * @return string
     */
    public function getNamespace()
    {
        return $this->namespace;
    }

    /**
     * @return string
     */
    public function getRelationNameStrategy()
    {
        return $this->relationNameStrategy;
    }

    /**
     * @return string
     */
    public function getBaseNamespace()
    {
        return $this->usesBaseFiles()
            ? $this->getNamespace().'\\Base'
            : $this->getNamespace();
    }

    /**
     * @param string $parent
     *
     * @return $this
     */
    public function withParentClass($parent)
    {
        $this->parentClass = '\\' . ltrim($parent, '\\');

        return $this;
    }

    /**
     * @return string
     */
    public function getParentClass()
    {
        return $this->parentClass;
    }

    /**
     * @return string
     */
    public function getQualifiedUserClassName()
    {
        return '\\'.$this->getNamespace().'\\'.$this->getClassName();
    }

    /**
     * @return string
     */
    public function getClassName()
    {
        // Model names can be manually overridden by users in the config file.
        // If a config entry exists for this table, use that name, rather than generating one.
        $overriddenName = $this->config('model_names.' . $this->getTable());
        if ($overriddenName) {
            return $overriddenName;
        }

        if ($this->shouldLowerCaseTableName()) {
            return Str::studly(Str::lower($this->getRecordName()));
        }

        return Str::studly($this->getRecordName());
    }

    /**
     * @return string
     */
    public function getRecordName()
    {
        if ($this->shouldPluralizeTableName()) {
            return Str::singular($this->removeTablePrefix($this->blueprint->table()));
        }

        return $this->removeTablePrefix($this->blueprint->table());
    }

    /**
     * @param bool $timestampsEnabled
     *
     * @return $this
     */
    public function withTimestamps($timestampsEnabled)
    {
        $this->timestamps = $timestampsEnabled;

        return $this;
    }

    /**
     * @return bool
     */
    public function usesTimestamps()
    {
        return $this->timestamps &&
               $this->blueprint->hasColumn($this->getCreatedAtField()) &&
               $this->blueprint->hasColumn($this->getUpdatedAtField());
    }

    /**
     * @param string $field
     *
     * @return $this
     */
    public function withCreatedAtField($field)
    {
        $this->CREATED_AT = $field;

        return $this;
    }

    /**
     * @return string
     */
    public function getCreatedAtField()
    {
        return $this->CREATED_AT;
    }

    /**
     * @return bool
     */
    public function hasCustomCreatedAtField()
    {
        return $this->usesTimestamps() &&
               $this->getCreatedAtField() != $this->getDefaultCreatedAtField();
    }

    /**
     * @return string
     */
    public function getDefaultCreatedAtField()
    {
        return Eloquent::CREATED_AT;
    }

    /**
     * @param string $field
     *
     * @return $this
     */
    public function withUpdatedAtField($field)
    {
        $this->UPDATED_AT = $field;

        return $this;
    }

    /**
     * @return string
     */
    public function getUpdatedAtField()
    {
        return $this->UPDATED_AT;
    }

    /**
     * @return bool
     */
    public function hasCustomUpdatedAtField()
    {
        return $this->usesTimestamps() &&
               $this->getUpdatedAtField() != $this->getDefaultUpdatedAtField();
    }

    /**
     * @return string
     */
    public function getDefaultUpdatedAtField()
    {
        return Eloquent::UPDATED_AT;
    }

    /**
     * @param bool $softDeletesEnabled
     *
     * @return $this
     */
    public function withSoftDeletes($softDeletesEnabled)
    {
        $this->softDeletes = $softDeletesEnabled;

        return $this;
    }

    /**
     * @return bool
     */
    public function usesSoftDeletes()
    {
        return $this->softDeletes &&
               $this->blueprint->hasColumn($this->getDeletedAtField());
    }

    /**
     * @param string $field
     *
     * @return $this
     */
    public function withDeletedAtField($field)
    {
        $this->DELETED_AT = $field;

        return $this;
    }

    /**
     * @return string
     */
    public function getDeletedAtField()
    {
        return $this->DELETED_AT;
    }

    /**
     * @return bool
     */
    public function hasCustomDeletedAtField()
    {
        return $this->usesSoftDeletes() &&
               $this->getDeletedAtField() != $this->getDefaultDeletedAtField();
    }

    /**
     * @return string
     */
    public function getDefaultDeletedAtField()
    {
        return 'deleted_at';
    }

    /**
     * @return array
     */
    public function getTraits()
    {
        $traits = $this->config('use', []);

        if (! is_array($traits)) {
            throw new \RuntimeException('Config use must be an array of valid traits to append to each model.');
        }

        if ($this->usesSoftDeletes()) {
            $traits = array_merge([SoftDeletes::class], $traits);
        }

        return $traits;
    }

    /**
     * @return bool
     */
    public function needsTableName()
    {
        return false === $this->shouldQualifyTableName() ||
            $this->shouldRemoveTablePrefix() ||
            $this->blueprint->table() != Str::plural($this->getRecordName()) ||
            ! $this->shouldPluralizeTableName();
    }

    /**
     * @return string
     */
    public function shouldRemoveTablePrefix()
    {
        return ! empty($this->tablePrefix);
    }

    /**
     * @param string $tablePrefix
     */
    public function withTablePrefix($tablePrefix)
    {
        $this->tablePrefix = $tablePrefix;
    }

    /**
     * @param string $relationNameStrategy
     */
    public function withRelationNameStrategy($relationNameStrategy)
    {
        $this->relationNameStrategy = $relationNameStrategy;
    }

    /**
     * @param string $table
     */
    public function removeTablePrefix($table)
    {
        if (($this->shouldRemoveTablePrefix()) && (substr($table, 0, strlen($this->tablePrefix)) == $this->tablePrefix)) {
            $table = substr($table, strlen($this->tablePrefix));
        }

        return $table;
    }

    /**
     * @param bool $showConnection
     */
    public function withConnection($showConnection)
    {
        $this->showConnection = $showConnection;
    }

    /**
     * @param string $connection
     */
    public function withConnectionName($connection)
    {
        $this->connection = $connection;
    }

    /**
     * @return bool
     */
    public function shouldShowConnection()
    {
        return (bool) $this->showConnection;
    }

    /**
     * @return string
     */
    public function getConnectionName()
    {
        return $this->connection;
    }

    /**
     * @return bool
     */
    public function hasCustomPrimaryKey()
    {
        return count($this->primaryKeys->columns) == 1 &&
               $this->getPrimaryKey() != $this->getDefaultPrimaryKeyField();
    }

    /**
     * @return string
     */
    public function getDefaultPrimaryKeyField()
    {
        return 'id';
    }

    /**
     * @todo: Improve it
     * @return string
     */
    public function getPrimaryKey()
    {
        if (empty($this->primaryKeys->columns)) {
            return;
        }

        return $this->primaryKeys->columns[0];
    }

    /**
     * @return string
     * @todo: check
     */
    public function getPrimaryKeyType()
    {
        return $this->primaryKeyColumn->type;
    }

    /**
     * @todo: Check whether it is necessary
     * @return bool
     */
    public function hasCustomPrimaryKeyCast()
    {
        return $this->getPrimaryKeyType() != $this->getDefaultPrimaryKeyType();
    }

    /**
     * @return string
     */
    public function getDefaultPrimaryKeyType()
    {
        return 'int';
    }

    /**
     * @return bool
     */
    public function doesNotAutoincrement()
    {
        return ! $this->autoincrement();
    }

    /**
     * @return bool
     */
    public function autoincrement()
    {
        if ($this->primaryKeyColumn) {
            return $this->primaryKeyColumn->autoincrement === true;
        }

        return false;
    }

    /**
     * @param $perPage
     */
    public function withPerPage($perPage)
    {
        $this->perPage = (int) $perPage;
    }

    /**
     * @return int
     */
    public function getPerPage()
    {
        return $this->perPage;
    }

    /**
     * @return bool
     */
    public function hasCustomPerPage()
    {
        return $this->perPage != $this->getDefaultPerPage();
    }

    /**
     * @return int
     */
    public function getDefaultPerPage()
    {
        return 15;
    }

    /**
     * @param string $format
     *
     * @return $this
     */
    public function withDateFormat($format)
    {
        $this->dateFormat = $format;

        return $this;
    }

    /**
     * @return string
     */
    public function getDateFormat()
    {
        return $this->dateFormat;
    }

    /**
     * @return bool
     */
    public function hasCustomDateFormat()
    {
        return $this->dateFormat != $this->getDefaultDateFormat();
    }

    /**
     * @return string
     */
    public function getDefaultDateFormat()
    {
        return 'Y-m-d H:i:s';
    }

    /**
     * @return string
     */
    public function getDefaultTablePrefix()
    {
        return '';
    }

    /**
     * @return string
     */
    public function getDefaultRelationNameStrategy()
    {
        return 'related';
    }

    /**
     * @return bool
     */
    public function hasCasts()
    {
        return ! empty($this->getCasts());
    }

    /**
     * @return array
     */
    public function getCasts()
    {
        if (
            array_key_exists($this->getPrimaryKey(), $this->casts) &&
            $this->autoincrement()
        ) {
            unset($this->casts[$this->getPrimaryKey()]);
        }

        return $this->casts;
    }

    /**
     * @return bool
     */
    public function hasDates()
    {
        return ! empty($this->getDates());
    }

    /**
     * @return array
     */
    public function getDates()
    {
        return array_diff(
            array_filter($this->casts, function (string $cast) {
                return $cast === 'datetime';
            }),
            [$this->CREATED_AT, $this->UPDATED_AT]
        );
    }

    /**
     * @return bool
     */
    public function usesSnakeAttributes()
    {
        return (bool) $this->config('snake_attributes', true);
    }

    /**
     * @return bool
     */
    public function doesNotUseSnakeAttributes()
    {
        return ! $this->usesSnakeAttributes();
    }

    /**
     * @return bool
     */
    public function hasHints()
    {
        return ! empty($this->getHints());
    }

    /**
     * @return array
     */
    public function getHints()
    {
        return $this->hints;
    }

    /**
     * @return array
     */
    public function getProperties()
    {
        return $this->properties;
    }

    /**
     * @param string $name
     *
     * @return bool
     */
    public function hasProperty($name)
    {
        return array_key_exists($name, $this->getProperties());
    }

    /**
     * @return \Reliese\Coders\Model\Relation[]
     */
    public function getRelations()
    {
        return $this->relations;
    }

    /**
     * @return bool
     */
    public function hasRelations()
    {
        return ! empty($this->relations);
    }

    /**
     * @return \Reliese\Coders\Model\Mutation[]
     */
    public function getMutations()
    {
        return $this->mutations;
    }

    /**
     * @param string $column
     *
     * @return bool
     */
    public function isHidden($column)
    {
        $attributes = $this->config('hidden', []);

        if (! is_array($attributes)) {
            throw new \RuntimeException('Config field [hidden] must be an array of attributes to hide from array or json.');
        }

        foreach ($attributes as $pattern) {
            if (Str::is($pattern, $column)) {
                return true;
            }
        }

        return false;
    }

    /**
     * @return bool
     */
    public function hasHidden()
    {
        return ! empty($this->hidden);
    }

    /**
     * @return array
     */
    public function getHidden()
    {
        return $this->hidden;
    }

    /**
     * @param string $column
     *
     * @return bool
     */
    public function isFillable($column)
    {
        $guarded = $this->config('guarded', []);

        if (! is_array($guarded)) {
            throw new \RuntimeException('Config field [guarded] must be an array of attributes to protect from mass assignment.');
        }

        $protected = [
            $this->getCreatedAtField(),
            $this->getUpdatedAtField(),
            $this->getDeletedAtField(),
        ];

        if ($this->primaryKeys->columns) {
            $protected = array_merge($protected, $this->primaryKeys->columns);
        }

        foreach (array_merge($guarded, $protected) as $pattern) {
            if (Str::is($pattern, $column)) {
                return false;
            }
        }

        return true;
    }

    /**
     * @return bool
     */
    public function hasFillable()
    {
        return ! empty($this->fillable);
    }

    /**
     * @return array
     */
    public function getFillable()
    {
        return $this->fillable;
    }

    /**
     * @return \Reliese\Meta\Blueprint
     */
    public function getBlueprint()
    {
        return $this->blueprint;
    }

    /**
     * @param \Illuminate\Support\Fluent $command
     *
     * @return bool
     */
    public function isPrimaryKey(Fluent $command)
    {
        foreach ((array) $this->primaryKeys->columns as $column) {
            if (! in_array($column, $command->columns)) {
                return false;
            }
        }

        return true;
    }

    /**
     * @param \Illuminate\Support\Fluent $command
     *
     * @return bool
     */
    public function isUniqueKey(Fluent $command)
    {
        return $this->blueprint->isUniqueKey($command);
    }

    /**
     * @return bool
     */
    public function usesBaseFiles()
    {
        return $this->config('base_files', false);
    }

    /**
     * @return bool
     */
    public function usesPropertyConstants()
    {
        return $this->config('with_property_constants', false);
    }

    public function usesColumnList()
    {
        return $this->config('with_column_list', false);
    }

    /**
     * @return int
     */
    public function indentWithSpace()
    {
        return (int) $this->config('indent_with_space', 0);
    }

    /**
     * @return bool
     */
    public function usesHints()
    {
        return $this->config('hints', false);
    }

    /**
     * @return bool
     */
    public function doesNotUseBaseFiles()
    {
        return ! $this->usesBaseFiles();
    }

    /**
     * @param string $key
     * @param mixed $default
     *
     * @return mixed
     */
    public function config($key = null, $default = null)
    {
        return $this->factory->config($this->getBlueprint(), $key, $default);
    }

    /**
     * @return bool
     */
    public function fillableInBaseFiles(): bool
    {
        return $this->config('fillable_in_base_files', false);
    }

    /**
     * @return bool
     */
    public function hiddenInBaseFiles(): bool
    {
        return $this->config('hidden_in_base_files', false);
    }

    /**
     * @return bool
     */
    public function definesReturnTypes()
    {
        return $this->definesReturnTypes;
    }
}
