<?php

namespace Velis\Dto;

use Exception;
use JsonSerializable;
use ReflectionException;
use ReflectionProperty;
use Serializable;
use stdClass;
use TypeError;
use Velis\Dto\Exceptions\ValidationException;
use Velis\Dto\RelationTransformer\Relation;
use Velis\Dto\Traits\CastTrait;
use Velis\Dto\Traits\RelationTrait;
use Velis\Dto\Traits\ValidationTrait;
use Velis\Dto\Utility\Computed;
use Velis\Dto\Utility\Optional;
use Velis\Dto\Utility\PropertyUtility;
use Velis\Dto\Utility\Unfiltered;
use Velis\Filter;
use Velis\Model\DataObject;

/**
 * This is a base DTO class.
 * It contains logic responsible for:
 * - creating DTO:
 *   - from Model
 *   - from Filter instance
 *   - from an array.
 * - casting values using Casters
 * - loading relations and transforming them into proper DTOs
 * - validation before returning
 *
 * @author Szymon Janaczek <szymon.janaczek@velistech.com>
 */
abstract class BaseDto implements Serializable, JsonSerializable
{
    use CastTrait;
    use RelationTrait;
    use ValidationTrait;

    /**
     * Available only when creating DTO from a model.
     */
    protected DataObject $model;

    /**
     * Available only when creating Dto from the request.
     */
    protected Filter $filter;

    /**
     * Defines the only fields we want to include, while converting DTO to array.
     * @var string[]
     */
    private array $only = [];

    /**
     * Defines fields we want to exclude by default, while converting DTO to array.
     * @var string[]
     */
    private array $exclude = ['filter', 'model'];

    /**
     * @throws ReflectionException
     * @throws ValidationException
     */
    private function __construct(array $data)
    {
        $properties = PropertyUtility::getPublicProperties($this);

        // iterate through all public properties
        foreach ($properties as $property) {
            // if property is computed, skip.
            $propertyName = $property->getName();
            $isComputed = count($property->getAttributes(Computed::class)) > 0;
            $isRelation = count($property->getAttributes(Relation::class)) > 0;
            if ($isComputed || $isRelation) {
                continue;
            }

            // if data for the property is provided
            if (array_key_exists($propertyName, $data)) {
                // data for the property was provided.
                $value = $data[$propertyName];

                /** @var array<class-string<BaseDto>>|false $dto */
                $dto = PropertyUtility::isDto($property);
                if (is_array($dto)) {
                    foreach ($dto as $class) {
                        try {
                            $this->hydrate($propertyName, $class, $value);
                            continue(2);
                        } catch (Exception) {
                        }
                    }
                }

                // If the property has a simple type, validate it, cast and assign.
                $this->validateField($property, $value, $data);
                $this->assignProperty($property, $value);

                continue;
            }

            // If data for a property is not passed, but the property has a default value, assign it.
            if ($property->hasDefaultValue()) {
                $this->$propertyName = $property->getDefaultValue();
                continue;
            }

            // If data for a property is not passed and has not a default value,
            // but is optional, assign Optional::set() to it.
            if (PropertyUtility::isOptional($property)) {
                $this->$propertyName = Optional::set();
                continue;
            }

            // If data for a property is not passed, and the property is neither optional nor has default value,
            // throw an exception.
            $errors = [
                [
                    'property' => $propertyName,
                    'message' => "Property $propertyName is required",
                ],
            ];
            throw new ValidationException("Property $propertyName is required", $errors);
        }
    }

    /**
     * Probably it will be the most often overwritten method of this class.
     * Here we can define how to transform the model into the array.
     * It's useful when we want to transform the model into the array in a different way
     * than just using `->getArrayCopy()` - or perform any additional operations.
     * For example, if model has translations, and we want to transform them into the Translation DTOs.
     * So, instead of overwriting `::fromModel` factory method when we want to manipulate the way of transforming model
     * to array, use this method.
     * This method is called in the `::fromModel` factory method just before DTO initialization.
     *
     * @return array<string, mixed>
     */

    protected static function normalizeModel(DataObject $model): array
    {
        return $model->getArrayCopy();
    }

