from ci.util.global_interrupt_handler import handle_keyboard_interrupt


#!/usr/bin/env python3
"""
PlatformIO Builder for FastLED

Provides a clean interface for building FastLED projects with PlatformIO.
"""

import gc
import hashlib
import platform
import shutil
import subprocess
import sys
import threading
import time
import urllib.request
import warnings
from concurrent.futures import Future, ThreadPoolExecutor
from pathlib import Path
from typing import Any, Optional

from running_process import EndOfStream, RunningProcess

from ci.boards import Board, create_board

# Import from new modules
from ci.compiler.build_config import (
    apply_board_specific_config,
    generate_build_info_json_from_existing_build,
    get_root_platformio_build_flags,
)
from ci.compiler.build_utils import (
    create_building_banner,
    get_example_error_message,
    get_pio_execution_env,
)
from ci.compiler.compiler import Compiler, InitResult, SketchResult
from ci.compiler.lock_manager import PlatformLock
from ci.compiler.package_manager import (
    aggressive_clean_pio_packages,
    detect_and_fix_corrupted_packages_dynamic,
    detect_package_exception_in_output,
    ensure_platform_installed,
    find_platform_path_from_board,
)
from ci.compiler.path_manager import FastLEDPaths, resolve_project_root
from ci.compiler.source_manager import (
    copy_boards_directory,
    copy_example_source,
    copy_fastled_library,
)
from ci.util.output_formatter import create_sketch_path_formatter


def _init_platformio_build(
    board: Board,
    verbose: bool,
    example: str,
    paths: FastLEDPaths,
    build_dir: Optional[Path] = None,
    additional_defines: Optional[list[str]] = None,
    additional_include_dirs: Optional[list[str]] = None,
    additional_libs: Optional[list[str]] = None,
    use_fbuild: bool = False,
) -> InitResult:
    """Initialize the PlatformIO build directory. Assumes lock is already held by caller.

    When use_fbuild=True, skips PIO-specific package management and the pio run build.
    The build directory, platformio.ini, and source files are still set up since fbuild
    uses the same project structure.
    """
    project_root = resolve_project_root()
    build_dir = build_dir or (project_root / ".build" / "pio" / board.board_name)

    if not use_fbuild:
        # Check for and fix corrupted packages before building
        # Find platform path based on board's platform URL (works for any platform)
        platform_path = find_platform_path_from_board(board, paths)
        detect_and_fix_corrupted_packages_dynamic(
            paths, board.board_name, platform_path
        )

    # Lock is already held by build() - no need to acquire again

    # Setup the build directory.
    build_dir.mkdir(parents=True, exist_ok=True)
    platformio_ini = build_dir / "platformio.ini"

    if not use_fbuild:
        # Ensure platform is installed if needed (fbuild handles this itself)
        if not ensure_platform_installed(board):
            return InitResult(
                success=False,
                output=f"Failed to install platform for {board.board_name}",
                build_dir=build_dir,
            )

    board_with_sketch_include = board.clone()
    if board_with_sketch_include.build_flags is None:
        board_with_sketch_include.build_flags = []

    # Make the staged source roots available as include roots so sketch-relative
    # includes resolve consistently under both PlatformIO and fbuild.
    default_include_dirs = [
        (build_dir / "src").resolve().as_posix(),
        (build_dir / "src" / "sketch").resolve().as_posix(),
    ]
    merged_include_dirs = list(default_include_dirs)
    if additional_include_dirs:
        merged_include_dirs.extend(additional_include_dirs)

    # Optimization report (`-fopt-info-all`) is OPT-IN per board because the
    # report file accumulates across every example in the matrix and can
    # exceed 100 MB on no-LTO platforms — that overwhelmed the GHA log
    # buffer and shut down the nrf52840 runners (see PR #2658 for the
    # downstream cap and PR opening this gate). Set
    # `generate_optimization_report=True` on the Board to opt in.
    #
    # The linker map (`-Wl,-Map,firmware.map`) stays unconditional; it's a
    # small per-example file and is consumed by size-analysis tooling.
    try:
        # Generate linker map in the board build directory using absolute path
        # (relative paths are unreliable — the linker CWD varies across PIO versions)
        map_path = (build_dir / "firmware.map").resolve()
        board_with_sketch_include.build_flags.append(f"-Wl,-Map,{map_path.as_posix()}")

        # ESP32-C2 and AVR platforms cannot work with -fopt-info-all even when
        # opted in — preserve the original suppression list.
        if (
            getattr(board, "generate_optimization_report", False)
            and board.board_name != "esp32c2"
            and board.platform != "atmelavr"
        ):
            opt_report_path = (build_dir / "optimization_report.txt").resolve()
            board_with_sketch_include.build_flags.append(
                f"-fopt-info-all={opt_report_path.as_posix()}"
            )
    except KeyboardInterrupt as ki:
        handle_keyboard_interrupt(ki)
        raise
    except Exception:
        # Non-fatal: continue without optimization artifacts if path resolution fails
        pass

    # Merge any per-env build_flags the user has declared in the root
    # platformio.ini (issue #2664). Without this step, flags in
    # [env:<board>].build_flags of the root file are silently discarded
    # when bash compile --backend platformio synthesises a fresh project
    # from ci/boards.py. Board flags come first, root flags are appended,
    # so root values take precedence per PlatformIO's "last wins" semantics
    # for repeated -D / linker flags.
    root_build_flags = get_root_platformio_build_flags(board.board_name, project_root)
    if root_build_flags:
        print(
            f"Merging {len(root_build_flags)} build_flag(s) from root "
            f"platformio.ini [env:{board.board_name}]: {root_build_flags}"
        )
        board_with_sketch_include.build_flags.extend(root_build_flags)

    # Apply board-specific configuration
    if not apply_board_specific_config(
        board_with_sketch_include,
        platformio_ini,
        example,
        paths,
        additional_defines,
        merged_include_dirs,
        additional_libs,
    ):
        return InitResult(
            success=False,
            output=f"Failed to apply board configuration for {board.board_name}",
            build_dir=build_dir,
        )

    # Print building banner first
    print(create_building_banner(example))

    ok_copy_src = copy_example_source(project_root, build_dir, example)
    if not ok_copy_src:
        error_msg = get_example_error_message(project_root, example)
        warnings.warn(error_msg)
        return InitResult(
            success=False,
            output=error_msg,
            build_dir=build_dir,
        )

    # Copy FastLED library
    ok_copy_fastled = copy_fastled_library(project_root, build_dir)
    if not ok_copy_fastled:
        warnings.warn("Failed to copy FastLED library")
        return InitResult(
            success=False, output="Failed to copy FastLED library", build_dir=build_dir
        )

    # Copy boards directory
    ok_copy_boards = copy_boards_directory(project_root, build_dir)
    if not ok_copy_boards:
        warnings.warn("Failed to copy boards directory")
        return InitResult(
            success=False,
            output="Failed to copy boards directory",
            build_dir=build_dir,
        )

    # On Windows, force garbage collection and small delay to release all file handles
    # This prevents file access errors when PlatformIO scans the libraries
    if sys.platform == "win32":
        gc.collect()
        time.sleep(0.1)  # Give filesystem time to release handles

    # Create sdkconfig.defaults if framework has "espidf" in it for esp32c2 board
    frameworks = {f.strip() for f in (board.framework or "").split(",")}
    if {"arduino", "espidf"}.issubset(frameworks):
        sdkconfig_path = build_dir / "sdkconfig.defaults"
        print("Creating sdkconfig.defaults file")
        try:
            sdkconfig_path.write_text(
                "CONFIG_FREERTOS_HZ=1000\r\nCONFIG_AUTOSTART_ARDUINO=y"
            )
            with open(sdkconfig_path, "r", encoding="utf-8", errors="ignore") as f:
                for line in f:
                    print(line, end="")
        except KeyboardInterrupt as ki:
            handle_keyboard_interrupt(ki)
            raise
        except Exception as e:
            warnings.warn(f"Failed to write sdkconfig: {e}")

    # Final platformio.ini is already written by apply_board_specific_config
    # No need to write it again

    if use_fbuild:
        # fbuild handles compilation itself — init only sets up the project structure
        if verbose:
            platformio_ini_path = build_dir / "platformio.ini"
            if platformio_ini_path.exists():
                print()
                print("=" * 60)
                print("PLATFORMIO.INI CONFIGURATION (fbuild):")
                print("=" * 60)
                ini_content = platformio_ini_path.read_text()
                print(ini_content)
                print("=" * 60)
                print()
        return InitResult(success=True, output="", build_dir=build_dir)

    # Run initial build with LDF enabled to set up the environment
    run_cmd: list[str] = ["pio", "run", "--project-dir", str(build_dir)]
    if verbose:
        run_cmd.append("--verbose")

    # Print platformio.ini content for the initialization build (verbose only)
    if verbose:
        platformio_ini_path = build_dir / "platformio.ini"
        if platformio_ini_path.exists():
            print()  # Add newline before configuration section
            print("=" * 60)
            print("PLATFORMIO.INI CONFIGURATION:")
            print("=" * 60)
            ini_content = platformio_ini_path.read_text()
            print(ini_content)
            print("=" * 60)
            print()  # Add newline after configuration section

        print(f"Running initial build command: {subprocess.list2cmdline(run_cmd)}")

    # Start timer for this example
    time.time()

    # Create formatter for path substitution and timestamping
    formatter = create_sketch_path_formatter(example)

    running_process = RunningProcess(
        run_cmd,
        cwd=build_dir,
        auto_run=True,
        output_formatter=formatter,
        env=get_pio_execution_env(),
    )
    # Output is transformed by the formatter, but we need to print it
    while line := running_process.get_next_line():
        if isinstance(line, EndOfStream):
            break
        # Print the transformed line
        print(line)

    running_process.wait()

    if running_process.returncode != 0:
        return InitResult(
            success=False,
            output=f"Initial build failed: {running_process.stdout}",
            build_dir=build_dir,
        )

    # After successful build, configuration is already properly set up
    # Board configuration includes all necessary settings

    # Generate build_info.json after successful initialization build
    # Pass example name to generate example-specific build_info_{example}.json
    generate_build_info_json_from_existing_build(build_dir, board, example)

    return InitResult(success=True, output="", build_dir=build_dir)


