#!/usr/bin/env php
<?php

declare(strict_types=1);

const APP_NAME = 'phpstorm-ebuild-svn-commit-helper';

main($argv);

/**
 * @param list<string> $argv
 */
function main(array $argv): void
{
    $args = parse_args($argv);

    if (isset($args['help'])) {
        print_usage();
        exit(0);
    }
    require_program('zenity');

    if (isset($args['manifest'])) {
        update_manifest_for_ebuild((string)$args['manifest'], isset($args['dry-run']));
        return;
    }
    $sourceDir = resolve_source_dir((string)($args['source'] ?? getcwd()));
    ensure_svn_working_copy($sourceDir, 'source');
    $dryRun = isset($args['dry-run']);

    $mapping = load_mapping();
    $mappingKey = $sourceDir;

    $ebuildDir = null;
    if (isset($args['ebuild-dir'])) {
        $ebuildDir = canonical_dir((string)$args['ebuild-dir']);
    } elseif (isset($mapping[$mappingKey])) {
        $candidate = expand_home((string)$mapping[$mappingKey]);
        if (is_dir($candidate)) {
            $ebuildDir = canonical_dir($candidate);
        }
    }

    if ($ebuildDir === null) {
        $ebuildDir = select_ebuild_dir_with_zenity($sourceDir);
        $mapping[$mappingKey] = $ebuildDir;
        save_mapping($mapping);
        info("Saved mapping: {$sourceDir} -> {$ebuildDir}");
    }
    ensure_svn_working_copy($ebuildDir, 'ebuild');
    update_ebuild_working_copy($ebuildDir, $dryRun);
    $sourceVersion = (string)($args['version'] ?? detect_meson_version($sourceDir) ?? '');
    if ($sourceVersion === '') {
        fail("Could not detect source version from {$sourceDir}/meson.build. Pass --version=VERSION if this package does not declare meson project(..., version: ...).");
    }

    $packageName = basename($ebuildDir);
    $oldEbuild = find_latest_ebuild($ebuildDir, $packageName, $sourceVersion);
    if ($oldEbuild === null) {
        fail("No {$packageName}-*.ebuild with version <= {$sourceVersion} found in {$ebuildDir}");
    }
    $dependencyWarning = dependency_warning($sourceDir, $oldEbuild);
    if ($dependencyWarning !== null) {
        warn($dependencyWarning);
        if (!isset($args['yes']) && !confirm_with_zenity('Meson deps changed', dependency_warning_confirmation_text())) {
            exit(2);
        }
    }
    $sourceRevision = read_committed_source_revision($sourceDir);

    $newEbuild = $ebuildDir . DIRECTORY_SEPARATOR . "{$packageName}-{$sourceVersion}_p{$sourceRevision}.ebuild";
    if (file_exists($newEbuild)) {
        fail("Target ebuild already exists: {$newEbuild}");
    }

    $category = basename(dirname($ebuildDir));
    $overlayCommitMessage = "{$category}/{$packageName}: add {$sourceVersion}_p{$sourceRevision}";
    $manifest = $ebuildDir . DIRECTORY_SEPARATOR . 'Manifest';

    run_or_show($ebuildDir, ['svn', 'cp', $oldEbuild, $newEbuild], $dryRun);
    transfer_abi_subslot_if_changed($sourceDir, $oldEbuild, $newEbuild, $dryRun);
    run_manifest_or_show($ebuildDir, $newEbuild, $dryRun);
    ensure_manifest_is_versioned($ebuildDir, $manifest, $dryRun);
    run_or_show($ebuildDir, ['svn', 'commit', $newEbuild, $manifest, '-m', $overlayCommitMessage], $dryRun);

    info("Done.");
    info("Source revision: r{$sourceRevision}");
    info("Created ebuild: {$newEbuild}");
}

/**
 * @param list<string> $argv
 * @return array<string, string|bool>
 */
