#!/usr/bin/env python3
#
# Copyright (c) 2024 Raspberry Pi (Trading) Ltd.
#
# SPDX-License-Identifier: BSD-3-Clause
#
#
# A script to ensure that all declared configuration options match across both
# CMake and Bazel.
#
# Usage:
#
# Run from anywhere.

from dataclasses import dataclass
import glob
import logging
import os
from pathlib import Path
import re
import subprocess
import sys
from typing import Dict

from bazel_common import SDK_ROOT, setup_logging

_LOG = logging.getLogger(__file__)

CMAKE_FILE_TYPES = (
    "**/CMakeLists.txt",
    "**/*.cmake",
)

BAZEL_FILE_TYPES = (
    "**/BUILD.bazel",
    "**/*.bzl",
    "**/*.BUILD",
)

ATTR_REGEX = re.compile(r",?\s*(?P<key>[^=]+)=(?P<value>[^,]+)")

BAZEL_MODULE_REGEX = re.compile(r'\s*commit\s*=\s*\"(?P<commit>[0-9a-fA-F]+)\"\s*,\s*#\s*keep-in-sync-with-submodule:\s*(?P<dependency>\S*)')

BAZEL_VERSION_REGEX = re.compile(r'module\(\s*name\s*=\s*"pico-sdk",\s*version\s*=\s*"(?P<sdk_version>[^"]+)",?\s*\)')

CMAKE_VERSION_REGEX = re.compile(r'^[^#]*set\(PICO_SDK_VERSION_(?P<part>\S+)\s+(?P<value>\S+)\)')

# Sometimes the build systems are supposed to be implemented differently. This
# allowlist permits the descriptions to differ between CMake and Bazel.
BUILD_SYSTEM_DESCRIPTION_DIFFERENCE_ALLOWLIST = (
    # Minor semantic differences in Bazel.
    "PICO_DEFAULT_BOOT_STAGE2_FILE",
    # In Bazel, not overridable by user environment variables (only flags).
    "PICO_BOARD",
    # In Bazel, it's a build label rather than a path.
    "PICO_CMSIS_PATH",
    # In Bazel, the semantics of embedded binary info are slightly different.
    "PICO_PROGRAM_NAME",
    "PICO_PROGRAM_DESCRIPTION",
    "PICO_PROGRAM_URL",
    "PICO_PROGRAM_VERSION_STRING",
    "PICO_TARGET_NAME",
)

CMAKE_ONLY_ALLOWLIST = (
    # Not relevant to Bazel: toolchain is fetched dynamically, and can be
    # overridden with native Bazel features.
    "PICO_TOOLCHAIN_PATH",
    # Bazel uses native --platforms mechanics.
    "PICO_PLATFORM",
    # Named PICO_TOOLCHAIN in Bazel.
    "PICO_COMPILER",
    # Entirely irrelevant to Bazel, use Bazel platforms:
    #     https://bazel.build/extending/platforms
    "PICO_CMAKE_PRELOAD_PLATFORM_FILE",
    # Both of these are marked as TODO and not actually set up in CMake.
    "PICO_CMSIS_VENDOR",
    "PICO_CMSIS_DEVICE",
    # Bazel build uses PICO_CONFIG_EXTRA_HEADER and PICO_CONFIG_PLATFORM_HEADER
    # instead.
    "PICO_CONFIG_HEADER_FILES",
    "PICO_RP2040_CONFIG_HEADER_FILES",
    "PICO_HOST_CONFIG_HEADER_FILES",
    # Bazel uses PICO_CONFIG_HEADER.
    "PICO_BOARD_CMAKE_DIRS",
    "PICO_BOARD_HEADER_FILE",
    "PICO_BOARD_HEADER_DIRS",
    # Bazel supports this differently.
    # TODO: Provide a helper rule for explicitly generating a UF2 so users don't
    # have to write out a bespoke run_binary.
    "PICO_NO_UF2",
    # Bazel will not provide a default for this.
    # TODO: Provide handy rules for PIOASM so users don't have to write out a
    # bespoke run_binary.
    "PICO_DEFAULT_PIOASM_OUTPUT_FORMAT",
    # Bazel always has picotool.
    "PICO_NO_PICOTOOL",
    # These aren't supported as build flags in Bazel. Prefer to
    # set these in board header files like other SDK defines.
    "CYW43_DEFAULT_PIN_WL_REG_ON",
    "CYW43_DEFAULT_PIN_WL_DATA_OUT",
    "CYW43_DEFAULT_PIN_WL_DATA_IN",
    "CYW43_DEFAULT_PIN_WL_HOST_WAKE",
    "CYW43_DEFAULT_PIN_WL_CLOCK",
    "CYW43_DEFAULT_PIN_WL_CS",
    "CYW43_PIO_CLOCK_DIV_INT",
    "CYW43_PIO_CLOCK_DIV_FRAC",
    "CYW43_PIO_CLOCK_DIV_DYNAMIC",
)

