"""Execution runtime and plan orchestration for prompt batches."""

from __future__ import annotations

import asyncio
import json
import signal
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import jsonschema
from jsonschema.exceptions import SchemaError, ValidationError

from dotpromptz.adapters import get_adapter, list_adapters
from dotpromptz.adapters._base import Adapter, GenerateResponse
from dotpromptz.compiler import compile_prompt
from dotpromptz.credentials import CredentialPool, load_credentials
from dotpromptz.errors import ExecutionConfigError
from dotpromptz.logging import get_logger
from dotpromptz.planner import ExecutionPlan, plan_prompt
from dotpromptz.typing import (
    AdapterConfig,
    DataPart,
    MediaPart,
    PromptOutputConfig,
    RenderedPrompt,
    RetryConfig,
    RuntimeConfig,
    TextPart,
)
from dotpromptz.yamlio import dump_yaml

logger = get_logger(__name__)

_STRUCTURED_OUTPUT_ERROR_PREVIEW_CHARS = 4000


def _resolve_output_extension(fmt: str) -> str:
    """Resolve output file extension from ``output.format``."""
    if fmt == 'image':
        return 'png'
    return fmt


@dataclass
class BatchResult:
    """Result of a single item within batch execution."""

    index: int
    input: dict[str, Any]
    status: str  # 'success' | 'error'
    prompt_file: Path | None = field(default=None)
    trace: tuple[int, ...] = field(default_factory=tuple)
    response: GenerateResponse | None = field(default=None)
    error: str | None = field(default=None)
    attempts: int = field(default=1)
    elapsed: float = field(default=0.0)
    item_no: int = field(default=0)
    total_items: int = field(default=0)
    completed_items: int = field(default=0)


@dataclass
class _ExecutionState:
    """Shared execution state across a chained prompt tree."""

    abort_event: asyncio.Event
    fatal_error: Exception | None = None


def _parse_structured_json_text(text: str) -> Any:
    """Parse structured model output text as JSON."""
    try:
        return json.loads(text)
    except json.JSONDecodeError as exc:
        raw_preview = _preview_value(text)
        raise ValueError(
            'Structured output requires valid JSON text from the model. '
            f'JSON parse error at line {exc.lineno}, column {exc.colno}: {exc.msg}. '
            f'Raw model text: {raw_preview}'
        ) from exc
    except TypeError as exc:
        raw_preview = _preview_value(text)
        raise ValueError(
            'Structured output requires valid JSON text from the model. '
            f'Received non-text response. Raw model text: {raw_preview}'
        ) from exc


def _preview_value(value: Any, *, max_chars: int = _STRUCTURED_OUTPUT_ERROR_PREVIEW_CHARS) -> str:
    """Return a safe, length-limited string preview for debugging output."""
    try:
        rendered = json.dumps(value, ensure_ascii=False)
    except TypeError:
        rendered = repr(value)

    if len(rendered) <= max_chars:
        return rendered

    omitted = len(rendered) - max_chars
    return f'{rendered[:max_chars]}... [truncated {omitted} chars]'


def _validate_response_schema(response: GenerateResponse, output_config: PromptOutputConfig | None) -> None:
    """Validate model response text against ``output.schema`` when configured."""
    if output_config is None or output_config.schema_value is None:
        return
    if output_config.format not in ('json', 'yaml'):
        return
    if not response.text:
        raise ValueError('Schema validation failed: model returned no text content.')

    instance = _parse_structured_json_text(response.text)
    try:
        jsonschema.validate(instance=instance, schema=output_config.schema_value)
    except SchemaError as exc:
        raise ValueError(f'Invalid output.schema: {exc.message}') from exc
    except ValidationError as exc:
        path = '.'.join(str(part) for part in exc.path) or '<root>'
        raw_preview = _preview_value(response.text)
        invalid_value_preview = _preview_value(exc.instance)
        raise ValueError(
            f'Schema validation failed at {path}: {exc.message}. '
            f'Invalid value: {invalid_value_preview}. '
            f'Raw model text: {raw_preview}'
        ) from exc


