import json
import lxml.html
from unittest.mock import patch, MagicMock

from django.contrib.messages import get_messages
from django.test import override_settings, TestCase
from django.urls import reverse
from django.utils import html, timezone

from apps.applets.models import Applet, UserApplet
from apps.billboard.forms import (
    DUPLICATE_LINE_ERROR,
    EMPTY_LINE_ERROR,
)
from apps.billboard.models import Line, Post
from apps.drama.models import Note
from apps.lyric.models import User


class HomePageTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="disco@test.io")
        self.client.force_login(self.user)

    def test_uses_home_template(self):
        response = self.client.get('/')
        self.assertTemplateUsed(response, 'apps/dashboard/home.html')

@override_settings(COMPRESS_ENABLED=False)
class NewPostTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="disco@test.io")
        self.client.force_login(self.user)
        Applet.objects.get_or_create(
            slug="new-post",
            defaults={"name": "New Post", "context": "billboard", "grid_cols": 9, "grid_rows": 3},
        )

    def test_can_save_a_POST_request(self):
        self.client.post("/billboard/new-post", data={"text": "A new post line"})
        self.assertEqual(Line.objects.count(), 1)
        new_line = Line.objects.get()
        self.assertEqual(new_line.text, "A new post line")

    def test_redirects_after_POST(self):
        response = self.client.post("/billboard/new-post", data={"text": "A new post line"})
        new_post = Post.objects.get()
        self.assertRedirects(response, f"/billboard/post/{new_post.id}/")

    # Post invalid input helper
    def post_invalid_input(self):
        return self.client.post("/billboard/new-post", data={"text": ""})

    def test_for_invalid_input_nothing_saved_to_db(self):
        self.post_invalid_input()
        self.assertEqual(Line.objects.count(), 0)

    def test_for_invalid_input_renders_billboard_template(self):
        response = self.post_invalid_input()
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, "apps/billboard/billboard.html")

    def test_for_invalid_input_shows_error_on_page(self):
        response = self.post_invalid_input()
        self.assertContains(response, html.escape(EMPTY_LINE_ERROR))

@override_settings(COMPRESS_ENABLED=False)
class PostViewTest(TestCase):
    def setUp(self):
        self.author = User.objects.create(email="author@test.io")
        # POST flows append a Line with author=request.user — non-null FK
        # since Line.author is required. force_login so the view's view_post
        # path saves cleanly; anonymous compose is no longer supported.
        self.client.force_login(self.author)

    def test_uses_post_template(self):
        mypost = Post.objects.create()
        response = self.client.get(f"/billboard/post/{mypost.id}/")
        self.assertTemplateUsed(response, "apps/billboard/post.html")

    def test_renders_input_form(self):
        mypost = Post.objects.create()
        url = f"/billboard/post/{mypost.id}/"
        response = self.client.get(url)
        parsed = lxml.html.fromstring(response.content)
        forms = parsed.cssselect("form[method=POST]")
        self.assertIn(url, [form.get("action") for form in forms])
        [form] = [form for form in forms if form.get("action") == url]
        inputs = form.cssselect("input")
        self.assertIn("text", [input.get("name") for input in inputs])

    def test_displays_only_lines_for_that_post(self):
        # Given/Arrange
        correct_post = Post.objects.create()
        Line.objects.create(text="itemey 1", post=correct_post, author=self.author)
        Line.objects.create(text="itemey 2", post=correct_post, author=self.author)
        other_post = Post.objects.create()
        Line.objects.create(text="other post line", post=other_post, author=self.author)
        # When/Act
        response = self.client.get(f"/billboard/post/{correct_post.id}/")
        # Then/Assert
        self.assertContains(response, "itemey 1")
        self.assertContains(response, "itemey 2")
        self.assertNotContains(response, "other post line")

    def test_can_save_a_POST_request_to_an_existing_post(self):
        other_post = Post.objects.create()
        correct_post = Post.objects.create()

        self.client.post(
            f"/billboard/post/{correct_post.id}/",
            data={"text": "A new line for an existing post"},
        )

        self.assertEqual(Line.objects.count(), 1)
        new_line = Line.objects.get()
        self.assertEqual(new_line.text, "A new line for an existing post")
        self.assertEqual(new_line.post, correct_post)

    def test_POST_redirects_to_post_view(self):
        other_post = Post.objects.create()
        correct_post = Post.objects.create()

        response = self.client.post(
            f"/billboard/post/{correct_post.id}/",
            data={"text": "A new line for an existing post"},
        )

        self.assertRedirects(response, f"/billboard/post/{correct_post.id}/")

    # Post invalid input helper
    def post_invalid_input(self):
        mypost = Post.objects.create()
        return self.client.post(f"/billboard/post/{mypost.id}/", data={"text": ""})

    def test_for_invalid_input_nothing_saved_to_db(self):
        self.post_invalid_input()
        self.assertEqual(Line.objects.count(), 0)

    def test_for_invalid_input_renders_post_template(self):
        response = self.post_invalid_input()
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, "apps/billboard/post.html")

    def test_for_invalid_input_shows_error_on_page(self):
        response = self.post_invalid_input()
        self.assertContains(response, html.escape(EMPTY_LINE_ERROR))

    def test_for_invalid_input_sets_is_invalid_class(self):
        response = self.post_invalid_input()
        parsed = lxml.html.fromstring(response.content)
        [input] = parsed.cssselect("input[name=text]")
        self.assertIn("is-invalid", set(input.classes))

    def test_duplicate_line_validation_errors_end_up_on_post_page(self):
        post1 = Post.objects.create()
        Line.objects.create(post=post1, text="lorem ipsum", author=self.author)

        response = self.client.post(
            f"/billboard/post/{post1.id}/",
            data={"text": "lorem ipsum"},
        )

        expected_error = html.escape(DUPLICATE_LINE_ERROR)
        self.assertContains(response, expected_error)
        self.assertTemplateUsed(response, "apps/billboard/post.html")
        self.assertEqual(Line.objects.all().count(), 1)

