<?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;

use Composer\Cache;
use Composer\Composer;
use Composer\DependencyResolver\Operation\OperationInterface;
use Composer\DependencyResolver\Operation\UninstallOperation;
use Composer\DependencyResolver\Operation\UpdateOperation;
use Composer\IO\IOInterface;
use Composer\Json\JsonFile;
use Composer\Package\BasePackage;
use Composer\Util\Http\Response as ComposerResponse;
use Composer\Util\HttpDownloader;
use Composer\Util\Loop;

/**
 * @author Fabien Potencier <fabien@symfony.com>
 * @author Nicolas Grekas <p@tchwork.com>
 */
class Downloader
{
    private const DEFAULT_ENDPOINTS = [
        'https://raw.githubusercontent.com/symfony/recipes/flex/main/index.json',
        'https://raw.githubusercontent.com/symfony/recipes-contrib/flex/main/index.json',
    ];
    private const MAX_LENGTH = 1000;

    private static $versions;
    private static $aliases;

    private $io;
    private $sess;
    private $cache;

    private HttpDownloader $rfs;
    private $degradedMode = false;
    private $endpoints;
    private $index;
    private $conflicts;
    private $legacyEndpoint;
    private $caFile;
    private $enabled = true;
    private $composer;

    public function __construct(Composer $composer, IOInterface $io, HttpDownloader $rfs)
    {
        if (getenv('SYMFONY_CAFILE')) {
            $this->caFile = getenv('SYMFONY_CAFILE');
        }

        if (null === $endpoint = $composer->getPackage()->getExtra()['symfony']['endpoint'] ?? null) {
            $this->endpoints = self::DEFAULT_ENDPOINTS;
        } elseif (\is_array($endpoint) || str_contains($endpoint, '.json') || 'flex://defaults' === $endpoint) {
            $this->endpoints = array_values((array) $endpoint);
            if (\is_string($endpoint) && str_contains($endpoint, '.json')) {
                $this->endpoints[] = 'flex://defaults';
            }
        } else {
            $this->legacyEndpoint = rtrim($endpoint, '/');
        }

        if (false === $endpoint = getenv('SYMFONY_ENDPOINT')) {
            // no-op
        } elseif (str_contains($endpoint, '.json') || 'flex://defaults' === $endpoint) {
            $this->endpoints ?? $this->endpoints = self::DEFAULT_ENDPOINTS;
            array_unshift($this->endpoints, $endpoint);
            $this->legacyEndpoint = null;
        } else {
            $this->endpoints = null;
            $this->legacyEndpoint = rtrim($endpoint, '/');
        }

        if (null !== $this->endpoints) {
            if (false !== $i = array_search('flex://defaults', $this->endpoints, true)) {
                array_splice($this->endpoints, $i, 1, self::DEFAULT_ENDPOINTS);
            }

            $this->endpoints = array_fill_keys($this->endpoints, []);
        }

        $this->io = $io;
        $config = $composer->getConfig();
        $this->rfs = $rfs;
        $this->cache = new Cache($io, $config->get('cache-repo-dir').'/flex');
        $this->sess = bin2hex(random_bytes(16));
        $this->composer = $composer;
    }

    public function getSessionId(): string
    {
        return $this->sess;
    }

    public function isEnabled()
    {
        return $this->enabled;
    }

    public function disable()
    {
        $this->enabled = false;
    }

    public function getVersions()
    {
        $this->initialize();

        return self::$versions ?? self::$versions = current($this->get([$this->legacyEndpoint.'/versions.json']));
    }

    public function getAliases()
    {
        $this->initialize();

        return self::$aliases ?? self::$aliases = current($this->get([$this->legacyEndpoint.'/aliases.json']));
    }

