import json

from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.utils.html import mark_safe
from django.db.models import Max, Q
from django.http import HttpResponseForbidden, JsonResponse
from django.shortcuts import redirect, render

from apps.applets.utils import applet_context, apply_applet_toggle

from apps.billboard.forms import ExistingPostLineForm, LineForm
from apps.billboard.models import Brief, Line, Post
from apps.dashboard.views import _PALETTE_DEFS
from apps.drama.models import GameEvent, Note, ScrollPosition
from apps.epic.models import Room
from apps.epic.utils import annotate_latest_event, rooms_for_user
from apps.lyric.models import User, get_or_create_adman

_PALETTE_LABELS = {p["name"]: p["label"] for p in _PALETTE_DEFS}


def _recent_posts(user, limit=3):
    """Most-recently-active Posts the user owns or is shared on. Attaches
    `latest_line` to each Post (Line or None) so the My Posts applet row
    can render its 3-col `<title> | <latest line text> | <ts>` shape
    without an extra template-side query."""
    posts = list(
        Post
            .objects
            .filter(Q(owner=user) | Q(shared_with=user))
            .annotate(last_line=Max('lines__id'))
            .order_by('-last_line')
            .distinct()[:limit]
    )
    for p in posts:
        p.latest_line = p.lines.order_by("-id").first()
    return posts


def _recent_buds(user, limit=3):
    """Most-recently-added buds, newest first. M2M has no explicit through
    model, so we sort the auto-through table by its monotonic `id`. The
    `select_related('to_user__active_title')` chain warms up the active
    title FK for the My Buds applet row's middle column."""
    through = User.buds.through  # type: ignore[attr-defined]
    rows = (
        through.objects
            .filter(from_user=user)
            .select_related("to_user__active_title")
            .order_by("-id")[:limit]
    )
    return [r.to_user for r in rows]


def _recent_notes(user, limit=3):
    """Most-recently-earned Notes. Attaches `description` from _NOTE_META
    so the My Notes applet row can render `<title> | <description> | <ts>`."""
    notes = list(user.notes.order_by("-earned_at")[:limit])
    for n in notes:
        n.description = _NOTE_META.get(n.slug, {}).get("description", "")
    return notes


def _billboard_context(user):
    my_rooms = annotate_latest_event(rooms_for_user(user).order_by("-created_at"))

    recent_room = (
        Room.objects.filter(
            Q(owner=user) | Q(gate_slots__gamer=user)
        )
        .annotate(last_event=Max("events__timestamp"))
        .filter(last_event__isnull=False)
        .order_by("-last_event")
        .distinct()
        .first()
    )
    # SIG_READY+retracted exclusion is done in Python because SQLite's NULL
    # semantics drop ALL SIG_READY events whose data has no `retracted` key:
    # `data__retracted=True` resolves to NULL via JSON_EXTRACT for missing keys,
    # and `WHERE NOT (NULL AND verb='sig_ready')` evaluates to NULL → row
    # filtered out. We pull a buffer (100) to absorb any retracted prefix and
    # then slice to 36 after Python filtering.
    if recent_room:
        candidates = list(
            recent_room.events
            .select_related("actor")
            .exclude(verb=GameEvent.SIG_UNREADY)
            .order_by("-timestamp")[:100]
        )
        visible = [
            e for e in candidates
            if not (e.verb == GameEvent.SIG_READY and e.data.get("retracted"))
        ]
        recent_events = visible[:36][::-1]
    else:
        recent_events = []

    return {
        "my_rooms": my_rooms,
        "recent_room": recent_room,
        "recent_events": recent_events,
        "viewer": user,
        "applets": applet_context(user, "billboard"),
        "form": LineForm(),
        "recent_posts": _recent_posts(user),
        "recent_buds":  _recent_buds(user),
        "recent_notes": _recent_notes(user),
    }


@login_required(login_url="/")
def billboard(request):
    return render(request, "apps/billboard/billboard.html", {
        **_billboard_context(request.user),
        "page_class": "page-billboard",
    })


@login_required(login_url="/")
def toggle_billboard_applets(request):
    checked = request.POST.getlist("applets")
    apply_applet_toggle(request.user, "billboard", checked)
    if request.headers.get("HX-Request"):
        return render(
            request,
            "apps/billboard/_partials/_applets.html",
            _billboard_context(request.user),
        )
    return redirect("billboard:billboard")


