"""The password rules and the argon2 parameters (accounts plan §4.4).

12 to 256 characters after NFC normalisation, no composition rules, refused when it contains
the username or the email's local part; the parameters stay at or above the OWASP floor
(memory 19 MiB with time 2, or more); a generated password is 16 unambiguous characters in
four groups. Pure, no database.
"""

import re

import pytest

from app.services import passwords


class TestRules:
    def test_length(self):
        assert passwords.check_rules("twelve chars") == "twelve chars"
        with pytest.raises(passwords.PasswordRuleError) as exc:
            passwords.check_rules("elevenchars")
        assert exc.value.code == "PASSWORD_TOO_SHORT"
        with pytest.raises(passwords.PasswordRuleError) as exc:
            passwords.check_rules("x" * 257)
        assert exc.value.code == "PASSWORD_TOO_LONG"

    def test_identity_is_refused_case_insensitively(self):
        with pytest.raises(passwords.PasswordRuleError) as exc:
            passwords.check_rules("my name is Adam here", username="adam")
        assert exc.value.code == "PASSWORD_CONTAINS_IDENTITY"
        with pytest.raises(passwords.PasswordRuleError):
            passwords.check_rules("adam.levy at large", email="Adam.Levy@example.com")
        assert passwords.check_rules("nothing personal here", username="adam", email="adam@example.com")
        # A two-letter username cannot poison every password that contains those letters.
        assert passwords.check_rules("a long passphrase", username="al")

    def test_nfc_normalisation_makes_composed_and_decomposed_equal(self):
        composed = "café au lait forever"
        decomposed = "café au lait forever"
        assert passwords.check_rules(decomposed) == composed
        stored = passwords.hash_password(composed)
        assert passwords.verify_password(stored, decomposed)


class TestHashing:
    def test_parameters_are_at_or_above_the_owasp_floor(self):
        assert passwords.MEMORY_COST >= 19 * 1024 and passwords.TIME_COST >= 2 and passwords.PARALLELISM >= 1

    def test_verify_is_false_for_a_missing_or_malformed_hash_and_never_raises(self):
        assert passwords.verify_password(None, "anything at all") is False
        assert passwords.verify_password("not-a-hash", "anything at all") is False
        assert passwords.verify_password(passwords.hash_password("right answer here"), "wrong answer here") is False

    def test_generated_password_shape(self):
        for _ in range(20):
            value = passwords.generate_password()
            assert re.fullmatch(r"[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}", value)
            assert not set(value) & set("0O1lI")
            assert passwords.check_rules(value) == value
