#!/usr/bin/env python3
# -*- coding: utf8 -*-

# Copyright (C) 2012-2016 Xyne
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# (version 2) as published by the Free Software Foundation.
#
#
# 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.

"""
Powerpill is a wrapper around Pacman that uses pm2ml, aria2c and rsync to speed
up package downloads. It is a replacement for the long-ago deprecated Perl
version that became quite popular.

It supports preferential downloads from Pacserve to reduce bandwidth.
"""

# The code is a bit messy because I cobbled it together from parisync.
# I intend to clean it up when I have the time and proper motivation.

import collections
import glob
import json
import logging
import os
import pm2ml
import pyalpm
import Reflector
import subprocess
import sys
import urllib.parse
import XCGF
import XCPF

try:
  from ThreadedServers.Pacserve import search_pkgs as search_pacserve
except ImportError:
  def search_pacserve(pacserve_url, pkgnames):
    return None

################################### Globals ####################################

DB_EXT = '.db'
SIG_EXT = '.sig'
DB_LOCK_FILE = 'db.lck'
DB_LOCK_NAME = 'database'
CACHE_LOCK_FILE = 'cache.lck'
CACHE_LOCK_NAME = 'cache'

OFFICIAL_REPOSITORIES = Reflector.MirrorStatus.REPOSITORIES
POWERPILL_CONFIG = '/etc/powerpill/powerpill.json'
ARIA2_EXT = '.aria2'

# See the aria2c manual page for details.
ARIA2_DOWNLOAD_ERROR_EXIT_CODES = (0, 2, 3, 4, 5)
# See the rsync manual page for details.
RSYNC_DOWNLOAD_ERROR_EXIT_CODES = (2, 5, 10, 12, 23, 24, 30)

# Arguments that change the Pacman configuration file.
PACMAN_CONF_OPTS = (
  ('-b', '--dbpath', 'DBPath'),
  ('-r', '--root', 'RootDir'),
  ('--arch', 'Architecture'),
  ('--cachedir', 'CacheDir'),
  ('--gpgdir', 'GPGDir'),
  ('--logfile', 'LogFile'),
  ('--color', 'Color'),
)

# Parameterized Pacman arguments.
# `--ask` is undocumented but mentioned along with a support request here:
# https://bbs.archlinux.org/viewtopic.php?pid=1577363#p1577363
PACMAN_PARAM_OPTS = set((
  '-b', '--dbpath',
  '--arch',
  '--ask',
  '--cachedir',
  '--color',
  '--config',
  '--gpgdir',
  '--ignore',
  '--ignoregroup',
  '--logfile',
  '--print-format'
))

# Non-download Pacman sync operations.
PACMAN_OPS = set((
  '-c', '--clean',
  '-g', '--groups',
  '-i', '--info',
  '-l', '--list',
  '-p', '--print',
  '-s', '--search',
))

# Order must match string of short options in RECOGNIZED_PACMAN_SHORT_OPTIONS.
RECOGNIZED_PACMAN_OPTIONS = (
  'sync',
  'refresh',
  'sysupgrade',
  'downloadonly',
  'quiet',
  'verbose',
  'debug',
)



RECOGNIZED_PACMAN_SHORT_OPTIONS = dict(
  ('-' + x, '--' + y) for x,y in zip('Syuwqv', RECOGNIZED_PACMAN_OPTIONS)
)

POWERPILL_PARAM_OPTS = set((
  '--powerpill-config',
))



###################### Configuration and Argument Parsing ######################

def expand_recognized_pacman_short_options(args):
  '''
  Replace recognized Pacman short options with their long equivalents.
  '''
  for a in args:
    try:
      yield RECOGNIZED_PACMAN_SHORT_OPTIONS[a]
    except KeyError:
      yield a


