"""Generate clang-tidy idedata via the native ESP-IDF toolchain.

Produces idedata for clang-tidy **without an ESPHome YAML config**. Instead of
running codegen on a config, it generates a minimal ESP-IDF CMake project:

* the managed-component dependencies come from ESPHome's own
  ``idf_component.yml`` (arduinojson, lvgl, mdns, ...);
* the PlatformIO ``lib_deps`` (qr-code, mlx90393, ...) are converted to local
  IDF components via the ESPHome PlatformIO->IDF converter;
* the ``main`` component ``REQUIRES`` every target-available builtin IDF
  component, so their public include dirs land on the translation unit;
* the repo ``sdkconfig.defaults`` enables sdkconfig-gated components (bt, ...).

then runs ``idf.py reconfigure`` (configure only, no compile) and reads the
resulting ``build/compile_commands.json``. The IDF version is the esp32
component's recommended version.

``ESPHOME_IDF_COMPILE_COMMANDS`` may point at an existing build's
``compile_commands.json`` to skip generation (fast iteration).
"""

from dataclasses import dataclass
import os
from pathlib import Path

TIDY_PROJECT_NAME = "esphome_tidy"

# A do-nothing C++ app: just enough for IDF to configure a valid project. It's
# C++ (not C) so the compile command uses the C++ compiler and flags, matching
# how clang-tidy analyzes ESPHome's C++ sources.
_TIDY_MAIN_CPP = 'extern "C" void app_main() {}\n'


@dataclass(frozen=True)
class _Settings:
    """Per-environment build settings derived from the tidy env name.

    The platform defines below are what a real ESPHome build adds via
    cg.add_define; defines.h only *consumes* them, so without them
    esphome/core/hal.h errors with "not implemented for this platform".
    """

    idf_target: str  # esp32, esp32s3, ...
    variant: str  # ESP32, ESP32S3, ...
    idf_version: str  # ESP-IDF version to build with
    target_framework: str  # "espidf" or "arduino"
    platform_defines: tuple[str, ...]
    # Extra idf_component.yml deps the framework needs (e.g. arduino-esp32).
    framework_deps: dict[str, dict]


def _settings_for(environment: str) -> _Settings:
    """Derive build settings from a ``<target>-<framework>-tidy`` env name.

    Arduino on esp32 is itself a native ESP-IDF build with the
    ``espressif/arduino-esp32`` component added, so both frameworks use this
    path -- only the defines, IDF version, and that one component differ.
    """
    from esphome.components.esp32 import (
        ARDUINO_FRAMEWORK_VERSION_LOOKUP,
        ARDUINO_IDF_VERSION_LOOKUP,
        ESP_IDF_FRAMEWORK_VERSION_LOOKUP,
    )

    parts = environment.split("-")
    if len(parts) != 3 or parts[2] != "tidy" or parts[1] not in ("idf", "arduino"):
        raise ValueError(
            f"Unsupported clang-tidy environment {environment!r}: expected "
            "<target>-<framework>-tidy with framework 'idf' or 'arduino' "
            "(e.g. esp32-idf-tidy, esp32s3-arduino-tidy)"
        )
    idf_target, framework, _ = parts
    variant = idf_target.upper()
    # Defines shared by both frameworks. ESPHOME_LOG_LEVEL must be set up front
    # (as the PlatformIO tidy build_flags do) -- otherwise log.h's ``#ifndef``
    # sets it to NONE before defines.h redefines it, a macro-redefined warning
    # across nearly every source.
    common_defines = (
        "USE_ESP32",
        f"USE_ESP32_VARIANT_{variant}",
        "ESPHOME_LOG_LEVEL=ESPHOME_LOG_LEVEL_VERY_VERBOSE",
    )

    if framework == "arduino":
        fw_version = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"]
        return _Settings(
            idf_target=idf_target,
            variant=variant,
            idf_version=str(ARDUINO_IDF_VERSION_LOOKUP[fw_version]),
            target_framework="arduino",
            platform_defines=(
                *common_defines,
                "USE_ARDUINO",
                "USE_ESP32_FRAMEWORK_ARDUINO",
            ),
            framework_deps=_arduino_framework_deps(str(fw_version)),
        )
    return _Settings(
        idf_target=idf_target,
        variant=variant,
        idf_version=str(ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]),
        target_framework="espidf",
        platform_defines=(
            *common_defines,
            "USE_ESP_IDF",
            "USE_ESP32_FRAMEWORK_ESP_IDF",
        ),
        framework_deps={},
    )


