package ctbrec.recorder;

import static ctbrec.event.Event.Type.*;

import java.io.File;
import java.io.IOException;
import java.nio.file.FileStore;
import java.nio.file.Files;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorCompletionService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;

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

import com.google.common.eventbus.Subscribe;
import com.iheartradio.m3u8.ParseException;
import com.iheartradio.m3u8.PlaylistException;

import ctbrec.Config;
import ctbrec.Model;
import ctbrec.Recording;
import ctbrec.Recording.State;
import ctbrec.event.Event;
import ctbrec.event.EventBusHolder;
import ctbrec.event.ModelIsOnlineEvent;
import ctbrec.event.RecordingStateChangedEvent;
import ctbrec.io.HttpClient;
import ctbrec.recorder.download.Download;
import ctbrec.sites.Site;

public class NextGenLocalRecorder implements Recorder {

    private static final transient Logger LOG = LoggerFactory.getLogger(NextGenLocalRecorder.class);
    private static final boolean IGNORE_CACHE = true;
    private List<Model> models = Collections.synchronizedList(new ArrayList<>());
    private Config config;
    private volatile boolean recording = true;
    private ReentrantLock modelLock = new ReentrantLock();
    private ReentrantLock recordingsLock = new ReentrantLock();
    private RecorderHttpClient client = new RecorderHttpClient();
    private long lastSpaceMessage = 0;
    private Map<Model, Recording> recordingProcesses = Collections.synchronizedMap(new HashMap<>());
    private RecordingManager recordingManager;

    // thread pools for downloads and post-processing
    private BlockingQueue<Runnable> downloadQueue = new SynchronousQueue<>();
    private ThreadPoolExecutor downloadPool = new ThreadPoolExecutor(2, 100, 5, TimeUnit.MINUTES, downloadQueue, createThreadFactory("Download"));

    private ExecutorCompletionService<Recording> completionService = new ExecutorCompletionService<>(downloadPool);
    private BlockingQueue<Runnable> ppQueue = new LinkedBlockingQueue<>();
    private ThreadPoolExecutor ppPool = new ThreadPoolExecutor(2, 2, 5, TimeUnit.MINUTES, ppQueue, createThreadFactory("PP"));

    private ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();

    public NextGenLocalRecorder(Config config, List<Site> sites) throws IOException {
        this.config = config;
        recordingManager = new RecordingManager(config, sites);
        config.getSettings().models.stream().forEach((m) -> {
            if (m.getSite() != null) {
                if (m.getSite().isEnabled()) {
                    models.add(m);
                } else {
                    LOG.info("{} disabled -> ignoring {}", m.getSite().getName(), m.getName());
                }
            } else {
                LOG.info("Site for model {} is unknown -> ignoring", m.getName());
            }
        });

        recording = true;
        registerEventBusListener();
        // if(Config.isServerMode()) {
        // processUnfinishedRecordings();
        // }

        LOG.debug("Recorder initialized");
        LOG.info("Models to record: {}", models);
        LOG.info("Saving recordings in {}", config.getSettings().recordingsDir);

        Thread completionHandler = new Thread(() -> {
            while (!Thread.interrupted()) {
                try {
                    Future<Recording> result = completionService.take();
                    Recording recording = result.get();
                    recordingsLock.lock();
                    try {
                        recordingProcesses.remove(recording.getModel());
                    } finally {
                        recordingsLock.unlock();
                    }
                    if (recording.getStatus() == State.WAITING) {
                        LOG.debug("Download finished for {} -> Starting post-processing", recording.getModel().getName());
                        submitPostProcessingJob(recording);

                        // check, if we have to restart the recording
                        Model model = recording.getModel();
                        tryRestartRecording(model);
                    } else {
                        if(recording.getStatus() != State.DELETED) {
                            delete(recording);
                        }
                        setRecordingStatus(recording, State.FAILED);
                    }
                } catch (InterruptedException | ExecutionException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException | IOException e) {
                    LOG.error("Error while completing recording", e);
                }
            }
        });
        completionHandler.setName("CompletionHandler");
        completionHandler.setDaemon(true);
        completionHandler.start();

        scheduler.scheduleWithFixedDelay(() -> {
            try {
                if (!recordingProcesses.isEmpty() && !enoughSpaceForRecording()) {
                    LOG.info("No space left -> Stopping all recordings");
                    stopRecordingProcesses();
                }
            } catch (IOException e) {
                LOG.error("Couldn't check space left on device", e);
            }
        }, 1, 1, TimeUnit.SECONDS);
    }

