/* Hearth — this residence's status vocabulary.
   A value not listed here does not exist. No screen may invent a status, pass a
   string literal into a Badge, or assign a tone: StatusBadge reads these lists
   and nothing else does.

   Everything computed from other fields is a DERIVED FLAG, not a status: a door
   unlocked while the alarm is away, a filter past its hours, a bill past its
   date, a routine naming a device that has been removed. Those live in the
   *_FLAGS blocks with the clock injected, and a record carries one status and
   any number of flags.

   Tone grouping, one question, product-wide: CAN THE HOUSE BE LEFT ALONE.
     danger   — a failure event: something is not working, or is actually wrong
     warning  — a deliberate state that wants a person: a decision, a setup, a date
     info     — running, doing its job, nobody owed anything
     success  — settled, closed, or secure
     neutral  — parked on purpose, or historic
*/

/* Whether the house can rely on a device. Deliberately NOT here: on and off —
   that is the device's control value, which every row renders as a control
   rather than a badge, and "low battery", which is derived from the level. */
const DEVICE_STATUS = [
  { id: "online", label: "Online", tone: "info" },
  { id: "offline", label: "Offline", tone: "danger" },
  { id: "setup_pending", label: "Needs setting up", tone: "warning" },
  { id: "updating", label: "Updating", tone: "neutral" },
  { id: "retired", label: "Out of the house", tone: "neutral" },
];

/* What a heating zone has been told to do. A zone is never "cold" — that is the
   temperature it reports, read against the target. */
const ZONE_STATUS = [
  { id: "comfort", label: "Comfort", tone: "info" },
  { id: "setback", label: "Setback", tone: "neutral" },
  { id: "boost", label: "Boost", tone: "warning" },
  { id: "frost", label: "Frost protection", tone: "neutral" },
  { id: "off", label: "Off", tone: "neutral" },
];

/* The alarm. triggered is danger — it is an event, not a state somebody chose;
   fault is danger for the same reason. */
const ALARM_STATUS = [
  { id: "disarmed", label: "Disarmed", tone: "neutral" },
  { id: "armed_home", label: "Armed — home", tone: "info" },
  { id: "armed_away", label: "Armed — away", tone: "info" },
  { id: "triggered", label: "Triggered", tone: "danger" },
  { id: "fault", label: "Fault", tone: "danger" },
];

/* A door, window or gate as its sensor reports it. Whether that is a PROBLEM is
   derived: an open window is fine in the afternoon and not fine when the house
   is armed away. */
const ENTRY_STATUS = [
  { id: "locked", label: "Closed and locked", tone: "success" },
  { id: "unlocked", label: "Closed, unlocked", tone: "warning" },
  { id: "open", label: "Open", tone: "warning" },
  { id: "unknown", label: "Sensor offline", tone: "danger" },
];

/* A piece of work on the house. Deliberately absent: "overdue" — derived from
   the due date, and for a filter from the hours it has actually run. */
const JOB_STATUS = [
  { id: "planned", label: "Planned", tone: "warning" },
  { id: "quoted", label: "Quoted", tone: "warning" },
  { id: "scheduled", label: "Scheduled", tone: "info" },
  { id: "in_progress", label: "In progress", tone: "info" },
  { id: "done", label: "Done", tone: "success" },
  { id: "cancelled", label: "Cancelled", tone: "neutral" },
];

/* Money owed for running the house. "overdue" is derived from the due date. */
const BILL_STATUS = [
  { id: "estimated", label: "Estimated", tone: "neutral" },
  { id: "due", label: "Due", tone: "warning" },
  { id: "paid", label: "Paid", tone: "success" },
  { id: "disputed", label: "Disputed", tone: "danger" },
];

/* A way into the house that is not a person: a keypad code, a handed-out key, a
   contractor's window of access. "Expiring" and "never used" are derived. */
const ACCESS_STATUS = [
  { id: "active", label: "Active", tone: "info" },
  { id: "scheduled", label: "Starts later", tone: "neutral" },
  { id: "suspended", label: "Suspended", tone: "warning" },
  { id: "revoked", label: "Revoked", tone: "neutral" },
];

