# -*- coding: utf-8 -*-

# Copyright (c) 2020 - 2021 Detlev Offenbach <detlev@die-offenbachs.de>
#

"""
Module implementing utility functions used by the security checks.
"""

import ast
import os

import AstUtilities


class InvalidModulePath(Exception):
    """
    Class defining an exception for invalid module paths.
    """
    pass


def getModuleQualnameFromPath(path):
    """
    Function to get the module's qualified name by analysis of the
    path.
    
    Resolve the absolute pathname and eliminate symlinks. This could result
    in an incorrect name if symlinks are used to restructure the python lib
    directory.
    
    Starting from the right-most directory component look for __init__.py
    in the directory component. If it exists then the directory name is
    part of the module name. Move left to the subsequent directory
    components until a directory is found without __init__.py.
    
    @param path path of the module to be analyzed
    @type str
    @return qualified name of the module
    @rtype str
    @exception InvalidModulePath raised to indicate an invalid module path
    """
    (head, tail) = os.path.split(path)
    if head == '' or tail == '':
        raise InvalidModulePath('Invalid python file path: "{0}"'
                                ' Missing path or file name'.format(path))
    
    qname = [os.path.splitext(tail)[0]]
    while head not in ['/', '.', '']:
        if os.path.isfile(os.path.join(head, '__init__.py')):
            (head, tail) = os.path.split(head)
            qname.insert(0, tail)
        else:
            break

    qualname = '.'.join(qname)
    return qualname


def namespacePathJoin(namespace, name):
    """
    Function to extend a given namespace path.
    
    @param namespace namespace to be extended
    @type str
    @param name node name to be appended
    @type str
    @return extended namespace
    @rtype str
    """
    return "{0}.{1}".format(namespace, name)


def namespacePathSplit(path):
    """
    Function to split a namespace path into a head and tail.
    
    Tail will be the last namespace path component and head will
    be everything leading up to that in the path. This is similar to
    os.path.split.
    
    @param path namespace path to be split
    @type str
    @return tuple containing the namespace path head and tail
    @rtype tuple of (str, str)
    """
    return tuple(path.rsplit('.', 1))


def getAttrQualName(node, aliases):
    """
    Function to get a the full name for the attribute node.

    This will resolve a pseudo-qualified name for the attribute
    rooted at node as long as all the deeper nodes are Names or
    Attributes. This will give you how the code referenced the name but
    will not tell you what the name actually refers to. If we
    encounter a node without a static name we punt with an
    empty string. If this encounters something more complex, such as
    foo.mylist[0](a,b) we just return empty string.
    
    @param node attribute node to be treated
    @type ast.Attribute
    @param aliases dictionary of import aliases
    @type dict
    @return qualified name of the attribute
    @rtype str
    """
    if isinstance(node, ast.Name):
        if node.id in aliases:
            return aliases[node.id]
        return node.id
    elif isinstance(node, ast.Attribute):
        name = "{0}.{1}".format(getAttrQualName(node.value, aliases),
                                node.attr)
        if name in aliases:
            return aliases[name]
        return name
    else:
        return ""


def getCallName(node, aliases):
    """
    Function to extract the call name from an ast.Call node.
    
    @param node node to extract information from
    @type ast.Call
    @param aliases dictionary of import aliases
    @type dict
    @return name of the ast.Call node
    @rtype str
    """
    if isinstance(node.func, ast.Name):
        if deepgetattr(node, 'func.id') in aliases:
            return aliases[deepgetattr(node, 'func.id')]
        return deepgetattr(node, 'func.id')
    elif isinstance(node.func, ast.Attribute):
        return getAttrQualName(node.func, aliases)
    else:
        return ""


def getQualAttr(node, aliases):
    """
    Function to extract the qualified name from an ast.Attribute node.
    
    @param node node to extract information from
    @type ast.Attribute
    @param aliases dictionary of import aliases
    @type dict
    @return qualified attribute name
    @rtype str
    """
    prefix = ""
    if isinstance(node, ast.Attribute):
        try:
            val = deepgetattr(node, 'value.id')
            if val in aliases:
                prefix = aliases[val]
            else:
                prefix = deepgetattr(node, 'value.id')
        except Exception:           # secok
            # We can't get the fully qualified name for an attr, just return
            # its base name.
            pass
        
        return "{0}.{1}".format(prefix, node.attr)
    else:
        return ""


