"""Class-based pipeline task runtime.

Rules enforced at class definition time for concrete tasks:
1. Subclasses must define ``@classmethod async def run(cls, ...)`` or inherit one from a parent task.
2. Every ``run()`` parameter after ``cls`` must use a supported annotation.
3. ``run()`` must return ``Document``, ``None``, ``list[Document]``, ``tuple[Document, ...]``, or unions of those shapes.
4. Bare ``Document`` is forbidden in both inputs and outputs; use concrete subclasses.
5. Class names must not start with ``Test``.
6. ``estimated_minutes`` must be >= 1, ``retries`` >= 0, and ``timeout_seconds`` positive when set.

Classes that explicitly declare ``_abstract_task = True`` skip definition-time validation.
Concrete subclasses of those abstract bases are validated normally.

Runtime behavior:
1. ``Task.run(...)`` returns an awaitable ``TaskHandle``.
2. ``await Task.run(...)`` executes the full lifecycle: retries, timeout, events, persistence, summaries, and replay capture.
3. Tasks must run inside an active pipeline execution context (or ``pipeline_test_context()`` in tests).
"""

import asyncio
import contextlib
import inspect
import math
import time
from collections.abc import Awaitable, Callable, Mapping, Sequence
from datetime import timedelta
from functools import update_wrapper
from types import MappingProxyType
from typing import Any, ClassVar, cast
from uuid import UUID, uuid7

from ai_pipeline_core._lifecycle_events import DocumentRef, TaskCompletedEvent, TaskFailedEvent, TaskStartedEvent
from ai_pipeline_core._llm_core import CoreMessage, Role
from ai_pipeline_core._llm_core import generate as core_generate
from ai_pipeline_core.database import SpanKind, SpanRecord, SpanStatus
from ai_pipeline_core.database._documents import load_documents_from_database
from ai_pipeline_core.documents import Document
from ai_pipeline_core.logger import get_pipeline_logger
from ai_pipeline_core.pipeline._execution_context import (
    ExecutionContext,
    FlowFrame,
    TaskContext,
    TaskFrame,
    _TaskDocumentContext,
    get_execution_context,
    get_sinks,
    record_lifecycle_event,
    set_execution_context,
    set_task_context,
)
from ai_pipeline_core.pipeline._parallel import TaskHandle
from ai_pipeline_core.pipeline._task_cache import build_task_cache_key, build_task_description
from ai_pipeline_core.pipeline._task_runtime import (
    _attach_task_attempt,
    _class_name,
    _get_task_attempt,
    _input_documents,
    _maybe_with_timeout,
    _ordered_unique_document_types,
    _persist_documents_to_database,
    _TaskRunSpec,
)
from ai_pipeline_core.pipeline._track_span import track_span
from ai_pipeline_core.pipeline._type_validation import (
    resolve_type_hints,
    validate_task_argument_value,
    validate_task_input_annotation,
    validate_task_return_annotation,
)
from ai_pipeline_core.settings import settings

logger = get_pipeline_logger(__name__)

MILLISECONDS_PER_SECOND = 1000

EVENT_PUBLISH_EXCEPTIONS = (OSError, RuntimeError, ValueError, TypeError)
SUMMARY_GENERATION_EXCEPTIONS = (Exception,)
TASK_EXECUTION_EXCEPTIONS = (Exception, asyncio.CancelledError)
RETRY_CAPTURE_EXCEPTIONS = (Exception,)
SUMMARY_EXCERPT_MAX_CHARS = 6000
_SUMMARY_PROMPT = "Write a concise 1-2 sentence summary of document '{name}'. Focus on the main topic and purpose.\n\nExcerpt:\n{excerpt}"
DEFAULT_TASK_LOG_SUMMARY = {"total": 0, "warnings": 0, "errors": 0, "last_error": ""}

__all__ = ["PipelineTask"]


