<?php

namespace Symfony\Flex\Configurator;

use Composer\IO\IOInterface;
use Symfony\Flex\Lock;
use Symfony\Flex\Recipe;
use Symfony\Flex\Update\RecipeUpdate;

/**
 * @author Kevin Bond <kevinbond@gmail.com>
 * @author Ryan Weaver <ryan@symfonycasts.com>
 */
class AddLinesConfigurator extends AbstractConfigurator
{
    private const POSITION_TOP = 'top';
    private const POSITION_BOTTOM = 'bottom';
    private const POSITION_AFTER_TARGET = 'after_target';

    private const VALID_POSITIONS = [
        self::POSITION_TOP,
        self::POSITION_BOTTOM,
        self::POSITION_AFTER_TARGET,
    ];

    /**
     * Holds file contents for files that have been loaded.
     * This allows us to "change" the contents of a file multiple
     * times before we actually write it out.
     *
     * @var string[]
     */
    private $fileContents = [];

    public function configure(Recipe $recipe, $config, Lock $lock, array $options = []): void
    {
        $this->fileContents = [];
        $this->executeConfigure($recipe, $config);

        foreach ($this->fileContents as $file => $contents) {
            $this->write(\sprintf('[add-lines] Patching file "%s"', $this->relativize($file)));
            file_put_contents($file, $contents);
        }
    }

    public function unconfigure(Recipe $recipe, $config, Lock $lock): void
    {
        $this->fileContents = [];
        $this->executeUnconfigure($recipe, $config);

        foreach ($this->fileContents as $file => $change) {
            $this->write(\sprintf('[add-lines] Reverting file "%s"', $this->relativize($file)));
            file_put_contents($file, $change);
        }
    }

    public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void
    {
        // manually check for "requires", as unconfigure ignores it
        $originalConfig = array_filter($originalConfig, function ($item) {
            return !isset($item['requires']) || $this->isPackageInstalled($item['requires']);
        });

        // reset the file content cache
        $this->fileContents = [];
        $this->executeUnconfigure($recipeUpdate->getOriginalRecipe(), $originalConfig);
        $this->executeConfigure($recipeUpdate->getNewRecipe(), $newConfig);
        $newFiles = [];
        $originalFiles = [];
        foreach ($this->fileContents as $file => $contents) {
            // set the original file to the current contents
            $originalFiles[$this->relativize($file)] = file_get_contents($file);
            // and the new file where the old recipe was unconfigured, and the new configured
            $newFiles[$this->relativize($file)] = $contents;
        }
        $recipeUpdate->addOriginalFiles($originalFiles);
        $recipeUpdate->addNewFiles($newFiles);
    }

