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.extensions.excel.applib.service;
  
  import java.io.ByteArrayInputStream;
  import java.io.File;
  import java.io.FileOutputStream;
  import java.io.IOException;
  import java.lang.annotation.Annotation;
  import java.lang.reflect.Field;
  import java.util.ArrayList;
  import java.util.List;
  import java.util.Map;
  import java.util.Objects;
  import java.util.Set;
  import java.util.UUID;
  import java.util.function.Predicate;
  import java.util.stream.Collectors;
  
  import org.apache.poi.openxml4j.exceptions.InvalidFormatException;
  import org.apache.poi.ss.usermodel.Cell;
  import org.apache.poi.ss.usermodel.CellStyle;
  import org.apache.poi.ss.usermodel.CellType;
  import org.apache.poi.ss.usermodel.CreationHelper;
  import org.apache.poi.ss.usermodel.Row;
  import org.apache.poi.ss.usermodel.Sheet;
  import org.apache.poi.ss.usermodel.VerticalAlignment;
  import org.apache.poi.ss.usermodel.Workbook;
  import org.apache.poi.xssf.usermodel.XSSFWorkbook;
  
  import org.jspecify.annotations.Nullable;
  
  import org.apache.causeway.applib.annotation.Where;
  import org.apache.causeway.applib.services.bookmark.BookmarkService;
  import org.apache.causeway.applib.services.inject.ServiceInjector;
  import org.apache.causeway.commons.internal.base._Casts;
  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.collections._Maps;
  import org.apache.causeway.commons.internal.collections._Sets;
  import org.apache.causeway.core.metamodel.consent.InteractionInitiatedBy;
  import org.apache.causeway.core.metamodel.object.ManagedObject;
  import org.apache.causeway.core.metamodel.objectmanager.ObjectManager;
  import org.apache.causeway.core.metamodel.spec.ObjectSpecification;
  import org.apache.causeway.core.metamodel.spec.feature.MixedIn;
  import org.apache.causeway.core.metamodel.spec.feature.ObjectAssociation;
  import org.apache.causeway.core.metamodel.spec.feature.OneToOneAssociation;
  import org.apache.causeway.core.metamodel.specloader.SpecificationLoader;
  import org.apache.causeway.extensions.excel.applib.AggregationType;
  import org.apache.causeway.extensions.excel.applib.ExcelMetaDataEnabled;
  import org.apache.causeway.extensions.excel.applib.Mode;
  import org.apache.causeway.extensions.excel.applib.RowHandler;
  import org.apache.causeway.extensions.excel.applib.WorksheetContent;
  import org.apache.causeway.extensions.excel.applib.WorksheetSpec;
  import org.apache.causeway.extensions.excel.applib.annotation.HyperLink;
  import org.apache.causeway.extensions.excel.applib.annotation.PivotColumn;
  import org.apache.causeway.extensions.excel.applib.annotation.PivotDecoration;
  import org.apache.causeway.extensions.excel.applib.annotation.PivotRow;
  import org.apache.causeway.extensions.excel.applib.annotation.PivotValue;
  import org.apache.causeway.extensions.excel.applib.util.PivotUtils;
  import org.apache.causeway.extensions.excel.applib.util.SheetPivoter;
  
  import lombok.ToString;
  
  class _ExcelConverter {
  
      private static final String XLSX_SUFFIX = ".xlsx";
  
      private static final Predicate<ObjectAssociation> VISIBLE_PROPERTIES =
              ObjectAssociation.Predicates.PROPERTIES.and(
              ObjectAssociation.Predicates.staticallyVisible(Where.STANDALONE_TABLES));
  
      static class RowFactory {
          private final Sheet sheet;
          private int rowNum;
  
          RowFactory(final Sheet sheet) {
              this.sheet = sheet;
          }
  
          public Row newRow() {
              return sheet.createRow(rowNum++);
          }
      }
  
      // //////////////////////////////////////
  
      private final SpecificationLoader specificationLoader;
      private final ObjectManager objectManager;
      private final BookmarkService bookmarkService;
      private final ServiceInjector serviceInjector;
  
      _ExcelConverter(
              final SpecificationLoader specificationLoader,
              final ObjectManager objectManager,
              final BookmarkService bookmarkService,
              final ServiceInjector serviceInjector) {
          this.specificationLoader = specificationLoader;
          this.objectManager = objectManager;
          this.bookmarkService = bookmarkService;
          this.serviceInjector = serviceInjector;
      }
  
      // //////////////////////////////////////
  
      File appendSheet(final List<WorksheetContent> worksheetContents, final XSSFWorkbook workbook) throws IOException {
          final Set<String> worksheetNames = worksheetContents.stream()
                  .map(x -> x.getSpec().getSheetName())
                  .collect(Collectors.toSet());
          if(worksheetNames.size() < worksheetContents.size()) {
              throw new IllegalArgumentException("Sheet names must have distinct names");
          }
          for (final String worksheetName : worksheetNames) {
              if(worksheetName.length() > 30) {
                  throw new IllegalArgumentException(
                          String.format("Sheet name cannot exceed 30 characters (invalid name: '%s')",
                                  worksheetName));
              }
          }
  
          final File tempFile =
                  File.createTempFile(_ExcelConverter.class.getName(), UUID.randomUUID().toString() + XLSX_SUFFIX);
          try(final FileOutputStream fos = new FileOutputStream(tempFile)) {
  
              for (WorksheetContent worksheetContent : worksheetContents) {
                  final WorksheetSpec spec = worksheetContent.getSpec();
                  appendSheet(workbook, worksheetContent.getDomainObjects(), spec.getFactory(), spec.getSheetName());
              }
              workbook.write(fos);
          }
          return tempFile;
      }
  
      private Sheet appendSheet(
              final XSSFWorkbook workbook,
              final List<?> domainObjects,
              final WorksheetSpec.RowFactory<?> factory,
              final String sheetName) throws IOException {
  
          final List<ManagedObject> adapters = domainObjects.stream()
                  .map(objectManager::adapt)
                  .collect(Collectors.toList());
  
          final List<OneToOneAssociation> propertyList = _Lists.newArrayList();
  
          specificationLoader.specForType(factory.getCls())
          .ifPresent(spec->spec.streamProperties(MixedIn.INCLUDED)
                  .filter(VISIBLE_PROPERTIES)
                  .forEach(propertyList::add));
  
          List<ObjectAssociation> annotatedAsHyperlink = new ArrayList<>();
          for (Field f : fieldsAnnotatedWith(factory.getCls(), HyperLink.class)){
              propertyList.stream()
                      .filter(oa -> Objects.equals(oa.getId(), f.getName()))
                      .forEach(annotatedAsHyperlink::add);
          }
  
          final Sheet sheet = ((Workbook) workbook).createSheet(sheetName);
  
          final RowFactory rowFactory = new RowFactory(sheet);
          final Row headerRow = rowFactory.newRow();
  
          // header row
          int i = 0;
          for (var property : propertyList) {
              final Cell cell = headerRow.createCell(i++);
              cell.setCellValue(property.getCanonicalFriendlyName());
          }
  
          final _CellMarshaller cellMarshaller = newCellMarshaller(workbook);
  
          // detail rows
          for (final ManagedObject objectAdapter : adapters) {
              final Row detailRow = rowFactory.newRow();
              i = 0;
              for (var property : propertyList) {
                  final Cell cell = detailRow.createCell(i++);
                  if (annotatedAsHyperlink.contains(property)){
                      cellMarshaller.setCellValueForHyperlink(objectAdapter, property, cell);
                  } else {
                      cellMarshaller.setCellValue(objectAdapter, property, cell);
                  }
              }
          }
  
          // freeze panes
          sheet.createFreezePane(0, 1);
  
          return sheet;
      }
  
      File appendPivotSheet(final List<WorksheetContent> worksheetContents) throws IOException {
  
          var worksheetNames = _NullSafe.stream(worksheetContents)
          .map(worksheetContent->worksheetContent==null
                  ? null
                  : worksheetContent.getSpec().getSheetName())
          .filter(_Strings::isNotEmpty)
          .collect(_Sets.toUnmodifiableSorted());
  
          if(worksheetNames.size() < worksheetContents.size()) {
              throw new IllegalArgumentException("Sheet names must have distinct names and cannot be empty");
          }
  
          for (var worksheetName : worksheetNames) {
              if(worksheetName.length() > 30) {
                  throw new IllegalArgumentException(
                          String.format("Sheet name cannot exceed 30 characters (invalid name: '%s')",
                                  worksheetName));
              }
          }
  
          try(final XSSFWorkbook workbook = new XSSFWorkbook()) {
              final File tempFile =
                      File.createTempFile(_ExcelConverter.class.getName(), UUID.randomUUID().toString() + XLSX_SUFFIX);
              try(final FileOutputStream fos = new FileOutputStream(tempFile)) {
  
                  for (WorksheetContent worksheetContent : worksheetContents) {
                      final WorksheetSpec spec = worksheetContent.getSpec();
                      appendPivotSheet(workbook, worksheetContent.getDomainObjects(), spec.getFactory(), spec.getSheetName());
                  }
                  workbook.write(fos);
              }
              return tempFile;
          }
      }
  
      private void appendPivotSheet(
              final XSSFWorkbook workbook,
              final List<?> domainObjects,
              final WorksheetSpec.RowFactory<?> factory,
              final String sheetName) throws IOException {
  
          final List<ObjectAssociation> propertyList = _Lists.newArrayList();
  
          specificationLoader.specForType(factory.getCls())
          .ifPresent(spec->spec.streamAssociations(MixedIn.INCLUDED)
                  .filter(VISIBLE_PROPERTIES)
                  .forEach(propertyList::add));
  
          // Validate the annotations for pivot
          validateAnnotations(propertyList, factory.getCls());
  
          // Proces the annotations for pivot
          final List<String> annotationList = new ArrayList<>();
          final List<Integer> orderList = new ArrayList<>();
          final List<AggregationType> typeList = new ArrayList<>();
          for (AnnotationOrderAndType annotationOrderAndType : getAnnotationAndOrderFrom(propertyList, factory.getCls())){
              annotationList.add(annotationOrderAndType.annotation);
              orderList.add(annotationOrderAndType.order);
              typeList.add(annotationOrderAndType.type);
          }
  
          // create pivot sheet
          final Sheet pivotSheet = ((Workbook) workbook).createSheet(sheetName);
  
          // Create source sheet for pivot
          String pivotSourceSheetName = ("source for ".concat(sheetName));
          if (WorksheetSpec.isTooLong(pivotSourceSheetName)) {
              pivotSourceSheetName = WorksheetSpec.trim(pivotSourceSheetName);
          }
          final Sheet pivotSourceSheet = appendSheet(workbook, domainObjects, factory, pivotSourceSheetName);
          pivotSourceSheet.shiftRows(0, pivotSourceSheet.getLastRowNum(), 3);
          final Row annotationRow = pivotSourceSheet.createRow(0);
          final Row orderRow = pivotSourceSheet.createRow(1);
          final Row typeRow = pivotSourceSheet.createRow(2);
          PivotUtils.createAnnotationRow(annotationRow, annotationList);
          PivotUtils.createOrderRow(orderRow, orderList);
          PivotUtils.createTypeRow(typeRow, typeList);
  
          // And finally: fill the pivot sheet with a pivot of the values found in pivot source sheet
          SheetPivoter p = new SheetPivoter();
          p.pivot(pivotSourceSheet, pivotSheet);
          pivotSourceSheet.removeRow(annotationRow);
          pivotSourceSheet.removeRow(orderRow);
          pivotSourceSheet.removeRow(typeRow);
          pivotSourceSheet.shiftRows(3, pivotSourceSheet.getLastRowNum(), -3);
      }
  
      private void validateAnnotations(final List<? extends ObjectAssociation> list, final Class<?> cls) throws IllegalArgumentException{
  
          if (fieldsAnnotatedWith(cls, PivotRow.class).size()==0){
              throw new IllegalArgumentException("No annotation for row found");
          }
          if (fieldsAnnotatedWith(cls, PivotRow.class).size()>1){
              throw new IllegalArgumentException("Only one annotation for row allowed");
          }
          if (fieldsAnnotatedWith(cls, PivotColumn.class).size()==0){
              throw new IllegalArgumentException("No annotation for column found");
          }
          if (fieldsAnnotatedWith(cls, PivotValue.class).size()==0){
              throw new IllegalArgumentException("No annotation for value found");
          }
  
      }
  
      private List<AnnotationOrderAndType> getAnnotationAndOrderFrom(final List<? extends ObjectAssociation> list, final Class<?> cls){
  
          List<AnnotationOrderAndType> results = new ArrayList<>();
          for (ObjectAssociation oa : list){
              AnnotationOrderAndType resultToAdd = null;
              if (fieldsAnnotatedWith(cls, PivotRow.class).get(0).getName().equals(oa.getId())){
                  resultToAdd = new AnnotationOrderAndType("row", 0, null);
              }
              for (Field f : fieldsAnnotatedWith(cls, PivotColumn.class)){
                  if (f.getName().equals(oa.getId())){
                      resultToAdd = new AnnotationOrderAndType("column", f.getAnnotation(PivotColumn.class).order(), null);
                  }
              }
              for (Field f : fieldsAnnotatedWith(cls, PivotValue.class)){
                  if (f.getName().equals(oa.getId())){
                      resultToAdd = new AnnotationOrderAndType("value", f.getAnnotation(PivotValue.class).order(), f.getAnnotation(PivotValue.class).type());
                  }
              }
              for (Field f : fieldsAnnotatedWith(cls, PivotDecoration.class)){
                  if (f.getName().equals(oa.getId())){
                      resultToAdd = new AnnotationOrderAndType("deco", f.getAnnotation(PivotDecoration.class).order(), null);
                  }
              }
              if (resultToAdd==null){
                  resultToAdd = new AnnotationOrderAndType("skip", 0, null);
              }
              results.add(resultToAdd);
          }
          return results;
      }
  
      private List<Field> fieldsAnnotatedWith(final Class<?> cls, final Class<? extends Annotation> annotationCls){
          List<Field> result = new ArrayList<>();
          for (Field f : cls.getDeclaredFields()){
              if (f.isAnnotationPresent(annotationCls)) {
                  result.add(f);
              }
          }
          return result;
      }
  
      private class AnnotationOrderAndType {
  
          AnnotationOrderAndType(final String annotation, final Integer order, final AggregationType type){
              this.annotation = annotation;
              this.order = order;
              this.type = type;
          }
  
          String annotation;
          Integer order;
          AggregationType type;
  
      }
  
      List<List<?>> fromBytes(
              final List<WorksheetSpec> worksheetSpecs,
              final byte[] bs) throws IOException, InvalidFormatException {
  
          final List<List<?>> listOfLists = _Lists.newArrayList();
          for (WorksheetSpec worksheetSpec : worksheetSpecs) {
              listOfLists.add(fromBytes(bs, worksheetSpec));
          }
          return listOfLists;
      }
  
      <T> List<T> fromBytes(
              final byte[] bs,
              final WorksheetSpec worksheetSpec) throws IOException, InvalidFormatException {
  
          try (ByteArrayInputStream bais = new ByteArrayInputStream(bs)) {
              final Workbook wb = org.apache.poi.ss.usermodel.WorkbookFactory.create(bais);
              return fromWorkbook(wb, worksheetSpec);
          }
      }
  
      private <T> List<T> fromWorkbook(
              final Workbook workbook,
              final WorksheetSpec worksheetSpec) {
  
          final WorksheetSpec.RowFactory<Object> factory = worksheetSpec.getFactory();
          this.serviceInjector.injectServicesInto(factory);
  
          final Class<T> cls = _Casts.uncheckedCast(factory.getCls());
          final String sheetName = worksheetSpec.getSheetName();
          final Mode mode = worksheetSpec.getMode();
  
          final List<T> importedItems = _Lists.newArrayList();
  
          final _CellMarshaller cellMarshaller = this.newCellMarshaller(workbook);
  
          final Sheet sheet = lookupSheet(cls, sheetName, workbook);
  
          boolean header = true;
          final Map<Integer, Property> propertyByColumn = _Maps.newHashMap();
  
          final ObjectSpecification objectSpec = specificationLoader.specForType(cls).orElse(null);
  
          T previousRow = null;
          for (final Row row : sheet) {
              if (header) {
                  for (final Cell cell : row) {
  
                      try{
                          if (cell.getCellType() != CellType.BLANK) {
                              final int columnIndex = cell.getColumnIndex();
                              final String propertyName = cellMarshaller.getStringCellValue(cell);
                              final OneToOneAssociation property = getAssociation(objectSpec, propertyName);
                              if (property != null) {
                                  final Class<?> propertyType = property.getElementType().getCorrespondingClass();
                                  propertyByColumn.put(columnIndex, new Property(propertyName, property, propertyType));
                              }
                          }
  
                      } catch (final Exception e) {
                          switch (mode) {
                          case RELAXED:
                              // ignore
                          default:
                              throw new ExcelServiceDefault.Exception(String.format("Error processing Excel row nr. %d. Message: %s", row.getRowNum(), e.getMessage()), e);
                          }
                      }
  
                  }
                  header = false;
              } else {
                  // detail
  
                  // Let's require at least one column to be not null for detecting a blank row.
                  // Excel can have physical rows with cells empty that it seem do not existent for the user.
                  ManagedObject templateAdapter = null;
                  T imported = null;
                  for (final Cell cell : row) {
  
                      try {
  
                          final int columnIndex = cell.getColumnIndex();
                          final Property property = propertyByColumn.get(columnIndex);
                          if (property != null) {
                              final OneToOneAssociation otoa = property.getOneToOneAssociation();
                              final Object value = cellMarshaller.getCellValue(cell, otoa);
                              if (value != null) {
                                  if (imported == null) {
                                      // copy the row into a new object
                                      imported = _Casts.uncheckedCast(factory.create());
                                      // set excel metadata if applicable
                                      if (ExcelMetaDataEnabled.class.isAssignableFrom(cls)){
                                          ExcelMetaDataEnabled importedEnhanced = (ExcelMetaDataEnabled) imported;
                                          importedEnhanced.setExcelRowNumber(row.getRowNum());
                                          importedEnhanced.setExcelSheetName(sheetName);
                                          imported = _Casts.uncheckedCast(importedEnhanced);
                                      }
                                      templateAdapter = this.objectManager.adapt(imported);
                                  }
                                  final ManagedObject valueAdapter = this.objectManager.adapt(value);
                                  otoa.set(templateAdapter, valueAdapter, InteractionInitiatedBy.PASS_THROUGH);
                              }
                          } else {
                              // not expected; just ignore.
                          }
  
                      } catch (final Exception e) {
                          switch (mode) {
                          case RELAXED:
                              // ignore
                              break;
                          default:
                              throw new ExcelServiceDefault.Exception(String.format("Error processing Excel row nr. %d. Message: %s", row.getRowNum(), e.getMessage()), e);
  
                          }
                      }
                  }
  
                  //
                  // TODO: v2: to review... there is no longer an API to remove adapters.
                  //  However, my hope is that it isn't needed, because we no longer maintain an oid <-> adapter map.
                  //
  //                // we need to remove the templateAdapter because earlier on we will have created an adapter (and corresponding OID)
  //                // for a view model where the OID is initially computed on the incomplete (in fact, empty) view model.
  //                // removing the adapter therefore removes the OID as well, so next time an adapter is needed for the view model
  //                // the OID will be recomputed based on the fully populated view model pojo.
  //                if(templateAdapter != null) {
  //                    this.objectManager.removeAdapter(templateAdapter);
  //                }
  
                  if (imported != null) {
                      importedItems.add(imported);
  
                      if(imported instanceof RowHandler) {
                          var rowHandler = (RowHandler<?>) imported;
                          var rowHandlerPrev = (RowHandler<?>) previousRow;
  
                          rowHandler.handleRow(_Casts.uncheckedCast(rowHandlerPrev));
                      }
  
                      previousRow = imported;
                  }
  
              }
  
          }
          return importedItems;
      }
  
      protected <T> Sheet lookupSheet(final Class<T> cls, final String sheetName, final Workbook workbook) {
          final List<String> sheetNames = determineCandidateSheetNames(sheetName, cls);
          return lookupSheet(workbook, sheetNames);
      }
  
      private static <T> List<String> determineCandidateSheetNames(final String sheetName, final Class<T> cls) {
          final List<String> names = _Lists.newArrayList();
          if(sheetName != null) {
              names.add(sheetName);
          }
          final String simpleName = cls.getSimpleName();
          if(WorksheetSpec.hasSuffix(simpleName)) {
              names.add(WorksheetSpec.prefix(simpleName));
          }
          return names;
      }
  
      protected Sheet lookupSheet(
              final Workbook wb,
              final List<String> sheetNames) {
          for (String sheetName : sheetNames) {
              final Sheet sheet = wb.getSheet(sheetName);
              if(sheet != null) {
                  return sheet;
              }
          }
          throw new IllegalArgumentException(String.format("Could not locate sheet named any of: '%s'", sheetNames));
      }
  
      private static OneToOneAssociation getAssociation(
              final @Nullable ObjectSpecification objectSpec,
              final String propertyNameOrId) {
  
          if(objectSpec==null) {
              return null;
          }
  
          return objectSpec.streamProperties(MixedIn.INCLUDED)
          .filter(association -> propertyNameOrId.equalsIgnoreCase(association.getCanonicalFriendlyName())
                              || propertyNameOrId.equalsIgnoreCase(association.getId()))
          .findFirst()
          .orElse(null);
      }
  
      @ToString(of = {"name", "type", "currentValue"})
      static class Property {
          private final String name;
          private final Class<?> type;
          private final OneToOneAssociation property;
          private Object currentValue;
  
          public Property(final String name, final OneToOneAssociation property, final Class<?> type) {
              this.name = name;
              this.property = property;
              this.type = type;
          }
  
          public String getName() {
              return name;
          }
  
          public OneToOneAssociation getOneToOneAssociation() {
              return property;
          }
  
          public Class<?> getType() {
              return type;
          }
  
          public Object getCurrentValue() {
              return currentValue;
          }
  
          public void setCurrentValue(final Object currentValue) {
              this.currentValue = currentValue;
          }
      }
  
  //    @SuppressWarnings("unused")
  //    private void autoSize(final Sheet sh, final int numProps) {
  //        for (int prop = 0; prop < numProps; prop++) {
  //            sh.autoSizeColumn(prop);
  //        }
  //    }
  
      // //////////////////////////////////////
  
      protected _CellMarshaller newCellMarshaller(final Workbook wb) {
          final CellStyle dateCellStyle = createDateFormatCellStyle(wb);
          final CellStyle defaultCellStyle = defaultCellStyle(wb);
          final _CellMarshaller cellMarshaller = new _CellMarshaller(bookmarkService, dateCellStyle, defaultCellStyle);
          return cellMarshaller;
      }
  
      protected CellStyle createDateFormatCellStyle(final Workbook wb) {
          final CreationHelper createHelper = wb.getCreationHelper();
          final short dateFormat = createHelper.createDataFormat().getFormat("yyyy-mm-dd");
          final CellStyle dateCellStyle = wb.createCellStyle();
          dateCellStyle.setDataFormat(dateFormat);
          dateCellStyle.setVerticalAlignment(VerticalAlignment.TOP);
          return dateCellStyle;
      }
  
      protected CellStyle defaultCellStyle(final Workbook wb) {
          final CellStyle defaultCellStyle = wb.createCellStyle();
          defaultCellStyle.setVerticalAlignment(VerticalAlignment.TOP);
          return defaultCellStyle;
      }
  
  }
  