"""
Passphrase generation and strength assessment using EFF Diceware wordlists.

All randomness comes from the ``secrets`` module (Python's CSPRNG), which
draws entropy from the OS (``/dev/urandom`` on Linux).  The ``random``
module is never imported or used here.

Entropy reference
-----------------
+------------------------------------+-----------+
| Configuration                      | Bits      |
+====================================+===========+
| 4 words, EFF large list            |  ~51.7    |
| 5 words, EFF large list            |  ~64.6    |
| 6 words, EFF large list (default)  |  ~77.5    |
| 7 words, EFF large list            |  ~90.4    |
+------------------------------------+-----------+
Each added digit token contributes log2(10) ≈ 3.3 bits plus position
entropy; each special-character token contributes log2(|char_set|) bits
plus position entropy.  The "random" capitalise mode adds log2(num_words)
bits.

NIST SP 800-63B recommends at least 80 bits for high-value credentials;
six words from the EFF large list reaches ~77.5 bits before extras.

Optional strength assessment (``check_passphrase_strength``) integrates
with the ``zxcvbn`` library when it is installed.  Install it with::

    pip install lastpass-vault-py[strength]

zxcvbn analyses string patterns to estimate real-world cracking resistance.
For randomly generated passphrases the mathematical entropy is the primary
security measure; zxcvbn provides a complementary attacker-model perspective.
"""

import dataclasses
import math
import secrets
from pathlib import Path
from typing import Dict, List, Optional

# ---------------------------------------------------------------------------
# Optional zxcvbn integration — imported lazily so the core library never
# has a hard dependency on it.
# ---------------------------------------------------------------------------
try:
    import zxcvbn as _zxcvbn_lib
    _ZXCVBN_AVAILABLE = True
except ImportError:
    _zxcvbn_lib = None  # type: ignore[assignment]
    _ZXCVBN_AVAILABLE = False

# ---------------------------------------------------------------------------
# Public constants
# ---------------------------------------------------------------------------

WORDLIST_LARGE: str = "large"
WORDLIST_SHORT1: str = "short1"
WORDLIST_SHORT2: str = "short2"

#: Default special-character pool used when ``num_special > 0``.
#: Chosen to be universally accepted while avoiding characters that
#: commonly cause shell or URL escaping issues in copy-paste flows.
#:
#: NOTE: This pool includes ``-``, ``_``, ``.``, and ``|``.  Any separator
#: that contains one of these characters will conflict with this pool when
#: ``num_special > 0``; see :data:`SAFE_SEPARATORS`.
DEFAULT_SPECIAL_CHARS: str = "!@#$%^&*()-_=+[]{}|;:,.<>?"

# ---------------------------------------------------------------------------
# Separator constants
# ---------------------------------------------------------------------------
# A separator "conflicts" with a special-character pool when the separator
# string contains at least one character that could also be randomly selected
# as a special-character token.  Such a conflict makes the passphrase
# impossible to round-trip by splitting on the separator alone.
#
# The constants below cover the most common choices.  Those marked "safe"
# contain no characters present in DEFAULT_SPECIAL_CHARS.

#: Space — natural English word boundary.  Safe with DEFAULT_SPECIAL_CHARS.
SEP_SPACE: str = " "

#: Forward slash — visually clean, no special meaning in password fields.
#: Safe with DEFAULT_SPECIAL_CHARS.
SEP_SLASH: str = "/"

#: Tilde — distinctive, rarely appears in passwords or URLs.
#: Safe with DEFAULT_SPECIAL_CHARS.
SEP_TILDE: str = "~"

#: Empty string — tokens are concatenated with no delimiter.
#: Always safe (a zero-length separator cannot conflict with anything).
SEP_NONE: str = ""

#: Dash — the most visually readable separator for passphrases, but
#: ``"-"`` is present in DEFAULT_SPECIAL_CHARS.  Using this separator with
#: ``num_special > 0`` and the default ``special_char_set`` will raise
#: ``ValueError``.  Safe only when ``special_char_set`` is explicitly set
#: to a pool that excludes ``"-"``.
SEP_DASH: str = "-"

