"""
Tests for lastpass_vault.passphrase — passphrase generation.

All randomness in the implementation uses ``secrets`` (CSPRNG).
These tests verify correctness of structure, entropy calculations,
validation, and — critically — that the ``random`` module is never
called.
"""

import dataclasses
import math
import secrets
import pytest

from lastpass_vault import (
    generate_passphrase,
    passphrase_entropy,
    check_passphrase_strength,
    PassphraseStrength,
    SEP_SPACE,
    SEP_SLASH,
    SEP_TILDE,
    SEP_NONE,
    SEP_DASH,
    SEP_DOT,
    SAFE_SEPARATORS,
)
from lastpass_vault.passphrase import (
    _load_wordlist,
    _WORDLIST_CACHE,
    _entropy_rating,
    DEFAULT_SPECIAL_CHARS,
    WORDLIST_LARGE,
    WORDLIST_SHORT1,
    WORDLIST_SHORT2,
)


# ===========================================================================
# Wordlist loading and integrity
# ===========================================================================

def test_wordlist_large_length():
    assert len(_load_wordlist(WORDLIST_LARGE)) == 7776


def test_wordlist_short1_length():
    assert len(_load_wordlist(WORDLIST_SHORT1)) == 1296


def test_wordlist_short2_length():
    assert len(_load_wordlist(WORDLIST_SHORT2)) == 1296


def test_wordlist_no_blank_entries():
    for name in (WORDLIST_LARGE, WORDLIST_SHORT1, WORDLIST_SHORT2):
        for word in _load_wordlist(name):
            assert word.strip() != "", f"Blank entry in wordlist '{name}'"


def test_wordlist_all_lowercase():
    for name in (WORDLIST_LARGE, WORDLIST_SHORT1, WORDLIST_SHORT2):
        for word in _load_wordlist(name):
            assert word == word.lower(), (
                f"Non-lowercase word {word!r} in wordlist '{name}'"
            )


def test_wordlist_caching_returns_same_object():
    """Second call must return the identical list object — no re-read."""
    _WORDLIST_CACHE.clear()
    first = _load_wordlist(WORDLIST_LARGE)
    second = _load_wordlist(WORDLIST_LARGE)
    assert first is second


def test_wordlist_invalid_name_raises():
    with pytest.raises(ValueError, match="wordlist must be one of"):
        _load_wordlist("nonexistent")


def test_wordlist_short2_unique_three_char_prefixes():
    """EFF short list #2 guarantees unique 3-character prefixes."""
    words = _load_wordlist(WORDLIST_SHORT2)
    prefixes = [w[:3] for w in words]
    assert len(prefixes) == len(set(prefixes)), (
        "EFF short list #2 must have unique 3-char prefixes"
    )


# ===========================================================================
# Public API importable from package root
# ===========================================================================

def test_generate_passphrase_importable():
    from lastpass_vault import generate_passphrase as gp
    assert callable(gp)


def test_passphrase_entropy_importable():
    from lastpass_vault import passphrase_entropy as pe
    assert callable(pe)


# ===========================================================================
# generate_passphrase — basic structure
# ===========================================================================

def test_default_produces_six_space_separated_tokens():
    result = generate_passphrase()
    tokens = result.split(SEP_SPACE)
    assert len(tokens) == 6


def test_word_count_respected():
    for n in (1, 3, 5, 8):
        result = generate_passphrase(num_words=n, separator=SEP_SPACE)
        assert len(result.split(SEP_SPACE)) == n


def test_single_word_no_separator():
    result = generate_passphrase(num_words=1)
    assert SEP_SPACE not in result


def test_words_come_from_wordlist():
    """Every token (before any extras) must be in the chosen wordlist."""
    words_set = set(_load_wordlist(WORDLIST_LARGE))
    for _ in range(10):
        result = generate_passphrase(num_words=4, separator=SEP_SLASH,
                                     capitalize="none",
                                     num_digits=0, num_special=0)
        for token in result.split(SEP_SLASH):
            assert token in words_set, f"Token {token!r} not in wordlist"


def test_custom_separator_space():
    result = generate_passphrase(num_words=4, separator=" ")
    assert len(result.split(" ")) == 4


def test_custom_separator_empty_string():
    result = generate_passphrase(num_words=3, separator="")
    assert " " not in result
    assert result != ""


