"""Security dashboard tests.

Covers the persistent security-event store and the features built on it: login
history, failed-login monitoring, recent activity, security score, expanded
timeline, Email-OTP two-factor (toggle + login gate), trusted-device rename /
remove-others, notification preferences, suspicious-activity detection, and the
login-history + printable report pages.

Self-contained unittest style, matching the rest of the suite. The login-OTP gate
is off by default under TestingConfig; tests that need it opt in per-instance.
"""

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 security_service
from app.auth.services import AuthEvent, log_auth_event
from app.extensions import db, mail
from app.models import SecurityEvent, TrustedDevice, User
from app.models.base import utcnow

PASSWORD = "Passw0rd!"
IPHONE_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit Mobile Safari"


class SecurityDashboardTests(unittest.TestCase):
    def setUp(self):
        self.app = create_app("testing")
        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 _login(self, email="u@c.edu", password=PASSWORD, ua=None, client=None):
        client = client or self.client
        headers = {"User-Agent": ua} if ua else {}
        return client.post("/login", data={"email": email, "password": password}, headers=headers)

    def _events(self, user, event=None):
        q = db.select(SecurityEvent).where(SecurityEvent.user_id == user.id)
        if event:
            q = q.where(SecurityEvent.event == event)
        return db.session.scalars(q).all()

    # --- persistence: login writes a SecurityEvent ----------------------------
    def test_login_persists_security_event(self):
        user = self._user()
        self._login()  # OTP off in testing -> immediate password login
        rows = self._events(user, AuthEvent.LOGIN_SUCCESS)
        self.assertEqual(len(rows), 1)
        self.assertEqual(rows[0].result, "success")
        self.assertEqual(rows[0].method, "password")
        self.assertIsNotNone(rows[0].ip_display)
        self.assertNotIn("127.0.0.1", rows[0].ip_display or "")  # masked, not raw

    def test_failed_login_persists_event(self):
        user = self._user()
        self._login(password="nope")
        self.assertEqual(len(self._events(user, AuthEvent.LOGIN_FAILED)), 1)
        summary = security_service.get_failed_login_summary(user)
        self.assertEqual(summary["count"], 1)

    # --- login history query + page -------------------------------------------
    def test_login_history_query_and_page(self):
        user = self._user()
        # Seed a success + a failure directly (one shared app-context can't host
        # two independent real logins — Flask-Login caches the user on the
        # context; this is a test-harness quirk, not app behaviour).
        with self.app.test_request_context("/", headers={"User-Agent": "Mozilla/5.0 (Windows) Chrome"}):
            log_auth_event(AuthEvent.LOGIN_SUCCESS, user=user, ip="1.2.3.4", method="password")
            log_auth_event(AuthEvent.LOGIN_FAILED, user=user, ip="1.2.3.5")
        hist = security_service.get_login_history(user, 1, 20)
        self.assertEqual(hist.total, 2)
        self._login()  # authenticate self.client so it can view the page
        page = self.client.get("/security/login-history")
        self.assertEqual(page.status_code, 200)
        self.assertIn("Login history", page.get_data(as_text=True))

    # --- failed-login alert shows on the security page ------------------------
    def test_failed_login_alert_visible(self):
        self._user()
        for _ in range(2):
            self._login(password="nope")
        # now sign in for real to view the page
        self._login()
        html = self.client.get("/security").get_data(as_text=True)
        self.assertIn("multiple unsuccessful sign-in attempts", html)

    # --- security score --------------------------------------------------------
    def test_security_score(self):
        user = self._user()
        result = security_service.compute_security_score(user)
        self.assertTrue(0 <= result["score"] <= 100)
        keys = {f["key"] for f in result["factors"]}
        self.assertEqual(keys, {"email", "twofa", "password", "activity", "devices"})
        # email verified factor should be satisfied
        self.assertTrue(next(f for f in result["factors"] if f["key"] == "email")["ok"])

    # --- expanded timeline includes persisted events --------------------------
    def test_timeline_includes_events(self):
        user = self._user()
        self._login()
        labels = [i["label"] for i in security_service.build_auth_timeline(user)]
        self.assertIn("Account created", labels)
        self.assertIn("Last login", labels)

    # --- 2FA enable / disable --------------------------------------------------
    def test_two_factor_enable_disable(self):
        user = self._user()
        self._login()
        self.client.post("/security/2fa/enable")
        db.session.refresh(user)
        self.assertTrue(user.two_factor_enabled)
        self.assertEqual(len(self._events(user, AuthEvent.TWO_FACTOR_ENABLED)), 1)
        # wrong password does not disable
        self.client.post("/security/2fa/disable", data={"password": "WRONGpass1!"})
        db.session.refresh(user)
        self.assertTrue(user.two_factor_enabled)
        # correct password disables
        self.client.post("/security/2fa/disable", data={"password": PASSWORD})
        db.session.refresh(user)
        self.assertFalse(user.two_factor_enabled)

    # --- 2FA gate forces OTP even when the global flag is off -----------------
    def test_two_factor_forces_otp(self):
        user = self._user()
        user.two_factor_enabled = True
        db.session.commit()
        # global login OTP is off in testing, but 2FA is on -> untrusted login
        # must go to the OTP step.
        resp = self._login()
        self.assertEqual(resp.status_code, 302)
        self.assertIn("/login/verify", resp.headers["Location"])
        self.assertEqual(self.client.get("/profile").status_code, 302)  # not logged in yet

    # --- trusted device rename -------------------------------------------------
    def test_device_rename(self):
        user = self._user()
        self._login()
        device = TrustedDevice(user_id=user.id, token_hash="a" * 64,
                               device_label="Chrome on Windows",
                               last_used_at=utcnow(), expires_at=utcnow() + timedelta(days=5))
        db.session.add(device)
        db.session.commit()
        self.client.post(f"/security/devices/{device.id}/rename", data={"name": "My laptop"})
        db.session.refresh(device)
        self.assertEqual(device.name, "My laptop")
        self.assertEqual(device.display_name, "My laptop")

    # --- remove others keeps the current device -------------------------------
    def test_remove_others(self):
        user = self._user()
        a = TrustedDevice(user_id=user.id, token_hash="a" * 64, last_used_at=utcnow(),
                          expires_at=utcnow() + timedelta(days=5))
        b = TrustedDevice(user_id=user.id, token_hash="b" * 64, last_used_at=utcnow(),
                          expires_at=utcnow() + timedelta(days=5))
        db.session.add_all([a, b])
        db.session.commit()
        from app.auth import trusted_devices
        kept = trusted_devices.revoke_all_for_user(user, except_device_id=a.id)
        self.assertEqual(kept, 1)  # only b revoked
        db.session.refresh(a)
        db.session.refresh(b)
        self.assertIsNone(a.revoked_at)
        self.assertIsNotNone(b.revoked_at)

    # --- notification preferences ---------------------------------------------
    def test_notification_prefs(self):
        user = self._user()
        self._login()
        # unchecking all boxes -> everything false
        self.client.post("/security/notifications", data={})
        prefs = security_service.get_notification_prefs(user)
        self.assertFalse(any(prefs.values()))
        # check two
        self.client.post("/security/notifications", data={"new_login": "on", "alerts": "on"})
        prefs = security_service.get_notification_prefs(user)
        self.assertTrue(prefs["new_login"])
        self.assertTrue(prefs["alerts"])
        self.assertFalse(prefs["password_changed"])

    # --- suspicious detection flags a new browser/device ----------------------
    def test_suspicious_new_device(self):
        user = self._user()
        # Seed a prior successful sign-in from a known desktop browser.
        with self.app.test_request_context("/", headers={"User-Agent": "Mozilla/5.0 (Windows NT 10) Chrome/120"}):
            log_auth_event(AuthEvent.LOGIN_SUCCESS, user=user, ip="203.0.113.1", method="password")
        # A real sign-in from a new browser + device should be flagged.
        with mail.record_messages() as outbox:
            self._login(ua=IPHONE_UA)
        self.assertEqual(len(self._events(user, AuthEvent.SUSPICIOUS_LOGIN)), 1)
        self.assertGreaterEqual(len(outbox), 1)  # a security email was sent

    def test_first_login_not_suspicious(self):
        user = self._user()
        self._login()
        self.assertEqual(len(self._events(user, AuthEvent.SUSPICIOUS_LOGIN)), 0)

    # --- password change emits event + notification ---------------------------
    def test_password_change_event_and_email(self):
        user = self._user()
        self._login()
        with mail.record_messages() as outbox:
            self.client.post("/security", data={
                "change_password": "1", "current_password": PASSWORD,
                "new_password": "NewPassw0rd!", "confirm_password": "NewPassw0rd!"})
        self.assertEqual(len(self._events(user, AuthEvent.PASSWORD_CHANGED)), 1)
        self.assertTrue(any("password" in m.subject.lower() for m in outbox))

    # --- report page renders ---------------------------------------------------
    def test_report_page(self):
        self._user()
        self._login()
        html = self.client.get("/security/report").get_data(as_text=True)
        self.assertIn("security report", html.lower())
        self.assertIn("Trusted devices", html)

    # --- recent activity feed --------------------------------------------------
    def test_recent_activity(self):
        user = self._user()
        self._login()
        activity = security_service.get_recent_activity(user)
        self.assertTrue(activity)
        self.assertIn("label", activity[0])
        self.assertIn("icon", activity[0])


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