def _validate_output_value_schema(value: Any, output_config: PromptOutputConfig | None) -> None:
    """Validate a fixed output value against ``output.schema`` when configured."""
    if output_config is None or output_config.schema_value is None:
        return
    if output_config.format not in ('json', 'yaml'):
        return

    try:
        jsonschema.validate(instance=value, schema=output_config.schema_value)
    except SchemaError as exc:
        raise ValueError(f'Invalid output.schema: {exc.message}') from exc
    except ValidationError as exc:
        path = '.'.join(str(part) for part in exc.path) or '<root>'
        invalid_value_preview = _preview_value(exc.instance)
        raw_preview = _preview_value(value)
        raise ValueError(
            f'Schema validation failed at {path}: {exc.message}. '
            f'Invalid value: {invalid_value_preview}. '
            f'Fixed output value: {raw_preview}'
        ) from exc


def _extract_fixed_output_content(output_config: PromptOutputConfig | None, fmt: str) -> str | None:
    """Return serialized fixed output content when configured."""
    if output_config is None or output_config.fixed is None:
        return None
    _validate_output_value_schema(output_config.fixed, output_config)
    return serialize_output(output_config.fixed, fmt)


def _compute_delay(attempt: int, retry_cfg: RetryConfig) -> float:
    """Compute delay before next retry attempt."""
    return retry_cfg.retry_delay * (attempt + 1)


def _make_adapter_for_attempt(
    attempt: int,
    adapter: Adapter,
    pool: CredentialPool | None,
) -> Adapter:
    """Return adapter for current retry attempt, rotating credentials when possible."""
    if attempt == 0 or pool is None:
        return adapter
    adapter_name = adapter._credential.adapter
    model_name: str | None = getattr(adapter, '_default_model', None)
    try:
        credential = pool.next(adapter_name, model=model_name)
        return get_adapter(adapter_name, credential=credential)
    except (ValueError, KeyError):
        return adapter


async def _execute_with_retry(
    coro_factory: Callable[[int], Any],
    retry_cfg: RetryConfig | None,
    timeout: float | None,
    abort_event: asyncio.Event | None = None,
    log_fields: dict[str, Any] | None = None,
) -> tuple[Any, int]:
    """Execute ``coro_factory(attempt)`` with optional retry and timeout."""
    if retry_cfg is not None:
        if retry_cfg.max_retries < 0:
            raise ValueError('retry.max_retries must be >= 0')
        if retry_cfg.retry_delay < 0:
            raise ValueError('retry.retry_delay must be >= 0')

    max_attempts = 1 + (retry_cfg.max_retries if retry_cfg else 0)
    last_exc: Exception | None = None

    for attempt in range(max_attempts):
        if abort_event is not None and abort_event.is_set():
            raise RuntimeError('Aborted before execution.')
        try:
            coro = coro_factory(attempt)
            if timeout is not None:
                result = await asyncio.wait_for(coro, timeout=timeout)
            else:
                result = await coro
            return result, attempt + 1
        except Exception as exc:
            last_exc = exc

            if retry_cfg is None:
                raise

            if attempt >= retry_cfg.max_retries:
                raise

            delay = _compute_delay(attempt, retry_cfg)
            logger.warning(
                'Retrying after error.',
                attempt=attempt + 1,
                max_retries=retry_cfg.max_retries,
                delay=f'{delay:.2f}s',
                error=str(exc),
                **(log_fields or {}),
            )
            if abort_event is None:
                await asyncio.sleep(delay)
            else:
                try:
                    await asyncio.wait_for(abort_event.wait(), timeout=delay)
                except TimeoutError:
                    pass
                else:
                    raise RuntimeError('Aborted during retry wait.') from exc

    raise last_exc  # type: ignore[misc]


