"""Grocy ingestion — expiring stock, missing stock, chores and battery charges.

READ-ONLY INVARIANT: this module issues GET requests and nothing else, ever.
Grocy's API is a full read/write API and the write endpoints are trivially
reachable with the same key — `POST /api/stock/products/{id}/{add,consume,
transfer,inventory,open}`, `POST /api/chores/{id}/execute`, `POST
/api/batteries/{id}/charge`, and the whole `POST /api/stock/shoppinglist/*`
family (`add-missing-products`, `add-overdue-products`, `add-expired-products`,
`add-product`, `remove-product`, `clear`) plus `POST /api/recipes/{id}/
add-not-fulfilled-products-to-shoppinglist`. None of them are used here and none
of them may ever be, per docs/project-plan.md Phase 12 step 8. That applies in
particular to the evening recipe/shopping-list feature in
synth/prompts/household.md: the suggested shopping list is rendered in the digest
for a human to act on, and is NEVER pushed into Grocy's own shopping list.

Sourcing, verified against grocy.openapi.json on grocy/grocy master 2026-07-28:

  `GET /api/stock/volatile?due_soon_days=N` is the direct answer to "what is
  about to go off". It returns one object with four arrays — `due_products`
  (within N days), `overdue_products` (past a best-before date),
  `expired_products` (past a hard expiration date) and `missing_products`
  (below min_stock_amount). Reconstructing that from `/api/objects/products`
  plus stock entries is unnecessary. Note the naming: Grocy renamed
  `expiring_products` -> `due_products` in v3.0.0, so older third-party examples
  showing `expiring_products` are wrong against a current install.

  The first three arrays are `CurrentStockResponse` objects: `product_id`,
  `amount`, `best_before_date` (documented as "the next due date for this
  product", not necessarily a best-before) and a nested `product`.
  `missing_products` is a different, smaller shape: `id`, `name`,
  `amount_missing`, `is_partly_in_stock`.

  `GET /api/stock` gives everything currently in stock. It is here only so the
  evening shopping-list suggestion can tell "we already have this" from "buy
  this" — see the note on GROCY_MAX_STOCK_ITEMS below.

  `GET /api/chores` and `GET /api/batteries` return next-execution/next-charge
  estimates. Both use `2999-12-31 23:59:59` as the "no schedule" sentinel
  (chores with period_type `manually`, batteries with no charge_interval_days),
  which is filtered out here rather than reported as a due date in the year 2999.
  `/api/batteries` returns only `battery_id`, so names come from
  `/api/objects/batteries`.

  Auth is a per-user API key in a `GROCY-API-KEY` header (the OpenAPI spec's only
  security scheme). Generate one in Grocy at Settings -> Manage API keys
  (`/manageapikeys`). There is no OAuth and no scoping: a Grocy API key carries
  that user's full read *and* write rights, so the read-only guarantee above
  comes from this code, exactly as it does for the OPNsense key.

Reachability: the deployed Grocy is `lscr.io/linuxserver/grocy` as service
`grocy` on port 80 internally (published as 9283), on the same default compose
network as digest-engine — so the default GROCY_URL is the container name, and
no host networking or extra compose plumbing is needed.
"""

import logging
import os
from datetime import date, datetime

import requests

LOG = logging.getLogger(__name__)

DEFAULT_URL = "http://grocy"
HTTP_TIMEOUT = 30

DEFAULT_DUE_SOON_DAYS = 5
DEFAULT_TASK_HORIZON_DAYS = 7
DEFAULT_MAX_STOCK_ITEMS = 200

# Grocy's "this never happens" sentinel date, used by chores with a manual period
# and by batteries with no charge interval.
NEVER_YEAR = 2999


def _get(session, base_url, path, params=None, default=None):
    try:
        response = session.get(base_url + path, params=params, timeout=HTTP_TIMEOUT)
        response.raise_for_status()
        return response.json()
    except Exception:
        LOG.warning("grocy: GET %s failed, continuing without it", path, exc_info=True)
        return default


def _num(value):
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def _parse_when(raw):
    text = str(raw or "").strip()
    if not text:
        return None
    try:
        parsed = datetime.fromisoformat(text)
    except ValueError:
        return None
    return None if parsed.year >= NEVER_YEAR else parsed


def _index(rows):
    return {str(row.get("id")): row for row in rows or [] if row.get("id") is not None}


def _unit_label(units, qu_id, amount):
    unit = units.get(str(qu_id))
    if not unit:
        return None
    if amount == 1:
        return unit.get("name")
    return unit.get("name_plural") or unit.get("name")


def _product_of(row, products):
    nested = row.get("product") or {}
    stored = products.get(str(row.get("product_id") or nested.get("id"))) or {}
    # Merged rather than either/or: /api/stock nests the product, /api/objects/
    # shopping_list only carries a product_id, and either request may have failed.
    return {**nested, **stored} if stored else nested


def _stock_entry(row, products, units, category, status, today):
    product = _product_of(row, products)
    amount = _num(row.get("amount"))
    due = _parse_when(row.get("best_before_date"))
    return {
        "source": "grocy",
        "category": category,
        "status": status,
        "product": product.get("name") or f"product #{row.get('product_id')}",
        "amount": amount,
        "unit": _unit_label(units, product.get("qu_id_stock"), amount),
        "due_date": due.date().isoformat() if due else None,
        "days_until_due": (due.date() - today).days if due else None,
    }


