from __future__ import print_function

"""
Handle configuration files with variables.
"""

"""
RCSid:
	$Id: conf.py,v 1.64 2026/04/02 21:00:31 sjg Exp $

	@(#) Copyright (c) 2012-2026 Simon J. Gerraty

	SPDX-License-Identifier: BSD-2-Clause
      
	Please send copies of changes and bug-fixes to:
	sjg@crufty.net

"""

import sys
import os
import vars

if sys.version_info[0] == 2:
    FileNotFoundError = IOError

def host_targets(n=3, m32=False):
    """return a list of n compatible host targets
    starting with current version.
    If n is 0 we do them all.
    """
    u = os.uname()
    sysname = u[0].lower()
    major = int(u[2].split('.')[0])
    machine = u[-1]
    if m32:
        if machine in ['x86_64', 'amd64']:
            machine = 'i386'
        elif machine.endswith('64'):
            machine = machine[:-2]
    r = []
    if not n:
        n = major
    for i in range(n):
        r.append('{0}{1}-{2}'.format(sysname, major - i, machine))
    return r 
    
def host_target():
    """return a string representing os major version and architecture
    eg.: freebsd11-amd64, linux4-x86_64, netbsd7-i386
    """
    return host_targets(1)[0]

def host_target32():
    """return a string representing os major version and architecture
    eg.: freebsd11-amd64, linux4-x86_64, netbsd7-i386
    """
    return host_targets(1,True)[0]

def initConfig(conf=None):
    """common logic to initialize config

    First time we are called we initilize vars and set ``config_path``.
    The variable package will automatically set
    ``progname`` and ``progdir``.

    ``progdir`` is always the first member of ``config_path``
    and supplemented by ``${${progname:R:tu}_CONFIG_PATH}``
    or ``${CONFIG_PATH}`` from the environment.
    """

    if not conf:
        conf = {}
    if '.Vars' in conf:
        return conf
    vs = conf['.Vars'] = vars.Vars(conf)
    vs.set('HOST_TARGET', host_target(), '?=')
    vs.set('HOST_TARGET32', host_target32(), '?=')
    cp = os.getenv(vs.subst('${progname:R:tu}_CONFIG_PATH'))
    if not cp:
        cp = os.getenv('CONFIG_PATH')
        if cp:
            vs.set('config_path', [vs.get('progdir')] + cp.split(':'))
        else:
            vs.set('config_path', [vs.get('progdir')])
    return conf

def stringList(s, conf={}):
    """split "s" in to list members with no leading/trailing white-space"""
    splits = conf.get('list_split', ',')
    r = []
    for a in s.split(splits):
        r.append(a.strip())
    return r

def stringConfig(s, conf=None, vs=None):
    """Add key value pairs to "conf" from a string "s"

    If the string starts with ``{``, it is assumed to be json
    and parsed as such, otherwise;

    string = one and two
    string := one two
    string ?= maybe
    string += three
    list [=] one,two,three
    list [?=] maybe,more
    list [+=] four,five
    dict {=} key = value
    dict {=} key [=] value
    dict {=} key [?=] value
    dict {=} key [+=] value

    values and indeed variable names may reference variables
    that correspond to keys in conf or the environment.
    For example::

    	Home ?= ${HOME:U/homes/${USER}}
    

    Note ``:=`` and ``=`` are equivalent since we always do variable
    expansion.

    Note: if the key for ``+=`` is already in conf and its value
    is a list, we will treat it as ``[+=]``.

    You can of course just use ``=`` and ``+=`` with ``,`` separated
    values and caller can split it to list later.

    For {=} we call ourselves recursively to deal with key/value
    so that it can be any of the other cases above.

    List values are split on ``conf['list_split']`` or ``,``

    With string input ``+=`` will use ``conf['string_sep']`` or space
    as separator.

    If "vs" is not set we need to call initConfig().
    """

    if not vs:
        conf = initConfig(conf)
        vs = conf['.Vars']
    splits = conf.get('list_split', ',')
    sep = conf.get('string_sep', ' ')

    if '{' in s:
        if s.strip().startswith('{'):
            import json

            objs = json.loads(s)
            for k,v in objs.items():
                conf[k] = v
            return conf

    if '[+=]' in s:
        # value is a list to append
        k,v = s.split('[+=]', 1)
        k = vs.subst(k).strip()
        val = stringList(vs.subst(v).strip(), conf)
        if not k in conf:
            conf[k] = val
        else:
            conf[k] += val
    elif '[?=]' in s:
        # value is a list
        k,v = s.split('[?=]', 1)
        k = vs.subst(k).strip()
        if k not in conf:
            conf[k] = stringList(vs.subst(v).strip(), conf)
    elif '[=]' in s:
        # value is a list
        k,v = s.split('[=]', 1)
        k = vs.subst(k).strip()
        conf[k] = stringList(vs.subst(v).strip(), conf)
    elif '{=}' in s:
        # one mapping in a dictionary
        k,v = s.split('{=}', 1)
        k = vs.subst(k).strip()
        if k not in conf:
            conf[k] = {}
        stringConfig(vs.subst(v).strip(), conf[k], vs)
    elif '+=' in s:
        k,v = s.split('+=', 1)
        k = vs.subst(k).strip()
        if not k in conf:
            conf[k] = vs.subst(v).strip()
        elif isinstance(conf[k], list):
            val = stringList(vs.subst(v).strip(), conf)
            conf[k] += val
        else:
            conf[k] += sep + vs.subst(v).strip()
    elif '?=' in s:
        k,v = s.split('?=', 1)
        k = vs.subst(k).strip()
        if k not in conf:
            conf[k] = vs.subst(v).strip()
    elif ':=' in s:
        k,v = s.split(':=', 1)
        k = vs.subst(k).strip()
        conf[k] = vs.subst(v).strip()
    elif '!=' in s:
        k,v = s.split('!=', 1)
        k = vs.subst(k).strip()
        val = vs.subst(v).strip()
        vs.set(k, val, '!=')
        conf[k] = vs.get(k)
    elif '=' in s:
        k,v = s.split('=', 1)
        k = vs.subst(k).strip()
        conf[k] = vs.subst(v).strip()
    return conf