/* Somebody's standing in the household. */
const MEMBER_STATUS = [
  { id: "invited", label: "Invited", tone: "warning" },
  { id: "active", label: "Active", tone: "success" },
  { id: "suspended", label: "Suspended", tone: "warning" },
  { id: "removed", label: "Removed", tone: "neutral" },
];

/* A routine the house runs on its own. "Broken" is not here: it is derived from
   whether the devices the routine names are still in the house. */
const ROUTINE_STATUS = [
  { id: "on", label: "On", tone: "info" },
  { id: "paused", label: "Paused", tone: "neutral" },
  { id: "draft", label: "Unfinished", tone: "warning" },
];

/* Somebody in the house asking for something the rules do not allow them. */
const REQUEST_STATUS = [
  { id: "pending", label: "Waiting on you", tone: "warning" },
  { id: "granted", label: "Granted", tone: "success" },
  { id: "denied", label: "Denied", tone: "neutral" },
];

/* ── Classification lists — read through labelOf, never through a Badge ───── */
const ROOMS = [
  { value: "kitchen", label: "Kitchen" },
  { value: "hall", label: "Hall" },
  { value: "living", label: "Drawing room" },
  { value: "study", label: "Study" },
  { value: "main_bed", label: "Main bedroom" },
  { value: "milo_bed", label: "Milo's room" },
  { value: "iris_bed", label: "Iris's room" },
  { value: "utility", label: "Utility" },
  { value: "garage", label: "Garage" },
  { value: "garden", label: "Garden" },
];
const ZONES = [
  { value: "ground", label: "Ground floor" },
  { value: "first", label: "First floor" },
  { value: "study", label: "Study" },
  { value: "water", label: "Hot water" },
  { value: "annexe", label: "Annexe" },
];
const DEVICE_KINDS = [
  { value: "light", label: "Light" },
  { value: "lock", label: "Lock" },
  { value: "thermostat", label: "Thermostat" },
  { value: "sensor", label: "Sensor" },
  { value: "camera", label: "Camera" },
  { value: "appliance", label: "Appliance" },
  { value: "meter", label: "Meter" },
  { value: "blind", label: "Blind" },
  { value: "speaker", label: "Speaker" },
];
const ENTRY_KINDS = [
  { value: "door", label: "Door" },
  { value: "window", label: "Window" },
  { value: "gate", label: "Gate" },
  { value: "garage", label: "Garage door" },
];
const HOUSEHOLD_ROLES = [
  { value: "owner", label: "Owner" },
  { value: "adult", label: "Adult" },
  { value: "child", label: "Child" },
  { value: "staff", label: "Household staff" },
  { value: "guest", label: "Guest" },
];
const ACCESS_KINDS = [
  { value: "code", label: "Keypad code" },
  { value: "key", label: "Physical key" },
  { value: "window", label: "Timed access" },
];
const JOB_KINDS = [
  { value: "service", label: "Service visit" },
  { value: "repair", label: "Repair" },
  { value: "consumable", label: "Consumable" },
  { value: "inspection", label: "Inspection" },
  { value: "garden", label: "Grounds" },
];
const TRADES = [
  { value: "heating", label: "Heating and plumbing" },
  { value: "electrical", label: "Electrical" },
  { value: "garden", label: "Grounds and garden" },
  { value: "cleaning", label: "Cleaning" },
  { value: "security", label: "Security" },
  { value: "chimney", label: "Chimney and flues" },
];
const BILL_KINDS = [
  { value: "energy", label: "Electricity" },
  { value: "water", label: "Water" },
  { value: "council", label: "Council tax" },
  { value: "insurance", label: "Insurance" },
  { value: "service", label: "Service contract" },
  { value: "broadband", label: "Broadband" },
];
const EVENT_KINDS = [
  { value: "device", label: "Device" },
  { value: "security", label: "Security" },
  { value: "climate", label: "Climate" },
  { value: "routine", label: "Routine" },
  { value: "person", label: "Person" },
  { value: "upkeep", label: "Upkeep" },
  { value: "money", label: "Money" },
];
const TRIGGER_KINDS = [
  { value: "time", label: "At a time" },
  { value: "presence", label: "When somebody arrives or leaves" },
  { value: "sensor", label: "When a sensor changes" },
  { value: "sunset", label: "Around sunset" },
];