class PioCompiler(Compiler):
    def __init__(
        self,
        board: Board | str,
        verbose: bool,
        global_cache_dir: Path,
        additional_defines: Optional[list[str]] = None,
        additional_include_dirs: Optional[list[str]] = None,
        additional_libs: Optional[list[str]] = None,
        use_fbuild: bool = False,
    ) -> None:
        # Call parent constructor
        super().__init__()

        # Convert string to Board object if needed
        if isinstance(board, str):
            self.board = create_board(board)
        else:
            self.board = board
        self.verbose = verbose
        self.additional_defines = additional_defines
        self.additional_include_dirs = additional_include_dirs
        self.additional_libs = additional_libs
        self.use_fbuild = use_fbuild

        # Global cache directory is already resolved by caller
        self.global_cache_dir = global_cache_dir

        # Use centralized path management
        self.paths = FastLEDPaths(self.board.board_name)
        self.platform_lock = PlatformLock(self.paths.platform_lock_file)

        # Always override the cache directory with our resolved path
        self.paths._global_platformio_cache_dir = self.global_cache_dir
        self.build_dir: Path = self.paths.build_dir

        # Ensure all directories exist
        self.paths.ensure_directories_exist()

        # Package installation lock is now handled by daemon (system-wide singleton)
        # No per-board lock needed

        self.initialized = False
        self.executor = ThreadPoolExecutor(max_workers=1)

    def _internal_init_build_no_lock(self, example: str) -> InitResult:
        """Initialize the PlatformIO build directory once with the first example."""
        if self.initialized:
            return InitResult(
                success=True, output="Already initialized", build_dir=self.build_dir
            )

        # Initialize with the actual first example being built
        result = _init_platformio_build(
            self.board,
            self.verbose,
            example,
            self.paths,
            build_dir=self.build_dir,
            additional_defines=self.additional_defines,
            additional_include_dirs=self.additional_include_dirs,
            additional_libs=self.additional_libs,
            use_fbuild=self.use_fbuild,
        )
        if result.success:
            self.initialized = True
        return result

    def cancel_all(self) -> None:
        """Cancel all builds."""
        # Provide immediate feedback that cancellation is starting
        sys.stdout.write("      → Shutting down build executor...\n")
        sys.stdout.flush()
        self.executor.shutdown(wait=False, cancel_futures=True)
        sys.stdout.write("      → Executor shutdown complete\n")
        sys.stdout.flush()

    def build(self, examples: list[str]) -> list[Future[SketchResult]]:
        """Build a list of examples with proper lock management.

        For fbuild boards, runs synchronously on the main thread so that
        Ctrl+C / SIGINT is delivered immediately.  The Rust native extension
        (conn.build) can hold the GIL in a worker thread, preventing signal
        delivery on Windows.
        """
        if not examples:
            return []

        # fbuild path: run on main thread for reliable Ctrl+C handling
        if self.use_fbuild:
            return self._build_fbuild(examples)

        # Acquire the global package lock for the first build (package installation)

        count = len(examples)

        def release_platform_lock(fut: Future[SketchResult]) -> None:
            """Release the platform lock when all builds complete."""
            nonlocal count
            count -= 1
            if count == 0:
                print(f"Releasing platform lock: {self.platform_lock.lock_file_path}\n")
                self.platform_lock.release()

        futures: list[Future[SketchResult]] = []

        # Package installation lock is now handled by daemon (in _internal_build_no_lock)
        # No global lock acquire/release needed here
        cancelled = threading.Event()
        try:
            for example in examples:
                future = self.executor.submit(
                    self._internal_build_no_lock, example, cancelled
                )
                future.add_done_callback(release_platform_lock)
                futures.append(future)
        except KeyboardInterrupt as ki:
            print("KeyboardInterrupt: Cancelling all builds")
            cancelled.set()
            for future in futures:
                future.cancel()
            handle_keyboard_interrupt(ki)
            raise
        except Exception as e:
            print(f"Exception: {e}")
            for future in futures:
                future.cancel()
            raise

        return futures

    def _build_fbuild(self, examples: list[str]) -> list[Future[SketchResult]]:
        """Build examples using fbuild, preferring ``ci`` when available.

        compile-many / ``fbuild ci`` are gated behind ``FASTLED_USE_FBUILD_CI``
        because today they re-build the framework from scratch in every
        stage-2 sketch dir (FastLED/fbuild#335). On uno that's a ~7 min run
        vs. ~3.5 min serial; on teensy41/esp32s3 (larger framework) it
        blows past the 30 min batch timeout — see the runs that landed on
        master commits d2a025441e / 63e0241808 / fa147ae894. Default off
        until fbuild#335 ships a shared framework cache; set
        ``FASTLED_USE_FBUILD_CI=1`` to opt in (e.g. for local timing
        experiments against the architectural fix once it lands).
        """
        import os

        from ci.util.fbuild_runner import (
            fbuild_supports_ci,
            fbuild_supports_compile_many,
        )

        opt_in = os.environ.get("FASTLED_USE_FBUILD_CI", "").lower() in (
            "1",
            "true",
            "yes",
            "on",
        )
        if opt_in:
            if fbuild_supports_ci():
                return self._build_fbuild_ci(examples)
            if fbuild_supports_compile_many():
                print(
                    "fbuild ci is unavailable in the installed fbuild; "
                    "falling back to fbuild compile-many."
                )
                return self._build_fbuild_compile_many(examples)
            print(
                "FASTLED_USE_FBUILD_CI=1 was set but fbuild ci/compile-many "
                "are unavailable in the installed fbuild; falling back to "
                "the legacy serial loop."
            )
        return self._build_fbuild_sync(examples)

    def _compile_many_project_dir(self, example: str, index: int) -> Path:
        """Return the staged project directory for one compile-many sketch."""
        if index == 0:
            return self.build_dir

        example_path = Path(example)
        if example_path.is_absolute():
            digest = hashlib.sha1(str(example_path).encode("utf-8")).hexdigest()[:12]
            safe_name = (
                "".join(
                    ch if ch.isalnum() or ch in "._-" else "_"
                    for ch in example_path.name
                )
                or f"example_{index}"
            )
            return self.build_dir / "compile_many" / f"{safe_name}_{digest}"
        return self.build_dir / "compile_many" / example_path

    def _stage_fbuild_compile_many_projects(
        self, examples: list[str]
    ) -> list[tuple[str, Path]]:
        """Stage one PlatformIO-compatible project directory per example."""
        staged_projects: list[tuple[str, Path]] = []
        for index, example in enumerate(examples):
            project_dir = self._compile_many_project_dir(example, index)
            init_result = _init_platformio_build(
                self.board,
                self.verbose,
                example,
                self.paths,
                build_dir=project_dir,
                additional_defines=self.additional_defines,
                additional_include_dirs=self.additional_include_dirs,
                additional_libs=self.additional_libs,
                use_fbuild=True,
            )
            if not init_result.success:
                raise RuntimeError(init_result.output)
            staged_projects.append((example, project_dir))
        return staged_projects

    def _build_fbuild_compile_many(
        self, examples: list[str]
    ) -> list[Future[SketchResult]]:
        """Build examples with one ``fbuild compile-many`` invocation."""
        from ci.util.fbuild_runner import run_fbuild_compile_many

        return self._build_fbuild_batch(
            examples=examples,
            command_label="compile-many",
            run_batch=run_fbuild_compile_many,
        )

    def _build_fbuild_ci(self, examples: list[str]) -> list[Future[SketchResult]]:
        """Build examples with one ``fbuild ci`` invocation."""
        from ci.util.fbuild_runner import run_fbuild_ci

        return self._build_fbuild_batch(
            examples=examples,
            command_label="ci",
            run_batch=run_fbuild_ci,
        )

    def _build_fbuild_batch(
        self,
        examples: list[str],
        command_label: str,
        run_batch: Any,
    ) -> list[Future[SketchResult]]:
        """Build examples with one batched ``fbuild`` invocation."""

        futures: list[Future[SketchResult]] = []

        try:
            staged_projects = self._stage_fbuild_compile_many_projects(examples)
        except KeyboardInterrupt as ki:
            handle_keyboard_interrupt(ki)
            raise
        except Exception as e:
            for example in examples:
                future: Future[SketchResult] = Future()
                future.set_result(
                    SketchResult(
                        success=False,
                        output=f"Failed to stage fbuild {command_label} project: {e}",
                        build_dir=self.build_dir,
                        example=example,
                    )
                )
                futures.append(future)
            print(f"Releasing platform lock: {self.platform_lock.lock_file_path}\n")
            self.platform_lock.release()
            return futures

        import time

        from ci.util.fbuild_runner import _parse_size_info_from_log

        batch_start = time.monotonic()
        try:
            compile_many_result = run_batch(
                board=self.board.board_name,
                sketch_project_dirs=[project_dir for _, project_dir in staged_projects],
                verbose=self.verbose,
                timeout=1800,
                quiet=False,
                log_file=None,
            )
            reported = {
                sketch_result.sketch_dir.resolve(): sketch_result
                for sketch_result in compile_many_result.sketch_results
            }

            success_count = 0
            fail_count = 0
            per_sketch_secs: list[float] = []
            for example, project_dir in staged_projects:
                sketch_result = reported.get(project_dir.resolve())
                if sketch_result is None:
                    output = (
                        f"fbuild {command_label} did not report a result for "
                        f"{project_dir}\n\n{compile_many_result.output}"
                    )
                    success = False
                    stage = "?"
                    build_secs = 0.0
                    flash_str: str | None = None
                    ram_str: str | None = None
                else:
                    output = sketch_result.message
                    if (
                        sketch_result.log_path is not None
                        and sketch_result.log_path.exists()
                    ):
                        output = sketch_result.log_path.read_text(
                            encoding="utf-8", errors="ignore"
                        )
                    success = sketch_result.success
                    stage = sketch_result.stage
                    build_secs = sketch_result.build_time_secs
                    flash_str, ram_str = _parse_size_info_from_log(
                        sketch_result.log_path
                    )

                # Normalized per-sketch summary line. The serial path emits
                # `build succeeded in Xs (flash: N bytes, ram: M bytes)` from
                # fbuild + `Compilation succeeded (fbuild) [Xs]` from FastLED.
                # Compile-many never sees those per-sketch lines (fbuild only
                # prints one aggregate table), so reconstruct an equivalent
                # line per sketch from the parsed result + per-sketch log.
                # Sizes come from the same fbuild `Flash:`/`RAM:` lines the
                # serial path renders, just kept in fbuild's display units
                # (e.g. "2.75KB") rather than re-converted to bytes — the
                # signal is "did flash/ram change?", not the literal value.
                size_part = ""
                if flash_str is not None and ram_str is not None:
                    size_part = f" (flash: {flash_str}, ram: {ram_str})"
                elif flash_str is not None:
                    size_part = f" (flash: {flash_str})"
                stage_tag = f"[{stage}]" if stage != "?" else "[?]"
                if success:
                    success_count += 1
                    green_color = "\033[32m"
                    reset_color = "\033[0m"
                    print(
                        f"{green_color}SUCCESS: {example}  "
                        f"build succeeded in {build_secs:.1f}s{size_part} {stage_tag}"
                        f"{reset_color}"
                    )
                    if generate_build_info_json_from_existing_build(
                        project_dir, self.board, example
                    ):
                        build_info_path = project_dir / f"build_info_{example}.json"
                        if project_dir != self.build_dir and build_info_path.exists():
                            shutil.copy2(
                                build_info_path,
                                self.build_dir / build_info_path.name,
                            )
                else:
                    fail_count += 1
                    red_color = "\033[31m"
                    reset_color = "\033[0m"
                    print(
                        f"{red_color}FAILED:  {example}  "
                        f"build failed in {build_secs:.1f}s {stage_tag}"
                        f"{reset_color}"
                    )

                per_sketch_secs.append(build_secs)

                future: Future[SketchResult] = Future()
                future.set_result(
                    SketchResult(
                        success=success,
                        output=output or f"fbuild {command_label}",
                        build_dir=project_dir,
                        example=example,
                    )
                )
                futures.append(future)

            # Total wall-clock and parallelism factor. The fbuild_runner step
            # already prints `Compilation succeeded (fbuild ci) [Xs]` with
            # the same wall time, but it lands BEFORE the per-sketch lines
            # (fbuild prints its summary as soon as the subprocess exits).
            # Re-emit a final-line total here so the bottom-of-log reader
            # sees the wall clock right after the per-sketch summary, with
            # an explicit parallelism factor so it's obvious when stage 2
            # actually fanned out vs. ran serially.
            batch_wall = time.monotonic() - batch_start
            sum_per_sketch = sum(per_sketch_secs)
            sketches_n = max(1, len(staged_projects))
            speedup = sum_per_sketch / batch_wall if batch_wall > 0 else 1.0
            print(
                f"\nfbuild {command_label} total wall-clock: {batch_wall:.1f}s "
                f"across {sketches_n} sketches "
                f"(sum-of-per-sketch={sum_per_sketch:.1f}s, "
                f"parallelism={speedup:.2f}x, "
                f"avg-wall={batch_wall / sketches_n:.2f}s/sketch, "
                f"ok={success_count}, fail={fail_count})\n"
            )
        finally:
            print(f"Releasing platform lock: {self.platform_lock.lock_file_path}\n")
            self.platform_lock.release()

        return futures

    def _build_fbuild_sync(self, examples: list[str]) -> list[Future[SketchResult]]:
        """Build examples using fbuild synchronously on the main thread.

        Returns completed futures for API compatibility with the async path.
        """
        futures: list[Future[SketchResult]] = []

        for example in examples:
            # Initialize build directory (once)
            if not self.initialized:
                init_result = self._internal_init_build_no_lock(example)
                if not init_result.success:
                    red_color = "\033[31m"
                    reset_color = "\033[0m"
                    print(f"{red_color}FAILED: {example}{reset_color}")
                    result = SketchResult(
                        success=False,
                        output=init_result.output,
                        build_dir=init_result.build_dir,
                        example=example,
                    )
                    future: Future[SketchResult] = Future()
                    future.set_result(result)
                    futures.append(future)
                    continue

            # Copy example source for subsequent examples
            if self.initialized:
                project_root = resolve_project_root()
                ok_copy_src = copy_example_source(project_root, self.build_dir, example)
                if not ok_copy_src:
                    error_msg = get_example_error_message(project_root, example)
                    result = SketchResult(
                        success=False,
                        output=error_msg,
                        build_dir=self.build_dir,
                        example=example,
                    )
                    future = Future()
                    future.set_result(result)
                    futures.append(future)
                    continue

            # Run fbuild on main thread — Ctrl+C works directly
            result = self._build_with_fbuild(example)
            future = Future()
            future.set_result(result)
            futures.append(future)

        # Release platform lock
        print(f"Releasing platform lock: {self.platform_lock.lock_file_path}\n")
        self.platform_lock.release()

        return futures

    def _build_internal(self, example: str) -> SketchResult:
        """Internal build method without lock management."""
        # Print building banner first
        print(create_building_banner(example))

        # Copy example source to build directory
        project_root = resolve_project_root()
        ok_copy_src = copy_example_source(project_root, self.build_dir, example)
        if not ok_copy_src:
            error_msg = get_example_error_message(project_root, example)
            return SketchResult(
                success=False,
                output=error_msg,
                build_dir=self.build_dir,
                example=example,
            )

        # Cache configuration is handled through build flags during initialization

        # Use fbuild for compilation if enabled
        if self.use_fbuild:
            return self._build_with_fbuild(example)

        # Attempt build with retry on PackageException
        max_attempts = 2
        for attempt in range(max_attempts):
            if attempt > 0:
                print(f"\n{'=' * 60}")
                print(f"RETRY ATTEMPT {attempt}/{max_attempts - 1}")
                print(f"{'=' * 60}\n")

            # Run PlatformIO build
            run_cmd: list[str] = [
                "pio",
                "run",
                "--project-dir",
                str(self.build_dir),
                "--disable-auto-clean",
            ]
            if self.verbose:
                run_cmd.append("--verbose")

            print(f"Running command: {subprocess.list2cmdline(run_cmd)}")

            # Create formatter for path substitution and timestamping
            formatter = create_sketch_path_formatter(example)

            running_process = RunningProcess(
                run_cmd,
                cwd=self.build_dir,
                auto_run=True,
                output_formatter=formatter,
                env=get_pio_execution_env(),
            )
            try:
                # Output is transformed by the formatter, but we need to print it
                while line := running_process.get_next_line(
                    timeout=900
                ):  # 15 minutes for platform builds
                    if isinstance(line, EndOfStream):
                        break
                    # Print the transformed line
                    print(line)
            except KeyboardInterrupt as ki:
                print("KeyboardInterrupt: Cancelling build")
                running_process.terminate()
                handle_keyboard_interrupt(ki)
                raise
            except OSError as e:
                # Handle output encoding issues on Windows
                print(f"Output encoding issue: {e}")
                pass

            running_process.wait()

            success = running_process.returncode == 0
            output = str(running_process.stdout)

            # Check for PackageException in output
            has_package_exception, package_info = detect_package_exception_in_output(
                output
            )

            # Print SUCCESS/FAILED message immediately in worker thread to avoid race conditions
            if success:
                green_color = "\033[32m"
                reset_color = "\033[0m"
                print(f"{green_color}SUCCESS: {example}{reset_color}")
            else:
                red_color = "\033[31m"
                reset_color = "\033[0m"
                print(f"{red_color}FAILED: {example}{reset_color}")

            # If build failed with PackageException and we have retries left, attempt recovery
            if not success and has_package_exception and attempt < max_attempts - 1:
                print("\nPackageException detected in build output")
                if package_info:
                    print(f"Package info: {package_info}")

                # Attempt aggressive cleanup and retry
                paths = FastLEDPaths(self.board.board_name)
                if aggressive_clean_pio_packages(paths, self.board.board_name):
                    print("Retrying build after package cleanup...")
                    continue  # Retry the build
                else:
                    print("Package cleanup did not remove any packages")
                    # Still try again - maybe something else will help
                    print("Retrying build anyway...")
                    continue

            # Build succeeded or no PackageException, return result
            # Generate build_info.json after successful build
            if success:
                generate_build_info_json_from_existing_build(
                    self.build_dir, self.board, example
                )

            return SketchResult(
                success=success,
                output=output,
                build_dir=self.build_dir,
                example=example,
            )

        # Should not reach here, but just in case
        return SketchResult(
            success=False,
            output="Build failed after all retry attempts",
            build_dir=self.build_dir,
            example=example,
        )

    def _build_with_fbuild(self, example: str) -> SketchResult:
        """Build using fbuild instead of PlatformIO CLI."""
        from ci.util.fbuild_runner import run_fbuild_compile

        environment = self.board.board_name
        result = run_fbuild_compile(
            self.build_dir,
            environment=environment,
            verbose=self.verbose,
        )
        success = result.success

        if success:
            green_color = "\033[32m"
            reset_color = "\033[0m"
            print(f"{green_color}SUCCESS: {example}{reset_color}")
            generate_build_info_json_from_existing_build(
                self.build_dir, self.board, example
            )
        else:
            red_color = "\033[31m"
            reset_color = "\033[0m"
            print(f"{red_color}FAILED: {example}{reset_color}")

        return SketchResult(
            success=success,
            output=result.output or "fbuild compilation",
            build_dir=self.build_dir,
            example=example,
        )

    def _internal_build_no_lock(
        self, example: str, cancelled: threading.Event
    ) -> SketchResult:
        """Build a specific example without lock management. Only call from build()."""
        if cancelled.is_set():
            return SketchResult(
                success=False,
                output="Cancelled",
                build_dir=self.build_dir,
                example=example,
            )
        try:
            # ============================================================
            # PHASE 0: Initialize build directory and create platformio.ini
            # ============================================================
            if not self.initialized:
                init_result = self._internal_init_build_no_lock(example)
                if not init_result.success:
                    # Print FAILED message immediately in worker thread
                    red_color = "\033[31m"
                    reset_color = "\033[0m"
                    print(f"{red_color}FAILED: {example}{reset_color}")

                    return SketchResult(
                        success=False,
                        output=init_result.output,
                        build_dir=init_result.build_dir,
                        example=example,
                    )

                if self.use_fbuild:
                    # fbuild handles packages and compilation — build the first example now
                    return self._build_with_fbuild(example)

                # ============================================================
                # PHASE 1: Ensure packages installed via daemon (ONCE per compiler instance)
                # ============================================================
                # NOW platformio.ini exists at self.build_dir/platformio.ini
                print("=" * 60)
                print("ENSURING PLATFORMIO PACKAGES INSTALLED")
                print("=" * 60)

                from ci.util.pio_package_client import ensure_packages_installed

                if not ensure_packages_installed(
                    self.build_dir,  # Project directory with platformio.ini
                    self.board.board_name,  # Environment name
                    timeout=1800,  # 30 minute timeout
                ):
                    print("\n❌ Package installation failed or timed out")
                    # Print FAILED message immediately in worker thread
                    red_color = "\033[31m"
                    reset_color = "\033[0m"
                    print(f"{red_color}FAILED: {example}{reset_color}")

                    return SketchResult(
                        success=False,
                        output="Package installation failed or timed out",
                        build_dir=self.build_dir,
                        example=example,
                    )

                print()  # Blank line separator

                # If initialization succeeded and we just built the example, return success
                # Print SUCCESS message immediately in worker thread
                green_color = "\033[32m"
                reset_color = "\033[0m"
                print(f"{green_color}SUCCESS: {example}{reset_color}")

                return SketchResult(
                    success=True,
                    output="Built during initialization",
                    build_dir=self.build_dir,
                    example=example,
                )

            # No lock management - caller (build) handles locks
            return self._build_internal(example)
        except KeyboardInterrupt as ki:
            print("KeyboardInterrupt: Cancelling build")
            cancelled.set()
            handle_keyboard_interrupt(ki)
            return SketchResult(
                success=False,
                output="Build cancelled by user",
                build_dir=self.build_dir,
                example=example,
            )

    def clean(self) -> None:
        """Clean build artifacts for this platform (acquire platform lock)."""
        print(f"Cleaning build artifacts for platform {self.board.board_name}...")

        try:
            # Remove the local build directory
            if self.build_dir.exists():
                print(f"Removing build directory: {self.build_dir}")
                shutil.rmtree(self.build_dir)
                print(f"✅ Cleaned local build artifacts for {self.board.board_name}")
            else:
                print(
                    f"✅ No build directory found for {self.board.board_name} (already clean)"
                )
        finally:
            pass  # we used to release the platform lock here, but we disabled it

    def clean_all(self) -> None:
        """Clean all build artifacts (local and global packages) for this platform."""
        print(f"Cleaning all artifacts for platform {self.board.board_name}...")

        # Use platform lock for automatic lock release
        with self.platform_lock:
            # Clean local build artifacts first
            if self.build_dir.exists():
                print(f"Removing build directory: {self.build_dir}")
                shutil.rmtree(self.build_dir)
                print(f"✅ Cleaned local build artifacts for {self.board.board_name}")
            else:
                print(f"✅ No build directory found for {self.board.board_name}")

            # Clean global packages directory
            if self.paths.packages_dir.exists():
                print(f"Removing global packages directory: {self.paths.packages_dir}")
                shutil.rmtree(self.paths.packages_dir)
                print(f"✅ Cleaned global packages for {self.board.board_name}")
            else:
                print(
                    f"✅ No global packages directory found for {self.board.board_name}"
                )

            # Clean global core directory (build cache, platforms)
            if self.paths.core_dir.exists():
                print(f"Removing global core directory: {self.paths.core_dir}")
                shutil.rmtree(self.paths.core_dir)
                print(f"✅ Cleaned global core cache for {self.board.board_name}")
            else:
                print(f"✅ No global core directory found for {self.board.board_name}")

    def deploy(
        self, example: str, upload_port: Optional[str] = None, monitor: bool = False
    ) -> SketchResult:
        """Deploy (upload) a specific example to the target device.

        Args:
            example: Name of the example to deploy
            upload_port: Optional specific port for upload (e.g., "/dev/ttyUSB0", "COM3")
            monitor: If True, attach to device monitor after successful upload
        """
        print(f"Deploying {example} to {self.board.board_name}...")

        try:
            # Ensure the build is initialized and the example is built
            if not self.initialized:
                init_result = self._internal_init_build_no_lock(example)
                if not init_result.success:
                    return SketchResult(
                        success=False,
                        output=init_result.output,
                        build_dir=init_result.build_dir,
                        example=example,
                    )
            else:
                # Build the example first (ensures it's up to date)
                build_result = self._build_internal(example)
                if not build_result.success:
                    return build_result

            # Run PlatformIO upload command
            upload_cmd: list[str] = [
                "pio",
                "run",
                "--project-dir",
                str(self.build_dir),
                "--target",
                "upload",
            ]

            if upload_port:
                upload_cmd.extend(["--upload-port", upload_port])

            if self.verbose:
                upload_cmd.append("--verbose")

            print(f"Running upload command: {subprocess.list2cmdline(upload_cmd)}")

            # Create formatter for upload output
            formatter = create_sketch_path_formatter(example)

            running_process = RunningProcess(
                upload_cmd,
                cwd=self.build_dir,
                auto_run=True,
                output_formatter=formatter,
                env=get_pio_execution_env(),
            )
            try:
                # Output is transformed by the formatter, but we need to print it
                while line := running_process.get_next_line(
                    timeout=900
                ):  # 15 minutes for platform builds
                    if isinstance(line, EndOfStream):
                        break
                    # Print the transformed line
                    print(line)
            except OSError as e:
                # Handle output encoding issues on Windows
                print(f"Upload encoding issue: {e}")
                pass

            running_process.wait()

            # Check if upload was successful
            upload_success = running_process.returncode == 0
            upload_output = str(running_process.stdout)

            if not upload_success:
                return SketchResult(
                    success=False,
                    output=upload_output,
                    build_dir=self.build_dir,
                    example=example,
                )

            # Upload completed successfully - release the lock before monitor
            print(f"✅ Upload completed successfully for {example}")

            # If monitor is requested and upload was successful, start monitor
            if monitor:
                print(
                    f"📡 Starting monitor for {example} on {self.board.board_name}..."
                )
                print("📝 Press Ctrl+C to exit monitor")

                monitor_cmd: list[str] = [
                    "pio",
                    "device",
                    "monitor",
                    "--project-dir",
                    str(self.build_dir),
                ]

                if upload_port:
                    monitor_cmd.extend(["--port", upload_port])

                print(
                    f"Running monitor command: {subprocess.list2cmdline(monitor_cmd)}"
                )

                # Start monitor process (no lock needed for monitoring)
                monitor_process = RunningProcess(
                    monitor_cmd,
                    cwd=self.build_dir,
                    auto_run=True,
                    env=get_pio_execution_env(),
                )
                try:
                    while line := monitor_process.get_next_line(
                        timeout=None
                    ):  # No timeout for monitor
                        if isinstance(line, EndOfStream):
                            break
                        print(line)  # No timestamp for monitor output
                except KeyboardInterrupt as ki:
                    handle_keyboard_interrupt(ki)
                    raise
                    print("\n📡 Monitor stopped by user")
                    monitor_process.terminate()
                except OSError as e:
                    print(f"Monitor encoding issue: {e}")
                    pass

                monitor_process.wait()

            return SketchResult(
                success=True,
                output=upload_output,
                build_dir=self.build_dir,
                example=example,
            )

        finally:
            # Only decrement the lock if it hasn't been released yet
            pass  # we used to release the platform lock here, but we disabled it

    def check_usb_permissions(self) -> tuple[bool, str]:
        """Check if USB device access is properly configured on Linux.

        Checks multiple methods for USB device access:
        1. PlatformIO udev rules
        2. User group membership (dialout, uucp, plugdev)
        3. Alternative udev rules files

        Returns:
            Tuple of (has_access, status_message)
        """
        if platform.system() != "Linux":
            return True, "Not applicable on non-Linux systems"

        access_methods: list[str] = []

        # Check 1: PlatformIO udev rules
        udev_rules_path = Path("/etc/udev/rules.d/99-platformio-udev.rules")
        if udev_rules_path.exists():
            access_methods.append("PlatformIO udev rules")

        # Check 2: User group membership
        user_groups = self._get_user_groups()
        usb_groups = ["dialout", "uucp", "plugdev", "tty"]
        user_usb_groups = [group for group in usb_groups if group in user_groups]
        if user_usb_groups:
            access_methods.append(f"Group membership: {', '.join(user_usb_groups)}")

        # Check 3: Alternative udev rules
        alt_udev_files = [
            "/etc/udev/rules.d/99-arduino.rules",
            "/etc/udev/rules.d/50-platformio-udev.rules",
            "/lib/udev/rules.d/99-platformio-udev.rules",
        ]
        for alt_file in alt_udev_files:
            if Path(alt_file).exists():
                access_methods.append(f"Alternative udev rules: {Path(alt_file).name}")

        # Check 4: Root user (always has access)
        if self._is_root_user():
            access_methods.append("Root user privileges")

        if access_methods:
            status = f"USB access available via: {'; '.join(access_methods)}"
            return True, status
        else:
            return False, "No USB device access methods detected"

    def _get_user_groups(self) -> list[str]:
        """Get list of groups the current user belongs to."""
        try:
            result = RunningProcess.run(
                ["groups"],
                cwd=None,
                check=False,
                timeout=10,
            )
            if result.returncode == 0:
                return result.stdout.strip().split()
            return []
        except KeyboardInterrupt as ki:
            handle_keyboard_interrupt(ki)
            raise
        except Exception:
            return []

    def _is_root_user(self) -> bool:
        """Check if running as root user."""
        try:
            import os

            geteuid = getattr(os, "geteuid", None)
            if geteuid is not None:
                return geteuid() == 0
            return False
        except KeyboardInterrupt as ki:
            handle_keyboard_interrupt(ki)
            raise
        except Exception:
            return False

    def check_udev_rules(self) -> bool:
        """Check if PlatformIO udev rules are installed on Linux.

        DEPRECATED: Use check_usb_permissions() instead for comprehensive checking.

        Returns:
            True if any USB access method is available, False otherwise
        """
        has_access, _ = self.check_usb_permissions()
        return has_access

    def install_usb_permissions(self) -> bool:
        """Install platform-specific USB permissions (e.g., udev rules on Linux).

        Returns:
            True if installation succeeded, False otherwise
        """
        import os

        if platform.system() != "Linux":
            print("INFO: udev rules are only needed on Linux systems")
            return True

        udev_url = "https://raw.githubusercontent.com/platformio/platformio-core/develop/platformio/assets/system/99-platformio-udev.rules"
        udev_rules_path = "/etc/udev/rules.d/99-platformio-udev.rules"

        print("📡 Downloading PlatformIO udev rules...")

        try:
            # Download the udev rules
            with urllib.request.urlopen(udev_url) as response:
                udev_content = response.read().decode("utf-8")

            # Write to temporary file first
            temp_file = "/tmp/99-platformio-udev.rules"
            with open(temp_file, "w") as f:
                f.write(udev_content)

            print("💾 Installing udev rules (requires sudo)...")

            # Use sudo to copy to system location
            result = RunningProcess.run(
                ["sudo", "cp", temp_file, udev_rules_path],
                cwd=None,
                check=False,
                timeout=10,
            )

            if result.returncode != 0:
                print(f"ERROR: Failed to install udev rules: {result.stdout}")
                return False

            # Clean up temp file
            os.unlink(temp_file)

            print("✅ PlatformIO udev rules installed successfully!")
            print("⚠️  To complete the installation, run one of the following:")
            print("   sudo service udev restart")
            print("   # or")
            print("   sudo udevadm control --reload-rules")
            print("   sudo udevadm trigger")
            print(
                "⚠️  You may also need to restart your system for the changes to take effect."
            )

            return True

        except KeyboardInterrupt as ki:
            handle_keyboard_interrupt(ki)
            raise
        except Exception as e:
            print(f"ERROR: Failed to install udev rules: {e}")
            return False

    def supports_merged_bin(self) -> bool:
        """Check if board supports merged binary generation.

        Returns:
            True if the board platform supports merged binary generation
        """
        supported_platforms = ["espressif32", "espressif8266"]
        return any(
            plat in str(self.board.platform).lower() for plat in supported_platforms
        )

    def _artifacts_dir(self, env_name: str) -> Path:
        # fbuild emits artifacts (firmware.*, bootloader.bin, partitions.bin,
        # boot_app0.bin) into .fbuild/build/<env>/release/. PlatformIO's
        # ESP-IDF post-build steps put the same set into .pio/build/<env>/.
        # Whichever backend ran the compile is where the artifacts live.
        if self.use_fbuild:
            return self.build_dir / ".fbuild" / "build" / env_name / "release"
        return self.build_dir / ".pio" / "build" / env_name

    def get_merged_bin_path(self, example: str) -> Optional[Path]:
        """Get path to merged binary if it exists.

        Args:
            example: Example name (unused but kept for API consistency)

        Returns:
            Path to merged.bin if it exists, None otherwise
        """
        merged_bin = self._artifacts_dir(self.board.board_name) / "merged.bin"
        return merged_bin if merged_bin.exists() else None

    def build_with_merged_bin(
        self, example: str, output_path: Optional[Path] = None
    ) -> SketchResult:
        """Build example and generate merged binary.

        Args:
            example: Example name to build
            output_path: Optional custom output path for merged binary

        Returns:
            SketchResult with merged_bin_path populated
        """
        # 1. Validate board support
        if not self.supports_merged_bin():
            return SketchResult(
                success=False,
                output=f"Board {self.board.board_name} does not support merged binary. "
                f"Supported platforms: ESP32, ESP8266",
                build_dir=self.build_dir,
                example=example,
            )

        # 2. Build normally first.
        #
        # Both backends produce the artifact set ``esptool merge_bin`` needs
        # (``firmware.bin`` + ``bootloader.bin`` + ``partitions.bin``), just
        # in different directories. ``_artifacts_dir`` returns whichever path
        # the active backend wrote to, so the merge step below reads from
        # the right place without copying anything around.
        #
        # fbuild >= 2.1.7 is required for ESP32 merged-bin support — earlier
        # versions did not emit the boot/partition artifacts (FastLED#2287).
        cancelled = threading.Event()
        result = self._internal_build_no_lock(example, cancelled)
        if not result.success:
            return result

        # 3. Use esptool to merge binaries
        # PlatformIO doesn't have a merge_bin target - we must use esptool directly
        env_name = self.board.board_name
        artifacts_dir = self._artifacts_dir(env_name)

        # Find required binary components
        bootloader_bin = artifacts_dir / "bootloader.bin"
        partitions_bin = artifacts_dir / "partitions.bin"
        boot_app0_bin = artifacts_dir / "boot_app0.bin"
        firmware_bin = artifacts_dir / "firmware.bin"

        # Verify all required files exist
        missing_files: list[str] = []
        for bin_file in [bootloader_bin, partitions_bin, firmware_bin]:
            if not bin_file.exists():
                missing_files.append(str(bin_file))

        if missing_files:
            # The build appeared to succeed but the expected artifacts aren't
            # where we look for them. Most often this means the compile
            # actually failed earlier, or fbuild's ESP32 orchestrator skipped
            # its boot-artifact step (e.g. on an older fbuild). Dump both the
            # active backend's dir and the inactive one so the real artifact
            # location is visible regardless of which backend ran.
            backend = "fbuild" if self.use_fbuild else "PlatformIO"
            diagnostics: list[str] = []
            diagnostics.append(
                f"Required binary files not found after build: {', '.join(missing_files)}"
            )
            diagnostics.append("")
            diagnostics.append(
                f"Build succeeded ({backend}) but expected artifacts are missing."
            )
            diagnostics.append(
                "This typically means the compile actually failed earlier, "
                "the build system emitted artifacts at a non-standard location, "
                "or an ESP-IDF post-build step was skipped."
            )
            for label, directory in [
                (f"Active backend ({backend}) artifacts dir", artifacts_dir),
                (
                    "PlatformIO artifacts dir",
                    self.build_dir / ".pio" / "build" / env_name,
                ),
                (
                    "fbuild release dir",
                    self.build_dir / ".fbuild" / "build" / env_name / "release",
                ),
            ]:
                diagnostics.append("")
                diagnostics.append(f"=== {label}: {directory} ===")
                if directory.exists():
                    try:
                        entries = sorted(p.name for p in directory.iterdir())
                    except OSError as e:
                        diagnostics.append(f"  (failed to list: {e})")
                    else:
                        if entries:
                            diagnostics.extend(f"  {name}" for name in entries)
                        else:
                            diagnostics.append("  (empty)")
                else:
                    diagnostics.append("  (does not exist)")
            diagnostics.append("")
            diagnostics.append("--- Last 4KB of compile output ---")
            build_output = result.output or ""
            tail = build_output[-4096:] if build_output else "(no output captured)"
            diagnostics.append(tail)
            diagnostics.append("--- End compile output ---")
            return SketchResult(
                success=False,
                output="\n".join(diagnostics),
                build_dir=self.build_dir,
                example=example,
            )

        # Create boot_app0.bin if it doesn't exist (it's optional)
        if not boot_app0_bin.exists():
            # Create default 8KB boot_app0.bin (all 0xFF)
            boot_app0_bin.write_bytes(b"\xff" * 8192)
            print("Created default boot_app0.bin")

        # Determine chip type and offsets based on board
        # ESP32 (original) uses 0x1000 for bootloader
        # ESP32-S3/C3 use 0x0 for bootloader
        platform_str = str(self.board.platform).lower()
        if "esp32s3" in env_name.lower() or "esp32s3" in platform_str:
            chip = "esp32s3"
            bootloader_offset = "0x0"
        elif "esp32c3" in env_name.lower() or "esp32c3" in platform_str:
            chip = "esp32c3"
            bootloader_offset = "0x0"
        elif "esp8266" in platform_str:
            chip = "esp8266"
            bootloader_offset = "0x0"
        else:
            # Default ESP32
            chip = "esp32"
            bootloader_offset = "0x1000"

        # Create merged binary using esptool
        merged_bin = artifacts_dir / "merged.bin"

        merge_cmd = [
            "uv",
            "run",
            "esptool",
            "--chip",
            chip,
            "merge-bin",
            "-o",
            str(merged_bin),
            "--flash-mode",
            "dio",
            "--flash-freq",
            "80m",
            "--flash-size",
            "4MB",
            "--pad-to-size",
            "4MB",
            bootloader_offset,
            str(bootloader_bin),
            "0x8000",
            str(partitions_bin),
            "0xe000",
            str(boot_app0_bin),
            "0x10000",
            str(firmware_bin),
        ]

        print(f"Running esptool merge_bin: {' '.join(merge_cmd)}")

        try:
            merge_proc = RunningProcess(
                merge_cmd,
                env=get_pio_execution_env(),
                auto_run=True,
                timeout=300,
            )
            output = ""
            while line := merge_proc.get_next_line(timeout=300):
                if isinstance(line, EndOfStream):
                    break
                output += str(line)
            exit_code = merge_proc.wait()
            if exit_code != 0:
                return SketchResult(
                    success=False,
                    output=f"esptool merge_bin failed: {output}",
                    build_dir=self.build_dir,
                    example=example,
                )
        except RuntimeError as e:
            if "timeout" in str(e).lower():
                return SketchResult(
                    success=False,
                    output="esptool merge_bin timed out after 300 seconds",
                    build_dir=self.build_dir,
                    example=example,
                )
            else:
                return SketchResult(
                    success=False,
                    output=f"esptool failed: {e}",
                    build_dir=self.build_dir,
                    example=example,
                )
        except FileNotFoundError:
            return SketchResult(
                success=False,
                output="esptool not found. Please install: uv pip install esptool",
                build_dir=self.build_dir,
                example=example,
            )

        # 4. Verify merged binary was created
        if not merged_bin.exists():
            return SketchResult(
                success=False,
                output=f"Merged binary not created at {merged_bin}",
                build_dir=self.build_dir,
                example=example,
            )

        # 5. Copy to output path if specified
        if output_path:
            output_path.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(merged_bin, output_path)
            merged_bin = output_path

        # 6. Return enhanced result
        return SketchResult(
            success=True,
            output=result.output,
            build_dir=self.build_dir,
            example=example,
            merged_bin_path=merged_bin,
        )


def run_pio_build(
    board: Board | str,
    examples: list[str],
    verbose: bool = False,
    additional_defines: Optional[list[str]] = None,
    additional_include_dirs: Optional[list[str]] = None,
) -> list[Future[SketchResult]]:
    """Run build for specified examples and platform using new PlatformIO system.

    Args:
        board: Board class instance or board name string (resolved via create_board())
        examples: List of example names to build
        verbose: Enable verbose output
        additional_defines: Additional defines to add to build flags (e.g., ["FASTLED_DEFINE=0", "DEBUG=1"])
        additional_include_dirs: Additional include directories to add to build flags (e.g., ["src/platforms/sub", "external/libs"])
    """
    pio = PioCompiler(
        board,
        verbose,
        Path.home() / ".fastled" / "platformio_cache",
        additional_defines,
        additional_include_dirs,
        None,
    )
    return pio.build(examples)
