"""pantry-vision — camera-driven grocery cataloguing for the kitchen display, from
docs/project-plan.md Phase 17.

The workflow this exists for: come home, put down the shopping bag, hold one item up
to the kitchen display's camera, the system proposes what it is and how long it keeps,
you confirm (editing anything it got wrong) before it's written anywhere, then put the
item away. The display also shows the resulting inventory sorted by what expires
soonest, and Grocy's recipes.

The camera is the input device for all four of the household's stock movements, and
each one is a screen in frontend/app.js:

  - **Unload groceries** (book in)  — the camera runs in a loop, proposes each item
    with its placement and pack size, one confirm per item.
  - **Consume article** (book out)  — hold up what you are eating, say how many
    individual units, it comes off stock.
  - **List expired foods**          — what is past its date, cleared by scanning the
    item you are about to throw away (booked out as spoiled, not consumed).
  - **Edit inventory**              — the keyboard-and-buttons fallback for when the
    camera got something wrong: every article, +/-, and a freeform amount.

SECURITY BOUNDARY, same shape as admin-canvas/server.py's, with one deliberate
difference: this service DOES get a published port (see the README and
setup-container-host.sh's PANTRY_VISION_PORT), because unlike admin-canvas — which is
only ever called by Home Assistant on the compose network — this one is called
directly by the kitchen-display kiosk, a separate physical device on the LAN. Every
request still requires the bearer token below; an unset token fails closed (rejects
everything), never "auth optional".

Endpoints, all token-gated:
  - POST /identify    raw image bytes -> Ollama vision model -> a *proposal*
  - POST /confirm     JSON, human-reviewed/edited -> adds to Grocy stock
  - POST /consume     JSON -> books an amount back out of Grocy stock
  - POST /adjust      JSON -> corrects an amount (the edit-inventory screen)
  - POST /transfer    JSON -> moves an amount from one appliance to another
  - POST /doorway-event  JSON, from an HA automation on a fridge door sensor ->
                      pulls a camera burst and records a *hint* (never stock)
  - GET  /inventory   proxies Grocy stock, soonest-expiring first, plus the
                      brand-folded `groups` view
  - GET  /expired     the subset of /inventory that is already past its date
  - GET  /doorway-events  the recent hints, newest first
  - GET  /recipes     proxies Grocy recipes (+ fulfillment, best-effort)
  - GET  /shopping-list  Grocy's own "below minimum stock", for the door panel

/identify NEVER writes anything by itself — same "propose, never auto-commit" rule
this project already applies to identity-merge confirmation (see the Identity store
row in docs/project-plan.md §2): the vision model's guess is shown to the person
standing at the display, who can edit the name/category/date/location before /confirm
is ever called. A wrong guess costs a tap to fix, not a wrong fact silently written
into the household's inventory. That rule is why the book-out and expiry flows also
identify-then-confirm rather than consuming straight off a recognition: the camera
choosing *what* to remove from stock by itself is the same silent-wrong-write, one
direction over.

TWO INVARIANTS THE REST OF THIS FILE DEPENDS ON
-----------------------------------------------
1. **Stock is counted in individual units, never in packages.** A twelve-pack of eggs
   is booked in as twelve, because "how many eggs do we have" is the question the
   household actually asks, and because it makes booking out three of them
   arithmetic instead of a fractions-of-a-pack problem. `units_per_package` from the
   vision model is a multiplier applied once at /confirm; nothing downstream stores
   or needs it. See _confirm_amount().
2. **The fold key is the brand-free product kind.** Twelve eggs of brand X and ten of
   brand Y are twenty-two eggs, and Grocy already has the right place to record which
   kind a product is — its product group. So /confirm files each product under a
   product group named after its kind, and the folding in _fold_stock() is a read of
   that, not a second classification scheme living here. The per-brand rows stay
   underneath, because "remove exactly these ten" has to remain possible on the
   edit-inventory screen. See _fold_key().

WHERE A THING IS, AND WHY THAT IS A HINT
-----------------------------------------
The household has more than one cold appliance, so "in the fridge" is not an answer.
Two mechanisms address that, and only the first of them is authoritative:

- **Grocy locations.** The confirm screen's placement choice resolves to a real Grocy
  location and rides along on the stock add; /transfer moves an amount between them.
  Both are human actions, both are what /inventory reports. This is the record.
- **Doorway hints** (doorway.py). A contact sensor on an appliance door triggers a
  camera burst, and whatever the vision model recognises is written to a separate
  SQLite file as an observation with a timestamp and a confidence — never to Grocy,
  never to stock. A camera at a door cannot tell in from out, misses when two things
  are carried at once, and sees nothing behind an arm. So it produces "last seen going
  past the loggia freezer, Tue 18:42", which a person can evaluate, and never a claim
  about where anything is. docs/fridge-item-location.md is the long form of this.
"""

from __future__ import annotations

import base64
import json
import logging
import os
import re
import sys
import threading
import urllib.error
import urllib.request
from datetime import date, datetime, timedelta
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlsplit

import doorway

LOG = logging.getLogger("pantry-vision")

TOKEN = os.environ.get("PANTRY_VISION_TOKEN", "")

OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://llm-host:11434").rstrip("/")
# TBD/unverified — see README.md's "Pick a vision model" section. Not every Ollama
# model can see images; this must be one that can (llava, qwen2.5vl, etc.) and must
# actually be pulled on the LLM host (`ollama pull <name>`) before /identify works.
OLLAMA_VISION_MODEL = os.environ.get("OLLAMA_VISION_MODEL", "llava")
OLLAMA_TIMEOUT = float(os.environ.get("OLLAMA_TIMEOUT", "120"))

GROCY_URL = os.environ.get("GROCY_URL", "http://grocy:80").rstrip("/")
GROCY_API_KEY = os.environ.get("GROCY_API_KEY", "")
# Fresh-install defaults ("Default" location, "Piece" quantity unit) — VERIFY against
# your own Grocy instance (Settings -> Locations / Quantity units) and override if
# they differ. Same "don't build against a guess" flag as every other cross-service ID
# in this project (ADMIN_WEB_URL placeholders, weather_entity_id, etc.).
GROCY_DEFAULT_LOCATION_ID = os.environ.get("GROCY_DEFAULT_LOCATION_ID", "1")
GROCY_DEFAULT_QU_ID = os.environ.get("GROCY_DEFAULT_QU_ID", "2")

