package net.lingala.zip4j.tasks;

import net.lingala.zip4j.exception.ZipException;
import net.lingala.zip4j.io.inputstream.ZipInputStream;
import net.lingala.zip4j.model.FileHeader;
import net.lingala.zip4j.model.LocalFileHeader;
import net.lingala.zip4j.model.UnzipParameters;
import net.lingala.zip4j.model.ZipModel;
import net.lingala.zip4j.progress.ProgressMonitor;
import net.lingala.zip4j.util.BitUtils;
import net.lingala.zip4j.util.UnzipUtil;
import net.lingala.zip4j.util.Zip4jUtil;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.regex.Matcher;

import static net.lingala.zip4j.util.InternalZipConstants.FILE_SEPARATOR;

public abstract class AbstractExtractFileTask<T> extends AsyncZipTask<T> {

  private final ZipModel zipModel;
  private final UnzipParameters unzipParameters;

  public AbstractExtractFileTask(ZipModel zipModel, UnzipParameters unzipParameters,
                                 AsyncTaskParameters asyncTaskParameters) {
    super(asyncTaskParameters);
    this.zipModel = zipModel;
    this.unzipParameters = unzipParameters;
  }

  protected void extractFile(ZipInputStream zipInputStream, FileHeader fileHeader, String outputPath,
                             String newFileName, ProgressMonitor progressMonitor, byte[] readBuff) throws IOException {

    boolean isSymbolicLink = isSymbolicLink(fileHeader);
    if (isSymbolicLink && !unzipParameters.isExtractSymbolicLinks()) {
      return;
    }

    if (!outputPath.endsWith(FILE_SEPARATOR)) {
      outputPath += FILE_SEPARATOR;
    }

    File outputFile = determineOutputFile(fileHeader, outputPath, newFileName);
    progressMonitor.setFileName(outputFile.getAbsolutePath());

    assertCanonicalPathsAreSame(outputFile, outputPath, fileHeader);

    verifyNextEntry(zipInputStream, fileHeader);

    if (fileHeader.isDirectory()) {
      if (!outputFile.exists()) {
        if (!outputFile.mkdirs()) {
          throw new ZipException("Could not create directory: " + outputFile);
        }
      }
    } else if (isSymbolicLink) {
      createSymLink(zipInputStream, fileHeader, outputFile, progressMonitor);
    } else {
      checkOutputDirectoryStructure(outputFile);
      unzipFile(zipInputStream, outputFile, progressMonitor, readBuff);
    }

    if (!isSymbolicLink) {
      UnzipUtil.applyFileAttributes(fileHeader, outputFile);
    }
  }

  private void assertCanonicalPathsAreSame(File outputFile, String outputPath, FileHeader fileHeader)
      throws IOException {

    String outputFileCanonicalPath = outputFile.getCanonicalPath();
    if (outputFile.isDirectory() && !outputFileCanonicalPath.endsWith(FILE_SEPARATOR)) {
      outputFileCanonicalPath = outputFileCanonicalPath + FILE_SEPARATOR;
    }

    String outputCanonicalPath = (new File(outputPath).getCanonicalPath());
    if (!outputCanonicalPath.endsWith(FILE_SEPARATOR)) {
      outputCanonicalPath += FILE_SEPARATOR;
    }

    // make sure no file is extracted outside the target directory (a.k.a. zip slip)
    if (!outputFileCanonicalPath.startsWith(outputCanonicalPath)) {
      throw new ZipException("illegal file name that breaks out of the target directory: "
          + fileHeader.getFileName());
    }
  }

  private boolean isSymbolicLink(FileHeader fileHeader) {
    byte[] externalFileAttributes = fileHeader.getExternalFileAttributes();

    if (externalFileAttributes == null || externalFileAttributes.length < 4) {
      return false;
    }

    return BitUtils.isBitSet(externalFileAttributes[3], 5);
  }

  private void unzipFile(ZipInputStream inputStream, File outputFile, ProgressMonitor progressMonitor, byte[] buff)
      throws IOException {

    int readLength;
    try (OutputStream outputStream = new FileOutputStream(outputFile)) {
      while ((readLength = inputStream.read(buff)) != -1) {
        outputStream.write(buff, 0, readLength);
        progressMonitor.updateWorkCompleted(readLength);
        verifyIfTaskIsCancelled();
      }
    } catch (Exception e) {
      if (outputFile.exists()) {
        outputFile.delete();
      }
      throw  e;
    }
  }

  private void createSymLink(ZipInputStream zipInputStream, FileHeader fileHeader, File outputFile,
                             ProgressMonitor progressMonitor) throws IOException {

    String symLinkPath = new String(readCompleteEntry(zipInputStream, fileHeader, progressMonitor));

    if (!outputFile.getParentFile().exists() && !outputFile.getParentFile().mkdirs()) {
      throw new ZipException("Could not create parent directories");
    }

    try {
      Path linkTarget = Paths.get(symLinkPath);
      if (outputFile.exists()) {
        if (!outputFile.delete()) {
          throw new ZipException("Could not delete existing symlink " + outputFile);
        }
      }
      Files.createSymbolicLink(outputFile.toPath(), linkTarget);
    } catch (NoSuchMethodError error) {
      try (OutputStream outputStream = new FileOutputStream(outputFile)) {
        outputStream.write(symLinkPath.getBytes());
      }
    }
  }

  private byte[] readCompleteEntry(ZipInputStream zipInputStream, FileHeader fileHeader,
                                   ProgressMonitor progressMonitor) throws IOException {
    byte[] b = new byte[(int) fileHeader.getUncompressedSize()];
    int readLength = zipInputStream.read(b);

    if (readLength != b.length) {
      throw new ZipException("Could not read complete entry");
    }

    progressMonitor.updateWorkCompleted(b.length);
    return b;
  }

  private void verifyNextEntry(ZipInputStream zipInputStream, FileHeader fileHeader) throws IOException {
    if (BitUtils.isBitSet(fileHeader.getGeneralPurposeFlag()[0], 6)) {
      throw new ZipException("Entry with name " + fileHeader.getFileName() + " is encrypted with Strong Encryption. " +
          "Zip4j does not support Strong Encryption, as this is patented.");
    }

    LocalFileHeader localFileHeader = zipInputStream.getNextEntry(fileHeader, false);

    if (localFileHeader == null) {
      throw new ZipException("Could not read corresponding local file header for file header: "
          + fileHeader.getFileName());
    }

    if (!fileHeader.getFileName().equals(localFileHeader.getFileName())) {
      throw new ZipException("File header and local file header mismatch");
    }
  }

  private void checkOutputDirectoryStructure(File outputFile) throws ZipException {
    if (!outputFile.getParentFile().exists() && !outputFile.getParentFile().mkdirs()) {
      throw new ZipException("Unable to create parent directories: " + outputFile.getParentFile());
    }
  }

  private File determineOutputFile(FileHeader fileHeader, String outputPath, String newFileName) {
    String outputFileName = fileHeader.getFileName();
    if (Zip4jUtil.isStringNotNullAndNotEmpty(newFileName)) {
      outputFileName = newFileName;
    }
    return new File(outputPath, getFileNameWithSystemFileSeparators(outputFileName));
  }

  private String getFileNameWithSystemFileSeparators(String fileNameToReplace) {
    String formattedFileName = fileNameToReplace.replaceAll(":\\\\", "_");
    return formattedFileName.replaceAll("[/\\\\]", Matcher.quoteReplacement(FILE_SEPARATOR));
  }

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

  public ZipModel getZipModel() {
    return zipModel;
  }
}
