#!/usr/bin/python3
#   autotrash.py - GNOME GVFS Trash old file auto prune
#
#   Copyright (C) 2008 A. Bram Neijt <bneijt@gmail.com>
#
#   This program is free software: you can redistribute it and/or modify
#   it under the terms of the GNU 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 General Public License for more details.
#
#   You should have received a copy of the GNU General Public License
#   along with this program. If not, see <http://www.gnu.org/licenses/>.

import sys
import optparse
import configparser
import shutil
import glob
import os
import time
import math
import logging
import re
import errno
import stat

# custom logging level between DEBUG and INFO
VERBOSE = 15

def on_remove_error(function, path, excinfo):
    if excinfo[0] == errno.EPERM:
        #Permission errors, try a chmod to recover
        if function == os.remove:
            #Tried to remove a file, but failed. Try to change the write permissions of the tree to delete it
            logging.log(VERBOSE, 'Failed to remove file at: %s\n\tgot exception: %s\n\tchanging permissions and trying again.', path, str(excinfo))
            os.chmod(path, stat.S_IWUSR)
            os.unlink(path)
            return
        if function == os.rmdir:
            #Tried to remove a directory, but failed. Try to change the write permissions of the tree to delete it
            logging.log(VERBOSE, 'Failed to remove directory at: %s\n\tgot exception: %s\n\tchanging permissions and trying again.', path, str(excinfo))
            os.chmod(path, stat.S_IWUSR)
            os.unlink(path)
            return
    #Other error, what will it be?
    logging.error('Failed to remove "%s", got exception: %s', path, str(excinfo))

def real_file_name(trash_name):
    '''Get real file name from trashinfo file name: basename without extension in ../files'''
    basename = os.path.basename(trash_name)
    trash_directory = os.path.abspath(os.path.join(os.path.dirname(trash_name), '..'))
    (file_name, trashinfo_ext) = os.path.splitext(basename)
    return os.path.join(trash_directory, 'files', file_name)

def purge(trash_directory, trash_name, dryrun):
    '''Purge the file behind the trash file fname'''
    assert os.path.exists(trash_name)
    target = real_file_name(trash_name)
    if dryrun:
        #Broken links will not os.path.exist
        if os.path.exists(target) or os.path.islink(target):
            logging.info('Remove %s', target)
        else:
            logging.info('Ignore %s', target)
        if os.path.exists(trash_name):
            logging.info('Remove %s', trash_name)
        else:
            logging.info('Ignore %s', trash_name)
        return False

    #The real deleting...
    if os.path.islink(target):
        logging.log(VERBOSE, 'Removing link %s', target)
        os.unlink(target)
    elif os.path.isdir(target):
        logging.log(VERBOSE, 'Removing directory %s', target)
        shutil.rmtree(target, False, on_remove_error)
    else:
        #Make sure we do not try to unlink a file that does not exist.
        if os.path.exists(target):
            logging.log(VERBOSE, 'Removing file %s', target)
            os.unlink(target)
        else:
            logging.log(VERBOSE, 'Ignore non-existing file %s', target)

    os.unlink(trash_name)
    return True

def trash_info_date(fname):
    try:
        parser = configparser.SafeConfigParser()
        readCorrectly = parser.read(fname)
        section = 'Trash Info'
        key = 'DeletionDate'
        if readCorrectly.count(fname) and parser.has_option(section, key):
            #Read the file succesfully
            return time.strptime(parser.get(section, key), '%Y-%m-%dT%H:%M:%S')
    except Exception as e:
        #Error because exit status will be >0 because of this
        logging.error("Failed to read %s: %s", fname, e)
    return None

def get_consumed_size(path):
    '''Get the amount of filesystem space actually consumed by a file or directory'''
    size = 0
    try:
        if os.path.islink(path):
            size = os.lstat(path).st_size
        else:
            size = os.stat(path).st_blocks * 512
            if os.path.isdir(path):
                for entry_name in os.listdir(path):
                    size += get_consumed_size(os.path.join(path, entry_name))
    except OSError:
        logging.error('Error getting size for %s', path)
    return size

def fmt_bytes(bytes, fmt='%.1f'):
    #If you NEED EiB, ZiB or YiB, please send me a mail I woul love to hear from you.
    for size, name in    (1<<50, 'PiB'), (1<<40, 'TiB'), (1<<30, 'GiB'), (1<<20, 'MiB'), (1<<10, 'KiB'):
        if bytes >= size:
            return '%s %s' % (fmt % (float(bytes) / size), name)
    return '%d bytes' % bytes

