from ci.util.global_interrupt_handler import handle_keyboard_interrupt


# pyright: reportUnknownMemberType=false
"""
Create build directory for project.
"""

import json
import os
import shutil
import subprocess
import time
import warnings
from pathlib import Path
from typing import Any, Callable, Optional

from ci.boards import Board
from ci.util.locked_print import locked_print


def _install_global_package(package: str) -> None:
    # example pio pkg -g -p "https://github.com/maxgerhardt/platform-raspberrypi.git".
    locked_print(f"*** Installing {package} ***")
    cmd_list = [
        "pio",
        "pkg",
        "install",
        "-g",
        "-p",
        package,
    ]
    cmd_str = subprocess.list2cmdline(cmd_list)
    locked_print(f"Running command:\n\n{cmd_str}\n\n")
    result = subprocess.run(
        cmd_str,
        shell=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        check=True,
    )
    locked_print(result.stdout)
    locked_print(f"*** Finished installing {package} ***")


def insert_tool_aliases(meta_json: dict[str, dict[str, Any]]) -> None:
    for board in meta_json.keys():
        aliases: dict[str, Optional[str]] = {}
        cc_path_value = meta_json[board].get("cc_path")
        resolved_cc_path: Optional[Path] = None
        if cc_path_value:
            try:
                candidate = Path(str(cc_path_value))
                if candidate.is_absolute() and candidate.exists():
                    resolved_cc_path = candidate
                elif candidate.exists():
                    resolved_cc_path = candidate.resolve()
                else:
                    which_result = shutil.which(
                        candidate.name if candidate.name else str(candidate)
                    )
                    if which_result:
                        resolved_cc_path = Path(which_result)
            except KeyboardInterrupt as ki:
                handle_keyboard_interrupt(ki)
                raise
            except Exception:
                resolved_cc_path = None

        # Try to infer toolchain bin directory and prefix from either CC or GDB path
        tool_bin_dir: Optional[Path] = None
        tool_prefix: str = ""
        tool_suffix: str = ""

        if resolved_cc_path and resolved_cc_path.exists():
            cc_base = resolved_cc_path.name
            # If cc_path points at a real gcc binary, derive prefix/suffix from it.
            # If it's a wrapper (e.g. cached_CC.cmd) without "gcc" in the name,
            # fall back to using gdb_path to derive the actual toolchain prefix/suffix.
            if "gcc" in cc_base:
                tool_bin_dir = resolved_cc_path.parent
                tool_prefix = cc_base.split("gcc")[0]
                tool_suffix = resolved_cc_path.suffix
            else:
                resolved_cc_path = None  # Force gdb-based fallback below
        if resolved_cc_path is None:
            gdb_path_value = meta_json[board].get("gdb_path")
            if gdb_path_value:
                try:
                    gdb_path = Path(str(gdb_path_value))
                    if not gdb_path.exists():
                        which_gdb = shutil.which(gdb_path.name)
                        if which_gdb:
                            gdb_path = Path(which_gdb)
                    if gdb_path.exists():
                        tool_bin_dir = gdb_path.parent
                        gdb_base = gdb_path.name
                        # Derive prefix like 'arm-none-eabi-' from 'arm-none-eabi-gdb'
                        tool_prefix = (
                            gdb_base.split("gdb")[0] if "gdb" in gdb_base else ""
                        )
                        tool_suffix = gdb_path.suffix
                except KeyboardInterrupt as ki:
                    handle_keyboard_interrupt(ki)
                    raise
                except Exception:
                    pass

        tools = [
            "gcc",
            "g++",
            "ar",
            "objcopy",
            "objdump",
            "size",
            "nm",
            "ld",
            "as",
            "ranlib",
            "strip",
            "c++filt",
            "readelf",
            "addr2line",
        ]

        if tool_bin_dir is not None:
            for tool in tools:
                name = f"{tool_prefix}{tool}" + tool_suffix
                tool_path = tool_bin_dir / name
                if tool_path.exists():
                    aliases[tool] = str(tool_path)
                else:
                    which_result = shutil.which(name)
                    aliases[tool] = str(Path(which_result)) if which_result else None
        else:
            # Fallback: resolve via PATH only
            for tool in tools:
                which_result = shutil.which(tool)
                aliases[tool] = str(Path(which_result)) if which_result else None

        meta_json[board]["aliases"] = aliases


