"""Journal curator: async LLM pass that extracts captures from a chat.

Architecture (2026-05-22 brainstorm, Fable note #172):

The journal chat model has no tools — it just talks. This curator is the
second LLM pass that reads recent journal messages and fires the tool
calls (record_moment, update_task, etc.) the chat model can't.

Runs against the user's `background_model` so the chat model's KV cache
isn't disturbed. Can be triggered manually via the journal route or by
the scheduler (phase 2). The chat model is unaffected during a curator
run; this is intentionally fire-and-go.

The curator does NOT add its own messages to the conversation. Only the
side-effects of its tool calls land in the database (moments table,
notes table, tasks, etc.). The user sees those side-effects via the
existing journal data surfaces, not via a chat-stream injection — see
the brainstorm doc's surfacing decision (right-rail captures panel, not
inline observer voice).
"""
from __future__ import annotations

import json
import logging
import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone

from sqlalchemy import select

from fabledassistant.config import Config
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation, Message
from fabledassistant.services.llm import pick_num_ctx, stream_chat_with_tools
from fabledassistant.services.settings import get_setting
from fabledassistant.services.tools import execute_tool, get_tools_for_user

logger = logging.getLogger(__name__)

# Tool-call iteration cap. The chat path uses 6; the curator should
# converge faster because its task is bounded (extract beats from a
# fixed transcript, not respond to evolving conversation).
_MAX_TOOL_ROUNDS = 4

_CURATOR_SYSTEM_PROMPT = """You are a curator reading a fragment of the user's journal conversation. Your job is to capture meaningful beats as structured records using the tools provided. You do NOT respond to the user — your only output is tool calls.

Beats worth recording:
- Events that happened ("went grocery shopping", "finished the network restage")
- Encounters with people ("had coffee with Sarah", "called Mom")
- Decisions ("going to switch jobs", "won't pursue the contract")
- Observations about the user's state or world ("the new place is loud", "feeling tired")
- Plans and commitments ("watching a show tonight", "dentist Thursday")
- Small accomplishments or changes the user made ("installed the new AP", "shipped the migration")

Rules:
- Use record_moment to capture each distinct beat. One tool call per beat — do not collapse multiple beats into one.
- When linking to entities (people, places, tasks, notes), use the *_names parameters and let the server resolve. Never invent ids.
- Before linking a task by title, call search_notes to confirm it exists. If you have not searched, do not pass task_titles.
- If the user explicitly references an existing task by name, prefer update_task to mark progress. If they describe finishing something, set status=done.
- If the user mentions a person or place you do not already know about, you may call save_person or save_place to create the entry. Otherwise skip new-entity creation — better to omit a link than to invent the wrong one.
- Skip meta-conversational fragments ("ok", "thanks", "got it") — those are not journal beats.
- Match the user's voice when writing moment content. First-person or imperative. Never "the user mentioned…" / "user reports…" framing.

After the tool calls, you may emit one short summary sentence (≤ 20 words) describing what you captured. The summary is shown back to the chat model in subsequent turns so it stays aware of recent topics; it is NOT shown to the user directly. Examples:
- "Captured network restage progress and a coffee mention with Sarah."
- "Recorded plan for tonight; nothing else stood out."
- "" (empty if nothing was captured — perfectly fine).
"""


@dataclass
class CuratorToolCall:
    """One tool call attempted by the curator."""

    name: str
    arguments: dict
    result: dict | None = None
    error: str | None = None
    status: str = "pending"  # success | error | pending


@dataclass
class CuratorRunResult:
    """What the curator did in a single pass over a conversation."""

    conv_id: int
    user_id: int
    model: str
    messages_examined: int
    tool_calls: list[CuratorToolCall] = field(default_factory=list)
    summary: str = ""
    duration_ms: int = 0
    error: str | None = None

    @property
    def tools_attempted(self) -> int:
        return len(self.tool_calls)

    @property
    def tools_succeeded(self) -> int:
        return sum(1 for tc in self.tool_calls if tc.status == "success")

    def to_dict(self) -> dict:
        return {
            "conv_id": self.conv_id,
            "user_id": self.user_id,
            "model": self.model,
            "messages_examined": self.messages_examined,
            "tool_calls": [
                {
                    "name": tc.name,
                    "arguments": tc.arguments,
                    "status": tc.status,
                    "error": tc.error,
                }
                for tc in self.tool_calls
            ],
            "tools_attempted": self.tools_attempted,
            "tools_succeeded": self.tools_succeeded,
            "summary": self.summary,
            "duration_ms": self.duration_ms,
            "error": self.error,
        }


def _format_transcript(messages: list[Message]) -> str:
    """Render a list of Message rows as a plain transcript the curator can read.

    Tool-call messages and previous assistant content are included so the
    curator has full context, but the curator's own focus is on extracting
    beats from user messages.
    """
    lines: list[str] = []
    for m in messages:
        if not m.content:
            continue
        ts = m.created_at.strftime("%H:%M") if m.created_at else "??:??"
        role = m.role.capitalize() if m.role else "Unknown"
        lines.append(f"[{ts}] {role}: {m.content.strip()}")
    return "\n".join(lines)


async def _load_messages_since(
    conv_id: int, since: datetime | None
) -> list[Message]:
    """Load conversation messages since the cutoff (or all of today)."""
    async with async_session() as session:
        stmt = select(Message).where(Message.conversation_id == conv_id)
        if since is not None:
            stmt = stmt.where(Message.created_at > since)
        stmt = stmt.order_by(Message.created_at.asc())
        result = await session.execute(stmt)
        return list(result.scalars().all())


