package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;

import androidx.annotation.NonNull;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;

import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.FileDownloadedDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.CreateFileMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.DownloadRequestMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.FileTransferDataMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SystemEventMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.UploadRequestMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.CreateFileStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.DownloadRequestStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.FileTransferDataStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.UploadRequestStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;

public class FileTransferHandler implements MessageHandler {
    private static final Logger LOG = LoggerFactory.getLogger(FileTransferHandler.class);
    private final GarminSupport deviceSupport;
    private final Download download;
    private final Upload upload;

    private static final Set<FileType.FILETYPE> FILE_TYPES_TO_PROCESS = new HashSet<FileType.FILETYPE>() {{
        add(FileType.FILETYPE.DIRECTORY);
        add(FileType.FILETYPE.ACTIVITY);
        add(FileType.FILETYPE.MONITOR);
        add(FileType.FILETYPE.METRICS);
        add(FileType.FILETYPE.CHANGELOG);
        add(FileType.FILETYPE.HRV_STATUS);
        add(FileType.FILETYPE.SLEEP);
    }};

    public FileTransferHandler(GarminSupport deviceSupport) {
        this.deviceSupport = deviceSupport;
        this.download = new Download();
        this.upload = new Upload();
    }

    public boolean isDownloading() {
        return download.getCurrentlyDownloading() != null;
    }

    public boolean isUploading() {
        return upload.getCurrentlyUploading() != null;
    }

    public GFDIMessage handle(GFDIMessage message) {
        if (message instanceof DownloadRequestStatusMessage)
            download.processDownloadRequestStatusMessage((DownloadRequestStatusMessage) message);
        else if (message instanceof FileTransferDataMessage)
            download.processDownloadChunkedMessage((FileTransferDataMessage) message);
        else if (message instanceof CreateFileStatusMessage)
            return upload.setCreateFileStatusMessage((CreateFileStatusMessage) message);
        else if (message instanceof UploadRequestStatusMessage)
            return upload.setUploadRequestStatusMessage((UploadRequestStatusMessage) message);
        else if (message instanceof FileTransferDataStatusMessage)
            return upload.processUploadProgress((FileTransferDataStatusMessage) message);

        return null;
    }

    public DownloadRequestMessage downloadDirectoryEntry(DirectoryEntry directoryEntry) {
        download.setCurrentlyDownloading(new FileFragment(directoryEntry));
        return new DownloadRequestMessage(directoryEntry.getFileIndex(), 0, DownloadRequestMessage.REQUEST_TYPE.NEW, 0, 0);
    }

    public DownloadRequestMessage initiateDownload() {
        download.setCurrentlyDownloading(new FileFragment(new DirectoryEntry(0, FileType.FILETYPE.DIRECTORY, 0, 0, 0, 0, null)));
        return new DownloadRequestMessage(0, 0, DownloadRequestMessage.REQUEST_TYPE.NEW, 0, 0);
    }
//    public DownloadRequestMessage downloadSettings() {
//        download.setCurrentlyDownloading(new FileFragment(new DirectoryEntry(0, FileType.FILETYPE.SETTINGS, 0, 0, 0, 0, null)));
//        return new DownloadRequestMessage(0, 0, DownloadRequestMessage.REQUEST_TYPE.NEW, 0, 0);
//    }
//
public CreateFileMessage initiateUpload(byte[] fileAsByteArray, FileType.FILETYPE filetype) {
    upload.setCurrentlyUploading(new FileFragment(new DirectoryEntry(0, filetype, 0, 0, 0, fileAsByteArray.length, null), fileAsByteArray));
    return new CreateFileMessage(fileAsByteArray.length, filetype);
}


    public class Download {
        private FileFragment currentlyDownloading;

        public FileFragment getCurrentlyDownloading() {
            return currentlyDownloading;
        }

        public void setCurrentlyDownloading(FileFragment currentlyDownloading) {
            this.currentlyDownloading = currentlyDownloading;
        }

        private void processDownloadChunkedMessage(FileTransferDataMessage fileTransferDataMessage) {
            if (!isDownloading())
                throw new IllegalStateException("Received file transfer of unknown file");

            currentlyDownloading.append(fileTransferDataMessage);
            if (!currentlyDownloading.dataHolder.hasRemaining())
                processCompleteDownload();
        }

        private void processCompleteDownload() {
            currentlyDownloading.dataHolder.flip();

            if (FileType.FILETYPE.DIRECTORY.equals(currentlyDownloading.directoryEntry.filetype)) { //is a directory
                parseDirectoryEntries();
            } else {
                saveFileToExternalStorage();
            }

            currentlyDownloading = null;
        }

        public void processDownloadRequestStatusMessage(DownloadRequestStatusMessage downloadRequestStatusMessage) {
            if (null == currentlyDownloading)
                throw new IllegalStateException("Received file transfer of unknown file");
            if (downloadRequestStatusMessage.canProceed())
                currentlyDownloading.setSize(downloadRequestStatusMessage);
            else
                currentlyDownloading = null;
        }

