#!/usr/bin/env python3
# ---------------------------------------------------------------------------
#
#  This file is part of the ecapp library (EtherCAT application devices).
#
#  Copyright (C) 2026  Florian Pose  <fp@igh.de>
#
#  The ecapp library is free software: you can redistribute it and/or
#  modify it under the terms of the GNU Lesser General Public License
#  as published by the Free Software Foundation, version 3 of the
#  License.
#
#  The ecapp library is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
#  Lesser General Public License for more details.
#
#  You should have received a copy of the GNU Lesser General Public
#  License along with the ecapp library. If not, see
#  <https://www.gnu.org/licenses/>.
#
# ---------------------------------------------------------------------------

"""Generates a buildable EtherCAT realtime application skeleton from the JSON
output of "ethercat slaves --json" (part of EtherLab' EtherCAT master command
line tool), using the ecapp library.

The generated CMake project sets up one EcApp::Master with one input and one
output EcApp::Domain running at 1 kHz, and instantiates one EcApp::SubDevice
per EtherCAT slave found in the given network snapshot -- each looked up by
its vendor ID/product code/revision (EcApp::createSubDevice(), see
ecapp/Factory.h) and addressed by (alias, position). A slave whose device
type this build of ecapp does not recognize is instead generated as an
EcApp::GenericSubDevice (see ecapp/GenericSubDevice.h), built from a live
"ethercat pdos --json" snapshot (src/Generic.h/src/Generic.cpp) -- so every
slave ends up with a real, working SubDevice either way. All SubDevices live
directly as members of a generated RtMain class.

The result is a one-time starting point, not code that is meant to be
regenerated in place: Application-specific control logic is added by
hand afterwards in RtMain::update().
"""

import argparse
import datetime
import json
import os
import re
import shutil
import subprocess
import sys
import textwrap
from pathlib import Path

# ---------------------------------------------------------------------------

#: Fixed cyclic task rate of every generated application, in Hz. Not
#: exposed as a command line option -- ecapp-generate always generates
#: a 1 kHz skeleton, matching common EtherCAT DC-synchronized setups;
#: adjust PERIOD_HZ in the generated main.cpp by hand if a different
#: rate is needed (PERIOD_NS there is derived from it via the
#: preprocessor, so that alone is enough).
PERIOD_HZ = 1000

# ---------------------------------------------------------------------------


class Ansi:
    """ANSI escape codes for colored terminal output. Applied only when
    the destination stream is a tty and $NO_COLOR is unset (see
    color()/style())."""

    RESET = "\033[0m"
    BOLD = "\033[1m"
    RED = "\033[31m"
    GREEN = "\033[32m"
    YELLOW = "\033[33m"
    CYAN = "\033[36m"
    #: 256-color escape -- there is no orange in the standard 8/16-color
    #: palette. Used for "generic" slaves in the report (known ones stay
    #: plain GREEN); degrades the same way every other color here does
    #: (see color_enabled()) if the terminal/NO_COLOR says not to.
    ORANGE = "\033[38;5;208m"


def color_enabled(stream) -> bool:
    return "NO_COLOR" not in os.environ and hasattr(
        stream, "isatty"
    ) and stream.isatty()


def style(text: str, *codes: str, stream=sys.stdout) -> str:
    if not codes or not color_enabled(stream):
        return text
    return "".join(codes) + text + Ansi.RESET


# ---------------------------------------------------------------------------


def find_template_dir() -> Path:
    """Locates the rtapp_template directory shipped alongside this
    script, whether run from the ecapp source tree (tools/) or from an
    installed location (e.g. <prefix>/bin next to
    <prefix>/share/ecapp/rtapp_template)."""

    script_dir = Path(__file__).resolve().parent
    candidates = [
        script_dir / "rtapp_template",
        script_dir.parent / "share" / "ecapp" / "rtapp_template",
    ]
    for candidate in candidates:
        if candidate.is_dir():
            return candidate

    raise FileNotFoundError(
        "Could not find the rtapp_template directory (looked in: "
        + ", ".join(str(c) for c in candidates)
        + ")"
    )


# ---------------------------------------------------------------------------


def sanitize_identifier(
    name: str, fallback: str = "slave", lower: bool = True
) -> str:
    """Turns an arbitrary string (e.g. an EtherCAT device's order code)
    into a valid C++ identifier fragment, lowercased unless lower is
    False (used to keep a device type's own casing, e.g. "EL7041")."""

    ident = re.sub(r"[^A-Za-z0-9_]+", "_", name).strip("_")
    ident = re.sub(r"_+", "_", ident)
    if lower:
        ident = ident.lower()
    if not ident:
        ident = fallback
    if ident[0].isdigit():
        ident = "_" + ident
    return ident


def camel_case_identifier(name: str, fallback: str = "Channel") -> str:
    """Turns arbitrary PDO/CoE entry text (e.g. "Status of input A",
    straight from a live "ethercat pdos --json" snapshot -- vendors are
    free to put whatever they like in there, including whitespace) into
    a whitespace-free CamelCase identifier ("StatusOfInputA"). Splits on
    any run of non-alphanumeric characters and upper-cases just the
    first letter of each resulting word, leaving the rest of it alone
    -- so an already-CamelCase/acronym word (e.g. "TxPDO") keeps its
    casing instead of getting mangled. Used for GenericEntry's name
    (see build_generic_tables()), which doubles as the "kind" passed to
    SubDevice::registerInputChannel()/registerOutputChannel() -- i.e.
    ends up part of a pdserv signal/parameter path -- so it may not
    contain whitespace any more than a real C++ identifier could."""

    words = [w for w in re.split(r"[^A-Za-z0-9]+", name) if w]
    ident = "".join(w[:1].upper() + w[1:] for w in words)
    if not ident:
        ident = fallback
    if ident[0].isdigit():
        ident = "_" + ident
    return ident


# ---------------------------------------------------------------------------

#: Generated C++ source wraps to this width, matching this project's own
#: .clang-format ColumnLimit -- generated code is meant to be edited by
#: hand afterwards, so it should already look at home next to the rest
#: of an ecapp-based project.
CODE_WIDTH = 78


def wrap_comment(text: str, width: int = CODE_WIDTH - 4) -> list:
    """Word-wraps text as a run of "// "-prefixed C++ comment lines, at
    most width wide -- the caller still has to add its own indent (see
    Slave.member_decl()/skip_comment() below, which build a whole
    block at width CODE_WIDTH - 4 and add the common 4-space member
    indent to every line in one pass at the end)."""

    return textwrap.wrap(
        text, width=width, initial_indent="// ", subsequent_indent="// "
    )


