#!/usr/bin/python3

import sys
import glob
import os
import pathlib
import re

from debian_linux.abi import Symbols
from debian_linux.config import ConfigCoreDump
from debian_linux.debian import Changelog, VersionLinux
from debian_linux.kconfig import KconfigFile


class CheckAbi(object):
    class SymbolInfo(object):
        def __init__(self, symbol, symbol_ref=None):
            self.symbol = symbol
            self.symbol_ref = symbol_ref or symbol

        @property
        def module(self):
            return self.symbol.module

        @property
        def name(self):
            return self.symbol.name

        def write(self, out, ignored):
            info = []
            if ignored:
                info.append("ignored")
            for name in ('module', 'version', 'export'):
                data = getattr(self.symbol, name)
                data_ref = getattr(self.symbol_ref, name)
                if data != data_ref:
                    info.append("%s: %s -> %s" % (name, data_ref, data))
                else:
                    info.append("%s: %s" % (name, data))
            out.write("%-48s %s\n" % (self.symbol.name, ", ".join(info)))

    def __init__(self, config, dir, arch, featureset, flavour):
        self.config = config
        self.arch, self.featureset, self.flavour = arch, featureset, flavour

        self.filename_new = "%s/Module.symvers" % dir

        try:
            version_abi = (self.config[('version',)]['abiname_base'] + '-'
                           + self.config['abi', arch]['abiname'])
        except KeyError:
            version_abi = self.config[('version',)]['abiname']
        self.filename_ref = ("debian/abi/%s/%s_%s_%s" %
                             (version_abi, arch, featureset, flavour))

    def __call__(self, out):
        ret = 0

        new = Symbols(open(self.filename_new))
        unversioned = [name for name in new
                       if new[name].version == '0x00000000']
        if unversioned:
            out.write("ABI is not completely versioned!  "
                      "Refusing to continue.\n")
            out.write("\nUnversioned symbols:\n")
            for name in sorted(unversioned):
                self.SymbolInfo(new[name]).write(out, False)
            ret = 1

        try:
            ref = Symbols(open(self.filename_ref))
        except IOError:
            out.write("Can't read ABI reference.  ABI not checked!\n")
            return ret

        symbols, add, change, remove = self._cmp(ref, new)

        ignore = self._ignore(symbols)

        add_effective = add - ignore
        change_effective = change - ignore
        remove_effective = remove - ignore

        if change_effective or remove_effective:
            out.write("ABI has changed!  Refusing to continue.\n")
            ret = 1
        elif change or remove:
            out.write("ABI has changed but all changes have been ignored.  "
                      "Continuing.\n")
        elif add_effective:
            out.write("New symbols have been added.  Continuing.\n")
        elif add:
            out.write("New symbols have been added but have been ignored.  "
                      "Continuing.\n")
        else:
            out.write("No ABI changes.\n")

        if add:
            out.write("\nAdded symbols:\n")
            for name in sorted(add):
                symbols[name].write(out, name in ignore)

        if change:
            out.write("\nChanged symbols:\n")
            for name in sorted(change):
                symbols[name].write(out, name in ignore)

        if remove:
            out.write("\nRemoved symbols:\n")
            for name in sorted(remove):
                symbols[name].write(out, name in ignore)

        return ret

    def _cmp(self, ref, new):
        ref_names = set(ref.keys())
        new_names = set(new.keys())

        add = set()
        change = set()
        remove = set()

        symbols = {}

        for name in new_names - ref_names:
            add.add(name)
            symbols[name] = self.SymbolInfo(new[name])

        for name in ref_names.intersection(new_names):
            s_ref = ref[name]
            s_new = new[name]

            if s_ref != s_new:
                change.add(name)
                symbols[name] = self.SymbolInfo(s_new, s_ref)

        for name in ref_names - new_names:
            remove.add(name)
            symbols[name] = self.SymbolInfo(ref[name])

        return symbols, add, change, remove

    def _ignore_pattern(self, pattern):
        ret = []
        for i in re.split(r'(\*\*?)', pattern):
            if i == '*':
                ret.append(r'[^/]*')
            elif i == '**':
                ret.append(r'.*')
            elif i:
                ret.append(re.escape(i))
        return re.compile('^' + ''.join(ret) + '$')

    def _ignore(self, symbols):
        # TODO: let config merge this lists
        configs = []
        configs.append(self.config.get(('abi', self.arch, self.featureset,
                                        self.flavour), {}))
        configs.append(self.config.get(('abi', self.arch, None, self.flavour),
                                       {}))
        configs.append(self.config.get(('abi', self.arch, self.featureset),
                                       {}))
        configs.append(self.config.get(('abi', self.arch), {}))
        configs.append(self.config.get(('abi', None, self.featureset), {}))
        configs.append(self.config.get(('abi',), {}))

        ignores = set()
        for config in configs:
            ignores.update(config.get('ignore-changes', []))

        filtered = set()
        for ignore in ignores:
            type = 'name'
            if ':' in ignore:
                type, ignore = ignore.split(':')
            if type in ('name', 'module'):
                p = self._ignore_pattern(ignore)
                for symbol in symbols.values():
                    if p.match(getattr(symbol, type)):
                        filtered.add(symbol.name)
            else:
                raise NotImplementedError

        return filtered


