/* vgmstream123.c
 *
 * Simple player frontend for vgmstream
 * Copyright (c) 2017 Daniel Richard G. <skunk@iSKUNK.ORG>
 *
 * Permission to use, copy, modify, and distribute this software for any
 * purpose with or without fee is hereby granted, provided that the above
 * copyright notice and this permission notice appear in all copies.
 *
 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 */

#ifdef HAVE_CONFIG_H
# include <config.h>
#endif

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <getopt.h>
#include <ao/ao.h>
#include <sys/time.h>
#ifdef WIN32
# include <io.h>
# include <fcntl.h>
#else
# include <signal.h>
# include <unistd.h>
# include <sys/wait.h>
#endif

#include "../src/vgmstream.h"
#include "../src/plugins.h"


#include "../version.h"
#ifndef VGMSTREAM_VERSION
#define VGMSTREAM_VERSION "unknown version " __DATE__
#endif
#define APP_NAME  "vgmstream123 player " VGMSTREAM_VERSION
#define APP_INFO  APP_NAME " (" __DATE__ ")"

 
//TODO: improve WIN32 builds (some features/behaviors are missing but works)
#ifdef WIN32
#define getline(line, line_mem, f)  0
#define mkdtemp(temp_dir)  0
#define signal(sig, interrupt_handler)  /*nothing*/
#define WIFSIGNALED(ret)  0
#define WTERMSIG(ret)  0
#define SIGQUIT  0
#define SIGINT  0
#define SIGHUP  0
#endif

/* If two interrupts (i.e. Ctrl-C) are received
 * within a span of this many seconds, then exit
 */
#define DOUBLE_INTERRUPT_TIME 1.0

#define LITTLE_ENDIAN_OUTPUT 1 /* untested in BE */


#define DEFAULT_PARAMS { 0, -1, 2.0, 10.0, 0.0,   0, 0, 0, 0 }
typedef struct {
    int stream_index;

    double min_time;
    double loop_count;
    double fade_time;
    double fade_delay;

    int ignore_loop;
    int force_loop;
    int really_force_loop;
    int play_forever;
} song_settings_t;

static const char *out_filename = NULL;
static int driver_id;
static ao_device *device = NULL;
static ao_option *device_options = NULL;
static ao_sample_format current_sample_format;

static sample_t *buffer = NULL;
/* reportedly 1kb helps Raspberry Pi Zero play FFmpeg formats without stuttering
 * (presumably other low powered devices too), plus it's the default in other plugins */
static int buffer_size_kb = 1;

static int repeat = 0;
static int verbose = 0;

static volatile int interrupted = 0;
static double interrupt_time = 0.0;

static int play_file(const char *filename, song_settings_t *par);

static void interrupt_handler(int signum) {
    interrupted = 1;
}

static int record_interrupt(void) {
    int ret = 0;
    struct timeval tv = { 0, 0 };
    double t;

    if (gettimeofday(&tv, NULL))
        return -1;

    t = (double)tv.tv_sec + (double)tv.tv_usec / 1.0e6;

    if (t - interrupt_time < DOUBLE_INTERRUPT_TIME)
        ret = 1;

    interrupt_time = t;
    interrupted = 0;

    return ret;
}

static void usage(const char* progname) {
    song_settings_t default_par = DEFAULT_PARAMS;
    const char* default_driver = "???";

    {
        ao_info *info = ao_driver_info(driver_id);
        if (info)
            default_driver = info->short_name;
    }

    printf(APP_INFO "\n"
        "Usage: %s [options] <infile> ...\n"
        "\n"
        "Options:\n"
        "    -d DRV      Use output driver DRV [%s]; available drivers:\n"
        "                ",
        progname,
        default_driver);

    {
        ao_info **info_list;
        int driver_count = 0;
        int i;

        info_list = ao_driver_info_list(&driver_count);

        for (i = 0; i < driver_count; i++)
            printf("%s ", info_list[i]->short_name);
    }

    printf("\n"
        "    -f OUTFILE  Set output filename for a file driver specified with -d\n"
        "    -o KEY:VAL  Pass option KEY with value VAL to the output driver\n"
        "                (see https://www.xiph.org/ao/doc/drivers.html)\n"
        "    -b N        Use an audio buffer of N kilobytes [%d]\n"
        "    -@ LSTFILE  Read playlist from LSTFILE\n"
        "    -h          Print this help\n"
        "    -r          Repeat playback again (with fade, use -p for infinite loops)\n"
        "    -v          Display stream metadata and playback progress\n"
        "    -S N        Play substream with index N\n"
        "\n"
        "Looping options:\n"
        "    -M MINTIME  Loop for a playback time of at least MINTIME seconds\n"
        "    -L N        Loop N times [%.1f]\n"
        "    -F FTIME    End playback with a fade-out of FTIME seconds [%.1f]\n"
        "    -D FDELAY   Delay fade-out for an additional FDELAY seconds [%.1f]\n"
        "    -i          Ignore loop\n"
        "    -e          Force loop (loop only if file doesn't have loop points)\n"
        "    -E          Really force loop (repeat file)\n"
        "    -p          Play forever (loops file until stopped)\n"
        "\n"
        "<infile> can be any stream file type supported by vgmstream, or an .m3u/.m3u8\n"
        "playlist referring to same. This program supports the \"EXT-X-VGMSTREAM\" tag\n"
        "in playlists, and files compressed with gzip/bzip2/xz.\n",
        buffer_size_kb,
        default_par.loop_count,
        default_par.fade_time,
        default_par.fade_delay
    );
}

