import json
from datetime import datetime
from email.policy import default
from time import perf_counter

import flask
from flask_sqlalchemy import SQLAlchemy
from flask_bcrypt import Bcrypt
from flask_httpauth import HTTPBasicAuth
from markupsafe import escape, Markup
from flask_migrate import Migrate, current
from jinja2_fragments.flask import render_block
from sqlalchemy.orm import backref
import sqlalchemy.dialects.postgresql
from os import path
from urllib.parse import urlencode
import mimetypes
import ruamel.yaml as yaml

from PIL import Image

import config
import markdown


app = flask.Flask(__name__)
bcrypt = Bcrypt(app)


app.config["SQLALCHEMY_DATABASE_URI"] = config.DB_URI
app.config["SECRET_KEY"] = config.DB_PASSWORD


db = SQLAlchemy(app)
migrate = Migrate(app, db)


@app.template_filter("split")
def split(value, separator=None, maxsplit=-1):
    return value.split(separator, maxsplit)


@app.template_filter("median")
def median(value):
    value = list(value)   # prevent generators
    return sorted(value)[len(value) // 2]


@app.template_filter("set")
def set_filter(value):
    return set(value)


@app.template_global()
def modify_query(**new_values):
    args = flask.request.args.copy()
    for key, value in new_values.items():
        args[key] = value

    return f"{flask.request.path}?{urlencode(args)}"


@app.context_processor
def default_variables():
    return {
        "current_user": db.session.get(User, flask.session.get("username")),
    }


with app.app_context():
    class User(db.Model):
        username = db.Column(db.String(32), unique=True, nullable=False, primary_key=True)
        password_hashed = db.Column(db.String(60), nullable=False)
        admin = db.Column(db.Boolean, nullable=False, default=False, server_default="false")
        pictures = db.relationship("PictureResource", back_populates="author")

        def __init__(self, username, password):
            self.username = username
            self.password_hashed = bcrypt.generate_password_hash(password).decode("utf-8")


    class Licence(db.Model):
        id = db.Column(db.String(64), primary_key=True)               # SPDX identifier
        title = db.Column(db.UnicodeText, nullable=False)             # the official name of the licence
        description = db.Column(db.UnicodeText, nullable=False)       # brief description of its permissions and restrictions
        info_url = db.Column(db.String(1024), nullable=False)          # the URL to a page with general information about the licence
        url = db.Column(db.String(1024), nullable=True)               # the URL to a page with the full text of the licence and more information
        pictures = db.relationship("PictureLicence", back_populates="licence")
        free = db.Column(db.Boolean, nullable=False, default=False)   # whether the licence is free or not
        logo_url = db.Column(db.String(1024), nullable=True)          # URL to the logo of the licence
        pinned = db.Column(db.Boolean, nullable=False, default=False) # whether the licence should be shown at the top of the list

        def __init__(self, id, title, description, info_url, url, free, logo_url=None, pinned=False):
            self.id = id
            self.title = title
            self.description = description
            self.info_url = info_url
            self.url = url
            self.free = free
            self.logo_url = logo_url
            self.pinned = pinned


    class PictureLicence(db.Model):
        id = db.Column(db.Integer, primary_key=True, autoincrement=True)

        resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"))
        licence_id = db.Column(db.String(64), db.ForeignKey("licence.id"))

        resource = db.relationship("PictureResource", back_populates="licences")
        licence = db.relationship("Licence", back_populates="pictures")

        def __init__(self, resource_id, licence_id):
            self.resource_id = resource_id
            self.licence_id = licence_id


    class Resource(db.Model):
        __abstract__ = True

        id = db.Column(db.Integer, primary_key=True, autoincrement=True)
        title = db.Column(db.UnicodeText, nullable=False)
        description = db.Column(db.UnicodeText, nullable=False)
        timestamp = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
        origin_url = db.Column(db.String(2048), nullable=True)        # should be left empty if it's original or the source is unknown but public domain


    class PictureNature(db.Model):
        # Examples:
        # "photo", "paper-scan", "2d-art-photo", "sculpture-photo", "computer-3d", "computer-painting",
        # "computer-line-art", "diagram", "infographic", "text", "map", "chart-graph", "screen-capture",
        # "screen-photo", "pattern", "collage", "ai", and so on
        id = db.Column(db.String(64), primary_key=True)
        description = db.Column(db.UnicodeText, nullable=False)
        resources = db.relationship("PictureResource", back_populates="nature")

        def __init__(self, id, description):
            self.id = id
            self.description = description


    class PictureObjectInheritance(db.Model):
        parent_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"),
                              primary_key=True)
        child_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"),
                             primary_key=True)

        parent = db.relationship("PictureObject", foreign_keys=[parent_id],
                                 back_populates="child_links")
        child = db.relationship("PictureObject", foreign_keys=[child_id],
                                back_populates="parent_links")

        def __init__(self, parent, child):
            self.parent = parent
            self.child = child


    class PictureObject(db.Model):
        id = db.Column(db.String(64), primary_key=True)
        description = db.Column(db.UnicodeText, nullable=False)

        child_links = db.relationship("PictureObjectInheritance",
                                      foreign_keys=[PictureObjectInheritance.parent_id],
                                      back_populates="parent")
        parent_links = db.relationship("PictureObjectInheritance",
                                       foreign_keys=[PictureObjectInheritance.child_id],
                                       back_populates="child")

        def __init__(self, id, description):
            self.id = id
            self.description = description


    class PictureRegion(db.Model):
        # This is for picture region annotations
        id = db.Column(db.Integer, primary_key=True, autoincrement=True)
        json = db.Column(sqlalchemy.dialects.postgresql.JSONB, nullable=False)

        resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=False)
        object_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), nullable=True)

        resource = db.relationship("PictureResource", backref="regions")
        object = db.relationship("PictureObject", backref="regions")

        def __init__(self, json, resource, object):
            self.json = json
            self.resource = resource
            self.object = object


    class PictureResource(Resource):
        # This is only for bitmap pictures. Vectors will be stored under a different model
        # File name is the ID in the picture directory under data, without an extension
        file_format = db.Column(db.String(64), nullable=False)        # MIME type
        width = db.Column(db.Integer, nullable=False)
        height = db.Column(db.Integer, nullable=False)
        nature_id = db.Column(db.String(32), db.ForeignKey("picture_nature.id"), nullable=True)
        author_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False)
        author = db.relationship("User", back_populates="pictures")

        nature = db.relationship("PictureNature", back_populates="resources")

        replaces_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=True)
        replaced_by_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"),
                                   nullable=True)

        replaces = db.relationship("PictureResource", remote_side="PictureResource.id",
                                   foreign_keys=[replaces_id], back_populates="replaced_by")
        replaced_by = db.relationship("PictureResource", remote_side="PictureResource.id",
                                      foreign_keys=[replaced_by_id])

        licences = db.relationship("PictureLicence", back_populates="resource")

        def __init__(self, title, author, description, origin_url, licence_ids, mime, nature=None,
                     replaces=None):
            self.title = title
            self.author = author
            self.description = description
            self.origin_url = origin_url
            self.file_format = mime
            self.width = self.height = 0
            self.nature = nature
            db.session.add(self)
            db.session.commit()
            for licence_id in licence_ids:
                joiner = PictureLicence(self.id, licence_id)
                db.session.add(joiner)
            if replaces is not None:
                self.replaces = replaces
                replaces.replaced_by = self

        def put_annotations(self, json):
            # Delete all previous annotations
            db.session.query(PictureRegion).filter_by(resource_id=self.id).delete()

            for region in json:
                object_id = region["object"]
                picture_object = db.session.get(PictureObject, object_id)

                region_data = {
                    "type": region["type"],
                    "shape": region["shape"],
                }

                region_row = PictureRegion(region_data, self, picture_object)
                db.session.add(region_row)


