"""Chat-session orchestration — port of v1's chat endpoints' logic
(main.py ≈7742-8059) restructured for streaming.

Responsibilities:

- session lifecycle: create (audit row, category 'chat'), list, replay,
  end (AI one-line title via provider.complete; EMPTY sessions are
  auto-discarded — session row + its activity_log row deleted, v1 behavior);
- turn orchestration: ``prepare_turn`` (sync, inside the caller's DB
  session: validates the session, saves photo uploads, persists the user
  message immediately — v1: "even if Gemini errors out, the user's input
  shouldn't vanish" — and builds history + starter context) and
  ``stream_turn`` (async generator running the function-calling loop).

THE LOOP LIVES HERE, not in the provider: each iteration is one
provider.chat_stream call; functionCall parts come back as tool_call
events, are dispatched through the registry (a SHORT DB session per
dispatch batch — per standards, no DB session is ever held across an
await), results are emitted as tool_result events and appended to the
message list, and the loop re-calls the model. Cap: 8 iterations (approved
plan; v1 had 6). The final assistant message is persisted with its full
tool-call record (chat_messages.tool_calls, json-ish TEXT exactly like v1)
so the replay view can show what the model looked at.

Photos travel to Gemini as inline base64 (v1 parity; the Files API upgrade
is post-cutover) and are stored through the notes-media pipeline with the
pipe-delimited ``chat_messages.photo_paths`` wart kept verbatim.

``session_factory`` / ``provider`` are injectable for tests (FakeProvider +
savepoint-bound sessions); production callers use the defaults.
"""
from __future__ import annotations

import base64
import json
import mimetypes
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import AsyncIterator, Callable

from fastapi import UploadFile
from sqlalchemy import func, select
from sqlalchemy.orm import Session

from ...db import get_engine
from ...errors import AppError
from ...models.assistant import ChatMessage, ChatSession
from .. import media
from ..audit import (
    delete_chat_log,
    log_chat_session_start,
    update_chat_log_summary,
)
from .providers.base import (
    AIProvider,
    FunctionCall,
    FunctionResponse,
    InlineData,
    Message,
    Part,
    StreamEvent,
    ToolSpec,
)
from .providers.gemini import GeminiProvider, chat_model
from .registry import dispatch, tool_specs
from .snapshot import build_starter_context
from .tools import garden_tools  # noqa: F401 — imports register the chat tools

# Approved plan: 8-iteration tool-loop cap (v1 CHAT_MAX_TOOL_ITERATIONS was 6).
CHAT_MAX_TOOL_ITERATIONS = 8

# --- error codes (chat domain) ---
CHAT_SESSION_NOT_FOUND = "chat_session_not_found"
CHAT_SESSION_ENDED = "chat_session_ended"
CHAT_MESSAGE_EMPTY = "chat_message_empty"