def parse_args(args=None):
  """
  Parse (Pacman) command-line arguments and extract those that control
  Powerpill.
  """
  if args is None:
    args = sys.argv[1:]
  # Arguments set to None will default to the powerpill configuration file.
  pargs = {
    'pacman_config' : None,
    'powerpill_config' : POWERPILL_CONFIG,
    'powerpill_clean' : False,
    'aria2_config' : None,
    'help' : False,
    'pacman_config_options' : { 'CacheDir' : list() },
    'pm2ml_options' : list(),
    'options' : list(),
    'args' : list(),
    'other_operation' : False,
    'raw' : list(XCGF.filter_arguments(args, remove={
      '--powerpill-clean' : 0,
      '--powerpill-config' : 1,
    })),
  }
  for rpo in RECOGNIZED_PACMAN_OPTIONS:
    pargs[rpo] = 0
  argq = collections.deque(expand_recognized_pacman_short_options(XCGF.expand_short_args(args)))
  included_stdin = False
  while argq:
    arg = argq.popleft()

    if arg == '-':
      if not included_stdin:
        included_stdin = True
        pargs['args'].extend(XCPF.get_args_from_stdin())
      else:
        pargs['args'].append(arg)
      continue

    elif arg == '--':
      if not included_stdin:
        argq = XCPF.maybe_insert_args_from_stdin(argq)
      pargs['args'].extend(argq)
      break

    elif arg[:2] == '--' and arg[2:] in RECOGNIZED_PACMAN_OPTIONS:
      pargs[arg[2:]] += 1

    elif arg in ('-h', '--help'):
      pargs['help'] = True

    elif arg in ('--config', '--powerpill-config'):
      try:
        next_arg = argq.popleft()
      except IndexError:
        raise ArgumentError('no file path given for "{}"'.format(arg))
      else:
        if arg == '--config':
          k = 'pacman_config'
        else:
          k = 'powerpill_config'
        pargs[k] = os.path.abspath(next_arg)

    elif arg == '--powerpill-clean':
      pargs['powerpill_clean'] = True

    elif arg[0] == '-':
      # (short argument if present, long argument, internal name)
      for conf_opt in PACMAN_CONF_OPTS:
        if arg in conf_opt[:-1]:
          opt = conf_opt[-1]
          try:
            val = argq.popleft()
          except IndexError:
            raise ArgumentError('no argument for option {}'.format(arg))
          if opt == 'CacheDir':
            pargs['pacman_config_options'][opt].append(val)
          else:
            pargs['pacman_config_options'][opt] = val
          break
      else:
        if arg in PACMAN_OPS:
          pargs['other_operation'] = True
        if arg in pm2ml.PACMAN_OPTIONS: # \
        #and arg not in ('--ignore', '--ignoregroup'):
          name = 'pm2ml_options'
        else:
          name = 'options'
        pargs[name].append(arg)
        if arg in PACMAN_PARAM_OPTS:
          try:
            next_arg = argq.popleft()
          except IndexError:
#             if arg in PACMAN_OPS:
#               pass
#             else:
            raise ArgumentError('no argument for pacman option {}'.format(arg))
          else:
            pargs[name].append(next_arg)
    else:
      pargs['args'].append(arg)
  return pargs



def unparse_args(pargs):
  """
  Convert parsed arguments to a list of Pacman arguments.
  """
  # All of the argument parsing assumes a sync operation and so overlapping
  # short arguments are expanded to their long sync arguments, which breaks
  # other operations. For example, "-u" will be expanded to "--sysupgrade" even
  # for a query operation for which the correct expansion would be "--upgrades".
  # This is a workaround until I refactor the current argument parsing mess.
  if not pargs['sync']:
    for a in pargs['raw']:
      yield a
    return
  # Map configuration file parameters to command-line options.
  pacman_opts = dict()
  for opt in PACMAN_CONF_OPTS:
    pacman_opts[opt[-1]] = opt[-2]

  for o in RECOGNIZED_PACMAN_OPTIONS:
    for i in range(pargs[o]):
      yield '--' + o
  if pargs['pacman_config']:
    yield '--config'
    yield pargs['pacman_config']

  for k, v in pargs['pacman_config_options'].items():
    opt = pacman_opts[k]
    if k == 'CacheDir':
      for d in v:
        yield opt
        yield d
    else:
      yield opt
      yield v

  if pargs['help']:
    yield '--help'
  for whatever in pargs['pm2ml_options']:
    yield whatever
  for whatever in pargs['options']:
    yield whatever
  for whatever in pargs['args']:
    yield whatever



