"""ITs for the My Buds feature (User.buds M2M + my_buds view +
add_bud JSON endpoint).

User.buds is a self M2M (symmetrical=False) — adding Alice to Disco's
list does NOT auto-reciprocate. Implicit auto-add on shared events
(post-share, gate-invite) is layered separately in those views.

Privacy: add_bud returns 200 with {bud: null} when the email is
unregistered, so the response shape never leaks membership.
"""
from django.test import TestCase
from django.urls import reverse

from apps.lyric.models import User


class UserBudsM2MTest(TestCase):
    """The buds field is asymmetric — A.buds.add(B) doesn't
    reciprocate to B.buds, only to B.added_as_bud."""

    def setUp(self):
        self.disco = User.objects.create(email="disco@test.io")
        self.alice = User.objects.create(email="alice@test.io")

    def test_add_bud_one_way(self):
        self.disco.buds.add(self.alice)
        self.assertIn(self.alice, self.disco.buds.all())
        self.assertNotIn(self.disco, self.alice.buds.all())

    def test_added_as_bud_reverse_relation(self):
        self.disco.buds.add(self.alice)
        self.assertIn(self.disco, self.alice.added_as_bud.all())

    def test_add_is_idempotent(self):
        self.disco.buds.add(self.alice)
        self.disco.buds.add(self.alice)
        self.assertEqual(self.disco.buds.count(), 1)


class MyBudsViewTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="me@test.io")
        self.client.force_login(self.user)
        self.alice = User.objects.create(email="alice@test.io", username="alice")
        self.bob = User.objects.create(email="bob@test.io", username="bob")
        self.user.buds.add(self.alice, self.bob)

    def test_my_buds_renders_template(self):
        response = self.client.get(reverse("billboard:my_buds"))
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, "apps/billboard/my_buds.html")

    def test_my_buds_lists_users_buds(self):
        response = self.client.get(reverse("billboard:my_buds"))
        buds = list(response.context["buds"])
        self.assertIn(self.alice, buds)
        self.assertIn(self.bob, buds)

    def test_my_buds_does_not_list_others_buds(self):
        other = User.objects.create(email="other@test.io")
        carol = User.objects.create(email="carol@test.io", username="carol")
        other.buds.add(carol)
        response = self.client.get(reverse("billboard:my_buds"))
        self.assertNotIn(carol, list(response.context["buds"]))

    def test_my_buds_redirects_anon_to_login(self):
        self.client.logout()
        response = self.client.get(reverse("billboard:my_buds"))
        self.assertEqual(response.status_code, 302)


class AddBudViewTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="me@test.io", username="me")
        self.client.force_login(self.user)

    def test_add_registered_email_adds_to_buds(self):
        alice = User.objects.create(email="alice@test.io", username="alice")
        response = self.client.post(
            reverse("billboard:add_bud"),
            data={"recipient": "alice@test.io"},
        )
        self.assertEqual(response.status_code, 200)
        self.assertIn(alice, self.user.buds.all())

    def test_add_returns_bud_payload_with_username(self):
        User.objects.create(email="alice@test.io", username="alice")
        response = self.client.post(
            reverse("billboard:add_bud"),
            data={"recipient": "alice@test.io"},
        )
        body = response.json()
        self.assertIsNotNone(body["bud"])
        self.assertEqual(body["bud"]["username"], "alice")

    def test_add_unregistered_email_returns_null_bud(self):
        """Privacy: 200 with bud=null so the response shape doesn't leak
        whether the address is on the system."""
        response = self.client.post(
            reverse("billboard:add_bud"),
            data={"recipient": "ghost@test.io"},
        )
        self.assertEqual(response.status_code, 200)
        self.assertIsNone(response.json()["bud"])
        self.assertEqual(self.user.buds.count(), 0)

    def test_add_own_email_is_silent_noop(self):
        """Adding yourself: no bud added, response carries bud=null."""
        response = self.client.post(
            reverse("billboard:add_bud"),
            data={"recipient": "me@test.io"},
        )
        self.assertEqual(response.status_code, 200)
        self.assertIsNone(response.json()["bud"])
        self.assertNotIn(self.user, self.user.buds.all())

    def test_add_existing_bud_is_idempotent(self):
        alice = User.objects.create(email="alice@test.io", username="alice")
        self.user.buds.add(alice)
        response = self.client.post(
            reverse("billboard:add_bud"),
            data={"recipient": "alice@test.io"},
        )
        self.assertEqual(response.status_code, 200)
        # Still only one bud entry — M2M dedup
        self.assertEqual(self.user.buds.count(), 1)
        # Response still carries the bud payload (so the JS can refresh
        # an entry if a fast double-click bypassed the data-bud-id guard).
        self.assertIsNotNone(response.json()["bud"])

    def test_add_falls_back_to_email_when_no_username(self):
        """Bud payload returns email when bud.username is None — display
        layer matches the navbar fallback (display_name filter)."""
        User.objects.create(email="anon@test.io")
        response = self.client.post(
            reverse("billboard:add_bud"),
            data={"recipient": "anon@test.io"},
        )
        self.assertEqual(response.json()["bud"]["username"], "anon@test.io")

    def test_get_returns_405(self):
        response = self.client.get(reverse("billboard:add_bud"))
        self.assertEqual(response.status_code, 405)

    def test_add_returns_at_handle_and_title_for_tooltip_row(self):
        """The async-appended My Buds row mirrors _my_buds_item.html, so the
        payload must carry the bud's at_handle + active_title_display to fill
        the data-tt-* attrs — without them the new row's tooltip renders empty
        (the entries above it, server-rendered, have them). Regression
        2026-05-29."""
        alice = User.objects.create(email="alice@test.io", username="alice")
        body = self.client.post(
            reverse("billboard:add_bud"), data={"recipient": "alice"},
        ).json()
        self.assertEqual(body["bud"]["at_handle"], "@alice")
        self.assertEqual(body["bud"]["title"], alice.active_title_display)

    def test_add_resolves_username_too_not_just_email(self):
        """Phase 2: recipient field accepts usernames as well as emails."""
        alice = User.objects.create(email="alice@test.io", username="alice")
        response = self.client.post(
            reverse("billboard:add_bud"),
            data={"recipient": "alice"},
        )
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json()["bud"]["username"], "alice")
        self.assertIn(alice, self.user.buds.all())


