#!/usr/bin/env php
<?php declare(strict_types=1);
/**
 * PrivateBin
 *
 * a zero-knowledge paste bin
 *
 * @link      https://github.com/PrivateBin/PrivateBin
 * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
 * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
 */

namespace PrivateBin;

use Exception;
use PrivateBin\Configuration;
use PrivateBin\Data\AbstractData;
use PrivateBin\Model\Paste;

define('PATH', dirname(__FILE__) . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR);
require PATH . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php';

/**
 * Administration
 *
 * Command line utility for administrative tasks.
 */
class Administration
{
    /**
     * configuration
     *
     * @access private
     * @var    Configuration
     */
    private $_conf;

    /**
     * options, parsed from the command line arguments
     *
     * @access private
     * @var    array
     */
    private $_opts = array();

    /**
     * data storage model
     *
     * @access private
     * @var    AbstractData
     */
    private $_store;

    /**
     * deletes the requested document ID, if a valid ID and it exists
     *
     * @access private
     * @param  string $pasteId
     */
    private function _delete($pasteId)
    {
        if (!Paste::isValidId($pasteId)) {
            self::_error('given ID is not a valid document ID (16 hexadecimal digits)', 5);
        }
        if (!$this->_store->exists($pasteId)) {
            self::_error('given ID does not exist, has expired or was already deleted', 6);
        }
        $this->_store->delete($pasteId);
        if ($this->_store->exists($pasteId)) {
            self::_error('document ID exists after deletion, permission problem?', 7);
        }
        exit("document $pasteId successfully deleted" . PHP_EOL);
    }

    /**
     * deletes all stored documents (regardless of expiration)
     *
     * @access private
     */
    private function _delete_all()
    {
        $ids = $this->_store->getAllPastes();
        foreach ($ids as $pasteid) {
            echo "Deleting document ID: $pasteid" . PHP_EOL;
            $this->_store->delete($pasteid);
        }
        exit("All documents successfully deleted" . PHP_EOL);
    }

    /**
     * deletes all unsupported v1 documents (regardless of expiration)
     *
     * @access private
     */
    private function _delete_v1()
    {
        $ids = $this->_store->getAllPastes();
        foreach ($ids as $pasteid) {
            try {
                $paste = $this->_store->read($pasteid);
            } catch (Exception $e) {
                echo "Error reading document {$pasteid}: ", $e->getMessage(), PHP_EOL;
            }
            if (array_key_exists('adata', $paste)) {
                continue;
            }
            echo "Deleting v1 document ID: $pasteid" . PHP_EOL;
            $this->_store->delete($pasteid);
        }
        exit("All unsupported legacy v1 documents successfully deleted" . PHP_EOL);
    }

    /**
     * removes empty directories, if current storage model uses Filesystem
     *
     * @access private
     */
    private function _empty_dirs()
    {
        if ($this->_conf->getKey('class', 'model') !== 'Filesystem') {
            self::_error('instance not using Filesystem storage, no directories to empty', 4);
        }
        $dir = $this->_conf->getKey('dir', 'model_options');
        passthru("find $dir -type d -empty -delete", $code);
        exit($code);
    }

    /**
     * display a message on STDERR and exits
     *
     * @access private
     * @static
     * @param  string $message
     * @param  int    $code optional, defaults to 1
     */
    private static function _error($message, $code = 1)
    {
        self::_error_echo($message);
        exit($code);
    }

    /**
     * display a message on STDERR
     *
     * @access private
     * @static
     * @param  string $message
     */
    private static function _error_echo($message)
    {
        fwrite(STDERR, 'Error: ' . $message . PHP_EOL);
    }

    /**
     * display usage help on STDOUT and exits
     *
     * @access private
     * @static
     * @param  int    $code optional, defaults to 0
     */
    private static function _help($code = 0)
    {
        echo <<<'EOT'
Usage:
  administration [--delete <document id> | --delete-all | --delete-v1 |
                  --empty-dirs | --help | --list-ids | --purge | --statistics]

Options:
  -d, --delete      deletes the requested document ID
  --delete-all      deletes all documents
  --delete-v1       deletes all unsupported v1 documents
  -e, --empty-dirs  removes empty directories (only if Filesystem storage is
                    configured)
  -h, --help        displays this help message
  -l, --list-ids    lists all document IDs
  -p, --purge       purge all expired documents
  -s, --statistics  reads all stored documents and reports statistics
EOT, PHP_EOL;
        exit($code);
    }

    /**
     * lists all stored document IDs
     *
     * @access private
     */
    private function _list_ids()
    {
        $ids = $this->_store->getAllPastes();
        foreach ($ids as $pasteid) {
            echo $pasteid, PHP_EOL;
        }
        exit;
    }

    /**
     * return option for given short or long keyname, if it got set
     *
     * @access private
     * @static
     * @param  string $short
     * @param  string $long
     * @return string|null
     */
    private function _option($short, $long)
    {
        foreach (array($short, $long) as $key) {
            if (array_key_exists($key, $this->_opts)) {
                return $this->_opts[$key];
            }
        }
        return null;
    }

