package ctbrec.recorder.download;

import java.io.EOFException;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.Callable;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.iheartradio.m3u8.Encoding;
import com.iheartradio.m3u8.Format;
import com.iheartradio.m3u8.ParseException;
import com.iheartradio.m3u8.ParsingMode;
import com.iheartradio.m3u8.PlaylistError;
import com.iheartradio.m3u8.PlaylistException;
import com.iheartradio.m3u8.PlaylistParser;
import com.iheartradio.m3u8.data.MediaPlaylist;
import com.iheartradio.m3u8.data.Playlist;
import com.iheartradio.m3u8.data.TrackData;

import ctbrec.Config;
import ctbrec.Model;
import ctbrec.Recording;
import ctbrec.Recording.State;
import ctbrec.io.HttpClient;
import ctbrec.io.HttpException;
import ctbrec.recorder.PlaylistGenerator;
import ctbrec.recorder.PlaylistGenerator.InvalidPlaylistException;
import okhttp3.Request;
import okhttp3.Response;

public class HlsDownload extends AbstractHlsDownload {

    private static final transient Logger LOG = LoggerFactory.getLogger(HlsDownload.class);

    protected Path downloadDir;

    private int segmentCounter = 1;
    private NumberFormat nf = new DecimalFormat("000000");
    private Object downloadFinished = new Object();
    private ZonedDateTime splitRecStartTime;
    private Config config;

    public HlsDownload(HttpClient client) {
        super(client);
    }

    @Override
    public void init(Config config, Model model) {
        this.config = config;
        super.model = model;
        startTime = Instant.now();
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(Config.RECORDING_DATE_FORMAT);
        String startTime = formatter.format(ZonedDateTime.ofInstant(this.startTime, ZoneId.systemDefault()));
        Path modelDir = FileSystems.getDefault().getPath(config.getSettings().recordingsDir, model.getSanitizedNamed());
        downloadDir = FileSystems.getDefault().getPath(modelDir.toString(), startTime);
    }

