// Copyright 2014-2025 Jesper Larsson
//
// This file is part of Klipspringer, <https://klipspringer.avadeaux.net/>
//
// Klipspringer is free software: you can redistribute it and/or modify it under the terms of the
// GNU General Public License as published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// Klipspringer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
// even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with Klipspringer. If
// not, see <https://www.gnu.org/licenses/>.

package net.avadeaux.klipspringer;

import java.io.*;
import java.nio.*;
import java.util.regex.*;
import javax.sound.sampled.*;
import net.avadeaux.klipspringer.codec.*;

/**
 * Dynamically loaded track information.
 */
public class Track {
    /** Byte oriented positions relatively to beginning of the track list. */
    public static class Limits {
        final long start;       // byte position where track starts
        final long inEnd;       // where fade-in ends, zero if no fade-in
        final long outStart;    // where next track starts to fade in
        final long end;         // where track including fade-out ends

        private Limits(long start, long inEnd, long outStart, long end) {
            this.start = start;
            this.inEnd = inEnd;
            this.outStart = outStart;
            this.end = end;
        }
    }

    public static class List {
        public final int firstTrackNo;                  // normally 1, but can be 0 or anything else
        public final int length;                        // total number of tracks
        public final String[] paths;                    // file names
        private final MixSpec[] mixs;                   // mix specs
        private final PcmFormat[] formats;
        private final long[] frames;                    // total played, including fade in and out
        private final double[] startTime, outTime;      // time where play and fade-out begins
        private final double[] fadedInTime;             // time where fade-in stops
        private final Track[] tracks;

        private AudioDecoder decoder = null;
        private int decoderIx = -1, populatedTracks = 0;
        private PcmWriter writer;
        private AudioDecoder.Target target = new AudioDecoder.Target() {
                public void metadata(PcmFormat fmt, long fileFrames) throws IOException {
                    formats[decoderIx] = fmt;
                    float r = fmt.getSampleRate();
                    long skipFrames = (long) (mixs[decoderIx].skip * r);
                    long fadeInFrames = (long) (mixs[decoderIx].fadeIn * r);
                    long fadeOutFrames = decoderIx+1 < length ? (long) (mixs[decoderIx+1].fadeIn * r) : 0;
                    long endFrames = (mixs[decoderIx].end >= 0 ? Math.min(fileFrames, (long) (mixs[decoderIx].end * r)) : fileFrames);
                    if (fadeInFrames+fadeOutFrames > endFrames-skipFrames) {
                        throw new IllegalArgumentException("Inconsistent mix info "+skipFrames+"/"+fadeInFrames+"/"+endFrames+"/"+paths[decoderIx]);
                    }
                    if ((decoderIx == 0 || !fmt.matches(formats[decoderIx-1])) && fadeInFrames > 0) {
                        if (System.getProperty("klipspringer.debug") != null) {
                            System.err.println("Ignoring fade-in at format-starting track: "+paths[decoderIx]);
                        }
                        fadeInFrames = 0;
                    }
                    frames[decoderIx] = endFrames-skipFrames;
                    startTime[decoderIx] = decoderIx == 0 ? 0 : outTime[decoderIx-1];
                    outTime[decoderIx] = startTime[decoderIx] + (frames[decoderIx] - fadeOutFrames)/r;
                    fadedInTime[decoderIx] = startTime[decoderIx] + fadeInFrames/r;
                }
                public boolean write(ByteBuffer buffer) throws IOException { return writer.write(buffer); }
                public void drain() { throw new UnsupportedOperationException(); } // should never happen
                public void close() { /* Not holding any resource to close. */ }
            };

        public List(String[] paths, MixSpec[] mixs, int firstTrackNo) {
            this.firstTrackNo = firstTrackNo;
            length = paths.length;
            this.paths = paths;
            this.mixs = mixs;
            formats = new PcmFormat[length];
            frames = new long[length];
            tracks = new Track[length];
            startTime = new double[length];
            outTime = new double[length];
            fadedInTime = new double[length];
            for (int i = 0; i < tracks.length; i++) { tracks[i] = new Track(this, i); }
        }

        static List single(String path) {
            return new List(new String[] { path }, new MixSpec[] { MixSpec.getInstance() }, 1);
        }

