"""Single-turn message processing pipeline.

Handles the full lifecycle of one user message:
  receive → context assembly → LLM call → reply assembly → persistence.

Extracted from AgentLoop._process_message() and related methods.
"""

from __future__ import annotations

import uuid
from datetime import datetime
from typing import TYPE_CHECKING, Any, Awaitable, Callable

from loguru import logger

from asedclaw.agent.agent_runner import run_agent
from asedclaw.agent.context import ContextBuilder
from asedclaw.agent.tools.message import MessageTool
from asedclaw.agent.transcript_policy import sanitize_transcript
from asedclaw.bus.events import InboundMessage, OutboundMessage
from asedclaw.media.dedupe import dedupe_pending_media, is_text_duplicate
from asedclaw.media.parse import strip_media_tags

if TYPE_CHECKING:
    from pathlib import Path

    from asedclaw.agent.context_engine import ContextEngine
    from asedclaw.agent.tools.registry import ToolRegistry
    from asedclaw.bus.queue import MessageBus
    from asedclaw.providers.base import LLMProvider
    from asedclaw.session.manager import Session, SessionManager


# Maximum chars kept per tool result in session history
_TOOL_RESULT_MAX_CHARS = 500


async def process_message(
    msg: InboundMessage,
    *,
    # core deps
    bus: MessageBus,
    sessions: SessionManager,
    context: ContextBuilder,
    context_engine: ContextEngine,
    provider: LLMProvider,
    model: str,
    temperature: float,
    reasoning_effort: str | None,
    tools: ToolRegistry,
    workspace: Path,
    memory_window: int,
    max_iterations: int,
    guardrails_config: Any | None,
    provider_type: str | None,
    # session lifecycle callbacks
    on_pre_process: Callable[[Session], Awaitable[None]] | None = None,
    on_new_session: Callable[[InboundMessage, Session], Awaitable[OutboundMessage]] | None = None,
    # optional overrides
    session_key: str | None = None,
    on_progress: Callable[[str], Awaitable[None]] | None = None,
) -> OutboundMessage | None:
    """Process a single inbound message and return the reply.

    ``on_pre_process`` is called before the LLM round to trigger
    consolidation / memory flush / index sync.

    ``on_new_session`` handles the ``/new`` slash command.
    """
    if msg.channel == "system":
        return await _process_system_message(
            msg,
            sessions=sessions, context=context, provider=provider,
            model=model, temperature=temperature,
            reasoning_effort=reasoning_effort, tools=tools,
            workspace=workspace, memory_window=memory_window,
            max_iterations=max_iterations,
            guardrails_config=guardrails_config,
            provider_type=provider_type,
        )

    preview = msg.content[:80] + ("..." if len(msg.content) > 80 else "")
    logger.info("Processing from {}:{}: {}", msg.channel, msg.sender_id, preview)

    task_id = uuid.uuid4().hex
    # Store task_id on inbound message so _dispatch can access it even when
    # the outbound reply is None (e.g. MessageTool already sent the response).
    if msg.metadata is None:
        msg.metadata = {}
    msg.metadata["_task_id"] = task_id

    key = session_key or msg.session_key
    session = sessions.get_or_create(key)

    # Slash commands
    cmd = msg.content.strip().lower()
    if cmd == "/new":
        if on_new_session:
            return await on_new_session(msg, session)
        return OutboundMessage(
            channel=msg.channel, chat_id=msg.chat_id,
            content="New session started.",
        )
    if cmd == "/help":
        return OutboundMessage(
            channel=msg.channel, chat_id=msg.chat_id,
            content="asedclaw commands:\n/new — Start a new conversation\n/stop — Stop the current task\n/help — Show available commands",
        )

    # Pre-processing hooks (consolidation, memory flush, index sync)
    if on_pre_process:
        await on_pre_process(session)

    # Inject per-turn context (channel/chat) into tools that require it
    for tool_name in ("message", "send_file", "spawn", "cron"):
        tool = tools.get(tool_name)
        if tool is None:
            continue
        if hasattr(tool, "set_context"):
            if tool_name == "message":
                tool.set_context(
                    msg.channel, msg.chat_id,
                    msg.metadata.get("message_id") if msg.metadata else None,
                )
            else:
                tool.set_context(msg.channel, msg.chat_id)
        if hasattr(tool, "start_turn"):
            tool.start_turn()

    mt = tools.get("message")

    history = session.get_history(max_messages=memory_window)
    initial_messages = context.build_messages(
        history=history,
        current_message=msg.content,
        media=msg.media or None,
        attachments=msg.attachments or None,
        channel=msg.channel, chat_id=msg.chat_id,
    )

    # Context Engine Stage 1: assemble within token budget
    initial_messages, _ = context_engine.assemble(initial_messages)

    # Build progress callback
    async def _bus_progress(
        content: str,
        *,
        tool_hint: bool = False,
        item_type: str = "answer_text",
        tool_status: str = "running",
        tool_args: dict | None = None,
        tool_result: str | None = None,
        tool_id: str | None = None,
    ) -> None:
        meta = dict(msg.metadata or {})
        meta["_progress"] = True
        meta["_tool_hint"] = tool_hint
        meta["_item_type"] = item_type
        meta["_task_id"] = task_id
        if tool_hint:
            meta["_tool_status"] = tool_status
            if tool_args:
                meta["_tool_input"] = tool_args
            if tool_result is not None:
                meta["_tool_result"] = tool_result
            if tool_id is not None:
                meta["_tool_id"] = tool_id
        await bus.publish_outbound(OutboundMessage(
            channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta,
            task_id=task_id,
        ))

    final_content, _, all_msgs, pending_media = await run_agent(
        initial_messages=initial_messages,
        provider=provider,
        model=model,
        temperature=temperature,
        reasoning_effort=reasoning_effort,
        tools=tools,
        context=context,
        workspace=workspace,
        guardrails_config=guardrails_config,
        max_iterations=max_iterations,
        on_progress=on_progress or _bus_progress,
    )

    # Context Engine Stage 2 & 3
    all_msgs = context_engine.maintain(all_msgs)
    all_msgs = context_engine.compress_tool_results_if_needed(all_msgs)

    save_turn(session, all_msgs, 1 + len(history), provider_type=provider_type)
    sessions.save(session)

    reply = assemble_reply(msg, final_content, pending_media, mt)
    if reply is not None:
        reply.metadata.setdefault("_task_id", task_id)
        reply.task_id = task_id
    return reply


