import base64
import logging

import orjson as json
import os
import zlib

from flask import current_app
from operator import itemgetter
from pathlib import Path
from shutil import copyfile
from zipfile import ZipFile

from flask_fs.errors import FileNotFound
from slugify import slugify
from sqlalchemy import or_
from sqlalchemy.orm import joinedload

from zou.app import config
from zou.app.stores import config_store, file_store

from zou.app.models.build_job import BuildJob
from zou.app.models.playlist import Playlist
from zou.app.models.preview_file import PreviewFile
from zou.app.models.task import Task
from zou.app.models.task_type import TaskType

from zou.utils import movie
from zou.app.utils import fields, events, remote_job, emails
from zou.app.utils import query as query_utils
from zou.app.stores.redis_lock import with_playlist_lock

from zou.app.services import (
    assets_service,
    base_service,
    entities_service,
    files_service,
    preview_files_service,
    projects_service,
    shots_service,
    tasks_service,
    names_service,
    persons_service,
    templates_service,
)

from zou.app.services.exception import (
    BuildJobNotFoundException,
    PlaylistNotFoundException,
)

logger = logging.getLogger(__name__)

# Page size for playlist list endpoints
PLAYLISTS_PAGE_SIZE = 20

# Scalar attributes for playlist list dict (avoids serializing heavy shots JSON)
_PLAYLIST_LIST_ATTRS = (
    "id",
    "name",
    "project_id",
    "episode_id",
    "task_type_id",
    "for_client",
    "for_entity",
    "is_for_all",
    "created_at",
    "updated_at",
    "created_by",
)


def _apply_playlist_pagination(query, page, sort_by, model=Playlist):
    """Apply sort, normalize page, and apply limit/offset to the query."""
    query = query_utils.apply_sort_by(model, query, sort_by)
    if page < 1:
        page = 1
    offset = (page - 1) * PLAYLISTS_PAGE_SIZE
    return query.limit(PLAYLISTS_PAGE_SIZE).offset(offset)


def all_playlists_for_project(
    project_id,
    for_client=False,
    page=1,
    sort_by="updated_at",
    task_type_id=None,
):
    """
    Return all playlists created for given project.
    """
    result = []
    query = Playlist.query.filter(Playlist.project_id == project_id)
    if for_client:
        query = query.filter(Playlist.for_client)

    if task_type_id is not None and len(task_type_id) > 0:
        query = query.filter(Playlist.task_type_id == task_type_id)

    query = _apply_playlist_pagination(query, page, sort_by)
    playlists = query.all()
    for playlist in playlists:
        playlist_dict = build_playlist_dict(playlist)
        result.append(playlist_dict)
    return result


def all_playlists_for_episode(
    project_id,
    episode_id,
    for_client=False,
    page=1,
    sort_by="updated_at",
    task_type_id=None,
):
    """
    Return all playlists created for given episode.
    """
    result = []
    query = Playlist.query
    if for_client:
        query = query.filter(Playlist.for_client)

    if task_type_id is not None and len(task_type_id) > 0:
        query = query.filter(Playlist.task_type_id == task_type_id)

    if episode_id == "main":
        query = (
            query.filter(Playlist.episode_id == None)
            .filter(Playlist.project_id == project_id)
            .filter(
                or_(Playlist.is_for_all is None, Playlist.is_for_all == False)
            )
        )
    elif episode_id == "all":
        query = (
            query.filter(Playlist.episode_id == None)
            .filter(Playlist.project_id == project_id)
            .filter(Playlist.is_for_all == True)
        )
    else:
        query = query.filter(Playlist.episode_id == episode_id)

    query = _apply_playlist_pagination(query, page, sort_by)
    playlists = query.all()
    for playlist in playlists:
        playlist_dict = build_playlist_dict(playlist)
        result.append(playlist_dict)
    return result


def build_playlist_dict(playlist):
    """
    Build a dictionary of a simplified version of the playlist. It just takes
    the information needed for displaying the list of playlists.
    Does not mutate the playlist model. Uses scalar attributes only to avoid
    serializing the potentially large shots JSON column.
    """
    out = {"type": "Playlist"}
    for attr in _PLAYLIST_LIST_ATTRS:
        if hasattr(playlist, attr):
            out[attr] = fields.serialize_value(getattr(playlist, attr))
    if playlist.for_entity is None:
        out["for_entity"] = "shot"
    first_shot_preview_file_id = get_first_shot_preview_file_id(playlist)
    if first_shot_preview_file_id is not None:
        out["first_preview_file_id"] = first_shot_preview_file_id
    return out


