/*
 * Copyright (c) 2009-2020 jMonkeyEngine
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * * Redistributions of source code must retain the above copyright
 *   notice, this list of conditions and the following disclaimer.
 *
 * * Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
 *   may be used to endorse or promote products derived from this software
 *   without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
 * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
package com.jme3.asset.plugins;

import com.jme3.asset.AssetInfo;
import com.jme3.asset.AssetKey;
import com.jme3.asset.AssetLocator;
import com.jme3.asset.AssetManager;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.StandardCharsets;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CoderResult;
import java.util.HashMap;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.zip.Inflater;
import java.util.zip.InflaterInputStream;
import java.util.zip.ZipEntry;

/**
 * <code>HttpZipLocator</code> is similar to {@link ZipLocator}, except
 * it allows loading assets from a <code>.ZIP</code> file on the web instead of
 * on the local filesystem.
 * <p>
 * The root path must be a valid HTTP(S) {@link URL} pointing to ZIP or
 * ZIP-like file (such as a JAR). For example,<br>
 * <code>https://www.example.com/my/sub/path/assets.zip</code>.
 * <p>
 * The locator is designed in such a way that it does not require downloading
 * the entire <code>.ZIP</code> file from the web in order to load
 * assets from it. Instead, the ZIP header is extracted first, and then
 * is used to lookup assets from within the ZIP file and download them
 * as requested by the user. 
 * 
 * @author Kirill Vainer
 */
public class HttpZipLocator implements AssetLocator {

    private static final Logger logger = Logger.getLogger(HttpZipLocator.class.getName());

    private URL zipUrl;
    private String rootPath = "";
    private int numEntries;
    private int tableOffset;
    private int tableLength;
    private HashMap<String, ZipEntry2> entries;
    
    private final ByteBuffer byteBuf = ByteBuffer.allocate(250);
    private final CharBuffer charBuf = CharBuffer.allocate(250);
    private final CharsetDecoder utf8Decoder = StandardCharsets.UTF_8.newDecoder();

    private static class ZipEntry2 {
        String name;
        int length;
        int offset;
        int compSize;
        long crc;
        boolean deflate;
        // These fields will be fetched later from local file header,
        // once asset requested.
        Integer nameLength;
        Integer extraLength;

        @Override
        public String toString(){
            return "ZipEntry[name=" + name +
                         ",  length=" + length +
                         ",  compSize=" + compSize +
                         ",  offset=" + offset + "]";
        }
    }

    private static int get16(byte[] b, int off) {
        return  (b[off++] & 0xff) |
               ((b[off]   & 0xff) << 8);
    }

    private static int get32(byte[] b, int off) {
        return  (b[off++] & 0xff) |
               ((b[off++] & 0xff) << 8) |
               ((b[off++] & 0xff) << 16) |
               ((b[off] & 0xff) << 24);
    }

    private static long getu32(byte[] b, int off) throws IOException{
        return (b[off++]&0xff) |
              ((b[off++]&0xff) << 8) |
              ((b[off++]&0xff) << 16) |
             (((long)(b[off]&0xff)) << 24);
    }

    private String getUTF8String(byte[] b, int off, int len) throws CharacterCodingException {
        StringBuilder sb = new StringBuilder();
        
        int read = 0;
        while (read < len){
            // Either read n remaining bytes in b or 250 if n is higher.
            int toRead = Math.min(len - read, byteBuf.capacity());
            
            boolean endOfInput = toRead < byteBuf.capacity();
            
            // read 'toRead' bytes into byteBuf
            byteBuf.put(b, off + read, toRead);
            
            // set limit to position and set position to 0
            // so data can be decoded
            byteBuf.flip();
            
            // decode data in byteBuf
            CoderResult result = utf8Decoder.decode(byteBuf, charBuf, endOfInput);
            
            // If the result is not an underflow, it's an error
            // that cannot be handled.
            // If the error is an underflow and it's the end of input,
            // then the decoder expects more bytes, but there are no more => error.
            if (!result.isUnderflow() || !endOfInput){
                result.throwException();
            }
            
            // flip the char buf to get the string just decoded
            charBuf.flip();
            
            // append the decoded data into the StringBuilder
            sb.append(charBuf.toString());
            
            // clear buffers for next use
            byteBuf.clear();
            charBuf.clear();
            
            read += toRead;
        }
        
        return sb.toString();
    }

    private InputStream readData(int offset, int length) throws IOException{
        HttpURLConnection conn = (HttpURLConnection) zipUrl.openConnection();
        conn.setDoOutput(false);
        conn.setUseCaches(false);
        conn.setInstanceFollowRedirects(false);
        String range = "-";
        if (offset != Integer.MAX_VALUE){
            range = offset + range;
        }
        if (length != Integer.MAX_VALUE){
            if (offset != Integer.MAX_VALUE){
                range = range + (offset + length - 1);
            }else{
                range = range + length;
            }
        }

        conn.setRequestProperty("Range", "bytes=" + range);
        conn.connect();
        if (conn.getResponseCode() == HttpURLConnection.HTTP_PARTIAL){
            return conn.getInputStream();
        }else if (conn.getResponseCode() == HttpURLConnection.HTTP_OK){
            throw new IOException("Your server does not support HTTP feature Content-Range. Please contact your server administrator.");
        }else{
            throw new IOException(conn.getResponseCode() + " " + conn.getResponseMessage());
        }
    }