function parse_args(array $argv): array
{
    $args = [];
    foreach (array_slice($argv, 1) as $arg) {
        if ($arg === '--help' || $arg === '-h') {
            $args['help'] = true;
            continue;
        }
        if ($arg === '--yes' || $arg === '-y') {
            $args['yes'] = true;
            continue;
        }
        if ($arg === '--dry-run') {
            $args['dry-run'] = true;
            continue;
        }
        if (str_starts_with($arg, '--source=')) {
            $args['source'] = substr($arg, strlen('--source='));
            continue;
        }
        if (str_starts_with($arg, '--manifest=')) {
            $args['manifest'] = substr($arg, strlen('--manifest='));
            continue;
        }
        if (str_starts_with($arg, '--ebuild-dir=')) {
            $args['ebuild-dir'] = substr($arg, strlen('--ebuild-dir='));
            continue;
        }
        if (str_starts_with($arg, '--version=')) {
            $args['version'] = substr($arg, strlen('--version='));
            continue;
        }
        if (!isset($args['source'])) {
            $args['source'] = $arg;
            continue;
        }
        fail("Unknown argument: {$arg}");
    }
    return $args;
}

function print_usage(): void
{
    echo APP_NAME, "\n\n";
    echo "Usage:\n";
    echo "  phpstorm-ebuild-svn-commit-helper.php [--source=PATH] [--ebuild-dir=PATH] [--version=VERSION] [--yes] [--dry-run]\n\n";
    echo "  phpstorm-ebuild-svn-commit-helper.php --manifest=PACKAGE.ebuild [--dry-run]\n\n";
    echo "PhpStorm External Tool example:\n";
    echo "  Program: phpstorm-ebuild-svn-commit-helper\n";
    echo "  Arguments: --source=\$FilePath\$\n";
    echo "  Working directory: \$FileDir\$\n";
}

function update_manifest_for_ebuild(string $path, bool $dryRun): void
{
    $ebuild = canonical_file($path);
    if (!str_ends_with($ebuild, '.ebuild')) {
        fail_with_zenity(
            'Not an ebuild file',
            "Manifest update can only be run for .ebuild files.\n\nSelected path:\n{$ebuild}"
        );
    }

    $ebuildDir = dirname($ebuild);
    ensure_svn_working_copy($ebuildDir, 'ebuild');

    $manifest = $ebuildDir . DIRECTORY_SEPARATOR . 'Manifest';
    run_manifest_or_show($ebuildDir, $ebuild, $dryRun);
    ensure_manifest_is_versioned($ebuildDir, $manifest, $dryRun);

    info(($dryRun ? 'Manifest update command prepared for: ' : 'Manifest updated for: ') . $ebuild);
}

function read_committed_source_revision(string $sourceDir): string
{
    $revision = read_max_last_changed_revision($sourceDir) ?? read_last_changed_revision($sourceDir);
    if ($revision === null) {
        fail('Could not determine last-changed source revision. Commit the source working copy in PhpStorm first.');
    }
    info("Using committed source revision r{$revision}.");
    return $revision;
}

function resolve_source_dir(string $path): string
{
    $dir = canonical_dir($path);
    $current = $dir;

    while (true) {
        if (is_file($current . DIRECTORY_SEPARATOR . 'meson.build')) {
            return $current;
        }

        $parent = dirname($current);
        if ($parent === $current) {
            break;
        }
        $current = $parent;
    }

    fail_with_zenity(
        'No meson.build found',
        "No meson.build found for the selected source path:\n{$dir}\n\nSelect a file or directory inside a Meson source package."
    );
}

function detect_meson_version(string $sourceDir): ?string
{
    $meson = $sourceDir . DIRECTORY_SEPARATOR . 'meson.build';
    if (!is_file($meson)) {
        return null;
    }
    $text = file_get_contents($meson);
    if ($text === false) {
        return null;
    }
    if (preg_match('/version\s*:\s*[\'"]([^\'"]+)[\'"]/', $text, $m)) {
        return $m[1];
    }
    return null;
}

