#!/usr/bin/env python3
#
# Exercise QEMU generated ACPI/SMBIOS tables using biosbits,
# https://biosbits.org/
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# 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, see <http://www.gnu.org/licenses/>.
#
#
# Author:
#  Ani Sinha <anisinha@redhat.com>

# pylint: disable=invalid-name
# pylint: disable=consider-using-f-string

"""
This is QEMU ACPI/SMBIOS functional tests using biosbits.
Biosbits is available originally at https://biosbits.org/.
This test uses a fork of the upstream bits and has numerous fixes
including an upgraded acpica. The fork is located here:
https://gitlab.com/qemu-project/biosbits-bits .
"""

import os
import re
import shutil
import subprocess

from typing import (
    List,
    Optional,
    Sequence,
)
from qemu.machine import QEMUMachine
from qemu_test import (QemuSystemTest, Asset, skipIfMissingCommands,
                       skipIfNotMachine)


# default timeout of 120 secs is sometimes not enough for bits test.
BITS_TIMEOUT = 200

class QEMUBitsMachine(QEMUMachine): # pylint: disable=too-few-public-methods
    """
    A QEMU VM, with isa-debugcon enabled and bits iso passed
    using -cdrom to QEMU commandline.

    """
    def __init__(self,
                 binary: str,
                 *,
                 args: Sequence[str] = (),
                 wrapper: Sequence[str] = (),
                 name: Optional[str] = None,
                 base_temp_dir: str = "/var/tmp",
                 debugcon_log: str = "debugcon-log.txt",
                 debugcon_addr: str = "0x403",
                 qmp_timer: Optional[float] = None):
        # pylint: disable=too-many-arguments

        if name is None:
            name = "qemu-bits-%d" % os.getpid()
        super().__init__(binary, args, wrapper=wrapper, name=name,
                         base_temp_dir=base_temp_dir,
                         qmp_timer=qmp_timer)
        self.debugcon_log = debugcon_log
        self.debugcon_addr = debugcon_addr
        self.base_temp_dir = base_temp_dir

    @property
    def _base_args(self) -> List[str]:
        args = super()._base_args
        args.extend([
            '-chardev',
            'file,path=%s,id=debugcon' %os.path.join(self.base_temp_dir,
                                                     self.debugcon_log),
            '-device',
            'isa-debugcon,iobase=%s,chardev=debugcon' %self.debugcon_addr,
        ])
        return args

    def base_args(self):
        """return the base argument to QEMU binary"""
        return self._base_args

