import bleach
import datetime
import geojson
import json
from enum import Enum
from flask import current_app
from sqlalchemy.types import Float, Text
from sqlalchemy import desc, cast, func, distinct
from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound
from sqlalchemy.orm.session import make_transient
from geoalchemy2 import Geometry
from backend import db
from typing import List
from backend.models.dtos.mapping_dto import TaskDTO, TaskHistoryDTO
from backend.models.dtos.validator_dto import MappedTasksByUser, MappedTasks
from backend.models.dtos.project_dto import (
    ProjectComment,
    ProjectCommentsDTO,
    LockedTasksForUser,
)
from backend.models.dtos.mapping_issues_dto import TaskMappingIssueDTO
from backend.models.postgis.statuses import TaskStatus, MappingLevel
from backend.models.postgis.user import User
from backend.models.postgis.utils import (
    InvalidData,
    InvalidGeoJson,
    ST_GeomFromGeoJSON,
    ST_SetSRID,
    timestamp,
    parse_duration,
    NotFound,
)
from backend.models.postgis.task_annotation import TaskAnnotation


class TaskAction(Enum):
    """ Describes the possible actions that can happen to to a task, that we'll record history for """

    LOCKED_FOR_MAPPING = 1
    LOCKED_FOR_VALIDATION = 2
    STATE_CHANGE = 3
    COMMENT = 4
    AUTO_UNLOCKED_FOR_MAPPING = 5
    AUTO_UNLOCKED_FOR_VALIDATION = 6


class TaskInvalidationHistory(db.Model):
    """ Describes the most recent history of task invalidation and subsequent validation """

    __tablename__ = "task_invalidation_history"
    id = db.Column(db.Integer, primary_key=True)
    project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=False)
    task_id = db.Column(db.Integer, nullable=False)
    is_closed = db.Column(db.Boolean, default=False)
    mapper_id = db.Column(db.BigInteger, db.ForeignKey("users.id", name="fk_mappers"))
    mapped_date = db.Column(db.DateTime)
    invalidator_id = db.Column(
        db.BigInteger, db.ForeignKey("users.id", name="fk_invalidators")
    )
    invalidated_date = db.Column(db.DateTime)
    invalidation_history_id = db.Column(
        db.Integer, db.ForeignKey("task_history.id", name="fk_invalidation_history")
    )
    validator_id = db.Column(
        db.BigInteger, db.ForeignKey("users.id", name="fk_validators")
    )
    validated_date = db.Column(db.DateTime)
    updated_date = db.Column(db.DateTime, default=timestamp)

    __table_args__ = (
        db.ForeignKeyConstraint(
            [task_id, project_id], ["tasks.id", "tasks.project_id"], name="fk_tasks"
        ),
        db.Index("idx_task_validation_history_composite", "task_id", "project_id"),
        db.Index(
            "idx_task_validation_mapper_status_composite", "invalidator_id", "is_closed"
        ),
        db.Index(
            "idx_task_validation_mapper_status_composite", "mapper_id", "is_closed"
        ),
        {},
    )

    def __init__(self, project_id, task_id):
        self.project_id = project_id
        self.task_id = task_id
        self.is_closed = False

    def delete(self):
        """ Deletes the current model from the DB """
        db.session.delete(self)
        db.session.commit()

    @staticmethod
    def get_open_for_task(project_id, task_id):
        return TaskInvalidationHistory.query.filter_by(
            task_id=task_id, project_id=project_id, is_closed=False
        ).one_or_none()

    @staticmethod
    def close_all_for_task(project_id, task_id):
        TaskInvalidationHistory.query.filter_by(
            task_id=task_id, project_id=project_id, is_closed=False
        ).update({"is_closed": True})

    @staticmethod
    def record_invalidation(project_id, task_id, invalidator_id, history):
        # Invalidation always kicks off a new entry for a task, so close any existing ones.
        TaskInvalidationHistory.close_all_for_task(project_id, task_id)

        last_mapped = TaskHistory.get_last_mapped_action(project_id, task_id)
        if last_mapped is None:
            return

        entry = TaskInvalidationHistory(project_id, task_id)
        entry.invalidation_history_id = history.id
        entry.mapper_id = last_mapped.user_id
        entry.mapped_date = last_mapped.action_date
        entry.invalidator_id = invalidator_id
        entry.invalidated_date = history.action_date
        entry.updated_date = timestamp()
        db.session.add(entry)

    @staticmethod
    def record_validation(project_id, task_id, validator_id, history):
        entry = TaskInvalidationHistory.get_open_for_task(project_id, task_id)

        # If no open invalidation to update, then nothing to do
        if entry is None:
            return

        last_mapped = TaskHistory.get_last_mapped_action(project_id, task_id)
        entry.mapper_id = last_mapped.user_id
        entry.mapped_date = last_mapped.action_date
        entry.validator_id = validator_id
        entry.validated_date = history.action_date
        entry.is_closed = True
        entry.updated_date = timestamp()