def test_custom_separator_multi_char():
    result = generate_passphrase(num_words=3, separator=" :: ")
    assert len(result.split(" :: ")) == 3


# ===========================================================================
# Separator constants and collision validation
# ===========================================================================

def test_sep_constants_have_expected_values():
    assert SEP_SPACE == " "
    assert SEP_SLASH == "/"
    assert SEP_TILDE == "~"
    assert SEP_NONE == ""
    assert SEP_DASH == "-"
    assert SEP_DOT == "."


def test_safe_separators_frozenset_contents():
    assert SEP_SPACE in SAFE_SEPARATORS
    assert SEP_SLASH in SAFE_SEPARATORS
    assert SEP_TILDE in SAFE_SEPARATORS
    assert SEP_NONE in SAFE_SEPARATORS
    assert SEP_DASH not in SAFE_SEPARATORS
    assert SEP_DOT not in SAFE_SEPARATORS


def test_safe_separators_no_conflict_with_default_special_chars():
    """Every separator in SAFE_SEPARATORS must share no characters with DEFAULT_SPECIAL_CHARS."""
    default_set = set(DEFAULT_SPECIAL_CHARS)
    for sep in SAFE_SEPARATORS:
        conflicts = set(sep) & default_set
        assert not conflicts, (
            f"SAFE_SEPARATORS contains {sep!r} which shares {sorted(conflicts)!r} "
            f"with DEFAULT_SPECIAL_CHARS"
        )


def test_sep_dash_conflicts_with_default_special_chars():
    assert "-" in DEFAULT_SPECIAL_CHARS, (
        "SEP_DASH ('-') must be in DEFAULT_SPECIAL_CHARS to trigger collision"
    )


def test_sep_dot_conflicts_with_default_special_chars():
    assert "." in DEFAULT_SPECIAL_CHARS, (
        "SEP_DOT ('.') must be in DEFAULT_SPECIAL_CHARS to trigger collision"
    )


def test_sep_space_with_num_special_does_not_raise():
    """SEP_SPACE is safe with num_special > 0 and DEFAULT_SPECIAL_CHARS."""
    result = generate_passphrase(num_words=4, separator=SEP_SPACE,
                                 num_special=2)
    assert result  # no exception raised


def test_sep_slash_with_num_special_does_not_raise():
    result = generate_passphrase(num_words=4, separator=SEP_SLASH,
                                 num_special=2)
    assert result


def test_sep_tilde_with_num_special_does_not_raise():
    result = generate_passphrase(num_words=4, separator=SEP_TILDE,
                                 num_special=2)
    assert result


def test_sep_none_with_num_special_does_not_raise():
    result = generate_passphrase(num_words=4, separator=SEP_NONE,
                                 num_special=2)
    assert result


def test_sep_dash_with_num_special_default_charset_raises():
    """SEP_DASH conflicts with DEFAULT_SPECIAL_CHARS — must raise ValueError."""
    with pytest.raises(ValueError, match="separator"):
        generate_passphrase(num_words=4, separator=SEP_DASH, num_special=1)


def test_sep_dot_with_num_special_default_charset_raises():
    """SEP_DOT conflicts with DEFAULT_SPECIAL_CHARS — must raise ValueError."""
    with pytest.raises(ValueError, match="separator"):
        generate_passphrase(num_words=4, separator=SEP_DOT, num_special=1)


def test_sep_dash_with_num_special_zero_does_not_raise():
    """When num_special=0 there is no conflict regardless of separator."""
    result = generate_passphrase(num_words=4, separator=SEP_DASH, num_special=0)
    assert result


def test_sep_dash_with_restricted_charset_does_not_raise():
    """SEP_DASH is safe when special_char_set explicitly excludes '-'."""
    result = generate_passphrase(num_words=4, separator=SEP_DASH,
                                 num_special=2, special_char_set="!@#$")
    assert result


def test_collision_error_message_names_safe_alternatives():
    """The ValueError should mention the safe separator constants."""
    with pytest.raises(ValueError) as exc_info:
        generate_passphrase(num_words=4, separator=SEP_DASH, num_special=1)
    msg = str(exc_info.value)
    assert "SEP_SPACE" in msg
    assert "SEP_SLASH" in msg or "SEP_NONE" in msg


