package org.forkalsrud.album.video;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.LineNumberReader;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;

import org.apache.commons.io.IOUtils;
import org.forkalsrud.album.db.Chunk;
import org.forkalsrud.album.db.MovieDatabase;
import org.forkalsrud.album.exif.Dimension;
import org.forkalsrud.album.exif.Thumbnail;
import org.forkalsrud.album.web.CachedImage;
import org.forkalsrud.album.web.PictureScaler;

public class MovieCoder {

    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(MovieCoder.class);

    private String ffmpegExecutable;
    private String mplayerExecutable;
    private final PictureScaler pictureScaler;
    private final HashMap<String, EncodingProcess> currentEncodings = new HashMap<>();

    public MovieCoder(PictureScaler pictureScaler) {
        this.pictureScaler = pictureScaler;
    }

    public void init() throws Exception {

        ExecUtil util = new ExecUtil();
        if (this.ffmpegExecutable == null) {
        	this.ffmpegExecutable = util.findExecutableInShellPath("ffmpeg");
        }
        this.mplayerExecutable = util.findExecutableInShellPath("mplayer");
    }


    public File createTempDirectory() throws IOException {

        final File temp = File.createTempFile("temp", Long.toString(System.nanoTime()));
        if (!temp.delete()) {
            throw new IOException("Could not delete temp file: " + temp.getAbsolutePath());
        }
        if (!temp.mkdir()) {
            throw new IOException("Could not create temp directory: " + temp.getAbsolutePath());
        }
        return temp;
    }

    private void deleteTempDirectory(File dir) {
        for (File sub : dir.listFiles()) {
            if (sub.isDirectory()) {
                deleteTempDirectory(sub);
            }
            sub.delete();
        }
    }

    
    public CachedImage extractFrame(File file, int secondNo, Thumbnail thumbnail, String size) throws IOException, InterruptedException {

        File tmpDir = createTempDirectory();
        try {
            File frame = new File(tmpDir, "00000001.jpg");
            ProcessBuilder pb = new ProcessBuilder().command(
                    mplayerExecutable, "-vo", "jpeg:quality=95:outdir=" + tmpDir.getAbsolutePath(), "-ao", "null", "-frames", "1", file.getAbsolutePath());
            pb.redirectErrorStream(true);
            Process p = pb.start();
            p.getOutputStream().close();
            log.debug(IOUtils.toString(p.getInputStream()));
            p.waitFor();
            return pictureScaler.scalePicture(frame, thumbnail, size);
        } finally {
            deleteTempDirectory(tmpDir);
        }
    }


    
    /**
     * @param file the file to encode
     * @param thumbnail
     * @param targetSize
     * @param key
     * @return a handle to the encoding process
     */
    private synchronized EncodingProcess submitEncodingJob(File file,
            Thumbnail thumbnail, Dimension targetSize, String key, MovieDatabase movieDb) {
        EncodingProcess ep;
        ep = new EncodingProcess(file, thumbnail, targetSize, movieDb);
        currentEncodings.put(key, ep);
        new Thread(ep).start();
        return ep;
    }




    class EncodingProcess implements Runnable, FlvFilter.FlvReceiver {

        private final int chunkSize = 4 * 65536;
        private final File file;
        private final Dimension targetSize;
        private Chunk currentChunk = null;
        private int chunkPos;
        private int remainingCapacity;
        private final AtomicInteger chunkInProgress;
        private volatile int chunkAvailable = 0;
        private final FlvFilter filter;
        private final String dbKey;
        private final long fileTimestamp;
        private final int orientation;
        private volatile boolean done = false;
        private final MovieDatabase movieDb;


        public EncodingProcess(File file, Thumbnail thumbnail, Dimension size, MovieDatabase movieDb) {
            this.file = file;
            this.fileTimestamp = file.lastModified();
            this.targetSize = size.even();
            this.dbKey = key(file, targetSize);
            FlvMetadata extraMeta = new FlvMetadata();
            extraMeta.setDuration(thumbnail.getDuration());
            this.chunkInProgress = new AtomicInteger(0);
            this.filter = new FlvFilter(this, extraMeta);
            this.orientation = thumbnail.getOrientation();
            this.movieDb = movieDb;
        }

