#!/usr/bin/python

"""Handle tasks like auto stabilizing.

Examples:

  # Mark s390 stable relative to amd64 & x86, and push commits
  # via the source repo in /usr/local/src/gentoo-x86/.
  $ %(prog)s \\
        --profile default/linux/s390 \\
        --ref-profile default/linux/x86 \\
        --ref-profile default/linux/amd64 \\
        --commit-dir /usr/local/src/gentoo-x86
"""

from __future__ import print_function

import argparse
import os
import shutil
import subprocess
import sys
import tempfile

import ekeyword

#path = os.path.dirname(os.path.realpath(__file__))
#sys.path.insert(0, path)
#path = os.path.realpath(os.path.join(path, '..', 'snakeoil'))
#sys.path.insert(0, path)
#del path

import pkgcore.config
import pkgcore.ebuild
import pkgcore.ebuild.resolver
import pkgcore.repository.util


NAME = 'ato-stabilize'


def create_configroot(configroot, profile_dir, parent_profile, unstable=False):
    etc_portage = os.path.join(configroot, 'etc', 'portage')
    os.makedirs(etc_portage)
    d = os.path.join(profile_dir, parent_profile)
    if not os.path.exists(d):
        raise ValueError('could not find profile "%s" in "%s"' %
                         (parent_profile, profile_dir))
    os.symlink(d, os.path.join(etc_portage, 'make.profile'))
    with open(os.path.join(etc_portage, 'make.conf'), 'w') as f:
        f.write('ACCEPT_KEYWORDS="%s${ARCH}"\n' % ('~' if unstable else '-~'))

    usr = os.path.join(configroot, 'usr')
    os.makedirs(usr)
    os.symlink('/usr/portage', os.path.join(usr, 'portage'))

    usr_share = os.path.join(usr, 'share')
    os.makedirs(usr_share)
    os.symlink('/usr/share/portage', os.path.join(usr_share, 'portage'))

    # Hack around pkgcore -- it expands make.conf before the profile.
    config = load_config(configroot)
    try:
        domain = config.get_default('domain')
    except Exception:
        print('Failed to set up profile using %s' % parent_profile)
        raise
    with open(os.path.join(etc_portage, 'make.conf'), 'w') as f:
        f.write('ACCEPT_KEYWORDS="%s%s"\n' % (('~' if unstable else '-~'), domain.arch))

    #vdb = os.path.join(configroot, 'var', 'db', 'pkg')
    #os.makedirs(vdb)


def load_config(configroot):
    # pkgcore doesn't provide an API for these settings.
    environ = os.environ.copy()
    os.environ['PORTAGE_CONFIGROOT'] = os.environ['ROOT'] = configroot
    config = pkgcore.config.load_config(user_conf_file='/', system_conf_file='/')
    os.environ.clear()
    os.environ.update(environ)
    return config


def RunCommand(cmd, cwd=None):
    print('###', cmd, 'cwd:', cwd)
    p = subprocess.Popen(cmd, cwd=cwd,
                         #stdin=subprocess.PIPE,
                         stdout=subprocess.PIPE,
                         stderr=subprocess.PIPE, close_fds=True)
    stdout, stderr = p.communicate()
    if p.returncode:
        raise ValueError('cmd failed:\nstdout:\n%s\nstderr:\n%s' % (stdout, stderr))
    class Result(object):
        def __init__(self, stdout, stderr):
            self.output = stdout
            self.error = stderr
    return Result(stdout, stderr)


class Target(object):
    """Contain details for a specific profile."""

    def __init__(self, configroot, profile_dir=None, profile=None):
        if profile is not None:
            unstable_root = os.path.join(configroot, 'unstable')
            create_configroot(configroot, profile_dir, profile)
            create_configroot(unstable_root, profile_dir, profile, unstable=True)

        self.config = load_config(configroot)
        self.domain = self.config.get_default('domain')

        if profile is not None:
            self.config_unstable = load_config(unstable_root)
            self.domain_unstable = self.config_unstable.get_default('domain')

    @property
    def system(self):
        return self.domain.profile.system

    def resolve(self, atoms, nodeps=False, unstable=False):
        domain = self.domain_unstable if unstable else self.domain
        resolver = pkgcore.ebuild.resolver.min_install_resolver(
            domain.installed_repositories.repositories,
            domain.source_repositories.repositories,
            verify_vdb=True, drop_cycles=True, nodeps=nodeps,
            resolver_cls=pkgcore.ebuild.resolver.empty_tree_merge_plan)
        resolver.add_atoms(atoms, finalize=False)
        ops = resolver.state.ops(only_real=True)
        return [x.pkg for x in ops]

    def resolve_club(self, atoms, show=False, unstable=False):
        # Breaking the loops sometimes produces atoms that are older than
        # the latest.  Test them by hand to workaround that.  For example,
        # looking up @system yields dev-libs/libxslt-1.1.28-r1 when there
        # is really dev-libs/libxslt-1.1.28-r3 available.  Similarly, some
        # atoms get dropped altogether.  Blah.
        ret = []
        for atom in atoms:
            ret += self.resolve([atom], nodeps=True, unstable=unstable)
        return ret

    def __str__(self):
        return self.domain.profile.path