/* What a connection test came back with. Not derived — it is what the hub
   reported when it last asked. Whether that answer is STALE is derived, from the
   clock against the policy window. */
const TEST_STATUS = [
  { id: "pass", label: "Answered", tone: "success" },
  { id: "slow", label: "Slow to answer", tone: "warning" },
  { id: "fail", label: "No answer", tone: "danger" },
  { id: "untested", label: "Never tested", tone: "neutral" },
];

/* What a camera caught. A clip is classified once, by the camera, and every
   surface reads the label from here. */
const CLIP_KINDS = [
  { value: "person", label: "A person" },
  { value: "vehicle", label: "A vehicle" },
  { value: "parcel", label: "A delivery" },
  { value: "animal", label: "An animal" },
  { value: "motion", label: "Movement" },
];

/* Signal strength is a reading in dBm, so the BAND is derived from it — three
   bands declared once, and every bar, badge and count reads this one function. */
const SIGNAL_BANDS = [
  { value: "good", label: "Strong", bars: 3 },
  { value: "fair", label: "Fair", bars: 2 },
  { value: "weak", label: "Weak", bars: 1 },
];
const signalBand = (dbm) => (dbm == null ? null : dbm >= -55 ? "good" : dbm >= -70 ? "fair" : "weak");

/* A verdict's ATTENTION LEVEL. Not a status — no record carries it; it is the
   tone a verdict function returns, named once here so the badge that shows it
   never carries a literal. Colour lands on a 6px dot rather than on the headline:
   a sentence in red is a shout, and the same sentence with a dotted badge beside
   it is a reading. */
const VERDICT_TONES = [
  { id: "danger", label: "Wrong now" },
  { id: "warning", label: "Wants you" },
  { id: "info", label: "Running" },
  { id: "success", label: "Settled" },
];

/* ── The household's own policy — every number the house acts on ─────────── */
const HOUSE_POLICY = {
  awaySetbackC: 15.5,          /* what the house holds when nobody is home and awake */
  nightSetbackC: 17,           /* what it holds once everybody is asleep */
  frostC: 7,
  boostMinutes: 60,
  darkAfter: "18:20",          /* sunset today — an unlocked door reads differently after it */
  quietFrom: "21:30",
  quietTo: "06:45",
  lowBatteryPercent: 20,
  jobDueSoonDays: 14,
  accessExpirySoonDays: 7,
  standingChargePence: 62,     /* per day */
  testStaleDays: 7,            /* after this a connection test is worth running again */
  weakSignalDbm: -70,          /* the band boundary signalBand reads */
};

/* What each role may do. Screens read these maps, never a name or a role label. */
const ROLE_CAN_UNLOCK = { owner: true, adult: true, child: false, staff: true, guest: false };
const ROLE_CAN_ARM = { owner: true, adult: true, child: false, staff: true, guest: false };
const ROLE_CAN_SPEND = { owner: true, adult: true, child: false, staff: false, guest: false };
const ROLE_CAN_GRANT_ACCESS = { owner: true, adult: false, child: false, staff: false, guest: false };
const ROLE_CAN_EDIT_ROUTINES = { owner: true, adult: true, child: false, staff: false, guest: false };
/* Watching the house is a privacy decision, so it is a role rule like any other:
   the children and a guest do not get the live view or the clips. */
const ROLE_CAN_WATCH = { owner: true, adult: true, child: false, staff: false, guest: false };
const ROLE_CAN_PAIR = { owner: true, adult: true, child: false, staff: false, guest: false };

/* ── Derived flags — computed at render, never stored, clock injected ─────── */
const DAY_MS = 864e5;
const HOUR_MS = 36e5;
const MINUTE_MS = 6e4;

/* Minutes since midnight, so a policy window can be compared to a clock. */
const minutesOfDay = (at) => { const d = new Date(at); return d.getHours() * 60 + d.getMinutes(); };
const parseClock = (text) => { const [h, m] = String(text).split(":").map(Number); return h * 60 + m; };
const isDark = (now) => minutesOfDay(now) >= parseClock(HOUSE_POLICY.darkAfter) || minutesOfDay(now) < 7 * 60;