/* Opens the audio device with the appropriate parameters
 */
static int set_sample_format(int channels, int sample_rate) {
    ao_sample_format format;


    memset(&format, 0, sizeof(format));
    format.bits = 8 * sizeof(sample_t);
    format.channels = channels;
    format.rate = sample_rate;
    format.byte_format =
#if LITTLE_ENDIAN_OUTPUT
        AO_FMT_LITTLE
#else
        AO_FMT_BIG
#endif
    ;

    if (memcmp(&format, &current_sample_format, sizeof(format))) {

        /* Sample format has changed, so (re-)open audio device */

        ao_info *info = ao_driver_info(driver_id);
        if (!info) return -1;

        if ((info->type == AO_TYPE_FILE) != !!out_filename) {
            if (out_filename)
                fprintf(stderr, "Live output driver \"%s\" does not take an output file\n", info->short_name);
            else
                fprintf(stderr, "File output driver \"%s\" requires an output filename\n", info->short_name);
            return -1;
        }

        if (device)
            ao_close(device);

        memcpy(&current_sample_format, &format, sizeof(format));

        if (out_filename)
            device = ao_open_file(driver_id, out_filename, 1, &format, device_options);
        else
            device = ao_open_live(driver_id, &format, device_options);

        if (!device) {
            fprintf(stderr, "Error opening \"%s\" audio device\n", info->short_name);
            return -1;
        }
    }

    return 0;
}

static void apply_config(VGMSTREAM* vgmstream, song_settings_t* cfg) {
    vgmstream_cfg_t vcfg = {0};

    vcfg.allow_play_forever = 1;

    vcfg.play_forever = cfg->play_forever;
    vcfg.fade_time = cfg->fade_time;
    vcfg.loop_count = cfg->loop_count;
    vcfg.fade_delay = cfg->fade_delay;

    vcfg.ignore_loop  = cfg->ignore_loop;
    vcfg.force_loop = cfg->force_loop;
    vcfg.really_force_loop = cfg->really_force_loop;

    vgmstream_apply_config(vgmstream, &vcfg);
}