def remove_readonly(func: Callable[..., Any], path: str, _: Any) -> None:
    "Clear the readonly bit and reattempt the removal"
    if os.name == "nt":
        os.system(f"attrib -r {path}")
    else:
        try:
            os.chmod(path, 0o777)
        except KeyboardInterrupt as ki:
            handle_keyboard_interrupt(ki)
            raise
        except Exception:
            print(f"Error removing readonly attribute from {path}")

    func(path)


def robust_rmtree(path: Path, max_retries: int, delay: float) -> bool:
    """
    Robustly remove a directory tree, handling race conditions and concurrent access.

    Args:
        path: Path to the directory to remove
        max_retries: Maximum number of retry attempts
        delay: Delay between retries in seconds

    Returns:
        True if removal was successful, False otherwise
    """
    if not path.exists():
        locked_print(f"Directory {path} doesn't exist, skipping removal")
        return True

    for attempt in range(max_retries):
        try:
            locked_print(
                f"Attempting to remove directory {path} (attempt {attempt + 1}/{max_retries})"
            )
            shutil.rmtree(path, onerror=remove_readonly)
            locked_print(f"Successfully removed directory {path}")
            return True
        except OSError as e:
            if attempt == max_retries - 1:
                locked_print(
                    f"Failed to remove directory {path} after {max_retries} attempts: {e}"
                )
                return False

            # Log the specific error and retry
            locked_print(
                f"Failed to remove directory {path} on attempt {attempt + 1}: {e}"
            )

            # Check if another process removed it
            if not path.exists():
                locked_print(f"Directory {path} was removed by another process")
                return True

            # Wait before retrying
            time.sleep(delay * (2**attempt))  # Exponential backoff

        except KeyboardInterrupt as ki:
            handle_keyboard_interrupt(ki)
            raise
        except Exception as e:
            locked_print(f"Unexpected error removing directory {path}: {e}")
            return False

    return False


def safe_file_removal(file_path: Path, max_retries: int) -> bool:
    """
    Safely remove a file with retry logic.

    Args:
        file_path: Path to the file to remove
        max_retries: Maximum number of retry attempts

    Returns:
        True if removal was successful, False otherwise
    """
    if not file_path.exists():
        return True

    for attempt in range(max_retries):
        try:
            file_path.unlink()
            locked_print(f"Successfully removed file {file_path}")
            return True
        except OSError as e:
            if attempt == max_retries - 1:
                locked_print(
                    f"Failed to remove file {file_path} after {max_retries} attempts: {e}"
                )
                return False

            locked_print(
                f"Failed to remove file {file_path} on attempt {attempt + 1}: {e}"
            )

            # Check if another process removed it
            if not file_path.exists():
                locked_print(f"File {file_path} was removed by another process")
                return True

            time.sleep(0.1 * (attempt + 1))

        except KeyboardInterrupt as ki:
            handle_keyboard_interrupt(ki)
            raise
        except Exception as e:
            locked_print(f"Unexpected error removing file {file_path}: {e}")
            return False

    return False


