// pantry-vision frontend logic (docs/project-plan.md Phase 17). Vanilla JS, no
// framework, no build step — same "vendored, dependency-free" choice as the digest/
// admin canvas SDKs' render.js.
//
// Config comes from URL query params (?api=...&token=...), set by the
// kitchen-display kiosk's own launch command, NOT hardcoded here — this file is a
// generic static asset with no secret in it, served read-only by pantry-web to
// whatever device points a browser at it.
//
// FOUR FLOWS, ONE CAMERA, ONE RULE
// --------------------------------
// Unload (book in), Consume (book out), Expired (throw away) and Edit (correct) are
// the four ways stock moves, and the first three are camera-first: hold the thing up,
// the model says what it is, a person confirms, it is written. The rule that shapes
// every one of them is server.py's — the camera proposes, the person disposes.
// Nothing here calls a write endpoint without a tap in between, including the flows
// where that costs an extra tap, because a camera that quietly books out the wrong
// brand of yoghurt produces an inventory nobody trusts, and an inventory nobody
// trusts is the same as no inventory.
"use strict";

const params = new URLSearchParams(location.search);
const API = (params.get("api") || "").replace(/\/$/, "");
const TOKEN = params.get("token") || "";

if (!API || !TOKEN) {
  document.body.innerHTML =
    '<p class="error" style="padding:24px">pantry-vision not configured — missing ' +
    "?api=&token= in the URL. See hosts/kitchen-display/configs/sway/pantry-kiosk.</p>";
  throw new Error("pantry-vision: missing ?api=/&token= query params");
}

function api(path, options) {
  options = options || {};
  options.headers = Object.assign({ Authorization: `Bearer ${TOKEN}` }, options.headers || {});
  return fetch(`${API}${path}`, options).then((res) => {
    if (!res.ok) {
      return res.json().catch(() => ({})).then((body) => {
        throw new Error(body.error || `${res.status} ${res.statusText}`);
      });
    }
    return res.json();
  });
}

function postJson(path, body) {
  return api(path, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
}

const $ = (id) => document.getElementById(id);

// --- Navigation ----------------------------------------------------------------
// Three tabs (Home / Inventory / Recipes) plus four flow screens reached from Home.
// "scan" is kept as an alias for the unload flow: it is the fragment
// kitchen-display-agent's "Show scan" MQTT button has been publishing since Phase 17,
// and a button in Home Assistant that stops working is a worse outcome than an
// old name living on here.
const SCREENS = ["home", "unload", "consume", "expired", "edit", "inventory", "recipes"];
const TABS = ["home", "inventory", "recipes"];

let currentScreen = "home";

function show(name) {
  if (name === "scan") name = "unload";
  if (!SCREENS.includes(name)) name = "home";

  // Leaving a flow always releases the camera and cancels its loop: the kiosk has one
  // webcam and a scan loop left running behind another screen would keep firing
  // /identify at the LLM host with nobody watching the answers.
  if (currentScreen !== name) stopScanner();
  currentScreen = name;

  document.querySelectorAll(".panel").forEach((p) => p.classList.toggle("active", p.id === name));
  document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t.dataset.tab === name));
  // A flow screen is not a tab; keep Home lit while one is open so the tab bar never
  // shows nothing selected.
  if (!TABS.includes(name)) document.querySelector('.tab[data-tab="home"]').classList.add("active");

  if (name === "inventory") loadInventory();
  if (name === "recipes") loadRecipes();
  if (name === "unload") startUnload();
  if (name === "consume") startConsume();
  if (name === "expired") startExpired();
  if (name === "edit") loadEditInventory();
}

document.querySelectorAll(".tab").forEach((t) => t.addEventListener("click", () => show(t.dataset.tab)));
document.querySelectorAll("[data-goto]").forEach((b) => b.addEventListener("click", () => show(b.dataset.goto)));

function toast(message, kind) {
  const el = $("toast");
  el.textContent = message;
  el.className = kind || "";
  el.hidden = false;
  clearTimeout(toast._timer);
  toast._timer = setTimeout(() => {
    el.hidden = true;
  }, 2600);
}