#: Dot — common in domain-style notation, but ``"."`` is present in
#: DEFAULT_SPECIAL_CHARS.  Same caveat as :data:`SEP_DASH`.
SEP_DOT: str = "."

#: The set of built-in separator constants that contain no characters from
#: :data:`DEFAULT_SPECIAL_CHARS` and can therefore always be used safely
#: alongside ``num_special > 0`` without a custom ``special_char_set``.
SAFE_SEPARATORS: frozenset = frozenset({SEP_SPACE, SEP_SLASH, SEP_TILDE, SEP_NONE})

_VALID_WORDLISTS: frozenset = frozenset({WORDLIST_LARGE, WORDLIST_SHORT1, WORDLIST_SHORT2})
_VALID_CAPITALIZE: frozenset = frozenset({"none", "first", "all", "random"})

_WORDLIST_FILES: Dict[str, str] = {
    WORDLIST_LARGE: "eff_large.txt",
    WORDLIST_SHORT1: "eff_short1.txt",
    WORDLIST_SHORT2: "eff_short2.txt",
}

_WORDLIST_DIR: Path = Path(__file__).parent / "wordlists"

# Module-level cache so each file is read from disk at most once per process.
_WORDLIST_CACHE: Dict[str, List[str]] = {}


# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------

def _load_wordlist(name: str) -> List[str]:
    """Return the word list for *name*, loading and caching on first call.

    Words are returned as a plain list of lowercase strings with no blank
    entries.  The list object is reused on subsequent calls (identity is
    preserved) so callers can rely on ``is`` for cache hit checks in tests.

    Raises:
        ValueError: If *name* is not a recognised wordlist identifier.
        FileNotFoundError: If the bundled wordlist file is unexpectedly absent.
    """
    if name not in _WORDLIST_FILES:
        raise ValueError(
            f"wordlist must be one of {sorted(_VALID_WORDLISTS)!r}; got {name!r}"
        )
    if name not in _WORDLIST_CACHE:
        path = _WORDLIST_DIR / _WORDLIST_FILES[name]
        words = [w for w in path.read_text(encoding="utf-8").splitlines() if w.strip()]
        _WORDLIST_CACHE[name] = words
    return _WORDLIST_CACHE[name]