    @Override
    public void start() throws IOException {
        try {
            running = true;
            splitRecStartTime = ZonedDateTime.now();

            if(!model.isOnline()) {
                throw new IOException(model.getName() +"'s room is not public");
            }

            String segments = getSegmentPlaylistUrl(model);
            if(segments != null) {
                if (!Files.exists(downloadDir, LinkOption.NOFOLLOW_LINKS)) {
                    Files.createDirectories(downloadDir);
                }
                int lastSegmentNumber = 0;
                int nextSegmentNumber = 0;
                int waitFactor = 1;
                while(running) {
                    SegmentPlaylist playlist = getNextSegments(segments);
                    emptyPlaylistCheck(playlist);
                    if(nextSegmentNumber > 0 && playlist.seq > nextSegmentNumber) {
                        // TODO switch to a lower bitrate/resolution ?!?
                        waitFactor *= 2;
                        LOG.warn("Missed segments {} < {} in download for {} - setting wait factor to 1/{}", nextSegmentNumber, playlist.seq, model, waitFactor);
                    }
                    int skip = nextSegmentNumber - playlist.seq;
                    Future<Boolean> lastSegmentDownload = null;
                    for (String segment : playlist.segments) {
                        if(skip > 0) {
                            skip--;
                        } else {
                            URL segmentUrl = new URL(segment);
                            String prefix = nf.format(segmentCounter++);
                            SegmentDownload segmentDownload = new SegmentDownload(playlist, segmentUrl, downloadDir, client, prefix);
                            lastSegmentDownload = downloadThreadPool.submit(segmentDownload);
                            //new SegmentDownload(segment, downloadDir).call();
                        }
                    }

                    // split recordings
                    boolean split = splitRecording(lastSegmentDownload);
                    if (split) {
                        break;
                    }

                    long wait = 0;
                    if(lastSegmentNumber == playlist.seq) {
                        // playlist didn't change -> wait for at least half the target duration
                        wait = (long) playlist.targetDuration * 1000 / waitFactor;
                        LOG.trace("Playlist didn't change... waiting for {}ms", wait);
                    } else {
                        // playlist did change -> wait for at least last segment duration
                        wait = 1;//(long) lsp.lastSegDuration * 1000;
                        LOG.trace("Playlist changed... waiting for {}ms", wait);
                    }

                    try {
                        Thread.sleep(wait);
                    } catch (InterruptedException e) {
                        if(running) {
                            LOG.error("Couldn't sleep between segment downloads. This might mess up the download!");
                        }
                    }

                    // this if check makes sure, that we don't decrease nextSegment. for some reason
                    // streamate playlists sometimes jump back. e.g. max sequence = 79 -> 80 -> 79
                    lastSegmentNumber = playlist.seq;
                    if(lastSegmentNumber + playlist.segments.size() > nextSegmentNumber) {
                        nextSegmentNumber = lastSegmentNumber + playlist.segments.size();
                    }
                }
            } else {
                throw new IOException("Couldn't determine segments uri");
            }
        } catch(ParseException e) {
            throw new IOException("Couldn't parse HLS playlist:\n" + e.getInput(), e);
        } catch(PlaylistException e) {
            throw new IOException("Couldn't parse HLS playlist", e);
        } catch(EOFException e) {
            // end of playlist reached
            LOG.debug("Reached end of playlist for model {}", model);
        } catch(HttpException e) {
            if(e.getResponseCode() == 404) {
                LOG.debug("Playlist not found (404). Model {} probably went offline", model);
                waitSomeTime();
            } else if(e.getResponseCode() == 403) {
                LOG.debug("Playlist access forbidden (403). Model {} probably went private or offline", model);
                waitSomeTime();
            } else {
                throw e;
            }
        } catch(Exception e) {
            throw new IOException("Couldn't download segment", e);
        } finally {
            downloadThreadPool.shutdown();
            try {
                LOG.debug("Waiting for last segments for {}", model);
                downloadThreadPool.awaitTermination(60, TimeUnit.SECONDS);
            } catch (InterruptedException e) {}
            synchronized (downloadFinished) {
                downloadFinished.notifyAll();
            }
            LOG.debug("Download for {} terminated", model);
        }
    }

    @Override
    public void postprocess(Recording recording) {
        recording.setStatusWithEvent(State.GENERATING_PLAYLIST, true);
        generatePlaylist(recording);
        recording.setStatusWithEvent(State.POST_PROCESSING, true);
        super.postprocess(recording);
    }

    private void generatePlaylist(Recording recording) {
        File recDir = recording.getAbsoluteFile();
        if(!config.getSettings().generatePlaylist) {
            return;
        }

        PlaylistGenerator playlistGenerator = new PlaylistGenerator();
        playlistGenerator.addProgressListener(percent -> recording.setProgress(percent));

        try {
            File playlist = playlistGenerator.generate(recDir);
            if(playlist != null) {
                playlistGenerator.validate(recDir);
            }
            recording.setProgress(-1);
        } catch (IOException | ParseException e) {
            LOG.error("Couldn't generate playlist file", e);
        } catch (PlaylistException e) {
            if(e.getErrors().isEmpty()) {
                LOG.error("Couldn't generate playlist file", e);
            } else {
                LOG.error("Playlist contains errors");
                for (PlaylistError error : e.getErrors()) {
                    LOG.error("Error: {}", error.toString());
                }
            }
        } catch (InvalidPlaylistException e) {
            LOG.error("Playlist is invalid and will be deleted", e);
            File playlist = new File(recDir, "playlist.m3u8");
            playlist.delete();
        }
    }

    private boolean splitRecording(Future<Boolean> lastSegmentDownload) {
        if(config.getSettings().splitRecordings > 0) {
            Duration recordingDuration = Duration.between(splitRecStartTime, ZonedDateTime.now());
            long seconds = recordingDuration.getSeconds();
            if(seconds >= config.getSettings().splitRecordings) {
                internalStop();
                return true;
            }
        }
        return false;
    }

