# Copyright 2024 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import glob
import logging
import os
import re
import subprocess
import sys

dump_tree = False


def add_clang_compat_module_to_sys_path_if_needed():
    try:
        import clang.cindex
    except:
        sys.path.append(os.path.join(os.path.dirname(__file__),
                        'clang_compat'))
        logging.info("Importing clang python compatibility module")
add_clang_compat_module_to_sys_path_if_needed()
import clang.cindex


def get_homebrew_llvm_lib_path():
    try:
        o = subprocess.check_output(['brew', 'ls', 'llvm'])
    except subprocess.CalledProcessError:
        # No brew llvm installed
        return None

    # Brittleness alert! Grepping output of `brew info llvm` for llvm bin path:
    m = re.search('.*/llvm-config', o)
    if m:
        llvm_config_path = m.group(0)

        o = subprocess.check_output([llvm_config_path, '--libdir'])
        llvm_lib_path = o.strip()

        # Make sure --enable-clang and --enable-python options were used:
        if os.path.exists(os.path.join(llvm_lib_path, 'libclang.dylib')) and \
           glob.glob(os.path.join(llvm_lib_path,
                                  'python*', 'site-packages', 'clang')):
            return llvm_lib_path
        else:
            logging.info("Found llvm from homebrew, but not installed with"
                         " --with-clang --with-python")


def load_library():
    try:
        libclang_lib = clang.cindex.conf.lib
    except clang.cindex.LibclangError:
        pass
    except:
        raise
    else:
        return

    if sys.platform == 'darwin':
        libclang_path = get_homebrew_llvm_lib_path()
        if not libclang_path:
            # Try using Xcode's libclang:
            logging.info("llvm from homebrew not found,"
                         " trying Xcode's instead")
            xcode_path = subprocess.check_output(['xcode-select',
                                                  '--print-path']).strip()
            libclang_path = \
                os.path.join(xcode_path,
                             'Toolchains/XcodeDefault.xctoolchain/usr/lib')
        clang.cindex.conf.set_library_path(libclang_path)
    elif sys.platform == 'linux2':
        libclang_path = subprocess.check_output(['llvm-config',
                                                 '--libdir']).strip()
        clang.cindex.conf.set_library_path(libclang_path)

    libclang_lib = clang.cindex.conf.lib


def do_libclang_setup():
    load_library()

    functions = (
        ("clang_Cursor_getCommentRange",
         [clang.cindex.Cursor],
         clang.cindex.SourceRange),
    )

    for f in functions:
        clang.cindex.register_function(clang.cindex.conf.lib, f, False)

def is_node_kind_a_type_decl(kind):
    return kind == clang.cindex.CursorKind.STRUCT_DECL or \
           kind == clang.cindex.CursorKind.ENUM_DECL or \
           kind == clang.cindex.CursorKind.TYPEDEF_DECL

def get_node_spelling(node):
    return clang.cindex.conf.lib.clang_getCursorSpelling(node)

def get_comment_range(node):
    source_range = clang.cindex.conf.lib.clang_Cursor_getCommentRange(node)
    if source_range.start.file is None:
        return None

    return source_range

def get_comment_range_for_decl(node):
    source_range = get_comment_range(node)
    if source_range is None:
        if node.kind == clang.cindex.CursorKind.TYPEDEF_DECL:
            for child in node.get_children():
                if is_node_kind_a_type_decl(child.kind) and len(get_node_spelling(child)) == 0:
                    source_range = get_comment_range(child)

    return source_range

def get_comment_string_for_decl(node):
    comment_range = get_comment_range_for_decl(node)
    comment_string = get_string_from_file(comment_range)
    if comment_string is None:
        return None

    if '@addtogroup' in comment_string:
        # This is actually a block comment, not a comment specifically for this type. Ignore it.
        return None

    return comment_string

def get_string_from_file(source_range):
    if source_range is None:
        return None

    source_range_file = source_range.start.file
    if source_range_file is None:
        return None

    with open(source_range_file.name) as f:
        f.seek(source_range.start.offset)
        return f.read(source_range.end.offset - source_range.start.offset)

def dump_node(node, indent_level=0):
    spelling = node.spelling
    if node.kind == clang.cindex.CursorKind.MACRO_DEFINITION:
        spelling = get_node_spelling(node)

    print "%*s%s> %s" % (indent_level * 2, "", node.kind, spelling)
    print "%*sRange:   %s" % (4 + (indent_level * 2), "", str(node.extent))
    print "%*sComment: %s" % (4 + (indent_level * 2), "", str(get_comment_range_for_decl(node)))

def return_true(node):
    return True

def for_each_node(node, func, level=0, filter_func=return_true):
    if not filter_func(node):
        return

    if dump_tree:
        # Skip over nodes that are added by clang internals
        if node.location.file is not None:
            dump_node(node, level)

    func(node)

    for child in node.get_children():
        for_each_node(child, func, level + 1, filter_func)


