#!/usr/bin/php
<?php
declare(strict_types=1);
/**
 * A utility for generating an RPM spec for composer packages
 *
 * @author Jarryd Lisher <jarryd@jlisher.com>
 * @license MIT
 */

namespace Jlisher\Composer2Rpm;

use JsonException;

const VERSION = '0.1.2';
const ERROR_NONE = 0;
const ERROR_INPUT = 1;
const ERROR_PROCESS = 2;

interface VersionRangeContract
{
    public function getMin(): ?string;

    public function getMax(): ?string;
}

class Options
{
    private ?string $package = null;
    private string $version_constraint = '*';
    private string $rpm_prefix = 'php';
    private string $collision_suffix = '.new';
    private bool $clean = true;
    private bool $archive_only = false;

    public function __construct(
        private int $argc,
        private array $argv,
    ) {
        $this->processArguments();

        if (!$this->hasRequired()) {
            echo $this->getHelp();
            exit(ERROR_INPUT);
        }
    }

    private function processArguments(): void
    {
        if ($this->argc < 2) {
            return;
        }

        array_shift(array: $this->argv);
        --$this->argc;

        while ($this->argc > 0) {
            $arg = array_shift(array: $this->argv);
            --$this->argc;

            if (!$arg) {
                break;
            }

            if ($arg[0] !== '-') {
                array_unshift($this->argv, $arg);
                ++$this->argc;
                break;
            }

            switch ($arg) {
                case '--':
                    break 2;

                case '-h':
                case '--help':
                    echo $this->getHelp();
                    exit(ERROR_NONE);

                case '-v':
                case '--version':
                    echo VERSION . PHP_EOL;
                    exit(ERROR_NONE);

                case '-p':
                case '--prefix':
                    $this->rpm_prefix = array_shift(array: $this->argv);
                    $this->argc--;
                    break;

                case '-n':
                case '--no-clean':
                    $this->clean = false;
                    break;

                case '-a':
                case '--archive':
                    $this->archive_only = true;
                    break;
            }
        }

        if ($this->argc > 0) {
            $this->package = array_shift(array: $this->argv);
            $this->argc--;
        }

        if ($this->argc > 0) {
            $this->version_constraint = array_shift(array: $this->argv);
            $this->argc--;
        }
    }

    private function getHelp(): string
    {
        return <<<EOF
composer2rpm is a utility for generating an RPM spec for composer packages

usage:
    composer2rpm [options] [--] package [version]

arguments:
    package    The composer package name
    version    An optional version constraint to use as a filter

options:
    Required values for long options are also required for short options

    -h, --help              Print help and exit
    -v, --version           Print version and exit
    -p, --prefix PREFIX     The prefix to use for the RPM package (default: `php`)
    -n, --no-clean          Do not clean generated files
    -a, --archive           Only generate the package archive

EOF;
    }

    private function hasRequired(): bool
    {
        return (bool)$this->getPackage();
    }

    public function getPackage(): ?string
    {
        return $this->package;
    }

    public function getVersionConstraint(): string
    {
        return $this->version_constraint;
    }

    public function getRpmPrefix(): string
    {
        return $this->rpm_prefix;
    }

    public function getCollisionSuffix(): string
    {
        return $this->collision_suffix;
    }

    public function shouldClean(): bool
    {
        return $this->clean;
    }

    public function shouldArchiveOnly(): bool
    {
        return $this->archive_only;
    }
}

abstract class OptionsControlled
{
    public function __construct(
        private Options $options,
    ) {
    }

    protected function getOptions(): Options
    {
        return $this->options;
    }
}

class Version implements VersionRangeContract
{
    private const TRIM_CHARS = 'v^~>=';

    private ?string $version = null;
    private ?string $constraint = null;
    private ?string $min = null;
    private ?string $max = null;

    public function __construct(
        private string $original = '*',
    ) {
    }