@login_required(login_url="/")
def scroll(request, room_id):
    room = Room.objects.get(id=room_id)
    events = room.events.select_related("actor").all()
    sp = ScrollPosition.objects.filter(user=request.user, room=room).first()
    return render(request, "apps/billboard/scroll.html", {
        "room": room,
        "events": events,
        "viewer": request.user,
        "scroll_position": sp.position if sp else 0,
        "page_class": "page-billscroll",
    })


def _palette_opts(names):
    return [{"name": n, "label": _PALETTE_LABELS.get(n, n)} for n in names]


_NOTE_META = {
    "stargazer": {
        "title":           "Stargazer",
        "description":     "You saved your first personal sky chart.",
        "palette_options": _palette_opts(["palette-bardo", "palette-sheol"]),
        "swatch_label":    None,
    },
    "schizo": {
        "title":           "Schizo",
        "description":     "The socius recognizes the line of flight.",
        "palette_options": [],
        "swatch_label":    None,
    },
    "nomad": {
        "title":           "Nomad",
        "description":     "The socius recognizes the smooth space.",
        "palette_options": [],
        "swatch_label":    None,
    },
    "super-schizo": {
        "title":           "Super-Schizo",
        "description":     mark_safe('Admin access granted to <span class="card-ref">I. The Schizo</span> as Significator'),
        "palette_options": [],
        "swatch_label":    "I",
    },
    "super-nomad": {
        "title":           "Super-Nomad",
        "description":     mark_safe('Admin access granted to <span class="card-ref">0. The Nomad</span> as Significator'),
        "palette_options": [],
        "swatch_label":    "0",
    },
    "baltimorean": {
        "title":           "Baltimorean",
        "description":     '"Aaron earned an iron urn."',
        "palette_options": _palette_opts(["palette-baltimore", "palette-maryland"]),
        "swatch_label":    None,
    },
}


@login_required(login_url="/")
def note_set_palette(request, slug):
    from django.http import Http404
    from apps.dashboard.views import _unlocked_palettes_for_user
    try:
        note = Note.objects.get(user=request.user, slug=slug)
    except Note.DoesNotExist:
        raise Http404
    if request.method == "POST":
        body = json.loads(request.body)
        palette = body.get("palette", "")
        note.palette = palette
        note.save(update_fields=["palette"])
        # Commit as the user's active sitewide palette now that the Note unlocks it.
        if palette in _unlocked_palettes_for_user(request.user):
            request.user.palette = palette
            request.user.save(update_fields=["palette"])
    return JsonResponse({"ok": True})


@login_required(login_url="/")
def my_notes(request):
    qs = Note.objects.filter(user=request.user)
    active_title = request.user.active_title
    note_items = [
        {
            "obj":               n,
            "title":             _NOTE_META.get(n.slug, {}).get("title", n.slug),
            "recognition_title": n.card_title,
            "description":       _NOTE_META.get(n.slug, {}).get("description", ""),
            "palette_options":   _NOTE_META.get(n.slug, {}).get("palette_options", []),
            "swatch_label":      _NOTE_META.get(n.slug, {}).get("swatch_label"),
            "palette_label":     _PALETTE_LABELS.get(n.palette, "") if n.palette else "",
            "is_equipped":       active_title is not None and active_title.pk == n.pk,
        }
        for n in qs
    ]
    return render(request, "apps/billboard/my_notes.html", {
        "notes": qs,
        "note_items": note_items,
        "page_class": "page-notes",
    })


@login_required(login_url="/")
def don_title(request, slug):
    from django.http import Http404
    try:
        note = Note.objects.get(user=request.user, slug=slug)
    except Note.DoesNotExist:
        raise Http404
    if request.method == "POST":
        request.user.active_title = note
        request.user.save(update_fields=["active_title"])
    return JsonResponse({"title": note.display_title, "greeting": note.display_greeting})


@login_required(login_url="/")
def doff_title(request, slug):
    if request.method == "POST":
        request.user.active_title = None
        request.user.save(update_fields=["active_title"])
    return JsonResponse({"ok": True, "greeting": "Welcome,", "title": "Earthman"})


# ── My Sign — global Significator picker (billboard surface) ────────────────
# Standalone page where a user picks their global personal significator. The
# selection persists on User.significator + User.significator_reversed and is
# reused across My Sea draws (and eventually other contexts). "Sign" is the
# billboard-context branding; "significator" stays at the storage layer +
# room sig-select context to keep the DRY model. Sprint 4a of
# [[project-my-sea-roadmap]] — picker UI is a simplified lift of the room's
# `_sig_select_overlay.html` (no countdown / WS / polarity / multi-user).
# Deck-source fallback (Brief-redirect to Game Kit when no equipped deck;
# Earthman-Backup default) deferred to a follow-up sub-sprint.