def test_sep_constants_exported_from_package():
    """All separator constants must be importable from the top-level package."""
    import lastpass_vault
    for name in ("SEP_SPACE", "SEP_SLASH", "SEP_TILDE", "SEP_NONE",
                 "SEP_DASH", "SEP_DOT", "SAFE_SEPARATORS"):
        assert hasattr(lastpass_vault, name), (
            f"{name} not exported from lastpass_vault"
        )


# ===========================================================================
# generate_passphrase — CSPRNG enforcement
# ===========================================================================

def test_random_module_never_called():
    """The ``random`` module must not be touched during generation."""
    import random as _random
    calls = []
    original = _random.random
    _random.random = lambda: calls.append(1) or 0.5
    try:
        generate_passphrase(num_words=6, num_digits=2, num_special=2)
    finally:
        _random.random = original
    assert calls == [], (
        "random.random() was called; passphrase generation must use secrets only"
    )


def test_secrets_choice_called_for_word_selection(monkeypatch):
    calls = []
    original_choice = secrets.choice
    def spy_choice(seq):
        calls.append(seq)
        return original_choice(seq)
    monkeypatch.setattr(secrets, "choice", spy_choice)
    generate_passphrase(num_words=4, num_digits=0, num_special=0)
    assert len(calls) >= 4


def test_secrets_randbelow_called_for_insertion_positions(monkeypatch):
    """randbelow must be used to pick insertion positions for digit/special tokens."""
    calls = []
    original = secrets.randbelow
    def spy(n):
        calls.append(n)
        return original(n)
    monkeypatch.setattr(secrets, "randbelow", spy)
    # 2 digits + 2 specials = 4 position picks via randbelow
    generate_passphrase(num_words=3, num_digits=2, num_special=2, special_char_set="!@")
    assert len(calls) >= 4


# ===========================================================================
# generate_passphrase — capitalize modes
# ===========================================================================

def test_capitalize_none_all_lowercase():
    for _ in range(20):
        result = generate_passphrase(num_words=4, separator=SEP_SLASH,
                                     capitalize="none",
                                     num_digits=0, num_special=0)
        for token in result.split(SEP_SLASH):
            assert token == token.lower(), (
                f"capitalize='none' produced uppercase token: {token!r}"
            )


def test_capitalize_first_only_first_token():
    for _ in range(20):
        result = generate_passphrase(num_words=5, separator=SEP_SLASH,
                                     capitalize="first",
                                     num_digits=0, num_special=0)
        tokens = result.split(SEP_SLASH)
        assert tokens[0][0].isupper(), "First token not capitalised"
        for token in tokens[1:]:
            assert token == token.lower(), (
                f"capitalize='first' capitalised non-first token: {token!r}"
            )


def test_capitalize_all_every_token_capitalised():
    result = generate_passphrase(num_words=5, separator=SEP_SLASH,
                                 capitalize="all",
                                 num_digits=0, num_special=0)
    for token in result.split(SEP_SLASH):
        assert token[0].isupper(), (
            f"capitalize='all' left token uncapitalised: {token!r}"
        )


def test_capitalize_random_exactly_one_word_capitalised():
    """Exactly one word token must be title-cased; digit/special tokens excluded."""
    words_set = set(_load_wordlist(WORDLIST_LARGE))
    for _ in range(50):
        result = generate_passphrase(num_words=6, separator=SEP_SLASH,
                                     capitalize="random",
                                     num_digits=0, num_special=0)
        word_tokens = [t for t in result.split(SEP_SLASH) if t.lower() in words_set]
        capitalised = [t for t in word_tokens if t[0].isupper()]
        assert len(capitalised) == 1, (
            f"Expected exactly 1 capitalised word, got {len(capitalised)}: {result!r}"
        )


def test_capitalize_random_varies_position():
    """The capitalised word must not always be the same position."""
    positions = set()
    for _ in range(200):
        result = generate_passphrase(num_words=6, separator=SEP_SLASH,
                                     capitalize="random",
                                     num_digits=0, num_special=0)
        tokens = result.split(SEP_SLASH)
        for i, t in enumerate(tokens):
            if t[0].isupper():
                positions.add(i)
    # After 200 draws from 6 positions we expect more than one unique position.
    assert len(positions) > 1, (
        "capitalize='random' always chose the same position — not random"
    )


