"""Chat REST endpoints."""

from __future__ import annotations

import base64
import logging

from fastapi import APIRouter, File, HTTPException, Request, Response, UploadFile
from pydantic import BaseModel

log = logging.getLogger(__name__)

router = APIRouter(tags=["chat"])

# 25 MB hard cap on uploaded audio — roughly 20 minutes of Opus at 64 kbps,
# far beyond any reasonable push-to-talk session.
_MAX_AUDIO_BYTES = 25 * 1024 * 1024


class ChatRequest(BaseModel):
    message: str
    conversation_id: str | None = None


class ChatResponse(BaseModel):
    conversation_id: str
    message: str


class MessageOut(BaseModel):
    id: str = ""
    role: str
    content: str
    # Incremental-persist flag (2026-04-19). When True, the content is a
    # draft being written by an in-flight chat turn; the UI should render
    # a streaming cursor. Flipped to False once the turn finalizes OR on
    # the next turn start (a stale draft from a crashed previous turn).
    streaming: bool = False
    created_at: str = ""


@router.post("/chat", response_model=ChatResponse)
async def chat(request: Request, body: ChatRequest) -> ChatResponse:
    """Send a message and get a non-streaming response."""
    agent = request.app.state.agent
    memory = request.app.state.memory

    conv_id = body.conversation_id
    if not conv_id or not await memory.conversation_exists(conv_id):
        conv_id = await memory.create_conversation()

    try:
        _provenance = {
            "kind": "chat",
            "source_channel": "rest",
        }
        response = await agent.chat(conv_id, body.message, provenance=_provenance)
    except Exception as exc:
        from agntspace.llm.base import LLMError

        if isinstance(exc, LLMError):
            log.error("LLM error in chat: %s", exc.user_message)
            response = f"⚠ {exc.user_message}"
        else:
            log.exception("Chat error")
            response = f"⚠ Error: {exc!s}"
    return ChatResponse(conversation_id=conv_id, message=response)


@router.get("/conversations")
async def list_conversations(request: Request, include_archived: bool = False) -> list[dict]:
    """List recent conversations. Pass include_archived=true to see archived chats."""
    memory = request.app.state.memory
    return await memory.list_conversations(include_archived=include_archived)


@router.get("/conversations/{conversation_id}/messages")
async def get_messages(
    request: Request, conversation_id: str
) -> list[MessageOut]:
    """Get messages for a conversation, including the streaming flag so
    the UI can render an in-flight cursor on draft assistant rows."""
    memory = request.app.state.memory
    rows = await memory.get_messages_detailed(conversation_id, limit=100)
    return [
        MessageOut(
            id=r["id"],
            role=r["role"],
            content=r["content"],
            streaming=r["streaming"],
            created_at=r["created_at"],
        )
        for r in rows
    ]


@router.get("/conversations/{conversation_id}/status")
async def conversation_status(request: Request, conversation_id: str) -> dict:
    """Return the live in-flight turn state for a conversation, or
    ``{"phase": "idle"}`` when no turn is running.

    The UI polls this every ~2 seconds while a conversation is open so
    it can render a "still working" indicator with the current phase
    (thinking / tool / streaming), the last tool name, and the number
    of tool calls attempted. When the phase flips to ``done``, the UI
    refreshes the messages list to pick up the finalized assistant
    reply. When it flips to ``error``, the UI surfaces the error text.
    """
    registry = getattr(request.app.state, "turn_registry", None)
    if registry is None:
        return {"phase": "idle", "conversation_id": conversation_id}
    state = registry.get_status(conversation_id)
    if state is None:
        return {"phase": "idle", "conversation_id": conversation_id}
    return state.to_dict()


class EditMessageRequest(BaseModel):
    content: str


class EditMessageResponse(BaseModel):
    """Result of editing a user message.

    ``replay_text`` is what the UI should send over the WebSocket as a
    ``{regenerate: true}`` payload to get a fresh assistant reply against
    the truncated history. Returned here so the UI doesn't need to re-fetch.
    """

    message_id: str
    content: str
    truncated: int
    replay_text: str