class SearchBudsViewTest(TestCase):
    """Top-3 prefix-match autocomplete endpoint backing #id_recipient."""

    def setUp(self):
        self.user = User.objects.create(email="me@test.io", username="me")
        self.client.force_login(self.user)
        self.alice  = User.objects.create(email="alice@test.io",  username="alice")
        self.albert = User.objects.create(email="albert@test.io", username="albert")
        self.alvin  = User.objects.create(email="alvin@test.io",  username="alvin")
        self.bob    = User.objects.create(email="bob@test.io",    username="bob")
        self.user.buds.add(self.alice, self.albert, self.alvin, self.bob)

    def test_username_prefix_match(self):
        response = self.client.get(reverse("billboard:search_buds"), {"q": "al"})
        usernames = [b["username"] for b in response.json()["buds"]]
        # alice, albert, alvin all start with "al" — exactly 3 (cap)
        self.assertEqual(len(usernames), 3)
        self.assertIn("alice", usernames)
        self.assertIn("albert", usernames)
        self.assertIn("alvin", usernames)
        self.assertNotIn("bob", usernames)

    def test_caps_at_three_results(self):
        d = User.objects.create(email="alfred@test.io", username="alfred")
        self.user.buds.add(d)
        response = self.client.get(reverse("billboard:search_buds"), {"q": "al"})
        self.assertEqual(len(response.json()["buds"]), 3)

    def test_email_prefix_also_matches(self):
        response = self.client.get(reverse("billboard:search_buds"), {"q": "bob@"})
        usernames = [b["username"] for b in response.json()["buds"]]
        self.assertIn("bob", usernames)

    def test_does_not_leak_non_buds(self):
        """Non-buds (other registered users) don't appear in suggestions."""
        User.objects.create(email="stranger@test.io", username="stranger")
        response = self.client.get(reverse("billboard:search_buds"), {"q": "str"})
        self.assertEqual(response.json()["buds"], [])

    def test_empty_q_returns_empty_list(self):
        response = self.client.get(reverse("billboard:search_buds"), {"q": ""})
        self.assertEqual(response.json()["buds"], [])

    def test_anon_redirects(self):
        self.client.logout()
        response = self.client.get(reverse("billboard:search_buds"), {"q": "al"})
        self.assertEqual(response.status_code, 302)