# ===========================================================================
# generate_passphrase — digit tokens
# ===========================================================================

def test_num_digits_count():
    result = generate_passphrase(num_words=4, num_digits=3, separator=SEP_SLASH)
    tokens = result.split(SEP_SLASH)
    digit_tokens = [t for t in tokens if t.isdigit() and len(t) == 1]
    assert len(digit_tokens) == 3
    assert len(tokens) == 7  # 4 words + 3 digits


def test_digits_are_single_characters():
    result = generate_passphrase(num_words=3, num_digits=5, separator=SEP_SPACE)
    for t in result.split(SEP_SPACE):
        if t.isdigit():
            assert len(t) == 1, f"Digit token {t!r} is not a single character"


def test_zero_digits_produces_no_digit_tokens():
    for _ in range(10):
        result = generate_passphrase(num_words=4, separator=SEP_SLASH,
                                     num_digits=0, num_special=0, capitalize="none")
        for t in result.split(SEP_SLASH):
            assert not t.isdigit(), f"Unexpected digit token {t!r}"


def test_digits_vary_in_position():
    """Digit tokens must not always appear at the same position."""
    positions_of_first_digit = set()
    for _ in range(100):
        result = generate_passphrase(num_words=4, num_digits=1, separator=SEP_SPACE)
        tokens = result.split(SEP_SPACE)
        for i, t in enumerate(tokens):
            if t.isdigit():
                positions_of_first_digit.add(i)
                break
    assert len(positions_of_first_digit) > 1, (
        "Digit token always inserted at the same position — not random"
    )


# ===========================================================================
# generate_passphrase — special-character tokens
# ===========================================================================

def test_num_special_count():
    char_set = "!@#"
    result = generate_passphrase(num_words=4, num_special=2,
                                 separator=SEP_SPACE, special_char_set=char_set)
    tokens = result.split(SEP_SPACE)
    special_tokens = [t for t in tokens if t in char_set]
    assert len(special_tokens) == 2
    assert len(tokens) == 6  # 4 words + 2 specials


def test_special_chars_drawn_from_custom_set():
    char_set = "XYZ"
    result = generate_passphrase(num_words=3, num_special=4,
                                 separator=SEP_SPACE, special_char_set=char_set)
    specials = [t for t in result.split(SEP_SPACE) if t in set(char_set)]
    assert len(specials) == 4
    for t in specials:
        assert t in char_set


def test_zero_specials_produces_no_special_tokens():
    default_set = set(DEFAULT_SPECIAL_CHARS)
    for _ in range(10):
        result = generate_passphrase(num_words=4, separator=SEP_SLASH,
                                     num_digits=0, num_special=0, capitalize="none")
        for t in result.split(SEP_SLASH):
            assert t not in default_set, (
                f"Unexpected special-char token {t!r} with num_special=0"
            )


def test_special_chars_vary_in_position():
    positions_of_first_special = set()
    char_set = "!"
    for _ in range(100):
        result = generate_passphrase(num_words=5, num_special=1,
                                     separator=SEP_SPACE, special_char_set=char_set)
        tokens = result.split(SEP_SPACE)
        for i, t in enumerate(tokens):
            if t in char_set:
                positions_of_first_special.add(i)
                break
    assert len(positions_of_first_special) > 1


# ===========================================================================
# generate_passphrase — combined extras
# ===========================================================================

def test_combined_words_digits_and_specials():
    result = generate_passphrase(num_words=4, num_digits=2, num_special=2,
                                 separator=SEP_SPACE, special_char_set="!@#$")
    tokens = result.split(SEP_SPACE)
    assert len(tokens) == 8  # 4 + 2 + 2


def test_combined_all_options():
    """Smoke test: all options active at once must not raise."""
    result = generate_passphrase(
        num_words=5,
        separator=" ",
        wordlist=WORDLIST_SHORT2,
        capitalize="random",
        num_digits=1,
        num_special=1,
        special_char_set="!#",
    )
    tokens = result.split(" ")
    assert len(tokens) == 7  # 5 words + 1 digit + 1 special


# ===========================================================================
# generate_passphrase — input validation
# ===========================================================================

