#!/usr/bin/env php
<?php
/**
 ***********************************************************************************************
 * Native Admidio command-line administration utility.
 *
 * @copyright The Admidio Team
 * @see https://www.admidio.org/
 * @license https://www.gnu.org/licenses/gpl-2.0.html GNU General Public License v2.0 only
 ***********************************************************************************************
 */

if (PHP_SAPI !== 'cli') {
    fwrite(STDERR, "The Admidio CLI may only be executed from the command line.\n");
    exit(1);
}

/**
 * Read one option needed before the Admidio autoloader/bootstrap is available.
 *
 * --config, --host and --organization have to be known before the configuration file is loaded,
 * which is long before the class autoloader exists. This small parser therefore cannot live in
 * CliApplication; the duplication with its option handling is intentional.
 *
 * @param array<int,string> $arguments
 */
function admCliBootstrapOption(array $arguments, string $name): string
{
    $prefix = '--' . $name . '=';
    $value = '';

    /*
     * CliApplication keeps the last value of a repeated scalar option until validation rejects the
     * duplicate. Do the same here so the bootstrap can never use a different host/organization
     * than the command parser, even for input that is rejected later.
     */
    for ($index = 1, $count = count($arguments); $index < $count; ++$index) {
        if (str_starts_with($arguments[$index], $prefix)) {
            $value = substr($arguments[$index], strlen($prefix));
            continue;
        }

        if ($arguments[$index] === '--' . $name && isset($arguments[$index + 1])) {
            $value = $arguments[$index + 1];
            ++$index;
        }
    }

    return $value;
}

/**
 * Read the name of the command out of the arguments.
 *
 * The command decides which bootstrap can be used, so it has to be known before the registry
 * exists. Only the first token that is neither an option nor the value of an option is taken; a
 * misspelled command is reported later by CliApplication, which knows every command. The tokens
 * are read exactly like CliApplication::findCommand() reads them, short flags included, so both
 * arrive at the same command.
 *
 * @param array<int,string> $arguments
 */
function admCliBootstrapCommand(array $arguments): string
{
    $globalFlags = array('quiet', 'no-interaction', 'yes', 'help', 'overwrite', 'no-truncate', 'dry-run');
    $globalShortFlags = array('h', 'q', 'y');
    $globalValueOptions = array('config', 'host', 'organization', 'as', 'format', 'output', 'width');

    for ($index = 1, $count = count($arguments); $index < $count; ++$index) {
        $token = $arguments[$index];

        if ($token === '--') {
            return $arguments[$index + 1] ?? '';
        }

        // Short flags may precede the command just like their long forms.
        if (!str_starts_with($token, '--') && str_starts_with($token, '-') && strlen($token) > 1) {
            if (in_array(substr($token, 1), $globalShortFlags, true)) {
                continue;
            }

            return '';
        }

        if (!str_starts_with($token, '--')) {
            return $token;
        }

        $optionName = substr($token, 2);
        if (str_contains($optionName, '=')) {
            continue;
        }

        if (in_array($optionName, $globalFlags, true)) {
            continue;
        }

        if (in_array($optionName, $globalValueOptions, true)) {
            ++$index;
            continue;
        }

        return '';
    }

    return '';
}

foreach (array('host', 'organization') as $bootstrapOption) {
    $matches = 0;
    $prefix = '--' . $bootstrapOption . '=';

    for ($index = 1, $count = count($argv); $index < $count; ++$index) {
        if (str_starts_with($argv[$index], $prefix) || $argv[$index] === '--' . $bootstrapOption) {
            ++$matches;
            if ($argv[$index] === '--' . $bootstrapOption) {
                ++$index;
            }
        }
    }

    if ($matches > 1) {
        fwrite(STDERR, 'Error: Option --' . $bootstrapOption . ' may only be specified once.' . PHP_EOL);
        exit(2);
    }
}

