<?php

declare(strict_types=1);

namespace Phan\Language\Type;

use InvalidArgumentException;
use Phan\CodeBase;
use Phan\Debug\Frame;
use Phan\Exception\RecursionDepthException;
use Phan\Language\Type;
use Phan\Language\UnionType;
use Phan\Language\UnionTypeBuilder;

/**
 * A temporary representation of `array<KeyType, T1|T2...>`
 *
 * Callers should split this up into multiple GenericArrayType instances.
 *
 * This is generated from phpdoc `array<int, T1|T2>` where callers expect a subclass of Type.
 * @phan-pure
 */
final class GenericMultiArrayType extends ArrayType implements MultiType, GenericArrayInterface
{
    /** @phan-override */
    public const NAME = 'array';

    /**
     * @var non-empty-list<Type>
     * The list of possible types of every element in this array (2 or more)
     */
    private $element_types;

    /**
     * @var int
     * Corresponds to the type of the array keys. Set this to a GenericArrayType::KEY_* constant.
     */
    private $key_type;

    /**
     * @var bool
     * True if the array will have one or more elements.
     */
    private $always_has_elements;

    /**
     * @var bool
     * True if the array will have consecutive keys starting from 0
     */
    private $is_list;

    /**
     * @var bool
     * True if the array will not have consecutive keys starting from 0
     */
    private $is_associative;

    /**
     * @param non-empty-list<Type> $types
     * The 2 or more possible types of every element in this array
     *
     * @param bool $is_nullable
     * Set to true if the type should be nullable, else pass false
     *
     * @param int $key_type
     * Corresponds to the type of the array keys. Set this to a GenericArrayType::KEY_* constant.
     *
     * @param bool $always_has_elements
     * True if the array will have one or more elements.
     *
     * @param bool $is_list
     * True if the array will have consecutive keys starting from 0
     *
     * @throws InvalidArgumentException if there are less than 2 types in $types
     */
    protected function __construct(
        array $types,
        bool $is_nullable,
        int $key_type,
        bool $always_has_elements = false,
        bool $is_list = false,
        bool $is_associative = false
    ) {
        if (\count($types) < 2) {
            throw new InvalidArgumentException('Expected $types to have at least 2 array elements');
        }
        // Could de-duplicate, but callers should be able to do that as well when converting to UnionType.
        // E.g. array<int|int> is int[].
        parent::__construct('\\', self::NAME, [], false);
        $this->element_types = $types;
        $this->is_nullable = $is_nullable;
        $this->key_type = $key_type;
        $this->always_has_elements = $always_has_elements;
        $this->is_list = $is_list;
        $this->is_associative = $is_associative;
    }

    /**
     * @param bool $is_nullable
     * Set to true if the type should be nullable, else pass
     * false
     *
     * @return Type
     * A new type that is a copy of this type but with the
     * given nullability value.
     */
    public function withIsNullable(bool $is_nullable): Type
    {
        if ($is_nullable === $this->is_nullable) {
            return $this;
        }

        return GenericMultiArrayType::fromElementTypes(
            $this->element_types,
            $is_nullable,
            $this->key_type,
            $this->always_has_elements,
            $this->is_list,
            $this->is_associative
        );
    }

    /**
     * @return non-empty-list<GenericArrayType>
     * @override
     */
    public function asIndividualTypeInstances(): array
    {
        return \array_map(function (Type $type): GenericArrayType {
            if ($this->always_has_elements) {
                if ($this->is_list) {
                    return NonEmptyListType::fromElementType($type, $this->is_nullable);
                } elseif ($this->is_associative) {
                    return NonEmptyAssociativeArrayType::fromElementType($type, $this->is_nullable, $this->key_type);
                }
                return NonEmptyGenericArrayType::fromElementType($type, $this->is_nullable, $this->key_type);
            } else {
                if ($this->is_list) {
                    return ListType::fromElementType($type, $this->is_nullable, $this->key_type);
                } elseif ($this->is_associative) {
                    return AssociativeArrayType::fromElementType($type, $this->is_nullable, $this->key_type);
                }
                return GenericArrayType::fromElementType($type, $this->is_nullable, $this->key_type);
            }
        }, UnionType::normalizeMultiTypes($this->element_types));
    }

    /**
     * Public creator of GenericMultiArrayType instances
     *
     * @param non-empty-list<Type> $element_types
     * @param bool $is_nullable
     * @param int $key_type
     * @param bool $always_has_elements
     * @param bool $is_list
     */
    public static function fromElementTypes(
        array $element_types,
        bool $is_nullable,
        int $key_type,
        bool $always_has_elements = false,
        bool $is_list = false,
        bool $is_associative = false
    ): GenericMultiArrayType {
        return new self($element_types, $is_nullable, $key_type, $always_has_elements, $is_list, $is_associative);
    }

