package org.qortal.arbitrary.misc;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.io.FilenameUtils;
import org.json.JSONObject;
import org.qortal.arbitrary.ArbitraryDataRenderer;
import org.qortal.transaction.Transaction;
import org.qortal.utils.FilesystemUtils;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;

import static java.util.Arrays.stream;
import static java.util.stream.Collectors.toMap;

public enum Service {
    AUTO_UPDATE(1, false, null, false, false, null),
    ARBITRARY_DATA(100, false, null, false, false, null),
    QCHAT_ATTACHMENT(120, true, 1024*1024L, true, false, null) {
        @Override
        public ValidationResult validate(Path path) throws IOException {
            ValidationResult superclassResult = super.validate(path);
            if (superclassResult != ValidationResult.OK) {
                return superclassResult;
            }

            File[] files = path.toFile().listFiles();
            // If already a single file, replace the list with one that contains that file only
            if (files == null && path.toFile().isFile()) {
                files = new File[] { path.toFile() };
            }
            // Now validate the file's extension
            if (files != null && files[0] != null) {
                final String extension = FilenameUtils.getExtension(files[0].getName()).toLowerCase();
                // We must allow blank file extensions because these are used by data published from a plaintext or base64-encoded string
                final List<String> allowedExtensions = Arrays.asList("qortal", "zip", "pdf", "txt", "odt", "ods", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "");
                if (extension == null || !allowedExtensions.contains(extension)) {
                    return ValidationResult.INVALID_FILE_EXTENSION;
                }
            }
            return ValidationResult.OK;
        }
    },
    QCHAT_ATTACHMENT_PRIVATE(121, true, 1024*1024L, true, true, null),
    ATTACHMENT(130, false, 50*1024*1024L, true, false, null),
    ATTACHMENT_PRIVATE(131, true, 50*1024*1024L, true, true, null),
    FILE(140, false, null, true, false, null),
    FILE_PRIVATE(141, true, null, true, true, null),
    FILES(150, false, null, false, false, null),
    CHAIN_DATA(160, true, 239L, true, false, null),
    WEBSITE(200, true, null, false, false, null) {
        @Override
        public ValidationResult validate(Path path) throws IOException {
            ValidationResult superclassResult = super.validate(path);
            if (superclassResult != ValidationResult.OK) {
                return superclassResult;
            }

            // Custom validation function to require an index HTML file in the root directory
            List<String> fileNames = ArbitraryDataRenderer.indexFiles();
            List<String> files;

            // single files are paackaged differently
            if( path.toFile().isFile() ) {
                files = new ArrayList<>(1);
                files.add(path.getFileName().toString());
            }
            else {
                files = new ArrayList<>(Arrays.asList(path.toFile().list()));
            }

            if (files != null) {
                for (String file : files) {
                    Path fileName = Paths.get(file).getFileName();
                    if (fileName != null && fileNames.contains(fileName.toString())) {
                        return ValidationResult.OK;
                    }
                }
            }
            return ValidationResult.MISSING_INDEX_FILE;
        }
    },
    GIT_REPOSITORY(300, false, null, false, false, null),
    IMAGE(400, true, 10*1024*1024L, true, false, null),
    IMAGE_PRIVATE(401, true, 10*1024*1024L, true, true, null),
    THUMBNAIL(410, true, 500*1024L, true, false, null),
    QCHAT_IMAGE(420, true, 500*1024L, true, false, null),
    VIDEO(500, false, null, true, false, null),
    VIDEO_PRIVATE(501, true, null, true, true, null),
    AUDIO(600, false, null, true, false, null),
    AUDIO_PRIVATE(601, true, null, true, true, null),
    QCHAT_AUDIO(610, true, 10*1024*1024L, true, false, null),
    QCHAT_VOICE(620, true, 10*1024*1024L, true, false, null),
    VOICE(630, true, 10*1024*1024L, true, false, null),
    VOICE_PRIVATE(631, true, 10*1024*1024L, true, true, null),
    PODCAST(640, false, null, true, false, null),
    BLOG(700, false, null, false, false, null),
    BLOG_POST(777, false, null, true, false, null),
    BLOG_COMMENT(778, true, 500*1024L, true, false, null),
    DOCUMENT(800, false, null, true, false, null),
    DOCUMENT_PRIVATE(801, true, null, true, true, null),
    LIST(900, true, null, true, false, null),
    PLAYLIST(910, true, null, true, false, null),
    APP(1000, true, 50*1024*1024L, false, false, null),
    METADATA(1100, false, null, true, false, null),
    JSON(1110, true, 25*1024L, true, false, null) {
        @Override
        public ValidationResult validate(Path path) throws IOException {
            ValidationResult superclassResult = super.validate(path);
            if (superclassResult != ValidationResult.OK) {
                return superclassResult;
            }

            // Require valid JSON
            byte[] data = FilesystemUtils.getSingleFileContents(path, 25*1024);
            String json = new String(data, StandardCharsets.UTF_8);
            try {
                objectMapper.readTree(json);
                return ValidationResult.OK;
            } catch (IOException e) {
                return ValidationResult.INVALID_CONTENT;
            }
        }
    },
    GIF_REPOSITORY(1200, true, 25*1024*1024L, false, false, null) {
        @Override
        public ValidationResult validate(Path path) throws IOException {
            ValidationResult superclassResult = super.validate(path);
            if (superclassResult != ValidationResult.OK) {
                return superclassResult;
            }

            // Custom validation function to require .gif files only, and at least 1
            int gifCount = 0;
            File[] files = path.toFile().listFiles();
            // If already a single file, replace the list with one that contains that file only
            if (files == null && path.toFile().isFile()) {
                files = new File[] { path.toFile() };
            }
            if (files != null) {
                for (File file : files) {
                    if (file.getName().equals(".qortal")) {
                        continue;
                    }
                    if (file.isDirectory()) {
                        return ValidationResult.DIRECTORIES_NOT_ALLOWED;
                    }
                    String extension = FilenameUtils.getExtension(file.getName()).toLowerCase();
                    if (!Objects.equals(extension, "gif")) {
                        return ValidationResult.INVALID_FILE_EXTENSION;
                    }
                    gifCount++;
                }
            }
            if (gifCount == 0) {
                return ValidationResult.MISSING_DATA;
            }
            return ValidationResult.OK;
        }
    },
    STORE(1300, false, null, true, false, null),
    PRODUCT(1310, false, null, true, false, null),
    OFFER(1330, false, null, true, false, null),
    COUPON(1340, false, null, true, false, null),
    CODE(1400, false, null, true, false, null),
    PLUGIN(1410, false, null, true, false, null),
    EXTENSION(1420, false, null, true, false, null),
    GAME(1500, false, null, false, false, null),
    ITEM(1510, false, null, true, false, null),
    NFT(1600, false, null, true, false, null),
    DATABASE(1700, false, null, false, false, null),
    SNAPSHOT(1710, false, null, false, false, null),
    COMMENT(1800, true, 500*1024L, true, false, null),
    CHAIN_COMMENT(1810, true, 239L, true, false, null),
    MAIL(1900, true, 1024*1024L, true, false, null),
    MAIL_PRIVATE(1901, true, 5*1024*1024L, true, true, null),
    MESSAGE(1910, true, 1024*1024L, true, false, null),
    MESSAGE_PRIVATE(1911, true, 1024*1024L, true, true, null);