def get_first_shot_preview_file_id(playlist):
    """Return the first shot's preview_file_id if any, else None."""
    if not playlist.shots or not isinstance(playlist.shots, list):
        return None
    first = playlist.shots[0]
    return first.get("preview_file_id") if isinstance(first, dict) else None


def get_playlist_with_preview_file_revisions(playlist_id):
    """
    Return given playlist. Shot list is augmented with all previews available
    for a given shot.
    """
    # Eager load build_jobs to avoid N+1 when building build_jobs list
    playlist = (
        Playlist.query.options(joinedload(Playlist.build_jobs))
        .filter(Playlist.id == playlist_id)
        .first()
    )
    if playlist is None:
        raise PlaylistNotFoundException()
    playlist_dict = playlist.serialize()
    playlist_dict = _add_build_job_infos_to_playlist_dict(
        playlist, playlist_dict
    )

    if playlist_dict["shots"] is None:
        playlist_dict["shots"] = []
    playlist_dict, preview_file_map = set_preview_files_for_entities(
        playlist_dict
    )

    for shot in playlist_dict["shots"]:
        try:
            preview_file = preview_file_map.get(shot["preview_file_id"], None)
            if preview_file is not None:
                shot["preview_file_id"] = preview_file["id"]
                shot["preview_file_extension"] = preview_file["extension"]
                shot["preview_file_revision"] = preview_file["revision"]
                shot["preview_file_width"] = preview_file["width"]
                shot["preview_file_height"] = preview_file["height"]
                shot["preview_file_duration"] = preview_file["duration"]
                shot["preview_file_status"] = preview_file["status"]
                shot["preview_file_annotations"] = preview_file["annotations"]
                shot["preview_file_task_id"] = preview_file["task_id"]
                shot["preview_file_previews"] = preview_file.get(
                    "previews", []
                )
            else:
                del shot["preview_file_id"]
        except Exception as e:
            logger.warning(
                "Failed to enrich shot with preview file: shot_id=%s, error=%s",
                shot.get("id"),
                e,
            )
    return playlist_dict


def _add_build_job_infos_to_playlist_dict(playlist, playlist_dict):
    playlist_dict["build_jobs"] = []
    for build_job in reversed(playlist.build_jobs):
        playlist_dict["build_jobs"].append(build_job.present())
    return playlist_dict


def set_preview_files_for_entities(playlist_dict):
    """
    Retrieve all preview files related to entities listed in given playlist.
    Add to each entity a dict with task as keys and preview list as values.
    """
    entity_ids = []
    for entity in playlist_dict["shots"]:
        if "id" not in entity:
            entity_id = entity.get("shot_id", entity.get("entity_id", None))
            if entity_id is not None:
                entity_ids.append(entity_id)
                entity["id"] = entity_id
        else:
            entity_ids.append(entity["id"])
    previews = {}
    preview_file_map = {}

    preview_files = (
        PreviewFile.query.join(Task)
        .join(TaskType)
        .filter(Task.entity_id.in_(entity_ids))
        .order_by(TaskType.priority.desc())
        .order_by(TaskType.name)
        .order_by(PreviewFile.revision.desc())
        .order_by(PreviewFile.position)
        .order_by(PreviewFile.created_at)
        .add_column(Task.task_type_id)
        .add_column(Task.entity_id)
        .all()
    )

    for preview_file, task_type_id, entity_id in preview_files:
        entity_id = str(entity_id)
        task_type_id = str(task_type_id)
        if entity_id not in previews:
            previews[entity_id] = {}

        if task_type_id not in previews[entity_id]:
            previews[entity_id][task_type_id] = []

        task_id = str(preview_file.task_id)
        preview_file_id = str(preview_file.id)

        light_preview_file = {
            "id": preview_file_id,
            "revision": preview_file.revision,
            "extension": preview_file.extension,
            "width": preview_file.width,
            "height": preview_file.height,
            "duration": float(preview_file.duration or 0),
            "status": str(preview_file.status),
            "annotations": preview_file.annotations,
            "created_at": fields.serialize_value(preview_file.created_at),
            "task_id": task_id,
        }  # Do not add too much field to avoid building too big responses
        previews[entity_id][task_type_id].append(light_preview_file)
        preview_file_map[preview_file_id] = light_preview_file

    for entity_id in previews.keys():
        for task_type_id in previews[entity_id].keys():
            previews[entity_id][task_type_id] = mix_preview_file_revisions(
                previews[entity_id][task_type_id]
            )

    for entity in playlist_dict["shots"]:
        if str(entity["id"]) in previews:
            entity["preview_files"] = previews[str(entity["id"])]
        else:
            entity["preview_files"] = []

    return (fields.serialize_value(playlist_dict), preview_file_map)