function dependency_warning(string $sourceDir, string $templateEbuild): ?string
{
    $meson = $sourceDir . DIRECTORY_SEPARATOR . 'meson.build';
    if (!is_file($meson)) {
        return null;
    }

    $templateRevision = extract_source_revision_from_ebuild($templateEbuild);
    if ($templateRevision === null) {
        return "Could not determine source SVN revision from template ebuild: " . basename($templateEbuild) . "\n\nReview the ebuild dependencies before publishing.";
    }

    $template = run_cmd($sourceDir, ['svn', 'cat', '-r', $templateRevision, $meson], false);
    if ($template['exit'] !== 0) {
        return "Could not read meson.build from template source revision r{$templateRevision}.\n\n" . combined_output($template) . "\n\nReview the ebuild dependencies before publishing.";
    }

    $current = file_get_contents($meson);
    if ($current === false) {
        return null;
    }

    $oldFingerprint = dependency_fingerprint($template['stdout']);
    $newFingerprint = dependency_fingerprint($current);

    if ($oldFingerprint === $newFingerprint) {
        return null;
    }

    return "Dependency-like parts of meson.build changed since template ebuild source revision r{$templateRevision}. Review the ebuild dependencies before publishing.\n\n"
        . "Old fingerprint:\n" . substr($oldFingerprint, 0, 1200)
        . "\n\nNew fingerprint:\n" . substr($newFingerprint, 0, 1200);
}

function dependency_warning_confirmation_text(): string
{
    return "Dependency-like lines in meson.build changed since the template ebuild.\n\n"
        . "Check BDEPEND/DEPEND/RDEPEND in the new ebuild. Continue only if the ebuild dependencies are already correct.\n\n"
        . "The detailed fingerprint diff was printed in the terminal.";
}

function transfer_abi_subslot_if_changed(string $sourceDir, string $templateEbuild, string $newEbuild, bool $dryRun): void
{
    $meson = $sourceDir . DIRECTORY_SEPARATOR . 'meson.build';
    if (!is_file($meson)) {
        return;
    }

    $templateRevision = extract_source_revision_from_ebuild($templateEbuild);
    if ($templateRevision === null) {
        return;
    }

    $oldResult = run_cmd($sourceDir, ['svn', 'cat', '-r', $templateRevision, $meson], false);
    if ($oldResult['exit'] !== 0) {
        warn("Could not read meson.build from template source revision r{$templateRevision}; ABI subslot was not auto-updated.");
        return;
    }

    $current = file_get_contents($meson);
    if ($current === false) {
        return;
    }

    $oldAbi = detect_meson_so_abi_version($oldResult['stdout']);
    $newAbi = detect_meson_so_abi_version($current);
    if ($newAbi === null || $oldAbi === $newAbi) {
        return;
    }

    if ($dryRun) {
        info("[dry-run] meson so_abi_version changed from " . ($oldAbi ?? '<unset>') . " to {$newAbi}; would set SO_ABI_VERSION and SLOT subslot in {$newEbuild}");
        return;
    }

    apply_ebuild_abi_subslot($newEbuild, $newAbi);
    info("Transferred meson so_abi_version ABI change into ebuild subslot: " . ($oldAbi ?? '<unset>') . " -> {$newAbi}");
}

function detect_meson_so_abi_version(string $text): ?string
{
    if (preg_match('/^\s*so_abi_version\s*=\s*[\'"]([^\'"]+)[\'"]/m', $text, $m)) {
        return $m[1];
    }
    return null;
}

function apply_ebuild_abi_subslot(string $ebuild, string $soAbiVersion): void
{
    $text = file_get_contents($ebuild);
    if ($text === false) {
        fail("Could not read ebuild for ABI subslot update: {$ebuild}");
    }

    $replacement = 'SO_ABI_VERSION="' . $soAbiVersion . '"';
    if (preg_match('/^SO_ABI_VERSION=/m', $text)) {
        $text = preg_replace('/^SO_ABI_VERSION=.*$/m', $replacement, $text, 1) ?? $text;
    } elseif (preg_match('/^LICENSE=/m', $text)) {
        $text = preg_replace('/^(LICENSE=.*\R)/m', '$1' . $replacement . "\n", $text, 1) ?? $text;
    } else {
        $text = $replacement . "\n" . $text;
    }

    if (preg_match('/^SLOT=/m', $text)) {
        $text = preg_replace('/^SLOT=.*$/m', 'SLOT="0/${SO_ABI_VERSION}"', $text, 1) ?? $text;
    } else {
        $text = preg_replace('/^(SO_ABI_VERSION=.*\R)/m', '$1SLOT="0/${SO_ABI_VERSION}"' . "\n", $text, 1) ?? $text;
    }

    if (file_put_contents($ebuild, $text) === false) {
        fail("Could not write ABI subslot update to ebuild: {$ebuild}");
    }
}

