<?php

/*
 * This file is part of PhpSpec, A php toolset to drive emergent
 * design by specification.
 *
 * (c) Marcello Duarte <marcello.duarte@gmail.com>
 * (c) Konstantin Kudryashov <ever.zet@gmail.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace PhpSpec\Matcher;

use PhpSpec\Formatter\Presenter\PresenterInterface;
use PhpSpec\Wrapper\Unwrapper;
use PhpSpec\Wrapper\DelayedCall;
use PhpSpec\Factory\ReflectionFactory;
use PhpSpec\Exception\Example\MatcherException;
use PhpSpec\Exception\Example\FailureException;
use PhpSpec\Exception\Example\NotEqualException;
use PhpSpec\Exception\Fracture\MethodNotFoundException;

class ThrowMatcher implements MatcherInterface
{
    /**
     * @var array
     */
    private static $ignoredProperties = array('file', 'line', 'string', 'trace', 'previous');

    /**
     * @var Unwrapper
     */
    private $unwrapper;

    /**
     * @var PresenterInterface
     */
    private $presenter;

    /**
     * @var ReflectionFactory
     */
    private $factory;

    /**
     * @param Unwrapper              $unwrapper
     * @param PresenterInterface     $presenter
     * @param ReflectionFactory|null $factory
     */
    public function __construct(Unwrapper $unwrapper, PresenterInterface $presenter, ReflectionFactory $factory = null)
    {
        $this->unwrapper = $unwrapper;
        $this->presenter = $presenter;
        $this->factory   = $factory ?: new ReflectionFactory();
    }

    /**
     * @param string $name
     * @param mixed  $subject
     * @param array  $arguments
     *
     * @return bool
     */
    public function supports($name, $subject, array $arguments)
    {
        return 'throw' === $name;
    }

    /**
     * @param string $name
     * @param mixed  $subject
     * @param array  $arguments
     *
     * @return DelayedCall
     */
    public function positiveMatch($name, $subject, array $arguments)
    {
        return $this->getDelayedCall(array($this, 'verifyPositive'), $subject, $arguments);
    }

    /**
     * @param string $name
     * @param mixed  $subject
     * @param array  $arguments
     *
     * @return DelayedCall
     */
    public function negativeMatch($name, $subject, array $arguments)
    {
        return $this->getDelayedCall(array($this, 'verifyNegative'), $subject, $arguments);
    }

    /**
     * @param callable           $callable
     * @param array              $arguments
     * @param null|object|string $exception
     *
     * @throws \PhpSpec\Exception\Example\FailureException
     * @throws \PhpSpec\Exception\Example\NotEqualException
     */
    public function verifyPositive($callable, array $arguments, $exception = null)
    {
        $exceptionThrown = null;

        try {
            call_user_func_array($callable, $arguments);
        } catch (\Exception $e) {
            $exceptionThrown = $e;
        } catch (\Throwable $e) {
            $exceptionThrown = $e;
        }

        if (!$exceptionThrown) {
            throw new FailureException('Expected to get exception / throwable, none got.');
        }

        if (null === $exception) {
            return;
        }

        if (!$exceptionThrown instanceof $exception) {
            throw new FailureException(
                sprintf(
                    'Expected exception of class %s, but got %s.',
                    $this->presenter->presentValue($exception),
                    $this->presenter->presentValue($exceptionThrown)
                )
            );
        }

        if (is_object($exception)) {
            $exceptionRefl = $this->factory->create($exception);
            foreach ($exceptionRefl->getProperties() as $property) {
                if (in_array($property->getName(), self::$ignoredProperties, true)) {
                    continue;
                }

                $property->setAccessible(true);
                $expected = $property->getValue($exception);
                $actual = $property->getValue($exceptionThrown);

                if (null !== $expected && $actual !== $expected) {
                    throw new NotEqualException(
                        sprintf(
                            'Expected exception `%s` to be %s, but it is %s.',
                            $property->getName(),
                            $this->presenter->presentValue($expected),
                            $this->presenter->presentValue($actual)
                        ), $expected, $actual
                    );
                }
            }
        }
    }

    /**
     * @param callable           $callable
     * @param array              $arguments
     * @param string|null|object $exception
     *
     * @throws \PhpSpec\Exception\Example\FailureException
     */
    public function verifyNegative($callable, array $arguments, $exception = null)
    {
        $exceptionThrown = null;

        try {
            call_user_func_array($callable, $arguments);
        } catch (\Exception $e) {
            $exceptionThrown = $e;
        } catch (\Throwable $e) {
            $exceptionThrown = $e;
        }

        if ($exceptionThrown && null === $exception) {
            throw new FailureException(
                sprintf(
                    'Expected to not throw any exceptions, but got %s.',
                    $this->presenter->presentValue($exceptionThrown)
                )
            );
        }

        if ($exceptionThrown && $exceptionThrown instanceof $exception) {
            $invalidProperties = array();
            if (is_object($exception)) {
                $exceptionRefl = $this->factory->create($exception);
                foreach ($exceptionRefl->getProperties() as $property) {
                    if (in_array($property->getName(), self::$ignoredProperties, true)) {
                        continue;
                    }

                    $property->setAccessible(true);
                    $expected = $property->getValue($exception);
                    $actual = $property->getValue($exceptionThrown);

                    if (null !== $expected && $actual === $expected) {
                        $invalidProperties[] = sprintf(
                            '  `%s`=%s',
                            $property->getName(),
                            $this->presenter->presentValue($expected)
                        );
                    }
                }
            }

            $withProperties = '';
            if (count($invalidProperties) > 0) {
                $withProperties = sprintf(
                    ' with'.PHP_EOL.'%s,'.PHP_EOL,
                    implode(",\n", $invalidProperties)
                );
            }

            throw new FailureException(
                sprintf(
                    'Expected to not throw %s exception%s but got it.',
                    $this->presenter->presentValue($exception),
                    $withProperties
                )
            );
        }
    }

    /**
     * @return int
     */
    public function getPriority()
    {
        return 1;
    }

    /**
     * @param callable $check
     * @param mixed    $subject
     * @param array    $arguments
     *
     * @return DelayedCall
     */
    private function getDelayedCall($check, $subject, array $arguments)
    {
        $exception = $this->getException($arguments);
        $unwrapper = $this->unwrapper;

        return new DelayedCall(
            function ($method, $arguments) use ($check, $subject, $exception, $unwrapper) {
                $arguments = $unwrapper->unwrapAll($arguments);

                $methodName = $arguments[0];
                $arguments = isset($arguments[1]) ? $arguments[1] : array();
                $callable = array($subject, $methodName);

                list($class, $methodName) = array($subject, $methodName);
                if (!method_exists($class, $methodName) && !method_exists($class, '__call')) {
                    throw new MethodNotFoundException(
                        sprintf('Method %s::%s not found.', get_class($class), $methodName),
                        $class,
                        $methodName,
                        $arguments
                    );
                }

                return call_user_func($check, $callable, $arguments, $exception);
            }
        );
    }

    /**
     * @param array $arguments
     *
     * @return null|string
     * @throws \PhpSpec\Exception\Example\MatcherException
     */
    private function getException(array $arguments)
    {
        if (0 === count($arguments)) {
            return null;
        }

        if (is_string($arguments[0])) {
            return $arguments[0];
        }

        if (is_object($arguments[0])) {
            if (class_exists('\Throwable') && $arguments[0] instanceof \Throwable) {
                return $arguments[0];
            } elseif ($arguments[0] instanceof \Exception) {
                return $arguments[0];
            }
        }

        throw new MatcherException(
            sprintf(
                "Wrong argument provided in throw matcher.\n".
                "Fully qualified classname or exception instance expected,\n".
                "Got %s.",
                $this->presenter->presentValue($arguments[0])
            )
        );
    }
}