def wrap_args(args: list, indent: str, width: int = CODE_WIDTH - 4) -> list:
    """Greedily packs a createSubDevice() call's arguments (each with
    its trailing comma already attached, except the last) onto lines
    at most width wide, continuation lines starting with indent. Every
    argument here is a single token with no internal whitespace (a
    literal, an identifier, or a quoted string with no spaces in it),
    so wrapping on whitespace -- and never inside a token, including
    one containing a hyphen, such as an EtherCAT order code with a
    variant suffix -- is exactly the packing we want."""

    tokens = [
        arg + ("," if i < len(args) - 1 else "")
        for i, arg in enumerate(args)
    ]
    return textwrap.wrap(
        " ".join(tokens),
        width=width,
        initial_indent=indent,
        subsequent_indent=indent,
        break_long_words=False,
        break_on_hyphens=False,
    )


# ---------------------------------------------------------------------------


class Slave:
    """One EtherCAT slave, as extracted from an "ethercat slaves --json"
    snapshot, plus the generated C++ identifier it gets in RtMain."""

    def __init__(self, entry: dict):
        try:
            identity = entry["identity"]
            self.position = int(entry["position"])
            self.alias = int(entry["alias"])
            self.vendor_id = int(identity["vendor_id"])
            self.product_code = int(identity["product_code"])
            self.revision = int(identity["revision_number"])
        except (KeyError, TypeError, ValueError) as e:
            raise ValueError(
                f"Slave entry is missing required field {e}: {entry!r}"
            ) from e

        general = entry.get("general") or {}
        self.order = general.get("order") or ""
        self.description = (
            general.get("device_name") or entry.get("name") or self.order
            or f"slave {self.position}"
        )

        # Set from the outside once known -- see generate():
        # identifier/prefix need this slave's 1-based position among all
        # slaves (assign_identifier(), not available yet here); known
        # needs ecapp-list-devices' output; generic_tables (only set for
        # a slave with known == False) needs the "ethercat pdos --json"
        # snapshot, loaded lazily and only if actually needed.
        self.identifier = None
        self.prefix = None
        self.known = None
        self.generic_tables = None

        # Set from the outside once known -- see generate(): a list of
        # {"name", "count", "type"} dicts per direction, either the
        # matched entry's from load_known_devices() (known slave) or
        # derived from generic_tables (generic slave) -- see
        # channels_from_generic_tables(). Used by alias_calls()/
        # channel_rows() to build the testmanager .tml.
        self.input_channels = None
        self.output_channels = None

    def assign_identifier(self, index: int):
        """Sets this slave's C++ identifier to "kf<index>_<TYPE>" (e.g.
        "kf01_EL1008") -- "kf01", "kf02", ... numbered sequentially in
        slave order (index is 1-based, zero-padded to at least 2
        digits), "EL1008" the sanitized, uppercased EtherCAT device
        type. The PdServ registration path uses the same name fully
        uppercased instead ("/EtherCAT/KF01_EL1008"), matching the
        all-caps convention PdServ signal/parameter names use."""

        type_name = sanitize_identifier(
            self.order or self.description, lower=False
        ).upper()
        self.identifier = f"kf{index:02d}_{type_name}"
        self.prefix = f"/EtherCAT/{self.identifier.upper()}"

    def label(self) -> str:
        return (self.order or self.description).replace("*/", "* /")

    def generic_var(self) -> str:
        """Base name for this slave's generated Generic.h/Generic.cpp
        symbols (e.g. "kf01_el7041_syncs") -- only meaningful once
        assign_identifier() has run and known is False."""

        return self.identifier.lower()

    @staticmethod
    def channel_aliases(kind: dict, letter: str) -> list:
        """"I1", "I2", ... (letter == "I", for an input kind) or "O1",
        "O2", ... (letter == "O", for an output kind) -- one per index,
        1-based -- the alias scheme both alias_calls() and
        channel_rows() use for a channel kind with more than one
        index."""

        return [f"{letter}{i}" for i in range(1, kind["count"] + 1)]

    def alias_calls(self) -> str:
        """setAlias() call(s) for this slave's constructor body, one per
        channel kind with more than one index -- e.g. a digital input
        terminal's 8-wide "Input" kind. Without this, such a kind is
        only ever registered as a single, multi-element pdserv signal
        (see SubDevice::registerInputChannel()), which testmanager has
        no way to address element-by-element -- setAlias() gives each
        index its own named scalar signal at "<prefix>/<kind>/<alias>"
        instead (see channel_aliases()), which channel_rows() then
        binds one widget each to. A generic slave never needs this: its
        PDO entries are already registered one-by-one (see
        GenericSubDevice), so every kind already has count == 1."""

        lines = []
        for letter, kinds in (
            ("I", self.input_channels or []),
            ("O", self.output_channels or []),
        ):
            for kind in kinds:
                if kind["count"] <= 1:
                    continue
                aliases = ", ".join(
                    f'"{a}"' for a in self.channel_aliases(kind, letter)
                )
                call = (
                    f'{self.identifier}->setAlias("{kind["name"]}", '
                    f"{{{aliases}}});"
                )
                lines.extend(
                    textwrap.wrap(
                        call,
                        width=CODE_WIDTH - 4,
                        initial_indent="    ",
                        subsequent_indent="            ",
                        break_long_words=False,
                        break_on_hyphens=False,
                    )
                )
        return "\n".join(lines)

    def channel_rows(self) -> list:
        """One (label, pdserv_path, is_boolean) tuple per pdserv signal
        this slave actually registers -- input channels first, then
        output -- for build_tml() to turn into a label/widget pair
        each. Relies on alias_calls() having given every count > 1
        kind the same "I1"/"O1", ... aliases already (see
        channel_aliases()), so every row here addresses one scalar
        signal."""

        def kind_rows(kind: dict, letter: str) -> list:
            is_bool = kind["type"] == "bool"
            if kind["count"] <= 1:
                return [
                    (kind["name"], f'{self.prefix}/{kind["name"]}', is_bool)
                ]
            return [
                (
                    f'{kind["name"]} {alias}',
                    f'{self.prefix}/{kind["name"]}/{alias}',
                    is_bool,
                )
                for alias in self.channel_aliases(kind, letter)
            ]

        rows = []
        for letter, kinds in (
            ("I", self.input_channels or []),
            ("O", self.output_channels or []),
        ):
            for kind in kinds:
                rows.extend(kind_rows(kind, letter))
        return rows

    def known_call_args(self) -> list:
        """EcApp::createSubDevice()'s arguments for a slave whose device
        type this build of ecapp recognizes -- by vendor ID/product
        code/revision (EcApp::Registry's actual matching key, see
        ecapp/Factory.h), not by the order code, which is only ever a
        catalog/display name (see is_known())."""

        return [
            f"0x{self.vendor_id:08x}",
            f"0x{self.product_code:08x}",
            "master",
            "&domainOut_",
            "&domainIn_",
            str(self.alias),
            str(self.position),
            "EcApp::Mode::Control",
            f'"{self.prefix}"',
            "pdServ",
            "task",
            f"0x{self.revision:08x}",
        ]

    def generic_call_args(self) -> list:
        """EcApp::makeGenericSubDevice()'s arguments for a slave whose
        device type this build of ecapp does not recognize -- the PDO
        tables named here are defined in the generated Generic.cpp (see
        build_generic_tables()). SDO configuration is deliberately not
        wired in here -- ecapp-generate has no live source for SDO
        values (unlike PDOs, an SDO's desired value is configuration
        intent, not something a bus snapshot carries) -- see
        generic_sdo_hint() instead: a commented-out
        EcApp::GenericDevice::configureSdo() call, emitted into RtMain's
        constructor body right before master_->activate(), for a human
        to uncomment and fill in."""

        var = self.generic_var()
        return [
            "EcApp::Mode::Control",
            "master",
            str(self.alias),
            str(self.position),
            f"0x{self.vendor_id:08x}",
            f"0x{self.product_code:08x}",
            "&domainOut_",
            "&domainIn_",
            f"{var}_syncs",
            f"{var}_outputs",
            f"{var}_inputs",
            f'"{self.prefix}"',
            "pdServ",
            "task",
        ]

    def generic_sdo_hint(self) -> str:
        """Commented-out invitation, emitted into RtMain's constructor
        body right before master_->activate(), showing how to configure
        an SDO for this generic slave via
        EcApp::GenericDevice::configureSdo() -- called after
        constructing the device (already done, in the initializer list)
        but before the master is activated; see generate(). Never
        auto-filled -- see generic_call_args()."""

        lines = wrap_comment(
            f"Optionally configure SDOs for {self.identifier} here, "
            "before master_->activate() (e.g. an operating mode it "
            "needs set up before its PDOs make sense) -- see "
            "EcApp::GenericDevice::configureSdo():"
        )
        lines += [
            f"// EcApp::capability<EcApp::GenericDevice>(*{self.identifier})",
            "//         .configureSdo(0x0000, 0x00, uint8_t {0});",
        ]
        return "\n".join(f"    {line}" for line in lines)

    def member_decl(self) -> str:
        """Every slave -- known or generic -- gets a real, uncommented
        member; only a slave neither known() nor generic (no PDO data
        available for it either) is possible, and that aborts generation
        entirely (see generate()) rather than reaching here."""

        lines = wrap_comment(self.description.replace("*/", "* /"))

        # An unusually long identifier (a long device order code) can
        # push even the bare declaration past CODE_WIDTH -- fall back
        # to breaking before the identifier, as clang-format would.
        decl_type = "std::unique_ptr<EcApp::SubDevice>"
        one_line = f"{decl_type} {self.identifier};"
        if len(f"    {one_line}") <= CODE_WIDTH:
            lines.append(one_line)
        else:
            lines.append(decl_type)
            lines.append(f"        {self.identifier};")

        return "\n".join(f"    {line}" for line in lines)

    def member_init(self) -> str:
        """Constructor initializer-list entry for this slave -- either a
        direct EcApp::createSubDevice() call (known) or a call to the
        generated project's own PDO tables via
        EcApp::makeGenericSubDevice() (generic); see generate()."""

        if self.known:
            call_lines = wrap_args(self.known_call_args(), indent="        ")
            call_lines[-1] += "))"
            lines = (
                [f"{self.identifier}(EcApp::createSubDevice("] + call_lines
            )
        else:
            call_lines = wrap_args(
                self.generic_call_args(), indent="        "
            )
            call_lines[-1] += "))"
            lines = (
                [f"{self.identifier}(EcApp::makeGenericSubDevice("]
                + call_lines
            )

        return "\n".join(f"    {line}" for line in lines)


