"""End-to-end login flow over a secure request.

Exercises the real login form + CSRF + secure-session-cookie path (the app runs
behind TLS in every deployment, and CSRF_COOKIE_SECURE/SESSION_COOKIE_SECURE are
on), then confirms a migrated WordPress password authenticates through the real
login view and is transparently upgraded to the preferred hasher.
"""
import re

from django.conf import settings
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.urls import reverse

from accounts.tests.test_hashers import WP_FIXTURES

User = get_user_model()


class SecureLoginFlowTests(TestCase):
    def setUp(self):
        self.plain, self.wp_hash, _phpass = WP_FIXTURES[0]
        self.user = User.objects.create(username="migrated-owner")
        # Exactly how the importer will store a migrated WordPress hash.
        self.user.password = "wp_bcrypt$" + self.wp_hash
        self.user.save(update_fields=["password"])

    def test_login_page_renders_form(self):
        response = self.client.get(reverse("login"), secure=True)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "csrfmiddlewaretoken")
        self.assertContains(response, "Reset your password")
        # The redesigned page: brand inside the card, no masthead, no
        # instructions stating the obvious.
        self.assertContains(response, 'class="login-card"')
        self.assertNotContains(response, "Owners' sign in")
        self.assertNotContains(response, "Please sign in to view documents.")
        self.assertNotContains(response, 'class="site-header"')
        self.assertNotContains(response, 'class="site-footer"')

    def test_migrated_wordpress_password_logs_in_and_is_upgraded(self):
        response = self.client.post(
            reverse("login"),
            {"username": "migrated-owner", "password": self.plain},
            secure=True,
        )
        self.assertEqual(response.status_code, 302, "valid credentials should redirect")
        self.assertEqual(response.url, "/")
        self.assertIn("_auth_user_id", self.client.session)

        # Just-in-time migration: the stored hash is no longer the WordPress one.
        self.user.refresh_from_db()
        self.assertFalse(self.user.password.startswith("wp_bcrypt$"))
        self.assertTrue(self.user.password.startswith("argon2"))
        # And the upgraded hash still authenticates the same password.
        self.assertTrue(self.user.check_password(self.plain))

    def test_wrong_password_does_not_log_in(self):
        response = self.client.post(
            reverse("login"),
            {"username": "migrated-owner", "password": self.plain + "x"},
            secure=True,
        )
        self.assertEqual(response.status_code, 200)
        self.assertNotIn("_auth_user_id", self.client.session)

    def test_logged_in_owner_reaches_library(self):
        self.client.post(
            reverse("login"),
            {"username": "migrated-owner", "password": self.plain},
            secure=True,
        )
        response = self.client.get(reverse("documents:library"), secure=True)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "Documents")