function extract_source_revision_from_ebuild(string $ebuild): ?string
{
    if (preg_match('/_p(\d+)(?:-r\d+)?\.ebuild$/', basename($ebuild), $m) && $m[1] !== '0') {
        return $m[1];
    }
    return null;
}

function dependency_fingerprint(string $text): string
{
    $selected = [];
    $captureList = false;
    $listDepth = 0;

    foreach (preg_split('/\R/', $text) ?: [] as $rawLine) {
        $line = trim($rawLine);
        $startsDependencyList =
            str_contains($line, '_deps') ||
            str_contains($line, '_pkg_names') ||
            str_contains($line, '_lib_names') ||
            str_contains($line, 'dependencies') ||
            str_contains($line, 'requires') ||
            str_contains($line, 'requires_private');

        if (
            str_contains($line, 'dependency(') ||
            str_contains($line, '.find_library(') ||
            str_contains($line, 'find_library(') ||
            str_contains($line, 'vala_depend') ||
            str_contains($line, 'pkg.generate(') ||
            $startsDependencyList
        ) {
            $selected[] = $line;
        }

        if ($startsDependencyList && str_contains($line, '[')) {
            $captureList = true;
            $listDepth += substr_count($line, '[') - substr_count($line, ']');
            continue;
        }

        if ($captureList) {
            $selected[] = $line;
            $listDepth += substr_count($line, '[') - substr_count($line, ']');
            if ($listDepth <= 0) {
                $captureList = false;
                $listDepth = 0;
            }
        }
    }

    return implode("\n", array_values(array_unique($selected)));
}

function find_latest_ebuild(string $ebuildDir, string $packageName, ?string $maxSourceVersion = null): ?string
{
    $files = glob($ebuildDir . DIRECTORY_SEPARATOR . $packageName . '-*.ebuild') ?: [];
    if ($maxSourceVersion !== null) {
        $files = array_values(array_filter(
            $files,
            static fn (string $file): bool => version_compare(
                ebuild_source_version(ebuild_version_from_filename($file, $packageName)),
                $maxSourceVersion,
                '<='
            )
        ));
    }

    if ($files === []) {
        return null;
    }

    usort($files, static function (string $a, string $b) use ($packageName): int {
        $left = split_ebuild_revision(ebuild_version_from_filename($a, $packageName));
        $right = split_ebuild_revision(ebuild_version_from_filename($b, $packageName));
        $cmp = version_compare($left['version'], $right['version']);
        if ($cmp !== 0) {
            return $cmp;
        }
        $cmp = $left['revision'] <=> $right['revision'];
        return $cmp !== 0 ? $cmp : strcmp(basename($a), basename($b));
    });

    return end($files) ?: null;
}

function ebuild_source_version(string $ebuildVersion): string
{
    $version = split_ebuild_revision($ebuildVersion)['version'];
    return preg_replace('/_p\d+$/', '', $version) ?? $version;
}

function ebuild_version_from_filename(string $path, string $packageName): string
{
    $name = basename($path);
    $prefix = $packageName . '-';
    if (str_starts_with($name, $prefix)) {
        $name = substr($name, strlen($prefix));
    }
    return preg_replace('/\.ebuild$/', '', $name) ?? $name;
}

/**
 * @return array{version:string, revision:int}
 */