def display_help():
  """
  Print the help message.
  """
  name = os.path.basename(sys.argv[0])
  title = name.title()

  print('''USAGE
  {name} [{name} options] [pacman args]

OPTIONS
  {title} should accept the same arguments as Pacman, e.g.

      {name} -Syu

  See "pacman --help" for further help.

  The following additional arguments are supported:

    --powerpill-config <path>
        The path to a Powerpill configuration file.
        Default: {powerpill_config}

    --powerpill-clean
        Clean up leftover .aria2 files from an unrecoverable download. Use this
        option to resolve aria2c length mismatch errors.

'''.format(
      name=name,
      title=title,
      powerpill_config=POWERPILL_CONFIG
    )
  )




def no_operation(pargs):
  return (not pargs['sync'] and not pargs['other_operation'])

def no_download(pargs):
  return (
    pargs['other_operation'] or not (
      pargs['sync'] and (
        (
          pargs['sysupgrade'] or pargs['args']
        ) or pargs['refresh']
      )
    )
  )

def info_operation(pargs):
  return '-i' in pargs['options'] or '--info' in pargs['options']

def search_operation(pargs):
  return '-s' in pargs['options'] or '--search' in pargs['options']

def list_operation(pargs):
  return '-l' in pargs['options'] or '--list' in pargs['options']

def search_operation(pargs):
  return '-s' in pargs['options'] or '--search' in pargs['options']

def proceed_to_installation(pargs):
  return pargs['downloadonly'] == 0 and (pargs['sysupgrade'] > 0 or pargs['args'])

def query_upgrades(pargs):
  return ('-Q' in pargs['raw'] or '--query' in pargs['raw']) and \
         ('-u' in pargs['raw'] or '--upgrades' in pargs['raw'])



def get_pacman_conf(pargs, powerpill_conf):
  if not pargs['pacman_config']:
    pargs['pacman_config'] = powerpill_conf.get('pacman/config')

  try:
    pacman_conf = pm2ml.PacmanConfig(pargs['pacman_config'])
  except FileNotFoundError as e:
    logging.error('failed to load {} [{}]'.format(pargs['pacman_config'], e))
    return None

  # RootDir affects other options, so it must be handled first.
  try:
    rootdir = pargs['pacman_config_options']['RootDir']
    for opt in ('DBPath', 'LogFile'):
      pacman_conf.options[opt] = os.path.join(rootdir, pacman_conf.options[opt][1:])
  except KeyError:
    pass

  for k, v in pargs['pacman_config_options'].items():
    if k != 'RootDir' and v:
      pacman_conf.options[k] = v

  return pacman_conf


################################## Exceptions ##################################

class PowerpillError(XCPF.XcpfError):
  '''Parent class of all custom exceptions raised by this module.'''
  def __str__(self):
    return '{}: {}'.format(self.__class__.__name__, self.msg)



class ConfigError(PowerpillError):
  '''Exceptions raised by the Config class.'''
  pass



class ArgumentError(PowerpillError):
  '''Exceptions raised by the Config class.'''
  pass



################################# Config Class #################################

