"""chores — camera-verified household task distribution, from
docs/project-plan.md Phase 20.

THE PRINCIPLE, stated once because it drives every design choice below: **"I don't
care who does it, as long as it gets done."** This is not an assignment system that
picks one fair person and waits on them — it's a nudge system that keeps redirecting
to whoever is actually around until the chore is done. Fairness is tracked for
comparison, not enforced by the algorithm — see "The tally is passive" below.

Runs every ~2 hours (systemd timer, `RandomizedDelaySec=1800` gives the "+/-30 min
in case something else is running" jitter) and does three things, in order:

1. **Trash-day-eve**: reads the same personal collection-date ICS feed
   `trash-calendar` already reads (`WASTE_ICS_URL`, own env file, own read — this
   script never writes to the calendar, `trash-calendar` owns that). If pickup is
   tomorrow and no open "trash" chore exists yet, creates one.
2. **Camera checks** (optional, off unless `CAMERA_WATCHPOINTS` is configured — no
   camera hardware has been chosen yet, see docs/project-plan.md §1.18): for each
   configured watch point (a Frigate camera + optional PTZ preset), grabs a
   snapshot via Frigate's own API and asks an Ollama vision model whether it shows
   a full bin, dirty dishes, litter left out, or **groceries still standing out that
   belong in a fridge/freezer/cupboard**. A "needs attention" result opens a chore if
   one isn't already open; a "clear" result auto-closes one if it was.
3. **Nudging**: for every open chore, ASAP, not "wait for a schedule" — the first
   run after a chore opens nudges whoever `identity` reports home right now (using
   `identity`'s own room field to prefer someone actually near the relevant spot,
   e.g. the kitchen for dishes, when that data is available). If the chore is
   still open `NEGLECT_THRESHOLD_HOURS` after the last nudge, the household
   calendar is checked for anyone currently marked busy (a lightweight, honest-
   about-its-limits check — see `_household_currently_busy()`), and if nobody's in
   a flagged busy window, the next available person — **preferring someone
   different from who was last nudged**, "the next person that walks by" — gets
   nudged instead. There is no fixed "assignee" who owns a chore; `last_nudged`
   just remembers who to avoid re-nagging immediately and who to give tally credit
   to if the chore resolves shortly after.

`identity`'s `chore_exempt` flag (set via `POST /people/<id>/chore-settings`, see
identity/README.md) takes a person out of the nudge rotation entirely — a frequent
guest who isn't a household member doesn't owe chores. **`litter` and
`groceries_out_of_place` are the exceptions** (`_EXEMPTIONS_DONT_APPLY` below):
everyone, exempt or not, still gets told to put trash they left out into the bin and
to put the milk away — neither is "doing a chore," both are cleaning up after
yourself, and a guest who unpacked the shopping is exactly as able to finish the job
as anyone else.

`groceries_out_of_place` is deliberately built as litter's twin — same
culprit-attribution, same exemption-proofing, same unassignability — with **one
difference that matters: a much shorter fuse** (`_NEGLECT_HOURS_OVERRIDE`, 30 minutes
against the usual four hours). Litter left for an afternoon is untidiness; a tub of
ice cream left for an afternoon is a bin bag. It is the only place in this file where
one chore type is treated as more urgent than another, and the reason is that the cost
of being late differs in kind, not degree.

## Assignment is a preference, not a lock

`identity`'s per-person `chore_assignments` (set in its admin panel, read off the same
`/presence` call as everything else here) says who *owes* a given chore type. When
someone assigned to a chore is home, they get nudged instead of whoever happens to be
nearest — that's the whole point of assigning it.

**But an assignee who isn't home doesn't block the chore.** The stated principle above
is "I don't care who does it, as long as it gets done," so if nobody assigned is
around, the nudge falls through to the ordinary whoever's-here rotation rather than
waiting. Set `CHORE_ASSIGNMENT_STRICT=true` if you'd rather it wait for the assignee —
that's the honest opposite reading of the same feature, and which one a household
wants isn't something this file can decide for it.

**`litter` and `groceries_out_of_place` ignore assignment entirely**
(`_ASSIGNMENTS_DONT_APPLY`), for the same reason they ignore exemptions: they go to
whoever left the mess, and "cleaning up after yourself" was never a task anyone could
be assigned in the first place.

`identity`'s `chore_reminder_style` free-text field (same endpoint) is passed to an
LLM that **phrases** the ntfy message in that person's preferred tone ("be
assertive," "be gentle, give me a few minutes of grace") — see `_compose_message()`.
This is wording only. It never touches who gets nudged or when, which stays
presence/calendar-driven per the principle above; if `OLLAMA_TEXT_MODEL` is unset,
the style is empty, or the call fails for any reason, the plain deterministic
template is used verbatim — same "never build against a guess" fallback discipline
as the vision checks above.

## The tally is passive

`chore_events`' `nudged` rows are the only record kept of who got asked about what.
**Nothing in the nudge logic above reads this tally to decide who to ask next** —
that decision is presence/calendar-driven only, exactly per the stated principle.
The tally exists purely so a household member can look at the numbers later and
judge fairness for themselves ("comparison for fairness's sake," not an automated
fairness algorithm). Attribution when a chore closes is a **best-effort heuristic**
(whoever was nudged most recently before it resolved) — nobody here actually
confirms who did it, see "What's not built."

NEVER auto-completes a chore from the nudge step itself — only a fresh camera check
(finding the watch point clear) or a manual close (not built here, see README.md)
ever marks one done. A nudge firing is not the same as the chore being done;
conflating the two would let a repeatedly-nudged, never-actually-done chore quietly
disappear.
"""

