#!/usr/bin/env python3
"""
idf_arduino_setup.py

Prepares an ESP-IDF project freshly created with the VisualGDB wizard
(tested with the "blink" and "hello_world" samples, ESP-IDF 6.1.0,
Arduino-ESP32 4.0.0-rc1) so that the Arduino-ESP32 core can be used as an
ESP-IDF component and debugged with VisualGDB.

Usage:
  1. Copy this script into the project root folder (where sdkconfig and
     the .vgdbproj file are located).
  2. Close Visual Studio or unload the project.
  3. Run it by double-clicking or with "python idf_arduino_setup.py".

Optionally, a different project folder can be passed as a parameter:
  python idf_arduino_setup.py D:\\path\\to\\project

A backup *.bak is created once for every modified file.
The script can safely be run multiple times.
"""

import re
import shutil
import sys
from pathlib import Path

# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------

ARDUINO_COMPONENT = "espressif/arduino-esp32"
ARDUINO_VERSION = "4.0.0-rc1"

# With IDF 6.1, Arduino core 4.0.0-rc1 produces warnings in ESP_I2S.cpp
# that would otherwise break the build because of -Werror.
COMPILE_OPTION = "-Wno-error=missing-field-initializers"
COMPILE_OPTION_LINE = f'idf_build_set_property(COMPILE_OPTIONS "{COMPILE_OPTION}" APPEND)'

# Arduino core: #warning hints (e.g. ESP_SR on ESP32-S3) are not treated as errors,
# only for the Arduino component. Must be placed AFTER project(...).
ARDUINO_CPP_WARN_LINES = [
    "idf_component_get_property(arduino_lib espressif__arduino-esp32 COMPONENT_LIB)",
    "target_compile_options(${arduino_lib} PRIVATE -Wno-error=cpp)",
]

# Values required by the Arduino core in the ESP-IDF configuration.
SDKCONFIG_VALUES = {
    "CONFIG_FREERTOS_HZ": "1000",               # FreeRTOS tick rate 1000 Hz
    "CONFIG_MBEDTLS_PSK_MODES": "y",            # Enable pre-shared-key ciphersuites
    "CONFIG_MBEDTLS_KEY_EXCHANGE_PSK": "y",     # Enable PSK based ciphersuite modes
}

# VisualGDB project settings.
VGDB_OPTIONS = {
    # "Use file names only when setting breakpoints": avoids the huge
    # -file-list-exec-source-files query that makes Visual Studio crash.
    "UseRelativePathsOnly": "true",
}

# Content of the new main source file.
MAIN_TEMPLATE = """#include "Arduino.h"

extern "C" void app_main()
{
    initArduino();
    Serial.begin(115200);

    int counter = 0;
    for (;;)
    {
        Serial.printf("Hello %d\\n", counter++);
        delay(1000);
    }
}
"""

# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------

errors = 0


def report(status, message):
    """Prints a status line. status: OK, NEW, CHANGED, NOTE, ERROR."""
    global errors
    if status == "ERROR":
        errors += 1
    print(f"  [{status:<7}] {message}")


def backup(path):
    """Creates a backup <file>.bak once (an existing backup is kept)."""
    bak = path.with_name(path.name + ".bak")
    if not bak.exists():
        shutil.copy2(path, bak)


class TextFile:
    """Reads a text file and writes it back with the same encoding,
    BOM and line endings."""

    def __init__(self, path):
        self.path = path
        raw = path.read_bytes()
        self.bom = raw.startswith(b"\xef\xbb\xbf")
        text = raw.decode("utf-8-sig")
        self.crlf = "\r\n" in text
        # Always work with \n internally
        self.text = text.replace("\r\n", "\n")
        self.original = self.text

    def save(self):
        if self.text == self.original:
            return False
        backup(self.path)
        text = self.text.replace("\n", "\r\n") if self.crlf else self.text
        data = text.encode("utf-8")
        if self.bom:
            data = b"\xef\xbb\xbf" + data
        self.path.write_bytes(data)
        return True


def set_kconfig_value(text, key, value):
    """Sets key=value in sdkconfig text. Returns (new_text, status)."""
    line = f"{key}={value}"
    k = re.escape(key)

    m = re.search(rf"^{k}=(.*)$", text, re.M)
    if m:
        if m.group(1) == value:
            return text, "already set"
        old = m.group(1)
        return text[:m.start()] + line + text[m.end():], f"changed ({old} -> {value})"

    m = re.search(rf"^# {k} is not set$", text, re.M)
    if m:
        return text[:m.start()] + line + text[m.end():], "enabled"

    if text and not text.endswith("\n"):
        text += "\n"
    return text + line + "\n", "added"