    private void submitPostProcessingJob(Recording recording) {
        ppPool.submit(() -> {
            try {
                setRecordingStatus(recording, State.POST_PROCESSING);
                recordingManager.saveRecording(recording);
                recording.postprocess();
                setRecordingStatus(recording, State.FINISHED);
                recordingManager.saveRecording(recording);
                deleteIfTooShort(recording);
            } catch (Exception e) {
                LOG.error("Error while post-processing recording {}", recording, e);
                recording.setStatus(State.FAILED);
                try {
                    recordingManager.saveRecording(recording);
                } catch (IOException e1) {
                    LOG.error("Couldn't update recording state for recording {}", recording, e1);
                }
            }
        });
    }

    private void setRecordingStatus(Recording recording, State status) {
        recording.setStatus(status);
        RecordingStateChangedEvent evt = new RecordingStateChangedEvent(recording.getDownload().getTarget(), status, recording.getModel(),
                recording.getStartDate());
        EventBusHolder.BUS.post(evt);
    }

    @Override
    public void startRecording(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException, IllegalStateException {
        if (!models.contains(model)) {
            LOG.info("Model {} added", model);
            modelLock.lock();
            try {
                models.add(model);
                config.getSettings().models.add(model);
                config.save();
            } catch (IOException e) {
                LOG.error("Couldn't save config", e);
            } finally {
                modelLock.unlock();
            }

            // try to start the recording immediately
            try {
                if (model.isOnline()) {
                    startRecordingProcess(model);
                }
            } catch (ExecutionException | InterruptedException e) {
            }
        }
    }

    private void startRecordingProcess(Model model) throws IOException {
        recordingsLock.lock();
        try {
            if (!recording) {
                // recorder is not in recording mode
                return;
            }

            if (model.isSuspended()) {
                LOG.info("Recording for model {} is suspended.", model);
                return;
            }

            if (recordingProcesses.containsKey(model)) {
                LOG.error("A recording for model {} is already running", model);
                return;
            }

            modelLock.lock();
            try {
                if (!models.contains(model)) {
                    LOG.info("Model {} has been removed. Restarting of recording cancelled.", model);
                    return;
                }
            } finally {
                modelLock.unlock();
            }

            if (!enoughSpaceForRecording()) {
                long now = System.currentTimeMillis();
                if ((now - lastSpaceMessage) > TimeUnit.MINUTES.toMillis(1)) {
                    LOG.info("Not enough space for recording, not starting recording for {}", model);
                    lastSpaceMessage = now;
                }
                return;
            }

            if (!downloadSlotAvailable()) {
                LOG.info("The number of downloads is maxed out, not starting recording for {}", model);
                return;
            }

            LOG.debug("Starting recording for model {}", model.getName());
            Download download = model.createDownload();
            download.init(config, model);
            LOG.debug("Downloading with {}", download.getClass().getSimpleName());

            Recording rec = new Recording();
            rec.setDownload(download);
            rec.setPath(download.getPath(model).replaceAll("\\\\", "/"));
            rec.setModel(model);
            rec.setStartDate(Instant.ofEpochMilli(System.currentTimeMillis()));
            recordingProcesses.put(model, rec);
            recordingManager.add(rec);
            completionService.submit(() -> {
                try {
                    setRecordingStatus(rec, State.RECORDING);
                    recordingManager.saveRecording(rec);
                    download.start();
                    boolean deleted = deleteIfEmpty(rec);
                    setRecordingStatus(rec, deleted ? State.DELETED : State.WAITING);
                    recordingManager.saveRecording(rec);
                } catch (IOException e) {
                    LOG.error("Download for {} failed. Download state: {}", model.getName(), rec.getStatus(), e);
                }
                return rec;
            });
        } finally {
            recordingsLock.unlock();
        }
    }

    private boolean deleteIfEmpty(Recording rec) throws IOException, ParseException, PlaylistException, InvalidKeyException, NoSuchAlgorithmException, IllegalStateException {
        rec.refresh();
        long sizeInByte = rec.getSizeInByte();
        if (sizeInByte == 0) {
            LOG.info("Deleting empty recording {}", rec);
            delete(rec);
            return true;
        } else {
            return false;
        }
    }

    private boolean deleteIfTooShort(Recording rec) throws IOException, ParseException, PlaylistException, InvalidKeyException, NoSuchAlgorithmException, IllegalStateException {
        Duration minimumLengthInSeconds = Duration.ofSeconds(Config.getInstance().getSettings().minimumLengthInSeconds);
        if (minimumLengthInSeconds.getSeconds() <= 0) {
            return false;
        }

        Duration recordingLength = rec.getLength();
        if (recordingLength.compareTo(minimumLengthInSeconds) < 0) {
            LOG.info("Deleting too short recording {} [{} < {}]", rec, recordingLength, minimumLengthInSeconds);
            delete(rec);
            return true;
        }

        return false;
    }

    @Override
    public void stopRecording(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException, IllegalStateException {
        modelLock.lock();
        try {
            if (models.contains(model)) {
                models.remove(model);
                config.getSettings().models.remove(model);
                LOG.info("Model {} removed", model);
                config.save();
            } else {
                throw new NoSuchElementException("Model " + model.getName() + " [" + model.getUrl() + "] not found in list of recorded models");
            }
        } finally {
            modelLock.unlock();
        }

        recordingsLock.lock();
        try {
            if (recordingProcesses.containsKey(model)) {
                Recording recording = recordingProcesses.get(model);
                recording.getDownload().stop();
            }
        } finally {
            recordingsLock.unlock();
        }
    }

    @Override
    public void switchStreamSource(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException, IllegalStateException {
        if (models.contains(model)) {
            int index = models.indexOf(model);
            models.get(index).setStreamUrlIndex(model.getStreamUrlIndex());
            config.save();
            LOG.debug("Switching stream source to index {} for model {}", model.getStreamUrlIndex(), model.getName());
            recordingsLock.lock();
            try {
                Recording recording = recordingProcesses.get(model);
                if (recording != null) {
                    stopRecordingProcess(model);
                }
            } finally {
                recordingsLock.unlock();
            }
            tryRestartRecording(model);
        } else {
            LOG.warn("Couldn't switch stream source for model {}. Not found in list", model.getName());
            return;
        }
    }

    private void stopRecordingProcess(Model model) {
        recordingsLock.lock();
        try {
            LOG.debug("Stopping recording for {}", model);
            Recording recording = recordingProcesses.get(model);
            LOG.debug("Stopping download for {}", model);
            recording.getDownload().stop();
        } finally {
            recordingsLock.unlock();
        }
    }

    private void stopRecordingProcesses() {
        recordingsLock.lock();
        try {
            for (Recording recording : recordingProcesses.values()) {
                recording.getDownload().stop();
            }
        } finally {
            recordingsLock.unlock();
        }
    }

    @Override
    public boolean isTracked(Model model) {
        modelLock.lock();
        try {
            return models.contains(model);
        } finally {
            modelLock.unlock();
        }
    }

    @Override
    public List<Model> getModels() {
        modelLock.lock();
        try {
            return new ArrayList<>(models);
        } finally {
            modelLock.unlock();
        }
    }

    @Override
    public List<Recording> getRecordings() throws IOException, InvalidKeyException, NoSuchAlgorithmException, IllegalStateException {
        return recordingManager.getAll();
    }

    @Override
    public void delete(Recording recording) throws IOException, InvalidKeyException, NoSuchAlgorithmException, IllegalStateException {
        recordingManager.delete(recording);
    }

    @Override
    public void shutdown() {
        // TODO add a config flag for waitign or stopping immediately
        LOG.info("Shutting down");
        recording = false;

        LOG.debug("Stopping all recording processes");
        recordingsLock.lock();
        try {
            // make a copy to avoid ConcurrentModificationException
            List<Recording> toStop = new ArrayList<>(recordingProcesses.values());
            for (Recording rec : toStop) {
                Optional.ofNullable(rec.getDownload()).ifPresent(Download::stop);
            }
        } finally {
            recordingsLock.unlock();
        }

        // wait for post-processing to finish
        LOG.info("Waiting for downloads to finish");
        for (int i = 0; i < 60; i++) {
            if (!recordingProcesses.isEmpty()) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    LOG.error("Error while waiting for downloads to finish", e);
                }
            }
        }

        // shutdown threadpools
        try {
            LOG.info("Shutting down pools");
            downloadPool.shutdown();
            ppPool.shutdown();
            client.shutdown();
            downloadPool.awaitTermination(1, TimeUnit.MINUTES);
            LOG.info("Waiting for post-processing to finish");
            ppPool.awaitTermination(10, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            LOG.error("Error while waiting for pools to finish", e);
        }
    }

    @Override
    public void suspendRecording(Model model) {
        modelLock.lock();
        try {
            if (models.contains(model)) {
                int index = models.indexOf(model);
                models.get(index).setSuspended(true);
                model.setSuspended(true);
                config.save();
            } else {
                LOG.warn("Couldn't suspend model {}. Not found in list", model.getName());
                return;
            }
        } catch (IOException e) {
            LOG.error("Couldn't save config", e);
        } finally {
            modelLock.unlock();
        }

        recordingsLock.lock();
        try {
            Recording recording = recordingProcesses.get(model);
            Optional.ofNullable(recording).map(Recording::getDownload).ifPresent(Download::stop);
        } finally {
            recordingsLock.unlock();
        }
    }

    @Override
    public void resumeRecording(Model model) throws IOException {
        modelLock.lock();
        try {
            if (models.contains(model)) {
                int index = models.indexOf(model);
                Model m = models.get(index);
                m.setSuspended(false);
                if (m.isOnline()) {
                    startRecordingProcess(m);
                }
                model.setSuspended(false);
                config.save();
            } else {
                LOG.warn("Couldn't resume model {}. Not found in list", model.getName());
                return;
            }
        } catch (ExecutionException | InterruptedException e) {
            LOG.error("Couldn't check, if model {} is online", model.getName());
        } finally {
            modelLock.unlock();
        }
    }

    @Override
    public boolean isSuspended(Model model) {
        modelLock.lock();
        try {
            int index = models.indexOf(model);
            if (index >= 0) {
                Model m = models.get(index);
                return m.isSuspended();
            } else {
                return false;
            }
        } finally {
            modelLock.unlock();
        }
    }

    @Override
    public List<Model> getOnlineModels() {
        return getModels().stream().filter(m -> {
            try {
                return m.isOnline();
            } catch (IOException | ExecutionException | InterruptedException e) {
                return false;
            }
        }).collect(Collectors.toList());
    }

    @Override
    public HttpClient getHttpClient() {
        return client;
    }

    @Override
    public long getTotalSpaceBytes() throws IOException {
        return getRecordingsFileStore().getTotalSpace();
    }

    @Override
    public long getFreeSpaceBytes() throws IOException {
        return getRecordingsFileStore().getUsableSpace();
    }

    private FileStore getRecordingsFileStore() throws IOException {
        File recordingsDir = new File(config.getSettings().recordingsDir);
        if (!recordingsDir.exists()) {
            Files.createDirectories(recordingsDir.toPath());
        }
        FileStore store = Files.getFileStore(recordingsDir.toPath());
        return store;
    }

    private boolean enoughSpaceForRecording() throws IOException {
        long minimum = config.getSettings().minimumSpaceLeftInBytes;
        if (minimum == 0) { // 0 means don't check
            return true;
        } else {
            return getFreeSpaceBytes() > minimum;
        }
    }

    private boolean downloadSlotAvailable() {
        int concurrentRecordings = Config.getInstance().getSettings().concurrentRecordings;
        return concurrentRecordings == 0 || concurrentRecordings > 0 && recordingProcesses.size() < concurrentRecordings;
    }

    private void tryRestartRecording(Model model) {
        if (!recording) {
            // recorder is not in recording state
            return;
        }

        try {
            boolean modelInRecordingList = isTracked(model);
            boolean online = model.isOnline(IGNORE_CACHE);
            if (modelInRecordingList && online) {
                LOG.info("Restarting recording for model {}", model);
                startRecordingProcess(model);
            }
        } catch (Exception e) {
            LOG.error("Couldn't restart recording for model {}", model);
        }
    }

    private void registerEventBusListener() {
        EventBusHolder.BUS.register(new Object() {
            @Subscribe
            public void modelEvent(Event e) {
                recordingsLock.lock();
                try {
                    if (e.getType() == MODEL_ONLINE) {
                        ModelIsOnlineEvent evt = (ModelIsOnlineEvent) e;
                        Model model = evt.getModel();
                        if (!isSuspended(model) && !recordingProcesses.containsKey(model)) {
                            startRecordingProcess(model);
                        }
                    }
                } catch (Exception e1) {
                    LOG.error("Error while handling model state changed event {}", e, e1);
                } finally {
                    recordingsLock.unlock();
                }
            }
        });
    }

    private ThreadFactory createThreadFactory(String name) {
        return new ThreadFactory() {
            @Override
            public Thread newThread(Runnable r) {
                Thread t = new Thread(r);
                t.setName(name + " " + UUID.randomUUID().toString().substring(0, 8));
                t.setDaemon(true);
                return t;
            }
        };
    }

    @Override
    public void rerunPostProcessing(Recording recording) {
        List<Recording> recordings = recordingManager.getAll();
        for (Recording other : recordings) {
            if(other.equals(recording)) {
                Download download = other.getModel().createDownload();
                download.init(Config.getInstance(), other.getModel());
                other.setDownload(download);
                submitPostProcessingJob(other);
                return;
            }
        }
        LOG.error("Recording {} not found. Can't rerun post-processing", recording);
    }
}