@login_required(login_url="/")
def my_sign(request):
    """Render the picker — same 18-card pile as room sig-select (16 middle
    arcana courts + Major 0 & 1), pulled from the user's equipped deck.
    Polarity is determined post-hoc by the FLIP btn (significator_reversed).

    Backup-deck branch: if the user has no equipped_deck AND no saved sig,
    `personal_sig_cards` falls back to the Earthman pile and the template
    renders an intro Brief banner labeling the backup as "Earthman [Shabby
    Paperboard]" with FYI (→ Game Kit) + NVM (dismiss + proceed) actions."""
    from apps.epic.models import personal_sig_cards
    cards = personal_sig_cards(request.user)
    no_equipped_deck = request.user.equipped_deck is None
    sig = request.user.significator
    return render(request, "apps/billboard/my_sign.html", {
        "cards": cards,
        "no_equipped_deck": no_equipped_deck,
        "show_backup_intro_banner": no_equipped_deck and sig is None,
        "current_significator": sig,
        "current_significator_reversed": request.user.significator_reversed,
        "page_class": "page-billboard page-my-sign",
    })


@login_required(login_url="/")
def save_sign(request):
    """Persist the user's sign choice — POST { card_id, reversed }."""
    from apps.epic.models import TarotCard
    if request.method != "POST":
        return redirect("billboard:my_sign")
    card_id = request.POST.get("card_id")
    reversed_flag = request.POST.get("reversed") in ("1", "true", "True", "on")
    try:
        card = TarotCard.objects.get(pk=card_id)
    except (TarotCard.DoesNotExist, ValueError, TypeError):
        return HttpResponseForbidden("invalid card_id")
    request.user.significator = card
    request.user.significator_reversed = reversed_flag
    request.user.save(update_fields=["significator", "significator_reversed"])
    return redirect("billboard:my_sign")


@login_required(login_url="/")
def clear_sign(request):
    """Wipe the user's saved sig — POST `/billboard/my-sign/clear`.
    Sprint 4b-adjacent. Unblocks manual verification of My Sea's no-sig
    branch on dev users w. a sig already set; also gives end users a way
    to undo a saved choice without re-picking. GET redirects back to the
    picker (no mutation) per the existing save_sign convention."""
    if request.method != "POST":
        return redirect("billboard:my_sign")
    request.user.significator = None
    request.user.significator_reversed = False
    request.user.save(update_fields=["significator", "significator_reversed"])
    return redirect("billboard:my_sign")


# ── Post / Line CRUD (relocated from apps.dashboard) ────────────────────────
# Templates also live under templates/apps/billboard/. URL names sit in the
# `billboard:` namespace so reversers across the codebase carry the prefix.

def _truncate_post_title(text, length=35):
    """Glean a Post.title from the first user-submitted Line: copy first
    `length` chars exactly, or truncate to `length-3` chars + "..." past
    that. Mirrors billboard/migrations/0004 backfill helper."""
    if len(text) <= length:
        return text
    return text[: length - 3] + "..."


def new_post(request):
    form = LineForm(data=request.POST)
    if form.is_valid():
        # Anonymous compose path (Percival ch. 18 chapters) keeps owner=null
        # but still needs an author for the Line FK. We require auth on this
        # view's caller paths in practice; no anonymous Lines reach prod.
        author = request.user if request.user.is_authenticated else None
        nupost = Post.objects.create(
            title=_truncate_post_title(form.cleaned_data["text"]),
        )
        if request.user.is_authenticated:
            nupost.owner = request.user
            nupost.save()
        if author is not None:
            form.save(for_post=nupost, author=author)
        return redirect(nupost)
    else:
        context = {
            "form": form,
            "page_class": "page-billboard",
        }
        if request.user.is_authenticated:
            context["applets"] = applet_context(request.user, "billboard")
            context["recent_posts"] = _recent_posts(request.user)
            context["recent_buds"]  = _recent_buds(request.user)
            context["recent_notes"] = _recent_notes(request.user)
        return render(request, "apps/billboard/billboard.html", context)


