"""
Standalone library API for tono signal processing nodes.

Usage::

    import tono

    # Load an SPM file
    fields = tono.load("scan.gwy")
    field = fields[0]

    # Apply a processing node
    result = tono.apply("GaussianFilter", field, sigma=3.0)

    # Chain operations
    result = tono.apply("PlaneLevelField", field, mode="subtract_mean")
    result = tono.apply("GaussianFilter", result, sigma=2.0)

    # Get available nodes
    print(tono.nodes())

    # Direct node access
    node = tono.get_node("GaussianFilter")
    (filtered,) = node.process(field=field, sigma=3.0)
"""

from __future__ import annotations

from pathlib import Path
from typing import Any

from backend.data_types import DataField, LineData, RecordTable, DataTable, MeshModel


def _ensure_registry() -> None:
    """Import all node modules so @register_node decorators fire."""
    from backend.node_registry import NODE_CLASS_MAPPINGS
    if NODE_CLASS_MAPPINGS:
        return
    import backend.nodes  # noqa: F401 — triggers auto-discovery


def load(path: str | Path) -> list[DataField]:
    """Load an SPM data file and return a list of DataField channels.

    Supported formats: .gwy, .sxm, .ibw, .hdf5, .png, .tif, .jpg, and more.

    Parameters
    ----------
    path : str or Path
        Path to the data file.

    Returns
    -------
    list[DataField]
        One DataField per channel in the file.

    Raises
    ------
    ValueError
        If the file extension is not supported.
    """
    from backend.importers import get_importer

    p = Path(path)
    ext = p.suffix.lower()
    importer = get_importer(ext)
    if importer is None:
        from backend.importers import all_extensions
        supported = ", ".join(sorted(all_extensions()))
        raise ValueError(
            f"Unsupported file extension {ext!r}. Supported: {supported}"
        )
    return importer.load(str(p))


def channel_names(path: str | Path) -> list[str]:
    """Return the channel names in a data file without loading the full data."""
    from backend.importers import get_importer

    p = Path(path)
    importer = get_importer(p.suffix.lower())
    if importer is None:
        raise ValueError(f"Unsupported file extension: {p.suffix!r}")
    return importer.channel_names(str(p))


def supported_formats() -> frozenset[str]:
    """Return all supported file extensions (e.g. {'.gwy', '.sxm', ...})."""
    from backend.importers import all_extensions
    return all_extensions()


def nodes() -> list[str]:
    """Return a sorted list of all registered node class names."""
    from backend.node_registry import NODE_CLASS_MAPPINGS
    _ensure_registry()
    return sorted(NODE_CLASS_MAPPINGS.keys())


def describe(class_name: str) -> dict:
    """Return a dict describing a node: inputs, outputs, description, keywords, category.

    Thin wrapper around :func:`backend.node_registry.get_node_info`.

    Parameters
    ----------
    class_name : str
        The node class name, e.g. ``"GaussianFilter"``.

    Returns
    -------
    dict
        Keys include ``name``, ``display_name``, ``category``, ``input``,
        ``input_order``, ``output``, ``output_name``, ``description``,
        ``keywords``, and more.

    Raises
    ------
    KeyError
        If no node with that name is registered.
    """
    from backend.node_registry import NODE_CLASS_MAPPINGS, get_node_info
    _ensure_registry()
    if class_name not in NODE_CLASS_MAPPINGS:
        raise KeyError(
            f"Unknown node {class_name!r}. "
            f"Use tono.nodes() to list available nodes."
        )
    return get_node_info(class_name)


def get_node(class_name: str) -> Any:
    """Return a fresh instance of the named node class.

    Parameters
    ----------
    class_name : str
        The node class name, e.g. ``"GaussianFilter"``.

    Returns
    -------
    object
        A node instance. Call its ``process(**kwargs)`` method to run it.

    Raises
    ------
    KeyError
        If no node with that name is registered.
    """
    from backend.node_registry import NODE_CLASS_MAPPINGS
    _ensure_registry()
    if class_name not in NODE_CLASS_MAPPINGS:
        raise KeyError(
            f"Unknown node {class_name!r}. "
            f"Use tono.nodes() to list available nodes."
        )
    return NODE_CLASS_MAPPINGS[class_name]()