// --- The camera ----------------------------------------------------------------
// One <video>, one MediaStream, moved between flows. The auto-scan loop samples a
// tiny greyscale thumbnail of each frame and only spends an /identify call when the
// picture has (a) settled and (b) actually changed since the last thing it
// identified. Both gates exist for the same reason: the vision model's latency is the
// known open risk in this phase (project-plan.md open decision #18), so the frames
// worth spending it on are the ones where somebody is holding something still, and
// the same tin should never be identified twice because nobody moved.
const SAMPLE_INTERVAL_MS = 700;
const STILL_THRESHOLD = 6; // mean per-pixel difference below which the frame is "settled"
const CHANGED_THRESHOLD = 12; // ...and above which it is a different item from the last one

const video = $("camera-preview");
const scanner = $("scanner");
const scanStatus = $("scan-status");
const cameraError = $("camera-error");
const captureNowBtn = $("capture-now");
const frameSample = $("frame-sample");
const capturedFrame = $("captured-frame");

let stream = null;
let scanTimer = null;
let scanBusy = false;
let previousSample = null;
let acceptedSample = null;
let onIdentified = null;

function mountScanner(screenId) {
  const slot = document.querySelector(`#${screenId} .scanner-slot`);
  if (slot && scanner.parentElement !== slot) slot.appendChild(scanner);
  scanner.hidden = false;
}

function startCamera() {
  if (stream) return Promise.resolve();
  return navigator.mediaDevices
    .getUserMedia({ video: { facingMode: "environment" }, audio: false })
    .then((s) => {
      stream = s;
      video.srcObject = s;
      cameraError.hidden = true;
    })
    .catch((err) => {
      cameraError.textContent = `Camera unavailable: ${err.message}. See hosts/kitchen-display/README.md.`;
      cameraError.hidden = false;
      throw err;
    });
}

function stopScanner() {
  clearInterval(scanTimer);
  scanTimer = null;
  scanBusy = false;
  onIdentified = null;
  previousSample = null;
  acceptedSample = null;
  scanner.hidden = true;
  if (stream) {
    stream.getTracks().forEach((t) => t.stop());
    stream = null;
    video.srcObject = null;
  }
}

/** A 32x24 greyscale thumbnail of the current frame, as a plain array. */
function sampleFrame() {
  if (!video.videoWidth) return null;
  frameSample.width = 32;
  frameSample.height = 24;
  const ctx = frameSample.getContext("2d", { willReadFrequently: true });
  ctx.drawImage(video, 0, 0, 32, 24);
  const data = ctx.getImageData(0, 0, 32, 24).data;
  const grey = new Array(32 * 24);
  for (let i = 0; i < grey.length; i++) {
    const p = i * 4;
    grey[i] = (data[p] * 299 + data[p + 1] * 587 + data[p + 2] * 114) / 1000;
  }
  return grey;
}

function frameDelta(a, b) {
  if (!a || !b) return Infinity;
  let total = 0;
  for (let i = 0; i < a.length; i++) total += Math.abs(a[i] - b[i]);
  return total / a.length;
}

/**
 * Run the camera on `screenId` and call `handler(proposal)` the first time it sees
 * something. The loop stops itself on a hit; the flow calls resumeScan() when the
 * person is done with that item and ready for the next one.
 */
function startScan(screenId, handler, statusText) {
  onIdentified = handler;
  mountScanner(screenId);
  scanStatus.textContent = "Starting camera…";
  startCamera()
    .then(() => {
      scanStatus.textContent = statusText || "Hold an item up to the camera…";
      previousSample = null;
      clearInterval(scanTimer);
      scanTimer = setInterval(tick, SAMPLE_INTERVAL_MS);
    })
    .catch(() => {
      scanStatus.textContent = "";
    });
}

function resumeScan(statusText) {
  if (!stream || !onIdentified) return;
  scanner.hidden = false;
  scanStatus.textContent = statusText || "Ready for the next one…";
  previousSample = null;
  clearInterval(scanTimer);
  scanTimer = setInterval(tick, SAMPLE_INTERVAL_MS);
}

function pauseScan() {
  clearInterval(scanTimer);
  scanTimer = null;
}

function tick() {
  if (scanBusy) return;
  const sample = sampleFrame();
  if (!sample) return;

  const settled = frameDelta(sample, previousSample) < STILL_THRESHOLD;
  const changed = frameDelta(sample, acceptedSample) > CHANGED_THRESHOLD;
  previousSample = sample;
  if (!settled) return;
  if (!changed) {
    scanStatus.textContent = "Waiting for the next item…";
    return;
  }
  identifyNow(sample);
}