class MyPostsTest(TestCase):
    def test_my_posts_url_renders_my_posts_template(self):
        user = User.objects.create(email="a@b.cde")
        self.client.force_login(user)
        response = self.client.get(f"/billboard/users/{user.id}/")
        self.assertTemplateUsed(response, "apps/billboard/my_posts.html")

    def test_passes_correct_owner_to_template(self):
        User.objects.create(email="wrongowner@example.com")
        correct_user = User.objects.create(email="a@b.cde")
        self.client.force_login(correct_user)
        response = self.client.get(f"/billboard/users/{correct_user.id}/")
        self.assertEqual(response.context["owner"], correct_user)

    def test_post_owner_is_saved_if_user_is_authenticated(self):
        user = User.objects.create(email="a@b.cde")
        self.client.force_login(user)
        self.client.post("/billboard/new-post", data={"text": "new line"})
        new_post = Post.objects.get()
        self.assertEqual(new_post.owner, user)

    def test_my_posts_redirects_if_not_logged_in(self):
        user = User.objects.create(email="a@b.cde")
        response = self.client.get(f"/billboard/users/{user.id}/")
        self.assertRedirects(response, "/")

    def test_my_posts_returns_403_for_wrong_user(self):
        user1 = User.objects.create(email="a@b.cde")
        user2 = User.objects.create(email="wrongowner@example.com")
        self.client.force_login(user2)
        response = self.client.get(f"/billboard/users/{user1.id}/")
        self.assertEqual(response.status_code, 403)

class SharePostTest(TestCase):
    def test_post_to_share_post_url_redirects_to_post(self):
        our_post = Post.objects.create()
        alice = User.objects.create(email="alice@example.com")
        response = self.client.post(
            f"/billboard/post/{our_post.id}/share-post",
            data={"recipient": "alice@example.com"},
        )
        self.assertRedirects(response, f"/billboard/post/{our_post.id}/")

    def test_post_with_email_adds_user_to_shared_with(self):
        our_post = Post.objects.create()
        alice = User.objects.create(email="alice@example.com")
        self.client.post(
            f"/billboard/post/{our_post.id}/share-post",
            data={"recipient": "alice@example.com"},
        )
        self.assertIn(alice, our_post.shared_with.all())

    def test_post_with_nonexistent_email_redirects_to_post(self):
        our_post = Post.objects.create()
        response = self.client.post(
            f"/billboard/post/{our_post.id}/share-post",
            data={"recipient": "nobody@example.com"},
        )
        self.assertRedirects(
            response,
            f"/billboard/post/{our_post.id}/",
            fetch_redirect_response=False,
        )

    def test_share_post_does_not_add_owner_as_recipient(self):
        owner = User.objects.create(email="owner@example.com")
        our_post = Post.objects.create(owner=owner)
        self.client.force_login(owner)
        self.client.post(reverse("billboard:share_post", args=[our_post.id]),
                         data={"recipient": "owner@example.com"})
        self.assertNotIn(owner, our_post.shared_with.all())

    @override_settings(MESSAGE_STORAGE='django.contrib.messages.storage.session.SessionStorage')
    def test_share_post_shows_privacy_safe_message(self):
        our_post = Post.objects.create()
        response = self.client.post(
            f"/billboard/post/{our_post.id}/share-post",
            data={"recipient": "nobody@example.com"},
            follow=True,
        )
        messages = list(get_messages(response.wsgi_request))
        self.assertEqual(
            str(messages[0]),
            "An invite has been sent if that address is registered.",
        )