const DEVICE_FLAGS = {
  /* One act-ability predicate, read by the row, the tab count and the nav badge. */
  needsYou: (d) => d.status === "offline" || d.status === "setup_pending" || (d.battery != null && d.battery <= 0),
  lowBattery: (d) => d.battery != null && d.battery > 0 && d.battery <= HOUSE_POLICY.lowBatteryPercent,
  /* Recessed rather than disabled: nobody can act on it, and the row says why. */
  recessed: (d) => d.status === "updating" || d.status === "retired",
  drawing: (d) => d.status === "online" && !!d.on && (d.watts || 0) > 0,
};

/* A camera is a device, so its status is DEVICE_STATUS. What is peculiar to a
   camera is whether it is WATCHING, and that is derived from three facts at once:
   it is online, it is set to record, and its shutter is open. */
const CAMERA_FLAGS = {
  watching: (c) => !!c.device && c.device.status === "online" && c.recording && !c.privacy,
  blind: (c) => !c.device || c.device.status !== "online",
  shuttered: (c) => !!c.privacy && !!c.device && c.device.status === "online",
  recessed: (c) => !c.device || c.device.status !== "online",
};

/* The radio link to a device, read by the connection test and by every signal
   bar. Weak, behind and stale are all derived — nothing writes them down. */
const LINK_FLAGS = {
  weak: (link) => !!link && signalBand(link.dbm) === "weak",
  behind: (link) => !!link && !!link.firmware && link.firmware !== link.latest,
  stale: (link, now) => !link || !link.testedAt || now - link.testedAt > HOUSE_POLICY.testStaleDays * DAY_MS,
  answered: (link) => !!link && link.result === "pass",
};

const ENTRY_FLAGS = {
  /* An entry is insecure against the alarm and the clock, never on its own: an
     open window at four in the afternoon is a window; the same window with the
     house armed away is the reason the alarm exists. */
  insecure: (entry, alarm, now) => {
    if (entry.status === "unknown") return true;
    if (entry.status === "locked") return false;
    if (alarm === "armed_away" || alarm === "triggered") return true;
    if (entry.kind === "window") return alarm === "armed_home" && entry.floor === "ground";
    return isDark(now);
  },
  wouldBlockArming: (entry) => entry.status === "open" || entry.status === "unknown",
};

const ZONE_FLAGS = {
  /* Heating into an open window is the one thing a heating screen must catch. */
  heatingWithWindowOpen: (zone, entries) => zone.status !== "off" && zone.reportedC < zone.targetC && entries.some((e) => e.zone === zone.id && e.status === "open"),
  belowTarget: (zone) => zone.reportedC < zone.targetC - 0.4,
  overridden: (zone) => !!zone.overrideUntil,
  callingForHeat: (zone) => zone.status !== "off" && zone.reportedC < zone.targetC - 0.2,
};

const JOB_FLAGS = {
  overdue: (job, now) => job.status !== "done" && job.status !== "cancelled" && !!job.dueAt && job.dueAt < now,
  dueSoon: (job, now) => job.status !== "done" && job.status !== "cancelled" && !!job.dueAt && job.dueAt >= now && job.dueAt - now <= HOUSE_POLICY.jobDueSoonDays * DAY_MS,
  /* A consumable's real due date is the hours it has run, not the calendar. */
  consumableSpent: (job) => job.kind === "consumable" && job.lifeHours != null && job.ranHours != null && job.ranHours >= job.lifeHours,
  lifeLeft: (job) => (job.lifeHours ? Math.max(0, 1 - job.ranHours / job.lifeHours) : null),
  needsYou: (job, now) => JOB_FLAGS.overdue(job, now) || job.status === "quoted" || JOB_FLAGS.consumableSpent(job),
};

const BILL_FLAGS = {
  overdue: (bill, now) => bill.status === "due" && bill.dueAt < now,
  aboveForecast: (bill) => bill.forecastMinor != null && bill.amountMinor > bill.forecastMinor,
};