        /*
         * We might want to do H.264 instead of the H.263 codec typically used in FLV files.
         * http://rob.opendot.cl/index.php/useful-stuff/ffmpeg-x264-encoding-guide/
         * http://rob.opendot.cl/index.php/useful-stuff/encoding-a-flv-video-for-embedded-web-playback/
         * 
         * Assuming video in is 1280x720 pixels resolution, and we want to show 640x360,
         * and we want an output bitrate about 1 Mbit/sec
         * we can do a one pass encoding like this:
         * 
         * ffmpeg -i <infile> -aspect 1280:720 -s 640x360 -b 1000000 -crf 20 \
         *   -vcodec libx264 -vpre knut_low \
         *   -acodec libfaac -aq 100 \
         *   <outfile>.mp4
         */
        @Override
        public void run() {

            // -vf transpose=1 (rotate 90)
            // -vf transpose=2 (rotate 270)
            // -vf vflip,hflip (rotate 180)
        	
            //   1        2       3      4         5            6           7          8
            // 
            // 888888  888888      88  88      8888888888  88                  88  8888888888
            // 88          88      88  88      88  88      88  88          88  88      88  88
            // 8888      8888    8888  8888    88          8888888888  8888888888          88
            // 88          88      88  88
            // 88          88  888888  888888
        	String vf = new String[] {
        			null,  // 1
        			"hflip", // 2
        			"vflip,hflip", // 3
        			"vflip", // 4
        			"transpose=1,hflip", // 5
        			"transpose=1", // 6
        			"transpose=2,hflip", // 7
        			"transpose=2", // 8
        	}[orientation - 1];

            try {

                ProcessBuilder pb = createTranscoderProcess(vf);

                log.info(pb.command().toString()); 
                pb.redirectErrorStream(false);
                Process p = pb.start();
                p.getOutputStream().close();
                InputStream movieStream = p.getInputStream();
                InputStream diagnostic = p.getErrorStream();
                new Thread(new ErrorStreamPumper("ffmpeg", diagnostic)).start();

                byte[] buffer = new byte[65536];
                int len;
                while ((len = movieStream.read(buffer)) > 0) {
                    filter.write(buffer, 0, len);
                }
                filter.flush();
                endLastChunk();
                // Generate header again to get updated metadata
                // TODO (knut 30 JUL 2011) Add some metadata about the file in the first chunk of the file
                // size of first chunk, size of last chunk, number number of chunks between first-last, size of chunks between first-last
                // This will be used to determine the right chunk number from a given byte offset in a range oriented HTTP request 
                filter.generateHeaderChunk();
            } catch (Exception e) {
                log.error("uh?", e);
                movieDb.delete(dbKey);
                synchronized (this) {
					this.chunkAvailable = Integer.MAX_VALUE;
				}
            } finally {
                synchronized (MovieCoder.this) {
                    currentEncodings.remove(key(file, targetSize));
                }
                done = true;
                notifyListeners(chunkAvailable);
            }
        }

        private ProcessBuilder createTranscoderProcess(String vf) {
            ArrayList<String> command = new ArrayList<>();
            command.add(ffmpegExecutable);
            command.add("-i");
            command.add(file.getAbsolutePath());
            command.add("-s");
            command.add(targetSize.getWidth() + "x" + targetSize.getHeight());
            command.add("-crf");
            command.add("30");
            command.add("-acodec");
            command.add("libmp3lame");
            command.add("-ar");
            command.add("22050");
            command.add("-vcodec");
            command.add("libx264");
            command.add("-g");
            command.add("150");
            if (vf != null) {
                command.add("-vf");
                command.add(vf);
            }
            command.add("-f");
            command.add("flv");
            command.add("-");
            ProcessBuilder pb = new ProcessBuilder(command);
            return pb;
        }

        /**
         * @return number of chunks available.
         * -Integer.MAX_VALUE if something went very wrong
         * -1 * number of chunks available when we're done. 
         */
        public synchronized int waitForData() {
        	if (!this.done) {
        		try {
        			this.wait(2000L);						
        		} catch (InterruptedException e) {
        			// ignore
        		}
        	}
        	int numChunks = this.chunkAvailable + 1;
        	return this.done ? -numChunks : numChunks;
        }

        private synchronized void notifyListeners(int chunkNo) {

        	this.chunkAvailable = chunkNo;
        	this.notifyAll();
        }

        @Override
        public void writeHeader(byte[] data) {
            int len = data.length;
            Chunk chunk0 = new Chunk(fileTimestamp, len, 0);
            chunk0.bits = data;
            log.info("Writing {} seq 0 ({}) {}", dbKey, chunkInProgress, data.length);
            movieDb.store(dbKey, 0, chunk0);
            notifyListeners(chunkInProgress.get());
        }

        @Override
        public void writeBody(byte[] data) {
            writeBody(data, 0, data.length);
        }

        private void writeBody(byte[] data, int offset, int len) {

            int remainingInput = len;
            int inputPos = offset;
            while (remainingInput > 0) {
                if (currentChunk == null) {
                    startNewChunk();
                }
                int copyLen = Math.min(remainingCapacity, remainingInput);
                System.arraycopy(data, inputPos, currentChunk.bits, chunkPos, copyLen);
                chunkPos += copyLen;
                remainingCapacity -= copyLen;
                inputPos += copyLen;
                remainingInput -= copyLen;
                if (remainingCapacity == 0) {
                    endChunk();
                }
            }
        }

        private void startNewChunk() {

            this.chunkInProgress.incrementAndGet();
            this.currentChunk = new Chunk(fileTimestamp, chunkSize, 0);
            this.chunkPos = 0;
            this.remainingCapacity = chunkSize;
        }