# v1 CHAT_SYSTEM_PROMPT (main.py ≈3742-3790), verbatim EXCEPT the
# suggest_new_tool paragraph — that tool is not ported (approved stale-drop).
CHAT_SYSTEM_PROMPT = (
    "You are a garden assistant for the user, who is the gardener.\n\n"
    "THE SNAPSHOT BELOW IS YOUR GROUND TRUTH. Top to bottom it has: "
    "today's date and season; the user's areas; the species CATALOGUE "
    "(types defined, NOT what's planted); WHAT IS CURRENTLY PLANTED "
    "(by species, with qty + varieties); WHAT IS STAGED (unplanted/idea "
    "pools); plantings (each with the species in it); RECENT CARE "
    "ACTIONS (done-tagged notes, 60 days); OPEN/RECENT ISSUES "
    "(issue-tagged notes, 60 days); and other recent notes (30 days). "
    "Treat all of this as authoritative — do not claim you don't know "
    "what's in the garden when this is right here.\n\n"
    "FOR ONGOING-CARE QUESTIONS (fertilize? water? treat? prune? "
    "transplant?) follow this loop EVERY TIME:\n"
    "  1. Read 'Recent care actions' for matching keywords first. If "
    "     the user already did the action recently (within a typical "
    "     cadence — fertilize ~4-6 weeks, water 2-7 days, etc.), say "
    "     so plainly and recommend what's next, not a repeat. Cite the "
    "     specific date and what was done.\n"
    "  2. Read 'Open / recent issues' for problems that should "
    "     influence your advice (aphids, deficiency, transplant shock, "
    "     etc.). Mention them by date.\n"
    "  3. Cross-check against 'Currently planted' to be specific to "
    "     species the user actually has — NOT hypothetical species.\n"
    "  4. If care relevant to the question is older than 60 days OR "
    "     scattered across many notes, CALL search_notes with a "
    "     keyword like 'fertiliz', 'water', 'mulch', 'prune', "
    "     'aphid' (substring match) before answering.\n\n"
    "USE TOOLS for: drill-down on a specific planting/area/species "
    "(get_planting_detail, get_area_detail, get_species_detail); the "
    "full notes on one entity (get_recent_notes); finding plants of a "
    "given species across the whole garden (find_plants); locating "
    "free space (find_empty_space); reading an artifact "
    "(read_artifact); searching all notes by keyword (search_notes).\n\n"
    "GENERAL GARDENING KNOWLEDGE is yours to use freely — but always "
    "ground it in the user's actual data above, not a generic answer.\n\n"
    "IMAGES: when the user uploads a photo, INSPECT IT and describe "
    "what you see — plants, pests, leaves, soil, structures, etc. "
    "Identify species/varieties as best you can and offer practical "
    "observations (disease signs, ripeness, growth stage, weeds, "
    "etc.). If ambiguous, say what you see and what would clarify. "
    "Never refuse to look at an image.\n\n"
    "ALWAYS produce a useful reply.\n\n"
    "Tone: concise, practical, friendly. Cite entity ids (e.g. "
    "'planting #5') and dates ('you fed them on 2026-04-18') when "
    "you reference garden data. Don't invent data — if a tool or "
    "snapshot section is empty, say so plainly.\n\n"
    "GARDEN SNAPSHOT:\n"
)

# v1 _chat_summarize_title prompt, verbatim.
_TITLE_PROMPT = (
    "Summarize this garden-assistant chat as a single short title "
    "(max 8 words, no quotation marks, no period). Reply with the "
    "title only.\n\n"
)

# v1 iteration-cap / empty-reply strings, verbatim.
_CAP_TEXT = (
    "(stopped after exceeding tool-call iteration limit; the model "
    "may need narrower questions or additional tools)"
)
_EMPTY_REPLY = "(empty reply)"


def _now() -> datetime:
    return datetime.now(timezone.utc)


def _short(value, limit: int = 80) -> str:
    if value is None:
        return ""
    s = str(value)
    if len(s) > limit:
        s = s[: limit - 1] + "…"
    return s


def default_session_factory() -> Session:
    return Session(get_engine())


_default_provider: GeminiProvider | None = None


def get_default_provider() -> AIProvider:
    global _default_provider
    if _default_provider is None:
        _default_provider = GeminiProvider()
    return _default_provider


# ---------------------------------------------------------------------------
# Session lifecycle
# ---------------------------------------------------------------------------

def _get_session(db: Session, garden_id: int, session_id: int) -> ChatSession:
    sess = db.scalar(
        select(ChatSession).where(
            ChatSession.id == session_id, ChatSession.garden_id == garden_id
        )
    )
    if sess is None:
        raise AppError(CHAT_SESSION_NOT_FOUND, f"chat session {session_id} not found", 404)
    return sess


def create_session(db: Session, garden_id: int) -> dict:
    """v1 api_chat_session_create: session row + ONE 'chat' activity-log row
    whose id is stored back on the session."""
    sess = ChatSession(garden_id=garden_id, created_at=_now(), status="open")
    db.add(sess)
    db.flush()
    log_id = log_chat_session_start(db, garden_id, sess.id)
    sess.activity_log_id = log_id
    db.commit()
    return {"id": sess.id, "activity_log_id": log_id}