    /**
     * Downloads recipes.
     *
     * @param OperationInterface[] $operations
     */
    public function getRecipes(array $operations): array
    {
        $this->initialize();

        if ($this->conflicts) {
            $lockedRepository = $this->composer->getLocker()->getLockedRepository(true);
            foreach ($this->conflicts as $conflicts) {
                foreach ($conflicts as $package => $versions) {
                    foreach ($versions as $version => $conflicts) {
                        foreach ($conflicts as $conflictingPackage => $constraint) {
                            if ($lockedRepository->findPackage($conflictingPackage, $constraint)) {
                                unset($this->index[$package][$version]);
                            }
                        }
                    }
                }
            }
            $this->conflicts = [];
        }

        $data = [];
        $urls = [];
        $chunk = '';
        $recipeRef = null;
        foreach ($operations as $operation) {
            $o = 'i';
            if ($operation instanceof UpdateOperation) {
                $package = $operation->getTargetPackage();
                $o = 'u';
            } else {
                $package = $operation->getPackage();
                if ($operation instanceof UninstallOperation) {
                    $o = 'r';
                }

                if ($operation instanceof InformationOperation) {
                    $recipeRef = $operation->getRecipeRef();
                }
            }

            $version = $package->getPrettyVersion();
            if ($operation instanceof InformationOperation && $operation->getVersion()) {
                $version = $operation->getVersion();
            }
            if (str_starts_with($version, 'dev-') && isset($package->getExtra()['branch-alias'])) {
                $branchAliases = $package->getExtra()['branch-alias'];
                if (
                    (isset($branchAliases[$version]) && $alias = $branchAliases[$version])
                    || (isset($branchAliases['dev-main']) && $alias = $branchAliases['dev-main'])
                    || (isset($branchAliases['dev-trunk']) && $alias = $branchAliases['dev-trunk'])
                    || (isset($branchAliases['dev-develop']) && $alias = $branchAliases['dev-develop'])
                    || (isset($branchAliases['dev-default']) && $alias = $branchAliases['dev-default'])
                    || (isset($branchAliases['dev-latest']) && $alias = $branchAliases['dev-latest'])
                    || (isset($branchAliases['dev-next']) && $alias = $branchAliases['dev-next'])
                    || (isset($branchAliases['dev-current']) && $alias = $branchAliases['dev-current'])
                    || (isset($branchAliases['dev-support']) && $alias = $branchAliases['dev-support'])
                    || (isset($branchAliases['dev-tip']) && $alias = $branchAliases['dev-tip'])
                    || (isset($branchAliases['dev-master']) && $alias = $branchAliases['dev-master'])
                ) {
                    $version = $alias;
                }
            }

            if ($recipeVersions = $this->index[$package->getName()] ?? null) {
                $version = explode('.', preg_replace('/^dev-|^v|\.x-dev$|-dev$/', '', $version));
                $version = $version[0].'.'.($version[1] ?? '9999999');

                foreach (array_reverse($recipeVersions) as $v => $endpoint) {
                    if (version_compare($version, $v, '<')) {
                        continue;
                    }

                    $data['locks'][$package->getName()]['version'] = $version;
                    $data['locks'][$package->getName()]['recipe']['version'] = $v;
                    $links = $this->endpoints[$endpoint]['_links'];

                    if (null !== $recipeRef && isset($links['archived_recipes_template'])) {
                        if (isset($links['archived_recipes_template_relative'])) {
                            $links['archived_recipes_template'] = preg_replace('{[^/\?]*+(?=\?|$)}', $links['archived_recipes_template_relative'], $endpoint, 1);
                        }

                        $urls[] = strtr($links['archived_recipes_template'], [
                            '{package_dotted}' => str_replace('/', '.', $package->getName()),
                            '{ref}' => $recipeRef,
                        ]);

                        break;
                    }

                    if (isset($links['recipe_template_relative'])) {
                        $links['recipe_template'] = preg_replace('{[^/\?]*+(?=\?|$)}', $links['recipe_template_relative'], $endpoint, 1);
                    }

                    $urls[] = strtr($links['recipe_template'], [
                        '{package_dotted}' => str_replace('/', '.', $package->getName()),
                        '{package}' => $package->getName(),
                        '{version}' => $v,
                    ]);

                    break;
                }

                continue;
            }

            if (\is_array($recipeVersions)) {
                $data['conflicts'][$package->getName()] = true;
            }

            if (null !== $this->endpoints) {
                continue;
            }

            // FIXME: Multi name with getNames()
            $name = str_replace('/', ',', $package->getName());
            $path = \sprintf('%s,%s%s', $name, $o, $version);
            if ($date = $package->getReleaseDate()) {
                $path .= ','.$date->format('U');
            }
            if (\strlen($chunk) + \strlen($path) > self::MAX_LENGTH) {
                $urls[] = $this->legacyEndpoint.'/p/'.$chunk;
                $chunk = $path;
            } elseif ($chunk) {
                $chunk .= ';'.$path;
            } else {
                $chunk = $path;
            }
        }
        if ($chunk) {
            $urls[] = $this->legacyEndpoint.'/p/'.$chunk;
        }

        if (null === $this->endpoints) {
            foreach ($this->get($urls, true) as $body) {
                foreach ($body['manifests'] ?? [] as $name => $manifest) {
                    $data['manifests'][$name] = $manifest;
                }
                foreach ($body['locks'] ?? [] as $name => $lock) {
                    $data['locks'][$name] = $lock;
                }
            }
        } else {
            foreach ($this->get($urls, true) as $body) {
                foreach ($body['manifests'] ?? [] as $name => $manifest) {
                    if (null === $version = $data['locks'][$name]['recipe']['version'] ?? null) {
                        continue;
                    }
                    $endpoint = $this->endpoints[$this->index[$name][$version]];

                    $data['locks'][$name]['recipe'] = [
                        'repo' => $endpoint['_links']['repository'],
                        'branch' => $endpoint['branch'],
                        'version' => $version,
                        'ref' => $manifest['ref'],
                    ];

                    foreach ($manifest['files'] ?? [] as $i => $file) {
                        $manifest['files'][$i]['contents'] = \is_array($file['contents']) ? implode("\n", $file['contents']) : base64_decode($file['contents']);
                    }

                    $data['manifests'][$name] = $manifest + [
                        'repository' => $endpoint['_links']['repository'],
                        'package' => $name,
                        'version' => $version,
                        'origin' => strtr($endpoint['_links']['origin_template'], [
                            '{package}' => $name,
                            '{version}' => $version,
                        ]),
                        'is_contrib' => $endpoint['is_contrib'] ?? false,
                    ];
                }
            }
        }

        return $data;
    }