BAZEL_ONLY_ALLOWLIST = (
    # Allows users to fully replace the final image for boot_stage2.
    "PICO_BOOT_STAGE2_LINK_IMAGE",
    # Allows users to inject an alternative TinyUSB library since TinyUSB
    # doesn't have native Bazel support.
    "PICO_TINYUSB_LIB",
    # Bazel can't do pico_set_* for the binary info defines, so there's a
    # different mechanism.
    "PICO_DEFAULT_BINARY_INFO",
    # Bazel analogue for PICO_CMAKE_BUILD_TYPE.
    "PICO_BAZEL_BUILD_TYPE",
    # Different mechanism for setting a linker script that is less complex.
    "PICO_DEFAULT_LINKER_SCRIPT",
    # Not yet documented in CMake (but probably should be):
    "PICO_CMAKE_BUILD_TYPE",
    # Replaces PICO_RP2040_CONFIG_HEADER_FILES and
    # PICO_HOST_CONFIG_HEADER_FILES.
    "PICO_CONFIG_EXTRA_HEADER",
    "PICO_CONFIG_PLATFORM_HEADER",
    # Effectively replaces:
    # - PICO_BOARD_CMAKE_DIRS
    # - PICO_BOARD_HEADER_FILE
    # - PICO_BOARD_HEADER_DIRS
    "PICO_CONFIG_HEADER",
    # Bazel configuration for 3p deps.
    "PICO_BTSTACK_CONFIG",
    "PICO_LWIP_CONFIG",
    "PICO_MBEDTLS_CONFIG",
    "PICO_FREERTOS_LIB",
    "PICO_MBEDTLS_LIB",
    # CMake has PICO_DEFAULT_CLIB, but it's not user-facing.
    "PICO_CLIB",
    # Selecting default library implementations.
    "PICO_MULTICORE_ENABLED",
    "PICO_DEFAULT_DOUBLE_IMPL",
    "PICO_DEFAULT_FLOAT_IMPL",
    "PICO_DEFAULT_DIVIDER_IMPL",
    "PICO_DEFAULT_PRINTF_IMPL",
    "PICO_DEFAULT_RAND_IMPL",
    "PICO_BINARY_INFO_ENABLED",
    "PICO_ASYNC_CONTEXT_IMPL",
    # Allows selection of clang/gcc when using the dynamically fetched
    # toolchains.
    "PICO_TOOLCHAIN",
    # In CMake, linking these libraries also sets defines for adjacent
    # libraries. That's an antipattern in Bazel, so there's flags to control
    # which modules to enable instead.
    "PICO_BT_ENABLE_BLE",
    "PICO_BT_ENABLE_CLASSIC",
    "PICO_BT_ENABLE_MESH",
    # Compilation modes remove, These allow the user to remove the defaults, with no args. See --compilation_mode cmd line option
    "PICO_COMPILATION_NO_OPT_ARGS",
    "PICO_COMPILATION_NO_DEBUG_ARGS",
    "PICO_COMPILATION_NO_FASTBUILD_ARGS",
)


@dataclass
class Option:
    name: str
    description: str
    attrs: Dict[str, str]

    def matches(self, other):
        matches = (self.name == other.name) and (self.attrs == other.attrs)
        if not self.name in BUILD_SYSTEM_DESCRIPTION_DIFFERENCE_ALLOWLIST:
            matches = matches and (self.description == other.description)
        return matches


def FindKnownOptions(option_pattern_matcher, file_paths):
    pattern = re.compile(
        option_pattern_matcher
        + r":\s+(?P<name>\w+),\s+(?P<description>[^,]+)(?:,\s+(?P<attrs>.*))?$"
    )
    options = {}
    for p in file_paths:
        with open(p, "r") as f:
            for line in f:
                match = re.search(pattern, line)
                if not match:
                    continue

                attrs = {
                    m.group("key"): m.group("value")
                    for m in re.finditer(ATTR_REGEX, match.group("attrs"))
                }

                options[match.group("name")] = Option(
                    match.group("name"),
                    match.group("description"),
                    attrs,
                )
    return options


def OptionsAreEqual(bazel_option, cmake_option, warnings_as_errors):
    if bazel_option is None:
        if cmake_option.name in CMAKE_ONLY_ALLOWLIST:
            return True
        _LOG.warning(f"    {cmake_option.name} does not exist in Bazel")
        return not warnings_as_errors
    elif cmake_option is None:
        if bazel_option.name in BAZEL_ONLY_ALLOWLIST:
            return True
        _LOG.warning(f"    {bazel_option.name} does not exist in CMake")
        return not warnings_as_errors
    elif not bazel_option.matches(cmake_option):
        _LOG.error("    Bazel and CMAKE definitions do not match:")
        _LOG.error(f"    [CMAKE]    {bazel_option}")
        _LOG.error(f"    [BAZEL]    {cmake_option}")
        return False

    return True


