from __future__ import annotations

import json
import logging
import queue
import threading
from collections import defaultdict
from typing import Any

from any_llm import completion
from any_llm.types.completion import ChatCompletionChunk
from django.conf import settings as django_settings
from django.contrib.auth.views import redirect_to_login
from django.http import HttpResponseRedirect, StreamingHttpResponse
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.views import View
from django.views.generic import DetailView, ListView

from django_anyllm.agent import _infer_provider, _resolve_model, _strip_files_from_history, _strip_unsupported_file_blocks
from django_anyllm.forms import MessageForm
from django_anyllm.models import Chat, Message, MessageFile
from django_anyllm.prompts import build_system_prompt
from django_anyllm.registry import default_registry
from django_anyllm.settings import get_extra_tools, get_setting
from django_anyllm.tools import get_tools

logger = logging.getLogger(__name__)


def _is_empty_assistant_message(msg: Message) -> bool:
    """Return True if *msg* is an assistant message with no content and no tool_calls.

    Reasoning is not counted as content because most providers (including
    OpenAI) reject assistant messages that only contain reasoning.
    """
    return (
        msg.role == Message.Role.ASSISTANT
        and not (msg.content or "").strip()
        and not msg.tool_calls
    )


def _db_message_to_llm(message: Message) -> dict[str, Any]:
    """Convert a database Message to an any-llm-sdk message dict."""
    content: str | list[dict[str, Any]] | None = message.content or None
    attached_files = list(message.files.all())
    if attached_files:
        import os

        parts: list[dict[str, Any]] = []
        file_meta = ", ".join(
            f"{os.path.basename(f.file.name)} (file_id={f.pk})" for f in attached_files
        )
        text = f"{content}\n[Attached files: {file_meta}]" if content else f"[Attached files: {file_meta}]"
        parts.append({"type": "text", "text": text})
        for f in attached_files:
            part = f.to_image_content() or f.to_file_content()
            if part:
                parts.append(part)
        content = parts if parts else None
    msg: dict[str, Any] = {"role": message.role}
    if content is not None:
        msg["content"] = content
    if message.tool_calls:
        msg["tool_calls"] = message.tool_calls
    if message.role == Message.Role.TOOL and message.tool_results:
        msg["tool_call_id"] = message.tool_results.get("tool_call_id", "")
    if message.role == Message.Role.ASSISTANT and message.reasoning:
        msg["reasoning_content"] = message.reasoning
    return msg


def _collect_streaming_tool_calls(
    chunks: list[ChatCompletionChunk],
) -> list[dict[str, Any]]:
    """Reconstruct complete tool calls from streaming chunks."""
    calls: dict[int, dict[str, Any]] = defaultdict(
        lambda: {"id": None, "function": {"name": "", "arguments": ""}}
    )
    for chunk in chunks:
        delta = chunk.choices[0].delta if chunk.choices else None
        if not delta or not delta.tool_calls:
            continue
        for tc in delta.tool_calls:
            idx = tc.index
            if tc.id:
                calls[idx]["id"] = tc.id
            if tc.function:
                if tc.function.name:
                    calls[idx]["function"]["name"] = tc.function.name
                if tc.function.arguments:
                    calls[idx]["function"]["arguments"] += tc.function.arguments
    return list(calls.values())


class LoginRequiredMixin:
    """Redirect unauthenticated users when LOGIN_REQUIRED is True."""

    def dispatch(self, request, *args, **kwargs):
        if get_setting("LOGIN_REQUIRED") and not request.user.is_authenticated:
            return redirect_to_login(
                request.get_full_path(),
                login_url=getattr(django_settings, "LOGIN_URL", "/accounts/login/"),
            )
        return super().dispatch(request, *args, **kwargs)


class ChatListView(LoginRequiredMixin, ListView):
    """Display a list of all chats."""

    model = Chat
    template_name = "django_anyllm/chat_list.html"
    context_object_name = "chats"


def _auto_title(chat: Chat, content: str) -> None:
    """Set chat title from first user message if not already set."""
    if chat.title:
        return
    title = content.strip().replace("\n", " ")
    if len(title) > 60:
        title = title[:57] + "..."
    chat.title = title
    chat.save(update_fields=["title"])


