// Operator booking rules (BOO-815), evaluated in the browser before a booking
// is submitted.
//
// Bookable's GET /booking-rules (Partner Booking Rules API, BOO-699) returns
// every active rule that applies to the caller. bookings-api enforces the same
// rules on POST /venues/{compositeId}/booking and answers a breach with a 400
// (BKRUL-N-001…006, BKRUL-N-100). This file mirrors that evaluation so the
// drawer can stop an invalid selection up front: grey out the rest of a
// same_package set, cap a stepper, say a booking will go in as an enquiry.
//
// It is a pre-check and nothing more. The booking endpoint is authoritative —
// a rule changed since the drawer opened, or a fact the portal cannot know (a
// default company the operator fills in, a duration the platform derives),
// still comes back as a BKRUL 400, which the drawer shows as such.
//
// Semantics follow the API description exactly, including its boundaries:
// preorder_required is `partySize >= threshold`, the lead-time windows are
// `daysAhead < leadTimeDays`, max_quantity* allow equality, require_deposit has
// half a penny of tolerance. The scenario blocks of that description are
// replayed in tests/booking-rules.test.js.
//
// No JSX and no React in here: the test lifts the whole file out of its source
// text, the same way tests/preorder-payload.test.js lifts a single function.

const PP_RULE_DEFAULT_MESSAGES = {
  same_package_exclusive: 'If this package is chosen, everyone must have the same one and nothing else can be added.',
  same_package_mixed: 'Everyone in the party must choose the same item from this set — selections cannot be mixed.',
};

// Today's date (YYYY-MM-DD) on the Europe/London calendar — the calendar
// `booking.daysAhead` is counted in, whatever the agent's own time zone.
function ppLondonToday(now) {
  const parts = new Intl.DateTimeFormat('en-GB', {
    timeZone: 'Europe/London', year: 'numeric', month: '2-digit', day: '2-digit',
  }).formatToParts(now || new Date());
  const get = (t) => (parts.find(p => p.type === t) || {}).value;
  return `${get('year')}-${get('month')}-${get('day')}`;
}

// Whole calendar days from `today` to `date`, both YYYY-MM-DD; negative when past.
function ppDaysAhead(date, today) {
  const toDay = (s) => {
    const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(s || ''));
    return m ? Date.UTC(+m[1], +m[2] - 1, +m[3]) / 86400000 : NaN;
  };
  const d = toDay(date) - toDay(today);
  return Number.isFinite(d) ? d : null;
}

const ppMoney2 = (n) => '£' + Number(n || 0).toFixed(2);

// ── Draft booking ────────────────────────────────────────────────────────────
//
// The evaluator's input, built from what the drawer is about to POST:
//   compositeId   4-segment product composite id the booking goes to
//   productSlug   aggregation slug of that product (null when it has none)
//   partySize, date (YYYY-MM-DD), time (HH:mm), type ('book' | 'request')
//   notes, company, depositAmount, commission, duration, spaceId — as sent;
//                 absent when the portal does not send them
//   lines         pre-order lines: [{ kind: 'package' | 'menu', id, slug, quantity }]
//                 A line without a slug is invisible to every rule, exactly as
//                 upstream drops it from `booking.items`.
//   today         YYYY-MM-DD, Europe/London (ppLondonToday())

function ppRuleLines(draft) {
  return ((draft && draft.lines) || []).filter(l => l && l.slug && Number(l.quantity) > 0);
}

// The rule's item set S, as a predicate over a line. Packages match package
// lines and menus match menu lines, by aggregation slug.
function ppRuleSet(rule) {
  const items = (rule && rule.appliesTo && rule.appliesTo.items) || {};
  const slugs = (list) => (list || []).map(x => x && x.slug).filter(Boolean);
  const packages = slugs(items.packages);
  const menus = slugs(items.menus);
  return {
    empty: !packages.length && !menus.length,
    has: (line) => (line.kind === 'menu' ? menus : packages).includes(line.slug),
  };
}

const ppLineKey = (line) => line.kind + ':' + line.slug;
const ppSumQty = (lines) => lines.reduce((n, l) => n + (Number(l.quantity) || 0), 0);