def get_preview_files_for_entity(entity_id):
    """
    Get all preview files available for given shot.
    """
    previews = {}
    query = (
        Task.query.filter_by(entity_id=entity_id)
        .add_columns(
            PreviewFile.id,
            PreviewFile.revision,
            PreviewFile.position,
            PreviewFile.original_name,
            PreviewFile.extension,
            PreviewFile.width,
            PreviewFile.height,
            PreviewFile.duration,
            PreviewFile.status,
            PreviewFile.annotations,
            PreviewFile.created_at,
            PreviewFile.task_id,
        )
        .join(PreviewFile, Task.id == PreviewFile.task_id)
        .join(TaskType)
        .order_by(TaskType.priority.desc())
        .order_by(TaskType.name)
        .order_by(PreviewFile.revision.desc())
        .order_by(PreviewFile.created_at)
    )

    task_previews = {}
    for (
        task,
        preview_file_id,
        preview_file_revision,
        preview_file_position,
        preview_file_original_name,
        preview_file_extension,
        preview_file_width,
        preview_file_height,
        preview_file_duration,
        preview_file_status,
        preview_file_annotations,
        preview_file_created_at,
        preview_file_task_id,
    ) in query.all():
        task_id = str(task.id)
        if task_id not in task_previews:
            task_previews[task_id] = []
        task_previews[task_id].append(
            fields.serialize_dict(
                {
                    "id": preview_file_id,
                    "revision": preview_file_revision,
                    "position": preview_file_position,
                    "original_name": preview_file_original_name,
                    "extension": preview_file_extension,
                    "width": preview_file_width,
                    "height": preview_file_height,
                    "duration": float(preview_file_duration or 0),
                    "status": preview_file_status,
                    "annotations": preview_file_annotations,
                    "created_at": preview_file_created_at,
                    "task_id": task_id,
                    "task_type_id": str(task.task_type_id),
                }
            )
        )

    for task_id in task_previews.keys():
        preview_files = task_previews[task_id]
        task_type_id = task_previews[task_id][0]["task_type_id"]

        if len(preview_files) > 0:
            preview_files = mix_preview_file_revisions(preview_files)
            previews[task_type_id] = [
                {
                    "id": preview_file["id"],
                    "revision": preview_file["revision"],
                    "original_name": preview_file["original_name"],
                    "extension": preview_file["extension"],
                    "width": preview_file["width"],
                    "height": preview_file["height"],
                    "duration": preview_file["duration"],
                    "status": preview_file["status"],
                    "annotations": preview_file["annotations"],
                    "previews": preview_file["previews"],
                    "created_at": preview_file["created_at"],
                    "task_id": preview_file["task_id"],
                }
                for preview_file in preview_files
            ]  # Do not add too much field to avoid building too big responses
    return previews


def mix_preview_file_revisions(preview_files):
    """
    The goal here is to group preview files with same revision in a single
    preview file, which encapsulates other preview_files.
    """
    revision_map = {}
    result = []
    for preview_file in preview_files:
        revision = preview_file["revision"]
        if revision not in revision_map:
            preview_file["previews"] = []
            revision_map[revision] = preview_file
            result.append(preview_file)
        else:
            parent_preview_file = revision_map[revision]
            parent_preview_file["previews"].append(preview_file)
    return result


