#!/usr/bin/env python3
"""
Vault Daily Digest
==================
Parses the Obsidian vault's per-area TODO trackers (each carries its action
items + an Open Items section for vault/wiki gaps) and the current Timeline,
classifies each open item by urgency and staleness, asks Claude to write a
crisp morning brief, and emails it.

Designed to run on an always-on host (the server runner) or in CI (the laptop
GitHub Actions runner). Recipients + the digest split come from
Agent/config.json (owner_digests); secrets come from the environment.

Usage (acts on the entity-split digests — the SAME split the hourly sweep sends):
    python -m alexandria_vault.engine.digest --vault X            # parse -> Claude -> email BOTH scoped digests
    python -m alexandria_vault.engine.digest --vault X --print    # parse -> Claude -> print each (no email)
    python -m alexandria_vault.engine.digest --vault X --dry-run  # parse only -> print each structured payload
The split is configured once in digest_specs() and delivered by run_digests() — the
single sender. sweep.py calls that SAME run_digests(), passing its 9am once/day gate as
callbacks, so there is no second send path. A spec with no recipient (its
`owner_digests[].to`; $DIGEST_TO on a bare vault) is skipped, so a half-configured
box still sends what it can.

Config is read from environment variables (see REQUIRED/OPTIONAL below).
"""

import os
import re
import sys
import json
import hashlib
import argparse
import datetime as dt
import unicodedata
from collections import defaultdict
from pathlib import Path

from alexandria_vault.engine import consume_vault_arg, load_fresh, resolve_vault
from alexandria_vault.models import AnthropicProvider, resolve_model
from alexandria_vault.vault_core import fs, load_config

# `--vault` must land in VAULT_DIR before the import-time resolution below runs.
consume_vault_arg()

# ---- Config (env) ----------------------------------------------------------
# REQUIRED for a real send: ANTHROPIC_API_KEY, DIGEST_TO, SMTP_HOST, SMTP_PASS
# OPTIONAL: VAULT_DIR, DIGEST_MODEL, DIGEST_FROM, DIGEST_FROM_NAME (sender display
#           name, default "the agent"), SMTP_PORT, SMTP_USER, DIGEST_STALE_DAYS,
#           DIGEST_HORIZON_DAYS. Recipients come from config (`owner_digests[].to`;
#           $DIGEST_TO only on a bare vault with no owner_digests) — see digest_specs().
# The one locator + the one Config loader (vault_core, docs/DEVAULT.md step 2).
# load_config is lenient by design — a missing/broken Agent/config.json is {} and
# Core falls back to built-in defaults and env vars. This is the Core-reads-Config
# seam: personalization (owner, agent name, digest routing, recipient addresses,
# feature paths) lives in Config, never hardcoded in this engine.
VAULT_DIR    = resolve_vault()
CONFIG       = load_config(VAULT_DIR)


def _agent_name():
    """The chief-of-staff persona's display name (the email 'from' name). From
    config.agent.name; falls back to the DIGEST_FROM_NAME env var, then a generic label."""
    return ((CONFIG.get("agent") or {}).get("name")
            or os.environ.get("DIGEST_FROM_NAME") or "Your assistant")


def _agent_email():
    """The agent's send-from address. From config.agent.email; falls back to DIGEST_FROM."""
    return (CONFIG.get("agent") or {}).get("email") or os.environ.get("DIGEST_FROM")


MODEL        = resolve_model(CONFIG, "digest")   # $DIGEST_MODEL wins -> config.model.digest -> .default
STALE_DAYS    = int(os.environ.get("DIGEST_STALE_DAYS", "10"))
HORIZON_DAYS  = int(os.environ.get("DIGEST_HORIZON_DAYS", "45"))    # timeline look-ahead
SOON_DAYS     = int(os.environ.get("DIGEST_SOON_DAYS", "7"))        # due/check within → "this week"
UPCOMING_DAYS = int(os.environ.get("DIGEST_UPCOMING_DAYS", "30"))   # within → collapsed "coming up"; beyond → backlog
def _today_local():
    """The date the digest treats as "today". The sweep pins this to the owner's resolved
    LOCAL date via DIGEST_TODAY (so "today/tomorrow" buckets track wherever the owner is);
    standalone runs fall back to this machine's clock (the home timezone on the host)."""
    iso = os.environ.get("DIGEST_TODAY")
    if iso:
        try:
            return dt.date.fromisoformat(iso)
        except ValueError:
            pass
    return dt.date.today()

TODAY        = _today_local()

# Priority-tier surfacing knobs (P0 critical … P4 someday). LEAD = how many days
# before its date an item may begin surfacing on the radar; PERIOD = base cadence
# (in days) while it's still far — tightened toward daily as the date nears.
TIER_LEAD    = {0: 3650, 1: 60, 2: 21, 3: 10, 4: 0}
TIER_PERIOD  = {0: 1, 1: 2, 2: 7, 3: 14, 4: 99999}
WEEK_CAP     = int(os.environ.get("DIGEST_WEEK_CAP", "6"))    # max items in "This week"
NEED_CAP     = int(os.environ.get("DIGEST_NEEDS_CAP", "10"))  # max items listed in "Needs a date"

# ---- Patterns --------------------------------------------------------------
DATE_RE    = re.compile(r"(\d{4}-\d{2}-\d{2})")
DUE_RE     = re.compile(r"\bdue (\d{4}-\d{2}-\d{2})")
ADDED_RE   = re.compile(r"\badded (\d{4}-\d{2}-\d{2})")
PRIO_RE    = re.compile(r"#(\d+)")                 # legacy ordinal rank (#1…#23)
TIER_RE    = re.compile(r"\(P([0-4])\)")           # (P0)…(P4) priority tier — P0 critical … P4 someday
CHECK_RE   = re.compile(r"\bcheck (\d{4}-\d{2}-\d{2})")   # soft review / snooze date
OPEN_RE    = re.compile(r"^- \[ \] (.*)")          # top-level open todo
DONE_RE    = re.compile(r"^- \[[xX]\]")            # completed todo
HEADING_RE = re.compile(r"^#{2,4}\s+(.*)")
WIKILINK   = re.compile(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]")
# Owner marker: "(Owner: [[X]])" / "Owners: [[X]], [[Y]]". Captures the run of
# consecutive [[wikilinks]] right after the label (stopping at the first non-link
# text, e.g. "— not the owner's task"), so trailing links like [[Personal TODO]] aren't
# mistaken for owners. Who counts as the owner is configurable via DIGEST_OWNER_SELF.
OWNER_RE   = re.compile(r"(?i)\bowners?:\s*((?:\[\[[^\]]+\]\][\s,&]*(?:and\s+)?)+)")
def _owner_self_names():
    """Names that count as 'the owner' for delegation — an item owned by one of these
    is still the owner's own. From config.owner (name / short_name / aliases), extensible
    via the DIGEST_OWNER_SELF env var. Never hardcodes a specific person."""
    names = set()
    owner = CONFIG.get("owner") or {}
    for key in ("name", "short_name"):
        v = owner.get(key)
        if v:
            names.add(v.strip().lower())
    for a in owner.get("aliases") or []:
        if a:
            names.add(a.strip().lower())
    for n in os.environ.get("DIGEST_OWNER_SELF", "").split(","):
        if n.strip():
            names.add(n.strip().lower())
    return names


OWNER_SELF = _owner_self_names()
MONTHS = {m: i for i, m in enumerate(
    ["January","February","March","April","May","June","July",
     "August","September","October","November","December"], start=1)}


def parse_date(s):
    try:
        return dt.date.fromisoformat(s)
    except (ValueError, TypeError):
        return None


# Dead "(Priority #N)" ordinal markers — leftovers from the Jan-2026 #1–#23 priority
# list. Priority is now the (P0)–(P4) tier system; these tags carry no real ordering
# and must never surface in the digest or Action Board. Matches optional wrapping
# asterisks and a "(was Priority #3: …)" parenthetical.
LEGACY_PRIO_RE = re.compile(r"\s*\*?\((?:was )?[Pp]riority #\d+[^)]*\)\*?")


def _strip_legacy_priority(s):
    return LEGACY_PRIO_RE.sub("", s)