class ViewAuthPostTest(TestCase):
    def setUp(self):
        self.owner = User.objects.create(email="disco@example.com")
        self.our_post = Post.objects.create(owner=self.owner)

    def test_anonymous_user_is_redirected(self):
        response = self.client.get(reverse("billboard:view_post", args=[self.our_post.id]))
        self.assertRedirects(response, "/", fetch_redirect_response=False)

    def test_non_owner_non_shared_user_gets_403(self):
        stranger = User.objects.create(email="stranger@example.com")
        self.client.force_login(stranger)
        response = self.client.get(reverse("billboard:view_post", args=[self.our_post.id]))
        self.assertEqual(response.status_code, 403)

    def test_shared_with_user_can_access_post(self):
        guest = User.objects.create(email="guest@example.com")
        self.our_post.shared_with.add(guest)
        self.client.force_login(guest)
        response = self.client.get(reverse("billboard:view_post", args=[self.our_post.id]))
        self.assertEqual(response.status_code, 200)

@override_settings(COMPRESS_ENABLED=False)
class SetPaletteTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="a@b.cde")
        self.client.force_login(self.user)
        self.url = reverse("home")
        Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})

    def test_anonymous_user_is_redirected_home(self):
        response = self.client.post("/dashboard/set_palette")
        self.assertRedirects(response, "/", fetch_redirect_response=False)

    def test_set_palette_updates_user_palette(self):
        User.objects.filter(pk=self.user.pk).update(palette="palette-sheol")
        self.client.post("/dashboard/set_palette", data={"palette": "palette-default"})
        self.user.refresh_from_db()
        self.assertEqual(self.user.palette, "palette-default")

    def test_locked_palette_is_rejected(self):
        response = self.client.post("/dashboard/set_palette", data={"palette": "palette-bardo"})
        self.user.refresh_from_db()
        self.assertEqual(self.user.palette, "palette-default")
        self.assertRedirects(response, "/", fetch_redirect_response=False)

    def test_set_palette_redirects_home(self):
        response = self.client.post("/dashboard/set_palette", data={"palette": "palette-default"})
        self.assertRedirects(response, "/", fetch_redirect_response=False)

    def test_set_palette_returns_json_when_requested(self):
        response = self.client.post(
            "/dashboard/set_palette",
            data={"palette": "palette-cedar"},
            headers={"Accept": "application/json"},
        )
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json(), {"palette": "palette-cedar"})

    def test_locked_palette_returns_unchanged_json(self):
        response = self.client.post(
            "/dashboard/set_palette",
            data={"palette": "palette-bardo"},
            headers={"Accept": "application/json"},
        )
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json(), {"palette": "palette-default"})

    def test_unlocked_swatches_count_matches_context(self):
        response = self.client.get(self.url)
        parsed = lxml.html.fromstring(response.content)
        swatches = parsed.cssselect(".swatch:not(.locked)")
        unlocked = [p for p in response.context["palettes"] if not p["locked"]]
        self.assertEqual(len(swatches), len(unlocked))

    def test_active_palette_swatch_has_active_class(self):
        response = self.client.get(self.url)
        parsed = lxml.html.fromstring(response.content)
        [active] = parsed.cssselect(".swatch.active")
        self.assertIn("palette-default", active.classes)

    def test_locked_palettes_are_not_forms(self):
        response = self.client.get(self.url)
        parsed = lxml.html.fromstring(response.content)
        locked = parsed.cssselect(".swatch.locked")
        expected_locked = [p for p in response.context["palettes"] if p["locked"]]
        self.assertEqual(len(locked), len(expected_locked))
        # they mustn't be button els
        for swatch in locked:
            self.assertNotEqual(swatch.tag, "button")

    def test_palette_picker_count_matches_context(self):
        response = self.client.get(self.url)
        parsed = lxml.html.fromstring(response.content)
        swatches = parsed.cssselect(".swatch")
        self.assertEqual(len(swatches), len(response.context["palettes"]))

class NotePaletteContextTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="recog_palette@test.io")
        self.client.force_login(self.user)
        Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})

    def test_note_palette_unlocks_swatch_in_context(self):
        Note.objects.create(
            user=self.user, slug="stargazer", earned_at=timezone.now(),
            palette="palette-bardo",
        )
        response = self.client.get("/")
        palettes = response.context["palettes"]
        bardo = next(p for p in palettes if p["name"] == "palette-bardo")
        self.assertFalse(bardo["locked"])

    def test_note_palette_description_contains_note_title(self):
        Note.objects.create(
            user=self.user, slug="stargazer", earned_at=timezone.now(),
            palette="palette-bardo",
        )
        response = self.client.get("/")
        palettes = response.context["palettes"]
        bardo = next(p for p in palettes if p["name"] == "palette-bardo")
        self.assertIn("Stargazer", bardo["description"])

    def test_default_palette_description_is_available_by_default(self):
        response = self.client.get("/")
        palettes = response.context["palettes"]
        default = next(p for p in palettes if p["name"] == "palette-default")
        self.assertEqual(default["description"], "available by default")

    def test_locked_palette_description_is_explore_to_unlock(self):
        response = self.client.get("/")
        palettes = response.context["palettes"]
        bardo = next(p for p in palettes if p["name"] == "palette-bardo")
        self.assertEqual(bardo["description"], "explore to unlock")

    def test_note_palette_description_is_recognized_via(self):
        Note.objects.create(
            user=self.user, slug="stargazer", earned_at=timezone.now(),
            palette="palette-bardo",
        )
        response = self.client.get("/")
        palettes = response.context["palettes"]
        bardo = next(p for p in palettes if p["name"] == "palette-bardo")
        self.assertEqual(bardo["description"], "recognized via Stargazer")

    def test_note_palette_entry_includes_unlocked_date_iso(self):
        earned = timezone.now()
        Note.objects.create(
            user=self.user, slug="stargazer", earned_at=earned,
            palette="palette-bardo",
        )
        response = self.client.get("/")
        palettes = response.context["palettes"]
        bardo = next(p for p in palettes if p["name"] == "palette-bardo")
        self.assertIn("unlocked_date", bardo)
        self.assertEqual(bardo["unlocked_date"], earned.isoformat())

    def test_note_without_palette_field_keeps_swatch_locked(self):
        Note.objects.create(
            user=self.user, slug="stargazer", earned_at=timezone.now(),
            palette=None,
        )
        response = self.client.get("/")
        palettes = response.context["palettes"]
        bardo = next(p for p in palettes if p["name"] == "palette-bardo")
        self.assertTrue(bardo["locked"])

    def test_note_palette_allows_set_palette_via_view(self):
        Note.objects.create(
            user=self.user, slug="stargazer", earned_at=timezone.now(),
            palette="palette-bardo",
        )
        self.client.post("/dashboard/set_palette", data={"palette": "palette-bardo"})
        self.user.refresh_from_db()
        self.assertEqual(self.user.palette, "palette-bardo")