class CheckImage(object):
    def __init__(self, config, dir, arch, featureset, flavour):
        self.dir = dir
        self.arch, self.featureset, self.flavour = arch, featureset, flavour

        self.changelog = Changelog(version=VersionLinux)[0]

        self.config_entry_base = config.merge('base', arch, featureset,
                                              flavour)
        self.config_entry_build = config.merge('build', arch, featureset,
                                               flavour)
        self.config_entry_image = config.merge('image', arch, featureset,
                                               flavour)

    def __call__(self, out):
        image = self.config_entry_build.get('image-file')
        uncompressed_image = self.config_entry_build \
                                 .get('uncompressed-image-file')

        if not image:
            # TODO: Bail out
            return 0

        image = os.path.join(self.dir, image)
        if uncompressed_image:
            uncompressed_image = os.path.join(self.dir, uncompressed_image)

        fail = 0

        fail |= self.check_size(out, image, uncompressed_image)

        return fail

    def check_size(self, out, image, uncompressed_image):
        value = self.config_entry_image.get('check-size')

        if not value:
            return 0

        dtb_size = 0
        if self.config_entry_image.get('check-size-with-dtb'):
            for dtb in glob.glob(
                    os.path.join(self.dir, 'arch',
                                 self.config_entry_base['kernel-arch'],
                                 'boot/dts/*.dtb')):
                dtb_size = max(dtb_size, os.stat(dtb).st_size)

        size = os.stat(image).st_size + dtb_size

        # 1% overhead is desirable in order to cope with growth
        # through the lifetime of a stable release. Warn if this is
        # not the case.
        usage = (float(size)/value) * 100.0
        out.write('Image size %d/%d, using %.2f%%.  ' % (size, value, usage))
        if size > value:
            out.write('Too large.  Refusing to continue.\n')
            return 1
        elif usage >= 99.0:
            out.write('Under 1%% space in %s.  ' % self.changelog.distribution)
        else:
            out.write('Image fits.  ')
        out.write('Continuing.\n')

        # Also check the uncompressed image
        if uncompressed_image and \
           self.config_entry_image.get('check-uncompressed-size'):
            value = self.config_entry_image.get('check-uncompressed-size')
            size = os.stat(uncompressed_image).st_size
            usage = (float(size)/value) * 100.0
            out.write('Uncompressed Image size %d/%d, using %.2f%%.  ' %
                      (size, value, usage))
            if size > value:
                out.write('Too large.  Refusing to continue.\n')
                return 1
            elif usage >= 99.0:
                out.write('Uncompressed Image Under 1%% space in %s.  ' %
                          self.changelog.distribution)
            else:
                out.write('Uncompressed Image fits.  ')
            out.write('Continuing.\n')

        return 0


class CheckSecureBootConfig:
    def __init__(self, config, dir, arch, *_):
        self.config = config
        self.dir = pathlib.Path(dir)
        self.arch = arch

    def __call__(self, out):
        fail = 0

        if self.config.merge('build', self.arch) \
                      .get('signed-code', False) \
           and not os.getenv('DEBIAN_KERNEL_DISABLE_SIGNED'):
            kconfig = KconfigFile()
            with (self.dir / '.config').open() as fh:
                kconfig.read(fh)

            for name, value in [('EFI_STUB', True),
                                ('LOCK_DOWN_IN_EFI_SECURE_BOOT', True),
                                ('SYSTEM_TRUSTED_KEYS', '""')]:
                if name not in kconfig:
                    out.write(f'Secure Boot: CONFIG_{name} is not defined\n')
                    fail = 1
                elif kconfig[name].value != value:
                    out.write(f'Secure Boot: CONFIG_{name} has wrong value:'
                              f' {kconfig[name].value}\n')
                    fail = 1

            if kconfig.get('MODULE_SIG_KEY').value == '"certs/signing_key.pem"':
                out.write('Secure Boot: CONFIG_MODULE_SIG_KEY has default value\n')
                fail = 1

        return fail


class Main(object):

    checks = {
        'setup': [CheckSecureBootConfig],
        'build': [CheckAbi, CheckImage],
    }

    def __init__(self, dir, arch, featureset, flavour, phase):
        self.args = dir, arch, featureset, flavour
        self.phase = phase

        self.config = ConfigCoreDump(open("debian/config.defines.dump", "rb"))

    def __call__(self):
        fail = 0

        for c in self.checks[self.phase]:
            fail |= c(self.config, *self.args)(sys.stdout)

        return fail


if __name__ == '__main__':
    sys.exit(Main(*sys.argv[1:])())