async def execute_batch(
    rendered_prompts: list[RenderedPrompt],
    input_list: list[dict[str, Any]],
    adapter: Adapter,
    *,
    prompt_file: Path | None = None,
    trace_prefix: tuple[int, ...] = (),
    max_workers: int = 5,
    on_item_complete: Callable[[BatchResult], None] | None = None,
    runtime: RuntimeConfig | None = None,
    pool: CredentialPool | None = None,
    abort_event: asyncio.Event | None = None,
) -> list[BatchResult]:
    """Execute a rendered prompt batch with concurrency control."""
    if max_workers < 1:
        raise ValueError('max_workers must be >= 1')
    if len(rendered_prompts) != len(input_list):
        raise ValueError('rendered_prompts and input_list must have the same length')
    if not rendered_prompts:
        return []

    total_items = len(input_list)
    retry_cfg = runtime.retry if runtime else None
    timeout = runtime.timeout if runtime else None
    sem = asyncio.Semaphore(max_workers)

    async def _process_one(index: int, variables: dict[str, Any], rendered: RenderedPrompt) -> BatchResult:
        item_no = index + 1
        if abort_event is not None and abort_event.is_set():
            return BatchResult(
                index=index,
                input=variables,
                prompt_file=prompt_file,
                trace=trace_prefix + (index,),
                status='error',
                response=None,
                error='Aborted before execution.',
                attempts=0,
                elapsed=0.0,
                item_no=item_no,
                total_items=total_items,
            )

        async with sem:
            t0 = time.monotonic()
            attempts = 1
            try:
                if abort_event is not None and abort_event.is_set():
                    raise RuntimeError('Aborted before execution.')

                def _make_coro(attempt: int) -> Any:
                    current_adapter = _make_adapter_for_attempt(attempt, adapter, pool)

                    async def _generate_and_validate() -> GenerateResponse:
                        response = await current_adapter.generate(rendered)
                        _validate_response_schema(response, rendered.output)
                        return response

                    return _generate_and_validate()

                response, attempts = await _execute_with_retry(
                    _make_coro,
                    retry_cfg,
                    timeout,
                    abort_event=abort_event,
                    log_fields={
                        'index': index,
                        'item_no': item_no,
                        'total_items': total_items,
                    },
                )
                elapsed = time.monotonic() - t0
                return BatchResult(
                    index=index,
                    input=variables,
                    prompt_file=prompt_file,
                    trace=trace_prefix + (index,),
                    status='success',
                    response=response,
                    error=None,
                    attempts=attempts,
                    elapsed=elapsed,
                    item_no=item_no,
                    total_items=total_items,
                )
            except Exception as exc:
                elapsed = time.monotonic() - t0
                return BatchResult(
                    index=index,
                    input=variables,
                    prompt_file=prompt_file,
                    trace=trace_prefix + (index,),
                    status='error',
                    response=None,
                    error=str(exc),
                    attempts=attempts,
                    elapsed=elapsed,
                    item_no=item_no,
                    total_items=total_items,
                )

    async def _safe_process_one(index: int, variables: dict[str, Any], rendered: RenderedPrompt) -> BatchResult:
        try:
            return await _process_one(index, variables, rendered)
        except Exception as exc:
            return BatchResult(
                index=index,
                input=variables,
                prompt_file=prompt_file,
                trace=trace_prefix + (index,),
                status='error',
                response=None,
                error=str(exc),
                item_no=index + 1,
                total_items=total_items,
            )

    final: list[BatchResult] = []
    completed_items = 0
    tasks = [
        asyncio.create_task(_safe_process_one(index, variables, rendered_prompts[index]))
        for index, variables in enumerate(input_list)
    ]

    for task in asyncio.as_completed(tasks):
        result = await task
        completed_items += 1
        result.completed_items = completed_items
        if on_item_complete is not None:
            try:
                on_item_complete(result)
            except Exception as exc:
                final.append(
                    BatchResult(
                        index=result.index,
                        input=result.input,
                        prompt_file=result.prompt_file,
                        trace=result.trace,
                        status='error',
                        response=None,
                        error=str(exc),
                        attempts=result.attempts,
                        elapsed=result.elapsed,
                        item_no=result.item_no,
                        total_items=result.total_items,
                        completed_items=result.completed_items,
                    )
                )
                continue
        final.append(result)

    final.sort(key=lambda item: item.index)
    return final


def resolve_batch_adapter(rendered: RenderedPrompt, *, pool: CredentialPool | None = None) -> Adapter:
    """Resolve an adapter from a ``RenderedPrompt`` adapter field."""
    adapter_cfg = rendered.adapter
    adapter_name: str | None = None
    group: str | None = None
    groups: list[str] | None = None

    if isinstance(adapter_cfg, AdapterConfig):
        adapter_name = adapter_cfg.name
        group = adapter_cfg.group
        groups = adapter_cfg.groups
    elif isinstance(adapter_cfg, str):
        adapter_name = adapter_cfg

    if not adapter_name:
        available = ', '.join(list_adapters())
        raise ValueError(
            f'No adapter specified. Add "adapter: <name>" to the .prompt frontmatter. Available adapters: {available}'
        )

    model_name: str | None = None
    config = rendered.config if isinstance(rendered.config, dict) else {}
    if isinstance(config, dict):
        model_name = config.get('model')

    if pool is None:
        pool = load_credentials()

    credential = pool.next(adapter_name, group=group, groups=groups, model=model_name)
    return get_adapter(adapter_name, credential=credential)


