<?php

/*
 * This file is part of the Symfony package.
 *
 * (c) Fabien Potencier <fabien@symfony.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Symfony\Flex\Configurator;

use Composer\Composer;
use Composer\Factory;
use Composer\IO\IOInterface;
use Composer\Json\JsonFile;
use Composer\Json\JsonManipulator;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Flex\Lock;
use Symfony\Flex\Options;
use Symfony\Flex\Recipe;
use Symfony\Flex\Update\RecipeUpdate;

/**
 * Adds services and volumes to compose.yaml file.
 *
 * @author Kévin Dunglas <kevin@dunglas.dev>
 */
class DockerComposeConfigurator extends AbstractConfigurator
{
    private $filesystem;

    public static $configureDockerRecipes;

    public function __construct(Composer $composer, IOInterface $io, Options $options)
    {
        parent::__construct($composer, $io, $options);

        $this->filesystem = new Filesystem();
    }

    public function configure(Recipe $recipe, $config, Lock $lock, array $options = [])
    {
        if (!self::shouldConfigureDockerRecipe($this->composer, $this->io, $recipe)) {
            return;
        }

        $this->configureDockerCompose($recipe, $config, $options['force'] ?? false);

        $this->write('Docker Compose definitions have been modified. Please run "docker compose up --build" again to apply the changes.');
    }

    public function unconfigure(Recipe $recipe, $config, Lock $lock)
    {
        $rootDir = $this->options->get('root-dir');
        foreach ($this->normalizeConfig($config) as $file => $extra) {
            if (null === $dockerComposeFile = $this->findDockerComposeFile($rootDir, $file)) {
                continue;
            }

            $name = $recipe->getName();
            // Remove recipe and add break line
            $contents = preg_replace(\sprintf('{%s+###> %s ###.*?###< %s ###%s+}s', "\n", $name, $name, "\n"), \PHP_EOL.\PHP_EOL, file_get_contents($dockerComposeFile), -1, $count);
            if (!$count) {
                return;
            }

            foreach ($extra as $key => $value) {
                if (0 === preg_match(\sprintf('{^%s:[ \t\r\n]*([ \t]+\w|#)}m', $key), $contents, $matches)) {
                    $contents = preg_replace(\sprintf('{\n?^%s:[ \t\r\n]*}sm', $key), '', $contents, -1, $count);
                }
            }

            $this->write(\sprintf('Removing Docker Compose entries from "%s"', $dockerComposeFile));
            file_put_contents($dockerComposeFile, ltrim($contents, "\n"));
        }

        $this->write('Docker Compose definitions have been modified. Please run "docker compose up" again to apply the changes.');
    }

    public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void
    {
        if (!self::shouldConfigureDockerRecipe($this->composer, $this->io, $recipeUpdate->getNewRecipe())) {
            return;
        }

        $recipeUpdate->addOriginalFiles(
            $this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getOriginalRecipe(), $originalConfig)
        );