function split_ebuild_revision(string $version): array
{
    if (preg_match('/^(.*)-r(\d+)$/', $version, $m)) {
        return ['version' => $m[1], 'revision' => (int)$m[2]];
    }
    return ['version' => $version, 'revision' => 0];
}

function read_last_changed_revision(string $dir): ?string
{
    $result = run_cmd($dir, ['svn', 'info', '--show-item', 'last-changed-revision', $dir], false);
    if ($result['exit'] !== 0) {
        return null;
    }
    $revision = trim($result['stdout']);
    return $revision !== '' ? $revision : null;
}

function read_max_last_changed_revision(string $dir): ?string
{
    $result = run_cmd($dir, ['svn', 'info', '-R', '--show-item', 'last-changed-revision', $dir], false);
    if ($result['exit'] !== 0) {
        return null;
    }

    $max = 0;
    foreach (preg_split('/\R/', trim($result['stdout'])) ?: [] as $line) {
        if (preg_match('/^\s*(\d+)\b/', $line, $m)) {
            $max = max($max, (int)$m[1]);
        }
    }

    return $max > 0 ? (string)$max : null;
}

function ensure_svn_working_copy(string $dir, string $label): void
{
    $result = run_cmd($dir, ['svn', 'info', $dir], false);
    if ($result['exit'] !== 0) {
        fail("{$label} path is not an SVN working copy: {$dir}\n" . combined_output($result));
    }
}

function update_ebuild_working_copy(string $ebuildDir, bool $dryRun): void
{
    if ($dryRun) {
        show_cmd($ebuildDir, ['svn', 'update', $ebuildDir]);
    } else {
        info("Updating ebuild working copy: {$ebuildDir}");
        $result = run_cmd($ebuildDir, ['svn', 'update', $ebuildDir], true);
        if ($result['exit'] !== 0) {
            fail("Could not update ebuild working copy:\n" . combined_output($result));
        }
    }
}

function ensure_manifest_is_versioned(string $ebuildDir, string $manifest, bool $dryRun): void
{
    if (!$dryRun) {
        if (!is_file($manifest)) {
            fail("Manifest was not generated: {$manifest}");
        }

        $result = run_cmd($ebuildDir, ['svn', 'info', $manifest], false);
        if ($result['exit'] !== 0) {
            run_or_show($ebuildDir, ['svn', 'add', $manifest], false);
        }
    }
}

function canonical_dir(string $path): string
{
    $expanded = expand_home($path);
    if (!is_dir($expanded)) {
        fail("Directory not found: {$expanded}");
    }
    $real = realpath($expanded);
    if ($real === false) {
        fail("Could not resolve directory: {$expanded}");
    }
    return $real;
}

function canonical_file(string $path): string
{
    $expanded = expand_home($path);
    if (!is_file($expanded)) {
        fail("File not found: {$expanded}");
    }
    $real = realpath($expanded);
    if ($real === false) {
        fail("Could not resolve file: {$expanded}");
    }
    return $real;
}

function expand_home(string $path): string
{
    if ($path === '~') {
        return getenv('HOME') ?: $path;
    }
    if (str_starts_with($path, '~/')) {
        return (getenv('HOME') ?: '') . substr($path, 1);
    }
    return $path;
}

function guess_ebuild_dir(string $sourceDir): string
{
    return find_gentoo_overlay_root($sourceDir) ?? '';
}

function find_gentoo_overlay_root(string $sourceDir): ?string
{
    $current = $sourceDir;
    while (true) {
        $candidate = $current . DIRECTORY_SEPARATOR . 'gentoo-repository-petersweeze';
        if (is_dir($candidate)) {
            return canonical_dir($candidate);
        }

        $parent = dirname($current);
        if ($parent === $current) {
            break;
        }
        $current = $parent;
    }

    $svnRoot = read_svn_working_copy_root($sourceDir);
    if ($svnRoot !== null) {
        foreach ([$svnRoot, dirname($svnRoot)] as $base) {
            $candidate = $base . DIRECTORY_SEPARATOR . 'gentoo-repository-petersweeze';
            if (is_dir($candidate)) {
                return canonical_dir($candidate);
            }
        }
    }

    $fallback = '/home/peters/SVN/gentoo-repository-petersweeze';
    return is_dir($fallback) ? canonical_dir($fallback) : null;
}

