<?php

namespace SimplyBook\Http\Entities;

use InvalidArgumentException;
use SimplyBook\Http\ApiClient;
use SimplyBook\Exceptions\FormException;
use SimplyBook\Exceptions\RestDataException;
use SimplyBook\Support\Utility\StringUtility;

/**
 * @SuppressWarnings("PHPMD.TooManyPublicMethods")
 * @SuppressWarnings("PHPMD.ExcessiveClassComplexity") This abstract class is
 * the blueprint for all Entities. By adding complexity here we can reduce
 * complexity in the actual Entity classes. This is by design.
 */
abstract class AbstractEntity
{
    /**
     * Return the name of the entity. This can either be a generic name that
     * represents the entity. Or a property like 'name' {@see Service}
     * or {@see ServiceProvider}
     */
    abstract public function getName(): string;

    /**
     * Get the remote endpoint URL for this entity. Used internally in the
     * entity class for easy access.
     */
    abstract public function getEndpoint(): string;

    /**
     * Get the internal endpoint URL for this entity. Used for registering
     * the REST API routes. For an example see:
     * {@see \SimplyBook\Http\Endpoints\AbstractCrudEndpoint::registerRoutes}
     */
    abstract public function getInternalEndpoint(): string;

    /**
     * Method should return an array of known errors per attribute. When an
     * entity is updates or created the remote could return error messages
     * per attribute that are not user-friendly. In those cases we translate
     * known errors this way. For the implementation
     * {@see \SimplyBook\Http\Endpoints\AbstractCrudEndpoint::buildTranslatedErrors}
     *
     * @example:
     * [
     *      'attribute_x' => [
     *          'not dynamic part of error string' => __('User friendly translation of error.', 'simplybook'),
     *      ],
     *      // Real example from the {@see Service} class:
     *      'duration' => [
     *          // "Duration is not multiple of '60' minutes"
     *          'is not multiple of' => __('Duration invalid. Please enter a valid number that is a multiple of your selected timeframe.', 'simplybook'),
     *       ]
     * ]
     */
    abstract public function getKnownErrors(): array;

    /**
     * The API client instance
     */
    protected ApiClient $client;

    /**
     * The entity's fillable attributes
     */
    protected array $fillable = [];

    /**
     * The entity's required attributes
     */
    protected array $required = [];

    /**
     * The entity's attributes
     */
    protected array $attributes = [];

    /**
     * The entity's changed attributes
     */
    protected array $dirtyAttributes = [];

    /**
     * Register the initialized state of this entity for dirty attributes
     * registration
     */
    protected bool $initializing = false;

    /**
     * Name of the primary key for this entity
     */
    protected string $primaryKey = 'id';

    /**
     * Entity constructor. Will always provide the API client to the child
     * entity class. It is used to do API requests.
     */
    public function __construct(ApiClient $client)
    {
        $this->client = $client;

        $this->registerConditionalProperties();
    }

    /**
     * Method is used so set the main identifier of a local entity object to
     * the one on the remote. Currently used to delete an entity by id
     * {@see AbstractCrudEndpoint}
     * @param mixed $value Usually a string value
     */
    public function setPrimaryValue($value): void
    {
        $this->attributes[$this->primaryKey] = $value;
    }

    /**
     * Method is used to register conditional properties or attributes
     * that should be available in the entity. It is called after the
     * constructor.
     *
     * @internal This method is intended to be overridden by child classes. For
     * example, when the entity has properties that should only be registered
     * when a special feature is used by the user in SimplyBook.me.
     */
    public function registerConditionalProperties(): void
    {
        /**
         *  @example
         *  if ($this->client->isSpecialFeatureEnabled('paid_events')) {
         *      $this->fillable[] = 'price';
         *      $this->required[] = 'price';
         *  }
         */
    }

    /**
     * Get the entity's attributes.
     */
    public function attributes(): array
    {
        return $this->attributes;
    }