def view_post(request, post_id):
    our_post = Post.objects.get(id=post_id)

    if our_post.owner:
        if not request.user.is_authenticated:
            return redirect("/")
        if request.user != our_post.owner and request.user not in our_post.shared_with.all():
            return HttpResponseForbidden()

    # Admin-Post (note-unlock thread) hard write-rejection — the per-Line
    # signal in billboard.models nukes any Line that bypasses this guard,
    # but at the view level we want a clean 403 so the FT/IT contract is
    # explicit and the client never sees a silent line vanish.
    if our_post.kind == Post.KIND_NOTE_UNLOCK and request.method == "POST":
        return HttpResponseForbidden()

    form = ExistingPostLineForm(for_post=our_post)

    if request.method == "POST":
        form = ExistingPostLineForm(for_post=our_post, data=request.POST)
        if form.is_valid():
            form.save(author=request.user)
            return redirect(our_post)

    # GET render is the FYI-read contract — flip every unread Brief on this
    # post for the requesting user. POST (compose) is intentionally excluded
    # because the user is authoring, not reviewing the new Line.
    if request.user.is_authenticated:
        Brief.objects.filter(
            owner=request.user, post=our_post, is_unread=True,
        ).update(is_unread=False)

    # Header-prose branching: post.html shows different self/shared lines
    # depending on whether the viewer IS the owner. The invitee branch
    # ("shared with me, @viewer …" + "created by @owner …") only kicks
    # in when (a) the viewer is authenticated AND (b) the post has an
    # owner AND (c) the viewer is NOT that owner. Ownerless posts and
    # anonymous viewers fall through to the owner-style rendering (which
    # handles missing data gracefully via the at_handle/display_name
    # filter guards).
    is_real_invitee = (
        request.user.is_authenticated
        and our_post.owner is not None
        and request.user != our_post.owner
    )
    viewer_is_owner = not is_real_invitee
    if is_real_invitee:
        other_recipients = our_post.shared_with.exclude(pk=request.user.pk)
    else:
        other_recipients = our_post.shared_with.all()

    return render(request, "apps/billboard/post.html", {
        "post": our_post,
        "form": form,
        "viewer_is_owner": viewer_is_owner,
        "other_recipients": other_recipients,
        "page_class": "page-billpost",
    })


def my_posts(request, user_id):
    owner = User.objects.get(id=user_id)
    if not request.user.is_authenticated:
        return redirect("/")
    if request.user.id != owner.id:
        return HttpResponseForbidden()
    return render(request, "apps/billboard/my_posts.html", {
        "owner": owner,
        "owner_posts_title":  "Posts by Me",
        "others_posts_title": "Posts by Others",
        "page_class": "page-billposts",
    })


@login_required
def delete_post(request, post_id):
    if request.method == "POST":
        post = Post.objects.get(id=post_id)
        if request.user == post.owner and post.kind != Post.KIND_NOTE_UNLOCK:
            post.delete()
    return redirect("billboard:my_posts", user_id=request.user.id)


@login_required
def abandon_post(request, post_id):
    if request.method == "POST":
        post = Post.objects.get(id=post_id)
        if post.kind != Post.KIND_NOTE_UNLOCK:
            post.shared_with.remove(request.user)
    return redirect("billboard:my_posts", user_id=request.user.id)


def share_post(request, post_id):
    our_post = Post.objects.get(id=post_id)
    is_ajax = "application/json" in request.headers.get("Accept", "")

    # Recipient may be email OR username — _resolve_recipient handles both
    # (email if "@" present, else username lookup). The raw value is kept
    # for the Line text since users see what they typed in the per-line
    # rendering (post-refresh + optimistic JS append).
    recipient_email = (request.POST.get("recipient") or "").strip()
    recipient = _resolve_recipient(recipient_email)

    # Sharer-tries-to-share-with-themselves: silent no-op (existing behavior).
    if recipient is not None and recipient == request.user:
        if is_ajax:
            return JsonResponse({"brief": None, "line_text": ""})
        return redirect(our_post)

    # Re-share dedup: if the recipient is already in shared_with (registered
    # email previously shared), skip the Line + Brief — silent no-op.
    # `add()` itself is idempotent on M2M, but we want the JSON response to
    # signal "nothing happened" so the JS can suppress the banner.
    is_reshare = recipient is not None and recipient in our_post.shared_with.all()

    if recipient is not None and not is_reshare:
        our_post.shared_with.add(recipient)
        # Implicit auto-add to the buds graph — symmetric on shared events
        # (per-spec): a share-event implies a mutual social link.
        # `add()` is idempotent on M2M, no need to pre-check membership.
        if request.user.is_authenticated:
            request.user.buds.add(recipient)
            recipient.buds.add(request.user)

    line = None
    brief = None
    line_text = ""
    if not is_reshare:
        # Plain "Shared with X" — timestamp display lives on the per-Line
        # `<time>` element, not in the prose. Author = sharer (post owner)
        # so the per-line "username" column attributes correctly. Anonymous
        # shares (legacy Percival ch. 19 ownerless-post path) fall back to
        # adman since AnonymousUser can't be FK'd. Privacy: we still create
        # the Line + Brief even when the address is unregistered, so the
        # response doesn't leak membership.
        line_text = f"Shared with {recipient_email}"
        author = request.user if request.user.is_authenticated else get_or_create_adman()
        line = Line.objects.create(
            post=our_post, text=line_text, author=author,
        )
        if request.user.is_authenticated:
            brief = Brief.objects.create(
                owner=request.user,
                post=our_post,
                line=line,
                kind=Brief.KIND_SHARE_INVITE,
                title="Invite sent",
            )

    if is_ajax:
        # recipient_display is populated only when the address resolves to a
        # registered User — same evidence the server-rendered .post-recipient
        # list exposes; doesn't widen the privacy surface beyond what the
        # post detail page already shows publicly.
        recipient_display = None
        recipient_user_id = None
        if recipient is not None:
            recipient_display = recipient.username or recipient.email
            recipient_user_id = str(recipient.id)
        return JsonResponse({
            "brief":             brief.to_banner_dict() if brief is not None else None,
            "line_text":         line_text,
            "recipient_display": recipient_display,
            "recipient_user_id": recipient_user_id,
            "already_present":   is_reshare,
        })

    messages.success(request, "An invite has been sent if that address is registered.")
    return redirect(our_post)