/** Capture at full resolution and ask the server what it is. */
function identifyNow(sample) {
  if (scanBusy) return;
  scanBusy = true;
  scanStatus.textContent = "Identifying…";

  capturedFrame.width = video.videoWidth;
  capturedFrame.height = video.videoHeight;
  capturedFrame.getContext("2d").drawImage(video, 0, 0);
  capturedFrame.toBlob(
    (blob) => {
      api("/identify", { method: "POST", body: blob, headers: { "Content-Type": "image/jpeg" } })
        .then((proposal) => {
          scanBusy = false;
          if (!proposal.present) {
            scanStatus.textContent = "Nothing recognised — hold the item closer.";
            return;
          }
          acceptedSample = sample || sampleFrame();
          pauseScan();
          if (onIdentified) onIdentified(proposal);
        })
        .catch((err) => {
          scanBusy = false;
          pauseScan();
          scanStatus.textContent = `Could not identify: ${err.message}`;
          // A dead vision model must not turn into a loop that keeps photographing
          // the counter at it. The flow decides what to offer instead — for unload,
          // the manual form; for the others, a message and a way out.
          if (onIdentified) onIdentified({ present: true, degraded: true, error: err.message });
        });
    },
    "image/jpeg",
    0.85
  );
}

// "Identify now" is the escape hatch for the stillness gate: a shiny jar under a
// kitchen downlight can flicker enough to never settle, and standing there waving it
// is not an acceptable answer.
captureNowBtn.addEventListener("click", () => {
  if (stream && !scanBusy) identifyNow(null);
});

// --- Shared item helpers -------------------------------------------------------
function escapeHtml(s) {
  return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}

function urgencyClass(daysLeft) {
  if (daysLeft === null || daysLeft === undefined) return "";
  if (daysLeft < 0) return "urgent-expired";
  if (daysLeft <= 2) return "urgent-soon";
  if (daysLeft <= 7) return "urgent-week";
  return "";
}

function dueLabel(days) {
  if (days === null || days === undefined) return "no date";
  if (days < 0) return `expired ${-days}d ago`;
  if (days === 0) return "expires today";
  return `${days}d left`;
}

function amountOf(row) {
  const n = Number(row.amount);
  return Number.isFinite(n) ? n : 0;
}

/** "12 × Eggs (Brand X)" the way every list in here writes it. */
function rowLabel(row) {
  return `${amountOf(row)} × ${row.name}`;
}

// --- Unload groceries (book in) ------------------------------------------------
const confirmForm = $("confirm-form");
let unloadAdded = 0;

function startUnload() {
  unloadAdded = 0;
  $("unload-count").textContent = "";
  confirmForm.hidden = true;
  startScan("unload", onUnloadProposal, "Hold the first item up to the camera…");
}

function onUnloadProposal(proposal) {
  scanner.hidden = false;
  confirmForm.hidden = false;
  $("confirm-status").textContent = "";

  const degraded = !!proposal.degraded;
  $("f-description").textContent = degraded
    ? "Could not identify this one — fill it in by hand."
    : proposal.description || proposal.name || "";
  $("f-name").value = proposal.name || "";
  $("f-category").value = proposal.category || "other";
  $("f-placement").value = proposal.recommended_placement || "cupboard";
  $("f-date").value = proposal.best_before_date || new Date().toISOString().slice(0, 10);
  $("f-date-source").textContent =
    proposal.best_before_source === "label"
      ? "Date read off the packaging — check it."
      : "Estimated from the category, not read off the packaging.";
  $("f-packages").value = 1;
  $("f-units").value = proposal.units_per_package || 1;
  confirmForm.dataset.unitName = proposal.unit_name || "piece";
  confirmForm.dataset.kind = proposal.kind || "";
  confirmForm.dataset.brand = proposal.brand || "";
  updateTotalLine();

  // "You already have ten of these" at the only moment it can still change what
  // somebody does — while the shopping is still on the counter.
  const matches = proposal.stock_matches || [];
  const held = matches.reduce((sum, m) => sum + amountOf(m), 0);
  $("f-have").textContent = held
    ? `Already in stock: ${held} (${matches.length} ${matches.length === 1 ? "entry" : "entries"}).`
    : proposal.stock_matches_unavailable
    ? "Could not check what is already in stock — Grocy did not answer."
    : "";

  $("f-confidence").textContent = degraded
    ? proposal.error || proposal.note || ""
    : `Model confidence: ${proposal.confidence || "unknown"}. Review before confirming.`;
}