def get_playlist_raw(playlist_id):
    """
    Return given playlist as active record.
    """
    return base_service.get_instance(
        Playlist, playlist_id, PlaylistNotFoundException
    )


def get_playlist(playlist_id):
    """
    Return given playlist as a dict.
    """
    return get_playlist_raw(playlist_id).serialize()


def add_entity_to_playlist(playlist_id, entity_id, preview_file_id=None):
    """
    Atomically append an entity to the playlist shots list.
    This function is designed to be safe under concurrent access.

    Uses a Redis-based distributed lock to prevent race conditions
    when multiple processes try to add entities to the same playlist
    concurrently. Falls back to database-level locking if Redis is
    unavailable.
    """
    entity_id_str = str(entity_id)
    with with_playlist_lock(
        playlist_id, timeout=30, wait_timeout=35
    ) as _acquired:
        playlist_dict = _add_entity_to_playlist_db(
            playlist_id, entity_id_str, preview_file_id
        )

    events.emit(
        "playlist:add_entity",
        {
            "playlist_id": playlist_dict["id"],
            "entity_id": entity_id_str,
            "preview_file_id": (
                str(preview_file_id) if preview_file_id is not None else None
            ),
        },
        project_id=playlist_dict["project_id"],
    )

    return playlist_dict


def _add_entity_to_playlist_db(playlist_id, entity_id_str, preview_file_id):
    """
    Internal helper function to add an entity to playlist in the database.
    Assumes the caller has acquired appropriate locking (Redis).
    """
    playlist = Playlist.get(playlist_id)
    shots = list(playlist.shots or [])

    if not any(shot.get("entity_id") == entity_id_str for shot in shots):
        shot = {
            "entity_id": entity_id_str,
        }
        if preview_file_id is not None:
            shot["preview_file_id"] = str(preview_file_id)
        shots.append(shot)
        playlist.shots = shots
        playlist.update({"shots": shots})

    return playlist.serialize()


def playlist_previews(shots, only_movies=False):
    """
    Retrieve all preview id and extension for the given shots.
    Uses a single batch query instead of N queries (avoids N+1).
    """
    preview_file_ids = []
    for entity in shots:
        if not isinstance(entity, dict):
            continue
        pf_id = entity.get("preview_file_id")
        if pf_id is not None and len(str(pf_id).strip()) > 0:
            preview_file_ids.append(str(pf_id))
    if not preview_file_ids:
        return []

    # Single query for all preview files (batch fetch)
    preview_models = PreviewFile.query.filter(
        PreviewFile.id.in_(preview_file_ids)
    ).all()
    id_to_preview = {
        str(pf.id): {"id": str(pf.id), "extension": pf.extension}
        for pf in preview_models
    }

    # Preserve order of shots and apply only_movies filter
    result = []
    for entity in shots:
        if not isinstance(entity, dict):
            continue
        pf_id = entity.get("preview_file_id")
        if not pf_id:
            continue
        preview = id_to_preview.get(str(pf_id))
        if preview is None:
            continue
        if only_movies and preview["extension"] != "mp4":
            continue
        result.append(preview)
    return result


def retrieve_playlist_tmp_files(preview_files, full=False):
    """
    Retrieve all files for a given playlist into the temporary folder.
    """
    file_paths = []
    for preview_file in preview_files:
        if full:
            preview_file = files_service.get_preview_file(preview_file["id"])
            sub_preview_files = (
                preview_files_service.get_preview_files_for_revision(
                    preview_file["task_id"], preview_file["revision"]
                )
            )
            for preview_file in sub_preview_files:
                tmp_file_path, file_name = retrieve_playlist_tmp_file(
                    preview_file
                )
                file_paths.append((tmp_file_path, file_name))
        else:
            tmp_file_path, file_name = retrieve_playlist_tmp_file(preview_file)
            file_paths.append((tmp_file_path, file_name))
    return file_paths


