"""Deterministic inbox classification and conservative date extraction.

No model calls. Ever. A model may add a label in a later pass; nothing here may be
replaced by one, because the whole value of the register is that a date captured in
January still surfaces in March regardless of what anything was feeling that day.
"""

from __future__ import annotations

import json
import re
import tomllib
from dataclasses import dataclass
from datetime import date, datetime, timezone
from email.utils import parseaddr
from pathlib import Path
from typing import Any

from notify import ROOT


RULES_PATH = ROOT / "config" / "rules.toml"
CRITICAL_SENDERS_PATH = ROOT / "config" / "critical-senders.txt"
BULK_SENDERS_PATH = ROOT / "config" / "bulk-senders.txt"
FINDINGS_DIR = ROOT / "data" / "findings"

# How much of the body the ACT rules and the date scanner are allowed to see.
#
# Measured 2026-07-29 over 4,351 distinct messages:
#   - of non-bulk messages with a keyword hit in the body, only 32% had it inside
#     400 chars; the rest were footers ("Privacy Notice", "change your password",
#     "payment methods"). Sampled real senders put their boilerplate hits at 710,
#     1091, 1394, 4406 and 7905 characters in.
#   - of bulk messages with a hit, 75% were past 4,000 characters. Pure footer.
#   - the emails that actually matter carry their signal in the SUBJECT, and the
#     migration agent's "He turns 45 on the 23/09/2026" sits at offset 165.
#
# 400 characters is the opening paragraph — greeting plus the ask. It keeps what a
# human wrote and drops what a template appended. Full body gave 606 ACT over six
# months; this gives 227.
SCOPE_CHARS = 400

MONTHS = {
    name.lower(): number
    for number, name in enumerate(
        (
            "",
            "January",
            "February",
            "March",
            "April",
            "May",
            "June",
            "July",
            "August",
            "September",
            "October",
            "November",
            "December",
        )
    )
}
for abbreviation, number in {
    "jan": 1, "feb": 2, "mar": 3, "apr": 4, "jun": 6, "jul": 7,
    "aug": 8, "sep": 9, "sept": 9, "oct": 10, "nov": 11, "dec": 12,
}.items():
    MONTHS[abbreviation] = number

ISO_DATE = re.compile(r"\b(20\d{2})-(0[1-9]|1[0-2])-([0-2]\d|3[01])\b")
WRITTEN_DATE = re.compile(
    r"\b([0-3]?\d)(?:st|nd|rd|th)?\s+"
    r"(Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|"
    r"Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|"
    r"Nov(?:ember)?|Dec(?:ember)?)"
    r"(?:\s+(20\d{2}))?\b",
    re.IGNORECASE,
)
# The year is MANDATORY. A bare "15/08" used to be read as this year, and a bare
# "15 August" already past was rolled into next year — which is where the register's
# phantom 2027 tail came from (32 entries in July 2027 alone, every one of them a
# date sitting in the body of an old invoice).
NUMERIC_DATE = re.compile(r"\b([0-3]?\d)[/-]([0-3]?\d)[/-](20\d{2}|\d{2})\b")
BARE_NUMERIC_DATE = re.compile(r"\b([0-3]?\d)[/-]([0-3]?\d)\b")


# --------------------------------------------------------------------------
# config loading — cached on file mtime
#
# classify() used to re-read and re-parse three config files for EVERY message.
# Over a 4,426-message backfill that is ~13,000 file opens to answer a question
# whose answer never changed.
# --------------------------------------------------------------------------
_CACHE: dict[Path, tuple[float, Any]] = {}


def _cached(path: Path, parse) -> Any:
    try:
        stamp = path.stat().st_mtime
    except FileNotFoundError:
        stamp = -1.0
    hit = _CACHE.get(path)
    if hit is not None and hit[0] == stamp:
        return hit[1]
    value = parse(path)
    _CACHE[path] = (stamp, value)
    return value


def _parse_values(path: Path) -> set[str]:
    if not path.exists():
        return set()
    return {
        line.strip().lower()
        for line in path.read_text(encoding="utf-8").splitlines()
        if line.strip() and not line.lstrip().startswith("#")
    }


def _parse_rules(path: Path) -> dict[str, Any]:
    config = tomllib.loads(path.read_text(encoding="utf-8"))
    compiled = {
        "act": {
            group: [(term, _word_regex(term)) for term in config["act"][group]]
            for group in ("money", "legal", "security")
        },
        "review": [(term, _word_regex(term)) for term in config["review"]["terms"]],
    }
    return compiled