    private function getConstraintMax(): string
    {
        $parts = explode(separator: '.', string: $this->getVersion());
        $part = 0;

        switch ($this->getConstraint()) {
            case '~':
                array_pop(array: $parts);
                $part = (int)array_pop(array: $parts);
                break;

            case '^':
                $new_parts = [];
                do {
                    $part = (int)array_shift(array: $parts);
                    $new_parts[] = $part;
                } while ($part === 0);
                array_pop(array: $new_parts);
                $parts = $new_parts;
                break;
        }

        $parts[] = ++$part;

        if (count(value: $parts) < 3) {
            $parts[] = 0;
        }

        return implode(separator: '.', array: $parts);
    }

    public function getVersion(): string
    {
        if (!$this->version) {
            $this->version = trim(string: $this->original, characters: static::TRIM_CHARS);
        }

        return $this->version;
    }

    public function getConstraint(): string
    {
        if (!$this->constraint) {
            $this->constraint = match ($this->original[0]) {
                '*', '>', '~', '^' => $this->original[0],
                default => '=',
            };
        }

        return $this->constraint;
    }

    public function getMin(): ?string
    {
        if (!$this->min) {
            $this->min = match ($this->getConstraint()) {
                '*' => null,
                default => $this->getVersion(),
            };
        }

        return $this->min;
    }

    public function getMax(): ?string
    {
        if (!$this->max) {
            $this->max = match ($this->getConstraint()) {
                '*', '>' => null,
                '=' => $this->getVersion(),
                default => $this->getConstraintMax(),
            };
        }

        return $this->max;
    }

    public function isSuccessorOf(VersionRangeContract $range): bool
    {
        if ($range->getMax() === null || $this->getMin() === null) {
            return false;
        }

        return version_compare(version1: $range->getMax(), version2: $this->getMin(), operator: 'eq');
    }
}

class VersionRange implements VersionRangeContract
{
    public function __construct(
        private ?string $min = null,
        private ?string $max = null,
    ) {
    }

    public function getMin(): ?string
    {
        return $this->min;
    }

    public function getMax(): ?string
    {
        return $this->max;
    }
}

class Dependency
{
    private string $package = '';
    /** @var VersionRange[] */
    private array $version_ranges = [];
    private string $rpm_constraints = '';

    public function __construct(
        private string $name,
        private string $version_constraints,
    ) {
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function getPackage(): string
    {
        if (!$this->package) {
            $name = $this->getName();
            $this->package = match (substr(string: $name, offset: 0, length: 4)) {
                'php' => 'php(language)',
                'ext-' => 'php-' . substr(string: $name, offset: 4),
                default => 'php-composer(' . $name . ')',
            };
        }

        return $this->package;
    }

    public function getVersionRanges(): array
    {
        if (!$this->version_ranges) {
            $constraints = explode(separator: '||', string: $this->version_constraints);
            $range = false;

            foreach ($constraints as $constraint) {
                $constraint = trim(string: $constraint);
                $version = new Version(original: $constraint);

                if ($range && $version->isSuccessorOf(range: $range)) {
                    array_pop(array: $this->version_ranges);
                    $range = new VersionRange(min: $range->getMin(), max: $version->getMax());
                    $this->version_ranges[] = $range;
                    continue;
                }

                $range = new VersionRange(min: $version->getMin(), max: $version->getMax());
                $this->version_ranges[] = $range;
            }
        }

        return $this->version_ranges;
    }

    public function getRpmConstraint(): string
    {
        if (!$this->rpm_constraints) {
            $constraints = [];

            foreach ($this->getVersionRanges() as $range) {
                if ($range->getMin() !== null && $range->getMax() !== null) {
                    if ($range->getMin() === $range->getMax()) {
                        $constraints[] = $this->getPackage() . ' = ' . $range->getMin();
                        continue;
                    }

                    $constraints[] = '(' .
                        $this->getPackage() . ' >= ' . $range->getMin() .
                        ' with ' .
                        $this->getPackage() . ' < ' . $range->getMax() .
                        ')';
                    continue;
                }

                if ($range->getMin() !== null) {
                    $constraints[] = $this->getPackage() . ' >= ' . $range->getMin();
                    continue;
                }

                $constraints[] = $this->getPackage();
            }

            if (count(value: $constraints) > 1) {
                $this->rpm_constraints = '(' . implode(separator: ' or ', array: $constraints) . ')';
            } else {
                $this->rpm_constraints = $constraints[0];
            }
        }

        return $this->rpm_constraints;
    }
}

class ComposerPackage extends OptionsControlled
{
    private array $info = [];

