#!/usr/bin/env python
# -*- coding: utf-8 -*-

# This file is part of Pymetrick.  The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.

"""Modulo para gestionar S3 BUCKETS de AWS o compatibles"""

try:
    from pymetrick.version import __author__, __version__, __license__, __copyright__
except ImportError:
    raise ImportError(
        'Failed to find (autogenerated) version.py '
    )

__date__ = '2012-09-21'
__credits__ = ''
__text__ = 'Tratamiento de S3 Buckets'
__file__ = 'bucket.py'
__boto3_version__ = '1.26.84'

#--- CHANGES ------------------------------------------------------------------
# 2021-09-14 v0.01 PL: - First version

import sys, os
# sin bytecode
sys.dont_write_bytecode = True

#from secrets import AWS_DEFAULT_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, endpoint_url
from typing import Iterator, Dict, Any, Optional, List
import boto3, botocore
from boto3.s3.transfer import S3Transfer, TransferConfig
from botocore.exceptions import ClientError, ParamValidationError


from pathlib import Path
import threading

import logging
import logging.handlers

try:
    import simplejson as json
except:
    import json

'''
DEBUG - debug message
INFO - info message
WARNING - warn message
ERROR - error message
CRITICAL - critical message
'''
LOG_LEVEL_VALUE = {'DEBUG':10,'INFO':20,'WARNING':30,'ERROR':40,'CRITICAL':50}
if str(os.environ.get('PYMETRICK_LOG_LEVEL',None)).upper() in LOG_LEVEL_VALUE:
    LOG_LEVEL = LOG_LEVEL_VALUE[str(os.environ.get('PYMETRICK_LOG_LEVEL')).upper()]
else:
    LOG_LEVEL = 30
LOG_FILENAME = '-'.join([os.path.abspath(__file__).split(os.sep)[len(os.path.abspath(__file__).split(os.sep))-1],])[:-3]
LOG = logging.getLogger(LOG_FILENAME)

if 'LD_LIBRARY_PATH' in list(os.environ.keys()):
    # CGI environment
    sys.stdout = sys.stderr
    logging.basicConfig(stream = sys.stderr, level=LOG_LEVEL, format='%(filename)s[line:%(lineno)d] - %(levelname)s - %(message)s')
else:
    # not CGI environment
    logging.basicConfig(stream=sys.stderr)
    hdlr = logging.handlers.RotatingFileHandler(filename=LOG_FILENAME+'.log',mode='a', encoding='utf-8', maxBytes=1048576, backupCount=3)
    formatter = logging.Formatter('%(asctime)s - %(filename)s[line:%(lineno)d] - %(levelname)s - %(message)s')
    hdlr.setFormatter(formatter)
    LOG.addHandler(hdlr)
    LOG.setLevel(LOG_LEVEL)

GB = 1024 ** 3
MP_THRESHOLD = 1
MP_CONCURRENCY = 5
MAX_RETRY_COUNT = 3