    /**
     * @return bool
     * True if this Type can be cast to the given Type
     * cleanly
     */
    protected function canCastToNonNullableType(Type $type): bool
    {
        if ($type instanceof GenericArrayType) {
            return $this->genericArrayElementUnionType()->canCastToUnionType(
                $type->genericArrayElementUnionType()
            );
        }

        // TODO: More precise about checking if can cast to ArrayShapeType

        if ($type->isArrayLike()) {
            return true;
        }

        $d = \strtolower((string)$type);
        if ($d[0] === '\\') {
            $d = \substr($d, 1);
        }
        if ($d === 'callable') {
            return true;
        }

        return parent::canCastToNonNullableType($type);
    }

    protected function canCastToNonNullableTypeWithoutConfig(Type $type): bool
    {
        if ($type instanceof GenericArrayType) {
            return $this->genericArrayElementUnionType()->canCastToUnionType(
                $type->genericArrayElementUnionType()
            );
        }

        // TODO: More precise about checking if can cast to ArrayShapeType

        if ($type->isArrayLike()) {
            return true;
        }

        $d = \strtolower((string)$type);
        if ($d[0] === '\\') {
            $d = \substr($d, 1);
        }
        if ($d === 'callable') {
            return true;
        }

        return parent::canCastToNonNullableTypeWithoutConfig($type);
    }

    public function isGenericArray(): bool
    {
        return true;
    }

    /**
     * @var ?UnionType the normalized element union type. Computed from `$this->element_types`.
     */
    private $element_types_union_type;

    /**
     * @return UnionType
     * A variation of this type that is not generic.
     * i.e. '(int|string)[]' becomes 'int|string'.
     *
     * @suppress PhanAccessReadOnlyProperty this is lazily instantiating a property.
     */
    public function genericArrayElementUnionType(): UnionType
    {
        return $this->element_types_union_type
            ?? ($this->element_types_union_type = UnionType::of(
                UnionType::normalizeMultiTypes($this->element_types),
                []
            ));
    }

    public function __toString(): string
    {
        $string = 'array<' . \implode('|', $this->element_types) . '>';
        if ($this->is_nullable) {
            $string = '?' . $string;
        }
        return $string;
    }

    /**
     * @param CodeBase $code_base
     * The code base to use in order to find super classes, etc.
     *
     * @param $recursion_depth
     * This thing has a tendency to run-away on me. This tracks
     * how bad I messed up by seeing how far the expanded types
     * go
     *
     * @return UnionType
     * Expands class types to all inherited classes returning
     * a superset of this type.
     * @override
     */
    public function asExpandedTypes(
        CodeBase $code_base,
        int $recursion_depth = 0
    ): UnionType {
        // We're going to assume that if the type hierarchy
        // is taller than some value we probably messed up
        // and should bail out.
        if ($recursion_depth >= 20) {
            throw new RecursionDepthException("Recursion has gotten out of hand: " . Frame::getExpandedTypesDetails());
        }

        // TODO: Use UnionType::merge from a future change?
        $result = new UnionTypeBuilder();
        try {
            foreach ($this->element_types as $type) {
                $result->addUnionType(
                    GenericArrayType::fromElementType(
                        $type,
                        $this->is_nullable,
                        $this->key_type
                    )->asExpandedTypes($code_base, $recursion_depth + 1)
                );
            }
        } catch (RecursionDepthException $_) {
            return ArrayType::instance($this->is_nullable)->asPHPDocUnionType();
        }
        return $result->getPHPDocUnionType();
    }

    /**
     * @param CodeBase $code_base
     * The code base to use in order to find super classes, etc.
     *
     * @param $recursion_depth
     * This thing has a tendency to run-away on me. This tracks
     * how bad I messed up by seeing how far the expanded types
     * go
     *
     * @return UnionType
     * Expands class types to all inherited classes returning
     * a superset of this type.
     * @override
     */
    public function asExpandedTypesPreservingTemplate(
        CodeBase $code_base,
        int $recursion_depth = 0
    ): UnionType {
        // We're going to assume that if the type hierarchy
        // is taller than some value we probably messed up
        // and should bail out.
        if ($recursion_depth >= 20) {
            throw new RecursionDepthException("Recursion has gotten out of hand: " . Frame::getExpandedTypesDetails());
        }

        // TODO: Use UnionType::merge from a future change?
        $result = new UnionTypeBuilder();
        try {
            foreach ($this->element_types as $type) {
                $result->addUnionType(
                    GenericArrayType::fromElementType(
                        $type,
                        $this->is_nullable,
                        $this->key_type
                    )->asExpandedTypesPreservingTemplate($code_base, $recursion_depth + 1)
                );
            }
        } catch (RecursionDepthException $_) {
            return ArrayType::instance($this->is_nullable)->asPHPDocUnionType();
        }
        return $result->getPHPDocUnionType();
    }

    public function getKeyType(): int
    {
        return $this->key_type;
    }

    public function isDefinitelyNonEmptyArray(): bool
    {
        return $this->always_has_elements;
    }
}