        private void saveFileToExternalStorage() {
            File dir;
            File outputFile;
            try {
                dir = deviceSupport.getWritableExportDirectory();
                outputFile = new File(dir, currentlyDownloading.getFileName());
                FileUtils.copyStreamToFile(new ByteArrayInputStream(currentlyDownloading.dataHolder.array()), outputFile);
                outputFile.setLastModified(currentlyDownloading.directoryEntry.fileDate.getTime());
            } catch (final IOException e) {
                LOG.error("Failed to save file", e);
                return; // do not signal file as saved
            }

            FileDownloadedDeviceEvent fileDownloadedDeviceEvent = new FileDownloadedDeviceEvent();
            fileDownloadedDeviceEvent.directoryEntry = currentlyDownloading.directoryEntry;
            fileDownloadedDeviceEvent.localPath = outputFile.getAbsolutePath();
            deviceSupport.evaluateGBDeviceEvent(fileDownloadedDeviceEvent);
        }

        private void parseDirectoryEntries() {
            LOG.debug("Parsing directory entries for {}", currentlyDownloading.directoryEntry);
            if ((currentlyDownloading.getDataSize() % 16) != 0)
                throw new IllegalArgumentException("Invalid directory data length");
            final GarminByteBufferReader reader = new GarminByteBufferReader(currentlyDownloading.dataHolder.array());
            reader.setByteOrder(ByteOrder.LITTLE_ENDIAN);
            final boolean fetchUnknownFiles = deviceSupport.getDevicePrefs().getFetchUnknownFiles();
            while (reader.remaining() > 0) {
                final int fileIndex = reader.readShort();//2
                final int fileDataType = reader.readByte();//3
                final int fileSubType = reader.readByte();//4
                final FileType.FILETYPE filetype = FileType.FILETYPE.fromDataTypeSubType(fileDataType, fileSubType);
                final int fileNumber = reader.readShort();//6
                final int specificFlags = reader.readByte();//7
                final int fileFlags = reader.readByte();//8
                final int fileSize = reader.readInt();//12
                final Date fileDate = new Date(GarminTimeUtils.garminTimestampToJavaMillis(reader.readInt()));//16
                final DirectoryEntry directoryEntry = new DirectoryEntry(fileIndex, filetype, fileNumber, specificFlags, fileFlags, fileSize, fileDate);
                if (directoryEntry.filetype == null) {
                    // discard unsupported files
                    LOG.warn("Unsupported directory entry of type {}/{}", fileDataType, fileSubType);
                    continue;
                }
                if (!FILE_TYPES_TO_PROCESS.contains(directoryEntry.filetype) && !fetchUnknownFiles) {
                    continue;
                }
                LOG.debug("Queueing {} for download", directoryEntry);
                deviceSupport.addFileToDownloadList(directoryEntry);
            }
            currentlyDownloading = null;
        }
    }

    public static class Upload {
        private FileFragment currentlyUploading;

        private UploadRequestMessage setCreateFileStatusMessage(CreateFileStatusMessage createFileStatusMessage) {
            if (createFileStatusMessage.canProceed()) {
                LOG.info("SENDING UPLOAD FILE");
                return new UploadRequestMessage(createFileStatusMessage.getFileIndex(), currentlyUploading.getDataSize());
            } else {
                LOG.warn("Cannot proceed with upload");
                this.currentlyUploading = null;
            }
            return null;
        }

        private FileTransferDataMessage setUploadRequestStatusMessage(UploadRequestStatusMessage uploadRequestStatusMessage) {
            if (null == currentlyUploading)
                throw new IllegalStateException("Received upload request status transfer of unknown file");
            if (uploadRequestStatusMessage.canProceed()) {
                if (uploadRequestStatusMessage.getDataOffset() != currentlyUploading.dataHolder.position())
                    throw new IllegalStateException("Received upload request with unaligned offset");
                return currentlyUploading.take();
            } else {
                LOG.warn("Cannot proceed with upload");
                this.currentlyUploading = null;
            }
            return null;
        }

        private GFDIMessage processUploadProgress(FileTransferDataStatusMessage fileTransferDataStatusMessage) {
            if (currentlyUploading.getDataSize() <= fileTransferDataStatusMessage.getDataOffset()) {
                this.currentlyUploading = null;
                LOG.info("SENDING SYNC COMPLETE!!!");

                return new SystemEventMessage(SystemEventMessage.GarminSystemEventType.SYNC_COMPLETE, 0);
            } else {
                if (fileTransferDataStatusMessage.canProceed()) {
                    LOG.info("SENDING NEXT CHUNK!!!");
                    if (fileTransferDataStatusMessage.getDataOffset() != currentlyUploading.dataHolder.position())
                        throw new IllegalStateException("Received file transfer status with unaligned offset");
                    return currentlyUploading.take();
                } else {
                    LOG.warn("Cannot proceed with upload");
                    this.currentlyUploading = null;
                }

            }
            return null;
        }