# ---------------------------------------------------------------------------


def decode_json_bytes(data: bytes) -> str:
    """Decodes JSON source bytes as UTF-8, falling back to Latin-1.

    Device names read from a slave's SII EEPROM are sometimes stored in
    a legacy 8-bit encoding, so "ethercat slaves --json" output is not
    always valid UTF-8 (seen in practice with e.g. German umlauts).
    Latin-1 never fails to decode, so this always succeeds."""

    try:
        return data.decode("utf-8")
    except UnicodeDecodeError:
        return data.decode("latin-1")


#: Below this version, the EtherLab "ethercat" command line tool has no
#: --json output at all (neither "slaves" nor "pdos") -- see
#: raise_ethercat_json_error().
ETHERCAT_JSON_MIN_VERSION = "1.7"


def raise_ethercat_json_error(command: list, result):
    """Raises a RuntimeError for a failed "ethercat ... --json"
    invocation. If the failure looks like an "ethercat" build that
    simply does not know the --json option yet (every "ethercat"
    subcommand rejects an unknown option the same way: exit code 1, a
    "unrecognized option '--json'" line, then a full command usage
    dump), the error names the actual, actionable problem -- an
    EtherLab EtherCAT master older than ETHERCAT_JSON_MIN_VERSION --
    instead of dumping that whole usage text."""

    cmd_str = " ".join(command)
    stderr = decode_json_bytes(result.stderr).strip()
    first_line = stderr.splitlines()[0] if stderr else ""

    if "unrecognized option" in first_line and "--json" in first_line:
        raise RuntimeError(
            f'"{cmd_str}" failed: this "ethercat" command line tool '
            "does not support --json -- it needs the EtherLab EtherCAT "
            f"master {ETHERCAT_JSON_MIN_VERSION} or newer "
            "(https://gitlab.com/etherlab.org/ethercat). Its own "
            f"error: {first_line}"
        )

    raise RuntimeError(
        f'"{cmd_str}" failed (exit code {result.returncode}): {stderr}'
    )


def run_ethercat_slaves() -> str:
    """Runs "ethercat slaves --json" and returns its stdout, used as the
    default JSON source when no file/stdin is given explicitly."""

    command = ["ethercat", "slaves", "--json"]
    try:
        result = subprocess.run(
            command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
        )
    except FileNotFoundError as e:
        raise FileNotFoundError(
            'Could not run "ethercat slaves --json" (is the etherlab '
            "command line tool installed and in PATH?): "
            f"{e}"
        ) from e

    if result.returncode != 0:
        raise_ethercat_json_error(command, result)

    return decode_json_bytes(result.stdout)


