#!/usr/bin/env python3

# Testsuite for pySim-shell.py
#
# (C) 2024 by sysmocom - s.f.m.c. GmbH
# All Rights Reserved
#
# Author: Philipp Maier
#
# 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/>.

import os
import sys
import re
import unittest
import yaml
import csv
import inspect
from smartcard.CardType import ATRCardType
from smartcard.CardRequest import CardRequest
from smartcard.util import toHexString, toBytes
from smartcard.System import readers
from smartcard.scard import SCARD_SHARE_EXCLUSIVE
from string import Template
from pySim.utils import i2h, h2i, dec_iccid, boxed_heading_str
from time import sleep
from pathlib import Path

# Paths must originate at the top directory of the project repository
CONFIG_YAML="/tests/pySim-shell_test/config.yaml"
CARD_DATA_CSV="/tests/pySim-shell_test/card_data.csv"

class UnittestUtils(unittest.TestCase):

    # Set to true to regenerate .ok files during test-run
    regenerate = False

    # Set to true to keep all temporary files (.log, .tmp, and files generated from templates)
    keepfiles = False

    # Print content of files that are read and/or compared
    print_content = False

    # The absolute path to the testcase that we are executing (where we find the .ok and .script files along with the
    # concrete testcase implementation
    test_dir = None

    # The absolute path to the top level directory (where we also find pySim-shell.py)
    top_dir = None

    # All cards installed in this test-rig will get a record in this dict that will contain the data from config.toml
    # and card_data.csv.
    cards = {}

    def __init__(self, *kwargs):
        super().__init__(*kwargs)
        self.maxDiff = None
        self.__templates_generated = []

    def __search_card(self, name:str, atr:str, iccid:str, eid:str) -> int:
        """ Search a card by its ATR and ICCID/EID (name only for reference) """
        reader_list=readers()

        print("searching for card:")
        if iccid:
            print("ATR: %s" % atr)
            print("ICCID: %s" % iccid)
        elif eid:
            print("ATR: %s" % atr)
            print("EID: %s" % eid)
        else:
            raise RuntimeError("a card must be searched either by ICCID or EID")

        for i in range(len(reader_list)):

            # Connect to card reader
            try:
                reader_connection = reader_list[i].createConnection()
                reader_connection.connect(mode = SCARD_SHARE_EXCLUSIVE)
            except:
                continue

            # Match ATR
            atr_found = i2h(reader_connection.getATR())
            if atr_found.lower() != atr.lower():
                print(" found ATR: %s -> no match, next card..." % atr_found)
                reader_connection.disconnect()
                continue
            print(" found ATR: %s" % atr_found)

            reader_connection.disconnect()
            reader_connection.connect(mode = SCARD_SHARE_EXCLUSIVE)

            # Match ICCID (UICC or UICC profile on an eUICC) or EID (eUICC)
            if iccid:
                response, sw1, sw2 = reader_connection.transmit(h2i("a0a40000022fe2"))
                if sw1 != 0x9f:
                    raise RuntimeError("unable to select EF.ICCID on card %s (sw1=%02x, sw2=%02x)" % (name, sw1, sw2))
                response, sw1, sw2 = reader_connection.transmit(h2i("a0b000000a"))
                if [sw1, sw2] != [0x90, 0x00]:
                    raise RuntimeError("unable to read EF.ICCID from card %s (sw1=%02x, sw2=%02x)" % (name, sw1, sw2))
                iccid_found = dec_iccid(i2h(response))
                if iccid_found.lower() != iccid.lower():
                    print("  -> found ICCID: %s -> no match, next card..." % iccid_found)
                    reader_connection.disconnect()
                    continue
                print("  -> found ICCID: %s" % iccid_found)
            elif eid:
                response, sw1, sw2 = reader_connection.transmit(h2i("0070000100"))
                if [sw1, sw2] != [0x90, 0x00]:
                    raise RuntimeError("unable to open lchan 1 on card %s" % name)
                response, sw1, sw2 = reader_connection.transmit(h2i("01A4040410A0000005591010FFFFFFFF8900000100"))
                if sw1 != 0x61:
                    raise RuntimeError("unable to select ISD-R on card %s" % name)
                response, sw1, sw2 = reader_connection.transmit(h2i("81E2910006BF3E035C015A"))
                if [sw1, sw2] != [0x61, 0x15]:
                    raise RuntimeError("unable to retrieve EID on card %s" % name)
                response, sw1, sw2 = reader_connection.transmit(h2i("01C0000015"))
                if [sw1, sw2] != [0x90, 0x00]:
                    raise RuntimeError("unable to read EID from card %s" % name)
                eid_found = i2h(response[5:])
                if eid_found.lower() != eid.lower():
                    print("  -> found EID: %s -> no match, next card..." % eid_found)
                    reader_connection.disconnect()
                    continue
                print("  -> found EID: %s" % eid_found)

            # We found the card we were looking for!
            reader_connection.disconnect()
            return i

        raise RuntimeError("missing card %s (atr:%s, eid:%s, iccid:%s), check test setup and configuration file (%s)" %
              (name, atr, eid, iccid, CONFIG_YAML))

    def __read_card_data(self, name, iccid, eid):
        """ Find card data by EID or ICCID (name only for reference) """

        if eid:
            key = 'eid'
            value = eid
        elif iccid:
            key = 'iccid'
            value = iccid
        else:
            raise RuntimeError("iccid and eid parameter missing for card %s, check test setup and configuration file (%s)" % (name, CONFIG_YAML))

        with open(self.top_dir + CARD_DATA_CSV, newline='') as csvfile:
            csv_reader = csv.DictReader(csvfile)
            for row in csv_reader:
                if row.get(key) == value:
                    return row
        raise RuntimeError("missing data for card %s (%s:%s), check card data file (%s)" % (name, key, value, CARD_DATA_CSV))

    def setUp(self):
        """ Initialize testsuite. This method is called automatically. It reads the test configuration file, finds and
        sets the working directory of the executed testcase and ensures that the required card are present. """
        print("")

        testcasepath = inspect.getfile(self.__class__)
        testcasename = testcasepath.split("/")[-2] + "." + self._testMethodName
        print(boxed_heading_str("testcase: " + testcasename))
        self.pysim_shell_log_counter = 0

        # Find directories
        self.test_dir = os.path.dirname(testcasepath)
        print ("Test directory: " + self.test_dir)
        self.top_dir = os.path.abspath(self.test_dir + "/../../../")
        print ("Top directory: " + self.top_dir)

        # Read test config
        with open(self.top_dir + CONFIG_YAML, "r") as cfg:
            config = yaml.load(cfg, Loader=yaml.FullLoader)
        self.keepfiles = config['keepfiles']
        self.regenerate = config['regenerate']
        self.print_content = config['print_content']

        if self.keepfiles:
            print("keepfiles = True, will not delete generated files (.tmp, .log, and files generated from templates) on cleanup")
        if self.regenerate:
            print("regenerate = True, will regenerate .ok files from the .tmp that are generated during the testcase execution")

        # Search cards
        cards = config['cards']
        for card in cards:
            name = card['name']
            atr = card['atr']
            iccid = card.get('iccid', None)
            eid = card.get('eid', None)
            del card['name']
            reader = self.__search_card(name, atr, iccid, eid)
            card_data = self.__read_card_data(name, iccid, eid)
            self.cards[name] = {**{'reader':reader}, **card, **card_data}

        # Print discovered card information
        print("Cards:")
        for card in self.cards:
            print(" %s:" % card)
            for key in self.cards[card]:
                print("  %s: %s" % (key, self.cards[card][key]))

        print("initialization done -- continuing with testcase %s ..." % testcasename)
        print("----------------------------------------------------------------------")
        os.chdir(self.test_dir)

    def tearDown(self):
        """ Cleanup all temporary files (.tmp, files generated from templates and logfiles). This method is
        called automatically """

        print("----------------------------------------------------------------------")
        print("testcase execution done -- cleaning up ...")
        if not self.keepfiles:
            os.system("rm -f ./*.tmp")
            os.system("rm -f ./*.log")
            for template in self.__templates_generated:
                os.system("rm -f ./" + template)

    def runPySimShell(self, cardname:str, script:str,
                      add_adm:bool = False,
                      add_csv:bool = False,
                      no_exceptions = False,
                      skip_card_init = False):

        """ execute pySimShell.py. Each testcase should run pySim-shell at least once. The working directlry is the
        testcase directory.

        Args:
           cardname : name of the card as specified in config file (CONFIG_YAML)
           script : filename of the script file to execute.
           add_adm : use the --adm option to supply an ADM key via the commandline
           add_csv : use the --csv option to supply a CardKeyProvider file (CARD_DATA_CSV)
           no_exceptions : fail the testcase in case any exceptions occurred while running pySim_shell
        """

        logfile_name = "pySim-shell_" + self._testMethodName + "_" + str(self.pysim_shell_log_counter) + ".log"
        self.pysim_shell_log_counter+=1

        # Make sure the script file is available
        if not os.access(script, os.R_OK):
            raise RuntimeError("script file (%s) not found" % script)

        # Form basic commandline
        if cardname not in self.cards:
            raise RuntimeError("unknown cardname %s, check test setup and configuration file (%s)" % (cardname, CONFIG_YAML))
        reader = self.cards[cardname]['reader']
        cmdline = self.top_dir + "/pySim-shell.py -p " + str(reader) + " --script " + str(script) + " --noprompt"

        # Add optional arguments
        if add_adm:
            adm1 = self.cards[cardname]['adm1']
            cmdline += " --pin-adm " + str(adm1)
        if add_csv:
            adm1 = self.cards[cardname]['adm1']
            cmdline += " --csv " + self.top_dir + CARD_DATA_CSV
        if skip_card_init:
            cmdline += " --skip-card-init"

        # Execute commandline
        cmdline += " > " + logfile_name + " 2>&1"
        print("Executing: " + cmdline)
        py_sim_shell_rc = os.system(cmdline)

        # Read logfile
        logfile = open(logfile_name)
        logfile_content = logfile.read()
        if self.print_content:
            print("pySim-shell logfile content: (%s)" % os.path.basename(logfile_name))
            print("-----------------------8<-----------------------")
            print(logfile_content)
            print("-----------------------8<-----------------------")
        logfile.close()

        # Exit early in case pySim-shell ran into a fundamental error
        if py_sim_shell_rc:
            raise RuntimeError("pySim-shell exits with error code %u" % py_sim_shell_rc)

        # Check log for exceptions
        exception_regex_compiled = re.compile('.*EXCEPTION.*|.*is not a recognized command, alias, or macro.*')
        exceptions_strings = re.findall(exception_regex_compiled, logfile_content)
        if exceptions_strings != []:
            print("The following exceptions occurred:")
            for exceptions_string in exceptions_strings:
                print(exceptions_string)
            if no_exceptions:
                self.assertTrue(False, "Unexpected exceptions occurred!")
            else:
                print("Note: the occurrence of exceptions may be expected, the sheer presence of exceptions is not necessarly an error.")

    def __filter_lines(self, text:str, ignore_regex_list:list[str],
                    mask_regex_list:list[str], interesting_regex_list:list[str]):
        """ Filter data from text lines using regex_lists """

        # In case nor ignore or mask regexes are supplied, it makes no sense to continue. In this case, the full,
        # unmodified text is returned.
        if ignore_regex_list is None and mask_regex_list is None:
            return text

        # Compile regexes
        ignore_regex_compiled_list = []
        if ignore_regex_list:
            for regex in ignore_regex_list:
                ignore_regex_compiled_list.append(re.compile(regex))
        mask_regex_compiled_list = []
        if mask_regex_list:
            for regex in mask_regex_list:
                mask_regex_compiled_list.append(re.compile(regex))
        interesting_regex_compiled_list = []
        if interesting_regex_list:
            for regex in interesting_regex_list:
                interesting_regex_compiled_list.append(re.compile(regex))

        # Split up text into individual lines
        text_lines_filtered = []
        text_lines = text.splitlines()

        # Go through the text line by line and apply regexes
        for line in text_lines:
            # Detect interesting line, such a line must not be modified as it is deemed as interesting
            interesting_line = False
            for interesting_regex_compiled in interesting_regex_compiled_list:
                if re.findall(interesting_regex_compiled, line) != []:
                    interesting_line = True
                    break

            # Anything else that is not deemed as interesting gets the ignore+mask regexes applied
            if not interesting_line:
                for ignore_regex_compiled in ignore_regex_compiled_list:
                    line = re.sub(ignore_regex_compiled, "", line)
                for mask_regex_compiled in mask_regex_compiled_list:
                    line = re.sub(mask_regex_compiled, "*", line)

            # Add the modified line to the output (strip spaces)
            line = line.strip()
            if line != "":
                text_lines_filtered.append(line)

        return "\n".join(text_lines_filtered)

    def assertEqualFiles(self, out_file_path:str, ok_file_path:str = None,
                         ignore_regex_list:list[str] = None,
                         mask_regex_list:list[str] = None,
                         interesting_regex_list:list[str] = None):
        """ Compare an out-file against an ok-file. If differences are detected an assertion is thrown and the
        testcase fails. This method can also be used to re-generate the ok-file when self.regenerate is set to
        True.

        Args:
           out_file_path : path to the file which is generated by the testcase (e.g. test.tmp)
           ok_file_path : file to compara against (e.g. test.ok, optional when .ok and .tmp file have the same basename)
           ignore_regex_list : a list with regex strings to remove certain zones in both files before comparison.
           mask_regex_list : a list with regex strings to mask certain zones in both files before comparison.
           interesting_regex_list : a list with regex strings to select certain lines in the file that shall not be
                                    affected by ignore_regex_list.
        """

        if ok_file_path is None:
            path = Path(out_file_path)
            ok_file_path = path.with_suffix('.ok')

        # Read/regenerate files
        out_file = open(out_file_path)
        out_file_content = out_file.read()
        out_file.close()
        if self.regenerate:
            print("File comparison: regenerating (overwriting) content of %s with content of %s" %
                  (os.path.basename(out_file_path), os.path.basename(ok_file_path)))
            ok_file = open(ok_file_path, "w")
            ok_file.write(out_file_content)
            ok_file.close()
            return
        ok_file = open(ok_file_path)
        ok_file_content = ok_file.read()
        ok_file.close()

        # Apply line based filters
        out_file_content = self.__filter_lines(out_file_content, ignore_regex_list, mask_regex_list, interesting_regex_list)
        ok_file_content = self.__filter_lines(ok_file_content, ignore_regex_list, mask_regex_list, interesting_regex_list)
        if self.print_content:
            print("File comparison: the following file contents are compared with each other:")
            print("Comparing (%s)" % os.path.basename(out_file_path))
            print("-----------------------8<-----------------------")
            print(out_file_content)
            print("-----------------------8<-----------------------")
            print("With (%s)" % os.path.basename(ok_file_path))
            print("-----------------------8<-----------------------")
            print(ok_file_content)
            print("-----------------------8<-----------------------")

        # Final comparison
        if out_file_content == ok_file_content:
            print("File comparison: content of %s matches content of %s -- ok" %
                  (os.path.basename(out_file_path), os.path.basename(ok_file_path)))
            return

        # Generate test error (this assertion will always fail, we just use it to generate an error message and a diff)
        self.assertEqual(ok_file_content, out_file_content,
                         "File comparison: content %s does not match content of %s -- test failed" %
                         (os.path.basename(out_file_path), os.path.basename(ok_file_path)))


    def equipTemplate(self, output_path:str, template_path:str = None, **kwargs):
        """ Equip a template file with useful contents. A template may contain placeholders in the form of $MY_VAR (see
        also https://docs.python.org/2/library/string.html#template-strings).

        Args:
           output_path : path to the file which is generated by the from the template (e.g. test.script)
           template_path : path to the template file (e.g. test.template, optional when .template and .script file have
                           the same basename)
        """

        if template_path is None:
            path = Path(output_path)
            template_path = path.with_suffix('.template')

        print("Template: using template %s to generate file %s" % (template_path, output_path))

        template_file = open(template_path)
        template_content = template_file.read()
        template_file.close()

        output_template = Template(template_content)
        output_content = output_template.substitute(**kwargs)

        output_file = open(output_path, "w")
        output_file.write(output_content)
        output_file.close()

        self.__templates_generated.append(output_path)


    def getFileContent(self, file_path:str, substr_regex:str = None) -> str:
        """ Get contents from a file, optionally apply a regex to extract an interesting substring

        Args:
           file_path : path to the file to read (e.g. test.tmp)
           substr_regex : a regex expression to extract an interesting substring from the file content
        """

        print("File: reading content of file %s" % file_path)
        if not os.access(file_path, os.R_OK):
            self.assertTrue(False, "file (%s) not readable!" % file_path)
        file = open(file_path)
        file_content = file.read()
        file.close()
        if self.print_content:
            print("Content of File (%s):" % os.path.basename(file_path))
            print("-----------------------8<-----------------------")
            print(file_content)
            print("-----------------------8<-----------------------")

        if substr_regex:
            substr_regex_compiled = (re.compile(substr_regex))
            file_content = re.search(substr_regex_compiled, file_content).group(1)
            if self.print_content:
                print("Content of File (%s) after regex ('%s') applied:" % (os.path.basename(file_path), substr_regex))
                print("-----------------------8<-----------------------")
                print(file_content)
                print("-----------------------8<-----------------------")

        return file_content
