import contextlib
import inspect
import json
import re
import sys
import textwrap
import time
import traceback
import types
import typing
from collections.abc import Callable, Iterable, Iterator
from copy import copy, deepcopy
from enum import Enum
from pathlib import Path
from typing import Any, ClassVar, Generic, Literal, Self, TypeVar, overload
from uuid import uuid4

from citeable import Citation
from scitrack import CachingLogger  # type: ignore[import-untyped]
from typeguard import TypeCheckError, check_type

from scinexus import typing as snx_typing
from scinexus._version import __version__
from scinexus.deserialise import register_deserialiser
from scinexus.misc import docstring_to_summary_rest, get_object_provenance
from scinexus.progress import Progress, get_progress
from scinexus.typing import (
    check_type_compatibility,
    get_type_display_names,
    resolve_type_hint,
)
from scinexus.warning import deprecated_callable

from .data_store import (
    DataMember,
    DataStoreABC,
    get_id_from_source,
)

_builtin_seqs = list, set, tuple

T = TypeVar("T")
R = TypeVar("R")
_T = TypeVar("_T")


def _make_logfile_name(process: object) -> str:
    text = re.split(r"\s+\+\s+", str(process))
    parts = []
    for part in text:
        if part.find("(") >= 0:
            part = part[: part.find("(")]
        parts.append(part)
    result = "-".join(parts)
    uid = str(uuid4())
    return f"{result}-{uid[:8]}.log"


def _get_origin(origin: typing.Any) -> str:
    return origin if isinstance(origin, str) else origin.__class__.__name__


class NotCompletedType(Enum):
    ERROR = "ERROR"
    FAIL = "FAIL"
    BUG = "BUG"


class NotCompleted(int):
    """results that failed to complete"""

    type: NotCompletedType
    origin: str
    message: str
    source: str | None
    _persistent: tuple[tuple[Any, ...], dict[str, Any]]

    def __new__(
        cls,
        type_: NotCompletedType | str,
        origin: typing.Any,
        message: str,
        source: typing.Any = None,
    ) -> Self:
        """
        Parameters
        ----------
        type_
            the category of failure, e.g. NotCompletedType.ERROR
        origin
            where the instance was created, can be an instance
        message
            descriptive message, succinct traceback
        source
            the data operated on that led to this result. May be a string
            or an instance with ``.source`` or ``.info.source`` attributes.
        """
        type_ = NotCompletedType(type_)
        origin = _get_origin(origin)
        try:
            source = get_id_from_source()(source)
        except Exception:  # noqa: BLE001
            source = None
        result = int.__new__(cls, False)  # noqa: FBT003
        result._persistent = (type_.value, origin, message), {"source": source}  # noqa: SLF001
        result.type = type_
        result.origin = origin
        result.message = message
        result.source = source
        return result

    def __getnewargs_ex__(self) -> tuple[tuple[Any, ...], dict[str, Any]]:
        return self._persistent[0], self._persistent[1]

    def __repr__(self) -> str:
        return str(self)

    def __str__(self) -> str:
        name = self.__class__.__name__
        source = self.source or "Unknown"
        return f'{name}(type={self.type.value}, origin={self.origin}, source="{source}", message="{self.message}")'

    def to_rich_dict(self) -> dict[str, Any]:
        """returns components for to_json"""
        return {
            "type": get_object_provenance(self),
            "not_completed_construction": {
                "args": self._persistent[0],
                "kwargs": self._persistent[1],
            },
            "version": __version__,
        }

    def to_json(self) -> str:
        """returns json string"""
        return json.dumps(self.to_rich_dict())


class AppType(Enum):
    LOADER = "loader"
    WRITER = "writer"
    GENERIC = "generic"
    NON_COMPOSABLE = "non_composable"


# Aliases to use Enum easily
LOADER = AppType.LOADER
WRITER = AppType.WRITER
GENERIC = AppType.GENERIC
NON_COMPOSABLE = AppType.NON_COMPOSABLE


def _get_raw_hints(
    main_func: Callable[..., Any], min_params: int
) -> tuple[object, object]:
    _no_value = inspect.Parameter.empty
    params = inspect.signature(main_func)
    if len(params.parameters) < min_params:
        msg = f"{main_func.__name__!r} must have at least {min_params} input parameters"
        raise ValueError(
            msg,
        )
    # annotation for first parameter other than self, params.parameters is an orderedDict
    first_param_type = [p.annotation for p in params.parameters.values()][
        min_params - 1
    ]
    return_type = params.return_annotation
    if return_type is _no_value:
        msg = "must specify type hint for return type"
        raise TypeError(msg)
    if first_param_type is _no_value:
        msg = "must specify type hint for first parameter"
        raise TypeError(msg)

    if first_param_type is None:
        msg = "NoneType invalid type for first parameter"
        raise TypeError(msg)
    if return_type is None:
        msg = "NoneType invalid type for return value"
        raise TypeError(msg)

    if isinstance(first_param_type, str):
        msg = (
            "Apps do not yet support string type hints "
            "(such as those caused by __future__ annotations). "
            f"Bad type hint: {first_param_type}"
        )
        raise NotImplementedError(msg)
    if isinstance(return_type, str):
        msg = (
            "Apps do not yet support string type hints "
            "(such as those caused by __future__ annotations). "
            f"Bad type hint: {return_type}"
        )
        raise NotImplementedError(msg)

    return first_param_type, return_type