// ── Scope ────────────────────────────────────────────────────────────────────

// The request fields `conditions[]` can read on create. A field the booking
// does not carry is undefined, and an undefined field never matches.
function ppConditionFields(draft) {
  const has = (v) => typeof v === 'string' && v.trim() !== '';
  return {
    partySize: draft.partySize,
    duration: draft.duration,
    spaceId: draft.spaceId,
    isCrossSale: !!draft.isCrossSale,
    commission: draft.commission,
    depositAmount: draft.depositAmount,
    hasCustomerCompany: has(draft.company),
    hasNotes: has(draft.notes),
  };
}

function ppConditionHolds(cond, fields) {
  const v = fields[cond.field];
  if (v === undefined || v === null) return false;
  const want = cond.value;
  switch (cond.operator) {
    case 'equals': return typeof v === 'number' && typeof want === 'number' ? v === want : v === want;
    case 'not_equals': return v !== want;
    case 'gte': return typeof v === 'number' && typeof want === 'number' && v >= want;
    case 'lte': return typeof v === 'number' && typeof want === 'number' && v <= want;
    case 'in': return Array.isArray(want) && want.includes(v);
    case 'contains': return Array.isArray(v) && v.includes(want);
    default: return false;
  }
}

// Does `rule` concern this create? Operator, venue, product, conditions and
// operation. Partner scope is already applied by the server (a rule aimed at
// another partner is never returned).
function ppRuleApplies(rule, draft) {
  if (!rule || !draft || !draft.compositeId) return false;
  const segs = String(draft.compositeId).split('|');
  if (String(rule.operatorId) !== segs[0]) return false;
  const scope = rule.appliesTo || {};
  if (!scope.allVenues) {
    const venueId = segs.slice(0, 3).join('|');
    if (!(scope.venues || []).some(v => v && v.id === venueId)) return false;
  }
  if (!scope.allProducts) {
    if (!draft.productSlug) return false;
    if (!(scope.products || []).some(p => p && p.slug === draft.productSlug)) return false;
  }
  if (!(rule.whenApplied || []).includes('booking_create')) return false;
  if (rule.effect === 'expression' && rule.hook !== 'booking.create') return false;
  const fields = ppConditionFields(draft);
  return (rule.conditions || []).every(c => ppConditionHolds(c, fields));
}

// ── Fixed effects ────────────────────────────────────────────────────────────
//
// Each returns null (no breach) or { message }, plus `enquiry: true` for
// route_to_enquiry and `mix: true` for a same_package mixing breach (as opposed
// to its wholeGroup quantity check). `ctx` carries the draft's lines and daysAhead.

function ppFixedEffect(rule, draft, ctx) {
  const p = rule.params || {};
  const own = rule.message || null;
  const set = ppRuleSet(rule);
  const lines = ctx.lines;
  const party = Number(draft.partySize) || 0;
  switch (rule.effect) {
    case 'same_package': {
      const inSet = lines.filter(set.has);
      if (!inSet.length) return null;
      const exclusive = p.exclusive !== false;
      const distinct = new Set(inSet.map(ppLineKey));
      if (distinct.size > 1 || (exclusive && inSet.length < lines.length)) {
        return { mix: true, message: own || (exclusive ? PP_RULE_DEFAULT_MESSAGES.same_package_exclusive : PP_RULE_DEFAULT_MESSAGES.same_package_mixed) };
      }
      if (p.wholeGroup && ppSumQty(inSet) !== party) {
        return { message: own || `Once chosen, this item's quantity must equal the party size (${party}).` };
      }
      return null;
    }
    case 'max_quantity_party_size': {
      const total = ppSumQty(set.empty ? lines : lines.filter(set.has));
      return total > party ? { message: own || `The total quantity of these items (${total}) cannot exceed the party size (${party}).` } : null;
    }
    case 'max_quantity': {
      const max = Number(p.max);
      if (!Number.isFinite(max)) return null;
      const total = ppSumQty(lines.filter(set.has));
      return total > max ? { message: own || `No more than ${max} of this item is allowed per booking.` } : null;
    }
    case 'preorder_required': {
      const threshold = Number.isFinite(Number(p.threshold)) ? Number(p.threshold) : 1;
      if (party < threshold) return null;
      const qualifies = set.empty ? lines.length > 0 : lines.some(set.has);
      return qualifies ? null : { message: own || `Groups of ${threshold} or more must include a pre-order.` };
    }
    case 'route_to_enquiry': {
      const lead = Number(p.leadTimeDays) || 0;
      return ctx.daysAhead != null && ctx.daysAhead < lead ? { enquiry: true } : null;
    }
    case 'lock_preorder_edits': {
      const lead = Number(p.leadTimeDays) || 0;
      if (ctx.daysAhead == null || ctx.daysAhead >= lead || !lines.length) return null;
      return { message: own || `Pre-order changes are not permitted less than ${lead} days before the booking date.` };
    }
    case 'require_deposit': {
      const amount = Number(p.amount);
      if (!Number.isFinite(amount) || amount <= 0) return null; // no usable amount never blocks
      const perBooking = p.per === 'booking';
      const required = perBooking ? amount : amount * party;
      const collected = Number(draft.depositAmount) || 0;
      if (collected + 0.005 >= required) return null;
      return {
        message: own || `A deposit of ${ppMoney2(required)} is required for this booking (${ppMoney2(amount)} per ${perBooking ? 'booking' : 'person'}).`,
        required,
      };
    }
    default:
      return null; // an effect this build does not know: the server still checks it
  }
}

