# Date: 02/08/2019
# Author: Mohamed
# Description: A secure notebook

import os
import sys
from time import time
from flask_wtf import CSRFProtect
from datetime import timedelta, datetime
from lib.cipher import get_random_bytes, CryptoAES
from lib.database.database import Account, Profile
from lib.const import SessionConst, CredentialConst, ProfileConst, PermissionConst
from flask import Flask, flash, render_template, request, session, jsonify, redirect, url_for, escape

# app
if getattr(sys, 'frozen', False):
    path = os.path.abspath('.')

    if not os.path.exists('database'):
        os.mkdir(os.path.join(path, 'database'))

    static_folder = os.path.join(path, 'static')
    template_folder = os.path.join(path, 'templates')

    app = Flask(__name__, template_folder=template_folder,
                static_folder=static_folder)
else:
    app = Flask(__name__)

app.config['SECRET_KEY'] = get_random_bytes(0x20)
app.permanent_session_lifetime = timedelta(
    minutes=SessionConst.SESSION_TTL.value)

# Protection against CSRF attack
CSRFProtect(app)

# databases
account_db = Account()
profile_db = Profile()

# core functions


def login_required(func):
    def wrapper(*args, **kwargs):
        if not 'logged_in' in session:
            return redirect(url_for('index'))
        elif not session['logged_in']:
            return redirect(url_for('index'))
        else:
            return func(*args, **kwargs)
    wrapper.__name__ = func.__name__
    return wrapper


def permission_required(func):
    def wrapper(*args, **kwargs):
        if session['access_level'] == PermissionConst.NONE.value:
            return redirect(url_for('index'))
        return func(*args, **kwargs)
    wrapper.__name__ = func.__name__
    return wrapper


def admin_required(func):
    def wrapper(*args, **kwargs):
        if session['access_level'] != PermissionConst.ROOT.value:
            return redirect(url_for('admin'))
        return func(*args, **kwargs)
    wrapper.__name__ = func.__name__
    return wrapper


def invalid_username(username):
    if len(username) < CredentialConst.MIN_USERNAME_LENGTH.value:
        return 'Username must be at least {} characters long'.format(
            CredentialConst.MIN_USERNAME_LENGTH.value
        )

    if len(username) > CredentialConst.MAX_USERNAME_LENGTH.value:
        return 'Username must not be longer than {} characters'.format(
            CredentialConst.MAX_USERNAME_LENGTH.value
        )

    if username.isdigit():
        return 'Username must contain a letter'

    if not username[0].isalpha():
        return 'Username must start with a letter'

    if [_ for _ in username if not _.isdigit() and not _.isalpha()]:
        return 'Username must not contain special characters'


def invalid_password(username, password, confirm):
    if password != confirm:
        return 'Passwords do not match'

    if len(password) < CredentialConst.MIN_PASSWORD_LENGTH.value:
        return 'Password must be at least {} characters long'.format(
            CredentialConst.MIN_PASSWORD_LENGTH.value
        )

    if len(password) > CredentialConst.MAX_PASSWORD_LENGTH.value:
        return 'Password must not be longer than {} characters'.format(
            CredentialConst.MAX_PASSWORD_LENGTH.value
        )

    if not ' ' in password:
        return 'Password must contain at least 1 space character'

    if password[0] == ' ' or password[-1] == ' ':
        return 'Password must not start or end with a space character'

    if password[-1].isdigit():
        return 'Password must not end with a number'

    if not [_ for _ in password if _.isalpha() if _ == _.upper()]:
        return 'Password must contain at least 1 capital letter'

    if ''.join([_ for _ in username if _.isalpha()]).lower() in password.lower():
        return 'Password must not contain your username'


def get_user_key():
    user_id = session['user_id']
    master_key = session['master_key']

    encrypted_user_key = account_db.get_encrypted_user_key(user_id)
    decrypted_user_key = CryptoAES.decrypt(encrypted_user_key, master_key)

    return decrypted_user_key


def create_topic(topic_name, time_stamp):
    user_key = get_user_key()
    user_id = session['user_id']
    topic_name = topic_name.strip()

    return profile_db.add_topic(user_id, user_key, topic_name, time_stamp)


def get_topics():
    user_key = get_user_key()
    user_id = session['user_id']

    return profile_db.decrypt_topics(user_id, user_key)


