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

# Copyright (C) 2009-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.


'''
Retrieve data from the AUR via the RPC interface.

Threads are used to speed up retrieval. Results are cached in an SQLite3
database to avoid redundant queries when practical.

See https://aur.archlinux.org/rpc.php
and https://projects.archlinux.org/aurweb.git/plain/doc/rpc.txt
'''

from AUR.common import AUR_URL, DEFAULT_TTL
import argparse
import datetime
import json
import logging
import math
import os
import queue
import sqlite3
import sys
import tarfile
import threading
import time
import types
import urllib.parse
import urllib.request
import XCGF
import XCPF
import xdg.BaseDirectory



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

RPC_URL = AUR_URL + '/rpc.php'
RPC_VERSION = 4
RPC_VERSIONED_URL = '{}?v={:d}'.format(RPC_URL, RPC_VERSION)
RPC_MAX_ARGS = 500
CODING = 'UTF-8'



################################### RPC URL ####################################

def rpc_url(typ, args, post=False):
  '''
  Format the RPC URL.
  '''
  qs = [
    ('v', RPC_VERSION),
    ('type', typ)
  ]
  if typ == 'multiinfo':
    param = 'arg[]'
  else:
    param = 'arg'
  qs.extend((param, a) for a in args)
  qs_str = urllib.parse.urlencode(qs)
  if post:
    return RPC_URL, qs_str.encode(CODING)
  else:
    return '{}?{}'.format(RPC_URL, qs_str)



############################## DB List Functions ###############################

def clean_list_for_db(xs):
  '''
  Ensure that the items are non-empty strings.
  '''
  for x in xs:
    if not isinstance(x, str):
      x = str(x)
    x = x.strip()
    if x:
      yield x



def list_to_db_text(xs):
  '''
  Convert a list to a database text field.
  '''
  return '\n'.join(clean_list_for_db(xs))



def list_from_db_text(txt):
  '''
  Convert a database text field to a list.
  '''
  if txt:
    return txt.split('\n')
  else:
    return list()



##################################### AUR ######################################

def insert_full_urls(pkgs):
  '''
  Replace partial URLS with full URLS for each passed package.
  '''
  try:
    for pkg in pkgs:
      try:
        if not pkg['URLPath'].startswith(AUR_URL):
          pkg['URLPath'] = AUR_URL + pkg['URLPath']
      except KeyError:
        pass
      yield pkg
  except TypeError:
    pass



class AURError(Exception):
  '''
  Exception raised by AUR objects.
  '''

  def __init__(self, msg, error=None):
    self.msg = msg
    self.error = error



def aur_query(typ, args):
  '''
  Query the AUR RPC interface.
  '''

  if args is None:
    args = ('',)
  elif isinstance(args, str):
    args = (args,)
  else:
    args = tuple(a if a is not None else '' for a in args)
  return _aur_query_wrapper(typ, args)



def _aur_query_wrapper(typ, args):
  '''
  Internal function.
  '''
  url = rpc_url(typ, args)
  try:
    for r in _aur_query(typ, url):
      yield r
  except urllib.error.HTTPError as e:
    # URI Too Long
    if e.code == 414:
      logging.debug(str(e))
      n = len(args)
      i = math.ceil(n / 2)
      for r in _aur_query_wrapper(typ, args[:i]):
        yield r
      if i < n:
        for r in _aur_query_wrapper(typ, args[i:]):
          yield r
    else:
      logging.error(str(e))
      raise e



