# -*- coding: utf-8 -*-

import json
import os

from werkzeug.exceptions import HTTPException, NotFound, Unauthorized
from werkzeug.routing import Map, Rule
from werkzeug.utils import redirect
from werkzeug.wrappers import Request, Response
from jinja2 import Environment, FileSystemLoader

from cryptography.exceptions import InvalidSignature

from . import crypto
from . import net
from . import remote
from . import validator
from .actionqueue import ActionQueue
from .appmgr import AppMgr
from .config import Config
from .pkgmgr import Stage
from .vmmgr import VMMgr
from .wsgilang import WSGILang
from .wsgisession import WSGISession

SESSION_KEY = os.urandom(26)

class WSGIApp:
    def __init__(self):
        self.conf = Config()
        self.vmmgr = VMMgr(self.conf)
        self.appmgr = AppMgr(self.conf)
        self.queue = ActionQueue()
        self.jinja_env = Environment(loader=FileSystemLoader('/usr/share/vmmgr/templates'), autoescape=True, lstrip_blocks=True, trim_blocks=True)
        self.jinja_env.globals.update(is_app_visible=self.is_app_visible)
        self.url_map = Map((
            Rule('/', endpoint='portal_view'),
            Rule('/login', methods=['GET'], endpoint='login_view'),
            Rule('/login', methods=['POST'], endpoint='login_action'),
            Rule('/setup-host', redirect_to='/login?redir=setup-host'),
            Rule('/setup-apps', redirect_to='/login?redir=setup-apps'),
            Rule('/setup-remote', redirect_to='/login?redir=setup-remote'),
        ))
        self.admin_url_map = Map((
            Rule('/', endpoint='portal_view'),
            Rule('/logout', endpoint='logout_action'),
            Rule('/setup-host', endpoint='setup_host_view'),
            Rule('/setup-apps', endpoint='setup_apps_view'),
            Rule('/setup-remote', endpoint='setup_remote_view'),
            Rule('/update-host', endpoint='update_host_action'),
            Rule('/verify-dns', endpoint='verify_dns_action'),
            Rule('/verify-https', endpoint='verify_http_action', defaults={'proto': 'https'}),
            Rule('/verify-http', endpoint='verify_http_action', defaults={'proto': 'http'}),
            Rule('/update-cert', endpoint='update_cert_action'),
            Rule('/update-common', endpoint='update_common_action'),
            Rule('/update-repo', endpoint='update_repo_action'),
            Rule('/update-app-visibility', endpoint='update_app_visibility_action'),
            Rule('/update-app-autostart', endpoint='update_app_autostart_action'),
            Rule('/start-app', endpoint='start_app_action'),
            Rule('/stop-app', endpoint='stop_app_action'),
            Rule('/install-app', endpoint='install_app_action'),
            Rule('/get-app-status', endpoint='get_app_status_action'),
            Rule('/clear-app-status', endpoint='clear_app_status_action'),
            Rule('/uninstall-app', endpoint='uninstall_app_action'),
            Rule('/update-app', endpoint='update_app_action'),
            Rule('/update-password', endpoint='update_password_action'),
            Rule('/shutdown-vm', endpoint='shutdown_vm_action'),
            Rule('/reboot-vm', endpoint='reboot_vm_action'),
            Rule('/update-ssh-keys', endpoint='update_ssh_keys_action'),
            Rule('/update-vpn', endpoint='update_vpn_action'),
            Rule('/generate-vpn-key', endpoint='generate_vpn_key_action'),
            Rule('/start-vpn', endpoint='start_vpn_action'),
            Rule('/stop-vpn', endpoint='stop_vpn_action'),
        ))
        self.localhost_url_map = Map((
            Rule('/reload-config', endpoint='reload_config_action'),
        ))

    def __call__(self, environ, start_response):
        return self.wsgi_app(environ, start_response)

    def wsgi_app(self, environ, start_response):
        request = Request(environ)
        # Enhance request
        request.session = WSGISession(request.cookies, SESSION_KEY)
        request.session.lang = WSGILang()
        # Dispatch request
        response = self.dispatch_request(request)
        return response(environ, start_response)

    def dispatch_request(self, request):
        if request.session['admin']:
            url_map = self.admin_url_map
        elif request.remote_addr in ('127.0.0.1', '::1'):
            url_map = self.localhost_url_map
        else:
            url_map = self.url_map
        adapter = url_map.bind_to_environ(request.environ)
        try:
            endpoint, values = adapter.match()
            response = getattr(self, endpoint)(request, **values)
            # Save session if changed
            request.session.save(response)
            return response
        except NotFound as e:
            # Return custom 404 page
            response = self.render_html('404.html', request)
            response.status_code = 404
            return response
        except HTTPException as e:
            # Return MethodNotAllowed or RequestRedirect as they are
            return e

    def render_template(self, template_name, request, **context):
        # Enhance context
        context['conf'] = self.conf
        context['session'] = request.session
        context['lang'] = request.session.lang
        # Render template
        template = self.jinja_env.get_template(template_name)
        return template.render(context)

    def render_html(self, template_name, request, **context):
        html = self.render_template(template_name, request, **context)
        return Response(html, mimetype='text/html')

    def render_json(self, data):
        return Response(json.dumps(data), mimetype='application/json')

    def get_session_message(self, request):
        # Consume and retrieve message stored in session
        if 'msg' not in request.session:
            return None
        message = request.session['msg']
        del request.session['msg']
        # Message is in format location:type:text
        return message.split(':', 2)

    def login_view(self, request):
        redir = request.args.get('redir', '')
        message = self.get_session_message(request)
        return self.render_html('login.html', request, redir=redir, message=message)

    def login_action(self, request):
        password = request.form['password']
        redir = request.form['redir']
        if crypto.adminpwd_verify(password, self.conf['host']['adminpwd']):
            request.session['admin'] = True
            return redirect('/{}'.format(redir))
        request.session['msg'] = 'login:error:{}'.format(request.session.lang.bad_password())
        return redirect('/login?redir={}'.format(redir)) if redir else redirect('/login')

    def logout_action(self, request):
        request.session.reset()
        return redirect('/')

    def portal_view(self, request):
        # Default portal view.
        host = net.compile_url(self.conf['host']['domain'], self.conf['host']['port'])[8:]
        if request.session['admin']:
            return self.render_html('portal-admin.html', request, host=host)
        return self.render_html('portal-user.html', request, host=host)

    def setup_host_view(self, request):
        # Host setup view.
        ex_ipv4 = net.get_external_ip(4)
        ex_ipv6 = net.get_external_ip(6)
        in_ipv4 = net.get_local_ip(4)
        in_ipv6 = net.get_local_ip(6)
        cert_info = crypto.get_cert_info()
        return self.render_html('setup-host.html', request, ex_ipv4=ex_ipv4, ex_ipv6=ex_ipv6, in_ipv4=in_ipv4, in_ipv6=in_ipv6, cert_info=cert_info)

    def setup_apps_view(self, request):
        # Application manager view.
        repo_error = None
        try:
            self.appmgr.pkgmgr.fetch_online_packages()
        except InvalidSignature:
            repo_error = request.session.lang.invalid_packages_signature()
        except Unauthorized:
            repo_error = request.session.lang.repo_invalid_credentials()
        except:
            repo_error = request.session.lang.repo_unavailable()
        table = self.render_setup_apps_table(request)
        message = self.get_session_message(request)
        return self.render_html('setup-apps.html', request, repo_error=repo_error, table=table, message=message)

    def render_setup_apps_table(self, request):
        lang = request.session.lang
        online_packages = self.appmgr.pkgmgr.online_packages
        pending_actions = self.queue.get_actions()
        actionable_apps = sorted(set([k for k, v in online_packages.items() if 'title' in v] + list(self.conf['apps'].keys())))
        app_data = {}
        for app in actionable_apps:
            installed = app in self.conf['packages'] and app in self.conf['apps']
            title = self.conf['packages'][app]['title'] if installed else online_packages[app]['title']
            visible = self.conf['apps'][app]['visible'] if installed else False
            autostarted = self.appmgr.is_service_autostarted(app) if installed else False
            if app in pending_actions:
                item = pending_actions[app]
                actions = '<div class="loader"></div>'
                if item.action == self.appmgr.start_app:
                    if not item.started:
                        status = '{} ({})'.format(lang.status_starting(), lang.status_queued())
                    elif isinstance(item.data, BaseException):
                        status = '<span class="error">{}</span> <a href="#" class="app-clear-status">OK</a>'.format(lang.stop_start_error())
                        actions = None
                    else:
                        status = lang.status_starting()
                elif item.action == self.appmgr.stop_app:
                    if not item.started:
                        status = '{} ({})'.format(lang.status_stopping(), lang.status_queued())
                    elif isinstance(item.data, BaseException):
                        status = '<span class="error">{}</span> <a href="#" class="app-clear-status">OK</a>'.format(lang.stop_start_error())
                        actions = None
                    else:
                        status = lang.status_stopping()
                elif item.action == self.appmgr.install_app:
                    if not item.started:
                        status = '{} ({})'.format(lang.status_downloading(), lang.status_queued())
                    elif isinstance(item.data, InvalidSignature):
                        status = '<span class="error">{}</span> <a href="#" class="app-clear-status">OK</a>'.format(lang.repo_package_invalid_signature())
                        actions = None
                    elif isinstance(item.data, NotFound):
                        status = '<span class="error">{}</span> <a href="#" class="app-clear-status">OK</a>'.format(lang.repo_package_missing())
                        actions = None
                    elif isinstance(item.data, BaseException):
                        status = '<span class="error">{}</span> <a href="#" class="app-clear-status">OK</a>'.format(lang.package_manager_error())
                        actions = None
                    else:
                        if item.data.stage == Stage.DOWNLOAD:
                            status = '{} ({} %)'.format(lang.status_downloading(), item.data.percent_downloaded)
                        elif item.data.stage == Stage.UNPACK:
                            status = lang.status_unpacking()
                        elif item.data.stage == Stage.INSTALL_DEPS:
                            status = lang.status_installing_deps()
                        else:
                            status = lang.status_installing()
                elif item.action == self.appmgr.uninstall_app:
                    if not item.started:
                        status = '{} ({})'.format(lang.status_uninstalling(), lang.status_queued())
                    elif isinstance(item.data, BaseException):
                        status = '<span class="error">{}</span> <a href="#" class="app-clear-status">OK</a>'.format(lang.package_manager_error())
                        actions = None
                    else:
                        status = lang.status_uninstalling()
            else:
                if not installed:
                    status = lang.status_not_installed()
                    actions = '<a href="#" class="app-install">{}</a>'.format(lang.action_install())
                else:
                    if self.appmgr.is_service_started(app):
                        status = '<span class="info">{}</span>'.format(lang.status_started())
                        actions = '<a href="#" class="app-stop">{}</a>'.format(lang.action_stop())
                    else:
                        status = '<span class="error">{}</span>'.format(lang.status_stopped())
                        actions = '<a href="#" class="app-start">{}</a>, <a href="#" class="app-uninstall">{}</a>'.format(lang.action_start(), lang.action_uninstall())
                    if self.appmgr.pkgmgr.has_update(app):
                        actions = '{}, <a href="#" class="app-update">{}</a>'.format(actions, lang.action_update())
            app_data[app] = {'title': title, 'visible': visible, 'installed': installed, 'autostarted': autostarted, 'status': status, 'actions': actions}
        return self.render_template('setup-apps-table.html', request, app_data=app_data)

    def setup_remote_view(self, request):
        # Remote access setup view
        authorized_keys = remote.get_authorized_keys()
        wg_conf = remote.get_wireguard_conf()
        wg_conf['status'] = ('info', request.session.lang.status_started()) if wg_conf['running'] else ('error', request.session.lang.status_stopped())
        message = self.get_session_message(request)
        return self.render_html('setup-remote.html', request, authorized_keys=authorized_keys, wg_conf=wg_conf, message=message)

    def update_host_action(self, request):
        # Update domain and port, then restart nginx
        domain = request.form['domain']
        port = request.form['port']
        if not validator.is_valid_domain(domain):
            return self.render_json({'error': request.session.lang.invalid_domain()})
        if not validator.is_valid_port(port):
            return self.render_json({'error': request.session.lang.invalid_port()})
        self.vmmgr.update_host(domain, port)
        url = '{}/setup-host'.format(net.compile_url(net.get_local_ip(), port))
        response = self.render_json({'ok': request.session.lang.host_updated(url, url)})
        response.call_on_close(self.vmmgr.restart_nginx)
        return response

    def verify_dns_action(self, request):
        # Check if all FQDNs for all applications are resolvable and point to current external IP
        domains = [self.vmmgr.domain]+['{}.{}'.format(self.conf['apps'][app]['host'], self.vmmgr.domain) for app in self.conf['apps']]
        ipv4 = net.get_external_ip(4)
        ipv6 = net.get_external_ip(6)
        for domain in domains:
            try:
                a = net.resolve_ip(domain, 'A')
                aaaa = net.resolve_ip(domain, 'AAAA')
                if not a and not aaaa:
                    return self.render_json({'error': request.session.lang.dns_record_does_not_exist(domain)})
                if a and a != ipv4:
                    return self.render_json({'error': request.session.lang.dns_record_mismatch(domain, a, ipv4)})
                if aaaa and aaaa != ipv6:
                    return self.render_json({'error': request.session.lang.dns_record_mismatch(domain, aaaa, ipv6)})
            except:
                return self.render_json({'error': request.session.lang.dns_timeout()})
        return self.render_json({'ok': request.session.lang.dns_records_ok()})

    def verify_http_action(self, request, **kwargs):
        # Check if all applications are accessible from the internet using 3rd party ping service
        proto = kwargs['proto']
        port = self.vmmgr.port if proto == 'https' else '80'
        domains = [self.vmmgr.domain]+['{}.{}'.format(self.conf['apps'][app]['host'], self.vmmgr.domain) for app in self.conf['apps']]
        for domain in domains:
            url = net.compile_url(domain, port, proto)
            try:
                if not net.ping_url(url):
                    return self.render_json({'error': request.session.lang.http_host_not_reachable(url)})
            except:
                return self.render_json({'error': request.session.lang.http_timeout()})
        return self.render_json({'ok': request.session.lang.http_hosts_ok(port)})

    def update_cert_action(self, request):
        # Update certificate - either request via Let's Encrypt or manually upload files
        if request.form['method'] == 'selfsigned':
            self.vmmgr.create_selfsigned_cert()
        elif request.form['method'] == 'automatic':
            self.vmmgr.request_acme_cert()
        elif request.form['method'] == 'manual':
            if not request.files['public']:
                return self.render_json({'error': request.session.lang.cert_file_missing()})
            if not request.files['private']:
                return self.render_json({'error': request.session.lang.key_file_missing()})
            request.files['public'].save('/tmp/public.pem')
            request.files['private'].save('/tmp/private.pem')
            self.vmmgr.install_manual_cert('/tmp/public.pem', '/tmp/private.pem')
            os.unlink('/tmp/public.pem')
            os.unlink('/tmp/private.pem')
        else:
            return self.render_json({'error': request.session.lang.cert_request_error()})
        url = net.compile_url(self.vmmgr.domain, self.vmmgr.port)
        return self.render_json({'ok': request.session.lang.cert_installed(url, url)})

    def update_common_action(self, request):
        # Update common settings shared between apps - admin e-mail address, Google Maps API key
        email = request.form['email']
        if not validator.is_valid_email(email):
            request.session['msg'] = 'common:error:{}'.format(request.session.lang.invalid_email(email))
        else:
            self.vmmgr.update_common_settings(email, request.form['gmaps-api-key'])
            request.session['msg'] = 'common:info:{}'.format(request.session.lang.common_updated())
        return redirect('/setup-apps')

    def update_repo_action(self, request):
        # Update repository URL and credentials
        url = request.form['repourl']
        if not validator.is_valid_repo_url(url):
            request.session['msg'] = 'repo:error:{}'.format(request.session.lang.invalid_url(url))
        else:
            self.appmgr.update_repo_settings(url, request.form['repousername'], request.form['repopassword'])
            request.session['msg'] = 'repo:info:{}'.format(request.session.lang.repo_updated())
        return redirect('/setup-apps')

    def update_app_visibility_action(self, request):
        # Update application visibility on portal page
        self.appmgr.update_app_visibility(request.form['app'], request.form['value'] == 'true')
        return self.render_json({'ok': 'ok'})

    def update_app_autostart_action(self, request):
        # Update value determining if the app should be automatically started after VM boot
        self.appmgr.update_app_autostart(request.form['app'], request.form['value'] == 'true')
        return self.render_json({'ok': 'ok'})

    def enqueue_app_action(self, request, action):
        # Common method for enqueuing app actions
        self.queue.enqueue_action(request.form['app'], action)
        response = self.render_json({'ok': self.render_setup_apps_table(request)})
        response.call_on_close(self.queue.process_actions)
        return response

    def start_app_action(self, request):
        # Queues application start along with its dependencies
        return self.enqueue_app_action(request, self.appmgr.start_app)

    def stop_app_action(self, request):
        # Queues application stop along with its dependencies
        return self.enqueue_app_action(request, self.appmgr.stop_app)

    def install_app_action(self, request):
        # Queues application installation
        return self.enqueue_app_action(request, self.appmgr.install_app)

    def uninstall_app_action(self, request):
        # Queues application uninstallation
        return self.enqueue_app_action(request, self.appmgr.uninstall_app)

    def update_app_action(self, request):
        # Queues application update
        return self.enqueue_app_action(request, self.appmgr.update_app)

    def get_app_status_action(self, request):
        # Gets application and queue status
        return self.render_json({'ok': self.render_setup_apps_table(request)})

    def clear_app_status_action(self, request):
        # Clears error status for an application
        self.queue.clear_action(request.form['app'])
        return self.render_json({'ok': self.render_setup_apps_table(request)})

    def update_password_action(self, request):
        # Updates password for both HDD encryption (LUKS-on-LVM) and web interface admin account
        try:
            if request.form['newpassword'] != request.form['newpassword2']:
                return self.render_json({'error': request.session.lang.password_mismatch()})
            if request.form['newpassword'] == '':
                return self.render_json({'error': request.session.lang.password_empty()})
            # No need to explicitly validate old password, update_luks_password will raise exception if it's wrong
            self.vmmgr.update_password(request.form['oldpassword'], request.form['newpassword'])
        except:
            return self.render_json({'error': request.session.lang.bad_password()})
        return self.render_json({'ok': request.session.lang.password_changed()})

    def reboot_vm_action(self, request):
        # Reboots VM
        response = self.render_json({'ok': request.session.lang.reboot_initiated()})
        response.call_on_close(self.vmmgr.reboot_vm)
        return response

    def shutdown_vm_action(self, request):
        # Shuts down VM
        response = self.render_json({'ok': request.session.lang.shutdown_initiated()})
        response.call_on_close(self.vmmgr.shutdown_vm)
        return response

    def reload_config_action(self, request):
        # Reload configuration (called by vmmgr.register_app())
        self.conf.load()
        return Response(status=204)

    def is_app_visible(self, app):
        return app in self.conf['apps'] and self.conf['apps'][app]['visible'] and self.appmgr.is_service_started(app)

    def update_ssh_keys_action(self, request):
        # Update authorized_keys file
        remote.set_authorized_keys(request.form['ssh-keys'].replace('\r', ''))
        request.session['msg'] = 'ssh:info:{}'.format(request.session.lang.ssh_keys_installed())
        return redirect('/setup-remote')

    def update_vpn_action(self, request):
        # Update WireGuard VPN listen port, virtual IP and peer list
        ip = request.form['vpn-lip']
        if not ip.isdigit() or not 0 < int(ip) < 255:
            request.session['msg'] = 'vpn:error:{}'.format(request.session.lang.invalid_ip())
            return redirect('/setup-remote')
        port = request.form['vpn-port']
        if not port.isdigit() or not 0 < int(port) < 65536:
            request.session['msg'] = 'vpn:error:{}'.format(request.session.lang.invalid_port())
            return redirect('/setup-remote')
        peers = request.form['vpn-peers'].replace('\r', '')
        remote.set_wireguard_conf(ip, port, peers)
        request.session['msg'] = 'vpn:info:{}'.format(request.session.lang.vpn_updated())
        return redirect('/setup-remote')

    def generate_vpn_key_action(self, request):
        # Regenerate WireGuard key pair
        remote.regenerate_wireguard_key()
        return redirect('/setup-remote')

    def start_vpn_action(self, request):
        # Start WireGuard VPN
        remote.start_wireguard()
        return redirect('/setup-remote')

    def stop_vpn_action(self, request):
        # Stop WireGuard VPN
        remote.stop_wireguard()
        return redirect('/setup-remote')
