/*
 * QQQ - Low-code Application Framework for Engineers.
 * Copyright (C) 2021-2022.  Kingsrook, LLC
 * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
 * contact@kingsrook.com
 * https://github.com/Kingsrook/
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package com.kingsrook.qqq.backend.module.filesystem.s3.utils;


import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.FileSystems;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.util.ArrayList;
import java.util.List;
import com.amazonaws.regions.Regions;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import com.amazonaws.services.s3.model.ListObjectsV2Request;
import com.amazonaws.services.s3.model.ListObjectsV2Result;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.S3ObjectSummary;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.AbstractFilesystemTableBackendDetails;
import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.Cardinality;
import com.kingsrook.qqq.backend.module.filesystem.exceptions.FilesystemException;
import com.kingsrook.qqq.backend.module.filesystem.local.actions.AbstractFilesystemAction;


/*******************************************************************************
 ** Utility methods for working with AWS S3.
 **
 ** Note:  May need a constructor (or similar) in the future that takes the
 ** S3BackendMetaData - e.g., if we need some metaData to construct the AmazonS3
 ** (api client) object, such as region, or authentication.
 *******************************************************************************/
public class S3Utils
{
   private static final QLogger LOG = QLogger.getLogger(S3Utils.class);

   private AmazonS3 amazonS3;



   /*******************************************************************************
    ** List the objects in an S3 bucket matching a glob, per:
    ** https://docs.oracle.com/javase/7/docs/api/java/nio/file/FileSystem.html#getPathMatcher(java.lang.String)
    *******************************************************************************/
   public List<S3ObjectSummary> listObjectsInBucketMatchingGlob(String bucketName, String path, String glob) throws QException
   {
      return listObjectsInBucketMatchingGlob(bucketName, path, glob, null, null);
   }