def _word_regex(term: str) -> re.Pattern[str]:
    """Match the term as a whole word, not as a substring.

    Substring matching made 'fine' fire on 'define' and 'refined' (92 of its 140
    hits), 'visa' fire on the card brand, and 'invoice' fire inside 'invoiced'
    (45 of 89). Measured 2026-07-29.
    """
    return re.compile(r"(?<!\w)" + re.escape(term.lower()) + r"(?!\w)")


def _load_values(path: Path) -> set[str]:
    return _cached(path, _parse_values)


def _load_rules() -> dict[str, Any]:
    return _cached(RULES_PATH, _parse_rules)


@dataclass
class DateMatch:
    original: str
    normalized: str | None
    ambiguous: bool
    within_30_days: bool
    future_beyond_30_days: bool


def _safe_date(year: int, month: int, day: int) -> date | None:
    try:
        return date(year, month, day)
    except ValueError:
        return None


def _classify_date(original: str, parsed: date | None, ambiguous: bool) -> DateMatch:
    today = datetime.now(timezone.utc).date()
    if parsed is None:
        return DateMatch(original, None, ambiguous, False, False)
    delta = (parsed - today).days
    return DateMatch(
        original,
        parsed.isoformat(),
        ambiguous,
        0 <= delta <= 30,
        delta > 30,
    )


def extract_dates(text: str) -> list[DateMatch]:
    """Find dates that state their own year. Everything else is ambiguous.

    A date without a year is not a deadline, it is a guess about a deadline. Guessing
    is what filled the register with 2027.
    """
    results: list[DateMatch] = []
    occupied: list[tuple[int, int]] = []

    for match in ISO_DATE.finditer(text):
        parsed = _safe_date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
        results.append(_classify_date(match.group(0), parsed, False))
        occupied.append(match.span())

    for match in WRITTEN_DATE.finditer(text):
        if any(match.start() < end and match.end() > start for start, end in occupied):
            continue
        occupied.append(match.span())
        if not match.group(3):
            # "15 August" with no year. Never invent one.
            results.append(_classify_date(match.group(0), None, True))
            continue
        month_key = match.group(2).lower()
        month = MONTHS.get(month_key, MONTHS.get(month_key[:3]))
        parsed = (
            _safe_date(int(match.group(3)), int(month), int(match.group(1)))
            if month
            else None
        )
        results.append(_classify_date(match.group(0), parsed, parsed is None))

    for match in NUMERIC_DATE.finditer(text):
        if any(match.start() < end and match.end() > start for start, end in occupied):
            continue
        occupied.append(match.span())
        first, second = int(match.group(1)), int(match.group(2))
        year_text = match.group(3)
        year = int(year_text) + 2000 if len(year_text) == 2 else int(year_text)
        if first <= 12 and second <= 12:
            # 08/09/2026 — the eighth of September or the ninth of August?
            results.append(_classify_date(match.group(0), None, True))
            continue
        if first > 12 and second <= 12:
            parsed = _safe_date(year, second, first)
        elif second > 12 and first <= 12:
            parsed = _safe_date(year, first, second)
        else:
            parsed = None
        results.append(_classify_date(match.group(0), parsed, parsed is None))

    for match in BARE_NUMERIC_DATE.finditer(text):
        if any(match.start() < end and match.end() > start for start, end in occupied):
            continue
        occupied.append(match.span())
        results.append(_classify_date(match.group(0), None, True))

    return results


def _is_bulk_sender(sender: str) -> bool:
    """Marketing senders whose copy is full of the words the ACT rules hunt for.

    Measured 2026-07-29: 1,268 of 1,746 real messages over six months came from these,
    and their 'final notice' / 'last chance' / 'renewal' phrasing fires the money and
    date rules constantly. Suppressing by sender is cheaper and far more accurate than
    trying to out-clever the copywriters.
    """
    lowered = sender.lower()
    return any(pattern in lowered for pattern in _load_values(BULK_SENDERS_PATH))


def _is_critical_sender(address: str) -> bool:
    """Senders whose mail is always worth his eyes, whatever words it uses.

    This exists because keywords cannot do this job. The single most important email
    in six months of mail — the migration agent's 26/05 note that the Carpenter skills
    assessment must be finalised before he turns 45 — contains neither 'visa' nor
    'immigration'. Nothing in the ACT vocabulary caught it and nothing was ever going
    to. Only the sender identifies it.

    Matches an exact address, or an exact domain. Deliberately NOT suffix matching:
    'ato.gov.au' must not drag in 'news.ato.gov.au', which is a newsletter.
    """
    if not address:
        return False
    values = _load_values(CRITICAL_SENDERS_PATH)
    if address in values:
        return True
    domain = address.rsplit("@", 1)[-1]
    return domain in values


