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
import os
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, licence):
            self.resource = resource
            self.licence = licence


    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, db.session.get(Licence, 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", resources=PictureResource.query.order_by(db.func.random()).limit(10).all())


@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("/picture/<int:id>/delete")
def delete_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)

    PictureLicence.query.filter_by(resource=resource).delete()
    PictureRegion.query.filter_by(resource=resource).delete()
    db.session.delete(resource)
    db.session.commit()

    return flask.redirect("/")


@app.route("/picture/<int:id>/edit-metadata")
def edit_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)

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

    types = PictureNature.query.all()

    return flask.render_template("edit-picture.html", resource=resource, licences=licences, types=types,
                                 PictureLicence=PictureLicence)


@app.route("/picture/<int:id>/edit-metadata", methods=["POST"])
def edit_picture_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)

    title = flask.request.form["title"]
    description = flask.request.form["description"]
    origin_url = flask.request.form["origin_url"]
    licence_ids = flask.request.form.getlist("licence")
    nature_id = flask.request.form["nature"]

    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.title = title
    resource.description = description
    resource.origin_url = origin_url
    for licence_id in licence_ids:
        joiner = PictureLicence(resource, db.session.get(Licence, licence_id))
        db.session.add(joiner)
    resource.nature = db.session.get(PictureNature, nature_id)

    db.session.commit()

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


@app.route("/picture/<int:id>/copy")
def copy_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)

    new_resource = PictureResource(resource.title, current_user, resource.description, resource.origin_url,
                                   [licence.licence_id for licence in resource.licences], resource.file_format,
                                   resource.nature)

    for region in resource.regions:
        db.session.add(PictureRegion(region.json, new_resource, region.object))

    db.session.commit()

    # Create a hard link for the new picture
    old_path = path.join(config.DATA_PATH, "pictures", str(resource.id))
    new_path = path.join(config.DATA_PATH, "pictures", str(new_resource.id))
    os.link(old_path, new_path)

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


@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})
