"""Guess what an uploaded batch is, so staff mostly just confirm.

The goal is that a batch of files arrives already named, dated and filed, and
the only thing left to type is the group's name. Everything here is a
suggestion the uploader can override.
"""
import math
import re
from collections import Counter, defaultdict
from datetime import date

from documents.models import Category, Document

MONTHS = {
    "jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
    "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
}

ISO = re.compile(r"(20\d{2})[-_./](\d{1,2})[-_./](\d{1,2})")
DMY = re.compile(r"\b(\d{1,2})[-_./](\d{1,2})[-_./](20\d{2})\b")
MONTH_YEAR = re.compile(
    r"\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*[\s\-_,]*(\d{1,2})?[\s\-_,]*(20\d{2})\b",
    re.I,
)
YEAR = re.compile(r"\b(20[0-3]\d)\b")

STOPWORDS = {
    "the", "and", "for", "of", "a", "an", "to", "in", "on", "at", "by", "with",
    "final", "draft", "copy", "new", "updated", "update", "scan", "scanned",
    "signed", "version", "pdf", "doc", "docx", "xls", "xlsx", "file", "files",
    "document", "documents",
}


def extract_date(text):
    """A date mentioned in a filename, or None."""
    match = ISO.search(text)
    if match:
        return _safe(int(match.group(1)), int(match.group(2)), int(match.group(3)))

    match = DMY.search(text)
    if match:
        first, second, year = int(match.group(1)), int(match.group(2)), int(match.group(3))
        # Ambiguous: prefer month-first unless that is impossible.
        if first > 12 >= second:
            return _safe(year, second, first)
        return _safe(year, first, second)

    match = MONTH_YEAR.search(text)
    if match:
        month = MONTHS[match.group(1)[:3].lower()]
        day = int(match.group(2)) if match.group(2) else 1
        return _safe(int(match.group(3)), month, day)

    match = YEAR.search(text)
    if match:
        year = int(match.group(1))
        if year <= date.today().year + 1:
            return _safe(year, 1, 1)
    return None


def _safe(year, month, day):
    try:
        return date(year, max(1, min(12, month)), max(1, min(28, day)) if day > 28 else max(1, day))
    except ValueError:
        return None


def tokens(text):
    words = re.split(r"[^A-Za-z0-9]+", (text or "").lower())
    return {w for w in words if len(w) > 2 and w not in STOPWORDS and not w.isdigit()}


def suggest_category(filenames, extension=""):
    """The category these files most likely belong in.

    Balances two signals: how much the filenames read like documents already in
    a category, and how recently and often that category has been used for this
    kind of file. Returns (category_or_None, confidence 0-1).
    """
    categories = list(Category.objects.all())
    if not categories:
        return None, 0.0

    upload_tokens = set()
    for name in filenames:
        upload_tokens |= tokens(name)
    if not upload_tokens:
        return None, 0.0

    # Vocabulary of each category, and how it is used per file type.
    vocab = defaultdict(Counter)
    totals = Counter()
    recency = Counter()
    per_extension = defaultdict(Counter)
    documents = (
        Document.objects.select_related("category")
        .exclude(category=None)
        .order_by("-published_date", "-created_at")[:600]
    )
    for position, document in enumerate(documents):
        cid = document.category_id
        for token in tokens(document.title):
            vocab[cid][token] += 1
            totals[cid] += 1
        # Earlier rows are newer, so weight them more.
        recency[cid] += max(1, 40 - position // 15)
        suffix = (document.file.name or "").rsplit(".", 1)[-1].lower()
        per_extension[suffix][cid] += 1

    # How many categories use each word. Words that appear everywhere ("strata",
    # "plan") carry almost no signal; words that belong to one category
    # ("minutes", "certificate") are what actually identify a document. Without
    # this, a large category wins on generic vocabulary alone.
    spread = Counter()
    for words in vocab.values():
        for token in words:
            spread[token] += 1
    breadth = max(len(vocab), 1)

    def weight(token):
        seen_in = spread.get(token, 0)
        if not seen_in:
            return 0.0
        return math.log(1 + breadth / seen_in)

    extension = (extension or "").lstrip(".").lower()
    reachable = sum(weight(token) for token in upload_tokens) or 1.0

    scores = {}
    for category in categories:
        words = vocab.get(category.id)
        if not words or not totals[category.id]:
            similarity = 0.0
        else:
            # Share of the category's vocabulary matched, weighted by how
            # distinctive each matching word is.
            matched = sum(
                weight(token) * min(1.0, words[token] / max(1, totals[category.id] / 12))
                for token in upload_tokens if token in words
            )
            similarity = matched / reachable

        habit = recency[category.id] / (max(recency.values(), default=0) or 1)
        affinity = 0.0
        if extension and per_extension.get(extension):
            counts = per_extension[extension]
            affinity = counts.get(category.id, 0) / (max(counts.values(), default=0) or 1)

        scores[category.id] = 0.74 * min(similarity, 1.0) + 0.16 * habit + 0.10 * affinity

    best_id = max(scores, key=scores.get)
    best = next(c for c in categories if c.id == best_id)
    ranked = sorted(scores.values(), reverse=True)
    margin = ranked[0] - (ranked[1] if len(ranked) > 1 else 0)
    confidence = min(1.0, round(scores[best_id] + margin, 2))
    return (best, confidence) if scores[best_id] > 0.10 else (None, 0.0)


def group_name_for(when):
    """The default, obviously-rename-me name for a batch."""
    return f"{when:%b %-d} uploads" if when else "New group"