def list_sessions(db: Session, garden_id: int) -> list[dict]:
    """Newest-first session index with titles + message counts."""
    counts = (
        select(ChatMessage.session_id, func.count().label("n"))
        .group_by(ChatMessage.session_id)
        .subquery()
    )
    rows = db.execute(
        select(ChatSession, func.coalesce(counts.c.n, 0).label("message_count"))
        .outerjoin(counts, counts.c.session_id == ChatSession.id)
        .where(ChatSession.garden_id == garden_id)
        .order_by(ChatSession.created_at.desc(), ChatSession.id.desc())
    ).all()
    return [
        {
            "id": s.id,
            "created_at": s.created_at,
            "ended_at": s.ended_at,
            "title": s.title or "",
            "summary": s.summary or "",
            "status": s.status,
            "message_count": n,
        }
        for s, n in rows
    ]


def _parse_tool_calls(raw: str | None) -> list:
    try:
        return json.loads(raw) if raw else []
    except json.JSONDecodeError:
        return []


def get_session_replay(db: Session, garden_id: int, session_id: int) -> dict:
    """v1 api_chat_session_get — session metadata + every message."""
    sess = _get_session(db, garden_id, session_id)
    rows = db.scalars(
        select(ChatMessage)
        .where(ChatMessage.session_id == session_id)
        .order_by(ChatMessage.id)
    ).all()
    return {
        "id": sess.id,
        "created_at": sess.created_at,
        "ended_at": sess.ended_at,
        "title": sess.title or "",
        "summary": sess.summary or "",
        "status": sess.status,
        "messages": [
            {
                "id": r.id,
                "created_at": r.created_at,
                "role": r.role,
                "text": r.text or "",
                "photos": [p for p in (r.photo_paths or "").split("|") if p],
                "tool_calls": _parse_tool_calls(r.tool_calls),
            }
            for r in rows
        ],
    }


async def end_session(
    *,
    garden_id: int,
    session_id: int,
    provider: AIProvider | None = None,
    session_factory: Callable[[], Session] | None = None,
) -> dict:
    """v1 api_chat_session_end: empty session -> discard (delete session +
    its activity_log row); otherwise title the transcript via a one-shot
    completion, stamp ended_at, and update the log summary. The title call
    NEVER blocks session end — any failure falls back to 'Untitled chat'."""
    provider = provider or get_default_provider()
    session_factory = session_factory or default_session_factory

    with session_factory() as db:
        sess = _get_session(db, garden_id, session_id)
        if sess.ended_at:
            return {"ok": True, "already_ended": True}
        msgs = db.execute(
            select(ChatMessage.role, ChatMessage.text, ChatMessage.tool_calls)
            .where(ChatMessage.session_id == session_id)
            .order_by(ChatMessage.id)
        ).all()
        if not msgs:
            # Empty session — drop it instead of leaving litter (v1).
            if sess.activity_log_id:
                delete_chat_log(db, sess.activity_log_id)
            db.delete(sess)
            db.commit()
            return {"ok": True, "discarded": True}
        log_id = sess.activity_log_id
        transcript_lines: list = []
        tool_count = 0
        for m in msgs:
            role = "User" if m.role == "user" else "Assistant"
            transcript_lines.append(f"{role}: {_short(m.text, 200)}")
            if m.role == "assistant" and m.tool_calls:
                tool_count += len(_parse_tool_calls(m.tool_calls))
        transcript = "\n".join(transcript_lines[:40])  # safety cap (v1)
        n_msgs = len(msgs)

    # AI call OUTSIDE any DB session (standards: no session across awaits).
    try:
        title = await provider.complete(
            model=chat_model(), prompt=_TITLE_PROMPT + transcript, temperature=0.0
        )
        title = _short(title.strip().replace("\n", " "), 80) or "Untitled chat"
    except Exception:
        title = "Untitled chat"

    if tool_count:
        log_summary = f"Chat: {title} ({n_msgs} messages, {tool_count} tools)"
    else:
        log_summary = f"Chat: {title} ({n_msgs} messages)"

    with session_factory() as db:
        sess = _get_session(db, garden_id, session_id)
        sess.ended_at = _now()
        sess.title = title
        sess.summary = log_summary
        if log_id:
            update_chat_log_summary(db, log_id, log_summary)
        db.commit()
    return {"ok": True, "title": title, "summary": log_summary}