def deepgetattr(obj, attr):
    """
    Function to recurs through an attribute chain to get the ultimate value.
    
    @param obj reference to the object to be recursed
    @type ast.Name or ast.Attribute
    @param attr attribute chain to be parsed
    @type ast.Attribute
    @return ultimate value
    @rtype ast.AST
    """
    for key in attr.split('.'):
        obj = getattr(obj, key)
    return obj


def linerange(node):
    """
    Function to get line number range from a node.
    
    @param node node to extract a line range from
    @type ast.AST
    @return list containing the line number range
    @rtype list of int
    """
    strip = {"body": None, "orelse": None,
             "handlers": None, "finalbody": None}
    for key in strip.keys():
        if hasattr(node, key):
            strip[key] = getattr(node, key)
            node.key = []
    
    lines_min = 9999999999
    lines_max = -1
    for n in ast.walk(node):
        if hasattr(n, 'lineno'):
            lines_min = min(lines_min, n.lineno)
            lines_max = max(lines_max, n.lineno)
    
    for key in strip.keys():
        if strip[key] is not None:
            node.key = strip[key]
    
    if lines_max > -1:
        return list(range(lines_min, lines_max + 1))
    
    return [0, 1]


def linerange_fix(node):
    """
    Function to get a line number range working around a known Python bug
    with multi-line strings.
    
    @param node node to extract a line range from
    @type ast.AST
    @return list containing the line number range
    @rtype list of int
    """
    # deal with multiline strings lineno behavior (Python issue #16806)
    lines = linerange(node)
    if (
        hasattr(node, '_securitySibling') and
        hasattr(node._securitySibling, 'lineno')
    ):
        start = min(lines)
        delta = node._securitySibling.lineno - start
        if delta > 1:
            return list(range(start, node._securitySibling.lineno))
    
    return lines


def escapedBytesRepresentation(b):
    """
    Function to escape bytes for comparison with other strings.
    
    In practice it turns control characters into acceptable codepoints then
    encodes them into bytes again to turn unprintable bytes into printable
    escape sequences.

    This is safe to do for the whole range 0..255 and result matches
    unicode_escape on a unicode string.
    
    @param b bytes object to be escaped
    @type bytes
    @return escaped bytes object
    @rtype bytes
    """
    return b.decode('unicode_escape').encode('unicode_escape')


def concatString(node, stop=None):
    """
    Function to build a string from an ast.BinOp chain.

    This will build a string from a series of ast.Str/ast.Constant nodes
    wrapped in ast.BinOp nodes. Something like "a" + "b" + "c" or "a %s" % val
    etc. The provided node can be any participant in the BinOp chain.
    
    @param node node to be processed
    @type ast.BinOp or ast.Str/ast.Constant
    @param stop base node to stop at
    @type ast.BinOp or ast.Str/ast.Constant
    @return tuple containing the root node of the expression and the string
        value
    @rtype tuple of (ast.AST, str)
    """
    def _get(node, bits, stop=None):
        if node != stop:
            bits.append(
                _get(node.left, bits, stop)
                if isinstance(node.left, ast.BinOp)
                else node.left
            )
            bits.append(
                _get(node.right, bits, stop)
                if isinstance(node.right, ast.BinOp)
                else node.right
            )
    
    bits = [node]
    while isinstance(node._securityParent, ast.BinOp):
        node = node._securityParent
    if isinstance(node, ast.BinOp):
        _get(node, bits, stop)
    
    return (
        node,
        " ".join([x.s for x in bits if AstUtilities.isString(x)])
    )


def getCalledName(node):
    """
    Function to get the function name from an ast.Call node.
    
    An ast.Call node representing a method call will present differently to one
    wrapping a function call: thing.call() vs call(). This helper will grab the
    unqualified call name correctly in either case.
    
    @param node reference to the call node
    @type ast.Call
    @return function name of the node
    @rtype str
    """
    func = node.func
    try:
        return func.attr if isinstance(func, ast.Attribute) else func.id
    except AttributeError:
        return ""

#
# eflag: noqa = M601