# The recommended-placement answer is a Grocy location, looked up (or created) by
# name, so "fridge" on the confirm screen and "Fridge" in Grocy's own UI are the same
# shelf. Override the names if your Grocy already calls them something else — a
# mismatch here silently creates a second, duplicate location rather than failing.
PLACEMENT_LOCATION_NAMES = {
    "fridge": os.environ.get("PANTRY_LOCATION_FRIDGE", "Fridge"),
    "freezer": os.environ.get("PANTRY_LOCATION_FREEZER", "Freezer"),
    "cupboard": os.environ.get("PANTRY_LOCATION_CUPBOARD", "Cupboard"),
    "counter": os.environ.get("PANTRY_LOCATION_COUNTER", "Counter"),
}

# Where the vision model's category lands when it does not answer the placement
# question itself, or answers it with something that is not one of the four above.
# Deliberately conservative: cold is a safe wrong answer for food, warm is not.
CATEGORY_PLACEMENT = {
    "produce": "fridge",
    "dairy": "fridge",
    "meat": "fridge",
    "frozen": "freezer",
    "pantry": "cupboard",
    "bakery": "cupboard",
    "beverage": "cupboard",
    "other": "cupboard",
}

MAX_IMAGE_BYTES = int(os.environ.get("PANTRY_VISION_MAX_IMAGE_MB", "15")) * 1024 * 1024
MAX_JSON_BYTES = 64 * 1024

# A pack size the model is confident about is useful; one it invented is a hundred
# phantom eggs in the inventory. Clamped, and always shown on the confirm screen as an
# editable number before it multiplies anything.
MAX_UNITS_PER_PACKAGE = int(os.environ.get("PANTRY_MAX_UNITS_PER_PACKAGE", "240"))

_IDENTIFY_PROMPT = """You are looking at a single grocery item held up to a kitchen \
camera. Identify it and answer ONLY with a JSON object, no other text, in exactly \
this shape:
{"present": true,
 "kind": "<what the item IS, with no brand name in it: 'eggs', 'whole milk', 'penne', 'frozen peas'>",
 "brand": "<the brand printed on the packaging, or an empty string if none is visible>",
 "description": "<one short line a person would want to read back: variety, size, flavour>",
 "category": "<produce|dairy|meat|frozen|pantry|bakery|beverage|other>",
 "recommended_placement": "<fridge|freezer|cupboard|counter — where this should be put away>",
 "estimated_shelf_life_days": <integer, typical days from today until it goes bad in that place>,
 "printed_best_before": "<YYYY-MM-DD, ONLY if a best-before or use-by date is actually legible on the packaging, otherwise null>",
 "units_per_package": <integer: how many individual items are inside. 12 for a box of 12 eggs, 6 for a six-pack, 1 for a single loose item or one bottle>,
 "unit_name": "<what one of those individual items is called: 'egg', 'bottle', 'slice', 'piece'>",
 "confidence": "<high|medium|low>"}

Two rules that matter more than the rest:
- If no grocery item is being held up to the camera — an empty counter, a hand, a \
person, a blurred or dark frame — answer exactly {"present": false} and nothing else.
- Never invent "printed_best_before". Fill it in only if you can genuinely read the \
date off the packaging in this image; otherwise null. A guessed date is worse than \
no date, because nobody will re-check it."""

_JSON_OBJECT_RE = re.compile(r"\{.*\}", re.DOTALL)
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_BRAND_SUFFIX_RE = re.compile(r"\s*\([^()]*\)\s*$")


def _http_json(method: str, url: str, payload: dict | None = None, headers: dict | None = None,
                timeout: float = 30.0) -> dict:
    data = json.dumps(payload).encode("utf-8") if payload is not None else None
    req = urllib.request.Request(url, data=data, method=method)
    req.add_header("Content-Type", "application/json")
    for key, value in (headers or {}).items():
        req.add_header(key, value)
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        body = resp.read()
    return json.loads(body) if body else {}


def _grocy_headers() -> dict:
    return {"GROCY-API-KEY": GROCY_API_KEY}


def _grocy_get(path: str):
    return _http_json("GET", f"{GROCY_URL}{path}", headers=_grocy_headers(), timeout=15)


def _grocy_post(path: str, payload: dict):
    return _http_json("POST", f"{GROCY_URL}{path}", payload=payload, headers=_grocy_headers(), timeout=15)


def _degraded_proposal(note: str, **extra) -> dict:
    """The proposal returned when the model could not be reached or could not be
    parsed. `degraded` is the field the frontend's unload loop watches: a degraded
    answer stops the loop and drops the person into the manual form, instead of
    re-photographing the same tin at a model that is down.
    """
    proposal = {
        "present": True,
        "degraded": True,
        "kind": "Unknown item",
        "brand": "",
        "description": "",
        "category": "other",
        "recommended_placement": "cupboard",
        "estimated_shelf_life_days": 7,
        "printed_best_before": None,
        "units_per_package": 1,
        "unit_name": "piece",
        "confidence": "low",
        "note": note,
    }
    proposal.update(extra)
    return proposal


def _identify_via_ollama(image_bytes: bytes) -> dict:
    """Calls the vision model and returns a best-effort proposal — degrades to a
    low-confidence placeholder rather than raising, mirroring digest-engine's
    llm_client._fallback_document: a broken/blank result is worse than one flagged
    as unreliable that a human has to review anyway (which they always do — see the
    module docstring, /identify never writes anything by itself).
    """
    payload = {
        "model": OLLAMA_VISION_MODEL,
        "prompt": _IDENTIFY_PROMPT,
        "images": [base64.b64encode(image_bytes).decode("ascii")],
        "stream": False,
        "options": {"temperature": 0.2},
    }
    try:
        result = _http_json("POST", f"{OLLAMA_HOST}/api/generate", payload=payload, timeout=OLLAMA_TIMEOUT)
        text = result.get("response", "")
    except Exception:
        LOG.warning("pantry-vision: Ollama call failed", exc_info=True)
        return _degraded_proposal(
            f"Could not reach the vision model at {OLLAMA_HOST} — fill this in manually."
        )

    match = _JSON_OBJECT_RE.search(text)
    if match:
        try:
            return _normalise_proposal(json.loads(match.group(0)))
        except ValueError:
            pass

    LOG.warning("pantry-vision: could not parse a JSON proposal out of the model's response")
    return _degraded_proposal(
        "The vision model's response wasn't valid JSON — fill this in manually.",
        raw_response=text[:500],
    )


