#!/usr/bin/env python3

# Originally modified from:
# https://github.com/zephyrproject-rtos/zephyr/blob/main/scripts/kconfig/kconfig.py

# SPDX-License-Identifier: ISC

# Writes/updates the lvgl/.config configuration file by merging configuration
# files passed as arguments
#
# When fragments haven't changed, lvgl/.config is both the input and the
# output, which just updates it. This is handled in the CMake files.
#
# Also does various checks (most via Kconfiglib warnings).

import argparse
import os
import re
import sys
import textwrap

# Lvgl doesn't use tristate symbols. They're supported here just to make the
# script a bit more generic.
from kconfiglib import (
    Kconfig,
    split_expr,
    expr_value,
    expr_str,
    BOOL,
    TRISTATE,
    TRI_TO_STR,
    AND,
    OR,
)


def main():
    print(sys.argv)
    args = parse_args()

    print("Parsing " + args.kconfig_file)
    kconf = Kconfig(args.kconfig_file, warn_to_stderr=False, suppress_traceback=True)

    if args.handwritten_input_configs:
        # Warn for assignments to undefined symbols, but only for handwritten
        # fragments, to avoid warnings-turned-errors when using an old
        # configuration file together with updated Kconfig files
        kconf.warn_assign_undef = True

        # prj.conf may override settings from the board configuration, so
        # disable warnings about symbols being assigned more than once
        kconf.warn_assign_override = False
        kconf.warn_assign_redun = False

    if args.forced_input_configs:
        # Do not warn on a redundant config.
        # The reason is that a regular .config will be followed by the forced
        # config which under normal circumstances should be identical to the
        # configured setting.
        # Only if user has modified to a value that gets overruled by the forced
        # a warning shall be issued.
        kconf.warn_assign_redun = False

    # Load files
    print(kconf.load_config(args.configs_in[0]))
    for config in args.configs_in[1:]:
        # replace=False creates a merged configuration
        print(kconf.load_config(config, replace=False))

    if args.handwritten_input_configs:
        # Check that there are no assignments to promptless symbols, which
        # have no effect.
        #
        # This only makes sense when loading handwritten fragments and not when
        # loading lvgl/.config, because lvgl/.config is configuration
        # output and also assigns promptless symbols.
        check_no_promptless_assign(kconf)

        # Print warnings for symbols that didn't get the assigned value. Only
        # do this for handwritten input too, to avoid likely unhelpful warnings
        # when using an old configuration and updating Kconfig files.
        check_assigned_sym_values(kconf)
        check_assigned_choice_values(kconf)

    if kconf.syms.get("WARN_DEPRECATED", kconf.y).tri_value == 2:
        check_deprecated(kconf)

    if kconf.syms.get("WARN_EXPERIMENTAL", kconf.y).tri_value == 2:
        check_experimental(kconf)

    # Hack: Force all symbols to be evaluated, to catch warnings generated
    # during evaluation. Wait till the end to write the actual output files, so
    # that we don't generate any output if there are warnings-turned-errors.
    #
    # Kconfiglib caches calculated symbol values internally, so this is still
    # fast.
    kconf.write_config(os.devnull)

    warn_only = r"warning:.*set more than once."

    if kconf.warnings:
        if args.forced_input_configs:
            error_out = False
        else:
            error_out = True

        # Put a blank line between warnings to make them easier to read
        for warning in kconf.warnings:
            print("\n" + warning, file=sys.stderr)

            if not error_out and not re.search(warn_only, warning):
                # The warning is not a warn_only, fail the Kconfig.
                error_out = True

        # Turn all warnings into errors, so that e.g. assignments to undefined
        # Kconfig symbols become errors.
        #
        # A warning is generated by this script whenever a symbol gets a
        # different value than the one it was assigned. Keep that one as just a
        # warning for now.
        if error_out:
            err("Aborting due to Kconfig warnings")

    # Write the merged configuration and the C header
    print(kconf.write_config(args.config_out))
    print(kconf.write_autoconf(args.header_out))

    # Write the list of parsed Kconfig files to a file
    write_kconfig_filenames(kconf, args.kconfig_list_out)


def check_no_promptless_assign(kconf):
    # Checks that no promptless symbols are assigned

    for sym in kconf.unique_defined_syms:
        if sym.user_value is not None and promptless(sym):
            err(
                f"""\
{sym.name_and_loc} is assigned in a configuration file, but is not directly
user-configurable (has no prompt). It gets its value indirectly from other
symbols. """
                + SYM_INFO_HINT.format(sym)
            )


def check_assigned_sym_values(kconf):
    # Verifies that the values assigned to symbols "took" (matches the value
    # the symbols actually got), printing warnings otherwise. Choice symbols
    # are checked separately, in check_assigned_choice_values().

    for sym in kconf.unique_defined_syms:
        if sym.choice:
            continue

        user_value = sym.user_value
        if user_value is None:
            continue

        # Tristate values are represented as 0, 1, 2. Having them as "n", "m",
        # "y" is more convenient here, so convert.
        if sym.type in (BOOL, TRISTATE):
            user_value = TRI_TO_STR[user_value]

        if user_value != sym.str_value:
            msg = (
                f"{sym.name_and_loc} was assigned the value '{user_value}'"
                f" but got the value '{sym.str_value}'. "
            )

            # List any unsatisfied 'depends on' dependencies in the warning
            mdeps = missing_deps(sym)
            if mdeps:
                expr_strs = []
                for expr in mdeps:
                    estr = expr_str(expr)
                    if isinstance(expr, tuple):
                        # Add () around dependencies that aren't plain symbols.
                        # Gives '(FOO || BAR) (=n)' instead of
                        # 'FOO || BAR (=n)', which might be clearer.
                        estr = f"({estr})"
                    expr_strs.append(f"{estr} " f"(={TRI_TO_STR[expr_value(expr)]})")

                msg += (
                    "Check these unsatisfied dependencies: "
                    + ", ".join(expr_strs)
                    + ". "
                )

            warn(msg + SYM_INFO_HINT.format(sym))