    private string $name = '';
    private string $version = '';
    private string $path_name = '';

    private string $rpm_name = '';
    private string $rpm_licenses = '';

    private string $archive_suffix = 'tar.gz';
    private string $archive_name = '';

    /** @var Dependency[] */
    private array $requires = [];
    /** @var Dependency[] */
    private array $recommends = [];
    /** @var Dependency[] */
    private array $provides = [];

    private array $composer_json = [];
    private string $composer_path = '';
    private array $composer_autoload_psr4 = [];
    private array $composer_autoload_files = [];
    private string $license_path = '';
    private string $docs_glob = '';

    private function exec(string $program, array $args, ?string &$output = null, bool $quiet = true): bool
    {
        $args = array_map(callback: static fn(string $arg) => escapeshellarg(arg: $arg), array: $args);
        $cmd_parts = [$program, ...$args];

        if ($quiet) {
            $cmd_parts[] = '2>/dev/null';
        }

        $cmd = implode(separator: ' ', array: $cmd_parts);

        exec(command: $cmd, output: $tmp_output, result_code: $result_code);

        $tmp_output = array_map(callback: static fn(string $line) => trim(string: $line), array: $tmp_output);
        $output = implode(separator: '', array: $tmp_output);

        return $result_code === 0;
    }

    private function execComposer(array $args, ?string &$output = null, bool $quiet = true): bool
    {
        $args = ['--no-ansi', '--no-interaction', '--format=json', ...$args];

        return $this->exec(program: '/usr/bin/composer', args: $args, output: $output, quiet: $quiet);
    }

    private function execTar(array $args, ?string &$output = null, bool $quiet = true): bool
    {
        return $this->exec(program: '/usr/bin/tar', args: $args, output: $output, quiet: $quiet);
    }

    private function isVersionStable(string $version): bool
    {
        foreach (['dev', 'rc', 'beta'] as $unstable_string) {
            if (!str_contains(haystack: $version, needle: $unstable_string)) {
                continue;
            }

            return false;
        }

        return true;
    }

    private function getInfo(): array
    {
        if (!$this->info) {
            $info_args = ['info', '--available', $this->getOptions()->getPackage(), $this->getOptions()->getVersionConstraint()];

            if (!$this->execComposer(args: $info_args, output: $info_raw) || !$info_raw) {
                return $this->info;
            }

            try {
                $this->info = json_decode(json: $info_raw, associative: true, depth: 64, flags: JSON_THROW_ON_ERROR);
            } catch (JsonException $e) {
                error_log(message: 'Warning: ' . $e->getMessage());
                return $this->info;
            }
        }

        return $this->info;
    }

    public function getName(): string
    {
        if (!$this->name) {
            $this->name = $this->getInfo()['name'];
        }

        return $this->name;
    }

    public function getVersion(): string
    {
        if (!$this->version) {
            foreach ($this->getInfo()['versions'] as $version) {
                if (!$this->isVersionStable(version: $version)) {
                    continue;
                }

                $this->version = ltrim(string: $version, characters: " \t\n\r\0\x0Bv");
                break;
            }
        }

        return $this->version;
    }

    public function getPathName(): string
    {
        if (!$this->path_name) {
            $this->path_name = str_replace(search: '/', replace: '-', subject: $this->getName());
        }

        return $this->path_name;
    }

    public function getDescription(): string
    {
        return $this->getInfo()['description'] ?? '';
    }

    public function getHomeUri(): string
    {
        return $this->getInfo()['homepage'] ?? 'https://packagist.org/packages/' . $this->getName();
    }