def create_note(topic_id, note_title, time_stamp):
    user_key = get_user_key()
    note_title = note_title.strip()

    return profile_db.add_note(topic_id, user_key, note_title, '', time_stamp)


def get_notes(topic_id):
    user_key = get_user_key()
    return profile_db.decrypt_notes(topic_id, user_key)


def delete_usr(user_id):
    if user_id == session['user_id'] and session['access_level'] == PermissionConst.ROOT.value:
        if account_db.get_admin() == 1:

            # sorry, I can't allow you to do that
            return False

    account_db.delete_account(user_id)
    profile_db.delete_account(user_id)
    return True

# endpoints
@app.before_request
def single_browser():
    if not 'logged_in' in session:
        return

    if not session['logged_in']:
        return

    if (time() - session['last_checked']) < 1.5:
        return

    user_id = session['user_id']
    session_token = session['token']
    session['last_checked'] = time()

    if not account_db.is_logged_in(user_id, session_token):
        logout()


@app.route('/settings')
@login_required
def settings():
    return render_template('settings.html', PermissionConst=PermissionConst)


@app.route('/updateusername', methods=['POST'])
@login_required
def update_username():
    resp = {'msg': 'Username Changed Successfully', 'resp_code': -1}

    if not 'username' in request.form:
        resp['msg'] = 'Incomplete form'
        return jsonify(resp)

    username = escape(request.form['username'].strip().lower())
    username_error = invalid_username(username)

    if username_error:
        resp['msg'] = username_error
        return jsonify(resp)

    if account_db.account_exists(username):
        resp['msg'] = 'Username already exists'
        return jsonify(resp)

    user_id = session['user_id']
    account_db.update_username(user_id, username)

    resp['resp_code'] = 0
    return jsonify(resp)


@app.route('/updatepassword', methods=['POST'])
@login_required
def update_password():
    resp = {'msg': 'Password Changed Successfully', 'resp_code': -1}

    if not ('old' in request.form and 'new' in request.form and 'conf' in request.form):
        resp['resp'] = 'Incomplete form'
        return jsonify(resp)

    old_password = escape(request.form['old'].strip())
    new_password = escape(request.form['new'].strip())
    confirm_password = escape(request.form['conf'].strip())

    if (
        (len(old_password) > CredentialConst.MAX_PASSWORD_LENGTH.value) or
        (len(new_password) > CredentialConst.MAX_PASSWORD_LENGTH.value) or
        (new_password != confirm_password)
    ):

        resp['msg'] = 'Password must not be longer than {} characters'.format(
            CredentialConst.MAX_PASSWORD_LENGTH.value
        )

        return jsonify(resp)

    user_id = session['user_id']

    if not account_db.compare_passwords(user_id, old_password):
        resp['msg'] = 'Check your current password field'
        return jsonify(resp)

    username = account_db.get_user_name(user_id)
    password_error = invalid_password(username, new_password, confirm_password)

    if password_error:
        resp['msg'] = password_error
        return jsonify(resp)

    if account_db.compare_passwords(user_id, new_password):
        resp['msg'] = 'You are already using that password'
        return jsonify(resp)

    new_master_key = account_db.update_password(
        user_id, old_password, new_password)
    session['master_key'] = new_master_key

    resp['resp_code'] = 0
    return jsonify(resp)

# topic
@app.route('/createtopic', methods=['POST'])
@login_required
def createtopic():
    resp = {'topic_id': '', 'date_created': '', 'resp': 'error-msg'}

    if not ('topic_name' in request.form and 'time_stamp' in request.form):
        return jsonify(resp)

    timestamp = request.form['time_stamp']

    if not timestamp.isdigit():
        return jsonify(resp)

    current_time = int(timestamp)/1000

    try:
        datetime.fromtimestamp(current_time)
    except:
        return jsonify(resp)

    topic_name = escape(request.form['topic_name'].strip())
    topic_len = len(topic_name)

    if (
        (topic_len < ProfileConst.MIN_TOPIC_LENGTH.value) or
        (topic_len > ProfileConst.MAX_TOPIC_LENGTH.value)
    ):
        return jsonify(resp)

    if profile_db.get_total_topics(session['user_id']) >= ProfileConst.MAX_TOPICS.value:
        return jsonify(resp)

    resp['resp'] = 'success-msg'
    resp['topic_id'], resp['date_created'] = create_topic(
        topic_name, current_time)

    return jsonify(resp)