def extract_declarations(tu, filenames, func):
    matching_basenames = {os.path.basename(f) for f in filenames}
    def filename_filter_func(node):
        node_file = node.location.file
        if node_file is None:
            return True

        node_filename = node_file.name
        if node_filename is None:
            return True

        base_name = os.path.basename(node_filename)
        return base_name in matching_basenames

    for_each_node(tu.cursor, func, filter_func=filename_filter_func)


def parse_file(filename, filenames, func, internal_sdk_build=False, compiler_flags=None):
    src_dir = os.path.join(os.path.dirname(__file__), "../src")

    args = [ "-I%s/core" % src_dir,
             "-I%s/include" % src_dir,
             "-I%s/fw" % src_dir,
             "-I%s/fw/applib/vendor/uPNG" % src_dir,
             "-I%s/fw/applib/vendor/tinflate" % src_dir,
             "-I%s/fw/vendor/jerryscript/jerry-core" % src_dir,
             "-I%s/libbtutil/include" % src_dir,
             "-I%s/libos/include" % src_dir,
             "-I%s/libutil/includes" % src_dir,
             "-I%s/libc/include" % src_dir,
             "-I%s/../build/src/fw" % src_dir,
             "-I%s/include" % src_dir,
             "-DSDK",
             "-fno-builtin-itoa"]

    # Add header search paths, recursing subdirs:
    for inc_sub_dir in ['fw/util']:
        args += [inc_sub_dir]
        args += ["-I%s" % d for d in glob.glob(os.path.join(src_dir, "%s/*/" % inc_sub_dir))]

    if internal_sdk_build:
        args.append("-DINTERNAL_SDK_BUILD")
    else:
        args.append("-DPUBLIC_SDK")

    args.extend(compiler_flags)

    # Check Clang for unsigned types being undefined
    # https://sourceware.org/ml/newlib/2014/msg00082.html
    # this workaround should be removed when fixed in newlib
    cmd = ['clang'] + ['-dM', '-E', '-']
    try:
        out = subprocess.check_output(cmd, stdin=open('/dev/null')).strip()
        if not isinstance(out, str):
            out = out.decode(sys.stdout.encoding or 'iso8859-1')
    except Exception as err:
        print('Could not run clang type checking %r' % err)
        raise

    if '__UINT8_TYPE__' not in out:
        args.insert(0, r"-D__UINT8_TYPE__=unsigned __INT8_TYPE__")
        args.insert(0, r"-D__UINT16_TYPE__=unsigned __INT16_TYPE__")
        args.insert(0, r"-D__UINT32_TYPE__=unsigned __INT32_TYPE__")
        args.insert(0, r"-D__UINT64_TYPE__=unsigned __INT64_TYPE__")
        args.insert(0, r"-D__UINTPTR_TYPE__=unsigned __INTPTR_TYPE__")

    # Tools pull in time.h from arm toolchain instead of using our core/utils/time/time.h
    # with modified definition of struct tm, so disable accidental include of wrong time.h
    args.insert(0, r"-D_TIME_H_")

    # Try and find our arm toolchain and use the headers from that.
    gcc_path = subprocess.check_output(['which', 'arm-none-eabi-gcc']).strip()
    include_path = os.path.join(os.path.dirname(gcc_path), '../arm-none-eabi/include')
    args.append("-I%s" % include_path)

    # Find the arm-none-eabi-gcc libgcc path including stdbool.h
    cmd = ['arm-none-eabi-gcc'] + ['-E', '-v', '-xc', '-']
    try:
        out = subprocess.check_output(cmd, stdin=open('/dev/null'), stderr=subprocess.STDOUT).strip().splitlines()
        if '#include <...> search starts here:' in out:
            libgcc_include_path = out[out.index('#include <...> search starts here:') + 1].strip()
            args.append("-I%s" % libgcc_include_path)
    except Exception as err:
        print('Could not run arm-none-eabi-gcc path detection %r' % err)

    if not os.path.isfile(filename):
        raise Exception("Invalid filename: " + filename)

    index = clang.cindex.Index.create()
    tu = index.parse(filename, args=args, options=clang.cindex.TranslationUnit.PARSE_DETAILED_PROCESSING_RECORD)

    extract_declarations(tu, filenames, func)

    for d in tu.diagnostics:
        if d.severity >= clang.cindex.Diagnostic.Error \
                and d.spelling != "conflicting types for 'itoa'":
            if d.severity == clang.cindex.Diagnostic.Error:
                error_str = "Error: %s" % d.__repr__()
            elif d.severity == clang.cindex.Diagnostic.Fatal:
                error_str = "Fatal: %s" % d.__repr__()

            class ParsingException(Exception):
                pass

            raise ParsingException(error_str)

do_libclang_setup()