        public FileFragment getCurrentlyUploading() {
            return this.currentlyUploading;
        }

        public void setCurrentlyUploading(FileFragment currentlyUploading) {
            this.currentlyUploading = currentlyUploading;
        }

    }

    public static class FileFragment {
        private final DirectoryEntry directoryEntry;
        private final int maxBlockSize = 500; //TODO: why 500?
        private int dataSize;
        private ByteBuffer dataHolder;
        private int runningCrc;

        FileFragment(DirectoryEntry directoryEntry) {
            this.directoryEntry = directoryEntry;
            this.setRunningCrc(0);
        }

        FileFragment(DirectoryEntry directoryEntry, byte[] contents) {
            this.directoryEntry = directoryEntry;
            this.setDataSize(contents.length);
            this.dataHolder = ByteBuffer.wrap(contents);
            this.dataHolder.flip(); //we'll be only reading from here on
            this.dataHolder.compact();
            this.setRunningCrc(0);
        }

        private int getMaxBlockSize() {
            return Math.min(maxBlockSize, GFDIMessage.getMaxPacketSize()); //TODO: can we use GFDIMessage.getMaxPacketSize() directly?
        }

        public String getFileName() {
            return directoryEntry.getFileName();
        }

        private void setSize(DownloadRequestStatusMessage downloadRequestStatusMessage) {
            if (0 != getDataSize())
                throw new IllegalStateException("Data size already set");

            this.setDataSize(downloadRequestStatusMessage.getMaxFileSize());
            this.dataHolder = ByteBuffer.allocate(getDataSize());
        }

        private void append(FileTransferDataMessage fileTransferDataMessage) {
            if (fileTransferDataMessage.getDataOffset() != dataHolder.position())
                throw new IllegalStateException("Received message that was already received");

            final int dataCrc = ChecksumCalculator.computeCrc(getRunningCrc(), fileTransferDataMessage.getMessage(), 0, fileTransferDataMessage.getMessage().length);
            if (fileTransferDataMessage.getCrc() != dataCrc)
                throw new IllegalStateException("Received message with invalid CRC");
            setRunningCrc(dataCrc);

            this.dataHolder.put(fileTransferDataMessage.getMessage());
        }

        private FileTransferDataMessage take() {
            final int currentOffset = this.dataHolder.position();
            final byte[] chunk = new byte[Math.min(this.dataHolder.remaining(), getMaxBlockSize() - 13)]; //actual payload in FileTransferDataMessage
            this.dataHolder.get(chunk);
            setRunningCrc(ChecksumCalculator.computeCrc(getRunningCrc(), chunk, 0, chunk.length));
            return new FileTransferDataMessage(chunk, currentOffset, getRunningCrc());
        }

        private int getDataSize() {
            return dataSize;
        }

        private void setDataSize(int dataSize) {
            this.dataSize = dataSize;
        }

        private int getRunningCrc() {
            return runningCrc;
        }

        private void setRunningCrc(int runningCrc) {
            this.runningCrc = runningCrc;
        }
    }

    public static class DirectoryEntry {
        private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ROOT);

        private final int fileIndex;
        private final FileType.FILETYPE filetype;
        private final int fileNumber;
        private final int specificFlags;
        private final int fileFlags;
        private final int fileSize;
        private final Date fileDate;

        public DirectoryEntry(int fileIndex, FileType.FILETYPE filetype, int fileNumber, int specificFlags, int fileFlags, int fileSize, Date fileDate) {
            this.fileIndex = fileIndex;
            this.filetype = filetype;
            this.fileNumber = fileNumber;
            this.specificFlags = specificFlags;
            this.fileFlags = fileFlags;
            this.fileSize = fileSize;
            this.fileDate = fileDate;
        }

        public int getFileIndex() {
            return fileIndex;
        }

        public FileType.FILETYPE getFiletype() {
            return filetype;
        }

        public String getFileName() {
            final StringBuilder sb = new StringBuilder(getFiletype().name());
            if (fileDate.getTime() != GarminTimeUtils.GARMIN_TIME_EPOCH * 1000L) {
                sb.append("_").append(SDF.format(fileDate));
            }
            sb.append("_").append(getFileIndex()).append(getFiletype().isFitFile() ? ".fit" : ".bin");
            return sb.toString();
        }

        public String getLegacyFileName() {
            SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss");
            String dateString = dateFormat.format(fileDate);
            return getFiletype().name() + "_" + getFileIndex() + "_" + dateString + (getFiletype().isFitFile() ? ".fit" : ".bin");
        }

        @NonNull
        @Override
        public String toString() {
            return "DirectoryEntry{" +
                    "fileIndex=" + fileIndex +
                    ", fileType=" + filetype +
                    ", fileNumber=" + fileNumber +
                    ", specificFlags=" + specificFlags +
                    ", fileFlags=" + fileFlags +
                    ", fileSize=" + fileSize +
                    ", fileDate=" + fileDate +
                    '}';
        }
    }
}