@pytest.mark.parametrize("kwargs,match", [
    ({"num_words": 0},                                         "num_words must be >= 1"),
    ({"num_words": -3},                                        "num_words must be >= 1"),
    ({"num_words": 1, "num_digits": -1},                       "num_digits must be >= 0"),
    ({"num_words": 1, "num_special": -1},                      "num_special must be >= 0"),
    ({"num_words": 1, "capitalize": "Title"},                  "capitalize must be one of"),
    ({"num_words": 1, "capitalize": "FIRST"},                  "capitalize must be one of"),
    ({"num_words": 1, "wordlist": "huge"},                     "wordlist must be one of"),
    ({"num_words": 1, "num_special": 1, "special_char_set": ""}, "special_char_set must not be empty"),
])
def test_generate_passphrase_invalid_inputs(kwargs, match):
    with pytest.raises(ValueError, match=match):
        generate_passphrase(**kwargs)


# ===========================================================================
# passphrase_entropy — formula correctness
# ===========================================================================

def test_entropy_large_6_words_baseline():
    e = passphrase_entropy(6, wordlist=WORDLIST_LARGE)
    expected = 6 * math.log2(7776)
    assert math.isclose(e, expected, rel_tol=1e-9)


def test_entropy_large_4_words():
    e = passphrase_entropy(4, wordlist=WORDLIST_LARGE)
    assert math.isclose(e, 4 * math.log2(7776), rel_tol=1e-9)


def test_entropy_short1_4_words():
    e = passphrase_entropy(4, wordlist=WORDLIST_SHORT1)
    assert math.isclose(e, 4 * math.log2(1296), rel_tol=1e-9)


def test_entropy_short2_4_words():
    e = passphrase_entropy(4, wordlist=WORDLIST_SHORT2)
    assert math.isclose(e, 4 * math.log2(1296), rel_tol=1e-9)


def test_entropy_with_two_digits():
    # k=0: pool=3 words, slots=4; k=1: pool=4, slots=5
    e = passphrase_entropy(3, num_digits=2)
    expected = (
        3 * math.log2(7776)
        + math.log2(10) + math.log2(4)
        + math.log2(10) + math.log2(5)
    )
    assert math.isclose(e, expected, rel_tol=1e-9)


def test_entropy_with_one_special():
    e = passphrase_entropy(3, num_special=1, special_char_set="!@#")
    # k=0: pool=3, slots=4, char_set_size=3
    expected = 3 * math.log2(7776) + math.log2(3) + math.log2(4)
    assert math.isclose(e, expected, rel_tol=1e-9)


def test_entropy_digits_and_specials_combined():
    # 2 words, 1 digit (k=0: pool=2, slots=3), 1 special (k=0: pool=3, slots=4)
    e = passphrase_entropy(2, num_digits=1, num_special=1, special_char_set="!@")
    expected = (
        2 * math.log2(7776)
        + math.log2(10) + math.log2(3)
        + math.log2(2) + math.log2(4)
    )
    assert math.isclose(e, expected, rel_tol=1e-9)


def test_entropy_capitalize_random_adds_log2_num_words():
    e_random = passphrase_entropy(4, capitalize="random")
    e_none = passphrase_entropy(4, capitalize="none")
    assert math.isclose(e_random - e_none, math.log2(4), rel_tol=1e-9)


def test_entropy_capitalize_first_equals_none():
    assert math.isclose(
        passphrase_entropy(4, capitalize="first"),
        passphrase_entropy(4, capitalize="none"),
        rel_tol=1e-9,
    )


def test_entropy_capitalize_all_equals_none():
    assert math.isclose(
        passphrase_entropy(4, capitalize="all"),
        passphrase_entropy(4, capitalize="none"),
        rel_tol=1e-9,
    )


def test_entropy_capitalize_random_single_word_no_extra_bits():
    """log2(1) == 0 so a single-word phrase gains nothing from random capitalize."""
    e_random = passphrase_entropy(1, capitalize="random")
    e_none = passphrase_entropy(1, capitalize="none")
    assert math.isclose(e_random, e_none, rel_tol=1e-9)


def test_entropy_increases_with_more_words():
    assert passphrase_entropy(5) > passphrase_entropy(4)


def test_entropy_increases_with_digits():
    assert passphrase_entropy(4, num_digits=1) > passphrase_entropy(4)


def test_entropy_increases_with_specials():
    assert (
        passphrase_entropy(4, num_special=1)
        > passphrase_entropy(4)
    )


