#!/usr/bin/python3
# TODO: type check inputs
# TODO: generate an easily reviewable section of transitions

import csv
import datetime
import os
import shutil
import subprocess
import sys
import decimal
from optparse import OptionParser


class Song:
    def __init__(self, num=None, file=None, start_time=None, fadein_length=None,
                 end_time=None, fadeout_length=None, prev_talkover=None, title='',
                 artist=''):
        self.num = num
        self.file = file
        self.start_time = start_time
        self.fadein_length = fadein_length
        self.end_time = end_time
        self.fadeout_length = fadeout_length
        self.prev_talkover = prev_talkover
        self.title = title
        self.artist = artist


def main(argv):
    global DEBUG
    DEBUG = False
    global VERBOSE
    VERBOSE = False
    global pipeoutput
    pipeoutput = subprocess.DEVNULL

    usage = """usage: %prog [options]\n
column headers for playlist.csv are\n
file, start_time, fadein_length, end_time, fadeout_length, title, artist, prev_talkover\n
of which only file is required"""

    parser = OptionParser(usage=usage)
    parser.add_option("-a", "--album",
                      dest="album", metavar="Album Name",
                      help="If --id3tag is selected, use this for album title")

    parser.add_option("-b", "--begin", type="int",
                      dest="begin", metavar="STARTING_TRACK_NUM",
                      help="Process only from this track in cuesheet")

    parser.add_option("-d", "--debug",
                      action="store_true", dest="DEBUG",
                      help="execute extra, debugging instructions and reports")

    parser.add_option("-e", "--end", type="int",
                      dest="end", metavar="ENDING_TRACK_NUM",
                      help="Process only to this track in cuesheet")

    parser.add_option("-i", "--id3tag",
                      action="store_true", dest="id3tag",
                      help="Add ID3 information.  Uses title if present in\
                      cue sheet, or else file name.")

    parser.add_option("-m", "--mp3",
                      action="store_true", dest="output_mp3",
                      help="Output MP3 files instead of wav")

    parser.add_option("-o", "--output_dir",
                      dest="output_dir", default="output", metavar="RELATIVE_PATH",
                      help="Location of output directory relative to working directory.")

    parser.add_option("-p", "--playlist",
                      dest="playlist_path", metavar="PATH", default="playlist.csv",
                      help="Read playlist csv from PATH.")

    parser.add_option("-r", "--version_tag",
                      dest="version", metavar="VERSION_STRING",
                      help="Add this version number string to the ID3 comment tag.")

    parser.add_option("-s", "--source_dir",
                      dest="source_dir", metavar="PATH",
                      help="Path prepended to each filename in playlist.")

    parser.add_option("-v", "--verbose",
                      action="store_true", dest="VERBOSE",
                      help="Show extra messages during execution.")

    (options, args) = parser.parse_args()

    if options.DEBUG:
        DEBUG = True
        pipeoutput = None

    if options.VERBOSE:
        VERBOSE = True

    working_path = os.getcwd()
    temp_path = subprocess.run('mktemp -d',
                               shell=True,
                               check=True,
                               stdout=subprocess.PIPE)\
                          .stdout.decode("utf-8").strip()

    output_dir = os.path.join(working_path, options.output_dir)

    output_dir_clear_wildcard = os.path.join(output_dir, '*')
    # TODO: should probably require a flag to do this without warning
    if os.path.isdir(output_dir):
        subprocess.call('rm {0}'.format(output_dir_clear_wildcard),
                        shell=True)

    if options.source_dir:
        if os.path.isdir(options.source_dir):
            source_dir = os.path.join(working_path, options.source_dir)
        else:
            print('Output directory {0} is not a directory'.format(source_dir))
            sys.exit(2)
    else:
        source_dir = ''

    try:
        id3tag = options.id3tag
    except AttributeError:
        id3tag = None
    try:
        album = options.album
    except AttributeError:
        album = None
    try:
        version_tag = options.version_tag
    except AttributeError:
        version_tag = None

    if not os.path.isfile(options.playlist_path):
        print('Missing playlist file "{0}"'.format(options.playlist_path))
        sys.exit(2)

    playlist = load_playlist(options.playlist_path, source_dir,
                             options.begin, options.end)

    convert_and_renumber(playlist, temp_path)
    normalize(temp_path)
    fade_and_crop(playlist, temp_path)
    if options.output_mp3:
        convert_to_mp3(playlist,
                       temp_path,
                       album,
                       id3tag,
                       version_tag)
    move_to_output(temp_path, output_dir)
    if os.path.isdir(temp_path):
        shutil.rmtree(temp_path)
    log('Done')