def clean(text):
    text = WIKILINK.sub(lambda m: m.group(2) or m.group(1), text)
    text = text.replace("**", "").replace("`", "")
    text = _strip_legacy_priority(text)
    return re.sub(r"\s+", " ", text).strip()


def area_from(path, text):
    m = re.search(r'area:\s*"?\[\[([^\]|]+)', text)
    if m:
        return m.group(1)
    return path.stem.replace(" TODO", "")


# ---- Owner / delegation ----------------------------------------------------
def _parse_owners(blob):
    """Owner names from a "(Owner: [[X]])" / "Owners: [[X]], [[Y]]" marker, or []."""
    m = OWNER_RE.search(blob)
    return [w.group(1).strip() for w in WIKILINK.finditer(m.group(1))] if m else []


def _owner_is_self(name):
    n = name.strip().lower()
    return n in OWNER_SELF or any(s and n.startswith(s) for s in OWNER_SELF)


def is_mine(it):
    """A todo is the owner's unless it carries an explicit owner who isn't them. Unowned
    = theirs (the default); a co-owned item that names the owner is still theirs."""
    owners = it.get("owners") or []
    return not owners or any(_owner_is_self(o) for o in owners)


# ---- Todo extraction -------------------------------------------------------
def finalize(c):
    blob = "\n".join(c["lines"])
    due = DUE_RE.search(blob)
    chk = CHECK_RE.search(blob)
    added = ADDED_RE.search(blob)
    tier = TIER_RE.search(c["lines"][0])
    rank = PRIO_RE.search(c["lines"][0])
    # context = the indented sub-bullets, minus the bare added/due/check metadata line
    context = [clean(l) for l in c["lines"][1:]
               if not re.match(r"(added|due|check)\s+\d{4}-\d{2}-\d{2}", l.strip())]
    return {
        "area": c["area"],
        "heading": c["heading"],
        "heading_date": c["heading_date"],
        "text": _strip_tier(clean(c["lines"][0])),   # links/bold/(Pn) stripped — for the HTML email
        "raw": c["lines"][0],               # original markdown (live wikilinks, (Pn)) — for the vault note
        "context": " ".join(x for x in context if x) or None,
        "due": due.group(1) if due else None,
        "check": chk.group(1) if chk else None,   # soft review / snooze date
        "added": added.group(1) if added else None,
        "tier": int(tier.group(1)) if tier else 2,   # (P0…P4); default P2 (normal)
        "rank": int(rank.group(1)) if rank else None,  # legacy #N fine-grained order
        "owners": _parse_owners(blob),      # [] = the owner's (default); non-the owner owners → delegated
    }


def collect_todos():
    items = []
    for path in fs.iter_paths(VAULT_DIR, "Areas/**/*TODO.md"):
        if path.name in ("Completed Todos.md",):
            continue
        text = path.read_text(encoding="utf-8")
        area = area_from(path, text)
        heading, heading_date, cur = "", None, None
        in_open_items = False

        def flush():
            if cur:
                items.append(finalize(cur))

        for line in text.splitlines():
            h = HEADING_RE.match(line)
            if h:
                flush(); cur = None
                heading = clean(h.group(1))
                # The "Open Items — vault & wiki gaps" section holds vault gaps, not
                # the owner's action items (collect_open_items() handles those). The flag is
                # STICKY across the section's ### subsections: only a level-2 `## `
                # heading enters/leaves Open Items, so `### Cross-cutting — contact
                # details` under `## Open Items` stays a gap instead of leaking in as an
                # action item. (Open Items is always the last ## section in every file.)
                if line[:3] != "###":
                    in_open_items = heading.lower().startswith("open items")
                hd = DATE_RE.search(h.group(1))
                heading_date = hd.group(1) if hd else None
                continue
            if in_open_items:
                continue
            if OPEN_RE.match(line):
                flush()
                cur = {"area": area, "heading": heading,
                       "heading_date": heading_date,
                       "lines": [OPEN_RE.match(line).group(1).strip()]}
                continue
            if DONE_RE.match(line):
                flush(); cur = None
                continue
            # indented context line belongs to the current item
            if cur and (line.startswith(" ") or line.startswith("\t")) and line.strip():
                cur["lines"].append(re.sub(r"^[-*+]\s+", "", line.strip()))
                continue
            # any other non-blank, non-indented line closes the item
            if cur and line.strip():
                flush(); cur = None
        flush()
    return items


def classify(it):
    """Bucket an item by its controlling date (a hard `due` or a soft `check`).
    Hard-deadline states win; then a reached check-in; then near vs far; an item
    with neither date is a 'floater' that needs one."""
    due = parse_date(it.get("due"))
    check = parse_date(it.get("check"))
    soon = TODAY + dt.timedelta(days=SOON_DAYS)
    horizon = TODAY + dt.timedelta(days=UPCOMING_DAYS)
    if due and due < TODAY:
        return "overdue"
    if due == TODAY:
        return "due_today"
    if check and check <= TODAY:
        return "check_due"          # a check-in came due → resurface
    if due and due <= soon:
        return "due_soon"
    eff = min([d for d in (due, check) if d], default=None)
    if eff is None:
        return "floater"            # no due and no check → needs a date
    if eff <= horizon:
        return "upcoming"           # dated/checked, near → collapsed in the digest
    return "later"                  # beyond the horizon → backlog count


# ---- Day-of-work surfacing (priority + cadence) ----------------------------
# Turns the flat todo list into the digest's "your day of work" sections. The
# engine — not the LLM — decides what surfaces: near-term work lands in
# Today/Tomorrow/This-week by date; far-but-important work surfaces on the Radar
# *intermittently*, at a cadence set by its priority tier and how close it is.
def _eff_date(it):
    """The controlling date for surfacing: the EARLIEST of a hard `due` and a soft
    `check` (a near check-in tees an item up ahead of a far deadline)."""
    ds = [d for d in (parse_date(it.get("due")), parse_date(it.get("check"))) if d]
    return min(ds) if ds else None


def _stable_hash(key):
    return int(hashlib.md5(key.encode("utf-8")).hexdigest()[:8], 16)


def _item_key(it):
    return f"{it['area']}|{it.get('text', '')}"


def _cluster_key(it):
    return f"{it['area']}|{it.get('heading', '')}"


def _cadence_period(tier, days_until):
    """How often (in days) a still-far item of this tier should surface, tightening
    toward daily as its date approaches. P0 = daily, P4 = effectively never."""
    base = TIER_PERIOD.get(tier, 7)
    if base >= 99999 or days_until is None:
        return base
    if days_until <= 3:
        return 1
    if days_until <= 7:
        return max(1, round(base / 3))
    if days_until <= 21:
        return max(1, round(base / 2))
    return base


def _surfaces_today(period, key, today):
    """Deterministic, stateless cadence: a stable per-item hash spreads items across
    days so the radar isn't bursty, and the same (item, day) always decides alike."""
    if period <= 1:
        return True
    if period >= 99999:
        return False
    return (today.toordinal() + _stable_hash(key)) % period == 0


