<?php

namespace WP_CLI_Login;

use WP_CLI;
use WP_CLI\Process;
use WP_User;

/**
 * Manage magic passwordless log-in.
 */
class LoginCommand
{
    use Randomness;

    /**
     * Option key for the persisted-data.
     */
    const OPTION = 'wp_cli_login';

    /**
     * Required version constraint of the wp-cli-login-server companion plugin.
     */
    const REQUIRED_PLUGIN_VERSION = '^1.5';

    /**
     * Package instance
     * @var Package
     */
    private static $package;

    /**
     * Create a magic log-in link for the given user.
     *
     * ## OPTIONS
     *
     * <user-locator>
     * : A string which identifies the user to be logged in as.
     * Possible values are: User ID, User Login, or User Email.
     *
     * [--expires=<seconds-from-now>]
     * : The number of seconds from now until the magic link expires. Default: 15 minutes
     * ---
     * default: 900
     * ---
     *
     * [--redirect-url=<url>]
     * : The URL to redirect to upon successfully logging in.
     * ---
     * default:
     * ---
     *
     * [--url-only]
     * : Output the magic link URL only.
     *
     * [--launch]
     * : Launch the magic URL immediately in your web browser.
     *
     * @param array $_
     * @param array $assoc
     *
     * @alias as
     */
    public function create($_, $assoc)
    {
        $this->ensurePluginRequirementsMet();

        list($user_locator) = $_;

        $user      = $this->lookupUser($user_locator);
        $expires   = human_time_diff(time(), time() + absint($assoc['expires']));
        $magic_url = $this->makeMagicUrl($user, $assoc['expires'], $assoc['redirect-url']);

        if (WP_CLI\Utils\get_flag_value($assoc, 'url-only')) {
            WP_CLI::line($magic_url);
            exit;
        }

        WP_CLI::success('Magic login link created!');
        WP_CLI::line(str_repeat('-', strlen($magic_url)));
        WP_CLI::line($magic_url);
        WP_CLI::line(str_repeat('-', strlen($magic_url)));
        WP_CLI::line("This link will self-destruct in $expires, or as soon as it is used; whichever comes first.");

        if (WP_CLI\Utils\get_flag_value($assoc, 'launch')) {
            $this->launch($magic_url);
        }
    }

    /**
     * Email a magic log-in link to the given user.
     *
     * ## OPTIONS
     *
     * <user-locator>
     * : A string which identifies the user to be logged in as.
     * Possible values are: User ID, User Login, or User Email.
     *
     * [--expires=<seconds-from-now>]
     * : The number of seconds from now until the magic link expires. Default: 15 minutes
     * ---
     * default: 900
     * ---
     *
     * [--redirect-url=<url>]
     * : The URL to redirect to upon successfully logging in.
     * ---
     * default:
     * ---
     *
     * [--subject=<email-subject>]
     * : The email subject field.
     * ---
     * default: Magic log-in link for {{domain}}
     * ---
     *
     * [--template=<path-to-template-file>]
     * : The path to a file to use for a custom email template.
     * Uses Mustache templating for dynamic html.
     * ---
     * default:
     * ---
     *
     * @param array $_
     * @param array $assoc
     */
    public function email($_, $assoc)
    {
        $this->ensurePluginRequirementsMet();

        list($user_locator) = $_;

        $user          = $this->lookupUser($user_locator);
        $expires       = human_time_diff(time(), time() + absint($assoc['expires']));
        $magic_url     = $this->makeMagicUrl($user, $assoc['expires'], $assoc['redirect-url']);
        $domain        = $this->domain();
        $subject       = $this->mustacheRender(
            $assoc['subject'],
            compact('domain')
        );
        $html_rendered = $this->renderEmailTemplate(
            compact('magic_url','domain','expires'),
            $assoc['template']
        );

        if (! $this->sendEmail($user, $domain, $subject, $html_rendered)) {
            WP_CLI::error('Email failed to send.');
        }

        WP_CLI::success('Email sent.');
    }

    /**
     * Render the given mustache template string.
     *
     * @param $template
     * @param $data
     *
     * @return string
     */
    private function mustacheRender($template, $data = [])
    {
        $m = new \Mustache_Engine(
            [
                'escape' => function ($val) {
                    return $val;
                },
            ]
        );
        return $m->render($template, $data);
    }

    /**
     * Render the given email template, for the given user.
     *
     * @param      $template_data
     * @param null $template_file
     *
     * @return string
     */
    private function renderEmailTemplate($template_data, $template_file = null)
    {
        return \WP_CLI\Utils\mustache_render(
            $template_file ?: $this->packagePath('template/email-default.mustache'),
            $template_data
        );
    }

    /**
     * Invalidate any existing magic links.
     */
    public function invalidate()
    {
        $this->resetOption();

        WP_CLI::success('Magic links invalidated.');
    }

