// 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; a closed booking window AVAIL-N-007/008). 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. Booking windows (BOO-934) also gate amend
// (BKRUL-N-007) and cancel (BKRUL-N-008), which the booking detail checks with
// ppBookingWindowCheck.
//
// 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;
}

// An instant's wall-clock time on the Europe/London calendar, as a UTC ms value
// (the London wall time read as if it were UTC) — for comparing and printing.
const PP_LONDON_WALL = new Intl.DateTimeFormat('en-GB', {
  timeZone: 'Europe/London', year: 'numeric', month: '2-digit', day: '2-digit',
  hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
});
function ppLondonWall(ms) {
  const parts = PP_LONDON_WALL.formatToParts(new Date(ms));
  const get = (t) => Number((parts.find(p => p.type === t) || {}).value);
  return Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'));
}

// A London wall-clock date + time (YYYY-MM-DD, HH:mm) as an instant (epoch ms),
// so "hours before the reservation" is real elapsed time across a clock change.
// As upstream's LondonClock.ToUtc: an ambiguous autumn time is its first (BST)
// occurrence, and a time in the spring-forward gap is read an hour later.
// Null when either part is unreadable.
function ppLondonInstant(date, time) {
  const d = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(date || ''));
  const t = /^(\d{1,2}):(\d{2})/.exec(String(time || ''));
  if (!d || !t) return null;
  const wall = Date.UTC(+d[1], +d[2] - 1, +d[3], +t[1], +t[2]);
  for (const offsetHours of [1, 0]) {
    const ms = wall - offsetHours * 3600000;
    if (ppLondonWall(ms) === wall) return ms;
  }
  return wall; // in the gap: the same wall time an hour later, in BST
}

// "2026-10-20 20:00 Europe/London", as upstream words it in its messages.
function ppLondonStamp(ms) {
  return new Date(ppLondonWall(ms)).toISOString().slice(0, 16).replace('T', ' ') + ' Europe/London';
}

// "5 days (120 hours)" when it divides into days, else "30 hours".
const ppDescribeHours = (h) => (h % 24 === 0
  ? `${h / 24} day${h === 24 ? '' : 's'} (${h} hours)`
  : `${h} hour${h === 1 ? '' : 's'}`);

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())
//   now           epoch ms the booking windows are measured from (Date.now())

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);
// The largest single item's quantity, lines of the same item (kind + slug)
// added up — what a `perItem` quantity rule compares with its cap.
function ppMaxItemQty(lines) {
  const byItem = {};
  for (const l of lines) byItem[ppLineKey(l)] = (byItem[ppLineKey(l)] || 0) + (Number(l.quantity) || 0);
  return Math.max(0, ...Object.values(byItem));
}

// ── 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 operation (`moment`, one of `whenApplied`)?
// 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, moment = 'booking_create') {
  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(moment)) 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;
    }
    // perItem caps each item in the set on its own rather than their total.
    case 'max_quantity_party_size': {
      const inSet = set.empty ? lines : lines.filter(set.has);
      if (p.perItem) {
        const most = ppMaxItemQty(inSet);
        return most > party ? { message: own || `No single item's quantity (${most}) can exceed the party size (${party}).` } : null;
      }
      const total = ppSumQty(inSet);
      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 inSet = lines.filter(set.has);
      if (p.perItem) {
        return ppMaxItemQty(inSet) > max ? { message: own || `No more than ${max} of each item is allowed per booking.` } : null;
      }
      const total = ppSumQty(inSet);
      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
  }
}

// ── Booking windows (BOO-934) ────────────────────────────────────────────────
//
// booking_window is not decided rule by rule: when several match, the strictest
// bound wins, so they are evaluated together, the way upstream's
// EvaluateBookingWindows does.

// A rule's window, read as upstream's BookingWindowParams.Read: an unknown
// action never applies, and a bound that is absent or below 1 is no limit.
function ppWindowParams(rule) {
  const p = (rule && rule.params) || {};
  const action = String(p.action || '').trim().toLowerCase();
  if (!['book', 'amend', 'cancel'].includes(action)) return null;
  const hours = (v) => (Number.isInteger(v) && v >= 1 ? v : null);
  return { action, min: hours(p.minHoursBefore), max: action === 'book' ? hours(p.maxHoursBefore) : null };
}

// The readable windows among `rules`, as [{ rule, w }].
function ppWindows(rules) {
  return (rules || []).map(rule => ({ rule, w: ppWindowParams(rule) })).filter(x => x.w);
}

// The window that binds hardest for one bound of one action: the largest
// minHoursBefore, or for `max` the smallest maxHoursBefore. Null when none sets it.
function ppStrictestWindow(windows, action, key) {
  const beats = key === 'max' ? (a, b) => a < b : (a, b) => a > b;
  return windows
    .filter(x => x.w.action === action && x.w[key] != null)
    .reduce((best, x) => (!best || beats(x.w[key], best.w[key]) ? x : best), null);
}