def day_sections(todos, today):
    """Build the digest sections. Each item lands in exactly one near-term section
    (Today/Tomorrow/This week) or surfaces intermittently on the Radar; far event
    clusters roll up under one line. P4 and snoozed items stay out of prose (a P0
    overrides its snooze — critical means now)."""
    tomorrow = today + dt.timedelta(days=1)
    week_end = today + dt.timedelta(days=SOON_DAYS)
    horizon = today + dt.timedelta(days=UPCOMING_DAYS)

    active, delegated = [], []
    for it in todos:
        if it.get("pinned"):
            active.append(it)              # the owner's explicit pick — always in his day-view
            continue
        chk = parse_date(it.get("check"))
        if chk and chk > today and it.get("tier", 2) > 0:
            continue                       # deliberate future check-in snoozes it (a P0/pin overrides)
        # Delegated todos (an explicit owner who isn't the owner) never appear in the owner's
        # day-of-work sections — they're collected for the Follow-ups section only.
        (active if is_mine(it) else delegated).append(it)

    shown = set()
    today_sec, tomorrow_sec, week_sec, radar = [], [], [], []

    def take(it, bucket):
        k = _item_key(it)
        if k in shown:
            return
        shown.add(k)
        bucket.append(it)

    # 0) PINNED — the owner's "on my mind" picks for today lead the section, above all.
    for it in active:
        if it.get("pinned"):
            take(it, today_sec)

    # 1) TODAY — overdue / due today / check-in reached / every NEAR P0.
    # A P0 dated far out isn't a "do it today" item — it drives its event cluster /
    # radar instead (below); only undated or within-the-week P0s land in Today.
    for it in active:
        due = parse_date(it.get("due"))
        chk = parse_date(it.get("check"))
        eff = _eff_date(it)
        p0_now = it.get("tier", 2) <= 0 and (eff is None or eff <= week_end)
        if (due and due <= today) or (chk and chk <= today) or p0_now:
            take(it, today_sec)

    # 2) TOMORROW — whatever's teed up to land tomorrow
    for it in active:
        if _eff_date(it) == tomorrow:
            take(it, tomorrow_sec)

    # notable event-clusters: ≥2 active items under one area+heading with a far anchor
    groups = defaultdict(list)
    for it in active:
        groups[_cluster_key(it)].append(it)
    notable = {k: ms for k, ms in groups.items()
               if ms[0].get("heading")           # must share a real section heading
               and len(ms) >= 2
               and any((_eff_date(m) or today) > week_end for m in ms)}
    notable_keys = set(notable)

    # 3) THIS WEEK — top priorities landing within the week, capped
    week_pool = sorted(
        (it for it in active
         if _item_key(it) not in shown
         and _eff_date(it) and tomorrow < _eff_date(it) <= week_end),
        key=_sortkey)
    for it in week_pool[:WEEK_CAP]:
        take(it, week_sec)
    week_more = max(0, len(week_pool) - WEEK_CAP)

    # 4) RADAR (clusters) — far events, surfaced intermittently by importance
    clusters = []
    for key, members in sorted(notable.items()):
        remaining = [m for m in members if _item_key(m) not in shown]
        if not remaining:
            continue
        dates = [d for d in (_eff_date(m) for m in members) if d]
        future = [d for d in dates if d >= today]
        anchor = min(future) if future else min(dates)
        days_until = (anchor - today).days
        best = min((m.get("tier", 2) for m in members), default=2)
        hot = sum(1 for m in members if m.get("tier", 2) <= 1)
        ctier = max(0, best - 1) if hot >= 3 else best        # a pile of high-pri items = hotter
        if days_until > TIER_LEAD.get(best, 21):
            continue
        if not _surfaces_today(_cadence_period(ctier, days_until), key, today):
            continue
        top = sorted(remaining, key=_sortkey)[:2]
        for m in top:
            shown.add(_item_key(m))
        clusters.append({
            "title": members[0].get("heading") or members[0]["area"],
            "area": members[0]["area"],
            "anchor": anchor.isoformat(),
            "days_until": days_until,
            "open_count": len(members),
            "top": [_slim(m) for m in top],
        })

    # 5) RADAR (standalone) — far items not in a cluster, on cadence
    for it in active:
        if _item_key(it) in shown or _cluster_key(it) in notable_keys:
            continue
        tier = it.get("tier", 2)
        if tier >= 4:
            continue
        eff = _eff_date(it)
        if eff and eff <= week_end:
            continue                       # near items already handled
        if eff is None and tier >= 2:
            continue                       # undated normal/low → Needs-a-date, not radar
        days_until = (eff - today).days if eff else None
        if days_until is not None and days_until > TIER_LEAD.get(tier, 21):
            continue
        if _surfaces_today(_cadence_period(tier, days_until), _item_key(it), today):
            take(it, radar)

    # the rest → counts + the needs-a-date nudge
    needs_date = [it for it in active
                  if not it.get("due") and not it.get("check")
                  and it.get("tier", 2) in (2, 3) and _item_key(it) not in shown]
    coming_up = backlog = 0
    for it in active:
        if _item_key(it) in shown or it.get("tier", 2) >= 4:
            continue
        eff = _eff_date(it)
        if eff and today <= eff <= horizon:
            coming_up += 1
        elif eff and eff > horizon:
            backlog += 1
    return {
        "today": today_sec, "tomorrow": tomorrow_sec,
        "this_week": week_sec, "this_week_more": week_more,
        "radar": radar, "clusters": clusters,
        "needs_date": needs_date, "coming_up": coming_up, "backlog": backlog,
        "delegated": delegated,
    }


# ---- Open Items (vault gaps) & Timeline ------------------------------------
def collect_open_items(area_filter=None):
    # Open Items (vault/wiki gaps) now live in an "## Open Items" section inside
    # each per-area *TODO.md, not in separate files. Scan that section only.
    # `area_filter` (callable area -> bool) optionally scopes which areas' gaps are
    # collected, so a per-area digest only counts its own vault gaps.
    gaps = []
    for path in fs.iter_paths(VAULT_DIR, "Areas/**/*TODO.md"):
        text = path.read_text(encoding="utf-8")
        if area_filter is not None and not area_filter(area_from(path, text)):
            continue
        in_open_items = False
        for line in text.splitlines():
            h = HEADING_RE.match(line)
            if h:
                in_open_items = clean(h.group(1)).lower().startswith("open items")
                continue
            if in_open_items:
                m = OPEN_RE.match(line.strip())
                if m:
                    gaps.append(clean(m.group(1)))
    return gaps


def collect_timeline():
    upcoming = []
    for path in fs.iter_paths(VAULT_DIR, "Events/*Events.md"):
        for line in path.read_text(encoding="utf-8").splitlines():
            m = re.match(r"-\s+\*\*([A-Z][a-z]+) (\d{1,2})", line)
            if not m:
                continue
            month, day = MONTHS.get(m.group(1)), int(m.group(2))
            if not month:
                continue
            try:
                d = dt.date(TODAY.year, month, day)
            except ValueError:
                continue
            if TODAY <= d <= TODAY + dt.timedelta(days=HORIZON_DAYS):
                label = clean(line.split("\u2014", 1)[-1]) if "\u2014" in line else clean(line)
                upcoming.append({"date": d.isoformat(), "label": label[:160]})
    return sorted(upcoming, key=lambda x: x["date"])


# ---- Daily "On My Mind" notes (the day's top-weighted signal) ---------------
# A priority/topic the owner flags in his Daily/ note the night before or morning of
# is the freshest signal there is — so it OUTRANKS the automated ranking, even
# P0. Modelled as a synthetic tier BELOW 0, so it sorts first everywhere the
# engine orders by tier; the digest also renders it as a dedicated lead block.
PINNED_TIER = -1


def _normalize(s):
    """casefold + strip accents + collapse non-alnum to single spaces — the fuzzy
    key matching a daily-note priority back to its canonical TODO."""
    s = unicodedata.normalize("NFKD", s or "")
    s = "".join(c for c in s if not unicodedata.combining(c))
    return re.sub(r"[^a-z0-9]+", " ", s.lower()).strip()


def _daily_note_path(day):
    """Path to the Daily/ note for `day` (`YYYY-MM-DD Dddd.md`), tolerant of the
    weekday suffix; None if there is no note for that day."""
    base = VAULT_DIR / "Daily"
    p = base / f"{day.isoformat()} {day.strftime('%A')}.md"
    if p.exists():
        return p
    hits = sorted(base.glob(f"{day.isoformat()} *.md")) if base.is_dir() else []
    return hits[0] if hits else None


def _pin_label_and_note(item_text):
    """Split a `## Priorities` line into (match-label, why-note): the label is the
    leading [[wikilink]]'s alias/target (else the lead text); the note is the
    trailing reason, with any '→ [[TODO]]' pointer stripped off."""
    wl = WIKILINK.search(item_text)
    if wl:
        label = (wl.group(2) or wl.group(1)).strip()
        note = re.sub(r"^\s*[—\-:·]\s*", "", item_text[wl.end():]).strip()
    else:
        parts = re.split(r"\s+[—-]\s+", clean(item_text), maxsplit=1)
        label, note = parts[0].strip(), (parts[1].strip() if len(parts) > 1 else "")
    note = re.sub(r"\s*(?:→|->).*$", "", note).strip()
    return label, (note or None)