class Vcs(object):
    """Manage a source VCS repo for updating/commits/etc..."""

    def __init__(self, path, dry_run=False):
        self.path = path
        self.dry_run = dry_run

        if os.path.isdir(os.path.join(path, 'CVS')):
            self.update = self.cvs_update
        elif os.path.isdir(os.path.join(path, '.svn')):
            self.update = self.svn_update
        elif os.path.isdir(os.path.join(path, '.git')):
            self.update = self.git_update

    def cvs_update(self, subpath, clean=True):
        if self.dry_run:
            return
        cwd = os.path.join(self.path, subpath)
        ret = RunCommand(['cvs', 'up'], cwd=cwd)
        # Make sure there are no local changes.
        for line in ret.output.splitlines():
            line = line.split(' ', 1)
            if line[0] in ('U', 'P'):
                break
            if clean:
                raise ValueError(ret.output)
            else:
                print(' %s' % line)

    def svn_update(self, subpath, clean=True):
        if self.dry_run:
            return
        cwd = os.path.join(self.path, subpath)
        RunCommand(['svn', 'up'], cwd=cwd)
        ret = RunCommand(['svn', 'st'], cwd=cwd)
        # Make sure there are no local changes.
        for line in ret.output.splitlines():
            line = line.split(' ', 1)
            if clean:
                raise ValueError(ret.output)
            else:
                print(' %s' % line)


class LiveRepo(object):
    """Manage a live ebuild repo for operating on."""

    def __init__(self, path, keywords=(), dry_run=False):
        self.path = path
        self.vcs = Vcs(path, dry_run=dry_run)
        self.keywords = keywords
        self.dry_run = dry_run

    def update(self):
        self.vcs.update('profiles', clean=False)

    def stabilize(self, atom, keywords=()):
        if not keywords:
            keywords = self.keywords
        if not keywords:
            raise ValueError('no KEYWORDS to update')

        ebuild_dir = os.path.join(atom.category, atom.package)
        self.vcs.update(ebuild_dir)
        ebuild = os.path.join(self.path, ebuild_dir, '%s-%s.ebuild' % (atom.package, atom.fullver))

        ops = [ekeyword.Op(None, x, None) for x in keywords]
        if not ekeyword.process_ebuild(ebuild, ops, dry_run=self.dry_run):
            return

        if self.dry_run:
            return

        cmd = ['repoman', 'commit', '--echangelog=y', '-m', 'Mark %s stable.' % '/'.join(sorted(keywords))]
        if raw_input().strip() == '':
            RunCommand(cmd, cwd=os.path.join(self.path, ebuild_dir))


def setup_targets(tempdir, profile_dir, profiles, prefix=''):
    targets = []
    for i, profile in enumerate(profiles):
        d = os.path.join(tempdir, '%sconfigroots' % prefix, str(i))
        targets.append(Target(d, profile_dir=profile_dir, profile=profile))
    return targets


def packages_to_atoms(target, packages):
    """Convert the text |packages| to pkgcore atom objects"""
    atoms = []
    for package in packages:
        if package.startswith('@'):
            if package == '@system':
                atoms += target.system
            else:
                raise AssertionError('unknown set "%s"' % package)
        else:
            atoms.append(pkgcore.ebuild.atom.atom(package))
    return atoms