def test_entropy_larger_special_set_gives_more_bits():
    small = passphrase_entropy(4, num_special=1, special_char_set="!@")
    large = passphrase_entropy(4, num_special=1, special_char_set="!@#$%^&*")
    assert large > small


def test_entropy_6_words_large_exceeds_77_bits():
    """NIST-aligned sanity check: ~77.5 bits for 6 EFF large words."""
    assert passphrase_entropy(6) > 77.0


# ===========================================================================
# passphrase_entropy — input validation
# ===========================================================================

@pytest.mark.parametrize("kwargs,match", [
    ({"num_words": 0},                                          "num_words must be >= 1"),
    ({"num_words": 1, "num_digits": -1},                        "num_digits must be >= 0"),
    ({"num_words": 1, "num_special": -1},                       "num_special must be >= 0"),
    ({"num_words": 1, "capitalize": "bad"},                     "capitalize must be one of"),
    ({"num_words": 1, "wordlist": "bad"},                       "wordlist must be one of"),
    ({"num_words": 1, "num_special": 1, "special_char_set": ""}, "special_char_set must not be empty"),
])
def test_passphrase_entropy_invalid_inputs(kwargs, match):
    with pytest.raises(ValueError, match=match):
        passphrase_entropy(**kwargs)


# ===========================================================================
# _entropy_rating helper
# ===========================================================================

@pytest.mark.parametrize("bits,expected_label", [
    (0.1,   "very weak"),
    (39.9,  "very weak"),
    (40.0,  "weak"),
    (59.9,  "weak"),
    (60.0,  "fair"),
    (79.9,  "fair"),
    (80.0,  "strong"),
    (99.9,  "strong"),
    (100.0, "very strong"),
    (200.0, "very strong"),
])
def test_entropy_rating_thresholds(bits, expected_label):
    assert _entropy_rating(bits) == expected_label


# ===========================================================================
# check_passphrase_strength — return type and basic fields
# ===========================================================================

def test_strength_returns_passphrase_strength_instance():
    result = check_passphrase_strength("test-phrase-here", entropy_bits=77.5)
    assert isinstance(result, PassphraseStrength)


def test_strength_entropy_bits_preserved():
    result = check_passphrase_strength("some-words", entropy_bits=64.6)
    assert math.isclose(result.entropy_bits, 64.6)


def test_strength_entropy_rating_correct():
    result = check_passphrase_strength("some-words", entropy_bits=64.6)
    assert result.entropy_rating == "fair"


def test_strength_nist_compliant_true_at_80_bits():
    result = check_passphrase_strength("some-phrase", entropy_bits=80.0)
    assert result.nist_compliant is True


def test_strength_nist_compliant_false_below_80_bits():
    result = check_passphrase_strength("some-phrase", entropy_bits=79.9)
    assert result.nist_compliant is False


def test_strength_is_frozen_dataclass():
    """PassphraseStrength must be immutable."""
    result = check_passphrase_strength("phrase", entropy_bits=77.5)
    with pytest.raises((dataclasses.FrozenInstanceError, TypeError, AttributeError)):
        result.entropy_bits = 0.0  # type: ignore[misc]


def test_strength_invalid_entropy_bits():
    with pytest.raises(ValueError, match="entropy_bits must be a positive finite number"):
        check_passphrase_strength("phrase", entropy_bits=0.0)


def test_strength_invalid_entropy_bits_negative():
    with pytest.raises(ValueError, match="entropy_bits must be a positive finite number"):
        check_passphrase_strength("phrase", entropy_bits=-5.0)


def test_strength_invalid_entropy_bits_inf():
    with pytest.raises(ValueError, match="entropy_bits must be a positive finite number"):
        check_passphrase_strength("phrase", entropy_bits=float("inf"))


# ===========================================================================
# check_passphrase_strength — public API import
# ===========================================================================

def test_check_passphrase_strength_importable():
    from lastpass_vault import check_passphrase_strength as cps
    assert callable(cps)


def test_passphrase_strength_importable():
    from lastpass_vault import PassphraseStrength as PS
    assert PS is not None


# ===========================================================================
# check_passphrase_strength — zxcvbn integration (zxcvbn installed)
# ===========================================================================