def load_playlist(playlist_file, source_dir, begin, end):
    playlist = []

    with open(playlist_file, 'rt') as f:
        reader = csv.DictReader(f)
        counter = 0

        for line in reader:
            try:
                file = line['file']
            except KeyError:
                file = ''

            if not file or file[0] == '#':
                continue
            else:
                file = os.path.join(source_dir, file)

            if not os.path.isfile(file):
                print('Could not find file {0}'.format(file))
                continue

            try:
                start_time = line['start_time']
            except KeyError:
                start_time = ''
            try:
                fadein_length = line['fadein_length']
            except KeyError:
                fadein_length = ''
            try:
                end_time = line['end_time']
            except KeyError:
                end_time = ''
            try:
                fadeout_length = line['fadeout_length']
            except KeyError:
                fadeout_length = ''
            try:
                title = line['title']
            except KeyError:
                title = ''
            try:
                artist = line['artist']
            except KeyError:
                artist = ''
            try:
                prev_talkover = decimal.Decimal(line['prev_talkover'])
            except (KeyError, TypeError, decimal.InvalidOperation):
                prev_talkover = ''

            playlist.append(Song(counter,
                                 file,
                                 start_time,
                                 fadein_length,
                                 end_time,
                                 fadeout_length,
                                 prev_talkover,
                                 title,
                                 artist))
            counter += 1

    if begin or end:
        if not begin:
            begin = 0
        if not end:
            end = len(playlist)
        playlist = playlist[begin:end]

    return playlist


def convert_and_renumber(playlist, temp_path):
    for song in playlist:
        from_path = song.file
        to_path = num_concat(temp_path, song.num, 'wav')
        log('Converting {0}'.format(song.file))
        subprocess.call('ffmpeg -i "{0}" -c:a pcm_s16le -vn "{1}"'.
                        format(from_path, to_path),
                        shell=True,
                        stdout=pipeoutput,
                        stderr=pipeoutput)