const ACCESS_FLAGS = {
  expiringSoon: (grant, now) => grant.status === "active" && !!grant.endsAt && grant.endsAt - now <= HOUSE_POLICY.accessExpirySoonDays * DAY_MS && grant.endsAt >= now,
  expired: (grant, now) => grant.status === "active" && !!grant.endsAt && grant.endsAt < now,
  neverUsed: (grant) => grant.status === "active" && !grant.lastUsedAt,
};

const ROUTINE_FLAGS = {
  /* Derived, so removing a device breaks its routines with nothing written on
     them, and every count that reads this moves by itself. */
  broken: (routine, devices) => routine.deviceIds.some((id) => !devices.some((d) => d.id === id && d.status !== "retired")),
  needsYou: (routine, devices) => routine.status === "draft" || ROUTINE_FLAGS.broken(routine, devices),
  recessed: (routine) => routine.status === "paused",
};

const REQUEST_FLAGS = { waiting: (r) => r.status === "pending" };

const MEMBER_FLAGS = {
  /* Awake is derived from that person's own bedtime and wake preferences against
     the clock — the house never stores whether somebody is awake. */
  isAwake: (member, now) => {
    const minute = minutesOfDay(now);
    const bed = parseClock(member.prefs.bedtime);
    const wake = parseClock(member.prefs.wake);
    return bed > wake ? minute < bed && minute >= wake : minute < bed || minute >= wake;
  },
  countsForComfort: (member, now) => member.status === "active" && member.home && MEMBER_FLAGS.isAwake(member, now),
};

/* ── Transitions — one function per entity, called by every surface ────────
   The set derives from the current state; a transition that does not apply is
   omitted, one that exists but this person may not take is disabled with the
   reason stated (§1.2). One transition is primary: the record's normal path. */
function deviceTransitions(device, viewer) {
  const may = ROLE_CAN_UNLOCK[viewer.role];
  const gate = (t) => Object.assign({}, t, may ? {} : { disabled: true, disabledReason: "Locks and doors are an adult's to move. You are signed in as " + labelOf(HOUSEHOLD_ROLES, viewer.role).toLowerCase() + "." });
  if (device.status === "setup_pending") return [{ id: "set_up", label: "Set up device", primary: true }, { id: "remove", label: "Remove device", destructive: true }];
  if (device.status === "offline") return [{ id: "retry", label: "Retry connection", primary: true }, { id: "remove", label: "Remove device", destructive: true }];
  if (device.status === "updating") return [];
  if (device.status === "retired") return [{ id: "restore", label: "Switch back on", primary: true }];
  const out = [];
  if (device.kind === "lock") out.push(gate({ id: device.on ? "unlock" : "lock", label: device.on ? "Unlock" : "Lock", primary: true }));
  else out.push({ id: device.on ? "turn_off" : "turn_on", label: device.on ? "Turn off" : "Turn on", primary: true });
  if (DEVICE_FLAGS.lowBattery(device) || (device.battery != null && device.battery <= 0)) out.push({ id: "battery_replaced", label: "Mark battery replaced" });
  out.push({ id: "retire", label: "Switch out of the house" });
  out.push({ id: "remove", label: "Remove device", destructive: true });
  return out;
}

/* A camera's controls, derived from what it is doing. Privacy and the live view
   are gated on the role rather than hidden, so a child sees why (§1.2). */
function cameraTransitions(camera, viewer) {
  const may = ROLE_CAN_WATCH[viewer.role];
  const gate = (t) => Object.assign({}, t, may ? {} : { disabled: true, disabledReason: "The live view and the shutters are an adult's. You are signed in as " + labelOf(HOUSEHOLD_ROLES, viewer.role).toLowerCase() + "." });
  if (!camera.device || camera.device.status === "setup_pending") return [gate({ id: "commission_camera", label: "Set the camera up", primary: true })];
  if (camera.device.status === "offline") return [gate({ id: "test_device", label: "Test the connection", primary: true })];
  if (camera.device.status === "updating") return [];
  const out = [];
  if (camera.privacy) out.push(gate({ id: "privacy_off", label: "Open the shutter", primary: true }));
  else out.push(gate({ id: "clip_now", label: "Record a clip", primary: true }));
  if (camera.talk) out.push(gate({ id: "talk", label: "Talk" }));
  if (camera.floodlight) out.push(gate({ id: "floodlight", label: camera.floodlightOn ? "Floodlight off" : "Floodlight on" }));
  if (!camera.privacy) out.push(gate({ id: "privacy_on", label: "Close the shutter" }));
  return out;
}