    /**
     * Used to "hide" a recipe version so that the next most-recent will be returned.
     *
     * This is used when resolving "conflicts".
     */
    public function removeRecipeFromIndex(string $packageName, string $version)
    {
        unset($this->index[$packageName][$version]);
    }

    public function getSymfonyPacks(array $packages)
    {
        $packs = [];
        foreach ($this->composer->getRepositoryManager()->getRepositories() as $repo) {
            if (!$packages) {
                break;
            }

            $result = $repo->loadPackages($packages, BasePackage::$stabilities, []);

            foreach ($result['packages'] ?? [] as $package) {
                if (!isset($packages[$package->getName()])) {
                    continue;
                }
                if ('symfony-pack' === $package->getType()) {
                    $packs[$package->getName()] = true;
                }
                unset($packages[$package->getName()]);
            }
        }

        return array_keys($packs);
    }

    /**
     * Fetches and decodes JSON HTTP response bodies.
     */
    private function get(array $urls, bool $isRecipe = false, int $try = 3): array
    {
        $responses = [];
        $retries = [];
        $options = [];

        foreach ($urls as $url) {
            $cacheKey = self::generateCacheKey($url);
            $headers = [];

            if (preg_match('{^https?://api\.github\.com/}', $url)) {
                $headers[] = 'Accept: application/vnd.github.v3.raw';
            } elseif (preg_match('{^https?://raw\.githubusercontent\.com/}', $url) && $this->io->hasAuthentication('github.com')) {
                $auth = $this->io->getAuthentication('github.com');
                if ('x-oauth-basic' === $auth['password']) {
                    $headers[] = 'Authorization: token '.$auth['username'];
                }
            } elseif ($this->legacyEndpoint) {
                $headers[] = 'Package-Session: '.$this->sess;
            }

            if ($contents = $this->cache->read($cacheKey)) {
                $cachedResponse = Response::fromJson(json_decode($contents, true));
                if ($lastModified = $cachedResponse->getHeader('last-modified')) {
                    $headers[] = 'If-Modified-Since: '.$lastModified;
                }
                if ($eTag = $cachedResponse->getHeader('etag')) {
                    $headers[] = 'If-None-Match: '.$eTag;
                }
                $responses[$url] = $cachedResponse->getBody();
            }

            $options[$url] = $this->getOptions($headers);
        }

        $loop = new Loop($this->rfs);
        $jobs = [];
        foreach ($urls as $url) {
            $jobs[] = $this->rfs->add($url, $options[$url])->then(function (ComposerResponse $response) use ($url, &$responses) {
                if (200 === $response->getStatusCode()) {
                    $cacheKey = self::generateCacheKey($url);
                    $responses[$url] = $this->parseJson($response->getBody(), $url, $cacheKey, $response->getHeaders())->getBody();
                }
            }, function (\Exception $e) use ($url, &$retries) {
                $retries[] = [$url, $e];
            });
        }
        $loop->wait($jobs);

        if (!$retries) {
            return $responses;
        }

        if (0 < --$try) {
            usleep(100000);

            return $this->get(array_column($retries, 0), $isRecipe, $try) + $responses;
        }

        foreach ($retries as [$url, $e]) {
            if (isset($responses[$url])) {
                $this->switchToDegradedMode($e, $url);
            } elseif ($isRecipe) {
                $this->io->writeError('<warning>Failed to download recipe: '.$e->getMessage().'</>');
            } else {
                throw $e;
            }
        }

        return $responses;
    }

