"""Standalone script to generate ECM model classes from an asobjdef XML file or a live server.

Usage::

    # From a local XML file
    python generate_ecm_models.py --file asobjdef.xml --output-dir ./generated_models
    python generate_ecm_models.py --file asobjdef.xml --output-dir ./generated_models --cabinet Unittest
    python generate_ecm_models.py --file asobjdef.xml  # prints to stdout

    # From a live enaio server
    python generate_ecm_models.py --host localhost --username root --password optimal --output-dir ./generated_models
    python generate_ecm_models.py --host localhost --port 4000 --username root --password optimal --cabinet Unittest

The script parses the asobjdef XML using ECMDefinition and generates one Python
file per cabinet containing typed ECMFolderModel, ECMRegisterModel, and
ECMDocumentModel subclasses with ECMField and ECMTableField annotations for every
non-static field in the definition.
"""

from __future__ import annotations

import argparse
import builtins
import keyword
import sys
from pathlib import Path

from ecmind_blue_client.ecm.definition import ECMDefinition
from ecmind_blue_client.ecm.definition._ecm_field_definition import (
    ECMControlType,
    ECMFieldDataType,
)
from ecmind_blue_client.ecm.definition._ecm_object_definition import ECMMainType

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

_DATA_TYPE_TO_PYTHON = {
    "string": "str",
    "integer": "int",
    "boolean": "bool",
    "double": "float",
    "date": "date",
    "time": "time",
    "date_time": "datetime",
}

_SKIP_CONTROLS = frozenset(
    (
        ECMControlType.GROUP,
        ECMControlType.PAGE_CONTROL,
        ECMControlType.BUTTON,
        ECMControlType.WEBCONTROL,
        ECMControlType.PICTURE,
    )
)

_BASE_CLASS = {
    ECMMainType.FOLDER: "ECMFolderModel",
    ECMMainType.REGISTER: "ECMRegisterModel",
}


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _enum_member_name(value: str, seen: set[str]) -> str:
    """Return a unique, Python-safe Enum member name for a raw list-entry value.

    Sanitises the value string to a valid uppercase identifier, resolves keyword
    conflicts, and appends a numeric suffix when the name would collide.

    Args:
        value: The raw ECM list entry display string.
        seen: Set of already-used member names (mutated in-place).

    Returns:
        A unique Python identifier suitable as an Enum member name.
    """
    name = "".join(c if c.isalnum() else "_" for c in value.strip()).upper()
    # Collapse consecutive underscores and strip edges.
    while "__" in name:
        name = name.replace("__", "_")
    name = name.strip("_")
    if not name:
        name = "EMPTY"
    elif name[0].isdigit():
        name = "V_" + name
    if keyword.iskeyword(name) or keyword.issoftkeyword(name):
        name += "_"
    base = name
    suffix = 2
    while name in seen:
        name = f"{base}_{suffix}"
        suffix += 1
    seen.add(name)
    return name


def _list_enum_entries(field_def) -> list[tuple[str, str]] | None:
    """Return ``(member_name, value)`` pairs for a field's inline list catalog.

    Flat catalogs use ``rows``; hierarchical catalogs are flattened depth-first
    from ``nodes``.  Returns ``None`` when the field carries no inline list or the
    list is empty.

    Args:
        field_def: An ``ECMFieldDefinition`` instance.

    Returns:
        Ordered list of ``(enum_member, raw_value)`` pairs, or ``None``.
    """
    lv = field_def.list_values
    if lv is None:
        return None

    entries: list[tuple[str, str]] = []
    seen: set[str] = set()

    if lv.rows:
        for row in lv.rows:
            if row.value:
                entries.append((_enum_member_name(row.value, seen), row.value))
    elif lv.nodes:
        def _walk(nodes: list) -> None:
            for node in nodes:
                if node.value:
                    entries.append((_enum_member_name(node.value, seen), node.value))
                if node.children:
                    _walk(node.children)
        _walk(lv.nodes)

    return entries or None


def _generate_enum_class(class_name: str, entries: list[tuple[str, str]]) -> list[str]:
    """Generate source lines for a ``str``-based Enum class.

    Args:
        class_name: Name of the generated Enum class.
        entries: Ordered ``(member_name, value_string)`` pairs.

    Returns:
        List of source lines (no trailing newline).
    """
    lines: list[str] = [f"class {class_name}(str, Enum):"]
    for member, value in entries:
        escaped = value.replace("\\", "\\\\").replace('"', '\\"')
        lines.append(f'    {member} = "{escaped}"')
    return lines