@app.route('/gettopics', methods=['POST'])
@login_required
def gettopics():
    resp = {'topics': []}
    resp['topics'] = get_topics()

    return jsonify(resp)


@app.route('/topic')
@login_required
def gettopic():
    if not 'id' in request.args:
        return render_template('topic.html', PermissionConst=PermissionConst)

    user_id = session['user_id']
    user_key = get_user_key()
    topic_id = escape(request.args.get('id'))

    if not profile_db.topic_exists(user_id, topic_id):
        return render_template('topic.html', PermissionConst=PermissionConst)

    topic = profile_db.decrypt_topic(topic_id, user_key)
    return render_template('topic.html', topic=topic, PermissionConst=PermissionConst)


@app.route('/settings/topic')
@login_required
def settings_topic():
    if not 'topic_id' in request.args:
        return redirect(url_for('index'))

    user_id = session['user_id']
    user_key = get_user_key()
    topic_id = escape(request.args.get('topic_id'))

    if not profile_db.topic_exists(user_id, topic_id):
        return redirect(url_for('index'))

    topic = profile_db.decrypt_topic(topic_id, user_key, get_notes=False)

    return render_template('settingstopic.html', topic=topic, PermissionConst=PermissionConst)


@app.route('/settings/topic/update', methods=['POST'])
@login_required
def update_topic():
    resp = {'resp': 'error-msg'}

    if not ('topic_id' in request.form and 'modified_name' in request.form):
        return jsonify(resp)

    modified_name = escape(request.form['modified_name'].strip())
    topic_id = escape(request.form['topic_id'].strip())
    modified_name_len = len(modified_name)
    user_id = session['user_id']
    user_key = get_user_key()

    if (
        (modified_name_len < ProfileConst.MIN_TOPIC_LENGTH.value) or
        (modified_name_len > ProfileConst.MAX_TOPIC_LENGTH.value) or
        not (profile_db.topic_exists(user_id, topic_id))
    ):
        return jsonify(resp)

    profile_db.modify_topic(topic_id, user_key, modified_name)

    resp['resp'] = 'success-msg'
    return jsonify(resp)


@app.route('/settings/topic/delete', methods=['POST'])
@login_required
def delete_topic():
    resp = {'resp': 'error-msg'}

    if not 'topic_id' in request.form:
        return jsonify(resp)

    user_id = session['user_id']
    topic_id = escape(request.form['topic_id'].strip())

    if not profile_db.topic_exists(user_id, topic_id):
        return jsonify(resp)

    profile_db.delete_topic(topic_id)

    resp['resp'] = 'success-msg'
    return jsonify(resp)

# note
@app.route('/createnote', methods=['POST'])
@login_required
def createnote():
    resp = {'note_id': '', 'date_created': '', 'resp': 'error-msg'}

    if not ('topic_id' in request.form and 'note_title' in request.form and 'time_stamp' in request.form):
        return jsonify(resp)

    if profile_db.get_total_notes(session['user_id']) >= ProfileConst.MAX_NOTES.value:
        return jsonify(resp)

    note_title = escape(request.form['note_title'].strip())
    topic_id = escape(request.form['topic_id'].strip())
    timestamp = escape(request.form['time_stamp'])
    note_len = len(note_title)

    if (
        (note_len < ProfileConst.MIN_NOTE_LENGTH.value) or
        (note_len > ProfileConst.MAX_NOTE_LENGTH.value)
    ):
        return jsonify(resp)

    if not timestamp.isdigit():
        return jsonify(resp)

    current_time = int(timestamp)/1000

    try:
        datetime.fromtimestamp(current_time)
    except:
        return jsonify(resp)

    resp['resp'] = 'success-msg'
    resp['note_id'], resp['date_created'] = create_note(
        topic_id, note_title, current_time)

    return jsonify(resp)


@app.route('/getnotes', methods=['POST'])
@login_required
def getnotes():
    resp = {'notes': []}

    if not 'topic_id' in request.form:
        return jsonify(resp)

    topic_id = escape(request.form['topic_id'].strip())

    if not len(topic_id):
        return jsonify(resp)

    resp['notes'] = get_notes(topic_id)
    return jsonify(resp)