def run_ethercat_pdos() -> str:
    """Runs "ethercat pdos --json" and returns its stdout, used as the
    default PDO source when no --pdos file/stdin is given explicitly.
    Only ever called when at least one slave's device type is not known
    to this build of ecapp (see generate()) -- otherwise unnecessary."""

    command = ["ethercat", "pdos", "--json"]
    try:
        result = subprocess.run(
            command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
        )
    except FileNotFoundError as e:
        raise FileNotFoundError(
            'Could not run "ethercat pdos --json" (is the etherlab '
            "command line tool installed and in PATH?): "
            f"{e}"
        ) from e

    if result.returncode != 0:
        raise_ethercat_json_error(command, result)

    return decode_json_bytes(result.stdout)


def find_list_devices_tool():
    """Locates the ecapp-list-devices helper shipped alongside this
    script (see find_template_dir()), falling back to PATH. Returns
    None if it cannot be found anywhere."""

    # Installed, it sits right next to this script (both go into
    # ${CMAKE_INSTALL_BINDIR}); in the source tree, neither is built,
    # so fall back to PATH.
    candidate = Path(__file__).resolve().parent / "ecapp-list-devices"
    if candidate.is_file() and os.access(candidate, os.X_OK):
        return candidate

    found = shutil.which("ecapp-list-devices")
    return Path(found) if found else None


def warn_list_devices_skipped(reason: str):
    print(
        style("warning: ", Ansi.BOLD, Ansi.YELLOW, stream=sys.stderr)
        + style(
            "ecapp-list-devices " + reason + " -- every slave will be "
            "treated as not recognized by this build of ecapp and "
            "generated as a generic device instead (requires PDO data, "
            "see --pdos).",
            Ansi.YELLOW,
            stream=sys.stderr,
        ),
        file=sys.stderr,
    )


def load_known_devices():
    """Returns a list of dicts -- one per EtherCAT device type this
    build of ecapp has a SubDevice implementation for, via the
    ecapp-list-devices helper -- or None if that helper cannot be
    found or run, in which case every slave is treated as not
    recognized (see is_known()/generate()). Either way, a warning is
    printed -- this failing silently previously masked a packaging bug
    (a missing RPATH) where the helper existed but could not actually
    run, so ecapp-generate looked like it had detected every device as
    supported.

    Each dict has "vendor_id"/"product_code"/"revision" (is_known()'s
    matching key) plus "input_channels"/"output_channels" -- lists of
    {"name", "count", "type"}, one per ChannelKind the device's
    SubDevice registers (see ecapp-list-devices' --json output),
    needed by channel_rows()/alias_calls() to know which pdserv paths
    a known slave will actually expose."""

    tool = find_list_devices_tool()
    if tool is None:
        warn_list_devices_skipped("was not found")
        return None

    try:
        result = subprocess.run(
            [str(tool), "--json"],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
        )
    except OSError as e:
        warn_list_devices_skipped(f"could not be run ({e})")
        return None

    if result.returncode != 0:
        detail = decode_json_bytes(result.stderr).strip()
        warn_list_devices_skipped(
            f"failed (exit code {result.returncode})"
            + (f": {detail}" if detail else "")
        )
        return None

    try:
        data = json.loads(decode_json_bytes(result.stdout))
        return [
            {
                "vendor_id": int(d["vendor_id"]),
                "product_code": int(d["product_code"]),
                "revision": int(d["revision"]),
                "input_channels": d.get("input_channels") or [],
                "output_channels": d.get("output_channels") or [],
            }
            for d in data["ethercat_devices"]
        ]
    except (json.JSONDecodeError, KeyError, TypeError) as e:
        warn_list_devices_skipped(f"produced unexpected output ({e})")
        return None


#: Matches EcApp::AnyRevision (ecapp/Factory.h) -- a registered device
#: with this revision matches a slave of any revision.
ANY_REVISION = 0xFFFFFFFF


def find_known_device(slave: "Slave", known_devices: list):
    """The entry in `known_devices` (as returned by
    load_known_devices()) matching `slave`'s vendor/product plus either
    an exact revision match or a registered AnyRevision entry -- or
    None. Shares its matching rule with is_known(), which only needs
    the yes/no answer; generate() calls this once it actually needs the
    matched device's channel lists (see Slave.channels)."""

    any_revision_match = None
    for d in known_devices:
        if (
            d["vendor_id"] != slave.vendor_id
            or d["product_code"] != slave.product_code
        ):
            continue
        if d["revision"] == slave.revision:
            return d
        if d["revision"] == ANY_REVISION:
            any_revision_match = d
    return any_revision_match


def is_known(slave: "Slave", known_devices: list) -> bool:
    """Whether `slave` is one of `known_devices` (as returned by
    load_known_devices()) -- vendor/product must match, plus either an
    exact revision match or a registered AnyRevision entry. Exactly the
    fallback rule EcApp::Registry::find(vendorId, productCode, revision)
    itself uses (src/Factory.cpp), reproduced here so "known" means
    "EcApp::createSubDevice() is guaranteed to find this at runtime", not
    just "looks plausible"."""

    return find_known_device(slave, known_devices) is not None


# ---------------------------------------------------------------------------

#: Bit length -> ChannelValue alternative a generic PDO entry can be
#: exposed as -- the only widths EcApp::ChannelValue's variant supports
#: without ecapp-generate having to guess a signedness/width it cannot
#: know from "ethercat pdos --json" alone (which carries no data type,
#: only bit_length; real snapshots have shown other widths too, e.g. 2-6
#: or 64 bit -- reserved/internal fields in practice). An entry with any
#: other bit_length still occupies its slot in the raw PDO entry table
#: (for correct bit alignment) but gets no channel -- see
#: build_generic_tables().
GENERIC_ENTRY_TYPES = {1: "bool", 8: "uint8_t", 16: "uint16_t", 32: "uint32_t"}


