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

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

import errno
import itertools
import json
import logging
import MemoizeDB
import os
import platform
import pyalpm
import pycman
import re
import sqlite3
import subprocess
import sys
import urllib.error
import urllib.request
import XCGF
import xdg.BaseDirectory



################################## Constants ###################################

NAME = 'XCGF'
PKGINFO_DBNAME = 'pkginfo.sqlite3'

# Variant of ISO 8601 (2015-10-05T09:23:04.883Z)
ARCHLINUX_ORG_JSON_TIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ'


ARCHLINUX_ORG_JSON_URL = 'https://www.archlinux.org/packages/{repo}/{arch}/{pkgname}/json/'

ARCHLINUX_ORG_ABS_RSYNC_HOST = 'rsync.archlinux.org'
ARCHLINUX_ORG_ABS_RSYNC_PATH = '::abs/{arch}/{repo}/'

ARCHLINUX_ORG_ABS_GIT_HOST = 'https://projects.archlinux.org'
ARCHLINUX_ORG_ABS_GIT_TRUNK = '/svntogit/{gitrepo}.git/plain/{pkgname}/trunk'

# Tuple elements:
# * repo name
# * repo architecture
# * is testing repo
# * Git repo name
ARCHLINUX_OFFICIAL_REPOS = (
  ('testing', 'any', True, 'packages'),
  ('core', 'any', False, 'packages'),
  ('extra', 'any', False, 'packages'),
  ('community-testing', 'any', True, 'community'),
  ('community', 'any', False, 'community'),
  ('multilib-testing', 'x86_64', True, 'community'),
  ('multilib', 'x86_64', False, 'community'),
)

ABS_GIT_REPOS = ('packages', 'community')



################################### Caching ####################################

def cachepath(what):
  '''
  Create a standard cache directory for this module and return a full path to
  the given subpath. Use this to re-use cached data across different scripts.
  '''
  cachedir = xdg.BaseDirectory.save_cache_path(NAME)
  return os.path.join(cachedir, what)


def pkginfo_dbpath():
  '''
  Return a standard path for the cached package info.
  '''
  return cachepath(PKGINFO_DBNAME)



################################ PkgInfoHandler ################################

