# Kudos to Werner Robitza, AVEQ GmbH, for helping with ffmpeg
# related content

import hashlib
import json
import os
import random
import shutil
import subprocess
import tempfile
from fractions import Fraction

import filetype
from django.conf import settings

CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

CRF_ENCODING_NUM_SECONDS = 2  # 0 * 60 # videos with greater duration will get
# CRF encoding and not two-pass
# Encoding individual chunks may yield quality variations if you use a
# too low bitrate, so if you go for the chunk-based variant
# you should use CRF encoding.

MAX_RATE_MULTIPLIER = 1.5
MIN_RATE_MULTIPLIER = 0.5

BUF_SIZE_MULTIPLIER = 1.5

# in seconds, anything between 2 and 6 makes sense
KEYFRAME_DISTANCE = 4
KEYFRAME_DISTANCE_MIN = 2

# speed presets
# see https://trac.ffmpeg.org/wiki/Encode/H.264
X26x_PRESET = "medium"  # "medium"
X265_PRESET = "medium"
X26x_PRESET_BIG_HEIGHT = "faster"

# VP9_SPEED = 1  # between 0 and 4, lower is slower
VP9_SPEED = 2


VIDEO_CRFS = {
    "h264_baseline": 23,
    "h264": 23,
    "h265": 28,
    "vp9": 32,
}

# video rates for 25 or 60 fps input, for different codecs, in kbps
VIDEO_BITRATES = {
    "h264": {
        25: {
            240: 300,
            360: 500,
            480: 1000,
            720: 2500,
            1080: 4500,
            1440: 9000,
            2160: 18000,
        },
        60: {720: 3500, 1080: 7500, 1440: 18000, 2160: 40000},
    },
    "h265": {
        25: {
            240: 150,
            360: 275,
            480: 500,
            720: 1024,
            1080: 1800,
            1440: 4500,
            2160: 10000,
        },
        60: {720: 1800, 1080: 3000, 1440: 8000, 2160: 18000},
    },
    "vp9": {
        25: {
            240: 150,
            360: 275,
            480: 500,
            720: 1024,
            1080: 1800,
            1440: 4500,
            2160: 10000,
        },
        60: {720: 1800, 1080: 3000, 1440: 8000, 2160: 18000},
    },
}


AUDIO_ENCODERS = {"h264": "aac", "h265": "aac", "vp9": "libopus"}

AUDIO_BITRATES = {"h264": 128, "h265": 128, "vp9": 96}

EXTENSIONS = {"h264": "mp4", "h265": "mp4", "vp9": "webm"}

VIDEO_PROFILES = {"h264": "main", "h265": "main"}


def get_portal_workflow():
    return settings.PORTAL_WORKFLOW


def get_default_state(user=None):
    # possible states given the portal workflow setting
    state = "private"
    if settings.PORTAL_WORKFLOW == "public":
        state = "public"
    if settings.PORTAL_WORKFLOW == "unlisted":
        state = "unlisted"
    if settings.PORTAL_WORKFLOW == "private_verified":
        if user and user.advancedUser:
            state = "unlisted"
    return state


def get_file_name(filename):
    return filename.split("/")[-1]


def get_file_type(filename):
    if not os.path.exists(filename):
        return None
    file_type = None
    kind = filetype.guess(filename)
    if kind is not None:
        if kind.mime.startswith("video"):
            file_type = "video"
        elif kind.mime.startswith("image"):
            file_type = "image"
        elif kind.mime.startswith("audio"):
            file_type = "audio"
        elif "pdf" in kind.mime:
            file_type = "pdf"
    else:
        # TODO: do something for files not supported by filetype lib
        pass
    return file_type


def rm_file(filename):
    if os.path.isfile(filename):
        try:
            os.remove(filename)
            return True
        except OSError:
            pass
    return False


def rm_files(filenames):
    if isinstance(filenames, list):
        for filename in filenames:
            rm_file(filename)
    return True


def rm_dir(directory):
    if os.path.isdir(directory):
        # refuse to delete a dir inside project BASE_DIR
        if directory.startswith(settings.BASE_DIR):
            try:
                shutil.rmtree(directory)
                return True
            except (FileNotFoundError, PermissionError):
                pass
    return False


def url_from_path(filename):
    # TODO: find a way to preserver http - https ...
    return "{0}{1}".format(settings.MEDIA_URL, filename.replace(settings.MEDIA_ROOT, ""))


def create_temp_file(suffix=None, dir=settings.TEMP_DIRECTORY):
    tf = tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=dir)
    return tf.name