def CompareOptions(bazel_pattern, bazel_files, cmake_pattern, cmake_files, warnings_as_errors=True):
    bazel_options = FindKnownOptions(bazel_pattern, bazel_files)
    cmake_options = FindKnownOptions(cmake_pattern, cmake_files)

    are_equal = True
    both = {}
    both.update(bazel_options)
    both.update(cmake_options)
    for k in both.keys():
        if not OptionsAreEqual(
            bazel_options.get(k, None),
            cmake_options.get(k, None),
            warnings_as_errors,
        ):
            are_equal = False
    return are_equal

def CompareExternalDependencyVersions():
    pattern = re.compile(BAZEL_MODULE_REGEX)
    all_okay = True
    with open(Path(SDK_ROOT) / "MODULE.bazel", "r") as bazel_module_file:
        for line in bazel_module_file:
            maybe_match = pattern.match(line)
            if not maybe_match:
                continue

            current_submodule_pin = subprocess.run(
                ("git", "-C", SDK_ROOT, "rev-parse", f'HEAD:{maybe_match.group("dependency")}'),
                text=True,
                check=True,
                capture_output=True,
            ).stdout.strip()
            if current_submodule_pin != maybe_match.group("commit"):
                _LOG.error("    External pins for %s do not match:", maybe_match.group("dependency"))
                _LOG.error("    [CMAKE]    %s", current_submodule_pin)
                _LOG.error("    [BAZEL]    %s", maybe_match.group("commit"))
                all_okay = False
            else:
                _LOG.info("    External pins for %s match!", maybe_match.group("dependency"))

    return all_okay

def CompareSdkVersion():
    # Find version string specified in Bazel.
    bazel_module_file_path = Path(SDK_ROOT) / "MODULE.bazel"
    bazel_module_file_contents = bazel_module_file_path.read_text()
    bazel_sdk_version = BAZEL_VERSION_REGEX.search(bazel_module_file_contents)
    if not bazel_sdk_version:
        _LOG.error("    Failed to find Bazel Pico SDK version string")
        return False
    bazel_version_string = bazel_sdk_version.group("sdk_version")

    # Find version string specified in CMake.
    cmake_version_parts = {}
    with open(Path(SDK_ROOT) / "pico_sdk_version.cmake", "r") as cmake_version_file:
        for line in cmake_version_file:
            match = CMAKE_VERSION_REGEX.match(line)
            if match:
                cmake_version_parts[match.group("part")] = match.group("value")
    if len(cmake_version_parts) < 3:
        _LOG.error("    Failed to find CMake Pico SDK version string")
        return False
    cmake_version_string = ".".join((
        cmake_version_parts["MAJOR"],
        cmake_version_parts["MINOR"],
        cmake_version_parts["REVISION"],
    ))
    if "PRE_RELEASE_ID" in cmake_version_parts:
        cmake_version_string += "-" + cmake_version_parts["PRE_RELEASE_ID"]

    if cmake_version_string != bazel_version_string:
        _LOG.error("    Declared CMake SDK version is %s and Bazel is %s", cmake_version_string, bazel_version_string)
        return False

    return True

def compare_build_systems():
    cmake_files = [
        f
        for p in CMAKE_FILE_TYPES
        for f in glob.glob(os.path.join(SDK_ROOT, p), recursive=True)
    ]
    bazel_files = [
        f
        for p in BAZEL_FILE_TYPES
        for f in glob.glob(os.path.join(SDK_ROOT, p), recursive=True)
    ]

    results = []
    _LOG.info("[1/3] Checking build system configuration flags...")
    results.append(CompareOptions(
        "PICO_BAZEL_CONFIG",
        bazel_files,
        "PICO_CMAKE_CONFIG",
        cmake_files,
        # For now, allow CMake and Bazel to go out of sync when it comes to
        # build configurability since it's a big ask to make contributors
        # implement the same functionality in both builds.
        warnings_as_errors=False,
    ))

    _LOG.info("[2/4] Checking build system defines...")
    results.append(CompareOptions(
        "PICO_BUILD_DEFINE", bazel_files, "PICO_BUILD_DEFINE", cmake_files
    ))

    _LOG.info("[3/4] Checking submodule pins...")
    results.append(CompareExternalDependencyVersions())

    _LOG.info("[4/4] Checking version strings...")
    results.append(CompareSdkVersion())

    if False not in results:
        _LOG.info("Passed with no blocking failures")
        return 0

    _LOG.error("One or more blocking failures detected")
    return 1


if __name__ == "__main__":
    setup_logging()
    sys.exit(compare_build_systems())
