package ctbrec.recorder.download.hls;

import static ctbrec.io.HttpConstants.*;

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.io.InterruptedIOException;
import java.net.URL;
import java.nio.file.FileSystems;
import java.nio.file.Files;
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.ExecutionException;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
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.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 okhttp3.Request;
import okhttp3.Response;

public class HlsDownload extends AbstractHlsDownload {

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

    protected transient Path downloadDir;

    private int segmentCounter = 1;
    private NumberFormat nf = new DecimalFormat("000000");
    private transient AtomicBoolean downloadFinished = new AtomicBoolean(false);
    private ZonedDateTime splitRecStartTime;
    protected transient Config config;

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

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

    @Override
    public void start() throws IOException {
        try {
            running = true;
            Thread.currentThread().setName("Download " + model.getName());
            splitRecStartTime = ZonedDateTime.now();

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

            String segments = getSegmentPlaylistUrl(model);
            if (segments != null) {
                if (!downloadDir.toFile().exists()) {
                    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) {
                        waitFactor *= 2;
                        LOG.warn("Missed segments {} < {} in download for {} - setting wait factor to 1/{}", nextSegmentNumber, playlist.seq, model,
                                waitFactor);
                    }
                    int skip = nextSegmentNumber - playlist.seq;
                    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);
                            enqueueDownload(segmentDownload, prefix, segmentUrl);
                        }
                    }

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

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

                    waitSomeTime(waitForMillis);

                    // 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) {
                ctbrec.Model.State modelState;
                try {
                    modelState = model.getOnlineState(false);
                } catch (ExecutionException e1) {
                    modelState = ctbrec.Model.State.UNKNOWN;
                }
                LOG.info("Playlist not found (404). Model {} probably went offline. Model state: {}", model, modelState);
                waitSomeTime(10_000);
            } else if (e.getResponseCode() == 403) {
                ctbrec.Model.State modelState;
                try {
                    modelState = model.getOnlineState(false);
                } catch (ExecutionException e1) {
                    modelState = ctbrec.Model.State.UNKNOWN;
                }
                LOG.info("Playlist access forbidden (403). Model {} probably went private or offline. Model state: {}", model, modelState);
                waitSomeTime(10_000);
            } 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) {
                Thread.currentThread().interrupt();
            }
            downloadFinished.set(true);
            synchronized (downloadFinished) {
                downloadFinished.notifyAll();
            }
            LOG.debug("Download for {} terminated", model);
        }
    }

    private void enqueueDownload(SegmentDownload segmentDownload, String prefix, URL segmentUrl) throws IOException, ExecutionException, InterruptedException {
        try {
            downloadThreadPool.submit(segmentDownload);
            if (downloadQueue.remainingCapacity() < 10) {
                LOG.debug("space left in queue {}", downloadQueue.remainingCapacity());
                // if the queue is running full, we might be struggling with timeouts
                // let's check, if the model is still online
                if (!model.isOnline(true)) {
                    downloadQueue.clear();
                    internalStop();
                }
            }
        } catch(RejectedExecutionException e) {
            LOG.warn("Download queue is full ({}). Skipping segment {} {}", downloadQueue.size(), prefix, segmentUrl);
        }
    }

    @Override
    public void postprocess(Recording recording) {
        Thread.currentThread().setName("PP " + model.getName());
        recording.setStatusWithEvent(State.GENERATING_PLAYLIST);
        try {
            generatePlaylist(recording);
            recording.setStatusWithEvent(State.POST_PROCESSING);
            runPostProcessingScript(recording);
        } catch (Exception e) {
            throw new PostProcessingException(e);
        }
    }

    protected File generatePlaylist(Recording recording) throws IOException, ParseException, PlaylistException {
        File recDir = recording.getAbsoluteFile();
        if (!config.getSettings().generatePlaylist) {
            return null;
        }
        PlaylistGenerator playlistGenerator = new PlaylistGenerator();
        playlistGenerator.addProgressListener(recording::setProgress);
        File playlist = playlistGenerator.generate(recDir);
        if (playlist != null) {
            playlistGenerator.validate(recDir);
        }
        recording.setProgress(-1);
        return playlist;

    }

    private boolean splitRecording() {
        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 void stop() {
        if (running) {
            internalStop();
            try {
                synchronized (downloadFinished) {
                    while (!downloadFinished.get()) {
                        downloadFinished.wait(TimeUnit.SECONDS.toMillis(60));
                    }
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                LOG.error("Couldn't wait for download to finish", e);
            }
        }
    }

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

    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 {}", url, file);
            for (int tries = 1; tries <= 3 && !Thread.currentThread().isInterrupted(); tries++) {
                Request request = new Request.Builder()
                        .url(url)
                        .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
                        .header(CONNECTION, KEEP_ALIVE)
                        .build();
                InputStream in = null;
                try (Response response = client.execute(request); FileOutputStream fos = new FileOutputStream(file.toFile())) {
                    if (response.isSuccessful()) {
                        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 && !Thread.currentThread().isInterrupted()) {
                            fos.write(b, 0, length);
                        }
                        return true;
                    }
                } catch(FileNotFoundException e) {
                    LOG.debug("Segment does not exist {}", url.getFile());
                    break;
                } catch (InterruptedIOException e) {
                    break;
                } catch(Exception e) {
                    LOG.error("Error", e);
                    if (tries == 3) {
                        LOG.warn("Error while downloading segment. Segment {} finally failed: {}", file.toFile().getName(), e.getMessage());
                    } else {
                        LOG.debug("Error while downloading segment on try {} - {}", tries, e.getMessage());
                    }
                }
            }
            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()) {
            try (FileInputStream fin = new FileInputStream(playlist)) {
                PlaylistParser playlistParser = new PlaylistParser(fin, 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");
        }
    }

    @Override
    public boolean isSingleFile() {
        return false;
    }
}