function updateTotalLine() {
  const packs = Number($("f-packages").value) || 1;
  const per = Number($("f-units").value) || 1;
  const unit = confirmForm.dataset.unitName || "piece";
  const total = packs * per;
  $("f-total").textContent =
    per > 1
      ? `Books in ${total} ${unit}${total === 1 ? "" : "s"} (${packs} × ${per}).`
      : `Books in ${total} ${unit}${total === 1 ? "" : "s"}.`;
}

$("f-packages").addEventListener("input", updateTotalLine);
$("f-units").addEventListener("input", updateTotalLine);

$("skip-btn").addEventListener("click", () => {
  confirmForm.hidden = true;
  resumeScan("Skipped. Hold up the next item…");
});

confirmForm.addEventListener("submit", (event) => {
  event.preventDefault();
  $("confirm-status").textContent = "Adding…";
  postJson("/confirm", {
    name: $("f-name").value.trim(),
    kind: confirmForm.dataset.kind || "",
    brand: confirmForm.dataset.brand || "",
    category: $("f-category").value,
    placement: $("f-placement").value,
    best_before_date: $("f-date").value,
    quantity: Number($("f-packages").value) || 1,
    units_per_package: Number($("f-units").value) || 1,
  })
    .then((result) => {
      unloadAdded += 1;
      $("unload-count").textContent = `${unloadAdded} booked in`;
      $("confirm-status").textContent = "";
      confirmForm.hidden = true;
      toast(`Added ${result.amount} × ${$("f-name").value.trim()} — put it away!`);
      resumeScan("Hold up the next item…");
    })
    .catch((err) => {
      $("confirm-status").textContent = `Could not add: ${err.message}`;
    });
});

// --- Consume article (book out) ------------------------------------------------
let consumeChoice = null;
let consumeUnitsPerPackage = 1;

function startConsume() {
  $("consume-result").hidden = true;
  $("consume-status").textContent = "";
  startScan("consume", onConsumeProposal, "Hold up what you are about to use…");
}

function onConsumeProposal(proposal) {
  $("consume-result").hidden = false;
  $("consume-amount").hidden = true;
  $("consume-status").textContent = "";
  consumeUnitsPerPackage = proposal.units_per_package || 1;

  if (proposal.degraded) {
    $("consume-identified").textContent = "Could not identify that.";
    $("consume-candidates").innerHTML =
      '<p class="hint">Try again, or correct the amount by hand on the Edit inventory screen.</p>';
    addRescanButton();
    return;
  }

  const matches = proposal.stock_matches || [];
  $("consume-identified").textContent = `Looks like: ${proposal.name}`;

  if (!matches.length) {
    $("consume-candidates").innerHTML =
      '<p class="hint">Nothing matching that is booked in, so there is nothing to book out. ' +
      "If it should be in stock, add it on the Unload screen first.</p>";
    addRescanButton();
    return;
  }

  // One unambiguous match goes straight to the amount step; several mean the camera
  // knows the kind but not the brand, and picking one at random here would be exactly
  // the silent wrong write this whole service is arranged to avoid.
  const exact = matches.filter((m) => m.exact);
  if (exact.length === 1) {
    chooseConsumeRow(exact[0]);
    return;
  }
  if (matches.length === 1) {
    chooseConsumeRow(matches[0]);
    return;
  }

  $("consume-candidates").innerHTML =
    '<p class="hint">Which one? (Same kind, different brands or dates.)</p>' +
    matches
      .map(
        (m, i) => `<button class="card choice" data-index="${i}">
          <span class="card-name">${escapeHtml(rowLabel(m))}</span>
          <span class="card-due">${escapeHtml(dueLabel(m.days_left))}</span>
        </button>`
      )
      .join("");
  $("consume-candidates")
    .querySelectorAll("button.choice")
    .forEach((btn) => btn.addEventListener("click", () => chooseConsumeRow(matches[Number(btn.dataset.index)])));
}

function addRescanButton() {
  const btn = document.createElement("button");
  btn.className = "big-btn secondary";
  btn.textContent = "Scan again";
  btn.addEventListener("click", () => {
    $("consume-result").hidden = true;
    resumeScan("Hold up what you are about to use…");
  });
  $("consume-candidates").appendChild(btn);
}