def _python_type(field_def) -> str:
    """Return the Python type string for a field definition.

    Args:
        field_def: An ECMFieldDefinition instance.

    Returns:
        A Python type name string such as ``"str"``, ``"int"``, ``"datetime"``.
    """
    dt = field_def.data_type
    if dt is None:
        return "str"
    return _DATA_TYPE_TO_PYTHON.get(dt.value, "str")


def _safe_identifier(name: str) -> str:
    """Return a valid Python identifier for the given ECM internal name.

    Args:
        name: The raw ECM internal field or object name.

    Returns:
        A Python-safe identifier — keyword conflicts are suffixed with ``_``.
    """
    if keyword.iskeyword(name) or keyword.issoftkeyword(name) or hasattr(builtins, name):
        return name + "_"
    safe = "".join(c if (c.isalnum() or c == "_") else "_" for c in name)
    if safe and safe[0].isdigit():
        safe = "_" + safe
    return safe


def _field_annotation_line(attr: str, internal_name: str, annotation: str, mandatory: bool = False) -> str:
    """Return one annotated field line, appending an alias comment when needed.

    Scalar ``ECMField`` descriptors are always emitted with an explicit assignment so
    that type checkers (via ``@dataclass_transform``) can determine whether the field
    is required or optional in the synthesised ``__init__``:

    * ``mandatory=True``  →  ``ECMField(type, mandatory=True)``  — required parameter.
    * ``mandatory=False`` →  ``ECMField(type, default=None)``    — optional parameter.

    Table fields (``ECMTableField[…]``) are emitted with ``= ECMTableField(default_factory=list)``
    so that ``@dataclass_transform``-aware type checkers (Pyright) recognise them as optional
    in the synthesised ``__init__``.

    Args:
        attr: The Python attribute name.
        internal_name: The ECM internal field name.
        annotation: The full annotation text, e.g. ``"ECMField[str]"`` or
            ``"ECMTableField[MyRow]"``.
        mandatory: Whether the field is mandatory (only relevant for ``ECMField``
            annotations; ignored for ``ECMTableField``).

    Returns:
        A source line such as
        ``"    Foo: ECMField[str] = ECMField(str, mandatory=True)"`` or
        ``"    Foo: ECMField[str] = ECMField(str, default=None)"`` or
        ``"    Bar: ECMTableField[BarRow] = ECMTableField(default_factory=list)"``
        (each with an optional ``# internal: …`` suffix when names differ).
    """
    # When the Python attribute name was sanitised (e.g. a keyword like ``from`` →
    # ``from_``), the original ECM internal name must be passed explicitly so that
    # the ECMField descriptor uses the correct server-side field name.
    alias_comment = f"  # internal: {internal_name}" if attr != internal_name else ""
    name_arg = f', internal_name="{internal_name}"' if attr != internal_name else ""
    if not annotation.startswith("ECMField["):
        # ECMTableField — emit with default_factory=list so that @dataclass_transform-aware
        # type checkers (Pyright) recognise the field as optional in the synthesised __init__.
        return f"    {attr}: {annotation} = ECMTableField(default_factory=list){alias_comment}"
    # Extract the type argument from e.g. "ECMField[str]" → "str"
    py_type = annotation[len("ECMField["):-1]
    if mandatory:
        return f"    {attr}: {annotation} = ECMField({py_type}{name_arg}, mandatory=True){alias_comment}"
    return f"    {attr}: {annotation} = ECMField({py_type}{name_arg}, default=None){alias_comment}"


# ---------------------------------------------------------------------------
# Code generation
# ---------------------------------------------------------------------------


