/**************************************************************************
 Skarynka - software for scan, process scanned images and build books

 Copyright (C) 2016 Aleś Bułojčyk

 This file is part of Skarynka.

 Skarynka 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.

 Skarynka 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 this program.  If not, see <http://www.gnu.org/licenses/>.
 **************************************************************************/
package org.alex73.skarynka.scan.process;

import java.io.File;
import java.io.FileFilter;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import javax.script.Bindings;
import javax.script.Compilable;
import javax.script.CompiledScript;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;

import org.alex73.skarynka.scan.Book2;
import org.alex73.skarynka.scan.Context;
import org.alex73.skarynka.scan.process.pdf.PdfCreator;
import org.apache.commons.beanutils.BeanUtils;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class ProcessDaemon extends Thread {
    private static Logger LOG = LoggerFactory.getLogger(ProcessDaemon.class);
    private static Charset UTF8 = Charset.forName("UTF-8");

    private boolean finish;
    private boolean paused;

    private Script currentScript;

    public void finish() {
        synchronized (this) {
            finish = true;
            notifyAll();
        }
    }

    public void setPaused(boolean p) {
        paused = p;
    }

    @Override
    public void run() {
        Thread.currentThread().setPriority(Thread.MIN_PRIORITY);
        LOG.info("ProcessDaemon started");
        while (!finish) {
            try {
                boolean processed = process();
                if (!finish) {
                    synchronized (this) {
                        wait(processed ? 50 : 10000);
                    }
                }
            } catch (Throwable ex) {
                LOG.error("Error process", ex);
            }
        }
    }

    boolean process() throws Exception {
        LOG.debug("check for processing...");
        if (Context.getPermissions().ProcessingControls && Context.getControlDir() != null) {
            // process control files
            LOG.trace("check for control dir " + Context.getControlDir());
            File[] controls = new File(Context.getControlDir()).listFiles(new FileFilter() {
                public boolean accept(File file) {
                    return file.isFile() && file.getName().endsWith(".do");
                }
            });
            if (controls != null) {
                LOG.trace("control files found: " + controls.length);
                for (File c : controls) {
                    LOG.trace("check for control file " + c);
                    File errorFile = new File(c.getPath() + ".err");
                    File outFile = new File(c.getPath() + ".out");
                    if (errorFile.exists() || outFile.exists()) {
                        continue;
                    }
                    try {
                        String cmd = FileUtils.readFileToString(c, "UTF-8");
                        String result = ProcessCommands.call(cmd);
                        FileUtils.writeStringToFile(outFile, result, "UTF-8");
                    } catch (Throwable ex) {
                        FileUtils.writeStringToFile(errorFile, ex.getMessage(), "UTF-8");
                    }
                }
            }
        }
        if (paused) {
            return false;
        }
        if (Context.getPermissions().ProcessingBooks) {
            // process books
            LOG.trace("check for book dir " + Context.getBookDir());
            File[] ls = new File(Context.getBookDir()).listFiles(new FileFilter() {
                public boolean accept(File pathname) {
                    return pathname.isDirectory() && new File(pathname, "book.properties").isFile();
                }
            });

            if (ls == null) {
                LOG.trace("books not found");
                return false;
            }

            int count = 0;
            for (File d : ls) {
                LOG.trace("check for book dir " + d);
                File processFile = new File(d, ".process");
                if (!processFile.exists()) {
                    // processing wasn't started yet
                    continue;
                }
                File processDoneFile = new File(d, ".process.done");
                if (processDoneFile.exists()) {
                    // processing finished
                    continue;
                }
                File errorFile = new File(d, ".errors");
                if (errorFile.exists()) {
                    // if book contains error files - skip this book
                    continue;
                }
                LOG.debug("Process book " + d);
                Book2 book = new Book2(d);
                if (!book.getErrors().isEmpty()) {
                    FileUtils.writeStringToFile(errorFile, "Error read book: " + book.getErrors(), UTF8);
                    continue;
                }
                String command = FileUtils.readFileToString(processFile, UTF8);

                Script newScript;
                try {
                    // update compiled script if script file was updated
                    newScript = new Script(command);
                    if (currentScript == null || !currentScript.theSame(newScript)) {
                        currentScript = newScript;
                        currentScript.compile();
                    }
                } catch (Throwable ex) {
                    FileUtils.writeStringToFile(errorFile, "Error get script: " + ex.getMessage(), UTF8);
                    continue;
                }
                currentScript.makeTemp(book);
                boolean wasError = false;
                for (String p : book.listPages()) {
                    if (finish) {
                        return false;
                    }
                    // PageFileInfo pfi = new PageFileInfo(book, p);
                    try {
                        if (currentScript.pageResultExist(book, p)) {
                            continue;
                        }
                        currentScript.pageExecute(book, p);
                        count++;
                    } catch (Throwable ex) {
                        LOG.info("Error process page " + p, ex);
                        FileUtils.writeStringToFile(errorFile,
                                "Error process page #" + p + ": " + ex.getMessage(), UTF8);
                        wasError = true;
                        break;
                    }
                    if (count >= 5) {
                        return true;
                    }
                }
                // pages process was finished - need to finish book
                try {
                    if (!wasError) {
                        if (!currentScript.bookResultExist(book)) {
                            currentScript.bookExecute(book);
                        }
                        currentScript.removeTemp(book);
                    }
                } catch (Throwable ex) {
                    LOG.info("Error process book", ex);
                    FileUtils.writeStringToFile(errorFile, "Error process book: " + ex.getMessage(), UTF8);
                    wasError = true;
                    break;
                }

                if (!wasError) {
                    // book finished without errors
                    processFile.renameTo(processDoneFile);
                }
            }
        }
        return false;
    }

    public static class PageContext {
        private final Book2 book;
        private final String page;
        private final Book2.PageInfo pi;
        private final Map<String, Boolean> tags;

        public PageContext(Book2 book, String page) {
            this.book = book;
            this.page = Book2.formatPageNumber(page);
            this.pi = book.getPageInfo(this.page);
            Map<String, Boolean> tags = new TreeMap<>();
            for (String t : pi.tags) {
                tags.put(t, true);
            }
            this.tags = Collections.unmodifiableMap(tags);
        }

        public String getNumber() {
            return page;
        }

        public Map<String, Boolean> getTags() {
            return tags;
        }

        public int getCropPosX() {
            return pi.cropPosX;
        }

        public int getCropPosY() {
            return pi.cropPosY;
        }

        public int getRotate() {
            return pi.rotate;
        }

        public String getCamera() {
            return pi.camera;
        }

        public int getImageSizeX() {
            return pi.imageSizeX;
        }

        public int getImageSizeY() {
            return pi.imageSizeY;
        }

        public String getOriginalPageFile() {
            return page + '.' + pi.pageOriginalFileExt;
        }

        public String getPreviewPageFile() {
            return getOriginalPageFile() + ".preview.jpg";
        }
    }

    public static class BookContext {
        private final Book2 book;

        public BookContext(Book2 book) {
            this.book = book;
        }

        public Book2 getBook() {
            return book;
        }

        public String getName() {
            return book.getName();
        }

        public int getScale() {
            return book.scale;
        }

        public int getDpi() {
            return book.dpi;
        }

        public int getCropSizeX() {
            return book.cropSizeX;
        }

        public int getCropSizeY() {
            return book.cropSizeY;
        }

        public List<String> getPages() {
            return book.listPages();
        }
    }

    public static class CommandContext {
        private final Book2 book;
        private final Bindings bindings;

        public CommandContext(Book2 book, Bindings bindings) {
            this.book = book;
            this.bindings = bindings;
        }

        public boolean fileExist(String name) {
            return new File(book.getBookDir(), name).exists();
        }

        public boolean fileRemove(String name) {
            return new File(book.getBookDir(), name).delete();
        }

        static final Pattern RE_VAR = Pattern.compile("\\$\\{.+?\\}");

        public void exec(String cmd) throws Exception {
            StringBuilder cmdo = new StringBuilder();
            Matcher m = RE_VAR.matcher(cmd);
            int prev = 0;
            while (m.find(prev)) {
                cmdo.append(cmd.substring(prev, m.start()));
                String var = cmd.substring(m.start() + 2, m.end() - 1);
                cmdo.append(BeanUtils.getProperty(bindings, var));
                prev = m.end();
            }
            cmdo.append(cmd.substring(prev));
            String[] cmda;
            switch (Context.thisOS) {
            case LINUX:
                cmda = new String[] { "nice", "ionice", "-c3", "sh", "-c", cmdo.toString() };
                break;
            case WINDOWS:
                cmda = new String[] { "cmd.exe", "/c", cmdo.toString() };
                break;
            default:
                throw new Exception("Unknown OS");
            }
            LOG.debug("Execute for book " + book.getName() + ": " + cmdo);
            Process process = Runtime.getRuntime().exec(cmda, null, book.getBookDir());
            int r = process.waitFor();
            LOG.debug("Execution result: " + r);
            if (r != 0) {
                String err = IOUtils.toString(process.getErrorStream(),
                        Context.getSettings().get("command_charset"));
                throw new Exception("Error execution " + Arrays.toString(cmda) + ": " + r + " / " + err);
            }
        }

        public void pdf(String bookOutPath, String imagesDirPath, String imagesExt) throws Exception {
            File outFile = new File(book.getBookDir(), bookOutPath);
            File imagesDir = new File(book.getBookDir(), imagesDirPath);
            File[] jpegs = imagesDir.listFiles(new FileFilter() {
                @Override
                public boolean accept(File f) {
                    return f.isFile() && f.getName().endsWith("." + imagesExt);
                }
            });
            Arrays.sort(jpegs);
            PdfCreator.create(outFile, jpegs);
        }

        public String getExecDir() {
            return new File(".").getAbsolutePath();
        }

        public PdfCreator pdf2(String bookOutPath) throws Exception {
            File outFile = new File(book.getBookDir(), bookOutPath);
            return new PdfCreator(outFile);
        }
    }

    public static class Script {
        public final String script;
        public final String command;
        public ScriptEngine engine;
        public CompiledScript csPreview;
        public CompiledScript csPageResultExists;
        public CompiledScript csPageExecute;
        public CompiledScript csBookResultExists;
        public CompiledScript csBookExecute;

        public Script(String command) throws Exception {
            this.script = FileUtils.readFileToString(new File("process.js"), "UTF-8");
            this.command = command;
        }

        public boolean theSame(Script obj) {
            return script.equals(obj.script) && command.equals(obj.command);
        }

        public void compilePreview() throws Exception {
            engine = new ScriptEngineManager().getEngineByName("nashorn");
            csPreview = ((Compilable) engine).compile(script + "\n command_preview();");
        }

        public void execPreview(Book2 book, String page) throws Exception {
            Bindings bindings = engine.createBindings();
            bindings.put("settings", Context.getSettings());
            bindings.put("page", new PageContext(book, page));
            bindings.put("book", new BookContext(book));
            bindings.put("cmd", new CommandContext(book, bindings));
            csPreview.eval(bindings);
        }

        public void compile() throws Exception {
            engine = new ScriptEngineManager().getEngineByName("nashorn");
            csPageResultExists = ((Compilable) engine).compile(script + "\n exist_" + command + "();");
            csPageExecute = ((Compilable) engine).compile(script + "\n execute_" + command + "();");
            csBookResultExists = ((Compilable) engine).compile(script + "\n bookexist_" + command + "();");
            csBookExecute = ((Compilable) engine).compile(script + "\n bookexecute_" + command + "();");
        }

        public boolean pageResultExist(Book2 book, String page) throws Exception {
            Bindings bindings = engine.createBindings();
            bindings.put("settings", Context.getSettings());
            bindings.put("page", new PageContext(book, page));
            bindings.put("book", new BookContext(book));
            bindings.put("cmd", new CommandContext(book, bindings));
            return (boolean) csPageResultExists.eval(bindings);
        }

        public void pageExecute(Book2 book, String page) throws Exception {
            Bindings bindings = engine.createBindings();
            bindings.put("settings", Context.getSettings());
            bindings.put("page", new PageContext(book, page));
            bindings.put("book", new BookContext(book));
            bindings.put("cmd", new CommandContext(book, bindings));
            csPageExecute.eval(bindings);
        }

        public boolean bookResultExist(Book2 book) throws Exception {
            Bindings bindings = engine.createBindings();
            bindings.put("settings", Context.getSettings());
            bindings.put("book", new BookContext(book));
            bindings.put("cmd", new CommandContext(book, bindings));
            return (boolean) csBookResultExists.eval(bindings);
        }

        public void bookExecute(Book2 book) throws Exception {
            Bindings bindings = engine.createBindings();
            bindings.put("settings", Context.getSettings());
            bindings.put("book", new BookContext(book));
            bindings.put("cmd", new CommandContext(book, bindings));
            csBookExecute.eval(bindings);
        }

        public void makeTemp(Book2 book) {
            new File(book.getBookDir(), "temp").mkdirs();
        }

        public void removeTemp(Book2 book) throws IOException {
            FileUtils.deleteDirectory(new File(book.getBookDir(), "temp"));
        }
    }

    private static Script previewScript;

    public static synchronized void createPreviewIfNeed(Book2 book, String page) {
        try {
            if (previewScript == null) {
                previewScript = new ProcessDaemon.Script("preview");
                previewScript.compilePreview();
            }
            PageFileInfo pfi = new PageFileInfo(book, page);
            if (!pfi.getPreviewFile().exists()) {
                updatePageSize(book, page);
                previewScript.execPreview(book, page);
            }
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }

    public static synchronized void updatePageSize(Book2 book, String page) throws Exception {
        Book2.PageInfo pi = book.getPageInfo(page);
        PageFileInfo pfi = new PageFileInfo(book, page);
        try (ImageInputStream iis = ImageIO.createImageInputStream(pfi.getOriginalFile())) {
            Iterator<ImageReader> readers = ImageIO.getImageReaders(iis);
            if (!readers.hasNext()) {
                throw new Exception("Error read image file meta: " + pfi.getOriginalFile().getAbsolutePath());
            }
            ImageReader rd = readers.next();
            rd.setInput(iis, true);
            pi.imageSizeX = rd.getWidth(0);
            pi.imageSizeY = rd.getHeight(0);
        }
    }
}
