"""Generic verification framework tests.

Exercises the framework independently of any single feature: generic
issue/verify, purpose isolation (a code for one purpose can't satisfy another),
password-reset readiness, explicit verification state, and audit logging that
records the purpose but never the code.
"""

import os
import unittest

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 (
    OtpRateLimited,
    issue_verification_code,
    requests_last_hour,
    verify_code,
)
from app.auth.services import register_user
from app.extensions import db
from app.models import EmailVerification, User, VerificationPurpose

EMAIL = VerificationPurpose.EMAIL_VERIFICATION
RESET = VerificationPurpose.PASSWORD_RESET
TWO_FACTOR = VerificationPurpose.TWO_FACTOR


class VerificationFrameworkTests(unittest.TestCase):
    def setUp(self):
        self.app = create_app("testing")
        self.ctx = self.app.app_context()
        self.ctx.push()
        db.create_all()

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

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

    # --- generic issue ---------------------------------------------------------
    def test_generic_issue_persists_hashed_code_with_purpose(self):
        user = self._user()
        code = issue_verification_code(user, RESET, enforce_cooldown=False)
        row = db.session.scalar(
            db.select(EmailVerification).where(EmailVerification.user_id == user.id))
        self.assertEqual(row.purpose, RESET)
        self.assertEqual(len(row.verification_token), 64)
        self.assertNotEqual(row.verification_token, code)

    # --- generic verify --------------------------------------------------------
    def test_generic_verify_success_and_single_use(self):
        user = self._user()
        code = issue_verification_code(user, TWO_FACTOR, enforce_cooldown=False)
        ok, error = verify_code(user, TWO_FACTOR, code)
        self.assertTrue(ok)
        self.assertIsNone(error)
        ok2, _ = verify_code(user, TWO_FACTOR, code)   # single-use
        self.assertFalse(ok2)

    # --- purpose isolation: wrong-purpose code is rejected --------------------
    def test_purpose_isolation_verify(self):
        user = self._user()
        reset_code = issue_verification_code(user, RESET, enforce_cooldown=False)
        # The reset code must NOT satisfy an email-verification check.
        ok, _ = verify_code(user, EMAIL, reset_code)
        self.assertFalse(ok)
        # ...but it verifies under its own purpose.
        ok_reset, _ = verify_code(user, RESET, reset_code)
        self.assertTrue(ok_reset)

    # --- purpose isolation: rate limits are per-purpose -----------------------
    def test_purpose_isolation_rate_limits(self):
        user = self._user()
        limit = self.app.config["OTP_MAX_PER_HOUR"]
        for _ in range(limit):
            issue_verification_code(user, EMAIL, enforce_cooldown=False)
        # Email purpose is now capped...
        with self.assertRaises(OtpRateLimited):
            issue_verification_code(user, EMAIL, enforce_cooldown=False)
        # ...but a different purpose has its own budget.
        code = issue_verification_code(user, RESET, enforce_cooldown=False)
        self.assertTrue(code)
        self.assertEqual(requests_last_hour(user, RESET), 1)
        self.assertEqual(requests_last_hour(user, EMAIL), limit)

    # --- password reset readiness (PART 5, no routes) -------------------------
    def test_password_reset_purpose_supported(self):
        user = self._user()
        code = issue_verification_code(user, RESET, enforce_cooldown=False)
        ok, _ = verify_code(user, RESET, code)
        self.assertTrue(ok)

    # --- explicit verification state ------------------------------------------
    def test_registration_is_explicitly_unverified(self):
        user = register_user("Reg User", "reg@c.edu", "Passw0rd!")
        self.assertFalse(user.is_verified)

    def test_no_implicit_verification_default(self):
        # A bare User() must NOT be silently verified (secure default).
        u = User(email="bare@c.edu", password_hash="x")
        db.session.add(u)
        db.session.commit()
        self.assertFalse(u.is_verified)

    def test_admin_cli_creates_verified_user(self):
        runner = self.app.test_cli_runner()
        result = runner.invoke(args=[
            "create-admin", "admin@c.edu", "--name", "Admin", "--password", "Passw0rd!"])
        self.assertEqual(result.exit_code, 0, result.output)
        admin = db.session.scalar(db.select(User).filter_by(email="admin@c.edu"))
        self.assertIsNotNone(admin)
        self.assertTrue(admin.is_verified)   # admin/seed helpers explicit True

    # --- audit logs purpose, never the code -----------------------------------
    def test_audit_logs_purpose_not_code(self):
        user = self._user()
        with self.assertLogs(self.app.logger, level="INFO") as cm:
            code = issue_verification_code(user, EMAIL, enforce_cooldown=False)
        output = "\n".join(cm.output)
        self.assertIn("verification.issued", output)
        self.assertIn("purpose=email_verification", output)
        self.assertNotIn(code, output)       # the code value is never logged


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