    public function executeConfigure(Recipe $recipe, $config): void
    {
        foreach ($config as $patch) {
            if (!isset($patch['file'])) {
                $this->write(\sprintf('The "file" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName()));

                continue;
            }

            if (isset($patch['requires']) && !$this->isPackageInstalled($patch['requires'])) {
                continue;
            }

            if (!isset($patch['content'])) {
                $this->write(\sprintf('The "content" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName()));

                continue;
            }
            $content = $patch['content'];

            $file = $this->path->concatenate([$this->options->get('root-dir'), $this->options->expandTargetDir($patch['file'])]);
            $warnIfMissing = isset($patch['warn_if_missing']) && $patch['warn_if_missing'];
            if (!is_file($file)) {
                $this->write([
                    \sprintf('Could not add lines to file <info>%s</info> as it does not exist. Missing lines:', $patch['file']),
                    '<comment>"""</comment>',
                    $content,
                    '<comment>"""</comment>',
                    '',
                ], $warnIfMissing ? IOInterface::NORMAL : IOInterface::VERBOSE);

                continue;
            }

            if (!isset($patch['position'])) {
                $this->write(\sprintf('The "position" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName()));

                continue;
            }
            $position = $patch['position'];
            if (!\in_array($position, self::VALID_POSITIONS, true)) {
                $this->write(\sprintf('The "position" key must be one of "%s" for the "add-lines" configurator for recipe "%s". Skipping', implode('", "', self::VALID_POSITIONS), $recipe->getName()));

                continue;
            }

            if (self::POSITION_AFTER_TARGET === $position && !isset($patch['target'])) {
                $this->write(\sprintf('The "target" key is required when "position" is "%s" for the "add-lines" configurator for recipe "%s". Skipping', self::POSITION_AFTER_TARGET, $recipe->getName()));

                continue;
            }
            $target = isset($patch['target']) ? $patch['target'] : null;

            $newContents = $this->getPatchedContents($file, $content, $position, $target, $warnIfMissing);
            $this->fileContents[$file] = $newContents;
        }
    }

    public function executeUnconfigure(Recipe $recipe, $config): void
    {
        foreach ($config as $patch) {
            if (!isset($patch['file'])) {
                $this->write(\sprintf('The "file" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName()));

                continue;
            }

            // Ignore "requires": the target packages may have just become uninstalled.
            // Checking for a "content" match is enough.

            $file = $this->path->concatenate([$this->options->get('root-dir'), $this->options->expandTargetDir($patch['file'])]);
            if (!is_file($file)) {
                continue;
            }

            if (!isset($patch['content'])) {
                $this->write(\sprintf('The "content" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName()));

                continue;
            }
            $value = $patch['content'];

            $newContents = $this->getUnPatchedContents($file, $value);
            $this->fileContents[$file] = $newContents;
        }
    }

    private function getPatchedContents(string $file, string $value, string $position, ?string $target, bool $warnIfMissing): string
    {
        $fileContents = $this->readFile($file);

        if (str_contains($fileContents, $value)) {
            return $fileContents; // already includes value, skip
        }

        switch ($position) {
            case self::POSITION_BOTTOM:
                $fileContents .= "\n".$value;

                break;
            case self::POSITION_TOP:
                $fileContents = $value."\n".$fileContents;

                break;
            case self::POSITION_AFTER_TARGET:
                $lines = explode("\n", $fileContents);
                $targetFound = false;
                foreach ($lines as $key => $line) {
                    if (str_contains($line, $target)) {
                        array_splice($lines, $key + 1, 0, $value);
                        $targetFound = true;

                        break;
                    }
                }
                $fileContents = implode("\n", $lines);

                if (!$targetFound) {
                    $this->write([
                        \sprintf('Could not add lines after "%s" as no such string was found in "%s". Missing lines:', $target, $file),
                        '<comment>"""</comment>',
                        $value,
                        '<comment>"""</comment>',
                        '',
                    ], $warnIfMissing ? IOInterface::NORMAL : IOInterface::VERBOSE);
                }

                break;
        }

        return $fileContents;
    }

    private function getUnPatchedContents(string $file, $value): string
    {
        $fileContents = $this->readFile($file);

        if (!str_contains($fileContents, $value)) {
            return $fileContents; // value already gone!
        }

        if (str_contains($fileContents, "\n".$value)) {
            $value = "\n".$value;
        } elseif (str_contains($fileContents, $value."\n")) {
            $value .= "\n";
        }

        $position = strpos($fileContents, $value);

        return substr_replace($fileContents, '', $position, \strlen($value));
    }

    private function isPackageInstalled($packages): bool
    {
        if (\is_string($packages)) {
            $packages = [$packages];
        }

        $installedRepo = $this->composer->getRepositoryManager()->getLocalRepository();

        foreach ($packages as $package) {
            $package = explode(':', $package, 2);
            $packageName = $package[0];
            $constraint = $package[1] ?? '*';

            if (null === $installedRepo->findPackage($packageName, $constraint)) {
                return false;
            }
        }

        return true;
    }

    private function relativize(string $path): string
    {
        $rootDir = $this->options->get('root-dir');
        if (str_starts_with($path, $rootDir)) {
            $path = substr($path, \strlen($rootDir) + 1);
        }

        return ltrim($path, '/\\');
    }

    private function readFile(string $file): string
    {
        if (isset($this->fileContents[$file])) {
            return $this->fileContents[$file];
        }

        $fileContents = file_get_contents($file);
        $this->fileContents[$file] = $fileContents;

        return $fileContents;
    }
}