def test_strength_zxcvbn_available_flag():
    """zxcvbn is installed in the dev environment — available must be True."""
    result = check_passphrase_strength("test-phrase", entropy_bits=77.5)
    assert result.zxcvbn_available is True


def test_strength_zxcvbn_score_is_int_in_range():
    result = check_passphrase_strength(
        generate_passphrase(num_words=6), entropy_bits=passphrase_entropy(6)
    )
    assert result.zxcvbn_score is not None
    assert 0 <= result.zxcvbn_score <= 4


def test_strength_zxcvbn_score_label_present():
    result = check_passphrase_strength("test-phrase-here-now", entropy_bits=60.0)
    assert result.zxcvbn_score_label in (
        "very weak", "weak", "fair", "strong", "very strong"
    )


def test_strength_zxcvbn_crack_times_all_scenarios_present():
    result = check_passphrase_strength("test-phrase-here-now", entropy_bits=60.0)
    assert result.zxcvbn_crack_times is not None
    expected_keys = {
        "online_throttling_100_per_hour",
        "online_no_throttling_10_per_second",
        "offline_slow_hashing_1e4_per_second",
        "offline_fast_hashing_1e10_per_second",
    }
    assert expected_keys == set(result.zxcvbn_crack_times.keys())


def test_strength_zxcvbn_crack_times_are_strings():
    result = check_passphrase_strength("test-phrase-here-now", entropy_bits=60.0)
    assert result.zxcvbn_crack_times is not None
    for scenario, display in result.zxcvbn_crack_times.items():
        assert isinstance(display, str), (
            f"crack_times[{scenario!r}] is not a string: {display!r}"
        )


def test_strength_zxcvbn_warning_is_string():
    result = check_passphrase_strength("test-phrase-here-now", entropy_bits=60.0)
    assert result.zxcvbn_warning is not None
    assert isinstance(result.zxcvbn_warning, str)


def test_strength_zxcvbn_suggestions_is_list():
    result = check_passphrase_strength("test-phrase-here-now", entropy_bits=60.0)
    assert result.zxcvbn_suggestions is not None
    assert isinstance(result.zxcvbn_suggestions, list)


def test_strength_strong_passphrase_gets_high_zxcvbn_score():
    """A 6-word EFF passphrase should score 4 in zxcvbn."""
    phrase = generate_passphrase(num_words=6)
    bits = passphrase_entropy(num_words=6)
    result = check_passphrase_strength(phrase, entropy_bits=bits)
    assert result.zxcvbn_score == 4, (
        f"Expected zxcvbn score 4 for 6-word passphrase, got {result.zxcvbn_score}"
    )


def test_strength_no_note_when_assessments_agree():
    """When entropy and zxcvbn both indicate a strong passphrase, no note needed."""
    phrase = generate_passphrase(num_words=6)
    bits = passphrase_entropy(num_words=6)
    result = check_passphrase_strength(phrase, entropy_bits=bits)
    assert result.zxcvbn_note is None


# ===========================================================================
# check_passphrase_strength — zxcvbn unavailable (mocked)
# ===========================================================================



def test_strength_without_zxcvbn(monkeypatch):
    """When zxcvbn is not installed all zxcvbn_* fields must be None."""
    import lastpass_vault.passphrase as _pm
    monkeypatch.setattr(_pm, "_ZXCVBN_AVAILABLE", False)
    monkeypatch.setattr(_pm, "_zxcvbn_lib", None)

    result = check_passphrase_strength("some-phrase-words", entropy_bits=77.5)

    assert result.zxcvbn_available is False
    assert result.zxcvbn_score is None
    assert result.zxcvbn_score_label is None
    assert result.zxcvbn_crack_times is None
    assert result.zxcvbn_warning is None
    assert result.zxcvbn_suggestions is None
    assert result.zxcvbn_note is None


def test_strength_without_zxcvbn_entropy_fields_still_populated(monkeypatch):
    import lastpass_vault.passphrase as _pm
    monkeypatch.setattr(_pm, "_ZXCVBN_AVAILABLE", False)
    monkeypatch.setattr(_pm, "_zxcvbn_lib", None)

    result = check_passphrase_strength("some-phrase-words", entropy_bits=77.5)

    assert math.isclose(result.entropy_bits, 77.5)
    assert result.entropy_rating == "fair"   # 77.5 < 80 → "fair"
    assert result.nist_compliant is False