def _get_main_hints(klass: type[Any]) -> tuple[object, object]:
    """return raw type hints for main method

    Returns
    -------
    (first_param_type_hint, return_type_hint)
    """
    # Check klass.main exists and is type method
    main_func = getattr(klass, "main", None)
    if (
        main_func is None
        or not inspect.isclass(klass)
        or not inspect.isfunction(main_func)
    ):
        msg = f"must define a callable main() method in {klass.__name__!r}"
        raise ValueError(msg)

    first_param_type, return_type = _get_raw_hints(main_func, 2)
    return first_param_type, return_type


def _set_hints(
    main_meth: Callable[..., Any], first_param_type: object, return_type: object
) -> Callable[..., Any]:
    """adds type hints to main"""
    main_meth.__annotations__["arg"] = first_param_type
    main_meth.__annotations__["return"] = return_type
    return main_meth


class source_proxy(Generic[_T]):
    """wraps an object to track its source through app pipelines"""

    __slots__ = ("_obj", "_src", "_uuid")

    def __init__(self, obj: _T) -> None:
        self._obj = obj
        self._src = obj
        self._uuid = uuid4()

    def __hash__(self) -> int:
        return hash(self._uuid)

    @property
    def obj(self) -> _T:
        return self._obj

    def set_obj(self, obj: typing.Any) -> None:
        self._obj = obj

    @property
    def source(self) -> typing.Any:
        """origin of this object"""
        return self._src

    @source.setter
    def source(self, src: typing.Any) -> None:
        # need to check whether src is hashable, how to cope if it isn't?
        # might need to make this instance hashable perhaps using a uuid?
        self._src = src

    @property
    def uuid(self) -> str:
        """unique identifier for this object"""
        return str(self._uuid)

    def __getattr__(self, name: str) -> typing.Any:
        return getattr(self._obj, name)

    def __setattr__(self, name: str, value: typing.Any) -> None:
        if name.startswith("_"):
            super().__setattr__(name, value)
        else:
            setattr(self._obj, name, value)

    def __bool__(self) -> bool:
        return bool(self._obj)

    def __repr__(self) -> str:
        return self.obj.__repr__()

    def __str__(self) -> str:
        return self.obj.__str__()

    def __eq__(self, other: object) -> bool:
        return self.obj.__eq__(other)

    def __len__(self) -> int:
        return self.obj.__len__()  # type: ignore[attr-defined]

    # pickling induces infinite recursion on python 3.10
    # only on Windows, so implementing the following methods explicitly
    def __getstate__(self) -> tuple[Any, Any, Any]:
        return self._obj, self._src, self._uuid

    def __setstate__(self, state: tuple[Any, Any, Any]) -> None:
        self._obj, self._src, self._uuid = state


def _proxy_input(dstore: Iterable[Any]) -> list[source_proxy[Any]]:
    inputs = []
    for e in dstore:
        if not e:
            continue
        if not isinstance(e, source_proxy):
            e = e if hasattr(e, "source") else source_proxy(e)
        inputs.append(e)

    return inputs


GetIdFuncType = typing.Callable[[source_proxy[Any] | snx_typing.HasSource], str | None]


class propagate_source:
    """retains result association with source

    Notes
    -----
    Returns the unwrapped result if it has a .source attribute,
    otherwise returns the original source_proxy with the .obj
    updated with result.
    """

    def __init__(self, app: "AppBase[Any, Any]", id_from_source: GetIdFuncType) -> None:
        self.app = app
        self.id_from_source = id_from_source

    def __call__(
        self, value: source_proxy[Any] | snx_typing.HasSource
    ) -> snx_typing.HasSource:
        if not isinstance(value, source_proxy):
            return self.app(value)

        result = self.app(value.obj)
        if self.id_from_source(result):
            return result

        value.set_obj(result)
        return value


# Forbidden methods per app kind
_FORBIDDEN_BASE = frozenset(
    {
        "__call__",
        "__repr__",
        "__str__",
        "__new__",
        "__copy__",
        "__eq__",
        "_validate_data_type",
        "as_completed",
        "check_data_type",
        "_get_citations",
        "citations",
        "bib",
    }
)
_FORBIDDEN_COMPOSABLE = _FORBIDDEN_BASE | frozenset(
    {
        "__add__",
        "disconnect",
        "input",
    }
)
_FORBIDDEN_WRITER = _FORBIDDEN_COMPOSABLE | frozenset(
    {
        "apply_to",
        "set_logger",
    }
)