def fade_and_crop(playlist, temp_path):
    # sox trim: Any number of positions may be given; ...  The effect
    # then alternates between copying and discarding audio at each
    # position.  If a position is preceded by an equals or minus sign,
    # it is interpreted relative to the beginning or the end of the
    # audio, respectively.  ... Otherwise, it is considered an offset
    # from the last position, or from the start of audio for the first
    # parameter.  Using a value of 0 for the first position parameter
    # allows copying from the beginning of the audio.

    # sox fade: fade [type] fade-in-length [stop-time
    # [fade-out-length]] Apply a fade effect to the beginning, end, or
    # both of the audio.  An optional type can be specified to select
    # the shape of the fade curve: ... h for half a sine wave, l for
    # logarithmic ....  A fade-in starts from the first sample and
    # ramps the signal level from 0 to full volume over fade-in-length
    # seconds.  Specify 0 seconds if no fade-in is wanted.  For
    # fade-outs, the audio will be truncated at stop-time and the
    # signal level will be ramped from full volume down to 0 starting
    # at fade-out-length seconds before the stop-time. If
    # fade-out-length is not specified, it defaults to the same value
    # as fade-in-length.  No fade-out is performed if stop-time is not
    # specified.

    for song in playlist:
        from_path = num_concat(temp_path, song.num, 'wav')
        sox_path = num_concat(temp_path, song.num, 'sox.wav')
        log('Fading and cropping {0}'.format(song.file))
        trim_command = ''

        if song.start_time or song.end_time:
            if song.start_time:
                start_time = song.start_time
            else:
                start_time = '0'

            if song.fadein_length:
                fadein_length = song.fadein_length
                fadein_command = ' fade l {0}'.format(fadein_length)
            else:
                fadein_command = ''

            if song.end_time:
                end_time = song.end_time
                trim_command = ' trim {0} ={1}'.format(start_time, end_time)
            else:
                fadeout_length = ''
                trim_command = ' trim {0}'.format(start_time)

            if song.fadeout_length:
                fadeout_length = song.fadeout_length
                fadeout_command = ' fade h 0 {0} {1}'.\
                                  format(end_time, fadeout_length)
            else:
                fadeout_command = ''

            # fadeout, trim, and fadein commands are ordered so that
            # all of the timing inputs can remain relative to the
            # original file start time.
            sox_command = 'sox "{0}" "{1}"  {2} {3} {4}'.format(from_path,
                                                                sox_path,
                                                                fadeout_command,
                                                                trim_command,
                                                                fadein_command)

            if DEBUG:
                print(sox_command)
            subprocess.call(sox_command,
                            shell=True,
                            stdout=pipeoutput,
                            stderr=pipeoutput)
            subprocess.call('mv {0} {1}'.format(sox_path, from_path),
                            shell=True,
                            stdout=pipeoutput,
                            stderr=pipeoutput)  # TODO: use python function for this?

        if song.prev_talkover:
            if song.num == 0:
                continue  # can't fade the first song with a previous song
            prev_num = song.num - 1
            first_file = num_concat(temp_path, prev_num, 'wav')
            second_file = num_concat(temp_path, song.num, 'wav')
            fadeout_file = os.path.join(temp_path, 'fadeout.wav')
            fadein_file = os.path.join(temp_path, 'fadein.wav')
            crossfade_file = os.path.join(temp_path, 'crossfade.wav')
            crossfadeout_file = os.path.join(temp_path, 'crossfadeout.wav')
            crossfadein_file = os.path.join(temp_path, 'crossfadein.wav')
            first_inter_file = os.path.join(temp_path, 'first_inter.wav')
            second_inter_file = os.path.join(temp_path, 'second_inter.wav')
            first_final_file = os.path.join(temp_path, 'first_final.wav')
            second_final_file = os.path.join(temp_path, 'second_final.wav')

            first_length = subprocess.call('sox "{0}" -n stat | grep Length | cut -d : -f 2 | cut -f 1'.format(first_file),
                                         shell=True,
                                         stdout=pipeoutput,
                                         stderr=pipeoutput)
            trim_length = first_length - song.prev_talkover
            crossfade_split_length = song.prev_talkover / decimal.Decimal('2')
            subprocess.call('sox {0} -r 44100 {1} trim {2}'.format(first_file, fadeout_file, trim_length),
                            shell=True,
                            stdout=pipeoutput,
                            stderr=pipeoutput)
            subprocess.call('sox {0} -r 44100 {1} trim 0 {2}'.format(second_file, fadein_file, song.prev_talkover),
                            shell=True,
                            stdout=pipeoutput,
                            stderr=pipeoutput)
            subprocess.call('sox -V -m -v 1.0 {0} -v 1.0 {1} {2}'.format(fadeout_file, fadein_file, crossfade_file),
                            shell=True,
                            stdout=pipeoutput,
                            stderr=pipeoutput)
            subprocess.call('sox {0} {1} trim 0 {2}'.format(crossfade_file, crossfadeout_file, crossfade_split_length),
                            shell=True,
                            stdout=pipeoutput,
                            stderr=pipeoutput)
            subprocess.call('sox {0} {1} trim {2}'.format(crossfade_file, crossfadein_file, crossfade_split_length),
                            shell=True,
                            stdout=pipeoutput,
                            stderr=pipeoutput)
            subprocess.call('sox {0} -r 44100 {1} trim 0 {2}'.format(first_file, first_inter_file, trim_length),
                            shell=True,
                            stdout=pipeoutput,
                            stderr=pipeoutput)
            subprocess.call('sox {0} -r 44100 {1} trim {2}'.format(second_file, second_inter_file, song.prev_talkover),
                            shell=True,
                            stdout=pipeoutput,
                            stderr=pipeoutput)
            subprocess.call('sox -V {0} {1} {2}'.format(first_inter_file, crossfadeout_file, first_final_file),
                            shell=True,
                            stdout=pipeoutput,
                            stderr=pipeoutput)
            subprocess.call('sox -V {0} {1} {2}'.format(crossfadein_file, second_inter_file, second_final_file),
                            shell=True,
                            stdout=pipeoutput,
                            stderr=pipeoutput)
            subprocess.call('mv {0} {1}'.format(first_final_file, first_file),
                            shell=True,
                            stdout=pipeoutput,
                            stderr=pipeoutput)
            subprocess.call('mv {0} {1}'.format(second_final_file, second_file),
                            shell=True,
                            stdout=pipeoutput,
                            stderr=pipeoutput)

            try:
                os.remove(fadeout_file)
                os.remove(fadein_file)
                os.remove(crossfade_file)
                os.remove(crossfadeout_file)
                os.remove(crossfadein_file)
                os.remove(first_inter_file)
                os.remove(second_inter_file)
            except FileNotFoundError:
                pass