class OfficialPkgInfo(object):
  '''
  A class to handle package information retrieval. It provides optional
  transparent caching of the remote data.
  '''
  def __init__(
    self,
    ttl=0,
    arch=None,
    testing=False,
    dbpath=None,
    mdb=None,
    handle=None,
    config=None,
    dbs=None,
    log_level=logging.DEBUG,
    resolve_pkgbases=False
  ):
    self.log_level=log_level
    self.resolve_pkgbases = resolve_pkgbases

    self.ttl = ttl
    if arch is None:
      arch = platform.machine()
    self.arch = arch
    self.testing = testing

    self.config = config
    if handle is None and config is not None:
      handle = pycman.config.init_with_config(config)
    self.handle = handle
    if dbs is None and handle is not None:
      dbs = handle.get_syncdbs()
    self.dbs = dbs

    if ttl > 0 or mdb is not None:
      if mdb is None:
        if dbpath is None:
          dbpath = pkginfo_dbpath()
        glue = {
          'request' : (XCGF.text_from_url, (('data', 'TEXT'),), ttl)
        }
        conn = sqlite3.connect(
          dbpath,
          detect_types=(sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES)
        )
        mdb = MemoizeDB.MemoizeDB(conn, glue)
        mdb.db_initialize()

      self.mdb = mdb

      def memoized_archlinux_org_pkg_info(repo, arch, pkgname):
        url = archlinux_org_pkg_info_url(repo, arch, pkgname)
        txt = mdb.get_one('request', url)
        return json.loads(txt)

      self.retrieve_pkginfo = memoized_archlinux_org_pkg_info

    else:
      self.retrieve_pkginfo = archlinux_org_pkg_info



  def pkginfo_by_pkgname(self, pkgname):
    '''
    Retrieve package information by pkgname. This will search through the
    official repos until the package is found or the repos are exhausted.
    '''
    for repo, a, testing, gr in ARCHLINUX_OFFICIAL_REPOS:
      if not testing and (a == 'any' or a == self.arch):
        try:
          return self.retrieve_pkginfo(repo, self.arch, pkgname)
        except urllib.error.HTTPError as e:
          if e.code == 404:
            continue
          else:
            raise e
    return None



  def pkginfo_by_pyalpm_package(self, pkg):
    '''
    Retrieve package information for an instance of pyalpm.Package.
    '''
    try:
      return self.retrieve_pkginfo(pkg.db.name, pkg.arch, pkg.name)
    except urllib.error.HTTPError as e:
      if e.code == 404:
        return None
      else:
        raise e



  def pkginfo_by_pkgname_or_pyalpm_package(self, pkg):
    '''
    Retrieve package information by pkgname or pyalpm.Package.
    '''
    if isinstance(pkg, pyalpm.Package):
      return self.pkginfo_by_pyalpm_package(pkg)
    else:
      p = self.pyalpm_package(pkg)
      if p is None:
        return self.pkginfo_by_pkgname(pkg)
      else:
        return self.pkginfo_by_pyalpm_package(p)



  def pyalpm_package(self, pkgname):
    '''
    Search databases for a package name.
    '''
    if isinstance(pkgname, pyalpm.Package):
      return pkgname
    elif not self.dbs:
      return None
    else:
      return find_pkgname(self.dbs, pkgname)



  def pyalpm_package_and_pkginfo(self, pkgname):
    '''
    Attempt to retrieve the pyalpm.Package and the package information.
    '''
    pkg = self.pyalpm_package(pkgname)
    if pkg is None:
      pkginfo = self.pkginfo_by_pkgname(pkgname)
    else:
      pkginfo = self.pkginfo_by_pyalpm_package(pkg)
    return pkg, pkginfo



  def map_to_pkgbases(self, pkgs):
    '''
    Iterate over tuples mapping pkgnames to pkgbases. If the pkginfo could not
    be found, then the pkgbase will be None.
    '''
    for p in pkgs:
      pkginfo = self.pkginfo_by_pkgname_or_pyalpm_package(p)
      try:
        yield p, pkginfo['base']
      except KeyError:
        try:
          yield  p, None
        except AttributeError:
          yield p, None



  def map_to_pkgname_pkgbase_repo(self, pkgname):
    '''
    Attempt to map a pkgname or pyalpm.Package to a pkgname, repo and pkgbase.

    Use this to collect package bases for ABS retrieval.
    '''
    pkg = self.pyalpm_package(pkgname)
    if pkg is None:
      name = pkgname
      pkginfo = self.pkginfo_by_pkgname(pkgname)
      try:
        base = pkginfo['pkgbase']
      except (TypeError, KeyError):
        base = None
      try:
        repo = pkginfo['repo']
      except (TypeError, KeyError):
        repo = None
    else:
      name = pkg.name
      repo = pkg.db.name
      pkginfo = self.pkginfo_by_pyalpm_package(pkg)
      try:
        base = pkginfo['pkgbase']
      except (TypeError, KeyError):
        base = None
    return name, repo, base



  def collect_pkgbases_by_repo(self, pkgnames):
    '''
    Attempt to map packages to package bases and organize them by repo.
    '''
    repos = dict()
    pkgbases = dict()
    if self.resolve_pkgbases:
      for name, repo, base in (
        self.map_to_pkgname_pkgbase_repo(p) for p in pkgnames
      ):
        try:
          pkgbases[base].add(name)
        except KeyError:
          pkgbases[base] = set((name,))

        if base is not None and name != base:
          logging.log(self.log_level, 'mapped {} to {} in {}'.format(
            name, base, repo
          ))
        if base is None:
          base = name
        try:
          repos[repo].add(base)
        except KeyError:
          repos[repo] = set((base,))
    else:
      repos[None] = set(
        p.name if isinstance(p, pyalpm.Package) else p for p in pkgnames
      )
    return repos, pkgbases



  def retrieve_abs_via_git(self, output_dir, pkgnames):
    '''
    Retrieve the requested files from ABS via the online Git interface if
    possible. Iterates over the found packages and their URLs.
    '''
    if self.resolve_pkgbases:
      repos, pkgbase_map = self.collect_pkgbases_by_repo(pkgnames)
    else:
      repos = {
        None : set(pkgnames)
      }
      pkgbase_map = dict()
    for repo, pkgbases in repos.items():
      for pkgbase in pkgbases:
        url = retrieve_abs_via_git(output_dir, pkgbase, repo=repo)
        if url:
          try:
            for pkgname in pkgbase_map[pkgbase]:
              yield pkgname, url
          except KeyError:
            yield pkgbase, url



  def get_blind_abs_rsync_paths(self, repos=None):
    '''
    Get a list of ABS rsync paths for the given architecture and repos.
    '''
    for repo, a, is_testing, foo in ARCHLINUX_OFFICIAL_REPOS:
      if repos and repo not in repos:
        continue
      elif is_testing and not self.testing:
        continue
      elif a == 'any':
        yield ARCHLINUX_ORG_ABS_RSYNC_PATH.format(arch=a, repo=repo)
        if self.arch != a:
          yield ARCHLINUX_ORG_ABS_RSYNC_PATH.format(arch=self.arch, repo=repo)
      elif a == self.arch:
        yield ARCHLINUX_ORG_ABS_RSYNC_PATH.format(arch=a, repo=repo)