@app.route("/")
def index():
    return flask.render_template("home.html")


@app.route("/accounts/")
def accounts():
    return flask.render_template("login.html")


@app.route("/login", methods=["POST"])
def login():
    username = flask.request.form["username"]
    password = flask.request.form["password"]

    user = db.session.get(User, username)

    if user is None:
        flask.flash("This username is not registered.")
        return flask.redirect("/accounts")

    if not bcrypt.check_password_hash(user.password_hashed, password):
        flask.flash("Incorrect password.")
        return flask.redirect("/accounts")

    flask.flash("You have been logged in.")

    flask.session["username"] = username
    return flask.redirect("/")


@app.route("/logout")
def logout():
    flask.session.pop("username", None)
    flask.flash("You have been logged out.")
    return flask.redirect("/")


@app.route("/signup", methods=["POST"])
def signup():
    username = flask.request.form["username"]
    password = flask.request.form["password"]

    if db.session.get(User, username) is not None:
        flask.flash("This username is already taken.")
        return flask.redirect("/accounts")

    if set(username) > set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"):
        flask.flash("Usernames can only contain the Latin alphabet, digits, hyphens, and underscores.")
        return flask.redirect("/accounts")

    if len(username) < 3 or len(username) > 32:
        flask.flash("Usernames must be between 3 and 32 characters long.")
        return flask.redirect("/accounts")

    if len(password) < 6:
        flask.flash("Passwords must be at least 6 characters long.")
        return flask.redirect("/accounts")

    user = User(username, password)
    db.session.add(user)
    db.session.commit()

    flask.session["username"] = username

    flask.flash("You have been registered and logged in.")

    return flask.redirect("/")


