#!/usr/bin/env python3
"""Main entry-point into the 'pritunl_dedicated' Flask application.

This is a Pritunl dedicated server

License: PROPRIETARY
Website: https://gitlab.salamek.cz/sadam/pritunl-dedicated.git

Command details:
    server              Run the application using the Flask Development
                        Server. Auto-reloads files when they change.
    shell               Starts a Python interactive shell with the Flask
                        application context.
    celerydev           Starts a Celery worker with Celery Beat in the same
                        process.
    create_all          Only create database tables if they don't exist and
                        then exit.
    celerybeat          Run a Celery Beat periodic task scheduler.
    celeryworker        Run a Celery worker process.
    list_routes         List all available routes
    post_install        Post install script
    migrations          Migrations
    user                User control
    
Usage:
    pritunl-dedicated server [-p NUM] [-l DIR] [--config_prod]
    pritunl-dedicated list_routes
    pritunl-dedicated shell [--config_prod]
    pritunl-dedicated post_install [--config_prod]
    pritunl-dedicated create_all [--config_prod]
    pritunl-dedicated migrations (upgrade|current|migrate|history|heads|show|stamp|downgrade|init|revision|merge|branches|edit) [--config_prod]
    pritunl-dedicated user <action> <username> [--config_prod]
    pritunl-dedicated migrations stamp <revision> [--config_prod] [-h] [-d DIRECTORY] [--sql] [--tag TAG]
    pritunl-dedicated (-h | --help)

Options:
    --config_prod               Load the production configuration instead of
                                development.
    -l DIR --log_dir=DIR        Log all statements to file in this directory
                                instead of stdout.
                                Only ERROR statements will go to stdout. stderr
                                is not used.
    -p NUM --port=NUM           Flask will listen on this port number.
"""

import hashlib
import logging
import logging.handlers
import os
import random
import signal
import subprocess
import sys
import urllib.parse
from functools import wraps

import flask
import yaml
from docopt import docopt
from flask_script import Shell, Manager
from flask_migrate import MigrateCommand, stamp
from pritunl_dedicated.extensions import db
from pritunl_dedicated.models.pritunl_dedicated import User
from pritunl_dedicated.application import create_app, get_config

OPTIONS = docopt(__doc__)


class CustomFormatter(logging.Formatter):
    LEVEL_MAP = {logging.FATAL: 'F', logging.ERROR: 'E', logging.WARN: 'W', logging.INFO: 'I', logging.DEBUG: 'D'}

    def format(self, record):
        record.levelletter = self.LEVEL_MAP[record.levelno]
        return super(CustomFormatter, self).format(record)


def setup_logging(name=None, level=logging.DEBUG) -> None:
    """Setup Google-Style logging for the entire application.

    At first I hated this but I had to use it for work, and now I prefer it. Who knew?
    From: https://github.com/twitter/commons/blob/master/src/python/twitter/common/log/formatters/glog.py

    Always logs DEBUG statements somewhere.

    Positional arguments:
    name -- Append this string to the log file filename.
    """
    log_to_disk = False
    if OPTIONS['--log_dir']:
        if not os.path.isdir(OPTIONS['--log_dir']):
            print('ERROR: Directory {} does not exist.'.format(OPTIONS['--log_dir']))
            sys.exit(1)
        if not os.access(OPTIONS['--log_dir'], os.W_OK):
            print('ERROR: No permissions to write to directory {}.'.format(OPTIONS['--log_dir']))
            sys.exit(1)
        log_to_disk = True

    fmt = '%(levelletter)s%(asctime)s.%(msecs).03d %(process)d %(filename)s:%(lineno)d] %(message)s'
    datefmt = '%m%d %H:%M:%S'
    formatter = CustomFormatter(fmt, datefmt)

    console_handler = logging.StreamHandler(sys.stdout)
    console_handler.setLevel(level)
    console_handler.setFormatter(formatter)

    root = logging.getLogger()
    root.setLevel(level)
    root.addHandler(console_handler)

    if log_to_disk:
        file_name = os.path.join(OPTIONS['--log_dir'], 'pritunl_dedicated_{}.log'.format(name))
        file_handler = logging.handlers.TimedRotatingFileHandler(file_name, when='d', backupCount=7)
        file_handler.setFormatter(formatter)
        root.addHandler(file_handler)