def _scope(message: dict[str, Any]) -> str:
    """Subject plus the opening of the body. See SCOPE_CHARS."""
    subject = message.get("subject", "") or ""
    body = message.get("body", "") or ""
    return f"{subject}\n{body[:SCOPE_CHARS]}"


def classify(message: dict[str, Any]) -> dict[str, Any]:
    raw_sender = message.get("from", "")
    if _is_bulk_sender(raw_sender):
        return {
            "account": message.get("account", "signup"),
            "message_id": message["message_id"],
            "bucket": "NOTE",
            "reasons": ["bulk sender"],
            "normalized_date": None,
            "original_date_text": None,
            "subject": message.get("subject", ""),
            "sender": raw_sender,
            "message_date": message.get("date"),
        }

    config = _load_rules()
    subject = message.get("subject", "")
    body = message.get("body", "")
    scope = _scope(message)
    lowered = scope.lower()
    reasons: list[str] = []
    act = False
    review = False
    dates = extract_dates(scope)

    for date_match in dates:
        if date_match.within_30_days:
            act = True
            reasons.append(f"date within 30 days: {date_match.original}")
        elif date_match.ambiguous:
            review = True
            reasons.append(f"ambiguous date: {date_match.original}")
        elif date_match.future_beyond_30_days:
            review = True
            reasons.append(f"future date: {date_match.original}")

    for group in ("money", "legal", "security"):
        for term, pattern in config["act"][group]:
            if pattern.search(lowered):
                act = True
                reasons.append(f"{group}: {term}")

    sender_address = parseaddr(raw_sender)[1].lower()
    if _is_critical_sender(sender_address):
        act = True
        reasons.append("critical sender")

    for term, pattern in config["review"]:
        if pattern.search(lowered):
            review = True
            reasons.append(f"review term: {term}")
    if message.get("attachments"):
        review = True
        reasons.append("has attachment")
    if (
        "?" in body
        and len(body) < 500
        and sender_address
        and "no-reply" not in sender_address
        and "noreply" not in sender_address
    ):
        review = True
        reasons.append("short question from human-looking sender")

    bucket = "ACT" if act else "REVIEW" if review else "NOTE"
    preferred_date = next(
        (item for item in dates if item.within_30_days),
        next((item for item in dates if item.normalized or item.ambiguous), None),
    )
    return {
        "account": message.get("account", "signup"),
        "message_id": message["message_id"],
        "bucket": bucket,
        "reasons": reasons,
        "normalized_date": preferred_date.normalized if preferred_date else None,
        "original_date_text": preferred_date.original if preferred_date else None,
        "subject": subject,
        "sender": raw_sender,
        "message_date": message.get("date"),
    }


def _finding_day(finding: dict[str, Any]) -> str:
    """Findings are filed under the day the MESSAGE arrived, never the day we ran.

    The store used to have two writers with two different answers to this: the brief
    filed everything under today, the backfill filed it under the message date. Neither
    could see the other's rows, so running the backfill and then the brief wrote every
    finding twice — 8,758 rows for 4,351 messages.
    """
    return (finding.get("message_date") or "")[:10] or "undated"


def known_findings() -> set[tuple[str, str]]:
    """Every (account, message_id) already in the store, across all day files."""
    known: set[tuple[str, str]] = set()
    if not FINDINGS_DIR.exists():
        return known
    for path in sorted(FINDINGS_DIR.glob("*.jsonl")):
        for line in path.read_text(encoding="utf-8").splitlines():
            if not line.strip():
                continue
            try:
                record = json.loads(line)
            except ValueError:
                continue
            known.add((record.get("account", "signup"), record.get("message_id", "")))
    return known


def write_findings(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """The ONE writer of the findings store. Idempotent, store-wide deduplicated."""
    if not messages:
        return []
    FINDINGS_DIR.mkdir(parents=True, exist_ok=True)
    existing = known_findings()

    fresh: list[dict[str, Any]] = []
    for message in messages:
        finding = classify(message)
        identity = (finding.get("account", "signup"), finding["message_id"])
        if identity in existing:
            continue
        existing.add(identity)
        fresh.append(finding)

    by_day: dict[str, list[dict[str, Any]]] = {}
    for finding in fresh:
        by_day.setdefault(_finding_day(finding), []).append(finding)
    for day, rows in sorted(by_day.items()):
        path = FINDINGS_DIR / f"{day}.jsonl"
        with path.open("a", encoding="utf-8", newline="\n") as handle:
            for row in rows:
                handle.write(json.dumps(row, ensure_ascii=False) + "\n")
    return fresh