def _generate_table_row_class(prefixed_name: str, table_field_def) -> list[str]:
    """Generate lines for an ECMTableRowModel subclass for one table field.

    Args:
        prefixed_name: Already-prefixed base name (e.g. ``"MyFolder_TableField"``).
            A ``"Row"`` suffix is appended to form the class name.
        table_field_def: The ECMFieldDefinition of the TABLEFIELD control.

    Returns:
        List of source code lines (without trailing newline).
    """
    class_name = prefixed_name + "Row"
    # columns is dict[str, ECMTableColumnDefinition]; skip columns without a data type.
    columns = [c for c in table_field_def.columns.values() if c.data_type is not None]

    lines: list[str] = [f"class {class_name}(ECMTableRowModel):"]
    for col in columns:
        attr = _safe_identifier(col.internal_name)
        lines.append(_field_annotation_line(attr, col.internal_name, f"ECMField[{_python_type(col)}]"))
    if not columns:
        lines.append("    pass")
    return lines


def _collect_used_imports(cabinet_def) -> tuple[set[str], bool, bool, bool, bool]:
    """Determine which ecm.model names and stdlib types the cabinet actually needs.

    Args:
        cabinet_def: An ECMCabinetDefinition instance.

    Returns:
        A five-tuple ``(model_names, needs_date, needs_time, needs_datetime, needs_enum)``
        where ``model_names`` is the set of names to import from
        ``ecmind_blue_client.ecm.model``.
    """
    model_names: set[str] = {"ECMField"}
    needs_date = False
    needs_time = False
    needs_datetime = False
    needs_enum = False

    for obj_def in cabinet_def.objects.values():
        base = _BASE_CLASS.get(obj_def.maintype, "ECMDocumentModel")
        model_names.add(base)

        scalar_fields, table_fields = _partition_fields(obj_def)

        if table_fields:
            model_names.update(("ECMTableField", "ECMTableRowModel"))

        for fd in scalar_fields:
            if fd.data_type == ECMFieldDataType.DATE:
                needs_date = True
            if fd.data_type == ECMFieldDataType.TIME:
                needs_time = True
            if fd.data_type == ECMFieldDataType.DATE_TIME:
                needs_datetime = True
            if _list_enum_entries(fd) is not None:
                needs_enum = True

        for tf in table_fields:
            for col in tf.columns.values():
                if col.data_type == ECMFieldDataType.DATE:
                    needs_date = True

    return model_names, needs_date, needs_time, needs_datetime, needs_enum


def _partition_fields(obj_def) -> tuple[list, list]:
    """Split all_fields into (scalar_fields, table_fields), skipping static/UI-only entries.

    Args:
        obj_def: An ECMObjectDefinition instance.

    Returns:
        A two-tuple ``(scalar_fields, table_fields)`` of ECMFieldDefinition lists.
    """
    scalar: list = []
    table: list = []
    for fd in obj_def.all_fields.values():
        if fd.is_static or fd.control in _SKIP_CONTROLS:
            continue
        if fd.control == ECMControlType.TABLEFIELD:
            table.append(fd)
        else:
            scalar.append(fd)
    return scalar, table


def _generate_object_class(obj_def, base_class: str) -> list[str]:  # pylint: disable=too-many-locals
    """Generate lines for a typed ECM model class.

    For scalar fields that carry an inline list catalog an optional ``str``-based
    Enum class is emitted first so callers can use symbolic names instead of raw
    strings.  The field annotation then becomes ``ECMField[EnumName]``.

    Args:
        obj_def: An ECMObjectDefinition (folder, register, or document).
        base_class: One of ``"ECMFolderModel"``, ``"ECMRegisterModel"``,
            ``"ECMDocumentModel"``.

    Returns:
        List of source code lines for any enum / row-helper classes followed by
        the main model class.
    """
    scalar_fields, table_fields = _partition_fields(obj_def)
    obj_prefix = _safe_identifier(obj_def.internal_name)

    prefix_lines: list[str] = []

    # Emit one Enum class per scalar field that has inline list values.
    for fd in scalar_fields:
        entries = _list_enum_entries(fd)
        if entries is None:
            continue
        enum_name = f"{obj_prefix}_{_safe_identifier(fd.internal_name)}Enum"
        prefix_lines.extend(_generate_enum_class(enum_name, entries))
        prefix_lines.append("")

    # Emit one ECMTableRowModel subclass per table field.
    for tf in table_fields:
        tf_attr = _safe_identifier(tf.internal_name)
        prefixed_name = f"{obj_prefix}_{tf_attr}"
        prefix_lines.extend(_generate_table_row_class(prefixed_name, tf))
        prefix_lines.append("")

    lines: list[str] = [
        f"class {obj_prefix}({base_class}):",
        f'    _internal_name_ = "{obj_def.internal_name}"',
    ]

    if not scalar_fields and not table_fields:
        lines.append("    pass")
        return prefix_lines + lines

    lines.append("")
    for fd in scalar_fields:
        attr = _safe_identifier(fd.internal_name)
        entries = _list_enum_entries(fd)
        if entries is not None:
            enum_name = f"{obj_prefix}_{attr}Enum"
            py_type = enum_name
        else:
            py_type = _python_type(fd)
        lines.append(_field_annotation_line(attr, fd.internal_name, f"ECMField[{py_type}]", mandatory=fd.required))
    for tf in table_fields:
        tf_attr = _safe_identifier(tf.internal_name)
        row_cls = f"{obj_prefix}_{tf_attr}Row"
        lines.append(_field_annotation_line(tf_attr, tf.internal_name, f"ECMTableField[{row_cls}]"))

    return prefix_lines + lines


