"""ESP-IDF direct build generator for ESPHome."""

import json
from pathlib import Path

from esphome.components.esp32 import get_esp32_variant, idf_version
import esphome.config_validation as cv
from esphome.core import CORE
from esphome.helpers import mkdir_p, write_file_if_changed
from esphome.writer import update_storage_json


def get_available_components() -> list[str] | None:
    """Get list of built-in ESP-IDF components from project_description.json.

    Excludes ``src``, IDF-managed components (``managed_components/``), and
    converted PIO libs (``pio_components/``). Returns ``None`` if the build
    dir or ``project_description.json`` isn't ready yet.
    """
    if CORE.build_path is None:
        return None
    project_desc = Path(CORE.build_path) / "build" / "project_description.json"
    if not project_desc.exists():
        return None

    try:
        with open(project_desc, encoding="utf-8") as f:
            data = json.load(f)

        component_info = data.get("build_component_info", {})

        result = []
        for name, info in component_info.items():
            # Exclude our own src component
            if name == "src":
                continue

            # Exclude IDF-managed and converted-PIO components (external).
            comp_dir = info.get("dir", "")
            if "managed_components" in comp_dir or "pio_components" in comp_dir:
                continue

            result.append(name)

        return result
    except (json.JSONDecodeError, OSError):
        return None


def has_discovered_components() -> bool:
    """Check if we have discovered components from a previous configure."""
    return get_available_components() is not None


def get_project_cmakelists(minimal: bool = False) -> str:
    """Generate the top-level CMakeLists.txt for ESP-IDF project.

    When ``minimal`` is true, omit ``ESPHOME_PROJECT_BUILTIN_COMPONENTS``
    since ``project_description.json`` may be stale on the first write.
    """
    # Get IDF target from ESP32 variant (e.g., ESP32S3 -> esp32s3)
    variant = get_esp32_variant()
    idf_target = variant.lower().replace("-", "")

    # esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and
    # removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get
    # --format=raw because the legacy mode doesn't support it.
    size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else ""

    # Project-wide compile options: -D defines and -W warning flags (skip
    # -Wl, linker flags — those go on the src component via
    # target_link_options below). Emitted via idf_build_set_property so the
    # flags propagate to every IDF component (including managed ones like
    # esphome__micro-mp3) rather than just src/. Required so suppressions
    # like ``-Wno-error=maybe-uninitialized`` actually silence warnings in
    # third-party components we don't author.
    project_compile_opts = [
        flag
        for flag in sorted(CORE.build_flags)
        if flag.startswith("-D")
        or (flag.startswith("-W") and not flag.startswith("-Wl,"))
    ]
    extra_compile_options = "\n".join(
        f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)'
        for flag in project_compile_opts
    )

    # Per-project list exposed as a CMake variable so converted PIO libs
    # can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking
    # project-specific names into their cached CMakeLists.
    #
    # Emit via idf_build_set_property (not plain set()) so the value is
    # serialised into build_properties.temp.cmake and visible to IDF's
    # early requirements-expansion pass (component_get_requirements.cmake
    # runs as a separate CMake script invocation that doesn't load the
    # project's top-level CMakeLists; without this, ${ESPHOME_PROJECT_
    # MANAGED_COMPONENTS} in a converted-lib REQUIRES expands to empty).
    from esphome.components.esp32 import get_managed_component_require_names

    managed_components_property = "\n".join(
        f"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS {name} APPEND)"
        for name in get_managed_component_require_names()
    )

    # Built-in IDF components exposed via our own property (not IDF's
    # __COMPONENT_REQUIRES_COMMON, which would append them to every
    # component's REQUIRES including real IDF components). Referenced by
    # src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped
    # on minimal writes because project_description.json may be stale.
    builtin_components_property = (
        ""
        if minimal
        else "\n".join(
            f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)"
            for name in sorted(get_available_components() or [])
        )
    )

    return f"""\
# Auto-generated by ESPHome
cmake_minimum_required(VERSION 3.16)

# On Windows, Ninja can fail with:
#   "CreateProcess: The parameter is incorrect (is the command line too long?)"
# when compiler/linker command lines exceed the OS length limit.
#
# The following settings force CMake/Ninja to use *response files* (@file.rsp)
# to pass long lists of includes, objects, and other arguments indirectly,
# avoiding command-line length limits and fixing the build failure.
#
# This is especially useful for large ESP-IDF / ESPHome projects with many
# source files or include directories.
set(CMAKE_C_USE_RESPONSE_FILE_FOR_INCLUDES 1)
set(CMAKE_CXX_USE_RESPONSE_FILE_FOR_INCLUDES 1)
set(CMAKE_C_USE_RESPONSE_FILE_FOR_OBJECTS 1)
set(CMAKE_CXX_USE_RESPONSE_FILE_FOR_OBJECTS 1)
set(CMAKE_NINJA_FORCE_RESPONSE_FILE 1)

set(IDF_TARGET {idf_target})
set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src)

include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)

{extra_compile_options}

{managed_components_property}

{builtin_components_property}

project({CORE.name})

# Emit raw JSON size data for ESPHome to read post-build.
add_custom_command(
    TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD
    COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw
            -o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json
            ${{CMAKE_PROJECT_NAME}}.map
    WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}}
    VERBATIM
)
"""


def get_component_cmakelists() -> str:
    """Generate the main component CMakeLists.txt.

    REQUIRES pulls in the discovered built-in IDF components via the
    project-level variables set in the top-level CMakeLists.
    """
    # Extract linker options (-Wl, flags). Compile flags (-D, -W) are
    # emitted project-wide via idf_build_set_property in
    # get_project_cmakelists so they reach every component, not just src/.
    link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")]
    link_opts_str = "\n    ".join(sorted(link_opts)) if link_opts else ""

    return f"""\
# Auto-generated by ESPHome
# CONFIGURE_DEPENDS asks CMake to re-check the glob each build so test
# runs that reuse the build dir don't compile stale source paths. It's
# invalid in script mode (cmake -P), which is how IDF's
# component_get_requirements.cmake includes us, so skip it there.
if(CMAKE_SCRIPT_MODE_FILE)
    file(GLOB_RECURSE app_sources
        "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
        "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
        "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
        "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
    )
else()
    file(GLOB_RECURSE app_sources CONFIGURE_DEPENDS
        "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
        "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
        "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
        "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
    )
endif()

idf_component_register(
    SRCS ${{app_sources}}
    INCLUDE_DIRS "." "esphome"
    REQUIRES ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}}
)

# Apply C++ standard
target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20)

# ESPHome linker options
target_link_options(${{COMPONENT_LIB}} PUBLIC
    {link_opts_str}
)
"""


def write_project(minimal: bool = False) -> None:
    """Write ESP-IDF project files."""
    # Refresh <data_dir>/storage/<name>.yaml.json so the dashboard's
    # /info and /downloads endpoints can locate the build (they 404
    # otherwise). This mirrors the PlatformIO build-gen path's call
    # in build_gen/platformio.py:write_ini().
    update_storage_json()
    mkdir_p(CORE.build_path)
    mkdir_p(CORE.relative_src_path())

    # Write top-level CMakeLists.txt
    write_file_if_changed(
        CORE.relative_build_path("CMakeLists.txt"),
        get_project_cmakelists(minimal=minimal),
    )

    # Write component CMakeLists.txt in src/
    write_file_if_changed(
        CORE.relative_src_path("CMakeLists.txt"),
        get_component_cmakelists(),
    )