   /*******************************************************************************
    ** List the objects in an S3 bucket matching a glob, per:
    ** https://docs.oracle.com/javase/7/docs/api/java/nio/file/FileSystem.html#getPathMatcher(java.lang.String)
    ** and also - (possibly) apply a file-name filter (based on the table's details).
    *******************************************************************************/
   public List<S3ObjectSummary> listObjectsInBucketMatchingGlob(String bucketName, String path, String glob, QQueryFilter filter, AbstractFilesystemTableBackendDetails tableDetails) throws QException
   {
      //////////////////////////////////////////////////////////////////////////////////////////////////
      // s3 list requests find nothing if the path starts with a /, so strip away any leading slashes //
      // also strip away trailing /'s, for consistent known paths.                                    //
      // also normalize any duplicated /'s to a single /.                                             //
      //////////////////////////////////////////////////////////////////////////////////////////////////
      path = path.replaceFirst("^/+", "").replaceFirst("/+$", "").replaceAll("//+", "/");
      String prefix = path;

      // todo - maybe this is some error - that the user put a * in the path instead of the glob?
      if(prefix.indexOf('*') > -1)
      {
         prefix = prefix.substring(0, prefix.indexOf('*'));
      }

      ///////////////////////////////////////////////////////////////////////////////////////////////////////
      // for a file-per-record (ONE) table, we may need to apply the filter to listing.                    //
      // but for MANY tables, the filtering would be done on the records after they came out of the files. //
      ///////////////////////////////////////////////////////////////////////////////////////////////////////
      boolean useQQueryFilter = false;
      if(tableDetails != null && Cardinality.ONE.equals(tableDetails.getCardinality()))
      {
         useQQueryFilter = true;
      }

      ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
      // if there's a filter for single file, make that file name the "prefix" that we send to s3, so we just get back that 1 file. //
      // as this will be a common case.                                                                                             //
      ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
      if(filter != null && useQQueryFilter)
      {
         if(filter.getCriteria() != null && filter.getCriteria().size() == 1)
         {
            QFilterCriteria criteria = filter.getCriteria().get(0);
            if(tableDetails.getFileNameFieldName().equals(criteria.getFieldName()) && criteria.getOperator().equals(QCriteriaOperator.EQUALS))
            {
               prefix += "/" + criteria.getValues().get(0);
            }
         }
      }

      ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
      // mmm, we're assuming here we always want more than 1 file - so there must be some * in the glob.                //
      // That's a bad assumption, as it doesn't consider other wildcards like ? and [-] - but - put that aside for now. //
      // Anyway, add a trailing /* to globs with no wildcards (or just a '*' if it's a request for the root (""))       //
      ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
      if(glob == null)
      {
         glob = "";
      }

      if(!glob.contains("*"))
      {
         if(glob.equals(""))
         {
            glob += "*";
         }
         else
         {
            glob += "/*";
         }
      }

      String      pathMatcherArg = AbstractFilesystemAction.stripDuplicatedSlashes("glob:/" + path + "/" + glob);
      PathMatcher pathMatcher    = FileSystems.getDefault().getPathMatcher(pathMatcherArg);

      ListObjectsV2Request listObjectsV2Request = new ListObjectsV2Request()
         .withBucketName(bucketName)
         .withPrefix(prefix);

      ListObjectsV2Result   listObjectsV2Result = null;
      List<S3ObjectSummary> rs                  = new ArrayList<>();

      do
      {
         if(listObjectsV2Result != null)
         {
            listObjectsV2Request.setContinuationToken(listObjectsV2Result.getNextContinuationToken());
         }
         LOG.info("Listing bucket=" + bucketName + ", path=" + path + ", prefix=" + prefix + ", glob=" + glob);
         listObjectsV2Result = getAmazonS3().listObjectsV2(listObjectsV2Request);

         //////////////////////////////////
         // put files in the result list //
         //////////////////////////////////
         for(S3ObjectSummary objectSummary : listObjectsV2Result.getObjectSummaries())
         {
            String key = objectSummary.getKey();

            //////////////////////////////////////////////////////////////////////////////////////////////////////
            // it looks like keys in s3 can have duplicated /'s - so normalize those, to create a "sane" result //
            //////////////////////////////////////////////////////////////////////////////////////////////////////
            key = key.replaceAll("//+", "/");

            ////////////////////////////////////////////////////////////////////////////////
            // always skip folders                                                        //
            // this seemed to fire when it was first written, but not in our unit tests - //
            // is this a difference with real s3 vs. localstack possibly?                 //
            ////////////////////////////////////////////////////////////////////////////////
            if(key.endsWith("/"))
            {
               // LOG.debug("Skipping file [{}] because it is a folder", key);
               continue;
            }

            ///////////////////////////////////////////
            // skip files that do not match the glob //
            ///////////////////////////////////////////
            if(!pathMatcher.matches(Path.of(URI.create("file:///" + key))))
            {
               // LOG.debug("Skipping file [{}] that does not match glob [{}]", key, glob);
               continue;
            }

            ///////////////////////////////////////////////////////////////////////////////////
            // if we're a file-per-record table, and we have a filter, compare the key to it //
            ///////////////////////////////////////////////////////////////////////////////////
            if(!doesObjectKeyMatchFilter(key, filter, tableDetails))
            {
               continue;
            }

            rs.add(objectSummary);

            /////////////////////////////////////////////////////////////////
            // if we have a limit, and we've hit it, break out of the loop //
            /////////////////////////////////////////////////////////////////
            if(filter != null && useQQueryFilter && filter.getLimit() != null)
            {
               if(rs.size() >= filter.getLimit())
               {
                  break;
               }
            }

         }
      }
      while(listObjectsV2Result.isTruncated());

      return rs;
   }



   /*******************************************************************************
    **
    *******************************************************************************/
   private boolean doesObjectKeyMatchFilter(String key, QQueryFilter filter, AbstractFilesystemTableBackendDetails tableDetails) throws QException
   {
      if(filter == null || !filter.hasAnyCriteria())
      {
         return (true);
      }

      if(CollectionUtils.nullSafeHasContents(filter.getSubFilters()))
      {
         ///////////////////////////////
         // todo - well, we could ... //
         ///////////////////////////////
         throw (new QException("Filters with sub-filters are not supported for querying filesystems at this time."));
      }

      Path path = Path.of(URI.create("file:///" + key));

      ////////////////////////////////////////////////////////////////////////////////////////////////////
      // foreach criteria, build a pathmatcher (or many, for an in-list), and check if the file matches //
      ////////////////////////////////////////////////////////////////////////////////////////////////////
      for(QFilterCriteria criteria : filter.getCriteria())
      {
         boolean matches = doesObjectKeyMatchOneCriteria(criteria, tableDetails, path);

         if(!matches && QQueryFilter.BooleanOperator.AND.equals(filter.getBooleanOperator()))
         {
            ////////////////////////////////////////////////////////////////////////////////
            // if it's not a match, and it's an AND filter, then the whole thing is false //
            ////////////////////////////////////////////////////////////////////////////////
            return (false);
         }

         if(matches && QQueryFilter.BooleanOperator.OR.equals(filter.getBooleanOperator()))
         {
            ////////////////////////////////////////////////////////////
            // if it's an OR filter, and we've a match, return a true //
            ////////////////////////////////////////////////////////////
            return (true);
         }
      }

      //////////////////////////////////////////////////////////////////////
      // if we didn't return above, return now                            //
      // for an OR - if we didn't find something true, then return false. //
      // else, an AND - if we didn't find a false, we can return true.    //
      //////////////////////////////////////////////////////////////////////
      if(QQueryFilter.BooleanOperator.OR.equals(filter.getBooleanOperator()))
      {
         return (false);
      }

      return (true);
   }