def _coerce_int(value, default: int, minimum: int, maximum: int) -> int:
    try:
        number = int(float(value))
    except (TypeError, ValueError):
        return default
    return max(minimum, min(maximum, number))


def _as_int(value) -> int | None:
    """A JSON field as an int, or None if it isn't one. Missing and malformed are the
    same answer on purpose: every caller here has to handle "not usable" anyway, and
    telling a null apart from the string "banana" has never changed what they do."""
    try:
        return int(value)
    except (TypeError, ValueError):
        return None


def _as_float(value) -> float | None:
    """Same, for amounts — with NaN and the infinities rejected, because both survive
    float() and neither is a quantity anybody can correct from a touchscreen once it
    is in Grocy's stock table."""
    try:
        number = float(value)
    except (TypeError, ValueError):
        return None
    if number != number or number in (float("inf"), float("-inf")):
        return None
    return number


def _printed_date(value) -> str | None:
    """The best-before the model claims to have read off the packaging, or None.

    Sanity-bounded on purpose: a misread label is the most likely single failure of
    this whole feature (small print, dot-matrix ink, curved packaging), and a date in
    1998 or 2071 is how that failure shows up. Out-of-range means "fall back to the
    shelf-life estimate", never "write it and hope somebody notices".
    """
    if not isinstance(value, str) or not _ISO_DATE_RE.match(value.strip()):
        return None
    try:
        parsed = datetime.strptime(value.strip(), "%Y-%m-%d").date()
    except ValueError:
        return None
    days_out = (parsed - date.today()).days
    if days_out < -365 or days_out > 3650:
        return None
    return parsed.isoformat()


def _display_name(kind: str, brand: str) -> str:
    """The Grocy product name. Brand in parentheses after the kind, so the fold key
    is recoverable from the name alone if the product group is ever missing — see
    _fold_key(), which strips exactly this suffix.
    """
    kind = kind.strip() or "Unknown item"
    brand = brand.strip()
    return f"{kind} ({brand})" if brand else kind


def _fold_key(name) -> str:
    """The brand-free key two products have in common when they are the same thing.

    Deliberately dumb — casefold, strip a trailing "(Brand)", collapse whitespace —
    because the smart version of this is a synonym problem nobody wants adjudicated
    by a kitchen display at 19:00 ("eggs" vs "egg" vs "free-range eggs"). The vision
    model is asked for a stable brand-free `kind` precisely so this does not have to
    be clever; when it answers inconsistently the two entries simply show up as two
    lines, which is a visible, fixable outcome rather than a silently merged one.
    """
    return " ".join(_BRAND_SUFFIX_RE.sub("", str(name or "")).split()).casefold()


def _normalise_proposal(raw: dict) -> dict:
    """The model's JSON, made safe to put in front of a person and to arithmetic on.

    Every field is defaulted and bounded here rather than in the handler, so the
    frontend can render a proposal without checking for missing keys, and so a model
    that answers half the schema still produces a usable confirm screen.
    """
    if isinstance(raw, dict) and raw.get("present") is False:
        return {"present": False}

    raw = raw if isinstance(raw, dict) else {}
    # `name` is accepted as an alias for `kind` because a model that ignores half the
    # schema still tends to produce a name, and one usable field beats a blank form.
    kind = str(raw.get("kind") or raw.get("name") or "Unknown item").strip()
    brand = str(raw.get("brand") or "").strip()
    category = str(raw.get("category") or "other").strip().lower()
    if category not in CATEGORY_PLACEMENT:
        category = "other"

    placement = str(raw.get("recommended_placement") or "").strip().lower()
    if placement not in PLACEMENT_LOCATION_NAMES:
        placement = CATEGORY_PLACEMENT[category]

    shelf_life = _coerce_int(raw.get("estimated_shelf_life_days"), default=7, minimum=0, maximum=3650)
    printed = _printed_date(raw.get("printed_best_before"))

    return {
        "present": True,
        "degraded": False,
        "kind": kind,
        "brand": brand,
        "name": _display_name(kind, brand),
        "description": str(raw.get("description") or "").strip(),
        "category": category,
        "recommended_placement": placement,
        "estimated_shelf_life_days": shelf_life,
        # What the confirm screen puts in its date field: the label if it could be
        # read, otherwise today plus the model's guess at how long it keeps. Which of
        # the two it was is carried in `best_before_source`, because "read off the
        # packet" and "estimated from the category" deserve different amounts of
        # trust from the person about to tap Confirm.
        "best_before_date": printed or _date_in_days(shelf_life),
        "best_before_source": "label" if printed else "estimate",
        "printed_best_before": printed,
        "units_per_package": _coerce_int(
            raw.get("units_per_package"), default=1, minimum=1, maximum=MAX_UNITS_PER_PACKAGE
        ),
        "unit_name": str(raw.get("unit_name") or "piece").strip() or "piece",
        "confidence": str(raw.get("confidence") or "low").strip().lower(),
    }


def _find_or_create_by_name(path: str, name: str, extra: dict | None = None) -> int | None:
    """Find an object of `path` whose name matches, or create it. None on any failure.

    One helper for products, locations and product groups because Grocy's object API
    is the same shape for all three. Returning None rather than raising is the point
    for the two optional ones: a Grocy that will not let us create a "Freezer"
    location must not cost the household the ability to book in a bag of peas — the
    item lands in the default location instead, which is wrong-but-visible.
    """
    needle = name.strip().casefold()
    if not needle:
        return None
    try:
        existing = _grocy_get(path)
        for row in existing if isinstance(existing, list) else []:
            if str(row.get("name", "")).strip().casefold() == needle:
                return int(row["id"])
        created = _grocy_post(path, {"name": name.strip(), **(extra or {})})
        return int(created["created_object_id"])
    except (urllib.error.URLError, urllib.error.HTTPError, KeyError, ValueError, TypeError):
        LOG.warning("pantry-vision: could not find or create %s named %r", path, name, exc_info=True)
        return None


