# Copyright (c) Microsoft. All rights reserved.

"""Agent runner orchestration for the harness console.

This module provides the HarnessAgentRunner class, which orchestrates agent
invocations with observer lifecycle management. It handles:
- User input dispatch
- Agent streaming with observer notifications
- Follow-up action collection
- Streaming state management
"""

from __future__ import annotations

import asyncio
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from agent_framework import Agent, AgentSession, MessageInjectionMiddleware
    from agent_framework import Message as FrameworkMessage

    from .app_state import FollowUpAction
    from .observers.base import ConsoleObserver
    from .state_driver import IUXStateDriver


class HarnessAgentRunner:
    """Orchestrates agent invocations driven by user-input events from the UI.

    The component invokes the runner's input handlers (run_turn) directly;
    the runner mutates UI state through the supplied IUXStateDriver.

    When the underlying agent has a ``MessageInjectionMiddleware`` wired in
    (as ``create_harness_agent`` does by default), the runner supports message
    injection: input submitted while a turn is streaming is enqueued via
    ``on_streaming_input`` and drained into the ongoing run by the middleware.
    """

    def __init__(
        self,
        agent: Agent,
        observers: list[ConsoleObserver],
        state_driver: IUXStateDriver,
        *,
        max_context_window_tokens: int | None = None,
        max_output_tokens: int | None = None,
    ) -> None:
        """Initialize the agent runner.

        Args:
            agent: The agent to orchestrate.
            observers: List of console observers for lifecycle events.
            state_driver: The UI state driver for observer updates.
            max_context_window_tokens: Optional max context window size for usage display.
            max_output_tokens: Optional max output tokens for usage display.
        """
        self._agent = agent
        self._observers = observers
        self._ux = state_driver
        self._max_context_window_tokens = max_context_window_tokens
        self._max_output_tokens = max_output_tokens
        self._input_gate = asyncio.Semaphore(1)  # Single turn at a time

        # Resolve the message-injection middleware (if any) so streaming-time
        # input can be enqueued into the ongoing run. Absent => injection no-ops.
        from agent_framework import MessageInjectionMiddleware

        self._message_injector: MessageInjectionMiddleware | None = next(
            (m for m in (agent.middleware or []) if isinstance(m, MessageInjectionMiddleware)),
            None,
        )
        # Snapshot of pending injected messages, used to detect consumption
        # during streaming. Safe as instance state because _input_gate
        # serialises turns.
        self._last_pending_messages: list[FrameworkMessage] = []

    async def run_turn(
        self,
        user_input: str,
        session: AgentSession | None = None,
    ) -> None:
        """Run a single agent turn with the given user input.

        Echoes the input, then delegates to the agent loop.

        Args:
            user_input: The user's input text.
            session: Optional agent session for conversation history.
        """
        async with self._input_gate:
            self._ux.write_user_input_echo(user_input)

            from agent_framework import Message

            messages = [Message(role="user", contents=[user_input])]
            await self._run_agent_loop(messages, session)

    async def start_agent_turn(
        self,
        messages: list,
        session: AgentSession | None = None,
    ) -> None:
        """Resume the agent loop with pre-built messages (from follow-up responses).

        Called by the app after the user finishes answering follow-up questions.
        If messages is empty, just completes the turn.

        Args:
            messages: List of Message objects to send to the agent.
            session: Optional agent session.
        """
        async with self._input_gate:
            if not messages:
                self._complete_turn()
                return
            await self._run_agent_loop(messages, session)

    def on_streaming_input(
        self,
        text: str,
        session: AgentSession | None = None,
    ) -> None:
        """Handle user input submitted while an agent turn is streaming.

        The text is enqueued via the ``MessageInjectionMiddleware`` so the agent
        can pick it up on its next opportunity within the ongoing run. No-op if
        the agent has no injection middleware or there is no active session.

        Args:
            text: The user's input text.
            session: The active agent session.
        """
        if self._message_injector is None or session is None:
            return

        text = text.strip()
        if not text:
            return

        from agent_framework import Message

        self._message_injector.enqueue_messages(session, Message(role="user", contents=[text]))
        pending = self._message_injector.get_pending_messages(session)
        self._ux.set_queued_messages([m.text for m in pending])

    def _sync_queued_message_display(self, session: AgentSession | None) -> None:
        """Sync the queued-items display with the injector's pending messages.

        Messages that have been consumed (drained by the middleware) since the
        last sync are echoed to the output area as regular user-input entries.
        No-op if there is no injection middleware or active session.

        Args:
            session: The active agent session.
        """
        if self._message_injector is None or session is None:
            return

        pending = self._message_injector.get_pending_messages(session)

        # The injection middleware drains the whole queue at once, so a message
        # is consumed when it is no longer present in the pending list. Compare
        # by object identity (snapshots share the same Message objects until the
        # queue is cleared) so consumed messages are echoed correctly even if a
        # drain is followed by a new enqueue before the next sync.
        current_ids = {id(m) for m in pending}
        for msg in self._last_pending_messages:
            if id(msg) not in current_ids:
                self._ux.write_user_input_echo(msg.text or "")

        self._last_pending_messages = pending
        self._ux.set_queued_messages([m.text for m in pending])

    async def _run_agent_loop(
        self,
        messages: list,
        session: AgentSession | None,
    ) -> None:
        """Run the agent loop, re-invoking as needed for follow-up messages.

        Loops while there are messages to send. After each stream:
        - Collects follow-up actions from observers
        - If questions exist → queue them and return (UI will collect answers)
        - If only direct messages → loop with those messages
        - If nothing → complete the turn

        Args:
            messages: Initial messages to send.
            session: Optional agent session.
        """
        next_messages = messages

        # Seed the pending-message snapshot so consumed injected messages can be
        # detected and echoed during streaming.
        self._last_pending_messages = (
            self._message_injector.get_pending_messages(session)
            if self._message_injector is not None and session is not None
            else []
        )

        while next_messages:
            # Configure run options
            options = self._configure_run_options(session)

            # Begin streaming
            self._ux.begin_streaming()
            self._ux.begin_streaming_output()
            self._ux.set_show_spinner(True)

            try:
                await self._stream_response_messages(next_messages, session, options)
            except Exception as ex:
                self._ux.append_info_line(
                    f"❌ Stream error: {ex.__class__.__name__}:\n{ex}",
                    color="red",
                )

            # Final sync after streaming (echo any messages consumed on the last update).
            self._sync_queued_message_display(session)

            # Stop spinner and end streaming output
            self._ux.set_show_spinner(False)

            # Collect follow-up actions from observers
            follow_up_actions = await self._collect_follow_up_actions(session)

            # Separate direct messages from questions
            has_follow_ups = len(follow_up_actions) > 0

            # Write no-text warning if applicable
            await self._ux.write_no_text_warning(has_follow_ups)

            # Enqueue all follow-up actions
            for action in follow_up_actions:
                self._ux.enqueue_follow_up_action(action)

            # Check if there are pending questions (UI needs user input)
            if self._ux.has_pending_questions():
                # Pause — the UI will collect answers and call start_agent_turn
                return

            # No questions — drain any accumulated direct messages and loop
            drained = self._ux.take_follow_up_responses()
            next_messages = drained if drained else None

        self._complete_turn()

    def _complete_turn(self) -> None:
        """Complete the current turn (end streaming)."""
        self._ux.end_streaming()

    def _configure_run_options(
        self,
        session: AgentSession | None,
    ) -> dict:
        """Configure run options via observers.

        Each observer can modify the options dict to influence agent behavior.

        Args:
            session: Optional agent session.

        Returns:
            Options dict for agent.run().
        """
        options = {}
        for observer in self._observers:
            observer.configure_run_options(options, self._agent, session)
        return options

    async def _stream_response(
        self,
        user_input: str,
        session: AgentSession | None,
        options: dict,
    ) -> None:
        """Stream agent response from a text input and dispatch to observers.

        Args:
            user_input: The user's input text.
            session: Optional agent session.
            options: Run options configured by observers.
        """
        # Stream response using agent.run(stream=True)
        stream = self._agent.run(
            user_input,
            stream=True,
            session=session,
            options=options,
        )

        # Process each update chunk
        async for update in stream:
            await self._dispatch_update(update, session)

        # Extract usage from the final response
        self._extract_usage(stream)

    async def _stream_response_messages(
        self,
        messages: list,
        session: AgentSession | None,
        options: dict,
    ) -> None:
        """Stream agent response from Message objects and dispatch to observers.

        Args:
            messages: List of Message objects to send.
            session: Optional agent session.
            options: Run options configured by observers.
        """
        stream = self._agent.run(
            messages,
            stream=True,
            session=session,
            options=options,
        )

        async for update in stream:
            await self._dispatch_update(update, session)

        self._extract_usage(stream)

    def _extract_usage(self, stream) -> None:
        """Extract token usage from a completed stream."""
        try:
            get_final = getattr(stream, "get_final_response", None)
            if not get_final:
                return

            import inspect

            if inspect.iscoroutinefunction(get_final):
                return

            final_response = get_final()
            if final_response is None:
                return

            usage = getattr(final_response, "usage_details", None)
            if not isinstance(usage, dict):
                return

            input_tokens = usage.get("input_token_count", 0) or 0
            output_tokens = usage.get("output_token_count", 0) or 0
            if input_tokens or output_tokens:
                self._ux.set_usage_text(self._format_usage(input_tokens, output_tokens))
        except (AttributeError, TypeError):
            pass

    async def _dispatch_update(
        self,
        update,  # AgentResponseUpdate
        session: AgentSession | None,
    ) -> None:
        """Dispatch a single update to all observers.

        Calls observer lifecycle methods in order:
        1. on_response_update (once per update)
        2. on_content (for each content item)
        3. on_text (if text is present)

        Args:
            update: The agent response update.
            session: Optional agent session.
        """
        # on_response_update
        for observer in self._observers:
            await observer.on_response_update(self._ux, update, self._agent, session)

        # on_content for each content item
        if hasattr(update, "contents") and update.contents:
            for content in update.contents:
                for observer in self._observers:
                    await observer.on_content(self._ux, content, self._agent, session)

        # on_text for text chunks
        if hasattr(update, "text") and update.text:
            for observer in self._observers:
                await observer.on_text(self._ux, update.text, self._agent, session)

        # Echo any injected messages consumed by the agent on this update.
        self._sync_queued_message_display(session)

    async def _collect_follow_up_actions(
        self,
        session: AgentSession | None,
    ) -> list[FollowUpAction]:
        """Collect follow-up actions from all observers.

        Called after streaming completes to gather any follow-up questions
        or messages from observers.

        Args:
            session: Optional agent session.

        Returns:
            List of follow-up actions from all observers.
        """
        actions: list[FollowUpAction] = []
        for observer in self._observers:
            observer_actions = await observer.on_stream_complete(self._ux, self._agent, session)
            if observer_actions:
                actions.extend(observer_actions)
        return actions

    def _format_usage(self, input_tokens: int, output_tokens: int) -> str:
        """Format token counts matching C# harness style: 📊 Tokens — input: X | output: Y | total: Z."""
        total_tokens = input_tokens + output_tokens

        input_budget = None
        if self._max_context_window_tokens and self._max_output_tokens:
            input_budget = self._max_context_window_tokens - self._max_output_tokens

        return (
            f"📊 Tokens — input: {self._format_token_count(input_tokens, input_budget)}"
            f" | output: {self._format_token_count(output_tokens, self._max_output_tokens)}"
            f" | total: {self._format_token_count(total_tokens, self._max_context_window_tokens)}"
        )

    @staticmethod
    def _format_token_count(count: int, budget: int | None) -> str:
        """Format a token count, optionally showing budget percentage."""
        if budget and budget > 0:
            pct = count / budget * 100
            return f"{count:,}/{budget:,} ({pct:.1f}%)"
        return f"{count:,}"
