# Source code of the Ubuntu-it website
# Copyright (C) 2018 Pietro Albini <pietroalbini@ubuntu.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; witout even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import functools
import uuid

import flask
import flask_openid
import pkg_resources
import time
import yaml

from uitwww.third_party import openid_teams


SESSION_EXPIRES_AFTER = 86400


class SessionError(BaseException):
    pass


class Sessions:

    def __init__(self, db):
        self.db = db

    def create(self, nickname, teams):
        """Create a new session and return its ID"""
        id = str(uuid.uuid4())
        ip = flask.request.remote_addr

        self.db.update(
            "INSERT INTO auth_sessions (id, nickname, teams, ip, expires_at) "
            "VALUES (?, ?, ?, ?, ?)", id, nickname, ",".join(teams), ip,
            int(time.time()) + SESSION_EXPIRES_AFTER,
        )
        return id

    def check(self, id):
        """Check if a session is valid and return its data"""
        data = self.db.query(
            "SELECT nickname, teams, ip, expires_at FROM auth_sessions "
            "WHERE id = ?", id,
        )
        if not data:
            raise SessionError("La tua sessione è scaduta, accedi di nuovo.")

        nickname = data[0][0]
        teams = data[0][1].split(",")
        ip = data[0][2]
        expires_at = data[0][3]

        if expires_at is None or expires_at < time.time():
            self.delete(id)
            raise SessionError("La tua sessione è scaduta, accedi di nuovo.")
        else:
            # Bump the session every time a new page is visited
            self.db.update(
                "UPDATE auth_sessions SET expires_at = ? WHERE id = ?",
                int(time.time()) + SESSION_EXPIRES_AFTER, id,
            )

        if ip != flask.request.remote_addr:
            raise SessionError("Questa sessione è valida solo su un'altra rete, accedi di nuovo.")

        return {
            "nickname": data[0][0],
            "teams": data[0][1].split(","),
        }

    def delete(self, id):
        """Delete a session"""
        self.db.update("DELETE FROM auth_sessions WHERE id = ?;", id)

    def delete_all(self, except_id):
        """Delete every session except this one"""
        self.db.update("DELETE FROM auth_sessions WHERE id != ?;", except_id)

    def all(self):
        """Return all the sessions"""
        rows = self.db.query("SELECT id, nickname, teams, ip, expires_at FROM auth_sessions;")

        now = time.time()
        result = []
        for row in rows:
            # Delete expired sessions
            if row[0] is None or row[4] < now:
                self.delete(row[0])
                continue

            result.append({
                "id": row[0],
                "nickname": row[1],
                "teams": row[2].split(","),
                "ip": row[3],
                "expires_at": row[4],
            })
        return result

    def get(self, id):
        """Return details about a session"""
        row = self.db.query("SELECT nickname, teams, ip, expires_at FROM auth_sessions WHERE id = ?;", id)
        if row:
            # Delete expired sessions
            if row[0][3] is None or row[0][3] < time.time():
                self.delete(id)
                return

            return {
                "id": id,
                "nickname": row[0][0],
                "teams": row[0][1].split(","),
                "ip": row[0][2],
                "expires_at": row[0][3],
            }

    def count(self):
        """Return the number of active sessions"""
        now = time.time()
        return self.db.query(
            "SELECT COUNT(*) FROM auth_sessions "
            "WHERE expires_at IS NOT NULL AND expires_at >= ?;", now,
        )[0][0]


class Permissions:

    def __init__(self):
        raw = pkg_resources.resource_string("uitwww", "data/permissions.yml")
        self.config = yaml.load(raw.decode("utf-8"))

    def allowed_teams(self):
        """Return a list of teams allowed to log in"""
        return list(self.config["teams"].keys())

    def check(self, name):
        """Check if the current user has the permission"""
        # The user must be authenticated
        if "auth_teams" not in flask.g:
            return False

        # The permission must exist
        if name not in self.config["permissions"]:
            raise RuntimeError("Missing permission: %s" % name)

        # Check if one of the user's teams has the permission
        for team in flask.g.auth_teams:
            if self.config["teams"][team] == "*":
                return True
            elif name in self.config["teams"][team]:
                return True

        return False