class Bucket(object):
    '''Gestion de S3 Buckets'''

    def __init__(self, *args,**kwargs):
        '''
        @parameters
          aws_access_key_id
          aws_secret_access_key
          endpoint_url
        '''
        try:
            # Define the configuration rules
            self.cors_configuration = {
                    'CORSRules': [{
                    'AllowedHeaders': ['Authorization'],
                    'AllowedMethods': ['GET', 'PUT', 'POST', 'DELETE'],
                    'AllowedOrigins': ['*'],
                    'ExposeHeaders': ['ETag', 'x-amz-request-id'],
                    'MaxAgeSeconds': 3000
                }]
            }

            if 'default_bucket' in kwargs:
                self.default_bucket = kwargs['default_bucket']
            else:
                self.default_bucket = None

            object_storage_config = {
            'aws_access_key_id' : None,
            'aws_secret_access_key' : None,
            'endpoint_url': None }

            for key in object_storage_config.keys():
                if key in kwargs:
                    object_storage_config[key] = kwargs[key]
                elif key == 'cors':
                    self.cors_configuration = cors if (isinstance(cors,(dict,)) and 'CORSRules' in cors) else None
                    
                
            self.client = boto3.client('s3', **object_storage_config )
            self.resource = boto3.resource('s3', **object_storage_config)

            if self.default_bucket and self.cors_configuration:
                self.client.put_bucket_cors(Bucket=self.default_bucket, CORSConfiguration=self.cors_configuration)

        except ClientError as error:
            for key, value in sorted(botocore.exceptions.__dict__.items()):
                if isinstance(value, type):
                    print(key)
        except ParamValidationError as error:
            raise ValueError('The parameters you provided are incorrect: {}'.format(error))


    def list_buckets(self):
        '''
        list buckets
        '''
        try:
            list_buckets = []
            response = self.client.list_buckets()
            if isinstance(response,dict) and "HTTPStatusCode" in response["ResponseMetadata"] and response["ResponseMetadata"]["HTTPStatusCode"]==200:
                if 'Buckets' in response:
                    for bucket in response['Buckets']:
                        list_buckets.append(bucket['Name'])
                return list_buckets
            else:
                return ['Error']

        except ClientError as error:
            for key, value in sorted(botocore.exceptions.__dict__.items()):
                if isinstance(value, type):
                    print(key)
        except ParamValidationError as error:
            raise ValueError('The parameters you provided are incorrect: {}'.format(error))

   
    def versioning(self, version=False):
        '''enabling|suspend versioning for s3 bucket object'''
        try:
            _status_init = False
            _status_final = False 
            if self.default_bucket:
                _versioning = self.resource.BucketVersioning(self.default_bucket)
                _status_init = _versioning.status

                if version:
                    _versioning.enable()
                else:
                    _versioning.suspend()

                _status_final = _versioning.status

                return [_status_init, _status_final]
            
        except ClientError as error:
            for key, value in sorted(botocore.exceptions.__dict__.items()):
                if isinstance(value, type):
                    print(key)
        except ParamValidationError as error:
            raise ValueError('The parameters you provided are incorrect: {}'.format(error))                    
            
    """
    def list_objects(self, prefix='', delimiter = None):
        try:
            '''
            You need to keep track the last key you successfully processed. With ContinuationToken, you don't need to know the last key, you just
            check existence of NextContinuationToken in the response

            response = client.list_objects_v2(
                Bucket='string',
                Delimiter='string',
                EncodingType='url',
                MaxKeys=123,
                Prefix='string',
                # Replace marker to list continuous page
                ContinuationToken='string',

                # set to True to fetch key owner info. Default is False.
                FetchOwner=True|False,

                # This is similar to the Marker in list_object()
                StartAfter='string'
            )

            Use example:
               for object in list_objects(prefix):
                   print(object['Size'])
            '''
            # 1000 max result line for request
            continuation_token = None
            args = {}
            args['Bucket'] = self.default_bucket
            args['MaxKeys'] = 1000
            args['Prefix'] = prefix
            if delimiter:
                # get CommonPrefixes, not Contents. Default '\'
                args['Delimiter'] = delimiter
            
            while True:
                if continuation_token:
                    args['ContinuationToken'] = continuation_token
                objects = self.client.list_objects_v2(**args)
                LOG.debug(f'''OBJECTS {objects}''')
                if 'CommonPrefixes' in objects:
                    # Loop through the prefixes (folder names)
                    yield from objects.get('CommonPrefixes',[])   # keys = 'Prefix'
                elif 'Contents' in objects:
                    # Loop through the contents keys
                    yield from objects.get('Contents', [])
                if not objects.get('IsTruncated'):  # At the end of the list?
                    break
                # if NextContinuationToken go to next request
                continuation_token = objects.get('NextContinuationToken',None)

        except ClientError as error:
            for key, value in sorted(botocore.exceptions.__dict__.items()):
                if isinstance(value, type):
                    LOG.error(value)
        except ParamValidationError as error:
            raise ValueError('The parameters you provided are incorrect: {}'.format(error))
        """

    def list_objects(self, prefix: str = '', delimiter: Optional[str] = None) -> Iterator[Dict[str, Any]]:
        """
        List objects in an S3 bucket.

        Cómo :   Inicializando la class ... lister = Bucket(...)
        e iterando ...
                        for obj in lister.list_objects(prefix='your_prefix'):
                        if 'Size' in obj:
                            print(f"Object: {obj['Key']}, Size: {obj['Size']}")
                        else:
                            print(f"Prefix: {obj['Prefix']}")

        :param prefix: Limits the response to keys that begin with the specified prefix.
        :param delimiter: A delimiter is a character you use to group keys.
        :return: An iterator over the objects or common prefixes.
        """
        try:
            args = {
                'Bucket': self.default_bucket,
                'MaxKeys': 1000,
                'Prefix': prefix
            }
            if delimiter:
                args['Delimiter'] = delimiter

            while True:
                response = self.client.list_objects_v2(**args)
                LOG.debug(f"OBJECTS {response}")

                if delimiter:
                    yield from response.get('CommonPrefixes', [])
                else:
                    yield from response.get('Contents', [])

                if not response.get('IsTruncated'):  # At the end of the list?
                    break

                args['ContinuationToken'] = response.get('NextContinuationToken')

        except ClientError as error:
            LOG.error(f"An error occurred: {error}")
            raise
        except ParamValidationError as error:
            LOG.error(f"Parameter validation error: {error}")
            raise ValueError(f"The parameters you provided are incorrect: {error}")


    def add_bucket(self, bucket_name=None ):
        try:
            if bucket_name:
                self.client.create_bucket(Bucket=bucket_name)
                self.default_bucket = bucket_name
        except ClientError as error:
            for key, value in sorted(botocore.exceptions.__dict__.items()):
                if isinstance(value, type):
                    print(value)
        except ParamValidationError as error:
            raise ValueError('The parameters you provided are incorrect: {}'.format(error))


    def delete_bucket(self, bucket_name=None ):
        try:
            if bucket_name:
                self.client.delete_bucket(Bucket=bucket_name)
            else:
                self.client.delete_bucket(Bucket=self.default_bucket)

        except ClientError as error:
            for key, value in sorted(botocore.exceptions.__dict__.items()):
                if isinstance(value, type):
                    print(value)
        except ParamValidationError as error:
            raise ValueError('The parameters you provided are incorrect: {}'.format(error))


    def upload_object(self, prefix='', list_objects=[], metadata=None):
        ''' Upload a file to an S3 bucket
            @parameters
            prefix: Bucket path
            list_objects: S3 list object name
            metadata: dict with 'extra_args' like extra_args={'ACL':'public-read'} or
            metadata={'GrantRead': 'uri="http://acs.amazonaws.com/groups/global/AllUsers"',
                'GrantFullControl': 'id="01234567890abcdefg"',}
            metadata={'ACL': 'public-read', 'ContentType': 'image/png'}

            @return
            :True if file was uploaded, else False
        '''
        try:

            # Multipart transfers occur when the file size exceeds the value of the multipart_threshold attribute

            
            if prefix is None:
                log.error("prefix is null!")
                raise ValueError("S3 object must be set!")
            
            GB = 1024 ** 3
            mp_threshold = MP_THRESHOLD*GB
            concurrency = MP_CONCURRENCY
            
            #transfer = S3Transfer(self.client)
            transfer_config = TransferConfig(multipart_threshold=mp_threshold, use_threads=True, max_concurrency=concurrency)
            for file in list_objects:
                retry = MAX_RETRY_COUNT
                while retry > 0:
                    try:
                        if Path(file).is_dir() or Path(file).is_symlink() or (Path(file).is_file() and file.endswith('.lnk')):
                            LOG.error("File [" + file + "] does not exist!")
                            raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), file)
                        
                        object_path = (''.join([prefix,os.path.split(file)[-1:][0]])).upper()
                        args = {
                            'Filename' : file,
                            'Bucket' : self.default_bucket,
                            'Key' : object_path,
                            'ExtraArgs' : metadata,
                            'Callback' : None,
                            'Config' : transfer_config }
                        if metadata == None:
                            args['ExtraArgs'] = {'ACL': 'private'}
                        self.client.upload_file(**args)
                        sys.stdout.write('\n')
                        LOG.info("File [" + file + "] uploaded successfully")
                        LOG.info("Object name: [" + object_path + "]")
                        retry = 0
                 
                    except ClientError as e:
                        LOG.error("Failed to upload object!")
                        LOG.exception(e)
                        if e.response['Error']['Code'] == 'ExpiredToken':
                            LOG.warning('Login token expired')
                            retry -= 1
                            LOG.debug("retry = " + str(retry))
                        else:
                            LOG.error("Unhandled error code:")
                            LOG.debug(e.response['Error']['Code'])
                            raise
             
                    except boto3.exceptions.S3UploadFailedError as e:
                        LOG.error("Failed to upload object!")
                        LOG.exception(e)
                        if 'ExpiredToken' in str(e):
                            LOG.warning('Login token expired')
                            LOG.info("Handling...")
                            retry -= 1
                            LOG.debug("retry = " + str(retry))
                        else:
                            LOG.error("Unknown error!")
                            raise
             
                    except Exception as e:
                        LOG.error("Unknown exception occured!")
                        template = "An exception of type {0} occurred. Arguments:\n{1!r}"
                        message = template.format(type(e).__name__, e.args)
                        LOG.debug(message)
                        retry = 0
                        LOG.debug("retry = " + str(retry))                        
                        LOG.exception(e)
                        raise                    
                
            return True
        except ClientError as error:
            for key, value in sorted(botocore.exceptions.__dict__.items()):
                if isinstance(value, type):
                    LOG.error(f'''bucket.upload_object {key}''')
            return False
        except ParamValidationError as error:
            LOG.error(ValueError('The parameters you provided are incorrect: {}'.format(error)))
            raise ValueError('The parameters you provided are incorrect: {}'.format(error))
            return False
        except Exception as e:
            if hasattr(e, 'message'):
                LOG.error('<%s> in line %s !!!' % (e.message,format(sys.exc_info()[-1].tb_lineno)))
            else:
                LOG.error('<%s> in line %s !!!' % (e,format(sys.exc_info()[-1].tb_lineno)))
            return False
        

    #from pathlib import Path
    def download_object(self, prefix='', list_objects=[]):
        try:

            objects = []
            #transfer = S3Transfer(self.client)
            concurrency = MP_CONCURRENCY
            
            #transfer = S3Transfer(self.client)
            transfer_config = TransferConfig(use_threads=True, max_concurrency=concurrency)
            if len(list_objects)==0:
                list_objects = self.client.list_objects(prefix=prefix)
                prefix=None
            for file in list_objects:
                retry = MAX_RETRY_COUNT
                while retry > 0:
                    try:                
                        if prefix:
                            object_path = (''.join([prefix,os.path.split(file)[-1:][0]])).upper()
                        else:
                            object_path = file.upper()
                        path, filename = os.path.split(file.upper())
                        #Create sub directories if its not existing
                        if not os.path.exists(path):
                            try:
                                os.makedirs(path)
                            except OSError:
                                if not os.path.isdir(path):
                                    raise
                        self.client.download_file(
                            Bucket=self.default_bucket,
                            Key=object_path,
                            Filename=os.sep.join([path,filename]),
                            Config=transfer_config)
                        objects.append(os.sep.join([path,filename]))
                        sys.stdout.write('\n')
                        LOG.info("File [" + file + "] downloaded successfully")
                        LOG.info("Object name: [" + object_path + "]")
                        retry = 0
                 
                    except ClientError as e:
                        log.error("Failed to download object!")
                        log.exception(e)
                        if e.response['Error']['Code'] == 'ExpiredToken':
                            LOG.warning('Login token expired')
                            retry -= 1
                            LOG.debug("retry = " + str(retry))
                        else:
                            LOG.error("Unhandled error code:")
                            LOG.debug(e.response['Error']['Code'])
                            raise
             
                    except boto3.exceptions.S3UploadFailedError as e:
                        log.error("Failed to download object!")
                        log.exception(e)
                        if 'ExpiredToken' in str(e):
                            LOG.warning('Login token expired')
                            LOG.info("Handling...")
                            retry -= 1
                            LOG.debug("retry = " + str(retry))
                        else:
                            LOG.error("Unknown error!")
                            raise
             
                    except Exception as e:
                        LOG.error("Unknown exception occured!")
                        template = "An exception of type {0} occurred. Arguments:\n{1!r}"
                        message = template.format(type(e).__name__, e.args)
                        LOG.debug(message)
                        retry = 0
                        LOG.debug("retry = " + str(retry))                        
                        LOG.exception(e)
                        raise
            return objects

        except ClientError as error:
            for key, value in sorted(botocore.exceptions.__dict__.items()):
                if isinstance(value, type):
                    LOG.error(value)
        except ParamValidationError as error:
            raise ValueError('The parameters you provided are incorrect: {}'.format(error))
        except Exception as e:
            print(e)
           

    def copy_object(self, prefix='', list_objects=[]):
        try:
            copy_source = {
            'Bucket': 'mybucket',
            'Key': 'mykey'
            }
            s3.meta.client.copy(copy_source, 'otherbucket', 'otherkey')
        except ClientError as error:
            for key, value in sorted(botocore.exceptions.__dict__.items()):
                if isinstance(value, type):
                    LOG.error(value)
        except ParamValidationError as error:
            raise ValueError('The parameters you provided are incorrect: {}'.format(error))        



    def delete_object(self, prefix: str = '', list_objects: Optional[List[str]] = None):
        try:
            keys_to_delete = []
            if list_objects is None or len(list_objects)==0:
                list_objects = []
                for obj in self.list_objects(prefix=prefix):
                    if 'Key' in obj:
                        keys_to_delete.append(obj['Key'])
                prefix=''
            else:
                keys_to_delete = list_objects # Usar la lista proporcionada
                
            if not keys_to_delete:
                return True

            # =========================================================
            #  Dividir la lista en lotes de 1000 (o menos)
            # =========================================================
            
            all_batches_successful = True
            
            for i, chunk in enumerate(self.chunker(keys_to_delete, 1000)):
                # 1. Construir el lote (chunk) actual
                # Usamos la clave completa (key) y la convertimos a mayúsculas
                delete_keys = {'Objects': [{'Key': key.upper()} for key in chunk]}
                
                LOG.info(f'Iniciando eliminación del lote {i+1} con {len(chunk)} objetos.')
                LOG.debug(f'Lote a eliminar: {delete_keys}')
                
                # 2. Ejecución de la eliminación por lotes
                response = self.client.delete_objects(
                    Bucket=self.default_bucket,
                    Delete=delete_keys
                )
                
                # 3. Manejo de resultados del lote
                deleted_count = len(response.get('Deleted', []))
                errors_count = len(response.get('Errors', []))

                if errors_count > 0:
                    all_batches_successful = False
                    LOG.error(f"Lote {i+1}: Se eliminaron {deleted_count} objetos con {errors_count} errores.")
                    LOG.debug(f"Errores en el lote {i+1}: {response.get('Errors')}")
                else:
                    LOG.info(f"Lote {i+1}: Eliminación exitosa de {deleted_count} objetos.")

            # 4. Retornar el estado general
            if all_batches_successful:
                LOG.info(f"Eliminación por lotes de todos los {len(keys_to_delete)} objetos exitosa.")
            return all_batches_successful
        
        except ClientError as error:
            LOG.error(f"ClientError al eliminar objetos. Código: {error.response['Error']['Code']}")
            LOG.debug(f"Mensaje de error: {error.response['Error']['Message']}")
            raise # Vuelve a lanzar la excepción si es necesario
            
        except ParamValidationError as error:
            # ✅ Solo lanza la excepción de validación de parámetros, que es informativa.
            raise ValueError('The parameters you provided are incorrect: {}'.format(error))
       

    def backup_object(self, prefix=''):
        try:
            for object in self.list_objects(prefix):
                self.download_object(prefix=None,list_objects=[object['Key'],])
            return
        except ClientError as error:
            for key, value in sorted(botocore.exceptions.__dict__.items()):
                if isinstance(value, type):
                    LOG.error(value)
        except ParamValidationError as error:
            raise ValueError('The parameters you provided are incorrect: {}'.format(error))

    def chunker(self, seq, size):
        """Divide una lista (seq) en trozos de tamaño (size)."""
        return (seq[pos:pos + size] for pos in range(0, len(seq), size))
        
    '''
    def copy_to_bucket(self, prefix='', target_bucket='', target_key=''):
        s3.copy(CopySource={
        'Bucket': sourceBucket,
        'Key': sourceKey}, 
    Bucket=targetBucket,
    Key=targetKey,
    ExtraArgs={'ACL': 'bucket-owner-full-control'})
    '''    

if __name__  == "__main__":
    print ('''copyright {0}'''.format( __copyright__))
    print ('''license {0}'''.format( __license__))
    print ('''version {0}'''.format( __version__))
    if len(sys.argv) < 2:
        sys.stderr.write("for help use -h o --help")
    elif sys.argv[1]=='-h' or sys.argv[1]=='--help':
        print ('''
        Tratamiento de S3 Bucket\n\n''')