# ===========================================================================
# check_passphrase_strength — discrepancy notes (mocked zxcvbn scores)
# ===========================================================================

def test_strength_note_when_entropy_high_zxcvbn_low(monkeypatch):
    """High entropy + low zxcvbn score should trigger an explanatory note."""
    import lastpass_vault.passphrase as _pm

    mock_result = {
        "score": 2,
        "crack_times_display": {
            "online_throttling_100_per_hour": "centuries",
            "online_no_throttling_10_per_second": "3 days",
            "offline_slow_hashing_1e4_per_second": "4 minutes",
            "offline_fast_hashing_1e10_per_second": "3 hours",
        },
        "feedback": {"warning": "Avoid common words", "suggestions": ["Add a symbol"]},
    }

    class _MockLib:
        @staticmethod
        def zxcvbn(pw):
            return mock_result

    monkeypatch.setattr(_pm, "_ZXCVBN_AVAILABLE", True)
    monkeypatch.setattr(_pm, "_zxcvbn_lib", _MockLib())

    result = check_passphrase_strength("some-phrase-words", entropy_bits=75.0)

    assert result.zxcvbn_note is not None
    assert "entropy_bits" in result.zxcvbn_note
    assert result.zxcvbn_score == 2


def test_strength_note_when_entropy_low_zxcvbn_high(monkeypatch):
    """Low entropy + high zxcvbn score should also trigger a note."""
    import lastpass_vault.passphrase as _pm

    mock_result = {
        "score": 4,
        "crack_times_display": {
            "online_throttling_100_per_hour": "centuries",
            "online_no_throttling_10_per_second": "centuries",
            "offline_slow_hashing_1e4_per_second": "centuries",
            "offline_fast_hashing_1e10_per_second": "centuries",
        },
        "feedback": {"warning": "", "suggestions": []},
    }

    class _MockLib:
        @staticmethod
        def zxcvbn(pw):
            return mock_result

    monkeypatch.setattr(_pm, "_ZXCVBN_AVAILABLE", True)
    monkeypatch.setattr(_pm, "_zxcvbn_lib", _MockLib())

    result = check_passphrase_strength("someword", entropy_bits=12.9)

    assert result.zxcvbn_note is not None
    assert "entropy" in result.zxcvbn_note.lower()
    assert result.zxcvbn_score == 4


def test_strength_no_note_when_both_agree_high(monkeypatch):
    """No note when both metrics indicate strength."""
    import lastpass_vault.passphrase as _pm

    mock_result = {
        "score": 4,
        "crack_times_display": {
            "online_throttling_100_per_hour": "centuries",
            "online_no_throttling_10_per_second": "centuries",
            "offline_slow_hashing_1e4_per_second": "centuries",
            "offline_fast_hashing_1e10_per_second": "centuries",
        },
        "feedback": {"warning": "", "suggestions": []},
    }

    class _MockLib:
        @staticmethod
        def zxcvbn(pw):
            return mock_result

    monkeypatch.setattr(_pm, "_ZXCVBN_AVAILABLE", True)
    monkeypatch.setattr(_pm, "_zxcvbn_lib", _MockLib())

    result = check_passphrase_strength("some-strong-phrase", entropy_bits=90.0)

    assert result.zxcvbn_note is None


def test_strength_no_note_border_case_entropy_59_score_2(monkeypatch):
    """Entropy just below 60 + score 2 does not trigger the high-entropy note."""
    import lastpass_vault.passphrase as _pm

    mock_result = {
        "score": 2,
        "crack_times_display": {k: "1 hour" for k in (
            "online_throttling_100_per_hour",
            "online_no_throttling_10_per_second",
            "offline_slow_hashing_1e4_per_second",
            "offline_fast_hashing_1e10_per_second",
        )},
        "feedback": {"warning": "weak", "suggestions": []},
    }

    class _MockLib:
        @staticmethod
        def zxcvbn(pw):
            return mock_result

    monkeypatch.setattr(_pm, "_ZXCVBN_AVAILABLE", True)
    monkeypatch.setattr(_pm, "_zxcvbn_lib", _MockLib())

    result = check_passphrase_strength("phrase", entropy_bits=59.9)

    assert result.zxcvbn_note is None  # threshold is >= 60.0