static int play_vgmstream(const char* filename, song_settings_t* cfg) {
    int ret = 0;
    STREAMFILE* sf;
    VGMSTREAM* vgmstream;
    FILE* save_fps[4];
    size_t buffer_size;
    int32_t max_buffer_samples;
    int i;
    int output_channels, input_channels;


    sf = open_stdio_streamfile(filename);
    if (!sf) {
        fprintf(stderr, "%s: cannot open file\n", filename);
        return -1;
    }

    vgmstream_set_log_stdout(VGM_LOG_LEVEL_ALL);

    sf->stream_index = cfg->stream_index;
    vgmstream = init_vgmstream_from_STREAMFILE(sf);
    close_streamfile(sf);

    if (!vgmstream) {
        fprintf(stderr, "%s: error opening stream\n", filename);
        return -1;
    }

    printf("Playing stream: %s\n", filename);

    /* Print metadata in verbose mode
     */
    if (verbose) {
        char description[4096] = { '\0' };
        describe_vgmstream(vgmstream, description, sizeof(description));
        puts(description);
        putchar('\n');
    }

    /* If the audio device hasn't been opened yet, then describe it
     */
    if (!device) {
        ao_info *info = ao_driver_info(driver_id);
        if (!info) {
            printf("Cannot find audio device\n");
            goto fail;
        }
        printf("Audio device: %s\n", info->name);
        printf("Comment: %s\n", info->comment);
        putchar('\n');
    }

    /* Stupid hack to hang onto a few low-numbered file descriptors
     * so that play_compressed_file() doesn't break, due to POSIX
     * wackiness like https://bugs.debian.org/590920
     */
    for (i = 0; i < 4; i++)
        save_fps[i] = fopen("/dev/null", "r");


    /* Calculate how many loops are needed to achieve a minimum
     * playback time. Note: This calculation is derived from the
     * logic in get_vgmstream_play_samples().
     */
    if (vgmstream->loop_flag && cfg->loop_count < 0) {
        double intro = (double)vgmstream->loop_start_sample / vgmstream->sample_rate;
        double loop = (double)(vgmstream->loop_end_sample - vgmstream->loop_start_sample) / vgmstream->sample_rate;
        double end = cfg->fade_time + cfg->fade_delay;
        if (loop < 1.0) loop = 1.0;
        cfg->loop_count = ((cfg->min_time - intro - end) / loop + 0.99);
        if (cfg->loop_count < 1.0) cfg->loop_count = 1.0;
    }


    /* Config
     */
    apply_config(vgmstream, cfg);

    input_channels = vgmstream->channels;
    output_channels = vgmstream->channels;
    vgmstream_mixing_enable(vgmstream, 0, &input_channels, &output_channels); /* query */


    /* Buffer size in bytes (after getting channels)
     */
    buffer_size = 1024 * buffer_size_kb;
    if (!buffer) {
        if (buffer_size_kb < 1) {
            fprintf(stderr, "Invalid buffer size '%d'\n", buffer_size_kb);
            return -1;
        }

        buffer = malloc(buffer_size);
        if (!buffer) goto fail;
    }

    max_buffer_samples = buffer_size / (input_channels * sizeof(sample));

    vgmstream_mixing_enable(vgmstream, max_buffer_samples, NULL, NULL); /* enable */


    /* Init
     */
    ret = set_sample_format(output_channels, vgmstream->sample_rate);
    if (ret) goto fail;

    /* Decode
     */
    {
        double total;
        int time_total_min;
        double time_total_sec;
        int play_forever = vgmstream_get_play_forever(vgmstream);
        int32_t decode_pos_samples = 0;
        int32_t length_samples = vgmstream_get_samples(vgmstream);
        if (length_samples <= 0) goto fail;

        if (out_filename && play_forever) {
            fprintf(stderr, "%s: cannot play forever and use output filename\n", filename);
            ret = -1;
            goto fail;
        }

        total = (double)length_samples / vgmstream->sample_rate;
        time_total_min = (int)total / 60;
        time_total_sec = total - 60 * time_total_min;


        while (!interrupted) {
            int to_do;

            if (decode_pos_samples + max_buffer_samples > length_samples && !play_forever)
                to_do = length_samples - decode_pos_samples;
            else
                to_do = max_buffer_samples;

            if (to_do <= 0) {
                break; /* EOF */
            }

            render_vgmstream(buffer, to_do, vgmstream);

#if LITTLE_ENDIAN_OUTPUT
            swap_samples_le(buffer, output_channels * to_do);
#endif

            if (verbose && !out_filename) {
                double played = (double)decode_pos_samples / vgmstream->sample_rate;
                double remain = (double)(length_samples - decode_pos_samples) / vgmstream->sample_rate;

                int time_played_min = (int)played / 60;
                double time_played_sec = played - 60 * time_played_min;
                int time_remain_min = (int)remain / 60;
                double time_remain_sec = remain - 60 * time_remain_min;

                /* Time: 01:02.34 [08:57.66] of 10:00.00 */
                printf("\rTime: %02d:%05.2f [%02d:%05.2f] of %02d:%05.2f ",
                    time_played_min, time_played_sec,
                    time_remain_min, time_remain_sec,
                    time_total_min,  time_total_sec);

                fflush(stdout);
            }

            if (!ao_play(device, (char *)buffer, to_do * output_channels * sizeof(sample))) {
                fputs("\nAudio playback error\n", stderr);
                ao_close(device);
                device = NULL;
                ret = -1;
                break;
            }

            decode_pos_samples += to_do;
        }


        if (verbose && !ret) {
            /* Clear time status line */
            putchar('\r');
            for (i = 0; i < 64; i++)
                putchar(' ');
            putchar('\r');
            fflush(stdout);
        }

        if (out_filename && !ret)
            printf("Wrote %02d:%05.2f of audio to %s\n\n",
                time_total_min, time_total_sec, out_filename);

        if (interrupted) {
            fputs("Playback terminated.\n\n", stdout);
            ret = record_interrupt();
            if (ret) fputs("Exiting...\n", stdout);
        }
    }


fail:
    close_vgmstream(vgmstream);

    for (i = 0; i < 4; i++)
        if (save_fps[i]) {
            fclose(save_fps[i]);
        }

    return ret;
}