class ChatCreateView(LoginRequiredMixin, View):
    """Create a new chat and redirect to it."""

    def post(self, request):
        chat = Chat.objects.create()
        return HttpResponseRedirect(
            reverse("django_anyllm:chat_detail", args=[chat.pk])
        )


class ChatDeleteView(LoginRequiredMixin, View):
    """Delete a chat."""

    def post(self, request, pk):
        chat = get_object_or_404(Chat, pk=pk)
        from_chat = request.POST.get("from_chat")
        chat.delete()
        chats = Chat.objects.all()
        response = render(
            request,
            "django_anyllm/_chat_list.html",
            {"chats": chats, "chat": None},
        )
        if from_chat and str(pk) == from_chat:
            response["HX-Redirect"] = reverse("django_anyllm:chat_list")
        return response


class ChatDetailView(LoginRequiredMixin, DetailView):
    """Display a single chat with its messages and the message form."""

    model = Chat
    template_name = "django_anyllm/chat.html"
    context_object_name = "chat"

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["form"] = MessageForm()
        ctx["chats"] = Chat.objects.all()
        return ctx


class MessageCreateView(LoginRequiredMixin, View):
    """Handle posting a new user message and return HTMX partials."""

    def post(self, request, pk):
        chat = get_object_or_404(Chat, pk=pk)
        form = MessageForm(request.POST, request.FILES)

        if not form.is_valid():
            return render(
                request,
                "django_anyllm/_message_form.html",
                {"form": form, "chat": chat},
            )

        # Save user message.
        user_msg = chat.messages.create(
            role=Message.Role.USER,
            content=form.cleaned_data["content"],
        )
        for uploaded in request.FILES.getlist("file"):
            MessageFile.objects.create(
                message=user_msg,
                file=uploaded,
                mime_type=uploaded.content_type or "application/octet-stream",
            )

        # Auto-title from first prompt.
        _auto_title(chat, form.cleaned_data["content"])

        # Create empty assistant message for streaming into.
        assistant_msg = chat.messages.create(
            role=Message.Role.ASSISTANT,
            content="",
        )
        chat.save(update_fields=["updated_at"])

        return render(
            request,
            "django_anyllm/_message_pair.html",
            {
                "chat": chat,
                "user_message": user_msg,
                "assistant_message": assistant_msg,
            },
        )