def _section_body(text, name):
    """Lines under the first heading whose title starts with `name` (lowercased),
    up to the next heading."""
    out, grab = [], False
    for line in text.splitlines():
        h = HEADING_RE.match(line)
        if h:
            grab = clean(h.group(1)).lower().startswith(name)
            continue
        if grab:
            out.append(line)
    return out


def collect_on_my_mind(canonical=None):
    """Parse TODAY's Daily/ note into (pins, journal). Pins come from `## Priorities`
    — each resolved to its canonical area TODO by a normalized match (inheriting its
    area/due/check/context so it routes to the right digest), or surfaced standalone
    if unmatched (never dropped). Journal = the `## On my mind` free text. Reads only
    TODAY, so future-dated notes never surface early; ([], None) if there is no note.
    Parses only Priorities + On my mind — ignores Daily Habits / Workout / Debrief."""
    path = _daily_note_path(TODAY)
    if not path:
        return [], None
    try:
        text = path.read_text(encoding="utf-8")
    except OSError:
        return [], None
    idx = {}
    for it in (canonical if canonical is not None else collect_todos()):
        for key in (_normalize(it.get("text", "")), _normalize(it.get("heading", ""))):
            if key:
                idx.setdefault(key, it)

    def _match(label):
        nl = _normalize(label)
        if not nl:
            return None
        if nl in idx:
            return idx[nl]
        return next((it for key, it in idx.items()
                     if key.startswith(nl) or nl in key), None)

    pins = []
    for line in _section_body(text, "priorities"):
        m = OPEN_RE.match(line.strip()) or re.match(r"-\s+(\S.*)", line.strip())
        if not m:
            continue
        item_text = m.group(1).strip()
        if not item_text:
            continue
        label, note = _pin_label_and_note(item_text)
        canon = _match(label)
        if canon:
            pin = dict(canon)
            pin.update(tier=PINNED_TIER, pinned=True, pinned_note=note)
        else:
            pin = {"area": None, "heading": "On my mind", "text": clean(label),
                   "raw": clean(item_text), "context": None, "due": None,
                   "check": None, "added": TODAY.isoformat(), "tier": PINNED_TIER,
                   "rank": None, "owners": [], "pinned": True,
                   "pinned_note": note, "unmatched": True}
        pins.append(pin)

    journal = "\n".join(
        l for l in _section_body(text, "on my mind")
        if l.strip() and not l.strip().startswith("<!--")).strip() or None
    return pins, journal


def ordinal(n):
    if 10 <= n % 100 <= 20:
        suf = "th"
    else:
        suf = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
    return f"{n}{suf}"


# ---- Follow-ups · milestones · workout · meetings · stats · location --------
def _is_followup(it):
    """A todo whose ACTION is to follow up with someone — not one that merely
    mentions 'follow-up' in its description (e.g. '…contacts with no follow-up')."""
    t = (it.get("text") or "").strip()
    if re.match(r"(?i)^(follow[\s-]?up|chase|circle back|check in with|reach out to|touch base)\b", t):
        return True
    if re.search(r"(?i)\bfollow up with\b", t):
        return True
    return bool(re.search(r"(?i)\bfollow[\s-]?up (?:call|email|meeting|message|note)\b", t)
                and re.search(r"(?i)\bwith\b", t))


def _followup_person(it):
    # the person is usually the first [[wikilink]] in a "follow up with [[X]]" line
    m = re.search(r"\[\[([^\]|#]+)", it.get("raw") or "")
    return m.group(1).strip() if m else None


def collect_milestones(within_days=180):
    """Major upcoming milestones from each area's `## Milestones` section — entries
    like `- 2026-10-15 — Kyoto launch → [[…]]`. Future-dated, within the horizon."""
    out = []
    for path in fs.iter_paths(VAULT_DIR, "Areas/**/*TODO.md"):
        text = path.read_text(encoding="utf-8")
        area = area_from(path, text)
        in_ms = False
        for line in text.splitlines():
            h = HEADING_RE.match(line)
            if h:
                in_ms = clean(h.group(1)).lower().startswith("milestones")
                continue
            if not in_ms:
                continue
            m = re.match(r"-\s*(\d{4}-\d{2}-\d{2})\s*[—-]\s*(.+)", line.strip())
            if not m:
                continue
            d = parse_date(m.group(1))
            if not d or d < TODAY or d > TODAY + dt.timedelta(days=within_days):
                continue
            out.append({"date": d.isoformat(), "text": clean(m.group(2)),
                        "area": area, "days_until": (d - TODAY).days})
    return sorted(out, key=lambda x: x["date"])


WEEKDAY_RE = re.compile(r"(monday|tuesday|wednesday|thursday|friday|saturday|sunday)", re.I)


def _rotation_data(text):
    """(anchor_date, {slot:int -> session str}) from a Workout Rotation note: the
    `rotation_anchor` frontmatter date and the `## Rotation` numbered index. The
    rotation length is however many `N.` lines the list has."""
    am = re.search(r'^rotation_anchor:\s*"?(\d{4}-\d{2}-\d{2})', text, re.M)
    anchor = parse_date(am.group(1)) if am else None
    rotation, in_rot = {}, False
    for line in text.splitlines():
        h = HEADING_RE.match(line)
        if h:
            in_rot = clean(h.group(1)).lower().startswith("rotation")
            continue
        if in_rot:
            m = re.match(r"(\d{1,2})\.\s+(.+)", line.strip())
            if m:
                rotation[int(m.group(1))] = clean(m.group(2))
    return anchor, rotation


def _exercises_for(text, session):
    """Pull the (exercise, sets×reps) rows for a session like
    'Week A · Friday — Lower Hypertrophy' out of the `## Week A`/`## Week B` day
    blocks — matching the day block by weekday within the right week. Returns
    (rows, prose); prose is the free text for recovery days that have no table."""
    wk = "week b" if "week b" in session.lower() else "week a"
    wm = WEEKDAY_RE.search(session)
    weekday = wm.group(1).lower() if wm else None
    rows, prose, in_week, in_day = [], [], False, False
    for line in text.splitlines():
        h = HEADING_RE.match(line)
        if h:
            head = clean(h.group(1)).lower()
            if head.startswith("week a") or head.startswith("week b"):
                in_week, in_day = head.startswith(wk), False
            elif in_week and WEEKDAY_RE.match(head):
                in_day = bool(weekday and weekday in head)
            elif in_week:
                in_day = False
            continue
        if in_week and in_day:
            t = line.strip()
            if t.startswith("|"):
                cells = [c.strip() for c in t.strip("|").split("|")]
                if (len(cells) >= 2 and cells[0]
                        and set(cells[0]) != {"-"} and cells[0].lower() != "exercise"):
                    rows.append((cells[0], cells[1]))
            elif t:
                prose.append(t)
    return rows, (" ".join(prose).strip() or None)


def workout_detail(day=None):
    """Today's slot in the rotation, WITH the day's exercise table — computed from
    `rotation_anchor`. The rotation note is OPTIONAL and config-driven
    (config.features.workout_note, a vault-relative path); if unset or missing this
    returns None so the digest stays quiet. The sweep materializes this into each day's
    note and the digest surfaces it — one source of logic, no drift."""
    day = day or TODAY
    workout_note = (CONFIG.get("features") or {}).get("workout_note")
    if not workout_note:
        return None
    path = VAULT_DIR / workout_note
    try:
        text = path.read_text(encoding="utf-8")
    except OSError:
        return None
    anchor, rotation = _rotation_data(text)
    if not rotation or not anchor or day < anchor:
        return None
    slot = ((day - anchor).days % len(rotation)) + 1
    session = rotation.get(slot, "—")
    rows, prose = _exercises_for(text, session)
    return {"day": slot, "session": session, "exercises": rows,
            "prose": prose, "is_rest": ("rest" in session.lower() and not rows)}


def canonical_habits():
    """The canonical daily-habits checklist TEXT from Daily/Daily.md (`## Daily
    Habits`). Habit text only — each day's note carries its own checkbox state."""
    path = VAULT_DIR / "Daily" / "Daily.md"
    try:
        text = path.read_text(encoding="utf-8")
    except OSError:
        return []
    out, in_hab = [], False
    for line in text.splitlines():
        h = HEADING_RE.match(line)
        if h:
            in_hab = clean(h.group(1)).lower().startswith("daily habits")
            continue
        if in_hab:
            m = re.match(r"-\s*\[[ xX]\]\s*(.+)", line.strip())
            if m:
                out.append(clean(m.group(1)))
    return out