def dedupe_entry_names(entries: list, direction_prefix: str) -> list:
    """Returns one (CamelCase, disambiguated) name per entry in
    `entries` (each a dict with "name"/"index"/"subindex" keys) -- e.g.
    "Sync error" and a later, second "Sync error" become "SyncError"
    and "SyncError2". A named entry is first run through
    camel_case_identifier() (CoE entry names, straight from a live
    "ethercat pdos --json" snapshot, may contain whitespace or other
    punctuation a pdserv signal/parameter path may not -- see its
    docstring); a completely unnamed one (a reserved/vendor-internal
    bit, or a device/master whose "ethercat pdos --json" output simply
    carries no names at all) is named after its CoE address instead --
    e.g. "In6000_01" -- traceable back to the device's object
    dictionary instead of a meaningless "InputChannel1",
    "InputChannel2", ... Either way, a bare numeric suffix (no space,
    no parentheses -- it has to stay a clean identifier too) is then
    appended to the second/third/... occurrence of an identical name.

    `direction_prefix` ("In"/"Out") both feeds the CoE-address name
    above and keeps every name here out of the other direction's --
    entry_vector()'s "input" and "output" calls must not produce a
    single common name: GenericSubDevice registers input/output kind
    names into the same pdserv namespace and throws if they collide,
    which two unnamed entries with the same address-derived name
    (impossible -- an (index, subindex) pair is unique to begin with)
    or, previously, the same generic "Channel" fallback (very much
    possible) would trigger."""

    seen: dict = {}
    names = []
    for e in entries:
        raw_name = (e.get("name") or "").strip()
        if raw_name:
            name = camel_case_identifier(raw_name)
        else:
            name = (
                f"{direction_prefix}{int(e['index']):04X}_"
                f"{int(e['subindex']):02X}"
            )
        seen[name] = seen.get(name, 0) + 1
        names.append(name if seen[name] == 1 else f"{name}{seen[name]}")
    return names


class GenericTables:
    """Generated Generic.h/Generic.cpp fragments for one generic slave --
    see build_generic_tables(). input_channels/output_channels list the
    same entries GenericSubDevice actually registers a pdserv signal
    for (i.e. skipping any entry whose bit_length has no
    GENERIC_ENTRY_TYPES mapping) as {"name", "count": 1, "type"} dicts,
    "type" being "bool" or "numeric" -- Slave.channel_rows()'s input,
    mirroring the "type" field ecapp-list-devices' --json emits for
    known devices."""

    def __init__(
            self,
            header: str,
            cpp: str,
            input_channels: list,
            output_channels: list):
        self.header = header
        self.cpp = cpp
        self.input_channels = input_channels
        self.output_channels = output_channels


def build_generic_tables(slave: "Slave", pdo_record: dict) -> GenericTables:
    """Builds this slave's Generic.h/Generic.cpp fragments (an `extern`
    declaration triple for Generic.h; the ec_pdo_entry_info_t/
    ec_pdo_info_t/ec_sync_info_t C arrays plus the two
    std::vector<EcApp::GenericEntry> definitions for Generic.cpp) 1:1
    from pdo_record (one slave's entry in an "ethercat pdos --json"
    snapshot: {"master", "slave", "sync_managers": [{"index",
    "pdo_type": "rx"/"tx", "pdos": [{"index", "name", "entries":
    [{"index", "subindex", "bit_length", "name"}]}]}]}). "rx" sync
    managers (the slave's receive direction) become the *output* side
    (EC_DIR_OUTPUT, application writes); "tx" become *input*
    (EC_DIR_INPUT). An entry with index 0 ("Gap") is skipped for
    channel purposes but still occupies its slot in the entry table, for
    correct bit alignment. A slave with no PDOs at all (e.g. a pure
    infrastructure/coupler device) is valid -- it just ends up with
    empty outputs/inputs vectors."""

    var = slave.generic_var()

    header = "\n".join(
        [
            f"extern ec_sync_info_t {var}_syncs[];",
            f"extern std::vector<EcApp::GenericEntry> {var}_outputs;",
            f"extern std::vector<EcApp::GenericEntry> {var}_inputs;",
        ]
    )

    cpp_lines = [
        f"// --- {slave.identifier} (Vendor 0x{slave.vendor_id:08x}, "
        f"Product 0x{slave.product_code:08x}) ---",
        f'// 1:1 generated from "ethercat pdos --json" (master '
        f'{int(pdo_record.get("master", 0))}, slave {slave.position}).',
        "",
    ]

    sync_lines = []
    raw_outputs = []
    raw_inputs = []

    for sm in pdo_record.get("sync_managers") or []:
        direction = (
            "EC_DIR_OUTPUT" if sm.get("pdo_type") == "rx" else "EC_DIR_INPUT"
        )
        pdos = sm.get("pdos") or []
        sm_index = int(sm["index"])

        if not pdos:
            sync_lines.append(
                f"    {{{sm_index}, {direction}, 0, nullptr, "
                "EC_WD_DEFAULT},"
            )
            continue

        pdo_var = f"{var}_sm{sm_index}_pdos"
        pdo_info_lines = []
        for pi, pdo in enumerate(pdos):
            entries = pdo.get("entries") or []
            entries_var = f"{var}_sm{sm_index}_pdo{pi}_entries"
            cpp_lines.append(
                f"ec_pdo_entry_info_t {entries_var}[] = {{  "
                f'// 0x{int(pdo["index"]):04x} {pdo.get("name", "")}'
            )
            for e in entries:
                cpp_lines.append(
                    f'    {{0x{int(e["index"]):04x}, '
                    f'0x{int(e["subindex"]):02x}, '
                    f'{int(e["bit_length"])}}},  // {e.get("name", "")}'
                )
            cpp_lines.append("};")

            pdo_info_lines.append(
                f'    {{0x{int(pdo["index"]):04x}, {len(entries)}, '
                f"{entries_var}}},"
            )

            for e in entries:
                if int(e.get("index", 0)) == 0:
                    continue  # Gap
                target = raw_outputs if direction == "EC_DIR_OUTPUT" \
                    else raw_inputs
                target.append(e)

        cpp_lines.append(f"ec_pdo_info_t {pdo_var}[] = {{")
        cpp_lines.extend(pdo_info_lines)
        cpp_lines.append("};")
        cpp_lines.append("")

        sync_lines.append(
            f"    {{{sm_index}, {direction}, {len(pdos)}, {pdo_var}, "
            "EC_WD_DEFAULT},"
        )

    cpp_lines.append(f"ec_sync_info_t {var}_syncs[] = {{")
    cpp_lines.extend(sync_lines)
    cpp_lines.append("    {0xff},")
    cpp_lines.append("};")
    cpp_lines.append("")

    def entry_vector(direction_label: str, raw_entries: list, name: str):
        lines = [f"std::vector<EcApp::GenericEntry> {name} = {{"]
        skipped = []
        channels = []
        direction_prefix = "In" if direction_label == "input" else "Out"
        for e, disambiguated in zip(
            raw_entries,
            dedupe_entry_names(raw_entries, direction_prefix),
        ):
            bit_length = int(e["bit_length"])
            type_tag = GENERIC_ENTRY_TYPES.get(bit_length)
            if type_tag is None:
                skipped.append((disambiguated, bit_length))
                continue
            lines.append(
                f'    {{0x{int(e["index"]):04x}, '
                f'0x{int(e["subindex"]):02x}, '
                f'"{disambiguated}", {type_tag} {{}}}},'
            )
            # Always gets both a real, working pdserv signal (via
            # GenericSubDevice) and a row in the generated .tml --
            # dedupe_entry_names() already turned even a completely
            # unnamed entry into something meaningful (its CoE
            # address), so there is no more "not worth showing" case
            # here.
            channels.append({
                "name": disambiguated,
                "count": 1,
                "type": "bool" if type_tag == "bool" else "numeric",
            })
        lines.append("};")
        for name_, bit_length in skipped:
            lines.append(
                f"// skipped {direction_label} entry '{name_}': "
                f"unsupported bit length {bit_length}"
            )
        return lines, channels

    output_lines, output_channels = entry_vector(
        "output", raw_outputs, f"{var}_outputs"
    )
    input_lines, input_channels = entry_vector(
        "input", raw_inputs, f"{var}_inputs"
    )
    cpp_lines.extend(output_lines)
    cpp_lines.append("")
    cpp_lines.extend(input_lines)

    return GenericTables(
        header=header,
        cpp="\n".join(cpp_lines),
        input_channels=input_channels,
        output_channels=output_channels,
    )