class LoginSceneTests(TestCase):
    """The sign-in backdrop now comes from the database, so staff can manage it."""

    def make_photo(self, caption):
        import io

        from django.core.files.base import ContentFile
        from PIL import Image

        from documents.models import LoginPhoto

        buffer = io.BytesIO()
        Image.new("RGB", (12, 8), "teal").save(buffer, format="JPEG")
        photo = LoginPhoto(caption=caption)
        photo.image.save(caption, ContentFile(buffer.getvalue()), save=True)
        return photo

    def test_the_scene_lists_every_photograph(self):
        import shutil
        import tempfile

        tmp = tempfile.mkdtemp(prefix="hartling-scene-test-")
        self.addCleanup(lambda: shutil.rmtree(tmp, ignore_errors=True))
        with override_settings(PRIVATE_MEDIA_ROOT=tmp):
            photos = [self.make_photo("a.jpg"), self.make_photo("b.jpg")]
            body = self.client.get(reverse("login"), secure=True).content.decode()
            self.assertIn("login-scene", body)
            for photo in photos:
                self.assertIn(reverse("documents:login_photo", args=[photo.pk]), body)
            # Two layers however many photographs there are.
            self.assertEqual(body.count('class="login-layer"'), 2)

    def test_the_backdrop_never_waits_forever_on_a_decode(self):
        """A background tab must still get a backdrop.

        The cycle decodes each photograph before showing it, so a transition
        can never race a decode. But Chrome never settles `decode()` in a
        HIDDEN tab — so making the first paint depend on it left the sign-in
        page with no backdrop at all (both layers at opacity 0) until the tab
        was focused. Every load must therefore resolve on its own: decoded if
        that happens promptly, undecoded otherwise.
        """
        import re
        import shutil
        import tempfile

        tmp = tempfile.mkdtemp(prefix="hartling-scene-test-")
        self.addCleanup(lambda: shutil.rmtree(tmp, ignore_errors=True))
        with override_settings(PRIVATE_MEDIA_ROOT=tmp):
            self.make_photo("a.jpg")
            self.make_photo("b.jpg")
            body = self.client.get(reverse("login"), secure=True).content.decode()

        loader = re.search(r"const load = url =>.*?\n  \}\);", body, re.S)
        self.assertIsNotNone(loader, "the scene loader is no longer recognisable")
        loader = loader.group(0)
        # Three ways in, and every one of them settles the promise.
        self.assertIn("setTimeout(() => finish(", loader)   # decode stalled or slow
        self.assertIn("img.onerror = () => finish(false)", loader)  # photograph missing
        self.assertIn("finish(true)", loader)               # the happy path
        # ...and it can only ever settle once.
        self.assertIn("if (!done)", loader)
        # The guard must report readiness, not just unblock: sweeping a
        # photograph whose bytes have not arrived animates an empty layer and
        # then pops the image in — the jolt again, on a slow connection.
        self.assertIn("img.complete", loader)
        self.assertIn("naturalWidth", loader)
        scene = body[body.index("const load = url"):]
        self.assertIn("|| !ready ||", scene)                # intro shows plainly
        self.assertIn("if (!await load(url)) continue", scene)   # rotation skips

    def test_the_arriving_photograph_stacks_above_the_one_it_replaces(self):
        """The sweep has to be VISIBLE, not merely running.

        The two layers swap roles every cycle. With no z-index, stacking fell
        to DOM order, so the first layer always painted *underneath* the
        second: every second transition swept correctly but did it hidden
        behind the opaque outgoing photograph, and all you saw was the old one
        blinking off. That is the "nice, jolt, nice, jolt" alternation — and it
        is invisible to any check that only asks whether the animation ran.
        """
        import re

        css = (settings.BASE_DIR / "static" / "portal.css").read_text()

        def z_of(selector):
            block = re.search(
                re.escape(selector) + r"\s*\{(.*?)\}", css, re.S)
            self.assertIsNotNone(block, "%s is gone from the stylesheet" % selector)
            found = re.search(r"z-index:\s*(\d+)", block.group(1))
            self.assertIsNotNone(found, "%s has no z-index" % selector)
            return int(found.group(1))

        self.assertGreater(z_of(".login-layer.is-arriving"), z_of(".login-layer.is-front"))

    def test_only_photographs_marked_as_openers_may_appear_first(self):
        """A photograph can be lovely in the rotation and a poor first
        impression, so staff choose which may open."""
        import shutil
        import tempfile

        from documents.models import LoginPhoto

        tmp = tempfile.mkdtemp(prefix="hartling-scene-test-")
        self.addCleanup(lambda: shutil.rmtree(tmp, ignore_errors=True))
        with override_settings(PRIVATE_MEDIA_ROOT=tmp):
            plain = self.make_photo("a.jpg")
            opener = self.make_photo("b.jpg")
            opener.is_starter = True
            opener.save()
            self.assertEqual(list(LoginPhoto.openers()), [opener])

            wanted = reverse("documents:login_photo", args=[opener.pk])
            # The pick is random among openers, so ask repeatedly: with one
            # marked it must be that one every single time.
            for _ in range(8):
                body = self.client.get(reverse("login"), secure=True).content.decode()
                self.assertEqual(re.search(r'data-opening="([^"]*)"', body).group(1), wanted)
            # ...and the unmarked one is still in the rotation, just never first.
            self.assertIn(reverse("documents:login_photo", args=[plain.pk]), body)

    def test_with_nothing_marked_any_photograph_may_open(self):
        """The fallback matters: the scene must work uncurated, or a fresh
        install would have no opening image at all."""
        import shutil
        import tempfile

        from documents.models import LoginPhoto

        tmp = tempfile.mkdtemp(prefix="hartling-scene-test-")
        self.addCleanup(lambda: shutil.rmtree(tmp, ignore_errors=True))
        with override_settings(PRIVATE_MEDIA_ROOT=tmp):
            photos = [self.make_photo("a.jpg"), self.make_photo("b.jpg")]
            self.assertEqual(LoginPhoto.openers().count(), 2)
            allowed = {reverse("documents:login_photo", args=[p.pk]) for p in photos}
            body = self.client.get(reverse("login"), secure=True).content.decode()
            self.assertIn(re.search(r'data-opening="([^"]*)"', body).group(1), allowed)

    def test_the_opening_photograph_is_preloaded(self):
        """The entrance has a one-second budget and cannot spend it waiting on
        JavaScript to decide which file to request. The server picks the
        opening photograph so the browser can start fetching it from the head,
        in parallel with the stylesheet — otherwise the sweep starts late and
        the choreography falls apart on a cold load."""
        import shutil
        import tempfile

        tmp = tempfile.mkdtemp(prefix="hartling-scene-test-")
        self.addCleanup(lambda: shutil.rmtree(tmp, ignore_errors=True))
        with override_settings(PRIVATE_MEDIA_ROOT=tmp):
            photo = self.make_photo("a.jpg")
            body = self.client.get(reverse("login"), secure=True).content.decode()

        url = reverse("documents:login_photo", args=[photo.pk])
        head = body[:body.index("</head>")]
        self.assertIn('rel="preload"', head)
        self.assertIn(url, head)
        # ...and it is the same photograph the scene actually opens with.
        self.assertEqual(re.search(r'data-opening="([^"]*)"', body).group(1), url)

    def test_the_entrance_holds_on_the_logo_before_anything_else_moves(self):
        """The shape rian asked for: the logo fades softly up on solid colour
        and is allowed to sit there, and only then does the panel open and the
        photograph sweep in.

        The hold is a single custom property so the three beats cannot drift
        apart when one is changed — which is the whole reason they are
        expressed as `calc(var(--hold) + …)` rather than as literals.
        """
        import re as _re

        css = (settings.BASE_DIR / "static" / "portal.css").read_text()

        def rule(selector):
            block = _re.search(
                r"^" + _re.escape(selector) + r"\s*\{(.*?)\}", css, re.S | re.M)
            self.assertIsNotNone(block, "%s is gone" % selector)
            return block.group(1)

        hold = float(_re.search(r"--hold:\s*([\d.]+)s", rule("main.wrap.login-page")).group(1))
        self.assertGreaterEqual(hold, 0.4, "the logo no longer gets a hold")

        # Beat one IS the hold: the logo takes the whole of it to arrive.
        brand = rule(".login-brand")
        self.assertIn("var(--hold)", brand)

        # Beats two and three are expressed relative to it, never as literals.
        for selector in (".login-card", ".login-layer.is-arriving.is-intro"):
            self.assertIn("calc(var(--hold)", rule(selector),
                          "%s no longer waits for the hold" % selector)

        # ...and the whole thing still ends, rather than growing without limit.
        offset = lambda text: float(
            _re.search(r"calc\(var\(--hold\) \+ ([\d.]+)s", text).group(1))
        panel = hold + offset(rule(".login-card")) + 0.5
        sweep = hold + offset(rule(".login-layer.is-arriving.is-intro")) + 0.8
        self.assertLessEqual(max(panel, sweep), 2.3,
                             "entrance runs %.2fs — longer than intended" % max(panel, sweep))

    def test_the_entrance_plays_once_per_session(self):
        """A greeting, not a toll gate.

        It must not replay on a back-button return, and above all not on the
        re-render after a mistyped password — the entrance would then stand
        between someone and the form every time they got it wrong. The check
        has to sit in the HEAD: the beats are CSS animations that start at
        first paint, so deciding any later would flash a frame of the opening
        state before it could be cancelled.
        """
        import shutil
        import tempfile

        tmp = tempfile.mkdtemp(prefix="hartling-scene-test-")
        self.addCleanup(lambda: shutil.rmtree(tmp, ignore_errors=True))
        with override_settings(PRIVATE_MEDIA_ROOT=tmp):
            self.make_photo("a.jpg")
            body = self.client.get(reverse("login"), secure=True).content.decode()

        head = body[:body.index("</head>")]
        self.assertIn("sessionStorage", head)
        self.assertIn("no-entrance", head)
        # Storage can throw outright in private mode; the greeting is not worth
        # a broken page, so the failure has to be swallowed.
        self.assertIn("catch", head)

        # All three beats are cancelled, and the photograph skips its sweep.
        css = (settings.BASE_DIR / "static" / "portal.css").read_text()
        self.assertIn(".no-entrance .login-brand { animation: none; }", css)
        self.assertIn(".no-entrance .login-card { animation: none;", css)
        self.assertIn("if (still || !ready || seen)", body)

    def test_the_sweep_starts_completely_hidden(self):
        """Geometry, not taste — and it bites only when something pauses on it.

        The mask is 300% wide and its gradient is not transparent until 78%.
        At the old start position of 100% the viewport mapped onto 66.7-100% of
        that gradient, so the left third of the screen showed a band of the
        incoming photograph BEFORE the sweep began. At a 200ms delay nobody
        saw it; once the entrance held on the logo for a second it was plain.
        The start must land past 78%, and `no-repeat` keeps the region beyond
        the mask transparent rather than tiling back to opaque.
        """
        import re as _re

        css = (settings.BASE_DIR / "static" / "portal.css").read_text()
        arriving = _re.search(
            r"^\.login-layer\.is-arriving\s*\{(.*?)\}", css, re.S | re.M).group(1)
        self.assertIn("mask-repeat: no-repeat", arriving)

        frames = _re.search(r"@keyframes login-sweep\s*\{(.*?)\}\n", css, re.S).group(1)
        start = float(_re.search(r"from\s*\{[^}]*?[^-]mask-position:\s*([\d.]+)%", frames).group(1))
        size, opaque_until = 300.0, 78.0
        # Fraction of the gradient the viewport's left edge sits at.
        self.assertGreaterEqual(
            start * (size - 100) / 100 / size * 100, opaque_until,
            "the sweep starts %g%% — the photograph is partly visible before it runs" % start)

    def test_stillness_wins_over_the_entrance(self):
        """A media query adds no specificity, so a `prefers-reduced-motion`
        override written ABOVE the rule it means to beat simply loses the
        cascade. That is exactly what happened here: the entrance kept
        animating for someone who had asked for stillness. The override must
        come after the beats it cancels.
        """
        import re as _re

        css = (settings.BASE_DIR / "static" / "portal.css").read_text()
        entrance = css.index(".login-brand { animation:")
        stills = [m.start() for m in _re.finditer(
            r"@media \(prefers-reduced-motion: reduce\)", css)]
        after = [pos for pos in stills if pos > entrance]
        self.assertTrue(after, "no reduced-motion block after the entrance rules")
        block = css[after[0]:css.index("}\n", css.index("}", after[0]) + 1) + 1]
        self.assertIn(".login-card", block)
        self.assertIn(".login-brand", block)
        self.assertIn("animation: none", block)

    def test_no_photographs_means_no_scene_and_no_broken_images(self):
        body = self.client.get(reverse("login"), secure=True).content.decode()
        self.assertNotIn("login-scene", body)
        self.assertNotIn("login-layer", body)
        self.assertIn('class="login-card"', body)