function jobTransitions(job, viewer) {
  const may = ROLE_CAN_SPEND[viewer.role];
  const gate = (t) => Object.assign({}, t, may ? {} : { disabled: true, disabledReason: "Committing money is the owner's or another adult's decision. You are signed in as " + labelOf(HOUSEHOLD_ROLES, viewer.role).toLowerCase() + "." });
  if (job.status === "planned") return [gate({ id: "request_quote", label: "Ask for a quote", primary: true }), gate({ id: "book", label: "Book a visit" }), { id: "cancel_job", label: "Cancel job" }];
  if (job.status === "quoted") return [gate({ id: "accept_quote", label: "Accept quote and book", primary: true }), { id: "cancel_job", label: "Cancel job" }];
  if (job.status === "scheduled") return [{ id: "start_job", label: "Mark as started", primary: true }, gate({ id: "reschedule", label: "Move the visit" }), { id: "cancel_job", label: "Cancel job" }];
  if (job.status === "in_progress") return [{ id: "complete_job", label: "Mark as done", primary: true }];
  return [];
}

function billTransitions(bill, viewer) {
  const may = ROLE_CAN_SPEND[viewer.role];
  const gate = (t) => Object.assign({}, t, may ? {} : { disabled: true, disabledReason: "Paying a bill is the owner's or another adult's decision. You are signed in as " + labelOf(HOUSEHOLD_ROLES, viewer.role).toLowerCase() + "." });
  if (bill.status === "due") return [gate({ id: "pay", label: "Mark as paid", primary: true }), gate({ id: "dispute", label: "Query the bill" })];
  if (bill.status === "estimated") return [gate({ id: "confirm_reading", label: "Send a meter reading", primary: true })];
  if (bill.status === "disputed") return [gate({ id: "resolve_dispute", label: "Close the query", primary: true })];
  return [];
}

function accessTransitions(grant, viewer) {
  const may = ROLE_CAN_GRANT_ACCESS[viewer.role];
  const gate = (t) => Object.assign({}, t, may ? {} : { disabled: true, disabledReason: "Only the owner hands out access. You are signed in as " + labelOf(HOUSEHOLD_ROLES, viewer.role).toLowerCase() + "." });
  if (grant.status === "active") return [gate({ id: "suspend_access", label: "Suspend access", primary: true }), gate({ id: "revoke_access", label: "Revoke access", destructive: true })];
  if (grant.status === "suspended") return [gate({ id: "restore_access", label: "Restore access", primary: true }), gate({ id: "revoke_access", label: "Revoke access", destructive: true })];
  if (grant.status === "scheduled") return [gate({ id: "revoke_access", label: "Revoke access", destructive: true })];
  return [];
}

function routineTransitions(routine, devices, viewer) {
  const may = ROLE_CAN_EDIT_ROUTINES[viewer.role];
  const gate = (t) => Object.assign({}, t, may ? {} : { disabled: true, disabledReason: "Routines are changed by an adult in the household. You are signed in as " + labelOf(HOUSEHOLD_ROLES, viewer.role).toLowerCase() + "." });
  if (routine.status === "draft") return [gate({ id: "finish_routine", label: "Finish routine", primary: true }), gate({ id: "delete_routine", label: "Delete routine", destructive: true })];
  if (ROUTINE_FLAGS.broken(routine, devices)) return [gate({ id: "fix_routine", label: "Fix routine", primary: true }), gate({ id: "pause_routine", label: "Pause routine" })];
  if (routine.status === "paused") return [gate({ id: "resume_routine", label: "Resume routine", primary: true }), gate({ id: "delete_routine", label: "Delete routine", destructive: true })];
  return [gate({ id: "run_now", label: "Run now", primary: true }), gate({ id: "pause_routine", label: "Pause routine" }), gate({ id: "delete_routine", label: "Delete routine", destructive: true })];
}