def collect_workout():
    """The digest's Workout payload: today's session, its exercise table (so the
    email can carry the full workout), and the canonical habit checklist. Returns
    None before the rotation starts (unchanged), so the section stays quiet."""
    wd = workout_detail(TODAY)
    if not wd:
        return None
    return {"day": wd["day"], "session": wd["session"],
            "exercises": wd["exercises"], "prose": wd["prose"],
            "is_rest": wd["is_rest"], "habits": canonical_habits()}


def collect_completed_this_week():
    """Count items completed in the last 7 days from [[Completed Todos]] stamps."""
    try:
        text = (VAULT_DIR / "Archive" / "Completed Todos.md").read_text(encoding="utf-8")
    except OSError:
        return 0
    cutoff = TODAY - dt.timedelta(days=7)
    return sum(1 for m in re.finditer(r"completed (\d{4}-\d{2}-\d{2})", text)
               if (parse_date(m.group(1)) or dt.date.min) >= cutoff)


def collect_meetings():
    """Today's calendar events via the Google Calendar API (Workspace service
    account with domain-wide delegation). Returns [] unless configured
    (GOOGLE_CALENDAR_SA_FILE + GOOGLE_CALENDAR_ID, optional _SUBJECT to impersonate)
    and the google client libs are installed — so it's safe before that setup."""
    sa = os.environ.get("GOOGLE_CALENDAR_SA_FILE")
    cal_id = os.environ.get("GOOGLE_CALENDAR_ID")
    if not sa or not cal_id:
        return []
    try:
        from google.oauth2 import service_account
        from googleapiclient.discovery import build as gbuild
        creds = service_account.Credentials.from_service_account_file(
            sa, scopes=["https://www.googleapis.com/auth/calendar.readonly"])
        subject = os.environ.get("GOOGLE_CALENDAR_SUBJECT")
        if subject:
            creds = creds.with_subject(subject)
        svc = gbuild("calendar", "v3", credentials=creds, cache_discovery=False)
        lo = dt.datetime.combine(TODAY, dt.time.min).isoformat() + "Z"
        hi = dt.datetime.combine(TODAY, dt.time.max).isoformat() + "Z"
        events = svc.events().list(calendarId=cal_id, timeMin=lo, timeMax=hi,
                                   singleEvents=True, orderBy="startTime").execute()
        out = []
        for e in events.get("items", []):
            st = e.get("start", {})
            when = st.get("dateTime", st.get("date", ""))
            out.append({
                "time": when[11:16] if "T" in when else "all-day",
                "title": e.get("summary", "(no title)"),
                "location": e.get("location"),
                "attendees": [a.get("email") for a in e.get("attendees", [])
                              if not a.get("self")][:8] or None,
            })
        return out
    except Exception:
        return []                          # missing libs / bad creds / API error → no section


def _resolve_full():
    """The full location dict from engine.location (incl. lat/lon),
    or {} on failure so standalone digest runs still work."""
    try:
        return load_fresh("location").resolve(VAULT_DIR) or {}
    except Exception:
        return {}


def _resolve_location():
    """Best-effort current-city label — None on failure."""
    return _resolve_full().get("location")


def collect_weather(loc=None):
    """Best-effort today's weather for the owner's current city, or None. Gated by
    DIGEST_WEATHER (default on). Called only on the digest send path (NOT in
    build_payload, which the hourly Action Board also runs) so it costs one network
    call per day, not per sweep. Returns the weather dict plus a preformatted `line`."""
    if os.environ.get("DIGEST_WEATHER", "1") in ("0", "false", ""):
        return None
    loc = loc if loc is not None else _resolve_full()
    lat, lon = loc.get("lat"), loc.get("lon")
    if lat is None or lon is None:
        return None
    try:
        wx = load_fresh("weather")
        w = wx.fetch(lat, lon, loc.get("tz"))
        return {**w, "line": wx.line(w, loc.get("location"))} if w else None
    except Exception:
        return None


# ---- Assemble payload ------------------------------------------------------
def _load_team_state():
    """Best-effort import of engine.team_state (the off-repo team email-loop store).
    Returns None when the team-digests module isn't enabled for this vault, so the
    digest never breaks when the team-reply feature isn't installed/enabled. (The
    module file used to render into the vault only when enabled — "file absent =
    module off"; app-side it always exists, so the gate is the config.)"""
    try:
        if "team-digests" not in (((CONFIG.get("modules") or {}).get("enabled")) or []):
            return None
        return load_fresh("team_state")
    except Exception:
        return None


def _collect_team_updates():
    """Progress updates the owner's team (his assistant, chief of staff, …) has reported by
    email since his last digest — queued by team_inbox (each tagged with `who`), drained
    into his morning brief, cleared once it sends."""
    st = _load_team_state()
    try:
        return st.get_pending_for_owner() if st else []
    except Exception:
        return []


def _sortkey(it):
    return (it.get("tier", 2), it.get("due") or it.get("check") or "9999", it.get("rank") or 999)


def build_payload(area_filter=None, personal=True, scope=None):
    """Assemble the full digest payload.

    `area_filter` (a callable taking an area name -> bool) scopes the AREA-DRIVEN
    content — todos, Open-Items gaps, and milestones — so one engine can build a
    single-area or everything-but-one-area digest (None = every area, the default the
    Action Board and standalone runs use). `personal` gates the cross-area / lifestyle
    sections — workout, meetings, contact follow-ups (in digest_payload), and the
    done-this-week stat; Timeline and the weather header are always included regardless
    so a scoped work digest still carries the day's context.

    `scope` names the single area this digest is for (e.g. "Acme") when it covers
    exactly one — surfaced to the prompt as `single_area` so it drops the now-redundant
    per-item area label. Leave None for a multi-area digest (area labels are kept).
    """
    all_todos = collect_todos()
    todos = all_todos
    if area_filter is not None:
        todos = [it for it in all_todos if area_filter(it.get("area", ""))]
    # Daily "on my mind" pins — the owner's freshest signal, ranked above P0. Matched
    # against the FULL todo set (so each resolves its canonical area), then scoped
    # to THIS digest: a matched pin rides its inherited area through area_filter; an
    # unmatched pin / the journal has no area, so it rides only the personal
    # (everything-else) digest, never the Acme split.
    pins_all, journal = collect_on_my_mind(canonical=all_todos)
    if area_filter is None:
        pins = list(pins_all)
    else:
        pins = [p for p in pins_all
                if (p.get("area") and area_filter(p.get("area", "")))
                or (p.get("area") is None and personal)]
    if not personal:
        journal = None
    buckets = {}
    if pins:
        buckets["pinned"] = list(pins)            # own bucket → Action Board 📌 section
    for it in todos:
        buckets.setdefault(classify(it), []).append(it)
    for b in buckets.values():
        b.sort(key=_sortkey)
    milestones = collect_milestones()
    if area_filter is not None:
        milestones = [m for m in milestones if area_filter(m.get("area", ""))]
    return {
        "date": TODAY.strftime("%A, %B ") + ordinal(TODAY.day) + TODAY.strftime(", %Y"),
        "buckets": buckets,                       # date-bucketed everything → Action Board
        "sections": day_sections(pins + todos, TODAY),   # pins lead; "day of work" view → email
        "on_my_mind": pins,                       # the day's top-weighted lead block
        "on_my_mind_journal": journal,
        "open_items": collect_open_items(area_filter),
        "timeline": collect_timeline(),
        "milestones": milestones,
        "workout": collect_workout() if personal else None,
        "meetings": collect_meetings() if personal else [],
        "location": _resolve_location(),
        "done_this_week": collect_completed_this_week() if personal else 0,
        "team_updates": _collect_team_updates() if personal else [],
        "counts": {k: len(v) for k, v in buckets.items()},
        "personal": personal,
        "scope": scope,
    }