    public function getRpmName(): string
    {
        if (!$this->rpm_name) {
            $this->rpm_name = $this->getOptions()->getRpmPrefix() . '-' . str_replace(search: '/', replace: '-', subject: $this->getName());
        }

        return $this->rpm_name;
    }

    public function getRpmSpecPath(): string
    {
        return $this->getRpmName() . '.spec';
    }

    public function getRpmLicenses(): string
    {
        if (!$this->rpm_licenses) {
            $rpm_licenses = array_map(callback: static fn(array $item) => $item['osi'], array: $this->getInfo()['licenses'] ?? []);
            $this->rpm_licenses = implode(separator: ' and ', array: $rpm_licenses);
        }

        return $this->rpm_licenses;
    }

    public function getArchiveName(): string
    {
        if (!$this->archive_name) {
            $this->archive_name = $this->getRpmName() . '-' . $this->getVersion();
        }

        return $this->archive_name;
    }

    public function getArchiveSuffix(): string
    {
        return $this->archive_suffix;
    }

    public function getArchivePath(): string
    {
        return $this->getArchiveName() . '.' . $this->getArchiveSuffix();
    }

    public function createArchive(): bool
    {
        if (file_exists(filename: $this->getArchivePath())) {
            return true;
        }

        $args = ['archive', '--format=' . $this->getArchiveSuffix(), '--file=' . $this->getArchiveName(), $this->getName(), $this->getVersion()];

        return $this->execComposer(args: $args);
    }

    public function extractArchive(): bool
    {
        if (is_dir(filename: $this->getArchiveName())) {
            return true;
        }

        if (!$this->createArchive()) {
            error_log(message: 'Error while creating the archive');
            return false;
        }

        if (!mkdir(directory: $this->getArchiveName()) && !is_dir(filename: $this->getArchiveName())) {
            error_log(message: 'Error while creating the extraction directory');
            return false;
        }

        $args = ['-axf', $this->getArchivePath(), '-C', $this->getArchiveName()];

        return $this->execTar(args: $args);
    }

    public function getRequires(): array
    {
        if (!$this->requires) {
            $requires = $this->getInfo()['requires'] ?? [];

            foreach ($requires as $name => $version_constraints) {
                $this->requires[] = new Dependency(name: $name, version_constraints: $version_constraints);
            }
        }

        return $this->requires;
    }

    public function getRecommends(): array
    {
        if (!$this->recommends) {
            $info = $this->getInfo();
            $dev_requires = $info['devRequires'] ?? [];
            $suggests = array_keys(array: $info['suggests'] ?? []);

            foreach ($suggests as $name) {
                $this->recommends[] = new Dependency(name: $name, version_constraints: $dev_requires[$name] ?? '*');
            }
        }

        return $this->recommends;
    }

    public function getProvides(): array
    {
        if (!$this->provides) {
            $provides = $this->getInfo()['provides'] ?? [];

            foreach ($provides as $name => $version_constraints) {
                $this->provides[] = new Dependency(name: $name, version_constraints: $version_constraints);
            }
        }

        return $this->provides;
    }

    public function getComposerPath(): string
    {
        if (!$this->composer_path) {
            $this->composer_path = $this->getArchiveName() . DIRECTORY_SEPARATOR . 'composer.json';
        }

        return $this->composer_path;
    }

    public function getComposerJson(): array
    {
        if (!$this->composer_json) {
            if (!$this->extractArchive()) {
                error_log(message: 'Error while extracting the package archive.');
            }

            if (!file_exists(filename: $this->getComposerPath())) {
                error_log(message: 'composer.json file not found');
                error_log(message: 'Could not generate the %install lines');
                exit(ERROR_PROCESS);
            }

            try {
                $this->composer_json = json_decode(
                    json: file_get_contents(filename: $this->getComposerPath()),
                    associative: true,
                    depth: 64,
                    flags: JSON_THROW_ON_ERROR
                );
            } catch (JsonException $e) {
                error_log(message: $e->getMessage());
                $this->composer_json = [];
            }
        }

        return $this->composer_json;
    }