static int play_playlist(const char *filename, song_settings_t *default_par) {
    int ret = 0;
    FILE *f;
    char *line = NULL;
    size_t line_mem = 0;
    ssize_t line_len = 0;
    song_settings_t par;

    memcpy(&par, default_par, sizeof(par));

    f = fopen(filename, "r");
    if (!f) {
        fprintf(stderr, "%s: cannot open playlist file\n", filename);
        return -1;
    }

    while ((line_len = getline(&line, &line_mem, f)) >= 0) {

        /* Remove any leading whitespace
         */
        size_t ws_len = strspn(line, "\t ");
        if (ws_len > 0) {
            line_len -= ws_len;
            memmove(line, line + ws_len, line_len + 1);
        }

        /* Remove trailing whitespace
         */
        while (line_len >= 1 && (line[line_len - 1] == '\r' || line[line_len - 1] == '\n'))
            line[--line_len] = '\0';

#define EXT_PREFIX "#EXT-X-VGMSTREAM:"

        if (!strncmp(line, EXT_PREFIX, sizeof(EXT_PREFIX) - 1)) {

            /* Parse vgmstream-specific metadata */

            char *param = strtok(line + sizeof(EXT_PREFIX) - 1, ",");

#define PARAM_MATCHES(NAME) (!strncmp(param, NAME "=", sizeof(NAME)) && arg)

            while (param) {
                char *arg = strchr(param, '=');
                if (arg) arg++;

                if (PARAM_MATCHES("FADEDELAY"))
                    par.fade_delay = atof(arg);
                else if (PARAM_MATCHES("FADETIME"))
                    par.fade_time = atof(arg);
                else if (PARAM_MATCHES("LOOPCOUNT"))
                    par.loop_count = atof(arg);
                else if (PARAM_MATCHES("STREAMINDEX"))
                    par.stream_index = atoi(arg);

                param = strtok(NULL, ",");
            }
        }

        /* Skip blank or comment lines
         */
        if (line[0] == '\0' || line[0] == '#')
            continue;

        ret = play_file(line, &par);
        if (ret) break;

        /* Reset playback options to default */
        memcpy(&par, default_par, sizeof(par));
    }

    free(line);
    fclose(f);

    return ret;
}

static int play_compressed_file(const char *filename, song_settings_t *par, const char *expand_cmd) {
    int ret;
    char temp_dir[128] = "/tmp/vgmXXXXXX";
    const char *base_name;
    char *last_slash, *last_dot;
    char *cmd = NULL, *temp_file = NULL;
    FILE *in_fp, *out_fp;

    cmd = malloc(strlen(filename) + 1024);
    temp_file = malloc(strlen(filename) + 256);

    if (!cmd || !temp_file)
        return -2;

    if (!mkdtemp(temp_dir)) {
        fprintf(stderr, "%s: error creating temp dir for decompression\n", temp_dir);
        ret = -1;
        goto fail;
    }

    /* Get the base name of the file path
     */
    last_slash = strrchr(filename, '/');
    if (last_slash)
        base_name = last_slash + 1;
    else
        base_name = filename;

    sprintf(temp_file, "%s/%s", temp_dir, base_name);

    /* Chop off the compressed-file extension
     */
    last_dot = strrchr(temp_file, '.');
    if (last_dot) *last_dot = '\0';

    printf("Decompressing file: %s\n", filename);

    in_fp  = fopen(filename, "rb");
    out_fp = fopen(temp_file, "wb");

    if (in_fp && out_fp) {
        setbuf(in_fp, NULL);
        setbuf(out_fp, NULL);

        /* Don't put filenames into the system() arg; that's insecure!
         */
        sprintf(cmd, "%s <&%d >&%d ", expand_cmd, fileno(in_fp), fileno(out_fp));
        ret = system(cmd);

        if (WIFSIGNALED(ret) && (WTERMSIG(ret) == SIGINT || WTERMSIG(ret) == SIGQUIT))
            interrupted = 1;
    }
    else
        ret = -1;

    if (in_fp && fclose(in_fp))
        ret = -1;
    if (out_fp && fclose(out_fp))
        ret = -1;

    if (ret) {
        if (interrupted) {
            putchar('\r');
            ret = record_interrupt();
            if (ret) fputs("Exiting...\n", stdout);
        }
        else
            fprintf(stderr, "%s: error decompressing file\n", filename);
    }
    else
        ret = play_file(temp_file, par);

    remove(temp_file);
    remove(temp_dir);

fail:
    free(cmd);
    free(temp_file);

    return ret;
}