# ── My Buds ───────────────────────────────────────────────────────────────
# User.buds is an asymmetric self M2M (lyric/0004 + 0005 rename). `my_buds`
# is the manage-page; `add_bud` is the JSON endpoint hit by the bud-panel
# slide-out. Privacy: when an entered email isn't a registered User, we
# 200 with {bud: null} so the response shape doesn't leak membership.

@login_required(login_url="/")
def my_buds(request):
    return render(request, "apps/billboard/my_buds.html", {
        "buds": request.user.buds.all(),
        "page_class": "page-billbuds",
    })


def _resolve_recipient(raw):
    """Resolve a free-form recipient (email OR username) to a User, or None.
    Email match takes precedence — if the input contains '@' we don't even
    try the username lookup, so a username that happens to match an email
    user's local part doesn't get coerced. Used by add_bud + share_post."""
    raw = (raw or "").strip()
    if not raw:
        return None
    if "@" in raw:
        try:
            return User.objects.get(email__iexact=raw)
        except User.DoesNotExist:
            return None
    try:
        return User.objects.get(username__iexact=raw)
    except User.DoesNotExist:
        return None


@login_required(login_url="/")
def add_bud(request):
    if request.method != "POST":
        from django.http import HttpResponseNotAllowed
        return HttpResponseNotAllowed(["POST"])

    candidate = _resolve_recipient(request.POST.get("recipient"))

    bud = None
    already_present = False
    recipient_display = None
    recipient_user_id = None
    if candidate is not None and candidate != request.user:
        already_present = candidate in request.user.buds.all()
        if not already_present:
            request.user.buds.add(candidate)
        display = candidate.username or candidate.email
        bud = {
            "id":       str(candidate.id),
            "username": display,
            "email":    candidate.email,
        }
        recipient_display = display
        recipient_user_id = str(candidate.id)

    return JsonResponse({
        "bud":               bud,
        "already_present":   already_present,
        "recipient_display": recipient_display,
        "recipient_user_id": recipient_user_id,
    })


@login_required(login_url="/")
def search_buds(request):
    """Top-3 prefix-match autocomplete pool for #id_recipient inputs.
    Pulls only from request.user.buds — buds that haven't been added yet
    don't appear in the autocomplete (privacy-by-default; new buds enter
    the list via explicit add or implicit auto-add on share/invite).
    Matches case-insensitive on either username or email prefix."""
    from django.db.models import Q
    q = (request.GET.get("q") or "").strip()
    if not q:
        return JsonResponse({"buds": []})
    matches = (
        request.user.buds
        .filter(Q(username__istartswith=q) | Q(email__istartswith=q))
        .order_by("username", "email")[:3]
    )
    return JsonResponse({"buds": [
        {
            "id":       str(b.id),
            "username": b.username or b.email,
            "email":    b.email,
        }
        for b in matches
    ]})


@login_required(login_url="/")
def save_scroll_position(request, room_id):
    if request.method != "POST":
        from django.http import HttpResponseNotAllowed
        return HttpResponseNotAllowed(["POST"])
    room = Room.objects.get(id=room_id)
    position = int(request.POST.get("position", 0))
    ScrollPosition.objects.update_or_create(
        user=request.user, room=room,
        defaults={"position": position},
    )
    from django.http import HttpResponse
    return HttpResponse(status=204)