class TaskMappingIssue(db.Model):
    """Describes an issue (along with an occurrence count) with a
    task mapping that contributed to invalidation of the task"""

    __tablename__ = "task_mapping_issues"
    id = db.Column(db.Integer, primary_key=True)
    task_history_id = db.Column(
        db.Integer, db.ForeignKey("task_history.id"), nullable=False, index=True
    )
    issue = db.Column(db.String, nullable=False)
    mapping_issue_category_id = db.Column(
        db.Integer,
        db.ForeignKey("mapping_issue_categories.id", name="fk_issue_category"),
        nullable=False,
    )
    count = db.Column(db.Integer, nullable=False)

    def __init__(self, issue, count, mapping_issue_category_id, task_history_id=None):
        self.task_history_id = task_history_id
        self.issue = issue
        self.count = count
        self.mapping_issue_category_id = mapping_issue_category_id

    def delete(self):
        """ Deletes the current model from the DB """
        db.session.delete(self)
        db.session.commit()

    def as_dto(self):
        issue_dto = TaskMappingIssueDTO()
        issue_dto.category_id = self.mapping_issue_category_id
        issue_dto.name = self.issue
        issue_dto.count = self.count
        return issue_dto

    def __repr__(self):
        return "{0}: {1}".format(self.issue, self.count)