def serialize_output(data: Any, fmt: str) -> str:
    """Serialize data into the target output format."""
    if fmt == 'json':
        if isinstance(data, str):
            try:
                data = json.loads(data)
            except (json.JSONDecodeError, TypeError):
                pass
        return json.dumps(data, indent=2, ensure_ascii=False)
    if fmt == 'yaml':
        if isinstance(data, str):
            try:
                data = json.loads(data)
            except (json.JSONDecodeError, TypeError):
                pass
        return dump_yaml(data, allow_unicode=True, default_flow_style=False, sort_keys=False)
    return str(data)


def part_to_output_dict(part: Any) -> dict[str, Any]:
    """Convert a content part model to a simple serializable dict."""
    if isinstance(part, TextPart):
        return {'text': part.text}
    if isinstance(part, MediaPart):
        return {'media': part.media.model_dump(exclude_none=True, by_alias=True)}
    if isinstance(part, DataPart):
        return {'data': part.data}
    return {'type': type(part).__name__}


def write_output_file(
    content: str | bytes,
    output_config: PromptOutputConfig,
    output_dir: Path,
    file_name: str,
    *,
    force: bool = False,
    extension_override: str | None = None,
) -> Path:
    """Write one result to disk using ``output.format`` as extension."""
    output_dir.mkdir(parents=True, exist_ok=True)
    ext = extension_override or _resolve_output_extension(output_config.format)
    filepath = output_dir / f'{file_name}.{ext}'
    if filepath.exists() and not force:
        raise FileExistsError(f'Output file already exists: {filepath}. Use --force to overwrite.')
    if isinstance(content, bytes):
        filepath.write_bytes(content)
    else:
        filepath.write_text(content, encoding='utf-8')
    return filepath


def extract_output_content(
    response: GenerateResponse,
    fmt: str,
    *,
    adapter_name: str | None = None,
) -> str | bytes:
    """Extract and serialize response payload for output."""
    if fmt == 'image':
        if not response.images:
            if adapter_name == 'anthropic':
                raise ValueError(
                    'Anthropic adapter does not support output.format=image. '
                    'Use adapter=openai or adapter=google for image generation.'
                )
            raise ValueError('Image output requires at least one generated image.')
        first = response.images[0]
        if first.mime_type.lower() != 'image/png':
            raise ValueError(f'Image output requires MIME image/png; got {first.mime_type!r}.')
        return first.data
    if response.text:
        return serialize_output(response.text, fmt)
    return ''


@dataclass(frozen=True)
class ExecutionReport:
    """Execution result for one planned run."""

    batch_results: list[BatchResult]
    written_files: list[Path]
    output_dir: Path

    @property
    def all_failed(self) -> bool:
        """Return whether every batch item failed."""
        return bool(self.batch_results) and all(item.status == 'error' for item in self.batch_results)


def _write_planned_output(
    content: str | bytes,
    output_config: PromptOutputConfig,
    output_dir: Path,
    stem: str,
    *,
    force: bool,
    extension_override: str | None = None,
) -> Path:
    """Write one planned output file."""
    return write_output_file(
        content,
        output_config,
        output_dir,
        stem,
        force=force,
        extension_override=extension_override,
    )


def _remember_fatal(state: _ExecutionState, exc: Exception) -> None:
    """Remember the first fatal execution error and abort remaining work."""
    if state.fatal_error is not None:
        return
    state.fatal_error = exc
    state.abort_event.set()


def _resolve_next_prompt_path(prompt_path: Path, next_ref: str) -> Path:
    """Resolve a ``runtime.next`` prompt path relative to the current prompt."""
    candidate = Path(next_ref)
    if candidate.is_absolute():
        return candidate
    return (prompt_path.parent / candidate).resolve()