class Config(object):
  """
  JSON object wrapper for implementing a configuration file.
  """
  DEFAULTS = {
    'aria2' : {
      'path' : '/usr/bin/aria2c',
    },
    'pacman' : {
      'path' : '/usr/bin/pacman',
      'config' : '/etc/pacman.conf',
    },
    'powerpill' : {
      'select' : True,
      'reflect databases' : False,
    },
    'rsync' : {
      'rsync' : '/usr/bin/rsync',
    },
  }
  def __init__(self, path=None):
    if path is None:
      self.obj = dict()
      self.path = None
    else:
      self.load(path)

  def __str__(self):
    return json.dumps(self.obj, indent='  ', sort_keys=True)


  def load(self, path):
    """
    Load the configuration file.
    """
    try:
      self.obj = XCGF.load_json(path)
    except ValueError as e:
      raise ConfigError(
      '''failed to load {} [{}]
Check the file for syntax errors.'''.format(path, e),
      error=e)
    except FileNotFoundError as e:
      raise ConfigError(str(e), error=e)
    self.path = path

  def save(self, path=None):
    """
    Save the configuration file.
    """
    if path is None:
      path = self.path
    if path is None:
      raise ConfigError('no path given for saving configuration file')
    with open(path, 'downloadonly') as f:
      json.dump(self.obj, f, indent='  ', sort_keys=True)

  def get(self, args):
    """
    Return the requested entry or None if it does not exist.
    """
    obj = self.obj
    args = args.split('/')
    for arg in args:
      try:
        obj = obj[arg]
      except KeyError:
        obj = None
        break
    # Get default if not found.
    if obj is None:
      obj = self.DEFAULTS
      for arg in args:
        try:
          obj = obj[arg]
        except KeyError:
          obj = None
          break
    return obj

  def set(self, args, value):
    """
    Set the requested entry to the given value.
    """
    obj = self.obj
    args = args.split('/')
    for arg in args[:-1]:
      try:
        obj = obj[arg]
      except KeyError:
        obj[arg] = dict()
        obj = obj[arg]
    obj[args[-1]] = value



################################## Powerpill ###################################

class Powerpill(object):

  def __init__(self, conf, pacman_conf, pargs):
    self.conf = conf
    self.pacman_conf = pacman_conf
    self.pargs = pargs
    self.db_lock = None


  def download_queue_to_rsync_cmd(
    self,
    rsync_server,
    queue,
    output_dir=None,
  ):
    """
    Convert a download queue to an rsync command list.
    """
    cmd = [self.conf.get('rsync/path'), '-aL'] + self.conf.get('rsync/args')

    url = urllib.parse.urlparse(rsync_server)
    host = url.netloc
    # [1:] to remove initial slash
    path = url.path[1:].replace('$arch', self.pacman_conf.options['Architecture'])

    host_added = False

    for db, sigs in queue.dbs:
      db_path = '::' + os.path.join(path.replace('$repo', db.name), db.name + DB_EXT)
      if not host_added:
        cmd.append(host + db_path)
        host_added = True
      else:
        cmd.append(db_path)
      if sigs:
        cmd.append(db_path + SIG_EXT)

    for pkg, urls, sigs in queue.sync_pkgs:
      pkg_path = '::' + os.path.join(path.replace('$repo', pkg.db.name), pkg.filename)
      if not host_added:
        cmd.append(host + pkg_path)
        host_added = True
      else:
        cmd.append(pkg_path)
      if sigs:
        cmd.append(pkg_path + SIG_EXT)

    if not output_dir:
      output_dir = '.'
    cmd.append(output_dir)
    return cmd



  def get_pm2ml_pkg_download_args(self, dpath=None, ignore=True):
    '''
    Iterate over pm2ml options for downloading packages.
    '''
    if dpath:
      yield '-o'
      yield dpath
    for p in ('sysupgrade', 'verbose', 'debug'):
      for i in range(self.pargs[p]):
        yield '--' + p
    for x in  self.pargs['pm2ml_options']:
      yield x
    if ignore:
      for pkg in self.pacman_conf.options['IgnorePkg']:
        yield '--ignore'
        yield pkg
      for grp in self.pacman_conf.options['IgnoreGroup']:
        yield '--ignoregroup'
        yield grp
    if self.conf.get('powerpill/select'):
      yield '--select'
    for x in self.pargs['args']:
      yield x



  def download(self, pm2ml_args, dbs=False, force=False):
    """
    Download files specified by pm2ml arguments.
    """