@app.route("/profile", defaults={"username": None})
@app.route("/profile/<username>")
def profile(username):
    if username is None:
        if "username" in flask.session:
            return flask.redirect("/profile/" + flask.session["username"])
        else:
            flask.flash("Please log in to perform this action.")
            return flask.redirect("/accounts")

    user = db.session.get(User, username)
    if user is None:
        flask.abort(404)

    return flask.render_template("profile.html", user=user)


@app.route("/object/<id>")
def has_object(id):
    object_ = db.session.get(PictureObject, id)
    if object_ is None:
        flask.abort(404)

    query = db.session.query(PictureResource).join(PictureRegion).filter(PictureRegion.object_id == id)

    page = int(flask.request.args.get("page", 1))
    per_page = int(flask.request.args.get("per_page", 16))

    resources = query.paginate(page=page, per_page=per_page)

    return flask.render_template("object.html", object=object_, resources=resources, page_number=page,
                                 page_length=per_page, num_pages=resources.pages, prev_page=resources.prev_num,
                                 next_page=resources.next_num, PictureRegion=PictureRegion)


@app.route("/upload")
def upload():
    if "username" not in flask.session:
        flask.flash("Log in to upload pictures.")
        return flask.redirect("/accounts")

    licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), Licence.title).all()

    types = PictureNature.query.all()

    return flask.render_template("upload.html", licences=licences, types=types)