// `rules`: the booking_window rules that apply. `snap`: { now, start,
// storedStart, isCreate, isCancel, confirmed }, the instants in epoch ms.
// Exactly minHoursBefore away is allowed. Amend and cancel windows gate
// confirmed bookings only and are measured from the STORED start; the book
// window is measured from the requested start, on an amend only when it moves.
// Returns [{ rule, code, message }], one per breached bound.
function ppWindowViolations(rules, snap) {
  const windows = ppWindows(rules);
  const now = snap.now;
  const out = [];
  if (!windows.length || now == null) return out;
  const strictest = (action, key) => ppStrictestWindow(windows, action, key);
  const checkMin = (action, start, code, what) => {
    const hit = strictest(action, 'min');
    if (!hit) return;
    const closesAt = start - hit.w.min * 3600000;
    if (now <= closesAt) return;
    out.push({ rule: hit.rule, code, message: hit.rule.message
      || `${what} close ${ppDescribeHours(hit.w.min)} before the reservation; they closed at ${ppLondonStamp(closesAt)}.` });
  };

  if (snap.isCancel) {
    if (snap.confirmed && snap.storedStart != null) checkMin('cancel', snap.storedStart, 'BKRUL-N-008', 'Cancellations');
    return out;
  }
  if (!snap.isCreate && snap.confirmed && snap.storedStart != null) {
    checkMin('amend', snap.storedStart, 'BKRUL-N-007', 'Amendments');
  }
  if ((snap.isCreate || snap.storedStart !== snap.start) && snap.start != null) {
    checkMin('book', snap.start, 'AVAIL-N-007', 'Bookings');
    const hit = strictest('book', 'max');
    if (hit && snap.start - now > hit.w.max * 3600000) {
      out.push({ rule: hit.rule, code: 'AVAIL-N-008', message: hit.rule.message
        || `Bookings open ${ppDescribeHours(hit.w.max)} before the reservation; they open at ${ppLondonStamp(snap.start - hit.w.max * 3600000)}.` });
    }
  }
  return out;
}

// The window pre-check for an existing booking, for the booking detail:
// `action` 'cancel', or 'amend' with `next` the { date, time } it would move to
// (omitted: it stays put). `booking`: { compositeId, productSlug, partySize,
// date, time, status }. Returns ppWindowViolations' list, empty when nothing
// blocks or there are no rules to check.
function ppBookingWindowCheck(response, booking, action, next, now) {
  if (!response || !Array.isArray(response.rules) || !booking) return [];
  const rules = ppBookingWindowRules(response, booking, action === 'cancel' ? 'booking_cancel' : 'booking_amend');
  const to = next || booking;
  return ppWindowViolations(rules, {
    now: now != null ? now : Date.now(),
    start: ppLondonInstant(to.date, to.time),
    storedStart: ppLondonInstant(booking.date, booking.time),
    isCancel: action === 'cancel',
    confirmed: booking.status === 'confirmed',
  });
}

// The booking_window rules in `response` that concern `booking` at `moment`.
function ppBookingWindowRules(response, booking, moment) {
  return response.rules.filter(r => r && r.effect === 'booking_window' && ppRuleApplies(r, booking, moment));
}

// When cancelling `booking` closes (BOO-1137): its stored London start less the
// strictest cancel window's minHoursBefore, the instant after which
// ppBookingWindowCheck(…, 'cancel') refuses. Same booking shape as there.
// Returns { at (epoch ms), hours, rule }, or null when no window gates it: not
// confirmed (cancel windows gate confirmed bookings only), no cancel window
// applies, or the start is unreadable.
function ppCancelCutoff(response, booking) {
  if (!response || !Array.isArray(response.rules) || !booking || booking.status !== 'confirmed') return null;
  const start = ppLondonInstant(booking.date, booking.time);
  const hit = start == null ? null
    : ppStrictestWindow(ppWindows(ppBookingWindowRules(response, booking, 'booking_cancel')), 'cancel', 'min');
  return hit ? { at: start - hit.w.min * 3600000, hours: hit.w.min, rule: hit.rule } : null;
}

// ── 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 },
    // The composite {operatorId}|{rmsSlug}|{venueId}, as the server builds it:
    // a raw venue id never matches a rule.
    venue: { id: segs[2] ? segs.slice(0, 3).join('|') : 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',
  });

  // A closed window comes first, as upstream ranks it: it refuses the booking
  // whatever else it carries.
  const windows = ppWindowViolations(applicable.filter(r => r.effect === 'booking_window'), {
    now: draft.now != null ? draft.now : Date.now(),
    start: ppLondonInstant(draft.date, draft.time),
    isCreate: true,
  });
  for (const w of windows) violations.push({ ...violation(w.rule, w.message), code: w.code });

  for (const rule of applicable) {
    if (rule.effect === 'expression' || rule.effect === 'booking_window') 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 });
      };
      // The other chosen lines that share the cap with this item: the whole set,
      // or with perItem only other lines of this same item.
      const othersIn = (inSet) => ppSumQty(inSet.filter(l =>
        !(l.kind === c.kind && String(l.id) === String(c.id)) && (!p.perItem || ppLineKey(l) === ppLineKey(c))));
      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 = othersIn(set.empty ? lines : lines.filter(set.has));
          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 = othersIn(lines.filter(set.has));
          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, ppBookingWindowCheck, ppCancelCutoff });
}
