/*
 * Copyright (c) 2022 MiLaboratories Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.milaboratory.util;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.milaboratory.primitivio.PrimitivI;
import com.milaboratory.primitivio.PrimitivO;
import com.milaboratory.primitivio.Serializer;
import com.milaboratory.primitivio.annotations.Serializable;
import org.apache.commons.io.IOUtils;

import java.io.IOException;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.ReadableByteChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;

/**
 * Calculates file checksum + info based on: its' name content = first N + middle N + last N + bytes + it's length
 * modification date.
 *
 * If descriptor has neither content checksum nor modification date, the files always supposed to be changed.
 */
@Serializable(by = LightFileDescriptor.IO.class)      // serialize using binary format by default
@JsonAutoDetect(                                      // enable json for output purposes
        fieldVisibility = JsonAutoDetect.Visibility.ANY,
        isGetterVisibility = JsonAutoDetect.Visibility.NONE,
        getterVisibility = JsonAutoDetect.Visibility.NONE)
public class LightFileDescriptor {
    private static int MD5_LENGTH = 16;
    private static int DEFAULT_CHUNK = 1024;
    public final String name;
    public final byte[] checksum;
    public final Long lastModified;

    @JsonCreator
    private LightFileDescriptor(@JsonProperty("name") String name,
                                @JsonProperty("checksum") byte[] checksum,
                                @JsonProperty("lastModified") Long lastModified) {
        this.name = name;
        this.checksum = checksum;
        this.lastModified = lastModified;
    }

    private LightFileDescriptor(String name) {
        this(name, null, null);
    }

    public boolean isAlwaysModified() {
        return checksum == null && lastModified == null;
    }

    public boolean hasChecksum() {
        return checksum != null;
    }

    public boolean hasModificationDate() {
        return lastModified != null;
    }

    /**
     * Returns true if file modified
     */
    public boolean checkModified(LightFileDescriptor other) {
        if (isAlwaysModified() || other.isAlwaysModified())
            return true;

        if (!name.equals(other.name))
            return true;

        if (!(hasChecksum() && other.hasChecksum()) && !(hasModificationDate() && other.hasModificationDate()))
            // No intersecting characteristics, cant compare => file is modified
            return true;

        if (hasChecksum() && other.hasChecksum() && !Arrays.equals(checksum, other.checksum))
            return true;

        if (hasModificationDate() && other.hasModificationDate() && !lastModified.equals(other.lastModified))
            return true;

        return false;
    }

    @Override
    public String toString() {
        return "LightFileDescriptor{" +
                "name='" + name + '\'' +
                ", checksum=" + Arrays.toString(checksum) +
                '}';
    }

    public static LightFileDescriptor calculate(Path file) {
        return calculate(file, true, false);
    }

    public static LightFileDescriptor calculate(Path file, boolean includeContentChecksum, boolean includeModificationDate) {
        return calculate(file, includeContentChecksum, includeModificationDate, DEFAULT_CHUNK);
    }

    public static LightFileDescriptor calculate(Path file, boolean includeContentChecksum, boolean includeModificationDate, int chunk) {
        String name = file.getFileName().toString();
        try {
            BasicFileAttributes attrs = Files.readAttributes(file, BasicFileAttributes.class);

            byte[] checksum = null;
            if (includeContentChecksum) {
                if (attrs.isOther() || attrs.isDirectory())
                    return new LightFileDescriptor(name);
                MessageDigest md = MessageDigest.getInstance("MD5");

                // Adding file size to MD5 sum
                md.update(ByteBuffer.allocate(Long.SIZE / Byte.SIZE).putLong(attrs.size()).array());

                // Adding actual content checksum
                try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ)) {
                    if (attrs.size() < chunk * 3) {
                        ByteBuffer buff = ByteBuffer.allocate((int) attrs.size());
                        IOUtils.read(channel, buff);
                        buff.flip();
                        md.update(buff);
                    } else {
                        ByteBuffer buff = ByteBuffer.allocate(chunk);

                        // Reading first chunk
                        channel.position(0);
                        IOUtils.read(channel, buff);
                        buff.flip();
                        md.update(buff);

                        // Reading middle chunk
                        buff.clear();
                        channel.position((attrs.size() - chunk) / 2);
                        IOUtils.read(channel, buff);
                        buff.flip();
                        md.update(buff);

                        // Reading last chunk
                        buff.clear();
                        channel.position(attrs.size() - chunk);
                        IOUtils.read(channel, buff);
                        buff.flip();
                        md.update(buff);
                    }
                }

                checksum = md.digest();
            }