def _init_subclass_setup(
    cls: Any,
    app_type: AppType | Literal["loader", "writer", "generic", "non_composable"],
    skip_not_completed: bool,
    cite: Citation | None,
) -> None:
    """Shared setup logic for __init_subclass__ and define_app."""
    app_type = AppType(app_type)

    if "__slots__" in cls.__dict__:
        msg = "slots are not currently supported"
        raise NotImplementedError(msg)

    if app_type is WRITER:
        forbidden = _FORBIDDEN_WRITER
    elif app_type is not NON_COMPOSABLE:
        forbidden = _FORBIDDEN_COMPOSABLE
    else:
        forbidden = _FORBIDDEN_BASE

    if (
        app_type is not NON_COMPOSABLE
        and "input" in cls.__dict__
        and cls.__dict__["input"] is not None
    ):
        msg = f"remove 'input' attribute in {cls.__name__!r}, reserved by the app framework"
        raise TypeError(msg)

    for meth in forbidden:
        if meth in cls.__dict__:
            val = cls.__dict__[meth]
            if isinstance(val, staticmethod):
                val = val.__func__
            if inspect.isfunction(val) or isinstance(val, property):
                msg = f"remove {meth!r} in {cls.__name__!r}, reserved by the app framework"
                raise TypeError(msg)

    raw_input, raw_return = _get_main_hints(cls)
    mod = sys.modules.get(cls.__module__) if cls.__module__ else None
    module_globals = vars(mod) if mod else {}
    cls._input_type = resolve_type_hint(raw_input, module_globals)
    cls._return_type = resolve_type_hint(raw_return, module_globals)
    cls.app_type = app_type
    cls._skip_not_completed = skip_not_completed
    cls._check_data_type = True
    cls._cite = cite
    cls._source_wrapped = None

    if app_type is not LOADER:
        cls.input = None