def permission(perms):
    """Process the endpoint only if the user has permission"""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            # Don't check anything if the user is not logged in
            if "auth_id" not in flask.g:
                return flask.abort(403)

            # Generate the list of allowed permissions
            names = []
            if type(perms) == dict:
                for name, condition in perms.items():
                    if condition(*args, **kwargs):
                        names.append(name)
            else:
                names.append(perms)

            # Check each allowed permission
            for name in names:
                if flask.current_app.permissions.check(name):
                    return func(*args, **kwargs)
            return flask.abort(403)

        return wrapper
    return decorator


def prepare_blueprint(app):
    """Prepare the auth blueprint"""
    bp = flask.Blueprint("auth", __name__)

    oid = flask_openid.OpenID(
        app,
        safe_roots=[],
        extension_responses=[openid_teams.TeamsResponse],
    )
    sessions = Sessions(app.db)
    app.permissions = Permissions()

    @app.context_processor
    def add_permission_function():
        return {
            "permission": app.permissions.check,
        }

    @app.before_request
    def check_auth():
        if "auth" in flask.session:
            try:
                data = sessions.check(flask.session["auth"])
            except SessionError as e:
                del flask.session["auth"]
                flask.flash(str(e), "error")
                return

            flask.g.auth_id = flask.session["auth"]
            flask.g.auth_name = data["nickname"]
            flask.g.auth_teams = data["teams"]

            flask.g.auth_sessions_count = sessions.count()

    @oid.after_login
    def receive_openid(resp):
        teams = resp.extensions["lp"].is_member
        if not teams or teams == [""]:
            flask.flash("Non hai i permessi per accedere al sito.", "error")
            return flask.redirect(flask.url_for("pages.index"))

        flask.session["auth"] = sessions.create(resp.nickname, teams)
        flask.flash("Benvenuto %s!" % resp.nickname, "success")
        return flask.redirect(flask.url_for("pages.index"))

    @bp.route("/login")
    @oid.loginhandler
    def login():
        if "auth_name" not in flask.g:
            return oid.try_login(
                "https://login.ubuntu.com/+openid",
                ask_for=["nickname"],
                extensions=[
                    openid_teams.TeamsRequest(app.permissions.allowed_teams())
                ],
            )
        else:
            flask.flash("Hai già effettuato l'accesso!", "info")
            return flask.redirect(flask.url_for("pages.index"))

    @bp.route("/logout")
    @permission("auth.logout")
    def logout():
        sessions.delete(flask.session["auth"])
        del flask.session["auth"]

        flask.flash("La sessione è stata terminata correttamente.", "success")
        return flask.redirect(flask.url_for("pages.index"))


    @bp.route("/sessions")
    @permission("auth.sessions.manage")
    def sessions_list():
        return flask.render_template(
            "auth/sessions.html",
            sessions=sessions.all(),
        )

    @bp.route("/sessions/+all/revoke")
    @permission("auth.sessions.manage")
    def sessions_revoke_all():
        sessions.delete_all(flask.g.auth_id)
        return flask.redirect(flask.url_for(".sessions_list"))

    @bp.route("/sessions/<id>")
    @permission({
        "auth.sessions.manage": lambda id: True,
        "auth.sessions.own": lambda id: flask.g.auth_id == id,
    })
    def sessions_show(id):
        data = sessions.get(id)
        if data is None:
            return flask.abort(404)

        all = sessions.all()
        others = []
        for session in all:
            if session["id"] == id or session["nickname"] != data["nickname"]:
                continue
            others.append(session)

        return flask.render_template(
            "auth/session.html",
            session=data,
            others=others,
        )

    @bp.route("/sessions/<id>/revoke")
    @permission({
        "auth.sessions.manage": lambda id: True,
        "auth.sessions.own": lambda id: flask.g.auth_id == id,
    })
    def sessions_revoke(id):
        sessions.delete(id)
        return flask.redirect(flask.url_for(".sessions_list"))

    return bp