    /**
     * It does the same as `::normalizeModel` method, but for the filter.
     * @return array<string, mixed>
     * @throws ReflectionException
     * @see BaseDto::normalizeModel
     */
    protected static function normalizeFilter(Filter $filter): array
    {
        $unfilteredPropertyNames = static::getUnfilteredPropertyNames();

        return $filter->getArrayCopy($unfilteredPropertyNames);
    }

    /**
     * @return array<string>
     * @throws ReflectionException
     */
    private static function getUnfilteredPropertyNames(): array
    {
        $properties = PropertyUtility::getPublicProperties(static::class);
        $unfilteredPropertyNames = [];

        foreach ($properties as $property) {
            if (PropertyUtility::hasAttribute($property, Unfiltered::class)) {
                $unfilteredPropertyNames[] = $property->getName();
            }
        }

        return $unfilteredPropertyNames;
    }

    /**
     * Factory method to create DTO from the model being an instance of the DataObject class.
     * In this initialization way, we can load relations and transform them into proper DTOs.
     * Additionally, save the model in the DTO, so we can use it later.
     * @throws Exception
     */
    public static function fromModel(DataObject $model): static
    {
        // Before next steps, we have to normalize model data - transform it into an array,
        // considering user instructions.
        $data = static::normalizeModel($model);

        // Set the model to the DTO, so to load relations and transform them into proper DTOs, or use RAW data later.
        // Before initialization, we have to define the model, to be able to use its value in the format() method
        // and loadRelations() in the constructor.
        $instance = new static($data);
        $instance->model = $model;
        $instance->loadRelations();

        // When model's data is loaded, we can format.
        $instance->init();

        // Initialize a new instance of the DTO with the model's data.
        return $instance;
    }

    /**
     * It's a factory method, actually being a wrapper for the `fromModel()` method – to handle creating DTO from
     * the array of models.
     * Helpful when transforming the output of the e.g., `listAll()` into DTOs
     *
     * @param DataObject[] $models
     * @return static[]
     * @throws Exception
     */
    public static function fromModels(array $models): array
    {
        $result = [];
        foreach ($models as $model) {
            $result[] = static::fromModel($model);
        }

        return $result;
    }

    /**
     * It's a factory method to create DTO from the filtered request body data.
     *
     * @throws ValidationException
     */
    public static function fromFilter(Filter $filter): static
    {
        $data = static::normalizeFilter($filter);

        // Before initialization, we have to define the filter, to be able to use its value in the format() method.
        $instance = new static($data);
        $instance->filter = $filter;

        // When filter's data is loaded, we can format and validate it.
        $instance->init();

        return $instance;
    }

    /**
     * It's a factory method to create DTO from the array (raw data).
     *
     * @throws ValidationException
     */
    public static function fromArray(array $data): static
    {
        $instance = new static($data);

        // When data loaded, we can format and validate it.
        $instance->init();

        return $instance;
    }

    /**
     * Use this method, when you want to create an array of DTOs from an array of arrays.
     * It's a wrapper for the `fromArray()` method.
     * Example:
     * ```php
     * $data = [
     *     ['name' => 'John', 'surname' => 'Doe'],
     *     ['name' => 'Adam', 'surname' => 'Smith']
     * ];
     *
     * $dtos = MyDto::fromArrays($data);
     * ```
     * @param array[] $arrays
     * @return static[]
     * @throws ValidationException
     */
    public static function fromArrays(array $arrays): array
    {
        return array_map(fn ($array) => static::fromArray($array), $arrays);
    }

    /**
     * Basic startup actions.
     * Common for all initialization methods.
     * Format the DTO.
     */
    protected function init(): void
    {
        $this->format();
    }

    /**
     * Converts the DTO object to an array.
     *
     * This method first converts the DTO to an array using the dtoToArray method.
     * It then clears the 'only' and 'exclude' properties of the DTO.
     *
     * @return array<string, mixed> The converted DTO as an array.
     */
    public function toArray(): array
    {
        $result = $this->dtoToArray($this);
        $this->only = [];
        $this->exclude = [];

        return $result;
    }

    /**
     * Converts a BaseDto instance to an array.
     *
     * This method skips optional and excluded values.
     * If only fields are defined, only those fields will be returned.
     *
     * @param BaseDto $dto The DTO instance to convert.
     * @return array<string, mixed> The converted DTO as an array.
     */
    private function dtoToArray(BaseDto $dto): array
    {
        $result = [];
        foreach (get_object_vars($dto) as $key => $value) {
            if ($this->shouldSkip($key, $dto)) {
                continue;
            }

            $result[$key] = $this->convertToArray($value);
        }

        return $result;
    }