def load_masters(json_text: str) -> list:
    data = json.loads(json_text)
    if not isinstance(data, list):
        raise ValueError(
            'Unexpected JSON structure: expected a top-level array, as '
            'produced by "ethercat slaves --json".'
        )
    return data


def select_master(masters: list, master_index) -> dict:
    if not masters:
        raise ValueError("The JSON snapshot contains no masters.")

    if master_index is None:
        if len(masters) > 1:
            available = ", ".join(str(m.get("master")) for m in masters)
            raise ValueError(
                "The JSON snapshot contains multiple masters "
                f"({available}) -- select one with --master."
            )
        return masters[0]

    for m in masters:
        if int(m.get("master", -1)) == master_index:
            return m

    available = ", ".join(str(m.get("master")) for m in masters)
    raise ValueError(
        f"No master with index {master_index} in the JSON snapshot "
        f"(available: {available})."
    )


# ---------------------------------------------------------------------------

#: testmanager .tml layout constants for build_tml() --
#: chosen to match an existing example layout (redest.tml):
#: a bold section header followed by one label/widget row
#: per signal, all left-aligned in fixed columns. LABEL_WIDTH is wide
#: on purpose (generated channel/kind names run longer than a
#: hand-picked German label ever would).
TML_LABEL_X = 16
TML_LABEL_WIDTH = 600
TML_VALUE_X = TML_LABEL_X + TML_LABEL_WIDTH + 16
TML_VALUE_WIDTH = 180
TML_TAB_WIDTH = TML_VALUE_X + TML_VALUE_WIDTH + 16
TML_ROW_HEIGHT = 24
TML_HEADER_HEIGHT = 22
TML_SECTION_GAP = 16
TML_TOP_MARGIN = 16

TML_STYLE_SHEET = (
    "Legend {\n\tcolor: #aaaaaa;\n\tfont-size: 12px;\n}\n\n"
    "QtPdWidgets--Digital {\n\tfont-family: fixed;\n\tfont-size: 13px;\n}\n\n"
    "QLabel {\n\tfont-size: 11px;\n}"
)


def tml_header(y: int, text: str) -> dict:
    return {
        "geometry": {
            "height": TML_HEADER_HEIGHT,
            "width": TML_TAB_WIDTH - 2 * TML_LABEL_X,
            "x": TML_LABEL_X,
            "y": y,
        },
        "properties": {
            "styleSheet": "font-weight: bold; font-size: 12px;",
            "text": text,
        },
        "type": "QLabel",
    }


def tml_row(y: int, label_text: str, url: str, is_bool: bool) -> list:
    """A label plus its bound widget -- a QtPdWidgets::Led for a
    boolean signal, a QtPdWidgets::Digital (a numeric readout) for
    anything else -- matching redest.tml's own convention."""

    if is_bool:
        widget_type = "QtPdWidgets::Led"
        properties = {"diameter": "16", "onColor": "#45e029"}
        width = 24
    else:
        widget_type = "QtPdWidgets::Digital"
        properties = {"decimals": "0", "suffix": ""}
        width = TML_VALUE_WIDTH

    return [
        {
            "geometry": {
                "height": 20,
                "width": TML_LABEL_WIDTH,
                "x": TML_LABEL_X,
                "y": y,
            },
            "properties": {
                "styleSheet": "font-size: 11px;",
                "text": label_text,
            },
            "type": "QLabel",
        },
        {
            "geometry": {
                "height": 20,
                "width": width,
                "x": TML_VALUE_X,
                "y": y,
            },
            "properties": properties,
            "slots": [{"url": url}],
            "type": widget_type,
        },
    ]


def domain_rows(prefix: str) -> list:
    """(label, pdserv_path, is_boolean) rows for one EcApp::Domain
    instance's signals -- registered unconditionally, the same for
    every generated application (see Domain.cpp and generate()'s
    domainIn_/domainOut_ initializers)."""

    return [
        ("Valid", f"{prefix}/Valid", True),
        ("WorkingCounter", f"{prefix}/WorkingCounter", False),
        ("WorkingCounterState", f"{prefix}/WorkingCounterState", False),
        ("RedundancyActive", f"{prefix}/RedundancyActive", False),
        ("BadCycles", f"{prefix}/BadCycles", False),
    ]


def build_tml(app_name: str, slaves: list) -> str:
    """Builds a testmanager .tml layout for the
    generated application: a single tab, one relatively wide,
    full-width section per subdevice (plus one for each EtherCAT
    domain), stacked below each other in slave order -- every section a
    bold header followed by one label/widget row per pdserv signal
    (see Slave.channel_rows()). Works the same way for a generic
    subdevice as for a known one, since both already expose their
    channels through the same Slave.channel_rows()."""

    containers = []
    y = TML_TOP_MARGIN

    def add_section(title: str, rows: list):
        nonlocal y
        containers.append(tml_header(y, title))
        y += TML_HEADER_HEIGHT
        for label_text, url, is_bool in rows:
            containers.extend(tml_row(y, label_text, url, is_bool))
            y += TML_ROW_HEIGHT
        y += TML_SECTION_GAP

    add_section("EtherCAT-Domain (Eingang)", domain_rows("/EtherCAT/DomainIn"))
    add_section(
        "EtherCAT-Domain (Ausgang)", domain_rows("/EtherCAT/DomainOut")
    )

    for slave in slaves:
        title = f"{slave.identifier}: {slave.label()}"
        if not slave.known:
            title += " [generic]"
        add_section(title, slave.channel_rows())

    tml = {
        "dataSources": [{"url": f"msr://{app_name[:15]}:2345"}],
        "styleSheet": TML_STYLE_SHEET,
        "tabs": [
            {"name": app_name, "detached": False, "containers": containers}
        ],
        "version": 3,
    }
    return json.dumps(tml, indent=4, ensure_ascii=False) + "\n"


