<?php

namespace UpdateHelper;

use Composer\Composer;
use Composer\EventDispatcher\Event;
use Composer\Installer\PackageEvent;
use Composer\IO\IOInterface;
use Composer\Json\JsonFile;
use Composer\Script\Event as ScriptEvent;
use Composer\Semver\Semver;
use Exception;
use InvalidArgumentException;
use RuntimeException;
use Throwable;

class UpdateHelper
{
    /** @var Event */
    private $event;
    /** @var IOInterface|null */
    private $io;
    /** @var Composer */
    private $composer;
    /** @var array */
    private $dependencies = array();
    /** @var string */
    private $composerFilePath;
    /** @var JsonFile */
    private $file;

    protected static function appendConfig(&$classes, $directory, $key = null)
    {
        $file = $directory.DIRECTORY_SEPARATOR.'composer.json';
        $json = new JsonFile($file);
        $key = $key ? $key : 'update-helper';

        try {
            $dependencyConfig = $json->read();
        } catch (Exception $e) {
            $dependencyConfig = null;
        }

        if (is_array($dependencyConfig) && isset($dependencyConfig['extra'], $dependencyConfig['extra'][$key])) {
            $classes[$file] = $dependencyConfig['extra'][$key];
        }
    }

    protected static function getUpdateHelperConfig(Composer $composer, $key = null)
    {
        $vendorDir = $composer->getConfig()->get('vendor-dir');

        $npm = array();

        foreach (scandir($vendorDir) as $namespace) {
            if ($namespace === '.' || $namespace === '..' || !is_dir($directory = $vendorDir.DIRECTORY_SEPARATOR.$namespace)) {
                continue;
            }

            foreach (scandir($directory) as $dependency) {
                if ($dependency === '.' || $dependency === '..' || !is_dir($subDirectory = $directory.DIRECTORY_SEPARATOR.$dependency)) {
                    continue;
                }

                static::appendConfig($npm, $subDirectory, $key);
            }
        }

        static::appendConfig($npm, dirname($vendorDir), $key);

        return $npm;
    }

    /**
     * @param Event       $event
     * @param IOInterface $io
     * @param Composer    $composer
     * @param string[]    $subClasses
     */
    protected static function checkHelper($event, IOInterface $io, $composer, $class)
    {
        if (!is_string($class) || !class_exists($class)) {
            throw new NotUpdateInterfaceInstanceException();
        }

        try {
            $helper = new $class();
        } catch (Exception $e) {
            throw new InvalidArgumentException($e->getMessage(), 1000, $e);
        } catch (Throwable $e) {
            throw new InvalidArgumentException($e->getMessage(), 1000, $e);
        }

        if (!($helper instanceof UpdateHelperInterface)) {
            throw new NotUpdateInterfaceInstanceException();
        }

        $helper->check(new static($event, $io, $composer));
    }

    /**
     * @param string      $file
     * @param Event       $event
     * @param IOInterface $io
     * @param Composer    $composer
     * @param string[]    $subClasses
     */
    protected static function checkFileHelpers($file, $event, IOInterface $io, $composer, array $subClasses)
    {
        foreach ($subClasses as $class) {
            try {
                static::checkHelper($event, $io, $composer, $class);
            } catch (InvalidArgumentException $exception) {
                $io->writeError(static::getErrorMessage($exception, $file, $class));
                continue;
            }
        }
    }

    protected static function getErrorMessage(InvalidArgumentException $exception, $file, $class)
    {
        if ($exception instanceof NotUpdateInterfaceInstanceException) {
            return 'UpdateHelper error in '.$file.":\n".JsonFile::encode($class).' is not an instance of UpdateHelperInterface.';
        }

        return 'UpdateHelper error: '.$exception->getPrevious()->getMessage().
            "\nFile: ".$exception->getPrevious()->getFile().
            "\nLine:".$exception->getPrevious()->getLine().
            "\n\n".$exception->getPrevious()->getTraceAsString();
    }

    public static function check(Event $event)
    {
        if (!($event instanceof ScriptEvent) && !($event instanceof PackageEvent)) {
            return;
        }

        $io = $event->getIO();
        $composer = $event->getComposer();
        $autoload = $composer->getConfig()->get('vendor-dir').'/autoload.php';

        if (file_exists($autoload)) {
            include_once $autoload;
        }

        $classes = static::getUpdateHelperConfig($composer);

        foreach ($classes as $file => $subClasses) {
            static::checkFileHelpers($file, $event, $io, $composer, (array) $subClasses);
        }
    }

