#!/usr/bin/python3

from __future__ import annotations

import itertools
import os
import tempfile
import subprocess
import contextlib
import logging
import tarfile
import re
import shutil
from functools import partial
from pathlib import Path
from typing import Optional, Tuple, Iterator, Generator

import zstandard
from cmdutils import so_depends

logger = logging.getLogger(__name__)

def path_suspicious(path: str) -> bool:
  basename = os.path.basename(path)
  return '/bin/' in path or '.so' in basename

def walkdir(dir: str) -> Iterator[os.DirEntry]:
  for entry in os.scandir(dir):
    yield entry
    if not entry.is_symlink() and entry.is_dir():
      yield from walkdir(entry.path)

def check_dependency(dir: str, lib_re: re.Pattern) -> Optional[Tuple[str, str]]:
  for entry in walkdir(dir):
    if not path_suspicious(entry.path):
      continue

    if not entry.is_file() or entry.is_symlink():
      continue

    try:
      libs = so_depends(entry.path)
      logger.debug('so_depends %s: %s', entry.path, libs)
    except subprocess.CalledProcessError:
      continue

    for l in libs:
      if lib_re.search(l):
        return entry.path, l

  return None

def handle_rmtree_error(tmpdir, func, path, excinfo):
  if isinstance(excinfo[1], PermissionError) and \
     os.path.commonpath((path, tmpdir)) == tmpdir:
    os.chmod(os.path.dirname(path), 0o700)
    if os.path.isdir(path):
      shutil.rmtree(path, onerror=partial(handle_rmtree_error, path))
    else:
      os.unlink(path)

@contextlib.contextmanager
def extract_package(pkg: os.PathLike) -> Generator[str, None, None]:
  logger.info('extracting %s...', pkg)
  d = tempfile.mkdtemp(prefix='depcheck-')
  try:
    subprocess.check_call(['bsdtar', 'xf', pkg, '--no-fflags', '-C', d])
    yield d
  finally:
    shutil.rmtree(d, onerror=partial(handle_rmtree_error, d))

@contextlib.contextmanager
def tarfile_open_zstd_compat(name: str) -> Generator[tarfile.TarFile, None, None]:
  if name.endswith('.zst'):
    dctx = zstandard.ZstdDecompressor()
    with open(name, 'rb') as f, dctx.stream_reader(f) as reader, tarfile.open(mode='r|', fileobj=reader) as tar:
      yield tar
  else:
    with tarfile.open(name) as tar:
      yield tar

def check_package(pkg: Path, lib_re: re.Pattern, dep_pkgname: Optional[str]) -> Optional[Tuple[str, str]]:
  if dep_pkgname is not None:
    if check_package_buildinfo(pkg, dep_pkgname):
      return None
  return check_package_so(pkg, lib_re)

def check_package_buildinfo(pkg: Path, dep_pkgname: str) -> bool:
  # If a package links to a library that matches `lib_re` but does not have
  # `dep_pkgname` installed during the build, that package is already broken
  # For example, packages with files linked to libprotobuf.so should have
  # protobuf installed during the build.
  has_dep = False
  buildinfo_found = False
  with tarfile_open_zstd_compat(str(pkg)) as tar:
    threshold = 10
    for tarinfo in itertools.islice(tar, threshold):
      if tarinfo.name == '.BUILDINFO':
        f = tar.extractfile(tarinfo)
        assert f
        for line in f.read().decode().split('\n'):
          if line.startswith('installed = '):
            # pkgver, pkgrel and arch are not used
            parts = line[len('installed = '):].rsplit('-', maxsplit=3)
            if len(parts) != 4:
              logger.warning('Old .BUILDINFO format - entry %s found in %s; checking anyway', line, pkg)
              has_dep = True
              break
            pkgname, _, _, _ = parts
            if pkgname == dep_pkgname:
              has_dep = True
              break
        buildinfo_found = True
        break
  if not buildinfo_found:
    logger.warning('Cannot find .BUILDINFO in first %d entries of %s; checking anyway', threshold, pkg)
    has_dep = True
  if not has_dep:
    logger.info('%s does not depend on %s, skipping' % (pkg, dep_pkgname))
    return True

  return False

def check_package_so(pkg: Path, lib_re: re.Pattern) -> Optional[Tuple[str, str]]:
  with extract_package(pkg) as d:
    logger.info('checking...')
    a = check_dependency(d, lib_re)
    if a is not None:
      r, lib = a
      r = os.path.relpath(r, d)
      logger.warning('%s depends on %s: %s', pkg, lib, r)
      return r, lib

  return None

def main(db: Path, lib_re: re.Pattern, dep_pkgname: Optional[str]) -> None:
  ret = []
  dir = db.parent

  with tarfile.open(db) as tar:
    for tarinfo in tar:
      if tarinfo.isdir():
        filename = files_match = None
        name = tarinfo.name.split('/', 1)[0]
        continue

      if '-debug-' in name:
        continue

      if tarinfo.name.endswith('/depends'):
        continue

      if tarinfo.name.endswith('/desc'):
        f = tar.extractfile(tarinfo)
        assert f
        data = f.read().decode()

        it = iter(data.splitlines())
        while True:
          try:
            l = next(it)
          except StopIteration:
            break
          if l == '%FILENAME%':
            filename = next(it)
            break

      if tarinfo.name.endswith('/files'):
        f = tar.extractfile(tarinfo)
        assert f
        data = f.read().decode()
        it = iter(data.splitlines())
        next(it)
        for path in it:
          if path_suspicious(path):
            files_match = True
            break

      if filename and files_match:
        r = check_package(dir / filename, lib_re, dep_pkgname)
        if r is not None:
          ret.append((name, *r))

  for name, file, lib in ret:
    print('%s: %s (%s)' % (name, file, lib))

if __name__ == '__main__':
  from nicelogger import enable_pretty_logging
  enable_pretty_logging('INFO')

  import argparse

  parser = argparse.ArgumentParser(
    description='find out what Arch packages need a particular library. Depends on cmdutils from winterpy.')
  parser.add_argument('pkgdb',
                      help='the package files database, eg. /data/repo/x86_64/archlinuxcn.files.tar.gz')
  parser.add_argument('libname',
                      help='the library filename regex to match')
  parser.add_argument('--dep-pkgname',
                      help='the package name that affected packages should depend on')
  args = parser.parse_args()

  main(Path(args.pkgdb), re.compile(args.libname), args.dep_pkgname)