    private int readTableEntry(byte[] table, int offset) throws IOException{
        if (get32(table, offset) != ZipEntry.CENSIG){
            throw new IOException("Central directory error, expected 'PK12'");
        }

        int nameLen = get16(table, offset + ZipEntry.CENNAM);
        int extraLen = get16(table, offset + ZipEntry.CENEXT);
        int commentLen = get16(table, offset + ZipEntry.CENCOM);
        int newOffset = offset + ZipEntry.CENHDR + nameLen + extraLen + commentLen;

        int flags = get16(table, offset + ZipEntry.CENFLG);
        if ((flags & 1) == 1){
            // ignore this entry, it uses encryption
            return newOffset;
        }
            
        int method = get16(table, offset + ZipEntry.CENHOW);
        if (method != ZipEntry.DEFLATED && method != ZipEntry.STORED){
            // ignore this entry, it uses unknown compression method
            return newOffset;
        }

        String name = getUTF8String(table, offset + ZipEntry.CENHDR, nameLen);
        if (name.charAt(name.length()-1) == '/'){
            // Ignore this entry. It is a directory node
            // or it has no name (?)
            return newOffset;
        }

        ZipEntry2 entry = new ZipEntry2();
        entry.name     = name;
        entry.deflate  = (method == ZipEntry.DEFLATED);
        entry.crc      = getu32(table, offset + ZipEntry.CENCRC);
        entry.length   = get32(table, offset + ZipEntry.CENLEN);
        entry.compSize = get32(table, offset + ZipEntry.CENSIZ);
        entry.offset   = get32(table, offset + ZipEntry.CENOFF);

        entries.put(entry.name, entry);
        
        return newOffset;
    }

    private void fillByteArray(byte[] array, InputStream source) throws IOException{
        int total = 0;
        int length = array.length;
        while (total < length) {
            int read = source.read(array, total, length - total);
            if (read < 0)
                throw new IOException("Failed to read entire array");

            total += read;
        }
    }

    private void readCentralDirectory() throws IOException{
        byte[] header = new byte[tableLength];
        try (InputStream in = readData(tableOffset, tableLength)) {
            // Fix for "PK12 bug in town.zip": sometimes
            // not entire byte array will be read with InputStream.read()
            // (especially for big headers)
            fillByteArray(header, in);

//        in.read(header);
        }

        entries = new HashMap<String, ZipEntry2>(numEntries);
        int offset = 0;
        for (int i = 0; i < numEntries; i++){
            offset = readTableEntry(header, offset);
        }
    }

    private void readEndHeader() throws IOException{

//        InputStream in = readData(Integer.MAX_VALUE, ZipEntry.ENDHDR);
//        byte[] header = new byte[ZipEntry.ENDHDR];
//        fillByteArray(header, in);
//        in.close();
//
//        if (get32(header, 0) != ZipEntry.ENDSIG){
//            throw new IOException("End header error, expected 'PK56'");
//        }

        // Fix for "PK56 bug in town.zip":
        // If there's a zip comment inside the end header,
        // PK56 won't appear in the -22 position relative to the end of the
        // file!
        // In that case, we have to search for it.
        // Increase search space to 200 bytes

        byte[] header = new byte[200];
        try (InputStream in = readData(Integer.MAX_VALUE, 200)) {
            fillByteArray(header, in);
        }

        int offset = -1;
        for (int i = 200 - 22; i >= 0; i--){
            if (header[i] == (byte) (ZipEntry.ENDSIG & 0xff)
              && get32(header, i) == ZipEntry.ENDSIG){
                // found location
                offset = i;
                break;
            }
        }
        if (offset == -1)
            throw new IOException("Cannot find Zip End Header in file!");

        numEntries  = get16(header, offset + ZipEntry.ENDTOT);
        tableLength = get32(header, offset + ZipEntry.ENDSIZ);
        tableOffset = get32(header, offset + ZipEntry.ENDOFF);
    }

    public void load(URL url) throws IOException {
        if (!url.getProtocol().equals("http") && !url.getProtocol().equals("https"))
            throw new UnsupportedOperationException("HttpZipLocator only supports HTTP(S) URLs");

        zipUrl = url;
        readEndHeader();
        readCentralDirectory();
    }

    private InputStream openStream(ZipEntry2 entry) throws IOException {
        if (entry.nameLength == null && entry.extraLength == null) {
            // Need to fetch local file header to obtain file name length
            // and extra field length.
            try (InputStream in = readData(entry.offset, ZipEntry.LOCHDR)) {
                byte[] localHeader = new byte[ZipEntry.LOCHDR];
                in.read(localHeader);
                entry.nameLength = get16(localHeader, ZipEntry.LOCNAM);
                entry.extraLength = get16(localHeader, ZipEntry.LOCEXT);
            }
        }

        // We want the offset in the file data:
        // move the offset forward to skip the LOC header.
        int fileDataOffset = entry.offset + ZipEntry.LOCHDR + entry.nameLength + entry.extraLength;
        InputStream in = readData(fileDataOffset, entry.compSize);
        if (entry.deflate) {
            return new InflaterInputStream(in, new Inflater(true));
        }
        return in;
    }

    public InputStream openStream(String name) throws IOException{
        ZipEntry2 entry = entries.get(name);
        if (entry == null)
            throw new RuntimeException("Entry not found: "+name);

        return openStream(entry);
    }

    @Override
    public void setRootPath(String path){
        if (!rootPath.equals(path)){
            rootPath = path;
            try {
                load(new URL(path));
            } catch (IOException ex) {
                logger.log(Level.WARNING, "Failed to set root path "+path, ex);
            }
        }
    }

    @Override
    public AssetInfo locate(AssetManager manager, AssetKey key){
        final ZipEntry2 entry = entries.get(key.getName());
        if (entry == null)
            return null;

        return new AssetInfo(manager, key){
            @Override
            public InputStream openStream() {
                try {
                    return HttpZipLocator.this.openStream(entry);
                } catch (IOException ex) {
                    logger.log(Level.WARNING, "Error retrieving "+entry.name, ex);
                    return null;
                }
            }
        };
    }

}
