package net.lingala.zip4j.tasks;

import net.lingala.zip4j.exception.ZipException;
import net.lingala.zip4j.headers.HeaderUtil;
import net.lingala.zip4j.headers.HeaderWriter;
import net.lingala.zip4j.io.outputstream.SplitOutputStream;
import net.lingala.zip4j.model.FileHeader;
import net.lingala.zip4j.model.Zip4jConfig;
import net.lingala.zip4j.model.ZipModel;
import net.lingala.zip4j.model.enums.RandomAccessFileMode;
import net.lingala.zip4j.progress.ProgressMonitor;
import net.lingala.zip4j.util.InternalZipConstants;
import net.lingala.zip4j.util.RawIO;
import net.lingala.zip4j.util.Zip4jUtil;

import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.nio.charset.Charset;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class RenameFilesTask extends AbstractModifyFileTask<RenameFilesTask.RenameFilesTaskParameters> {

  private final ZipModel zipModel;
  private final HeaderWriter headerWriter;
  private final RawIO rawIO;

  public RenameFilesTask(ZipModel zipModel, HeaderWriter headerWriter, RawIO rawIO, AsyncTaskParameters asyncTaskParameters) {
    super(asyncTaskParameters);
    this.zipModel = zipModel;
    this.headerWriter = headerWriter;
    this.rawIO = rawIO;
  }

  @Override
  protected void executeTask(RenameFilesTaskParameters taskParameters, ProgressMonitor progressMonitor) throws IOException {
    Map<String, String> fileNamesMap = filterNonExistingEntriesAndAddSeparatorIfNeeded(taskParameters.fileNamesMap);
    if (fileNamesMap.size() == 0) {
      return;
    }

    File temporaryFile = getTemporaryFile(zipModel.getZipFile().getPath());
    boolean successFlag = false;
    try(RandomAccessFile inputStream = new RandomAccessFile(zipModel.getZipFile(), RandomAccessFileMode.WRITE.getValue());
        SplitOutputStream outputStream = new SplitOutputStream(temporaryFile)) {

      long currentFileCopyPointer = 0;
      Charset charset = taskParameters.zip4jConfig.getCharset();

      // Maintain a different list to iterate, so that when the file name is changed in the central directory
      // we still have access to the original file names. If iterating on the original list from central directory,
      // it might be that a file name has changed because of other file name, ex: if a directory name has to be changed
      // and the file is part of that directory, by the time the file has to be changed, its name might have changed
      // when changing the name of the directory. There is some overhead with this approach, but is safer.
      List<FileHeader> sortedFileHeaders = cloneAndSortFileHeadersByOffset(zipModel.getCentralDirectory().getFileHeaders());

      for (FileHeader fileHeader : sortedFileHeaders) {
        Map.Entry<String, String> fileNameMapForThisEntry = getCorrespondingEntryFromMap(fileHeader, fileNamesMap);
        progressMonitor.setFileName(fileHeader.getFileName());

        long lengthToCopy = getOffsetOfNextEntry(sortedFileHeaders, fileHeader, zipModel) - outputStream.getFilePointer();
        if (fileNameMapForThisEntry == null) {
          // copy complete entry without any changes
          currentFileCopyPointer += copyFile(inputStream, outputStream, currentFileCopyPointer, lengthToCopy,
              progressMonitor, taskParameters.zip4jConfig.getBufferSize());
        } else {
          String newFileName = getNewFileName(fileNameMapForThisEntry.getValue(), fileNameMapForThisEntry.getKey(), fileHeader.getFileName());
          byte[] newFileNameBytes = HeaderUtil.getBytesFromString(newFileName, charset);
          int headersOffset = newFileNameBytes.length - fileHeader.getFileNameLength();

          currentFileCopyPointer = copyEntryAndChangeFileName(newFileNameBytes, fileHeader, currentFileCopyPointer, lengthToCopy,
              inputStream, outputStream, progressMonitor, taskParameters.zip4jConfig.getBufferSize());

          updateHeadersInZipModel(sortedFileHeaders, fileHeader, newFileName, newFileNameBytes, headersOffset);
        }

        verifyIfTaskIsCancelled();
      }

      headerWriter.finalizeZipFile(zipModel, outputStream, charset);
      successFlag = true;
    } finally {
      cleanupFile(successFlag, zipModel.getZipFile(), temporaryFile);
    }

  }

  @Override
  protected long calculateTotalWork(RenameFilesTaskParameters taskParameters) {
    return zipModel.getZipFile().length();
  }

  @Override
  protected ProgressMonitor.Task getTask() {
    return ProgressMonitor.Task.RENAME_FILE;
  }

  private long copyEntryAndChangeFileName(byte[] newFileNameBytes, FileHeader fileHeader, long start, long totalLengthOfEntry,
                                          RandomAccessFile inputStream, OutputStream outputStream,
                                          ProgressMonitor progressMonitor, int bufferSize) throws IOException {
    long currentFileCopyPointer = start;

    currentFileCopyPointer += copyFile(inputStream, outputStream, currentFileCopyPointer, 26, progressMonitor, bufferSize); // 26 is offset until file name length

    rawIO.writeShortLittleEndian(outputStream, newFileNameBytes.length);

    currentFileCopyPointer += 2; // length of file name length
    currentFileCopyPointer += copyFile(inputStream, outputStream, currentFileCopyPointer, 2, progressMonitor, bufferSize); // 2 is for length of extra field length

    outputStream.write(newFileNameBytes);
    currentFileCopyPointer += fileHeader.getFileNameLength();

    long remainingLengthToCopy = totalLengthOfEntry - (currentFileCopyPointer - start);

    currentFileCopyPointer += copyFile(inputStream, outputStream, currentFileCopyPointer,
       remainingLengthToCopy, progressMonitor, bufferSize);

    return currentFileCopyPointer;
  }

  private Map.Entry<String, String> getCorrespondingEntryFromMap(FileHeader fileHeaderToBeChecked, Map<String,
      String> fileNamesMap) {

    for (Map.Entry<String, String> fileHeaderToBeRenamed : fileNamesMap.entrySet()) {
      if (fileHeaderToBeChecked.getFileName().startsWith(fileHeaderToBeRenamed.getKey())) {
        return fileHeaderToBeRenamed;
      }
    }

    return null;
  }

  private void updateHeadersInZipModel(List<FileHeader> sortedFileHeaders, FileHeader fileHeader, String newFileName,
                                       byte[] newFileNameBytes, int headersOffset) throws ZipException {

    FileHeader fileHeaderToBeChanged = HeaderUtil.getFileHeader(zipModel, fileHeader.getFileName());

    if (fileHeaderToBeChanged == null) {
      // If this is the case, then the file name in the header that was passed to this method was already changed.
      // In theory, should never be here.
      throw new ZipException("could not find any header with name: " + fileHeader.getFileName());
    }

    fileHeaderToBeChanged.setFileName(newFileName);
    fileHeaderToBeChanged.setFileNameLength(newFileNameBytes.length);

    updateOffsetsForAllSubsequentFileHeaders(sortedFileHeaders, zipModel, fileHeaderToBeChanged, headersOffset);

    zipModel.getEndOfCentralDirectoryRecord().setOffsetOfStartOfCentralDirectory(
        zipModel.getEndOfCentralDirectoryRecord().getOffsetOfStartOfCentralDirectory() + headersOffset);

    if (zipModel.isZip64Format()) {
      zipModel.getZip64EndOfCentralDirectoryRecord().setOffsetStartCentralDirectoryWRTStartDiskNumber(
          zipModel.getZip64EndOfCentralDirectoryRecord().getOffsetStartCentralDirectoryWRTStartDiskNumber() + headersOffset
      );

      zipModel.getZip64EndOfCentralDirectoryLocator().setOffsetZip64EndOfCentralDirectoryRecord(
          zipModel.getZip64EndOfCentralDirectoryLocator().getOffsetZip64EndOfCentralDirectoryRecord() + headersOffset
      );
    }
  }

  private Map<String, String> filterNonExistingEntriesAndAddSeparatorIfNeeded(Map<String, String> inputFileNamesMap) throws ZipException {
    Map<String, String> fileNamesMapToBeChanged = new HashMap<>();
    for (Map.Entry<String, String> allNamesToBeChanged : inputFileNamesMap.entrySet()) {
      if (!Zip4jUtil.isStringNotNullAndNotEmpty(allNamesToBeChanged.getKey())) {
        continue;
      }

      FileHeader fileHeaderToBeChanged = HeaderUtil.getFileHeader(zipModel, allNamesToBeChanged.getKey());
      if (fileHeaderToBeChanged != null) {
        if (fileHeaderToBeChanged.isDirectory() && !allNamesToBeChanged.getValue().endsWith(InternalZipConstants.ZIP_FILE_SEPARATOR)) {
          fileNamesMapToBeChanged.put(allNamesToBeChanged.getKey(), allNamesToBeChanged.getValue() + InternalZipConstants.ZIP_FILE_SEPARATOR);
        } else {
          fileNamesMapToBeChanged.put(allNamesToBeChanged.getKey(), allNamesToBeChanged.getValue());
        }
      }
    }
    return fileNamesMapToBeChanged;
  }

  private String getNewFileName(String newFileName, String oldFileName, String fileNameFromHeaderToBeChanged) throws ZipException {
    if (fileNameFromHeaderToBeChanged.equals(oldFileName)) {
      return newFileName;
    } else if (fileNameFromHeaderToBeChanged.startsWith(oldFileName)) {
      String fileNameWithoutOldName = fileNameFromHeaderToBeChanged.substring(oldFileName.length());
      return newFileName + fileNameWithoutOldName;
    }

    // Should never be here.
    // If here by any chance, it means that the file header was marked as to-be-modified, even when the file names do not
    // match. Logic in the method getCorrespondingEntryFromMap() has to be checked
    throw new ZipException("old file name was neither an exact match nor a partial match");
  }

  public static class RenameFilesTaskParameters extends AbstractZipTaskParameters {
    private final Map<String, String> fileNamesMap;

    public RenameFilesTaskParameters(Map<String, String> fileNamesMap, Zip4jConfig zip4jConfig) {
      super(zip4jConfig);
      this.fileNamesMap = fileNamesMap;
    }
  }
}
