"""Trusted-device enhancement tests.

Covers the behaviours added on top of the login-OTP gate: rolling expiration on
trusted sign-in, the new-device email notification, password-reset revocation +
cookie clearing, the Security → Trusted Devices management page (list / mark
current / remove one / remove all), the expired-device cleanup service and its
CLI command, and that every lifecycle event is emitted through the shared audit
logger.

Mirrors the self-contained unittest style of the rest of the suite; the login-OTP
gate is opted into per-instance (it is off by default under TestingConfig).
"""

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 trusted_devices
from app.auth.password_reset import complete_password_reset
from app.extensions import db, mail
from app.models import TrustedDevice, User
from app.models.base import utcnow

PASSWORD = "Passw0rd!"
COOKIE = "cc_trusted_device"


class TrustedDeviceTests(unittest.TestCase):
    def setUp(self):
        self.app = create_app("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 _extract_code(self, outbox):
        self.assertTrue(outbox, "expected an email to have been sent")
        match = re.search(r"\b(\d{6})\b", outbox[-1].body)
        self.assertIsNotNone(match)
        return match.group(1)

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

    def _trust_device(self, email="u@c.edu", client=None):
        """Run login → OTP → verify-with-trust, returning (verify_response, device_email_outbox)."""
        client = client or self.client
        _, outbox = self._login(email, client=client)
        code = self._extract_code(outbox)
        with mail.record_messages() as devbox:
            resp = client.post("/login/verify", data={"code": code, "trust_device": "y"})
        return resp, devbox

    # --- rolling expiration ----------------------------------------------------
    def test_rolling_expiration_on_trusted_login(self):
        user = self._user()
        self._trust_device()
        device = db.session.scalar(db.select(TrustedDevice))
        # Age the device: near-term expiry + stale last-used.
        device.expires_at = utcnow() + timedelta(days=1)
        device.last_used_at = utcnow() - timedelta(days=5)
        db.session.commit()

        # Log out (trust survives) and sign in again on the same browser.
        self.client.post("/logout")
        resp, outbox = self._login()
        self.assertEqual(len(outbox), 0)                      # trusted → no OTP email
        self.assertNotIn("/login/verify", resp.headers["Location"])

        db.session.refresh(device)
        # expires_at rolled forward to ~now + TRUSTED_DEVICE_DAYS (well beyond 1 day)...
        self.assertGreater(device.expires_at, utcnow() + timedelta(days=20))
        # ...and last_used_at refreshed to ~now.
        self.assertGreater(device.last_used_at, utcnow() - timedelta(minutes=1))

    # --- new-device email notification ----------------------------------------
    def test_new_device_email_sent(self):
        self._user()
        _, devbox = self._trust_device()
        self.assertEqual(len(devbox), 1)
        self.assertEqual(devbox[0].subject, "New device added to your Student Bazaar account")
        self.assertIn("trusted", devbox[0].body.lower())

    # --- password reset revokes all devices + clears cookie (service) ---------
    def test_password_reset_revokes_devices(self):
        user = self._user()
        self._trust_device()
        self.assertEqual(len(trusted_devices.list_active_devices(user)), 1)
        complete_password_reset(user, "NewPassw0rd!")
        # No active devices remain; the row is revoked (not deleted).
        self.assertEqual(len(trusted_devices.list_active_devices(user)), 0)
        device = db.session.scalar(db.select(TrustedDevice))
        self.assertIsNotNone(device.revoked_at)

    # --- password reset clears the browser cookie (full route flow) -----------
    def test_password_reset_route_clears_cookie(self):
        self._user("reset@c.edu")
        self._trust_device("reset@c.edu")
        self.client.post("/logout")   # keep the trusted cookie, drop the session

        # Step 1: request a reset code.
        with mail.record_messages() as outbox:
            self.client.post("/forgot-password", data={"email": "reset@c.edu"})
        code = self._extract_code(outbox)
        # Step 2: verify the reset code.
        self.client.post("/reset-password/verify", data={"code": code})
        # Step 3: set the new password — response should clear the trusted cookie.
        resp = self.client.post("/reset-password", data={
            "new_password": "NewPassw0rd!", "confirm_password": "NewPassw0rd!"})
        set_cookie = " ".join(resp.headers.getlist("Set-Cookie"))
        self.assertIn(COOKIE, set_cookie)
        self.assertIn("Max-Age=0", set_cookie)

    # --- cleanup service removes only expired rows ----------------------------
    def test_cleanup_expired_service(self):
        user = self._user()
        live = TrustedDevice(user_id=user.id, token_hash="a" * 64,
                             last_used_at=utcnow(), expires_at=utcnow() + timedelta(days=5))
        expired = TrustedDevice(user_id=user.id, token_hash="b" * 64,
                                last_used_at=utcnow() - timedelta(days=40),
                                expires_at=utcnow() - timedelta(days=1))
        db.session.add_all([live, expired])
        db.session.commit()

        removed = trusted_devices.cleanup_expired()
        self.assertEqual(removed, 1)
        remaining = db.session.scalars(db.select(TrustedDevice)).all()
        self.assertEqual([d.token_hash for d in remaining], ["a" * 64])

    # --- cleanup CLI command ---------------------------------------------------
    def test_cleanup_cli_command(self):
        user = self._user()
        db.session.add(TrustedDevice(user_id=user.id, token_hash="c" * 64,
                                     last_used_at=utcnow() - timedelta(days=40),
                                     expires_at=utcnow() - timedelta(days=1)))
        db.session.commit()
        result = self.app.test_cli_runner().invoke(args=["cleanup-trusted-devices"])
        self.assertEqual(result.exit_code, 0)
        self.assertIn("Removed 1", result.output)

    # --- management page lists devices and marks the current one --------------
    def test_devices_page_lists_and_marks_current(self):
        self._user()
        self._trust_device()
        html = self.client.get("/security/devices").get_data(as_text=True)
        self.assertIn("Trusted devices", html)
        self.assertIn(" on ", html)          # the "Browser on OS" label
        self.assertIn("This device", html)   # current device flagged

    # --- remove a single device (current → cookie cleared) --------------------
    def test_remove_current_device_clears_cookie(self):
        user = self._user()
        self._trust_device()
        device = db.session.scalar(db.select(TrustedDevice))
        resp = self.client.post(f"/security/devices/{device.id}/remove")
        self.assertEqual(resp.status_code, 302)
        db.session.refresh(device)
        self.assertIsNotNone(device.revoked_at)
        set_cookie = " ".join(resp.headers.getlist("Set-Cookie"))
        self.assertIn(COOKIE, set_cookie)
        self.assertIn("Max-Age=0", set_cookie)

    # --- a user cannot remove another user's device ---------------------------
    def test_cannot_remove_other_users_device(self):
        victim = self._user("victim@c.edu")
        other = TrustedDevice(user_id=victim.id, token_hash="d" * 64,
                              last_used_at=utcnow(), expires_at=utcnow() + timedelta(days=5))
        db.session.add(other)
        db.session.commit()
        # Sign in as a different (trusted) account and try to remove the victim's device.
        self._user("attacker@c.edu")
        self._trust_device("attacker@c.edu")
        self.client.post(f"/security/devices/{other.id}/remove")
        db.session.refresh(other)
        self.assertIsNone(other.revoked_at)  # untouched — ownership enforced

    # --- remove all devices ----------------------------------------------------
    def test_remove_all_devices(self):
        user = self._user()
        self._trust_device()
        # A second (older) device on the same account.
        db.session.add(TrustedDevice(user_id=user.id, token_hash="e" * 64,
                                     last_used_at=utcnow(), expires_at=utcnow() + timedelta(days=5)))
        db.session.commit()
        self.assertEqual(len(trusted_devices.list_active_devices(user)), 2)

        resp = self.client.post("/security/devices/remove-all")
        self.assertEqual(resp.status_code, 302)
        self.assertEqual(len(trusted_devices.list_active_devices(user)), 0)
        set_cookie = " ".join(resp.headers.getlist("Set-Cookie"))
        self.assertIn("Max-Age=0", set_cookie)

    # --- audit events reuse the shared logger ---------------------------------
    def test_audit_events_logged(self):
        self._user()
        with self.assertLogs("app", level="INFO") as cm:
            self._trust_device()
        joined = " ".join(cm.output)
        self.assertIn("trusted_device_created", joined)
        self.assertIn("login_otp_success", joined)

    def test_password_reset_revocation_audited(self):
        user = self._user()
        self._trust_device()
        with self.assertLogs("app", level="INFO") as cm:
            complete_password_reset(user, "NewPassw0rd!")
        self.assertIn("trusted_devices_revoked_after_password_reset", " ".join(cm.output))


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