class AppBase(Generic[T, R]):
    """Base for all app types. Provides __call__, __repr__, etc.

    Raises
    ------
    TypeError
        If a subclass defines any method name reserved by the app framework.
        The reserved names depend on the app type.

        All app types: ``__call__``, ``__repr__``, ``__str__``, ``__new__``,
        ``__copy__``, ``__eq__``, ``_validate_data_type``, ``as_completed``,
        ``check_data_type``, ``_get_citations``, ``citations``, ``bib``.

        Composable apps (GENERIC, LOADER, WRITER) additionally:
        ``__add__``, ``disconnect``, ``input``.

        Writer apps additionally: ``apply_to``, ``set_logger``.
    """

    _is_intermediate_base: bool = False
    _skip_not_completed: bool
    _check_data_type: bool
    _source_wrapped: propagate_source | None
    _cite: Citation | None
    _input_type: type
    _return_type: type
    _init_vals: dict[str, Any]
    app_type: AppType
    input: Any
    main: Callable[..., Any]

    def __init_subclass__(
        cls,
        skip_not_completed: bool = True,
        cite: Citation | None = None,
        **kwargs: Any,
    ) -> None:
        super().__init_subclass__(**kwargs)
        # Skip setup for intermediate bases and classes built by define_app
        if "_is_intermediate_base" in cls.__dict__ or getattr(
            cls, "_define_app_pending", False
        ):
            return
        app_type = getattr(cls, "_default_app_type", GENERIC)
        _init_subclass_setup(cls, app_type, skip_not_completed, cite)

    def __new__(cls, *args: Any, **kwargs: Any) -> Self:
        obj = object.__new__(cls)

        if hasattr(cls, "_func_sig"):
            # we have a decorated function, the first parameter in the signature
            # is not given to constructor, so we create a new signature excluding that one
            params = cls._func_sig.parameters  # type: ignore[attr-defined]
            init_sig = inspect.Signature(parameters=list(params.values())[1:])
            bargs = init_sig.bind_partial(*args, **kwargs)
        else:
            init_sig = inspect.signature(cls.__init__)
            bargs = init_sig.bind_partial(cls, *args, **kwargs)
        bargs.apply_defaults()
        init_vals = bargs.arguments
        init_vals.pop("self", None)

        obj._init_vals = init_vals
        return obj

    def __copy__(self) -> Self:
        new = object.__new__(type(self))
        new.__dict__.update(self.__dict__)
        return new

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, type(self)):
            return False
        return all(
            v is other.__dict__.get(k) for k, v in self.__dict__.items() if k != "input"
        )

    __hash__ = None  # type: ignore[assignment]

    @property
    def check_data_type(self) -> bool:
        """toggle whether the type of input data matches the defined compatible types

        Notes
        -----
        If False, and an invalid data type is passed, the error will
        still be caught, but in a potentially less informative way. For instance
        "'NoneType' object has no attribute 'blah'"
        """
        return self._check_data_type

    @check_data_type.setter
    def check_data_type(self, value: bool) -> None:
        self._check_data_type = value
        head = getattr(self, "input", None)
        while head is not None:
            head._check_data_type = value
            head = getattr(head, "input", None)

    def __call__(self, val: T, *args: Any, **kwargs: Any) -> R | NotCompleted:
        if val is None:
            return NotCompleted(
                NotCompletedType.ERROR, self, "unexpected input value None", source=val
            )

        if isinstance(val, NotCompleted) and self._skip_not_completed:
            return val

        if self.app_type is not LOADER and self.input:  # passing to connected app
            val = self.input(val, *args, **kwargs)
            if isinstance(val, NotCompleted) and self._skip_not_completed:
                return val

        if self._check_data_type:
            type_checked = self._validate_data_type(val)
            if not type_checked:
                return type_checked  # type: ignore[return-value]

        try:
            result = self.main(val, *args, **kwargs)
        except Exception:
            result = NotCompleted(
                NotCompletedType.ERROR, self, traceback.format_exc(), source=val
            )

        if result is None:
            result = NotCompleted(
                NotCompletedType.BUG, self, "unexpected output value None", source=val
            )
        return result

    def __repr__(self) -> str:
        val = f"{self.input!r} + " if self.app_type is not LOADER and self.input else ""
        all_args = {**self._init_vals}
        args_items = all_args.pop("args", None)
        data = ", ".join(f"{v!r}" for v in args_items) if args_items else ""
        kwargs_items = all_args.pop("kwargs", None)
        data += (
            ", ".join(f"{k}={v!r}" for k, v in kwargs_items.items())
            if kwargs_items
            else ""
        )
        data += ", ".join(f"{k}={v!r}" for k, v in all_args.items())
        data = f"{val}{self.__class__.__name__}({data})"
        return textwrap.fill(
            data, width=80, break_long_words=False, break_on_hyphens=False
        )

    __str__ = __repr__

    def _validate_data_type(self, data: Any) -> bool | NotCompleted:
        """checks data type matches defined compatible types using typeguard"""
        if isinstance(data, NotCompleted):
            if self._skip_not_completed:
                return data
            # skip_not_completed=False means the app handles NotCompleted itself
            return True

        if isinstance(data, source_proxy):
            data = data.obj

        if isinstance(data, _builtin_seqs) and len(data) == 0:
            return NotCompleted(
                NotCompletedType.ERROR, self, message="empty data", source=data
            )

        try:
            check_type(data, self._input_type)
            return True
        except TypeCheckError:
            class_name = data.__class__.__name__
            expected = get_type_display_names(self._input_type)
            msg = f"invalid data type, '{class_name}' not in {', '.join(sorted(expected))}"
            return NotCompleted(NotCompletedType.ERROR, self, message=msg, source=data)

    def as_completed(
        self,
        dstore: DataStoreABC | Iterable[Any] | str,
        parallel: bool = False,
        par_kw: dict[str, Any] | None = None,
        id_from_source: GetIdFuncType | None = None,
        show_progress: bool | Progress = False,
    ) -> Iterator[Any]:
        """invokes self composable function on the provided data store

        Parameters
        ----------
        dstore
            a path, list of paths, or DataStore to which the process will be
            applied.
        parallel
            run in parallel, according to arguments in par_kwargs. If True,
            the last step of the composable function serves as the master
            process, with earlier steps being executed in parallel for each
            member of dstore.
        par_kw
            dict of values for configuring parallel execution.
        id_from_source
            extracts a unique identifier from each input. If not provided,
            defaults to the function registered via
            ``scinexus.data_store.set_id_from_source``, falling back to
            ``scinexus.data_store.get_unique_id``.
        show_progress
            controls progress bar display. Pass ``True`` for the default
            progress bar, ``False`` to disable, or a ``Progress`` instance
            for a custom backend.

        Notes
        -----
        If run in parallel, this instance serves as the master object and
        aggregates results. If run in serial, results are returned in the
        same order as provided.
        """
        if id_from_source is None:
            id_from_source = get_id_from_source()
        if self._source_wrapped is None:
            app = propagate_source(
                self.input if self.app_type is WRITER else self, id_from_source
            )
        else:
            app = (
                self.input._source_wrapped
                if self.app_type is WRITER
                else self._source_wrapped
            )

        if isinstance(dstore, str):
            dstore = [dstore]
        elif isinstance(dstore, DataStoreABC):
            dstore = dstore.completed
        mapped = _proxy_input(dstore)
        if not mapped:
            return (_ for _ in ())

        if parallel:
            from scinexus import parallel as snxpar

            par_kw = par_kw or {}
            to_do: typing.Iterable = snxpar.as_completed(app, mapped, **par_kw)
        else:
            to_do = map(app, mapped)

        progress = get_progress(show_progress)
        return progress(to_do, total=len(mapped))

    def _get_citations(self) -> tuple[Citation, ...]:
        """Return citations for this app and all composed input apps."""
        seen: set[Citation] = set()
        result: list[Citation] = []

        if self._cite is not None:
            self._cite.app = self.__class__.__name__
            seen.add(self._cite)
            result.append(self._cite)

        head = getattr(self, "input", None)
        while head is not None:
            if head._cite is not None and head._cite not in seen:
                head._cite.app = head.__class__.__name__
                seen.add(head._cite)
                result.append(head._cite)
            head = getattr(head, "input", None)

        return tuple(result)

    @property
    def citations(self) -> tuple[Citation, ...]:
        """Citations for this app and all composed input apps."""
        return self._get_citations()

    @property
    def bib(self) -> str:
        """BibTeX formatted string of citations for this app and all composed input apps."""
        return "\n\n".join(str(cite) for cite in self.citations)