def missing_deps(sym):
    # check_assigned_sym_values() helper for finding unsatisfied dependencies.
    #
    # Given direct dependencies
    #
    #     depends on <expr> && <expr> && ... && <expr>
    #
    # on 'sym' (which can also come from e.g. a surrounding 'if'), returns a
    # list of all <expr>s with a value less than the value 'sym' was assigned
    # ("less" instead of "not equal" just to be general and handle tristates,
    # even though lvgl doesn't use them).
    #
    # For string/int/hex symbols, just looks for <expr> = n.
    #
    # Note that <expr>s can be something more complicated than just a symbol,
    # like 'FOO || BAR' or 'FOO = "string"'.

    deps = split_expr(sym.direct_dep, AND)

    if sym.type in (BOOL, TRISTATE):
        return [dep for dep in deps if expr_value(dep) < sym.user_value]
    # string/int/hex
    return [dep for dep in deps if expr_value(dep) == 0]


def check_assigned_choice_values(kconf):
    # Verifies that any choice symbols that were selected (by setting them to
    # y) ended up as the selection, printing warnings otherwise.
    #
    # We check choice symbols separately to avoid warnings when two different
    # choice symbols within the same choice are set to y. This might happen if
    # a choice selection from a board defconfig is overridden in a prj.conf,
    # for example. The last choice symbol set to y becomes the selection (and
    # all other choice symbols get the value n).
    #
    # Without special-casing choices, we'd detect that the first symbol set to
    # y ended up as n, and print a spurious warning.

    for choice in kconf.unique_choices:
        if choice.user_selection and choice.user_selection is not choice.selection:

            warn(
                f"""\
The choice symbol {choice.user_selection.name_and_loc} was selected (set =y),
but {choice.selection.name_and_loc if choice.selection else "no symbol"} ended
up as the choice selection. """
                + SYM_INFO_HINT.format(choice.user_selection)
            )


# Hint on where to find symbol information. Used like
# SYM_INFO_HINT.format(sym).
SYM_INFO_HINT = """\
See https://docs.lvgl.io/master/intro/add-lvgl-to-your-project/configuration.html
look up {0.name} in the menuconfig/guiconfig interface. The Application
Development Primer, Setting Configuration Values, and Kconfig - Tips and Best
Practices sections of the manual might be helpful too.\
"""


def check_deprecated(kconf):
    deprecated = kconf.syms.get("DEPRECATED")
    dep_expr = kconf.n if deprecated is None else deprecated.rev_dep

    if dep_expr is not kconf.n:
        selectors = [s for s in split_expr(dep_expr, OR) if expr_value(s) == 2]
        for selector in selectors:
            selector_name = split_expr(selector, AND)[0].name
            warn(f"Deprecated symbol {selector_name} is enabled.")


def check_experimental(kconf):
    experimental = kconf.syms.get("EXPERIMENTAL")
    dep_expr = kconf.n if experimental is None else experimental.rev_dep

    if dep_expr is not kconf.n:
        selectors = [s for s in split_expr(dep_expr, OR) if expr_value(s) == 2]
        for selector in selectors:
            selector_name = split_expr(selector, AND)[0].name
            warn(f"Experimental symbol {selector_name} is enabled.")


def promptless(sym):
    # Returns True if 'sym' has no prompt. Since the symbol might be defined in
    # multiple locations, we need to check all locations.

    return not any(node.prompt for node in sym.nodes)


def write_kconfig_filenames(kconf, kconfig_list_path):
    # Writes a sorted list with the absolute paths of all parsed Kconfig files
    # to 'kconfig_list_path'. The paths are realpath()'d, and duplicates are
    # removed. This file is used by CMake to look for changed Kconfig files. It
    # needs to be deterministic.

    with open(kconfig_list_path, "w") as out:
        for path in sorted(
            {
                os.path.realpath(os.path.join(kconf.srctree, path))
                for path in kconf.kconfig_filenames
            }
        ):
            print(path, file=out)


def parse_args():
    parser = argparse.ArgumentParser(allow_abbrev=False)

    parser.add_argument(
        "--handwritten-input-configs",
        action="store_true",
        help="Assume the input configuration fragments are "
        "handwritten fragments and do additional checks "
        "on them, like no promptless symbols being "
        "assigned",
    )
    parser.add_argument(
        "--forced-input-configs",
        action="store_true",
        help="Indicate the input configuration files are "
        "followed by an forced configuration file."
        "The forced configuration is used to forcefully "
        "set specific configuration settings to a "
        "pre-defined value and thereby remove any user "
        " adjustments.",
    )
    parser.add_argument("kconfig_file", help="Top-level Kconfig file")
    parser.add_argument("config_out", help="Output configuration file")
    parser.add_argument("header_out", help="Output header file")
    parser.add_argument(
        "kconfig_list_out", help="Output file for list of parsed Kconfig files"
    )
    parser.add_argument(
        "configs_in",
        nargs="+",
        help="Input configuration fragments. Will be merged " "together.",
    )

    return parser.parse_args()


def warn(msg):
    # Use a large fill() width to try to avoid linebreaks in the symbol
    # reference link, and add some extra newlines to set the message off from
    # surrounding text (this usually gets printed as part of spammy CMake
    # output)
    print("\n" + textwrap.fill("warning: " + msg, 100) + "\n", file=sys.stderr)


def err(msg):
    sys.exit("\n" + textwrap.fill("error: " + msg, 100) + "\n")


if __name__ == "__main__":
    main()