   /*******************************************************************************
    **
    *******************************************************************************/
   private static boolean doesObjectKeyMatchOneCriteria(QFilterCriteria criteria, AbstractFilesystemTableBackendDetails tableBackendDetails, Path path) throws QException
   {
      if(tableBackendDetails.getFileNameFieldName().equals(criteria.getFieldName()))
      {
         if(QCriteriaOperator.EQUALS.equals(criteria.getOperator()) && CollectionUtils.nonNullList(criteria.getValues()).size() == 1)
         {
            return (FileSystems.getDefault().getPathMatcher("glob:**/" + criteria.getValues().get(0)).matches(path));
         }
         else if(QCriteriaOperator.IN.equals(criteria.getOperator()) && !CollectionUtils.nonNullList(criteria.getValues()).isEmpty())
         {
            boolean anyMatch = false;
            for(int i = 0; i < criteria.getValues().size(); i++)
            {
               if(FileSystems.getDefault().getPathMatcher("glob:**/" + criteria.getValues().get(i)).matches(path))
               {
                  anyMatch = true;
                  break;
               }
            }

            return (anyMatch);
         }
         else
         {
            throw (new QException("Unable to query filename field using operator: " + criteria.getOperator()));
         }
      }
      else
      {
         throw (new QException("Unable to query filesystem table by field: " + criteria.getFieldName()));
      }
   }



   /*******************************************************************************
    ** Get the contents (as an InputStream) for an object in s3
    *******************************************************************************/
   public InputStream getObjectAsInputStream(S3ObjectSummary s3ObjectSummary)
   {
      return getAmazonS3().getObject(s3ObjectSummary.getBucketName(), s3ObjectSummary.getKey()).getObjectContent();
   }



   /*******************************************************************************
    ** Write a file
    *******************************************************************************/
   public void writeFile(String bucket, String key, byte[] contents)
   {
      ObjectMetadata objectMetadata = new ObjectMetadata();
      objectMetadata.setContentLength(contents.length);
      getAmazonS3().putObject(bucket, key, new ByteArrayInputStream(contents), objectMetadata);
   }



   /*******************************************************************************
    ** Delete an object (file) from a bucket
    *******************************************************************************/
   public void deleteObject(String bucketName, String key) throws FilesystemException
   {
      //////////////////////////////////////////////////////////////////////////////////////////////
      // note, aws s3 api does not appear to have any way to check the success or failure here... //
      //////////////////////////////////////////////////////////////////////////////////////////////
      try
      {
         getAmazonS3().deleteObject(bucketName, key);
      }
      catch(Exception e)
      {
         throw (new FilesystemException("Error deleting s3 object " + key + " in bucket " + bucketName, e));
      }
   }



   /*******************************************************************************
    ** Move an object (file) within a bucket
    *******************************************************************************/
   public void moveObject(String bucketName, String source, String destination) throws FilesystemException
   {
      //////////////////////////////////////////////////////////////////////////////////////////////
      // note, aws s3 api does not appear to have any way to check the success or failure here... //
      //////////////////////////////////////////////////////////////////////////////////////////////
      try
      {
         getAmazonS3().copyObject(bucketName, source, bucketName, destination);
         getAmazonS3().deleteObject(bucketName, source);
      }
      catch(Exception e)
      {
         throw (new FilesystemException("Error moving s3 object " + source + " to " + destination + " in bucket " + bucketName, e));
      }
   }



   /*******************************************************************************
    ** Setter for AmazonS3 client object.
    *******************************************************************************/
   public void setAmazonS3(AmazonS3 amazonS3)
   {
      this.amazonS3 = amazonS3;
   }



   /*******************************************************************************
    ** Getter for AmazonS3 client object.
    *******************************************************************************/
   public AmazonS3 getAmazonS3()
   {
      if(amazonS3 == null)
      {
         // TODO - get this (and other props?) from backend meta data
         amazonS3 = AmazonS3ClientBuilder.standard().withRegion(Regions.US_EAST_1).build();
      }

      return amazonS3;
   }

}
