"""Assistant HTTP views.

* `panel_view` — entry point loaded by HTMX when the user first opens the
  widget. Returns `panel_chat.html` for the user's most-recent (or freshly
  created) conversation.
* `conversation_list_view` — list of past conversations for the history tab.
* `conversation_create_view` — POST creates a fresh conversation, returns
  `panel_chat.html` for it.
* `conversation_detail_view` — GET loads a specific conversation.
* `send_message_view` — the SSE streaming endpoint. Records user msg,
  runs the agent, streams events, persists assistant msg + audit log.
* `archive_view` — POST archives a conversation (hides from history).
"""
from __future__ import annotations

import json
import logging
import re

from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.http import (
    HttpResponse,
    HttpResponseBadRequest,
    HttpResponseForbidden,
    StreamingHttpResponse,
)
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.http import require_GET, require_POST

from apps.core.audit.models import AuditLog

from .agent import has_api_key, run_turn_streaming
from .citations import hydrate_citations
from .models import Conversation, Message, ToolCall
from .ratelimit import check_and_consume

logger = logging.getLogger(__name__)


# ── Helpers ───────────────────────────────────────────────────────────────


def _client_ip(request) -> str | None:
    xff = request.META.get("HTTP_X_FORWARDED_FOR", "")
    if xff:
        return xff.split(",")[0].strip()
    return request.META.get("REMOTE_ADDR")


def _ensure_conversation(user) -> Conversation:
    conv = (
        Conversation.objects.filter(user=user, is_archived=False)
        .order_by("-updated_at")
        .first()
    )
    if conv:
        return conv
    return Conversation.objects.create(user=user, title="")


def _sse_frame(event: str, data: dict) -> bytes:
    return f"event: {event}\ndata: {json.dumps(data)}\n\n".encode("utf-8")


# Path prefixes our tools surface — used to detect LLM-fabricated absolute
# URLs and rewrite them to their relative form. Mirrors the JS list in
# static/js/assistant.js::APP_PATH_PREFIXES.
_APP_PATH_PREFIXES = (
    "/dashboard", "/accounts", "/admin",
    "/rims", "/rep", "/repository", "/mel", "/alumni", "/smehub",
    "/inbox", "/notifications", "/api",
)
_FABRICATED_HOST_RE = re.compile(
    r"\[([^\]]+)\]\((https?://[^/)\s]+)(/[^)\s]*)\)"
)


def _strip_fabricated_hosts(text: str) -> str:
    """Rewrite markdown links of the form `[T](https://host/<app-path>)` to
    `[T](/<app-path>)` when the path starts with a known app prefix. Tools
    only ever return relative paths, so any absolute URL pointing at an
    app path is the LLM hallucinating a host."""
    if not text or "](" not in text:
        return text

    def _sub(m: re.Match) -> str:
        label, _scheme_host, path = m.group(1), m.group(2), m.group(3)
        for prefix in _APP_PATH_PREFIXES:
            if path == prefix or path.startswith(prefix + "/"):
                return f"[{label}]({path})"
        return m.group(0)

    return _FABRICATED_HOST_RE.sub(_sub, text)


# ── Views ─────────────────────────────────────────────────────────────────


@login_required
@require_GET
def panel_view(request):
    conv = _ensure_conversation(request.user)
    return render(
        request,
        "assistant/panel_chat.html",
        {"conversation": conv, "messages_qs": conv.messages.all(),
         "send_url": reverse("assistant:send_message", kwargs={"pk": conv.id})},
    )


@login_required
@require_GET
def conversation_list_view(request):
    qs = list(
        Conversation.objects.filter(user=request.user, is_archived=False)
        .order_by("-updated_at")[:50]
    )
    # Group conversations by recency: Today / Yesterday / Earlier this week / Earlier
    today = timezone.localdate()
    groups: list[tuple[str, list[Conversation]]] = [
        ("Today", []), ("Yesterday", []),
        ("Earlier this week", []), ("Earlier", []),
    ]
    for c in qs:
        d = timezone.localtime(c.updated_at).date()
        delta = (today - d).days
        if delta <= 0:
            groups[0][1].append(c)
        elif delta == 1:
            groups[1][1].append(c)
        elif delta <= 7:
            groups[2][1].append(c)
        else:
            groups[3][1].append(c)
    grouped = [(label, items) for label, items in groups if items]
    return render(
        request,
        "assistant/panel_history.html",
        {"conversations": qs, "grouped_conversations": grouped},
    )


@login_required
@require_POST
def conversation_create_view(request):
    conv = Conversation.objects.create(user=request.user, title="")
    return render(
        request,
        "assistant/panel_chat.html",
        {"conversation": conv, "messages_qs": conv.messages.all(),
         "send_url": reverse("assistant:send_message", kwargs={"pk": conv.id})},
    )


@login_required
@require_GET
def conversation_detail_view(request, pk):
    conv = get_object_or_404(Conversation, pk=pk, user=request.user)
    return render(
        request,
        "assistant/panel_chat.html",
        {"conversation": conv, "messages_qs": conv.messages.all(),
         "send_url": reverse("assistant:send_message", kwargs={"pk": conv.id})},
    )