@app.route("/upload", methods=["POST"])
def upload_post():
    title = flask.request.form["title"]
    description = flask.request.form["description"]
    origin_url = flask.request.form["origin_url"]
    author = db.session.get(User, flask.session.get("username"))
    licence_ids = flask.request.form.getlist("licence")
    nature_id = flask.request.form["nature"]

    if author is None:
        flask.abort(401)

    file = flask.request.files["file"]

    if not file or not file.filename:
        flask.flash("Select a file")
        return flask.redirect(flask.request.url)

    if not file.mimetype.startswith("image/"):
        flask.flash("Only images are supported")
        return flask.redirect(flask.request.url)

    if not title:
        flask.flash("Enter a title")
        return flask.redirect(flask.request.url)

    if not description:
        description = ""

    if not nature_id:
        flask.flash("Select a picture type")
        return flask.redirect(flask.request.url)

    if not licence_ids:
        flask.flash("Select licences")
        return flask.redirect(flask.request.url)

    licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids]
    if not any(licence.free for licence in licences):
        flask.flash("Select at least one free licence")
        return flask.redirect(flask.request.url)

    resource = PictureResource(title, author, description, origin_url, licence_ids, file.mimetype,
                               db.session.get(PictureNature, nature_id))
    db.session.add(resource)
    db.session.commit()
    file.save(path.join(config.DATA_PATH, "pictures", str(resource.id)))
    pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id)))
    resource.width, resource.height = pil_image.size
    db.session.commit()

    if flask.request.form.get("annotations"):
        try:
            resource.put_annotations(json.loads(flask.request.form.get("annotations")))
            db.session.commit()
        except json.JSONDecodeError:
            flask.flash("Invalid annotations")

    flask.flash("Picture uploaded successfully")

    return flask.redirect("/picture/" + str(resource.id))


@app.route("/picture/<int:id>/")
def picture(id):
    resource = db.session.get(PictureResource, id)
    if resource is None:
        flask.abort(404)

    image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id)))

    return flask.render_template("picture.html", resource=resource,
                                 file_extension=mimetypes.guess_extension(resource.file_format),
                                 size=image.size)



@app.route("/picture/<int:id>/annotate")
def annotate_picture(id):
    resource = db.session.get(PictureResource, id)
    if resource is None:
        flask.abort(404)

    current_user = db.session.get(User, flask.session.get("username"))
    if current_user is None:
        flask.abort(401)
    if resource.author != current_user and not current_user.admin:
        flask.abort(403)

    return flask.render_template("picture-annotation.html", resource=resource,
                                 file_extension=mimetypes.guess_extension(resource.file_format))


@app.route("/picture/<int:id>/put-annotations-form")
def put_annotations_form(id):
    resource = db.session.get(PictureResource, id)
    if resource is None:
        flask.abort(404)

    current_user = db.session.get(User, flask.session.get("username"))
    if current_user is None:
        flask.abort(401)

    if resource.author != current_user and not current_user.admin:
        flask.abort(403)

    return flask.render_template("put-annotations-form.html", resource=resource)


@app.route("/picture/<int:id>/put-annotations-form", methods=["POST"])
def put_annotations_form_post(id):
    resource = db.session.get(PictureResource, id)
    if resource is None:
        flask.abort(404)

    current_user = db.session.get(User, flask.session.get("username"))
    if current_user is None:
        flask.abort(401)

    if resource.author != current_user and not current_user.admin:
        flask.abort(403)

    resource.put_annotations(json.loads(flask.request.form["annotations"]))

    db.session.commit()

    return flask.redirect("/picture/" + str(resource.id))



@app.route("/picture/<int:id>/save-annotations", methods=["POST"])
def save_annotations(id):
    resource = db.session.get(PictureResource, id)
    if resource is None:
        flask.abort(404)

    current_user = db.session.get(User, flask.session.get("username"))
    if resource.author != current_user and not current_user.admin:
        flask.abort(403)

    resource.put_annotations(flask.request.json)

    db.session.commit()

    response = flask.make_response()
    response.status_code = 204
    return response


@app.route("/picture/<int:id>/get-annotations")
def get_annotations(id):
    resource = db.session.get(PictureResource, id)
    if resource is None:
        flask.abort(404)

    regions = db.session.query(PictureRegion).filter_by(resource_id=id).all()

    regions_json = []

    for region in regions:
        regions_json.append({
            "object": region.object_id,
            "type": region.json["type"],
            "shape": region.json["shape"],
        })

    return flask.jsonify(regions_json)


