<?php

namespace Dedoc\Scramble\Support\InferExtensions;

use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Dedoc\Scramble\Infer\AutoResolvingArgumentTypeBag;
use Dedoc\Scramble\Infer\Extensions\Event\MethodCallEvent;
use Dedoc\Scramble\Infer\Extensions\Event\PropertyFetchEvent;
use Dedoc\Scramble\Infer\Extensions\Event\StaticMethodCallEvent;
use Dedoc\Scramble\Infer\Extensions\MethodReturnTypeExtension;
use Dedoc\Scramble\Infer\Extensions\PropertyTypeExtension;
use Dedoc\Scramble\Infer\Extensions\StaticMethodReturnTypeExtension;
use Dedoc\Scramble\Infer\Scope\GlobalScope;
use Dedoc\Scramble\Infer\Services\ReferenceTypeResolver;
use Dedoc\Scramble\Support\ResponseExtractor\ModelInfo;
use Dedoc\Scramble\Support\Type\AbstractType;
use Dedoc\Scramble\Support\Type\ArrayItemType_;
use Dedoc\Scramble\Support\Type\ArrayType;
use Dedoc\Scramble\Support\Type\BooleanType;
use Dedoc\Scramble\Support\Type\Contracts\LiteralString;
use Dedoc\Scramble\Support\Type\FloatType;
use Dedoc\Scramble\Support\Type\Generic;
use Dedoc\Scramble\Support\Type\IntegerType;
use Dedoc\Scramble\Support\Type\KeyedArrayType;
use Dedoc\Scramble\Support\Type\Literal\LiteralStringType;
use Dedoc\Scramble\Support\Type\NullType;
use Dedoc\Scramble\Support\Type\ObjectType;
use Dedoc\Scramble\Support\Type\Reference\MethodCallReferenceType;
use Dedoc\Scramble\Support\Type\Reference\StaticMethodCallReferenceType;
use Dedoc\Scramble\Support\Type\StringType;
use Dedoc\Scramble\Support\Type\TemplateType;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\TypeWalker;
use Dedoc\Scramble\Support\Type\Union;
use Dedoc\Scramble\Support\Type\UnknownType;
use Illuminate\Database\Eloquent\Attributes\UseResource;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use ReflectionClass;
use Throwable;

class ModelExtension implements MethodReturnTypeExtension, PropertyTypeExtension, StaticMethodReturnTypeExtension
{
    private static $cache;

    public function shouldHandle(ObjectType|string $type): bool
    {
        if (is_string($type)) {
            return is_a($type, Model::class, true);
        }

        return $type->isInstanceOf(Model::class);
    }

    public function hasProperty(ObjectType $type, string $name)
    {
        $info = $this->getModelInfo($type);

        return $info->get('attributes')->has($name) || $info->get('relations')->has($name);
    }

    public function getPropertyType(PropertyFetchEvent $event): ?Type
    {
        if (! $this->hasProperty($event->getInstance(), $event->getName())) {
            return null;
        }

        $info = $this->getModelInfo($event->getInstance());

        if ($attribute = $info->get('attributes')->get($event->getName())) {
            $baseType = $this->getAttributeTypeFromEloquentCasts($attribute['cast'] ?? '')
                ?? $this->getAttributeTypeFromDbColumnType($attribute['type'], $attribute['driver'])
                ?? new UnknownType("Virtual attribute ({$attribute['name']}) type inference not supported.");

            if ($attribute['nullable']) {
                return Union::wrap([$baseType, new NullType]);
            }

            return $baseType;
        }

        if ($relation = $info->get('relations')->get($event->getName())) {
            return $this->getRelationType($relation);
        }

        throw new \LogicException('Should not happen');
    }

    /**
     * MySQL/MariaDB decimal is mapped to a string by PDO.
     * Floating point numbers and decimals are all mapped to strings when using the pgsql driver.
     */
    private function getAttributeTypeFromDbColumnType(?string $columnType, ?string $dbDriverName): ?AbstractType
    {
        if ($columnType === null) {
            return null;
        }

        $typeName = str($columnType)
            ->before(' ') // strip modifiers from a type name such as `bigint unsigned`
            ->before('(') // strip the length from a type name such as `tinyint(4)`
            ->toString();

        if (in_array($typeName, ['int', 'integer', 'tinyint', 'smallint', 'mediumint', 'bigint'])) {
            return new IntegerType;
        }

        if ($dbDriverName === 'sqlite' && in_array($typeName, ['float', 'double', 'decimal'])) {
            return new FloatType;
        }

        if (in_array($dbDriverName, ['mysql', 'mariadb']) && in_array($typeName, ['float', 'double'])) {
            return new FloatType;
        }

        return new StringType;
    }