function chooseConsumeRow(row) {
  consumeChoice = row;
  $("consume-candidates").innerHTML = "";
  $("consume-amount").hidden = false;
  $("consume-chosen").textContent = `${row.name} — ${amountOf(row)} in stock, ${dueLabel(row.days_left)}`;

  // "If multipack, ask how many": in stock terms a multipack is simply a row holding
  // more than one unit, since everything was booked in as individual units. The quick
  // buttons are the answers people actually give — one, the whole pack, or all of it.
  const held = amountOf(row);
  const quick = [1];
  if (consumeUnitsPerPackage > 1 && consumeUnitsPerPackage <= held) quick.push(consumeUnitsPerPackage);
  if (held > 1 && !quick.includes(held)) quick.push(held);

  $("consume-qty").value = 1;
  $("consume-qty").max = held || 1;
  $("consume-quick").innerHTML = quick
    .map(
      (n) =>
        `<button type="button" class="quick-btn" data-amount="${n}">${
          n === held ? `all ${n}` : n === consumeUnitsPerPackage && n > 1 ? `whole pack (${n})` : n
        }</button>`
    )
    .join("");
  $("consume-quick")
    .querySelectorAll(".quick-btn")
    .forEach((btn) =>
      btn.addEventListener("click", () => {
        $("consume-qty").value = btn.dataset.amount;
      })
    );
}

$("consume-cancel").addEventListener("click", () => {
  consumeChoice = null;
  $("consume-result").hidden = true;
  resumeScan("Hold up what you are about to use…");
});

$("consume-confirm").addEventListener("click", () => {
  if (!consumeChoice) return;
  const amount = Number($("consume-qty").value);
  if (!(amount > 0)) {
    $("consume-status").textContent = "Enter how many.";
    return;
  }
  $("consume-status").textContent = "Booking out…";
  postJson("/consume", { product_id: consumeChoice.product_id, amount, spoiled: false })
    .then(() => {
      toast(`Booked out ${amount} × ${consumeChoice.name}`);
      consumeChoice = null;
      $("consume-result").hidden = true;
      $("consume-status").textContent = "";
      resumeScan("Hold up the next thing…");
    })
    .catch((err) => {
      $("consume-status").textContent = `Could not book out: ${err.message}`;
    });
});

// --- Expired foods -------------------------------------------------------------
let expiredRows = [];

function startExpired() {
  scanner.hidden = true;
  $("expired-status").textContent = "";
  loadExpired();
}

function loadExpired() {
  const el = $("expired-list");
  api("/expired")
    .then((data) => {
      expiredRows = data.items || [];
      if (!expiredRows.length) {
        el.innerHTML = '<p class="hint">Nothing has expired. </p>';
        return;
      }
      el.innerHTML = expiredRows
        .map(
          (row, i) => `<div class="card urgent-expired">
            <span class="card-name">${escapeHtml(rowLabel(row))}</span>
            <span class="card-due">${escapeHtml(dueLabel(row.days_left))}</span>
            <button class="row-btn" data-index="${i}">Throw away</button>
          </div>`
        )
        .join("");
      el.querySelectorAll(".row-btn").forEach((btn) =>
        btn.addEventListener("click", () => throwAway(expiredRows[Number(btn.dataset.index)]))
      );
    })
    .catch((err) => {
      el.innerHTML = `<p class="error">Could not load expired foods: ${escapeHtml(err.message)}</p>`;
    });
}

// Scanning is the intended way to clear this list — you are standing at the bin with
// the thing in your hand — and the per-row button is the fallback for when the label
// is unreadable or the food no longer looks like itself.
$("expired-scan-btn").addEventListener("click", () => {
  $("expired-status").textContent = "";
  startScan("expired", onExpiredProposal, "Hold up what you are throwing away…");
});

function onExpiredProposal(proposal) {
  if (proposal.degraded) {
    $("expired-status").textContent = "Could not identify that — use the Throw away button on the row instead.";
    resumeScan("Try another item…");
    return;
  }
  const matches = (proposal.stock_matches || []).filter((m) =>
    expiredRows.some((row) => row.product_id === m.product_id)
  );
  if (!matches.length) {
    $("expired-status").textContent = `${proposal.name} is not on the expired list — nothing removed.`;
    resumeScan("Hold up the next one…");
    return;
  }
  throwAway(matches[0]);
}