def _arduino_framework_deps(version: str) -> dict[str, dict]:
    """Arduino-only managed deps merged on top of esphome/idf_component.yml.

    arduino-esp32 provides Arduino.h and the arduino libraries; its version is
    the recommended arduino framework version so the tidy build matches what
    ESPHome ships.
    """
    from esphome.components.esp32 import ARDUINO_ESP32_COMPONENT_NAME

    return {ARDUINO_ESP32_COMPONENT_NAME: {"version": version}}


_TOP_CMAKELISTS = """\
# Auto-generated by ESPHome (clang-tidy idedata project)
cmake_minimum_required(VERSION 3.16)
set(IDF_TARGET {idf_target})
include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
{compile_options}
project({name})
"""

_MAIN_CMAKELISTS = """\
# Auto-generated by ESPHome (clang-tidy idedata project)
idf_component_register(
    SRCS "tidy.cpp"
    REQUIRES {requires}
)
"""


def _setup_core(work_dir: Path, settings: _Settings) -> None:
    """Point CORE at the tidy project + IDF version, without any YAML config."""
    from esphome.const import (
        KEY_CORE,
        KEY_ESP32,
        KEY_IDF_VERSION,
        KEY_TARGET_FRAMEWORK,
        KEY_TARGET_PLATFORM,
        KEY_VARIANT,
    )
    from esphome.core import CORE, Version

    CORE.name = TIDY_PROJECT_NAME
    # config_path's parent is the data dir root for per-run artifacts (idedata,
    # converted pio_components). The IDF install is in the global cache dir,
    # independent of this path.
    CORE.config_path = work_dir.parent / "tidy.yaml"
    CORE.build_path = work_dir
    esp32 = CORE.data.setdefault(KEY_ESP32, {})
    esp32[KEY_IDF_VERSION] = Version.parse(settings.idf_version)
    esp32[KEY_VARIANT] = settings.variant
    # The target framework drives the PlatformIO-library -> IDF-component
    # converter and ESPHome's CORE.using_arduino / using_esp_idf helpers.
    CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = "esp32"
    CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = settings.target_framework

    # Gates arduino-only components in esphome/idf_component.yml (IDF reads it at
    # reconfigure time). Set here -- before the manifest is written/reconfigured.
    os.environ["ESPHOME_ARDUINO_COMPONENT"] = (
        "1" if settings.target_framework == "arduino" else "0"
    )


# Special IDF "components" that are tools/subprojects, not requirable by an app
# (they provide no public includes and break requirement resolution), plus our
# own ``main``.
_NON_REQUIRABLE_COMPONENTS = frozenset(
    {"bootloader", "esptool_py", "partition_table", "main"}
)


def _parse_lib_deps(platformio_ini: Path, framework: str):
    """Parse the framework's ``lib_deps`` from platformio.ini into Library specs.

    These are the PlatformIO libraries ESPHome components pull in via
    ``cg.add_library``. The set is framework-specific: the arduino envs add
    libs (FastLED, NeoPixelBus, MideaUART, ...) the idf envs don't. We read the
    relevant ``[common*]`` sections directly (resolving the env's ``extends``
    chain) and skip the ``${...}`` cross-references and non-library entries.
    """
    import configparser

    from esphome.core import Library

    parser = configparser.ConfigParser(interpolation=None, strict=False)
    parser.read(platformio_ini)

    sections = [("common", "lib_deps_base"), ("common", "lib_deps")]
    if framework == "arduino":
        sections += [
            ("common:arduino", "lib_deps"),
            ("common:esp32-arduino", "lib_deps"),
        ]
    else:
        sections += [
            ("common:idf", "lib_deps"),
            ("common:esp32-idf", "lib_deps"),
        ]

    tokens: list[str] = []
    for section, key in sections:
        if parser.has_option(section, key):
            tokens += parser.get(section, key).splitlines()

    libs: list[Library] = []
    seen: set[str] = set()
    for token in tokens:
        token = token.split(";", 1)[0].strip()  # drop trailing ; comment
        # Skip blanks, ${...} cross-refs, and +<...> source filters.
        if not token or token.startswith(("${", "+<")) or token in seen:
            continue
        seen.add(token)
        if "://" in token or ".git" in token:
            libs.append(Library(token, None, token))  # git repository (with #ref)
        elif "@" in token:
            name, _, version = token.partition("@")
            libs.append(Library(name, version))
        # A bare name (SPI, Wire, WiFi, Networking, "ESP32 Async UDP", ...) is an
        # Arduino framework built-in provided by arduino-esp32, not a convertible
        # registry library (no owner/version), so skip it.
    return libs