@app.route('/note', methods=['GET'])
@login_required
def get_note():
    if not ('topic_id' in request.args and 'note_id' in request.args):
        return redirect(url_for('index'))

    user_id = session['user_id']
    topic_id = escape(request.args.get('topic_id'))
    note_id = escape(request.args.get('note_id'))

    if not (profile_db.topic_exists(user_id, topic_id) and profile_db.note_exists(topic_id, note_id)):
        return redirect(url_for('index'))

    user_key = get_user_key()
    topic = profile_db.decrypt_topic(topic_id, user_key, False)
    topic_info = {'topic_id': topic_id, 'topic_name':  topic['topic_name']}

    note = dict(topic_info, **profile_db.decrypt_note(note_id, user_key))

    return render_template('note.html', note=note, PermissionConst=PermissionConst)


@app.route('/save', methods=['POST'])
@login_required
def save_note():
    resp = {'resp': 'success-msg'}

    if not ('topic_id' in request.form and 'note_id' in request.form and 'content' in request.form):
        return jsonify(resp)

    user_id = session['user_id']
    user_key = get_user_key()
    note_id = escape(request.form['note_id'].strip())
    topic_id = escape(request.form['topic_id'].strip())
    note_content = escape(request.form['content'].strip())

    if not (profile_db.topic_exists(user_id, topic_id) and profile_db.note_exists(topic_id, note_id)):
        return jsonify(resp)

    profile_db.modify_note_content(topic_id, note_id, note_content, user_key)
    return jsonify(resp)


@app.route('/modify', methods=['POST'])
@login_required
def modify_note():
    resp = {'resp': 'error-msg'}

    if not ('topic_id' in request.form and 'note_id' in request.form and 'modified_title' in request.form):
        return jsonify(resp)

    note_title = escape(request.form['modified_title'])
    modified_title_len = len(note_title)
    topic_id = escape(request.form['topic_id'])
    note_id = escape(request.form['note_id'])
    user_id = session['user_id']
    user_key = get_user_key()

    if (
        (modified_title_len < ProfileConst.MIN_NOTE_LENGTH.value) or
        (modified_title_len > ProfileConst.MAX_NOTE_LENGTH.value) or
        not profile_db.topic_exists(user_id, topic_id) or
        not profile_db.note_exists(topic_id, note_id)
    ):
        return jsonify(resp)

    profile_db.modify_note_title(topic_id, note_id, note_title, user_key)
    resp['resp'] = 'success-msg'

    return jsonify(resp)


@app.route('/delete', methods=['POST'])
@login_required
def delete_note():
    resp = {'resp': 'error-msg'}

    if not ('topic_id' in request.form and 'note_id' in request.form):
        return jsonify(resp)

    user_id = session['user_id']
    note_id = escape(request.form['note_id'])
    topic_id = escape(request.form['topic_id'])

    if not (profile_db.topic_exists(user_id, topic_id) and profile_db.note_exists(topic_id, note_id)):
        return jsonify(resp)

    profile_db.delete_note(topic_id, note_id)

    resp['resp'] = 'success-msg'
    return jsonify(resp)


@app.route('/session_check', methods=['POST'])
@login_required
def session_check():
    return jsonify({'resp': 0})

# admin
@app.route('/admin')
@login_required
@permission_required
def admin():

    users = []
    stats = {'total_users': 0, 'total_topics': 0, 'total_notes': 0}

    for row in account_db.get_users():
        user_id = row[0]
        ip_address = account_db.get_ip_address(user_id)
        permission = account_db.get_access_level(user_id)
        last_online = account_db.get_last_online(user_id)
        date_created = account_db.get_date_created(user_id)
        username = account_db.get_user_name(user_id).title()
        permission = ('Admin' if permission == PermissionConst.ROOT.value
                      else 'View Only' if permission == PermissionConst.VIEW.value else 'User')

        total_notes = profile_db.get_total_notes(user_id)
        total_topics = profile_db.get_total_topics(user_id)

        stats['total_users'] += 1
        stats['total_notes'] += total_notes
        stats['total_topics'] += total_topics

        users.append({
            'user_id': user_id,
            'username': username,
            'ip_address': ip_address,
            'access_level': permission,
            'last_online': last_online,
            'total_notes': total_notes,
            'date_created': date_created,
            'total_topics': total_topics,
        })

    stats['total_users'] = '{:02,}'.format(stats['total_users'])
    stats['total_notes'] = '{:02,}'.format(stats['total_notes'])
    stats['total_topics'] = '{:02,}'.format(stats['total_topics'])

    return render_template('admin.html', users=users, stats=stats)