def _aur_query(typ, url, post_data=None):
  '''
  Internal function.
  '''
  logging.debug('retrieving {}'.format(url))
  with urllib.request.urlopen(url, data=post_data) as f:
    response = json.loads(f.read().decode(CODING))
  logging.debug(json.dumps(response, indent='  ', sort_keys=True))
  try:
    rtyp = response['type']
    if rtyp == typ:
      if response['resultcount'] == 0:
        logging.info('no results found')
        return
      results = response['results']
      for r in results:
        for field in AUR.LIST_FIELDS:
          try:
            if not isinstance(r[field], list):
              logging.error(
                'Unexpected type in returned "{}" field of {}: {} instead of list.'.format(
                  field,
                  r['Name'],
                  type(r[field])
                )
              )
          except KeyError:
            # Ensure that the field is present and can be iterated without
            # preliminary checks. The list fields are automatically created when
            # retrieving package information from the database (if present), so
            # this provides consistent behavior when the information is first
            # downloaded.
            r[field] = list()
        yield r
    elif rtyp == 'error':
      logging.error('RPC error {}'.format(response['results']))
    else:
      logging.error('Unexpected RPC return type {}'.format(rtyp))
  except KeyError:
    logging.error('Unexpected RPC error.')



def AURRetriever(request_queue, response_queue):
  '''
  Worker thread target function for retrieving data from the AUR.
  '''
  while True:
    typ, arg = request_queue.get()
    results = aur_query(typ, arg)
    response_queue.put(results)
    request_queue.task_done()