class ComposableApp(AppBase[T, R]):
    """Adds __add__ for LOADER/GENERIC.

    Raises
    ------
    TypeError
        If a subclass defines any method name reserved by the app framework:
        ``__call__``, ``__repr__``, ``__str__``, ``__new__``, ``__copy__``,
        ``__eq__``, ``_validate_data_type``, ``as_completed``,
        ``check_data_type``, ``_get_citations``, ``citations``, ``bib``,
        ``__add__``, ``disconnect``, ``input``.
    """

    _is_intermediate_base: bool = True

    def __add__(self, other: "ComposableApp[Any, Any]") -> "ComposableApp[Any, Any]":
        if getattr(other, "app_type", None) not in {WRITER, LOADER, GENERIC}:
            msg = f"{other!r} is not composable"
            raise TypeError(msg)

        if other == self:
            msg = "cannot add an app to itself"
            raise ValueError(msg)

        # Check order
        if self.app_type is WRITER:
            msg = "Left hand side of add operator must not be of type writer"
            raise TypeError(msg)
        if other.app_type is LOADER:
            msg = "Right hand side of add operator must not be of type loader"
            raise TypeError(msg)

        if not check_type_compatibility(self._return_type, other._input_type):
            self_names = get_type_display_names(self._return_type)
            other_names = get_type_display_names(other._input_type)
            msg = (
                f"{self.__class__.__name__!r} return_type {self_names} "
                f"incompatible with {other.__class__.__name__!r} input "
                f"type {other_names}"
            )
            raise TypeError(msg)
        result = copy(other)
        result.input = copy(self)
        return result

    @deprecated_callable(
        version="2026.9",
        reason="no longer required",
        is_discontinued=True,
    )
    def disconnect(self) -> None:  # pragma: no cover
        """Deprecated. No longer required since composition uses copies."""