@app.route('/edit_user')
@login_required
@admin_required
def edit_user():
    if not 'id' in request.args:
        return redirect(url_for('admin'))

    user_id = escape(request.args.get('id'))

    if not account_db.user_id_exists(user_id):
        return redirect(url_for('admin'))

    user = {}

    user['user_id'] = user_id
    permission = account_db.get_access_level(user_id)
    user['ip_address'] = account_db.get_ip_address(user_id)
    user['last_online'] = account_db.get_last_online(user_id)
    user['date_created'] = account_db.get_date_created(user_id)
    user['username'] = account_db.get_user_name(user_id).title()
    user['total_notes'] = '{:02,}'.format(profile_db.get_total_notes(user_id))
    user['total_topics'] = '{:02,}'.format(
        profile_db.get_total_topics(user_id))
    user['access_level'] = ('Admin' if permission == PermissionConst.ROOT.value
                            else 'View Only' if permission == PermissionConst.VIEW.value else 'User')

    return render_template('adminedit.html', user=user, PermissionConst=PermissionConst)


@app.route('/update_access', methods=['POST'])
@login_required
@admin_required
def update_access():
    resp = {'resp': 'error-msg'}

    if not ('user_id' in request.form and 'access_id' in request.form):
        return jsonify(resp)

    user_id = escape(request.form['user_id'])
    access_id = escape(request.form['access_id'])

    if not account_db.user_id_exists(user_id):
        return jsonify(resp)

    if not access_id.isdigit():
        return jsonify(resp)

    access_id = int(access_id)

    if (access_id != PermissionConst.ROOT.value and
        access_id != PermissionConst.VIEW.value and
            access_id != PermissionConst.NONE.value):
        return jsonify(resp)

    if access_id == account_db.get_access_level(user_id):
        return jsonify(resp)

    if user_id == session['user_id']:
        if account_db.get_admin() == 1:
            # sorry, I can't allow you to do that
            return jsonify(resp)

    resp['resp'] = 'success-msg'
    account_db.update_permission(user_id, access_id)

    account_db.logout(user_id)
    return jsonify(resp)


@app.route('/logout_user', methods=['POST'])
@login_required
@admin_required
def logout_user():
    resp = {'resp': 'error'}

    if not 'user_id' in request.form:
        return jsonify(resp)

    user_id = escape(request.form['user_id'])

    if not account_db.user_id_exists(user_id):
        return jsonify(resp)

    resp['resp'] = 'success'
    account_db.logout(user_id)
    return jsonify(resp)


@app.route('/delete_user', methods=['POST'])
@login_required
@admin_required
def delete_user():
    resp = {'resp': 'error'}

    if not 'user_id' in request.form:
        return jsonify(resp)

    user_id = escape(request.form['user_id'])

    if not account_db.user_id_exists(user_id):
        return jsonify(resp)

    if delete_usr(user_id):
        resp['resp'] = 'success'

    return jsonify(resp)


@app.route('/')
def index():
    if not 'logged_in' in session:
        session['logged_in'] = False
        return render_template('index.html')

    if not session['logged_in']:
        username = session.get('username')
        username = username if username else ''

        if username:
            session.pop('username')

        return render_template('index.html', username=username)

    last_active_timestamp = session['last_active']
    return render_template('home.html', PermissionConst=PermissionConst, lastActiveTimestamp=last_active_timestamp)