class AUR(object):
  '''
  Interact with the Arch Linux User Repository (AUR)

  Data retrieved via the RPC interface is cached temporarily in an SQLite3
  database to avoid unnecessary remote calls.
  '''

  INFO_TABLE = ('info', (
    ('Name',           'TEXT'),
    ('PackageBase',    'TEXT'),
    ('Version',        'TEXT'),
    ('Description',    'TEXT'),
    ('URL',            'TEXT'),
    ('URLPath',        'TEXT'),
    ('Maintainer',     'TEXT'),
    ('Depends',        'TEXT'),
    ('MakeDepends',    'TEXT'),
    ('CheckDepends',   'TEXT'),
    ('OptDepends',     'TEXT'),
    ('Conflicts',      'TEXT'),
    ('Provides',       'TEXT'),
    ('Replaces',       'TEXT'),
    ('Groups',         'TEXT'),
    ('License',        'TEXT'),
    ('NumVotes',       'INTEGER'),
    ('FirstSubmitted', 'INTEGER'),
    ('LastModified',   'INTEGER'),
    ('OutOfDate',      'INTEGER'),
    ('ID',             'INTEGER'),
    ('PackageBaseID',  'INTEGER'),
  ))
  SEARCH_TABLE = ('search', (
    ('Query', 'TEXT'),
    ('IDs',   'TEXT'),
  ))
  MSEARCH_TABLE = ('msearch', (
    ('Maintainer', 'TEXT'),
  ))
  TIMESTAMP_COLUMN = ('_timestamp', 'timestamp')

  LIST_FIELDS = (
    'Depends',
    'MakeDepends',
    'CheckDepends',
    'OptDepends',
    'Conflicts',
    'Provides',
    'Replaces',
    'Groups',
    'License',
  )

  NOCASE_FIELDS = (
    'Maintainer',
  )

  def __init__(
    self, database=None, ttl=DEFAULT_TTL, threads=1, clean=True, full_info=True
  ):
    '''
    Initialize the AUR object.

    database:
      SQLite3 database path. Use ":memory:" to avoid creating a cache file.
      default: $XDG_CACHE_HOME/AUR/RPC.sqlite3

    ttl:
      Time to live, i.e. how long to cache individual results in the database.

    threads:
      Number of threads to use when retrieving data from the AUR.
      default: 1

    clean:
      Clean the database to remove old entries and ensure integrity.

    full_info:
      If True, return (and cache) full package information for each package when
      performing searches and msearches. Previously the RPC interface returned
      full package information for all searches but this is no longer true and
      thus additional queries are required to get this information for some
      operations.
    '''

    if not database:
      cache_dir = xdg.BaseDirectory.save_cache_path('AUR')
      database = os.path.join(cache_dir, 'RPC.sqlite3')

    try:
      self.conn = sqlite3.connect(database, detect_types=sqlite3.PARSE_DECLTYPES|sqlite3.PARSE_COLNAMES)
    except sqlite3.OperationalError as e:
      if database != ':memory:':
        pdir = os.path.abspath(os.path.dirname(database))
        if not os.path.isdir(pdir):
          os.makedirs(pdir, exist_ok=True)
          self.conn = sqlite3.connect(database, detect_types=sqlite3.PARSE_DECLTYPES|sqlite3.PARSE_COLNAMES)
        else:
          self.die('failed to establish database connection [{:s}]'.format())
      else:
        raise e
    self.cursor = self.conn.cursor()
    self.ttl = ttl

    if clean:
      self.db_clean()
    else:
      self.db_initialize()

    if threads < 1:
      threads = 1
    self.threads = threads

    if self.threads > 1:
      self.threads_initialize()

    self.full_info = full_info




  ##############################################################################
  # Threading
  ##############################################################################

  # This can probably be removed once the AUR supports multiple arguments per
  # query.

  def threads_initialize(self):
    '''
    Initialize AURRetriever threads.
    '''

    self.request_queue = queue.Queue()
    self.response_queue = queue.Queue()

    for i in range(self.threads):
      t = threading.Thread(target=AURRetriever, args=(self.request_queue, self.response_queue))
      t.daemon = True
      t.start()



  ##############################################################################
  # Database Functions
  ##############################################################################

  def die(self, msg, error=None):
    '''
    Log errors and raise an AURError.
    '''
    logging.error(msg)
    raise AURError(msg, error)



  ##############################################################################
  # Database Functions
  ##############################################################################

  def db_execute(self, query, args=None):
    '''
    Execute the SQL query with optional arguments.
    '''
    c = self.cursor
    try:
      if args:
        c.execute(query, args)
        logging.debug(query)
        logging.debug(str(args))
      else:
        c.execute(query)
        logging.debug(query)
      self.conn.commit()
      return c
    except sqlite3.OperationalError as e:
      self.die("sqlite3.OperationalError {:s} (query: {:s})".format(e, query), error=e)



  def db_executemany(self, query, args=None):
    '''
    Execute the SQL query once for each list of arguments.
    '''

    if args is None:
      args = list()
    c = self.cursor
    try:
      logging.debug(query)
      for a in args:
        logging.debug(str(a))
      c.executemany(query, args)
      self.conn.commit()
      return c
    except sqlite3.OperationalError as e:
      self.die(str(e), error=e)



  def db_initialize(self):
    '''
    Initialize the database by creating the info table if necessary.
    '''

    for table in self.INFO_TABLE, self.SEARCH_TABLE, self.MSEARCH_TABLE:
      name = table[0]
      primary_col = table[1][0]
      other_cols = table[1][1:]

      query = 'CREATE TABLE IF NOT EXISTS "{}" ('.format(name)
      query += '"{0[0]}" {0[1]} PRIMARY KEY'.format(primary_col)
      for col in other_cols:
        query += ',"{0[0]}" {0[1]}'.format(col)
        if col[0] in self.NOCASE_FIELDS:
          query += ' COLLATE NOCASE'
      query += ',"{0[0]}" {0[1]}'.format(self.TIMESTAMP_COLUMN)
      query += ')'
      self.db_execute(query)



  def db_insert_info(self, pkgs):
    '''
    Insert package data into the info table.
    '''
    if pkgs:
      table, cols = self.INFO_TABLE
      cols += (self.TIMESTAMP_COLUMN,)
      now = datetime.datetime.utcnow()

      qs = ','.join('?' for x in cols)
      query = 'REPLACE INTO "{}" VALUES ({})'.format(table, qs)
      many_args = list()
      for pkg in pkgs:
        args = list()
        for k, t in cols[:-1]:
          try:
            if k in self.LIST_FIELDS:
              a = list_to_db_text(pkg[k])
            else:
              a = pkg[k]
          except KeyError:
            a = None
          args.append(a)
        args.append(now)
        many_args.append(args)
      self.db_executemany(query, many_args)



  def db_insert_msearch(self, maintainers):
    '''
    Track msearch times.

    Only the times are tracked because the query results contain package info,
    which is inserted in the info table.
    '''
    if maintainers:
      query = 'REPLACE INTO "{}" VALUES (?, ?)'.format(self.MSEARCH_TABLE[0])
      now = datetime.datetime.utcnow()
      args = [(m, now) for m in maintainers]
      self.db_executemany(query, args)



  def db_insert_search(self, args):
    '''
    Track search results.

    Only the matching package IDs and times are tracked because the query
    results contain package info, which is inserted in the info table.

    "args" should be a list of tuples, with each tuple containing the search
    term in the first position, and a list of matching package IDs in the second
    position, e.g. [(foo, [343, 565, 23443]), (bar, [93, 445])].
    '''
    if args:
      query = 'REPLACE INTO "{}" VALUES (?, ?, ?)'.format(self.SEARCH_TABLE[0])
      now = datetime.datetime.utcnow()
      args = [
        (
          term,
          list_to_db_text(ids),
          now
        ) for term, ids in args
      ]
      self.db_executemany(query, args)



  # Clean up the database.
  def db_clean(self, wipe=False):
    '''
    Clean up the database.

    This will update the columns of the info table if necessary and purge old
    records.

    Set wipe to True to reset all tables.
    '''
    try:
      c = self.cursor

      # Format: (table, ((name, type), (name, type), ...))
      for table, expected_cols in (self.INFO_TABLE, self.MSEARCH_TABLE, self.SEARCH_TABLE):
        expected_cols += (self.TIMESTAMP_COLUMN,)

        cols = c.execute('PRAGMA table_info("{}")'.format(table)).fetchall()
        if len(expected_cols) == len(cols):
          for (name, typ), meta in zip(expected_cols, cols):
            if name != meta[1] or typ != meta[2]:
              c.execute('DROP TABLE "{}"'.format(table))
              logging.debug('dropped {}'.format(table))
              break
          else:
            if wipe or (self.ttl is not None and self.ttl >= 0):
              if wipe:
                query = 'DELETE FROM "{}"'
              else:
                max_ttl = datetime.timedelta(seconds=self.ttl)
                arg = datetime.datetime.utcnow() - max_ttl
                query = 'DELETE FROM "{}" WHERE {}<?'.format(table, self.TIMESTAMP_COLUMN[0])
              deleted = c.execute(query, (arg,)).rowcount
              if deleted > 0:
                logging.debug('deleted {:d} row(s) from {}'.format(deleted, table))
        elif cols:
          c.execute('DROP TABLE "{}"'.format(table))
          logging.debug('dropped {}'.format(table))

      # Make sure any dropped tables are recreated.
      self.db_initialize()
      c.execute('VACUUM')
      self.conn.commit()
    except sqlite3.OperationalError as e:
      self.die( str(e) )



  def db_get_packages(self, query, args):
    '''
    Return package information from the database.
    '''
    c = self.db_execute(query, args)
    for row in c:
      pkg = {}
      for (name, typ), value in zip(self.INFO_TABLE[1], row):
        if value is None:
          if name in self.LIST_FIELDS:
            pkg[name] = list()
          else:
            pkg[name] = None
        else:
          if typ == 'INTEGER':
            pkg[name] = int(value)
          elif name in self.LIST_FIELDS:
            pkg[name] = list_from_db_text(value)
          else:
            pkg[name] = value
      yield pkg



  def db_get_matching_packages(self, field, matches, check_time=True):
    '''
    Return package information where the field matches one of the arguments.

    Expression tree limits are taken into consideration to split queries when
    necessary.
    '''
    for where, args in self.db_get_where_clauses(field, matches, check_time=check_time):
      query = 'SELECT * FROM "{}" WHERE {}'.format(self.INFO_TABLE[0], where)
      for pkg in self.db_get_packages(query, args):
        yield pkg



  def db_get_where_clauses(self, field, matches, check_time=True):
    '''
    Return WHERE clauses.

    Expression tree limits are taken into consideration to split queries when
    necessary.

    The results are returned as a generator.
    '''

    matches = tuple(matches)
    limit = 998
    while matches:
      ors = ','.join(['?' for x in matches[:limit]])
      where = "{} IN ({})".format(field, ors)
      args = matches[:limit]

      if check_time and self.ttl is not None and self.ttl >= 0:
        max_ttl = datetime.timedelta(seconds=self.ttl)
        t = datetime.datetime.utcnow() - max_ttl
        where = '"{}" >= ? AND ({})'.format(self.TIMESTAMP_COLUMN[0], where)
        args = (t,) + args

      yield (where, args)

      matches = matches[limit:]


  ##############################################################################
  # AUR RPC
  ##############################################################################

  def aur_format(self, pkg):
    '''
    Format package info fields to expected formats.
    '''

    for key, typ in self.INFO_TABLE[1]:
      if typ == 'INTEGER':
        try:
          pkg[key] = int(pkg[key])
        except (KeyError, TypeError):
          pass

    return pkg



  def aur_query(self, typ, args):
    '''
    Query the AUR.

    Results are returned using a generator.
    '''

    if isinstance(args, str) or args is None:
      args = (args, )

    if not args:
      return

    # Ensure unique arguments to avoid redundant queries.
    args = set(args)

    # With the advent of multiinfo, all results will not be lists.
    results = list()

    if typ == 'multiinfo' or typ == 'info':
      result = list(aur_query(typ, args))
      if result:
        results = result
        for r in results:
          try:
            args.remove(r['Name'])
          except KeyError:
            pass
        for a in sorted(args):
          logging.warn('{} query ({}): no results'.format(typ, a))

    elif self.threads > 1 and len(args) > 1:
      for arg in args:
        self.request_queue.put((typ, arg))
      for arg in args:
        results.extend(self.response_queue.get())
        self.response_queue.task_done()
    else:
      for arg in args:
        results.extend(aur_query(typ, arg))

    for pkg in results:
      yield self.aur_format(pkg)



  def aur_info(self, pkgnames):
    '''
    Query AUR for package information.

    Returns a generator of the matching packages.
    '''
    return self.aur_query('multiinfo', pkgnames)



  def aur_msearch(self, maintainers):
    '''
    Query AUR for packages information by maintainer.

    Returns a generator of the matching packages.
    '''
    return self.aur_query('msearch', maintainers)



  def aur_search(self, what):
    '''
    Query the AUR for packages matching the search string.

    Returns a generator of the matching packages.
    '''
    return self.aur_query('search', what)


  ##############################################################################
  # Accessibility Methods
  ##############################################################################

  # When first written, "search" and "msearch" returned full package information
  # that could be inserted into the database. This is no longer the case.
  def get(self, typ, args):
    '''
    Retrieve data.

    typ: the query type

    args: the query arguments

    Cached data will be returned when practical. The order of the results will
    vary and can not be relied on.

    Returns an iterator.
    '''

    if args is not None and not args:
      return

    try:
      args.__iter__
      if isinstance(args, str):
        args = (args,)
    except AttributeError:
      args = (args,)

    arg_set = set(args)

    if typ == 'info' or typ == 'multiinfo':
      # Check the info tables for the packages first.
      found = set()
      pkgs = list()

      # Determine the names of packages for which information must be retrieved.
      # by stripping version requirements from the names. Once the data has been
      # retrieved, the version requirements will be checked and only those
      # packages which satisfy all given requirements will be returned.

      ver_reqs = XCPF.collect_version_requirements(arg_set)

      names = set(ver_reqs)
      for pkg in self.db_get_matching_packages(self.INFO_TABLE[1][0][0], names):
        found.add(pkg['Name'])
        pkgs.append(pkg)

      # Retrieve whatever we didn't find from the server.
      not_found = names - found

      if not_found:
        new = list()
        for pkg in self.aur_info(not_found):
          if pkg:
            new.append(pkg)
        self.db_insert_info(new)
        pkgs.extend(new)

      for pkg in pkgs:
        if XCPF.satisfies_all_version_requirements(pkg['Version'], ver_reqs[pkg['Name']]):
          yield pkg



    elif typ == 'msearch':
      cached_maintainers = set()

      # Check the msearch table to see if msearches have been done previously
      # for the given arguments. If they have then we use the pkg info cached
      # in the info table because it will have been updated with the results
      # from the previous msearch.
      #
      # The package info in the table is only updated when the entry is older
      # than the ttl field. If the msearch entry is still valid then so is the
      # package entry. The maintainer field will therefore be present.
      for where, args in self.db_get_where_clauses(self.MSEARCH_TABLE[1][0][0], arg_set):
        query = 'SELECT * FROM "{}" WHERE {}'.format(self.MSEARCH_TABLE[0], where)
        c = self.db_execute(query, args)

        # If we find it then the cached results are still good.
        for row in c:
          cached_maintainers.add(row[0])

      # Retrieve cached pkg info if it's still valid.
      # check_time=False to avoid cases where enough time elapses after checking
      # the msearch table and before checking the info table that the info data
      # would be rejected. Without this, we could end up returning incomplete
      # sets for some maintainers. Of course, this may have changed during
      # the caching interval but that is less likely.
      if cached_maintainers:
        for where, args in self.db_get_where_clauses('Maintainer', cached_maintainers, check_time=False):
          query = 'SELECT * FROM "{}" WHERE {}'.format(self.INFO_TABLE[0], where)

          for pkg in self.db_get_packages(query, args):
            yield pkg

      # Retrieve whatever wasn't cached.
      uncached_maintainers = arg_set - cached_maintainers
      maintainers = set()
      for maintainer in uncached_maintainers:
        results = list(self.aur_msearch(maintainer))
        if results:
          # The results no longer contain the full information of the info
          # search and should not be cached.
