<?php

/**
 * Mockery (https://docs.mockery.io/en/stable/)
 *
 * @copyright https://github.com/mockery/mockery/blob/HEAD/COPYRIGHT.md
 * @license   https://github.com/mockery/mockery/blob/HEAD/LICENSE BSD 3-Clause License
 * @see       https://github.com/mockery/mockery for the canonical source repository
 */

namespace Mockery\Generator;

use Iterator;
use IteratorAggregate;
use Mockery\Exception;
use ReflectionClass;
use Serializable;

use function array_filter;
use function array_key_exists;
use function array_keys;
use function array_map;
use function array_merge;
use function array_pop;
use function array_unique;
use function array_values;
use function class_alias;
use function class_exists;
use function explode;
use function get_class;
use function implode;
use function in_array;
use function interface_exists;
use function is_object;
use function md5;
use function preg_match;
use function serialize;
use function strpos;
use function strtolower;
use function trait_exists;

/**
 * This class describes the configuration of mocks and hides away some of the
 * reflection implementation
 */
class MockConfiguration
{
    /**
     * Instance cache of all methods
     *
     * @var list<Method>
     */
    protected $allMethods = [];

    /**
     * Methods that should specifically not be mocked
     *
     * This is currently populated with stuff we don't know how to deal with, should really be somewhere else
     */
    protected $blackListedMethods = [];

    protected $constantsMap = [];

    /**
     * An instance mock is where we override the original class before it's autoloaded
     *
     * @var bool
     */
    protected $instanceMock = false;

    /**
     * If true, overrides original class destructor
     *
     * @var bool
     */
    protected $mockOriginalDestructor = false;

    /**
     * The class name we'd like to use for a generated mock
     *
     * @var string|null
     */
    protected $name;

    /**
     * Param overrides
     *
     * @var array<string,mixed>
     */
    protected $parameterOverrides = [];

    /**
     * A class that we'd like to mock
     *
     * @var TargetClassInterface|null
     */
    protected $targetClass;

    /**
     * @var class-string|null
     */
    protected $targetClassName;

    /**
     * @var list<class-string>
     */
    protected $targetInterfaceNames = [];

    /**
     * A number of interfaces we'd like to mock, keyed by name to attempt to keep unique
     *
     * @var list<TargetClassInterface>
     */
    protected $targetInterfaces = [];

    /**
     * An object we'd like our mock to proxy to
     *
     * @var object|null
     */
    protected $targetObject;

    /**
     * @var array<class-string>
     */
    protected $targetTraitNames = [];

    /**
     * A number of traits we'd like to mock, keyed by name to attempt to keep unique
     *
     * @var array<string,DefinedTargetClass>
     */
    protected $targetTraits = [];

    /**
     * If not empty, only these methods will be mocked
     *
     * @var array<string>
     */
    protected $whiteListedMethods = [];

    /**
     * @param array<class-string|object>         $targets
     * @param array<string>                      $blackListedMethods
     * @param array<string>                      $whiteListedMethods
     * @param string|null                        $name
     * @param bool                               $instanceMock
     * @param array<string,mixed>                $parameterOverrides
     * @param bool                               $mockOriginalDestructor
     * @param array<string,array<scalar>|scalar> $constantsMap
     */
    public function __construct(
        array $targets = [],
        array $blackListedMethods = [],
        array $whiteListedMethods = [],
        $name = null,
        $instanceMock = false,
        array $parameterOverrides = [],
        $mockOriginalDestructor = false,
        array $constantsMap = []
    ) {
        $this->addTargets($targets);
        $this->blackListedMethods = $blackListedMethods;
        $this->whiteListedMethods = $whiteListedMethods;
        $this->name = $name;
        $this->instanceMock = $instanceMock;
        $this->parameterOverrides = $parameterOverrides;
        $this->mockOriginalDestructor = $mockOriginalDestructor;
        $this->constantsMap = $constantsMap;
    }

    /**
     * Generate a suitable name based on the config
     *
     * @return class-string
     *
     * @throws Exception
     */
    public function generateName()
    {
        $mockNameBuilder = new MockNameBuilder();

        $targetObject = $this->getTargetObject();
        if (null !== $targetObject) {
            $className = get_class($targetObject);

            $mockNameBuilder->addPart(strpos($className, '@') !== false ? md5($className) : $className);
        }

        $targetClass = $this->getTargetClass();
        if ($targetClass instanceof TargetClassInterface) {
            $className = $targetClass->getName();

            $mockNameBuilder->addPart(strpos($className, '@') !== false ? md5($className) : $className);
        }

        foreach ($this->getTargetInterfaces() as $targetInterface) {
            $mockNameBuilder->addPart($targetInterface->getName());
        }

        return $mockNameBuilder->build();
    }