# ---------------------------------------------------------------------------


def render(text: str, substitutions: dict) -> str:
    for key, value in substitutions.items():
        text = text.replace(f"@@{key}@@", value)
    return text


def check_not_exists(path: Path, force: bool):
    if path.exists() and not force:
        raise FileExistsError(
            f"{path} already exists (use --force to overwrite)"
        )


def write_file(path: Path, content: str):
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content, encoding="utf-8")


# ---------------------------------------------------------------------------


def generate(
    json_text: str,
    source_label: str,
    output_dir: Path,
    app_name: str,
    master_index,
    force: bool,
    load_pdos_text,
):
    """load_pdos_text is a zero-argument callable returning (json_text,
    source_label) for an "ethercat pdos --json" snapshot -- called at
    most once, and only if at least one slave's device type turns out
    not to be known to this build of ecapp (see is_known()), since PDO
    data is otherwise unnecessary."""

    template_dir = find_template_dir()

    masters = load_masters(json_text)
    master_entry = select_master(masters, master_index)
    selected_master_index = int(master_entry.get("master", 0))

    raw_slaves = sorted(
        master_entry.get("slaves") or [], key=lambda s: int(s["position"])
    )
    if not raw_slaves:
        raise ValueError(
            f"Master {selected_master_index} has no slaves in the JSON "
            "snapshot."
        )

    slaves = [Slave(s) for s in raw_slaves]

    # "kf01_el1008", "kf02_el2521", ... -- numbered sequentially in
    # slave (i.e. position) order, so identifiers are unique by
    # construction regardless of how many slaves share a type.
    for index, slave in enumerate(slaves, start=1):
        slave.assign_identifier(index)

    # known == False means "not registered under this vendor/product/
    # revision in this build of ecapp" -- such a slave is generated as
    # EcApp::GenericSubDevice instead (see EcApp::makeGenericSubDevice()),
    # built from real PDO data, rather than left out or guessed at. If
    # ecapp-list-devices itself could not be consulted, every slave is
    # conservatively treated as unknown (== generic), since there is no
    # longer a runtime fallback that would catch an over-optimistic
    # guess (see warn_list_devices_skipped()).
    known_devices = load_known_devices()
    for slave in slaves:
        slave.known = known_devices is not None and is_known(
            slave, known_devices
        )

    generic_slaves = [s for s in slaves if not s.known]

    # input_channels/output_channels is what alias_calls()/
    # channel_rows() (used to build the testmanager .tml below) work
    # off of -- for a known slave, the matched entry from
    # ecapp-list-devices --json; for a generic one, filled in below
    # once generic_tables exists.
    for slave in slaves:
        if slave.known:
            matched = find_known_device(slave, known_devices)
            slave.input_channels = matched["input_channels"]
            slave.output_channels = matched["output_channels"]

    pdos_source_label = None
    if generic_slaves:
        pdos_text, pdos_source_label = load_pdos_text()
        try:
            pdos_data = json.loads(pdos_text)
        except json.JSONDecodeError as e:
            raise ValueError(
                f"Could not parse {pdos_source_label} as JSON: {e}"
            ) from e
        if not isinstance(pdos_data, list):
            raise ValueError(
                f"Unexpected JSON structure in {pdos_source_label}: "
                'expected a top-level array, as produced by "ethercat '
                'pdos --json".'
            )
        pdos_by_position = {
            int(p["slave"]): p
            for p in pdos_data
            if int(p.get("master", 0)) == selected_master_index
        }

        for slave in generic_slaves:
            record = pdos_by_position.get(slave.position)
            if record is None:
                raise ValueError(
                    f"No PDO data for slave {slave.position} "
                    f"({slave.label()}) in {pdos_source_label} -- cannot "
                    "generate EcApp::GenericSubDevice for it. Make sure "
                    "the PDO snapshot was taken from the same, unchanged "
                    "bus as the slave snapshot."
                )
            slave.generic_tables = build_generic_tables(slave, record)
            slave.input_channels = slave.generic_tables.input_channels
            slave.output_channels = slave.generic_tables.output_channels

    member_decls = "\n".join(s.member_decl() for s in slaves)
    all_inits = [
        "    master_(master)",
        '    domainIn_(pdServ, task, "/EtherCAT/DomainIn", master)',
        '    domainOut_(pdServ, task, "/EtherCAT/DomainOut", master)',
    ] + [s.member_init() for s in slaves]
    member_inits = ",\n".join(all_inits)

    generic_decls = "\n".join(s.generic_tables.header for s in generic_slaves)
    generic_defs = "\n\n".join(s.generic_tables.cpp for s in generic_slaves)
    generic_sdo_hints = "\n\n".join(
        s.generic_sdo_hint() for s in generic_slaves
    )
    alias_call_blocks = [s.alias_calls() for s in slaves]
    alias_calls = "\n\n".join(b for b in alias_call_blocks if b)

    substitutions = {
        "APP_NAME": app_name,
        "APP_NAME_SHORT": app_name[:15],
        "PERIOD_HZ": str(PERIOD_HZ),
        "SOURCE_JSON": source_label,
        "MASTER_INDEX": str(selected_master_index),
        "GENERATED_DATE": datetime.date.today().isoformat(),
        "MEMBER_DECLS": member_decls,
        "MEMBER_INITS": member_inits,
        "ALIAS_CALLS": alias_calls,
        "GENERIC_DECLS": generic_decls,
        "GENERIC_DEFS": generic_defs,
        "GENERIC_SDO_HINTS": generic_sdo_hints,
    }

    file_map = {
        "CMakeLists.txt.in": "CMakeLists.txt",
        "gitignore.in": ".gitignore",
        "README.md.in": "README.md",
        "src/main.cpp.in": "src/main.cpp",
        "src/RtMain.h.in": "src/RtMain.h",
        "src/RtMain.cpp.in": "src/RtMain.cpp",
        "src/Generic.h.in": "src/Generic.h",
        "src/Generic.cpp.in": "src/Generic.cpp",
    }

    output_paths = {
        template_name: output_dir / output_name
        for template_name, output_name in file_map.items()
    }
    tml_path = output_dir / "gui" / f"{app_name}.tml"

    for out_path in list(output_paths.values()) + [tml_path]:
        check_not_exists(out_path, force)

    generated_files = []
    for template_name, out_path in output_paths.items():
        template_text = (template_dir / template_name).read_text(
            encoding="utf-8"
        )
        content = render(template_text, substitutions)
        write_file(out_path, content)
        generated_files.append(out_path)

    # testmanager GUI layout -- one section per
    # EtherCAT domain and per subdevice, see build_tml().
    write_file(tml_path, build_tml(app_name, slaves))
    generated_files.append(tml_path)

    return generated_files, slaves, selected_master_index, pdos_source_label