// ── Expression language ──────────────────────────────────────────────────────
//
// JsonLogic as the platform evaluates it, which is stricter than the reference
// JsonLogic: no truthiness, no coercion, and anything it cannot evaluate SKIPS
// the rule rather than failing the booking. A skip is thrown as PP_SKIP and
// caught per rule.

const PP_SKIP = { skip: true };
const PP_EXPR_LIMITS = { depth: 12, nodes: 96, ops: 2000, list: 200, actions: 8 };

function ppKind(v) {
  if (v === null || v === undefined) return 'null';
  if (Array.isArray(v)) return 'list';
  return typeof v === 'object' ? 'object' : typeof v; // number | string | boolean
}

function ppSameValue(a, b) {
  const ka = ppKind(a);
  if (ka !== ppKind(b)) return false;
  if (ka === 'list' || ka === 'object') return JSON.stringify(a) === JSON.stringify(b);
  return ka === 'null' || a === b;
}

function ppCountNodes(node) {
  if (Array.isArray(node)) return 1 + node.reduce((n, x) => n + ppCountNodes(x), 0);
  if (node && typeof node === 'object') return 1 + Object.values(node).reduce((n, x) => n + ppCountNodes(x), 0);
  return 1;
}

function ppReadPath(data, path) {
  if (path === '') return data;
  let cur = data;
  for (const seg of String(path).split('.')) {
    if (cur === null || cur === undefined) return null;
    if (Array.isArray(cur)) cur = /^\d+$/.test(seg) ? cur[Number(seg)] : undefined;
    else if (typeof cur === 'object') cur = cur[seg];
    else return null;
  }
  return cur === undefined ? null : cur;
}