def _location_id_for(placement: str) -> int:
    """The Grocy location id for fridge/freezer/cupboard/counter, or the configured
    default. Never raises: an unplaceable item is still an item worth booking in.
    """
    name = PLACEMENT_LOCATION_NAMES.get(str(placement).strip().lower())
    if name:
        found = _find_or_create_by_name("/api/objects/locations", name)
        if found is not None:
            return found
    return int(GROCY_DEFAULT_LOCATION_ID)


def _find_or_create_product(name: str, kind: str = "", location_id: int | None = None) -> int:
    """Best-effort product lookup/create against Grocy's object API.

    `kind` becomes the product's Grocy **product group** — that is where the
    brand-folding in _fold_stock() reads from, and putting it in Grocy rather than in
    a table of our own means the grouping is visible and editable in Grocy's own UI
    (invariant 2 in the module docstring). A product that already exists is left
    exactly as it is, group included: the household's own corrections in Grocy outrank
    a vision model's guess on the next scan of the same tin.

    VERIFY against a real Grocy instance — this assumes GET /api/objects/products
    returns a flat list of {id, name, ...} and that POST to the same path with just
    name/location_id/qu_id_purchase/qu_id_stock is enough to create a minimal product.
    Grocy's actual required-field set depends on its own settings and has not been
    checked against a live instance; see README.md's verification list.
    """
    products = _grocy_get("/api/objects/products")
    needle = name.strip().casefold()
    for product in products if isinstance(products, list) else []:
        if str(product.get("name", "")).strip().casefold() == needle:
            return int(product["id"])

    payload = {
        "name": name,
        "location_id": int(location_id if location_id is not None else GROCY_DEFAULT_LOCATION_ID),
        "qu_id_purchase": int(GROCY_DEFAULT_QU_ID),
        "qu_id_stock": int(GROCY_DEFAULT_QU_ID),
    }
    group_id = _find_or_create_by_name("/api/objects/product_groups", kind) if kind.strip() else None
    if group_id is not None:
        payload["product_group_id"] = group_id

    created = _grocy_post("/api/objects/products", payload)
    return int(created["created_object_id"])


def _add_to_stock(product_id: int, amount: float, best_before_date: str, location_id: int | None = None) -> dict:
    payload = {
        "amount": amount,
        "best_before_date": best_before_date,
        "transaction_type": "purchase",
    }
    if location_id is not None:
        payload["location_id"] = int(location_id)
    return _grocy_post(f"/api/stock/products/{product_id}/add", payload)


def _consume_stock(product_id: int, amount: float, spoiled: bool) -> dict:
    """Book an amount back out. `spoiled=True` is what the expired-foods screen sends:
    Grocy tracks thrown-away separately from eaten, and that distinction is the only
    way the household will ever find out what it keeps buying and binning.
    """
    return _grocy_post(
        f"/api/stock/products/{product_id}/consume",
        {"amount": amount, "spoiled": bool(spoiled), "transaction_type": "consume"},
    )


def _set_stock_amount(product_id: int, new_amount: float, best_before_date: str | None = None) -> dict:
    """The edit-inventory screen's write: Grocy's own inventory-correction endpoint.

    Both directions go through here on purpose. A correction is not a purchase and not
    a meal — it is somebody saying the number was wrong — and Grocy's stock journal
    should say so, otherwise the consumption history that Grocy's own reports are
    built on quietly fills up with corrections dressed as eating.
    """
    payload: dict = {"new_amount": new_amount}
    if best_before_date:
        payload["best_before_date"] = best_before_date
    return _grocy_post(f"/api/stock/products/{product_id}/inventory", payload)


def _transfer_stock(product_id: int, amount: float, location_id_from: int, location_id_to: int) -> dict:
    """Move an amount between appliances — the action a doorway hint exists to prompt.

    This is the one write that makes multi-fridge locations worth recording at all:
    without it, a location is "where it was when it was bought", which decays into a
    confidently wrong answer within a fortnight. See docs/fridge-item-location.md.
    """
    return _grocy_post(
        f"/api/stock/products/{product_id}/transfer",
        {"amount": amount, "location_id_from": int(location_id_from), "location_id_to": int(location_id_to)},
    )


def _date_in_days(days: int) -> str:
    return (date.today() + timedelta(days=max(0, int(days)))).isoformat()


def _days_left(best_before: str | None) -> int | None:
    if not best_before:
        return None
    try:
        due = datetime.strptime(best_before, "%Y-%m-%d").date()
    except ValueError:
        return None
    return (due - date.today()).days


def _stock_items() -> list[dict]:
    """Grocy's stock as this service's own row shape, product metadata attached.

    The product catalogue is fetched separately and joined here rather than trusted to
    arrive nested inside the stock rows: whether `GET /api/stock` embeds a `product`
    object at all varies by Grocy version (see README.md), and every screen added in
    this phase needs the product's id, group and location — not just its name — to be
    able to consume, fold or correct it. A failed catalogue read degrades to whatever
    the stock rows themselves carry, same "degrade, don't blank" rule as everywhere
    else here.
    """
    stock = _grocy_get("/api/stock")

    products: dict[int, dict] = {}
    groups: dict[int, str] = {}
    locations: dict[int, str] = {}
    try:
        for row in _grocy_get("/api/objects/products") or []:
            products[int(row["id"])] = row
        for row in _grocy_get("/api/objects/product_groups") or []:
            groups[int(row["id"])] = str(row.get("name", ""))
        for row in _grocy_get("/api/objects/locations") or []:
            locations[int(row["id"])] = str(row.get("name", ""))
    except (urllib.error.URLError, urllib.error.HTTPError, KeyError, ValueError, TypeError):
        LOG.warning("pantry-vision: product catalogue unreadable, folding on names alone", exc_info=True)

    # Attached, not joined in Grocy: see doorway.py on why an observation and a stock
    # row are kept in different files. A hint that has fallen out of the retention
    # window simply isn't here, which reads on screen as "no recent sighting".
    last_seen = doorway.last_seen_by_product()

    items = []
    for row in stock if isinstance(stock, list) else []:
        product_id = _as_int(row.get("product_id"))
        if product_id is None:
            continue
        product = products.get(product_id) or row.get("product") or {}
        name = product.get("name") or f"Product #{product_id}"
        best_before = row.get("best_before_date")

        group_id = _as_int(product.get("product_group_id"))
        group_name = groups.get(group_id, "") if group_id is not None else ""

        # No product group (a product created before this phase, or by hand in Grocy)
        # means the name minus its brand suffix is the fold key. That is the whole
        # reason _display_name() puts the brand in parentheses.
        kind = group_name or _BRAND_SUFFIX_RE.sub("", str(name)).strip()
        brand_match = _BRAND_SUFFIX_RE.search(str(name))

        # The product's own default location — where /confirm put it. Grocy can also
        # hold stock of one product in several locations at once (per stock entry);
        # this is the product-level answer, which is the one the household set and the
        # one /transfer moves. Anything finer would need /stock/products/{id}/entries
        # and a screen to show it on, and neither exists yet.
        location_id = None
        for source in (row, product):
            location_id = _as_int(source.get("location_id"))
            if location_id is not None:
                break

        items.append(
            {
                "product_id": product_id,
                "name": name,
                "kind": kind,
                "brand": brand_match.group(0).strip(" ()") if brand_match else "",
                "amount": row.get("amount"),
                "best_before_date": best_before,
                "days_left": _days_left(best_before),
                "location_id": location_id,
                "location": locations.get(location_id, "") if location_id is not None else "",
                "last_seen": last_seen.get(product_id),
            }
        )

    items.sort(key=lambda i: (i["days_left"] is None, i["days_left"]))
    return items