def retrieve_playlist_tmp_file(preview_file):
    if preview_file["extension"] == "mp4":
        get_path_func = file_store.get_local_movie_path
        open_func = file_store.open_movie
        exists_func = file_store.exists_movie
        prefix = "previews"
    elif preview_file["extension"] == "png":
        get_path_func = file_store.get_local_picture_path
        open_func = file_store.open_picture
        exists_func = file_store.exists_picture
        prefix = "original"
    else:
        get_path_func = file_store.get_local_file_path
        open_func = file_store.open_file
        exists_func = file_store.exists_file
        prefix = "previews"

    if config.FS_BACKEND == "local":
        file_path = get_path_func(prefix, preview_file["id"])
    else:
        file_path = os.path.join(
            config.TMP_DIR,
            "cache-previews-%s.%s"
            % (preview_file["id"], preview_file["extension"]),
        )
        if not os.path.exists(file_path) or os.path.getsize(file_path) == 0:
            if exists_func(prefix, preview_file["id"]):
                with open(file_path, "wb") as tmp_file:
                    try:
                        for chunk in open_func(prefix, preview_file["id"]):
                            tmp_file.write(chunk)
                    except FileNotFound:
                        pass
    file_name = names_service.get_preview_file_name(preview_file["id"])
    tmp_file_path = os.path.join(config.TMP_DIR, file_name)
    copyfile(file_path, tmp_file_path)
    return tmp_file_path, file_name


def build_playlist_zip_file(playlist):
    """
    Build a zip for all files for a given playlist into the temporary folder.
    """
    previews = playlist_previews(playlist["shots"])
    tmp_file_paths = retrieve_playlist_tmp_files(previews, full=True)

    zip_file_path = get_playlist_zip_file_path(playlist)
    if os.path.exists(zip_file_path):
        os.remove(zip_file_path)
    with ZipFile(zip_file_path, "w") as zip:
        for file_path, file_name in tmp_file_paths:
            zip.write(file_path, file_name)
    return zip_file_path


def build_playlist_movie_file(playlist, job, shots, params, full, remote):
    """
    Build a movie for all files for a given playlist into the temporary folder.
    """
    success = False
    from zou.app import app

    with app.app_context():
        try:
            previews = playlist_previews(shots, only_movies=True)
            movie_file_path = get_playlist_movie_file_path(job)
            tmp_file_paths = retrieve_playlist_tmp_files(previews)

            if tmp_file_paths:
                if not remote:
                    success = False
                    if not full:
                        success = _run_concatenation(
                            playlist,
                            job,
                            tmp_file_paths,
                            movie_file_path,
                            params,
                            movie.concat_demuxer,
                        )

                    # Try again using concat filter
                    if not success:
                        success = _run_concatenation(
                            playlist,
                            job,
                            tmp_file_paths,
                            movie_file_path,
                            params,
                            movie.concat_filter,
                        )
                else:
                    try:
                        _run_remote_job_build_playlist(
                            app, job, previews, params, movie_file_path, full
                        )
                        success = True
                    except Exception as exc:
                        app.logger.error(exc)
                        success = False

        except Exception as exc:
            app.logger.error(exc)
            success = False

        # exception will be logged by rq
        finally:
            job = end_build_job(playlist, job, success)

    if not success:
        raise Exception("Failure while building playlist %r" % playlist["id"])

    return job


def _run_concatenation(
    playlist, job, tmp_file_paths, movie_file_path, params, mode
):
    success = False
    try:
        result = movie.build_playlist_movie(
            mode, tmp_file_paths, movie_file_path, **params._asdict()
        )
        if result["success"] and os.path.exists(movie_file_path):
            file_store.add_movie("playlists", job["id"], movie_file_path)
            success = True
        if result.get("message"):
            from zou.app import app

            with app.app_context():
                app.logger.error(result["message"])
    except Exception:
        from zou.app import app

        with app.app_context():
            app.logger.error(
                "Unable to build playlist %r using %s",
                (playlist["id"], mode.__qualname__),
                exc_info=1,
            )
    return success


def _run_remote_job_build_playlist(
    app, job, previews, params, movie_file_path, full
):
    preview_ids = [
        preview["id"] for preview in previews if preview["extension"] == "mp4"
    ]
    input_bytes = zlib.compress(json.dumps(preview_ids))
    input_string = base64.b64encode(input_bytes).decode("ascii")
    params = {
        "version": "1",
        "output_filename": Path(movie_file_path).name,
        "output_key": file_store.make_key("playlists", job["id"]),
        "input": input_string,
        "width": params.width,
        "height": params.height,
        "fps": params.fps,
        "full": str(full).lower(),
    }
    nomad_job = config_store.get_nomad_playlist_job()
    remote_job.run_job(app, config, nomad_job, params)

    with open(movie_file_path, "wb") as movie_file:
        for chunk in file_store.open_movie("playlists", job["id"]):
            movie_file.write(chunk)

    return movie_file_path