def _slim(it):
    # No "rank": the legacy #N ordinal is an internal sort tiebreaker only — never
    # expose it to the digest LLM, or it resurfaces as a bogus "(Priority #N)".
    return {k: it.get(k) for k in ("area", "text", "context", "due", "check", "tier")}


def digest_payload(full):
    """The decluttered 'your day of work' view for the EMAIL. Today gets full
    briefings; Tomorrow + This week are short previews; far-but-important work
    surfaces on the Radar (standalone items + event clusters) intermittently;
    everything else collapses to counts + the needs-a-date nudge."""
    s = full["sections"]
    # Pull follow-up todos OUT of every section into a dedicated Follow-ups section
    # that LEADS the email — a habit-builder for staying in touch. They scatter
    # across today / this-week / radar / needs-a-date, so sweep them all.
    fu_items = []

    def _split(items):
        keep = []
        for it in items:
            (fu_items if _is_followup(it) else keep).append(it)
        return keep

    today_n = _split(s["today"])
    tomorrow_n = _split(s["tomorrow"])
    week_n = _split(s["this_week"])
    radar_n = _split(s["radar"])
    needs_n = _split(s["needs_date"])
    # Follow-ups carry NO area: a follow-up is keyed on the PERSON to reach out to, and
    # an area label is noise (and unknowable for contact reminders, which made the model
    # fabricate the digest's entity — e.g. tagging every follow-up "Acme Holdings").
    followups = [{"person": _followup_person(it), "text": it.get("text"),
                  "context": it.get("context"), "due": it.get("due"),
                  "source": "todo"} for it in fu_items]
    # Delegated todos (owner ≠ the owner): surfaced ONLY here, as a nudge to follow up with
    # the owner — never in the owner's day-of-work sections.
    followups += [{"person": ", ".join(it.get("owners") or []) or None,
                   "text": it.get("text"), "context": it.get("context"),
                   "due": it.get("due"), "source": "delegated"} for it in s.get("delegated", [])]
    # Follow-ups come ONLY from area todos (todo + delegated). Contacts are pure
    # relationship records and never carry tasks/follow-ups (AGENTS → Contacts), so the
    # digest does not source follow-ups from Contacts/ — a follow-up the owner owes someone
    # lives as a "Follow up with [[Person]]" item in that person's area `<Area> TODO.md`.
    payload = {
        "date": full["date"],
        # The day's top-weighted lead block — what the owner flagged as on his mind. Ranked
        # above everything (even tier-0); the prompt leads the email with it.
        "on_my_mind": [{"area": p.get("area"), "text": p.get("text"),
                        "context": p.get("context"), "note": p.get("pinned_note"),
                        "due": p.get("due"), "check": p.get("check")}
                       for p in (full.get("on_my_mind") or [])],
        "on_my_mind_journal": full.get("on_my_mind_journal"),
        "single_area": full.get("scope"),   # set → single-area digest; prompt drops per-item area labels
        "location": full.get("location"),
        "followups": followups,
        "meetings": full.get("meetings", []),
        "workout": full.get("workout"),
        "today": [_slim(it) for it in today_n],
        "tomorrow": [_slim(it) for it in tomorrow_n],
        "this_week": [_slim(it) for it in week_n],
        "this_week_more": s["this_week_more"],
        "radar": [_slim(it) for it in radar_n],
        "clusters": s["clusters"],
        "milestones": full.get("milestones", []),
        "needs_date": [_slim(it) for it in needs_n[:NEED_CAP]],
        "needs_date_total": len(needs_n),
        "coming_up_count": s["coming_up"],
        "backlog_count": s["backlog"],
        "done_this_week": full.get("done_this_week", 0),
        "team_updates": full.get("team_updates", []),   # team's emailed progress since last digest (each has `who`)
        "timeline": full["timeline"][:8],
        "vault_gaps_count": len(full["open_items"]),
    }
    # Single-area digest: strip the now-redundant per-item area so the prose can't
    # reprint it (e.g. "(Acme)" on the radar) even if the model ignores the
    # prompt's omit rule. The multi-area general digest (scope None) keeps its labels.
    if full.get("scope"):
        for key in ("today", "tomorrow", "this_week", "radar", "needs_date", "on_my_mind"):
            for it in payload.get(key) or []:
                it.pop("area", None)
        for c in payload.get("clusters") or []:
            c.pop("area", None)
            for t in c.get("top") or []:
                t.pop("area", None)
    return payload


# ---- Action Board (vault dashboard note) -----------------------------------
def _trim(s, n=240):
    """Trim a long top-line for the board without breaking a wikilink: cut on a
    word boundary, and back off if it would leave an unbalanced `[[`."""
    s = s.strip()
    if len(s) <= n:
        return s
    cut = s[:n].rsplit(" ", 1)[0]
    if cut.count("[[") > cut.count("]]"):
        cut = cut[:cut.rfind("[[")].rstrip()
    return cut.rstrip(" ,.;:—-") + " …"


def _strip_tier(s):
    """Drop the (Pn) marker from display text — the board shows it as a badge — and
    the dead (Priority #N) ordinals. (Applied to the Action Board's raw markdown,
    which doesn't pass through clean().)"""
    return _strip_legacy_priority(re.sub(r"\s*\(P[0-4]\)\s*", " ", s)).strip()


def render_action_board(payload, location=None, generated_at=None):
    """Render the collated todos as the top-level `Action Board.md` markdown.
    Deterministic (no Claude) so it's idempotent — the same payload always
    produces byte-identical output, which keeps the git history quiet. Uses the
    `raw` text so wikilinks stay live (clickable) in Obsidian.

    `generated_at` (a local datetime) stamps a wall-clock time alongside the date
    so it's visible when the board last *regenerated*. The caller's idempotency
    check (`sweep.py task_action_board`) excludes the time from its diff — same
    todos ⇒ no write ⇒ stamp stays at the last actual regeneration, which is the
    truthful semantics. Omit the arg (default) to render without a time, which is
    what one-off / test renders want."""
    b = payload["buckets"]
    pinned = b.get("pinned", [])      # the owner's "on my mind" picks — their own top section
    # P0 (critical) items get their own top section, pulled out of their date bucket.
    p0 = [it for k, v in b.items() if k != "pinned" for it in v if it.get("tier") == 0]

    def fmt(it):
        text = _trim(_strip_tier(it.get("raw") or it["text"]))
        badge = {0: "🔴 ", 1: "🟠 ", 3: "🟢 ", 4: "⚪ "}.get(it.get("tier", 2), "")
        if it.get("due"):
            d = f" *(due {it['due']})*"
        elif it.get("check"):
            d = f" *(check {it['check']})*"
        else:
            d = " *(no date — needs one)*"
        return f"- {badge}**{it.get('area') or 'On my mind'}** — {text}{d}"

    def section(emoji, title, items, drop_p0=True):
        if drop_p0:
            items = [it for it in items if it.get("tier") != 0]
        if not items:
            return ""
        items = sorted(items, key=_sortkey)
        return f"\n## {emoji} {title} ({len(items)})\n\n" + "\n".join(fmt(it) for it in items) + "\n"

    parts = [
        section("\U0001F4CC", "On my mind today", pinned, drop_p0=False),
        section("🚨", "Critical — P0", p0, drop_p0=False),
        section("⏰", "Overdue", b.get("overdue", [])),
        section("\U0001F4CD", "Due today", b.get("due_today", [])),
        section("\U0001F514", "Check-in due", b.get("check_due", [])),
        section("\U0001F5D3️", "This week", b.get("due_soon", [])),
        section("\U0001F52D", "Coming up (≤%dd)" % UPCOMING_DAYS, b.get("upcoming", [])),
        section("\U0001F4A4", "Backlog / later", b.get("later", [])),
        section("⚠️", "Needs a date", b.get("floater", [])),
    ]
    tl = payload.get("timeline") or []
    if tl:
        parts.append("\n## \U0001F4C5 Events (%d)\n\n" % len(tl)
                     + "\n".join(f"- **{u['date']}** — {u['label']}" for u in tl) + "\n")
    gaps = payload.get("open_items") or []
    if gaps:
        parts.append("\n## \U0001F573️ Vault gaps (%d)\n\n" % len(gaps)
                     + "\n".join(f"- {g}" for g in gaps) + "\n")

    body = "".join(p for p in parts if p) or "\n*Nothing open — inbox zero.*\n"
    loc = f" · {location}" if location else ""
    n = sum(v for k, v in payload["counts"].items() if k != "pinned")
    # Time-of-day stamp on the "generated" line — stripped by the caller's idempotency
    # check so it doesn't churn the git history when no todos have actually changed.
    at = f" at {generated_at.strftime('%H:%M')}" if generated_at is not None else ""
    return (
        "---\ntype: note\ntags: [meta, dashboard]\nauthor: claude\n---\n"
        "<!-- AUTO-GENERATED by the hourly sweep (Agent/scripts/sweep). Do not hand-edit —\n"
        "     regenerated each run; edits are overwritten. Manage todos in the per-area\n"
        "     <Area> TODO.md files. -->\n\n"
        "# Action Board\n\n"
        f"> Every open action item across all areas, collated by the [[Vault Hourly Agent]] "
        f"sweep from each area's `<Area> TODO.md` (action items + Open Items). "
        f"**{n} open** · generated {payload['date']}{at}{loc}.\n"
        f">\n> 🚨🔴 P0 (critical) · 🟠 P1 (high) · 🟢 P3 (low) · ⚪ P4 (someday) · "
        f"sorted by priority then date. Items with no `due`/`check` date are flagged **Needs a date**.\n"
        f"{body}"
    )