# ---------------------------------------------------------------------------
# History reconstruction (v1 _chat_history_for_session, main.py:7750-7806)
# ---------------------------------------------------------------------------

def _inline_image_part(rel_path: str) -> Part | None:
    abs_p = media.safe_note_path(rel_path)
    if not abs_p:
        return None
    try:
        with open(abs_p, "rb") as f:
            b64 = base64.b64encode(f.read()).decode("ascii")
    except OSError:
        return None
    mime = mimetypes.guess_type(abs_p)[0] or "image/jpeg"
    return Part(inline_data=InlineData(mime_type=mime, data=b64))


def _history_messages(db: Session, session_id: int) -> list[Message]:
    """Rebuild provider-neutral messages from chat_messages. Tool calls are
    persisted as a JSON array on the assistant row; replay them as an
    assistant function_call followed by a tool function_response so the
    model sees the same context it produced last turn (v1)."""
    rows = db.execute(
        select(
            ChatMessage.role,
            ChatMessage.text,
            ChatMessage.photo_paths,
            ChatMessage.tool_calls,
        )
        .where(ChatMessage.session_id == session_id)
        .order_by(ChatMessage.id)
    ).all()
    messages: list[Message] = []
    for r in rows:
        if r.role == "user":
            parts: list[Part] = []
            if r.text:
                parts.append(Part(text=r.text))
            for p in (r.photo_paths or "").split("|"):
                if not p:
                    continue
                part = _inline_image_part(p)
                if part:
                    parts.append(part)
            if parts:
                messages.append(Message(role="user", parts=parts))
        else:
            for tc in _parse_tool_calls(r.tool_calls):
                name = tc.get("name", "")
                messages.append(
                    Message(
                        role="assistant",
                        parts=[
                            Part(
                                function_call=FunctionCall(
                                    name=name, args=tc.get("args", {}) or {}
                                )
                            )
                        ],
                    )
                )
                messages.append(
                    Message(
                        role="tool",
                        parts=[
                            Part(
                                function_response=FunctionResponse(
                                    name=name, response=tc.get("result", {}) or {}
                                )
                            )
                        ],
                    )
                )
            if r.text:
                messages.append(
                    Message(role="assistant", parts=[Part(text=r.text)])
                )
    return messages


# ---------------------------------------------------------------------------
# Turn orchestration
# ---------------------------------------------------------------------------

@dataclass
class TurnContext:
    """Everything a streaming turn needs, gathered SYNCHRONOUSLY before the
    SSE response starts — no DB session crosses into the stream."""

    garden_id: int
    session_id: int
    model: str
    system: str
    messages: list[Message]
    tools: list[ToolSpec] = field(default_factory=list)


def _has_file(upload: UploadFile | None) -> bool:
    return upload is not None and bool((upload.filename or "").strip())


def _save_photos(photos: list[UploadFile] | None) -> list[str]:
    """Save chat photo uploads; on failure delete what was already saved."""
    rels: list[str] = []
    try:
        for ph in photos or []:
            if _has_file(ph):
                rels.append(media.save_upload(ph, "photo"))
    except Exception:
        for rel in rels:
            media.delete_note_file(rel)
        raise
    return rels


def prepare_turn(
    db: Session,
    garden_id: int,
    session_id: int,
    *,
    text: str = "",
    photos: list[UploadFile] | None = None,
) -> TurnContext:
    """Validate + persist the user message and assemble the turn context.
    Runs inside the caller's (sync) DB session; the caller commits."""
    text = (text or "").strip()
    sess = _get_session(db, garden_id, session_id)
    if sess.ended_at:
        raise AppError(CHAT_SESSION_ENDED, "chat session is already ended", 400)

    photo_rels = _save_photos(photos)
    user_parts: list[Part] = []
    if text:
        user_parts.append(Part(text=text))
    for rel in photo_rels:
        part = _inline_image_part(rel)
        if part:
            user_parts.append(part)
    if not user_parts:
        raise AppError(
            CHAT_MESSAGE_EMPTY,
            "message must include text or at least one photo",
            400,
        )

    history = _history_messages(db, session_id)
    snapshot = build_starter_context(db, garden_id)

    # Persist the user message immediately — even if the model errors out,
    # the user's input shouldn't vanish (v1).
    db.add(
        ChatMessage(
            session_id=session_id,
            created_at=_now(),
            role="user",
            text=text,
            photo_paths="|".join(photo_rels),
        )
    )
    return TurnContext(
        garden_id=garden_id,
        session_id=session_id,
        model=chat_model(),
        system=CHAT_SYSTEM_PROMPT + snapshot,
        messages=history + [Message(role="user", parts=user_parts)],
        tools=tool_specs(),
    )