def _convert_pio_libs(
    platformio_ini: Path, framework: str
) -> dict[str, dict[str, str]]:
    """Convert the PlatformIO libs to IDF components; return manifest deps.

    Returns a mapping suitable for an ``idf_component.yml`` ``dependencies``
    block (``{name: {"override_path": <converted component dir>}}``), reusing
    ESPHome's own PlatformIO->IDF converter (registry/git resolution, no pio).

    The whole library set is resolved as a single batch so a shared transitive
    dependency (e.g. esphome/libsodium pulled by both noise-c and esp_wireguard)
    is deduplicated to one component instead of clashing override_path entries.
    """
    from esphome.espidf.component import generate_idf_components

    libraries = _parse_lib_deps(platformio_ini, framework)
    deps: dict[str, dict[str, str]] = {}
    for component in generate_idf_components(libraries):
        deps[component.get_sanitized_name()] = {"override_path": str(component.path)}
    return deps


def _arduino_excluded_stubs(work_dir: Path) -> dict[str, dict]:
    """Stub the arduino-bundled IDF components ESPHome doesn't use.

    arduino-esp32 declares deps (libsodium, RainMaker, modbus, ...) that ESPHome
    replaces with its own library (noise-c) or doesn't use; point each at an
    empty override_path component so the IDF manager doesn't resolve/download
    them -- notably so ``espressif/libsodium`` doesn't clash with the converted
    noise-c's ``libsodium``. Mirrors esp32's ``_write_idf_component_yml``.

    Components ESPHome's own idf_component.yml provides (e.g. lan867x for
    ethernet) are NOT stubbed -- those are real deps we need, and arduino-esp32
    resolves to the same component rather than conflicting.
    """
    import yaml

    from esphome.components.esp32 import (
        ARDUINO_EXCLUDED_IDF_COMPONENTS,
        _idf_component_dep_name,
        _idf_component_stub_name,
    )

    esphome_dir = Path(__file__).resolve().parent.parent
    base_manifest = yaml.safe_load(
        (esphome_dir / "idf_component.yml").read_text(encoding="utf-8")
    )
    esphome_deps = set(base_manifest.get("dependencies") or {})

    stubs_dir = work_dir / "component_stubs"
    stubs_dir.mkdir(parents=True, exist_ok=True)
    deps: dict[str, dict] = {}
    for component in sorted(ARDUINO_EXCLUDED_IDF_COMPONENTS):
        if _idf_component_dep_name(component) in esphome_deps:
            continue  # ESPHome needs this one for real (don't stub it away)
        stub_path = stubs_dir / _idf_component_stub_name(component)
        stub_path.mkdir(exist_ok=True)
        (stub_path / "CMakeLists.txt").write_text(
            "idf_component_register()\n", encoding="utf-8"
        )
        deps[_idf_component_dep_name(component)] = {
            "version": "*",
            "override_path": str(stub_path),
        }
    return deps


def _write_tidy_project(
    work_dir: Path,
    requires: list[str],
    extra_deps: dict[str, dict[str, str]],
    settings: _Settings,
) -> None:
    """Generate the minimal IDF CMake project (top + main + idf_component.yml)."""
    main_dir = work_dir / "main"
    main_dir.mkdir(parents=True, exist_ok=True)

    compile_options = "\n".join(
        f'idf_build_set_property(COMPILE_OPTIONS "-D{define}" APPEND)'
        for define in settings.platform_defines
    )
    (work_dir / "CMakeLists.txt").write_text(
        _TOP_CMAKELISTS.format(
            name=TIDY_PROJECT_NAME,
            compile_options=compile_options,
            idf_target=settings.idf_target,
        ),
        encoding="utf-8",
    )
    (main_dir / "CMakeLists.txt").write_text(
        _MAIN_CMAKELISTS.format(requires=" ".join(requires)), encoding="utf-8"
    )
    (main_dir / "tidy.cpp").write_text(_TIDY_MAIN_CPP, encoding="utf-8")

    # Managed components: ESPHome's own manifest (arduinojson, lvgl, mdns, ...),
    # plus the converted PlatformIO libs as local (override_path) deps. Placing
    # it in main/ makes every dep a requirement of the main component, so their
    # public includes land on the tidy translation unit.
    import yaml

    esphome_dir = Path(__file__).resolve().parent.parent  # esphome/espidf -> esphome
    manifest = yaml.safe_load(
        (esphome_dir / "idf_component.yml").read_text(encoding="utf-8")
    )
    manifest.setdefault("dependencies", {}).update(extra_deps)
    (main_dir / "idf_component.yml").write_text(
        yaml.safe_dump(manifest, sort_keys=False), encoding="utf-8"
    )

    # ESPHome's static-analysis sdkconfig (repo root): enables the flags any
    # component sets (e.g. CONFIG_BT_ENABLED) so sdkconfig-gated IDF components
    # register and expose their includes. IDF reads ``sdkconfig.defaults`` from
    # the project root, plus a per-target ``sdkconfig.defaults.<idf_target>``
    # for variant-only components (e.g. openthread on c6/h2).
    repo_root = esphome_dir.parent
    (work_dir / "sdkconfig.defaults").write_text(
        (repo_root / "sdkconfig.defaults").read_text(encoding="utf-8"),
        encoding="utf-8",
    )
    target_defaults = repo_root / f"sdkconfig.defaults.{settings.idf_target}"
    if target_defaults.is_file():
        (work_dir / target_defaults.name).write_text(
            target_defaults.read_text(encoding="utf-8"), encoding="utf-8"
        )