#     for pkg in self.pacman_conf.options['IgnorePkg']:
#       pm2ml_args.extend(('--ignore', pkg))
#     for grp in self.pacman_conf.options['IgnoreGroup']:
#       pm2ml_args.extend(('--ignoregroup', grp))
#     if self.conf.get('powerpill/select'):
#       pm2ml_args.append('--select')
    if dbs:
#       pm2ml_args.append('--preference')
      reflect = self.conf.get('powerpill/reflect databases')
    else:
      reflect = True
    # This must be added last.
    if reflect and self.conf.get('reflector/args'):
      pm2ml_args += ['--reflector'] + self.conf.get('reflector/args')
    pm2ml_pargs = pm2ml.parse_args(pm2ml_args)
    handle = self.pacman_conf.initialize_alpm()
    sync_pkgs, sync_deps, \
      aur_pkgs, aur_deps, \
      not_found, unknown_deps, orphans = \
        pm2ml.resolve_targets_from_arguments(handle, self.pacman_conf, pm2ml_pargs)

    download_queue = \
      pm2ml.build_download_queue(
        handle,
        self.pacman_conf,
        pm2ml_pargs,
        sync_pkgs | sync_deps
      )

    rsync_queue = pm2ml.DownloadQueue()
    metalink_queue = pm2ml.DownloadQueue()

    output_dir = pm2ml_pargs.output_dir
    if output_dir:
      # A FileExistsError will be raised even with exists_ok=True if the mode
      # does not match the umask-masked mode.
      try:
        os.makedirs(output_dir, exist_ok=True)
      except FileExistsError:
        pass
    pushd = XCGF.Pushd(output_dir)

    rsync_servers = self.conf.get('rsync/servers')
    pacserve_server = self.conf.get('pacserve/server')

    if dbs:
      for db, sigs in download_queue.dbs:
        is_local = False
        for server in db.servers:
          if server[:7] == 'file://':
            db_name = db.name + DB_EXT
            local_path = os.path.join(server[7:], db_name)
            output_path = os.path.join(output_dir, db_name)
            try:
              XCGF.copy_file_and_maybe_sig(local_path, output_path, sig=sigs)
            except FileNotFoundError:
              continue
            else:
              is_local=True
              break
        if is_local:
          continue
        if rsync_servers and db.name in OFFICIAL_REPOSITORIES:
          rsync_queue.add_db(db, sigs)
        else:
          metalink_queue.add_db(db, sigs)

    else:
      queued = dict()
      for pkg, urls, sigs in download_queue.sync_pkgs:
        is_local = False
        for server in urls:
          if server[:7] == 'file://':
            local_path = server[7:]
            output_path = os.path.join(output_dir, pkg.filename)
            try:
              XCGF.copy_file_and_maybe_sig(local_path, output_path, sig=sigs)
            except FileNotFoundError:
              continue
            else:
              is_local=True
              break
        if not is_local:
          queued[pkg.filename] = pkg

      found = None
      if pacserve_server:
        queued_names = sorted(queued)
        found = search_pacserve(pacserve_server, queued_names)
        # The local pacserve server likely points to the same cache
        # directory. The incoming file would be written to the same file
        # that Pacserve is reading, thus truncating the file. Avoid this
        # by skipping the file if it has a valid checksum, otherwise remove
        # it and requery Pacserve.
        if found is not None:
          unlinked = False
          for filename, found_url in found.items():
            if found_url.startswith(pacserve_server):
              db_sha256sum = queued[filename].sha256sum
              for d in self.pacman_conf.options['CacheDir']:
                file_cachepath = os.path.join(d, filename)
                cache_sha256 = XCGF.get_checksum(file_cachepath, typ='sha256')
                if cache_sha256 is None:
                  continue
                elif cache_sha256 != db_sha256sum:
                  os.unlink(file_cachepath)
                  unlinked = True
                  continue
                else:
                  break
          if unlinked:
            found = search_pacserve(pacserve_server, queued_names)

    for pkg, urls, sigs in download_queue.sync_pkgs:
      use_pacserve = True
      try:
        urls = [found[pkg.filename]]
      except (KeyError, TypeError):
        use_pacserve = False

      if not use_pacserve \
      and not self.conf.get('rsync/db only') \
      and rsync_servers \
      and pkg.db.name in OFFICIAL_REPOSITORIES:
        rsync_queue.add_sync_pkg(pkg, urls, sigs)
      else:
        metalink_queue.add_sync_pkg(pkg, urls, sigs)

    metalink_queue.aur_pkgs = download_queue.aur_pkgs

    if metalink_queue:
      metalink = str(pm2ml.download_queue_to_metalink(metalink_queue, set_preference=dbs)).encode()
      aria2_cmd = [
        self.conf.get('aria2/path'),
        '--metalink-file=-',
      ] + self.conf.get('aria2/args')
      if dbs:
        aria2_cmd += [
          '--split=1',
        ]
      if force:
        aria2_cmd += [
          '--continue=false',
          '--remove-control-file=true',
          '--allow-overwrite=true',
          '--conditional-get=false',
        ]
      elif dbs:
        aria2_cmd += [
          '--continue=false',
          '--remove-control-file=true',
          '--allow-overwrite=true',
          '--conditional-get=true',
        ]
      with pushd:
        aria2c_p = subprocess.Popen(aria2_cmd, stdin=subprocess.PIPE)
        aria2c_p.communicate(input=metalink)

    if rsync_queue:
      for rsync_server in rsync_servers:
        rsync_cmd = self.download_queue_to_rsync_cmd(
          rsync_server,
          rsync_queue,
          output_dir=output_dir
        )
        with pushd:
          rsync_p = subprocess.Popen(rsync_cmd)
          e = rsync_p.wait()
        if e == 0:
          # Success
          break
        elif e in RSYNC_DOWNLOAD_ERROR_EXIT_CODES:
          # Server error, try another one.
          continue
        else:
          raise PowerpillError('rsync exited with {:d}\n> server: {}'.format(e, rsync_server))
      else:
        # Fall back on Aria2
        metalink2 = str(pm2ml.download_queue_to_metalink(rsync_queue)).encode()
        aria2_cmd2 = [
          self.conf.get('aria2/path'),
          '--metalink-file=-',
        ] + self.conf.get('aria2/args')
        with pushd:
          aria2c_p2 = subprocess.Popen(aria2_cmd2, stdin=subprocess.PIPE)
          aria2c_p2.communicate(input=metalink2)
          e = aria2c_p2.wait()
        if e not in ARIA2_DOWNLOAD_ERROR_EXIT_CODES:
          raise PowerpillError('aria2c exited with {:d}'.format(e))


    if metalink_queue:
      e = aria2c_p.wait()
      if e not in ARIA2_DOWNLOAD_ERROR_EXIT_CODES:
        raise PowerpillError('aria2c exited with {:d}'.format(e))




  def run_pacman(self, args=None):
    """
    Run Pacman (or equivalent) with the given arguments.
    """
    if args is None:
      args = list(unparse_args(self.pargs))
    return subprocess.call([self.conf.get('pacman/path')] + args)