    public function __construct(Event $event, IOInterface $io = null, Composer $composer = null)
    {
        $this->event = $event;
        $this->io = $io ?: (method_exists($event, 'getIO') ? $event->getIO() : null);
        $this->composer = $composer ?: (method_exists($event, 'getComposer') ? $event->getComposer() : null);

        if ($this->composer &&
            ($directory = $this->composer->getConfig()->get('archive-dir')) &&
            file_exists($file = $directory.'/composer.json')
        ) {
            $this->composerFilePath = $file;
            $this->file = new JsonFile($this->composerFilePath);
            $this->dependencies = $this->file->read();
        }
    }

    /**
     * @return JsonFile
     */
    public function getFile()
    {
        return $this->file;
    }

    /**
     * @return string
     */
    public function getComposerFilePath()
    {
        return $this->composerFilePath;
    }

    /**
     * @return Composer
     */
    public function getComposer()
    {
        return $this->composer;
    }

    /**
     * @return Event
     */
    public function getEvent()
    {
        return $this->event;
    }

    /**
     * @return IOInterface|null
     */
    public function getIo()
    {
        return $this->io;
    }

    /**
     * @return array
     */
    public function getDependencies()
    {
        return $this->dependencies;
    }

    /**
     * @return array
     */
    public function getDevDependencies()
    {
        return isset($this->dependencies['require-dev']) ? $this->dependencies['require-dev'] : array();
    }

    /**
     * @return array
     */
    public function getProdDependencies()
    {
        return isset($this->dependencies['require']) ? $this->dependencies['require'] : array();
    }

    /**
     * @return array
     */
    public function getFlattenDependencies()
    {
        return array_merge($this->getDevDependencies(), $this->getProdDependencies());
    }

    /**
     * @param string $dependency
     *
     * @return bool
     */
    public function hasAsDevDependency($dependency)
    {
        return isset($this->dependencies['require-dev'][$dependency]);
    }

    /**
     * @param string $dependency
     *
     * @return bool
     */
    public function hasAsProdDependency($dependency)
    {
        return isset($this->dependencies['require'][$dependency]);
    }

    /**
     * @param string $dependency
     *
     * @return bool
     */
    public function hasAsDependency($dependency)
    {
        return $this->hasAsDevDependency($dependency) || $this->hasAsProdDependency($dependency);
    }

    /**
     * @param string $dependency
     * @param string $version
     *
     * @return bool
     */
    public function isDependencyAtLeast($dependency, $version)
    {
        if ($this->hasAsProdDependency($dependency)) {
            return Semver::satisfies($version, $this->dependencies['require'][$dependency]);
        }

        if ($this->hasAsDevDependency($dependency)) {
            return Semver::satisfies($version, $this->dependencies['require-dev'][$dependency]);
        }

        return false;
    }

    /**
     * @param string $dependency
     * @param string $version
     *
     * @return bool
     */
    public function isDependencyLesserThan($dependency, $version)
    {
        return !$this->isDependencyAtLeast($dependency, $version);
    }

    /**
     * @param string $dependency
     * @param string $version
     * @param array  $environments
     *
     * @throws Exception
     *
     * @return $this
     */
    public function setDependencyVersion($dependency, $version, $environments = array('require', 'require-dev'))
    {
        return $this->setDependencyVersions(array($dependency => $version), $environments);
    }

    /**
     * @param array $dependencies
     * @param array $environments
     *
     * @throws Exception
     *
     * @return $this
     */
    public function setDependencyVersions($dependencies, $environments = array('require', 'require-dev'))
    {
        if (!$this->composerFilePath) {
            throw new RuntimeException('No composer instance detected.');
        }

        $touched = false;

        foreach ($environments as $environment) {
            foreach ($dependencies as $dependency => $version) {
                if (isset($this->dependencies[$environment], $this->dependencies[$environment][$dependency])) {
                    $this->dependencies[$environment][$dependency] = $version;
                    $touched = true;
                }
            }
        }

        if ($touched) {
            if (!$this->composerFilePath) {
                throw new RuntimeException('composer.json not found (custom vendor-dir are not yet supported).');
            }

            $file = new JsonFile($this->composerFilePath);
            $file->write($this->dependencies);
        }

        return $this;
    }

    /**
     * @return $this
     */
    public function update()
    {
        $output = shell_exec('composer update --no-scripts');

        if (!empty($output)) {
            $this->write($output);
        }

        return $this;
    }

    /**
     * @param string|array $text
     */
    public function write($text)
    {
        if ($this->io) {
            $this->io->write($text);

            return;
        }

        if (is_array($text)) {
            $text = implode("\n", $text);
        }

        echo $text;
    }

    /**
     * @return bool
     */
    public function isInteractive()
    {
        return $this->io && $this->io->isInteractive();
    }
}