    public final int value;
    private final boolean requiresValidation;
    private final Long maxSize;
    private final boolean single;
    private final boolean isPrivate;
    private final List<String> requiredKeys;

    private static final Map<Integer, Service> map = stream(Service.values())
            .collect(toMap(service -> service.value, service -> service));

    // For JSON validation
    private static final ObjectMapper objectMapper = new ObjectMapper();

    private static final String encryptedDataPrefix = "qortalEncryptedData";
    private static final String encryptedGroupDataPrefix = "qortalGroupEncryptedData";

    Service(int value, boolean requiresValidation, Long maxSize, boolean single, boolean isPrivate, List<String> requiredKeys) {
        this.value = value;
        this.requiresValidation = requiresValidation;
        this.maxSize = maxSize;
        this.single = single;
        this.isPrivate = isPrivate;
        this.requiredKeys = requiredKeys;
    }

    public ValidationResult validate(Path path) throws IOException {
        if (!this.isValidationRequired()) {
            return ValidationResult.OK;
        }

        // Load the first 25KB of data. This only needs to be long enough to check the prefix
        // and also to allow for possible additional future validation of smaller files.
        byte[] data = FilesystemUtils.getSingleFileContents(path, 25*1024);
        long size = FilesystemUtils.getDirectorySize(path);

        // Validate max size if needed
        if (this.maxSize != null) {
            if (size > this.maxSize) {
                return ValidationResult.EXCEEDS_SIZE_LIMIT;
            }
        }

        // Validate file count if needed
        if (this.single && data == null) {
            return ValidationResult.INVALID_FILE_COUNT;
        }

        // Validate private data for single file resources
        if (this.single) {
            String dataString = new String(data, StandardCharsets.UTF_8);
            if (this.isPrivate && !dataString.startsWith(encryptedDataPrefix) && !dataString.startsWith(encryptedGroupDataPrefix)) {
                return ValidationResult.DATA_NOT_ENCRYPTED;
            }
            if (!this.isPrivate && (dataString.startsWith(encryptedDataPrefix) || dataString.startsWith(encryptedGroupDataPrefix))) {
                return ValidationResult.DATA_ENCRYPTED;
            }
        }

        // Validate required keys if needed
        if (this.requiredKeys != null) {
            if (data == null) {
                return ValidationResult.MISSING_KEYS;
            }
            JSONObject json = Service.toJsonObject(data);
            for (String key : this.requiredKeys) {
                if (!json.has(key)) {
                    return ValidationResult.MISSING_KEYS;
                }
            }
        }

        // Validation passed
        return ValidationResult.OK;
    }