def create_temp_dir(suffix=None, dir=settings.TEMP_DIRECTORY):
    td = tempfile.mkdtemp(dir=dir)
    return td


def produce_friendly_token(token_len=settings.FRIENDLY_TOKEN_LEN):
    token = ""
    while len(token) != token_len:
        token += CHARS[random.randint(0, len(CHARS) - 1)]
    return token


def clean_friendly_token(token):
    # cleans token
    for char in token:
        if char not in CHARS:
            token.replace(char, "")
    return token


def mask_ip(ip_address):
    return hashlib.md5(ip_address.encode("utf-8")).hexdigest()


def run_command(cmd, cwd=None):
    """
    Run a command directly
    """
    if isinstance(cmd, str):
        cmd = cmd.split()
    ret = {}
    if cwd:
        process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd)
    else:
        process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    stdout, stderr = process.communicate()
    # TODO: catch unicodedecodeerrors here...
    if process.returncode == 0:
        try:
            ret["out"] = stdout.decode("utf-8")
        except BaseException:
            ret["out"] = ""
        try:
            ret["error"] = stderr.decode("utf-8")
        except BaseException:
            ret["error"] = ""
    else:
        try:
            ret["error"] = stderr.decode("utf-8")
        except BaseException:
            ret["error"] = ""
    return ret