#           self.db_insert_info(results)
#           for r in results:
#             yield r
          if self.full_info:
            info_args = list(r['Name'] for r in results)
            for r in self.get('multiinfo', info_args):
              yield r
            maintainers.add(maintainer)
          else:
            for r in results:
              yield r
      self.db_insert_msearch(maintainers)



    elif typ == 'search':
      # See msearch comments for explanations.
      cached_queries = set()
      ids = set()
      for where, args in self.db_get_where_clauses(self.SEARCH_TABLE[1][0][0], arg_set):
        query = 'SELECT * FROM "{}" WHERE {}'.format(self.SEARCH_TABLE[0], where)
        c = self.db_execute(query, args)

        for row in c:
          cached_queries.add(row[0])
          ids |= set(list_from_db_text(row[1]))

      if ids:
        for where, args in self.db_get_where_clauses('ID', ids, check_time=False):
          query = 'SELECT * FROM "{}" WHERE {}'.format(self.INFO_TABLE[0], where)

          for pkg in self.db_get_packages(query, args):
            yield pkg

      # Retrieve whatever wasn't cached.
      uncached_queries = arg_set - cached_queries
      # Misnomer, but analogous to msearch function above.
      retrieved_terms = list()
      for query in uncached_queries:
        matching_pkgs = list(self.aur_search(query))
        # The results no longer contain the full information of the info search
        # and should not be cached.
        #self.db_insert_info(matching_pkgs)