    @Override
    public synchronized void stop() {
        if (running) {
            internalStop();
            try {
                synchronized (downloadFinished) {
                    downloadFinished.wait();
                }
            } catch (InterruptedException e) {
                LOG.error("Couldn't wait for download to finish", e);
            }
        }
    }

    @Override
    void internalStop() {
        running = false;
    }

    private static class SegmentDownload implements Callable<Boolean> {
        private URL url;
        private Path file;
        private HttpClient client;
        private SegmentPlaylist playlist;

        public SegmentDownload(SegmentPlaylist playlist, URL url, Path dir, HttpClient client, String prefix) {
            this.playlist = playlist;
            this.url = url;
            this.client = client;
            File path = new File(url.getPath());
            file = FileSystems.getDefault().getPath(dir.toString(), prefix + '_' + path.getName());
        }

        @Override
        public Boolean call() throws Exception {
            LOG.trace("Downloading segment to " + file);
            int maxTries = 3;
            for (int i = 1; i <= maxTries; i++) {
                Request request = new Request.Builder().url(url).addHeader("connection", "keep-alive").build();
                Response response = client.execute(request);
                InputStream in = null;
                try (FileOutputStream fos = new FileOutputStream(file.toFile())) {
                    in = response.body().byteStream();
                    if(playlist.encrypted) {
                        in = new Crypto(playlist.encryptionKeyUrl, client).wrap(in);
                    }
                    byte[] b = new byte[1024 * 100];
                    int length = -1;
                    while( (length = in.read(b)) >= 0 ) {
                        fos.write(b, 0, length);
                    }
                    return true;
                } catch(FileNotFoundException e) {
                    LOG.debug("Segment does not exist {}", url.getFile());
                    break;
                } catch(Exception e) {
                    if (i == maxTries) {
                        LOG.warn("Error while downloading segment. Segment {} finally failed", file.toFile().getName());
                    } else {
                        LOG.warn("Error while downloading segment on try {}", i);
                    }
                } finally {
                    if(in != null) {
                        in.close();
                    }
                    response.close();
                }
            }
            return false;
        }
    }

    @Override
    public File getTarget() {
        return downloadDir.toFile();
    }

    @Override
    public String getPath(Model model) {
        String absolutePath = getTarget().getAbsolutePath();
        String recordingsDir = Config.getInstance().getSettings().recordingsDir;
        String relativePath = absolutePath.replaceFirst(Pattern.quote(recordingsDir), "");
        return relativePath;
    }

    @Override
    public Duration getLength() {
        try {
            File playlist = new File(getTarget(), "playlist.m3u8");
            if (playlist.exists()) {
                return Duration.ofSeconds((long) getPlaylistLength(playlist));
            }
        } catch (IOException | ParseException | PlaylistException e) {
            LOG.error("Couldn't determine recording length", e);
        }
        return Duration.ofSeconds(0);
    }

    private double getPlaylistLength(File playlist) throws IOException, ParseException, PlaylistException {
        if (playlist.exists()) {
            PlaylistParser playlistParser = new PlaylistParser(new FileInputStream(playlist), Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT);
            Playlist m3u = playlistParser.parse();
            MediaPlaylist mediaPlaylist = m3u.getMediaPlaylist();
            double length = 0;
            for (TrackData trackData : mediaPlaylist.getTracks()) {
                length += trackData.getTrackInfo().duration;
            }
            return length;
        } else {
            throw new FileNotFoundException(playlist.getAbsolutePath() + " does not exist");
        }
    }

    /**
     * Causes the current thread to sleep for a short amount of time.
     * This is used to slow down retries, if something is wrong with the playlist.
     * E.g. HTTP 403 or 404
     */
    private void waitSomeTime() {
        try {
            Thread.sleep(10_000);
        } catch (Exception e) {}
    }
}