def _validate_inputs(
    num_words: int,
    wordlist: str,
    capitalize: str,
    num_digits: int,
    num_special: int,
    special_char_set: str,
    separator: str = "",
) -> None:
    """Raise ``ValueError`` for any invalid argument combination."""
    if num_words < 1:
        raise ValueError(f"num_words must be >= 1; got {num_words!r}")
    if num_digits < 0:
        raise ValueError(f"num_digits must be >= 0; got {num_digits!r}")
    if num_special < 0:
        raise ValueError(f"num_special must be >= 0; got {num_special!r}")
    if capitalize not in _VALID_CAPITALIZE:
        raise ValueError(
            f"capitalize must be one of {sorted(_VALID_CAPITALIZE)!r}; got {capitalize!r}"
        )
    if wordlist not in _VALID_WORDLISTS:
        raise ValueError(
            f"wordlist must be one of {sorted(_VALID_WORDLISTS)!r}; got {wordlist!r}"
        )
    if num_special > 0 and not special_char_set:
        raise ValueError("special_char_set must not be empty when num_special > 0")
    if num_special > 0 and separator:
        conflicts = set(separator) & set(special_char_set)
        if conflicts:
            raise ValueError(
                f"separator {separator!r} shares character(s) {sorted(conflicts)!r} "
                f"with special_char_set. A special-character token could be "
                f"indistinguishable from the separator, making the output "
                f"unparseable. Use a separator whose characters do not appear in "
                f"special_char_set — e.g. one of the safe built-in constants: "
                f"SEP_SPACE={SEP_SPACE!r}, SEP_SLASH={SEP_SLASH!r}, "
                f"SEP_TILDE={SEP_TILDE!r}, or SEP_NONE={SEP_NONE!r}."
            )


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def generate_passphrase(
    num_words: int = 6,
    separator: str = SEP_SPACE,
    wordlist: str = WORDLIST_LARGE,
    capitalize: str = "none",
    num_digits: int = 0,
    num_special: int = 0,
    special_char_set: str = DEFAULT_SPECIAL_CHARS,
) -> str:
    """Generate a cryptographically secure passphrase.

    Words are chosen from the specified EFF Diceware wordlist using
    ``secrets.choice``.  Optional digit and special-character tokens are
    inserted at randomly chosen positions so they are not predictably at
    the start or end of the phrase.

    Args:
        num_words: Number of words to include.  Must be >= 1.
            Six words from the large EFF list gives ~77.5 bits of entropy,
            which meets or exceeds most high-security requirements.
        separator: String placed between every token.  Defaults to
            :data:`SEP_SPACE` (a single space).  Use the named separator
            constants to avoid conflicts with ``special_char_set``:

            * :data:`SEP_SPACE` ``" "``  — space (default); safe with
              :data:`DEFAULT_SPECIAL_CHARS`.
            * :data:`SEP_SLASH` ``"/"``  — forward slash; safe.
            * :data:`SEP_TILDE` ``"~"``  — tilde; safe.
            * :data:`SEP_NONE`  ``""``   — no delimiter; always safe.
            * :data:`SEP_DASH`  ``"-"``  — most readable, but conflicts with
              :data:`DEFAULT_SPECIAL_CHARS`; only safe when ``num_special``
              is 0 or ``special_char_set`` excludes ``"-"``.
            * :data:`SEP_DOT`   ``"."``  — dot; same caveat as
              :data:`SEP_DASH`.

            When ``num_special > 0``, a ``ValueError`` is raised if the
            separator shares any character with ``special_char_set``.  See
            :data:`SAFE_SEPARATORS` for the set of built-in constants that
            never trigger this error with :data:`DEFAULT_SPECIAL_CHARS`.
        wordlist: Which EFF wordlist to draw from.

            * ``"large"``  — 7 776 words, ~12.9 bits per word (default).
            * ``"short1"`` — 1 296 words, ~10.3 bits per word.
            * ``"short2"`` — 1 296 words, unique 3-char prefixes; suits
              environments with auto-complete.

        capitalize: How to capitalise word tokens.

            * ``"none"``   — all words remain lowercase (default).
            * ``"first"``  — first word is title-cased; deterministic,
              adds no entropy.
            * ``"all"``    — every word is title-cased; deterministic,
              adds no entropy.
            * ``"random"`` — exactly one word is title-cased, chosen via
              ``secrets.randbelow``; adds ``log2(num_words)`` bits.

        num_digits: Number of single-digit tokens (0–9) to insert at
            randomly chosen positions.  Each contributes ~3.3 bits of
            value entropy plus position entropy.
        num_special: Number of single special-character tokens to insert
            at randomly chosen positions.  Each contributes
            ``log2(len(special_char_set))`` bits plus position entropy.
        special_char_set: Pool from which special characters are drawn.
            Must not be empty when ``num_special > 0``.

    Returns:
        The generated passphrase as a single string.

    Raises:
        ValueError: If any argument fails validation (see
            :func:`passphrase_entropy` for the same parameter constraints).

    Security note:
        The return value contains a credential.  The caller must not log
        it, store it in plaintext, or include it in error messages.
        Pass it directly to :py:meth:`~lastpass_vault.LastPassVault.add`
        or store it via the vault rather than keeping it in application
        state.

    Example::

        # Basic 6-word phrase — ~77.5 bits of entropy
        phrase = generate_passphrase()

        # Meets "must contain uppercase, digit, and symbol" requirements
        phrase = generate_passphrase(
            num_words=5,
            capitalize="first",
            num_digits=1,
            num_special=1,
        )

        # Store directly in the vault
        vault.add("Work/NewLogin", template=f"Password: {phrase}")
    """
    _validate_inputs(num_words, wordlist, capitalize, num_digits, num_special,
                     special_char_set, separator=separator)

    words_list = _load_wordlist(wordlist)

    # --- Step 1: select words -------------------------------------------------
    tokens: List[str] = [secrets.choice(words_list) for _ in range(num_words)]

    # --- Step 2: apply capitalisation -----------------------------------------
    if capitalize == "first":
        tokens[0] = tokens[0].capitalize()
    elif capitalize == "all":
        tokens = [w.capitalize() for w in tokens]
    elif capitalize == "random":
        idx = secrets.randbelow(num_words)
        tokens[idx] = tokens[idx].capitalize()
    # "none" requires no action

    # --- Step 3: insert digit tokens at random positions ----------------------
    for _ in range(num_digits):
        digit = str(secrets.randbelow(10))
        pos = secrets.randbelow(len(tokens) + 1)
        tokens.insert(pos, digit)

    # --- Step 4: insert special-character tokens at random positions ----------
    for _ in range(num_special):
        char = secrets.choice(special_char_set)
        pos = secrets.randbelow(len(tokens) + 1)
        tokens.insert(pos, char)

    return separator.join(tokens)