        private synchronized void open(int ix) throws IOException {
            if (decoderIx != ix) {
                if (decoder != null) {
                    decoder.close();
                    decoder = null;
                }
                decoderIx = ix;
                String ext = paths[ix].substring(paths[ix].lastIndexOf('.')+1).toLowerCase();
                if ("flac".equals(ext) || "oga".equals(ext)) {
                    decoder = new FlacDecoder(paths[ix], target);
                } else if ("ogg".equals(ext)) {
                    decoder = new VorbisDecoder(paths[ix], target);
                } else if ("opus".equals(ext)) {
                    decoder = new OpusDecoder(paths[ix], target);
                } else if ("raw".equals(ext)) {
                    // Decode using format specification in the file name.
                    PcmFormat fmt = rawFormat(paths[ix]);
                    RandomAccessFile f = new RandomAccessFile(paths[ix], "r");
                    decoder = PcmChannelDecoder.ofRaw(f.getChannel(), fmt, f.length()/fmt.fs(), target);
                } else if ("wav".equals(ext)) {
                    // Attempt to decode WAV file with NIO for efficiency.
                    RandomAccessFile f = new RandomAccessFile(paths[ix], "r");
                    try {
                        decoder = PcmChannelDecoder.ofWav(f.getChannel(), target);
                    } catch (UnsupportedFormatException ex) {
                        // Could not decode, let the Java audio system try it.
                        System.err.println("Warning: file unsupported by Klipspringer decoder (trying Java native): "+paths[ix]+", "+ex);
                    }
                }
                if (decoder == null) {
                    try {
                        decoder = new AudioInputStreamDecoder(AudioSystem.getAudioInputStream(new File(paths[ix])), target);
                    } catch (UnsupportedAudioFileException ex) {
                        throw new UnsupportedFormatException(ex);
                    }
                }
                decoder.decodeMetadata();
            }
        }

        public synchronized String decodingQuality() {
            return decoder == null ? null : decoder.quality();
        }

        private synchronized void populate(int ix) throws IOException {
            while (populatedTracks <= ix) { open(populatedTracks++); }
        }

        private synchronized void closeDecoder() throws IOException {
            if (decoder != null) { decoder.close(); }
            decoder = null;
            decoderIx = -1;
        }

        private boolean decodeTrack(int ix, long fromFrame, PcmWriter writer) throws IOException {
            open(ix);
            try {
                this.writer = writer;
                return decoder.decodeAll((long) (mixs[ix].skip * formats[ix].getSampleRate()) + fromFrame);
            } finally {
                this.writer = null;
                if (decoder != null && !decoder.reusable()) { closeDecoder(); }
            }
        }

        public synchronized Track ofIndex(int ix) throws IOException {
            if (ix >= tracks.length) { return null; }
            populate(ix);
            return tracks[ix];
        }

        public Track ofTrackNo(int trackNo) throws IOException { return ofIndex(trackNo - firstTrackNo); }

        public Track ofTime(double time) throws IOException {
            int lo = 0, hi = populatedTracks-1;
            while (lo < hi) {
                int mid = (lo+hi+1)/2;
                if (time < fadedInTime[mid]) { hi = mid-1; }
                else { lo = mid; }
            }
            while (lo+1 == populatedTracks && populatedTracks < length) {
                populate(lo+1);
                if (time < fadedInTime[lo+1]) { break; }
                ++lo;
            }
            return tracks[lo];
        }
    }

    private final static Pattern optP = Pattern.compile("[a-zA-Z](?:\\s+(\\S+))?\\s*");
    private final static PcmFormat rawDefault = new PcmFormat(44100, 16, 2);
    private final static Pattern rawP = Pattern
        .compile("(.*?)(?:\\s+-[a-z](?:\\s+[a-z0-9+][a-z0-9+\\-]*)?)*\\.raw", Pattern.CASE_INSENSITIVE);

    private static int uintarg(Matcher m) {
        String arg = m.group(1);
        if (arg == null) { throw new IllegalArgumentException("Argument missing: "+m.group()); }
        return Integer.parseUnsignedInt(arg);
    }

    public static String fileNameStem(String fileName) {
        Matcher m = rawP.matcher(fileName);
        if (m.matches()) { return m.group(1); }
        int dotPos = fileName.lastIndexOf('.');
        return dotPos < 0 ? fileName : fileName.substring(0, dotPos);
    }

    public static PcmFormat rawFormat(String fileName) {
        int pad = rawDefault.fs()/rawDefault.channels()*8 - rawDefault.bips();
        String fileStem = fileName.regionMatches(true, fileName.length()-4, ".raw", 0, 4)
            ? fileName.substring(0, fileName.length()-4)
            : fileName;
        return rawFormat(fileStem, rawDefault.rate(), rawDefault.bips(), pad, rawDefault.channels(), rawDefault.signed(), rawDefault.bigend());
    }