def media_file_info(input_file):
    """
    Get the info about an input file, as determined by ffprobe

    Returns a dict, with the keys:
    - `filename`: Filename
    - `file_size`: Size of the file in bytes
    - `video_duration`: Duration of the video in `s.msec`
    - `video_frame_rate_d`: Framerate franction denominator
    - `video_frame_rate_n`: Framerate fraction nominator
    - `video_bitrate`: Bitrate of the video stream in kBit/s
    - `video_width`: Width in pixels
    - `video_height`: Height in pixels
    - `interlaced` : True if the video is interlaced
    - `video_codec`: Video codec
    - `audio_duration`: Duration of the audio in `s.msec`
    - `audio_sample_rate`: Audio sample rate in Hz
    - `audio_codec`: Audio codec name (`aac`)
    - `audio_bitrate`: Bitrate of the video stream in kBit/s

    Also returns the video and audio info raw from ffprobe.
    """
    ret = {}

    if not os.path.isfile(input_file):
        ret["fail"] = True
        return ret

    video_info = {}
    audio_info = {}
    cmd = ["stat", "-c", "%s", input_file]

    stdout = run_command(cmd).get("out")
    if stdout:
        file_size = int(stdout.strip())
    else:
        ret["fail"] = True
        return ret

    cmd = ["md5sum", input_file]
    stdout = run_command(cmd).get("out")
    if stdout:
        md5sum = stdout.split()[0]
    else:
        md5sum = ""

    cmd = [
        settings.FFPROBE_COMMAND,
        "-loglevel",
        "error",
        "-show_streams",
        "-show_entries",
        "format=format_name",
        "-of",
        "json",
        input_file,
    ]
    stdout = run_command(cmd).get("out")
    try:
        info = json.loads(stdout)
    except TypeError:
        ret["fail"] = True
        return ret

    has_video = False
    has_audio = False
    for stream_info in info["streams"]:
        if stream_info["codec_type"] == "video":
            video_info = stream_info
            has_video = True
            if info.get("format") and info["format"].get("format_name", "") in [
                "tty",
                "image2",
                "image2pipe",
                "bin",
                "png_pipe",
                "gif",
            ]:
                ret["fail"] = True
                return ret
        elif stream_info["codec_type"] == "audio":
            audio_info = stream_info
            has_audio = True

    if not has_video:
        ret["is_video"] = False
        ret["is_audio"] = has_audio
        ret["audio_info"] = audio_info
        return ret

    if "duration" in video_info.keys():
        video_duration = float(video_info["duration"])
    elif "tags" in video_info.keys() and "DURATION" in video_info["tags"]:
        duration_str = video_info["tags"]["DURATION"]
        try:
            hms, msec = duration_str.split(".")
        except ValueError:
            hms, msec = duration_str.split(",")

        total_dur = sum(int(x) * 60**i for i, x in enumerate(reversed(hms.split(":"))))
        video_duration = total_dur + float("0." + msec)
    else:
        # fallback to format, eg for webm
        cmd = [
            settings.FFPROBE_COMMAND,
            "-loglevel",
            "error",
            "-show_format",
            "-of",
            "json",
            input_file,
        ]
        stdout = run_command(cmd).get("out")
        format_info = json.loads(stdout)["format"]
        try:
            video_duration = float(format_info["duration"])
        except KeyError:
            ret["fail"] = True
            return ret

    if "bit_rate" in video_info.keys():
        video_bitrate = round(float(video_info["bit_rate"]) / 1024.0, 2)
    else:
        cmd = [
            settings.FFPROBE_COMMAND,
            "-loglevel",
            "error",
            "-select_streams",
            "v",
            "-show_entries",
            "packet=size",
            "-of",
            "compact=p=0:nk=1",
            input_file,
        ]
        stdout = run_command(cmd).get("out")
        stream_size = sum([int(line) for line in stdout.split("\n") if line != ""])
        video_bitrate = round((stream_size * 8 / 1024.0) / video_duration, 2)

    if "r_frame_rate" in video_info.keys():
        video_frame_rate = video_info["r_frame_rate"].partition("/")
        video_frame_rate_n = video_frame_rate[0]
        video_frame_rate_d = video_frame_rate[2]

    interlaced = False
    if video_info.get("field_order") in ("tt", "tb", "bt", "bb"):
        interlaced = True

    ret = {
        "filename": input_file,
        "file_size": file_size,
        "video_duration": video_duration,
        "video_frame_rate_n": video_frame_rate_n,
        "video_frame_rate_d": video_frame_rate_d,
        "video_bitrate": video_bitrate,
        "video_width": video_info["width"],
        "video_height": video_info["height"],
        "video_codec": video_info["codec_name"],
        "has_video": has_video,
        "has_audio": has_audio,
        "color_range": video_info.get("color_range"),
        "color_space": video_info.get("color_space"),
        "color_transfer": video_info.get("color_space"),
        "color_primaries": video_info.get("color_primaries"),
        "interlaced": interlaced,
        "display_aspect_ratio": video_info.get("display_aspect_ratio"),
        "sample_aspect_ratio": video_info.get("sample_aspect_ratio"),
    }

    if has_audio:
        if "duration" in audio_info.keys():
            audio_duration = float(audio_info["duration"])
        elif "tags" in audio_info.keys() and "DURATION" in audio_info["tags"]:
            duration_str = audio_info["tags"]["DURATION"]
            try:
                hms, msec = duration_str.split(".")
            except ValueError:
                hms, msec = duration_str.split(",")
            total_dur = sum(int(x) * 60**i for i, x in enumerate(reversed(hms.split(":"))))
            audio_duration = total_dur + float("0." + msec)
        else:
            # fallback to format, eg for webm
            cmd = [
                settings.FFPROBE_COMMAND,
                "-loglevel",
                "error",
                "-show_format",
                "-of",
                "json",
                input_file,
            ]
            stdout = run_command(cmd).get("out")
            format_info = json.loads(stdout)["format"]
            audio_duration = float(format_info["duration"])

        if "bit_rate" in audio_info.keys():
            audio_bitrate = round(float(audio_info["bit_rate"]) / 1024.0, 2)
        else:
            # fall back to calculating from accumulated frame duration
            cmd = [
                settings.FFPROBE_COMMAND,
                "-loglevel",
                "error",
                "-select_streams",
                "a",
                "-show_entries",
                "packet=size",
                "-of",
                "compact=p=0:nk=1",
                input_file,
            ]
            stdout = run_command(cmd).get("out")
            # ffprobe appends a pipe at the end of the output, thus we have to remove it
            stream_size = sum([int(line.replace("|", "")) for line in stdout.split("\n") if line != ""])
            audio_bitrate = round((stream_size * 8 / 1024.0) / audio_duration, 2)

        ret.update(
            {
                "audio_duration": audio_duration,
                "audio_sample_rate": audio_info["sample_rate"],
                "audio_codec": audio_info["codec_name"],
                "audio_bitrate": audio_bitrate,
                "audio_channels": audio_info["channels"],
            }
        )

    ret["video_info"] = video_info
    ret["audio_info"] = audio_info
    ret["is_video"] = True
    ret["md5sum"] = md5sum
    return ret


def calculate_seconds(duration):
    # returns seconds, given a ffmpeg extracted string
    ret = 0
    if isinstance(duration, str):
        duration = duration.split(":")
        if len(duration) != 3:
            return ret
    else:
        return ret

    ret += int(float(duration[2]))
    ret += int(float(duration[1])) * 60
    ret += int(float(duration[0])) * 60 * 60
    return ret