class WriterApp(ComposableApp[T, R]):
    """Adds apply_to and set_logger for WRITER.

    Raises
    ------
    TypeError
        If a subclass defines any method name reserved by the app framework:
        ``__call__``, ``__repr__``, ``__str__``, ``__new__``, ``__copy__``,
        ``__eq__``, ``_validate_data_type``, ``as_completed``,
        ``check_data_type``, ``_get_citations``, ``citations``, ``bib``,
        ``__add__``, ``disconnect``, ``input``, ``apply_to``, ``set_logger``.
    """

    _is_intermediate_base: bool = True
    _default_app_type: ClassVar[AppType] = WRITER
    data_store: DataStoreABC
    logger: CachingLogger | None

    def apply_to(
        self,
        dstore: DataStoreABC | Iterable[Any] | str | Path,
        id_from_source: GetIdFuncType | None = None,
        parallel: bool = False,
        par_kw: dict[str, Any] | None = None,
        logger: bool | CachingLogger = True,
        cleanup: bool = True,
        show_progress: bool | Progress = False,
    ) -> DataStoreABC:
        """invokes self composable function on the provided data store

        Parameters
        ----------
        dstore
            a path, list of paths, or DataStore to which the process will be
            applied.
        id_from_source
            makes the unique identifier from elements of dstore that will be
            used for writing results. If not provided, defaults to the
            function registered via
            ``scinexus.data_store.set_id_from_source``, falling back to
            ``scinexus.data_store.get_unique_id``.
        parallel
            run in parallel, according to arguments in par_kwargs. If True,
            the last step of the composable function serves as the master
            process, with earlier steps being executed in parallel for each
            member of dstore.
        par_kw
            dict of values for configuring parallel execution.
        logger
            Controls logging. If True (default), a CachingLogger is created
            automatically. If False, logging is disabled. A CachingLogger
            instance can be passed directly.
        cleanup
            after copying of log files into the data store, it is deleted
            from the original location
        show_progress
            controls progress bar display

        Returns
        -------
        The output data store instance

        Notes
        -----
        This is an append only function, meaning that if a member already exists
        in self.data_store for an input, it is skipped.

        If run in parallel, this instance spawns workers and aggregates results.
        """
        if id_from_source is None:
            id_from_source = get_id_from_source()
        if self.app_type is WRITER:
            if self.input is None:
                msg = "writer app has no composed input"
                raise RuntimeError(msg)
            self.input._source_wrapped = propagate_source(self.input, id_from_source)
            self._source_wrapped = propagate_source(self, id_from_source)

        if isinstance(dstore, str | Path):  # one filename
            dstore = [dstore]
        elif isinstance(dstore, DataStoreABC):
            dstore = dstore.completed

        # TODO this should fail if somebody provides data that cannot produce a unique_id
        inputs = {}
        for m in dstore:
            input_id = Path(m.unique_id) if isinstance(m, DataMember) else m
            input_id = id_from_source(input_id)  # type: ignore[arg-type]
            if input_id in inputs or not input_id:
                msg = f"non-unique identifier {input_id!r} detected in data"
                raise ValueError(msg)
            if input_id in self.data_store:
                # we are assuming that this query returns True only when
                # an input_id is completed, we will not hit this if not_completed
                continue
            inputs[input_id] = m

        if (
            not dstore
        ):  # this should just return datastore, because if all jobs are done!
            msg = "dstore is empty"
            raise ValueError(msg)

        self.set_logger(logger)
        active_logger: CachingLogger | None = self.logger
        if active_logger:
            start = time.time()
            active_logger.log_message(str(self), label="composable function")
            active_logger.log_versions(["scinexus"])

        proxied = _proxy_input(inputs.values())
        for result in self.as_completed(
            proxied,
            parallel=parallel,
            par_kw=par_kw,
            show_progress=show_progress,
        ):
            member = self.main(
                data=getattr(result, "obj", result),
                identifier=id_from_source(result),  # type: ignore[arg-type]
            )
            if active_logger:
                md5 = getattr(member, "md5", None)
                active_logger.log_message(str(member), label="output")
                if md5:
                    active_logger.log_message(md5, label="output md5sum")

        if active_logger:
            taken = time.time() - start
            active_logger.log_message(f"{taken}", label="TIME TAKEN")
            log_file_path = Path(active_logger.log_file_path)
            active_logger.shutdown()
            self.data_store.write_log(
                unique_id=log_file_path.name,
                data=log_file_path.read_text(),
            )
            if cleanup:
                log_file_path.unlink(missing_ok=True)

        # write citations
        self.data_store.write_citations(data=self.citations)

        return self.data_store

    def set_logger(self, logger: bool | CachingLogger = True) -> None:
        if logger is False:
            self.logger = None
            return
        if logger is True:
            logger = CachingLogger(create_dir=True)
        if not isinstance(logger, CachingLogger):
            msg = f"logger must be of type CachingLogger not {type(logger)}"
            raise TypeError(msg)
        if not logger.log_file_path:
            src = Path(self.data_store.source).parent  # type: ignore[attr-defined]
            logger.log_file_path = str(src / _make_logfile_name(self))
        self.logger = logger


class LoaderApp(ComposableApp[T, R]):
    """Intermediate base class for LOADER apps.

    Subclasses of ``LoaderApp`` are automatically assigned
    ``app_type=LOADER``. Loaders sit at the start of a composed pipeline
    and have no ``input`` attribute.

    Raises
    ------
    TypeError
        If a subclass defines any method name reserved by the app framework:
        ``__call__``, ``__repr__``, ``__str__``, ``__new__``, ``__copy__``,
        ``__eq__``, ``_validate_data_type``, ``as_completed``,
        ``check_data_type``, ``_get_citations``, ``citations``, ``bib``,
        ``__add__``, ``disconnect``, ``input``.

    Examples
    --------
    Define a loader by inheritance::

        class my_loader(LoaderApp):
            def main(self, path):
                return path
    """

    _is_intermediate_base: bool = True
    _default_app_type: ClassVar[AppType] = LOADER


class NonComposableApp(AppBase[T, R]):
    """Intermediate base class for NON_COMPOSABLE apps.

    Subclasses of ``NonComposableApp`` are automatically assigned
    ``app_type=NON_COMPOSABLE``. Non-composable apps cannot participate
    in pipeline composition via ``+``.

    Raises
    ------
    TypeError
        If a subclass defines any method name reserved by the app framework:
        ``__call__``, ``__repr__``, ``__str__``, ``__new__``, ``__copy__``,
        ``__eq__``, ``_validate_data_type``, ``as_completed``,
        ``check_data_type``, ``_get_citations``, ``citations``, ``bib``.

    Examples
    --------
    Define a non-composable app by inheritance::

        class my_app(NonComposableApp):
            def main(self, val):
                return val
    """

    _is_intermediate_base: bool = True
    _default_app_type: ClassVar[AppType] = NON_COMPOSABLE