#         for mp in matching_pkgs:
#           yield mp
        if self.full_info:
          info_args = list(r['Name'] for r in matching_pkgs)
          for r in self.get('multiinfo', info_args):
            yield r
          retrieved_terms.append((query, [p['ID'] for p in matching_pkgs]))
        else:
          for r in matching_pkgs:
            yield r

      self.db_insert_search(retrieved_terms)



  def info(self, args):
    '''
    Retrieve package information.
    '''
    return self.get('info', args)


  def msearch(self, args):
    '''
    Retrieve package information for specific maintainers.
    '''
    return self.get('msearch', args)


  def search(self, args):
    '''
    Search for packages.
    '''
    return self.get('search', args)



################################### Download ###################################

def download_archives(output_dir, pkgs):
  '''
  Download the AUR files to the target directory.
  '''
  for pkg in insert_full_urls(pkgs):
    with urllib.request.urlopen(pkg['URLPath']) as f:
      logging.debug('extracting {} to {}'.format(pkg['URLPath'], output_dir))
      tarfile.open(mode='r|gz', fileobj=f).extractall(path=output_dir)



############################### User Interaction ###############################

def parse_args(args=None):
  '''
  Parse command-line arguments.

  If no arguments are passed then arguments are read from sys.argv.
  '''
  parser = argparse.ArgumentParser(
    description='Query the AUR RPC interface.',
    epilog='For msearches, pass an empty string (\'\') to search for orphans.'
  )
  parser.add_argument(
    'args', metavar='<arg>', nargs='+'
  )
  parser.add_argument(
    '-i', '--info', action='store_true',
    help='Query package information.'
  )
  parser.add_argument(
    '-m', '--msearch', action='store_true',
    help='Query package information by maintainer.'
  )
  parser.add_argument(
    '-s', '--search', action='store_true',
    help='Search the AUR.'
  )
  parser.add_argument(
    '--debug', action='store_true',
    help='Enable debugging.'
  )
  parser.add_argument(
    '--log', metavar='<path>',
    help='Log debugging information to <path>.'
  )
  parser.add_argument(
    '--ttl', metavar='<minutes>', type=int, default=(DEFAULT_TTL//60),
    help='Time-to-live of cached data (default: %(default)s)'
  )
  parser.add_argument(
    '--full-info', action='store_true',
    help='Return full information for searches and msearches.'
  )
  return parser.parse_args(args)



def print_query(args, typ='search', aur=None):
  '''
  Search the AUR and print matching package information.
  '''
  if not aur:
    aur = AUR()
  pkgs = list(aur.get(typ, args))
  for info in format_pkginfo(aur, pkgs):
    print(info + '\n')


def format_pkginfo(aur, pkgs):
  '''
  Format package information for display similarly to "pacman -Si".
  '''
  fields = [name for name, typ in aur.INFO_TABLE[1]]
  fields.insert(0, 'Repository')
  fields.insert(6, 'AURPage')
  w = max(map(len, fields)) + 1
  fmt = '{{:<{:d}s}}\t{{!s:<s}}\n'.format(w)

  for pkg in insert_full_urls(sorted(pkgs, key=lambda p: p['Name'])):
    info = ''
    pkg['Repository'] = 'AUR'
    pkg['AURPage'] = AUR_URL + '/packages.php?ID={:d}'.format(pkg['ID'])
    if pkg['OutOfDate'] is not None:
      pkg['OutOfDate'] = time.strftime(XCGF.DISPLAY_TIME_FORMAT, time.localtime(pkg['OutOfDate']))
    else:
      pkg['OutOfDate'] = ''
    for foo in ('FirstSubmitted', 'LastModified'):
      pkg[foo] = time.strftime(XCGF.DISPLAY_TIME_FORMAT, time.localtime(pkg[foo]))
    for f in fields:
      try:
        if f in aur.LIST_FIELDS:
          if pkg[f]:
            value = XCPF.format_pkginfo_list(
              pkg[f],
              per_line=(f in ('OptDepends',)),
              margin=w+1,
            )
          else:
            value = 'None'
        else:
          value = pkg[f]
        info += fmt.format(f + ':', value)
      except KeyError:
        pass
    yield info



def main(args=None):
  '''
  Parse command-line arguments and print query results to STDOUT.
  '''
  pargs = parse_args(args)


  if pargs.debug:
    log_level = logging.DEBUG
  else:
    log_level = None
  XCGF.configure_logging(level=log_level, log=pargs.log)

  ttl = max(pargs.ttl, 0) * 60
  aur = AUR(ttl=ttl, threads=1)
  if pargs.info:
    print_query(pargs.args, typ='info', aur=aur)
  else:
    aur.full_info = pargs.full_info
    if pargs.msearch:
      ms = list(m if m else None for m in pargs.args)
      print_query(ms, typ='msearch', aur=aur)
    else:
      print_query(pargs.args, typ='search', aur=aur)



def run_main(args=None):
  '''
  Run main() with exception handling.
  '''
  try:
    main(args)
  except (KeyboardInterrupt, BrokenPipeError):
    pass
  except AURError as e:
    sys.exit('error: {}'.format(e.msg))
  except urllib.error.URLError as e:
    sys.exit('URLError: {}'.format(e))



if __name__ == '__main__':
  run_main()