@app.route("/query-pictures", methods=["POST"])      # sadly GET can't have a body
def query_pictures():
    offset = int(flask.request.args.get("offset", 0))
    limit = int(flask.request.args.get("limit", 16))
    ordering = flask.request.args.get("ordering", "date-desc")

    yaml_parser = yaml.YAML()
    query_data = yaml_parser.load(flask.request.data) or {}

    query = db.session.query(PictureResource)

    requirement_conditions = {
        "has_object": lambda value: PictureResource.regions.any(
            PictureRegion.object_id.in_(value)),
        "nature": lambda value: PictureResource.nature_id.in_(value),
        "licence": lambda value: PictureResource.licences.any(
            PictureLicence.licence_id.in_(value)),
        "author": lambda value: PictureResource.author_name.in_(value),
        "title": lambda value: PictureResource.title.ilike(value),
        "description": lambda value: PictureResource.description.ilike(value),
        "origin_url": lambda value: db.func.lower(db.func.substr(
                PictureResource.origin_url,
                db.func.length(db.func.split_part(PictureResource.origin_url, "://", 1)) + 4
        )).in_(value),
        "above_width": lambda value: PictureResource.width >= value,
        "below_width": lambda value: PictureResource.width <= value,
        "above_height": lambda value: PictureResource.height >= value,
        "below_height": lambda value: PictureResource.height <= value,
        "before_date": lambda value: PictureResource.timestamp <= datetime.utcfromtimestamp(
            value),
        "after_date": lambda value: PictureResource.timestamp >= datetime.utcfromtimestamp(
            value)
    }
    if "want" in query_data:
        for i in query_data["want"]:
            requirement, value = list(i.items())[0]
            condition = requirement_conditions.get(requirement)
            if condition:
                query = query.filter(condition(value))
    if "exclude" in query_data:
        for i in query_data["exclude"]:
            requirement, value = list(i.items())[0]
            condition = requirement_conditions.get(requirement)
            if condition:
                query = query.filter(~condition(value))
    if not query_data.get("include_obsolete", False):
        query = query.filter(PictureResource.replaced_by_id.is_(None))

    match ordering:
        case "date-desc":
            query = query.order_by(PictureResource.timestamp.desc())
        case "date-asc":
            query = query.order_by(PictureResource.timestamp.asc())
        case "title-asc":
            query = query.order_by(PictureResource.title.asc())
        case "title-desc":
            query = query.order_by(PictureResource.title.desc())
        case "random":
            query = query.order_by(db.func.random())
        case "number-regions-desc":
            query = query.order_by(db.func.count(PictureResource.regions).desc())
        case "number-regions-asc":
            query = query.order_by(db.func.count(PictureResource.regions).asc())

    query = query.offset(offset).limit(limit)
    resources = query.all()

    json_response = {
        "date_generated": datetime.utcnow().timestamp(),
        "resources": [],
        "offset": offset,
        "limit": limit,
    }

    json_resources = json_response["resources"]

    for resource in resources:
        json_resource = {
            "id": resource.id,
            "title": resource.title,
            "description": resource.description,
            "timestamp": resource.timestamp.timestamp(),
            "origin_url": resource.origin_url,
            "author": resource.author_name,
            "file_format": resource.file_format,
            "width": resource.width,
            "height": resource.height,
            "nature": resource.nature_id,
            "licences": [licence.licence_id for licence in resource.licences],
            "replaces": resource.replaces_id,
            "replaced_by": resource.replaced_by_id,
            "regions": [],
            "download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id),
        }
        for region in resource.regions:
            json_resource["regions"].append({
                "object": region.object_id,
                "type": region.json["type"],
                "shape": region.json["shape"],
            })

        json_resources.append(json_resource)

    response = flask.jsonify(json_response)
    response.headers["Content-Type"] = "application/json"
    return response


@app.route("/raw/picture/<int:id>")
def raw_picture(id):
    resource = db.session.get(PictureResource, id)
    if resource is None:
        flask.abort(404)

    response = flask.send_from_directory(path.join(config.DATA_PATH, "pictures"), str(resource.id))
    response.mimetype = resource.file_format

    return response


@app.route("/api/object-types")
def object_types():
    objects = db.session.query(PictureObject).all()
    return flask.jsonify({object.id: object.description for object in objects})