# The digest's system prompt is editable in the vault at Agent/Vault Daily Digest Prompt.md
# (loaded by _load_system() below). This constant is the built-in FALLBACK, used only if
# that note is missing or empty — keep it functional and roughly in sync with the note.
_DEFAULT_SYSTEM = """You are the owner's chief of staff. Write their morning vault digest as a COMPLETE email in SIMPLE HTML \u2014 framed as "your day of work," not a list dump.
The owner's areas are the folders under Areas/; treat each item's area as given and don't infer roles or context beyond the payload.

You are given an ALREADY-FILTERED payload \u2014 the engine has already decided what surfaces today and what stays collapsed. Do NOT invent items or pull in anything beyond what's given, and do NOT second-guess the filtering. Each item has: area, text (the task), context (who/what/why/blockers), due (hard deadline), check (soft review date), tier (0=critical, 1=high, 2=normal, 3=low).

FORMAT \u2014 plain, like a plaintext email with light structure:
- Use ONLY these tags: <h3> headers, <ul>/<li> lists, <b> emphasis, <br> line breaks. No CSS, style/class, <div>, <p>, <table>. Close every tag.
- No Markdown, no asterisk bullets, no code fences.

PAYLOAD \u2192 SECTIONS, in this order \u2014 emit a section ONLY if its list is non-empty:
1. One short greeting line that names the SHAPE of the day (e.g. "3 to land today, 2 teed up for tomorrow, Kyoto is heating up").
1b. <h3>On your mind today</h3> — from on_my_mind, ONLY if non-empty: the priorities/topics the owner flagged the night before or this morning. They are the FRESHEST signal and OUTRANK everything below, even tier-0 — lead with them, right after the greeting. One <li> each using its note/context (and "(due {due})" if set). If on_my_mind_journal is present, add a short <h3>Notes to self</h3> with their journal text right after.
2. <h3>Today</h3> \u2014 the must-dos. Give each a real briefing from context: what it is, who's involved, the deadline/blocker, the concrete next action. 2\u20134 sentences each; do NOT compress these. Call out tier-0 items as critical.
3. <h3>Tomorrow</h3> \u2014 one short line each: what's teed up to land tomorrow, so he can pre-load it.
4. <h3>This week</h3> \u2014 the key priorities landing within ~7 days, one substantive line each, grouped by area. If this_week_more > 0, end the section with "\u2026and {this_week_more} more this week."
5. <h3>On the radar</h3> \u2014 far-but-important work, kept deliberately brief:
   - For each entry in clusters: one line \u2014 "<b>{title}</b> ({area}) \u2014 {days_until}d out \u00b7 {open_count} open" then name its 1\u20132 top items as the critical path.
   - For each item in radar: one short line (these are far high-priority items surfacing as a nudge so they don't sneak up).
6. <h3>Needs a date or priority</h3> \u2014 the needs_date items have NO due/check date. Short one-liners; if needs_date_total exceeds the list shown, end with "\u2026and N more need a date." Tell the owner to add a `due`/`check` date or a P-tier.
7. One closing line collapsing the rest to counts: "Coming up: {coming_up_count} within 30 days \u00b7 Backlog: {backlog_count} \u00b7 Events: N \u00b7 Vault gaps: {vault_gaps_count}." If events are present you may list them in one brief <ul>.

If team_updates is non-empty, add an <h3>Team reported</h3> section right after the greeting: one <li> per update \u2014 "<b>{who}</b> \u00b7 {area} \u2014 {text}: {action}" plus the note if present. These are progress reports members of the owner's team (each item's `who`) have sent by email since the last digest (action is done / blocked / progress); they are FYI and already recorded in the vault \u2014 do NOT re-list them as action items for the owner.

Keep it tight and skimmable \u2014 surface what matters today and on the near horizon; never drown it in far or low-priority items. The owner should be able to run their day straight from this email."""


def _load_system():
    """Load the digest's system prompt from the editable Config note
    `Agent/Digest Prompt.md` (or $DIGEST_SYSTEM_FILE). Strips YAML frontmatter +
    HTML comments, and uses the first fenced code block if present. Falls back to
    _DEFAULT_SYSTEM if the note is missing or empty, so a deleted or half-saved note
    never breaks the digest."""
    env = os.environ.get("DIGEST_SYSTEM_FILE")
    path = Path(env).expanduser() if env else (VAULT_DIR / "Agent" / "Digest Prompt.md")
    try:
        text = path.read_text(encoding="utf-8")
    except OSError:
        return _DEFAULT_SYSTEM
    text = re.sub(r"^\s*---\n.*?\n---\n", "", text, count=1, flags=re.S)   # strip frontmatter
    text = re.sub(r"<!--.*?-->", "", text, flags=re.S)                     # strip HTML comments
    fence = re.search(r"```[a-zA-Z]*\n(.*?)```", text, flags=re.S)         # prefer a fenced block
    if fence:
        text = fence.group(1)
    return text.strip() or _DEFAULT_SYSTEM


def write_digest(payload):
    # Outbound send: a provider error PROPAGATES (a broken digest must fail loudly,
    # never mail an empty brief) — the swallow-vs-propagate split of V3-PLAN §2.1.
    provider = AnthropicProvider(max_retries=4)   # SDK retries transient 429/5xx/network
    reply = provider.chat(
        [{"role": "user", "content": json.dumps(digest_payload(payload), ensure_ascii=False)}],
        system=_load_system(), model=MODEL, max_tokens=8000,
    )
    html = reply.unfenced()
    # Prepend a deterministic weather/sun line (exact numbers from our code, not the
    # LLM). Best-effort: collect_weather() returns None on any failure, so the digest
    # is unaffected if the fetch is down or the city note lacks coordinates.
    wline = (collect_weather() or {}).get("line")
    if wline:
        html = f"{wline}<br><br>\n{html}"
    # Append deterministic federation status (§6 of the federation plan): exact strings
    # from outbox state — staged-awaiting-fire, delivered, expiring, expired, rejected —
    # never handed to the LLM to rephrase. Best-effort: empty (or the extra absent) means
    # nothing is appended, so a non-federating brain's digest is untouched.
    try:
        from alexandria_vault.federation import surfaces as _fed_surfaces
        fed_lines = _fed_surfaces.digest_lines(CONFIG)
    except Exception:  # noqa: BLE001 — missing extra / no state yet / any read failure
        fed_lines = []
    if fed_lines:
        html = f"{html}\n<br><br>\n" + "<br>\n".join(fed_lines)
    return html


def _html_to_text(html):
    t = re.sub(r"(?i)<br\s*/?>", "\n", html)
    t = re.sub(r"(?i)</(li|h2|h3|p|ul|ol)>", "\n", t)
    t = re.sub(r"(?i)<li>", "- ", t)
    t = re.sub(r"<[^>]+>", "", t)
    return re.sub(r"\n{3,}", "\n\n", t).strip()