def passphrase_entropy(
    num_words: int = 6,
    wordlist: str = WORDLIST_LARGE,
    capitalize: str = "none",
    num_digits: int = 0,
    num_special: int = 0,
    special_char_set: str = DEFAULT_SPECIAL_CHARS,
) -> float:
    """Calculate the entropy in bits for a given passphrase configuration.

    The result reflects the actual uncertainty an attacker faces when
    the generation algorithm and all parameters are known — only the
    random choices themselves are unknown.

    Formula
    -------
    * **Words**: ``num_words × log2(wordlist_size)``
    * **Each digit token** (insertion *k*, 0-indexed):
      ``log2(10) + log2(num_words + k + 1)``
      (value entropy + position entropy given the current token pool size)
    * **Each special token** (insertion *k*, 0-indexed):
      ``log2(|special_char_set|) + log2(num_words + num_digits + k + 1)``
    * **capitalize="random"**: ``+ log2(num_words)`` (which word is
      capitalised); zero when ``num_words == 1`` since ``log2(1) == 0``.
    * capitalize="none", "first", "all" add **zero** entropy — the
      transformation is deterministic.

    Args:
        num_words: Number of words in the passphrase.
        wordlist: Wordlist identifier — "large", "short1", or "short2".
        capitalize: Capitalisation mode — see :func:`generate_passphrase`.
        num_digits: Number of digit tokens inserted.
        num_special: Number of special-character tokens inserted.
        special_char_set: Pool used for special characters.

    Returns:
        Entropy in bits as a ``float``.

    Raises:
        ValueError: Same conditions as :func:`generate_passphrase`.
    """
    _validate_inputs(num_words, wordlist, capitalize, num_digits, num_special, special_char_set)

    wordlist_size = len(_load_wordlist(wordlist))
    bits = float(num_words) * math.log2(wordlist_size)

    # Digit tokens — pool grows by 1 after each insertion
    for k in range(num_digits):
        pool_size = num_words + k
        bits += math.log2(10) + math.log2(pool_size + 1)

    # Special-character tokens — pool continues growing
    for k in range(num_special):
        pool_size = num_words + num_digits + k
        bits += math.log2(len(special_char_set)) + math.log2(pool_size + 1)

    # Random capitalisation — log2(1) == 0 so single-word case is handled
    if capitalize == "random" and num_words > 1:
        bits += math.log2(num_words)

    return bits


# ---------------------------------------------------------------------------
# Strength assessment
# ---------------------------------------------------------------------------

#: Entropy thresholds that map to human-readable rating labels.
#: Boundaries align with NIST SP 800-63B guidance (≥80 bits for high-value
#: credentials) and common community practice.
_ENTROPY_THRESHOLDS: List = [
    (40.0,  "very weak"),
    (60.0,  "weak"),
    (80.0,  "fair"),
    (100.0, "strong"),
]
_ENTROPY_RATING_MAX = "very strong"