def log_messages(app) -> None:
    """Log messages common to Tornado and devserver."""
    log = logging.getLogger(__name__)
    log.info('Server is running at http://{}:{}/'.format(app.config['HOST'], app.config['PORT']))
    log.info('Flask version: {}'.format(flask.__version__))
    log.info('DEBUG: {}'.format(app.config['DEBUG']))
    log.info('STATIC_FOLDER: {}'.format(app.static_folder))


def parse_options():
    """Parses command line options for Flask.

    Returns:
    Config instance to pass into create_app().
    """
    # Figure out which class will be imported.
    if OPTIONS['--config_prod']:
        config_class_string = 'pritunl_dedicated.config.Production'
    else:
        config_class_string = 'pritunl_dedicated.config.Config'
    config_obj = get_config(config_class_string)

    # Force port from commandline
    if OPTIONS['--port']:
        if not OPTIONS['--port'].isdigit():
            print('ERROR: Port should be a number.')
            sys.exit(1)
        config_obj.PORT = OPTIONS['--port']

    return config_obj


def command(func):
    """Decorator that registers the chosen command/function.

    If a function is decorated with @command but that function name is not a valid "command" according to the docstring,
    a KeyError will be raised, since that's a bug in this script.

    If a user doesn't specify a valid command in their command line arguments, the above docopt(__doc__) line will print
    a short summary and call sys.exit() and stop up there.

    If a user specifies a valid command, but for some reason the developer did not register it, an AttributeError will
    raise, since it is a bug in this script.

    Finally, if a user specifies a valid command and it is registered with @command below, then that command is "chosen"
    by this decorator function, and set as the attribute `chosen`. It is then executed below in
    `if __name__ == '__main__':`.

    Doing this instead of using Flask-Script.

    Positional arguments:
    func -- the function to decorate
    """
    @wraps(func)
    def wrapped():
        return func()

    # Register chosen function.
    if func.__name__ not in OPTIONS:
        raise KeyError('Cannot register {}, not mentioned in docstring/docopt.'.format(func.__name__))
    if OPTIONS[func.__name__]:
        command.chosen = func

    return wrapped


@command
def server():
    options = parse_options()
    setup_logging('server', logging.DEBUG if options.DEBUG else logging.WARNING)
    app = create_app(options)
    log_messages(app)
    app.run(host=app.config['HOST'], port=int(app.config['PORT']), debug=app.config['DEBUG'])


@command
def shell():
    setup_logging('shell')
    app = create_app(parse_options())
    app.app_context().push()
    Shell(make_context=lambda: dict(app=app)).run(no_ipython=False, no_bpython=False)


@command
def list_routes():
    output = []
    app = create_app(parse_options())
    app.config['SERVER_NAME'] = 'example.com'
    with app.app_context():
        for rule in app.url_map.iter_rules():

            integer_replaces = {}
            options = {}
            integer = 0
            for arg in rule.arguments:
                options[arg] = str(integer)
                integer_replaces[str(integer)] = "[{0}]".format(arg)
                integer = +1

            methods = ','.join(rule.methods)
            url = flask.url_for(rule.endpoint, **options)
            for integer_replace in integer_replaces:
                url = url.replace(integer_replace, integer_replaces[integer_replace])
            line = urllib.parse.unquote("{:50s} {:20s} {}".format(rule.endpoint, methods, url))
            output.append(line)

        for line in sorted(output):
            print(line)