function memberTransitions(member, viewer) {
  const may = viewer.role === "owner";
  const gate = (t) => Object.assign({}, t, may ? {} : { disabled: true, disabledReason: "Only the owner changes somebody's standing in the household." });
  if (member.id === viewer.id) return [];
  if (member.status === "invited") return [gate({ id: "resend_invite", label: "Resend invitation", primary: true }), gate({ id: "remove_member", label: "Remove from household", destructive: true })];
  if (member.status === "active") return [gate({ id: "suspend_member", label: "Suspend access" }), gate({ id: "remove_member", label: "Remove from household", destructive: true })];
  if (member.status === "suspended") return [gate({ id: "restore_member", label: "Restore access", primary: true }), gate({ id: "remove_member", label: "Remove from household", destructive: true })];
  return [];
}

function requestTransitions(request, viewer) {
  if (request.status !== "pending") return [];
  const may = viewer.role === "owner" || viewer.role === "adult";
  const gate = (t) => Object.assign({}, t, may ? {} : { disabled: true, disabledReason: "An adult in the household answers a request." });
  return [gate({ id: "grant", label: "Grant", primary: true }), gate({ id: "deny", label: "Deny" })];
}

/* Registry — what a status field means, for the reader of this file. */
const STATUS_FIELDS = [
  { entity: "device", field: "status", means: "whether the house can rely on it", values: DEVICE_STATUS },
  { entity: "zone", field: "status", means: "what a heating zone has been told to do", values: ZONE_STATUS },
  { entity: "house", field: "alarm", means: "what the alarm is set to", values: ALARM_STATUS },
  { entity: "entry", field: "status", means: "how a door, window or gate is reported", values: ENTRY_STATUS },
  { entity: "job", field: "status", means: "where a piece of work has got to", values: JOB_STATUS },
  { entity: "bill", field: "status", means: "whether money is owed for it", values: BILL_STATUS },
  { entity: "grant", field: "status", means: "whether a way in still works", values: ACCESS_STATUS },
  { entity: "member", field: "status", means: "somebody's standing in the household", values: MEMBER_STATUS },
  { entity: "routine", field: "status", means: "whether the house runs it", values: ROUTINE_STATUS },
  { entity: "request", field: "status", means: "whether somebody is owed an answer", values: REQUEST_STATUS },
  { entity: "link", field: "result", means: "what the last connection test came back with", values: TEST_STATUS },
];

/* Coverage — values with no seeded fixture: alarm.triggered and alarm.fault
   (both one arming away), zone.frost, member.removed, request.denied,
   routine.paused, bill.paid has fixtures. Each is reachable in the running
   product, so no screen claims a count for one that is not. */

Object.assign(window, {
  DEVICE_STATUS, ZONE_STATUS, ALARM_STATUS, ENTRY_STATUS, JOB_STATUS, BILL_STATUS, ACCESS_STATUS, MEMBER_STATUS, ROUTINE_STATUS, REQUEST_STATUS,
  TEST_STATUS, CLIP_KINDS, SIGNAL_BANDS, signalBand, VERDICT_TONES,
  ROOMS, ZONES, DEVICE_KINDS, ENTRY_KINDS, HOUSEHOLD_ROLES, ACCESS_KINDS, JOB_KINDS, TRADES, BILL_KINDS, EVENT_KINDS, TRIGGER_KINDS,
  HOUSE_POLICY, ROLE_CAN_UNLOCK, ROLE_CAN_ARM, ROLE_CAN_SPEND, ROLE_CAN_GRANT_ACCESS, ROLE_CAN_EDIT_ROUTINES, ROLE_CAN_WATCH, ROLE_CAN_PAIR,
  DAY_MS, HOUR_MS, MINUTE_MS, minutesOfDay, parseClock, isDark,
  DEVICE_FLAGS, ENTRY_FLAGS, ZONE_FLAGS, JOB_FLAGS, BILL_FLAGS, ACCESS_FLAGS, ROUTINE_FLAGS, REQUEST_FLAGS, MEMBER_FLAGS, CAMERA_FLAGS, LINK_FLAGS,
  deviceTransitions, cameraTransitions, jobTransitions, billTransitions, accessTransitions, routineTransitions, memberTransitions, requestTransitions,
  STATUS_FIELDS,
});