def send_email(html, subject, to=None, frm=None, from_name=None, to_name=None, in_reply_to=None):
    """Send the multipart text+HTML email. `to` / `frm` override the DIGEST_TO /
    DIGEST_FROM env defaults so the sweep can fire the two scoped digests at different
    recipients off one helper. The From header carries a display NAME so recipients see
    `"the agent" <pascal@example.com>`, not a bare address — name from `from_name`,
    else DIGEST_FROM_NAME, default "the agent" (pass from_name="" to force a bare address).
    `to_name` does the same for the recipient — pass it to address e.g. `"a team member"
    <olivia@…>` instead of a bare address (default None = bare, unchanged for the owner's
    digests). `in_reply_to` (a Message-ID) threads this as a reply (used by the team
    inbox confirmation). Always stamps an explicit Message-ID and RETURNS it, so the team
    digest can record which briefs it sent (the inbox proves a reply threads off a real
    one). Auth is always as SMTP_USER (the authenticating mailbox), independent of the
    visible From — which must be either that SAME mailbox (the current setup since
    2026-06-17: DIGEST_FROM == SMTP_USER == pascal@example.com, the agent's own
    dedicated account) or a verified send-as alias on it."""
    import smtplib
    import time
    from email.message import EmailMessage
    from email.utils import formataddr, make_msgid
    to = to or os.environ.get("DIGEST_TO")
    frm = frm or _agent_email() or to
    if from_name is None:
        from_name = os.environ.get("DIGEST_FROM_NAME") or _agent_name()
    em = EmailMessage()
    em["Subject"] = subject
    msg_id = make_msgid(domain=frm.split("@")[-1] if "@" in frm else None)
    em["Message-ID"] = msg_id
    if in_reply_to:
        em["In-Reply-To"] = in_reply_to
        em["References"] = in_reply_to
    em["From"] = formataddr((from_name, frm)) if from_name else frm
    em["To"] = formataddr((to_name, to)) if to_name else to
    em.set_content(_html_to_text(html))
    em.add_alternative(html, subtype="html")
    host = os.environ["SMTP_HOST"]
    port = int(os.environ.get("SMTP_PORT", "587"))
    # Retry a transient SMTP/network failure (with a connect timeout) so one blip doesn't
    # silently drop the digest for the hour.
    last = None
    for attempt in range(3):
        try:
            with smtplib.SMTP(host, port, timeout=30) as s:
                s.starttls()
                s.login(os.environ.get("SMTP_USER", frm), os.environ["SMTP_PASS"])
                s.send_message(em)
            break
        except (smtplib.SMTPException, OSError) as e:
            last = e
            if attempt < 2:
                time.sleep(2 ** attempt)
    else:
        raise last
    # Record this outbound Message-ID keyed by recipient, so a reply FROM that recipient can
    # be proven to thread off a genuine the agent email (the inbound paths' threading gate —
    # lets the owner/team reply naturally without re-supplying a passphrase). Best-effort.
    try:
        ts = _load_team_state()
        if ts:
            ts.record_sent_message(to, msg_id)
    except Exception:
        pass
    return msg_id


def _area_key(a):
    return (a or "").strip().lower()


def digest_specs():
    """The entity-split configuration \u2014 the SINGLE source of truth for how the daily
    digest is split. Each spec carries the cadence `key`, the email `label` (subject),
    the recipient `to` (the address, from config — not an env var), the `area_filter`,
    whether to include the `personal` sections, and the `scope` (single-area name, or
    None). Consumed by both digest.py `main()` (sends/prints all) and
    sweep.py.task_daily_digest (which wraps each with the 9am once/day gating)."""
    ods = CONFIG.get("owner_digests") or []
    if not ods:
        # Bare vault (no config): one all-areas digest to $DIGEST_TO so the engine still runs.
        return [{"key": "general", "label": "Vault Daily Digest",
                 "to": os.environ.get("DIGEST_TO"), "area_filter": (lambda a: True),
                 "personal": True, "scope": None, "is_general": True}]
    claimed = set()
    for od in ods:
        if isinstance(od.get("areas"), list):
            claimed |= {_area_key(a) for a in od["areas"]}
    specs = []
    for od in ods:
        areas = od.get("areas")
        is_rest = areas in (None, "rest")
        if is_rest:
            area_filter = (lambda a, c=frozenset(claimed): _area_key(a) not in c)
            scope = None
        else:
            names = frozenset(_area_key(a) for a in areas)
            area_filter = (lambda a, n=names: _area_key(a) in n)
            scope = areas[0] if len(areas) == 1 else None
        specs.append({
            "key": od.get("id") or od.get("title") or "digest",
            "label": od.get("title") or "Vault Daily Digest",
            "to": od.get("to"),
            "area_filter": area_filter,
            "personal": bool(od.get("personal", is_rest)),
            "scope": scope,
            "is_general": is_rest,
        })
    return specs


def subject_for(spec, payload):
    return "\U0001F4EC " + spec["label"] + " \u2014 " + payload["date"]


def run_digests(mode="send", should_send=None, after_send=None, sink=print):
    """THE single digest send/render path \u2014 the master both entry points funnel through.

    For each spec in digest_specs(), produce output per `mode`:
      'send'    build -> Claude prose -> email          (production, and `digest.sh`)
      'print'   build -> Claude prose -> print HTML      (no send)
      'payload' build -> print the JSON payload          (no Claude, no send; --dry-run)
      'plan'    report "would send \u2026"                    (no build, no Claude, no send)

    `should_send(key) -> bool` gates each spec (default: send all). sweep.py passes its
    9am once/day check here, so the cadence *wraps* this one sender rather than
    re-implementing the loop. `after_send(key)` runs only after a real send (sweep.py
    uses it to persist the per-digest "sent" flag immediately). `sink` receives each
    status line \u2014 pass None to stay silent and read the returned list instead.

    There is no other place that sends a digest: both `python3 digest.py` and
    sweep.py.task_daily_digest call this, so the split (specs) AND the delivery live in
    exactly one spot. A spec whose recipient env is unset is skipped (send/plan) or
    tagged (print/payload, so it's still inspectable locally)."""
    results = []
    for spec in digest_specs():
        if should_send is not None and not should_send(spec["key"]):
            continue
        to = spec.get("to")
        if not to and mode in ("send", "plan"):
            results.append(f"skipped {spec['label']}: no recipient set (owner_digests[].to)")
            continue
        if mode == "plan":
            results.append(f"would send {spec['label']} -> {to}")
            continue
        payload = build_payload(area_filter=spec["area_filter"],
                                personal=spec["personal"], scope=spec["scope"])
        subject = subject_for(spec, payload)
        tag = to or "(no recipient set)"
        if mode == "payload":
            results.append(f"===== {subject}  ->  {tag} =====\n"
                           + json.dumps(digest_payload(payload), indent=2, ensure_ascii=False))
            continue
        html = write_digest(payload)
        if mode == "print":
            results.append(f"===== {subject}  ->  {tag} =====\n{html}\n")
            continue
        send_email(html, subject, to=to)                 # mode == "send"
        results.append(f"sent {spec['label']} -> {to}")
        # the owner's personal brief carries the team's emailed progress updates; once it has
        # actually gone out, drain that queue so they don't repeat in tomorrow's digest.
        if spec.get("is_general"):
            st = _load_team_state()
            if st:
                try:
                    st.clear_pending_for_owner()
                except Exception:
                    pass
        if after_send is not None:
            after_send(spec["key"])
    if sink is not None:
        for r in results:
            sink(r)
    return results


def main():
    ap = argparse.ArgumentParser(description="Send (or preview) the entity-split vault digests.")
    ap.add_argument("--dry-run", action="store_true", help="parse only; print each scoped payload")
    ap.add_argument("--print", dest="print_only", action="store_true", help="call Claude; print each; no email")
    args = ap.parse_args()
    if not VAULT_DIR.exists():
        sys.exit(f"VAULT_DIR not found: {VAULT_DIR}")
    run_digests(mode=("payload" if args.dry_run else "print" if args.print_only else "send"))


if __name__ == "__main__":
    main()
