import os
import xml.dom.minidom
from fnmatch import fnmatch

import koji
import koji.util
from koji.tasks import ServerExit
from __main__ import BaseBuildTask, BuildImageTask, BuildRoot, SCM


class KiwiBuildTask(BuildImageTask):
    Methods = ['kiwiBuild']
    _taskWeight = 4.0

    def get_nvrp(self, cfg):
        try:
            newxml = xml.dom.minidom.parse(cfg)  # nosec
        except Exception:
            raise koji.GenericError(
                f"Kiwi description {os.path.basename(cfg)} can't be parsed as XML.")
        try:
            image = newxml.getElementsByTagName('image')[0]
        except IndexError:
            raise koji.GenericError(
                f"Kiwi description {os.path.basename(cfg)} doesn't contain <image> tag.")

        name = image.getAttribute('name')
        version = None
        for preferences in image.getElementsByTagName('preferences'):
            try:
                version = preferences.getElementsByTagName('version')[0].childNodes[0].data
            except Exception:
                pass
        profile = None
        try:
            for p in image.getElementsByTagName('profiles')[0].getElementsByTagName('profile'):
                if p.getAttribute('image') == 'true':
                    profile = p.getAttribute('name')
        except IndexError:
            # missing profiles section
            pass
        if not version:
            raise koji.BuildError("Description file doesn't contain preferences/version")
        return name, version, profile

    def handler(self, target, arches, desc_url, desc_path, opts=None):
        target_info = self.session.getBuildTarget(target, strict=True)
        build_tag = target_info['build_tag']
        repo_info = self.getRepo(build_tag)
        # check requested arches against build tag
        buildconfig = self.session.getBuildConfig(build_tag)
        if not buildconfig['arches']:
            raise koji.BuildError("No arches for tag %(name)s [%(id)s]" % buildconfig)
        tag_archlist = [koji.canonArch(a) for a in buildconfig['arches'].split()]
        if arches:
            for arch in arches:
                if koji.canonArch(arch) not in tag_archlist:
                    raise koji.BuildError("Invalid arch for build tag: %s" % arch)
        else:
            arches = tag_archlist

        if not opts:
            opts = {}
        if not opts.get('scratch'):
            opts['scratch'] = False
        if not opts.get('optional_arches'):
            opts['optional_arches'] = []
        if not buildconfig['extra'].get('mock.new_chroot', True):
            opts['mount_dev'] = True
        self.opts = opts

        # get configuration
        scm = SCM(desc_url)
        scm.assert_allowed(allowed=self.options.allowed_scms,
                           session=self.session,
                           by_config=self.options.allowed_scms_use_config,
                           by_policy=self.options.allowed_scms_use_policy,
                           policy_data={
                               'user_id': self.taskinfo['owner'],
                               'channel': self.session.getChannel(self.taskinfo['channel_id'],
                                                                  strict=True)['name'],
                               'scratch': opts['scratch'],
                           })
        logfile = os.path.join(self.workdir, 'checkout.log')
        self.run_callbacks('preSCMCheckout', scminfo=scm.get_info(),
                           build_tag=build_tag, scratch=opts['scratch'])
        scmdir = self.workdir
        koji.ensuredir(scmdir)
        scmsrcdir = scm.checkout(scmdir, self.session,
                                 self.getUploadDir(), logfile)
        self.run_callbacks("postSCMCheckout",
                           scminfo=scm.get_info(),
                           build_tag=build_tag,
                           scratch=opts['scratch'],
                           srcdir=scmsrcdir)

        path = os.path.join(scmsrcdir, desc_path)

        name, version, default_profile = self.get_nvrp(path)
        if opts.get('profile') or default_profile:
            # package name is a combination of name + profile
            # in case profiles are not used, let's use the standalone name
            name = "%s-%s" % (name, opts.get('profile', default_profile))

        bld_info = {}
        if opts.get('release'):
            release = opts['release']
        else:
            release = self.session.getNextRelease({'name': name, 'version': version})
        if not opts['scratch']:
            bld_info = self.initImageBuild(name, version, release, target_info, opts)
            release = bld_info['release']

        try:
            subtasks = {}
            canfail = []
            self.logger.debug("Spawning jobs for image arches: %r" % (arches))
            for arch in arches:
                subtasks[arch] = self.session.host.subtask(
                    method='createKiwiImage',
                    arglist=[name, version, release, arch,
                             target_info, build_tag, repo_info,
                             desc_url, desc_path, opts],
                    label=arch, parent=self.id, arch=arch)
                if arch in self.opts['optional_arches']:
                    canfail.append(subtasks[arch])
            self.logger.debug("Got image subtasks: %r" % (subtasks))
            self.logger.debug("Waiting on image subtasks (%s can fail)..." % canfail)
            results = self.wait(list(subtasks.values()), all=True,
                                failany=True, canfail=canfail)

            # if everything failed, fail even if all subtasks are in canfail
            self.logger.debug('subtask results: %r', results)
            all_failed = True
            for result in results.values():
                if not isinstance(result, dict) or 'faultCode' not in result:
                    all_failed = False
                    break
            if all_failed:
                raise koji.GenericError("all subtasks failed")

            # determine ignored arch failures
            ignored_arches = set()
            for arch in arches:
                if arch in self.opts['optional_arches']:
                    task_id = subtasks[arch]
                    result = results[task_id]
                    if isinstance(result, dict) and 'faultCode' in result:
                        ignored_arches.add(arch)

            self.logger.debug('Image Results for hub: %s' % results)
            results = {str(k): v for k, v in results.items()}
            if opts['scratch']:
                self.session.host.moveImageBuildToScratch(self.id, results)
            else:
                self.session.host.completeImageBuild(self.id, bld_info['id'], results)
        except (SystemExit, ServerExit, KeyboardInterrupt):
            # we do not trap these
            raise
        except Exception:
            if not opts['scratch']:
                if bld_info:
                    self.session.host.failBuild(self.id, bld_info['id'])
            raise

        # tag it
        if not opts['scratch'] and not opts.get('skip_tag'):
            tag_task_id = self.session.host.subtask(method='tagBuild',
                                                    arglist=[target_info['dest_tag'],
                                                             bld_info['id'], False, None, True],
                                                    label='tag', parent=self.id, arch='noarch')
            self.wait(tag_task_id)

        # report results
        report = ''
        if opts['scratch']:
            respath = ', '.join(
                [os.path.join(koji.pathinfo.work(),
                              koji.pathinfo.taskrelpath(tid)) for tid in subtasks.values()])
            report += 'Scratch '
        else:
            respath = koji.pathinfo.imagebuild(bld_info)
        report += 'image build results in: %s' % respath
        return report