def show_file_size(size):
    if size:
        size = size / 1000000
        size = round(size, 1)
        size = "{0}MB".format(str(size))
    return size


def get_base_ffmpeg_command(
    input_file,
    output_file,
    has_audio,
    codec,
    encoder,
    audio_encoder,
    target_fps,
    interlaced,
    target_height,
    target_rate,
    target_rate_audio,
    pass_file,
    pass_number,
    enc_type,
    chunk,
):
    """Get the base command for a specific codec, height/rate, and pass

    Arguments:
        input_file {str} -- input file name
        output_file {str} -- output file name
        has_audio {bool} -- does the input have audio?
        codec {str} -- video codec
        encoder {str} -- video encoder
        audio_encoder {str} -- audio encoder
        target_fps {fractions.Fraction} -- target FPS
        interlaced {bool} -- true if interlaced
        target_height {int} -- height
        target_rate {int} -- target bitrate in kbps
        target_rate_audio {int} -- audio target bitrate
        pass_file {str} -- path to temp pass file
        pass_number {int} -- number of passes
        enc_type {str} -- encoding type (twopass or crf)
    """

    # avoid very high frame rates
    while target_fps > 60:
        target_fps = target_fps / 2

    if target_fps < 1:
        target_fps = 1

    filters = []

    if interlaced:
        filters.append("yadif")

    target_width = round(target_height * 16 / 9)
    scale_filter_opts = [
        f"if(lt(iw\\,ih)\\,{target_height}\\,{target_width})",  # noqa
        f"if(lt(iw\\,ih)\\,{target_width}\\,{target_height})",  # noqa
        "force_original_aspect_ratio=decrease",
        "force_divisible_by=2",
        "flags=lanczos",
    ]
    scale_filter_str = "scale=" + ":".join(scale_filter_opts)
    filters.append(scale_filter_str)

    fps_str = f"fps=fps={target_fps}"
    filters.append(fps_str)

    filters_str = ",".join(filters)

    base_cmd = [
        settings.FFMPEG_COMMAND,
        "-y",
        "-i",
        input_file,
        "-c:v",
        encoder,
        "-filter:v",
        filters_str,
        "-pix_fmt",
        "yuv420p",
    ]

    if enc_type == "twopass":
        base_cmd.extend(["-b:v", str(target_rate) + "k"])
    elif enc_type == "crf":
        base_cmd.extend(["-crf", str(VIDEO_CRFS[codec])])
        if encoder == "libvpx-vp9":
            base_cmd.extend(["-b:v", str(target_rate) + "k"])

    if has_audio:
        base_cmd.extend(
            [
                "-c:a",
                audio_encoder,
                "-b:a",
                str(target_rate_audio) + "k",
                # stereo audio only, see https://trac.ffmpeg.org/ticket/5718
                "-ac",
                "2",
            ]
        )

    # get keyframe distance in frames
    keyframe_distance = int(target_fps * KEYFRAME_DISTANCE)

    # start building the command
    cmd = base_cmd[:]

    # preset settings
    if encoder == "libvpx-vp9":
        if pass_number == 1:
            speed = 4
        else:
            speed = VP9_SPEED
    elif encoder in ["libx264"]:
        preset = X26x_PRESET
    elif encoder in ["libx265"]:
        preset = X265_PRESET
    if target_height >= 720:
        preset = X26x_PRESET_BIG_HEIGHT

    if encoder == "libx264":
        level = "4.2" if target_height <= 1080 else "5.2"

        x264_params = [
            "keyint=" + str(keyframe_distance * 2),
            "keyint_min=" + str(keyframe_distance),
        ]

        cmd.extend(
            [
                "-maxrate",
                str(int(int(target_rate) * MAX_RATE_MULTIPLIER)) + "k",
                "-bufsize",
                str(int(int(target_rate) * BUF_SIZE_MULTIPLIER)) + "k",
                "-force_key_frames",
                "expr:gte(t,n_forced*" + str(KEYFRAME_DISTANCE) + ")",
                "-x264-params",
                ":".join(x264_params),
                "-preset",
                preset,
                "-profile:v",
                VIDEO_PROFILES[codec],
                "-level",
                level,
            ]
        )

        if enc_type == "twopass":
            cmd.extend(["-passlogfile", pass_file, "-pass", pass_number])

    elif encoder == "libx265":
        x265_params = [
            "vbv-maxrate=" + str(int(int(target_rate) * MAX_RATE_MULTIPLIER)),
            "vbv-bufsize=" + str(int(int(target_rate) * BUF_SIZE_MULTIPLIER)),
            "keyint=" + str(keyframe_distance * 2),
            "keyint_min=" + str(keyframe_distance),
        ]

        if enc_type == "twopass":
            x265_params.extend(["stats=" + str(pass_file), "pass=" + str(pass_number)])

        cmd.extend(
            [
                "-force_key_frames",
                "expr:gte(t,n_forced*" + str(KEYFRAME_DISTANCE) + ")",
                "-x265-params",
                ":".join(x265_params),
                "-preset",
                preset,
                "-profile:v",
                VIDEO_PROFILES[codec],
            ]
        )
    elif encoder == "libvpx-vp9":
        cmd.extend(
            [
                "-g",
                str(keyframe_distance),
                "-keyint_min",
                str(keyframe_distance),
                "-maxrate",
                str(int(int(target_rate) * MAX_RATE_MULTIPLIER)) + "k",
                "-minrate",
                str(int(int(target_rate) * MIN_RATE_MULTIPLIER)) + "k",
                "-bufsize",
                str(int(int(target_rate) * BUF_SIZE_MULTIPLIER)) + "k",
                "-speed",
                speed,
                #            '-deadline', 'realtime',
            ]
        )

        if enc_type == "twopass":
            cmd.extend(["-passlogfile", pass_file, "-pass", pass_number])

    cmd.extend(
        [
            "-strict",
            "-2",
        ]
    )

    # end of the command
    if pass_number == 1:
        cmd.extend(["-an", "-f", "null", "/dev/null"])
    elif pass_number == 2:
        if output_file.endswith("mp4") and chunk:
            cmd.extend(["-movflags", "+faststart"])
        cmd.extend([output_file])

    return cmd