    /**
     * initialize options from given argument array
     *
     * @access private
     * @static
     * @param  array $arguments
     */
    private function _options_initialize($arguments)
    {
        if ($arguments > 3) {
            self::_error_echo('too many arguments given');
            echo PHP_EOL;
            self::_help(1);
        }

        if ($arguments < 2) {
            self::_error_echo('missing arguments');
            echo PHP_EOL;
            self::_help(2);
        }

        $this->_opts = getopt('hd:elps', array('help', 'delete:', 'delete-all', 'delete-v1', 'empty-dirs', 'list-ids', 'purge', 'statistics'));

        if (!$this->_opts) {
            self::_error_echo('unsupported arguments given');
            echo PHP_EOL;
            self::_help(3);
        }
    }

    /**
     * reads all stored documents and reports statistics
     *
     * @access public
     */
    private function _statistics()
    {
        $counters = array(
            'burn'          => 0,
            'damaged'       => 0,
            'discussion'    => 0,
            'expired'       => 0,
            'legacy'        => 0,
            'md'            => 0,
            'percent'       => 1,
            'plain'         => 0,
            'progress'      => 0,
            'syntax'        => 0,
            'total'         => 0,
            'unknown'       => 0,
        );
        $time = time();
        $ids = $this->_store->getAllPastes();
        $counters['total'] = count($ids);
        $dots = $counters['total'] < 100 ? 10 : (
            $counters['total'] < 1000 ? 50 : 100
        );
        $percentages = $counters['total'] < 100 ? 0 : (
            $counters['total'] < 1000 ? 4 : 10
        );

        echo "Total:\t\t\t{$counters['total']}", PHP_EOL;
        foreach ($ids as $pasteid) {
            try {
                $paste = $this->_store->read($pasteid);
            } catch (Exception $e) {
                echo "Error reading document {$pasteid}: ", $e->getMessage(), PHP_EOL;
                ++$counters['damaged'];
            }
            ++$counters['progress'];

            if (
                array_key_exists('meta', $paste) &&
                array_key_exists('expire_date', $paste['meta']) &&
                $paste['meta']['expire_date'] < $time
            ) {
                ++$counters['expired'];
            }

            if (array_key_exists('adata', $paste)) {
                switch ($paste['adata'][Paste::ADATA_FORMATTER]) {
                    case 'plaintext':
                        ++$counters['plain'];
                        break;
                    case 'syntaxhighlighting':
                        ++$counters['syntax'];
                        break;
                    case 'markdown':
                        ++$counters['md'];
                        break;
                    default:
                        ++$counters['unknown'];
                        break;
                }
                $counters['discussion'] += (int) $paste['adata'][Paste::ADATA_OPEN_DISCUSSION];
                $counters['burn'] += (int) $paste['adata'][Paste::ADATA_BURN_AFTER_READING];
            } else {
                echo "Unsupported v1 paste ", $pasteid, PHP_EOL;
                ++$counters['legacy'];
            }

            // display progress
            if ($counters['progress'] % $dots === 0) {
                echo '.';
                if ($percentages) {
                    $progress = $percentages / $counters['total'] * $counters['progress'];
                    if ($progress >= $counters['percent']) {
                        printf(' %d%% ', 100 / $percentages * $progress);
                        ++$counters['percent'];
                    }
                }
            }
        }

        echo PHP_EOL, <<<EOT
Expired:\t\t{$counters['expired']}
Burn after reading:\t{$counters['burn']}
Discussions:\t\t{$counters['discussion']}
Plain Text:\t\t{$counters['plain']}
Source Code:\t\t{$counters['syntax']}
Markdown:\t\t{$counters['md']}
EOT, PHP_EOL;
        if ($counters['legacy'] > 0) {
            echo "Legacy v1:\t\t{$counters['legacy']}", PHP_EOL;
        }
        if ($counters['damaged'] > 0) {
            echo "Damaged:\t\t{$counters['damaged']}", PHP_EOL;
        }
        if ($counters['unknown'] > 0) {
            echo "Unknown format:\t\t{$counters['unknown']}", PHP_EOL;
        }
    }

    /**
     * constructor
     *
     * initializes and runs administrative tasks
     *
     * @access public
     */
    public function __construct()
    {
        $this->_options_initialize($_SERVER['argc']);

        if ($this->_option('h', 'help') !== null) {
            self::_help();
        }

        $this->_conf = new Configuration;

        if ($this->_option('e', 'empty-dirs') !== null) {
            $this->_empty_dirs();
        }

        $class = 'PrivateBin\\Data\\' . $this->_conf->getKey('class', 'model');
        $this->_store = new $class($this->_conf->getSection('model_options'));

        if (($pasteId = $this->_option('d', 'delete')) !== null) {
            $this->_delete($pasteId);
        }

        if ($this->_option(null, 'delete-all') !== null) {
            $this->_delete_all();
        }

        if ($this->_option(null, 'delete-v1') !== null) {
            $this->_delete_v1();
        }

        if ($this->_option('l', 'list-ids') !== null) {
            $this->_list_ids();
        }

        if ($this->_option('p', 'purge') !== null) {
            try {
                $this->_store->purge(PHP_INT_MAX);
            } catch (Exception $e) {
                echo 'Error purging documents: ', $e->getMessage(), PHP_EOL,
                    'Run the statistics to find damaged document IDs and either delete them or restore them from backup.', PHP_EOL;
            }
            exit('purging of expired documents concluded' . PHP_EOL);
        }

        if ($this->_option('s', 'statistics') !== null) {
            $this->_statistics();
        }
    }
}

new Administration();