    public function getComposerAutoloadPsr4(): array
    {
        if (!$this->composer_autoload_psr4) {
            $autoload = $this->getComposerJson()['autoload'] ?? [];
            $this->composer_autoload_psr4 = $autoload['psr-4'] ?? [];
        }

        return $this->composer_autoload_psr4;
    }

    public function getComposerAutoloadFiles(): array
    {
        if (!$this->composer_autoload_files) {
            $autoload = $this->getComposerJson()['autoload'] ?? [];
            $this->composer_autoload_files = $autoload['files'] ?? [];
        }

        return $this->composer_autoload_files;
    }

    public function getLicensePath(): string
    {
        if (!$this->license_path) {
            $this->license_path = $this->getArchiveName() . DIRECTORY_SEPARATOR . 'LICENSE';
        }

        return $this->license_path;
    }

    public function getDocsGlob(): string
    {
        if (!$this->docs_glob) {
            $this->docs_glob = $this->getArchiveName() . DIRECTORY_SEPARATOR . '*.md';
        }

        return $this->docs_glob;
    }
}

class Generator extends OptionsControlled
{
    private ?ComposerPackage $package = null;

    private function getPackage(): ComposerPackage
    {
        if (!$this->package) {
            $this->package = new ComposerPackage(options: $this->getOptions());
        }

        return $this->package;
    }

    private function renderRequires(): string
    {
        $output = '';

        foreach ($this->getPackage()->getRequires() as $require) {
            $output .= <<<EOF
                Requires:       {$require->getRpmConstraint()}

                EOF;
        }

        return $output;
    }

    private function renderRecommends(): string
    {
        $output = PHP_EOL;

        foreach ($this->getPackage()->getRecommends() as $recommend) {
            $output .= <<<EOF
                Recommends:     {$recommend->getRpmConstraint()}

                EOF;
        }

        return $output;
    }

    private function renderDependencies(): string
    {
        return $this->renderRequires() . $this->renderRecommends();
    }

    private function renderProvides(): string
    {
        $output = '';

        foreach ($this->getPackage()->getProvides() as $provide) {
            $output .= <<<EOF
                Provides:       {$provide->getRpmConstraint()}

                EOF;
        }

        return $output;
    }