def produce_ffmpeg_commands(media_file, media_info, resolution, codec, output_filename, pass_file, chunk=False):
    try:
        media_info = json.loads(media_info)
    except BaseException:
        media_info = {}

    if codec == "h264":
        encoder = "libx264"
        # ext = "mp4"
    elif codec in ["h265", "hevc"]:
        encoder = "libx265"
        # ext = "mp4"
    elif codec == "vp9":
        encoder = "libvpx-vp9"
        # ext = "webm"
    else:
        return False

    target_fps = Fraction(int(media_info.get("video_frame_rate_n", 30)), int(media_info.get("video_frame_rate_d", 1)))
    if target_fps <= 30:
        target_rate = VIDEO_BITRATES[codec][25].get(resolution)
    else:
        target_rate = VIDEO_BITRATES[codec][60].get(resolution)
    if not target_rate:  # INVESTIGATE MORE!
        target_rate = VIDEO_BITRATES[codec][25].get(resolution)
    if not target_rate:
        return False

    if media_info.get("video_height") < resolution:
        if resolution not in [240, 360]:  # always get these two
            return False

    #    if codec == "h264_baseline":
    #        target_fps = 25
    #    else:

    if media_info.get("video_duration") > CRF_ENCODING_NUM_SECONDS:
        enc_type = "crf"
    else:
        enc_type = "twopass"

    if enc_type == "twopass":
        passes = [1, 2]
    elif enc_type == "crf":
        passes = [2]

    interlaced = media_info.get("interlaced")

    cmds = []
    for pass_number in passes:
        cmds.append(
            get_base_ffmpeg_command(
                media_file,
                output_file=output_filename,
                has_audio=media_info.get("has_audio"),
                codec=codec,
                encoder=encoder,
                audio_encoder=AUDIO_ENCODERS[codec],
                target_fps=target_fps,
                interlaced=interlaced,
                target_height=resolution,
                target_rate=target_rate,
                target_rate_audio=AUDIO_BITRATES[codec],
                pass_file=pass_file,
                pass_number=pass_number,
                enc_type=enc_type,
                chunk=chunk,
            )
        )
    return cmds


def clean_query(query):
    """This is used to clear text in order to comply with SearchQuery
    known exception cases

    :param query: str - the query text that we want to clean
    :return:
    """

    if not query:
        return ""

    chars = ["^", "{", "}", "&", "|", "<", ">", '"', ")", "(", "!", ":", ";", "'", "#"]
    for char in chars:
        query = query.replace(char, "")

    return query.lower()


def get_alphanumeric_only(string):
    """Returns a query that contains only alphanumeric characters
    This include characters other than the English alphabet too
    """
    string = "".join([char for char in string if char.isalnum()])
    return string.lower()