    /**
     * Fill the entity from an array. Use `$firstInitialization` to determine if
     * this is the first time the entity is being initialized. If it is, the
     * `reset()` method will be called to clear any previous attributes
     * and changes to make sure the entity is in a clean state.
     */
    public function fill(array $attributes, bool $firstInitialization = true): void
    {
        if ($firstInitialization) {
            $this->reset();
            $this->enableFirstInitialize();
        }

        foreach ($this->fillableFromArray($attributes) as $key => $value) {
            if ($this->isFillable($key)) {
                $this->setAttribute($key, $value);
            }
        }

        if ($firstInitialization) {
            $this->disableFirstInitialize();
        }
    }

    /**
     * Register the current entity as initializing.
     */
    protected function enableFirstInitialize(): void
    {
        $this->initializing = true;
    }

    /**
     * Register the current entity as initialized.
     */
    protected function disableFirstInitialize(): void
    {
        $this->initializing = false;
    }

    /**
     * Get the fillable attributes of an array.
     */
    protected function fillableFromArray(array $attributes): array
    {
        if (count($this->fillable) > 0) {
            return array_intersect_key($attributes, array_flip($this->fillable));
        }

        return $attributes;
    }

    /**
     * Check if the key is fillable.
     * @param mixed $key
     */
    protected function isFillable($key): bool
    {
        return in_array($key, $this->fillable, true);
    }

    /**
     * Set an attribute and register it as changed if it is different from the
     * previous value.
     * @param mixed $value
     */
    protected function setAttribute(string $key, $value): void
    {
        $setterMethod = 'set' . StringUtility::snakeToPascalCase($key) . 'Attribute';

        if (method_exists($this, $setterMethod)) {
            $value = $this->$setterMethod($value);
        }

        // If the entity is initializing, we do not register changes
        if ($this->initializing) {
            $this->attributes[$key] = $value;
            return;
        }

        if (! isset($this->dirtyAttributes[$key])) {
            $this->dirtyAttributes[$key] = [
                'from' => $this->attributes[$key] ?? null,
                'to' => null,
            ];
        }

        $this->dirtyAttributes[$key]['to'] = $value;

        $this->attributes[$key] = $value;
    }

    /**
     * Check if the entity exists by checking if the primary key is set in
     * the attributes and is not empty.
     * @internal This does NOT check if the entity exists in the API!
     */
    public function exists(): bool
    {
        if (!array_key_exists($this->primaryKey, $this->attributes)) {
            return false;
        }

        return !empty($this->attributes[$this->primaryKey]);
    }

    /**
     * Determine if an attribute exists on the entity.
     */
    public function has(string $name): bool
    {
        return isset($this->attributes[$name]);
    }

    /**
     * Convert the entity to a JSON string.
     */
    public function json(): string
    {
        return json_encode($this->attributes());
    }

    /**
     * Validate the required attributes of the entity. Errors format should be
     * consistent with
     * {@see \SimplyBook\Http\Endpoints\AbstractCrudEndpoint::processAttributesException}
     *
     * @throws FormException
     */
    public function validate(): bool
    {
        $errors = [];

        // Store locally so we can modify it without affecting the Entity
        $required = $this->required;

        if ($this->exists()) {
            $required[] = $this->primaryKey;
        }

        foreach ($required as $attribute) {
            // No empty() check to prevent falsy matches
            $requiredFieldIsEmpty = (
                !isset($this->attributes[$attribute])
                || $this->attributes[$attribute] === ''
            );

            if ($requiredFieldIsEmpty) {
                $errors[$attribute] = [
                    __('Field is required.', 'simplybook'),
                ];
            }
        }

        if (!empty($errors)) {
            throw (new FormException())->setErrors($errors);
        }

        return true;
    }

    /**
     * Check if the entity has any dirty attributes.
     */
    public function isDirty(): bool
    {
        return !empty($this->dirtyAttributes);
    }

    /**
     * All keys that are changed in this entity.
     */
    public function getDirty(): array
    {
        return array_keys($this->dirtyAttributes);
    }

    /**
     * All changed keys with it values.
     */
    public function getDirtyValues(): array
    {
        return $this->dirtyAttributes;
    }