_ZXCVBN_SCORE_LABELS: Dict[int, str] = {
    0: "very weak",
    1: "weak",
    2: "fair",
    3: "strong",
    4: "very strong",
}


def _entropy_rating(bits: float) -> str:
    for threshold, label in _ENTROPY_THRESHOLDS:
        if bits < threshold:
            return label
    return _ENTROPY_RATING_MAX


@dataclasses.dataclass(frozen=True)
class PassphraseStrength:
    """Strength assessment for a generated passphrase.

    Attributes:
        entropy_bits: Mathematical entropy of the passphrase in bits, computed
            from the generation parameters via :func:`passphrase_entropy`.
            This is the primary security measure for randomly generated
            passphrases.
        entropy_rating: Human-readable label derived from ``entropy_bits``:
            ``"very weak"`` / ``"weak"`` / ``"fair"`` / ``"strong"`` /
            ``"very strong"``.
        nist_compliant: ``True`` when ``entropy_bits >= 80``, the threshold
            recommended by NIST SP 800-63B for high-value credentials.
        zxcvbn_available: ``True`` when the ``zxcvbn`` package is installed.
            All ``zxcvbn_*`` fields below are ``None`` when this is ``False``.
        zxcvbn_score: Integer 0–4 from zxcvbn's pattern-matching estimator.
            Reflects an attacker's practical guessing difficulty based on
            recognised patterns, dictionary matches, and common substitutions.
        zxcvbn_score_label: Human-readable label for ``zxcvbn_score``
            (``"very weak"`` … ``"very strong"``), or ``None``.
        zxcvbn_crack_times: Dict mapping four attack scenarios to
            human-readable crack-time estimates (e.g. ``"centuries"``):

            * ``online_throttling_100_per_hour`` — rate-limited service
            * ``online_no_throttling_10_per_second`` — no rate-limiting
            * ``offline_slow_hashing_1e4_per_second`` — bcrypt-class hashing
            * ``offline_fast_hashing_1e10_per_second`` — fast hash (SHA-1)

        zxcvbn_warning: Warning message from zxcvbn when ``zxcvbn_score``
            is low (empty string when score > 2), or ``None``.
        zxcvbn_suggestions: List of improvement suggestions from zxcvbn,
            or ``None``.
        zxcvbn_note: Contextual note when a meaningful discrepancy exists
            between the mathematical entropy and zxcvbn's score.  ``None``
            when the two assessments are broadly consistent.

            Two cases trigger a note:

            * **Entropy high, zxcvbn score low** (score < 3 but bits ≥ 60):
              zxcvbn penalises dictionary words even when they are randomly
              selected.  The mathematical entropy is authoritative here.
            * **Entropy low, zxcvbn score high** (score ≥ 3 but bits < 40):
              zxcvbn may be over-crediting pattern complexity.  The
              entropy figure reflects the true search space.

    Security note:
        This object contains no credential values — the passphrase string
        is analysed but never stored here.  Even so, treat the object with
        appropriate care; ``zxcvbn_crack_times`` and the score implicitly
        describe a real credential's strength.
    """

    entropy_bits: float
    entropy_rating: str
    nist_compliant: bool
    zxcvbn_available: bool
    zxcvbn_score: Optional[int]
    zxcvbn_score_label: Optional[str]
    zxcvbn_crack_times: Optional[Dict[str, str]]
    zxcvbn_warning: Optional[str]
    zxcvbn_suggestions: Optional[List[str]]
    zxcvbn_note: Optional[str]