@login_required
@require_POST
def archive_view(request, pk):
    conv = get_object_or_404(Conversation, pk=pk, user=request.user)
    conv.is_archived = True
    conv.save(update_fields=["is_archived", "updated_at"])
    return HttpResponse(status=204)


@login_required
@require_POST
def send_message_view(request, pk):
    """Stream a single agent turn as SSE events.

    The body of the response is `text/event-stream`. Frames:

    - ``user_saved``    — the user message was persisted.
    - ``tool_call``     — the agent is about to call a tool.
    - ``tool_result``   — a tool returned (preview only).
    - ``token``         — a chunk of the assistant's final answer.
    - ``citations``     — list of `{title, url, public_document_id}`.
    - ``done``          — final marker with assistant message id.
    - ``error``         — fatal error (also closes the stream).
    """
    conv = get_object_or_404(Conversation, pk=pk, user=request.user)
    text = (request.POST.get("message") or "").strip()
    if not text:
        return HttpResponseBadRequest("Empty message.")
    if len(text) > 4000:
        return HttpResponseBadRequest("Message too long.")
    if not check_and_consume(request.user):
        resp = HttpResponse(status=429, content_type="text/event-stream")
        resp.write(_sse_frame("error", {"message": "Rate limit reached. Try again later."}))
        resp.write(_sse_frame("done", {"rate_limited": True}))
        return resp

    user = request.user
    ip = _client_ip(request)
    user_agent = request.META.get("HTTP_USER_AGENT", "")[:512]
    request_path = request.path

    def _stream():
        # 1. Persist the user's message.
        user_msg = Message.objects.create(
            conversation=conv, role=Message.Role.USER, content=text
        )
        if not conv.title:
            conv.derive_title(text)
        conv.save(update_fields=["title", "updated_at"])
        yield _sse_frame("user_saved", {"id": str(user_msg.id),
                                        "title": conv.title})

        # 2. Run the agent and forward streaming events.
        full_text_parts: list[str] = []
        tool_call_records: list[dict] = []
        for ev in run_turn_streaming(conv, user, text):
            etype = ev["type"]
            payload = ev["payload"]
            if etype == "tool_call":
                yield _sse_frame("tool_call", payload)
            elif etype == "tool_result":
                tool_call_records.append(payload)
                yield _sse_frame("tool_result", {k: v for k, v in payload.items()
                                                  if k != "surfaced_object_ids"})
            elif etype == "token":
                full_text_parts.append(payload.get("text", ""))
                yield _sse_frame("token", payload)
            elif etype == "final":
                # Use the canonical final output if available.
                if payload.get("output"):
                    full_text_parts = [payload["output"]]
            elif etype == "error":
                yield _sse_frame("error", payload)

        final_text = _strip_fabricated_hosts(
            "".join(full_text_parts).strip()
        ) or "(no answer)"

        # 3. Hydrate citations from surfaced doc IDs.
        all_pids: list[str] = []
        for r in tool_call_records:
            for pid in r.get("surfaced_object_ids", []) or []:
                if pid not in all_pids:
                    all_pids.append(pid)
        citations = hydrate_citations(all_pids, user) if all_pids else []

        # 4. Persist assistant message + tool calls + audit log.
        asst_msg = Message.objects.create(
            conversation=conv,
            role=Message.Role.ASSISTANT,
            content=final_text,
            citations=citations,
            metadata={"model": settings.LANGCHAIN_CHAT_MODEL_FAST if has_api_key() else "stub"},
        )
        for r in tool_call_records:
            ToolCall.objects.create(
                message=asst_msg,
                name=r.get("name", ""),
                arguments=r.get("arguments", {}),
                result_preview=(r.get("preview") or "")[:2000],
                surfaced_object_ids=r.get("surfaced_object_ids", []) or [],
            )

        try:
            AuditLog.objects.create(
                actor=user,
                action=AuditLog.Action.ACCESS,
                target_app="assistant",
                target_model="Conversation",
                object_id=str(conv.id),
                object_repr=(conv.title or "")[:200],
                changes={
                    "tools_called": [r.get("name", "") for r in tool_call_records],
                    "surfaced_object_ids": all_pids,
                    "model": settings.LANGCHAIN_CHAT_MODEL_FAST if has_api_key() else "stub",
                    "user_chars": len(text),
                    "answer_chars": len(final_text),
                },
                ip_address=ip,
                user_agent=user_agent,
                path=request_path,
                method="POST",
            )
        except Exception:  # noqa: BLE001 — never block the stream on audit failures
            logger.exception("AuditLog write failed for assistant turn")

        # 5. Final SSE frames.
        yield _sse_frame("citations", {"items": citations})
        yield _sse_frame("done", {
            "message_id": str(asst_msg.id),
            "conversation_id": str(conv.id),
            "title": conv.title,
        })

    response = StreamingHttpResponse(_stream(), content_type="text/event-stream")
    response["Cache-Control"] = "no-cache"
    response["X-Accel-Buffering"] = "no"
    return response