    /**
     * @return array<string>
     */
    public function getBlackListedMethods()
    {
        return $this->blackListedMethods;
    }

    /**
     * @return array<string,scalar|array<scalar>>
     */
    public function getConstantsMap()
    {
        return $this->constantsMap;
    }

    /**
     * Attempt to create a hash of the configuration, in order to allow caching
     *
     * @TODO workout if this will work
     *
     * @return string
     */
    public function getHash()
    {
        $vars = [
            'targetClassName' => $this->targetClassName,
            'targetInterfaceNames' => $this->targetInterfaceNames,
            'targetTraitNames' => $this->targetTraitNames,
            'name' => $this->name,
            'blackListedMethods' => $this->blackListedMethods,
            'whiteListedMethod' => $this->whiteListedMethods,
            'instanceMock' => $this->instanceMock,
            'parameterOverrides' => $this->parameterOverrides,
            'mockOriginalDestructor' => $this->mockOriginalDestructor,
        ];

        return md5(serialize($vars));
    }

    /**
     * Gets a list of methods from the classes, interfaces and objects and filters them appropriately.
     * Lot's of filtering going on, perhaps we could have filter classes to iterate through
     *
     * @return list<Method>
     *
     * @throws Exception
     */
    public function getMethodsToMock()
    {
        $methods = $this->getAllMethods();

        foreach ($methods as $key => $method) {
            if ($method->isFinal()) {
                unset($methods[$key]);
            }
        }

        /**
         * Whitelist trumps everything else
         */
        $whiteListedMethods = $this->getWhiteListedMethods();
        if ([] !== $whiteListedMethods) {
            $whitelist = array_map('strtolower', $whiteListedMethods);

            return array_filter($methods, static function ($method) use ($whitelist) {
                if ($method->isAbstract()) {
                    return true;
                }

                return in_array(strtolower($method->getName()), $whitelist, true);
            });
        }

        /**
         * Remove blacklisted methods
         */
        $blackListedMethods = $this->getBlackListedMethods();
        if ([] !== $blackListedMethods) {
            $blacklist = array_map('strtolower', $blackListedMethods);

            $methods = array_filter($methods, static function ($method) use ($blacklist) {
                return ! in_array(strtolower($method->getName()), $blacklist, true);
            });
        }

        /**
         * Internal objects can not be instantiated with newInstanceArgs and if
         * they implement Serializable, unserialize will have to be called. As
         * such, we can't mock it and will need a pass to add a dummy
         * implementation
         */
        $targetClass = $this->getTargetClass();

        if (
            null !== $targetClass
            && $targetClass->implementsInterface(Serializable::class)
            && $targetClass->hasInternalAncestor()
        ) {
            $methods = array_filter($methods, static function ($method) {
                return $method->getName() !== 'unserialize';
            });
        }

        return array_values($methods);
    }

    /**
     * @return class-string|null
     */
    public function getName()
    {
        return $this->name;
    }

    /**
     * @return string
     */
    public function getNamespaceName()
    {
        $parts = explode('\\', $this->getName());
        array_pop($parts);

        if ([] !== $parts) {
            return implode('\\', $parts);
        }

        return '';
    }

    /**
     * @return array<string,mixed>
     */
    public function getParameterOverrides()
    {
        return $this->parameterOverrides;
    }

    /**
     * @return string
     */
    public function getShortName()
    {
        $parts = explode('\\', $this->getName());

        return array_pop($parts);
    }

    /**
     * @return null|TargetClassInterface
     *
     * @throws Exception
     */
    public function getTargetClass()
    {
        if ($this->targetClass) {
            return $this->targetClass;
        }

        if (! $this->targetClassName) {
            return null;
        }

        if (class_exists($this->targetClassName)) {
            $alias = null;
            if (strpos($this->targetClassName, '@') !== false) {
                $alias = (new MockNameBuilder())
                    ->addPart('anonymous_class')
                    ->addPart(md5($this->targetClassName))
                    ->build();
                class_alias($this->targetClassName, $alias);
            }

            $definedTargetClass = DefinedTargetClass::factory($this->targetClassName, $alias);

            if ($this->getTargetObject() === null) {
                if ($definedTargetClass->isFinal()) {
                    throw new Exception(
                        'The class ' . $this->targetClassName . ' is marked final and its methods'
                        . ' cannot be replaced. Classes marked final can be passed in'
                        . ' to \Mockery::mock() as instantiated objects to create a'
                        . ' partial mock, but only if the mock is not subject to type'
                        . ' hinting checks.'
                    );
                }

                if ($definedTargetClass->isReadOnly()) {
                    throw new Exception(
                        'The class ' . $this->targetClassName . ' is marked readonly and its methods'
                        . ' cannot be replaced. Classes marked readonly can be passed in'
                        . ' to \Mockery::mock() as instantiated objects to create a'
                        . ' partial mock, but only if the mock is not subject to type'
                        . ' hinting checks.'
                    );
                }
            }

            $this->targetClass = $definedTargetClass;
        } else {
            $this->targetClass = UndefinedTargetClass::factory($this->targetClassName);
        }

        return $this->targetClass;
    }