    private function parseJson(string $json, string $url, string $cacheKey, array $lastHeaders): Response
    {
        $data = JsonFile::parseJson($json, $url);
        if (!empty($data['warning'])) {
            $this->io->writeError('<warning>Warning from '.$url.': '.$data['warning'].'</>');
        }
        if (!empty($data['info'])) {
            $this->io->writeError('<info>Info from '.$url.': '.$data['info'].'</>');
        }

        $response = new Response($data, $lastHeaders);
        if ($cacheKey && ($response->getHeader('last-modified') || $response->getHeader('etag'))) {
            $this->cache->write($cacheKey, json_encode($response));
        }

        return $response;
    }

    private function switchToDegradedMode(\Exception $e, string $url)
    {
        if (!$this->degradedMode) {
            $this->io->writeError('<warning>'.$e->getMessage().'</>');
            $this->io->writeError('<warning>'.$url.' could not be fully loaded, package information was loaded from the local cache and may be out of date</>');
        }
        $this->degradedMode = true;
    }

    private function getOptions(array $headers): array
    {
        $options = ['http' => ['header' => $headers]];

        if (null !== $this->caFile) {
            $options['ssl']['cafile'] = $this->caFile;
        }

        return $options;
    }

    private function initialize()
    {
        if (null !== $this->index || null === $this->endpoints) {
            $this->index ?? $this->index = [];

            return;
        }

        $indexes = self::$versions = self::$aliases = [];

        foreach ($this->get(array_keys($this->endpoints)) as $endpoint => $index) {
            $indexes[$endpoint] = $index;
        }

        foreach ($this->endpoints as $endpoint => $config) {
            $config = $indexes[$endpoint] ?? [];
            foreach ($config['recipes'] ?? [] as $package => $versions) {
                $this->index[$package] = $this->index[$package] ?? array_fill_keys($versions, $endpoint);
            }
            $this->conflicts[] = $config['recipe-conflicts'] ?? [];
            self::$versions += $config['versions'] ?? [];
            self::$aliases += $config['aliases'] ?? [];
            unset($config['recipes'], $config['recipe-conflicts'], $config['versions'], $config['aliases']);
            $this->endpoints[$endpoint] = $config;
        }
    }

    private static function generateCacheKey(string $url): string
    {
        $url = preg_replace('{^https://api.github.com/repos/([^/]++/[^/]++)/contents/}', '$1/', $url);
        $url = preg_replace('{^https://raw.githubusercontent.com/([^/]++/[^/]++)/}', '$1/', $url);

        $key = preg_replace('{[^a-z0-9.]}i', '-', $url);

        // eCryptfs can have problems with filenames longer than around 143 chars
        return \strlen($key) > 140 ? md5($url) : $key;
    }
}