from __future__ import annotations

import json
import logging
import os
import sqlite3
import sys
import time
import urllib.request
from datetime import date, datetime, timedelta, timezone
from pathlib import Path

LOG = logging.getLogger("chores")

DB_PATH = Path(os.environ.get("CHORES_DB_PATH", "/data/chores.db"))

IDENTITY_URL = os.environ.get("IDENTITY_URL", "").rstrip("/")
IDENTITY_TOKEN = os.environ.get("IDENTITY_TOKEN", "")

FRIGATE_URL = os.environ.get("FRIGATE_URL", "").rstrip("/")
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://llm-host:11434").rstrip("/")
OLLAMA_VISION_MODEL = os.environ.get("OLLAMA_VISION_MODEL", "llava")
# Optional — phrases (never decides) reminders per-person, see _compose_message().
# Unset means every reminder uses the plain template, same as before this existed.
OLLAMA_TEXT_MODEL = os.environ.get("OLLAMA_TEXT_MODEL", "").strip()

NTFY_URL = os.environ.get("NTFY_URL", "").rstrip("/")
NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "")

NEGLECT_THRESHOLD_HOURS = float(os.environ.get("NEGLECT_THRESHOLD_HOURS", "4"))
WASTE_ICS_URL = os.environ.get("WASTE_ICS_URL", "").strip()

# Household calendar busy-check (see _household_currently_busy()) — reuses
# digest-engine's own CALDAV_* credential names for the same "one Nextcloud app
# password" reasoning as trash-calendar, but this is a THIRD, independent read of
# it (never a write) — a lightweight, honestly-scoped check, not per-person
# availability, see README.md's limitation note.
CALDAV_URL = os.environ.get("CALDAV_URL", "").strip()
CALDAV_USERNAME = os.environ.get("CALDAV_USERNAME", "").strip()
CALDAV_PASSWORD = os.environ.get("CALDAV_PASSWORD", "")
CALDAV_VERIFY_TLS = os.environ.get("CALDAV_VERIFY_TLS", "true").strip().lower() == "true"
CALDAV_QUIET_KEYWORDS = [
    k.strip().lower() for k in os.environ.get("CALDAV_QUIET_KEYWORDS", "busy,meeting,call,movie,sleep").split(",") if k.strip()
]