def _generate_compile_commands(
    work_dir: Path, settings: _Settings, platformio_ini: Path
) -> Path:
    """Generate the tidy project and run ``idf.py reconfigure`` (no build).

    Two-phase, like a real ESPHome build: a first configure with no builtin
    requires discovers which components actually register for the target (e.g.
    ``esp_tee`` only registers on c5/c6/h2), then a second configure requires
    that discovered set so their public includes reach the tidy TU.
    """
    import logging

    from esphome.build_gen.espidf import get_available_components
    from esphome.espidf import toolchain

    # Surface ESPHome's INFO logs (ESP-IDF framework download/extract/install,
    # git-library clones) -- they go through logging, which the clang-tidy
    # script otherwise leaves at WARNING so the first-run downloads look silent.
    logging.basicConfig(level=logging.INFO, format="%(message)s")

    _setup_core(work_dir, settings)

    # Framework deps (e.g. arduino-esp32) + PlatformIO libs converted to local
    # IDF components, all added to the manifest as deps.
    extra_deps = dict(settings.framework_deps)
    extra_deps.update(_convert_pio_libs(platformio_ini, settings.target_framework))
    if settings.target_framework == "arduino":
        # Stub the arduino-bundled components ESPHome doesn't use (avoids the
        # libsodium clash with noise-c and ~26 unused heavy downloads).
        extra_deps.update(_arduino_excluded_stubs(work_dir))

    # Phase 1: discover the components available for this target.
    _write_tidy_project(work_dir, [], extra_deps, settings)
    if toolchain.run_reconfigure() != 0:
        raise RuntimeError("idf.py reconfigure (discovery) failed")

    requires = sorted(
        set(get_available_components() or []) - _NON_REQUIRABLE_COMPONENTS
    )

    # Phase 2: require every available builtin component.
    _write_tidy_project(work_dir, requires, extra_deps, settings)
    if toolchain.run_reconfigure() != 0:
        raise RuntimeError("idf.py reconfigure failed")

    return work_dir / "build" / "compile_commands.json"


def _idedata_from_tidy_project(compile_commands: Path) -> dict:
    """Assemble idedata from the single tidy translation unit.

    Unlike a real ESPHome build (many ``/src/esphome/`` TUs unioned), the tidy
    project has one TU (``main/tidy.cpp``) that -- by requiring every component --
    already carries the full include set, so we parse it directly.
    """
    import json

    from esphome.espidf.idedata import _get_toolchain_includes, _parse_entry

    entries = json.loads(Path(compile_commands).read_text(encoding="utf-8"))
    entry = next((e for e in entries if e["file"].endswith("tidy.cpp")), None)
    if entry is None:
        raise RuntimeError(f"tidy.cpp not found in {compile_commands}")
    cxx_path, defines, includes, cxx_flags = _parse_entry(entry)

    return {
        "cxx_path": cxx_path,
        "cxx_flags": cxx_flags,
        "defines": defines,
        "includes": {
            "build": includes,
            "toolchain": _get_toolchain_includes(cxx_path),
        },
    }


def load_idedata(environment: str, temp_folder: str, platformio_ini: Path) -> dict:
    if explicit := os.environ.get("ESPHOME_IDF_COMPILE_COMMANDS"):
        compile_commands = Path(explicit)
    else:
        # The tidy env is ``<target>-<framework>-tidy`` (e.g. esp32-idf-tidy,
        # esp32s3-arduino-tidy); derive the target, variant and framework.
        settings = _settings_for(environment)
        # Resolve to an absolute path: ``override_path`` entries in the generated
        # component manifests are interpreted by the IDF component manager relative
        # to the manifest's own directory, so a relative work dir would be
        # mis-resolved (doubled under ``main/``).
        work_dir = (
            Path(temp_folder)
            / f"idf-tidy-{settings.idf_target}-{settings.target_framework}"
        ).resolve()
        compile_commands = _generate_compile_commands(
            work_dir, settings, platformio_ini
        )

    if not compile_commands.is_file():
        raise RuntimeError(f"compile_commands.json not found: {compile_commands}")
    return _idedata_from_tidy_project(compile_commands)