def subst(s, conf=None):
    """substitute any vars in "s" """
    conf = initConfig(conf)
    vs = conf['.Vars']
    return vs.subst(s)

def track_input(vs, name, var='config'):
    """set ${var} ${var}_dir and ${var}_file}"""
    vs.set('.', name)
    vs.set('.', '${.:tA}', ':=')
    vs.set(var, '${.}', ':=')
    vs.set(var+'_dir', '${.:H}', ':=')
    vs.set(var+'_file', '${.:T}', ':=')
    vs.delete('.')

def evalCondition(a):
    """evaluate simple conditionals

    lhs [op [rhs]]
    """
    alen = len(a)
    if alen == 0:                       # single term which was blank
        return False
    op = None
    for i in range(alen):
        if a[i] in ['==','!=','<','<=','>=','>']:
            op = a[i]
            break
    if not op is None:
        if i == 0:                  # lhs blank
            if alen == 1 or a[1] == '""': # rhs blank
                if op == '==':
                    return True
            return False
        if alen == i+1 or a[i+1] == '""': # rhs blank
            if op == '!=':
                return True
            return False
        lhs = ' '.join(a[:i]).strip('"')
        rhs = ' '.join(a[i+1:]).strip('"')
        if op == '==':
            return lhs == rhs
        if op == '!=':
            return lhs != rhs
        if op == '>':
            return int(lhs) > int(rhs)
        if op == '<':
            return int(lhs) < int(rhs)
        if op == '>=':
            return int(lhs) >= int(rhs)
        if op == '<=':
            return int(lhs) <= int(rhs)
    if alen == 1:                   # single term not blank
        return True
    raise ValueError('not implemented: {}'.format(a))

def find_it(name, start='.', stop='/'):
    """find "name" in '.' or one of its parents

    If we reach "stop" without finding it, return None.
    """
    d = os.path.realpath(start)
    stop = os.path.realpath(stop)
    while True:
        f = os.path.join(d,name)
        if os.path.exists(f):
            return f
        if d == stop:
            return None
        d = os.path.dirname(d)