@command
def post_install():
    app = create_app(parse_options())
    config_path = os.path.join('/', 'etc', 'pritunl-dedicated', 'config.yml')

    def run_psql_command(sql):
        subprocess.call(['su', '-c', 'psql -c "{}"'.format(sql), 'postgres'])

    def get_random_password():
        return hashlib.md5(str(random.randint(0, sys.maxsize)).encode('UTF-8')).hexdigest()

    configuration = {}
    if os.path.isfile(config_path):
        with open(config_path) as f:
            loaded_data = yaml.load(f)
            if isinstance(loaded_data, dict):
                configuration.update(loaded_data)

    # Generate database and config if nothing is specified
    if 'SQLALCHEMY_DATABASE_URI' not in configuration or not configuration['SQLALCHEMY_DATABASE_URI']:
        # Create Database
        database_username = 'pritunl_dedicated'
        database_name = 'pritunl_dedicated'
        database_password = get_random_password()

        run_psql_command('CREATE USER {} WITH PASSWORD \'{}\';'.format(database_username, database_password))
        run_psql_command('CREATE DATABASE {};'.format(database_name))
        run_psql_command('GRANT ALL PRIVILEGES ON DATABASE {} TO {};'.format(database_name, database_username))

        configuration['SQLALCHEMY_DATABASE_URI'] = 'postgresql://{}:{}@localhost/{}'.format(
            database_username,
            database_password,
            database_name
        )

        # We need to set DB config to make stamp work
        app.config['SQLALCHEMY_DATABASE_URI'] = configuration['SQLALCHEMY_DATABASE_URI']

        # Create empty database
        with app.app_context():
            db.create_all()

        with app.app_context():
            stamp()

        # Generate secret key
    if 'SECRET_KEY' not in configuration or not configuration['SECRET_KEY']:
        app.config['SECRET_KEY'] = configuration['SECRET_KEY'] = get_random_password()

    # Set port and host
    if 'HOST' not in configuration or not configuration['HOST']:
        configuration['HOST'] = '0.0.0.0'

    if 'PORT' not in configuration or not configuration['PORT']:
        configuration['PORT'] = 80

    # Write new configuration
    with open(config_path, 'w') as f:
        yaml.dump(configuration, f, default_flow_style=False, allow_unicode=True)


@command
def create_all():
    options = parse_options()
    setup_logging('create_all', logging.DEBUG if options.DEBUG else logging.WARNING)
    app = create_app(options)
    log = logging.getLogger(__name__)
    with app.app_context():
        tables_before = set(db.engine.table_names())
        db.create_all()
        tables_after = set(db.engine.table_names())
    created_tables = tables_after - tables_before
    for table in created_tables:
        log.info('Created table: {}'.format(table))


@command
def user():
    action = OPTIONS['<action>']
    username = OPTIONS['<username>']

    def add():
        found_user = User.query.filter_by(username=username).first()
        if found_user:
            raise Exception('Username {} already exists.'.format(username))

        password = input('Password: ')
        if not password:
            raise Exception('No password entered.')

        first_name = input('First name: ')
        if not first_name:
            raise Exception('No first name entered.')

        last_name = input('Last name: ')
        if not last_name:
            raise Exception('No first name entered.')

        new_user = User()
        new_user.username = username
        new_user.set_password(password)
        new_user.first_name = first_name
        new_user.last_name = last_name

        db.session.add(new_user)
        db.session.commit()

        print('User ID: {} created.'.format(new_user.id))

    def delete():
        found_user = User.query.filter_by(username=username).first()
        if not found_user:
            raise Exception('Username {} not found.'.format(username))

        db.session.delete(found_user)
        db.session.commit()

    actions = {
        'add': add,
        'delete': delete
    }

    action_call = actions.get(action)

    if not action_call:
        raise Exception('Unknown action {}'.format(action))

    options = parse_options()
    app = create_app(options)
    with app.app_context():
        action_call()


@command
def migrations():
    app = create_app(parse_options())
    manager = Manager(app)
    manager.add_command('migrations', MigrateCommand)
    manager.run()


def main():
    signal.signal(signal.SIGINT, lambda *_: sys.exit(0))  # Properly handle Control+C
    getattr(command, 'chosen')()  # Execute the function specified by the user.


if __name__ == '__main__':
    main()