##################################### Main #####################################

def get_cleaning_targets(pacman_conf):
  yield os.path.join(pacman_conf.options['DBPath'], 'sync'), DB_LOCK_FILE
  for cachedir in pacman_conf.options['CacheDir']:
    yield cachedir, CACHE_LOCK_FILE



def clean(cleaning_targets):
  '''
  Clean up leftover download files in the sync database and package cache.
  '''
  for dpath, lockname in cleaning_targets:
    lockfile = os.path.join(dpath, lockname)
    lock = XCGF.Lockfile(lockfile, CACHE_LOCK_NAME)
    logging.info('cleaning {}'.format(dpath))
    with lock:
      for a in glob.iglob(os.path.join(dpath, '*' + ARIA2_EXT)):
        try:
          os.unlink(a)
        except FileNotFoundError:
          pass
        except IOError as e:
          logging.error('failed to remove {} [{}]'.format(path, e))
          raise e
        else:
          logging.debug('removed {}'.format(path))
  logging.info('cleaning complete')



def refresh_databases(powerpill):
  '''
  Download Pacman sync databases.
  '''
  pacman_conf = powerpill.pacman_conf
  sync_dir = os.path.join(pacman_conf.options['DBPath'], 'sync')
  pm2ml_args = ['-yso', sync_dir]
  pm2ml_args.extend(('--' + p for p in ('verbose', 'debug') if powerpill.pargs[p] > 0))
  pm2ml_args.extend(powerpill.pargs['pm2ml_options'])
  db_lockfile = os.path.join(pacman_conf.options['DBPath'], DB_LOCK_FILE)
  db_lock = XCGF.Lockfile(db_lockfile, DB_LOCK_NAME)
  with db_lock:
    powerpill.download(pm2ml_args, dbs=True, force=(powerpill.pargs['refresh'] > 1))



