"""Login OTP + trusted-device tests.

Covers the login-verification gate for untrusted devices end to end: the OTP is
emailed (never logging the user in first), a correct code signs them in, a
trusted device skips the OTP on the next sign-in, plus wrong/expired codes, the
verify-attempt cap, resend, and temporary account lockout.

The login-OTP gate is disabled under TestingConfig so the *existing* credential
logins across the suite stay deterministic; these tests opt in explicitly via
``LOGIN_OTP_ENABLED = True`` on their app instance (mirroring how CSRF is toggled
per-test).
"""

import os
import re
import unittest
from datetime import timedelta

os.environ["TEST_DATABASE_URL"] = "sqlite:///:memory:"

from werkzeug.security import generate_password_hash

from app import create_app
from app.auth import otp_service
from app.auth.otp_service import issue_verification_code
from app.extensions import db, mail
from app.models import EmailVerification, TrustedDevice, User, VerificationPurpose
from app.models.base import utcnow

LOGIN = VerificationPurpose.LOGIN_VERIFICATION
PASSWORD = "Passw0rd!"


class LoginOtpTests(unittest.TestCase):
    def setUp(self):
        self.app = create_app("testing")
        # Opt in to the login-OTP gate for these tests (off by default in testing).
        self.app.config["LOGIN_OTP_ENABLED"] = True
        self.ctx = self.app.app_context()
        self.ctx.push()
        db.create_all()
        self.client = self.app.test_client()

    def tearDown(self):
        db.session.remove()
        db.drop_all()
        self.ctx.pop()

    # --- helpers ---------------------------------------------------------------
    def _user(self, email="u@c.edu", *, verified=True):
        u = User(email=email, password_hash=generate_password_hash(PASSWORD), is_verified=verified)
        db.session.add(u)
        db.session.commit()
        return u

    def _by_email(self, email):
        return db.session.scalar(db.select(User).filter_by(email=email))

    def _extract_code(self, outbox):
        """Pull the 6-digit code out of the most recent verification email."""
        self.assertTrue(outbox, "expected a verification email to have been sent")
        match = re.search(r"\b(\d{6})\b", outbox[-1].body)
        self.assertIsNotNone(match, "no 6-digit code found in the email body")
        return match.group(1)

    def _login(self, email=None, password=PASSWORD, client=None):
        client = client or self.client
        email = email or "u@c.edu"
        with mail.record_messages() as outbox:
            resp = client.post("/login", data={"email": email, "password": password})
        return resp, outbox

    # --- untrusted device: OTP required, not logged in yet --------------------
    def test_login_untrusted_device_requires_otp(self):
        self._user("student@c.edu")
        resp, outbox = self._login("student@c.edu")
        self.assertEqual(resp.status_code, 302)
        self.assertIn("/login/verify", resp.headers["Location"])
        # An email was sent, with the login-specific subject...
        self.assertEqual(len(outbox), 1)
        self.assertEqual(outbox[0].subject, "Your Student Bazaar login code")
        # ...but the session is NOT authenticated until the OTP is confirmed.
        self.assertEqual(self.client.get("/profile").status_code, 302)

    # --- wrong credentials: no OTP, generic error -----------------------------
    def test_wrong_password_no_otp(self):
        self._user("student@c.edu")
        resp, outbox = self._login("student@c.edu", password="WRONGpass1!")
        self.assertEqual(len(outbox), 0)
        self.assertEqual(resp.status_code, 200)  # re-render with error, no redirect
        self.assertEqual(self.client.get("/profile").status_code, 302)

    # --- correct OTP logs the user in -----------------------------------------
    def test_correct_otp_logs_in(self):
        self._user("student@c.edu")
        _, outbox = self._login("student@c.edu")
        code = self._extract_code(outbox)
        resp = self.client.post("/login/verify", data={"code": code})
        self.assertEqual(resp.status_code, 302)
        self.assertNotIn("/login/verify", resp.headers["Location"])
        # Session is now authenticated.
        self.assertEqual(self.client.get("/profile").status_code, 200)

    # --- wrong OTP rejected, retry allowed ------------------------------------
    def test_wrong_otp_rejected(self):
        self._user("student@c.edu")
        self._login("student@c.edu")
        resp = self.client.post("/login/verify", data={"code": "000000"})
        self.assertEqual(resp.status_code, 200)  # stays on the verify page
        self.assertEqual(self.client.get("/profile").status_code, 302)  # still not logged in

    # --- expired OTP rejected --------------------------------------------------
    def test_expired_otp_rejected(self):
        user = self._user("student@c.edu")
        _, outbox = self._login("student@c.edu")
        code = self._extract_code(outbox)
        row = db.session.scalar(
            db.select(EmailVerification)
            .where(EmailVerification.user_id == user.id, EmailVerification.purpose == LOGIN)
            .order_by(EmailVerification.created_at.desc())
        )
        row.expires_at = utcnow() - timedelta(seconds=1)
        db.session.commit()
        resp = self.client.post("/login/verify", data={"code": code})
        self.assertEqual(resp.status_code, 200)
        self.assertEqual(self.client.get("/profile").status_code, 302)

    # --- verify-attempt cap disables the code ---------------------------------
    def test_verify_attempt_cap_invalidates_code(self):
        user = self._user("student@c.edu")
        self._login("student@c.edu")
        for _ in range(self.app.config["OTP_MAX_VERIFY_ATTEMPTS"]):
            self.client.post("/login/verify", data={"code": "111111"})
        self.assertFalse(otp_service.has_active_code(user, LOGIN))

    # --- trust this device: skips OTP on the next sign-in ---------------------
    def test_trusted_device_skips_otp(self):
        self._user("student@c.edu")
        _, outbox = self._login("student@c.edu")
        code = self._extract_code(outbox)
        # Verify AND trust this browser.
        resp = self.client.post("/login/verify", data={"code": code, "trust_device": "y"})
        self.assertEqual(resp.status_code, 302)
        # A trusted-device row now exists (token stored hashed, never raw).
        device = db.session.scalar(db.select(TrustedDevice))
        self.assertIsNotNone(device)
        self.assertEqual(len(device.token_hash), 64)
        # The cookie is HttpOnly.
        set_cookie = " ".join(resp.headers.getlist("Set-Cookie"))
        self.assertIn(self.app.config["TRUSTED_DEVICE_COOKIE"], set_cookie)
        self.assertIn("HttpOnly", set_cookie)

        # Log out (trust intentionally survives sign-out), then log in again on
        # the SAME client (which still holds the trusted cookie).
        self.client.post("/logout")
        resp2, outbox2 = self._login("student@c.edu")
        self.assertEqual(len(outbox2), 0)  # no OTP email this time
        self.assertNotIn("/login/verify", resp2.headers["Location"])
        self.assertEqual(self.client.get("/profile").status_code, 200)

    # --- raw device token is never stored -------------------------------------
    def test_device_token_not_stored_raw(self):
        self._user("student@c.edu")
        _, outbox = self._login("student@c.edu")
        code = self._extract_code(outbox)
        resp = self.client.post("/login/verify", data={"code": code, "trust_device": "y"})
        # Pull the raw token out of the Set-Cookie header and confirm it's absent
        # from the DB (only its salted hash is persisted).
        cookie_name = self.app.config["TRUSTED_DEVICE_COOKIE"]
        set_cookie = " ".join(resp.headers.getlist("Set-Cookie"))
        match = re.search(rf"{cookie_name}=([^;]+)", set_cookie)
        self.assertIsNotNone(match, "trusted-device cookie was not set")
        raw = match.group(1)
        device = db.session.scalar(db.select(TrustedDevice))
        self.assertIsNotNone(device)
        self.assertNotEqual(device.token_hash, raw)

    # --- another account's cookie is not trusted for this user ----------------
    def test_trust_is_per_user(self):
        self._user("a@c.edu")
        self._user("b@c.edu")
        # Trust the device for account A.
        _, outbox = self._login("a@c.edu")
        self.client.post("/login/verify", data={"code": self._extract_code(outbox), "trust_device": "y"})
        self.client.post("/logout")
        # Account B on the same browser must still be challenged with an OTP.
        resp, outbox_b = self._login("b@c.edu")
        self.assertEqual(len(outbox_b), 1)
        self.assertIn("/login/verify", resp.headers["Location"])

    # --- unverified user still hits the email-verification gate, not login OTP -
    def test_unverified_user_uses_email_gate(self):
        self._user("pending@c.edu", verified=False)
        resp, _ = self._login("pending@c.edu")
        self.assertEqual(resp.status_code, 302)
        self.assertIn("/verify-email", resp.headers["Location"])

    # --- OTP disabled → classic immediate login (regression guard) ------------
    def test_login_otp_disabled_logs_in_immediately(self):
        self.app.config["LOGIN_OTP_ENABLED"] = False
        self._user("student@c.edu")
        resp, outbox = self._login("student@c.edu")
        self.assertEqual(len(outbox), 0)
        self.assertNotIn("/login/verify", resp.headers["Location"])
        self.assertEqual(self.client.get("/profile").status_code, 200)

    # --- temporary account lockout after repeated failures --------------------
    def test_account_lockout(self):
        self.app.config["LOGIN_MAX_FAILED_ATTEMPTS"] = 3
        user = self._user("student@c.edu")
        for _ in range(3):
            self._login("student@c.edu", password="WRONGpass1!")
        db.session.refresh(user)
        self.assertIsNotNone(user.locked_until)
        # Even the CORRECT password is refused while locked (no OTP issued).
        resp, outbox = self._login("student@c.edu")
        self.assertEqual(len(outbox), 0)
        self.assertNotIn("/login/verify", resp.headers.get("Location", ""))
        self.assertEqual(resp.status_code, 200)

    # --- successful credential check resets the failure counter ---------------
    def test_failure_counter_resets_on_success(self):
        user = self._user("student@c.edu")
        self._login("student@c.edu", password="WRONGpass1!")
        db.session.refresh(user)
        self.assertEqual(user.failed_login_attempts, 1)
        # A correct password (which then goes to the OTP step) clears the counter.
        self._login("student@c.edu")
        db.session.refresh(user)
        self.assertEqual(user.failed_login_attempts, 0)

    # --- login code lifetime honours the per-purpose override -----------------
    def test_login_code_uses_login_expiry_minutes(self):
        user = self._user("student@c.edu")
        before = utcnow()
        self._login("student@c.edu")
        row = db.session.scalar(
            db.select(EmailVerification)
            .where(EmailVerification.user_id == user.id, EmailVerification.purpose == LOGIN)
        )
        lifetime_min = (row.expires_at - before).total_seconds() / 60
        # Should be ~LOGIN_OTP_EXPIRY_MINUTES (10), clearly longer than the 5-min default.
        self.assertGreater(lifetime_min, 6)

    # --- resend issues a fresh code, invalidating the previous one ------------
    def test_resend_invalidates_previous_code(self):
        user = self._user("student@c.edu")
        _, outbox = self._login("student@c.edu")
        first = self._extract_code(outbox)
        # Force a fresh code (bypassing the 60s cooldown) as the resend route does.
        second = issue_verification_code(user, LOGIN, enforce_cooldown=False, event="resent")
        self.assertNotEqual(first, second)
        ok_old, _ = otp_service.verify_code(user, LOGIN, first)
        self.assertFalse(ok_old)
        ok_new, _ = otp_service.verify_code(user, LOGIN, second)
        self.assertTrue(ok_new)


if __name__ == "__main__":
    unittest.main()