# ---------------------------------------------------------------------------
# Individual steps
# ---------------------------------------------------------------------------

def step_component_manifest(root):
    print("\n1. Add Arduino core to main/idf_component.yml")
    path = root / "main" / "idf_component.yml"
    entry_value = f'{ARDUINO_COMPONENT}: "{ARDUINO_VERSION}"'

    if not path.exists():
        path.write_text(f"dependencies:\n  {entry_value}\n", encoding="utf-8")
        report("NEW", f"{path.name} created")
        return

    f = TextFile(path)
    m = re.search(rf"^([ \t]*){re.escape(ARDUINO_COMPONENT)}[ \t]*:(.*)$", f.text, re.M)
    if m:
        rest = m.group(2).strip()
        if not rest:
            report("NOTE", "Entry exists in extended form, please check the version manually")
            return
        new_line = f"{m.group(1)}{entry_value}"
        if m.group(0) == new_line:
            report("OK", f"already present ({ARDUINO_VERSION})")
            return
        f.text = f.text[:m.start()] + new_line + f.text[m.end():]
        f.save()
        report("CHANGED", f"version updated to {ARDUINO_VERSION}")
        return

    dep = re.search(r"^dependencies:[ \t]*$", f.text, re.M)
    if dep:
        # Use indentation of the next entry, otherwise two spaces
        after = f.text[dep.end():]
        nxt = re.search(r"\n([ \t]+)\S", after)
        indent = nxt.group(1) if nxt else "  "
        f.text = f.text[:dep.end()] + "\n" + indent + entry_value + f.text[dep.end():]
    else:
        if f.text and not f.text.endswith("\n"):
            f.text += "\n"
        f.text += f"dependencies:\n  {entry_value}\n"
    f.save()
    report("CHANGED", f"{ARDUINO_COMPONENT} {ARDUINO_VERSION} added")


def step_root_cmakelists(root):
    print("\n2. Add compiler options to CMakeLists.txt (project root)")
    path = root / "CMakeLists.txt"
    f = TextFile(path)

    m = re.search(r"^[ \t]*project[ \t]*\(", f.text, re.M)
    if not m:
        report("ERROR", "project(...) line not found, please add manually:")
        print(f"              {COMPILE_OPTION_LINE}")
        for line in ARDUINO_CPP_WARN_LINES:
            print(f"              {line}")
        return

    # a) before project(...)
    if COMPILE_OPTION in f.text:
        report("OK", "-Wno-error=missing-field-initializers already present")
    else:
        inc = f.text.find("project.cmake")
        if inc == -1 or inc > m.start():
            report("ERROR", "include(.../project.cmake) is not before project(...), please check")
            return
        f.text = f.text[:m.start()] + COMPILE_OPTION_LINE + "\n" + f.text[m.start():]
        report("CHANGED", "-Wno-error=missing-field-initializers inserted before project(...)")

    # b) after project(...): append at end of file
    if "-Wno-error=cpp" in f.text:
        report("OK", "-Wno-error=cpp for Arduino component already present")
    else:
        if f.text and not f.text.endswith("\n"):
            f.text += "\n"
        f.text += "\n# Arduino core: do not treat #warning hints as errors (e.g. ESP_SR on ESP32-S3)\n" + "\n".join(ARDUINO_CPP_WARN_LINES) + "\n"
        report("CHANGED", "-Wno-error=cpp for Arduino component appended at end of file")

    f.save()


def step_sdkconfig(root, filename, create_if_missing):
    path = root / filename
    if not path.exists():
        if not create_if_missing:
            report("ERROR", f"{filename} not found")
            return
        path.write_text("", encoding="utf-8")
        report("NEW", f"{filename} created")

    f = TextFile(path)
    for key, value in SDKCONFIG_VALUES.items():
        f.text, status = set_kconfig_value(f.text, key, value)
        report("OK" if status == "already set" else "CHANGED", f"{key}={value}: {status}")
    f.save()