function ppEvalExpr(node, data, state, depth) {
  if (depth > PP_EXPR_LIMITS.depth || ++state.ops > PP_EXPR_LIMITS.ops) throw PP_SKIP;
  if (Array.isArray(node)) return node.map(x => ppEvalExpr(x, data, state, depth + 1));
  if (node === null || typeof node !== 'object') return node;
  const keys = Object.keys(node);
  if (keys.length !== 1) throw PP_SKIP; // an object literal cannot be written
  const op = keys[0];
  const raw = node[op];
  const rawArgs = Array.isArray(raw) ? raw : [raw];
  const ev = (x) => ppEvalExpr(x, data, state, depth + 1);
  const num = (v) => { if (typeof v !== 'number' || !Number.isFinite(v)) throw PP_SKIP; return v; };
  const bool = (v) => { if (typeof v !== 'boolean') throw PP_SKIP; return v; };
  const list = (v) => { if (!Array.isArray(v) || v.length > PP_EXPR_LIMITS.list) throw PP_SKIP; return v; };

  switch (op) {
    case 'var': {
      const path = ev(rawArgs[0]);
      if (typeof path !== 'string' && typeof path !== 'number') throw PP_SKIP;
      const v = ppReadPath(data, String(path));
      if (v === null && rawArgs.length > 1) return ev(rawArgs[1]);
      return v;
    }
    case '==': case '!=': {
      const [a, b] = rawArgs.map(ev);
      if (ppKind(a) !== ppKind(b)) throw PP_SKIP; // a type clash skips, it is not false
      const same = ppSameValue(a, b);
      return op === '==' ? same : !same;
    }
    case '>': case '>=': case '<': case '<=': {
      const vals = rawArgs.map(ev);
      if (vals.length < 2 || vals.length > 3) throw PP_SKIP;
      const allNum = vals.every(v => typeof v === 'number');
      const allStr = vals.every(v => typeof v === 'string');
      if (!allNum && !allStr) throw PP_SKIP;
      const cmp = (a, b) => (op === '>' ? a > b : op === '>=' ? a >= b : op === '<' ? a < b : a <= b);
      for (let i = 0; i + 1 < vals.length; i++) if (!cmp(vals[i], vals[i + 1])) return false;
      return true;
    }
    case 'and': {
      for (const a of rawArgs) if (!bool(ev(a))) return false;
      return true;
    }
    case 'or': {
      for (const a of rawArgs) if (bool(ev(a))) return true;
      return false;
    }
    case '!':
      return !bool(ev(rawArgs[0]));
    case 'if': {
      let i = 0;
      for (; i + 1 < rawArgs.length; i += 2) if (bool(ev(rawArgs[i]))) return ev(rawArgs[i + 1]);
      return i < rawArgs.length ? ev(rawArgs[i]) : null;
    }
    case '+': return rawArgs.map(ev).map(num).reduce((a, b) => a + b, 0);
    case '*': return rawArgs.map(ev).map(num).reduce((a, b) => a * b, 1);
    case '-': {
      const v = rawArgs.map(ev).map(num);
      if (v.length === 1) return -v[0];
      if (v.length !== 2) throw PP_SKIP;
      return v[0] - v[1];
    }
    case '/': case '%': {
      const v = rawArgs.map(ev).map(num);
      if (v.length !== 2 || v[1] === 0) throw PP_SKIP;
      return op === '/' ? v[0] / v[1] : v[0] % v[1];
    }
    case 'min': case 'max': {
      const v = rawArgs.map(ev).map(num);
      if (!v.length) throw PP_SKIP;
      return op === 'min' ? Math.min(...v) : Math.max(...v);
    }
    case 'in': {
      const needle = ev(rawArgs[0]);
      const hay = ev(rawArgs[1]);
      if (Array.isArray(hay)) return hay.some(x => ppSameValue(x, needle));
      if (typeof hay === 'string') {
        if (typeof needle !== 'string') throw PP_SKIP;
        return hay.includes(needle);
      }
      throw PP_SKIP;
    }
    case 'filter': case 'map': case 'some': case 'all': case 'none': {
      const items = list(ev(rawArgs[0]));
      const pred = rawArgs[1];
      // Inside the predicate `var` reads the current element; the rest of the
      // facts are out of reach.
      const each = (x) => ppEvalExpr(pred, x, state, depth + 1);
      if (op === 'map') return items.map(each);
      const results = items.map(x => bool(each(x)));
      if (op === 'filter') return items.filter((_, i) => results[i]);
      if (op === 'some') return results.some(Boolean);
      if (op === 'all') return results.every(Boolean);
      return !results.some(Boolean);
    }
    case 'count': return list(ev(rawArgs[0])).length;
    case 'sum': return list(ev(rawArgs[0])).map(num).reduce((a, b) => a + b, 0);
    case 'distinct': {
      const out = [];
      for (const x of list(ev(rawArgs[0]))) if (!out.some(y => ppSameValue(x, y))) out.push(x);
      return out;
    }
    case 'pluck': {
      const items = list(ev(rawArgs[0]));
      const field = rawArgs[1];
      if (typeof field !== 'string') throw PP_SKIP;
      return items.map(x => (x && typeof x === 'object' && !Array.isArray(x) && x[field] !== undefined ? x[field] : null));
    }
    default:
      throw PP_SKIP; // unknown operator
  }
}