#   def get_abs_rsync_paths(self, repos):
#     '''
#     Get a list of ABS rsync paths for the given architecture and repos.
#     '''
#     for repo, pkgbases in repos.items():
#       for pkgbase in pkgbases:
#         r = ARCHLINUX_ORG_ABS_RSYNC_PATH.format(arch=self.arch, repo=repo)
#         yield '{}{}/'.format(r, pkgbase)



  def retrieve_abs_via_rsync(
    self,
    output_dir,
    pkgnames,
    rsync_path='rsync',
  ):
    '''
    Retrieve the requested files from ABS via rsync.
    '''

    cmd = [rsync_path, '-rt']

    repos, pkgbase_map = self.collect_pkgbases_by_repo( pkgnames)
    pkgbases = set()
    for pbs in repos.values():
      pkgbases |= pbs

#     if None in repos:
    abs_paths = list(self.get_blind_abs_rsync_paths())
    cmd.extend('--include=/{}/***'.format(x) for x in pkgbases)
    cmd.append('--exclude=/*')
#     else:
#       abs_paths = list(self.get_abs_rsync_paths(repos))

    if abs_paths:
      abs_paths[0] = ARCHLINUX_ORG_ABS_RSYNC_HOST + abs_paths[0]
    cmd.extend(abs_paths)
    cmd.append(output_dir)

    # Use mtimes to determine if rsync retrieved a file.
    # This avoid piping and parsing rsync output.
    pkgdirs = tuple(os.path.join(output_dir, x) for x in pkgbases)
    mtimes = dict()
    for d in pkgdirs:
      try:
        mtime = os.path.getmtime(d)
        mtimes[d] = mtime
        os.utime(d, (0,0))
      except OSError as e:
        if e.errno != errno.ENOENT:
          raise e

    logging.debug('invoking {}'.format(cmd))
    with open(os.devnull, 'wb') as f:
      subprocess.call(cmd, stdout=f)

    for d in pkgdirs:
      try:
        mtime = os.path.getmtime(d)
      except OSError as e:
        if e.errno != errno.ENOENT:
          raise e
      else:
        if mtime != 0:
          pkgbase = os.path.basename(d)
          try:
            for p in pkgbase_map[pkgbase]:
              yield p
          except KeyError:
            yield pkgbase
        else:
          t = mtimes[d]
          os.utime(d, (t,t))



################################## Network ####################################

def archlinux_org_pkg_info_url(repo, arch, pkgname):
  '''
  Retrieve information about official packages from archlinux.org.
  '''
  return ARCHLINUX_ORG_JSON_URL.format(repo=repo, arch=arch, pkgname=pkgname)



def archlinux_org_pkg_info(repo, arch, pkgname):
  '''
  Retrieve information about official packages from archlinux.org.
  '''
  url = archlinux_org_pkg_info_url(repo, arch, pkgname)
  return XCGF.load_json_from_url(url)



def archlinux_abs_git_repos(repo=None):
  '''
  Iterate over the ABS git repos.
  '''
  if repo:
    for r, x, y, gr in ARCHLINUX_OFFICIAL_REPOS:
      if repo == r:
        yield gr
        break
  else:
    for gr in ABS_GIT_REPOS:
      yield gr