    /**
     * Launch the magic link URL in the default browser.
     *
     * @param $url
     */
    private function launch($url)
    {
        static::debug('Attempting to launch magic login with system browser...');

        if ($cmd = getenv('WP_CLI_LOGIN_LAUNCH_WITH')) {
            // system/environment override
        }
        elseif (preg_match('/^darwin/i', PHP_OS)) {
            $cmd = 'open';
        } elseif (preg_match('/^win/i', PHP_OS)) {
            $cmd = 'start';
        } elseif (preg_match('/^linux/i', PHP_OS)) {
            $cmd = 'xdg-open';
        } else {
            WP_CLI::error('Your operating system does not seem to support launching from the command line.  Please open an issue (https://github.com/aaemnnosttv/wp-cli-login-command/issues) and be sure to include the output from this command: `php -r \'echo PHP_OS;\'`');
            exit; // make IDE happy.
        }

        static::debug("Launching browser with: $cmd");

        $process = Process::create("$cmd '$url'");
        $result  = $process->run();

        if ($result->return_code > 0) {
            WP_CLI::error($result->stderr);
        }

        self::debug($result->stdout);

        WP_CLI::success("Magic link launched!");
    }

    /**
     * Create the endpoint if it does not exist, and return the current value.
     *
     * @return string
     */
    private function endpoint()
    {
        $saved   = json_decode(get_option(static::OPTION));
        $version = isset($saved->version) ? $saved->version : false;

        if (! $saved) {
            static::debug('Creating endpoint');
            $saved = $this->resetOption();
        } elseif (! $this->installedPlugin()->versionSatisfies($version) && $this->promptForReset($version)) {
            static::debug("Updating endpoint for version $version");
            $saved = $this->resetOption();
        }

        return $saved->endpoint;
    }

    /**
     * Prompt the user about resetting log-ins.
     *
     * @param null $version
     *
     * @return string
     */
    private function promptForReset($version = null)
    {
        if ($version) {
            WP_CLI::line("Version $version requires an update for compatibility with the current version of the login command.");
            WP_CLI::line('Your site will not be able to respond to newly created magic log-in links until updating.');
        }
        WP_CLI::warning('This will invalidate any existing magic links.');

        return $this->confirm('Are you sure?');
    }

    /**
     * Prompt the user for a yes/no answer.
     *
     * @param $question
     *
     * @return bool
     */
    private function confirm($question)
    {
        fwrite(STDOUT, $question . ' [y/N] ');
        $response = trim(fgets(STDIN));

        return ('y' == strtolower($response));
    }

    /**
     * Reset the saved option with fresh data.
     *
     * @return \stdClass
     */
    private function resetOption()
    {
        static::debug('Resetting option...');

        $option = [
            'endpoint' => $this->randomness(4),
            'version'  => static::REQUIRED_PLUGIN_VERSION,
        ];

        update_option(static::OPTION, json_encode($option));

        return (object) $option;
    }

    /**
     * Install/update the companion server plugin.
     *
     * ## OPTIONS
     *
     * [--activate]
     * : Activate the plugin after installing.
     *
     * [--mu]
     * : Install as a Must Use plugin.
     *
     * [--yes]
     * : Suppress confirmation to overwrite the installed plugin if it exists.
     *
     * @param array $_
     * @param array $assoc
     */
    public function install($_, $assoc)
    {
        static::debug('Installing plugin.');

        if (\WP_CLI\Utils\get_flag_value($assoc, 'mu')) {
            $installed = ServerPlugin::mustUse();
        } else {
            $installed = $this->installedPlugin();
        }

        $suppress_prompt = \WP_CLI\Utils\get_flag_value($assoc, 'yes');

        if ($installed->exists() && ! $suppress_prompt && ! $this->confirmOverwrite($installed)) {
            WP_CLI::line('Update aborted by user.');
            exit;
        }

        wp_mkdir_p(dirname($installed->fullPath()));

        // update / overwrite / refresh installed plugin file
        copy(
            $this->bundledPlugin()->fullPath(),
            $installed->fullPath()
        );

        if (! $installed->exists()) {
            WP_CLI::error('Plugin install failed.');
        }

        WP_CLI::success('Companion plugin installed.');

        if (WP_CLI\Utils\get_flag_value($assoc, 'activate')) {
            $this->toggle(['on']);
        }
    }

    /**
     * Confirm the overwrite of the given server plugin with the user.
     *
     * @param ServerPlugin $plugin
     *
     * @return bool
     */
    private function confirmOverwrite(ServerPlugin $plugin)
    {
        return $plugin->isComposerInstalled()
            ? $this->confirm('This plugin appears to be installed by Composer. Overwrite anyway?')
            : $this->confirm('Overwrite existing plugin?');
    }

    /**
     * Toggle the active state of the companion server plugin.
     *
     * [<on|off>]
     * : Toggle the companion plugin on or off.
     * Default: toggles active status.
     * ---
     * options:
     *   - on
     *   - off
     * ---
     *
     * @param array $_
     */
    public function toggle($_)
    {
        if (($setState = reset($_)) && ! in_array($setState, ['on','off'])) {
            WP_CLI::error('Invalid toggle value. Possible options are "on" and "off".');
        }

        if (! $setState) {
            $setState = ServerPlugin::isActive() ? 'off' : 'on';
        }

        self::debug("Toggling companion plugin: $setState");

        $command = $setState == 'on' ? 'activate' : 'deactivate';

        WP_CLI::runcommand("plugin $command wp-cli-login-server");
    }