class ChatStreamView(LoginRequiredMixin, View):
    """SSE endpoint that streams the LLM response for a chat."""

    def get(self, request, pk):
        chat = get_object_or_404(Chat, pk=pk)
        assistant_id = request.GET.get("message")
        assistant_msg = get_object_or_404(
            Message, pk=assistant_id, chat=chat, role=Message.Role.ASSISTANT
        )

        # Build message history from DB (skip the empty assistant row).
        system_prompt = chat.system_prompt or build_system_prompt(default_registry)
        messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
        for msg in chat.messages.order_by("created_at"):
            if msg.pk == assistant_msg.pk:
                continue
            if _is_empty_assistant_message(msg):
                continue
            messages.append(_db_message_to_llm(msg))

        provider = chat.provider or _infer_provider(chat.model)
        messages = _strip_unsupported_file_blocks(messages, provider)
        messages = _strip_files_from_history(messages)

        tools = get_tools() + get_extra_tools()
        tool_map = {t.__name__: t for t in tools}
        max_rounds = 5

        def _sse_event(event_type: str, data: str) -> str:
            """Format a multi-line string as a proper SSE event."""
            lines = data.split("\n")
            data_lines = "".join(f"data: {line}\n" for line in lines)
            return f"event: {event_type}\n{data_lines}\n"

        def event_stream():
            nonlocal messages, assistant_msg
            for _round in range(max_rounds):
                chunks: list[ChatCompletionChunk] = []
                try:
                    stream = completion(
                        model=_resolve_model(chat.model, chat.provider or None),
                        messages=messages,
                        tools=tools,
                        stream=True,
                        api_key=getattr(request, "anyllm_api_key", None),
                    )
                except Exception as exc:
                    logger.exception("LLM completion failed")
                    yield _sse_event("error", str(exc))
                    yield _sse_event("done", "")
                    return

                content_buffer: list[str] = []
                reasoning_buffer: list[str] = []

                # Consume the stream in a background thread so the main
                # generator can emit SSE heartbeat comments during long
                # gaps (e.g. reasoning). This prevents gunicorn/nginx
                # timeouts on slow-to-respond streams.
                _SENTINEL = object()
                chunk_queue: queue.Queue = queue.Queue()
                stream_error: list[Exception] = []

                def _consume_stream() -> None:
                    try:
                        for c in stream:
                            chunk_queue.put(c)
                    except Exception as exc:
                        stream_error.append(exc)
                    finally:
                        chunk_queue.put(_SENTINEL)

                threading.Thread(target=_consume_stream, daemon=True).start()

                try:
                    while True:
                        try:
                            chunk = chunk_queue.get(timeout=5)
                        except queue.Empty:
                            yield ": keepalive\n\n"
                            continue
                        if chunk is _SENTINEL:
                            break

                        chunks.append(chunk)
                        choice = chunk.choices[0] if chunk.choices else None
                        if not choice:
                            continue
                        delta = choice.delta
                        if delta.content:
                            content_buffer.append(delta.content)
                            yield _sse_event("content", delta.content)
                        if delta.reasoning and delta.reasoning.content:
                            reasoning_buffer.append(delta.reasoning.content)
                            yield _sse_event("reasoning", delta.reasoning.content)
                except Exception as exc:
                    logger.exception("LLM streaming failed")
                    yield _sse_event("error", str(exc))
                    yield _sse_event("done", "")
                    return

                if stream_error:
                    logger.exception("LLM streaming failed")
                    yield _sse_event("error", str(stream_error[0]))
                    yield _sse_event("done", "")
                    return

                tool_calls = _collect_streaming_tool_calls(chunks)
                content = "".join(content_buffer)
                reasoning = "".join(reasoning_buffer)

                assistant_msg.content = content
                assistant_msg.reasoning = reasoning
                assistant_msg.tool_calls = tool_calls or None
                assistant_msg.save(update_fields=["content", "reasoning", "tool_calls"])

                llm_msg: dict[str, Any] = {"role": "assistant"}
                if content:
                    llm_msg["content"] = content
                if assistant_msg.reasoning:
                    llm_msg["reasoning_content"] = assistant_msg.reasoning
                if tool_calls:
                    llm_msg["tool_calls"] = tool_calls
                messages.append(llm_msg)

                if not tool_calls:
                    break

                # Execute tool calls and append results.
                for tc in tool_calls:
                    name = tc["function"]["name"]
                    try:
                        args = json.loads(tc["function"]["arguments"])
                    except json.JSONDecodeError:
                        args = {}
                    yield _sse_event("tool_call", name)

                    func = tool_map.get(name)
                    if func is None:
                        result = f"Error: tool '{name}' not found."
                    else:
                        try:
                            result = func(**args)
                        except Exception as exc:
                            result = f"Error executing {name}: {exc}"

                    if isinstance(result, str):
                        result_str = result
                    else:
                        try:
                            result_str = json.dumps(result)
                        except (TypeError, ValueError):
                            result_str = str(result)
                    yield _sse_event("tool_result", result_str)

                    chat.messages.create(
                        role=Message.Role.TOOL,
                        content=result_str,
                        tool_results={
                            "tool_call_id": tc.get("id", ""),
                            "name": name,
                        },
                    )
                    messages.append(
                        {
                            "role": "tool",
                            "tool_call_id": tc.get("id", ""),
                            "name": name,
                            "content": result_str,
                        }
                    )

                # Prepare next assistant message row for multi-round streaming.
                assistant_msg = chat.messages.create(
                    role=Message.Role.ASSISTANT,
                    content="",
                )

            yield _sse_event("done", "")

        return StreamingHttpResponse(
            event_stream(),
            content_type="text/event-stream",
        )
