package org.forkalsrud.album.web;

import java.io.*;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.apache.commons.io.IOUtils;
import org.apache.log4j.PropertyConfigurator;
import org.forkalsrud.album.db.DirectoryDatabase;
import org.forkalsrud.album.db.DirectoryProps;
import org.forkalsrud.album.db.MovieDatabase;
import org.forkalsrud.album.db.ThumbnailDatabase;
import org.forkalsrud.album.exif.*;
import org.forkalsrud.album.video.MovieCoder;
import org.springframework.web.util.HtmlUtils;

import com.sleepycat.je.Environment;
import com.sleepycat.je.EnvironmentConfig;


public class AlbumServlet
    extends HttpServlet
{
    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(AlbumServlet.class);

    static void addDummyLoggerFor(String... names) {
        for (String name : names)
        try {
            Logger ll = Logger.getLogger(name);
            Handler[] hh = ll.getHandlers();
            if (hh == null) {
                ll.addHandler(new Handler() {

                    @Override
                    public void close() throws SecurityException {
                        //
                    }

                    @Override
                    public void flush() {
                        //
                    }

                    @Override
                    public void publish(LogRecord record) {
                        //
                        Throwable t = record.getThrown();
                        if (Level.INFO.equals(record.getLevel())) {
                            log.info(record.getMessage(), t);
                        } else if (Level.WARNING.equals(record.getLevel())) {
                            log.warn(record.getMessage(), t);
                        } else if (Level.SEVERE.equals(record.getLevel())) {
                            log.error(record.getMessage(), t);
                        } else {
                            log.debug(record.getMessage(), t);
                        }
                    }
                });
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    static {
        addDummyLoggerFor("com.sleepycat.je.evictor.PrivateEvictor",
                "com.sleepycat.je.dbi.EnvironmentImpl",
                "com.sleepycat.je.recovery.Checkpointer",
                "com.sleepycat.je.incomp.INCompressor",
                "com.sleepycat.je.cleaner.UtilizationProfile",
                "com.sleepycat.je.cleaner.Cleaner",
                "com.sleepycat.je.cleaner.FileProcessor",
                "com.sleepycat.je.recovery.RecoveryManager");
    }




    /**
     * maps files to URIs relative to servlet, e.g. /home/joe/photos/holiday/arrival.jpg -> /photos/holiday/arrival.jpg
     * assuming base is /home/joe/photos
     */
    public static class Mapper {

        private final File base;

        public Mapper(File base) {
            this.base = base;
        }

        public String map(File file) {
            StringBuilder buf = new StringBuilder();
            return appendFile(buf, file).toString();
        }

        private StringBuilder appendFile(StringBuilder buf, File file) {
            if (file == null) {
                return buf;
            }
            if (base.equals(file.getAbsoluteFile())) {
                return buf.append("/").append(base.getName());
            } else {
                return appendFile(buf, file.getParentFile()).append('/').append(file.getName());
            }
        }
    }


    class Root {

        String name;
        File base;
        String basePrefix;
        DirectoryEntryFactory dirEntryFactory;

        private final Environment environment;
        ThumbnailDatabase thumbDb;
        DirectoryDatabase dirDb;
        MovieDatabase movieDb;
        Entry cachedRootNode = null;

        public Root(String name, File base, File dbDir) {
            this.name = name;
            this.base = base;
            this.basePrefix = "/" + base.getName();


            EnvironmentConfig environmentConfig = new EnvironmentConfig();
            environmentConfig.setAllowCreate(true);
            environmentConfig.setTransactional(true);
            this.environment = new Environment(dbDir, environmentConfig);

            this.thumbDb = new ThumbnailDatabase(environment);
            this.dirDb = new DirectoryDatabase(environment);
            this.movieDb = new MovieDatabase(environment);


            try {
                dirEntryFactory = new DirectoryEntryFactory(dirDb);
            } catch (IOException | InterruptedException e) {
                log.error("initialization of " + name, e);
            }
        }


        public void flushCache() {
            cachedRootNode = null;
        }


        public void close() {
            dirDb.close();
            thumbDb.close();
            movieDb.close();
            environment.close();
        }

        public String getName() {
            return name;
        }




        Entry resolveEntry(String pathInfo) {

            if (pathInfo == null || "/".equals(pathInfo)) return resolve(base);
            return resolve(new File(base.getParentFile(), pathInfo));
        }

        Entry resolve(File file) {

            if (base.equals(file.getAbsoluteFile())) {
                synchronized (this) {
                    if (cachedRootNode == null) {
                        cachedRootNode = dirEntryFactory.getEntry(file, null);
                    }
                }
                return cachedRootNode;
            } else {
                return ((DirectoryEntry)resolve(file.getParentFile())).get(file);
            }
        }




        void handleSearch(HttpServletRequest req, HttpServletResponse res, DirectoryEntry entry) throws Exception {
            String query = req.getParameter("q");

            SearchEngine search = new SearchEngine(entry);
            SearchResults results = search.search(query);

            res.setContentType("text/html");
            req.setAttribute("search", query);
            req.setAttribute("entry", results);
            req.setAttribute("thmb", 250);
            req.setAttribute("full", 800);
            RequestDispatcher rd = req.getRequestDispatcher("/WEB-INF/velocity/photo.vm");
            rd.forward(req, res);
        }


        void handlePhoto(HttpServletRequest req, HttpServletResponse res, FileEntry entry) throws Exception {
            res.setContentType("text/html");
            req.setAttribute("entry", entry);
            req.setAttribute("thmb", 800);
            RequestDispatcher rd = req.getRequestDispatcher("/WEB-INF/velocity/photo.vm");
            rd.forward(req, res);
        }

        void handleAlbum(HttpServletRequest req, HttpServletResponse res, DirectoryEntry entry) throws Exception {
            res.setContentType("text/html");
            req.setAttribute("entry", entry);
            req.setAttribute("thmb", 250);
            req.setAttribute("full", 800);
            req.setAttribute("D", "$");
            RequestDispatcher rd = req.getRequestDispatcher("/WEB-INF/ng.html");
            rd.forward(req, res);
        }

        void handleMovieFrame(HttpServletRequest req, HttpServletResponse res, FileEntry entry) {

            File file = entry.getPath();
            if (notModified(req, file)) {
                res.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
                res.setDateHeader("Expires", System.currentTimeMillis() + (30 * 24 * 3600 * 1000L)); // 30 days
                log.info("{} not modified (based on date)", file.getName());
                return;
            }
            int secondNo = 3;
            String size = req.getParameter("size");
            if (size == null) {
                size = "250";
            }
            String key = file.getPath() + ":" + secondNo + ":" + size;
            CachedImage cimg = thumbDb.load(key);
            if (cimg != null) {
                long fileTs = file.lastModified();
                if (cimg.lastModified >= fileTs) {
                    // log.info("cache hit on " + key);
                } else {
                    log.info("             {} has changed so cache entry wil be refreshed", key);
                    cimg = null;
                }
            }
            if (cimg == null) {
                try {
                    cimg = movieCoder.extractFrame(file, secondNo, entry.getThumbnail(), size);
                    thumbDb.store(key, cimg);
                    log.info("             {} added to the cache with size {} -- now {} entries", key, cimg.bits.length, thumbDb.size());
                } catch (Exception e) {
                    //e.fillInStackTrace();
                    throw new RuntimeException("sadness", e);
                }
            }
            try {
                res.setStatus(HttpServletResponse.SC_OK);
                res.setDateHeader("Last-Modified", file.lastModified());
                res.setDateHeader("Expires", System.currentTimeMillis() + (30 * 24 * 3600 * 1000L)); // 30 days
                res.setContentType(cimg.mimeType);
                res.setContentLength(cimg.bits.length);
                res.getOutputStream().write(cimg.bits);
            } catch (Exception ex) {
                throw new RuntimeException("sadness", ex);
            }
        }

        void handleMovie(HttpServletRequest req, HttpServletResponse res, FileEntry entry) {

            File file = entry.getPath();
            if (notModified(req, file)) {
                res.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
                res.setDateHeader("Expires", System.currentTimeMillis() + (30 * 24 * 3600 * 1000L)); // 30 days
                log.info("{} not modified (based on date)", file.getName());
                return;
            }

            String size = req.getParameter("size");
            if (size == null) {


                try (FileInputStream in = new FileInputStream(file);
                     OutputStream out = res.getOutputStream()) {
                    res.setStatus(HttpServletResponse.SC_OK);
                    res.setDateHeader("Last-Modified", entry.getPath().lastModified());
                    res.setDateHeader("Expires", System.currentTimeMillis() + (30 * 24 * 3600 * 1000L)); // 30 days
                    res.setContentType("video/mp4");
                    IOUtils.copyLarge(in, out);
                } catch (IOException e) {
                    log("sadness", e);
                }
                return;
            }


            try {
                res.setStatus(HttpServletResponse.SC_OK);
                res.setDateHeader("Last-Modified", entry.getPath().lastModified());
                res.setDateHeader("Expires", System.currentTimeMillis() + (30 * 24 * 3600 * 1000L)); // 30 days
                // res.setHeader("Cache-control", "no-cache");
                res.setContentType("video/x-flv");
                movieCoder.stream(entry.getPath(), entry.getThumbnail(), size, res.getOutputStream(), movieDb);
            } catch (Exception ex) {
                log.error("darn", ex);
                throw new RuntimeException("sadness", ex);
            }
        }


        void handleJson(HttpServletRequest req, HttpServletResponse res, DirectoryEntry entry) {
            try {


                EntryWithChildren<?> contents;
                String query = req.getParameter("q");
                if (query != null && !"".equals(query.trim())) {
                    SearchEngine search = new SearchEngine(entry);
                    contents = search.search(query);
                } else {
                    contents = entry;
                }


                Mapper mapper = new Mapper(base);
                res.setContentType("application/json");
                res.setCharacterEncoding("UTF-8");
                ObjectMapper json = new ObjectMapper();
                OutputStream out = res.getOutputStream();
                ObjectNode root = json.createObjectNode();
                root.put("name", entry.getName());

                addDir(root, entry.parent(), "parent", mapper);
                addDir(root, entry.prev(), "prev", mapper);
                addDir(root, entry.next(), "next", mapper);

                if (entry.groupByYear()) {
                    root.put("groupPrefix", 4);
                }
                ArrayNode list = root.putArray("contents");
                for (Entry e : contents.getContents()) {
                    try {
                        ObjectNode item = list.addObject();
                        item.put("name", e.getName());
                        item.put("type", e.getType());
                        if ("dir".equals(e.getType())) {
                            if (e.getEarliest() != null) {
                                SimpleDateFormat sdf = new SimpleDateFormat("yyyy");
                                item.put("earliest", sdf.format(e.getEarliest()));
                            }
                        }
                        Thumbnail thumb = e.getThumbnail();
                        if (thumb != null) {
                            item.put("path", mapper.map(thumb.getPath()));
                            item.put("thumbtype", thumb.getType());
                            item.put("width", thumb.getSize().getWidth());
                            item.put("height", thumb.getSize().getHeight());
                        }
                        if (e.getCaption() != null) {
                            item.put("caption", e.getCaption());
                        }
                    } catch (Exception ex) {
                        throw new Exception(e.toString(), ex);
                    }
                }
                json.writeValue(out, root);
            } catch (Exception e) {
                throw new RuntimeException("sadness", e);
            }
        }


        void addDir(ObjectNode root, Entry candidate, String label,  Mapper mapper) {
            if (candidate != null && "dir".equals(candidate.getType())) {
                root.put(label, mapper.map(candidate.getPath()));
            }
        }



        void handleCache(HttpServletRequest req, HttpServletResponse res, DirectoryEntry entry) {
            if (entry == null) {
                res.setStatus(HttpServletResponse.SC_NOT_FOUND);
                return;
            }
            DirectoryProps props = entry.getCache();
            if (props == null) {
                props = new DirectoryProps();
            }
            try {
                res.setContentType("text/plain");
                res.setCharacterEncoding("UTF-8");
                PrintWriter out = res.getWriter();
                out.println("# cache timestamp: " + props.getTimestamp());
                out.println("# dir   timestamp: " + entry.getPath().lastModified());
                out.println();
                props.store(out, "");
            } catch (Exception e) {
                throw new RuntimeException("sadness", e);
            }
        }
        void handleEdit(HttpServletRequest req, HttpServletResponse res, FileEntry entry) throws Exception {
            String value = req.getParameter("value");
            if (value != null) {
                File propertyFile = new File(entry.getPath().getParent(), "album.properties");
                Properties props = new Properties();
                if (propertyFile.exists()) {
                    FileInputStream fis = new FileInputStream(propertyFile);
                    props.load(fis);
                    fis.close();
                }
                props.setProperty("file." + entry.getName() + ".caption", value);
                FileOutputStream fos = new FileOutputStream(propertyFile);
                props.store(fos, "online editor");
                fos.close();
                res.setContentType("text/html");
                res.getWriter().println(HtmlUtils.htmlEscape(value));
                cachedRootNode = null;
                return;
            }
            res.setContentType("text/html");
            req.setAttribute("entry", entry);
            req.setAttribute("thmb", 640);
            RequestDispatcher rd = req.getRequestDispatcher("/WEB-INF/velocity/edit.vm");
            rd.forward(req, res);
        }





        void procesScaledImageRequest(HttpServletRequest req, HttpServletResponse res, File file, Thumbnail thumbnail, String size) throws IOException {

            if (notModified(req, file)) {
                res.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
                res.setDateHeader("Expires", System.currentTimeMillis() + (30 * 24 * 3600 * 1000L)); // 30 days
                log.info("{} not modified (based on date)", file.getName());
                return;
            }
            String fileEtag = thumbnail.getEtag() + "-" + size;
            if (etagMatches(req, fileEtag)) {
                res.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
                res.setDateHeader("Expires", System.currentTimeMillis() + (30 * 24 * 3600 * 1000L)); // 30 days
                log.info("{} not modified (based on etag)", file.getName());
                return;
            }

            String key = file.getPath() + ":" + size;

            CachedImage cimg = thumbDb.load(key);
            if (cimg != null) {
                if (cimg.lastModified == file.lastModified()) {
                    // log.info("cache hit on " + key);
                } else {
                    log.info("             {} has changed so cache entry wil be refreshed", key);
                    cimg = null;
                }
            }
            if (cimg == null) {
                cimg = pictureScaler.scalePicture(file, thumbnail, size);
                thumbDb.store(key, cimg);
                int byteSize = cimg.bits != null ? cimg.bits.length : 0;
                log.info("             {} added to the cache with size {} -- now {} entries", key, byteSize, thumbDb.size());
            }
            res.setStatus(HttpServletResponse.SC_OK);
            res.setDateHeader("Last-Modified", file.lastModified());
            res.setHeader("ETag", fileEtag);
            res.setDateHeader("Expires", System.currentTimeMillis() + (30 * 24 * 3600 * 1000L)); // 30 days
            res.setContentType(cimg.mimeType);
            res.setContentLength(cimg.bits.length);
            res.getOutputStream().write(cimg.bits);
        }
    }



    Map<String, Root> roots = new HashMap<>();


    PictureScaler pictureScaler;
    MovieCoder movieCoder;

    long lastCacheFlushTime;
    long nextCacheRefresh;

    @Override
    public void init()
        throws ServletException
    {
        Properties props = new Properties();

        File forkalsrudOrg = new File(System.getProperty("user.home"), "forkalsrud.org");
        if (! forkalsrudOrg.exists()) {
            forkalsrudOrg.mkdirs();
        }
        File photoConf = new File(forkalsrudOrg, "photo.properties");
        if (photoConf.exists()) {
            try {
                props.load(new FileReader(photoConf));
            } catch (Exception e) {
                throw new ServletException("unable to load settings from " + photoConf.getPath(), e);
            }
        }

        log4jInit("/log4j.properties");
        log.info("in init of Album");
        long minute = 60 * 1000L;
        nextCacheRefresh = System.currentTimeMillis() + minute;

        Set<String> rootNames = props.stringPropertyNames().stream()
                .filter(s -> s.startsWith("root."))
                .map(s -> s.split("\\.")[1])
                .collect(Collectors.toSet());
        if (rootNames.isEmpty()) {
            File photos = new File("photos");
            File dbdir = new File(System.getProperty("java.io.tmpdir"), "album");
            photos.mkdirs();
            dbdir.mkdirs();
            roots.put("photos", new Root("photos", photos, dbdir));
        } else {
            rootNames.forEach(name -> {
                File base = new File(props.getProperty("root." + name + ".base", "photos")).getAbsoluteFile();
                String dbDirName = props.getProperty("root." + name + ".dbdir");
                File dbDir = new File(dbDirName);
                dbDir.mkdirs();
                roots.put(name, new Root(name, base, dbDir));
            });
        }



        pictureScaler = new PictureScaler();
        movieCoder = new MovieCoder(pictureScaler);
        movieCoder.setFfmpegPath(props.getProperty("ffmpeg.path"));
        try {
            movieCoder.init();
        } catch (Exception e) {
            throw new ServletException("unable to locate movie helpers (mplayer and ffmpeg)", e);
        }


        lastCacheFlushTime = System.currentTimeMillis();

        Object baseUrl = getServletContext().getAttribute("album.baseurl");
        String url = (baseUrl != null ? String.valueOf(baseUrl) : "http://localhost:8080/photo")
                + "/" + getServletConfig().getServletName() + "/"
                + rootNames.stream().findFirst().orElse("photos") + ".album";
        System.out.println("Visit " + url);
    }

    private void log4jInit(String resource) {
        try {
            Properties props = new Properties();
            props.load(getClass().getResourceAsStream(resource));
            PropertyConfigurator.configure(props);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void destroy() {
        log.info("Shutting down Album");
        roots.values().forEach(Root::close);
    }


    static Pattern ROOT_MATCHER = Pattern.compile("^/(\\w+)");

    Root rootFor(String pathInfo) {

        if (pathInfo == null) {
            return null;
        }
        Matcher m = ROOT_MATCHER.matcher(pathInfo);
        if (!m.find()) {
            return null;
        }
        return roots.get(m.group(1));
    }



    @Override
    public void doPost(HttpServletRequest req, HttpServletResponse res)
        throws ServletException, IOException
    {
        doGet(req, res);
    }

    @Override
    public void doGet(HttpServletRequest req, HttpServletResponse res)
        throws ServletException, IOException
    {

        req.setAttribute("assets", req.getContextPath() + "/assets");
        req.setAttribute("req", req);
        req.setAttribute("base", req.getContextPath() + req.getServletPath());
        String pathInfo = req.getPathInfo();

        // help the user get to the top level page
        if (pathInfo == null || "/".equals(pathInfo)) {
            String rootPath = roots.values().stream()
                    .findFirst()
                    .map(r -> req.getContextPath() + "/album/" + r.getName() + ".album")
                    .orElse(null);
            if (rootPath != null) {
                res.sendRedirect(rootPath);
            } else {
                res.setStatus(404);
            }
            return;
        }

        if ("/_roots.json".equals(pathInfo)) {
            res.setContentType("application/json");
            res.setCharacterEncoding("UTF-8");

            ObjectMapper json = new ObjectMapper();
            ArrayNode arr = json.createArrayNode();
            roots.values().stream().map(Root::getName).forEach(arr::add);
            json.writeValue(res.getOutputStream(), arr);
        	return;
        }

        long now = System.currentTimeMillis();
        if (now > nextCacheRefresh) {
            roots.values().forEach(Root::flushCache);
            long minute = 60 * 1000L;
            nextCacheRefresh = now + minute;
        }

        try {
            Root root = rootFor(pathInfo);
            if (root == null) {
                res.setStatus(HttpServletResponse.SC_NOT_FOUND);
                return;
            }
            req.setAttribute("mapper", new Mapper(root.base));

            if (pathInfo.endsWith(".search")) {
                pathInfo = pathInfo.substring(0, pathInfo.length() - ".search".length());
                root.handleSearch(req, res, (DirectoryEntry)root.resolveEntry(pathInfo));
                return;
            }

            if (pathInfo.endsWith(".album")) {
                pathInfo = pathInfo.substring(0, pathInfo.length() - ".album".length());
                root.handleAlbum(req, res, (DirectoryEntry)root.resolveEntry(pathInfo));
                return;
            }

            if (pathInfo.endsWith(".photo")) {
                pathInfo = pathInfo.substring(0, pathInfo.length() - ".photo".length());
                root.handlePhoto(req, res, (FileEntry)root.resolveEntry(pathInfo));
                return;
            }

            if (pathInfo.endsWith(".frame")) {
                pathInfo = pathInfo.substring(0, pathInfo.length() - ".frame".length());
                root.handleMovieFrame(req, res, (FileEntry)root.resolveEntry(pathInfo));
                return;
            }

            if (pathInfo.endsWith(".movie")) {
                pathInfo = pathInfo.substring(0, pathInfo.length() - ".movie".length());
                root.handleMovie(req, res, (FileEntry)root.resolveEntry(pathInfo));
                return;
            }

            if (pathInfo.endsWith(".edit")) {
                pathInfo = pathInfo.substring(0, pathInfo.length() - ".edit".length());
                root.handleEdit(req, res, (FileEntry)root.resolveEntry(pathInfo));
                return;
            }
        
            if (pathInfo.endsWith(".json")) {
                pathInfo = pathInfo.substring(0, pathInfo.length() - ".json".length());
                DirectoryEntry directoryEntry = (DirectoryEntry)root.resolveEntry(pathInfo);
                if (directoryEntry == null) {
                    res.setStatus(HttpServletResponse.SC_NOT_FOUND);
                    return;
                }
                root.handleJson(req, res, directoryEntry);
                return;
            }

            if (pathInfo.endsWith(".cache")) {
                pathInfo = pathInfo.substring(0, pathInfo.length() - ".cache".length());
                root.handleCache(req, res, (DirectoryEntry)root.resolveEntry(pathInfo));
                return;
            }

            File file = new File(root.base.getParentFile(), pathInfo);
            if (!file.canRead()) {
                res.setStatus(HttpServletResponse.SC_FORBIDDEN);
                return;
            }

            String size = req.getParameter("size");
            if ("max".equals(size)) {
                getServletContext().getNamedDispatcher("file").forward(req, res);
            } else if (size != null) {
                FileEntry e = (FileEntry)root.resolve(file);
                root.procesScaledImageRequest(req, res, file, e.getThumbnail(), size);
                return;
            }
        } catch (Exception e) {
            throw new ServletException("sadness", e);
        }
        res.setStatus(HttpServletResponse.SC_NOT_FOUND);
    }



    boolean etagMatches(HttpServletRequest req, String fileEtag) {

    	String cacheControl = req.getHeader("Cache-Control");
    	if ("no-cache".equals(cacheControl)) {
    		return false;
    	}
    	String reqEtag = req.getHeader("If-None-Match");
        if (reqEtag != null) {
            return reqEtag.equals(fileEtag);
        }
        return false;
    }

    boolean notModified(HttpServletRequest req, File f) {

        String cacheControl = req.getHeader("Cache-Control");
        if ("no-cache".equals(cacheControl)) {
            return false;
        }
        long reqDate = req.getDateHeader("If-Modified-Since");
        long fDate = f.lastModified();
        return reqDate > 0 && fDate > 0 && fDate <= reqDate;
    }





    @Override
    public String getServletInfo() {
        return "Display of org.forkalsrud.album";
    }

}

// eof