    private static PcmFormat rawFormat(String fileStem, int rate, int bits, int pad, int channels, boolean signed, boolean bigend) {
        boolean unsigned = !signed;
        String[] opts = (" "+fileStem).split("\\s+-");
        for (int i = 1; i < opts.length; i++) {
            Matcher m = optP.matcher(opts[i]);
            if (!m.matches()) { throw new IllegalArgumentException("Invalid file format specification: "+opts[i]); }
            switch (opts[i].charAt(0)) {
            case 'r':
                rate = uintarg(m);
                break;
            case 'b':
                String barg = m.group(1);
                int pluspos = barg.indexOf('+');
                bits = Integer.parseUnsignedInt(pluspos < 0 ? barg : barg.substring(0, pluspos));
                pad = pluspos < 0 ? 0 : Integer.parseUnsignedInt(barg.substring(pluspos+1));
                break;
            case 'c':
                channels = uintarg(m);
                break;
            case 'e':
                String arg = m.group(1);
                boolean s = "signed-integer".startsWith(arg);
                boolean u = "unsigned-integer".startsWith(arg);
                if (signed == unsigned) { throw new IllegalArgumentException("Invalid encoding: "+opts[i]); }
                signed = s;
                break;
            case 'B':
            case 'L':
                if (m.group(1) != null) { throw new IllegalArgumentException("Invalid file format specification: "+opts[i]); }
                bigend = opts[i].charAt(0) == 'B';
                break;
            default:
                throw new IllegalArgumentException("Invalid file format specification: "+opts[i]);
            }
        }
        if ((bits+pad) % 8 != 0 || bits+pad > 32) { throw new IllegalArgumentException("Invalid bits specification: "+bits+"+"+pad); }
        return new PcmFormat(rate, bits, channels, signed, (bits+pad)/8*channels, bigend ? ByteOrder.BIG_ENDIAN : ByteOrder.LITTLE_ENDIAN, bits);
    }

    public static String rawParamString(PcmFormat format) {
        return " -r "+format.rate()
            +" -b "+rawBipsString(format)
            +" -c "+format.getChannels()
            +" -e "+(format.signed() ? "signed-integer" : "unsigned-integer")
            +(format.isBigEndian() ? " -B" : " -L");
    }

    public static String rawParamQualityString(PcmFormat format) {
        return " -r "+format.rate()
            +" -b "+rawBipsString(format)
            +" -c "+format.getChannels();
    }

    public static String rawBipsString(PcmFormat format) {
        int pad = format.fs()/format.channels()*8 - format.bips();
        return format.bips()+(pad == 0 ? "" : "+"+pad);
    }

    public static Track single(String path) throws IOException {
        return List.single(path).ofIndex(0);
    }

    private final List list;
    public final int index;

    private Track(List list, int index) {
        this.list = list;
        this.index = index;
    }

    public int trackNo() { return list.firstTrackNo + index; }

    public boolean decode(long fromFrame, PcmWriter writer) throws IOException {
        return list.decodeTrack(index, fromFrame, writer);
    }

    /** Audio format obtained from metadata of file. */
    public PcmFormat format() { return list.formats[index]; }

    /** Start time of track from beginning of track list. */
    public double startTimeSecs() { return list.startTime[index]; }

    /** Time when the following track starts playing, or playing ends if this is last track. */
    public double outTimeSecs() { return list.outTime[index]; }

    /** Playing time of track. The same as <code>outTimeSecs() - startTimeSecs()</code>. */
    public double playTimeSecs() { return outTimeSecs() - startTimeSecs(); }

    /** Number of played frames in the track. */
    public long frames() { return list.frames[index]; }

    /** Type of fade. */
    public MixSpec.Fade fade() { return list.mixs[index].fade; }

    /** Checks if last in track list. */
    public boolean lastTrack() { return index == list.length-1; }

    /** Check if first in chain of compatible-format tracks. */
    public boolean firstInChain() { return index == 0 || !list.formats[index].matches(list.formats[index-1]); }

    /** Byte-unit limits of track, using given frame size rather than that of source metadata. */
    public Limits limits(int fs, long start) {
        float r = list.formats[index].getSampleRate();

        long fadeInFrames = (long) (list.mixs[index].fadeIn * r);
        long fadeOutFrames = index+1 < list.length ? (long) (list.mixs[index+1].fadeIn * r) : 0;
        long relEndFrames = (list.mixs[index].end >= 0
                             ? Math.min(list.frames[index], (long) (list.mixs[index].end * r))
                             : list.frames[index]);

        long out = fadeOutFrames * fs;
        long in = fadeInFrames * fs;
        long len = relEndFrames * fs;

        return new Limits(start, start+in, start+len-out, start+len);
    }

    public String path() { return list.paths[index]; }

    /** If decoding is in progress, the value of {@link Audiodecoder#quality()}, otherwise null. */
    public String quality() { return list.decodingQuality(); }

    /** For debugging convenience. */
    public String toString() { return "Track "+(list.firstTrackNo+index)+" "+list.paths[index]; }
}