def create_build_dir(
    board: Board,
    defines: list[str],
    customsdk: Optional[str],
    no_install_deps: bool,
    extra_packages: list[str],
    build_dir: Optional[str],
    board_dir: Optional[str],
    build_flags: Optional[list[str]],
    extra_scripts: Optional[str],
) -> tuple[bool, str]:
    """Create the build directory for the given board."""
    import threading

    # filter out "web" board because it's not a real board.
    if board.board_name == "web":
        locked_print(f"Skipping web target for board {board.board_name}")
        return True, ""
    if board.defines:
        defines.extend(board.defines)
        # remove duplicates
        defines = list(set(defines))
    board_name = board.board_name
    real_board_name = board.get_real_board_name()
    thread_id = threading.current_thread().ident
    locked_print(
        f"*** [Thread {thread_id}] Initializing environment for {board_name} ***"
    )
    # builddir = Path(build_dir) / board if build_dir else Path(".build") / board
    build_dir = build_dir or ".build"
    builddir = Path(build_dir) / board_name

    locked_print(f"[Thread {thread_id}] Creating build directory: {builddir}")
    try:
        builddir.mkdir(parents=True, exist_ok=True)
        locked_print(
            f"[Thread {thread_id}] Successfully created build directory: {builddir}"
        )
    except KeyboardInterrupt as ki:
        handle_keyboard_interrupt(ki)
        raise
    except Exception as e:
        locked_print(
            f"[Thread {thread_id}] Error creating build directory {builddir}: {e}"
        )
        return False, f"Failed to create build directory: {e}"
    # if lib directory (where FastLED lives) exists, remove it. This is necessary to run on
    # recycled build directories for fastled to update. This is a fast operation.
    srcdir = builddir / "lib"
    if srcdir.exists():
        locked_print(f"[Thread {thread_id}] Removing existing lib directory: {srcdir}")
        # STRICT: Explicit retry parameters - NO defaults allowed
        if not robust_rmtree(srcdir, max_retries=5, delay=0.1):
            locked_print(
                f"[Thread {thread_id}] Warning: Failed to remove lib directory {srcdir}, continuing anyway"
            )

    platformio_ini = builddir / "platformio.ini"
    if platformio_ini.exists():
        locked_print(
            f"[Thread {thread_id}] Removing existing platformio.ini: {platformio_ini}"
        )
        # STRICT: Explicit retry parameter - NO defaults allowed
        if not safe_file_removal(platformio_ini, max_retries=3):
            locked_print(
                f"[Thread {thread_id}] Warning: Failed to remove {platformio_ini}, continuing anyway"
            )

    if board_dir:
        dst_dir = builddir / "boards"
        locked_print(
            f"[Thread {thread_id}] Processing board directory: {board_dir} -> {dst_dir}"
        )

        if dst_dir.exists():
            locked_print(
                f"[Thread {thread_id}] Removing existing boards directory: {dst_dir}"
            )
            # STRICT: Explicit retry parameters - NO defaults allowed
            if not robust_rmtree(dst_dir, max_retries=5, delay=0.1):
                locked_print(
                    f"[Thread {thread_id}] Error: Failed to remove boards directory {dst_dir}"
                )
                return False, f"Failed to remove existing boards directory {dst_dir}"

        try:
            locked_print(
                f"[Thread {thread_id}] Copying board directory: {board_dir} -> {dst_dir}"
            )
            shutil.copytree(str(board_dir), str(dst_dir))
            locked_print(
                f"[Thread {thread_id}] Successfully copied board directory to {dst_dir}"
            )
        except KeyboardInterrupt as ki:
            handle_keyboard_interrupt(ki)
            raise
        except Exception as e:
            locked_print(f"[Thread {thread_id}] Error copying board directory: {e}")
            return False, f"Failed to copy board directory: {e}"
    if board.platform_needs_install:
        if board.platform:
            try:
                _install_global_package(board.platform)
            except subprocess.CalledProcessError as e:
                stdout = e.stdout
                return False, stdout
        else:
            warnings.warn("Platform install was specified but no platform was given.")

    cmd_list = [
        "pio",
        "project",
        "init",
        "--project-dir",
        builddir.as_posix(),
        "--board",
        real_board_name,
    ]
    if board.platform:
        cmd_list.append(f"--project-option=platform={board.platform}")
    if board.platform_packages:
        cmd_list.append(f"--project-option=platform_packages={board.platform_packages}")
    if board.framework:
        cmd_list.append(f"--project-option=framework={board.framework}")
    if board.board_build_core:
        cmd_list.append(f"--project-option=board_build.core={board.board_build_core}")
    if board.board_build_filesystem_size:
        cmd_list.append(
            f"--project-option=board_build.filesystem_size={board.board_build_filesystem_size}"
        )
    if build_flags is not None:
        for build_flag in build_flags:
            cmd_list.append(f"--project-option=build_flags={build_flag}")
    if defines:
        build_flags_str = " ".join(f"-D{define}" for define in defines)
        cmd_list.append(f"--project-option=build_flags={build_flags_str}")
    if board.customsdk:
        cmd_list.append(f"--project-option=custom_sdkconfig={customsdk}")
    if extra_packages:
        cmd_list.append(f"--project-option=lib_deps={','.join(extra_packages)}")
    if no_install_deps:
        cmd_list.append("--no-install-dependencies")

    # Get absolute paths for scripts
    project_root = Path.cwd()
    ci_flags_script = (project_root / "ci" / "ci-flags.py").resolve().as_posix()

    # Create a list of scripts with pre: prefix
    script_list = [f"pre:{ci_flags_script}"]

    # Add any additional scripts
    if extra_scripts:
        # Convert to absolute path and use Unix-style separators
        extra_scripts_path = str(Path(extra_scripts).resolve().as_posix())
        if not extra_scripts_path.startswith("pre:"):
            extra_scripts_path = f"pre:{extra_scripts_path}"
        script_list.append(extra_scripts_path)

    # Add the scripts as a list
    cmd_list.append(f"--project-option=extra_scripts=[{','.join(script_list)}]")

    cmd_str = subprocess.list2cmdline(cmd_list)
    locked_print(f"\n\nRunning command:\n  {cmd_str}\n")
    result = subprocess.run(
        cmd_str,
        shell=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        check=False,
    )
    stdout = result.stdout
    locked_print(result.stdout)
    if result.returncode != 0:
        locked_print(
            f"*** [Thread {thread_id}] Error setting up board {board_name} ***"
        )
        return False, stdout
    locked_print(
        f"*** [Thread {thread_id}] Finished initializing environment for board {board_name} ***"
    )

    # Print the location of the generated platformio.ini file
    platformio_ini_path = builddir / "platformio.ini"
    locked_print(f"Writing to platformio.ini {platformio_ini_path}")

    # Print the contents of the generated platformio.ini file for debugging
    platformio_ini_path = builddir / "platformio.ini"
    if platformio_ini_path.exists():
        locked_print(
            f"\n*** Contents of {platformio_ini_path} after initialization ***"
        )
        try:
            with open(platformio_ini_path, "r") as f:
                ini_contents = f.read()
                locked_print(f"\n\n{ini_contents}\n\n")
        except KeyboardInterrupt as ki:
            handle_keyboard_interrupt(ki)
            raise
        except Exception as e:
            locked_print(f"Error reading {platformio_ini_path}: {e}")
        locked_print(f"*** End of {platformio_ini_path} contents ***\n")
    else:
        locked_print(
            f"Warning: {platformio_ini_path} was not found after initialization"
        )

    # dumping enviorment variables to help debug.
    # this is the command: pio run --target envdump
    cwd = str(builddir.resolve())
    cmd_list = [
        "pio",
        "project",
        "metadata",
        "--json-output",
    ]
    cmd_str = subprocess.list2cmdline(cmd_list)
    stdout = subprocess.run(
        cmd_list,
        cwd=cwd,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        check=False,
    ).stdout

    try:
        data = json.loads(stdout)
        # now dump the values to the file at the root of the build directory.
        matadata_json = builddir / "build_info.json"
        try:
            insert_tool_aliases(data)
            formatted = json.dumps(data, indent=4, sort_keys=True)
            with open(matadata_json, "w") as f:
                f.write(formatted)
        except KeyboardInterrupt as ki:
            handle_keyboard_interrupt(ki)
            raise
        except Exception:
            with open(matadata_json, "w") as f:
                f.write(stdout)
    except json.JSONDecodeError:
        msg = f"build_info.json will not be generated because of error because stdout does not look like a json file:\n#### STDOUT ####\n{stdout}\n#### END STDOUT ####\n"
        locked_print(msg)
    return True, stdout