def _volatile_entries(volatile, products, units, today):
    entries = []

    for key, status in (
        ("due_products", "due_soon"),
        ("overdue_products", "overdue"),
        ("expired_products", "expired"),
    ):
        for row in volatile.get(key) or []:
            entries.append(_stock_entry(row, products, units, "expiring_stock", status, today))

    for row in volatile.get("missing_products") or []:
        entries.append(
            {
                "source": "grocy",
                "category": "missing_stock",
                "product": row.get("name") or f"product #{row.get('id')}",
                "amount_missing": _num(row.get("amount_missing")),
                "partly_in_stock": bool(_num(row.get("is_partly_in_stock"))),
            }
        )

    return entries


def _stock_entries(stock, products, units, today, limit):
    rows = sorted(stock or [], key=lambda row: str((_product_of(row, products)).get("name") or ""))
    if len(rows) > limit:
        LOG.warning(
            "grocy: %d products in stock, only the first %d are reported — raise "
            "GROCY_MAX_STOCK_ITEMS or the evening shopping list may suggest buying "
            "something you already have",
            len(rows),
            limit,
        )
        rows = rows[:limit]
    return [_stock_entry(row, products, units, "in_stock", "in_stock", today) for row in rows]


def _chore_entries(chores, today, horizon_days):
    entries = []
    for row in chores or []:
        due = _parse_when(row.get("next_estimated_execution_time"))
        if due is None:
            continue
        days = (due.date() - today).days
        if days > horizon_days:
            continue
        assigned = row.get("next_execution_assigned_user") or {}
        entries.append(
            {
                "source": "grocy",
                "category": "chore",
                "name": row.get("chore_name") or f"chore #{row.get('chore_id')}",
                "due_at": due.isoformat(),
                "days_until_due": days,
                "last_done": row.get("last_tracked_time"),
                "assigned_to": assigned.get("display_name") or assigned.get("username"),
            }
        )
    return entries


def _battery_entries(batteries, meta, today, horizon_days):
    entries = []
    for row in batteries or []:
        due = _parse_when(row.get("next_estimated_charge_time"))
        if due is None:
            continue
        days = (due.date() - today).days
        if days > horizon_days:
            continue
        battery = meta.get(str(row.get("battery_id"))) or {}
        entries.append(
            {
                "source": "grocy",
                "category": "battery",
                "name": battery.get("name") or f"battery #{row.get('battery_id')}",
                "used_in": battery.get("used_in"),
                "due_at": due.isoformat(),
                "days_until_due": days,
                "last_charged": row.get("last_tracked_time"),
            }
        )
    return entries


def _shopping_entries(items, products, units):
    entries = []
    for row in items or []:
        product = _product_of(row, products)
        amount = _num(row.get("amount"))
        entries.append(
            {
                "source": "grocy",
                "category": "shopping_list",
                "product": product.get("name") or (row.get("note") or "").strip() or "unnamed item",
                "amount": amount,
                "unit": _unit_label(units, product.get("qu_id_stock"), amount),
                "note": (row.get("note") or "").strip() or None,
            }
        )
    return entries


def fetch(lookback_hours):
    del lookback_hours  # Grocy state is a snapshot of right now, not a time window

    base_url = os.environ.get("GROCY_URL", DEFAULT_URL).strip().rstrip("/")
    api_key = os.environ.get("GROCY_API_KEY", "").strip()
    if not api_key:
        LOG.warning("grocy: GROCY_API_KEY not set, skipping (create one at Grocy -> Manage API keys)")
        return []

    due_soon_days = int(os.environ.get("GROCY_DUE_SOON_DAYS", DEFAULT_DUE_SOON_DAYS))
    horizon_days = int(os.environ.get("GROCY_TASK_HORIZON_DAYS", DEFAULT_TASK_HORIZON_DAYS))
    max_stock_items = int(os.environ.get("GROCY_MAX_STOCK_ITEMS", DEFAULT_MAX_STOCK_ITEMS))

    session = requests.Session()
    session.headers.update({"GROCY-API-KEY": api_key, "Accept": "application/json"})

    volatile = _get(session, base_url, "/api/stock/volatile", {"due_soon_days": due_soon_days})
    if not isinstance(volatile, dict):
        # Bail on the first call rather than letting seven more requests each burn
        # their own connect timeout: if this one failed, Grocy is unreachable or the
        # key is wrong, and the rest will fail identically.
        LOG.warning("grocy: the stock query failed, skipping the source entirely")
        return []

    products = _index(_get(session, base_url, "/api/objects/products", default=[]))
    units = _index(_get(session, base_url, "/api/objects/quantity_units", default=[]))
    today = date.today()

    entries = _volatile_entries(volatile, products, units, today)
    entries += _stock_entries(_get(session, base_url, "/api/stock", default=[]), products, units, today, max_stock_items)
    entries += _chore_entries(_get(session, base_url, "/api/chores", default=[]), today, horizon_days)
    entries += _battery_entries(
        _get(session, base_url, "/api/batteries", default=[]),
        _index(_get(session, base_url, "/api/objects/batteries", default=[])),
        today,
        horizon_days,
    )
    entries += _shopping_entries(
        _get(session, base_url, "/api/objects/shopping_list", default=[]), products, units
    )

    expiring = sum(1 for entry in entries if entry["category"] == "expiring_stock")
    LOG.info("grocy: %d entr(ies), %d of them expiring stock", len(entries), expiring)
    return entries