async def _execute_plan_tree(
    plan: ExecutionPlan,
    state: _ExecutionState,
    *,
    trace_prefix: tuple[int, ...] = (),
) -> ExecutionReport:
    """Execute one plan and any chained descendants."""
    if not plan.items:
        return ExecutionReport(batch_results=[], written_files=[], output_dir=plan.output_dir)

    fixed_mode = any(item.rendered.output is not None and item.rendered.output.fixed is not None for item in plan.items)
    if fixed_mode and not all(
        item.rendered.output is not None and item.rendered.output.fixed is not None for item in plan.items
    ):
        fatal = ExecutionConfigError(
            'Mixed fixed and generated outputs within the same plan are not supported.',
            code='output_fixed_mixed_mode',
        )
        _remember_fatal(state, fatal)
        raise fatal

    if fixed_mode:
        plan.output_dir.mkdir(parents=True, exist_ok=True)
        written_files: list[Path] = []
        batch_results: list[BatchResult] = []
        for completed_count, item in enumerate(plan.items, start=1):
            progress_fields = {
                'index': item.index,
                'item_no': item.index + 1,
                'total_items': len(plan.items),
                'completed_items': completed_count,
            }
            try:
                item_content = _extract_fixed_output_content(item.rendered.output, plan.fmt)
                if item_content is None:
                    raise ValueError('Missing fixed output content.')
                output_path = _write_planned_output(
                    item_content,
                    plan.output_config,
                    plan.output_dir,
                    item.output_stem,
                    force=plan.force,
                )
                written_files.append(output_path)
                logger.info(
                    'Batch item completed.',
                    attempts=0,
                    elapsed='0.000s',
                    output_path=str(output_path),
                    **progress_fields,
                )
                batch_results.append(
                    BatchResult(
                        index=item.index,
                        input=item.variables,
                        prompt_file=plan.compiled.prompt_path,
                        trace=trace_prefix + (item.index,),
                        status='success',
                        response=None,
                        error=None,
                        attempts=0,
                        elapsed=0.0,
                        item_no=item.index + 1,
                        total_items=len(plan.items),
                        completed_items=completed_count,
                    )
                )
            except Exception as exc:
                logger.error(
                    'Batch item execution failed.',
                    attempts=0,
                    elapsed='0.000s',
                    error=str(exc),
                    **progress_fields,
                )
                batch_results.append(
                    BatchResult(
                        index=item.index,
                        input=item.variables,
                        prompt_file=plan.compiled.prompt_path,
                        trace=trace_prefix + (item.index,),
                        status='error',
                        response=None,
                        error=str(exc),
                        attempts=0,
                        elapsed=0.0,
                        item_no=item.index + 1,
                        total_items=len(plan.items),
                        completed_items=completed_count,
                    )
                )
        return ExecutionReport(batch_results=batch_results, written_files=written_files, output_dir=plan.output_dir)

    try:
        adapter = resolve_batch_adapter(plan.items[0].rendered)
    except ValueError as exc:
        fatal = ExecutionConfigError(str(exc), code='adapter_resolution_failed')
        _remember_fatal(state, fatal)
        raise fatal from exc
    adapter_name = getattr(getattr(adapter, '_credential', None), 'adapter', None)
    if not isinstance(adapter_name, str):
        adapter_name = None

    plan.output_dir.mkdir(parents=True, exist_ok=True)
    written_files: list[Path] = []
    stems = {item.index: item.output_stem for item in plan.items}
    items = {item.index: item for item in plan.items}
    fmt = plan.fmt
    child_tasks: list[asyncio.Task[ExecutionReport]] = []
    completed_count = 0

    def _mark_fatal(message: str, *, code: str) -> None:
        _remember_fatal(state, ExecutionConfigError(message, code=code))

    def _write_or_mark_fatal(
        content: str | bytes,
        stem: str,
        *,
        extension_override: str | None = None,
    ) -> Path | None:
        try:
            path = _write_planned_output(
                content,
                plan.output_config,
                plan.output_dir,
                stem,
                force=plan.force,
                extension_override=extension_override,
            )
            written_files.append(path)
            return path
        except FileExistsError as exc:
            _mark_fatal(str(exc), code='output_exists')
            return None

    async def _run_child_chain(
        *,
        next_ref: str,
        chain_source: Path,
        parent_trace: tuple[int, ...],
    ) -> ExecutionReport:
        try:
            next_prompt_path = _resolve_next_prompt_path(plan.compiled.prompt_path, next_ref)
            compiled = compile_prompt(
                next_prompt_path,
                default_model=plan.compiled.default_model,
                model_configs=plan.compiled.model_configs,
                helpers=plan.compiled.helpers,
                chain_source=chain_source,
            )
            child_plan = plan_prompt(compiled, force=plan.force)
            return await _execute_plan_tree(
                child_plan,
                state,
                trace_prefix=parent_trace,
            )
        except Exception as exc:
            _remember_fatal(state, exc)
            raise

    def _on_item_complete(result: BatchResult) -> None:
        nonlocal completed_count
        if state.fatal_error is not None:
            return

        completed_count += 1
        idx = result.index
        stem = stems[idx]
        progress_fields = {
            'index': idx,
            'item_no': result.item_no or idx + 1,
            'total_items': result.total_items or len(plan.items),
            'completed_items': result.completed_items or completed_count,
        }
        if result.status == 'error':
            logger.error(
                'Batch item execution failed.',
                attempts=result.attempts,
                elapsed=f'{result.elapsed:.3f}s',
                error=result.error,
                **progress_fields,
            )
            return

        if result.response is None:
            _mark_fatal(f'Missing response for successful batch result at index={idx}.', code='missing_response')
            return

        try:
            item_content = extract_output_content(result.response, fmt, adapter_name=adapter_name)
        except (ValueError, AttributeError) as exc:
            if fmt == 'image':
                logger.warning(
                    'Failed to extract image response content.',
                    attempts=result.attempts,
                    elapsed=f'{result.elapsed:.3f}s',
                    error=str(exc),
                    **progress_fields,
                )
                return
            logger.warning(
                'Failed to extract response content, writing empty output.',
                error=str(exc),
                **progress_fields,
            )
            item_content = serialize_output('', fmt)
        output_path = _write_or_mark_fatal(item_content, stem)
        if output_path is None or state.abort_event.is_set():
            return

        logger.info(
            'Batch item completed.',
            attempts=result.attempts,
            elapsed=f'{result.elapsed:.3f}s',
            output_path=str(output_path),
            **progress_fields,
        )

        item = items[idx]
        runtime = item.rendered.runtime
        next_ref = runtime.next if runtime else None
        if not next_ref:
            return

        child_tasks.append(
            asyncio.create_task(
                _run_child_chain(
                    next_ref=next_ref,
                    chain_source=output_path,
                    parent_trace=result.trace,
                )
            )
        )

    batch_results = await execute_batch(
        [item.rendered for item in plan.items],
        [item.variables for item in plan.items],
        adapter,
        prompt_file=plan.compiled.prompt_path,
        trace_prefix=trace_prefix,
        max_workers=plan.runtime.max_workers if plan.runtime else 5,
        on_item_complete=_on_item_complete,
        runtime=plan.runtime,
        abort_event=state.abort_event,
    )

    child_outcomes = await asyncio.gather(*child_tasks, return_exceptions=True) if child_tasks else []

    if state.fatal_error is not None:
        raise state.fatal_error

    combined_results = list(batch_results)
    combined_written_files = list(written_files)
    for outcome in child_outcomes:
        if isinstance(outcome, Exception):
            _remember_fatal(state, outcome)
            continue
        if not isinstance(outcome, ExecutionReport):
            raise outcome
        combined_results.extend(outcome.batch_results)
        combined_written_files.extend(outcome.written_files)

    if state.fatal_error is not None:
        raise state.fatal_error

    combined_results.sort(key=lambda item: item.trace)

    return ExecutionReport(
        batch_results=combined_results,
        written_files=combined_written_files,
        output_dir=plan.output_dir,
    )


async def execute_plan(plan: ExecutionPlan) -> ExecutionReport:
    """Execute an ``ExecutionPlan`` and persist outputs."""
    abort_event = asyncio.Event()
    state = _ExecutionState(abort_event=abort_event)
    loop = asyncio.get_running_loop()
    sigint_count = 0
    signal_handler_installed = False

    def _sigint_handler() -> None:
        nonlocal sigint_count
        sigint_count += 1
        if sigint_count == 1:
            logger.warning(
                'Interrupt received. Aborting after in-flight tasks finish... (press Ctrl+C again to force exit)',
            )
            abort_event.set()
            return
        raise KeyboardInterrupt

    try:
        loop.add_signal_handler(signal.SIGINT, _sigint_handler)
        signal_handler_installed = True
    except NotImplementedError:
        pass

    try:
        report = await _execute_plan_tree(plan, state)
    finally:
        if signal_handler_installed:
            loop.remove_signal_handler(signal.SIGINT)

    if state.fatal_error is not None:
        raise state.fatal_error
    return report