try {
    $cliHost = admCliBootstrapOption($argv, 'host');
    $cliOrganization = admCliBootstrapOption($argv, 'organization');
    $cliCommand = admCliBootstrapCommand($argv);

    /*
     * A second installation on the same machine, a test database or a restored backup can be
     * addressed with its own configuration file. The path is resolved here so that the bootstrap,
     * the check below and the install commands all mean the same file.
     */
    $cliConfigFile = admCliBootstrapOption($argv, 'config');

    /*
     * Who may use the command line at all, and the defaults of the options that are needed before
     * config.php can be read, are configured in adm_my_files/cli-config.php. That file describes
     * the installation whose code is executed, so it is looked up beside this script and can be
     * selected neither with an option nor through the environment.
     */
    require_once __DIR__ . '/vendor/autoload.php';
    $cliPolicy = Admidio\Infrastructure\Cli\CliPolicy::load(__DIR__);

    if ($cliConfigFile === '') {
        $cliConfigFile = (string)$cliPolicy->optionDefault($cliCommand, 'config');
    }
    if ($cliConfigFile === '') {
        $cliConfigFile = __DIR__ . '/adm_my_files/config.php';
    }
    if ($cliHost === '') {
        $cliHost = (string)$cliPolicy->optionDefault($cliCommand, 'host');
    }
    if ($cliOrganization === '') {
        $cliOrganization = (string)$cliPolicy->optionDefault($cliCommand, 'organization');
    }

    $cliDenial = $cliPolicy->denialReason($cliCommand, $cliConfigFile);
    if ($cliDenial !== null) {
        throw new Admidio\Infrastructure\Cli\CliAccessDeniedException($cliDenial);
    }

    foreach ($cliPolicy->warnings() as $cliWarning) {
        fwrite(STDERR, 'Warning: ' . $cliWarning . PHP_EOL);
    }

    /*
     * Maintenance mode has to be manageable while the database is unavailable, so its command is
     * bootstrapped without a database connection (see system/bootstrap/cli.php).
     */
    $cliSkipDatabase = $cliCommand === 'maintenance:mode';

    /*
     * An installation has no configuration file and no database yet, so its commands need a
     * bootstrap that creates neither. Everything the constants are derived from is read from the
     * command line there, and the commands check that their options match what was bootstrapped.
     */
    $installationCommands = array('install:check', 'install:run');

    /*
     * The commands that only describe the command line have to work on an Admidio that is not
     * installed yet, otherwise install:run could not even be looked up before it is used.
     */
    $documentationCommands = array(
        '', 'help', 'list', 'version', 'completion', 'cli:defaultconfig', 'cli:selfcheck', 'status'
    );

    /*
     * cli:defaultconfig explains how the command line is configured, which is what an
     * administrator reads when something is wrong. It must therefore not depend on a database
     * connection either, and it needs nothing from one.
     */
    $installationBootstrap = in_array($cliCommand, $installationCommands, true)
        || $cliCommand === 'cli:defaultconfig'
        || (in_array($cliCommand, $documentationCommands, true) && !is_file($cliConfigFile));

    if (in_array($cliCommand, $installationCommands, true)) {
        /*
         * Ask for the values that the command line does not contain. This happens here and not in
         * the command, because the table prefix and the time zone are needed to build the table
         * names and the timestamps of the installation, which Admidio does while it is bootstrapping.
         */
        if (!in_array('--help', $argv, true) && !in_array('-h', $argv, true)) {
            require __DIR__ . '/vendor/autoload.php';
            $argv = Admidio\Infrastructure\Cli\CliInstallationInput::complete($argv, __DIR__);
        }

        $cliRootUrl = admCliBootstrapOption($argv, 'root-url');
        $cliLanguage = admCliBootstrapOption($argv, 'language');
        $cliTablePrefix = admCliBootstrapOption($argv, 'table-prefix');
        $cliDbType = admCliBootstrapOption($argv, 'db-type');
        $cliTimezone = admCliBootstrapOption($argv, 'timezone');
    }

    if ($installationBootstrap) {
        require __DIR__ . '/system/bootstrap/cli-installation.php';
    } else {
        require __DIR__ . '/system/bootstrap/cli.php';
    }

    $application = new Admidio\Infrastructure\Cli\CliApplication();
    $cliExitCode = $application->run($argv);

    $cliPolicy->logInvocation($argv, $cliExitCode, Admidio\Infrastructure\Cli\CliApplication::actorDescription());
    exit($cliExitCode);
} catch (Throwable $exception) {
    /*
     * The bootstrap itself can fail, for example when config.php is missing, and then the class is
     * not available to classify the failure. Fall back to the plain message in that case. A refused
     * command is the exception: it is rejected before anything of the application was needed, and
     * it still has to be reported as a rejection and in the requested output format.
     */
    if (class_exists(Admidio\Infrastructure\Cli\CliApplication::class, false)
        || $exception instanceof Admidio\Infrastructure\Cli\CliAccessDeniedException) {
        $cliExitCode = Admidio\Infrastructure\Cli\CliApplication::handleThrowable($exception, $argv);
    } else {
        fwrite(STDERR, 'Error: ' . $exception->getMessage() . PHP_EOL);
        $cliExitCode = 1;
    }

    /*
     * A refused or failed invocation is exactly what the log of the command line is read for, so it
     * is written here as well and not only on the successful path.
     */
    if (isset($cliPolicy)) {
        $cliPolicy->logInvocation($argv, $cliExitCode, Admidio\Infrastructure\Cli\CliApplication::actorDescription());
    }

    exit($cliExitCode);
}