    /**
     * Clear the changed/dirty attribute in this entity.
     */
    public function clearDirty(): void
    {
        $this->dirtyAttributes = [];
    }

    /**
     * Check if the attribute is changed since the last save/update/create
     * action.
     */
    public function isAttributeDirty(string $attributeName): bool
    {
        if (array_key_exists($attributeName, $this->dirtyAttributes)) {
            return true;
        }

        return false;
    }

    /**
     * Reset all attributes and changes in this entity. This is useful when you
     * want to reset the entity to a clean state.
     */
    public function reset(): void
    {
        $this->attributes = [];
        $this->dirtyAttributes = [];
        $this->initializing = false;
    }

    /**
     * Get an attribute value.
     * @return mixed
     */
    public function __get(string $key)
    {
        if (isset($this->attributes[$key]) === false) {
            return null;
        }

        $getterMethod = 'get' . StringUtility::snakeToPascalCase($key) . 'Attribute';
        if (method_exists($this, $getterMethod)) {
            return $this->$getterMethod($this->attributes[$key]);
        }

        return $this->attributes[$key];
    }

    /**
     * Set an attribute value.
     * @param mixed $value
     */
    public function __set(string $key, $value): void
    {
        if ($this->isFillable($key)) {
            $this->setAttribute($key, $value);
        }
    }

    /**
     * Find an entity by its primary. If no primary is provided, use the current
     * instance. Throws an exception if the entity is not found.
     * @throws \Exception|RestDataException
     */
    public function find(string $primary = ''): AbstractEntity
    {
        $primary = ($primary ?: $this->{$this->primaryKey});

        $endpoint = trailingslashit($this->getEndpoint()) . sanitize_text_field($primary);
        $entityData = $this->client->get($endpoint);

        if (empty($entityData)) {
            throw new RestDataException('Entity not found');
        }

        $this->fill($entityData);
        return $this;
    }

    /**
     * Get all entities from the SimplyBook API.
     * @internal Override this method if you want to customize the logic.
     */
    public function all(): array
    {
        try {
            $response = $this->client->get($this->getEndpoint());
        } catch (\Throwable $e) {
            return [];
        }

        return ($response['data'] ?? []);
    }

    /**
     * Update the entity in the SimplyBook API. Exceptions should be handled
     * by the caller for specific error handling.
     * @throws FormException|RestDataException
     * @internal Override this method if you want to customize the logic.
     */
    public function update(): bool
    {
        $this->validate();

        $endpoint = trailingslashit($this->getEndpoint()) . sanitize_text_field($this->{$this->primaryKey});
        $this->client->put($endpoint, $this->json());

        return true;
    }

    /**
     * Delete the entity from the SimplyBook API. Either delete the current
     * instance or a specific entity by ID. Exceptions should be handled
     * by the caller for specific error handling.
     * @throws InvalidArgumentException|RestDataException
     * @internal Override this method if you want to customize the logic.
     */
    public function delete(string $primary = ''): bool
    {
        $primary = ($primary ?: $this->{$this->primaryKey});
        if (empty($primary)) {
            throw new InvalidArgumentException('Entity primary is required for deletion');
        }

        $endpoint = trailingslashit($this->getEndpoint()) . $primary;
        $this->client->delete($endpoint);

        return true;
    }

    /**
     * Create a new entity in the SimplyBook API. Use the attributes to
     * build the entity before validating and sending the request.
     * @throws InvalidArgumentException|RestDataException|FormException
     * @internal Override this method if you want to customize the logic.
     */
    public function create(array $attributes = []): AbstractEntity
    {
        if (!empty($attributes)) {
            $this->fill($attributes);
        }

        $this->validate();

        $response = $this->client->post(
            $this->getEndpoint(),
            $this->json()
        );

        if (empty($response[$this->primaryKey])) {
            throw new RestDataException('Failed to create new entity');
        }

        $this->{$this->primaryKey} = $response[$this->primaryKey];
        return $this;
    }
}