function throwAway(row) {
  if (!row) return;
  const amount = amountOf(row) || 1;
  // One tap between the camera and a write, same as everywhere else here — with the
  // amount stated, because "throw away" on a row holding twelve means all twelve.
  if (!window.confirm(`Throw away ${amount} × ${row.name}?`)) {
    resumeScan("Hold up the next one…");
    return;
  }
  $("expired-status").textContent = "Removing…";
  postJson("/consume", { product_id: row.product_id, amount, spoiled: true })
    .then(() => {
      toast(`Binned ${amount} × ${row.name}`);
      $("expired-status").textContent = "";
      loadExpired();
      resumeScan("Hold up the next one…");
    })
    .catch((err) => {
      $("expired-status").textContent = `Could not remove: ${err.message}`;
    });
}

// --- Edit inventory ------------------------------------------------------------
// The only screen that shows the folded view and its per-brand rows together: "22
// eggs" is the number the household thinks in, and "10 of brand Y" is the number you
// need to be able to correct exactly. Hence a <details> per kind rather than two
// separate screens.
let knownLocations = [];

function loadEditInventory() {
  const el = $("edit-list");
  $("edit-status").textContent = "";
  api("/inventory")
    .then((data) => {
      knownLocations = data.locations || [];
      const groups = data.groups || [];
      if (!groups.length) {
        el.innerHTML = '<p class="hint">Nothing booked in yet.</p>';
        return;
      }
      el.innerHTML = groups.map(renderEditGroup).join("");
      wireEditRows(el);
    })
    .catch((err) => {
      el.innerHTML = `<p class="error">Could not load inventory: ${escapeHtml(err.message)}</p>`;
    });
}

function renderEditGroup(group) {
  const brands = group.brand_count > 1 ? ` · ${group.brand_count} brands` : "";
  return `<details class="group ${urgencyClass(group.soonest_days_left)}">
    <summary>
      <span class="card-name">${escapeHtml(group.display_name || group.kind)}</span>
      <span class="card-amount">${escapeHtml(String(group.total_amount))}${escapeHtml(brands)}</span>
      <span class="card-due">${escapeHtml(dueLabel(group.soonest_days_left))}</span>
    </summary>
    ${(group.entries || []).map(renderEditRow).join("")}
  </details>`;
}

function renderEditRow(row) {
  return `<div class="edit-row" data-product="${row.product_id}">
    <span class="card-name">${escapeHtml(row.brand || row.name)}</span>
    <span class="card-due">${escapeHtml(dueLabel(row.days_left))}</span>
    <button class="step-btn" data-step="-1">−</button>
    <input class="amount-input" type="number" min="0" step="1" value="${escapeHtml(String(amountOf(row)))}">
    <button class="step-btn" data-step="1">+</button>
    <button class="row-btn save-btn">Save</button>
  </div>
  ${renderWhereRow(row)}`;
}

// Where a thing is, in two registers that are deliberately not merged: the location
// Grocy records (which somebody chose, and which "Move" changes) and the doorway
// camera's last sighting (which nobody chose, and which is only ever a hint — see
// docs/fridge-item-location.md). Showing the sighting as a sentence with a time in it
// is what keeps it readable as a clue rather than as a fact.
function renderWhereRow(row) {
  if (!knownLocations.length && !row.last_seen) return "";
  const seen = row.last_seen
    ? `Camera last saw something like this at ${escapeHtml(row.last_seen.location_name)}, ${escapeHtml(
        relativeTime(row.last_seen.created_at)
      )} — a sighting, not a fact.`
    : "";
  const options = knownLocations
    .map(
      (loc) =>
        `<option value="${loc.id}"${loc.id === row.location_id ? " selected" : ""}>${escapeHtml(loc.name)}</option>`
    )
    .join("");
  return `<div class="where-row" data-product="${row.product_id}">
    <span class="hint">${seen}</span>
    ${
      knownLocations.length
        ? `<label class="where-move">Move to
            <select class="where-select">${options}</select>
          </label>
          <button class="row-btn move-btn">Move</button>`
        : ""
    }
  </div>`;
}

function relativeTime(iso) {
  const then = Date.parse(iso || "");
  if (!Number.isFinite(then)) return "at an unknown time";
  const minutes = Math.round((Date.now() - then) / 60000);
  if (minutes < 1) return "just now";
  if (minutes < 60) return `${minutes} min ago`;
  const hours = Math.round(minutes / 60);
  if (hours < 24) return `${hours}h ago`;
  return `${Math.round(hours / 24)}d ago`;
}