@override_settings(COMPRESS_ENABLED=False)
class ProfileViewTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="discoman@example.com")
        self.client.force_login(self.user)

    def test_post_username_saves_to_user(self):
        self.client.post("/dashboard/set_profile", data={"username": "discoman"})
        self.user.refresh_from_db()
        self.assertEqual(self.user.username, "discoman")

    def test_post_username_requires_login(self):
        self.client.logout()
        response = self.client.post("/dashboard/set_profile", data={"username": "somnambulist"})
        self.assertRedirects(response, "/?next=/dashboard/set_profile", fetch_redirect_response=False)

    def test_dash_renders_username_applet(self):
        response = self.client.get("/")
        parsed = lxml.html.fromstring(response.content)
        [applet] = parsed.cssselect("#id_applet_username")
        self.assertIn("@", applet.text_content())
        [input_el] = parsed.cssselect("#id_new_username")
        self.assertEqual("", input_el.get("value"))

    def test_dash_shows_display_name_in_applet(self):
        self.user.username = "discoman"
        self.user.save()
        response = self.client.get("/")
        parsed = lxml.html.fromstring(response.content)
        [username_input] = parsed.cssselect("#id_new_username")
        self.assertEqual("discoman", username_input.get("value"))

    def test_post_reserved_username_does_not_save(self):
        """RESERVED_USERNAMES (e.g. 'adman') must be rejected — the view bails
        with an error message + redirect to / before reaching user.save()."""
        original_username = self.user.username
        self.client.post("/dashboard/set_profile", data={"username": "adman"})
        self.user.refresh_from_db()
        self.assertEqual(self.user.username, original_username)

    def test_post_reserved_username_redirects_home(self):
        response = self.client.post("/dashboard/set_profile", data={"username": "adman"})
        self.assertRedirects(response, "/", fetch_redirect_response=False)


class KitBagViewTest(TestCase):
    """`kit_bag` view — renders the kit-bag panel partial w. equipped + token state."""

    def setUp(self):
        from apps.lyric.models import Token
        self.user = User.objects.create(email="gamer@test.io")
        self.client.force_login(self.user)
        # Stash a TITHE token so the list-comprehension branch lands non-empty
        Token.objects.create(user=self.user, token_type=Token.TITHE)
        self.url = "/dashboard/kit-bag/"

    def test_get_returns_200_and_renders_panel(self):
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, "core/_partials/_kit_bag_panel.html")

    def test_context_passes_free_and_tithe_counts(self):
        response = self.client.get(self.url)
        # signal seeds a FREE + COIN; we added a TITHE in setUp.
        self.assertEqual(response.context["tithe_count"], 1)
        self.assertGreaterEqual(response.context["free_count"], 0)

    def test_requires_login(self):
        self.client.logout()
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 302)
        self.assertIn("/?next=", response["Location"])

    def test_deck_section_renders_card_stack_svg_not_fa_id_badge(self):
        """Sprint A.4 follow-up — kit-bag Deck section uses the new card-deck
        SVG icon partial, replacing the prior `<i class="fa-regular fa-id-badge">`
        for both the equipped-deck branch and the placeholder branch."""
        import lxml.html
        response = self.client.get(self.url)
        parsed = lxml.html.fromstring(response.content)
        # Auto-equipped Earthman appears in the Deck section.
        deck = parsed.cssselect(".kit-bag-deck")[0]
        [_] = deck.cssselect("svg.deck-stack-icon")
        self.assertEqual(
            len(deck.cssselect("i.fa-id-badge")), 0,
            "fa-regular fa-id-badge must be gone from kit-bag-deck",
        )

    def test_no_deck_equipped_renders_placeholder_card_stack_icon(self):
        """Sprint A.4 follow-up — when the user has no equipped_deck, the
        kit-bag Deck section renders the same .deck-stack-icon SVG inside a
        .kit-bag-placeholder wrapper (no fan-out trigger; CSS dims it to
        --quaUser at 0.3 alpha to match the empty dice slot)."""
        import lxml.html
        # Clear the auto-equipped Earthman to land in the placeholder branch.
        self.user.equipped_deck = None
        self.user.save(update_fields=["equipped_deck"])
        response = self.client.get(self.url)
        parsed = lxml.html.fromstring(response.content)
        # equipped-deck branch is skipped → placeholder branch fires.
        self.assertEqual(
            len(parsed.cssselect(".kit-bag-deck")), 0,
            "No equipped deck → no .kit-bag-deck wrapper",
        )
        placeholders = parsed.cssselect(".kit-bag-section .kit-bag-placeholder")
        # First placeholder is the Deck slot (Dice + Trinket slots also use
        # .kit-bag-placeholder when empty); assert at least one carries the
        # card-stack SVG.
        deck_placeholder = placeholders[0]
        [_] = deck_placeholder.cssselect("svg.deck-stack-icon")
        # Old fa-id-badge must be gone.
        self.assertEqual(
            len(deck_placeholder.cssselect("i.fa-id-badge")), 0,
            "fa-regular fa-id-badge must be gone from kit-bag-placeholder",
        )

    def test_polarized_equipped_deck_tooltip_has_x2_decoration(self):
        """Same (x2) decoration as the gameboard applet — polarized decks
        signal segment-doubling in the tooltip card-count line via --terUser
        `.tt-x2` span. Element-presence assertion (the literal `×2` content
        is exercised by the parent template; this test only locks the conditional
        render on `is_polarized`)."""
        import lxml.html
        response = self.client.get(self.url)
        parsed = lxml.html.fromstring(response.content)
        deck = parsed.cssselect(".kit-bag-deck")[0]
        # Earthman is auto-equipped + is_polarized=True per A.0 migration.
        self.assertEqual(
            len(deck.cssselect(".tt-x2")), 1,
            "Polarized equipped deck must render .tt-x2 decoration in kit-bag tooltip",
        )


class ToggleDashAppletsViewTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="disco@test.io")
        self.client.force_login(self.user)
        self.username_applet, _ = Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
        self.palette_applet, _ = Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
        self.url = reverse("toggle_applets")

    def test_unauthenticated_user_is_redirected(self):
        self.client.logout()
        response = self.client.post(self.url)
        self.assertRedirects(
            response, f"/?next={self.url}", fetch_redirect_response=False
        )

    def test_unchecked_applet_gets_user_applet_with_visible_false(self):
        self.client.post(self.url, {"applets": ["username"]})
        ua = UserApplet.objects.get(user=self.user, applet=self.palette_applet)
        self.assertFalse(ua.visible)

    def test_redirects_on_normal_post(self):
        response = self.client.post(
            self.url, {"applets": ["username", "palette"]}
        )
        self.assertRedirects(response, reverse("home"), fetch_redirect_response=False)

    def test_returns_200_on_htmx_post(self):
        response = self.client.post(
            self.url,
            {"applets": ["username", "palette"]},
            HTTP_HX_REQUEST="true",
        )
        self.assertEqual(response.status_code, 200)

    def test_htmx_post_renders_visible_applets_only(self):
        response = self.client.post(
            self.url,
            {"applets": ["username"]},
            HTTP_HX_REQUEST="true",
        )
        parsed = lxml.html.fromstring(response.content)
        self.assertEqual(len(parsed.cssselect("#id_applet_username")), 1)
        self.assertEqual(len(parsed.cssselect("#id_applet_palette")), 0)

    def test_toggle_applets_does_not_affect_gameboard_applets(self):
        game_applet, _ = Applet.objects.get_or_create(
            slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
        )
        self.client.post(self.url, {"applets": ["username", "palette"]})
        self.assertFalse(UserApplet.objects.filter(user=self.user, applet=game_applet). exists())

class AppletVisibilityContextTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="disco@test.io")
        self.client.force_login(self.user)
        self.username_applet, _ = Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
        self.palette_applet, _ = Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
        UserApplet.objects.create(user=self.user, applet=self.palette_applet, visible=False)

    def test_dash_reflects_user_applet_visibility(self):
        response = self.client.get("/")
        applet_map = {entry["applet"].slug: entry["visible"] for entry in response.context["applets"]}
        self.assertFalse(applet_map["palette"])
        self.assertTrue(applet_map["username"])

class FooterNavTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="disco@test.io")
        self.client.force_login(self.user)

    def test_footer_nav_present_on_dashboard(self):
        response = self.client.get("/")
        parsed = lxml.html.fromstring(response.content)
        [nav] = parsed.cssselect("#id_footer_nav")
        self.assertIsNotNone(nav)

    def test_footer_nav_has_dashboard_link(self):
        response = self.client.get("/")
        parsed = lxml.html.fromstring(response.content)
        [nav] = parsed.cssselect("#id_footer_nav")
        links = [a.get("href") for a in nav.cssselect("a")]
        self.assertIn("/", links)

    def test_footer_nav_has_gameboard_link(self):
        response = self.client.get("/")
        parsed = lxml.html.fromstring(response.content)
        [nav] = parsed.cssselect("#id_footer_nav")
        links = [a.get("href") for a in nav.cssselect("a")]
        self.assertIn("/gameboard/", links)

class WalletAppletTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="disco@test.io")
        self.client.force_login(self.user)
        Applet.objects.get_or_create(slug="wallet", defaults={"name": "Wallet"})
        response = self.client.get("/")
        self.parsed = lxml.html.fromstring(response.content)

    def test_wallet_applet_present_on_dash(self):
        [_] = self.parsed.cssselect("#id_applet_wallet")

    def test_wallet_applet_has_manage_link(self):
        [link] = self.parsed.cssselect("#id_applet_wallet a.wallet-manage-link")
        self.assertEqual(link.get("href"), "/dashboard/wallet/")


ENRICHED_CHART = {
    "planets": {"Sun": {"lon": 10.0, "sign": "Aries", "house": 1, "degree": 10.0}},
    "houses": {"cusps": [float(i * 30) for i in range(12)]},
    "elements": {
        "Fire": {"count": 3, "contributors": ["Sun", "Mars", "Jupiter"]},
        "Stone": {"count": 1, "contributors": ["Venus"]},
        "Air": {"count": 2, "contributors": ["Mercury", "Uranus"]},
        "Water": {"count": 0, "contributors": []},
        "Time": {"count": 1, "stellia": ["Saturn"]},
        "Space": {"count": 1, "parades": ["Neptune"]},
    },
    "distinctions": [],
    "timezone": "UTC",
}

BIRTH_PAYLOAD = {
    "birth_dt": "1990-06-15T12:00:00Z",
    "birth_lat": 51.5,
    "birth_lon": -0.1,
    "birth_place": "London",
    "house_system": "O",
    "chart_data": {"stale": True},
}


@override_settings(PYSWISS_URL="http://pyswiss-test")
class SkySaveViewTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(
            email="disco@test.io",
            sky_birth_lat=51.5,
            sky_birth_lon=-0.1,
            sky_birth_place="London",
        )
        self.client.force_login(self.user)

    def _post(self, payload=None):
        return self.client.post(
            "/dashboard/sky/save",
            data=json.dumps(payload or BIRTH_PAYLOAD),
            content_type="application/json",
        )

    def test_save_stores_client_chart_data(self):
        """sky_save stores the chart_data from the client (already enriched by sky_preview)."""
        client_chart = {
            "planets": {},
            "houses": {"cusps": [float(i * 30) for i in range(12)], "asc": 123.4, "mc": 45.6},
            "elements": {"Fire": {"count": 1, "contributors": []}},
        }
        payload = dict(BIRTH_PAYLOAD, chart_data=client_chart)

        response = self._post(payload)

        self.assertEqual(response.status_code, 200)
        self.user.refresh_from_db()
        self.assertAlmostEqual(self.user.sky_chart_data["houses"]["asc"], 123.4)


class SkyDataViewTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="disco@test.io")
        self.client.force_login(self.user)

    def test_returns_stored_chart_with_asc_preserved(self):
        """sky_data returns sky_chart_data — asc must match what was saved."""
        stored = {
            "planets": {},
            "houses": {"cusps": [float(i * 30) for i in range(12)], "asc": 236.1, "mc": 159.1},
            "elements": {"Fire": {"count": 1, "contributors": []}},
            "aspects": [],
            "house_system": "O",
        }
        self.user.sky_chart_data = stored
        self.user.save(update_fields=["sky_chart_data"])

        response = self.client.get("/dashboard/sky/data")

        self.assertEqual(response.status_code, 200)
        data = json.loads(response.content)
        self.assertAlmostEqual(data["houses"]["asc"], 236.1)
        self.assertIn("distinctions", data)

    def test_returns_404_if_no_chart_data_saved(self):
        response = self.client.get("/dashboard/sky/data")
        self.assertEqual(response.status_code, 404)

    def test_requires_login(self):
        self.client.logout()
        response = self.client.get("/dashboard/sky/data")
        self.assertRedirects(response, "/?next=/dashboard/sky/data", fetch_redirect_response=False)


class SetPronounsViewTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="they@test.io")
        self.client.force_login(self.user)
        self.url = reverse("set_pronouns")

    def test_post_valid_choice_persists_on_user(self):
        response = self.client.post(self.url, data={"pronouns": "misogyny"})
        self.assertEqual(response.status_code, 204)
        self.user.refresh_from_db()
        self.assertEqual(self.user.pronouns, "misogyny")

    def test_post_each_valid_choice(self):
        """Every PRONOUN_TABLE key persists. Note: bawlmorese also triggers the
        Baltimorean Note-unlock JSON response (status 200) on first call —
        covered separately by SetPronounsBawlmoreseUnlockTest. Here we just
        confirm the user.pronouns write lands for each choice."""
        for key in ("pluralism", "bawlmorese", "misogyny", "misandry", "misanthropy"):
            with self.subTest(key=key):
                self.client.post(self.url, data={"pronouns": key})
                self.user.refresh_from_db()
                self.assertEqual(self.user.pronouns, key)

    def test_post_invalid_choice_returns_400_and_does_not_change_user(self):
        original = self.user.pronouns
        response = self.client.post(self.url, data={"pronouns": "bogus"})
        self.assertEqual(response.status_code, 400)
        self.user.refresh_from_db()
        self.assertEqual(self.user.pronouns, original)

    def test_get_returns_405(self):
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 405)

    def test_requires_login(self):
        self.client.logout()
        response = self.client.post(self.url, data={"pronouns": "misogyny"})
        self.assertEqual(response.status_code, 302)


class SetPronounsBawlmoreseUnlockTest(TestCase):
    """First-time selection of `pronouns = bawlmorese` grants the Baltimorean
    Note + returns a Brief banner payload (similar to sky_save's stargazer
    unlock). Subsequent selections are idempotent — no new Note, no new
    Brief, status 204 like other pronoun choices."""

    def setUp(self):
        self.user = User.objects.create(email="aaron@bawlmore.io")
        self.client.force_login(self.user)
        self.url = reverse("set_pronouns")

    def test_first_bawlmorese_returns_200_with_brief_json(self):
        response = self.client.post(self.url, data={"pronouns": "bawlmorese"})
        self.assertEqual(response.status_code, 200)
        data = response.json()
        self.assertIn("brief", data)
        self.assertIsNotNone(data["brief"])

    def test_first_bawlmorese_grants_baltimorean_note(self):
        self.client.post(self.url, data={"pronouns": "bawlmorese"})
        self.assertTrue(
            Note.objects.filter(user=self.user, slug="baltimorean").exists()
        )

    def test_brief_payload_carries_baltimorean_title(self):
        response = self.client.post(self.url, data={"pronouns": "bawlmorese"})
        brief = response.json()["brief"]
        self.assertEqual(brief["title"], "Baltimorean")

    def test_brief_payload_square_url_jumps_to_my_notes(self):
        """NOTE_UNLOCK Briefs carry a `square_url` pointing at /billboard/my-notes/
        so the banner's `?` square jumps straight to the user's Note list."""
        response = self.client.post(self.url, data={"pronouns": "bawlmorese"})
        brief = response.json()["brief"]
        self.assertEqual(brief["square_url"], "/billboard/my-notes/")

    def test_second_bawlmorese_is_idempotent_no_new_note(self):
        self.client.post(self.url, data={"pronouns": "bawlmorese"})
        self.client.post(self.url, data={"pronouns": "bawlmorese"})
        self.assertEqual(
            Note.objects.filter(user=self.user, slug="baltimorean").count(), 1
        )

    def test_second_bawlmorese_returns_204(self):
        """No fresh Brief on the repeat call — status returns to the no-payload
        204 that other pronoun choices use."""
        self.client.post(self.url, data={"pronouns": "bawlmorese"})
        response = self.client.post(self.url, data={"pronouns": "bawlmorese"})
        self.assertEqual(response.status_code, 204)

    def test_non_bawlmorese_choice_does_not_grant_baltimorean(self):
        self.client.post(self.url, data={"pronouns": "misogyny"})
        self.assertFalse(
            Note.objects.filter(user=self.user, slug="baltimorean").exists()
        )

    def test_non_bawlmorese_choice_still_returns_204(self):
        response = self.client.post(self.url, data={"pronouns": "misogyny"})
        self.assertEqual(response.status_code, 204)

    def test_bawlmorese_after_other_choice_still_grants_note(self):
        """User picks misogyny first, then bawlmorese — the first bawlmorese
        click is still the unlock moment regardless of prior pronoun history."""
        self.client.post(self.url, data={"pronouns": "misogyny"})
        response = self.client.post(self.url, data={"pronouns": "bawlmorese"})
        self.assertEqual(response.status_code, 200)
        self.assertTrue(
            Note.objects.filter(user=self.user, slug="baltimorean").exists()
        )