function read_svn_working_copy_root(string $dir): ?string
{
    $result = run_cmd($dir, ['svn', 'info', '--show-item', 'wc-root', $dir], false);
    if ($result['exit'] !== 0) {
        return null;
    }

    $root = trim($result['stdout']);
    return $root !== '' ? $root : null;
}

/**
 * @return array<string, string>
 */
function load_mapping(): array
{
    $file = mapping_file();
    if (!is_file($file)) {
        return [];
    }

    $json = file_get_contents($file);
    if ($json === false || trim($json) === '') {
        return [];
    }

    $decoded = json_decode($json, true);
    return is_array($decoded) ? array_filter($decoded, 'is_string') : [];
}

/**
 * @param array<string, string> $mapping
 */
function save_mapping(array $mapping): void
{
    $file = mapping_file();
    $dir = dirname($file);
    if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
        fail("Could not create config directory: {$dir}");
    }

    $json = json_encode($mapping, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    if ($json === false || file_put_contents($file, $json . "\n") === false) {
        fail("Could not write mapping file: {$file}");
    }
}

function mapping_file(): string
{
    $configHome = getenv('XDG_CONFIG_HOME') ?: ((getenv('HOME') ?: '.') . '/.config');
    return $configHome . '/' . APP_NAME . '/mappings.json';
}

function require_program(string $program): void
{
    $result = run_cmd(getcwd() ?: '.', ['sh', '-c', 'command -v ' . escapeshellarg($program)], false);
    if ($result['exit'] !== 0 || trim($result['stdout']) === '') {
        fail("Required program not found in PATH: {$program}");
    }
}

function select_ebuild_dir_with_zenity(string $sourceDir): string
{
    $overlayRoot = guess_ebuild_dir($sourceDir);
    if ($overlayRoot === '') {
        return canonical_dir(select_directory_with_zenity("Ebuild package directory for {$sourceDir}", ''));
    }

    $categories = glob($overlayRoot . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR) ?: [];
    sort($categories, SORT_NATURAL | SORT_FLAG_CASE);

    if ($categories === []) {
        return canonical_dir(select_directory_with_zenity("Ebuild package directory for {$sourceDir}", $overlayRoot));
    }

    $categoryCmd = zenity_cmd([
        '--list',
        '--title', "Ebuild category for {$sourceDir}",
        '--text', 'Select the ebuild category.',
        '--width=700',
        '--height=700',
        '--column', 'Category',
        '--column', 'Path',
        '--print-column=2',
    ]);

    foreach ($categories as $categoryDir) {
        $categoryCmd[] = basename($categoryDir);
        $categoryCmd[] = $categoryDir;
    }

    $categoryResult = run_cmd(getcwd() ?: '.', $categoryCmd, false);
    if ($categoryResult['exit'] !== 0) {
        fail("No ebuild category selected.\n" . combined_output($categoryResult));
    }

    $categoryDir = canonical_dir(trim($categoryResult['stdout']));
    $dirs = glob($categoryDir . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR) ?: [];
    sort($dirs, SORT_NATURAL | SORT_FLAG_CASE);

    if ($dirs === []) {
        return canonical_dir(select_directory_with_zenity("Ebuild package directory for {$sourceDir}", $categoryDir));
    }

    $packageCmd = zenity_cmd([
        '--list',
        '--title', "Ebuild package directory for {$sourceDir}",
        '--text', 'Select the ebuild package directory.',
        '--width=850',
        '--height=700',
        '--column', 'Package',
        '--column', 'Path',
        '--print-column=2',
    ]);

    foreach ($dirs as $dir) {
        $packageCmd[] = basename($dir);
        $packageCmd[] = $dir;
    }

    $result = run_cmd(getcwd() ?: '.', $packageCmd, false);
    if ($result['exit'] !== 0) {
        fail("No ebuild directory selected.\n" . combined_output($result));
    }

    $value = trim($result['stdout']);
    if ($value === '') {
        fail('No ebuild directory selected.');
    }
    return canonical_dir($value);
}