// One expression rule against the facts. Returns null when it does not apply
// (when false, or skipped), else { rejects: [message], writes: [{path, value}], flags }.
// Atomic: if any action cannot be carried out, the whole rule is skipped.
function ppRunExpressionRule(rule, facts, writable) {
  try {
    if (ppCountNodes(rule.when) > PP_EXPR_LIMITS.nodes) throw PP_SKIP;
    const state = { ops: 0 };
    const when = ppEvalExpr(rule.when, facts, state, 0);
    if (when !== true) {
      if (typeof when !== 'boolean') throw PP_SKIP;
      return null;
    }
    const actions = Array.isArray(rule.then) ? rule.then : [];
    if (!actions.length || actions.length > PP_EXPR_LIMITS.actions) throw PP_SKIP;
    const out = { rejects: [], writes: [], flags: [] };
    for (const action of actions) {
      const keys = action && typeof action === 'object' ? Object.keys(action) : [];
      if (keys.length !== 1) throw PP_SKIP;
      const body = action[keys[0]] || {};
      if (keys[0] === 'reject') {
        out.rejects.push(body.message || rule.message || `Rule '${rule.name}' rejected the booking.`);
      } else if (keys[0] === 'flag') {
        if (typeof body.name !== 'string') throw PP_SKIP;
        out.flags.push(body.name);
      } else if (keys[0] === 'set') {
        const target = writable.find(w => w.path === body.path);
        if (!target) throw PP_SKIP; // not writable at this hook
        const value = ppEvalExpr(body.value, facts, state, 0);
        // A value outside allowedValues is ignored by the platform, not an error.
        if (!(target.allowedValues || []).length || target.allowedValues.includes(value)) {
          out.writes.push({ path: body.path, value });
        }
      } else {
        throw PP_SKIP;
      }
    }
    return out;
  } catch (e) {
    if (e === PP_SKIP) return null;
    throw e;
  }
}

// The booking.create fact document, per `hooks[].facts[].howToObtain`.
// The portal sends no duration, commission, company, deposit or space, so
// those facts are null (a rule comparing them is skipped) — the server may know
// better (it derives a duration and fills a default company before its rules
// run), which is one more reason this is only a pre-check.
function ppCreateFacts(draft, caller, ctx) {
  const segs = String(draft.compositeId || '').split('|');
  const items = ctx.lines.map(l => ({ kind: l.kind, slug: l.slug, quantity: Number(l.quantity) }));
  const blank = (v) => typeof v !== 'string' || v.trim() === '';
  return {
    booking: {
      partySize: Number(draft.partySize) || 0,
      durationMinutes: typeof draft.duration === 'number' ? draft.duration : null,
      date: draft.date || null,
      time: draft.time || null,
      daysAhead: ctx.daysAhead,
      type: draft.type === 'request' ? 'request' : 'book',
      enquiryReason: 'none',
      isCreate: true,
      isCrossSale: !!draft.isCrossSale,
      commission: typeof draft.commission === 'number' ? draft.commission : null,
      depositAmount: typeof draft.depositAmount === 'number' ? draft.depositAmount : null,
      hasNotes: !blank(draft.notes),
      hasCustomerCompany: !blank(draft.company),
      items,
      itemCount: items.length,
      packageCount: items.filter(i => i.kind === 'package').length,
      menuCount: items.filter(i => i.kind === 'menu').length,
      originalItems: [],
      preorderChanged: items.length > 0,
    },
    partner: {
      id: caller && typeof caller.partnerId === 'number' ? caller.partnerId : null,
      categoryIds: (caller && Array.isArray(caller.partnerCategoryIds)) ? caller.partnerCategoryIds : [],
    },
    product: { slug: draft.productSlug || null },
    venue: { id: segs[2] || null, groupId: segs[0] && /^\d+$/.test(segs[0]) ? Number(segs[0]) : null },
    // The portal sends no spaceId today; `draft.space` is there for when it does.
    space: {
      id: (draft.space && draft.space.id) || null,
      tmsZoneId: (draft.space && draft.space.tmsZoneId) || null,
      name: (draft.space && draft.space.name) || null,
    },
  };
}