def check_passphrase_strength(
    passphrase: str,
    entropy_bits: float,
) -> PassphraseStrength:
    """Assess the strength of a passphrase from two complementary angles.

    Combines a mathematical entropy assessment with an optional pattern-based
    analysis from zxcvbn to give a rounded picture of passphrase strength.

    Args:
        passphrase: The passphrase string to analyse.  It is passed to zxcvbn
            locally (no network calls are made) and is not stored in the
            returned object.

            SECURITY: Do not log this argument or include it in error messages.

        entropy_bits: Pre-computed entropy for this passphrase configuration,
            obtained from :func:`passphrase_entropy`.  This encapsulates how
            the passphrase was generated (word count, wordlist, extras) and
            is the authoritative security measure for randomly generated
            passphrases.

    Returns:
        A :class:`PassphraseStrength` instance.  When ``zxcvbn`` is not
        installed all ``zxcvbn_*`` fields are ``None`` and
        ``zxcvbn_available`` is ``False``.

    Raises:
        ValueError: If ``entropy_bits`` is not a positive finite number.

    Example::

        phrase = generate_passphrase(num_words=6, capitalize="first",
                                     num_digits=1, num_special=1)
        bits   = passphrase_entropy(num_words=6, capitalize="first",
                                    num_digits=1, num_special=1)
        result = check_passphrase_strength(phrase, bits)

        print(f"Entropy : {result.entropy_bits:.1f} bits ({result.entropy_rating})")
        print(f"NIST OK : {result.nist_compliant}")
        if result.zxcvbn_available:
            print(f"zxcvbn  : {result.zxcvbn_score}/4 ({result.zxcvbn_score_label})")
            print(f"Offline fast crack: "
                  f"{result.zxcvbn_crack_times['offline_fast_hashing_1e10_per_second']}")
        if result.zxcvbn_note:
            print(f"Note    : {result.zxcvbn_note}")
    """
    if not (math.isfinite(entropy_bits) and entropy_bits > 0):
        raise ValueError(
            f"entropy_bits must be a positive finite number; got {entropy_bits!r}"
        )

    rating = _entropy_rating(entropy_bits)
    nist_ok = entropy_bits >= 80.0

    # --- zxcvbn analysis (optional) ------------------------------------------
    zxcvbn_score: Optional[int] = None
    zxcvbn_score_label: Optional[str] = None
    zxcvbn_crack_times: Optional[Dict[str, str]] = None
    zxcvbn_warning: Optional[str] = None
    zxcvbn_suggestions: Optional[List[str]] = None
    zxcvbn_note: Optional[str] = None

    if _ZXCVBN_AVAILABLE and _zxcvbn_lib is not None:
        # passphrase is analysed locally — zxcvbn makes no network calls.
        result = _zxcvbn_lib.zxcvbn(passphrase)

        zxcvbn_score = int(result["score"])
        zxcvbn_score_label = _ZXCVBN_SCORE_LABELS.get(zxcvbn_score)
        zxcvbn_crack_times = dict(result["crack_times_display"])
        zxcvbn_warning = result["feedback"].get("warning", "")
        zxcvbn_suggestions = list(result["feedback"].get("suggestions", []))

        # Detect meaningful discrepancies between entropy and zxcvbn score.
        if entropy_bits >= 60.0 and zxcvbn_score < 3:
            zxcvbn_note = (
                "zxcvbn rates this passphrase lower than its mathematical "
                "entropy suggests. This typically occurs when words appear in "
                "zxcvbn's dictionaries, even though they were randomly selected. "
                "For passphrases generated with a CSPRNG, entropy_bits is the "
                "authoritative security measure."
            )
        elif entropy_bits < 40.0 and zxcvbn_score >= 3:
            zxcvbn_note = (
                "zxcvbn rates this passphrase higher than its mathematical "
                "entropy suggests. The entropy figure reflects the true search "
                "space for an attacker who knows the generation algorithm. "
                "Prefer entropy_bits when evaluating passphrase security."
            )

    return PassphraseStrength(
        entropy_bits=entropy_bits,
        entropy_rating=rating,
        nist_compliant=nist_ok,
        zxcvbn_available=_ZXCVBN_AVAILABLE,
        zxcvbn_score=zxcvbn_score,
        zxcvbn_score_label=zxcvbn_score_label,
        zxcvbn_crack_times=zxcvbn_crack_times,
        zxcvbn_warning=zxcvbn_warning,
        zxcvbn_suggestions=zxcvbn_suggestions,
        zxcvbn_note=zxcvbn_note,
    )