def find_trash_directories(override_dir=None, find_mounts=False):
    if override_dir:
        return [override_dir]

    trash_paths = []

    # Add user trash directory
    trash_path = os.path.join(os.environ.get('XDG_DATA_HOME', os.path.expanduser('~/.local/share')), 'Trash')
    trash_paths.append(trash_path)
    logging.log(VERBOSE, "Found trash directory: %s" % (trash_path))

    # Add trash "top directories" in all mount points (if they exist)
    if find_mounts:
        with open("/proc/mounts", "r") as mounts:
            for line in mounts.readlines():
                mount_path = line.split()[1]

                # Find a usable trash path on this device
                trash_path_options = [
                    os.path.join(mount_path, ".Trash", str(os.getuid())),
                    os.path.join(mount_path, ".Trash-%d" % (os.getuid()))
                ]
                for trash_path in trash_path_options:
                    if os.path.exists(trash_path):
                        logging.log(VERBOSE, "Found trash directory: %s" % (trash_path))
                        trash_paths.append(trash_path)
                        break

    return trash_paths

def main(args):
    #Load and set configuration options
    parser = optparse.OptionParser(usage='%prog -d <days of age to purge>')
    parser.set_defaults(
            days = 0,
            trash_path = None,
            max_free = 0,
            delete = 0,
            min_free = 0,
            verbose = False,
            quiet = False,
            check = False,
            dryrun = False,
            stat = False,
            delete_first = [],
            version = False,
            )
    parser.add_option('-d', '--days', dest='days', type='int', help='delete files older then DAYS number of days.', metavar='DAYS')
    parser.add_option('-T', '--trash-path', dest='trash_path', help='empty the trash path in the given DIRECTORY instead of using the user home directory', metavar='DIRECTORY')
    parser.add_option('-t', '--trash-mounts', dest='trash_mounts', action='store_true', default=False, help='Process all user trash directories instead of just the one in the home directory')
    parser.add_option('--max-free', dest='max_free', type='int', help='only run if less then M megabytes of free space is left.', metavar='M')
    parser.add_option('--delete', dest='delete', type='int', help='delete at least M megabytes.', metavar='M')
    parser.add_option('--min-free', '--keep-free', dest='min_free', type='int', help='set --delete to make sure M megabytes of space is available.', metavar='M')
    parser.add_option('-D', '--delete-first', action='append', dest='delete_first', help='push files matching this REGEX to the top of the deletion queue', metavar='REGEX')
    parser.add_option('-v', '--verbose', action='store_true', dest='verbose', help='be more verbose, a must when testing something out')
    parser.add_option('-q', '--quiet', action='store_true', dest='quiet', help='only output warnings')
    parser.add_option('--check', action='store_true', dest='check', help='report .trashinfo files without a real file')
    parser.add_option('--dry-run', action='store_true', dest='dryrun', help='just list what would have been done')
    parser.add_option('--stat', action='store_true', dest='stat', help='show the number, and total size of files involved')
    parser.add_option('-V', '--version', action='store_true', dest='version', help='show version and exit')
    (options, args) = parser.parse_args()

    logging.basicConfig(level=logging.INFO, format='%(message)s')
    logging.addLevelName(VERBOSE, 'VERBOSE')
    if options.verbose:
        logging.getLogger().setLevel(VERBOSE)
    elif options.quiet:
        logging.getLogger().setLevel(logging.WARNING)

    if options.version:
        logging.info('''Version 0.2.0\nCopyright (C) 2008 A. Bram Neijt <bneijt@gmail.com>\nLicense GPLv3+''')
        return 1

    if options.delete + options.min_free + options.days == 0:
        parser.error('You need to specify at least one of:\n\t -d <days of age to purge>,\n\t --delete <number of megabytes to purge>, or\n\t --min-free <number of megabytes to make free>\n for this command to have any effect.')

    if options.days < 0:
        parser.error('Can not work with a negative or zero days')

    if options.max_free < 0:
        parser.error('Can not work with a negative value for --max-free')

    if options.delete < 0:
        parser.error('Can not work with a negative value for --delete')

    if options.min_free < 0:
        parser.error('Can not work with a negative value for --min-free')

    if options.trash_path and options.trash_mounts:
        parser.error('Cannot auto-detect trash directories when setting a specific one')

    if options.stat and options.quiet:
        parser.error('Specifying both --quiet and --stat does not make sense')

    if options.verbose and options.quiet:
        parser.error('Specifying both --quiet and --verbose does not make sense')

    if options.delete and options.min_free:
        parser.error('Combining --delete and --min-free results in unpredictable behaviour as --delete may or may not be ignored depending on the free space.')

    if (not options.min_free) and options.delete_first:
        parser.error('Using --delete-first (-D) without --min-free does not have any effect. Age based purging will still work as predicted.')


    # Compile list of possible trash directories
    trash_paths = find_trash_directories(options.trash_path, options.trash_mounts)

    # Set variables for stats collecting
    total_size = 0
    total_files = 0
    deleted_size = 0
    deleted_files = 0

    for trash_path in trash_paths:
        trash_info_path = os.path.expanduser(os.path.join(trash_path, 'info'))
        if not os.path.exists(trash_info_path):
            logging.error('Can not find trash information directory: %s', trash_info_path)
            return 1

        if options.max_free or options.min_free: #Free space calculation is needed
            fs_stat = os.statvfs(trash_info_path)
            if fs_stat.f_bsize <= 0:
                logging.error('Can not determine free space because the returned filesystem block size was %i\n    The --max-free option may not be supported for this filesystem.' % fs_stat.f_bsize)
                return 1
            free_megabytes = int((fs_stat.f_bavail * fs_stat.f_bsize) / (1024*1024))

            if options.max_free:
                #Check if there is less then max_free megabytes of free space
                #if there is not less, then do nothing and skip the glob.
                if free_megabytes > options.max_free:
                    logging.log(VERBOSE, 'I see %i MB of free space at "%s"\n    which is more then --max-free, doing nothing.', free_megabytes, trash_info_path)
                    continue
            if options.min_free and free_megabytes < options.min_free:
                options.delete = options.min_free - free_megabytes
                logging.log(VERBOSE, 'Setting --delete to %i to make sure at least %i MB becomes free.\n\t Currently we have %i megabytes of free space.', options.delete, options.min_free, free_megabytes)


        deleted_target = 0
        if options.delete:
            deleted_target = options.delete * 1024 * 1024

        #Collect file info's
        files = []
        failures = 0
        if True: #Scope protection
            trash_info_file_names = [ os.path.join(trash_info_path, fn) for fn in os.listdir(trash_info_path) if fn.endswith(".trashinfo")]
            for file_name in trash_info_file_names:
                real_file = real_file_name(file_name)
                file_info = {
                    'trash_info': file_name,
                    'real_file': real_file
                    }
                if options.check:
                    if not os.path.exists(real_file):
                        logging.warning('%s has no real file associated with it', file_name)

                file_time = trash_info_date(file_name)
                if not file_time:
                    #This happens when a trashinfo file is corrupted (issue #9)
                    logging.warning("Failed to read trash info for real file: %s", file_info['real_file'])
                    failures += 1
                    continue
                file_info['time'] = time.mktime(file_time)
                file_info['age_seconds'] = time.time() - file_info['time']
                file_info['age_days'] = int(math.floor(file_info['age_seconds']/(3600.0 * 24.0)))

                if options.stat or options.delete:
                    # calculating file size is relatively expensive; only do it if needed
                    file_size = get_consumed_size(file_name)
                    if os.path.exists(real_file):
                        if os.path.isdir(real_file):
                            logging.log(VERBOSE, 'Calculating size of directory %s (may take a long time)', real_file)
                        file_size += get_consumed_size(real_file)
                    file_info['size'] = file_size

                logging.log(VERBOSE, 'File %s', real_file)
                logging.log(VERBOSE, '    is %d days old, %d seconds, so it should %sbe removed',
                        file_info['age_days'],
                        file_info['age_seconds'],
                        ['not ',''][int(file_info['age_days'] > options.days)])
                logging.log(VERBOSE, '    deletion date was %s', time.strftime('%c', file_time))
                if options.stat:
                    logging.log(VERBOSE, '    consumes %s', fmt_bytes(file_info['size']))

                files.append(file_info)

        #Kill sorting: first will get purged first if --delete is enabled
        files.sort(key = lambda x: x['time'], reverse=True)


        #Push priority files (delete_first) to the top of the queue
        for pattern in reversed(options.delete_first):
            r = re.compile(pattern)
            moved_count = 0
            for i in range(len(files)):
                if r.match(os.path.basename(files[i]['real_file'])) != None:
                    file_info = files.pop(i)
                    logging.log(VERBOSE, 'Pushing %s to top of queue because it matches %s', os.path.basename(file_info['real_file']), pattern )
                    files.insert(moved_count, file_info)
                    moved_count += 1

        for file_info in files:
            if options.stat:
                total_size += file_info['size']
                total_files += 1

            if (options.days and file_info['age_days'] > options.days) or deleted_size < deleted_target:
                purge(options.trash_path, file_info['trash_info'], options.dryrun)
                if deleted_target or options.stat:
                    deleted_size += file_info['size']
                    deleted_files += 1

    if options.stat:
        logging.info('Trash statistics:')
        logging.info('  %6d entries at start (%s)', total_files, fmt_bytes(total_size))
        logging.info(' -%6d deleted (%s)', deleted_files, fmt_bytes(deleted_size))
        logging.info(' =%6d remaining (%s)', (total_files - deleted_files), fmt_bytes(total_size - deleted_size))
    return (0 if failures == 0 else 1)

if __name__ == '__main__':
    sys.exit(main(sys.argv))