# ---------------------------------------------------------------------------

#: Report lines wrap to this width, matching a standard 80-column
#: terminal with a little margin.
REPORT_WIDTH = 78


def slave_report_lines(
    s: "Slave", pos_width: int, id_width: int
) -> list:
    """One compact block per slave for main()'s report: a header line
    (position, identifier, bare vendor/product hex, and a "[generic]"
    marker if applicable) followed by the slave's description,
    word-wrapped onto its own indented line(s) -- kept separate from the
    header since descriptions can be long, and every line stays within
    REPORT_WIDTH. pos_width/id_width are the widest position/identifier
    across all slaves being reported, so the position, identifier and
    vendor/product columns line up from row to row."""

    header = (
        f"[{s.position:>{pos_width}}] {s.identifier:<{id_width}}  "
        f"(0x{s.vendor_id:08x}, 0x{s.product_code:08x})"
    )
    if not s.known:
        header += "  [generic]"

    lines = [f"  {header}"]
    lines.extend(
        textwrap.wrap(
            s.description,
            width=REPORT_WIDTH,
            initial_indent="      ",
            subsequent_indent="      ",
        )
    )
    return lines


# ---------------------------------------------------------------------------


def main(argv=None) -> int:
    parser = argparse.ArgumentParser(
        description=(
            "Generate a buildable EtherCAT realtime application skeleton "
            'from the JSON output of "ethercat slaves --json".'
        )
    )
    parser.add_argument(
        "json_file",
        nargs="?",
        default=None,
        help='Path to a file containing "ethercat slaves --json" output, '
        'or "-" to read it from stdin. If omitted, "ethercat slaves '
        '--json" is run directly to obtain it.',
    )
    parser.add_argument(
        "-o",
        "--output-dir",
        default=".",
        help="Directory the application project is written into "
        "(default: current directory).",
    )
    parser.add_argument(
        "-n",
        "--name",
        default=None,
        help="Application/CMake project name (default: the output "
        "directory's name).",
    )
    parser.add_argument(
        "-m",
        "--master",
        type=int,
        default=None,
        metavar="INDEX",
        help="Index of the master to use, if the JSON snapshot contains "
        "more than one (required in that case).",
    )
    parser.add_argument(
        "--pdos",
        dest="pdos_file",
        default=None,
        metavar="FILE",
        help='Path to a file containing "ethercat pdos --json" output, '
        'or "-" to read it from stdin. Only consulted if the slave '
        "snapshot contains a device type this build of ecapp does not "
        'recognize (in which case, if omitted, "ethercat pdos --json" '
        "is run directly to obtain it) -- otherwise ignored, and not "
        "required.",
    )
    parser.add_argument(
        "-f",
        "--force",
        action="store_true",
        help="Overwrite files that already exist in the output directory.",
    )
    args = parser.parse_args(argv)

    try:
        if args.json_file is None:
            json_text = run_ethercat_slaves()
            source_label = "ethercat slaves --json"
        elif args.json_file == "-":
            json_text = decode_json_bytes(sys.stdin.buffer.read())
            source_label = "<stdin>"
        else:
            json_path = Path(args.json_file)
            json_text = decode_json_bytes(json_path.read_bytes())
            source_label = json_path.name
    except (FileNotFoundError, RuntimeError, OSError) as e:
        print(
            style("error: ", Ansi.BOLD, Ansi.RED, stream=sys.stderr)
            + style(str(e), Ansi.RED, stream=sys.stderr),
            file=sys.stderr,
        )
        return 1

    def load_pdos_text():
        if args.pdos_file is None:
            return run_ethercat_pdos(), "ethercat pdos --json"
        elif args.pdos_file == "-":
            return decode_json_bytes(sys.stdin.buffer.read()), "<stdin>"
        else:
            pdos_path = Path(args.pdos_file)
            return decode_json_bytes(pdos_path.read_bytes()), pdos_path.name

    output_dir = Path(args.output_dir).resolve()
    app_name = args.name or sanitize_identifier(
        output_dir.name, fallback="rtapp"
    )

    try:
        generated_files, slaves, master_index, pdos_source_label = generate(
            json_text=json_text,
            source_label=source_label,
            output_dir=output_dir,
            app_name=app_name,
            master_index=args.master,
            force=args.force,
            load_pdos_text=load_pdos_text,
        )
    except (
        FileNotFoundError,
        FileExistsError,
        RuntimeError,
        OSError,
        ValueError,
        json.JSONDecodeError,
    ) as e:
        print(
            style("error: ", Ansi.BOLD, Ansi.RED, stream=sys.stderr)
            + style(str(e), Ansi.RED, stream=sys.stderr),
            file=sys.stderr,
        )
        return 1

    print(
        style(f'Generated application "{app_name}"', Ansi.BOLD, Ansi.GREEN)
        + f" in {output_dir}:"
    )
    for f in generated_files:
        print(style(f"  {f.relative_to(output_dir)}", Ansi.CYAN))
    print()

    generic_count = sum(1 for s in slaves if not s.known)
    print(style(f"Master {master_index}, {len(slaves)} slave(s):", Ansi.BOLD))
    pos_width = max(len(str(s.position)) for s in slaves)
    id_width = max(len(s.identifier) for s in slaves)
    for s in slaves:
        color = Ansi.GREEN if s.known else Ansi.ORANGE
        for line in slave_report_lines(s, pos_width, id_width):
            print(style(line, color))
    if generic_count:
        print()
        summary = (
            f"{generic_count} slave(s) are not recognized by this build "
            f"of ecapp and were generated as EcApp::GenericSubDevice "
            f"instead, from the PDO layout in {pdos_source_label} -- "
            "see src/Generic.h/src/Generic.cpp."
        )
        for line in textwrap.wrap(summary, width=REPORT_WIDTH):
            print(style(line, Ansi.ORANGE))
    print()
    print(style("Next steps:", Ansi.BOLD))
    print(f"  cd {output_dir}")
    print("  cmake -B build")
    print("  cmake --build build")

    return 0


if __name__ == "__main__":
    sys.exit(main())

# ---------------------------------------------------------------------------