    /**
     * Create a magic login URL
     *
     * @param WP_User  $user User to create login URL for.
     * @param int      $expires Number of seconds from now until the magic link expires.
     * @param string   $redirect_url URL to redirect to upon successfully logging in.
     *
     * @return string URL
     */
    private function makeMagicUrl(WP_User $user, $expires, $redirect_url)
    {
        static::debug("Generating a new magic login for User $user->ID expiring in {$expires} seconds.");

        $endpoint = $this->endpoint();
        $magic    = new MagicUrl($user, $this->domain(), time() + $expires, $redirect_url);

        $this->persistMagicUrl($magic, $endpoint, $expires);

        return $this->homeUrl($endpoint . '/' . $magic->getKey());
    }

    /**
     * Store the Magic Url to be used later.
     *
     * @param MagicUrl $magic
     * @param string $endpoint
     * @param int $expires
     */
    private function persistMagicUrl(MagicUrl $magic, $endpoint, $expires)
    {
        // We need to hash the salt to produce a key that won't exceed the maximum of 64 bytes.
        $key = sodium_crypto_generichash(wp_salt('auth'));
        $bin_hash = sodium_crypto_generichash($magic->getKey(), $key);

        if (! set_transient(
            self::OPTION . '/' . sodium_bin2base64($bin_hash, SODIUM_BASE64_VARIANT_URLSAFE),
            json_encode($magic->generate($endpoint)),
            ceil($expires)
        )) {
            WP_CLI::error('Failed to persist magic login.');
        }
    }

    /**
     * Get the target user by the given locator.
     *
     * @param  mixed $locator User login, ID, or email address
     *
     * @return WP_User
     */
    private function lookupUser($locator)
    {
        static::debug("Looking up user by '$locator'");

        $fetcher = new WP_CLI\Fetchers\User();
        $user    = $fetcher->get($locator);

        if (! $user instanceof WP_User) {
            WP_CLI::error("No user found by: $locator");
        }

        return $user;
    }

    /**
     * Check that the login server plugin meets all necessary requirements.
     * If any criteria is not met, abort with instructions as to how to proceed.
     */
    private function ensurePluginRequirementsMet()
    {
        $plugin = $this->installedPlugin();

        if (! ServerPlugin::isActive()) {
            WP_CLI::error('This command requires the companion plugin to be installed and active. Run `wp login install --activate` and try again.');
        }

        if (! $plugin->versionSatisfies(self::REQUIRED_PLUGIN_VERSION)) {
            WP_CLI::error(
                sprintf('The current version of the login command requires version %s of %s, but version %s is installed. Run `wp login install` to install it.',
                    static::REQUIRED_PLUGIN_VERSION,
                    $plugin->name(),
                    $plugin->version()
                )
            );
        }
    }

    /**
     * Get a ServerPlugin instance for the installed plugin.
     *
     * @return ServerPlugin
     */
    private function installedPlugin()
    {
        return ServerPlugin::installed();
    }

    /**
     * Get a ServerPlugin instance for the bundled plugin.
     *
     * @return ServerPlugin
     */
    private function bundledPlugin()
    {
        return new ServerPlugin($this->packagePath('plugin/wp-cli-login-server.php'));
    }

    /**
     * Get the domain of the current site.
     *
     * @return mixed
     */
    private function domain()
    {
        return parse_url(home_url(), PHP_URL_HOST);
    }

    /**
     * Get the home URL.
     *
     * @param string $path
     *
     * @return string
     */
    private function homeUrl($path = '')
    {
        $url = home_url($path);

        /**
         * If the global --url is passed it will set the HTTP_HOST.
         * Here we replace the hostname in the default home URL with the one set by the command.
         * This preserves compatibility with sites installed as a subdirectory.
         */
        if (! empty($_SERVER['HTTP_HOST'])) {
            return preg_replace('#//[^/]+#', '//'. $_SERVER['HTTP_HOST'], $url, 1);
        }

        return $url;
    }

    /**
     * Get an absolute file path, from the given path relative to the package root.
     *
     * @param $relative
     *
     * @return string
     */
    private function packagePath($relative)
    {
        return self::$package->fullPath($relative);
    }

    /**
     * Set the package instance.
     *
     * @param Package $package
     */
    public static function setPackage(Package $package)
    {
        self::$package = $package;
    }

    /**
     * Log to debug.
     *
     * @param $message
     */
    private static function debug($message)
    {
        WP_CLI::debug($message, 'aaemnnosttv/wp-cli-login-command');
    }

    /**
     * Send an email to the given user.
     *
     * @param $user
     * @param $domain
     * @param $subject
     * @param $html_rendered
     *
     * @return bool|void
     */
    private function sendEmail($user, $domain, $subject, $html_rendered)
    {
        static::debug("Sending email to $user->user_email");

        return wp_mail($user->user_email,
            $subject,
            $html_rendered,
            [
                'Content-Type: text/html',
                "From: WordPress <no-reply@{$domain}>",
            ]
        );
    }
}