def step_main_source(root):
    print("\n5. Convert main source to C++ with Arduino skeleton")
    main_dir = root / "main"
    cmake = main_dir / "CMakeLists.txt"

    for cpp in main_dir.glob("*.cpp"):
        if re.search(r"\bapp_main\s*\(", cpp.read_text(encoding="utf-8", errors="ignore")):
            report("OK", f"{cpp.name} with app_main exists, no change")
            return

    candidates = [
        p for p in main_dir.glob("*.c")
        if re.search(r"\bapp_main\s*\(", p.read_text(encoding="utf-8", errors="ignore"))
    ]
    if len(candidates) != 1:
        report("ERROR", f"{len(candidates)} .c files with app_main found, expected exactly 1")
        return

    c_file = candidates[0]
    cpp_file = c_file.with_suffix(".cpp")
    if cpp_file.exists():
        report("ERROR", f"{cpp_file.name} already exists, please check manually")
        return

    # Keep the line endings of the old file
    crlf = b"\r\n" in c_file.read_bytes()
    content = MAIN_TEMPLATE.replace("\n", "\r\n") if crlf else MAIN_TEMPLATE
    cpp_file.write_bytes(content.encode("utf-8"))

    bak = c_file.with_name(c_file.name + ".bak")
    if bak.exists():
        c_file.unlink()
    else:
        c_file.rename(bak)
    report("CHANGED", f"{c_file.name} -> {cpp_file.name} (original kept as {bak.name})")

    if not cmake.exists():
        report("ERROR", "main/CMakeLists.txt not found")
        return
    f = TextFile(cmake)
    pattern = rf"\b{re.escape(c_file.name)}\b"
    if not re.search(pattern, f.text):
        report("NOTE", f"{c_file.name} not found in main/CMakeLists.txt, please check SRCS")
        return
    f.text = re.sub(pattern, cpp_file.name, f.text)
    f.save()
    report("CHANGED", f"main/CMakeLists.txt: {c_file.name} -> {cpp_file.name}")


def step_vgdbproj(root):
    print("\n6. VisualGDB settings in the .vgdbproj file")
    for proj in root.glob("*.vgdbproj"):
        f = TextFile(proj)
        for element, value in VGDB_OPTIONS.items():
            pattern = re.compile(rf"<{element}>\s*([^<]*?)\s*</{element}>")
            found = pattern.findall(f.text)
            if not found:
                report("ERROR", f"{proj.name}: <{element}> not found, please set it manually in VisualGDB")
                continue
            if all(v == value for v in found):
                report("OK", f"{proj.name}: {element} already {value}")
                continue
            f.text = pattern.sub(f"<{element}>{value}</{element}>", f.text)
            report("CHANGED", f"{proj.name}: {element} -> {value}")
        f.save()


# ---------------------------------------------------------------------------
# Main program
# ---------------------------------------------------------------------------

def check_project(root):
    problems = []
    if not (root / "sdkconfig").is_file():
        problems.append("sdkconfig missing (let VisualGDB configure the project once)")
    if not list(root.glob("*.vgdbproj")):
        problems.append("no .vgdbproj file found")
    if not (root / "CMakeLists.txt").is_file():
        problems.append("CMakeLists.txt missing")
    if not (root / "main").is_dir():
        problems.append("main folder missing")
    return problems


def main():
    if len(sys.argv) > 1:
        root = Path(sys.argv[1]).resolve()
    else:
        root = Path(__file__).resolve().parent

    print("=" * 70)
    print(" Set up Arduino-ESP32 as an ESP-IDF component")
    print(f" Arduino-Core: {ARDUINO_COMPONENT} {ARDUINO_VERSION}")
    print(f" Project folder: {root}")
    print("=" * 70)

    problems = check_project(root)
    if problems:
        print("\nThis does not look like a VisualGDB ESP-IDF project folder:")
        for p in problems:
            print(f"  - {p}")
        print("\nAborted, nothing was changed.")
        return 1

    print("\nPlease close Visual Studio or unload the project first.")
    try:
        input("Press Enter to continue, Ctrl+C to abort ... ")
    except KeyboardInterrupt:
        print("\nAborted, nothing was changed.")
        return 1

    steps = [
        lambda: step_component_manifest(root),
        lambda: step_root_cmakelists(root),
        lambda: (print("\n3. Update sdkconfig"), step_sdkconfig(root, "sdkconfig", False)),
        lambda: (print("\n4. Update sdkconfig.defaults"), step_sdkconfig(root, "sdkconfig.defaults", True)),
        lambda: step_main_source(root),
        lambda: step_vgdbproj(root),
    ]
    for step in steps:
        try:
            step()
        except Exception as exc:  # report the error, continue with the remaining steps
            report("ERROR", f"Unexpected error: {exc}")

    print("\n" + "=" * 70)
    if errors:
        print(f" Finished with {errors} error(s). Please check the messages above.")
    else:
        print(" Done. (Re)load the project in Visual Studio and build it.")
        print(" The first build downloads the Arduino core and takes longer.")
    print("=" * 70)
    return 1 if errors else 0


if __name__ == "__main__":
    code = 1
    try:
        code = main()
    finally:
        try:
            input("\nPress Enter to exit ... ")
        except (KeyboardInterrupt, EOFError):
            pass
    sys.exit(code)