async def stream_turn(
    ctx: TurnContext,
    *,
    provider: AIProvider | None = None,
    session_factory: Callable[[], Session] | None = None,
) -> AsyncIterator[StreamEvent]:
    """Run one user turn: the 8-iteration function-calling loop.

    Yields the pinned SSE vocabulary. Tool dispatch and the final
    assistant-message persist each use a SHORT DB session opened between
    awaits (standards). On a provider error the turn stops WITHOUT
    persisting an assistant row (v1: an HTTP error left only the user
    message) — the user message was already persisted by prepare_turn.
    """
    provider = provider or get_default_provider()
    session_factory = session_factory or default_session_factory

    messages = list(ctx.messages)
    tool_calls_record: list[dict] = []
    final_text = _CAP_TEXT  # overwritten unless the iteration cap is hit

    try:
        for _ in range(CHAT_MAX_TOOL_ITERATIONS):
            turn_text: list[str] = []
            pending_calls: list[dict] = []
            async for ev in provider.chat_stream(
                model=ctx.model,
                system=ctx.system,
                messages=messages,
                tools=ctx.tools,
            ):
                if ev.type == "text_delta":
                    turn_text.append(ev.data.get("text", ""))
                    yield ev
                elif ev.type == "tool_call":
                    pending_calls.append(
                        {
                            "name": ev.data.get("name", ""),
                            "args": ev.data.get("args", {}) or {},
                        }
                    )
                    yield ev
                elif ev.type == "error":
                    yield ev
                    return
                # provider message_done is internal; the orchestrator emits
                # its own once the WHOLE turn (loop included) is finished.

            if pending_calls:
                # Append the model's tool-call message to the running
                # history (v1 kept any accompanying text; the call is
                # authoritative for the loop).
                model_parts = (
                    [Part(text="".join(turn_text))] if any(turn_text) else []
                )
                model_parts += [
                    Part(function_call=FunctionCall(name=c["name"], args=c["args"]))
                    for c in pending_calls
                ]
                messages.append(Message(role="assistant", parts=model_parts))

                response_parts: list[Part] = []
                with session_factory() as db:
                    for call in pending_calls:
                        result = dispatch(
                            db, ctx.garden_id, call["name"], call["args"]
                        )
                        record = {
                            "name": call["name"],
                            "args": call["args"],
                            "result": result,
                        }
                        tool_calls_record.append(record)
                        response_parts.append(
                            Part(
                                function_response=FunctionResponse(
                                    name=call["name"], response=result
                                )
                            )
                        )
                        yield StreamEvent(type="tool_result", data=record)
                messages.append(Message(role="tool", parts=response_parts))
                continue

            final_text = "".join(turn_text).strip() or _EMPTY_REPLY
            break

        with session_factory() as db:
            db.add(
                ChatMessage(
                    session_id=ctx.session_id,
                    created_at=_now(),
                    role="assistant",
                    text=final_text,
                    tool_calls=json.dumps(tool_calls_record),
                )
            )
            db.commit()

        yield StreamEvent(
            type="message_done",
            data={"text": final_text, "tool_calls": tool_calls_record},
        )
    except AppError as e:
        yield StreamEvent(
            type="error", data={"code": e.code, "message": e.message}
        )
    except Exception as e:  # never leak a raw traceback into the SSE stream
        yield StreamEvent(
            type="error",
            data={"code": "ai_upstream_error", "message": str(e)[:500]},
        )