def search_abs_git_repos(pkgname, repo=None):
  '''
  Scrape the online Git interface to retrieve URLs and names.
  '''
  for gr in archlinux_abs_git_repos(repo=repo):
    url = ARCHLINUX_ORG_ABS_GIT_HOST + ARCHLINUX_ORG_ABS_GIT_TRUNK.format(
      gitrepo=gr,
      pkgname=pkgname
    )
    try:
      with urllib.request.urlopen(url) as f:
        yield url, None
        for line in f:
          m = re.search(r"href='(.+?)'>(.+?)<".encode('utf-8'), line)
          if m:
            href = m.group(1).decode()
            name = m.group(2).decode()
            if name[:2] != '..':
              yield ARCHLINUX_ORG_ABS_GIT_HOST + href, name
    except urllib.error.HTTPError as e:
      if e.code != 404:
        raise e
    else:
      return



def retrieve_abs_via_git(output_dir, pkgbase, repo=None):
  '''
  Retrieve ABS files via the Git interface. Return the page url if successful.
  '''
  urls = search_abs_git_repos(pkgbase, repo=repo)
  try:
    url, nothing = next(urls)
  except StopIteration:
    return None
  else:
    pkgdir = os.path.join(output_dir, pkgbase)
    os.makedirs(pkgdir, exist_ok=True)
    for href, name in urls:
      XCGF.mirror(href, os.path.join(pkgdir, name), cache_time=0)
    return url




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

class XcpfError(Exception):
  '''
  Parent class of all custom exceptions raised by this module.
  '''
  def __init__(self, msg, error=None):
    self.msg = msg
    self.error = error

  def __str__(self):
    s = '{}: {}'.format(self.__class__.__name__, self.msg)
    if self.error:
      s += ' [{}]'.format(self.error)
    return s



############################### pyalpm Database ################################

def join_db_pkgcaches(dbs):
  '''
  Create a unified list of packages from the given databases. The order is
  preserved.
  '''
  return list(itertools.chain.from_iterable(db.pkgcache for db in dbs))



def find_pkgname(dbs, pkgname):
  '''
  Search for a package in the given databases.
  '''
  for db in dbs:
    pkg = db.get_pkg(pkgname)
    if pkg is not None:
      return pkg
  return None


############################### Version Parsing ################################

def split_version_requirement(name):
  '''
  Split a name with a version requirement into a name, equality relation and
  version.
  '''
  for x in ('>=', '<=', '>', '<', '='):
    try:
      name, ver = name.split(x,1)
      return name, x, ver
    except ValueError:
      continue
  else:
    return name, None, None



def satisfies_version_requirement(version, relation, required_version):
  '''
  Check if a version requirement is satisfied by the given version.
  '''
  if relation is None:
    return True
  else:
    vc = pyalpm.vercmp(version,required_version)
    return \
      (vc > 0 and '>' in relation) or \
      (vc == 0 and '=' in relation) or \
      (vc < 0 and '<' in relation)



def satisfies_all_version_requirements(version, reqs):
  '''
  Check if all version requirements are satisfied.
  '''
  return all(
    satisfies_version_requirement(version, *req) for req in reqs
  )



def collect_version_requirements(args):
  '''
  Collect version requirements per package.

  Returns a dictionary of package names mapped to lists of (relation, version)
  tuples for that name.
  '''
  ver_reqs = dict()

  for arg in args:
    name, relation, version = split_version_requirement(arg)
    if relation is None:
      if name not in ver_reqs:
        ver_reqs[name] = set()
    else:
      try:
        ver_reqs[name].add((relation, version))
      except KeyError:
        ver_reqs[name] = set(((relation, version),))

  return ver_reqs



def get_required_version_strings(ver_reqs, names):
  '''
  Return an iterator over the strings specifying the version requirements for
  the named package.
  '''
  for name in names:
    yielded = False
    try:
      for rel, ver in ver_reqs[name]:
        yield '{}{}{}'.format(name, rel, ver)
        yielded = True
    except KeyError:
      yield name
    else:
      if not yielded:
        yield name



def select_name_or_first(name, pkgs):
  '''
  Given a list of packages, return the package with the exact name if it exists,
  otherwise return the first package in the list.
  '''
  try:
    first = next(pkgs)
  except StopIteration:
    return None

  if first.name == name:
    return first

  for pkg in pkgs:
    if pkg.name == name:
      return pkg

  return first