async def run_curator_for_conversation(
    conv_id: int,
    *,
    since: datetime | None = None,
    user_id_override: int | None = None,
) -> CuratorRunResult:
    """Run a single curator pass over the given journal conversation.

    Args:
        conv_id: target conversation.
        since: only consider messages after this timestamp. Defaults to
            the last 24 hours so a first manual trigger gets the day's
            worth of context without going back forever.
        user_id_override: optional — used by the scheduler to attribute
            the run to the conversation's owner without re-fetching.
            Manual triggers from the route pass None and we read from
            the conversation row.

    Returns a CuratorRunResult; never raises (errors land in result.error).
    """
    started_at = time.monotonic()

    async with async_session() as session:
        conv = await session.get(Conversation, conv_id)
    if conv is None:
        return CuratorRunResult(
            conv_id=conv_id, user_id=0, model="",
            messages_examined=0, error=f"Conversation {conv_id} not found",
        )
    if conv.conversation_type != "journal":
        return CuratorRunResult(
            conv_id=conv_id, user_id=conv.user_id, model="",
            messages_examined=0,
            error=f"Curator only runs on journal conversations (got {conv.conversation_type!r})",
        )

    user_id = user_id_override or conv.user_id
    # Default lookback: last 24h. Phase 2's scheduler will narrow this
    # by passing the conversation's last_curator_run_at as `since`.
    if since is None:
        since = datetime.now(timezone.utc) - timedelta(hours=24)

    messages = await _load_messages_since(conv_id, since)
    if not messages:
        logger.info(
            "Curator skipped conv %d: no new messages since %s",
            conv_id, since.isoformat(),
        )
        return CuratorRunResult(
            conv_id=conv_id, user_id=user_id,
            model="", messages_examined=0,
            duration_ms=int((time.monotonic() - started_at) * 1000),
        )

    # Use the background model so the chat model's KV cache survives.
    # Falls back to OLLAMA_MODEL if no background model is configured.
    model = await get_setting(user_id, "background_model", "") or Config.OLLAMA_MODEL

    tools = await get_tools_for_user(user_id, conversation_type="journal")
    transcript = _format_transcript(messages)

    user_prompt = (
        "Below is a fragment of the user's journal conversation. Extract "
        "the captureable beats using the tools provided, then emit one "
        "short summary line (or empty).\n\n"
        "TRANSCRIPT:\n"
        f"{transcript}\n"
    )

    llm_messages: list[dict] = [
        {"role": "system", "content": _CURATOR_SYSTEM_PROMPT},
        {"role": "user", "content": user_prompt},
    ]

    result = CuratorRunResult(
        conv_id=conv_id, user_id=user_id, model=model,
        messages_examined=len(messages),
    )

    try:
        num_ctx = pick_num_ctx(llm_messages, tools=tools)
        summary_chunks: list[str] = []

        # Tool-call iteration loop — same shape as run_generation, but
        # without SSE streaming since nothing is watching live.
        for round_idx in range(_MAX_TOOL_ROUNDS):
            tool_calls_this_round: list[dict] = []
            content_this_round: list[str] = []

            async for chunk in stream_chat_with_tools(
                llm_messages, model, tools=tools, think=False, num_ctx=num_ctx,
            ):
                if chunk.type == "content":
                    content_this_round.append(chunk.content)
                elif chunk.type == "tool_calls":
                    tool_calls_this_round = chunk.tool_calls or []
                elif chunk.type == "done":
                    break

            # The model's content this round contributes to the summary
            # only on the final round (after no more tool calls fire).
            if not tool_calls_this_round:
                summary_chunks.append("".join(content_this_round).strip())
                break

            # Execute each tool call, capture result, append back into the
            # message list as a tool-role message so the model sees what
            # happened on the next round.
            llm_messages.append({
                "role": "assistant",
                "content": "".join(content_this_round),
                "tool_calls": tool_calls_this_round,
            })

            for tc in tool_calls_this_round:
                fn = tc.get("function", {}) if isinstance(tc, dict) else {}
                name = fn.get("name") or ""
                args = fn.get("arguments") or {}
                if isinstance(args, str):
                    try:
                        args = json.loads(args)
                    except Exception:
                        args = {}
                call = CuratorToolCall(name=name, arguments=args)
                try:
                    tool_result = await execute_tool(
                        user_id, name, args, conv_id=conv_id,
                    )
                    call.result = tool_result
                    call.status = (
                        "success" if (tool_result or {}).get("success", True)
                        and not (tool_result or {}).get("error")
                        else "error"
                    )
                    if call.status == "error":
                        call.error = str((tool_result or {}).get("error", ""))[:500]
                except Exception as e:
                    call.status = "error"
                    call.error = f"{type(e).__name__}: {e}"[:500]
                    tool_result = {"success": False, "error": call.error}
                    logger.exception("Curator tool %r failed for conv %d", name, conv_id)
                result.tool_calls.append(call)
                llm_messages.append({
                    "role": "tool",
                    "content": json.dumps(tool_result)[:4000],
                })
        else:
            logger.warning(
                "Curator hit _MAX_TOOL_ROUNDS=%d for conv %d (still emitting tool calls)",
                _MAX_TOOL_ROUNDS, conv_id,
            )

        # Trim summary: at most one sentence, cap length aggressively.
        summary = " ".join(summary_chunks).strip().splitlines()
        result.summary = (summary[0][:240] if summary and summary[0] else "")
    except Exception as e:
        result.error = f"{type(e).__name__}: {e}"
        logger.exception("Curator run failed for conv %d", conv_id)

    result.duration_ms = int((time.monotonic() - started_at) * 1000)
    logger.info(
        "Curator pass complete: conv=%d model=%s messages=%d "
        "tool_calls=%d (ok=%d) duration=%dms summary=%r",
        conv_id, model, result.messages_examined,
        result.tools_attempted, result.tools_succeeded,
        result.duration_ms, result.summary[:60],
    )
    return result
