content: /*
   *  Licensed to the Apache Software Foundation (ASF) under one
   *  or more contributor license agreements.  See the NOTICE file
   *  distributed with this work for additional information
   *  regarding copyright ownership.  The ASF licenses this file
   *  to you 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 org.apache.causeway.commons.io;
  
  import java.io.BufferedInputStream;
  import java.io.ByteArrayInputStream;
  import java.io.InputStream;
  import java.nio.charset.Charset;
  import java.nio.charset.StandardCharsets;
  import java.nio.file.FileSystem;
  import java.nio.file.FileSystems;
  import java.nio.file.Files;
  import java.nio.file.Path;
  import java.util.ArrayList;
  import java.util.List;
  import java.util.Optional;
  import java.util.function.Function;
  import java.util.function.Predicate;
  import java.util.stream.Collectors;
  import java.util.stream.Stream;
  import java.util.zip.ZipEntry;
  import java.util.zip.ZipInputStream;
  import java.util.zip.ZipOutputStream;
  
  import org.jspecify.annotations.NonNull;
  import org.jspecify.annotations.Nullable;
  
  import org.apache.causeway.commons.functional.Try;
  import org.apache.causeway.commons.internal.base._Bytes;
  import org.apache.causeway.commons.internal.base._NullSafe;
  import org.apache.causeway.commons.internal.base._Strings;
  import org.apache.causeway.commons.internal.collections._Lists;
  import org.apache.causeway.commons.internal.functions._Predicates;
  
  import lombok.Builder;
  import lombok.SneakyThrows;
  import lombok.experimental.UtilityClass;
  
  /**
   * Utilities to zip and unzip data.
   *
   * @since 2.0 {@index}
   */
  @UtilityClass
  public class ZipUtils {
  
      @Builder()
      public record ZipOptions(int bufferSize,
              /**
               * The {@link java.nio.charset.Charset charset} to be
               *        used to decode the ZIP entry name (ignored if the
               *        <a href="package-summary.html#lang_encoding"> language
               *        encoding bit</a> of the ZIP entry's general purpose bit
               *        flag is set).
               */
              @NonNull Charset zipEntryCharset,
              @NonNull Predicate<ZipEntry> zipEntryFilter
              ) {
  
          // canonical constructor
          public ZipOptions(
                  final int bufferSize,
                  final Charset zipEntryCharset,
                  final Predicate<ZipEntry> zipEntryFilter) {
              this.bufferSize = bufferSize>0 ? bufferSize : 64*1024; // 64k
              this.zipEntryCharset = zipEntryCharset!=null ? zipEntryCharset : StandardCharsets.UTF_8;
              this.zipEntryFilter = zipEntryFilter!=null ? zipEntryFilter : _Predicates.alwaysTrue();
          }
      }
  
      public record ZipEntryDataSource(
              ZipEntry zipEntry,
              byte[] bytes) implements DataSource {
  
          @Override
          public <T> Try<T> tryReadAll(final @NonNull Function<InputStream, Try<T>> consumingMapper) {
              try {
                  try(var bis = new ByteArrayInputStream(bytes)){
                      return consumingMapper.apply(bis);
                  }
              } catch (Throwable e) {
                  return Try.failure(e);
              }
          }
  
          public static ZipEntryDataSource of(
                  final @NonNull ZipEntry zipEntry,
                  final @Nullable byte[] bytes) {
              return new ZipEntryDataSource(zipEntry, _NullSafe.toNonNull(bytes));
          }
  
          public static ZipEntryDataSource of(
                  final @NonNull ZipEntry zipEntry,
                  final @NonNull DataSource dataSource) {
              return of(zipEntry, dataSource.bytes());
          }
  
          @SneakyThrows
          void writeTo(
                  final ZipOutputStream zipOutputStream) {
              zipOutputStream.putNextEntry(zipEntry());
              if(!_NullSafe.isEmpty(bytes)) {
                  zipOutputStream.write(bytes);
              }
              zipOutputStream.closeEntry();
          }
      }
  
      // -- READING
  
      /**
       * Returns a {@link Stream} of {@link ZipEntryDataSource}, buffered in memory,
       * which allows consumption even after the underlying zipped {@link DataSource} was closed.
       * @implNote Only partly optimized for heap usage, as it just reads all pre-filtered data into memory,
       *      doing so, regardless of what is actually consumed later from the returned {@link Stream}.
       */
      @SneakyThrows
      public Stream<ZipEntryDataSource> streamZipEntries(
              final @NonNull DataSource zippedSource,
              final @NonNull ZipOptions zipOptions) {
  
          var zipEntryDataSources = _Lists.<ZipEntryDataSource>newArrayList();
  
          zippedSource.consumeAsFile(zipFile->{
              try (FileSystem fs = FileSystems.newFileSystem(zipFile.toPath())) {
                  try (Stream<Path> entries = Files.walk(fs.getPath("/"))) {
                      final List<Path> filesInZip = entries.filter(Files::isRegularFile).collect(Collectors.toList());
                      for(Path path : filesInZip) {
                          var zipEntry = new ZipEntry(path.toString());
                          if(!zipOptions.zipEntryFilter().test(zipEntry)) continue;
                          var bytes = Files.readAllBytes(path);
                          zipEntryDataSources.add(new ZipEntryDataSource(zipEntry, bytes));
                      }
                  }
              }
          });
  
          return zipEntryDataSources.stream();
      }
  
      /*
       * Former implementation: did not require a temp file,
       * but may result in Exception 'Only DEFLATED entries can have EXT descriptor'
       * @see https://bugs.openjdk.org/browse/JDK-8327690
       */
      /*
      public Stream<ZipEntryDataSource> streamZipEntries(
              final @NonNull DataSource zippedSource,
              final @NonNull ZipOptions zipOptions) {
  
          var zipEntryDataSources = _Lists.<ZipEntryDataSource>newArrayList();
  
          zippedSource.tryReadAndAccept(is->{
              try(final ZipInputStream in = new ZipInputStream(
                      new BufferedInputStream(is, zipOptions.bufferSize()),
                      zipOptions.zipEntryCharset())) {
  
                  ZipEntry zipEntry;
                  while((zipEntry = in.getNextEntry())!=null) {
                      if(zipEntry.isDirectory()) continue;
                      if(zipOptions.zipEntryFilter().test(zipEntry)) {
                          zipEntryDataSources.add(
                                  new ZipEntryDataSource(zipEntry, _Bytes.ofKeepOpen(in)));
                      }
                  }
              }
          })
          .ifFailureFail();
  
          return zipEntryDataSources.stream();
      }*/
  
      /**
       * Shortcut for {@code streamZipEntries(zippedSource, ZipOptions.builder().build())}
       * @see #streamZipEntries(DataSource, ZipOptions)
       */
      public Stream<ZipEntryDataSource> streamZipEntries(
              final @NonNull DataSource zippedSource) {
          return streamZipEntries(zippedSource, ZipOptions.builder().build());
      }
  
      /**
       * Optionally the first zip-entry as {@link ZipEntryDataSource}, based on whether an entry exists.
       */
      public Optional<ZipEntryDataSource> firstZipEntry(
              final @NonNull DataSource zippedSource,
              final @NonNull ZipOptions zipOptions) {
  
          var zipEntryDataSources = _Lists.<ZipEntryDataSource>newArrayList(1);
  
          zippedSource.tryReadAndAccept(is->{
              try(final ZipInputStream in = new ZipInputStream(
                      new BufferedInputStream(is, zipOptions.bufferSize()),
                      zipOptions.zipEntryCharset())) {
  
                  ZipEntry zipEntry;
                  while((zipEntry = in.getNextEntry())!=null) {
                      if(zipEntry.isDirectory()) continue;
                      if(zipOptions.zipEntryFilter().test(zipEntry)) {
                          zipEntryDataSources.add(
                                  new ZipEntryDataSource(zipEntry, _Bytes.ofKeepOpen(in)));
                          return; // stop further processing
                      }
                  }
              }
          })
          .ifFailureFail();
  
          return _Lists.firstElement(zipEntryDataSources);
      }
  
      /**
       * Shortcut for {@code firstZipEntry(zippedSource, ZipOptions.builder().build())}
       * @see #firstZipEntry(DataSource, ZipOptions)
       */
      public Optional<ZipEntryDataSource> firstZipEntry(
              final @NonNull DataSource zippedSource) {
          return firstZipEntry(zippedSource, ZipOptions.builder().build());
      }
  
      // -- WRITING
  
      public static byte[] zipToBytes(final @NonNull Stream<ZipEntryDataSource> entryStream) {
          var buffer = DataPeer.inMemory(16*1024); // 16k default
          writeTo(entryStream, buffer);
          return buffer.bytes();
      }
  
      @SneakyThrows
      public static void writeTo(final @NonNull Stream<ZipEntryDataSource> entryStream, final @NonNull DataSink dataSink) {
          dataSink.writeAll(os->{
              try(var zos = new ZipOutputStream(os)) {
                  entryStream.forEach(entry->entry.writeTo(zos));
              }
          });
      }
  
      // -- ENTRY BUILDER
  
      public static class EntryBuilder {
  
          private final List<ZipEntryDataSource> entries = new ArrayList<>();
  
          public EntryBuilder add(final @NonNull ZipEntryDataSource zipEntryDataSource){
              entries.add(zipEntryDataSource);
              return this;
          }
  
          // -- SHORTCUTS
  
          public EntryBuilder add(final @NonNull String entryName, final @Nullable byte[] bytes){
              return add(ZipEntryDataSource.of(new ZipEntry(entryName), bytes));
          }
  
          public EntryBuilder add(final @NonNull String entryName, final @NonNull DataSource dataSource){
              return add(entryName, dataSource.bytes());
          }
  
          public EntryBuilder add(final @NonNull String entryName, final @Nullable String string, final @NonNull Charset charset){
              return add(entryName, _Strings.toBytes(string, charset));
          }
  
          public EntryBuilder addAsUtf8(final @NonNull String entryName, final @Nullable String string){
              return add(entryName, _Strings.toBytes(string, StandardCharsets.UTF_8));
          }
  
          // -- TERMINALS
  
          public Stream<ZipEntryDataSource> stream(){
              return entries.stream();
          }
  
          public void writeTo(final DataSink dataSink) {
              ZipUtils.writeTo(stream(), dataSink);
          }
  
          public byte[] toBytes() {
              return ZipUtils.zipToBytes(stream());
          }
  
      }
  
      /**
       * typical example:
       * <pre>{@code
       * var builder = ZipUtils.zipEntryBuilder();
       * for (Map.Entry<String, String> entry : schemaMap.entrySet()) {
       *     var namespaceUri = entry.getKey();
       *     var schemaText = entry.getValue();
       *     builder.addAsUtf8(zipEntryNameFor(namespaceUri), schemaText);
       * }
       * return Blob.of(fileName, CommonMimeType.ZIP, builder.toBytes());
       * }
       * <pre>
       */
      public EntryBuilder zipEntryBuilder() {
          return new EntryBuilder();
      }
  
  }
  