    /**
     * @todo Add support for custom castables.
     */
    private function getAttributeTypeFromEloquentCasts(string $cast): ?AbstractType
    {
        if ($cast && enum_exists($cast)) {
            return new ObjectType($cast);
        }

        $castAsType = Str::before($cast, ':');
        $castAsParameters = str($cast)->after("{$castAsType}:")->explode(',');

        if (Str::startsWith($castAsType, 'encrypted:')) {
            $castAsType = $castAsParameters->first(); // array, collection, json, object
        }

        return match ($castAsType) {
            'array', 'json' => new ArrayType,
            'real', 'float', 'double' => new FloatType,
            'int', 'integer', 'timestamp' => new IntegerType,
            'bool', 'boolean' => new BooleanType,
            'string', 'decimal' => new StringType,
            'object' => new ObjectType('\stdClass'),
            'collection' => new ObjectType(Collection::class),
            'Illuminate\Database\Eloquent\Casts\AsEnumCollection' => new Generic(Collection::class, [
                new IntegerType, // @todo array-key
                new TemplateType($castAsParameters->first()),
            ]),
            'date', 'datetime', 'custom_datetime' => $this->addDateFormatForCast(new ObjectType(Carbon::class), $castAsParameters),
            'immutable_date', 'immutable_datetime', 'immutable_custom_datetime' => $this->addDateFormatForCast(new ObjectType(CarbonImmutable::class), $castAsParameters),
            default => null,
        };
    }

    /**
     * @param  Collection<int, string>  $castAsParameters
     */
    private function addDateFormatForCast(ObjectType $type, Collection $castAsParameters): ObjectType
    {
        $format = match ($castAsParameters->first()) {
            'Y-m-d' => 'date',
            default => null,
        };

        if ($format) {
            $type->setAttribute('format', $format);
        }

        return $type;
    }

    private function getRelationType(array $relation): Type
    {
        if ($isManyRelation = Str::contains($relation['type'], 'Many')) {
            return ModelCollectionTypeResolver::resolve(new ObjectType($relation['related']));
        }

        return new ObjectType($relation['related']);
    }

    protected function getToArrayMethodReturnType(MethodCallEvent $event): ?Type
    {
        if ($this->getRealToArrayMethodDefinitionClassName($event) !== Model::class) {
            return null;
        }

        $info = $this->getModelInfo($event->getInstance());

        /** @var Model|null $instance */
        $instance = $info->get('instance');
        if (! $instance) {
            return null;
        }

        $arrayableAttributesTypes = $info->get('attributes', collect())
            ->when($instance->getVisible(), fn ($c, $visible) => $c->only($visible))
            ->when($instance->getHidden(), fn ($c, $visible) => $c->except($visible))
            ->filter(fn ($attr) => $attr['appended'] !== false)
            ->map(function ($_, $name) use ($event) {
                $propertyType = $event->getInstance()->getPropertyType($name);

                (new TypeWalker)->replace($propertyType, function (Type $t) {
                    return ($t->isInstanceOf(Carbon::class) || $t->isInstanceOf(CarbonImmutable::class))
                        ? tap(new StringType, fn ($type) => $type->setAttribute('format', $t->getAttribute('format') ?? 'date-time'))
                        : null;
                });

                return $propertyType;
            });

        $arrayableRelationsTypes = $info->get('relations', collect())
            ->only($this->getProtectedValue($instance, 'with'))
            ->when($instance->getVisible(), fn ($c, $visible) => $c->only($visible))
            ->when($instance->getHidden(), fn ($c, $visible) => $c->except($visible))
            ->map(function ($_, $name) use ($event) {
                return $event->getInstance()->getPropertyType($name);
            });

        return new KeyedArrayType([
            ...$arrayableAttributesTypes->map(fn ($type, $name) => new ArrayItemType_($name, $type))->values()->all(),
            ...$arrayableRelationsTypes->map(fn ($type, $name) => new ArrayItemType_($name, $type, isOptional: true))->values()->all(),
        ]);
    }

    protected function getGetOriginalMethodReturnType(MethodCallEvent $event): ?Type
    {
        $key = $event->getArg('key', 0);

        if (! $key instanceof LiteralStringType) {
            return null;
        }

        return $this->getPropertyType(
            new PropertyFetchEvent($event->getInstance(), $key->value, $event->scope)
        );
    }