_CHORE_PROMPTS = {
    "trash": None,  # never camera-checked — trash-day-eve driven only, see module docstring
    "bin_full": "Look at this photo of a trash/recycling bin. Answer with exactly one word: FULL, PARTIAL, or EMPTY.",
    "dishes": "Look at this photo of a kitchen sink/counter area. Answer with exactly one word: DIRTY or CLEAN.",
    "litter": (
        "Look at this photo of a household area. Is there any trash/garbage/litter left out "
        "that does not belong there (not properly disposed of in a bin)? Answer with exactly "
        "one word: YES or NO."
    ),
    # Groceries left standing out — the same shape as litter (somebody put something
    # down and walked away) with a much shorter fuse, because the failure here is not
    # untidiness, it is food going off. The prompt names the perishable cases
    # explicitly rather than asking the general "is anything out of place" question,
    # which a vision model will happily answer YES to for a fruit bowl, a bread bin,
    # or a kettle — and a chore that fires every two hours about the fruit bowl is one
    # the household will mute within a week.
    "groceries_out_of_place": (
        "Look at this photo of a kitchen counter, table or worktop. Are there GROCERIES sitting "
        "out that belong in a fridge, freezer or cupboard — for example milk, yoghurt, cheese, "
        "meat, fish, eggs, opened jars, frozen food, or a shopping bag that has not been put "
        "away? Ignore things that normally live on a worktop: fruit bowls, bread bins, coffee, "
        "spices, oil, salt, appliances, cookware and dishes. Answer with exactly one word: "
        "YES or NO."
    ),
}

# Which watch points get "who was just seen here" culprit-attribution treatment
# (see _likely_culprit()) instead of the general "whoever's around" nudge — litter
# is specifically about telling whoever left it, not just whoever's nearby now.
# Groceries left out is the same situation: somebody carried it there.
_ATTRIBUTE_TO_RECENT_VIEWER = {"litter", "groceries_out_of_place"}

# Chore types where identity's chore_exempt flag does NOT apply — everyone still
# gets told to clean up litter they left out, exempt household member or not (see
# module docstring). Currently the same set as _ATTRIBUTE_TO_RECENT_VIEWER, but
# they mean different things — one is about attribution, this is about eligibility
# — so they're kept as separate names rather than reusing one for both purposes.
_EXEMPTIONS_DONT_APPLY = {"litter", "groceries_out_of_place"}

# Chore types that can't be assigned to anyone — see the module docstring's
# "Assignment is a preference, not a lock". Third set with the same members as the
# two above, and kept separate for the third distinct reason: attribution, then
# eligibility, now assignability. If they ever diverge (a chore that's assignable but
# exempt-proof, say) collapsing them now would be the thing that made that painful.
_ASSIGNMENTS_DONT_APPLY = {"litter", "groceries_out_of_place"}

# How the one-word camera answer is read, per chore type. Extracted from an inline
# expression once there were four of them: adding a chore type should be a line in a
# table, not a new clause in a boolean nobody can read.
_ATTENTION_ANSWERS = {
    # "PARTIAL" contains neither FULL nor a separate word, but a model answering
    # "PARTIALLY FULL" must not be read as full — hence the exclusion, kept from the
    # original inline check.
    "bin_full": lambda a: "FULL" in a and "PARTIAL" not in a,
    "dishes": lambda a: "DIRTY" in a,
    "litter": lambda a: "YES" in a,
    "groceries_out_of_place": lambda a: "YES" in a,
}

# Per-type override for how long a chore may sit before it is redirected to somebody
# else. A dropped crisp packet can wait the default four hours; a tub of ice cream on
# the counter cannot, and neither can the shopping nobody unpacked. This is the one
# place in this file where a chore type is treated as more urgent than another, and it
# is here because the cost of being late is different in kind — spoiled food, not an
# untidy room.
_NEGLECT_HOURS_OVERRIDE = {
    "groceries_out_of_place": float(os.environ.get("GROCERIES_NEGLECT_THRESHOLD_HOURS", "0.5")),
}

# Whether an assigned person who ISN'T home blocks the chore from falling through to
# whoever is. Default false — "as long as it gets done" is the house rule; true makes
# assignment binding instead. See the module docstring.
CHORE_ASSIGNMENT_STRICT = os.environ.get("CHORE_ASSIGNMENT_STRICT", "false").strip().lower() == "true"


def _now() -> str:
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