static int play_file(const char *filename, song_settings_t *par) {
    size_t len = strlen(filename);

#define ENDS_IN(EXT) !strcasecmp(EXT, filename + len - sizeof(EXT) + 1)

    if (ENDS_IN(".m3u") || ENDS_IN(".m3u8"))
        return play_playlist(filename, par);
    else if (ENDS_IN(".bz2"))
        return play_compressed_file(filename, par, "bzip2 -cd");
    else if (ENDS_IN(".gz"))
        return play_compressed_file(filename, par, "gzip -cd");
    else if (ENDS_IN(".lzma"))
        return play_compressed_file(filename, par, "lzma -cd");
    else if (ENDS_IN(".xz"))
        return play_compressed_file(filename, par, "xz -cd");
    else
        return play_vgmstream(filename, par);
}

static void add_driver_option(const char *key_value) {
    char buf[1024];
    char *value = NULL;
    char *sep;

    strncpy(buf, key_value, sizeof(buf));
    buf[sizeof(buf) - 1] = '\0';

    sep = strchr(buf, ':');
    if (sep) {
        *sep = '\0';
        value = sep + 1;
    }

    ao_append_option(&device_options, buf, value);
}

int main(int argc, char **argv) {
    int error = 0;
    int opt;
    song_settings_t cfg;
    int extension = 0;

    signal(SIGHUP,  interrupt_handler);
    signal(SIGINT,  interrupt_handler);
    signal(SIGQUIT, interrupt_handler);

    ao_initialize();
    driver_id = ao_default_driver_id();
    memset(&current_sample_format, 0, sizeof(current_sample_format));

    if (argc == 1) {
        /* We were invoked with no arguments */
        usage(argv[0]);
        goto done;
    }

again_opts:
    {
        song_settings_t default_par = DEFAULT_PARAMS;
        cfg = default_par;
    }

    while ((opt = getopt(argc, argv, "-D:F:L:M:S:b:d:f:o:@:hrvieEp")) != -1) {
        switch (opt) {
            case 1:
                /* glibc getopt extension
                 * (files may appear multiple times in any position, ex. "file.adx -L 1.0 file.adx") */
                extension = 1;
                if (play_file(optarg, &cfg)) {
                    error = 1;
                    goto done;
                }
                break;
            case '@':
                if (play_playlist(optarg, &cfg)) {
                    error = 1;
                    goto done;
                }
                break;

            case 'D':
                cfg.fade_delay = atof(optarg);
                break;
            case 'F':
                cfg.fade_time = atof(optarg);
                break;
            case 'L':
                cfg.loop_count = atof(optarg);
                break;
            case 'M':
                cfg.min_time = atof(optarg);
                cfg.loop_count = -1.0;
                break;
            case 'S':
                cfg.stream_index = atoi(optarg);
                break;
            case 'i':
                cfg.ignore_loop = 1;
                break;
            case 'e':
                cfg.force_loop = 1;
                break;
            case 'E':
                cfg.really_force_loop = 1;
                break;
            case 'p':
                cfg.play_forever = 1;
                break;

            case 'b':
                if (!buffer)
                    buffer_size_kb = atoi(optarg);
                break;
            case 'd':
                driver_id = ao_driver_id(optarg);
                if (driver_id < 0) {
                    fprintf(stderr, "Invalid output driver \"%s\"\n", optarg);
                    error = 1;
                    goto done;
                }
                break;
            case 'f':
                out_filename = optarg;
                break;
            case 'h':
                usage(argv[0]);
                goto done;
            case 'o':
                add_driver_option(optarg);
                break;
            case 'r':
                repeat = 1;
                break;
            case 'v':
                verbose = 1;
                break;
            default:
                VGM_LOG("vgmstream123: unknown opt %x", opt);
                goto done;
        }
    }

again_files:
    if (!extension) {
        /* standard POSIX getopt
         * (files are expected to go at the end, optind is at that point) */
        for (opt = optind; opt < argc; ++opt) {
            if (play_file(argv[opt], &cfg)) {
                error = 1;
                goto done;
            }
        }
    }

    if (repeat) {
        if (extension) {
            optind = 0; /* mark reset (BSD may need optreset?) */
            goto again_opts;
        }
        else {
            goto again_files;
        }
    }

done:
    if (device)
        ao_close(device);
    if (buffer)
        free(buffer);

    ao_free_options(device_options);
    ao_shutdown();

    return error;
}