def _class_from_func(func: Callable[..., Any]) -> type[Any]:
    """make a class based on func

    Notes
    -----
    produces a class consistent with the necessary properties for
    the define_app class decorator.

    func becomes a static method on the class
    """

    # these methods MUST be in function scope so that separate instances are
    # created for each decorated function
    def _init(self: Any, *args: Any, **kwargs: Any) -> None:
        self._args = args
        self._kwargs = kwargs
        self._source_wrapped = None

    def _main(self: Any, arg: Any, *args: Any, **kwargs: Any) -> Any:
        kw_args = deepcopy(self._kwargs)
        kw_args = {**kw_args, **kwargs}
        args = (arg, *args, *deepcopy(self._args))
        bound = self._func_sig.bind(*args, **kw_args)
        return self._user_func(**bound.arguments)

    module = func.__module__  # to be assigned to the generated class
    sig = inspect.signature(func)
    class_name = func.__name__
    _main = _set_hints(_main, *_get_raw_hints(func, 1))
    summary, body = docstring_to_summary_rest(func.__doc__ or "")
    func.__doc__ = None

    _class_dict = {"__init__": _init, "main": _main, "_user_func": staticmethod(func)}

    for method_name, method in _class_dict.items():
        method.__name__ = method_name  # type: ignore[attr-defined]
        method.__qualname__ = f"{class_name}.{method_name}"  # type: ignore[attr-defined]

    result = types.new_class(class_name, (), exec_body=lambda x: x.update(_class_dict))
    result.__module__ = module  # necessary for pickle support
    result._func_sig = sig  # type: ignore[attr-defined]
    result.__doc__ = summary
    result.__init__.__doc__ = body  # type: ignore[misc]
    return result


def _fix_super_class_cells(
    old_klass: type[Any],
    new_klass: type[Any],
) -> None:
    """Update __class__ closure cells so zero-arg super() works in the new class.

    When define_app rebuilds a class via types.new_class, methods copied from
    the original class retain closure cells pointing to the old class. Python's
    zero-arg super() uses the __class__ cell, so it fails when the instance is
    of the new class. This function patches those cells to point to new_klass.
    """
    for attr in new_klass.__dict__.values():
        fn = attr if isinstance(attr, types.FunctionType) else None
        if fn is None:
            # unwrap classmethod / staticmethod
            fn = getattr(attr, "__func__", None)
        if not isinstance(fn, types.FunctionType):
            continue
        closure = fn.__closure__
        if closure is None:
            continue
        freevars = fn.__code__.co_freevars
        for i, name in enumerate(freevars):
            if name == "__class__":
                with contextlib.suppress(ValueError):
                    if closure[i].cell_contents is old_klass:
                        closure[i].cell_contents = new_klass


@overload
def define_app(
    klass: type[Any] | Callable[..., Any],
) -> type[ComposableApp[Any, Any]]: ...


@overload
def define_app(  # type: ignore[overload-overlap]
    klass: None = None,
    *,
    app_type: Literal[AppType.NON_COMPOSABLE, "non_composable"],
    skip_not_completed: bool = ...,
    cite: Citation | None = ...,
) -> Callable[[type[Any] | Callable[..., Any]], type[AppBase[Any, Any]]]: ...


@overload
def define_app(  # type: ignore[overload-overlap]
    klass: None = None,
    *,
    app_type: Literal[AppType.WRITER, "writer"],
    skip_not_completed: bool = ...,
    cite: Citation | None = ...,
) -> Callable[[type[Any] | Callable[..., Any]], type[WriterApp[Any, Any]]]: ...


@overload
def define_app(
    klass: None = None,
    *,
    app_type: AppType | Literal["loader", "writer", "generic", "non_composable"] = ...,
    skip_not_completed: bool = ...,
    cite: Citation | None = ...,
) -> Callable[[type[Any] | Callable[..., Any]], type[ComposableApp[Any, Any]]]: ...