        private void endChunk() {

            log.info("store chunk {}", chunkInProgress);
            movieDb.store(dbKey, chunkInProgress.get(), currentChunk);
            log.info("Writing {} seq {} ({}) {}", dbKey, chunkInProgress, chunkInProgress, currentChunk.bits.length);
            currentChunk = null;
            notifyListeners(chunkInProgress.get());
        }

        private void endLastChunk() {

            if (currentChunk == null) {
                return;
            }
            // reallocate
            Chunk last = new Chunk(fileTimestamp, chunkPos, 0);
            System.arraycopy(currentChunk.bits, 0, last.bits, 0, chunkPos);
            movieDb.store(dbKey, chunkInProgress.get(), last);
            log.info("Writing {} seq {} ({}) {}", dbKey, chunkInProgress, chunkInProgress, last.bits.length);
            currentChunk = null;
        }
    }

    static class ErrorStreamPumper implements Runnable {

        private final InputStream is;
        private final String name;

        public ErrorStreamPumper(String processName, InputStream is) {
            this.name = processName;
            this.is = is;
        }

        @Override
        public void run() {
            org.slf4j.Logger diagnostic = org.slf4j.LoggerFactory.getLogger(this.name);
            try {
                LineNumberReader lnr = new LineNumberReader(new InputStreamReader(is));
                String line;
                while ((line = lnr.readLine()) != null) {
                    diagnostic.info(line);
                }
            } catch (Exception e) {
                log.error("stderr?", e);
            }
        }
    }
    

    // TODO (knut 05 JUL 2011) Come up with a better interface for supporting range requests etcetera
    public void stream(File file, Thumbnail thumbnail, String size, OutputStream out, MovieDatabase movieDb) {

        try {
            grabStream(file, thumbnail, size, movieDb).stream(out);
        } catch (Exception e) {
            log.error("stream fail", e);
        }
    }

    private static String key(File file, Dimension size) {

        return file.getPath() + ":" + size.getWidth();
    }



    synchronized VideoStreamer grabStream(File file, Thumbnail thumbnail, String size, MovieDatabase movieDb) {

        Dimension targetSize = thumbnail.getSize().scale(size).even();
        String key = key(file, targetSize);

        // See if we have (at least the beginning of) the file in the DB
        // and whether file is currently in the process of being encoded
        EncodingProcess ep = currentEncodings.get(key);
        Chunk chunk = movieDb.load(key, 0);

        // If the file has been modified since our last encoding job finished 
        if (chunk != null && ep == null) {
            if (chunk.timestamp != file.lastModified()) {
                log.info("             {} has changed so cache entry wil be refreshed", key);
                movieDb.delete(key);
                chunk = null;
            }
        }
        // If neither we need to start the encoding process
        if (chunk == null && ep == null) {
            ep = submitEncodingJob(file, thumbnail, targetSize, key, movieDb);
        }
        return new VideoStreamer(key, ep, chunk, movieDb);
    }


    class VideoStreamer {

        private int chunkNo = 0;
        private final EncodingProcess ep;
        private Chunk chunk;
        private final String key;
        private boolean done = false;
        private final MovieDatabase movieDb;


        private VideoStreamer(String key, EncodingProcess ep, Chunk chunk0, MovieDatabase movieDb) {
            this.key = key;
            this.ep = ep;
            this.chunk = chunk0;
            this.movieDb = movieDb;
            // Range requests can hook in here
            // if we have chunk metadata in chunk0 we can use that to compute the first
            // chunk we want and set this.chunkNo accordingly.  Otherwise (not likely
            // to happen in practice) we just have to wait for each chunk to become
            // available and count the number of bytes as we skip them.
            //
            // The end of the range is also a matter of counting the bytes.
        }

        public void stream(OutputStream out) throws IOException {

            while (!done) {

                if (chunk == null) {
                    log.info("Looking for {} - {}", key, chunkNo);
                    chunk = movieDb.load(key, chunkNo);
                }
                if (chunk != null) {
                    log.info("Sending {}, {} bytes", chunkNo, chunk.bits.length);
                    out.write(chunk.bits);
                    chunk = null;
                    chunkNo++;
                    continue;
                }
                if (!done) {
                	if (ep != null) {
                		int numChunks = ep.waitForData();
                		System.out.println("Got " + numChunks);
                		if (numChunks == -Integer.MAX_VALUE) {
                			this.done = true;
                		} else if (numChunks < 0) {
                			done = chunkNo >= -numChunks;
                		}
                	} else {
                		// We ran out of chunks, so we must be done.
            			this.done = true;
                		break;
                	}
                }
            }
            log.info("done sending");
        }

    }


	public void setFfmpegPath(String property) {
		
		if (property != null) {
			File program = new File(property);
			if (program.canExecute()) {
				this.ffmpegExecutable = property;
			}
		}
	}
}