function wireEditRows(root) {
  root.querySelectorAll(".edit-row").forEach((rowEl) => {
    const input = rowEl.querySelector(".amount-input");
    rowEl.querySelectorAll(".step-btn").forEach((btn) =>
      btn.addEventListener("click", () => {
        const next = (Number(input.value) || 0) + Number(btn.dataset.step);
        input.value = Math.max(0, next);
        // +/- write immediately: a step button that needs a second tap on Save is a
        // step button people will forget to save. The freeform field waits for Save,
        // because it is mid-typing until then.
        saveEditRow(rowEl);
      })
    );
    rowEl.querySelector(".save-btn").addEventListener("click", () => saveEditRow(rowEl));
  });

  root.querySelectorAll(".where-row").forEach((whereEl) => {
    const moveBtn = whereEl.querySelector(".move-btn");
    if (!moveBtn) return;
    moveBtn.addEventListener("click", () => {
      const productId = Number(whereEl.dataset.product);
      const rowEl = root.querySelector(`.edit-row[data-product="${productId}"]`);
      const amount = Number(rowEl && rowEl.querySelector(".amount-input").value);
      if (!(amount > 0)) {
        $("edit-status").textContent = "Nothing to move — the amount is zero.";
        return;
      }
      $("edit-status").textContent = "Moving…";
      postJson("/transfer", {
        product_id: productId,
        amount,
        to_location_id: Number(whereEl.querySelector(".where-select").value),
      })
        .then((result) => {
          $("edit-status").textContent = "";
          toast(result.unchanged ? "Already there" : "Moved");
          loadEditInventory();
        })
        .catch((err) => {
          $("edit-status").textContent = `Could not move: ${err.message}`;
        });
    });
  });
}

function saveEditRow(rowEl) {
  const productId = Number(rowEl.dataset.product);
  const amount = Number(rowEl.querySelector(".amount-input").value);
  if (!(amount >= 0)) {
    $("edit-status").textContent = "Amount has to be zero or more.";
    return;
  }
  $("edit-status").textContent = "Saving…";
  postJson("/adjust", { product_id: productId, amount })
    .then(() => {
      $("edit-status").textContent = "";
      toast("Corrected");
      loadEditInventory();
    })
    .catch((err) => {
      $("edit-status").textContent = `Could not save: ${err.message}`;
    });
}

// --- Inventory (read-only) -----------------------------------------------------
function loadInventory() {
  const el = $("inventory-list");
  api("/inventory")
    .then((data) => {
      const groups = data.groups || [];
      if (!groups.length) {
        el.innerHTML = '<p class="hint">Nothing in stock yet — unload some groceries!</p>';
        return;
      }
      el.innerHTML = groups
        .map((group) => {
          const brands = group.brand_count > 1 ? `${group.brand_count} brands` : "";
          return `<div class="card ${urgencyClass(group.soonest_days_left)}">
            <span class="card-name">${escapeHtml(group.display_name || group.kind)}</span>
            <span class="card-amount">×${escapeHtml(String(group.total_amount))} ${escapeHtml(brands)}</span>
            <span class="card-due">${escapeHtml(dueLabel(group.soonest_days_left))}</span>
          </div>`;
        })
        .join("");
    })
    .catch((err) => {
      el.innerHTML = `<p class="error">Could not load inventory: ${escapeHtml(err.message)}</p>`;
    });
}

// --- Recipes -----------------------------------------------------------------
function loadRecipes() {
  const el = $("recipes-list");
  api("/recipes")
    .then((data) => {
      const recipes = data.recipes || [];
      if (!recipes.length) {
        el.innerHTML = '<p class="hint">No recipes in Grocy yet.</p>';
        return;
      }
      el.innerHTML = recipes
        .map((r) => {
          const badge = r.fulfilled === true ? "✓ can make now" : r.fulfilled === false ? "missing items" : "";
          return `<div class="card">
            <span class="card-name">${escapeHtml(r.name || "")}</span>
            <span class="card-due">${escapeHtml(badge)}</span>
          </div>`;
        })
        .join("");
    })
    .catch((err) => {
      el.innerHTML = `<p class="error">Could not load recipes: ${escapeHtml(err.message)}</p>`;
    });
}

// A "Show <screen>" MQTT command from HA arrives as a URL fragment reload
// (kitchen-display-agent kills and relaunches Chromium at index.html#inventory, same
// pattern as the thin client's digest-browser) — honour it on load, same as any tap.
show(location.hash ? location.hash.slice(1) : "home");