class KiwiCreateImageTask(BaseBuildTask):
    Methods = ['createKiwiImage']
    _taskWeight = 2.0

    def prepareDescription(self, desc_path, name, version, repos, arch):
        # XML errors should have already been caught by parent task
        newxml = xml.dom.minidom.parse(desc_path)  # nosec
        image = newxml.getElementsByTagName('image')[0]

        # apply includes - kiwi can include only top-level nodes, so we can simply
        # go through "include" elements and replace them with referred content (without
        # doing it recursively)
        for inc_node in image.getElementsByTagName('include'):
            path = inc_node.getAttribute('from')
            if path.startswith('this://'):
                path = koji.util.joinpath(os.path.dirname(desc_path), path[7:])
            else:
                # we want to reject other protocols, e.g. file://, https://
                # reachingoutside of repo
                raise koji.GenericError(f"Unhandled include protocol in include path: {path}.")
            inc = xml.dom.minidom.parse(path)  # nosec
            # every included xml has image root element again
            try:
                for node in list(inc.getElementsByTagName('image')[0].childNodes):
                    if node.nodeName != 'repository':
                        image.appendChild(node)
            except IndexError:
                raise koji.GenericError("Included file needs to contain <image> tag.")
            image.removeChild(inc_node)

        # remove remaining old repos
        for old_repo in image.getElementsByTagName('repository'):
            image.removeChild(old_repo)

        # add koji ones
        for repo in sorted(set(repos)):
            repo_node = newxml.createElement('repository')
            repo_node.setAttribute('type', 'rpm-md')
            source = newxml.createElement('source')
            source.setAttribute('path', repo)
            repo_node.appendChild(source)
            image.appendChild(repo_node)

        image.setAttribute('name', name)
        preferences = image.getElementsByTagName('preferences')[0]
        try:
            preferences.getElementsByTagName('release-version')[0].childNodes[0].data = version
        except IndexError:
            releasever_node = newxml.createElement('release-version')
            text = newxml.createTextNode(version)
            releasever_node.appendChild(text)
            preferences.appendChild(releasever_node)

        types = []
        for pref in image.getElementsByTagName('preferences'):
            for type in pref.getElementsByTagName('type'):
                # TODO: if type.getAttribute('primary') == 'true':
                types.append(type.getAttribute('image'))

        # write new file
        newcfg = os.path.splitext(desc_path)[0] + f'.{arch}.kiwi'
        with open(newcfg, 'wt') as f:
            s = newxml.toprettyxml()
            # toprettyxml adds too many whitespaces/newlines
            s = '\n'.join([x for x in s.splitlines() if x.strip()])
            f.write(s)
        os.unlink(desc_path)

        return newcfg, types

    def getImagePackagesFromCache(self, cachepath):
        """
        Read RPM header information from the yum cache available in the
        given path. Returns a list of dictionaries for each RPM included.
        """
        found = False
        hdrlist = {}
        fields = ['name', 'version', 'release', 'epoch', 'arch',
                  'buildtime', 'sigmd5']
        for root, dirs, files in os.walk(cachepath):
            for f in files:
                if fnmatch(f, '*.rpm'):
                    pkgfile = os.path.join(root, f)
                    hdr = koji.get_header_fields(pkgfile, fields)
                    hdr['size'] = os.path.getsize(pkgfile)
                    hdr['payloadhash'] = koji.hex_string(hdr['sigmd5'])
                    del hdr['sigmd5']
                    hdrlist[os.path.basename(pkgfile)] = hdr
                    found = True
        if not found:
            raise koji.LiveCDError('No repos found in yum cache!')
        return list(hdrlist.values())

    def getImagePackages(self, result):
        """Proper handler for getting rpminfo from result list,
        it need result list to contain payloadhash, etc. to work correctly"""
        hdrlist = []
        for line in open(result, 'rt'):
            line = line.strip()
            name, epoch, version, release, arch, disturl, license = line.split('|')
            if epoch == '(none)':
                epoch = None
            else:
                epoch = int(epoch)
            hdrlist.append({
                'name': name,
                'epoch': epoch,
                'version': version,
                'release': release,
                'arch': arch,
                'payloadhash': '',
                'size': 0,
                'buildtime': 0,
            })

        return hdrlist

    def handler(self, name, version, release, arch,
                target_info, build_tag, repo_info,
                desc_url, desc_path, opts=None):
        self.opts = opts
        build_tag = target_info['build_tag']
        bind_opts = {'dirs': {}}
        if self.opts.get('mount_dev'):
            bind_opts['dirs']['/dev'] = '/dev'
        broot = BuildRoot(self.session, self.options,
                          tag=build_tag,
                          arch=arch,
                          task_id=self.id,
                          repo_id=repo_info['id'],
                          install_group='kiwi-build',
                          setup_dns=True,
                          bind_opts=bind_opts)
        broot.workdir = self.workdir

        # create the mock chroot
        self.logger.debug("Initializing kiwi buildroot")
        broot.init()
        self.logger.debug("Kiwi buildroot ready: " + broot.rootdir())

        # get configuration
        scm = SCM(desc_url)
        scm.assert_allowed(allowed=self.options.allowed_scms,
                           session=self.session,
                           by_config=self.options.allowed_scms_use_config,
                           by_policy=self.options.allowed_scms_use_policy,
                           policy_data={
                               'user_id': self.taskinfo['owner'],
                               'channel': self.session.getChannel(self.taskinfo['channel_id'],
                                                                  strict=True)['name'],
                               'scratch': self.opts.get('scratch', False)
                           })
        logfile = os.path.join(self.workdir, 'checkout-%s.log' % arch)
        self.run_callbacks('preSCMCheckout', scminfo=scm.get_info(),
                           build_tag=build_tag, scratch=self.opts.get('scratch', False))
        scmdir = broot.tmpdir()
        koji.ensuredir(scmdir)
        scmsrcdir = scm.checkout(scmdir, self.session,
                                 self.getUploadDir(), logfile)
        self.run_callbacks("postSCMCheckout",
                           scminfo=scm.get_info(),
                           build_tag=build_tag,
                           scratch=self.opts.get('scratch', False),
                           srcdir=scmsrcdir)

        # user repos
        repos = self.opts.get('repos', [])
        # buildroot repo
        path_info = koji.PathInfo(topdir=self.options.topurl)
        repopath = path_info.repo(repo_info['id'], target_info['build_tag_name'])
        baseurl = '%s/%s' % (repopath, arch)
        self.logger.debug('BASEURL: %s' % baseurl)
        repos.append(baseurl)

        base_path = os.path.dirname(desc_path)
        if opts.get('make_prep'):
            cmd = ['make', 'prep']
            rv = broot.mock(['--cwd', os.path.join(broot.tmpdir(within=True),
                                                   os.path.basename(scmsrcdir), base_path),
                             '--chroot', '--'] + cmd)
            if rv:
                raise koji.GenericError("Preparation step failed")

        path = os.path.join(scmsrcdir, desc_path)
        desc, types = self.prepareDescription(path, name, version, repos, arch)
        self.uploadFile(desc)

        target_dir = '/builddir/result/image'
        os.symlink(  # symlink log to resultdir, so it is incrementally uploaded
            os.path.join(broot.rootdir(), f'tmp/image-root.{arch}.log'),
            os.path.join(broot.resultdir(), f'image-root.{arch}.log')
        )
        cmd = ['kiwi-ng']
        if self.opts.get('profile'):
            cmd.extend(['--profile', self.opts['profile']])
        if self.opts.get('type'):
            cmd.extend(['--type', self.opts['type']])
        cmd.extend([
            '--kiwi-file', os.path.basename(desc),  # global option for image/system commands
            '--logfile', f'/tmp/image-root.{arch}.log',
            'system', 'build',
            '--description', os.path.join(os.path.basename(scmsrcdir), base_path),
            '--target-dir', target_dir,
        ])
        rv = broot.mock(['--cwd', broot.tmpdir(within=True), '--chroot', '--'] + cmd)
        if rv:
            raise koji.GenericError("Kiwi failed")

        # rename artifacts accordingly to release
        os.symlink(  # symlink log to resultdir, so it is incrementally uploaded
            os.path.join(broot.rootdir(), f'/tmp/kiwi-result-bundle.{arch}.log'),
            os.path.join(broot.resultdir(), f'kiwi-result-bundle.{arch}.log')
        )
        bundle_dir = '/builddir/result/bundle'
        cmd = ['kiwi-ng',
               '--logfile', f'/tmp/kiwi-result-bundle.{arch}.log',
               'result', 'bundle',
               '--target-dir', target_dir,
               '--bundle-dir', bundle_dir,
               '--id', release]
        rv = broot.mock(['--cwd', broot.tmpdir(within=True), '--chroot', '--'] + cmd)
        if rv:
            raise koji.GenericError("Kiwi failed")

        imgdata = {
            'arch': arch,
            'task_id': self.id,
            'logs': [
                os.path.basename(desc),
            ],
            'name': name,
            'version': version,
            'release': release,
            'rpmlist': [],
            'files': [],
        }

        bundle_path = os.path.join(broot.rootdir(), bundle_dir[1:])
        for fname in os.listdir(bundle_path):
            self.uploadFile(os.path.join(bundle_path, fname), remoteName=fname)
            imgdata['files'].append(fname)

        if not self.opts.get('scratch'):
            if False:
                # should be used after kiwi update
                fpath = os.path.join(
                    bundle_path,
                    next(f for f in imgdata['files'] if f.endswith('.packages')),
                )
                hdrlist = self.getImagePackages(fpath)
            else:
                cachepath = os.path.join(broot.rootdir(), 'var/cache/kiwi/dnf')
                hdrlist = self.getImagePackagesFromCache(cachepath)
            broot.markExternalRPMs(hdrlist)
            imgdata['rpmlist'] = hdrlist

        broot.expire()

        self.logger.error("Uploading image data: %s", imgdata)
        return imgdata