    /**
     * @return class-string|null
     */
    public function getTargetClassName()
    {
        return $this->targetClassName;
    }

    /**
     * @return list<TargetClassInterface>
     */
    public function getTargetInterfaces()
    {
        if ([] !== $this->targetInterfaces) {
            return $this->targetInterfaces;
        }

        foreach ($this->targetInterfaceNames as $targetInterfaceName) {
            if (! interface_exists($targetInterfaceName)) {
                /** @var class-string $targetInterface */
                $this->targetInterfaces[] = UndefinedTargetClass::factory($targetInterfaceName);

                continue;
            }

            $dtc = DefinedTargetClass::factory($targetInterfaceName);
            $extendedInterfaces = array_keys($dtc->getInterfaces());
            $extendedInterfaces[] = $targetInterfaceName;

            $traversableFound = false;
            $iteratorShiftedToFront = false;
            foreach ($extendedInterfaces as $extendedInterface) {
                if (! $traversableFound && preg_match('/^\\?Iterator(|Aggregate)$/i', $extendedInterface)) {
                    break;
                }

                if (preg_match('/^\\\\?IteratorAggregate$/i', $extendedInterface)) {
                    $this->targetInterfaces[] = DefinedTargetClass::factory(IteratorAggregate::class);
                    $iteratorShiftedToFront = true;

                    continue;
                }

                if (preg_match('/^\\\\?Iterator$/i', $extendedInterface)) {
                    $this->targetInterfaces[] = DefinedTargetClass::factory(Iterator::class);
                    $iteratorShiftedToFront = true;

                    continue;
                }

                if (preg_match('/^\\\\?Traversable$/i', $extendedInterface)) {
                    $traversableFound = true;
                }
            }

            if ($traversableFound && ! $iteratorShiftedToFront) {
                $this->targetInterfaces[] = DefinedTargetClass::factory(IteratorAggregate::class);
            }

            /**
             * We never straight up implement Traversable
             */
            $isTraversable = preg_match('/^\\\\?Traversable$/i', $targetInterfaceName);
            if (0 === $isTraversable || false === $isTraversable) {
                $this->targetInterfaces[] = $dtc;
            }
        }

        return $this->targetInterfaces = array_unique($this->targetInterfaces);
    }

    /**
     * @return object|null
     */
    public function getTargetObject()
    {
        return $this->targetObject;
    }

    /**
     * @return list<TargetClassInterface>
     */
    public function getTargetTraits()
    {
        if ([] !== $this->targetTraits) {
            return $this->targetTraits;
        }

        foreach ($this->targetTraitNames as $targetTraitName) {
            $this->targetTraits[] = DefinedTargetClass::factory($targetTraitName);
        }

        $this->targetTraits = array_unique($this->targetTraits); // just in case

        return $this->targetTraits;
    }

    /**
     * @return array<string>
     */
    public function getWhiteListedMethods()
    {
        return $this->whiteListedMethods;
    }

    /**
     * @return bool
     */
    public function isInstanceMock()
    {
        return $this->instanceMock;
    }

    /**
     * @return bool
     */
    public function isMockOriginalDestructor()
    {
        return $this->mockOriginalDestructor;
    }

    /**
     * @param  class-string $className
     * @return self
     */
    public function rename($className)
    {
        $targets = [];

        if ($this->targetClassName) {
            $targets[] = $this->targetClassName;
        }

        if ($this->targetInterfaceNames) {
            $targets = array_merge($targets, $this->targetInterfaceNames);
        }

        if ($this->targetTraitNames) {
            $targets = array_merge($targets, $this->targetTraitNames);
        }

        if ($this->targetObject) {
            $targets[] = $this->targetObject;
        }

        /** @var array<class-string|object> $targets */
        return new self(
            $targets,
            $this->blackListedMethods,
            $this->whiteListedMethods,
            $className,
            $this->instanceMock,
            $this->parameterOverrides,
            $this->mockOriginalDestructor,
            $this->constantsMap,
        );
    }