        $recipeUpdate->addNewFiles(
            $this->getContentsAfterApplyingRecipe($recipeUpdate->getRootDir(), $recipeUpdate->getNewRecipe(), $newConfig)
        );
    }

    public static function shouldConfigureDockerRecipe(Composer $composer, IOInterface $io, Recipe $recipe): bool
    {
        if (null !== self::$configureDockerRecipes) {
            return self::$configureDockerRecipes;
        }

        if (null !== $dockerPreference = $composer->getPackage()->getExtra()['symfony']['docker'] ?? null) {
            self::$configureDockerRecipes = filter_var($dockerPreference, \FILTER_VALIDATE_BOOLEAN);

            return self::$configureDockerRecipes;
        }

        if ('install' !== $recipe->getJob()) {
            // default to not configuring
            return false;
        }

        if (!isset($_SERVER['SYMFONY_DOCKER'])) {
            $answer = self::askDockerSupport($io, $recipe);
        } elseif (filter_var($_SERVER['SYMFONY_DOCKER'], \FILTER_VALIDATE_BOOLEAN)) {
            $answer = 'p';
        } else {
            $answer = 'x';
        }

        if ('n' === $answer) {
            self::$configureDockerRecipes = false;

            return self::$configureDockerRecipes;
        }
        if ('y' === $answer) {
            self::$configureDockerRecipes = true;

            return self::$configureDockerRecipes;
        }

        // yes or no permanently
        self::$configureDockerRecipes = 'p' === $answer;
        $json = new JsonFile(Factory::getComposerFile());
        $manipulator = new JsonManipulator(file_get_contents($json->getPath()));
        $manipulator->addSubNode('extra', 'symfony.docker', self::$configureDockerRecipes);
        file_put_contents($json->getPath(), $manipulator->getContents());

        return self::$configureDockerRecipes;
    }

    /**
     * Normalizes the config and return the name of the main Docker Compose file if applicable.
     */
    private function normalizeConfig(array $config): array
    {
        foreach ($config as $key => $val) {
            // Support for the short recipe syntax that modifies compose.yaml only
            if (isset($val[0])) {
                return ['compose.yaml' => $config];
            }

            if (!str_starts_with($key, 'docker-')) {
                continue;
            }

            // If the recipe still use the legacy "docker-compose.yml" names, remove the "docker-" prefix and change the extension
            $newKey = pathinfo(substr($key, 7), \PATHINFO_FILENAME).'.yaml';
            $config[$newKey] = $val;
            unset($config[$key]);
        }

        return $config;
    }

    /**
     * Finds the Docker Compose file according to these rules: https://docs.docker.com/compose/reference/envvars/#compose_file.
     */
    private function findDockerComposeFile(string $rootDir, string $file): ?string
    {
        if (isset($_SERVER['COMPOSE_FILE'])) {
            $filenameToFind = pathinfo($file, \PATHINFO_FILENAME);
            $separator = $_SERVER['COMPOSE_PATH_SEPARATOR'] ?? ('\\' === \DIRECTORY_SEPARATOR ? ';' : ':');

            $files = explode($separator, $_SERVER['COMPOSE_FILE']);
            foreach ($files as $f) {
                $filename = pathinfo($f, \PATHINFO_FILENAME);
                if ($filename !== $filenameToFind && "docker-$filenameToFind" !== $filename) {
                    continue;
                }

                if (!$this->filesystem->isAbsolutePath($f)) {
                    $f = realpath(\sprintf('%s/%s', $rootDir, $f));
                }

                if ($this->filesystem->exists($f)) {
                    return $f;
                }
            }
        }

        // COMPOSE_FILE not set, or doesn't contain the file we're looking for
        $dir = $rootDir;
        do {
            if (
                $this->filesystem->exists($dockerComposeFile = \sprintf('%s/%s', $dir, $file))
                // Test with the ".yml" extension if the file doesn't end up with ".yaml"
                || $this->filesystem->exists($dockerComposeFile = substr($dockerComposeFile, 0, -3).'ml')
                // Test with the legacy "docker-" suffix if "compose.ya?ml" doesn't exist
                || $this->filesystem->exists($dockerComposeFile = \sprintf('%s/docker-%s', $dir, $file))
                || $this->filesystem->exists($dockerComposeFile = substr($dockerComposeFile, 0, -3).'ml')
            ) {
                return $dockerComposeFile;
            }

            $previousDir = $dir;
            $dir = \dirname($dir);
        } while ($dir !== $previousDir);

        return null;
    }

    private function parse($level, $indent, $services): string
    {
        $line = '';
        foreach ($services as $key => $value) {
            $line .= str_repeat(' ', $indent * $level);
            if (!\is_array($value)) {
                if (\is_string($key)) {
                    $line .= \sprintf('%s:', $key);
                }
                $line .= \sprintf("%s\n", $value);
                continue;
            }
            $line .= \sprintf("%s:\n", $key).$this->parse($level + 1, $indent, $value);
        }

        return $line;
    }

    private function configureDockerCompose(Recipe $recipe, array $config, bool $update): void
    {
        $rootDir = $this->options->get('root-dir');
        foreach ($this->normalizeConfig($config) as $file => $extra) {
            $dockerComposeFile = $this->findDockerComposeFile($rootDir, $file);
            if (null === $dockerComposeFile) {
                $dockerComposeFile = $rootDir.'/'.$file;
                file_put_contents($dockerComposeFile, '');
                $this->write(\sprintf('  Created <fg=green>"%s"</>', $file));
            }

            if (!$update && $this->isFileMarked($recipe, $dockerComposeFile)) {
                continue;
            }

            $this->write(\sprintf('Adding Docker Compose definitions to "%s"', $dockerComposeFile));

            $offset = 2;
            $node = null;
            $endAt = [];
            $startAt = [];
            $lines = [];
            $nodesLines = [];
            foreach (file($dockerComposeFile) as $i => $line) {
                $lines[] = $line;
                $ltrimedLine = ltrim($line, ' ');
                if (null !== $node) {
                    $nodesLines[$node][$i] = $line;
                }

                // Skip blank lines and comments
                if (('' !== $ltrimedLine && str_starts_with($ltrimedLine, '#')) || '' === trim($line)) {
                    continue;
                }

                // Extract Docker Compose keys (usually "services" and "volumes")
                if (!preg_match('/^[\'"]?([a-zA-Z0-9]+)[\'"]?:\s*$/', $line, $matches)) {
                    // Detect indentation to use
                    $offestLine = \strlen($line) - \strlen($ltrimedLine);
                    if ($offset > $offestLine && 0 !== $offestLine) {
                        $offset = $offestLine;
                    }
                    continue;
                }

                // Keep end in memory (check break line on previous line)
                $endAt[$node] = !$i || '' !== trim($lines[$i - 1]) ? $i : $i - 1;
                $node = $matches[1];
                if (!isset($nodesLines[$node])) {
                    $nodesLines[$node] = [];
                }
                if (!isset($startAt[$node])) {
                    // the section contents starts at the next line
                    $startAt[$node] = $i + 1;
                }
            }
            $endAt[$node] = \count($lines) + 1;

            foreach ($extra as $key => $value) {
                if (isset($endAt[$key])) {
                    $data = $this->markData($recipe, $this->parse(1, $offset, $value));
                    $updatedContents = $this->updateDataString(implode('', $nodesLines[$key]), $data);
                    if (null === $updatedContents) {
                        // not an update: just add to section
                        array_splice($lines, $endAt[$key], 0, $data);

                        continue;
                    }

                    $originalEndAt = $endAt[$key];
                    $length = $endAt[$key] - $startAt[$key];
                    array_splice($lines, $startAt[$key], $length, ltrim($updatedContents, "\n"));

                    // reset any start/end positions after this to the new positions
                    foreach ($startAt as $sectionKey => $at) {
                        if ($at > $originalEndAt) {
                            $startAt[$sectionKey] = $at - $length - 1;
                        }
                    }
                    foreach ($endAt as $sectionKey => $at) {
                        if ($at > $originalEndAt) {
                            $endAt[$sectionKey] = $at - $length;
                        }
                    }

                    continue;
                }

                $lines[] = \sprintf("\n%s:", $key);
                $lines[] = $this->markData($recipe, $this->parse(1, $offset, $value));
            }

            file_put_contents($dockerComposeFile, implode('', $lines));
        }
    }

    private function getContentsAfterApplyingRecipe(string $rootDir, Recipe $recipe, array $config): array
    {
        if (0 === \count($config)) {
            return [];
        }

        $files = array_filter(array_map(function ($file) use ($rootDir) {
            return $this->findDockerComposeFile($rootDir, $file);
        }, array_keys($config)));

        $originalContents = [];
        foreach ($files as $file) {
            $originalContents[$file] = file_exists($file) ? file_get_contents($file) : null;
        }

        $this->configureDockerCompose(
            $recipe,
            $config,
            true
        );

        $updatedContents = [];
        foreach ($files as $file) {
            $localPath = $file;
            if (str_starts_with($file, $rootDir)) {
                $localPath = substr($file, \strlen($rootDir) + 1);
            }
            $localPath = ltrim($localPath, '/\\');
            $updatedContents[$localPath] = file_exists($file) ? file_get_contents($file) : null;
        }

        foreach ($originalContents as $file => $contents) {
            if (null === $contents) {
                if (file_exists($file)) {
                    unlink($file);
                }
            } else {
                file_put_contents($file, $contents);
            }
        }

        return $updatedContents;
    }

    private static function askDockerSupport(IOInterface $io, Recipe $recipe): string
    {
        $warning = $io->isInteractive() ? 'WARNING' : 'IGNORING';
        $io->writeError(\sprintf('  - <warning> %s </> %s', $warning, $recipe->getFormattedOrigin()));
        $question = '    The recipe for this package contains some Docker configuration.

    This may create/update <comment>compose.yaml</comment> or update <comment>Dockerfile</comment> (if it exists).

    Do you want to include Docker configuration from recipes?
    [<comment>y</>] Yes
    [<comment>n</>] No
    [<comment>p</>] Yes permanently, never ask again for this project
    [<comment>x</>] No permanently, never ask again for this project
    (defaults to <comment>y</>): ';

        return $io->askAndValidate(
            $question,
            function ($value) {
                if (null === $value) {
                    return 'y';
                }
                $value = strtolower($value[0]);
                if (!\in_array($value, ['y', 'n', 'p', 'x'], true)) {
                    throw new \InvalidArgumentException('Invalid choice.');
                }

                return $value;
            },
            null,
            'y'
        );
    }
}