# ---------------------------------------------------------------------------
# System messages
# ---------------------------------------------------------------------------

async def _process_system_message(
    msg: InboundMessage,
    *,
    sessions: SessionManager,
    context: ContextBuilder,
    provider: LLMProvider,
    model: str,
    temperature: float,
    reasoning_effort: str | None,
    tools: ToolRegistry,
    workspace: Path,
    memory_window: int,
    max_iterations: int,
    guardrails_config: Any | None,
    provider_type: str | None,
) -> OutboundMessage:
    channel, chat_id = (msg.chat_id.split(":", 1) if ":" in msg.chat_id else ("cli", msg.chat_id))
    logger.info("Processing system message from {}", msg.sender_id)
    session = sessions.get_or_create(f"{channel}:{chat_id}")
    history = session.get_history(max_messages=memory_window)
    messages = context.build_messages(
        history=history, current_message=msg.content, channel=channel, chat_id=chat_id,
    )
    final_content, _, all_msgs, pending_media = await run_agent(
        initial_messages=messages,
        provider=provider,
        model=model,
        temperature=temperature,
        reasoning_effort=reasoning_effort,
        tools=tools,
        context=context,
        workspace=workspace,
        guardrails_config=guardrails_config,
        max_iterations=max_iterations,
    )
    save_turn(session, all_msgs, 1 + len(history), provider_type=provider_type)
    sessions.save(session)
    return OutboundMessage(
        channel=channel, chat_id=chat_id,
        content=final_content or "Background task completed.",
        media=pending_media,
    )


# ---------------------------------------------------------------------------
# Reply assembly
# ---------------------------------------------------------------------------

def assemble_reply(
    msg: InboundMessage,
    final_content: str | None,
    pending_media: list[str],
    mt: Any,
) -> OutboundMessage | None:
    """Build the final outbound reply, deduplicating media and text."""
    sent_media = mt.sent_media_paths if isinstance(mt, MessageTool) else set()
    sent_texts = mt.sent_texts if isinstance(mt, MessageTool) else []
    deduped_media = dedupe_pending_media(pending_media, sent_media)

    if final_content and is_text_duplicate(final_content, sent_texts):
        final_content = None
    if final_content:
        final_content = strip_media_tags(final_content)

    if isinstance(mt, MessageTool) and mt._sent_in_turn and not deduped_media and not final_content:
        return None
    if final_content is None and not deduped_media:
        final_content = "I've completed processing but have no response to give."

    return OutboundMessage(
        channel=msg.channel, chat_id=msg.chat_id,
        content=final_content or "",
        media=deduped_media,
        metadata=msg.metadata or {},
    )


# ---------------------------------------------------------------------------
# Session persistence
# ---------------------------------------------------------------------------

def save_turn(
    session: Session,
    messages: list[dict],
    skip: int,
    *,
    provider_type: str | None = None,
) -> None:
    """Persist new messages from an LLM round into the session."""
    new_msgs = messages[skip:]
    if new_msgs:
        new_msgs = sanitize_transcript(new_msgs, provider_type=provider_type)

    for m in new_msgs:
        entry = dict(m)
        role, content = entry.get("role"), entry.get("content")
        if role == "assistant" and not content and not entry.get("tool_calls"):
            continue
        if role == "tool" and isinstance(content, str) and len(content) > _TOOL_RESULT_MAX_CHARS:
            entry["content"] = content[:_TOOL_RESULT_MAX_CHARS] + "\n... (truncated)"
        elif role == "user":
            entry = clean_user_entry(entry, content)
            if entry is None:
                continue
        entry.setdefault("timestamp", datetime.now().isoformat())
        session.messages.append(entry)
    session.updated_at = datetime.now()


def clean_user_entry(entry: dict, content: Any) -> dict | None:
    """Strip runtime context tags and inline images from user entries."""
    tag = ContextBuilder._RUNTIME_CONTEXT_TAG
    if isinstance(content, str) and content.startswith(tag):
        parts = content.split("\n\n", 1)
        if len(parts) > 1 and parts[1].strip():
            entry["content"] = parts[1]
        else:
            return None
    if isinstance(content, list):
        filtered = []
        for c in content:
            if c.get("type") == "text" and isinstance(c.get("text"), str) and c["text"].startswith(tag):
                continue
            if c.get("type") == "image_url" and c.get("image_url", {}).get("url", "").startswith("data:image/"):
                filtered.append({"type": "text", "text": "[image]"})
            else:
                filtered.append(c)
        if not filtered:
            return None
        entry["content"] = filtered
    return entry
