<?php

namespace Velis\Dto\Validators;

use Attribute;
use Throwable;
use Velis\Dto\BaseDto;
use Velis\Dto\Casters\Castable;
use Velis\Dto\Exceptions\ValidationException;
use Velis\Filter;
use Velis\Model\DataObject;

/**
 * This class is a bit weird, so please, read this description carefully for the best understanding of this case.
 * This class is responsible for validating if the given value is an array of a specific type.
 * The type can be a primitive type (int, float, string, bool) or a class that extends BaseDto.
 * If the type is a class that extends BaseDto, the validator will try to cast each item of the array
 * to the given class - so this class, in addition to being a Validator, is also a Caster,
 * that's why it implements both: Validatable and Castable interfaces.
 *
 * Hope that one day, PHP develops its own generics, so we can avoid this kind of weirdness and allow developers
 * to pass more advanced types than just basic `array` as a property type.
 *
 * @author Jakub Szczugieł <jakub.szczugiel@velistech.com>
 * @author Szymon Janaczek <szymon.janaczek@velistech.com>
 */
#[Attribute]
class ArrayOf extends BaseValidator implements Validatable, Castable
{
    public const TYPE_INT = 'integer';
    public const TYPE_FLOAT = 'double';
    public const TYPE_STRING = 'string';
    public const TYPE_BOOL = 'boolean';
    public string $message = 'Property must be an array of %s';

    public function __construct(
        public string $type,
        readonly bool $notEmpty = false
    ) {
    }

    public function validate($value): bool
    {
        if (!is_array($value)) {
            return false;
        }

        if ($this->notEmpty && $value === []) {
            return false;
        }

        if (in_array($this->type, self::getSupportedPrimitives())) {
            foreach ($value as $item) {
                if (!$this->validatePrimitive($item)) {
                    return false;
                }
            }
        } elseif (is_subclass_of($this->type, BaseDto::class)) {
            try {
                foreach ($value as $item) {
                    $class = $this->type;
                    $this->castItem($item, $class);
                }
            } catch (ValidationException $e) {
                $this->message .= '. ' . $e->getErrors()[0]['message'];
                return false;
            } catch (Throwable $e) {
                return false;
            }
        } else {
            return false;
        }

        return true;
    }

    public function getMessage(): string
    {
        return sprintf($this->message, $this->type);
    }

    /**
     * This method comes from Validatable interface - so this is the part, when the validator begins to act as a caster.
     * It happens because of the need for casting each item of the array to the given class (for example, a DTO).
     * @throws ValidationException
     */
    public function get($value, $data = null): ?array
    {
        // If the value is null, return null.
        // This field was previously validated by this validator, so it's safe to assume that if this value is null
        // at this point, it is allowed to be null.
        if (is_null($value)) {
            return null;
        }

        if (!in_array($this->type, self::getSupportedPrimitives())) {
            foreach ($value as &$item) {
                $class = $this->type;
                $item = $this->castItem($item, $class);
            }
        }

        return array_values($value);
    }

    /**
     * @return string[]
     */
    private static function getSupportedPrimitives(): array
    {
        return [
            self::TYPE_INT,
            self::TYPE_FLOAT,
            self::TYPE_STRING,
            self::TYPE_BOOL,
        ];
    }

    private function validatePrimitive(mixed $value): bool
    {
        $valueType = gettype($value);

        if (self::TYPE_STRING === $valueType) {
            if (self::TYPE_INT === $this->type) {
                return Filter::filterInt($value) == $value;
            }

            if (self::TYPE_FLOAT === $this->type) {
                return floatval($value) == $value;
            }
        }

        return $valueType === $this->type;
    }

    /**
     * This method is responsible for casting each item of the array to the given class (for example, a DTO).
     * @throws ValidationException
     */
    private function castItem($item, $class)
    {
        if ($item instanceof $this->type) {
            return $item;
        }

        if (is_array($item) && reset($item) instanceof DataObject) {
            return $class::fromModels($item);
        }

        if (is_array($item)) {
            return $class::fromArray($item);
        }

        if ($item instanceof DataObject) {
            return $class::fromModel($item);
        }

        if ($item instanceof Filter) {
            return $class::fromFilter($item);
        }

        throw new ValidationException('Invalid type of the array item');
    }
}