    /**
     * Determines whether a key should be skipped during the conversion process.
     *
     * This method checks if a key is in the list of excluded keys or if it's not in the list of only keys.
     * It also checks if the key corresponds to an optional value or a BaseDto instance.
     *
     * @param string $key The key to check.
     * @param BaseDto $dto The DTO instance.
     * @return bool True if the key should be skipped, false otherwise.
     */
    private function shouldSkip(string $key, BaseDto $dto): bool
    {
        $skipKeys = array_merge($dto->exclude, ['exclude', 'only', 'filter', 'model']);
        if (in_array($key, $skipKeys)) {
            return true;
        }

        if (count($dto->only) > 0 && !in_array($key, $dto->only)) {
            return true;
        }

        if ($dto->$key instanceof Optional) {
            return true;
        }

        return false;
    }

    /**
     * Converts any data to an array.
     *
     * This method handles the conversion of objects and arrays.
     * If the data is an instance of BaseDto, it uses the dtoToArray method for the conversion.
     * If data is a simple type (int, string, bool, etc.), return it.
     *
     * @param mixed $data The data to convert.
     * @return array The converted data as an array.
     */
    private function convertToArray(mixed $data): mixed
    {
        if (is_object($data) && !($data instanceof BaseDto)) {
            $data = get_object_vars($data);
        }

        if (is_array($data)) {
            return array_map([$this, 'convertToArray'], $data);
        }

        if ($data instanceof BaseDto) {
            $serializedData = $this->dtoToArray($data);
            // If we have a DTO with all Optional and empty values, we should return an empty object instead of an empty array.
            if (is_array($serializedData) && empty($serializedData)) {
                return new stdClass();
            }
            return $serializedData;
        }

        return $data;
    }

    /**
     * Define the only fields to be transformed into array.
     *
     * @param string[] $fields
     */
    public function only(array $fields): static
    {
        $this->only = $fields;

        return $this;
    }

    /**
     * Define the fields to be excluded from transforming into the array.
     *
     * @param string[] $fields
     */
    public function exclude(array $fields): static
    {
        $this->exclude = $fields;

        return $this;
    }


    /**
     * It's the method where we can do some "magic" with our DTO before it's validated and returned as an output.
     * For example, we can set a full name from name and surname or append more data.
     *
     * Validation is performed after this method.
     */
    protected function format(): void
    {
    }

    /**
     * @throws ValidationException
     */
    private function assignProperty(ReflectionProperty $property, $value)
    {

        $propertyName = $property->getName();
        try {
            $this->$propertyName = $this->cast($propertyName, $value);
        } catch (TypeError) {
            $errorMsg = "Property $propertyName should be of type " . $property->getType();
            $errors = [
                [
                    'property' => $propertyName,
                    'message' => $errorMsg,
                ],
            ];
            throw new ValidationException($errorMsg, $errors);
        }
    }

    /**
     * Hydrates the DTO with the given value.
     */
    private function hydrate(string $propertyName, string $dtoClass, $value): void
    {
        // If hydrating DTO with array data, use fromArray() builder.
        if (is_array($value)) {
            $this->$propertyName = $dtoClass::fromArray($value);
            return;
        }

        // If hydrating DTO with model data, use fromModel() builder.
        if ($value instanceof DataObject) {
            $this->$propertyName = $dtoClass::fromModel($value);
            return;
        }

        // If hydrating DTO with request data, use fromFilter() builder.
        if ($value instanceof Filter) {
            $this->$propertyName = $dtoClass::fromFilter($value);
            return;
        }

        throw new Exception("Unsupported data type for hydrating DTO");
    }

    public function serialize(): array
    {
        return $this->toArray();
    }

    public function unserialize($serialized): void
    {
        $this->__construct($serialized);
    }

    public function __serialize(): array
    {
        return $this->toArray();
    }

    /**
     * @param array<string, mixed> $data
     * @throws ReflectionException
     * @throws ValidationException
     */
    public function __unserialize(array $data): void
    {
        $this->__construct($data);
    }

    public function jsonSerialize(): array
    {
        return $this->toArray();
    }
}