def _db() -> sqlite3.Connection:
    DB_PATH.parent.mkdir(parents=True, exist_ok=True)
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    return conn


def init_db(conn: sqlite3.Connection) -> None:
    conn.executescript(
        """
        CREATE TABLE IF NOT EXISTS chores (
            id INTEGER PRIMARY KEY,
            type TEXT NOT NULL,
            status TEXT NOT NULL DEFAULT 'open',
            assigned_to TEXT,
            created_at TEXT NOT NULL,
            assigned_at TEXT,
            done_at TEXT,
            reminder_count INTEGER NOT NULL DEFAULT 0,
            last_reminder_at TEXT
        );
        CREATE TABLE IF NOT EXISTS chore_events (
            id INTEGER PRIMARY KEY,
            chore_id INTEGER,
            event TEXT NOT NULL,
            detail TEXT,
            created_at TEXT NOT NULL
        );
        """
    )


def _log_event(conn, chore_id, event, detail="") -> None:
    conn.execute(
        "INSERT INTO chore_events (chore_id, event, detail, created_at) VALUES (?, ?, ?, ?)",
        (chore_id, event, detail, _now()),
    )


def _open_chore(conn, chore_type: str):
    return conn.execute("SELECT * FROM chores WHERE type = ? AND status = 'open'", (chore_type,)).fetchone()


def _create_chore(conn, chore_type: str) -> int:
    cur = conn.execute(
        "INSERT INTO chores (type, status, created_at) VALUES (?, 'open', ?)", (chore_type, _now())
    )
    assert cur.lastrowid is not None
    _log_event(conn, cur.lastrowid, "created")
    LOG.info("chores: created a new %r chore", chore_type)
    return cur.lastrowid


def _close_chore(conn, chore_row) -> None:
    conn.execute("UPDATE chores SET status = 'done', done_at = ? WHERE id = ?", (_now(), chore_row["id"]))
    _log_event(conn, chore_row["id"], "auto_closed")
    LOG.info("chores: %r chore #%d auto-closed (camera check came back clear)", chore_row["type"], chore_row["id"])


# --- 1. Trash-day-eve ------------------------------------------------------------
def check_trash_day(conn) -> None:
    if not WASTE_ICS_URL:
        return
    from icalendar import Calendar as ICalendar

    try:
        req = urllib.request.Request(WASTE_ICS_URL, headers={"User-Agent": "smartesthome-chores/1"})
        with urllib.request.urlopen(req, timeout=30) as resp:
            raw = resp.read()
        tomorrow = date.today() + timedelta(days=1)
        pickup_tomorrow = False
        for component in ICalendar.from_ical(raw).walk("VEVENT"):
            dtstart = component.get("dtstart")
            if dtstart is None:
                continue
            start = dtstart.dt
            start_date = start.date() if isinstance(start, datetime) else start
            if start_date == tomorrow:
                pickup_tomorrow = True
                break
    except Exception:
        LOG.warning("chores: could not check WASTE_ICS_URL for trash-day-eve", exc_info=True)
        return

    if pickup_tomorrow and _open_chore(conn, "trash") is None:
        _create_chore(conn, "trash")


# --- 2. Camera checks --------------------------------------------------------------
def _watchpoints() -> list[tuple[str, str, str | None]]:
    """CAMERA_WATCHPOINTS format: "type:camera[:preset],type:camera[:preset],...".
    type must be a key in _CHORE_PROMPTS other than "trash" — i.e. bin_full, dishes,
    litter, or groceries_out_of_place.
    """
    raw = os.environ.get("CAMERA_WATCHPOINTS", "").strip()
    if not raw:
        return []
    points = []
    for entry in raw.split(","):
        parts = [p.strip() for p in entry.split(":")]
        # _ATTENTION_ANSWERS as well as _CHORE_PROMPTS: a chore type with a prompt but
        # no way to read its answer is a KeyError in check_cameras(), i.e. a crash on
        # the timer rather than here. Adding a type means adding both.
        if len(parts) < 2 or parts[0] not in _CHORE_PROMPTS or parts[0] not in _ATTENTION_ANSWERS:
            LOG.warning("chores: ignoring malformed CAMERA_WATCHPOINTS entry %r", entry)
            continue
        points.append((parts[0], parts[1], parts[2] if len(parts) > 2 else None))
    return points