@router.patch(
    "/conversations/{conversation_id}/messages/{message_id}",
    response_model=EditMessageResponse,
)
async def edit_message(
    request: Request,
    conversation_id: str,
    message_id: str,
    body: EditMessageRequest,
) -> EditMessageResponse:
    """Rewrite a user message and truncate every later message.

    The agent's previous reply (and any tool traces it left) is wiped so
    the conversation stays coherent — replying to the OLD prompt would
    misrepresent what the user just said. The UI follows this call with
    ``{regenerate: true}`` over the WebSocket to get a fresh reply against
    the edited prompt.

    Refuses non-user messages — agents cannot rewrite their own past
    output. Refuses empty content. Returns 404 when the message does
    not belong to this conversation (cross-conversation lookups must
    fail closed).
    """
    memory = request.app.state.memory
    new_content = body.content.strip()
    if not new_content:
        raise HTTPException(status_code=400, detail="Edited content is empty")

    target = await memory.get_message(conversation_id, message_id)
    if not target:
        raise HTTPException(status_code=404, detail="Message not found")
    if target["role"] != "user":
        raise HTTPException(
            status_code=400,
            detail="Only user messages can be edited",
        )

    truncated = await memory.delete_messages_after(conversation_id, message_id)
    ok = await memory.update_user_message_content(
        conversation_id, message_id, new_content,
    )
    if not ok:
        # Should be unreachable — get_message confirmed role=user above —
        # but keep the gate so a future refactor can't silently regress.
        raise HTTPException(status_code=500, detail="Update failed")

    return EditMessageResponse(
        message_id=message_id,
        content=new_content,
        truncated=truncated,
        replay_text=new_content,
    )


class RegenerateResponse(BaseModel):
    """Result of preparing a regenerate.

    The route only truncates the trailing assistant turn — the actual
    new reply is produced by the WebSocket ``{regenerate: true}`` flow
    so it streams to the client through the same channel as a normal
    chat reply.
    """

    message_id: str
    truncated: int
    replay_text: str


@router.post(
    "/conversations/{conversation_id}/regenerate",
    response_model=RegenerateResponse,
)
async def regenerate(
    request: Request, conversation_id: str,
) -> RegenerateResponse:
    """Drop the trailing assistant turn so the next WebSocket regenerate
    can produce a fresh reply against the existing user prompt.

    Finds the most recent ``role=user`` message and deletes everything
    strictly after it (the previous assistant reply + any tool traces).
    Returns the text the UI should send over the WebSocket as
    ``{regenerate: true}``.
    """
    memory = request.app.state.memory
    last_user = await memory.get_last_user_message(conversation_id)
    if not last_user:
        raise HTTPException(
            status_code=404,
            detail="No user message to regenerate from",
        )
    truncated = await memory.delete_messages_after(
        conversation_id, last_user["id"],
    )
    return RegenerateResponse(
        message_id=last_user["id"],
        truncated=truncated,
        replay_text=last_user["content"],
    )


@router.patch("/conversations/{conversation_id}/archive")
async def archive_conversation(request: Request, conversation_id: str) -> dict:
    """Archive a conversation (hides from default list)."""
    memory = request.app.state.memory
    await memory.archive_conversation(conversation_id)
    return {"archived": True}


@router.patch("/conversations/{conversation_id}/unarchive")
async def unarchive_conversation(request: Request, conversation_id: str) -> dict:
    """Restore an archived conversation."""
    memory = request.app.state.memory
    await memory.unarchive_conversation(conversation_id)
    return {"archived": False}


@router.delete("/conversations/{conversation_id}")
async def delete_conversation(request: Request, conversation_id: str) -> dict:
    """Permanently delete a conversation and all its messages."""
    memory = request.app.state.memory
    await memory.delete_conversation(conversation_id)
    return {"deleted": True}


class SpeakRequest(BaseModel):
    text: str
    # Optional override; falls back to llm.speak.DEFAULT_VOICE.
    voice: str | None = None