// ── Evaluation ───────────────────────────────────────────────────────────────
//
// ppEvaluateBookingRules(response, draft) → {
//   violations: [{ ruleId, ruleName, effect, code, message, blocking }],
//     every rule the booking would break. `blocking` is false for
//     require_deposit: the portal cannot collect a deposit, so it warns and
//     lets the agent submit (the server will still refuse it).
//   enquiry:    true when the booking will go in as an enquiry (type request)
//   enquiryRules: the rules that make it one
//   flags:      expression flags (annotations, no behaviour)
//   blocked:    any blocking violation
// }
function ppEvaluateBookingRules(response, draft) {
  const empty = { violations: [], enquiry: false, enquiryRules: [], flags: [], blocked: false };
  if (!response || !Array.isArray(response.rules) || !draft) return empty;
  const today = draft.today || ppLondonToday();
  const ctx = { lines: ppRuleLines(draft), daysAhead: ppDaysAhead(draft.date, today) };
  const applicable = response.rules.filter(r => ppRuleApplies(r, draft));
  const violations = [];
  const enquiryRules = [];
  const violation = (rule, message) => ({
    ruleId: rule.id, ruleName: rule.name, effect: rule.effect,
    code: rule.errorCode || null, message, blocking: rule.effect !== 'require_deposit',
  });

  for (const rule of applicable) {
    if (rule.effect === 'expression') continue;
    const hit = ppFixedEffect(rule, draft, ctx);
    if (!hit) continue;
    if (hit.enquiry) enquiryRules.push(rule);
    else violations.push(violation(rule, hit.message));
  }

  // Expression rules run only once every fixed rule has passed. A deposit
  // shortfall is left out of that gate so the agent sees the expression rules
  // too — the warning alone does not stop them submitting.
  const flags = [];
  let type = draft.type === 'request' || enquiryRules.length ? 'request' : 'book';
  if (!violations.some(v => v.blocking)) {
    const facts = ppCreateFacts(draft, response.caller, ctx);
    if (enquiryRules.length) {
      facts.booking.type = 'request';
      facts.booking.enquiryReason = 'operatorRule';
    }
    const hook = (response.hooks || []).find(h => h && h.name === 'booking.create');
    const writable = (hook && hook.writablePaths) || [];
    for (const rule of applicable) {
      if (rule.effect !== 'expression') continue;
      const out = ppRunExpressionRule(rule, facts, writable);
      if (!out) continue;
      for (const message of out.rejects) violations.push(violation(rule, message));
      for (const w of out.writes) {
        const [, key] = w.path.split('.');
        facts.booking[key] = w.value; // later rules read the writes made so far
        if (w.path === 'booking.type' && w.value === 'request' && !enquiryRules.includes(rule)) enquiryRules.push(rule);
      }
      flags.push(...out.flags);
    }
    type = facts.booking.type;
  }

  return {
    violations,
    enquiry: type === 'request' && draft.type !== 'request',
    enquiryRules: type === 'request' ? enquiryRules : [],
    flags,
    blocked: violations.some(v => v.blocking),
  };
}