    public function getMethodReturnType(MethodCallEvent $event): ?Type
    {
        return match ($event->getName()) {
            'toArray' => $this->getToArrayMethodReturnType($event),
            'getOriginal' => $this->getGetOriginalMethodReturnType($event),
            'resolveResourceFromAttribute' => $this->getResolveResourceFromAttributeMethodReturnType($event),
            'guessResource' => $this->getGuessResourceMethodReturnType($event),
            'toResource' => $this->getToResourceMethodReturnType($event),
            default => $this->maybeProxyMethodCallToBuilder($event),
        };
    }

    protected function getToResourceMethodReturnType(MethodCallEvent $event): ?Type
    {
        $resourceClassArg = $event->getArg('resourceClass', 0);

        if ($resourceClassArg instanceof LiteralString) {
            return $this->makeResource($resourceClassArg->getValue(), $event->getInstance());
        }

        return $this->getGuessResourceMethodReturnType($event);
    }

    protected function makeResource(string $resourceClass, ObjectType $model): ?ObjectType
    {
        $resource = ReferenceTypeResolver::getInstance()
            ->resolve(
                new GlobalScope,
                new StaticMethodCallReferenceType($resourceClass, 'make', [$model])
            );

        if (! $resource instanceof ObjectType) {
            return null;
        }

        return $resource;
    }

    protected function getGuessResourceMethodReturnType(MethodCallEvent $event): ?Type
    {
        $resourceClass = $this->resolveResourceFromAttribute($event->getInstance()->name);

        if (is_string($resourceClass) && class_exists($resourceClass)) {
            return $this->makeResource($resourceClass, $event->getInstance());
        }

        try {
            /** @var array<string> $candidates */
            $candidates = $event->getInstance()->name::guessResourceName();
            foreach ($candidates as $candidate) {
                if (is_string($candidate) && class_exists($candidate)) { // @phpstan-ignore function.alreadyNarrowedType
                    return $this->makeResource($candidate, $event->getInstance());
                }
            }
        } catch (\Throwable) {
        }

        return null;
    }

    protected function getResolveResourceFromAttributeMethodReturnType(MethodCallEvent $event): Type
    {
        $result = $this->resolveResourceFromAttribute($event->getInstance()->name);

        return $result ? new LiteralStringType($result) : new NullType;
    }

    protected function resolveResourceFromAttribute(string $modelClassName): ?string
    {
        if (! class_exists(UseResource::class)) {
            return null;
        }

        if (! class_exists($modelClassName)) {
            return null;
        }

        $attributes = (new ReflectionClass($modelClassName))->getAttributes(UseResource::class);

        return $attributes !== []
            ? $attributes[0]->newInstance()->class
            : null;
    }

    public function getStaticMethodReturnType(StaticMethodCallEvent $event): ?Type
    {
        return match ($event->name) {
            'all' => ModelCollectionTypeResolver::resolve(new ObjectType($event->callee)),
            default => $this->maybeProxyMethodCallToBuilder($event),
        };
    }

    private function maybeProxyMethodCallToBuilder(MethodCallEvent|StaticMethodCallEvent $event): ?Type
    {
        if (! $definition = $event->getDefinition()) {
            return null;
        }

        if ($definition->hasMethodDefinition($event->getName())) {
            return null;
        }

        $referenceCall = new MethodCallReferenceType(
            new Generic(Builder::class, [new ObjectType($definition->name)]),
            $event->getName(),
            $event->arguments instanceof AutoResolvingArgumentTypeBag ? $event->arguments->allUnresolved() : $event->arguments->all(),
        );

        return ReferenceTypeResolver::getInstance()->resolve($event->scope, $referenceCall);
    }

    private function getModelInfo(ObjectType $type)
    {
        return static::$cache[$type->name] ??= (new ModelInfo($type->name))->handle();
    }

    private function getProtectedValue($obj, $name)
    {
        $array = (array) $obj;
        $prefix = chr(0).'*'.chr(0);

        return $array[$prefix.$name];
    }

    /**
     * Due to vendor classes being not analyzed for now, we may have a situation when defining class name in event is not
     * truly represents the location of the method. But we want to make sure to get it right.
     */
    private function getRealToArrayMethodDefinitionClassName(MethodCallEvent $event)
    {
        $className = $event->methodDefiningClassName ?: $event->getInstance()->name;

        try {
            $reflectionMethod = new \ReflectionMethod($className, 'toArray');

            return $reflectionMethod->getDeclaringClass()->getName();
        } catch (Throwable) {
        }

        return $event->methodDefiningClassName;
    }
}