class PipelineTask:
    """Base class for pipeline tasks.

    Tasks are stateless units of work. Define ``run`` as a **@classmethod** because tasks
    carry no per-invocation instance state — all inputs arrive as arguments, all outputs
    are returned documents. The framework wraps ``run`` with retries, persistence,
    and event emission automatically.

    Set ``_abstract_task = True`` on an intermediate base class to skip ``run()``
    validation on that class. Concrete subclasses do not inherit that skip; they must
    define ``run()`` or inherit a validated implementation from a non-abstract parent.

    Minimal example::

        class SummarizeTask(PipelineTask):
            @classmethod
            async def run(cls, documents: tuple[ArticleDocument, ...]) -> tuple[SummaryDocument, ...]:
                conv = Conversation(model="gemini-3-flash").with_context(documents[0])
                conv = await conv.send("Summarize this article.")
                return (SummaryDocument.derive(derived_from=(documents[0],), name="summary.md", content=conv.content),)

    Calling ``await SummarizeTask.run((doc,))`` dispatches the full lifecycle. Calling without
    ``await`` returns a ``TaskHandle`` for parallel execution via ``collect_tasks``.
    """

    name: ClassVar[str]
    estimated_minutes: ClassVar[float] = 1.0
    retries: ClassVar[int] = 0
    retry_delay_seconds: ClassVar[int] = 20
    timeout_seconds: ClassVar[int | None] = None
    cacheable: ClassVar[bool] = False
    cache_version: ClassVar[int] = 1
    cache_ttl_seconds: ClassVar[int | None] = None
    _abstract_task: ClassVar[bool] = False
    BASE_COST_USD: ClassVar[float] = 0.0
    expected_cost: ClassVar[float | None] = None

    input_document_types: ClassVar[list[type[Document]]] = []
    output_document_types: ClassVar[list[type[Document]]] = []
    _run_spec: ClassVar[_TaskRunSpec]

    def __init_subclass__(cls, **kwargs: Any) -> None:
        super().__init_subclass__(**kwargs)
        if cls is PipelineTask:
            return
        if cls.__dict__.get("_abstract_task", False) is True:
            return

        cls._validate_class_config()

        own_run = cls.__dict__.get("run")
        if own_run is None:
            inherited_spec = getattr(cls, "_run_spec", None)
            if inherited_spec is None:
                raise TypeError(f"PipelineTask '{cls.__name__}' must define @classmethod async def run(cls, ...) or inherit a validated run() implementation.")
            cls.input_document_types = list(inherited_spec.input_document_types)
            cls.output_document_types = list(inherited_spec.output_document_types)
            return

        spec = cls._validate_run_signature(own_run)
        cls._run_spec = spec
        cls.input_document_types = list(spec.input_document_types)
        cls.output_document_types = list(spec.output_document_types)
        cls.run = classmethod(cls._build_run_wrapper(spec))

    @classmethod
    def _validate_class_config(cls) -> None:
        if cls.__name__.startswith("Test"):
            raise TypeError(
                f"PipelineTask class name cannot start with 'Test': {cls.__name__}. Use a production-style class name; pytest classes reserve the Test* prefix."
            )
        if "name" not in cls.__dict__:
            cls.name = cls.__name__
        if cls.estimated_minutes < 1:
            raise TypeError(f"PipelineTask '{cls.__name__}' has estimated_minutes={cls.estimated_minutes}. Use a value >= 1.")
        if not math.isfinite(cls.BASE_COST_USD) or cls.BASE_COST_USD < 0:
            raise TypeError(
                f"PipelineTask '{cls.__name__}' has BASE_COST_USD={cls.BASE_COST_USD}. "
                "Use a finite float >= 0 representing the minimum cost in USD per execution."
            )
        if cls.retries < 0:
            raise TypeError(f"PipelineTask '{cls.__name__}' has retries={cls.retries}. Use a value >= 0.")
        if cls.timeout_seconds is not None and cls.timeout_seconds <= 0:
            raise TypeError(f"PipelineTask '{cls.__name__}' has timeout_seconds={cls.timeout_seconds}. Use a positive integer timeout or None.")
        if cls.cache_version < 1:
            raise TypeError(f"PipelineTask '{cls.__name__}' has cache_version={cls.cache_version}. Use an integer >= 1.")
        if cls.cache_ttl_seconds is not None and cls.cache_ttl_seconds <= 0:
            raise TypeError(f"PipelineTask '{cls.__name__}' has cache_ttl_seconds={cls.cache_ttl_seconds}. Use a positive integer number of seconds or None.")

    @classmethod
    def _validate_run_signature(cls, run_descriptor: Any) -> _TaskRunSpec:
        if not isinstance(run_descriptor, classmethod):
            raise TypeError(f"PipelineTask '{cls.__name__}'.run must be declared with @classmethod.")

        descriptor = cast(object, run_descriptor)
        descriptor_func = getattr(descriptor, "__func__", None)
        if descriptor_func is None or not callable(descriptor_func):
            raise TypeError(f"PipelineTask '{cls.__name__}'.run descriptor is invalid. Declare it as @classmethod async def run(cls, ...).")

        user_run = cast(Callable[..., Awaitable[Any]], descriptor_func)
        if not inspect.iscoroutinefunction(user_run):
            raise TypeError(f"PipelineTask '{cls.__name__}'.run must be async def. Use async operations in task code and return Documents.")

        signature = inspect.signature(user_run)
        parameters = list(signature.parameters.values())
        if not parameters:
            raise TypeError(f"PipelineTask '{cls.__name__}'.run must accept 'cls' as the first parameter.")
        if parameters[0].name != "cls":
            raise TypeError(
                f"PipelineTask '{cls.__name__}'.run must use signature @classmethod async def run(cls, ...). Found first parameter '{parameters[0].name}'."
            )

        hints = resolve_type_hints(user_run)
        input_document_types: list[type[Document]] = []
        for parameter in parameters[1:]:
            annotation = hints.get(parameter.name)
            if annotation is None:
                raise TypeError(
                    f"PipelineTask '{cls.__name__}'.run parameter '{parameter.name}' is missing a type annotation. Annotate every task input explicitly."
                )
            input_document_types.extend(
                validate_task_input_annotation(
                    annotation,
                    task_name=cls.__name__,
                    parameter_name=parameter.name,
                )
            )

        return_annotation = hints.get("return")
        if return_annotation is None:
            raise TypeError(
                f"PipelineTask '{cls.__name__}'.run is missing a return annotation. "
                "Return Document, None, list[Document], tuple[Document, ...], or unions of those shapes."
            )
        output_document_types = validate_task_return_annotation(return_annotation, task_name=cls.__name__)

        return _TaskRunSpec(
            user_run=user_run,
            signature=signature,
            hints=MappingProxyType(hints),
            input_document_types=_ordered_unique_document_types(input_document_types),
            output_document_types=_ordered_unique_document_types(output_document_types),
        )

    @classmethod
    def _public_signature(cls) -> inspect.Signature:
        parameters = tuple(cls._run_spec.signature.parameters.values())[1:]
        return cls._run_spec.signature.replace(parameters=parameters)

    @classmethod
    def _bind_run_arguments(cls, args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]:
        try:
            bound = cls._run_spec.signature.bind(cls, *args, **kwargs)
        except TypeError as exc:
            raise TypeError(f"PipelineTask '{cls.__name__}.run' called with invalid arguments. Expected signature {cls._public_signature()}: {exc}") from exc

        bound.apply_defaults()
        arguments = {name: value for name, value in bound.arguments.items() if name != "cls"}
        for name, value in arguments.items():
            validate_task_argument_value(
                task_name=cls.__name__,
                parameter_name=name,
                value=value,
                annotation=cls._run_spec.hints[name],
            )
        return arguments

    @classmethod
    def _build_run_wrapper(cls, spec: _TaskRunSpec) -> Callable[..., TaskHandle[tuple[Document[Any], ...]]]:
        def wrapped(task_cls: type[PipelineTask], *args: Any, **kwargs: Any) -> TaskHandle[tuple[Document[Any], ...]]:
            arguments = task_cls._bind_run_arguments(args, kwargs)
            try:
                asyncio.get_running_loop()
            except RuntimeError as exc:
                raise RuntimeError(
                    f"PipelineTask '{task_cls.__name__}.run' must be called from async code. Use `await Task.run(...)` inside a flow or test context."
                ) from exc

            execution_ctx = get_execution_context()
            if execution_ctx is None:
                raise RuntimeError(
                    f"PipelineTask '{task_cls.__name__}.run' called outside pipeline execution context. "
                    "Run tasks inside PipelineFlow/PipelineDeployment execution or pipeline_test_context()."
                )

            task = asyncio.create_task(task_cls._execute_invocation(arguments))
            handle = TaskHandle(
                _task=task,
                task_class=task_cls,
                input_arguments=MappingProxyType(dict(arguments)),
            )
            execution_ctx.active_task_handles.add(handle)
            task.add_done_callback(lambda _finished: execution_ctx.active_task_handles.discard(handle))
            return handle

        wrapped.__name__ = spec.user_run.__name__
        wrapped.__qualname__ = spec.user_run.__qualname__
        wrapped.__doc__ = spec.user_run.__doc__
        wrapped.__signature__ = cls._public_signature()  # type: ignore[attr-defined]
        return update_wrapper(wrapped, spec.user_run)

    @classmethod
    async def _persist_documents(
        cls,
        documents: tuple[Document, ...],
    ) -> tuple[Document, ...]:
        """Deduplicate and persist documents to the database."""
        deduped = _TaskDocumentContext.deduplicate(list(documents))
        if not deduped:
            return ()

        execution_ctx = get_execution_context()
        if execution_ctx is not None:
            await _persist_documents_to_database(deduped, execution_ctx.database)

        return tuple(deduped)

    @classmethod
    async def _run_with_retries(cls, arguments: Mapping[str, Any]) -> tuple[tuple[Document, ...], int]:
        attempts = cls.retries + 1
        last_error: Exception | None = None

        for attempt in range(attempts):
            outcome = (
                await asyncio.gather(
                    _maybe_with_timeout(
                        cls.timeout_seconds,
                        lambda: cls._run_and_normalize(arguments),
                    ),
                    return_exceptions=True,
                )
            )[0]
            if isinstance(outcome, RETRY_CAPTURE_EXCEPTIONS):
                last_error = outcome
                if attempt < attempts - 1:
                    await asyncio.sleep(cls.retry_delay_seconds)
                continue
            if isinstance(outcome, BaseException):
                raise _attach_task_attempt(outcome, attempt)
            return outcome, attempt

        if last_error is None:
            raise RuntimeError(f"PipelineTask '{cls.__name__}' failed without raising a concrete exception.")
        raise _attach_task_attempt(last_error, attempts - 1)

    @classmethod
    async def _run_and_normalize(cls, arguments: Mapping[str, Any]) -> tuple[Document, ...]:
        result = await cls._run_spec.user_run(cls, **dict(arguments))
        return cls._normalize_result_documents(result)

    @classmethod
    def _task_cache_ttl(cls, execution_ctx: ExecutionContext) -> timedelta | None:
        if execution_ctx.disable_cache:
            return None
        if cls.cache_ttl_seconds is not None:
            return timedelta(seconds=cls.cache_ttl_seconds)
        return execution_ctx.cache_ttl

    @classmethod
    async def _load_cached_outputs(
        cls,
        database: Any,
        output_document_shas: tuple[str, ...],
    ) -> tuple[Document, ...] | None:
        if not output_document_shas:
            logger.warning(
                "Task cache span for '%s' has no output_document_shas. Persist cached task outputs on the completed span before expecting cache reuse.",
                cls.__name__,
            )
            return None
        documents = await load_documents_from_database(
            database,
            set(output_document_shas),
            filter_types=list(cls.output_document_types) if cls.output_document_types else None,
        )
        documents_by_sha = {str(document.sha256): document for document in documents}
        missing_shas = [sha for sha in output_document_shas if sha not in documents_by_sha]
        if missing_shas:
            logger.warning(
                "Task cache hit for '%s' could not hydrate cached documents %s. "
                "Persist every cached output document and blob before reusing task cache entries.",
                cls.__name__,
                ", ".join(missing_shas),
            )
            return None
        return tuple(documents_by_sha[sha] for sha in output_document_shas)

    @classmethod
    async def _reuse_cached_output(
        cls,
        *,
        arguments: Mapping[str, Any],
        execution_ctx: ExecutionContext,
        task_frame: TaskFrame,
        task_span_id: UUID,
        parent_span_id: UUID | None,
        task_description: str,
        input_docs: tuple[Document, ...],
        task_cache_key: str,
        task_cache_source_span: SpanRecord,
        database: Any,
    ) -> tuple[Document, ...] | None:
        cached_documents = await cls._load_cached_outputs(database, task_cache_source_span.output_document_shas)
        if cached_documents is None:
            return None

        start_time = time.monotonic()
        output_refs = cls._build_output_refs(cached_documents)
        flow_step = execution_ctx.flow_frame.step if execution_ctx.flow_frame is not None else 0
        cached_input_sha256s = tuple(doc.sha256 for doc in input_docs)
        await cls._emit_task_completed(
            execution_ctx,
            execution_ctx.flow_frame,
            step=flow_step,
            task_name=cls.name,
            task_class_name=cls.__name__,
            span_id=str(task_span_id),
            start_time=start_time,
            output_documents=output_refs,
            status=str(SpanStatus.CACHED),
            input_document_sha256s=cached_input_sha256s,
        )
        record_lifecycle_event(
            "task.cached",
            f"Reused cached task output for {cls.name}",
            task_name=cls.name,
            task_class=cls.__name__,
            flow_name=execution_ctx.flow_frame.name if execution_ctx.flow_frame is not None else "",
            step=flow_step,
            cache_key=task_cache_key,
            cache_source_span_id=str(task_cache_source_span.span_id),
        )
        with contextlib.ExitStack() as cached_stack:
            cached_stack.enter_context(set_execution_context(execution_ctx.with_task(task_frame)))
            async with track_span(
                SpanKind.TASK,
                cls.name,
                f"classmethod:{cls.__module__}:{cls.__qualname__}.run",
                sinks=get_sinks(),
                span_id=task_span_id,
                parent_span_id=parent_span_id,
                encode_receiver={"mode": "constructor_args", "value": {"task_class": cls}},
                encode_input=dict(arguments),
                db=database,
                input_preview={
                    "task_class": cls.__name__,
                    "input_documents": [doc.name for doc in input_docs],
                },
            ) as span_ctx:
                span_ctx.set_status(SpanStatus.CACHED)
                span_ctx.set_meta(
                    attempt=0,
                    retries=cls.retries,
                    retry_delay_seconds=cls.retry_delay_seconds,
                    timeout_seconds=cls.timeout_seconds,
                    cache_hit=True,
                    cache_key=task_cache_key,
                    cache_source_span_id=str(task_cache_source_span.span_id),
                    description=task_description,
                )
                span_ctx.set_output_preview({"documents": [doc.name for doc in cached_documents]})
                span_ctx._set_output_value(cached_documents)
        return cached_documents

    @classmethod
    def _normalize_result_documents(cls, result: Any) -> tuple[Document[Any], ...]:
        if result is None:
            return ()
        if isinstance(result, Document):
            raw_items = cast(Sequence[Any], (result,))
        elif isinstance(result, (list, tuple)):
            raw_items = cast(Sequence[Any], result)
        else:
            raise TypeError(
                f"PipelineTask '{cls.__name__}' returned {type(result).__name__}. "
                "run() must return Document, None, list[Document], tuple[Document, ...], or unions of those shapes."
            )

        normalized_docs: list[Document[Any]] = []
        bad_types: set[str] = set()
        for item in raw_items:
            if isinstance(item, Document):
                normalized_docs.append(cast(Document[Any], item))
                continue
            bad_types.add(_class_name(type(item)))

        if bad_types:
            bad_types_text = ", ".join(sorted(bad_types))
            raise TypeError(f"PipelineTask '{cls.__name__}' returned non-Document items ({bad_types_text}). run() must return only Document subclasses.")
        return tuple(normalized_docs)

    @staticmethod
    async def _emit_task_started(
        execution_ctx: ExecutionContext,
        flow_frame: FlowFrame | None,
        *,
        step: int,
        task_name: str,
        task_class_name: str,
        span_id: str,
        input_document_sha256s: tuple[str, ...] = (),
    ) -> None:
        if flow_frame is None:
            return
        try:
            await execution_ctx.publisher.publish_task_started(
                TaskStartedEvent(
                    run_id=execution_ctx.run_id,
                    span_id=span_id,
                    root_deployment_id=str(execution_ctx.root_deployment_id or ""),
                    parent_deployment_task_id=str(execution_ctx.parent_deployment_task_id) if execution_ctx.parent_deployment_task_id else None,
                    flow_name=flow_frame.name,
                    step=step,
                    total_steps=flow_frame.total_steps,
                    status=str(SpanStatus.RUNNING),
                    task_name=task_name,
                    task_class=task_class_name,
                    parent_span_id=str(execution_ctx.flow_span_id or ""),
                    input_document_sha256s=input_document_sha256s,
                )
            )
        except EVENT_PUBLISH_EXCEPTIONS as exc:
            logger.warning("Task started event publish failed for '%s': %s", task_name, exc)

    @staticmethod
    async def _emit_task_completed(
        execution_ctx: ExecutionContext,
        flow_frame: FlowFrame | None,
        *,
        step: int,
        task_name: str,
        task_class_name: str,
        span_id: str,
        start_time: float,
        output_documents: tuple[DocumentRef, ...],
        status: str = str(SpanStatus.COMPLETED),
        input_document_sha256s: tuple[str, ...] = (),
    ) -> None:
        if flow_frame is None:
            return
        try:
            await execution_ctx.publisher.publish_task_completed(
                TaskCompletedEvent(
                    run_id=execution_ctx.run_id,
                    span_id=span_id,
                    root_deployment_id=str(execution_ctx.root_deployment_id or ""),
                    parent_deployment_task_id=str(execution_ctx.parent_deployment_task_id) if execution_ctx.parent_deployment_task_id else None,
                    flow_name=flow_frame.name,
                    step=step,
                    total_steps=flow_frame.total_steps,
                    status=status,
                    task_name=task_name,
                    task_class=task_class_name,
                    duration_ms=int((time.monotonic() - start_time) * MILLISECONDS_PER_SECOND),
                    output_documents=output_documents,
                    parent_span_id=str(execution_ctx.flow_span_id or ""),
                    input_document_sha256s=input_document_sha256s,
                )
            )
        except EVENT_PUBLISH_EXCEPTIONS as exc:
            logger.warning("Task completed event publish failed for '%s': %s", task_name, exc)

    @staticmethod
    async def _emit_task_failed(
        execution_ctx: ExecutionContext,
        flow_frame: FlowFrame | None,
        *,
        step: int,
        task_name: str,
        task_class_name: str,
        span_id: str,
        error_message: str,
        input_document_sha256s: tuple[str, ...] = (),
    ) -> None:
        if flow_frame is None:
            return
        try:
            await execution_ctx.publisher.publish_task_failed(
                TaskFailedEvent(
                    run_id=execution_ctx.run_id,
                    span_id=span_id,
                    root_deployment_id=str(execution_ctx.root_deployment_id or ""),
                    parent_deployment_task_id=str(execution_ctx.parent_deployment_task_id) if execution_ctx.parent_deployment_task_id else None,
                    flow_name=flow_frame.name,
                    step=step,
                    total_steps=flow_frame.total_steps,
                    status=str(SpanStatus.FAILED),
                    task_name=task_name,
                    task_class=task_class_name,
                    error_message=error_message,
                    parent_span_id=str(execution_ctx.flow_span_id or ""),
                    input_document_sha256s=input_document_sha256s,
                )
            )
        except EVENT_PUBLISH_EXCEPTIONS as exc:
            logger.warning("Task failed event publish failed for '%s': %s", task_name, exc)

    @staticmethod
    async def _generate_summaries(documents: Sequence[Document]) -> None:
        """Generate missing summaries via LLM and set them directly on documents."""
        for document in documents:
            if document.summary:
                continue
            if not settings.doc_summary_enabled or not document.is_text:
                continue
            try:
                excerpt = document.text[:SUMMARY_EXCERPT_MAX_CHARS]
                response = await core_generate(
                    [CoreMessage(role=Role.USER, content=_SUMMARY_PROMPT.format(name=document.name, excerpt=excerpt))],
                    model=settings.doc_summary_model,
                    purpose="doc_summary",
                )
                generated = response.content.strip()
                if generated:
                    object.__setattr__(document, "summary", generated)
            except SUMMARY_GENERATION_EXCEPTIONS as exc:
                logger.warning("Inline summary generation failed for '%s': %s", document.name, exc)

    @staticmethod
    def _build_output_refs(documents: Sequence[Document]) -> tuple[DocumentRef, ...]:
        return tuple(
            DocumentRef(
                sha256=document.sha256,
                class_name=type(document).__name__,
                name=document.name,
                summary=document.summary,
                publicly_visible=getattr(type(document), "publicly_visible", False),
                derived_from=tuple(document.derived_from),
                triggered_by=tuple(document.triggered_by),
            )
            for document in documents
        )

    @classmethod
    async def _execute_lifecycle(
        cls,
        arguments: Mapping[str, Any],
        *,
        execution_ctx: ExecutionContext,
        flow_frame: FlowFrame | None,
        task_name: str,
        span_id: str,
        flow_step: int,
        start_time: float,
        input_document_sha256s: tuple[str, ...] = (),
    ) -> tuple[tuple[Document, ...], int]:
        """Execute task lifecycle with events and persistence."""
        await cls._emit_task_started(
            execution_ctx,
            flow_frame,
            step=flow_step,
            task_name=task_name,
            task_class_name=cls.__name__,
            span_id=span_id,
            input_document_sha256s=input_document_sha256s,
        )
        record_lifecycle_event(
            "task.started",
            f"Starting task {task_name}",
            task_name=task_name,
            task_class=cls.__name__,
            flow_name=flow_frame.name if flow_frame is not None else "",
            step=flow_step,
        )
        try:
            documents, attempt = await cls._run_with_retries(arguments)

            await cls._generate_summaries(documents)
            persisted_docs = await cls._persist_documents(documents)

            await cls._emit_task_completed(
                execution_ctx,
                flow_frame,
                step=flow_step,
                task_name=task_name,
                task_class_name=cls.__name__,
                span_id=span_id,
                start_time=start_time,
                output_documents=cls._build_output_refs(persisted_docs),
                input_document_sha256s=input_document_sha256s,
            )
            record_lifecycle_event(
                "task.completed",
                f"Completed task {task_name}",
                task_name=task_name,
                task_class=cls.__name__,
                flow_name=flow_frame.name if flow_frame is not None else "",
                step=flow_step,
                output_count=len(persisted_docs),
            )
            return persisted_docs, attempt
        except TASK_EXECUTION_EXCEPTIONS as exc:
            await cls._emit_task_failed(
                execution_ctx,
                flow_frame,
                step=flow_step,
                task_name=task_name,
                task_class_name=cls.__name__,
                span_id=span_id,
                error_message=str(exc),
                input_document_sha256s=input_document_sha256s,
            )
            record_lifecycle_event(
                "task.failed",
                f"Task {task_name} failed",
                task_name=task_name,
                task_class=cls.__name__,
                flow_name=flow_frame.name if flow_frame is not None else "",
                step=flow_step,
                error_type=type(exc).__name__,
                error_message=str(exc),
            )
            raise

    @classmethod
    async def _execute_invocation(cls, arguments: Mapping[str, Any]) -> tuple[Document, ...]:
        """Execute task lifecycle with events, summaries, and persistence."""
        execution_ctx = get_execution_context()
        if execution_ctx is None:
            raise RuntimeError(
                f"PipelineTask '{cls.__name__}.run' called outside pipeline execution context. "
                "Run tasks inside PipelineFlow/PipelineDeployment execution or pipeline_test_context()."
            )

        parent_task = execution_ctx.task_frame
        task_span_id = uuid7()
        task_function_path = f"{cls.__module__}:{cls.__qualname__}"
        task_frame = TaskFrame(
            task_class_name=cls.__name__,
            task_id=str(task_span_id),
            depth=(parent_task.depth + 1) if parent_task else 0,
            parent=parent_task,
        )

        database = execution_ctx.database
        input_docs = _input_documents(arguments)
        parent_span_id = execution_ctx.current_span_id or execution_ctx.span_id
        task_description = build_task_description(arguments)
        task_cache_key = ""
        task_cache_source_span: SpanRecord | None = None
        if cls.cacheable:
            task_cache_ttl = cls._task_cache_ttl(execution_ctx)
            if database is not None and task_cache_ttl is not None:
                task_cache_key = build_task_cache_key(
                    task_class_path=task_function_path,
                    cache_version=cls.cache_version,
                    arguments=arguments,
                )
                task_cache_source_span = await cast(Any, database).get_cached_completion(task_cache_key, max_age=task_cache_ttl)
                if task_cache_source_span is not None:
                    cached_documents = await cls._reuse_cached_output(
                        arguments=arguments,
                        execution_ctx=execution_ctx,
                        task_frame=task_frame,
                        task_span_id=task_span_id,
                        parent_span_id=parent_span_id,
                        task_description=task_description,
                        input_docs=tuple(input_docs),
                        task_cache_key=task_cache_key,
                        task_cache_source_span=task_cache_source_span,
                        database=database,
                    )
                    if cached_documents is not None:
                        return cached_documents

        task_exec_ctx = execution_ctx.with_task(task_frame)
        task_ctx = TaskContext(task_class_name=cls.__name__)

        start_time = time.monotonic()
        task_name = cls.name
        flow_frame = execution_ctx.flow_frame
        input_doc_names = [doc.name for doc in input_docs]
        task_target = f"classmethod:{cls.__module__}:{cls.__qualname__}.run"
        task_receiver = {"mode": "constructor_args", "value": {"task_class": cls}}
        task_input_preview = {
            "task_class": cls.__name__,
            "input_documents": input_doc_names,
        }

        with contextlib.ExitStack() as stack:
            stack.enter_context(set_execution_context(task_exec_ctx))
            stack.enter_context(set_task_context(task_ctx))
            task_attempt = 0

            async with track_span(
                SpanKind.TASK,
                task_name,
                task_target,
                sinks=get_sinks(),
                span_id=task_span_id,
                parent_span_id=parent_span_id,
                encode_receiver=task_receiver,
                encode_input=dict(arguments),
                db=database,
                input_preview=task_input_preview,
            ) as span_ctx:
                if cls.BASE_COST_USD > 0:
                    span_ctx._add_cost(cls.BASE_COST_USD)
                span_ctx.set_meta(
                    attempt=task_attempt,
                    retries=cls.retries,
                    retry_delay_seconds=cls.retry_delay_seconds,
                    timeout_seconds=cls.timeout_seconds,
                    cache_hit=False,
                    description=task_description,
                    cache_key=task_cache_key,
                )
                try:
                    input_sha256s = tuple(doc.sha256 for doc in input_docs)
                    result, task_attempt = await cls._execute_lifecycle(
                        arguments,
                        execution_ctx=execution_ctx,
                        flow_frame=flow_frame,
                        task_name=task_name,
                        span_id=str(span_ctx.span_id),
                        flow_step=flow_frame.step if flow_frame is not None else 0,
                        start_time=start_time,
                        input_document_sha256s=input_sha256s,
                    )
                except TASK_EXECUTION_EXCEPTIONS as exc:
                    task_attempt = _get_task_attempt(exc)
                    span_ctx.set_meta(
                        attempt=task_attempt,
                        retries=cls.retries,
                        retry_delay_seconds=cls.retry_delay_seconds,
                        timeout_seconds=cls.timeout_seconds,
                        cache_hit=False,
                        description=task_description,
                        cache_key=task_cache_key,
                    )
                    raise

                span_ctx.set_meta(
                    attempt=task_attempt,
                    retries=cls.retries,
                    retry_delay_seconds=cls.retry_delay_seconds,
                    timeout_seconds=cls.timeout_seconds,
                    cache_hit=False,
                    description=task_description,
                    cache_key=task_cache_key,
                )
                span_ctx.set_output_preview({"documents": [doc.name for doc in result]})
                span_ctx._set_output_value(result)
                return result