// ── Per-item limits for the pre-order UI ─────────────────────────────────────
//
// What the catalogue should let the agent do NEXT, given the lines already
// chosen. `candidates` are the catalogue items on screen:
//   [{ kind: 'package' | 'menu', id, slug, quantity }]
// where `quantity` is the line the item would make (a menu goes out at one
// cover per guest). Returns { [kind + ':' + id]: { max, disabled, reason } }:
// `max` caps a stepper (Infinity when unlimited); `disabled` greys the item out
// with the rule's reason. An item already chosen is never disabled — it must
// stay reachable to be removed — and an item with no slug is never limited.
function ppRuleItemLimits(response, draft, candidates) {
  const out = {};
  for (const c of candidates || []) out[c.kind + ':' + c.id] = { max: Infinity, disabled: false, reason: null };
  if (!response || !Array.isArray(response.rules) || !draft) return out;
  const today = draft.today || ppLondonToday();
  const lines = ppRuleLines(draft);
  const daysAhead = ppDaysAhead(draft.date, today);
  const party = Number(draft.partySize) || 0;
  const chosenQty = (c) => {
    const l = lines.find(x => x.kind === c.kind && x.slug === c.slug && String(x.id) === String(c.id));
    return l ? Number(l.quantity) : 0;
  };
  const cap = (c, n, reason) => {
    const e = out[c.kind + ':' + c.id];
    if (n < e.max) e.max = Math.max(0, n);
    if (e.max <= chosenQty(c) && !e.reason) e.reason = reason;
  };
  const disable = (c, reason) => {
    const e = out[c.kind + ':' + c.id];
    if (chosenQty(c) > 0 || e.disabled) return;
    e.disabled = true;
    e.reason = reason;
  };

  for (const rule of response.rules) {
    if (rule.effect === 'expression' || !ppRuleApplies(rule, draft)) continue;
    const p = rule.params || {};
    const set = ppRuleSet(rule);
    const ctxMsg = (hit) => (hit && hit.message) || rule.message || rule.summary || rule.name;
    for (const c of candidates || []) {
      if (!c.slug) continue;
      // Would adding this item (one more of it) break the rule? Asked of the
      // effect itself, so the UI cannot drift from the evaluation above.
      const add = (qty) => {
        const next = lines.filter(l => !(l.kind === c.kind && String(l.id) === String(c.id)));
        next.push({ kind: c.kind, id: c.id, slug: c.slug, quantity: qty });
        return ppFixedEffect(rule, { ...draft, lines: next }, { lines: next, daysAhead });
      };
      switch (rule.effect) {
        case 'same_package': {
          // Only the mixing half greys items out; wholeGroup is a quantity the
          // agent reaches by stepping, so it is reported, not prevented.
          // Only grey out what would START a mix: once the lines already mix,
          // the violation is on screen and every item stays reachable to fix it.
          const now = ppFixedEffect(rule, draft, { lines, daysAhead });
          const mixing = add(chosenQty(c) || c.quantity || 1);
          if (mixing && mixing.mix && !(now && now.mix)) disable(c, ctxMsg(mixing));
          if (p.wholeGroup && set.has(c)) cap(c, party, ctxMsg(null));
          break;
        }
        case 'max_quantity_party_size': {
          if (!set.empty && !set.has(c)) break;
          const others = ppSumQty((set.empty ? lines : lines.filter(set.has)).filter(l => !(l.kind === c.kind && String(l.id) === String(c.id))));
          if (c.kind === 'menu') { if (!chosenQty(c) && others + (c.quantity || 1) > party) disable(c, ctxMsg(add(c.quantity || 1))); }
          else cap(c, party - others, ctxMsg(add(party - others + 1)));
          break;
        }
        case 'max_quantity': {
          if (!set.has(c) || !Number.isFinite(Number(p.max))) break;
          const others = ppSumQty(lines.filter(set.has).filter(l => !(l.kind === c.kind && String(l.id) === String(c.id))));
          const room = Number(p.max) - others;
          if (c.kind === 'menu') { if (!chosenQty(c) && (c.quantity || 1) > room) disable(c, ctxMsg(add(c.quantity || 1))); }
          else cap(c, room, ctxMsg(add(room + 1)));
          break;
        }
        case 'lock_preorder_edits': {
          const hit = add(c.quantity || 1);
          if (hit) disable(c, ctxMsg(hit));
          break;
        }
        default:
          break;
      }
    }
  }
  return out;
}

// Can the agent still fix a breach of `rule` on the guest-details step? Only
// the notes are editable there, so: a custom rule whose `when` reads
// booking.hasNotes. Every other breach is fixed on the pre-order step or not at
// all (party size, date, partner…), so the drawer holds the pre-order step on
// it rather than let the agent fill in guest details for a booking that cannot
// be confirmed.
function ppRuleFixableOnDetails(rule) {
  return !!rule && rule.effect === 'expression' && /"booking\.hasNotes"/.test(JSON.stringify(rule.when || null));
}

if (typeof window !== 'undefined') {
  Object.assign(window, { ppLondonToday, ppEvaluateBookingRules, ppRuleItemLimits, ppRuleFixableOnDetails });
}