    private function renderInstall(): string
    {
        $output = '';
        $package = $this->getPackage();
        $src_dir = null;
        $src_dir_length = 0;
        $install_dest = null;

        foreach ($package->getComposerAutoloadPsr4() as $namespace => $path) {
            $path = rtrim(string: $path, characters: '/');

            if (!$src_dir) {
                $src_dir = $path;
                $src_dir_length = strlen(string: $src_dir);
            }

            $install_dir = rtrim(string: str_replace(search: '\\', replace: DIRECTORY_SEPARATOR, subject: $namespace), characters: '/');

            if (!$install_dest) {
                $install_dest = $install_dir;
            }

            $output .= <<<EOF
                %{__install} -dDm 0755 %{buildroot}%{_phpautoload_dir}/{$install_dir}
                %{__cp} -pr {$path}/* %{buildroot}%{_phpautoload_dir}/{$install_dir}/

                EOF;
        }

        foreach ($package->getComposerAutoloadFiles() as $file) {
            if ($src_dir && str_starts_with(haystack: $file, needle: $src_dir)) {
                $file = substr(string: $file, offset: $src_dir_length + 1);

                $output .= <<<EOF
                    %{__install} -dDm 0755 %{buildroot}%{_phpautoload_files_dir}
                    %{__ln_s} -nr %{buildroot}%{_phpautoload_dir}/{$install_dest}/{$file} \
                        %{buildroot}%{_phpautoload_files_dir}/{$package->getPathName()}.php

                    EOF;
                continue;
            }

            $output .= <<<EOF
                %{__install} -pDm 0644 {$file} \
                    %{buildroot}%{_phpautoload_files_dir}/{$package->getPathName()}.php

                EOF;
        }

        return $output;
    }

    private function renderFiles(): string
    {
        $output = '';
        $package = $this->getPackage();

        if (file_exists(filename: $package->getLicensePath())) {
            $output .= <<<EOF
                %license LICENSE

                EOF;
        }

        $path_offset = strlen(string: $package->getArchiveName() . DIRECTORY_SEPARATOR);
        $doc_files = [...glob(pattern: $package->getDocsGlob()), $package->getComposerPath()];
        $doc_files = array_map(callback: static fn(string $item) => substr(string: $item, offset: $path_offset), array: $doc_files);
        $doc_files = implode(separator: ' ', array: $doc_files);

        $output .= <<<EOF
            %doc {$doc_files}

            EOF;

        if ($package->getComposerAutoloadPsr4()) {
            foreach (array_keys(array: $package->getComposerAutoloadPsr4()) as $namespace) {
                $install_dir = rtrim(string: str_replace(search: '\\', replace: DIRECTORY_SEPARATOR, subject: $namespace), characters: '/');

                $output .= <<<EOF
                    %{_phpautoload_dir}/{$install_dir}

                    EOF;
            }
        }

        if ($package->getComposerAutoloadFiles()) {
            $output .= <<<EOF
                %{_phpautoload_files_dir}/{$package->getPathName()}.php

                EOF;
        }

        return $output;
    }

    private function renderSpec(): string
    {
        $package = $this->getPackage();

        return <<<EOF
            %{!?_phpautoload_dir:       %global _phpautoload_dir        %{_datadir}/php}
            %{!?_phpautoload_files_dir: %global _phpautoload_files_dir  %{_phpautoload_dir}/autoload-files}

            Name:           {$package->getRpmName()}
            Version:        {$package->getVersion()}
            Release:        %{autorelease}
            Summary:        {$package->getDescription()}
            License:        {$package->getRpmLicenses()}
            URL:            {$package->getHomeUri()}

            Source0:         %{name}-%{version}.tar.gz

            BuildRequires:  php-autoload-rpm-macros

            Requires:       php-autoload
            {$this->renderDependencies()}
            Provides:       php-composer({$package->getName()}) = %{version}
            {$this->renderProvides()}
            %description
            {$package->getDescription()}.


            %prep
            %setup -q -c


            %build
            : Nothing to build


            %install
            {$this->renderInstall()}

            %check
            : Please write a test


            %files
            {$this->renderFiles()}

            %changelog
            %{autochangelog}

            EOF;
    }

    private function removeDirectory(string $directory): void
    {
        if (!file_exists(filename: $directory)) {
            return;
        }

        if (!is_dir(filename: $directory)) {
            unlink(filename: $directory);
            return;
        }

        $files = [...glob(pattern: $directory . DIRECTORY_SEPARATOR . '*')];

        foreach ($files as $file) {
            $this->removeDirectory(directory: $file);
        }

        rmdir(directory: $directory);
    }

    public function run(): bool
    {
        $package = $this->getPackage();
        $options = $this->getOptions();

        if ($options->shouldArchiveOnly()) {
            return $package->createArchive();
        }

        $spec = $this->renderSpec();
        $spec_path = $package->getRpmSpecPath();

        if (file_exists(filename: $spec_path)) {
            $spec_path .= $options->getCollisionSuffix();
        }

        $written = file_put_contents(filename: $spec_path, data: $spec);

        if ($options->shouldClean()) {
            $this->removeDirectory(directory: $package->getArchiveName());
            $this->removeDirectory(directory: 'vendor');
        }

        return (bool)$written;
    }
}

(static function (int $argc, array $argv): void {
    ini_set(option: 'error_log', value: '/dev/stderr');

    $options = new Options(argc: $argc, argv: $argv);
    $generator = new Generator(options: $options);

    if (!$generator->run()) {
        error_log(message: 'There was an issue running the generator.');
        exit(ERROR_PROCESS);
    }

    echo 'Successfully generated files.' . PHP_EOL;
    exit(ERROR_NONE);
})(argc: $argc, argv: $argv);