def repo_and_package(pkg):
  '''
  Split repo prefix from package name, e.g. "core/foo" -> "core", "foo".
  '''
  try:
    repo, version = pkg.split('/', 1)
    return repo, version
  except ValueError:
    return None, pkg



def find_satisfier(pkgs, req):
  '''
  Wrapper around pyalpm.find_satisfier that considers repo prefixes, e.g.
  core/foo.
  '''
  repo, req = repo_and_package(req)
  pkg = pyalpm.find_satisfier(pkgs, req)
  if repo is None or (pkg and pkg.db.name == repo):
    return pkg
  else:
    return None



def find_satisfiers_in_dbs(dbs, reqs):
  '''
  Search for packages that satisfy all of the given version requirements in the
  given databases.
  '''
  for db in dbs:
    for pkg in find_satisfiers_in_pkgs(db.pkgcache, reqs):
      yield pkg



def find_satisfiers_in_pkgs(pkgs, reqs):
  '''
  Search for packages that satisfy all of the given version requirements among
  the given packages.
  '''
  # The package that satisfies one version requirement may not satisfy them
  # all. Different packages may provide different versions of the
  # requirement yet the "find_satisfier" function will only return the
  # first. All requirements must therefore be checked against the result of
  # each query.
  if pkgs:
    for req in reqs:
      pkg = find_satisfier(pkgs, req)
      if pkg:
        ps = (pkg,)
        for r in reqs:
          if r != req:
            if not pyalpm.find_satisfier(ps, r):
              break
        else:
          # Control reaches here only if all requirements have been met.
          yield pkg



############################### Argument Parsing ###############################

def get_args_from_stdin():
  '''
  Get arguments from STDIN. This should emulate pacman's parsing of the "-"
  argument to insert package names piped via STDIN.
  '''
  for l in sys.stdin:
    for a in l.strip().split():
      yield a



def maybe_insert_args_from_stdin(args):
  '''
  Replace "-" with arguments from STDIN if it appears in the given arguments.
  '''
  first = True
  for arg in args:
    if arg == '-' and first:
      first = False
      for a in get_args_from_stdin():
        yield a
    else:
      yield arg



################################### Display ####################################

# From pkg-query (fmtlst)
# margin = max field name length in format_pkginfo.
def format_pkginfo_list(xs, per_line=False, margin=17):
  if xs:
    txt = ''

    if per_line:
      return ('\n' + ' ' * margin).join([format_pkginfo_list(x.split(), margin=margin+2) for x in xs])

    line = margin
    for x in xs:
      l = len(x)
      if line + l + 1 >= 79:
        txt += '\n' + (' ' * margin) + x
        line = margin + l
      else:
        if txt:
          txt += ' ' + x
        else:
          txt = x
        line += 1 + l
  else:
    txt = 'None'
  return txt

# From pkg-query
def format_pkginfo(pkg):
  '''
  Format package information for display similarly to "pacman -Si".
  '''
  return '''Repository     : {repo}
Name           : {name}
Version        : {version}
Description    : {desc}
URL            : {url}
Packager       : {packager}
Provides       : {provides}
Optional Deps  : {optdeps}
Groups         : {groups}
Depends On     : {deps}
Conflicts With : {conflicts}
Replaces       : {replaces}
Licenses       : {license}
Download Size  : {dlsize}
Installed Size : {isize}
Architecture   : {arch}
Build Date     : {date}
'''.format(
    repo=pkg.db.name,
    name=pkg.name,
    version=pkg.version,
    desc=pkg.desc,
    url=pkg.url,
    license=format_pkginfo_list(pkg.licenses),
    groups=format_pkginfo_list(pkg.groups),
    provides=format_pkginfo_list(pkg.provides),
    deps=format_pkginfo_list(pkg.depends),
    optdeps=format_pkginfo_list(pkg.optdepends, per_line=True),
    conflicts=format_pkginfo_list(pkg.conflicts),
    replaces=format_pkginfo_list(pkg.replaces),
    dlsize=pkg.download_size,
    isize=pkg.isize,
    packager=pkg.packager,
    arch=pkg.arch,
    date=pkg.installdate,
  )


