"""
Naive Bayes text classification using a multinomial event model.

The implementation in this module is intentionally educational and keeps the
logic explicit: token counting, prior probabilities, and posterior scoring in
log-space.

References:
- https://en.wikipedia.org/wiki/Naive_Bayes_classifier
- https://scikit-learn.org/stable/modules/naive_bayes.html
"""

from __future__ import annotations

import re
from collections import Counter, defaultdict
from math import exp, log


class NaiveBayesTextClassifier:
    """
    Multinomial Naive Bayes classifier for short text documents.

    Args:
        alpha: Additive (Laplace) smoothing parameter. Must be greater than 0.

    >>> NaiveBayesTextClassifier(alpha=0)
    Traceback (most recent call last):
    ...
    ValueError: alpha must be greater than 0.
    """

    def __init__(self, alpha: float = 1.0) -> None:
        if alpha <= 0:
            raise ValueError("alpha must be greater than 0.")

        self.alpha = alpha
        self.classes_: list[str] = []
        self.vocabulary_: set[str] = set()
        self.class_document_counts_: Counter[str] = Counter()
        self.class_token_counts_: dict[str, Counter[str]] = defaultdict(Counter)
        self.class_total_tokens_: Counter[str] = Counter()
        self.class_log_prior_: dict[str, float] = {}
        self.is_fitted_ = False

    @staticmethod
    def _tokenize(text: str) -> list[str]:
        """
        Split text into lowercase alphanumeric tokens.

        >>> NaiveBayesTextClassifier._tokenize("Hello, NLP world!")
        ['hello', 'nlp', 'world']
        """
        return re.findall(r"[a-z0-9']+", text.lower())

    def fit(self, texts: list[str], labels: list[str]) -> None:
        """
        Fit the classifier from labeled training texts.

        >>> model = NaiveBayesTextClassifier()
        >>> model.fit(["cheap meds", "project meeting"], ["spam", "ham"])
        >>> sorted(model.classes_)
        ['ham', 'spam']

        >>> model.fit(["only one text"], ["ham", "spam"])
        Traceback (most recent call last):
        ...
        ValueError: texts and labels must have the same length.

        >>> model.fit([], [])
        Traceback (most recent call last):
        ...
        ValueError: training data must not be empty.
        """
        if not texts:
            raise ValueError("training data must not be empty.")
        if len(texts) != len(labels):
            raise ValueError("texts and labels must have the same length.")

        self.classes_ = sorted(set(labels))
        self.vocabulary_.clear()
        self.class_document_counts_.clear()
        self.class_token_counts_ = defaultdict(Counter)
        self.class_total_tokens_.clear()
        self.class_log_prior_.clear()

        for text, label in zip(texts, labels):
            if not isinstance(text, str) or not isinstance(label, str):
                raise TypeError("texts and labels must contain strings only.")

            tokens = self._tokenize(text)
            self.class_document_counts_[label] += 1
            self.class_token_counts_[label].update(tokens)
            self.class_total_tokens_[label] += len(tokens)
            self.vocabulary_.update(tokens)

        total_documents = len(texts)
        self.class_log_prior_ = {
            label: log(self.class_document_counts_[label] / total_documents)
            for label in self.classes_
        }
        self.is_fitted_ = True

    def predict_proba(self, text: str) -> dict[str, float]:
        """
        Return posterior probabilities for every class.

        >>> train_texts, train_labels = build_toy_dataset()
        >>> model = NaiveBayesTextClassifier()
        >>> model.fit(train_texts, train_labels)
        >>> probs = model.predict_proba("cheap meds available now")
        >>> round(sum(probs.values()), 6)
        1.0
        >>> probs['spam'] > probs['ham']
        True

        An empty input text has no tokens, so predictions fall back to class priors.
        >>> empty_probs = model.predict_proba("")
        >>> round(empty_probs['spam'], 3), round(empty_probs['ham'], 3)
        (0.5, 0.5)

        >>> NaiveBayesTextClassifier().predict_proba("hello")
        Traceback (most recent call last):
        ...
        ValueError: model has not been fitted yet.
        """
        if not self.is_fitted_:
            raise ValueError("model has not been fitted yet.")
        if not isinstance(text, str):
            raise TypeError("text must be a string.")

        tokens = self._tokenize(text)
        vocabulary_size = len(self.vocabulary_)
        log_posteriors: dict[str, float] = {}

        for label in self.classes_:
            log_prob = self.class_log_prior_[label]
            token_counts = self.class_token_counts_[label]
            denominator = self.class_total_tokens_[label] + self.alpha * vocabulary_size

            for token in tokens:
                count = token_counts[token]
                log_prob += log((count + self.alpha) / denominator)

            log_posteriors[label] = log_prob

        max_log = max(log_posteriors.values())
        exp_scores = {
            label: exp(score - max_log) for label, score in log_posteriors.items()
        }
        normalizer = sum(exp_scores.values())
        return {label: score / normalizer for label, score in exp_scores.items()}

    def predict(self, text: str) -> str:
        """
        Predict the most likely class label for a text.

        >>> train_texts, train_labels = build_toy_dataset()
        >>> model = NaiveBayesTextClassifier(alpha=1.0)
        >>> model.fit(train_texts, train_labels)
        >>> model.predict("free cheap meds")
        'spam'
        >>> model.predict("project meeting schedule")
        'ham'
        """
        probabilities = self.predict_proba(text)
        return max(probabilities, key=lambda label: probabilities[label])


def build_toy_dataset() -> tuple[list[str], list[str]]:
    """
    Build a tiny text dataset for examples and quick local testing.

    >>> texts, labels = build_toy_dataset()
    >>> len(texts), len(labels)
    (6, 6)
    >>> sorted(set(labels))
    ['ham', 'spam']
    """
    texts = [
        "buy cheap meds now",
        "cheap meds available online",
        "win cash prizes now",
        "project meeting schedule attached",
        "let us discuss the project timeline",
        "team meeting moved to monday",
    ]
    labels = ["spam", "spam", "spam", "ham", "ham", "ham"]
    return texts, labels


if __name__ == "__main__":
    import doctest

    doctest.testmod()

    sample_texts, sample_labels = build_toy_dataset()
    classifier = NaiveBayesTextClassifier(alpha=1.0)
    classifier.fit(sample_texts, sample_labels)

    print("Prediction:", classifier.predict("cheap prizes available now"))
    print("Prediction:", classifier.predict("team meeting about project timeline"))