@router.post("/speak")
async def speak(request: Request, body: SpeakRequest) -> Response:
    """Synthesize text-to-speech audio for the chat-reply playback button.

    Returns ``audio/mpeg`` (MP3) bytes. Designed for the chat UI's "▶ play
    this reply" toolbar button — the agent text is synthesized server-side
    via the edge-tts provider (free, online, no API key) and streamed back
    so the browser can play it through a regular ``<audio>`` element.

    Hard caps: 4000 chars per call (≈30s of audio). Larger payloads are
    truncated, not rejected, so a clipped reply still plays.

    Returns 503 with a ``hint`` field when no TTS provider is installed —
    the UI can render the install command directly to the user.
    """
    from agntspace.llm.speak import (
        DEFAULT_VOICE,
        TTSUnavailable,
        synthesize,
    )

    text = (body.text or "").strip()
    if not text:
        raise HTTPException(status_code=400, detail="Empty text")

    voice = (body.voice or DEFAULT_VOICE).strip() or DEFAULT_VOICE

    try:
        audio = await synthesize(text, voice=voice)
    except TTSUnavailable as exc:
        # Surface the install hint when present so the UI can suggest
        # `pip install edge-tts`. Generic 503 otherwise.
        detail = str(exc)
        if exc.install_hint:
            detail = f"{detail}. Install with: {exc.install_hint}"
        raise HTTPException(status_code=503, detail=detail) from exc
    except Exception as exc:  # noqa: BLE001 — must never break the chat UI
        log.exception("Unexpected TTS failure")
        raise HTTPException(status_code=500, detail=f"TTS failed: {exc!s}") from exc

    return Response(
        content=audio,
        media_type="audio/mpeg",
        headers={
            # Audio is deterministic for (text, voice) but small enough
            # that disk caching isn't worth it. Tell the browser it can
            # cache for an hour so a user replaying the same reply twice
            # in a row doesn't re-synthesize.
            "Cache-Control": "private, max-age=3600",
        },
    )


class TranscribeResponse(BaseModel):
    text: str


@router.post("/transcribe", response_model=TranscribeResponse)
async def transcribe(request: Request, audio: UploadFile = File(...)) -> TranscribeResponse:
    """Transcribe a short audio blob recorded by the chat UI.

    Uses the same pipeline as WhatsApp voice notes
    (`agntspace.llm.transcribe.transcribe_audio`) — OpenAI Whisper API when
    a cloud key is available, local `whisper` as a fallback. The UI records
    with MediaRecorder (typically ``audio/webm`` in Chromium-based browsers,
    ``audio/mp4`` in Safari) and sends the blob as multipart form-data.
    """
    audio_bytes = await audio.read()
    if not audio_bytes:
        raise HTTPException(status_code=400, detail="Empty audio upload")
    if len(audio_bytes) > _MAX_AUDIO_BYTES:
        raise HTTPException(status_code=413, detail="Audio file too large (max 25 MB)")

    mimetype = audio.content_type or "audio/webm"

    from agntspace.infra.config import get_settings
    from agntspace.llm.transcribe import NO_BACKEND_AVAILABLE, transcribe_audio

    settings = get_settings()
    api_key = settings.openai_api_key or None

    try:
        audio_b64 = base64.b64encode(audio_bytes).decode("ascii")
        text = await transcribe_audio(audio_b64, mimetype, api_key=api_key)
    except Exception as exc:
        log.exception("Chat transcription failed")
        raise HTTPException(status_code=500, detail=f"Transcription failed: {exc!s}") from exc

    if text == NO_BACKEND_AVAILABLE:
        raise HTTPException(
            status_code=503,
            detail=(
                "No transcription backend available. Install a local backend "
                "with `pip install faster-whisper` (recommended, ~100MB) or "
                "configure an OpenAI API key for cloud transcription."
            ),
        )

    # Backend ran but produced nothing — silent or non-speech audio.
    # Return 200 with empty text so the UI can show a gentle "no speech
    # detected" hint instead of treating it as a backend failure.
    return TranscribeResponse(text=(text or "").strip())