function select_directory_with_zenity(string $title, string $default = ''): string
{
    $cmd = zenity_cmd(['--file-selection', '--directory', '--title', $title]);
    if ($default !== '') {
        $cmd[] = '--filename=' . rtrim($default, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
    }

    $result = run_cmd(getcwd() ?: '.', $cmd, false);
    if ($result['exit'] !== 0) {
        fail("No ebuild directory selected.\n" . combined_output($result));
    }

    $value = trim($result['stdout']);
    if ($value === '') {
        fail('No ebuild directory selected.');
    }
    return $value;
}

function input_with_zenity(string $title, string $default = ''): string
{
    $cmd = zenity_cmd(['--entry', '--title', $title, '--text', $title]);
    if ($default !== '') {
        $cmd[] = '--entry-text=' . $default;
    }

    $result = run_cmd(getcwd() ?: '.', $cmd, false);
    if ($result['exit'] !== 0) {
        fail("No input for: {$title}\n" . combined_output($result));
    }

    $value = trim($result['stdout']);
    if ($value === '') {
        fail("Empty value for: {$title}");
    }
    return $value;
}

function confirm_with_zenity(string $title, string $message): bool
{
    $result = run_cmd(
        getcwd() ?: '.',
        zenity_cmd(['--question', '--title', $title, '--width=520', '--text', $message, '--ok-label=Continue', '--cancel-label=Stop']),
        false
    );
    return $result['exit'] === 0;
}

function fail_with_zenity(string $title, string $message): never
{
    run_cmd(
        getcwd() ?: '.',
        zenity_cmd(['--error', '--title', $title, '--width=700', '--text', $message]),
        false
    );
    fail($message);
}

/**
 * @param list<string> $args
 * @return list<string>
 */
function zenity_cmd(array $args): array
{
    return array_merge(['env', 'MESA_LOG_LEVEL=error', 'zenity'], $args);
}

function prompt(string $label, string $default = ''): string
{
    $suffix = $default !== '' ? " [{$default}]" : '';
    write_stderr("{$label}{$suffix}: ");
    $line = read_stdin();
    if ($line === false) {
        fail("No input for: {$label}");
    }

    $value = trim($line);
    if ($value === '' && $default !== '') {
        return $default;
    }
    if ($value === '') {
        fail("Empty value for: {$label}");
    }
    return $value;
}

function confirm(string $label, bool $default): bool
{
    $suffix = $default ? ' [Y/n]' : ' [y/N]';
    write_stderr("{$label}{$suffix}: ");
    $line = read_stdin();
    if ($line === false) {
        return $default;
    }
    $value = strtolower(trim($line));
    if ($value === '') {
        return $default;
    }
    return in_array($value, ['y', 'yes', 'j', 'ja'], true);
}

/**
 * @param list<string> $cmd
 */
function run_or_show(string $cwd, array $cmd, bool $dryRun): void
{
    if ($dryRun) {
        show_cmd($cwd, $cmd);
    } else {
        $result = run_cmd($cwd, $cmd, true);
        if ($result['exit'] !== 0) {
            fail("Command failed:\n" . combined_output($result));
        }
    }
}

function run_manifest_or_show(string $ebuildDir, string $newEbuild, bool $dryRun): void
{
    $distdir = writable_distdir();
    $cmd = ['env', 'DISTDIR=' . $distdir];
    $readOnlyDistdir = read_only_distdir();
    if ($readOnlyDistdir !== null) {
        $cmd[] = 'PORTAGE_RO_DISTDIRS=' . $readOnlyDistdir;
    }
    $cmd[] = 'ebuild';
    $cmd[] = $newEbuild;
    $cmd[] = 'manifest';

    if ($dryRun) {
        show_cmd($ebuildDir, $cmd);
        cleanup_distdir($distdir);
    } else {
        try {
            run_or_show($ebuildDir, $cmd, false);
        } finally {
            cleanup_distdir($distdir);
        }
    }
}

function writable_distdir(): string
{
    $cacheHome = getenv('XDG_CACHE_HOME') ?: ((getenv('HOME') ?: sys_get_temp_dir()) . '/.cache');
    $baseDir = rtrim($cacheHome, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . APP_NAME;
    $distdir = $baseDir . DIRECTORY_SEPARATOR . 'distfiles-' . getmypid();

    if (!is_dir($distdir) && !mkdir($distdir, 0700, true) && !is_dir($distdir)) {
        fail("Could not create writable distfiles directory: {$distdir}");
    }
    if (!is_writable($distdir)) {
        fail("Distfiles directory is not writable by the current user: {$distdir}");
    }

    return $distdir;
}

function read_only_distdir(): ?string
{
    $distdir = '/var/cache/distfiles';
    return is_dir($distdir) && is_readable($distdir) ? $distdir : null;
}

function cleanup_distdir(string $distdir): void
{
    remove_tree($distdir);
    @rmdir(dirname($distdir));
}

function remove_tree(string $dir): void
{
    if (!is_dir($dir)) {
        return;
    }

    $items = scandir($dir);
    if ($items === false) {
        return;
    }

    foreach ($items as $item) {
        if ($item === '.' || $item === '..') {
            continue;
        }
        $path = $dir . DIRECTORY_SEPARATOR . $item;
        if (is_dir($path) && !is_link($path)) {
            remove_tree($path);
        } else {
            @unlink($path);
        }
    }

    @rmdir($dir);
}

/**
 * @param list<string> $cmd
 * @return array{exit:int, stdout:string, stderr:string}
 */
function run_cmd(string $cwd, array $cmd, bool $passthru): array
{
    $command = implode(' ', array_map('escapeshellarg', $cmd));

    if ($passthru) {
        write_stderr('$ ' . $command . "\n");
    }

    $descriptorSpec = [
        0 => ['file', '/dev/null', 'r'],
        1 => ['pipe', 'w'],
        2 => ['pipe', 'w'],
    ];

    $process = proc_open($command, $descriptorSpec, $pipes, $cwd);
    if (!is_resource($process)) {
        fail("Could not start command: {$command}");
    }

    $stdout = stream_get_contents($pipes[1]);
    $stderr = stream_get_contents($pipes[2]);

    fclose($pipes[1]);
    fclose($pipes[2]);

    $exit = proc_close($process);

    if ($passthru) {
        if ($stdout !== false && $stdout !== '') {
            write_stdout($stdout);
        }
        if ($stderr !== false && $stderr !== '') {
            write_stderr($stderr);
        }
    }

    return [
        'exit' => $exit,
        'stdout' => $stdout !== false ? $stdout : '',
        'stderr' => $stderr !== false ? $stderr : '',
    ];
}

/**
 * @param list<string> $cmd
 */
function show_cmd(string $cwd, array $cmd): void
{
    info('[dry-run] cd ' . $cwd . ' && ' . implode(' ', array_map('escapeshellarg', $cmd)));
}

/**
 * @param array{exit:int, stdout:string, stderr:string} $result
 */
function combined_output(array $result): string
{
    return trim($result['stdout'] . "\n" . $result['stderr']);
}

function info(string $message): void
{
    write_stderr("[INFO] {$message}\n");
}

function warn(string $message): void
{
    write_stderr("[WARN] {$message}\n");
}

function fail(string $message): never
{
    write_stderr("[ERROR] {$message}\n");
    exit(1);
}

function write_stdout(string $message): void
{
    static $stream = null;
    $stream ??= fopen('php://stdout', 'w');
    if ($stream !== false) {
        fwrite($stream, $message);
    }
}

function write_stderr(string $message): void
{
    static $stream = null;
    $stream ??= fopen('php://stderr', 'w');
    if ($stream !== false) {
        fwrite($stream, $message);
    }
}

function read_stdin(): string|false
{
    static $stream = null;
    $stream ??= fopen('php://stdin', 'r');
    if ($stream === false) {
        return false;
    }

    return fgets($stream);
}
