#!/usr/bin/env python

#=============================================================================
# _Spexygen_ - Traceable Specifications Based on doxygen
# Copyright (C) 2024 Quantum Leaps, LLC <www.state-machine.com>
#
# SPDX-License-Identifier: MIT
#
# Permission is hereby granted, free of charge, to any person obtaining a copy of
# this software and associated documentation files (the "Software"), to deal in
# the Software without restriction, including without limitation the rights to
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
# the Software, and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#
# Contact information:
# <www.state-machine.com>
# <info@state-machine.com>
#=============================================================================

'''
Spexygen is a Doxygen extension for creating traceable technical
specifications, such as:
- traceable requirement specifications
- traceable source code
- traceable tests
- traceable specifications of other kind
'''

import sys
import os
import json

class Spexygen:
    '''
    Spexygen class that encapsulates tracing and code generation
    '''

    # public class constants
    VERSION = 224

    UID_DOC  = 1
    UID_CODE = 2
    LEVELS   = ('', '  ', '    ', '      ', '        ')
    _debug   = False

    def __init__(self):
        # private members
        self._uid = None           # current UID
        self._kind = 0             # current UID-kind
        self._fname = ''           # current file name (for generating output)
        self._lnum = 0             # current linie number (for generating output)
        self._file = None          # current file (for generating output)
        self._prefix = ''          # prefix for each generated line
        self._bw_trace = ''        # bw-trace for current uid requested
        self._uid_trace_dict = {}  # UID dictionary collected during tracing
        self._uid_brief_dict = {}  # UID-brief dictionary collected during tracing
        self._uid_traced_list = [] # UID list built during generation
        self._fw_trace_levels = len(Spexygen.LEVELS) - 2 # number of fw-trace levels to generate

    @staticmethod
    def debug(*args, **kwargs):
        '''debug output (if requesed in command-line parameters)
        '''
        if Spexygen._debug:
            print(*args, **kwargs)


    def on_file_pattern(self, fname):
        '''return True if file-name is recognizable to Spexygen
        '''
        return fname.endswith('.dox') or \
               fname.endswith('.h') or fname.endswith('.c') or \
               fname.endswith('.hpp') or fname.endswith('.cpp') or \
               fname.endswith('.py') or fname.endswith('.lnt')

    def on_gen_fw_trace(self, uid_in, level):
        '''recursively generate the forward trace for a "uid"
        '''
        self._uid_traced_list.append(uid_in)
        Spexygen.debug("  level=", level, "uid=", uid_in,
                       "self._uid_traced_list:", self._uid_traced_list)
        for uid in self._uid_trace_dict.get(uid_in):
            if uid not in self._uid_traced_list:
                self._file.write("%s%s- @tr{%s}: <i>%s</i>\n"
                    %(self._prefix, Spexygen.LEVELS[level],
                    uid, self._uid_brief_dict[uid]))
                if level < self._fw_trace_levels:
                    if uid in self._uid_trace_dict:
                        self.on_gen_fw_trace(uid, level+1) # recursive!
                elif level >= len(Spexygen.LEVELS) - 2:
                    #self._file.write("%s%s- ...\n"
                    #    %(self._prefix, Spexygen.LEVELS[level+1]))
                    self._file.write(
                        f"{self._prefix}{Spexygen.LEVELS[level+1]}- ...\n")
                    print(f"  {self._fname}:{self._lnum} too many"\
                          f' forward trace levels for "{uid}"')

    def uid_begin(self, line):
        '''set the current uid 'self._uid' and 'self._kind'
        if UID found in the given 'line'
        '''
        if self._uid != '':
            print("Looking for new UID while previous is still active"\
                  f" {self._uid}")
        kind = Spexygen.UID_DOC
        if (i := line.find('@uid{')) >= 0:
            l = 5
        elif (i := line.find('@code_uid{')) >= 0:
            kind = Spexygen.UID_CODE
            l = 10
        elif (i := line.find('@code_alias{')) >= 0:
            kind = Spexygen.UID_CODE
            l = 12
        else:
            return

        j = line.find(',', i + l)
        if j < 0:
            print("Error: missing ',' in UID definition",
                    "line", self._lnum, ":", i)
            return

        k = line.find('}', j + 1)
        if k < 0:
            print("Error: missing '}' in UID definition",
                    "line", self._lnum, ":", i)
            return

        self._kind = kind
        self._uid = line[i + l:j].strip()
        brief = line[j + 1:k].strip()
        self._bw_trace = ''
        if self._uid not in self._uid_brief_dict:
            self._uid_brief_dict[self._uid] = brief
        Spexygen.debug("  uid:", self._uid, brief)

    def uid_end(self, line):
        '''return True if UID-end was found in the "line"
        '''
        if self._uid == '':
            print("Looking for UID-end while no UID active")
            return False

        if self._kind == Spexygen.UID_DOC:
            if line.find('@enduid') >= 0:
                Spexygen.debug("  end:", self._uid)
                self._uid = ''
                self._bw_trace = ''
                return True
        elif self._kind == Spexygen.UID_CODE:
            if line.find('@endcode_uid') >= 0:
                Spexygen.debug("  end:", self._uid)
                self._uid = ''
                self._bw_trace = ''
                return True
        else:
            print(f"Unknown current UID kind={self._kind}")

        return False

    def uid_bw_trace(self, line):
        '''return True if bw-trace placeholder found in "line"
        '''
        if self._uid == '':
            return False
        l = 0
        i = -1
        if self._kind == Spexygen.UID_DOC:
            if (i := line.find('@uid_bw_trace')) >= 0:
                l = 13
        elif self._kind == Spexygen.UID_CODE:
            if (i := line.find('@code_bw_trace')) >= 0:
                l = 14
        else:
            print(f"Unknown current UID kind={self._kind}")
            return False

        if i < 0:
            return False

        if line.find('{', i+l) == i+l: # parameter present?
            j = line.find('}', i+l+1)
            if j >= 0:
                self._bw_trace = line[i+l+1:j]
            else:
                print("Error: missing '}' for '@uid_bw_trace{' in line",
                        self._lnum, ":", i+l+1)
                return False
        else:
            self._bw_trace = 'empty'
        return True

    def uid_tr(self, line):
        '''return list of backward traces found in a given "line"
        '''
        tr_list = []
        i = 0
        while (i := line.find('@tr{', i)) >= 0:
            j = line.find('}', i + 4)
            if j >= 0:
                tr = line[i + 4:j]
                tr_list.append(tr)
                i = j
            else:
                print("Error: missing '}' for '@tr{' in line",
                      self._lnum, ":", i)
                break
        return tr_list

    def trace(self, fname):
        '''trace a given file and harvest the traces into the
        dictionaries: self._uid_brief_dict and self._uid_trace_dict
        '''
        try:
            f = open(fname, encoding="utf-8")
        except OSError:
            print("File not found", fname)
            return
        with f:
            lines = f.readlines()

        print("Tracing:", fname)
        self._uid = ''
        self._lnum = 0
        for line in lines:
            self._lnum += 1
            if self._uid == '':
                self.uid_begin(line)
            else:
                if self.uid_end(line):
                    pass
                elif self.uid_bw_trace(line):
                    pass
                elif self._bw_trace != '':
                    tr_list = self.uid_tr(line)
                    for tr in tr_list:
                        if not self._uid_trace_dict.get(tr):
                            self._uid_trace_dict[tr] = []
                        if self._uid not in self._uid_trace_dict[tr]:
                            self._uid_trace_dict[tr].append(self._uid)
                            Spexygen.debug(tr, '<-', self._uid)

    def gen_bw_trace(self, line):
        '''find a bw-trace placeholder
        as long as bw-trace found
        return True if bw-trace placeholder or "@tr{}" found
        '''
        if self.uid_bw_trace(line):
            self._file.write(line)
            return True

        if self._bw_trace != '':
            if (i := line.find('@tr{')) >= 0:
                j = line.find('}', i + 4)
                tr = ''
                if j >= 0:
                    tr = line[i + 4:j]
                else:
                    print("Error: missing '}' for '@tr{' in line",
                          self._lnum, ":", i)
                    self._file.write(line)
                    return True
                if tr in self._uid_brief_dict:
                    if self._bw_trace == 'brief':
                        self._file.write(line[:j+1])
                        self._file.write(f": <i>{self._uid_brief_dict[tr]}</i>")
                        self._file.write(line[j+1:])
                    else:
                        self._file.write(line)
                else:
                    print(f'  {self._fname}:{self._lnum} '\
                          f'"{tr}" undefined in backward trace'\
                          f' for UID: "{self._uid}"')
                    self._file.write(line)
                return True
        return False


    def gen_fw_trace(self, line):
        '''find a fw-trace placeholder and generate fw-trace
        return True if the placeholder found
        '''
        l = 0
        if (i := line.find('@uid_fw_trace')) >= 0:
            l = 13
        elif (i := line.find('@code_fw_trace')) >= 0:
            l = 14
        if i < 0:
            return False # placeholder not found

        # parse the optional number of levels parameter
        levels = len(Spexygen.LEVELS) - 2
        if line.find('{', i+l) == i+l: # parameter present?
            j = line.find('}', i+l+1)
            if j >= 0:
                try:
                    levels = int(line[i+l+1:j])
                    if levels >= 1:
                        levels -= 1
                    elif levels > len(Spexygen.LEVELS) - 2:
                        levels = len(Spexygen.LEVELS) - 2
                except ValueError:
                    print("Error: non-numeric parameter in '@uid_fw_trace{}'")
                    return False
            else:
                print("Error: missing '}' for '@uid_fw_trace{' in line",
                        self._lnum, ":", i+l+1)
                return False
        self._fw_trace_levels = levels

        self._prefix = line[:i]
        self._file.write(line)
        if self._uid in self._uid_trace_dict:
            self._uid_traced_list = []
            self.on_gen_fw_trace(self._uid, 0)
        else:
            print(f'  {self._fname}:{self._lnum} empty forward trace'\
                  f' for UID: "{self._uid}"')

        return True

    def gen(self, gendir, fname):
        '''generate a given file, replacing the detected placeholdrs
        with information from the dictionaries:
        self._uid_brief_dict and self._uid_trace_dict
        '''
        try:
            f = open(fname, encoding="utf-8")
        except OSError:
            print("File not found", fname)
            return
        with f:
            lines = f.readlines()

        self._fname = os.path.basename(fname)
        fname = gendir + '/' + self._fname
        try:
            self._file = open(fname, 'w', encoding="utf-8")
        except OSError:
            print("File cannot be written", fname)
            return
        with self._file:
            print("Generating:", fname)
            self._lnum = 0
            self._uid = ''
            self._bw_trace = ''
            for line in lines:
                self._lnum += 1
                if self._uid == '': # no current item
                    self._file.write(line)
                    self.uid_begin(line)
                else:              # have current item
                    if self.uid_end(line):
                        self._file.write(line)
                    elif self.gen_fw_trace(line):
                        pass
                    elif self.gen_bw_trace(line):
                        pass
                    else:
                        self._file.write(line)

    def main(self):
        '''main entry point to Spexygen
        process command-line arguments and run Spexygen
        '''
        print(f"Spexygen: traceable technical documentation system "\
            f"{self.VERSION//100}.{(self.VERSION//10) % 10}."\
            f"{self.VERSION % 10}")
        print("Copyright (c) 2005-2024 Quantum Leaps, www.state-machine.com\n")

        # defaults
        sfname = 'spex.json'
        gendir = 'spex'
        spexyinp = 'Spexyinput'

        # process command-line arguments...
        argv = sys.argv
        argc = len(argv)
        arg  = 1 # skip the 'Spexygen' argument

        if '-h' in argv or '--help' in argv or '?' in argv \
            or '--version' in argv:
            print("Usage: python [-d] spexygen.py [spexyfile]",
                  "\n(deafult spexyfile: 'spexy.json')")
            sys.exit(0)

        if '-d' in argv:
            Spexygen._debug = True
            argv.remove('-d')
            argc -= 1

        if arg < argc:
            # is the next argument a test script?
            sfname = argv[arg]

        # parse the provided spexyfile as json data
        try:
            sfile = open(sfname, encoding="utf-8")
        except OSError:
            print("spexyfile not found:", sfname)
            sys.exit(-1)
        with sfile:
            try:
                spex = json.load(sfile)
            except ValueError as e:
                print("Error in spexyfile:", sfname)
                print(e)
                sys.exit(-1)

        # perform tracing
        key = 'trace'
        if key not in spex:
            key = 'gen' # no 'trace' section means use 'gen' section
        if key not in spex:
            print("Spexygen: nothing to do")
            return
        for path in spex[key]:
            if os.path.isfile(path):
                if self.on_file_pattern(path):
                    self.trace(path)
            elif os.path.isdir(path):
                for fname in os.listdir(path):
                    if self.on_file_pattern(fname):
                        self.trace(path + '/' + fname)
            else:
                print("not exist:", path)
        Spexygen.debug("\n_uid_trace_dict", self._uid_trace_dict)
        Spexygen.debug("\n_uid_brief_dict", self._uid_brief_dict)

        key = 'gen-dir'
        if key in spex:
            gendir = spex[key]
        if gendir == '':
            print("No code generation (empty gen directory)")
            return

        key = 'gen-inc'
        if key in spex:
            spexyinp = spex[key]

        key = 'gen'
        geninc = None
        if key not in spex:
            print("Spexygen: nothing to generate")
            return

        if not os.path.exists(gendir):
            os.mkdir(gendir)
        if spexyinp != '':
            try:
                geninc = open(gendir + '/' + spexyinp, 'w',
                              encoding="utf-8")
            except OSError:
                print("spexyinp cannot be written:", spexyinp)
                sys.exit(-1)
        if geninc:
            geninc.write('INPUT +=')

        # generate documentation
        for path in spex[key]:
            if os.path.isfile(path):
                if self.on_file_pattern(path):
                    self.gen(gendir, path)
                    if geninc:
                        geninc.write(' \\\n' + gendir + '/'
                                     + os.path.basename(path))
            elif os.path.isdir(path):
                for fname in os.listdir(path):
                    if self.on_file_pattern(fname):
                        self.gen(gendir, path + '/' + fname)
                        if geninc:
                            geninc.write(' \\\n' + gendir + '/'
                                        + os.path.basename(fname))
            else:
                print("not exist:", path)

        if geninc:
            geninc.close()

#=============================================================================
if __name__ == "__main__":
    spx = Spexygen()
    spx.main()