    public boolean isValidationRequired() {
        // We must always validate single file resources, to ensure they are actually a single file
        return this.requiresValidation || this.single;
    }

    public boolean isPrivate() {
        return this.isPrivate;
    }

    public static Service valueOf(int value) {
        return map.get(value);
    }

    public static JSONObject toJsonObject(byte[] data) {
        String dataString = new String(data, StandardCharsets.UTF_8);
        return new JSONObject(dataString);
    }

    public static List<Service> publicServices() {
        List<Service> privateServices = new ArrayList<>();
        for (Service service : Service.values()) {
            if (!service.isPrivate) {
                privateServices.add(service);
            }
        }
        return privateServices;
    }

    /**
     * Fetch a list of Service objects that require encrypted data.
     *
     * These can ultimately be used to help inform the cleanup manager
     * on the best order to delete files when the node runs out of space.
     * Public data should be given priority over private data (unless
     * this node is part of a data market contract for that data - this
     * isn't developed yet).
     *
     * @return a list of Service objects that require encrypted data.
     */
    public static List<Service> privateServices() {
        List<Service> privateServices = new ArrayList<>();
        for (Service service : Service.values()) {
            if (service.isPrivate) {
                privateServices.add(service);
            }
        }
        return privateServices;
    }

    public enum ValidationResult {
        OK(1),
        MISSING_KEYS(2),
        EXCEEDS_SIZE_LIMIT(3),
        MISSING_INDEX_FILE(4),
        DIRECTORIES_NOT_ALLOWED(5),
        INVALID_FILE_EXTENSION(6),
        MISSING_DATA(7),
        INVALID_FILE_COUNT(8),
        INVALID_CONTENT(9),
        DATA_NOT_ENCRYPTED(10),
        DATA_ENCRYPTED(10);

        public final int value;

        private static final Map<Integer, Transaction.ValidationResult> map = stream(Transaction.ValidationResult.values()).collect(toMap(result -> result.value, result -> result));

        ValidationResult(int value) {
            this.value = value;
        }

        public static Transaction.ValidationResult valueOf(int value) {
            return map.get(value);
        }
    }
}