class SharePostImplicitAutoAddTest(TestCase):
    """Per-spec: when a share lands a recipient on Post.shared_with, the
    sharer + recipient mutually auto-add each other to their buds lists."""

    def setUp(self):
        from apps.billboard.models import Post
        self.sharer = User.objects.create(email="sharer@test.io", username="sharer")
        self.client.force_login(self.sharer)
        self.alice = User.objects.create(email="alice@test.io", username="alice")
        self.post = Post.objects.create(owner=self.sharer)

    def _share(self, recipient):
        return self.client.post(
            reverse("billboard:share_post", args=[self.post.id]),
            data={"recipient": recipient},
            HTTP_ACCEPT="application/json",
        )

    def test_share_adds_recipient_to_sharer_buds(self):
        self._share("alice@test.io")
        self.assertIn(self.alice, self.sharer.buds.all())

    def test_share_adds_sharer_to_recipient_buds(self):
        """Symmetric on shared events — recipient also gets the sharer."""
        self._share("alice@test.io")
        self.assertIn(self.sharer, self.alice.buds.all())

    def test_share_with_username_also_auto_adds(self):
        self._share("alice")
        self.assertIn(self.alice, self.sharer.buds.all())
        self.assertIn(self.sharer, self.alice.buds.all())

    def test_unregistered_recipient_does_not_auto_add(self):
        """Privacy: unregistered email doesn't touch the buds graph."""
        self._share("ghost@test.io")
        self.assertEqual(self.sharer.buds.count(), 0)


class AddBudAlreadyPresentTest(TestCase):
    """Duplicate-add guard: add_bud's JSON response distinguishes "newly
    added" from "already a bud" so the bud-btn JS can render an error
    Brief titled `@<username> is already present` instead of the normal
    bud-entry append. `recipient_user_id` is the highlight target id that
    the Brief FYI button toggles `.bud-duplicate-flash` onto."""

    def setUp(self):
        self.user = User.objects.create(email="me@test.io", username="me")
        self.client.force_login(self.user)
        self.alice = User.objects.create(email="alice@test.io", username="alice")
        self.user.buds.add(self.alice)

    def _add(self, recipient):
        return self.client.post(
            reverse("billboard:add_bud"), {"recipient": recipient},
        )

    def test_already_present_true_when_candidate_is_already_a_bud(self):
        self.assertTrue(self._add("alice@test.io").json()["already_present"])

    def test_already_present_false_for_new_bud(self):
        User.objects.create(email="bob@test.io", username="bob")
        self.assertFalse(self._add("bob@test.io").json()["already_present"])

    def test_already_present_false_for_unregistered_email(self):
        self.assertFalse(self._add("ghost@test.io").json()["already_present"])

    def test_recipient_display_carries_username_on_duplicate(self):
        self.assertEqual(self._add("alice@test.io").json()["recipient_display"], "alice")

    def test_recipient_user_id_carries_alice_id_on_duplicate(self):
        self.assertEqual(
            self._add("alice@test.io").json()["recipient_user_id"],
            str(self.alice.id),
        )

    def test_username_typed_recipient_also_detects_duplicate(self):
        """A user typing 'alice' (no @) resolves the same way as the email."""
        self.assertTrue(self._add("alice").json()["already_present"])


class SharePostAlreadyPresentTest(TestCase):
    """Duplicate-share guard mirrors AddBudAlreadyPresentTest — when the
    recipient is already in post.shared_with, response carries
    already_present + recipient_user_id for the .post-recipient highlight."""

    def setUp(self):
        from apps.billboard.models import Post
        self.sharer = User.objects.create(email="sharer@test.io", username="sharer")
        self.client.force_login(self.sharer)
        self.alice = User.objects.create(email="alice@test.io", username="alice")
        self.post = Post.objects.create(owner=self.sharer)
        self.post.shared_with.add(self.alice)

    def _share(self, recipient):
        return self.client.post(
            reverse("billboard:share_post", args=[self.post.id]),
            data={"recipient": recipient},
            HTTP_ACCEPT="application/json",
        )

    def test_already_present_true_when_recipient_in_shared_with(self):
        self.assertTrue(self._share("alice@test.io").json()["already_present"])

    def test_already_present_false_for_new_recipient(self):
        User.objects.create(email="bob@test.io", username="bob")
        self.assertFalse(self._share("bob@test.io").json()["already_present"])

    def test_recipient_user_id_present_on_duplicate(self):
        self.assertEqual(
            self._share("alice@test.io").json()["recipient_user_id"],
            str(self.alice.id),
        )

    def test_recipient_display_already_present_on_duplicate(self):
        """`recipient_display` already exists on the success path; on
        duplicate the same field must carry the matched user's handle."""
        self.assertEqual(self._share("alice@test.io").json()["recipient_display"], "alice")