def define_app(
    klass: type[Any] | Callable[..., Any] | None = None,
    *,
    app_type: AppType
    | Literal["loader", "writer", "generic", "non_composable"] = GENERIC,
    skip_not_completed: bool = True,
    cite: Citation | None = None,
) -> type[Any]:
    """decorator for building callable apps

    Parameters
    ----------
    klass
        either a class or a function. If a function, it is converted to a
        class with the function bound as a static method.
    app_type
        what type of app, typically you just want GENERIC.
    skip_not_completed
        if True (default), NotCompleted instances are returned without being
        passed to the app.
    cite
        a Citation instance describing the software or algorithm. If provided,
        its ``.app`` attribute is set to the class name.

    Raises
    ------
    TypeError
        If the decorated class defines any method name reserved by the app
        framework. The reserved names depend on the app type.

        All app types: ``__call__``, ``__repr__``, ``__str__``, ``__new__``,
        ``__copy__``, ``__eq__``, ``_validate_data_type``, ``as_completed``,
        ``check_data_type``, ``_get_citations``, ``citations``, ``bib``.

        Composable apps (GENERIC, LOADER, WRITER) additionally:
        ``__add__``, ``disconnect``, ``input``.

        Writer apps additionally: ``apply_to``, ``set_logger``.

    Examples
    --------

    An example app definition.

    >>> from scinexus.composable import define_app

    >>> @define_app
    ... class noop:
    ...     def main(self, data: int) -> int:
    ...         return data

    Notes
    -----

    Instances of scinexus apps are callable. If an exception occurs,
    the app returns a ``NotCompleted`` instance with logging information.
    Apps defined with app_type ``LOADER``, ``GENERIC`` or ``WRITER`` can be
    "composed" (summed together) to produce a single callable that
    sequentially invokes the composed apps. For example, the independent
    usage of app instances ``app1`` and ``app2`` as

    ```python { notest }
    app2(app1(data))
    ```

    is equivalent to

    ```python { notest }
    combined = app1 + app2
    combined(data)
    ```

    The ``app_type`` attribute is used to constrain how apps can be composed.
    ``LOADER`` and ``WRITER`` are special cases. If included, a ``LOADER``
    must always be first, e.g.

    ```python { notest }
    app = a_loader + a_generic
    ```

    If included, a ``WRITER`` must always be last, e.g.

    ```python { notest }
    app = a_generic + a_writer
    ```

    Changing the order for either of the above will result in a ``TypeError``.

    There are no constraints on ordering of ``GENERIC`` aside from compatability of
    their input and return types (see below).

    In order to be decorated with ``@define_app`` a class **must**

    - implement a method called ``main``
    - type hint the first argument of ``main``
    - type hint the return type for ``main``

    While you can have more than one argument in ``main``, this is currently not
    supported in composable apps.

    Overlap between the return type hint and first argument hint is required
    for two apps to be composed together.

    ``define_app`` adds a ``__call__`` method which checks an input value prior
    to passing it to ``app.main()`` as a positional argument. The data checking
    results in ``NotCompleted`` being returned immediately, unless
    ``skip_not_completed==False``. If the input value type is consistent with
    the type hint on the first argument of main it is passed to ``app.main()``.
    If it does not match, a new ``NotCompleted`` instance is returned.
    """

    if hasattr(klass, "app_type"):
        msg = (
            f"The class {klass.__name__!r} is already decorated, avoid using "  # type: ignore[union-attr]
            "inheritance from a decorated class."
        )
        raise TypeError(
            msg,
        )

    app_type = AppType(app_type)

    def wrapped(klass: type[Any] | Callable[..., Any]) -> type[Any]:
        if inspect.isfunction(klass):
            klass = _class_from_func(klass)
        if not inspect.isclass(klass):
            msg = f"{klass} is not a class"
            raise ValueError(msg)

        # Select base class based on app_type
        base: type[AppBase[Any, Any]]
        if app_type is WRITER:
            base = WriterApp
        elif app_type is not NON_COMPOSABLE:
            base = ComposableApp
        else:
            base = AppBase

        if "__slots__" in klass.__dict__:
            msg = "slots are not currently supported"
            raise NotImplementedError(msg)

        # Get type hints before rebuilding the class
        raw_input, raw_return = _get_main_hints(klass)

        # Collect the user's class dict (excluding metaclass artefacts)
        original_dict = {
            k: v
            for k, v in klass.__dict__.items()
            if k not in ("__dict__", "__weakref__")
        }
        # Prevent __init_subclass__ from running setup (we do it below)
        original_dict["_define_app_pending"] = True

        # Preserve user-specified bases (excluding object) so super()
        # resolves through the original MRO
        extra_bases = tuple(b for b in klass.__bases__ if b is not object)

        # Recreate class with the base (types.new_class stores
        # parameterised form in __orig_bases__ for type checkers)
        new_klass = types.new_class(
            klass.__name__,
            (base[raw_input, raw_return], *extra_bases),  # type: ignore[index]
            exec_body=lambda ns: ns.update(original_dict),
        )
        new_klass.__module__ = klass.__module__
        new_klass.__qualname__ = klass.__qualname__
        _fix_super_class_cells(klass, new_klass)
        del new_klass._define_app_pending  # type: ignore[attr-defined]

        # Run setup once with the decorator's arguments
        _init_subclass_setup(new_klass, app_type, skip_not_completed, cite)

        return new_klass

    return wrapped(klass) if klass else wrapped  # type: ignore[return-value]


def is_app_composable(obj: object) -> bool:
    """checks whether obj has been decorated by define_app and it's app_type attribute is not NON_COMPOSABLE"""
    return is_app(obj) and obj.app_type is not NON_COMPOSABLE  # type: ignore[attr-defined]


def is_app(obj: object) -> bool:
    """checks whether obj has been decorated by define_app"""
    return hasattr(obj, "app_type")


@register_deserialiser(get_object_provenance(NotCompleted))
def deserialise_not_completed(data: dict[str, Any]) -> NotCompleted:
    """deserialising NotCompletedResult"""
    data.pop("version", None)
    init = data.pop("not_completed_construction")
    args = init.pop("args")
    kwargs = init.pop("kwargs")
    return NotCompleted(*args, **kwargs)