@skipIfMissingCommands("xorriso", "mformat")
@skipIfNotMachine("x86_64")
class AcpiBitsTest(QemuSystemTest): #pylint: disable=too-many-instance-attributes
    """
    ACPI and SMBIOS tests using biosbits.
    """
    # in slower systems the test can take as long as 3 minutes to complete.
    timeout = BITS_TIMEOUT

    # following are some standard configuration constants
    # gitlab CI does shallow clones of depth 20
    BITS_INTERNAL_VER = 2020
    # commit hash must match the artifact tag below
    BITS_COMMIT_HASH = 'c7920d2b'
    # this is the latest bits release as of today.
    BITS_TAG = "qemu-bits-10262023"

    ASSET_BITS = Asset(("https://gitlab.com/qemu-project/"
                        "biosbits-bits/-/jobs/artifacts/%s/"
                        "download?job=qemu-bits-build" % BITS_TAG),
                       '1b8dd612c6831a6b491716a77acc486666aaa867051cdc34f7ce169c2e25f487')

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._vm = None

        self._debugcon_addr = '0x403'
        self._debugcon_log = 'debugcon-log.txt'

    def _print_log(self, log):
        self.log.info('\nlogs from biosbits follows:')
        self.log.info('==========================================\n')
        self.log.info(log)
        self.log.info('==========================================\n')

    def copy_bits_config(self):
        """ copies the bios bits config file into bits.
        """
        bits_config_file = self.data_file('acpi-bits',
                                          'bits-config',
                                          'bits-cfg.txt')
        target_config_dir = self.scratch_file('bits-%d' %
                                              self.BITS_INTERNAL_VER,
                                              'boot')
        self.assertTrue(os.path.exists(bits_config_file))
        self.assertTrue(os.path.exists(target_config_dir))
        shutil.copy2(bits_config_file, target_config_dir)
        self.log.info('copied config file %s to %s',
                      bits_config_file, target_config_dir)

    def copy_test_scripts(self):
        """copies the python test scripts into bits. """

        bits_test_dir = self.data_file('acpi-bits', 'bits-tests')
        target_test_dir = self.scratch_file('bits-%d' % self.BITS_INTERNAL_VER,
                                            'boot', 'python')

        self.assertTrue(os.path.exists(bits_test_dir))
        self.assertTrue(os.path.exists(target_test_dir))

        for filename in os.listdir(bits_test_dir):
            if os.path.isfile(os.path.join(bits_test_dir, filename)) and \
               filename.endswith('.py2'):
                # All test scripts are named with extension .py2 so that
                # they are not run by accident.
                #
                # These scripts are intended to run inside the test VM
                # and are written for python 2.7 not python 3, hence
                # would cause syntax errors if loaded ouside the VM.
                newfilename = os.path.splitext(filename)[0] + '.py'
                shutil.copy2(os.path.join(bits_test_dir, filename),
                             os.path.join(target_test_dir, newfilename))
                self.log.info('copied test file %s to %s',
                              filename, target_test_dir)

                # now remove the pyc test file if it exists, otherwise the
                # changes in the python test script won't be executed.
                testfile_pyc = os.path.splitext(filename)[0] + '.pyc'
                if os.access(os.path.join(target_test_dir, testfile_pyc),
                             os.F_OK):
                    os.remove(os.path.join(target_test_dir, testfile_pyc))
                    self.log.info('removed compiled file %s',
                                  os.path.join(target_test_dir,
                                               testfile_pyc))

    def fix_mkrescue(self, mkrescue):
        """ grub-mkrescue is a bash script with two variables, 'prefix' and
            'libdir'. They must be pointed to the right location so that the
            iso can be generated appropriately. We point the two variables to
            the directory where we have extracted our pre-built bits grub
            tarball.
        """
        grub_x86_64_mods = self.scratch_file('grub-inst-x86_64-efi')
        grub_i386_mods = self.scratch_file('grub-inst')

        self.assertTrue(os.path.exists(grub_x86_64_mods))
        self.assertTrue(os.path.exists(grub_i386_mods))

        new_script = ""
        with open(mkrescue, 'r', encoding='utf-8') as filehandle:
            orig_script = filehandle.read()
            new_script = re.sub('(^prefix=)(.*)',
                                r'\1"%s"' %grub_x86_64_mods,
                                orig_script, flags=re.M)
            new_script = re.sub('(^libdir=)(.*)', r'\1"%s/lib"' %grub_i386_mods,
                                new_script, flags=re.M)

        with open(mkrescue, 'w', encoding='utf-8') as filehandle:
            filehandle.write(new_script)

    def generate_bits_iso(self):
        """ Uses grub-mkrescue to generate a fresh bits iso with the python
            test scripts
        """
        bits_dir = self.scratch_file('bits-%d' % self.BITS_INTERNAL_VER)
        iso_file = self.scratch_file('bits-%d.iso' % self.BITS_INTERNAL_VER)
        mkrescue_script = self.scratch_file('grub-inst-x86_64-efi',
                                            'bin',
                                            'grub-mkrescue')

        self.assertTrue(os.access(mkrescue_script,
                                  os.R_OK | os.W_OK | os.X_OK))

        self.fix_mkrescue(mkrescue_script)

        self.log.info('using grub-mkrescue for generating biosbits iso ...')

        try:
            if os.getenv('V') or os.getenv('BITS_DEBUG'):
                proc = subprocess.run([mkrescue_script, '-o', iso_file,
                                       bits_dir],
                                      stdout=subprocess.PIPE,
                                      stderr=subprocess.STDOUT,
                                      check=True)
                self.log.info("grub-mkrescue output %s", proc.stdout)
            else:
                subprocess.check_call([mkrescue_script, '-o',
                                      iso_file, bits_dir],
                                      stderr=subprocess.DEVNULL,
                                      stdout=subprocess.DEVNULL)
        except Exception as e: # pylint: disable=broad-except
            self.skipTest("Error while generating the bits iso. "
                          "Pass V=1 in the environment to get more details. "
                          + str(e))

        self.assertTrue(os.access(iso_file, os.R_OK))

        self.log.info('iso file %s successfully generated.', iso_file)

    def setUp(self): # pylint: disable=arguments-differ
        super().setUp()

        prebuiltDir = self.scratch_file('prebuilt')
        if not os.path.isdir(prebuiltDir):
            os.mkdir(prebuiltDir, mode=0o775)

        bits_zip_file = self.scratch_file('prebuilt',
                                          'bits-%d-%s.zip'
                                          %(self.BITS_INTERNAL_VER,
                                            self.BITS_COMMIT_HASH))
        grub_tar_file = self.scratch_file('prebuilt',
                                          'bits-%d-%s-grub.tar.gz'
                                          %(self.BITS_INTERNAL_VER,
                                            self.BITS_COMMIT_HASH))

        # extract the bits artifact in the temp working directory
        self.archive_extract(self.ASSET_BITS, sub_dir='prebuilt', format='zip')

        # extract the bits software in the temp working directory
        self.archive_extract(bits_zip_file)
        self.archive_extract(grub_tar_file)

        self.copy_test_scripts()
        self.copy_bits_config()
        self.generate_bits_iso()

    def parse_log(self):
        """parse the log generated by running bits tests and
           check for failures.
        """
        debugconf = self.scratch_file(self._debugcon_log)
        log = ""
        with open(debugconf, 'r', encoding='utf-8') as filehandle:
            log = filehandle.read()

        matchiter = re.finditer(r'(.*Summary: )(\d+ passed), (\d+ failed).*',
                                log)
        for match in matchiter:
            # verify that no test cases failed.
            try:
                self.assertEqual(match.group(3).split()[0], '0',
                                 'Some bits tests seems to have failed. ' \
                                 'Please check the test logs for more info.')
            except AssertionError as e:
                self._print_log(log)
                raise e
            if os.getenv('V') or os.getenv('BITS_DEBUG'):
                self._print_log(log)

    def tearDown(self):
        """
           Lets do some cleanups.
        """
        if self._vm:
            self.assertFalse(not self._vm.is_running)
        super().tearDown()

    def test_acpi_smbios_bits(self):
        """The main test case implementation."""

        self.set_machine('pc')
        iso_file = self.scratch_file('bits-%d.iso' % self.BITS_INTERNAL_VER)

        self.assertTrue(os.access(iso_file, os.R_OK))

        self._vm = QEMUBitsMachine(binary=self.qemu_bin,
                                   base_temp_dir=self.workdir,
                                   debugcon_log=self._debugcon_log,
                                   debugcon_addr=self._debugcon_addr)

        self._vm.add_args('-cdrom', '%s' %iso_file)
        # the vm needs to be run under icount so that TCG emulation is
        # consistent in terms of timing. smilatency tests have consistent
        # timing requirements.
        self._vm.add_args('-icount', 'auto')
        # currently there is no support in bits for recognizing 64-bit SMBIOS
        # entry points. QEMU defaults to 64-bit entry points since the
        # upstream commit bf376f3020 ("hw/i386/pc: Default to use SMBIOS 3.0
        # for newer machine models"). Therefore, enforce 32-bit entry point.
        self._vm.add_args('-machine', 'smbios-entry-point-type=32')

        # enable console logging
        self._vm.set_console()
        self._vm.launch()


        # biosbits has been configured to run all the specified test suites
        # in batch mode and then automatically initiate a vm shutdown.
        self._vm.event_wait('SHUTDOWN', timeout=BITS_TIMEOUT)
        self._vm.wait(timeout=None)
        self.log.debug("Checking console output ...")
        self.parse_log()

if __name__ == '__main__':
    QemuSystemTest.main()