def apply(class_name: str, *args: Any, **kwargs: Any) -> Any:
    """Run a node's process function and return the result.

    Positional arguments are mapped to the node's required inputs in order.
    Keyword arguments are passed directly.

    If the node returns a single output, it is unwrapped from the tuple.
    If the node returns multiple outputs, the full tuple is returned.

    Parameters
    ----------
    class_name : str
        The node class name, e.g. ``"GaussianFilter"``.
    *args
        Positional arguments mapped to required inputs in declaration order.
    **kwargs
        Keyword arguments passed directly to the node's process function.

    Returns
    -------
    DataField or tuple
        Single output unwrapped, or tuple of outputs if multiple.

    Examples
    --------
    >>> result = tono.apply("GaussianFilter", field, sigma=3.0)
    >>> leveled, mask = tono.apply("PlaneLevelField", field, mode="subtract_mean")
    """
    from backend.node_registry import NODE_CLASS_MAPPINGS
    from backend.execution_context import execution_callbacks, active_node

    _ensure_registry()
    if class_name not in NODE_CLASS_MAPPINGS:
        raise KeyError(
            f"Unknown node {class_name!r}. "
            f"Use tono.nodes() to list available nodes."
        )

    cls = NODE_CLASS_MAPPINGS[class_name]
    instance = cls()

    input_types = cls.INPUT_TYPES()
    required = input_types.get("required", {})
    required_names = list(required.keys())

    # Map positional args to required input names in declaration order
    if args:
        for i, arg in enumerate(args):
            if i >= len(required_names):
                raise TypeError(
                    f"{class_name} has {len(required_names)} required inputs, "
                    f"but {len(args)} positional arguments were given"
                )
            name = required_names[i]
            if name in kwargs:
                raise TypeError(
                    f"{class_name}: input {name!r} specified both as positional "
                    f"argument and keyword argument"
                )
            kwargs[name] = arg

    # Fill in defaults from INPUT_TYPES metadata for any missing required inputs.
    # The metadata dict is the single source of truth for default values — the
    # GUI pre-populates widgets from it, and we mirror that behaviour here so
    # tono.GaussianFilter(field) works without repeating the default at the call site.
    for name, spec in required.items():
        if name in kwargs:
            continue
        if isinstance(spec, (list, tuple)) and len(spec) >= 2 and isinstance(spec[1], dict):
            meta = spec[1]
            if "default" in meta:
                kwargs[name] = meta["default"]

    func = getattr(instance, cls.FUNCTION)

    # Run inside an execution context so emit_* calls are no-ops
    with execution_callbacks(), active_node("api"):
        result = func(**kwargs)

    if not isinstance(result, tuple):
        result = (result,)

    return result[0] if len(result) == 1 else result


def field(data: Any, xreal: float = 1e-6, yreal: float = 1e-6,
          si_unit_xy: str = "m", si_unit_z: str = "m", **kwargs: Any) -> DataField:
    """Create a DataField from a 2D array.

    A convenience wrapper around ``DataField(...)`` with sensible defaults.

    Parameters
    ----------
    data : array_like
        2D numpy array or anything convertible to one.
    xreal : float
        Physical width in metres (default 1 um).
    yreal : float
        Physical height in metres (default 1 um).
    si_unit_xy : str
        Lateral unit string (default "m").
    si_unit_z : str
        Height/value unit string (default "m").
    **kwargs
        Additional DataField keyword arguments.

    Returns
    -------
    DataField
    """
    import numpy as np
    return DataField(
        data=np.asarray(data, dtype=np.float64),
        xreal=xreal,
        yreal=yreal,
        si_unit_xy=si_unit_xy,
        si_unit_z=si_unit_z,
        **kwargs,
    )