def _frigate_snapshot(camera: str, preset: str | None) -> bytes | None:
    # VERIFY: Frigate's PTZ-move-to-preset API shape is assumed, not confirmed
    # against a real Frigate PTZ camera — see README.md. A failure here just means
    # the snapshot is taken from wherever the camera already was, not a hard error.
    if preset:
        try:
            move_req = urllib.request.Request(
                f"{FRIGATE_URL}/api/{camera}/ptz/move/{preset}", method="POST"
            )
            urllib.request.urlopen(move_req, timeout=10).close()
            time.sleep(3)  # give the camera time to physically move before snapshotting
        except Exception:
            LOG.warning("chores: could not move camera %r to preset %r (continuing anyway)", camera, preset, exc_info=True)

    try:
        with urllib.request.urlopen(f"{FRIGATE_URL}/api/{camera}/latest.jpg", timeout=15) as resp:
            return resp.read()
    except Exception:
        LOG.warning("chores: could not fetch snapshot for camera %r", camera, exc_info=True)
        return None


def _ask_vision(image: bytes, prompt: str) -> str | None:
    import base64

    payload = {
        "model": OLLAMA_VISION_MODEL,
        "prompt": prompt,
        "images": [base64.b64encode(image).decode("ascii")],
        "stream": False,
        "options": {"temperature": 0.1},
    }
    try:
        req = urllib.request.Request(
            f"{OLLAMA_HOST}/api/generate", data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}
        )
        with urllib.request.urlopen(req, timeout=90) as resp:
            result = json.loads(resp.read())
        return (result.get("response") or "").strip().upper()
    except Exception:
        LOG.warning("chores: Ollama vision call failed", exc_info=True)
        return None


def check_cameras(conn) -> None:
    for chore_type, camera, preset in _watchpoints():
        if not FRIGATE_URL:
            LOG.warning("chores: CAMERA_WATCHPOINTS configured but FRIGATE_URL is unset, skipping")
            return

        image = _frigate_snapshot(camera, preset)
        if image is None:
            continue

        answer = _ask_vision(image, _CHORE_PROMPTS[chore_type])
        if answer is None:
            continue

        needs_attention = _ATTENTION_ANSWERS[chore_type](answer)
        existing = _open_chore(conn, chore_type)

        if needs_attention and existing is None:
            _create_chore(conn, chore_type)
        elif not needs_attention and existing is not None:
            _close_chore(conn, existing)


# --- 3. Nudging — presence/calendar-driven, "whoever's around," see module docstring
def _presence() -> list[dict]:
    """Each dict: {"name", "speak_name", "nickname", "home", "room",
    "face_seen_recently", "chore_exempt", "chore_reminder_style", "chore_assignments"}
    — the raw shape identity's own /presence returns. Empty list (never raises past
    this point) if identity is unreachable — nudging just waits for the next run.

    Everything this module needs about a person arrives in this one call, including
    who's assigned what; there's no second lookup per person. (`_anyone_assigned()`
    does make one extra call, but only in strict mode and only about a chore type, not
    a person.)
    """
    if not IDENTITY_URL or not IDENTITY_TOKEN:
        return []
    try:
        req = urllib.request.Request(f"{IDENTITY_URL}/presence")
        req.add_header("Authorization", f"Bearer {IDENTITY_TOKEN}")
        with urllib.request.urlopen(req, timeout=10) as resp:
            data = json.loads(resp.read())
        return [p for p in data.get("people", []) if p.get("home")]
    except Exception:
        LOG.warning("chores: could not reach identity for /presence", exc_info=True)
        return []