class TaskHistory(db.Model):
    """ Describes the history associated with a task """

    __tablename__ = "task_history"

    id = db.Column(db.Integer, primary_key=True)
    project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), index=True)
    task_id = db.Column(db.Integer, nullable=False)
    action = db.Column(db.String, nullable=False)
    action_text = db.Column(db.String)
    action_date = db.Column(db.DateTime, nullable=False, default=timestamp)
    user_id = db.Column(
        db.BigInteger,
        db.ForeignKey("users.id", name="fk_users"),
        index=True,
        nullable=False,
    )
    invalidation_history = db.relationship(
        TaskInvalidationHistory, lazy="dynamic", cascade="all"
    )

    actioned_by = db.relationship(User)
    task_mapping_issues = db.relationship(TaskMappingIssue, cascade="all")

    __table_args__ = (
        db.ForeignKeyConstraint(
            [task_id, project_id], ["tasks.id", "tasks.project_id"], name="fk_tasks"
        ),
        db.Index("idx_task_history_composite", "task_id", "project_id"),
        db.Index("idx_task_history_project_id_user_id", "user_id", "project_id"),
        {},
    )

    def __init__(self, task_id, project_id, user_id):
        self.task_id = task_id
        self.project_id = project_id
        self.user_id = user_id

    def set_task_locked_action(self, task_action: TaskAction):
        if task_action not in [
            TaskAction.LOCKED_FOR_MAPPING,
            TaskAction.LOCKED_FOR_VALIDATION,
        ]:
            raise ValueError("Invalid Action")

        self.action = task_action.name

    def set_comment_action(self, comment):
        self.action = TaskAction.COMMENT.name
        clean_comment = bleach.clean(
            comment
        )  # Bleach input to ensure no nefarious script tags etc
        self.action_text = clean_comment

    def set_state_change_action(self, new_state):
        self.action = TaskAction.STATE_CHANGE.name
        self.action_text = new_state.name

    def set_auto_unlock_action(self, task_action: TaskAction):
        self.action = task_action.name

    def delete(self):
        """ Deletes the current model from the DB """
        db.session.delete(self)
        db.session.commit()

    @staticmethod
    def update_task_locked_with_duration(
        task_id: int, project_id: int, lock_action: TaskStatus, user_id: int
    ):
        """
        Calculates the duration a task was locked for and sets it on the history record
        :param task_id: Task in scope
        :param project_id: Project ID in scope
        :param lock_action: The lock action, either Mapping or Validation
        :param user_id: Logged in user updating the task
        :return:
        """
        try:
            last_locked = TaskHistory.query.filter_by(
                task_id=task_id,
                project_id=project_id,
                action=lock_action.name,
                action_text=None,
                user_id=user_id,
            ).one()
        except NoResultFound:
            # We suspect there's some kind or race condition that is occasionally deleting history records
            # prior to user unlocking task. Most likely stemming from auto-unlock feature. However, given that
            # we're trying to update a row that doesn't exist, it's better to return without doing anything
            # rather than showing the user an error that they can't fix
            return
        except MultipleResultsFound:
            # Again race conditions may mean we have multiple rows within the Task History.  Here we attempt to
            # remove the oldest duplicate rows, and update the newest on the basis that this was the last action
            # the user was attempting to make.
            TaskHistory.remove_duplicate_task_history_rows(
                task_id, project_id, lock_action, user_id
            )

            # Now duplicate is removed, we recursively call ourself to update the duration on the remaining row
            TaskHistory.update_task_locked_with_duration(
                task_id, project_id, lock_action, user_id
            )
            return

        duration_task_locked = datetime.datetime.utcnow() - last_locked.action_date
        # Cast duration to isoformat for later transmission via api
        last_locked.action_text = (
            (datetime.datetime.min + duration_task_locked).time().isoformat()
        )
        db.session.commit()

    @staticmethod
    def remove_duplicate_task_history_rows(
        task_id: int, project_id: int, lock_action: TaskStatus, user_id: int
    ):
        """Method used in rare cases where we have duplicate task history records for a given action by a user
        This method will remove the oldest duplicate record, on the basis that the newest record was the
        last action the user was attempting to perform
        """
        dupe = (
            TaskHistory.query.filter(
                TaskHistory.project_id == project_id,
                TaskHistory.task_id == task_id,
                TaskHistory.action == lock_action.name,
                TaskHistory.user_id == user_id,
            )
            .order_by(TaskHistory.id.asc())
            .first()
        )

        dupe.delete()

    @staticmethod
    def update_expired_and_locked_actions(
        project_id: int, task_id: int, expiry_date: datetime, action_text: str
    ):
        """
        Sets auto unlock state to all not finished actions, that are older then the expiry date.
        Action is considered as a not finished, when it is in locked state and doesn't have action text
        :param project_id: Project ID in scope
        :param task_id: Task in scope
        :param expiry_date: Action created before this date is treated as expired
        :param action_text: Text which will be set for all changed actions
        :return:
        """
        all_expired = TaskHistory.query.filter(
            TaskHistory.task_id == task_id,
            TaskHistory.project_id == project_id,
            TaskHistory.action_text.is_(None),
            TaskHistory.action.in_(
                [
                    TaskAction.LOCKED_FOR_VALIDATION.name,
                    TaskAction.LOCKED_FOR_MAPPING.name,
                ]
            ),
            TaskHistory.action_date <= expiry_date,
        ).all()

        for task_history in all_expired:
            unlock_action = (
                TaskAction.AUTO_UNLOCKED_FOR_MAPPING
                if task_history.action == "LOCKED_FOR_MAPPING"
                else TaskAction.AUTO_UNLOCKED_FOR_VALIDATION
            )

            task_history.set_auto_unlock_action(unlock_action)
            task_history.action_text = action_text

        db.session.commit()

    @staticmethod
    def get_all_comments(project_id: int) -> ProjectCommentsDTO:
        """ Gets all comments for the supplied project_id"""

        comments = (
            db.session.query(
                TaskHistory.task_id,
                TaskHistory.action_date,
                TaskHistory.action_text,
                User.username,
            )
            .join(User)
            .filter(
                TaskHistory.project_id == project_id,
                TaskHistory.action == TaskAction.COMMENT.name,
            )
            .all()
        )

        comments_dto = ProjectCommentsDTO()
        for comment in comments:
            dto = ProjectComment()
            dto.comment = comment.action_text
            dto.comment_date = comment.action_date
            dto.user_name = comment.username
            dto.task_id = comment.task_id
            comments_dto.comments.append(dto)

        return comments_dto

    @staticmethod
    def get_last_status(project_id: int, task_id: int, for_undo: bool = False):
        """ Get the status the task was set to the last time the task had a STATUS_CHANGE"""
        result = (
            db.session.query(TaskHistory.action_text)
            .filter(
                TaskHistory.project_id == project_id,
                TaskHistory.task_id == task_id,
                TaskHistory.action == TaskAction.STATE_CHANGE.name,
            )
            .order_by(TaskHistory.action_date.desc())
            .all()
        )

        if not result:
            return TaskStatus.READY  # No result so default to ready status

        if len(result) == 1 and for_undo:
            # We're looking for the previous status, however, there isn't any so we'll return Ready
            return TaskStatus.READY

        if for_undo and result[0][0] in [
            TaskStatus.MAPPED.name,
            TaskStatus.BADIMAGERY.name,
        ]:
            # We need to return a READY when last status of the task is badimagery or mapped.
            return TaskStatus.READY

        if for_undo:
            # Return the second last status which was status the task was previously set to
            return TaskStatus[result[1][0]]
        else:
            return TaskStatus[result[0][0]]

    @staticmethod
    def get_last_action(project_id: int, task_id: int):
        """Gets the most recent task history record for the task"""
        return (
            TaskHistory.query.filter(
                TaskHistory.project_id == project_id, TaskHistory.task_id == task_id
            )
            .order_by(TaskHistory.action_date.desc())
            .first()
        )

    @staticmethod
    def get_last_action_of_type(
        project_id: int, task_id: int, allowed_task_actions: list
    ):
        """Gets the most recent task history record having provided TaskAction"""
        return (
            TaskHistory.query.filter(
                TaskHistory.project_id == project_id,
                TaskHistory.task_id == task_id,
                TaskHistory.action.in_(allowed_task_actions),
            )
            .order_by(TaskHistory.action_date.desc())
            .first()
        )

    @staticmethod
    def get_last_locked_action(project_id: int, task_id: int):
        """Gets the most recent task history record with locked action for the task"""
        return TaskHistory.get_last_action_of_type(
            project_id,
            task_id,
            [TaskAction.LOCKED_FOR_MAPPING.name, TaskAction.LOCKED_FOR_VALIDATION.name],
        )

    @staticmethod
    def get_last_locked_or_auto_unlocked_action(project_id: int, task_id: int):
        """Gets the most recent task history record with locked or auto unlocked action for the task"""
        return TaskHistory.get_last_action_of_type(
            project_id,
            task_id,
            [
                TaskAction.LOCKED_FOR_MAPPING.name,
                TaskAction.LOCKED_FOR_VALIDATION.name,
                TaskAction.AUTO_UNLOCKED_FOR_MAPPING.name,
                TaskAction.AUTO_UNLOCKED_FOR_VALIDATION.name,
            ],
        )

    def get_last_mapped_action(project_id: int, task_id: int):
        """Gets the most recent mapped action, if any, in the task history"""
        return (
            db.session.query(TaskHistory)
            .filter(
                TaskHistory.project_id == project_id,
                TaskHistory.task_id == task_id,
                TaskHistory.action == TaskAction.STATE_CHANGE.name,
                TaskHistory.action_text.in_(
                    [TaskStatus.BADIMAGERY.name, TaskStatus.MAPPED.name]
                ),
            )
            .order_by(TaskHistory.action_date.desc())
            .first()
        )