            Long lastModified = includeModificationDate ? attrs.lastModifiedTime().toMillis() : null;

            return new LightFileDescriptor(name, checksum, lastModified);
        } catch (NoSuchAlgorithmException | IOException e) {
            return new LightFileDescriptor(name);
        }
    }

    static final int CHUNK_SIZE = 1 << 15; // 32kb

    public static void addLightFileCache(Path file, MessageDigest md) throws IOException {
        long fileSize = Files.size(file);

        // Adding file size to MD5 sum
        md.update(ByteBuffer.allocate(Long.SIZE / Byte.SIZE).putLong(fileSize).array());

        // Adding actual content checksum
        try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ)) {
            if (fileSize < CHUNK_SIZE) {
                ByteBuffer buff = ByteBuffer.allocate((int) fileSize);
                read(channel, buff, md);
            } else {
                ByteBuffer buff = ByteBuffer.allocate(CHUNK_SIZE);

                // Reading first chunk
                channel.position(0);
                read(channel, buff, md);

                // Reading middle chunk
                channel.position((fileSize - CHUNK_SIZE) / 2);
                read(channel, buff, md);

                // Reading last chunk
                channel.position(fileSize - CHUNK_SIZE);
                read(channel, buff, md);
            }
        }
    }

    public static BigInteger lightFileCacheAsBI(Path file) throws IOException {
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            addLightFileCache(file, md);
            return new BigInteger(1, md.digest());
        } catch (NoSuchAlgorithmException e) {
            return null;
        }
    }

    /** No dependence on file order */
    public static byte[] calculateCommutativeLightHash(List<Path> files) {
        try {
            BigInteger sum = BigInteger.ZERO;
            for (Path f : files) {
                BigInteger hash = lightFileCacheAsBI(f);
                if (hash == null)
                    return null;
                sum = sum.add(hash);
            }
            MessageDigest md = MessageDigest.getInstance("MD5");
            // Adding sum (this will be a hash of a sum of hashes)
            md.update(sum.toByteArray());
            return md.digest();
        } catch (NoSuchAlgorithmException | IOException e) {
            return null;
        }
    }

    private static void read(ReadableByteChannel input, ByteBuffer buffer, MessageDigest md) throws IOException {
        buffer.clear();
        while (buffer.remaining() > 0)
            if (-1 == input.read(buffer))
                break;
        if (buffer.remaining() != 0)
            throw new IllegalArgumentException();
        buffer.flip();
        md.update(buffer);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof LightFileDescriptor)) return false;
        LightFileDescriptor that = (LightFileDescriptor) o;
        return Objects.equals(name, that.name) &&
                Arrays.equals(checksum, that.checksum) &&
                Objects.equals(lastModified, that.lastModified);
    }

    @Override
    public int hashCode() {
        int result = Objects.hash(name, lastModified);
        result = 31 * result + Arrays.hashCode(checksum);
        return result;
    }

    public static final class IO implements Serializer<LightFileDescriptor> {
        @Override
        public void write(PrimitivO output, LightFileDescriptor object) {
            output.writeUTF(object.name);
            output.writeObject(object.checksum);
            output.writeObject(object.lastModified);
        }

        @Override
        public LightFileDescriptor read(PrimitivI input) {
            String name = input.readUTF();
            byte[] checksum = input.readObject(byte[].class);
            Long lastModified = input.readObject(Long.class);
            return new LightFileDescriptor(name, checksum, lastModified);
        }

        @Override
        public boolean isReference() {
            return true;
        }

        @Override
        public boolean handlesReference() {
            return false;
        }
    }
}