def start_build_job(playlist):
    """
    clients that a new job is running.
    Register in database that a new build is running. Emits an event to notify
    """
    job = BuildJob.create(
        status="running", job_type="movie", playlist_id=playlist["id"]
    )
    events.emit(
        "build-job:new",
        {
            "build_job_id": str(job.id),
            "playlist_id": playlist["id"],
            "created_at": fields.serialize_value(job.created_at),
        },
        project_id=playlist["project_id"],
    )
    return job.serialize()


def end_build_job(playlist, job, success):
    """
    Register in database that a build is finished. Emits an event to notify
    clients that the build is done.
    """
    if success:
        status = "succeeded"
    else:
        status = "failed"

    build_job = BuildJob.get(job["id"])
    if build_job is not None:
        build_job.end(status=status)
    events.emit(
        "build-job:update",
        {
            "build_job_id": job["id"],
            "playlist_id": playlist["id"],
            "status": status,
        },
        project_id=playlist["project_id"],
    )
    if build_job is not None:
        return build_job.serialize()
    else:
        return {}


def build_playlist_job(playlist, job, shots, params, email, full, remote):
    """
    Build playlist file (concatenate all movie previews). This function is
    aimed at being run as a job in a job queue.
    """
    build_playlist_movie_file(playlist, job, shots, params, full, remote)

    # Just in case, since rq jobs which encounter an error raise an
    # exception in order to be flagged as failed.
    if job["status"] == "succeeded":
        person = persons_service.get_person_by_email_raw(email)
        organisation = persons_service.get_organisation()
        playlist_url = "%s://%s/api/data/playlists/%s/jobs/%s/build/mp4" % (
            config.DOMAIN_PROTOCOL,
            config.DOMAIN_NAME,
            playlist["id"],
            job["id"],
        )
        html = f"""<p>Hello {person.first_name},</p>
<p>Your playlist {playlist["name"]} build is available:
</p>
<p class="cta">
<a href="{playlist_url}">Download Playlist Build (.mp4)</a>
</p>
"""

        subject = f"{organisation['name']} - Kitsu: playlist download"
        title = "Playlist Download"
        email_html_body = templates_service.generate_html_body(title, html)
        emails.send_email(subject, email_html_body, email)


def get_playlist_download_context_name(project, playlist):
    """
    Build the context name (slug) for playlist download filenames.
    For tvshow projects, appends episode name (or "main pack" / "all assets").
    """
    context_name = slugify(project["name"], separator="_")
    if project.get("production_type") == "tvshow":
        episode_id = playlist.get("episode_id")
        if episode_id is not None:
            episode = shots_service.get_episode(episode_id)
            episode_name = episode["name"]
        elif playlist.get("is_for_all"):
            episode_name = "all assets"
        else:
            episode_name = "main pack"
        context_name += "_%s" % slugify(episode_name, separator="_")
    return context_name


def get_playlist_file_name(playlist):
    """
    Build file name for the movie file matching given playlist.
    """
    project = projects_service.get_project(playlist["project_id"])
    download_name = "%s_%s" % (
        slugify(project["name"]),
        slugify(playlist["name"]),
    )
    return slugify(download_name)


def get_playlist_movie_file_path(build_job):
    """
    Build file path for the movie file matching given playlist.
    """
    movie_file_name = "cache-playlists-%s.mp4" % build_job["id"]
    return os.path.join(config.TMP_DIR, movie_file_name)


def get_playlist_zip_file_path(playlist):
    """
    Build file path for the archive file matching given playlist.
    """
    zip_file_name = "%s.zip" % playlist["id"]
    return os.path.join(config.TMP_DIR, zip_file_name)


def get_build_job_raw(build_job_id):
    """
    Return given build job as active record.
    """
    return base_service.get_instance(
        BuildJob, build_job_id, BuildJobNotFoundException
    )