class Task(db.Model):
    """ Describes an individual mapping Task """

    __tablename__ = "tasks"

    # Table has composite PK on (id and project_id)
    id = db.Column(db.Integer, primary_key=True)
    project_id = db.Column(
        db.Integer, db.ForeignKey("projects.id"), index=True, primary_key=True
    )
    x = db.Column(db.Integer)
    y = db.Column(db.Integer)
    zoom = db.Column(db.Integer)
    extra_properties = db.Column(db.Unicode)
    # Tasks need to be split differently if created from an arbitrary grid or were clipped to the edge of the AOI
    is_square = db.Column(db.Boolean, default=True)
    geometry = db.Column(Geometry("MULTIPOLYGON", srid=4326))
    task_status = db.Column(db.Integer, default=TaskStatus.READY.value)
    locked_by = db.Column(
        db.BigInteger, db.ForeignKey("users.id", name="fk_users_locked"), index=True
    )
    mapped_by = db.Column(
        db.BigInteger, db.ForeignKey("users.id", name="fk_users_mapper"), index=True
    )
    validated_by = db.Column(
        db.BigInteger, db.ForeignKey("users.id", name="fk_users_validator"), index=True
    )

    # Mapped objects
    task_history = db.relationship(
        TaskHistory, cascade="all", order_by=desc(TaskHistory.action_date)
    )
    task_annotations = db.relationship(TaskAnnotation, cascade="all")
    lock_holder = db.relationship(User, foreign_keys=[locked_by])
    mapper = db.relationship(User, foreign_keys=[mapped_by])

    def create(self):
        """ Creates and saves the current model to the DB """
        db.session.add(self)
        db.session.commit()

    def update(self):
        """ Updates the DB with the current state of the Task """
        db.session.commit()

    def delete(self):
        """ Deletes the current model from the DB """
        db.session.delete(self)
        db.session.commit()

    @classmethod
    def from_geojson_feature(cls, task_id, task_feature):
        """
        Constructs and validates a task from a GeoJson feature object
        :param task_id: Unique ID for the task
        :param task_feature: A geojson feature object
        :raises InvalidGeoJson, InvalidData
        """
        if type(task_feature) is not geojson.Feature:
            raise InvalidGeoJson("Task: Invalid GeoJson should be a feature")

        task_geometry = task_feature.geometry

        if type(task_geometry) is not geojson.MultiPolygon:
            raise InvalidGeoJson("Task: Geometry must be a MultiPolygon")

        is_valid_geojson = geojson.is_valid(task_geometry)
        if is_valid_geojson["valid"] == "no":
            raise InvalidGeoJson(
                f"Task: Invalid MultiPolygon - {is_valid_geojson['message']}"
            )

        task = cls()
        try:
            task.x = task_feature.properties["x"]
            task.y = task_feature.properties["y"]
            task.zoom = task_feature.properties["zoom"]
            task.is_square = task_feature.properties["isSquare"]
        except KeyError as e:
            raise InvalidData(f"Task: Expected property not found: {str(e)}")

        if "extra_properties" in task_feature.properties:
            task.extra_properties = json.dumps(
                task_feature.properties["extra_properties"]
            )

        task.id = task_id
        task_geojson = geojson.dumps(task_geometry)
        task.geometry = ST_SetSRID(ST_GeomFromGeoJSON(task_geojson), 4326)

        return task

    @staticmethod
    def get(task_id: int, project_id: int):
        """
        Gets specified task
        :param task_id: task ID in scope
        :param project_id: project ID in scope
        :return: Task if found otherwise None
        """
        # LIKELY PROBLEM AREA

        return Task.query.filter_by(id=task_id, project_id=project_id).one_or_none()

    @staticmethod
    def get_tasks(project_id: int, task_ids: List[int]):
        """ Get all tasks that match supplied list """
        return Task.query.filter(
            Task.project_id == project_id, Task.id.in_(task_ids)
        ).all()

    @staticmethod
    def get_all_tasks(project_id: int):
        """ Get all tasks for a given project """
        return Task.query.filter(Task.project_id == project_id).all()

    @staticmethod
    def auto_unlock_delta():
        return parse_duration(current_app.config["TASK_AUTOUNLOCK_AFTER"])

    @staticmethod
    def auto_unlock_tasks(project_id: int):
        """Unlock all tasks locked for longer than the auto-unlock delta"""
        expiry_delta = Task.auto_unlock_delta()
        lock_duration = (datetime.datetime.min + expiry_delta).time().isoformat()
        expiry_date = datetime.datetime.utcnow() - expiry_delta

        old_tasks = (
            db.session.query(Task.id)
            .filter(Task.id == TaskHistory.task_id)
            .filter(Task.project_id == TaskHistory.project_id)
            .filter(Task.task_status.in_([1, 3]))
            .filter(
                TaskHistory.action.in_(["LOCKED_FOR_VALIDATION", "LOCKED_FOR_MAPPING"])
            )
            .filter(TaskHistory.action_text.is_(None))
            .filter(Task.project_id == project_id)
            .filter(TaskHistory.action_date <= str(expiry_date))
        )

        if old_tasks.count() == 0:
            # no tasks older than the delta found, return without further processing
            return

        for old_task in old_tasks:
            task = Task.get(old_task[0], project_id)
            task.auto_unlock_expired_tasks(expiry_date, lock_duration)

    def auto_unlock_expired_tasks(self, expiry_date, lock_duration):
        """Unlock all tasks locked before expiry date. Clears task lock if needed"""
        TaskHistory.update_expired_and_locked_actions(
            self.project_id, self.id, expiry_date, lock_duration
        )

        last_action = TaskHistory.get_last_locked_or_auto_unlocked_action(
            self.project_id, self.id
        )
        if last_action.action in [
            "AUTO_UNLOCKED_FOR_MAPPING",
            "AUTO_UNLOCKED_FOR_VALIDATION",
        ]:
            self.clear_lock()

    def is_mappable(self):
        """ Determines if task in scope is in suitable state for mapping """
        if TaskStatus(self.task_status) not in [
            TaskStatus.READY,
            TaskStatus.INVALIDATED,
        ]:
            return False

        return True

    def set_task_history(
        self, action, user_id, comment=None, new_state=None, mapping_issues=None
    ):
        """
        Sets the task history for the action that the user has just performed
        :param task: Task in scope
        :param user_id: ID of user performing the action
        :param action: Action the user has performed
        :param comment: Comment user has added
        :param new_state: New state of the task
        :param mapping_issues: Identified issues leading to invalidation
        """
        history = TaskHistory(self.id, self.project_id, user_id)

        if action in [TaskAction.LOCKED_FOR_MAPPING, TaskAction.LOCKED_FOR_VALIDATION]:
            history.set_task_locked_action(action)
        elif action == TaskAction.COMMENT:
            history.set_comment_action(comment)
        elif action == TaskAction.STATE_CHANGE:
            history.set_state_change_action(new_state)
        elif action in [
            TaskAction.AUTO_UNLOCKED_FOR_MAPPING,
            TaskAction.AUTO_UNLOCKED_FOR_VALIDATION,
        ]:
            history.set_auto_unlock_action(action)

        if mapping_issues is not None:
            history.task_mapping_issues = mapping_issues

        self.task_history.append(history)
        return history

    def lock_task_for_mapping(self, user_id: int):
        self.set_task_history(TaskAction.LOCKED_FOR_MAPPING, user_id)
        self.task_status = TaskStatus.LOCKED_FOR_MAPPING.value
        self.locked_by = user_id
        self.update()

    def lock_task_for_validating(self, user_id: int):
        self.set_task_history(TaskAction.LOCKED_FOR_VALIDATION, user_id)
        self.task_status = TaskStatus.LOCKED_FOR_VALIDATION.value
        self.locked_by = user_id
        self.update()

    def reset_task(self, user_id: int):
        expiry_delta = Task.auto_unlock_delta()
        lock_duration = (datetime.datetime.min + expiry_delta).time().isoformat()
        if TaskStatus(self.task_status) in [
            TaskStatus.LOCKED_FOR_MAPPING,
            TaskStatus.LOCKED_FOR_VALIDATION,
        ]:
            self.record_auto_unlock(lock_duration)

        self.set_task_history(TaskAction.STATE_CHANGE, user_id, None, TaskStatus.READY)
        self.mapped_by = None
        self.validated_by = None
        self.locked_by = None
        self.task_status = TaskStatus.READY.value
        self.update()

    def clear_task_lock(self):
        """
        Unlocks task in scope in the database.  Clears the lock as though it never happened.
        No history of the unlock is recorded.
        :return:
        """
        # clear the lock action for the task in the task history
        last_action = TaskHistory.get_last_locked_action(self.project_id, self.id)
        last_action.delete()

        # Set locked_by to null and status to last status on task
        self.clear_lock()

    def record_auto_unlock(self, lock_duration):
        locked_user = self.locked_by
        last_action = TaskHistory.get_last_locked_action(self.project_id, self.id)
        next_action = (
            TaskAction.AUTO_UNLOCKED_FOR_MAPPING
            if last_action.action == "LOCKED_FOR_MAPPING"
            else TaskAction.AUTO_UNLOCKED_FOR_VALIDATION
        )

        self.clear_task_lock()

        # Add AUTO_UNLOCKED action in the task history
        auto_unlocked = self.set_task_history(action=next_action, user_id=locked_user)
        auto_unlocked.action_text = lock_duration
        self.update()

    def unlock_task(
        self, user_id, new_state=None, comment=None, undo=False, issues=None
    ):
        """ Unlock task and ensure duration task locked is saved in History """
        if comment:
            self.set_task_history(
                action=TaskAction.COMMENT,
                comment=comment,
                user_id=user_id,
                mapping_issues=issues,
            )

        history = self.set_task_history(
            action=TaskAction.STATE_CHANGE,
            new_state=new_state,
            user_id=user_id,
            mapping_issues=issues,
        )

        if (
            new_state in [TaskStatus.MAPPED, TaskStatus.BADIMAGERY]
            and TaskStatus(self.task_status) != TaskStatus.LOCKED_FOR_VALIDATION
        ):
            # Don't set mapped if state being set back to mapped after validation
            self.mapped_by = user_id
        elif new_state == TaskStatus.VALIDATED:
            TaskInvalidationHistory.record_validation(
                self.project_id, self.id, user_id, history
            )
            self.validated_by = user_id
        elif new_state == TaskStatus.INVALIDATED:
            TaskInvalidationHistory.record_invalidation(
                self.project_id, self.id, user_id, history
            )
            self.mapped_by = None
            self.validated_by = None

        if not undo:
            # Using a slightly evil side effect of Actions and Statuses having the same name here :)
            TaskHistory.update_task_locked_with_duration(
                self.id, self.project_id, TaskStatus(self.task_status), user_id
            )

        self.task_status = new_state.value
        self.locked_by = None
        self.update()

    def reset_lock(self, user_id, comment=None):
        """Removes a current lock from a task, resets to last status and
        updates history with duration of lock"""
        if comment:
            self.set_task_history(
                action=TaskAction.COMMENT, comment=comment, user_id=user_id
            )

        # Using a slightly evil side effect of Actions and Statuses having the same name here :)
        TaskHistory.update_task_locked_with_duration(
            self.id, self.project_id, TaskStatus(self.task_status), user_id
        )
        self.clear_lock()

    def clear_lock(self):
        """ Resets to last status and removes current lock from a task """
        self.task_status = TaskHistory.get_last_status(self.project_id, self.id).value
        self.locked_by = None
        self.update()

    @staticmethod
    def get_tasks_as_geojson_feature_collection(
        project_id,
        task_ids_str: str = None,
        order_by: str = None,
        order_by_type: str = "ASC",
        status: int = None,
    ):
        """
        Creates a geoJson.FeatureCollection object for tasks related to the supplied project ID
        :param project_id: Owning project ID
        :order_by: sorting option: available values update_date and building_area_diff
        :status: task status id to filter by
        :return: geojson.FeatureCollection
        """
        # subquery = (
        #     db.session.query(func.max(TaskHistory.action_date))
        #     .filter(
        #         Task.id == TaskHistory.task_id,
        #         Task.project_id == TaskHistory.project_id,
        #     )
        #     .correlate(Task)
        #     .group_by(Task.id)
        #     .label("update_date")
        # )
        query = db.session.query(
            Task.id,
            Task.x,
            Task.y,
            Task.zoom,
            Task.is_square,
            Task.task_status,
            Task.geometry.ST_AsGeoJSON().label("geojson"),
            Task.locked_by,
            # subquery,
        )

        filters = [Task.project_id == project_id]

        if task_ids_str:
            task_ids = map(int, task_ids_str.split(","))
            tasks = Task.get_tasks(project_id, task_ids)
            if not tasks or len(tasks) == 0:
                raise NotFound()
            else:
                tasks_filters = [task.id for task in tasks]
            filters = [Task.project_id == project_id, Task.id.in_(tasks_filters)]
        else:
            tasks = Task.get_all_tasks(project_id)
            if not tasks or len(tasks) == 0:
                raise NotFound()

        if status:
            filters.append(Task.task_status == status)

        if order_by == "effort_prediction":
            query = query.outerjoin(TaskAnnotation).filter(*filters)
            if order_by_type == "DESC":
                query = query.order_by(
                    desc(
                        cast(
                            cast(TaskAnnotation.properties["building_area_diff"], Text),
                            Float,
                        )
                    )
                )
            else:
                query = query.order_by(
                    cast(
                        cast(TaskAnnotation.properties["building_area_diff"], Text),
                        Float,
                    )
                )
        # elif order_by == "last_updated":
        #     if order_by_type == "DESC":
        #         query = query.filter(*filters).order_by(desc("update_date"))
        #     else:
        #         query = query.filter(*filters).order_by("update_date")
        else:
            query = query.filter(*filters)

        project_tasks = query.all()

        tasks_features = []
        for task in project_tasks:
            task_geometry = geojson.loads(task.geojson)
            task_properties = dict(
                taskId=task.id,
                taskX=task.x,
                taskY=task.y,
                taskZoom=task.zoom,
                taskIsSquare=task.is_square,
                taskStatus=TaskStatus(task.task_status).name,
                lockedBy=task.locked_by,
            )

            feature = geojson.Feature(
                geometry=task_geometry, properties=task_properties
            )
            tasks_features.append(feature)

        return geojson.FeatureCollection(tasks_features)

    @staticmethod
    def get_tasks_as_geojson_feature_collection_no_geom(project_id):
        """
        Creates a geoJson.FeatureCollection object for all tasks related to the supplied project ID without geometry
        :param project_id: Owning project ID
        :return: geojson.FeatureCollection
        """
        project_tasks = (
            db.session.query(
                Task.id, Task.x, Task.y, Task.zoom, Task.is_square, Task.task_status
            )
            .filter(Task.project_id == project_id)
            .all()
        )

        tasks_features = []
        for task in project_tasks:
            task_properties = dict(
                taskId=task.id,
                taskX=task.x,
                taskY=task.y,
                taskZoom=task.zoom,
                taskIsSquare=task.is_square,
                taskStatus=TaskStatus(task.task_status).name,
            )

            feature = geojson.Feature(properties=task_properties)
            tasks_features.append(feature)

        return geojson.FeatureCollection(tasks_features)

    @staticmethod
    def get_mapped_tasks_by_user(project_id: int):
        """ Gets all mapped tasks for supplied project grouped by user"""
        results = (
            db.session.query(
                User.username,
                User.mapping_level,
                func.count(distinct(Task.id)),
                func.json_agg(distinct(Task.id)),
                func.max(TaskHistory.action_date),
                User.date_registered,
                User.last_validation_date,
            )
            .filter(Task.project_id == TaskHistory.project_id)
            .filter(Task.id == TaskHistory.task_id)
            .filter(Task.mapped_by == User.id)
            .filter(Task.project_id == project_id)
            .filter(Task.task_status == 2)
            .filter(TaskHistory.action_text == "MAPPED")
            .group_by(
                User.username,
                User.mapping_level,
                User.date_registered,
                User.last_validation_date,
            )
        )

        mapped_tasks_dto = MappedTasks()
        for row in results:
            user_mapped = MappedTasksByUser()
            user_mapped.username = row[0]
            user_mapped.mapping_level = MappingLevel(row[1]).name
            user_mapped.mapped_task_count = row[2]
            user_mapped.tasks_mapped = row[3]
            user_mapped.last_seen = row[4]
            user_mapped.date_registered = row[5]
            user_mapped.last_validation_date = row[6]

            mapped_tasks_dto.mapped_tasks.append(user_mapped)

        return mapped_tasks_dto

    @staticmethod
    def get_max_task_id_for_project(project_id: int):
        """Gets the nights task id currently in use on a project"""
        result = (
            db.session.query(func.max(Task.id))
            .filter(Task.project_id == project_id)
            .group_by(Task.project_id)
        )
        if result.count() == 0:
            raise NotFound()
        for row in result:
            return row[0]

    def as_dto(
        self,
        task_history: List[TaskHistoryDTO] = [],
        last_updated: datetime.datetime = None,
        comments: int = None,
    ):
        """Just converts to a TaskDTO"""
        task_dto = TaskDTO()
        task_dto.task_id = self.id
        task_dto.project_id = self.project_id
        task_dto.task_status = TaskStatus(self.task_status).name
        task_dto.lock_holder = self.lock_holder.username if self.lock_holder else None
        task_dto.task_history = task_history
        task_dto.last_updated = last_updated if last_updated else None
        task_dto.auto_unlock_seconds = Task.auto_unlock_delta().total_seconds()
        task_dto.comments_number = comments if type(comments) == int else None
        return task_dto

    def as_dto_with_instructions(self, preferred_locale: str = "en") -> TaskDTO:
        """Get dto with any task instructions"""
        task_history = []
        for action in self.task_history:
            history = TaskHistoryDTO()
            history.history_id = action.id
            history.action = action.action
            history.action_text = action.action_text
            history.action_date = action.action_date
            history.action_by = (
                action.actioned_by.username if action.actioned_by else None
            )
            history.picture_url = (
                action.actioned_by.picture_url if action.actioned_by else None
            )
            if action.task_mapping_issues:
                history.issues = [
                    issue.as_dto() for issue in action.task_mapping_issues
                ]

            task_history.append(history)

        last_updated = None
        if len(task_history) > 0:
            last_updated = task_history[0].action_date

        task_dto = self.as_dto(task_history, last_updated=last_updated)

        per_task_instructions = self.get_per_task_instructions(preferred_locale)

        # If we don't have instructions in preferred locale try again for default locale
        task_dto.per_task_instructions = (
            per_task_instructions
            if per_task_instructions
            else self.get_per_task_instructions(self.projects.default_locale)
        )

        annotations = self.get_per_task_annotations()
        task_dto.task_annotations = annotations if annotations else []

        return task_dto

    def get_per_task_annotations(self):
        result = [ta.get_dto() for ta in self.task_annotations]
        return result

    def get_per_task_instructions(self, search_locale: str) -> str:
        """ Gets any per task instructions attached to the project """
        project_info = self.projects.project_info.all()

        for info in project_info:
            if info.locale == search_locale:
                return self.format_per_task_instructions(info.per_task_instructions)

    def format_per_task_instructions(self, instructions) -> str:
        """ Format instructions by looking for X, Y, Z tokens and replacing them with the task values """
        if not instructions:
            return ""  # No instructions so return empty string

        properties = {}

        if self.x:
            properties["x"] = str(self.x)
        if self.y:
            properties["y"] = str(self.y)
        if self.zoom:
            properties["z"] = str(self.zoom)
        if self.extra_properties:
            properties.update(json.loads(self.extra_properties))

        try:
            instructions = instructions.format(**properties)
        except KeyError:
            pass
        return instructions

    def copy_task_history(self) -> list:
        copies = []
        for entry in self.task_history:
            db.session.expunge(entry)
            make_transient(entry)
            entry.id = None
            entry.task_id = None
            db.session.add(entry)
            copies.append(entry)

        return copies

    def get_locked_tasks_for_user(user_id: int):
        """ Gets tasks on project owned by specified user id"""
        tasks = Task.query.filter_by(locked_by=user_id)
        tasks_dto = LockedTasksForUser()
        for task in tasks:
            tasks_dto.locked_tasks.append(task.id)
            tasks_dto.project = task.project_id
            tasks_dto.task_status = TaskStatus(task.task_status).name

        return tasks_dto

    def get_locked_tasks_details_for_user(user_id: int):
        """ Gets tasks on project owned by specified user id"""
        tasks = Task.query.filter_by(locked_by=user_id)
        locked_tasks = [task for task in tasks]

        return locked_tasks