def download_packages(powerpill):
  '''
  Download files to cache.
  '''
  pacman_conf = powerpill.pacman_conf
  cachedir = pacman_conf.options['CacheDir'][0]
  pm2ml_args = list(powerpill.get_pm2ml_pkg_download_args(dpath=cachedir))
  cache_lockfile = os.path.join(cachedir, CACHE_LOCK_FILE)
  cache_lock = XCGF.Lockfile(cache_lockfile, CACHE_LOCK_NAME)
  with cache_lock:
    powerpill.download(pm2ml_args)



def configure_logging(pargs, quiet=False):
  if quiet:
    level = logging.ERROR
  elif pargs['debug']:
    level = logging.DEBUG
  elif pargs['verbose']:
    level = logging.INFO
  elif pargs['quiet']:
    level = logging.ERROR
  else:
    level = None

  XCGF.configure_logging(level=level)



def main(args=None):
  pargs = parse_args(args)

  if pargs['help']:
    display_help()
    return 0

  configure_logging(pargs)

  # Parse this first to get the Pacman config path.
  powerpill_conf = Config(pargs['powerpill_config'])
  pacman_conf = get_pacman_conf(pargs, powerpill_conf)
  powerpill = Powerpill(powerpill_conf, pacman_conf, pargs)

  # Clean up before doing anything else.
  if pargs['powerpill_clean']:
    clean(get_cleaning_targets(pacman_conf))
    if no_operation(pargs):
      return 0

  if not pargs['sync']:
    return powerpill.run_pacman()

  if pargs['refresh'] > 0:
    refresh_databases(powerpill)
    pargs['refresh'] = 0

  # Jump straight to Pacman if the operation does not involve a download.
  if no_download(pargs):
    if pargs['other_operation']:
      return powerpill.run_pacman()
    else:
      return 0

  if pargs['sysupgrade'] > 0 or pargs['args']:
    download_packages(powerpill)

  if proceed_to_installation(pargs):
    return powerpill.run_pacman()

  return 0


def run_main(args=None):
  try:
    return main(args)
  except (KeyboardInterrupt, BrokenPipeError):
    pass
  except (PowerpillError, XCPF.XcpfError, pyalpm.error, PermissionError) as e:
    return e



if __name__ == '__main__':
  sys.exit(run_main())