def loadConfig(name, conf=None, raise_not_found=True):
    """Add key value pairs from a file into conf

    If the filename ends with ``.json`` it will be loaded as such,
    (and any variable references left untouched), otherwise;

    A line may be a *simple* conditional:

    	.if condition
    	.elif condition
    	.else
    	.endif

    where "condition" is::

    	${var}
    
        lhs op rhs

    ``op`` is one of ``== != < <= >= >``
    for the single term ``${var}`` the result is true if not empty.

    A directive::
    
        .export var1 [...]
        .info message
    	.[-]include [<"]file[">"

    with ``.-include`` it is not an error if no file is found.
    
    When doing ``.include "file"``, ``${config_dir}`` is searched
    before ``${config_path}``.  Also if ``"file"`` starts with
    ``.../`` we search the current directory and its parents (until we
    hit ``${config_find_stop:U/}``) for ``file`` after stripping off
    the ``.../``.

    Otherwise the line is assumed to be a variable assignment
    and after stripping trailing white-space, 
    is parsed by stringConfig_

    We set ``config`` to the path of the file being read,
    ``config_dir`` to the directory it resides in,
    and ``config_file`` to its basename.

    When doing an include; we set ``included_from``, ``included_from_dir``
    and ``included_from_file`` in a similar manner.

    The variable package will automatically set
    ``progname`` and ``progdir``.

    ``progdir`` is always the first member of ``config_path``
    and supplemented by ``${${progname:R:tu}_CONFIG_PATH}``
    or ``${CONFIG_PATH}`` from the environment.
    
    """

    conf = initConfig(conf)
    vs = conf['.Vars']

    skipping = 0
    fname = None
    if '$' in name:
        name = vs.subst(name)
    _name = name
    if os.path.exists(name):
        fname = name
    else:
        if name.startswith('"'):
            name = name.strip('"')
            dirs = [vs.get('config_dir')] + vs.get('config_path')
        else:
            dirs = vs.get('config_path')

        if name.startswith('<'):
            name = name[1:-1]

        if name.startswith('.../'):
            name = name[4:]
            fname = find_it(name, '.', conf.get('config_find_stop', '/'))

        if not fname:
            for d in dirs:
                n = os.path.join(d,name)
                if os.path.exists(n):
                    fname = n
                    break

    if fname:
        name = fname
    try:
        cf = open(name, 'r')
        track_input(vs, name)
        if name.endswith('.json'):
            import json

            objs = json.load(cf)
            for k,v in objs.items():
                conf[k] = v
            return conf
    except FileNotFoundError:
        if raise_not_found:
            raise
        return conf

    once = getBool(conf, 'config_load_once', True)
    if once:
        included = conf.get('.included', {})
        # Do not read a file more than once
        if not fname:
            fname = os.path.realpath(name)
        if fname in included:
            return conf
        included[fname] = True
        conf['.included'] = included

    for line in cf:
        line = line.strip()
        if line.startswith('#'):
            continue
        if line.startswith('.'):
            w = vs.subst(line).split()
            if w[0] == '.if':
                if skipping == 0:
                    if not evalCondition(w[1:]):
                        skipping = 1
                else:
                    skipping += 1
                continue
            if w[0] == '.else':
                if skipping == 0:
                    skipping = 1
                elif skipping == 1:
                    skipping = 0
                continue
            if w[0] == '.elif':
                if skipping == 0:
                    skipping = 1
                elif skipping == 1:
                    if evalCondition(w[1:]):
                        skipping = 0
                continue
            if w[0] == '.endif':
                if skipping > 0:
                    skipping -= 1
                continue
            if skipping:
                continue
            if w[0] == '.export':
                for k in w[1:]:
                    if k in conf:
                        os.environ[k] = conf[k]
                continue
            if w[0] == '.info':
                print(' '.join(w[1:]))
                continue
            if 'include' in w[0]:
                try:
                    track_input(vs, name, 'included_from')
                    conf = loadConfig(w[1], conf)
                    track_input(vs, name)
                except FileNotFoundError:
                    if not line.startswith('.-'):
                        raise
        if skipping:
            continue
        conf = stringConfig(line, conf)
    return conf

def dumpConfig(conf, file=sys.stdout, internals=False, prefix=''):
    """dump all the vars and values in "conf" to "file"
    unless "internals" is True skip variables that start with '.'
    and apply any "prefix" supplied.
    """
    for k,v in conf.items():
        if not internals and k.startswith('.'):
            continue
        print('{}{} = {}'.format(prefix,k,v), file=file)

def getBool(conf, name, default=False):
    """Builtin bool() is useless for strings like 'no'"""
    
    if name in conf:
        x = conf[name]
        if isinstance(x, bool):
            return x
        if x[0] in '1TtYy':
            return True
        return False
    return default

def lookup(conf, keys, default=None):
    """return value of first key found in "conf", or "default" """
    for k in keys:
        if k in conf:
            return conf[k]
    return default

def str2Class(s, module='__main__'):
    """look up class s in module"""
    return getattr(sys.modules[module], s)

if __name__ == '__main__':
    import getopt
    import sys

    opts,args = getopt.getopt(sys.argv[1:], 'dc:o:p:')
    debug = 0
    conf = {}
    prefix=''
    for o,a in opts:
        if o == '-d':
            debug = int(conf.get('debug', debug))
            debug += 1
            conf['debug'] = debug
        elif o == '-c':
            conf = loadConfig(a, conf)
        elif o == '-o':
            conf = stringConfig(a, conf)
        elif o == '-p':
            prefix = a

    for a in args:
        if '=' in a:
            conf = stringConfig(a, conf)

    if getBool(conf, 'verbose'):
        print('verbose')
    dumpConfig(conf, internals=(debug > 1), prefix=prefix)