def get_stabilize_plans(targets, ref_targets, packages):
    plans = []
    for target in targets:
        plan = {}
        plans.append(plan)

        in_atoms = packages_to_atoms(target, packages)
        pkgs = target.resolve_club(in_atoms)

        for ref_target in ref_targets:
            #ref_in_atoms = [x.unversioned_atom for x in pkgs]
            ref_in_atoms = packages_to_atoms(ref_target, packages)
            ref_pkgs = ref_target.resolve_club(ref_in_atoms, True)

            old_pkgs = dict([(x.versioned_atom.cpvstr, x.versioned_atom) for x in pkgs])
            old_cat_pkgs = dict([('%s/%s' % (x.versioned_atom.category, x.versioned_atom.package), x.versioned_atom) for x in pkgs])
            new_pkgs = dict([(x.versioned_atom.cpvstr, x.versioned_atom) for x in ref_pkgs])
            todo = set(new_pkgs.keys()) - set(old_pkgs.keys())
            for pkg in todo:
                atom_new = new_pkgs[pkg]
                cat_pkg = '%s/%s' % (atom_new.category, atom_new.package)
                if cat_pkg not in old_cat_pkgs:
                    # See if our target only has unstable versions.
                    old_pkg = target.resolve_club([atom_new], unstable=True)
                    if not old_pkg:
                        # If the ref profile pulls in packages that we don't
                        # know about (maybe they enable diff set of USE flags),
                        # ignore it as part of our todo.
                        continue

                    atom_old = old_pkg[0].versioned_atom
                    upgrade = -1
                else:
                    atom_old = old_cat_pkgs[cat_pkg]
                    upgrade = pkgcore.ebuild.atom.cmp(atom_old, atom_new)

                if upgrade < 0:
                    if cat_pkg in plan:
                        print('collide[%s]:\n\t%s\n\t%s' % (cat_pkg, (atom_old, atom_new), plan[cat_pkg]))
                        if pkgcore.ebuild.atom.cmp(plan[cat_pkg][0], atom_new) < 0:
                            plan[cat_pkg] = (atom_old, atom_new)
                    else:
                        plan[cat_pkg] = (atom_old, atom_new)

    return plans


def mark_stable(repo, targets, ref_targets, packages):
    plans = get_stabilize_plans(targets, ref_targets, packages)

    all_cat_pkg = set()
    for plan in plans:
        all_cat_pkg.update(plan.keys())
    for cat_pkg in all_cat_pkg:
        keywords = set()
        atom_new = None
        for plan, target in zip(plans, targets):
            if cat_pkg in plan:
                keywords.add(target.domain.arch)
                atom_old, atom_new = plan[cat_pkg]
        print('%s %s: %s' % (cat_pkg, atom_new.fullver, ', '.join(keywords)))
        repo.stabilize(atom_new, keywords=keywords)


def get_parser():
    """Return an ArgumentParser for this module"""
    parser = argparse.ArgumentParser(
        description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument('--profile', dest='profiles', action='append',
                        required=True,
                        help='Profile to stabilize for')
    parser.add_argument('--ref-profile', dest='ref_profiles', action='append',
                        required=True,
                        help='Profile to stabilize against')
    parser.add_argument('--profile-dir', type=str,
                        help='Root directory to look up profiles')
    parser.add_argument('--commit-dir', type=str,
                        required=True,
                        help='Path to VCS repo to make commits to')
    parser.add_argument('-n', '--dry-run', default=False, action='store_true',
                        help='Do not make any changes; show what would be done')
    parser.add_argument('--debug', default=False, action='store_true',
                        help='Dump extended internal state')
    parser.add_argument('packages', nargs='*',
                        help='Packages to mark stable (defaults to @system)')
    return parser


def parse_args(argv):
    """Process the argv, default options, and run sanity checks."""
    parser = get_parser()
    opts = parser.parse_args(argv)

    if opts.profile_dir is None:
        config = pkgcore.config.load_config()
        domain = config.get_default('domain')
        for repo in domain.repos:
            if repo.repo_id == 'gentoo':
                opts.profile_dir = os.path.join(repo.location, 'profiles')
                break
        else:
            parser.error('Could not locate the "gentoo" repo')

    if not opts.packages:
        opts.packages = ['@system']

    return opts


def main(argv):
    opts = parse_args(argv)

    repo = LiveRepo(opts.commit_dir, dry_run=opts.dry_run)
    repo.update()

    # Silence all the cycle info from the resolver.
    pkgcore.resolver.plan.limiters = set()
    try:
        tempdir = tempfile.mkdtemp(prefix=NAME)

        targets = setup_targets(tempdir, opts.profile_dir, opts.profiles)
        ref_targets = setup_targets(tempdir, opts.profile_dir,
                                    opts.ref_profiles, prefix='ref_')

        mark_stable(repo, targets, ref_targets, opts.packages)
    finally:
        pass #shutil.rmtree(tempdir, ignore_errors=True)


if __name__ == '__main__':
    main(sys.argv[1:])