@app.route('/signup', methods=['GET', 'POST'])
def signup():
    if 'logged_in' in session:
        if session['logged_in']:
            return redirect(url_for('index'))

    if request.method == 'GET':
        return render_template('register.html', min_password_length=CredentialConst.MIN_PASSWORD_LENGTH.value,
                               max_password_length=CredentialConst.MAX_PASSWORD_LENGTH.value)

    form = request.form

    if not ('username' in form and 'password' in form and 'confirm' in form):
        flash('Incomplete form', 'error')
        return render_template('register.html', min_password_length=CredentialConst.MIN_PASSWORD_LENGTH.value,
                               max_password_length=CredentialConst.MAX_USERNAME_LENGTH.value)

    username, password, confirm = escape(form['username'].strip()), escape(
        form['password']), escape(form['confirm'])
    creds = {'username': username, 'password': password,
             'confirm': confirm if confirm == password else '', 'success': 0}

    if not (username and password and confirm):
        flash('Incomplete form', category='error')
        return render_template('register.html', data=creds, min_password_length=CredentialConst.MIN_PASSWORD_LENGTH.value,
                               max_password_length=CredentialConst.MAX_USERNAME_LENGTH.value)

    username_error = invalid_username(username)

    if username_error:
        flash(username_error, 'error')
        return render_template('register.html', data=creds,
                               min_password_length=CredentialConst.MIN_PASSWORD_LENGTH.value,
                               max_password_length=CredentialConst.MAX_USERNAME_LENGTH.value)

    if account_db.account_exists(username.lower()):
        flash('{} already exists'.format(username).format(username), 'error')
        return render_template('register.html', data=creds, min_password_length=CredentialConst.MIN_PASSWORD_LENGTH.value,
                               max_password_length=CredentialConst.MAX_USERNAME_LENGTH.value)

    password_error = invalid_password(username, password, confirm)

    if password_error:
        flash(password_error, 'error')
        return render_template('register.html', data=creds, min_password_length=CredentialConst.MIN_PASSWORD_LENGTH.value,
                               max_password_length=CredentialConst.MAX_USERNAME_LENGTH.value)

    creds['success'] = 1
    session['logged_in'] = False
    account_db.register(username, password.strip())

    return render_template('register.html', data=creds, min_password_length=CredentialConst.MIN_PASSWORD_LENGTH.value,
                           max_password_length=CredentialConst.MAX_USERNAME_LENGTH.value)


@app.route('/login', methods=['GET', 'POST'])
def login():

    if not 'logged_in' in session:
        return redirect(url_for('index'))

    if session['logged_in']:
        return redirect(url_for('index'))

    if not ('username' in request.form and 'password' in request.form and 'timestamp' in request.form):
        return jsonify({'is_authenticated': False, 'msg': 'Provide all requirements'})

    username = escape(request.form['username'].strip())
    password = escape(request.form['password'])
    timestamp = escape(request.form['timestamp'])

    if not timestamp.isdigit():
        return jsonify({'is_authenticated': False, 'msg': 'Invalid timestamp'})

    current_time = int(timestamp)/1000

    try:
        datetime.fromtimestamp(current_time)
    except:
        return jsonify({'is_authenticated': False, 'msg': 'Invalid timestamp'})

    if ((len(password) > CredentialConst.MAX_PASSWORD_LENGTH.value) or
        (len(username) > CredentialConst.MAX_USERNAME_LENGTH.value) or
                (len(username) < CredentialConst.MIN_USERNAME_LENGTH.value)
        ):
        return jsonify({'is_authenticated': False, 'msg': 'Account does not exist'})

    session['username'] = username
    ip_addr = request.headers.get('X-Forwarded-For')
    account_data, err_msg = account_db.authenticate(
        username, password, ip_addr, current_time)

    if not account_data:
        return jsonify({'is_authenticated': False, 'msg': err_msg})

    user_id, master_key, token, last_active, access_level = account_data

    session['token'] = token
    session.permanent = True
    session['logged_in'] = True
    session['user_id'] = user_id
    session['last_checked'] = time()
    session['master_key'] = master_key
    session['last_active'] = last_active
    session['username'] = username.title()
    session['access_level'] = access_level

    return jsonify({'is_authenticated': True, 'msg': ''})


@app.route('/delete_account', methods=['POST'])
@login_required
def delete_account():
    user_id = session['user_id']

    if not delete_usr(user_id):
        return jsonify({'resp': ''})

    session.clear()
    return jsonify({'resp': ''})


@app.route('/logout')
@login_required
def logout():
    session.clear()
    return redirect(url_for('index'))


if __name__ == '__main__':
    app.run()