def get_build_job(build_job_id):
    """
    Return given build job as a dict.
    """
    return get_build_job_raw(build_job_id).serialize()


def remove_playlist(playlist_id):
    """
    Remove given playlist from database (and delete related build jobs).
    """
    playlist = get_playlist_raw(playlist_id)
    playlist_dict = playlist.serialize()
    jobs = BuildJob.query.filter_by(playlist_id=playlist_id).all()
    for job in jobs:
        _remove_build_job_impl(playlist_dict, job.serialize())
    playlist.delete()
    events.emit(
        "playlist:delete",
        {"playlist_id": playlist_dict["id"]},
        project_id=playlist_dict["project_id"],
    )
    return playlist_dict


def _remove_build_job_impl(playlist, job_dict):
    """
    Remove one build job (file + DB record + event). Caller must pass
    serialized job dict to avoid re-fetching when deleting multiple jobs.
    """
    build_job_id = job_dict["id"]
    movie_file_path = get_playlist_movie_file_path(job_dict)
    if os.path.exists(movie_file_path):
        os.remove(movie_file_path)
    if config.REMOVE_FILES:
        try:
            file_store.remove_movie("playlists", build_job_id)
        except Exception:
            current_app.logger.error(
                "Playlist file can't be deleted: %s" % build_job_id
            )
    job = BuildJob.get(build_job_id)
    if job is not None:
        job.delete()
    events.emit(
        "build-job:delete",
        {"build_job_id": build_job_id, "playlist_id": playlist["id"]},
        project_id=playlist["project_id"],
    )
    return movie_file_path


def remove_build_job(playlist, build_job_id):
    """
    Remove build job from database and remove related temporary file from
    hard drive.
    """
    job = BuildJob.get(build_job_id)
    if job is None:
        return None
    return _remove_build_job_impl(playlist, job.serialize())


def get_build_jobs_for_project(project_id):
    """
    Return all build_jobs for given project.
    """
    build_jobs = BuildJob.query.join(Playlist).filter(
        Playlist.project_id == project_id
    )
    return fields.serialize_list(build_jobs)


def get_playlists_for_project(project_id, page=0):
    """
    Return all playlists for given project.
    """
    query = Playlist.query.filter(Playlist.project_id == project_id)
    return query_utils.get_paginated_results(query, page, relations=True)


def generate_temp_playlist(task_ids, sort=True):
    """
    Generate the data structure of a playlist for a given task list. It doesn't
    persist anything. The goal is to build a temporary playlist used to see
    a quick preview of several shots.
    """
    if len(task_ids) == 0:
        return []

    task_id = task_ids[0]
    task = tasks_service.get_task(task_id)
    for_entity = entities_service.get_for_entity_from_task(task)
    task_type_links = projects_service.get_task_type_links(
        task["project_id"], for_entity
    )
    entities = []

    for task_id in task_ids:
        entity = generate_playlisted_entity_from_task(task_id, task_type_links)
        entities.append(entity)
    if len(entities) > 0:
        if not sort:
            return entities
        try:
            if "episode_name" in entities[0]:
                return sorted(entities, key=itemgetter("episode_name", "name"))
            elif "sequence_name" in entities[0]:
                return sorted(
                    entities, key=itemgetter("sequence_name", "name")
                )
            elif "asset_type_name" in entities[0]:
                return sorted(
                    entities, key=itemgetter("asset_type_name", "name")
                )
            else:
                return entities
        except Exception:
            return entities
    else:
        return []