def _amount_of(item: dict) -> float:
    try:
        return float(item.get("amount") or 0)
    except (TypeError, ValueError):
        return 0.0


def _fold_stock(items: list[dict]) -> list[dict]:
    """Twelve eggs of brand X plus ten of brand Y as one line saying twenty-two.

    The per-brand rows ride along in `entries` rather than being summed away, because
    the edit-inventory screen has to be able to take ten off brand Y specifically.
    Ordering matches /inventory's: soonest-expiring group first, and inside a group,
    soonest-expiring brand first — the thing to use up leads in both directions.
    """
    folded: dict[str, dict] = {}
    for item in items:
        key = _fold_key(item.get("kind") or item.get("name"))
        group = folded.setdefault(
            key,
            {"kind": key, "display_name": item.get("kind") or item.get("name"), "total_amount": 0.0, "entries": []},
        )
        group["total_amount"] += _amount_of(item)
        group["entries"].append(item)

    groups = []
    for group in folded.values():
        days = [e["days_left"] for e in group["entries"] if e["days_left"] is not None]
        group["soonest_days_left"] = min(days) if days else None
        group["brand_count"] = len({e["brand"] for e in group["entries"]})
        group["entries"].sort(key=lambda e: (e["days_left"] is None, e["days_left"]))
        groups.append(group)

    groups.sort(key=lambda g: (g["soonest_days_left"] is None, g["soonest_days_left"]))
    return groups


def _stock_matches(items: list[dict], kind: str, name: str) -> list[dict]:
    """The stock rows a just-identified item could be, best candidate first.

    Book-out and expiry-clearing both need this: the camera says "eggs", and the
    question is which of the household's egg rows to take from. An exact name match
    (kind *and* brand) is the confident case and is marked as such; everything else in
    the same fold group is offered as a choice rather than picked silently, because
    guessing the brand wrong here writes a wrong number into stock — the one thing
    this service is built not to do.
    """
    key = _fold_key(kind or name)
    exact_needle = str(name or "").strip().casefold()

    matches = []
    for item in items:
        is_exact = bool(exact_needle) and str(item["name"]).strip().casefold() == exact_needle
        if is_exact or _fold_key(item.get("kind") or item.get("name")) == key:
            matches.append({**item, "exact": is_exact})

    matches.sort(key=lambda m: (not m["exact"], m["days_left"] is None, m["days_left"]))
    return matches


def _locations() -> list[dict]:
    """Grocy's locations, for the edit screen's "move it to…" control. Best-effort:
    an empty list just means that control isn't offered this load."""
    try:
        rows = _grocy_get("/api/objects/locations")
        return [{"id": int(r["id"]), "name": str(r.get("name", ""))} for r in rows if r.get("id") is not None]
    except (urllib.error.URLError, urllib.error.HTTPError, KeyError, ValueError, TypeError):
        LOG.warning("pantry-vision: could not read Grocy locations", exc_info=True)
        return []


def _stock_matches_for(kind: str, name: str) -> list[dict]:
    """_stock_matches against a fresh read of stock — what doorway.py is handed so it
    never has to know Grocy exists."""
    return _stock_matches(_stock_items(), kind, name)