def log(message):
    if VERBOSE:
        print('{0}: {1}'.
              format(datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
                     message))


def num_concat(temp_path, num, suffix):
    num_pad = '{:0>2}'.format(num)
    return os.path.join(temp_path, '{0}.{1}'.format(num_pad, suffix))


def normalize(temp_path):
    orig_path = os.getcwd()
    os.chdir(temp_path)
    log('Normalizing')
    subprocess.call('normalize-audio -a -8dB *wav',
                    shell=True,
                    stdout=pipeoutput,
                    stderr=pipeoutput)
    os.chdir(orig_path)


def convert_to_mp3(playlist, temp_path, album, id3tag, version):
    track_count = len(playlist)
    for i, song in enumerate(playlist):
        from_path = num_concat(temp_path, song.num, 'wav')
        output_path = num_concat(temp_path, song.num, 'mp3')
        log('Converting to mp3'.format(song))
        subprocess.call('lame -vbr-new {0} {1}'.format(from_path, output_path),
                        shell=True,
                        stdout=pipeoutput,
                        stderr=pipeoutput)
        if id3tag:

            if version:
                version_arg = '--comment="version {0}"'.format(version)
            else:
                version_arg = ''

            num_pad = '{:0>2}'.format(i + 1)
            if song.title:
                track_title = song.title
            else:
                track_title = 'Track {0}'.format(num_pad)

            subprocess.call('eyeD3 --album="{0}"  --title="{1}" --artist="{2}" --track="{3}" --track-total="{4}" {5} "{6}"'.format(album, track_title, song.artist, num_pad, track_count, version_arg, output_path),  # noqa
                            shell=True,
                            stdout=pipeoutput,
                            stderr=pipeoutput)

        os.remove(from_path)


def move_to_output(temp_path, output_path):
    from_path = os.path.join(temp_path, '*')
    if not os.path.isdir(output_path):
        os.mkdir(output_path)

    subprocess.call('mv {0} {1}'.format(from_path, output_path),
                    shell=True,
                    stdout=pipeoutput,
                    stderr=pipeoutput)


if __name__ == "__main__":
    main(sys.argv[1:])