def generate_playlisted_entity_from_task(task_id, task_type_links):
    """
    Generate the data structure of a playlisted shot for a given task. It
    doesn't persist anything.
    """
    task = tasks_service.get_task(task_id)
    entity = entities_service.get_entity(task["entity_id"])
    if shots_service.is_shot(entity):
        playlisted_entity = get_base_shot_for_playlist(entity, task_id)
    elif shots_service.is_sequence(entity):
        playlisted_entity = get_base_sequence_for_playlist(entity, task_id)
    elif shots_service.is_episode(entity):
        playlisted_entity = get_base_episode_for_playlist(entity, task_id)
    else:
        playlisted_entity = get_base_asset_for_playlist(entity, task_id)

    task_type_id = task["task_type_id"]
    preview_files = get_preview_files_for_entity(entity["id"])

    preview_file = None
    if task_type_id in preview_files and len(preview_files[task_type_id]) > 0:
        preview_file = preview_files[task_type_id][0]

    if preview_file is None and len(preview_files.keys()) > 0:
        available_task_types = [
            link["task_type_id"]
            for link in task_type_links
            if link["task_type_id"] in preview_files
        ]
        if len(available_task_types) > 0:
            task_type_id = available_task_types[0]
            preview_file = preview_files[task_type_id][0]

    if preview_file is not None:
        playlisted_entity.update(
            {
                "preview_file_id": preview_file["id"],
                "preview_file_extension": preview_file["extension"],
                "preview_file_width": preview_file["width"],
                "preview_file_height": preview_file["height"],
                "preview_file_duration": preview_file["duration"],
                "preview_file_revision": preview_file["revision"],
                "preview_file_status": preview_file["status"],
                "preview_file_annotations": preview_file["annotations"] or [],
                "preview_file_previews": preview_file["previews"],
            }
        )
    playlisted_entity["preview_files"] = preview_files
    return playlisted_entity


def get_base_episode_for_playlist(entity, task_id):
    episode = shots_service.get_episode(entity["id"])
    playlisted_entity = {
        "id": episode["id"],
        "name": episode["name"],
        "preview_file_task_id": task_id,
        "sequence_id": "",
        "sequence_name": "",
        "parent_name": "",
    }
    return playlisted_entity


def get_base_sequence_for_playlist(entity, task_id):
    sequence = shots_service.get_sequence(entity["id"])
    episode = None
    try:
        episode = shots_service.get_episode(sequence["parent_id"])
    except Exception as e:
        logger.debug(
            "No episode for sequence parent_id=%s: %s",
            sequence.get("parent_id"),
            e,
        )
    if episode is not None:
        playlisted_entity = {
            "id": sequence["id"],
            "name": sequence["name"],
            "preview_file_task_id": task_id,
            "episode_id": episode["id"],
            "episode_name": episode["name"],
            "parent_name": episode["name"],
        }
    else:
        playlisted_entity = {
            "id": sequence["id"],
            "name": sequence["name"],
            "preview_file_task_id": task_id,
            "sequence_id": "",
            "sequence_name": "",
            "parent_name": "",
        }
    return playlisted_entity


def get_base_shot_for_playlist(entity, task_id):
    shot = shots_service.get_shot(entity["id"])
    sequence = shots_service.get_sequence(shot["parent_id"])
    playlisted_entity = {
        "id": shot["id"],
        "name": shot["name"],
        "preview_file_task_id": task_id,
        "sequence_id": sequence["id"],
        "sequence_name": sequence["name"],
        "parent_name": sequence["name"],
    }
    return playlisted_entity


def get_base_asset_for_playlist(entity, task_id):
    asset = assets_service.get_asset(entity["id"])
    asset_type = assets_service.get_asset_type(asset["entity_type_id"])
    playlisted_entity = {
        "id": asset["id"],
        "name": asset["name"],
        "preview_file_task_id": task_id,
        "asset_type_id": asset_type["id"],
        "asset_type_name": asset_type["name"],
        "parent_name": asset_type["name"],
    }
    return playlisted_entity


def get_preview_files_for_task(task_id):
    """
    Return all preview file active records for given task.
    """
    preview_files = (
        PreviewFile.query.filter_by(task_id=task_id)
        .order_by(PreviewFile.revision.desc())
        .all()
    )
    return _get_playlist_preview_file_list(preview_files)


def _get_playlist_preview_file_list(preview_files):
    """
    Turn preview file active records into preview file dict that match the
    playlist data structure.
    """
    return [
        {
            "id": str(preview_file.id),
            "revision": preview_file.revision,
            "extension": preview_file.extension,
            "width": preview_file.width,
            "height": preview_file.height,
            "duration": float(preview_file.duration or 0),
            "status": str(preview_file.status),
            "annotations": preview_file.annotations,
            "created_at": fields.serialize_value(preview_file.created_at),
            "task_id": str(preview_file.task_id),
        }
        for preview_file in preview_files
    ]