def generate_cabinet_module(cabinet_def) -> str:
    """Generate the full Python source for one cabinet's models.

    Args:
        cabinet_def: An ECMCabinetDefinition instance.

    Returns:
        Complete Python source code string for the generated module.
    """
    # Determine which imports are actually needed before emitting any code.
    model_names, needs_date, needs_time, needs_datetime, needs_enum = _collect_used_imports(cabinet_def)

    # Emit imports in a stable alphabetical order, only those that are used.
    _import_order = [
        "ECMDocumentModel",
        "ECMField",
        "ECMFolderModel",
        "ECMRegisterModel",
        "ECMTableField",
        "ECMTableRowModel",
    ]
    import_lines = [f"    {name}," for name in _import_order if name in model_names]

    parts: list[str] = [
        f'"""Auto-generated ECM model classes for cabinet "{cabinet_def.internal_name}".',
        "",
        "DO NOT EDIT — regenerate with generate_ecm_models.py.",
        '"""',
        "",
    ]
    if needs_enum:
        parts += ["from enum import Enum", ""]
    if needs_date or needs_time or needs_datetime:
        dt_imports = ", ".join(sorted(filter(None, [
            "date" if needs_date else "",
            "datetime" if needs_datetime else "",
            "time" if needs_time else "",
        ])))
        parts += [f"from datetime import {dt_imports}", ""]
    parts += [
        "from ecmind_blue_client.ecm.model import (",
        *import_lines,
        ")",
        "",
        "",
    ]

    for obj_def in cabinet_def.objects.values():
        base = _BASE_CLASS.get(obj_def.maintype, "ECMDocumentModel")
        parts.extend(_generate_object_class(obj_def, base))
        parts += ["", ""]

    while parts and parts[-1] == "":
        parts.pop()
    parts.append("")

    return "\n".join(parts)


# ---------------------------------------------------------------------------
# Server fetch
# ---------------------------------------------------------------------------


def _fetch_definition_from_server(
    host: str,
    port: int,
    username: str,
    password: str,
    use_ssl: bool = True,
    name: str | None = None,
) -> ECMDefinition:
    """Connect to a live enaio server and retrieve the object definition via DMS.GetObjDef.

    Uses ``SyncPoolClient`` for connection management and session lifecycle
    (``krn.SessionAttach`` → ``krn.SessionLogin`` → ``dms.GetObjDef`` → ``krn.SessionLogout``).

    Uses ``Flags=1|4096``: returns the XML as a BASE64-encoded UTF-8 string in the
    ``sRet`` output parameter (no temporary server-side file required).

    Args:
        host: Hostname or IP address of the enaio server.
        port: TCP port of the enaio server (default 4000).
        username: Login username.
        password: Login password.
        use_ssl: Whether to use SSL/TLS (default ``True``).
        name: Optional application name shown in Enterprise Manager.

    Returns:
        A parsed :class:`ECMDefinition` instance.

    Raises:
        SystemExit: On connection or job errors.
    """
    from ecmind_blue_client.pool import ServerConnectionSettings, SyncPoolClient  # pylint: disable=import-outside-toplevel
    from ecmind_blue_client.rpc._jobs import Jobs  # pylint: disable=import-outside-toplevel

    client = SyncPoolClient(
        servers=[ServerConnectionSettings(hostname=host, port=port)],
        username=username,
        password=password,
        name=name,
        use_ssl=use_ssl,
    )
    # Flags=1  → return XML in sRet (BASE64) instead of a server-side file
    # Flags=4096 → encode XML as UTF-8 instead of the default UTF-16
    result = client.execute(Jobs.DMS_GETOBJDEF, Flags=1 | 4096)
    xml_bytes: bytes = result.get("sRet", bytes)
    return ECMDefinition(xml_bytes)


# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------


def main() -> None:
    """Parse arguments and drive model generation."""
    parser = argparse.ArgumentParser(
        description=(
            "Generate typed ECM model classes (ECMFolderModel / ECMRegisterModel / "
            "ECMDocumentModel) from a local asobjdef XML file or a live enaio server."
        )
    )

    source_group = parser.add_argument_group("source (exactly one required)")
    source_exclusive = source_group.add_mutually_exclusive_group(required=True)
    source_exclusive.add_argument(
        "--file",
        type=Path,
        metavar="XML_FILE",
        help="Path to a local asobjdef XML file (UTF-16 or UTF-8 encoded).",
    )
    source_exclusive.add_argument(
        "--host",
        metavar="HOST",
        help="Hostname or IP address of the enaio server.",
    )

    server_group = parser.add_argument_group("server options (used with --host)")
    server_group.add_argument(
        "--port",
        type=int,
        default=4000,
        metavar="PORT",
        help="TCP port of the enaio server (default: 4000).",
    )
    server_group.add_argument(
        "--username",
        metavar="USER",
        help="Login username.",
    )
    server_group.add_argument(
        "--password",
        metavar="PASS",
        help="Login password.",
    )
    server_group.add_argument(
        "--no-ssl",
        action="store_true",
        help="Disable SSL/TLS (enabled by default).",
    )
    server_group.add_argument(
        "--name",
        metavar="APPNAME",
        default=None,
        help="Application name shown in Enterprise Manager (optional).",
    )

    parser.add_argument(
        "--output-dir",
        type=Path,
        default=None,
        help=(
            "Directory to write generated .py files into. "
            "One file per cabinet: <cabinet_internal_name>.py. "
            "If omitted the generated code is printed to stdout."
        ),
    )
    parser.add_argument(
        "--cabinet",
        default=None,
        help="Only generate models for the cabinet with this internal name.",
    )
    args = parser.parse_args()

    if args.host is not None:
        if not args.username or not args.password:
            parser.error("--username and --password are required when using --host")
        try:
            definition = _fetch_definition_from_server(
                host=args.host,
                port=args.port,
                username=args.username,
                password=args.password,
                use_ssl=not args.no_ssl,
                name=args.name,
            )
        except Exception as exc:  # pylint: disable=broad-except
            print(f"ERROR: could not fetch object definition from server: {exc}", file=sys.stderr)
            sys.exit(1)
    else:
        xml_path: Path = args.file
        if not xml_path.exists():
            print(f"ERROR: file not found: {xml_path}", file=sys.stderr)
            sys.exit(1)
        try:
            definition = ECMDefinition(xml_path.read_bytes())
        except Exception as exc:  # pylint: disable=broad-except
            print(f"ERROR: could not parse {xml_path}: {exc}", file=sys.stderr)
            sys.exit(1)

    cabinets = definition.cabinets
    if args.cabinet:
        if args.cabinet not in cabinets:
            available = ", ".join(cabinets.keys())
            print(f"ERROR: cabinet '{args.cabinet}' not found. Available: {available}", file=sys.stderr)
            sys.exit(1)
        cabinets = {args.cabinet: cabinets[args.cabinet]}

    output_dir: Path | None = args.output_dir
    if output_dir is not None:
        output_dir.mkdir(parents=True, exist_ok=True)

    for cab_name, cabinet_def in cabinets.items():
        source = generate_cabinet_module(cabinet_def)
        if output_dir is None:
            if len(cabinets) > 1:
                print(f"# {'=' * 60}\n# Cabinet: {cab_name}\n# {'=' * 60}")
            print(source)
        else:
            out_file = output_dir / f"{cab_name}.py"
            out_file.write_text(source, encoding="utf-8")
            print(f"Written: {out_file}")


if __name__ == "__main__":
    main()