    /**
     * We declare the __callStatic method to handle undefined stuff, if the class
     * we're mocking has also defined it, we need to comply with their interface
     *
     * @return bool
     *
     * @throws Exception
     */
    public function requiresCallStaticTypeHintRemoval()
    {
        foreach ($this->getAllMethods() as $method) {
            if ($method->getName() === '__callStatic') {
                $params = $method->getParameters();

                if (! array_key_exists(1, $params)) {
                    return false;
                }

                return ! $params[1]->isArray();
            }
        }

        return false;
    }

    /**
     * We declare the __call method to handle undefined stuff, if the class
     * we're mocking has also defined it, we need to comply with their interface
     *
     * @return bool
     *
     * @throws Exception
     */
    public function requiresCallTypeHintRemoval()
    {
        foreach ($this->getAllMethods() as $method) {
            if ($method->getName() === '__call') {
                $params = $method->getParameters();

                return ! $params[1]->isArray();
            }
        }

        return false;
    }

    /**
     * @param class-string|object $target
     */
    protected function addTarget($target)
    {
        if (is_object($target)) {
            $this->setTargetObject($target);
            $this->setTargetClassName(get_class($target));

            return;
        }

        if ('\\' !== $target[0]) {
            $target = '\\' . $target;
        }

        if (class_exists($target)) {
            $this->setTargetClassName($target);

            return;
        }

        if (interface_exists($target)) {
            $this->addTargetInterfaceName($target);

            return;
        }

        if (trait_exists($target)) {
            $this->addTargetTraitName($target);

            return;
        }

        /**
         * Default is to set as class, or interface if class already set
         *
         * Don't like this condition, can't remember what the default
         * targetClass is for
         */
        if ($this->getTargetClassName()) {
            /** @var class-string $target */
            $this->addTargetInterfaceName($target);

            return;
        }

        /** @var class-string $target */
        $this->setTargetClassName($target);
    }

    /**
     * If we attempt to implement Traversable,
     * we must ensure we are also implementing either Iterator or IteratorAggregate,
     * and that whichever one it is comes before Traversable in the list of implements.
     *
     * @param class-string $targetInterface
     */
    protected function addTargetInterfaceName($targetInterface)
    {
        $this->targetInterfaceNames[] = $targetInterface;
    }

    /**
     * @param class-string $targetTraitName
     */
    protected function addTargetTraitName($targetTraitName)
    {
        $this->targetTraitNames[] = $targetTraitName;
    }

    /**
     * @param array<class-string> $interfaces
     */
    protected function addTargets($interfaces)
    {
        foreach ($interfaces as $interface) {
            $this->addTarget($interface);
        }
    }

    /**
     * @return list<Method>
     *
     * @throws Exception
     */
    protected function getAllMethods()
    {
        if ($this->allMethods) {
            return $this->allMethods;
        }

        $classes = $this->getTargetInterfaces();

        if ($this->getTargetClass()) {
            $classes[] = $this->getTargetClass();
        }

        /** @var Method[] $methods */
        $methods = [];

        foreach ($classes as $class) {
            $methods = array_merge($methods, $class->getMethods());
        }

        foreach ($this->getTargetTraits() as $trait) {
            foreach ($trait->getMethods() as $method) {
                if ($method->isAbstract()) {
                    $methods[] = $method;
                }
            }
        }

        $unique = [];

        foreach ($methods as $method) {
            $name = $method->getName();

            if (! isset($unique[$name])) {
                $unique[$name] = $method;

                continue;
            }

            $existing = $unique[$name];

            /** @var ReflectionClass $existingDeclaringClass */
            $existingDeclaringClass = $existing->getDeclaringClass();

            /** @var ReflectionClass $newDeclaringClass */
            $newDeclaringClass = $method->getDeclaringClass();

            if ($newDeclaringClass->isSubclassOf($existingDeclaringClass->getName())) {
                $unique[$name] = $method;
            }
        }

        return $this->allMethods = array_values($unique);
    }

    /**
     * @param class-string $targetClassName
     */
    protected function setTargetClassName($targetClassName)
    {
        $this->targetClassName = $targetClassName;
    }

    /**
     * @param object $object
     */
    protected function setTargetObject($object)
    {
        $this->targetObject = $object;
    }
}