def _household_currently_busy() -> bool:
    """A deliberately lightweight check, not per-person availability: is there a
    CURRENTLY ACTIVE event on the one shared household calendar whose summary
    contains one of CALDAV_QUIET_KEYWORDS? If so, nudges wait for the next run
    rather than interrupting a meeting/movie/whoever's asleep. This is a real
    limitation, not a bug: it can't tell that only ONE person is busy and nudge
    someone else anyway — everyone's nudges pause together. See README.md.
    Fails open (returns False, i.e. "not busy") on any error — a broken calendar
    check must never be the reason chores stop getting nudged at all.
    """
    if not (CALDAV_URL and CALDAV_USERNAME and CALDAV_PASSWORD) or not CALDAV_QUIET_KEYWORDS:
        return False
    try:
        import caldav as caldav_lib

        now = datetime.now(timezone.utc)
        with caldav_lib.DAVClient(
            url=CALDAV_URL, username=CALDAV_USERNAME, password=CALDAV_PASSWORD, ssl_verify_cert=CALDAV_VERIFY_TLS
        ) as client:
            for calendar in client.principal().calendars():
                for item in calendar.search(start=now, end=now + timedelta(minutes=1), event=True, expand=True):
                    summary = str(item.icalendar_component.get("summary", "")).lower()
                    if any(keyword in summary for keyword in CALDAV_QUIET_KEYWORDS):
                        return True
    except Exception:
        LOG.warning("chores: calendar busy-check failed, proceeding as not-busy", exc_info=True)
        return False
    return False


def _anyone_assigned(home: list[dict], chore_type: str) -> bool:
    """Is this chore type assigned to ANYONE — including people who aren't home?

    Only consulted in CHORE_ASSIGNMENT_STRICT mode, and only once the cheaper check
    (is an assignee home?) has already come back empty, which is why it's allowed to
    cost an extra request. The distinction it draws matters: strict mode should wait
    for an absent assignee, but must not wait forever on a chore nobody was ever
    assigned — that would silently stop unassigned chores from being nudged at all.

    Fails OPEN (returns False, i.e. "nobody's assigned, go ahead and nudge whoever's
    around") if identity can't be reached — same rule as the calendar busy-check: a
    broken lookup must never be the reason chores stop getting done.
    """
    if any(chore_type in (p.get("chore_assignments") or []) for p in home):
        return True
    if not IDENTITY_URL or not IDENTITY_TOKEN:
        return False
    try:
        req = urllib.request.Request(f"{IDENTITY_URL}/chore-assignments")
        req.add_header("Authorization", f"Bearer {IDENTITY_TOKEN}")
        with urllib.request.urlopen(req, timeout=10) as resp:
            data = json.loads(resp.read())
        return bool((data.get("assignments") or {}).get(chore_type))
    except Exception:
        LOG.warning("chores: could not reach identity for /chore-assignments", exc_info=True)
        return False


def _likely_culprit(candidates: list[dict]) -> dict | None:
    """For litter-type chores: prefer whoever was MOST RECENTLY seen by camera face
    recognition — a best-effort "who was just here" guess, not a certainty. Falls
    back to None (caller then treats it like any other chore) if nobody's
    face_seen_recently.
    """
    seen = [p for p in candidates if p.get("face_seen_recently")]
    return seen[0] if seen else None