class Handler(BaseHTTPRequestHandler):
    server_version = "pantry-vision/1"

    def log_message(self, format, *args):  # noqa: A002
        LOG.info("%s - %s", self.address_string(), format % args)

    def _authorized(self) -> bool:
        if not TOKEN:
            return False
        return self.headers.get("Authorization", "") == f"Bearer {TOKEN}"

    def _respond(self, status: HTTPStatus, payload) -> None:
        body = json.dumps(payload).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Access-Control-Allow-Origin", "*")
        self.end_headers()
        self.wfile.write(body)

    def _read_body(self, max_bytes: int) -> bytes:
        try:
            length = int(self.headers.get("Content-Length") or 0)
        except ValueError:
            raise ValueError("missing or invalid Content-Length") from None
        if length <= 0:
            return b""
        if length > max_bytes:
            raise ValueError(f"body too large ({length} > {max_bytes} bytes)")
        return self.rfile.read(length)

    def do_OPTIONS(self):  # noqa: N802
        # The frontend (pantry-web, a separate origin/port) fetches this API directly
        # from the browser, so a plain CORS preflight has to succeed.
        self.send_response(HTTPStatus.NO_CONTENT)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        self.end_headers()

    def do_GET(self):  # noqa: N802
        if not self._authorized():
            self._respond(HTTPStatus.UNAUTHORIZED, {"error": "missing or invalid bearer token"})
            return
        path = urlsplit(self.path).path
        if path == "/inventory":
            self._handle_inventory()
        elif path == "/expired":
            self._handle_expired()
        elif path == "/doorway-events":
            self._handle_doorway_events()
        elif path == "/recipes":
            self._handle_recipes()
        elif path == "/shopping-list":
            self._handle_shopping_list()
        else:
            self._respond(HTTPStatus.NOT_FOUND, {"error": "no such endpoint"})

    def do_POST(self):  # noqa: N802
        if not self._authorized():
            self._respond(HTTPStatus.UNAUTHORIZED, {"error": "missing or invalid bearer token"})
            return
        path = urlsplit(self.path).path
        if path == "/identify":
            self._handle_identify()
        elif path == "/confirm":
            self._handle_confirm()
        elif path == "/consume":
            self._handle_consume()
        elif path == "/adjust":
            self._handle_adjust()
        elif path == "/transfer":
            self._handle_transfer()
        elif path == "/doorway-event":
            self._handle_doorway_event()
        else:
            self._respond(HTTPStatus.NOT_FOUND, {"error": "no such endpoint"})

    def _json_body(self) -> dict | None:
        """The request body as a dict, or None after having already answered 400."""
        try:
            raw = self._read_body(MAX_JSON_BYTES)
            payload = json.loads(raw or b"{}")
        except (ValueError, json.JSONDecodeError) as exc:
            self._respond(HTTPStatus.BAD_REQUEST, {"error": f"bad request body: {exc}"})
            return None
        if not isinstance(payload, dict):
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "body must be a JSON object"})
            return None
        return payload

    def _product_id_and_amount(self, payload: dict) -> tuple[int, float] | None:
        """The two fields every write endpoint needs, validated once.

        `amount` must be positive and finite — see _as_float() for why the second half
        of that matters.
        """
        product_id = _as_int(payload.get("product_id"))
        if product_id is None:
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "'product_id' is required"})
            return None
        amount = _as_float(payload.get("amount"))
        if amount is None:
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "'amount' must be a number"})
            return None
        if amount <= 0:
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "'amount' must be greater than zero"})
            return None
        return product_id, amount

    def _handle_identify(self) -> None:
        try:
            image = self._read_body(MAX_IMAGE_BYTES)
        except ValueError as exc:
            self._respond(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": str(exc)})
            return
        if not image:
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "empty image body"})
            return

        proposal = _identify_via_ollama(image)

        # What the household already has of this, attached to the identification
        # itself: it is what the book-out and expiry screens select from, and on the
        # book-in screen it is the answer to "did I already buy milk on Tuesday?"
        # asked at the only moment it can still change what somebody does. Best-effort
        # — an unreachable Grocy degrades the proposal to a scan-only one rather than
        # failing the identification the person is standing there waiting for.
        if proposal.get("present"):
            try:
                proposal["stock_matches"] = _stock_matches(
                    _stock_items(), proposal.get("kind", ""), proposal.get("name", "")
                )
            except (urllib.error.URLError, urllib.error.HTTPError, ValueError, TypeError):
                LOG.warning("pantry-vision: could not read stock for match lookup", exc_info=True)
                proposal["stock_matches"] = []
                proposal["stock_matches_unavailable"] = True

        self._respond(HTTPStatus.OK, proposal)

    def _handle_confirm(self) -> None:
        payload = self._json_body()
        if payload is None:
            return

        kind = str(payload.get("kind") or "").strip()
        brand = str(payload.get("brand") or "").strip()
        # `name` stays the authoritative field (an older frontend sends only this one);
        # kind/brand refine it when the newer confirm screen supplies them.
        name = str(payload.get("name") or "").strip() or _display_name(kind, brand)
        if not kind:
            kind = _BRAND_SUFFIX_RE.sub("", name).strip()
        best_before = str(payload.get("best_before_date", "")).strip()

        # Invariant 1 (module docstring): stock is individual units. `quantity` is how
        # many packages were scanned, `units_per_package` how many things are in one,
        # and their product is what Grocy is told about. Both default to 1, so the
        # pre-existing payload shape means exactly what it always meant.
        packages = _coerce_int(payload.get("quantity"), default=1, minimum=1, maximum=10000)
        units_per_package = _coerce_int(
            payload.get("units_per_package"), default=1, minimum=1, maximum=MAX_UNITS_PER_PACKAGE
        )
        amount = packages * units_per_package

        if not name:
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "'name' is required"})
            return
        if not re.match(r"^\d{4}-\d{2}-\d{2}$", best_before):
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "'best_before_date' must be YYYY-MM-DD"})
            return
        if not GROCY_API_KEY:
            self._respond(HTTPStatus.SERVICE_UNAVAILABLE, {"error": "GROCY_API_KEY is not configured"})
            return

        try:
            location_id = _location_id_for(payload.get("placement") or payload.get("recommended_placement") or "")
            product_id = _find_or_create_product(name, kind=kind, location_id=location_id)
            _add_to_stock(product_id, amount, best_before, location_id=location_id)
        except (urllib.error.URLError, urllib.error.HTTPError, KeyError, ValueError) as exc:
            LOG.warning("pantry-vision: Grocy write failed", exc_info=True)
            self._respond(HTTPStatus.BAD_GATEWAY, {"error": f"Grocy write failed: {exc}"})
            return

        LOG.info(
            "pantry-vision: added %s x%s (%s x %s per pack, best before %s) to Grocy",
            name, amount, packages, units_per_package, best_before,
        )
        self._respond(HTTPStatus.OK, {"ok": True, "product_id": product_id, "amount": amount})

    def _handle_consume(self) -> None:
        """Book out — the "Consume article" screen, and the expired list's Remove.

        Takes a product_id rather than a name on purpose: by the time this is called,
        either the camera matched a specific stock row and the person confirmed which
        one, or they picked it off a list. Resolving a name to a product here would
        put the guess back on the write side, where nobody sees it.
        """
        payload = self._json_body()
        if payload is None:
            return
        parsed = self._product_id_and_amount(payload)
        if parsed is None:
            return
        product_id, amount = parsed

        if not GROCY_API_KEY:
            self._respond(HTTPStatus.SERVICE_UNAVAILABLE, {"error": "GROCY_API_KEY is not configured"})
            return

        spoiled = bool(payload.get("spoiled"))
        try:
            _consume_stock(product_id, amount, spoiled)
        except (urllib.error.URLError, urllib.error.HTTPError, KeyError, ValueError) as exc:
            # Grocy refuses to consume more than it holds, and that refusal arrives
            # here as an HTTPError. It is a real answer to a real question ("we have
            # fewer than you think"), so it is passed through as one rather than
            # flattened into "something went wrong".
            LOG.warning("pantry-vision: Grocy consume failed", exc_info=True)
            self._respond(HTTPStatus.BAD_GATEWAY, {"error": f"Grocy consume failed: {exc}"})
            return

        LOG.info("pantry-vision: consumed %s of product %s (spoiled=%s)", amount, product_id, spoiled)
        self._respond(HTTPStatus.OK, {"ok": True, "product_id": product_id, "amount": amount, "spoiled": spoiled})

    def _handle_adjust(self) -> None:
        """The edit-inventory screen: +, −, and the freeform amount, all one write.

        `amount` here is the absolute new figure, not a delta, because that is what
        Grocy's inventory endpoint takes and because a delta computed on the display
        from a number it read a minute ago is a lost update waiting for two people in
        a kitchen. The +/- buttons do their arithmetic against the row the person is
        looking at and send the result.
        """
        payload = self._json_body()
        if payload is None:
            return
        product_id = _as_int(payload.get("product_id"))
        if product_id is None:
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "'product_id' is required"})
            return
        # Zero is allowed here and nowhere else: "we have none of these" is a correction
        # somebody makes on purpose, unlike consuming or transferring nothing.
        new_amount = _as_float(payload.get("amount"))
        if new_amount is None or new_amount < 0:
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "'amount' must be zero or greater"})
            return

        if not GROCY_API_KEY:
            self._respond(HTTPStatus.SERVICE_UNAVAILABLE, {"error": "GROCY_API_KEY is not configured"})
            return

        best_before = str(payload.get("best_before_date") or "").strip()
        if best_before and not re.match(r"^\d{4}-\d{2}-\d{2}$", best_before):
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "'best_before_date' must be YYYY-MM-DD"})
            return

        try:
            _set_stock_amount(product_id, new_amount, best_before or None)
        except (urllib.error.URLError, urllib.error.HTTPError, KeyError, ValueError) as exc:
            LOG.warning("pantry-vision: Grocy inventory correction failed", exc_info=True)
            self._respond(HTTPStatus.BAD_GATEWAY, {"error": f"Grocy correction failed: {exc}"})
            return

        LOG.info("pantry-vision: corrected product %s to %s", product_id, new_amount)
        self._respond(HTTPStatus.OK, {"ok": True, "product_id": product_id, "amount": new_amount})

    def _handle_transfer(self) -> None:
        """Move stock from one appliance to another — "I put the peas in the other
        freezer". Takes the placement name the confirm screen uses, so the household
        only ever names appliances one way.
        """
        payload = self._json_body()
        if payload is None:
            return
        parsed = self._product_id_and_amount(payload)
        if parsed is None:
            return
        product_id, amount = parsed

        if not GROCY_API_KEY:
            self._respond(HTTPStatus.SERVICE_UNAVAILABLE, {"error": "GROCY_API_KEY is not configured"})
            return

        # Three ways to say where it went, in order of directness: a Grocy location id
        # (what the edit screen's picker sends, since /inventory already handed it the
        # list), a placement word (what the confirm screen speaks), or a location name.
        to_placement = str(payload.get("to_placement") or "").strip()
        to_location_name = str(payload.get("to_location") or "").strip()
        location_to = _as_int(payload.get("to_location_id"))
        if location_to is None:
            if to_placement:
                location_to = _location_id_for(to_placement)
            elif to_location_name:
                found = _find_or_create_by_name("/api/objects/locations", to_location_name)
                if found is None:
                    self._respond(HTTPStatus.BAD_GATEWAY, {"error": f"could not resolve location {to_location_name!r}"})
                    return
                location_to = found
            else:
                self._respond(
                    HTTPStatus.BAD_REQUEST,
                    {"error": "one of 'to_location_id', 'to_placement' or 'to_location' is required"},
                )
                return

        location_from = _as_int(payload.get("from_location_id"))
        if location_from is None:
            # Not supplied: read the product's current location rather than guessing,
            # because Grocy's transfer needs a real source and a wrong one moves stock
            # that isn't there.
            try:
                for item in _stock_items():
                    if item["product_id"] == product_id:
                        location_from = item["location_id"]
                        break
            except (urllib.error.URLError, urllib.error.HTTPError):
                LOG.warning("pantry-vision: could not read stock to find the source location", exc_info=True)
            if location_from is None:
                self._respond(
                    HTTPStatus.BAD_REQUEST,
                    {"error": "could not work out where this is now — send 'from_location_id'"},
                )
                return

        if int(location_from) == int(location_to):
            self._respond(HTTPStatus.OK, {"ok": True, "product_id": product_id, "unchanged": True})
            return

        try:
            _transfer_stock(product_id, amount, location_from, location_to)
        except (urllib.error.URLError, urllib.error.HTTPError, KeyError, ValueError) as exc:
            LOG.warning("pantry-vision: Grocy transfer failed", exc_info=True)
            self._respond(HTTPStatus.BAD_GATEWAY, {"error": f"Grocy transfer failed: {exc}"})
            return

        LOG.info("pantry-vision: transferred %s of product %s to location %s", amount, product_id, location_to)
        self._respond(
            HTTPStatus.OK,
            {"ok": True, "product_id": product_id, "amount": amount, "location_id": location_to},
        )

    def _handle_doorway_event(self) -> None:
        """A fridge/freezer door sensor fired. Answer immediately, look afterwards.

        Home Assistant's automation is holding a `rest_command` open while this
        returns, and the work behind it is up to three snapshots and a vision call
        each. So the request is validated, acknowledged with 202, and the camera burst
        runs on its own thread — an appliance door is not something to keep a home
        automation waiting on, and there is nothing for HA to do with the answer
        anyway. Nothing here writes stock; see doorway.py's docstring for why.
        """
        payload = self._json_body()
        if payload is None:
            return

        configured = doorway.appliances()
        appliance_id = str(payload.get("appliance") or "").strip()
        appliance = configured.get(appliance_id)
        if appliance is None:
            self._respond(
                HTTPStatus.BAD_REQUEST,
                {
                    "error": f"unknown appliance {appliance_id!r}",
                    "known": sorted(configured),
                    "hint": "set PANTRY_DOOR_APPLIANCES — see pantry-vision.env.example",
                },
            )
            return

        state = str(payload.get("state") or "opened").strip().lower()
        if state not in ("opened", "closed"):
            self._respond(HTTPStatus.BAD_REQUEST, {"error": "'state' must be 'opened' or 'closed'"})
            return

        def run():
            try:
                doorway.handle_event(appliance, state, _identify_via_ollama, _stock_matches_for)
            except Exception:
                LOG.warning("pantry-vision: doorway event handling failed", exc_info=True)

        threading.Thread(target=run, name=f"doorway-{appliance_id}", daemon=True).start()
        self._respond(HTTPStatus.ACCEPTED, {"ok": True, "appliance": appliance_id, "state": state})

    def _handle_doorway_events(self) -> None:
        try:
            limit = int(parse_qs(urlsplit(self.path).query).get("limit", ["50"])[0])
        except (TypeError, ValueError):
            limit = 50
        self._respond(
            HTTPStatus.OK,
            {"appliances": sorted(doorway.appliances()), "events": doorway.recent_hints(limit)},
        )

    def _handle_inventory(self) -> None:
        try:
            items = _stock_items()
        except (urllib.error.URLError, urllib.error.HTTPError) as exc:
            LOG.warning("pantry-vision: Grocy /api/stock unreachable", exc_info=True)
            self._respond(HTTPStatus.BAD_GATEWAY, {"error": f"Grocy unreachable: {exc}"})
            return

        # Both views, always. `items` is the per-product list the inventory tab and
        # the edit screen work from; `groups` is the same rows folded brand-free
        # (invariant 2). One request rather than two because they are one read of
        # Grocy either way, and because a display showing "22 eggs" that disagrees
        # with its own expanded list is the failure mode worth designing out.
        # `locations` rides along so the edit screen can offer "move it to…" without a
        # second round trip.
        self._respond(
            HTTPStatus.OK,
            {"items": items, "groups": _fold_stock(items), "locations": _locations()},
        )

    def _handle_expired(self) -> None:
        """Everything already past its best-before date, most overdue first.

        Its own endpoint rather than a client-side filter of /inventory so that "what
        counts as expired" has one definition — this is the list the household throws
        food away from, and Home Assistant will want the same answer.
        """
        try:
            items = _stock_items()
        except (urllib.error.URLError, urllib.error.HTTPError) as exc:
            LOG.warning("pantry-vision: Grocy /api/stock unreachable", exc_info=True)
            self._respond(HTTPStatus.BAD_GATEWAY, {"error": f"Grocy unreachable: {exc}"})
            return

        expired = [i for i in items if i["days_left"] is not None and i["days_left"] < 0]
        expired.sort(key=lambda i: i["days_left"])
        self._respond(HTTPStatus.OK, {"items": expired})

    def _handle_recipes(self) -> None:
        try:
            recipes = _grocy_get("/api/objects/recipes")
        except (urllib.error.URLError, urllib.error.HTTPError) as exc:
            LOG.warning("pantry-vision: Grocy recipes unreachable", exc_info=True)
            self._respond(HTTPStatus.BAD_GATEWAY, {"error": f"Grocy unreachable: {exc}"})
            return

        results = []
        for recipe in recipes if isinstance(recipes, list) else []:
            entry = {"id": recipe.get("id"), "name": recipe.get("name"), "fulfilled": None}
            # Best-effort only: Grocy's fulfillment endpoint needs the recipes feature
            # actually set up (ingredients entered per recipe) to mean anything, and
            # its exact response shape is unverified — see README.md. A failure here
            # degrades to "fulfilled: null" (rendered as "unknown" client-side), never
            # a broken page.
            try:
                fulfillment = _grocy_get(f"/api/recipes/{entry['id']}/fulfillment")
                entry["fulfilled"] = bool(fulfillment.get("need_fulfilled"))
            except Exception:
                pass
            results.append(entry)

        self._respond(HTTPStatus.OK, {"recipes": results})

    def _handle_shopping_list(self) -> None:
        """"Groceries running low" for hosts/door-panel/'s dashboard (Phase 18) —
        distinct from /inventory's soonest-to-expire sort: this is about quantity
        below Grocy's own per-product minimum stock amount, not expiry date. Grocy's
        `/api/stock/volatile` endpoint already computes exactly this
        (`missing_products`) natively, so this is a thin reshape, not new logic —
        same "don't duplicate what Grocy already tracks" rule as /inventory and
        /recipes.
        """
        try:
            volatile = _grocy_get("/api/stock/volatile?missing_days=0")
        except (urllib.error.URLError, urllib.error.HTTPError) as exc:
            LOG.warning("pantry-vision: Grocy /api/stock/volatile unreachable", exc_info=True)
            self._respond(HTTPStatus.BAD_GATEWAY, {"error": f"Grocy unreachable: {exc}"})
            return

        # VERIFY: assumes `missing_products` entries carry "name" and
        # "amount_missing" fields directly — Grocy's documented shape, not checked
        # against a live instance. Falls back to "product_id" as the display name
        # rather than dropping the row, same degrade rule as /inventory.
        missing = volatile.get("missing_products") if isinstance(volatile, dict) else None
        items = []
        for row in missing if isinstance(missing, list) else []:
            items.append(
                {
                    "name": row.get("name") or f"Product #{row.get('product_id')}",
                    "amount_missing": row.get("amount_missing"),
                }
            )

        self._respond(HTTPStatus.OK, {"items": items})


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

    if not TOKEN:
        LOG.error(
            "PANTRY_VISION_TOKEN is not set — every request will be rejected until it is. "
            "See pantry-vision.env.example."
        )
    if not GROCY_API_KEY:
        LOG.warning("GROCY_API_KEY is not set — /confirm will fail until it is configured.")

    port = int(os.environ.get("PANTRY_VISION_PORT", "8095"))
    server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
    LOG.info("pantry-vision listening on :%d (Ollama: %s, Grocy: %s)", port, OLLAMA_HOST, GROCY_URL)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    return 0


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