def nudge_open_chores(conn) -> None:
    open_chores = conn.execute("SELECT * FROM chores WHERE status = 'open'").fetchall()
    if not open_chores:
        return

    home = _presence()
    if not home:
        LOG.info("chores: %d open chore(s), but nobody is home yet — nothing to do", len(open_chores))
        return

    if _household_currently_busy():
        LOG.info("chores: household calendar shows a busy/quiet window right now — skipping nudges this run")
        return

    for chore in open_chores:
        last_nudged = chore["assigned_to"]
        last_nudged_at = chore["assigned_at"]

        if last_nudged_at is not None:
            elapsed_hours = (datetime.now(timezone.utc) - datetime.fromisoformat(last_nudged_at.replace("Z", "+00:00"))).total_seconds() / 3600
            threshold = _NEGLECT_HOURS_OVERRIDE.get(chore["type"], NEGLECT_THRESHOLD_HOURS)
            if elapsed_hours < threshold:
                continue  # not neglected yet — leave whoever was last nudged alone for now

        # chore_exempt people are out of the rotation entirely, EXCEPT litter — see
        # _EXEMPTIONS_DONT_APPLY's module-level comment.
        eligible = home if chore["type"] in _EXEMPTIONS_DONT_APPLY else [p for p in home if not p.get("chore_exempt")]
        if not eligible:
            LOG.info(
                "chores: %r chore #%d open, but everyone home right now is chore_exempt — skipping",
                chore["type"], chore["id"],
            )
            continue

        # Whoever's actually been assigned this chore type in identity's admin panel,
        # and is home right now — see the module docstring's "Assignment is a
        # preference, not a lock" for why an empty list here doesn't stop the nudge.
        assigned = (
            []
            if chore["type"] in _ASSIGNMENTS_DONT_APPLY
            else [p for p in eligible if chore["type"] in (p.get("chore_assignments") or [])]
        )
        if assigned:
            eligible = assigned
        elif CHORE_ASSIGNMENT_STRICT and _anyone_assigned(home, chore["type"]):
            LOG.info(
                "chores: %r chore #%d is assigned, but no assignee is home and "
                "CHORE_ASSIGNMENT_STRICT is on — waiting rather than redirecting",
                chore["type"], chore["id"],
            )
            continue

        # "The next person that walks by": prefer someone home right now who ISN'T
        # who we last nudged (a real redirect, not the same person nagged again) —
        # falls back to re-nudging the same person if they're genuinely the only
        # one home. Litter gets a different preference first: whoever the camera
        # most recently saw, since the point there is telling the actual culprit.
        target = None
        if chore["type"] in _ATTRIBUTE_TO_RECENT_VIEWER:
            target = _likely_culprit(eligible)
        if target is None:
            different = [p for p in eligible if p["name"] != last_nudged]
            target = (different or eligible)[0]

        # identity's speak_name is ALWAYS the person's real name, never a nickname
        # they've been given — see identity/server.py's module docstring. Reminders go
        # out as text and get read aloud by whatever's showing them, so this is one of
        # the consumers that rule exists for. Falls back to `name` for an identity
        # older than the nickname feature.
        name = target.get("speak_name") or target["name"]
        conn.execute(
            "UPDATE chores SET assigned_to = ?, assigned_at = ?, reminder_count = reminder_count + 1, "
            "last_reminder_at = ? WHERE id = ?",
            (name, _now(), _now(), chore["id"]),
        )
        redirected = last_nudged is not None and last_nudged != name
        _log_event(conn, chore["id"], "nudged", name)
        redirect_note = f" (redirected from {last_nudged})" if redirected and last_nudged else ""
        LOG.info("chores: nudged %s about %r chore #%d%s", name, chore["type"], chore["id"], redirect_note)
        _notify(
            name,
            chore["type"],
            redirected,
            is_culprit=chore["type"] in _ATTRIBUTE_TO_RECENT_VIEWER,
            reminder_style=target.get("chore_reminder_style"),
        )


# The culprit-framed wording, per type. Both are "you probably left this, please deal
# with it" — but "put it in the bin" is exactly wrong for a tub of ice cream, and a
# reminder that tells you to do the wrong thing is one you stop reading.
_CULPRIT_MESSAGES = {
    "litter": "{name}, looks like something was left out — could you put it in the bin?",
    "groceries_out_of_place": (
        "{name}, there are groceries still standing out — could you put them away before they spoil?"
    ),
}

# What the LLM phrasing pass is told the situation is, when there is a model
# configured. Same split, same reason.
_CULPRIT_SITUATIONS = {
    "litter": (
        "Something was left out and may belong to them specifically — ask them to put it away, "
        "don't accuse them outright. "
    ),
    "groceries_out_of_place": (
        "Groceries have been left standing out instead of being put in the fridge, freezer or "
        "cupboard, and they may be the person who left them — ask them to put the food away "
        "before it spoils, don't accuse them outright. "
    ),
}


# Chore types are snake_case identifiers; people are not. Everything user-facing goes
# through here — a notification that says "could you take care of:
# groceries_out_of_place" is a notification that reads like a stack trace.
_CHORE_LABELS = {
    "trash": "putting the bins out",
    "bin_full": "emptying the bin",
    "dishes": "the dishes",
    "litter": "clearing up what was left out",
    "groceries_out_of_place": "putting the groceries away",
}


def _label(chore_type: str) -> str:
    return _CHORE_LABELS.get(chore_type, chore_type.replace("_", " "))


def _default_message(name: str, chore_type: str, redirected: bool, is_culprit: bool) -> str:
    if is_culprit:
        template = _CULPRIT_MESSAGES.get(chore_type, "{name}, looks like something was left out — could you deal with it?")
        return template.format(name=name)
    if redirected:
        return f"{name}, this one's still open — could you take care of: {_label(chore_type)}?"
    return f"{name}, could you take care of: {_label(chore_type)}?"


def _compose_message(name: str, chore_type: str, redirected: bool, is_culprit: bool, reminder_style: str | None) -> str:
    """Wording only — see module docstring's OLLAMA_TEXT_MODEL paragraph. Falls back
    to _default_message() verbatim whenever there's no model configured, no style
    set for this person, or the call fails/returns nothing — never blocks a nudge
    from going out over an LLM hiccup.
    """
    fallback = _default_message(name, chore_type, redirected, is_culprit)
    if not OLLAMA_TEXT_MODEL or not reminder_style:
        return fallback

    prompt = (
        f"Write ONE short household chore reminder (max 2 sentences) addressed to {name}. "
        f"The chore is: {_label(chore_type)}. "
        f"Follow {name}'s own stated preference for how they like to be reminded: \"{reminder_style}\". "
        + (_CULPRIT_SITUATIONS.get(
            chore_type,
            "Something was left out and may belong to them specifically — ask them to put it away, "
            "don't accuse them outright. ",
        ) if is_culprit else "")
        + ("They were already asked about this once before and it's still not done. " if redirected else "")
        + "Reply with ONLY the message text itself — no preamble, no quotation marks."
    )
    try:
        payload = {"model": OLLAMA_TEXT_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.4}}
        req = urllib.request.Request(
            f"{OLLAMA_HOST}/api/generate", data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}
        )
        with urllib.request.urlopen(req, timeout=30) as resp:
            result = json.loads(resp.read())
        text = (result.get("response") or "").strip()
        return text if text else fallback
    except Exception:
        LOG.warning("chores: Ollama message-phrasing call failed, using plain template", exc_info=True)
        return fallback


def _notify(name: str, chore_type: str, redirected: bool, is_culprit: bool, reminder_style: str | None = None) -> None:
    if not NTFY_URL or not NTFY_TOPIC:
        return
    message = _compose_message(name, chore_type, redirected, is_culprit, reminder_style)
    try:
        req = urllib.request.Request(f"{NTFY_URL}/{NTFY_TOPIC}", data=message.encode("utf-8"), method="POST")
        urllib.request.urlopen(req, timeout=10).close()
    except Exception:
        LOG.warning("chores: ntfy notification failed", exc_info=True)


def print_tally(conn) -> None:
    """Read-only reporting, per the module docstring — never feeds back into
    nudge_open_chores()'s choice of who to nudge next. Logged, not served
    anywhere yet — see README.md's "What's not built."
    """
    rows = conn.execute(
        "SELECT detail AS name, COUNT(*) AS n FROM chore_events "
        "WHERE event = 'nudged' AND created_at >= ? GROUP BY detail ORDER BY n DESC",
        ((datetime.now(timezone.utc) - timedelta(days=30)).isoformat(),),
    ).fetchall()
    if rows:
        LOG.info("chores: 30-day nudge tally (for comparison only, not used to pick who's next): %s",
                 ", ".join(f"{r['name']}: {r['n']}" for r in rows))


def main() -> int:
    logging.basicConfig(
        level=os.environ.get("LOG_LEVEL", "INFO").upper(),
        format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
    )

    conn = _db()
    try:
        init_db(conn)
        check_trash_day(conn)
        check_cameras(conn)
        conn.commit()
        nudge_open_chores(conn)
        conn.commit()
        print_tally(conn)
    finally:
        conn.close()

    return 0


if __name__ == "__main__":
    sys.exit(main())
