// render-watches.js — Watch rendering and CRUD functions for the dashboard

// Pull watches.json straight off disk through /state/engine/watches.json
// instead of waiting on the /api/status outer cache (issue #2949). The
// engine writes watch status / _history transitions directly into
// watches.json, so mtime+size ETag surfaces them on the next 4 s poll.
// Falls back to data.watches when /state/ returns null.
async function fetchWatchesFromDisk() {
  try {
    const r = await fetch('/state/engine/watches.json');
    if (!r.ok) return null;
    const arr = await r.json();
    return Array.isArray(arr) ? arr : null;
  } catch {
    return null;
  }
}

// ─── Helpers ────────────────────────────────────────────────────────────────

const _WATCH_STATUS_BADGES = {
  active: '<span class="pr-badge approved">active</span>',
  paused: '<span class="pr-badge rejected">paused</span>',
  triggered: '<span class="pr-badge" style="background:rgba(210,153,34,0.15);color:var(--yellow);border-color:var(--yellow)">triggered</span>',
  expired: '<span class="pr-badge" style="background:rgba(139,148,158,0.15);color:var(--muted);border-color:var(--muted)">expired</span>',
};

// F7: ids whose pause/resume POST is in flight — used to render the toggle
// button as `disabled` so a second click can't race the first request.
const _watchToggleInFlight = new Set();

// Cache of target types fetched from /api/watches/target-types. Populated on
// first modal open and refreshed per modal show. Each entry:
//   { value, label, conditions: [...], description }
let _watchTargetTypesCache = null;

// Cache of action types fetched from /api/watches/action-types. Populated on
// first modal open. Each entry:
//   { value, label, description, params: [{ key, required, description }] }
let _watchActionTypesCache = null;

// Built-in fallback labels — used when the API response hasn't loaded yet, or
// for legacy targetTypes/conditions that may appear on stored watches but not
// in the live registry. The registry from the API is authoritative when present.
const _WATCH_TARGET_LABELS = {
  pr: 'Pull Request',
  'work-item': 'Work Item',
  meeting: 'Meeting',
  plan: 'Plan / PRD',
  schedule: 'Schedule',
  pipeline: 'Pipeline',
  dispatch: 'Dispatch',
  agent: 'Agent',
};

const _WATCH_CONDITION_LABELS = {
  merged: 'Merged',
  'build-fail': 'Build Fail',
  'build-pass': 'Build Pass',
  completed: 'Completed',
  failed: 'Failed',
  'status-change': 'Status Change',
  any: 'Any Change',
  'new-comments': 'New Comments',
  'vote-change': 'Vote Change',
  concluded: 'Concluded',
  approved: 'Approved',
  rejected: 'Rejected',
  'stage-complete': 'Stage Complete',
  ran: 'Ran',
  enabled: 'Enabled',
  disabled: 'Disabled',
  'activity-change': 'Activity Change',
  // P-w4e2f6a1 — Phase 2.1: PR predicate conditions
  'head-commit-change': 'Head Commit Changed',
  'mergeable-flipped': 'Mergeable Flipped',
  'ready-for-merge': 'Ready For Merge',
  'behind-master': 'Behind Master',
  'draft-flipped': 'Draft Flipped',
  // P-w5b8d2c9 — Phase 2.2: work-item / plan / pipeline predicate conditions
  stalled: 'Stalled',
  'retry-limit-reached': 'Retry Limit Reached',
  'dependency-met': 'Dependency Met',
  'all-items-done': 'All Items Done',
  'item-failed-n-times': 'Item Failed N Times',
  'stage-advanced': 'Stage Advanced',
  'stuck-in-stage': 'Stuck In Stage',
};

function _conditionLabel(cond) {
  if (_WATCH_CONDITION_LABELS[cond]) return _WATCH_CONDITION_LABELS[cond];
  // Title-case unknown conditions for graceful fallback
  return String(cond || '').replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}

function _targetTypeLabel(type) {
  if (_watchTargetTypesCache) {
    const hit = _watchTargetTypesCache.find(t => t.value === type);
    if (hit) return hit.label;
  }
  return _WATCH_TARGET_LABELS[type] || (type || '');
}

// W-mq1j5f9z00030b8f — `teams-channel` target is `{teamId, channelId}` (object);
// other targetTypes use strings. Avoid `String(target)` → "[object Object]";
// route all target rendering here so future object-target plugins inherit safety.
function _formatWatchTarget(target, targetType) {
  if (target == null || target === '') return '';
  if (typeof target === 'string') return target;
  if (typeof target !== 'object') return String(target);
  if (targetType === 'teams-channel'
      && typeof target.teamId === 'string' && target.teamId
      && typeof target.channelId === 'string' && target.channelId) {
    var team = target.teamId.slice(0, 8) + '…';
    var chPart = target.channelId.split(':')[1] || target.channelId;
    var ch = chPart.slice(0, 12) + '…';
    return 'Team ' + team + ' > Channel ' + ch;
  }
  var json;
  try { json = JSON.stringify(target); } catch (_) { return '[unserializable target]'; }
  if (json == null) return '';
  return json.length > 60 ? json.slice(0, 57) + '…' : json;
}

// W-mq1j5f9z00030b8f — title fallback chain: title → description → formatted target → "(no target)".
function _watchTitleFallback(w) {
  if (!w) return '(no target)';
  if (w.title) return w.title;
  if (w.description) return w.description;
  var formatted = _formatWatchTarget(w.target, w.targetType);
  return formatted || '(no target)';
}

function _intervalToHuman(ms) {
  if (!ms) return 'default';
  const sec = Math.floor(ms / 1000);
  if (sec < 60) return sec + 's';
  const min = Math.floor(sec / 60);
  if (min < 60) return min + 'm';
  return Math.floor(min / 60) + 'h ' + (min % 60) + 'm';
}

// Parse human-friendly interval strings: "15m", "2h", "30s", "90000" (ms)
function _parseIntervalStr(s) {
  if (!s) return 300000;
  s = String(s).trim().toLowerCase();
  if (/^\d+$/.test(s)) {
    const n = parseInt(s, 10);
    return n >= 1000 ? n : n * 1000; // bare numbers: ≥1000 treated as ms, else seconds
  }
  const match = s.match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|hours?)$/);
  if (!match) return 300000;
  const n = parseFloat(match[1]);
  const unit = match[2][0];
  if (unit === 's') return Math.round(n * 1000);
  if (unit === 'm') return Math.round(n * 60000);
  if (unit === 'h') return Math.round(n * 3600000);
  return 300000;
}

// ─── Rendering ──────────────────────────────────────────────────────────────

let _watchPage = 0;
const WATCH_PER_PAGE = 15;

function renderWatches(watchesData, opts) {
  opts = opts || {};
  var watches = (watchesData || []).filter(function(w) { return !isDeleted('watch:' + w.id); });
  var el = document.getElementById('watches-content');
  var countEl = document.getElementById('watches-count');
  if (!el) return;
  if (countEl) countEl.textContent = watches.length;
  window._lastWatches = watches;
  var scrollState = opts.preserveScroll === false ? null : captureDashboardScrollState(el, ['.pr-table-wrap']);

  if (!watches.length) {
    el.innerHTML = '<p class="empty">No active watches. Create one to monitor PRs or work items.</p>';
    restoreDashboardScrollState(el, scrollState);
    return;
  }

  var totalPages = Math.ceil(watches.length / WATCH_PER_PAGE);
  if (_watchPage >= totalPages) _watchPage = totalPages - 1;
  var start = _watchPage * WATCH_PER_PAGE;
  var pageItems = watches.slice(start, start + WATCH_PER_PAGE);

  var html = '<div class="pr-table-wrap"><table class="pr-table"><thead><tr>' +
    '<th>ID</th><th>Target</th><th>Type</th><th>Condition</th><th>Action</th><th>Interval</th><th>Owner</th><th>Status</th><th>Triggers</th><th>Last Checked</th><th></th>' +
    '</tr></thead><tbody>';

  for (var i = 0; i < pageItems.length; i++) {
    var w = pageItems[i];
    var statusBadge = _WATCH_STATUS_BADGES[w.status] || escHtml(w.status || 'unknown');
    var targetLabel = escHtml(_targetTypeLabel(w.targetType));
    var condLabel = escHtml(_conditionLabel(w.condition));
    var actionLabel = (w.action && w.action.type) ? escHtml(w.action.type) : '<span style="color:var(--muted)">notify</span>';
    var lastChecked = w.last_checked ? timeAgo(w.last_checked) : 'never';
    var lastTriggered = w.last_triggered ? timeAgo(w.last_triggered) : 'never';
    var triggerInfo = (w.triggerCount || 0) + (w.stopAfter > 0 ? '/' + w.stopAfter : '');

    html += '<tr style="cursor:pointer" onclick="if(shouldIgnoreSelectionClick(event))return;openWatchDetail(\'' + escHtml(w.id) + '\')">' +
      '<td><span class="pr-id">' + escHtml(w.id) + '</span></td>' +
      '<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escHtml(_watchTitleFallback(w)) + '">' + escHtml(_formatWatchTarget(w.target, w.targetType) || _watchTitleFallback(w)) + '</td>' +
      '<td><span class="dispatch-type explore">' + escHtml(targetLabel) + '</span></td>' +
      '<td><span style="font-size:var(--text-base);color:var(--blue)">' + escHtml(condLabel) + '</span></td>' +
      '<td><span style="font-size:var(--text-sm)">' + actionLabel + '</span></td>' +
      '<td><span style="font-size:var(--text-sm);color:var(--muted)">' + escHtml(_intervalToHuman(w.interval)) + '</span></td>' +
      '<td><span class="pr-agent">' + escHtml(w.owner || 'human') + '</span></td>' +
      '<td>' + statusBadge + '</td>' +
      '<td title="Last triggered: ' + escHtml(lastTriggered) + '"><span style="font-size:var(--text-base)">' + escHtml(triggerInfo) + '</span></td>' +
      '<td><span class="pr-date">' + escHtml(lastChecked) + '</span></td>' +
      '<td style="white-space:nowrap">';

    // Pause/Resume button (F7: marks as `disabled` while POST in flight)
    var pauseBusyCls = _watchToggleInFlight.has(w.id) ? ' disabled' : '';
    if (w.status === 'active') {
      html += '<button class="pr-pager-btn' + pauseBusyCls + '" style="font-size:var(--text-xs);padding:1px 6px;color:var(--yellow);border-color:var(--yellow);margin-right:4px" onclick="event.stopPropagation();toggleWatchPause(\'' + escHtml(w.id) + '\',true)" title="Pause">&#x23F8;</button>';
    } else if (w.status === 'paused') {
      html += '<button class="btn-action' + pauseBusyCls + '" style="margin-right:4px" onclick="event.stopPropagation();toggleWatchPause(\'' + escHtml(w.id) + '\',false)" title="Resume">&#x25B6;</button>';
    }

    // Delete button
    html += '<button class="btn-destructive" onclick="event.stopPropagation();deleteWatch(\'' + escHtml(w.id) + '\')" title="Delete">&#x2715;</button>';
    html += '</td></tr>';
  }

  html += '</tbody></table></div>';

  if (watches.length > WATCH_PER_PAGE) {
    html += '<div class="pr-pager">' +
      '<span class="pr-page-info">Showing ' + (start + 1) + ' to ' + Math.min(start + WATCH_PER_PAGE, watches.length) + ' of ' + watches.length + '</span>' +
      '<div class="pr-pager-btns">' +
        '<button class="pr-pager-btn ' + (_watchPage === 0 ? 'disabled' : '') + '" onclick="_watchPrev()">Prev</button>' +
        '<button class="pr-pager-btn ' + (_watchPage >= totalPages - 1 ? 'disabled' : '') + '" onclick="_watchNext()">Next</button>' +
      '</div>' +
    '</div>';
  }

  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: watch id/target/description/action type/owner/timestamps)
  el.innerHTML = html;
  restoreDashboardScrollState(el, scrollState);
}

function _watchPrev() { if (_watchPage > 0) { _watchPage--; renderWatches(window._lastWatches || [], { preserveScroll: false }); } }
function _watchNext() { var tp = Math.ceil((window._lastWatches || []).length / WATCH_PER_PAGE); if (_watchPage < tp - 1) { _watchPage++; renderWatches(window._lastWatches || [], { preserveScroll: false }); } }

// ─── Detail Modal ───────────────────────────────────────────────────────────

// P-w14e7a8c — Phase 7.1: render the action/chain block for the detail modal.
// Single-object action prints one step (parity with legacy view); array-form
// chain prints each step indexed. Each step surfaces guard.expr when present
// so users can verify the editor round-tripped through engine/watches.js.
function _renderWatchActionDetail(action) {
  if (!action) return '';
  if (Array.isArray(action)) {
    if (action.length === 0) return '';
    var stepHtml = action.map(function(step, i) {
      return '<div style="margin-top:4px;padding:8px;background:var(--surface2);border-radius:4px">' +
        '<div style="font-size:var(--text-base);color:var(--muted)">Step ' + (i + 1) + '</div>' +
        '<div style="font-size:var(--text-md)"><strong>Type:</strong> <span class="dispatch-type explore">' + escHtml(step && step.type ? step.type : '?') + '</span></div>' +
        (step && step.guard && step.guard.expr ? '<div style="font-size:var(--text-base);margin-top:2px"><strong>Guard:</strong> <code>' + escHtml(step.guard.expr) + '</code></div>' : '') +
        (step && step.params && Object.keys(step.params).length ? '<div style="margin-top:4px;font-size:var(--text-base);font-family:monospace;white-space:pre-wrap">' + escHtml(JSON.stringify(step.params, null, 2)) + '</div>' : '') +
        '</div>';
    }).join('');
    return '<div><strong style="color:var(--muted)">Follow-up Action Chain (' + action.length + ' step' + (action.length === 1 ? '' : 's') + '):</strong>' + stepHtml + '</div>';
  }
  if (action.type) {
    return '<div><strong style="color:var(--muted)">Follow-up Action:</strong> <span class="dispatch-type explore">' + escHtml(action.type) + '</span>' +
      (action.guard && action.guard.expr ? '<div style="font-size:var(--text-base);margin-top:2px"><strong>Guard:</strong> <code>' + escHtml(action.guard.expr) + '</code></div>' : '') +
      (action.params && Object.keys(action.params).length ? '<div style="margin-top:4px;padding:8px;background:var(--surface2);border-radius:4px;font-size:var(--text-base);font-family:monospace;white-space:pre-wrap">' + escHtml(JSON.stringify(action.params, null, 2)) + '</div>' : '') +
      '</div>';
  }
  return '';
}

// P-w14e7a8c — Phase 7.1: render the cross-target requires[] block for the
// detail modal. Empty / missing requires renders nothing.
function _renderWatchRequiresDetail(requires) {
  if (!Array.isArray(requires) || requires.length === 0) return '';
  var rows = requires.map(function(r, i) {
    return '<div style="margin-top:4px;padding:6px 8px;background:var(--surface2);border-radius:4px;font-size:var(--text-base)">' +
      '<span style="color:var(--muted)">[' + (i + 1) + ']</span> ' +
      escHtml(r && r.target ? (_formatWatchTarget(r.target, r.targetType) || '?') : '?') +
      ' <span class="dispatch-type explore">' + escHtml(r && r.targetType ? r.targetType : '?') + '</span>' +
      ' <span style="color:var(--blue)">' + escHtml(_conditionLabel(r && r.condition ? r.condition : '?')) + '</span>' +
      '</div>';
  }).join('');
  return '<div><strong style="color:var(--muted)">Requires (AND-join, ' + requires.length + '):</strong>' + rows + '</div>';
}

// P-w15f1d4b — Phase 7.2: render the evaluation history block for the detail
// modal. `history` is the Phase 6.2 `_history` array shape
// `[{checked_at, fired, reason, condition}]` (oldest-first, capped at 25 by
// the engine). The lazy fetcher in openWatchDetail injects the rendered HTML
// into a `watch-history-<id>` placeholder div once /api/watches/:id/history
// resolves. Empty / missing arrays render a "no checks yet" hint so legacy
// watches that were created before Phase 6.2 don't show a half-empty table.
function _renderWatchHistoryDetail(history) {
  if (!Array.isArray(history) || history.length === 0) {
    return '<div style="color:var(--muted);font-style:italic;font-size:var(--text-base)">no history yet — watch has not been checked, or pre-dates the history feature</div>';
  }
  // Render newest-first for readability — the engine stores oldest-first.
  var entries = history.slice().reverse();
  var rows = entries.map(function(entry) {
    var fired = !!(entry && entry.fired);
    var ts = entry && entry.checked_at ? formatLocalDateTime(entry.checked_at) : '?';
    var reason = entry && entry.reason ? entry.reason : '';
    var indicator = fired
      ? '<span style="color:var(--green)" title="fired">✓ fired</span>'
      : '<span style="color:var(--muted)" title="not fired">·</span>';
    return '<tr>' +
      '<td style="padding:2px 8px 2px 0;color:var(--muted);white-space:nowrap">' + escHtml(ts) + '</td>' +
      '<td style="padding:2px 8px 2px 0;white-space:nowrap">' + indicator + '</td>' +
      '<td style="padding:2px 0;color:var(--text)">' + escHtml(reason) + '</td>' +
    '</tr>';
  }).join('');
  return '<div><strong style="color:var(--muted)">Evaluation History (' + history.length + ' check' + (history.length === 1 ? '' : 's') + ', newest first):</strong>' +
    '<table style="margin-top:4px;font-size:var(--text-base);border-collapse:collapse;width:100%"><tbody>' + rows + '</tbody></table>' +
    '</div>';
}

function openWatchDetail(id) {
  var w = (window._lastWatches || []).find(function(x) { return x.id === id; });
  if (!w) return;
  var statusBadge = _WATCH_STATUS_BADGES[w.status] || escHtml(w.status || '');
  var lastChecked = w.last_checked ? formatLocalDateTime(w.last_checked) : 'never';
  var lastTriggered = w.last_triggered ? formatLocalDateTime(w.last_triggered) : 'never';
  var createdAt = w.created_at ? formatLocalDateTime(w.created_at) : 'unknown';
  var targetLabel = _targetTypeLabel(w.targetType);
  var condLabel = _conditionLabel(w.condition);

  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: watch description/target/id)
  document.getElementById('modal-title').innerHTML = escHtml(_watchTitleFallback(w)) +
    ' <div style="display:flex;gap:4px;margin-top:4px">' +
      (w.status === 'active' ? '<button class="pr-pager-btn" style="font-size:var(--text-sm);padding:2px 10px;color:var(--yellow)" onclick="toggleWatchPause(\'' + escHtml(w.id) + '\',true);closeModal()">Pause</button>' : '') +
      (w.status === 'paused' ? '<button class="pr-pager-btn" style="font-size:var(--text-sm);padding:2px 10px;color:var(--green)" onclick="toggleWatchPause(\'' + escHtml(w.id) + '\',false);closeModal()">Resume</button>' : '') +
      '<button class="pr-pager-btn" style="font-size:var(--text-sm);padding:2px 10px;color:var(--red)" onclick="deleteWatch(\'' + escHtml(w.id) + '\');closeModal()">Delete</button>' +
    '</div>';

  var body = '<div style="display:flex;flex-direction:column;gap:10px;font-size:var(--text-md);line-height:1.6">' +
    '<div><strong style="color:var(--muted)">ID:</strong> ' + escHtml(w.id) + '</div>' +
    '<div><strong style="color:var(--muted)">Target:</strong> ' + escHtml(_formatWatchTarget(w.target, w.targetType)) + '</div>' +
    '<div><strong style="color:var(--muted)">Target Type:</strong> <span class="dispatch-type explore">' + escHtml(targetLabel) + '</span></div>' +
    '<div><strong style="color:var(--muted)">Condition:</strong> <span style="color:var(--blue)">' + escHtml(condLabel) + '</span></div>' +
    '<div><strong style="color:var(--muted)">Check Interval:</strong> ' + escHtml(_intervalToHuman(w.interval)) + '</div>' +
    '<div><strong style="color:var(--muted)">Owner:</strong> ' + escHtml(w.owner || 'human') + '</div>' +
    '<div><strong style="color:var(--muted)">Status:</strong> ' + statusBadge + '</div>' +
    '<div><strong style="color:var(--muted)">Notify:</strong> ' + escHtml(w.notify || 'inbox') + '</div>' +
    '<div><strong style="color:var(--muted)">Triggers:</strong> ' + (w.triggerCount || 0) + (w.stopAfter > 0 ? ' / ' + w.stopAfter + ' (expires after)' : ' (runs forever)') + '</div>' +
    (w.onNotMet ? '<div><strong style="color:var(--muted)">On Each Poll (not met):</strong> ' + escHtml(w.onNotMet) + '</div>' : '') +
    // P-w14e7a8c — Phase 7.1: render single action OR array-form chain.
    // Each step shows its type, params, and (if present) guard expression
    // so users can verify that the chain editor round-tripped through the
    // back-end (engine/watches.js createWatch persists the shape unchanged).
    _renderWatchActionDetail(w.action) +
    // P-w14e7a8c — Phase 7.1: render cross-target requirements list when
    // the watch carries a non-empty requires[] from the editor.
    _renderWatchRequiresDetail(w.requires) +
    (w._lastActionResult ? '<div><strong style="color:var(--muted)">Last Action Result:</strong> <span style="color:' + (w._lastActionResult.ok ? 'var(--green)' : 'var(--red)') + '">' + (w._lastActionResult.ok ? 'OK' : 'FAILED') + '</span> — ' + escHtml(w._lastActionResult.summary || '') + (w._lastActionResult.dispatchedItemId ? ' (dispatched: ' + escHtml(w._lastActionResult.dispatchedItemId) + ')' : '') + '</div>' : '') +
    '<div><strong style="color:var(--muted)">Created:</strong> ' + escHtml(createdAt) + '</div>' +
    '<div><strong style="color:var(--muted)">Last Checked:</strong> ' + escHtml(lastChecked) + '</div>' +
    '<div><strong style="color:var(--muted)">Last Triggered:</strong> ' + escHtml(lastTriggered) + '</div>' +
    (w._lastTriggerMessage ? '<div><strong style="color:var(--muted)">Last Trigger Message:</strong><div style="margin-top:4px;padding:8px;background:var(--surface2);border-radius:4px;font-size:var(--text-base)">' + escHtml(w._lastTriggerMessage) + '</div></div>' : '') +
    (w.project ? '<div><strong style="color:var(--muted)">Project:</strong> ' + escHtml(w.project) + '</div>' : '') +
    (w.description ? '<div><strong style="color:var(--muted)">Description:</strong> ' + escHtml(w.description) + '</div>' : '') +
    // P-w15f1d4b — Phase 7.2: reserve a placeholder for the lazily-fetched
    // evaluation history table. The fetch below populates it once
    // /api/watches/:id/history resolves; failures fall back to the inline
    // _history copy from /api/watches (best-effort) or a "no history" hint.
    '<div id="watch-history-' + escHtml(w.id) + '"><div style="color:var(--muted);font-style:italic;font-size:var(--text-base)">Loading history…</div></div>' +
  '</div>';

  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() by watch detail renderers (fields: watch id/target/owner/description/action params/requires/history/result messages)
  document.getElementById('modal-body').innerHTML = body;
  document.getElementById('modal-body').style.whiteSpace = 'normal';
  document.getElementById('modal-body').style.fontFamily = "'Segoe UI', system-ui, sans-serif";
  document.getElementById('modal').classList.add('open');

  // P-w15f1d4b — Phase 7.2: lazy history fetch. We don't block modal render
  // on this — the placeholder above is replaced once /api/watches/:id/history
  // resolves. On failure, fall back to whatever _history is already inline
  // on the watch object (often present from the most recent /api/watches
  // poll) so legacy watches still surface what they have locally.
  (function loadHistory() {
    var container = document.getElementById('watch-history-' + w.id);
    if (!container) return;
    fetch('/api/watches/' + encodeURIComponent(w.id) + '/history').then(function(res) {
      if (!res.ok) throw new Error('HTTP ' + res.status);
      return res.json();
    }).then(function(data) {
      if (!container.isConnected) return; // user closed the modal
      // eslint-disable-next-line no-unsanitized/property -- reason: _renderWatchHistoryDetail() escapes all user-controlled fields before assembling HTML
      container.innerHTML = _renderWatchHistoryDetail(data && data.history);
    }).catch(function() {
      if (!container.isConnected) return;
      // Fall back to the inline _history from /api/watches (best-effort).
      // eslint-disable-next-line no-unsanitized/property -- reason: _renderWatchHistoryDetail() escapes all user-controlled fields before assembling HTML
      container.innerHTML = _renderWatchHistoryDetail(w._history || []);
    });
  })();
}

// ─── CRUD Actions ───────────────────────────────────────────────────────────

function toggleWatchPause(id, pause) {
  if (_watchToggleInFlight.has(id)) return; // F7: prevent double-fires
  var newStatus = pause ? 'paused' : 'active';
  var list = window._lastWatches || [];
  var w = list.find(function(x) { return x.id === id; });
  var prevStatus = w ? w.status : null;
  // F7: optimistic — flip cached status + re-render so the badge AND the
  // pause/resume action button switch synchronously. Rollback on failure.
  if (w) { w.status = newStatus; }
  _watchToggleInFlight.add(id);
  renderWatches(list);
  showToast('cmd-toast', (pause ? 'Pausing' : 'Resuming') + ' watch...', true);
  fetch('/api/watches/update', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ id: id, status: newStatus })
  }).then(async function(res) {
    var data = await res.json().catch(function() { return {}; });
    if (!res.ok || data.error) {
      if (w) { w.status = prevStatus; }
      _watchToggleInFlight.delete(id);
      renderWatches(list);
      showToast('cmd-toast', 'Error: ' + (data.error || ('HTTP ' + res.status)), false);
      return;
    }
    _watchToggleInFlight.delete(id);
    renderWatches(list);
    if (typeof refresh === 'function') refresh();
  }).catch(function(err) {
    if (w) { w.status = prevStatus; }
    _watchToggleInFlight.delete(id);
    renderWatches(list);
    showToast('cmd-toast', 'Error: ' + err.message, false);
  });
}

function deleteWatch(id) {
  if (!confirm('Delete this watch?')) return;
  showToast('cmd-toast', 'Deleting watch...', true);
  markDeleted('watch:' + id);
  renderWatches(window._lastWatches || []);
  fetch('/api/watches/delete', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ id: id })
  }).then(async function(res) {
    const data = await res.json().catch(() => ({}));
    if (!res.ok || data.error) {
      clearDeleted('watch:' + id);
      showToast('cmd-toast', 'Delete failed: ' + (data.error || 'unknown'), false);
      if (typeof refresh === 'function') refresh();
      return;
    }
    renderWatches(window._lastWatches || []);
  }).catch(function(err) {
    clearDeleted('watch:' + id);
    showToast('cmd-toast', 'Delete error: ' + err.message, false);
    if (typeof refresh === 'function') refresh();
  });
}

// ─── Create Watch Modal ─────────────────────────────────────────────────────

// Build the form HTML using the (cached) target-type registry. Target type
// and condition options come from the live registry — no hardcoded list — so
// new types registered server-side appear here without UI changes.
function _watchFormHtml() {
  var inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';

  var targetTypes = _watchTargetTypesCache && _watchTargetTypesCache.length
    ? _watchTargetTypesCache
    : [{ value: 'pr', label: 'Pull Request', conditions: ['merged','build-fail','build-pass','status-change','any','new-comments','vote-change'] }];

  var ttOpts = targetTypes.map(function(t) {
    return '<option value="' + escHtml(t.value) + '">' + escHtml(t.label) + '</option>';
  }).join('');

  // Condition options for the initially-selected target type (first entry).
  var initialConds = (targetTypes[0] && targetTypes[0].conditions) || [];
  var condOpts = initialConds.map(function(c) {
    return '<option value="' + escHtml(c) + '">' + escHtml(_conditionLabel(c)) + '</option>';
  }).join('');

  var agentOpts = '<option value="">human</option>' + (cmdAgents || []).map(function(a) { return '<option value="' + escHtml(a.id) + '">' + escHtml(a.name) + '</option>'; }).join('');
  var projOpts = '<option value="">Any</option>' + (cmdProjects || []).map(function(p) { return '<option value="' + escHtml(p.name) + '">' + escHtml(p.name) + '</option>'; }).join('');

  return '<div style="display:flex;flex-direction:column;gap:12px;font-family:inherit">' +
    '<div id="watch-form-error" style="display:none;color:var(--red);font-size:var(--text-md);padding:6px 10px;background:rgba(255,50,50,0.1);border-radius:var(--radius-sm)"></div>' +
    '<label style="color:var(--text);font-size:var(--text-md)">Target (e.g. PR number, work item ID, meeting/plan/schedule/pipeline/dispatch/agent ID)<input id="watch-edit-target" placeholder="e.g. 1057, W-abc123, M-xyz, schedule-42" style="' + inputStyle + '"></label>' +
    '<label style="color:var(--text);font-size:var(--text-md)">Target Type<select id="watch-edit-target-type" style="' + inputStyle + '" onchange="_updateWatchConditionOptions()">' + ttOpts + '</select></label>' +
    '<label style="color:var(--text);font-size:var(--text-md)">Condition<select id="watch-edit-condition" style="' + inputStyle + '">' + condOpts + '</select></label>' +
    '<label style="color:var(--text);font-size:var(--text-md)">Check Interval <span style="font-size:var(--text-sm);color:var(--muted)">(e.g. 5m, 15m, 1h — default 5m)</span><input id="watch-edit-interval" placeholder="5m" style="' + inputStyle + '"></label>' +
    '<label style="color:var(--text);font-size:var(--text-md)">Owner (who gets notified)<select id="watch-edit-owner" style="' + inputStyle + '">' + agentOpts + '</select></label>' +
    '<label style="color:var(--text);font-size:var(--text-md)">Project<select id="watch-edit-project" style="' + inputStyle + '">' + projOpts + '</select></label>' +
    '<label style="color:var(--text);font-size:var(--text-md)">Description<input id="watch-edit-desc" placeholder="Optional description" style="' + inputStyle + '"></label>' +
    '<label style="color:var(--text);font-size:var(--text-md)">Stop After N Triggers <span style="font-size:var(--text-sm);color:var(--muted)">(0 = run forever, 1 = expire on first match)</span><input id="watch-edit-stop-after" type="number" value="0" min="0" style="' + inputStyle + '"></label>' +
    '<label style="color:var(--text);font-size:var(--text-md)">On Each Poll (if condition not met)<select id="watch-edit-on-not-met" style="' + inputStyle + '"><option value="">None — do nothing</option><option value="notify">Notify — write to inbox each poll</option></select></label>' +
    // P-w14e7a8c — Phase 7.1: action chain editor (step list + add/remove)
    '<div style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px;display:flex;flex-direction:column;gap:8px">' +
      '<div style="color:var(--text);font-size:var(--text-md);display:flex;align-items:center;justify-content:space-between">' +
        '<span>Follow-up Action(s) <span style="font-size:var(--text-sm);color:var(--muted)">(runs after the inbox notification when the watch fires; 1 step = single, 2+ = chain)</span></span>' +
        '<button type="button" class="btn-add btn-add-lg" onclick="_addWatchStepRow()">+ Add step</button>' +
      '</div>' +
      '<div id="watch-edit-steps"></div>' +
    '</div>' +
    // P-w14e7a8c — Phase 7.1: requires editor (cross-target AND-join rows)
    '<div style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px;display:flex;flex-direction:column;gap:8px">' +
      '<div style="color:var(--text);font-size:var(--text-md);display:flex;align-items:center;justify-content:space-between">' +
        '<span>Requires <span style="font-size:var(--text-sm);color:var(--muted)">(cross-target AND-join — every requirement must also be true to fire)</span></span>' +
        '<button type="button" class="btn-add btn-add-lg" onclick="_addRequireRow()">+ Add requirement</button>' +
      '</div>' +
      '<div id="watch-edit-requires"></div>' +
    '</div>' +
  '</div>';
}

// P-w14e7a8c — Phase 7.1: action chain step editor.
// Renders a single step block with type / params / guard inputs into the
// container at #watch-edit-steps. Step indexes are stable per-block so the
// remove button can target the right node; the submit handler walks live
// rows (not these indexes) and packs them into a single object (1 step) or
// array (2+ steps) before sending to /api/watches.
var _watchStepSeq = 0;
function _addWatchStepRow(initial) {
  var container = document.getElementById('watch-edit-steps');
  if (!container) return;
  _watchStepSeq += 1;
  var idx = _watchStepSeq;
  var inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';
  var actionOpts = '<option value="">— select action —</option>';
  if (_watchActionTypesCache && _watchActionTypesCache.length) {
    actionOpts += _watchActionTypesCache.map(function(a) {
      return '<option value="' + escHtml(a.value) + '" title="' + escHtml(a.description || '') + '">' + escHtml(a.label) + '</option>';
    }).join('');
  }
  var initType = (initial && initial.type) || '';
  var initParams = initial && initial.params ? JSON.stringify(initial.params, null, 2) : '';
  var initGuard = initial && initial.guard && initial.guard.expr ? initial.guard.expr : '';
  var row = document.createElement('div');
  row.className = 'watch-step-row';
  row.setAttribute('data-step-idx', String(idx));
  row.style.cssText = 'border:1px solid var(--border);border-radius:var(--radius-sm);padding:8px;display:flex;flex-direction:column;gap:6px;background:var(--surface)';
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: action registry values/labels/descriptions)
  row.innerHTML =
    '<div style="display:flex;align-items:center;justify-content:space-between;gap:8px">' +
      '<select id="watch-step-' + idx + '-type" style="' + inputStyle + ';margin-top:0;flex:1" onchange="_updateWatchStepParamsHint(' + idx + ')">' + actionOpts + '</select>' +
      '<button type="button" class="btn-destructive btn-destructive-lg" onclick="_removeWatchStepRow(' + idx + ')">Remove</button>' +
    '</div>' +
    '<label style="color:var(--text);font-size:var(--text-base)">Params (JSON) <span style="font-size:var(--text-sm);color:var(--muted)" id="watch-step-' + idx + '-params-hint"></span>' +
      '<textarea id="watch-step-' + idx + '-params" rows="3" placeholder=\'{"title": "Re-check {{target}} after merge"}\' style="' + inputStyle + ';font-family:monospace"></textarea>' +
    '</label>' +
    '<label style="color:var(--text);font-size:var(--text-base)">Guard expression <span style="font-size:var(--text-sm);color:var(--muted)">(optional — skip step if expr is false; e.g. <code>newState.mergeable === true</code>)</span>' +
      '<input id="watch-step-' + idx + '-guard" placeholder="" style="' + inputStyle + ';font-family:monospace">' +
    '</label>';
  container.appendChild(row);
  if (initType) document.getElementById('watch-step-' + idx + '-type').value = initType;
  if (initParams) document.getElementById('watch-step-' + idx + '-params').value = initParams;
  if (initGuard) document.getElementById('watch-step-' + idx + '-guard').value = initGuard;
  _updateWatchStepParamsHint(idx);
}

function _removeWatchStepRow(idx) {
  var row = document.querySelector('.watch-step-row[data-step-idx="' + idx + '"]');
  if (row && row.parentNode) row.parentNode.removeChild(row);
}

// Refresh the hint next to a single step's params textarea based on its
// currently selected action type. Same registry-driven hint as the legacy
// single-action form (kept for parity).
function _updateWatchStepParamsHint(idx) {
  var sel = document.getElementById('watch-step-' + idx + '-type');
  var hint = document.getElementById('watch-step-' + idx + '-params-hint');
  if (!sel || !hint) return;
  if (!sel.value) { hint.textContent = ''; return; }
  var entry = (_watchActionTypesCache || []).find(function(a) { return a.value === sel.value; });
  if (!entry) { hint.textContent = ''; return; }
  var paramHints = (entry.params || []).map(function(p) {
    return p.key + (p.required ? ' (required)' : '?');
  }).join(', ');
  hint.textContent = paramHints ? '— ' + paramHints : '';
}

// P-w14e7a8c — Phase 7.1: requires-row editor.
// Each row is one cross-target requirement: {target, targetType, condition}.
// Per the engine contract (engine/watches.js _validateRequires) requirements
// MUST NOT themselves declare nested requires — the form intentionally
// surfaces only the three fields the back-end accepts.
var _watchRequireSeq = 0;
function _addRequireRow(initial) {
  var container = document.getElementById('watch-edit-requires');
  if (!container) return;
  _watchRequireSeq += 1;
  var idx = _watchRequireSeq;
  var inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';
  var targetTypes = _watchTargetTypesCache && _watchTargetTypesCache.length ? _watchTargetTypesCache : [];
  var ttOpts = '<option value="">— targetType —</option>' + targetTypes.map(function(t) {
    return '<option value="' + escHtml(t.value) + '">' + escHtml(t.label) + '</option>';
  }).join('');
  var initTarget = (initial && initial.target) || '';
  var initTT = (initial && initial.targetType) || '';
  var initCond = (initial && initial.condition) || '';
  var row = document.createElement('div');
  row.className = 'watch-require-row';
  row.setAttribute('data-require-idx', String(idx));
  row.style.cssText = 'display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:6px;align-items:end';
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: target type registry values/labels)
  row.innerHTML =
    '<label style="color:var(--text);font-size:var(--text-base);margin:0">Target<input id="watch-require-' + idx + '-target" placeholder="e.g. PR-123, pipeline-X" style="' + inputStyle + '"></label>' +
    '<label style="color:var(--text);font-size:var(--text-base);margin:0">Target Type<select id="watch-require-' + idx + '-target-type" style="' + inputStyle + '" onchange="_updateRequireConditionOptions(' + idx + ')">' + ttOpts + '</select></label>' +
    '<label style="color:var(--text);font-size:var(--text-base);margin:0">Condition<select id="watch-require-' + idx + '-condition" style="' + inputStyle + '"></select></label>' +
    '<button type="button" class="btn-destructive btn-destructive-lg" style="padding:6px 8px" onclick="_removeRequireRow(' + idx + ')">Remove</button>';
  container.appendChild(row);
  if (initTarget) document.getElementById('watch-require-' + idx + '-target').value = initTarget;
  if (initTT) document.getElementById('watch-require-' + idx + '-target-type').value = initTT;
  _updateRequireConditionOptions(idx);
  if (initCond) document.getElementById('watch-require-' + idx + '-condition').value = initCond;
}

function _removeRequireRow(idx) {
  var row = document.querySelector('.watch-require-row[data-require-idx="' + idx + '"]');
  if (row && row.parentNode) row.parentNode.removeChild(row);
}

// Per-row condition refresh — mirrors _updateWatchConditionOptions for the
// primary watch's condition select but scoped to the requirement row.
function _updateRequireConditionOptions(idx) {
  var ttSel = document.getElementById('watch-require-' + idx + '-target-type');
  var condSel = document.getElementById('watch-require-' + idx + '-condition');
  if (!ttSel || !condSel || !_watchTargetTypesCache) return;
  var entry = _watchTargetTypesCache.find(function(t) { return t.value === ttSel.value; });
  var conditions = (entry && entry.conditions) || [];
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: condition registry values, condition labels)
  condSel.innerHTML = (conditions.length ? '' : '<option value="">— select target type first —</option>') +
    conditions.map(function(c) {
      return '<option value="' + escHtml(c) + '">' + escHtml(_conditionLabel(c)) + '</option>';
    }).join('');
}

// Refresh the condition <select> based on the currently selected target type.
function _updateWatchConditionOptions() {
  var ttSel = document.getElementById('watch-edit-target-type');
  var condSel = document.getElementById('watch-edit-condition');
  if (!ttSel || !condSel || !_watchTargetTypesCache) return;
  var entry = _watchTargetTypesCache.find(function(t) { return t.value === ttSel.value; });
  var conditions = (entry && entry.conditions) || [];
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: condition registry values, condition labels)
  condSel.innerHTML = conditions.map(function(c) {
    return '<option value="' + escHtml(c) + '">' + escHtml(_conditionLabel(c)) + '</option>';
  }).join('');
}

function _renderCreateWatchModal() {
  document.getElementById('modal-title').innerHTML = 'Create Watch' +
    ' <button class="btn-action btn-action-lg" style="padding:2px 12px;margin-left:8px" onclick="submitWatch()">Create</button>';
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() by _watchFormHtml() (fields: target/action registries, project names, agent ids/names)
  document.getElementById('modal-body').innerHTML = _watchFormHtml();
  document.getElementById('modal-body').style.whiteSpace = 'normal';
  document.getElementById('modal-body').style.fontFamily = "'Segoe UI', system-ui, sans-serif";
  document.getElementById('modal').classList.add('open');
  // P-w14e7a8c — Phase 7.1: seed one empty action step row so the picker is
  // visible by default (matches today's UX where the action <select> is
  // always visible). Users who want notify-only just leave the type empty
  // (or click Remove); users who want a chain click "+ Add step" again.
  _addWatchStepRow();
}

function openCreateWatchModal() {
  // Render an initial form immediately (uses cached target types if present).
  _renderCreateWatchModal();
  // Fetch the live registry and re-render so newly registered target types
  // appear without requiring a page reload.
  Promise.all([
    fetch('/api/watches/target-types').then(function(res) { return res.json(); }).catch(function() { return null; }),
    fetch('/api/watches/action-types').then(function(res) { return res.json(); }).catch(function() { return null; }),
  ]).then(function(results) {
    var ttData = results[0], atData = results[1];
    var changed = false;
    if (ttData && Array.isArray(ttData.targetTypes) && ttData.targetTypes.length > 0) {
      _watchTargetTypesCache = ttData.targetTypes;
      changed = true;
    }
    if (atData && Array.isArray(atData.actionTypes) && atData.actionTypes.length > 0) {
      _watchActionTypesCache = atData.actionTypes;
      changed = true;
    }
    if (changed) {
      var titleEl = document.getElementById('modal-title');
      if (titleEl && titleEl.textContent.indexOf('Create Watch') === 0) {
        _renderCreateWatchModal();
      }
    }
  });
}

function submitWatch() {
  var target = (document.getElementById('watch-edit-target') || {}).value || '';
  var targetType = (document.getElementById('watch-edit-target-type') || {}).value || 'pr';
  var condition = (document.getElementById('watch-edit-condition') || {}).value || 'merged';
  var interval = _parseIntervalStr((document.getElementById('watch-edit-interval') || {}).value);
  var owner = (document.getElementById('watch-edit-owner') || {}).value || '';
  var project = (document.getElementById('watch-edit-project') || {}).value || '';
  var description = (document.getElementById('watch-edit-desc') || {}).value || '';
  var stopAfter = parseInt((document.getElementById('watch-edit-stop-after') || {}).value, 10) || 0;
  var onNotMet = (document.getElementById('watch-edit-on-not-met') || {}).value || '';

  var errEl = document.getElementById('watch-form-error');
  function showErr(msg) { if (errEl) { errEl.textContent = msg; errEl.style.display = 'block'; } }

  if (!target.trim()) { showErr('Target is required'); return; }

  // P-w14e7a8c — Phase 7.1: harvest action steps from the chain editor.
  // 0 steps -> action: null (notify-only legacy behavior).
  // 1 step  -> action: { type, params, guard? } (single-object form).
  // 2+ steps -> action: [ {...}, ... ] (array-form chain — engine/watch-actions.js
  //             runWatchActionChain handles it).
  var stepRows = document.querySelectorAll('.watch-step-row');
  var steps = [];
  for (var i = 0; i < stepRows.length; i++) {
    var row = stepRows[i];
    var stepIdx = row.getAttribute('data-step-idx');
    var typeEl = document.getElementById('watch-step-' + stepIdx + '-type');
    var paramsEl = document.getElementById('watch-step-' + stepIdx + '-params');
    var guardEl = document.getElementById('watch-step-' + stepIdx + '-guard');
    var sType = typeEl && typeEl.value ? typeEl.value : '';
    if (!sType) continue; // empty type → blank row, skip silently
    var sParamsRaw = paramsEl ? paramsEl.value : '';
    var sParams = {};
    if (sParamsRaw && sParamsRaw.trim()) {
      try { sParams = JSON.parse(sParamsRaw); }
      catch (e) { showErr('Step ' + (steps.length + 1) + ' params must be valid JSON: ' + e.message); return; }
      if (sParams === null || typeof sParams !== 'object' || Array.isArray(sParams)) {
        showErr('Step ' + (steps.length + 1) + ' params must be a JSON object'); return;
      }
    }
    var step = { type: sType, params: sParams };
    var sGuard = guardEl && guardEl.value ? guardEl.value.trim() : '';
    if (sGuard) step.guard = { expr: sGuard };
    steps.push(step);
  }
  var action = null;
  if (steps.length === 1) action = steps[0];
  else if (steps.length > 1) action = steps;

  // P-w14e7a8c — Phase 7.1: harvest cross-target requirements. Skip rows
  // with no target (treat as empty/blank). Send `requires: []` (omit) when
  // there are no usable rows so the back-end stays in legacy single-watch
  // shape — engine/watches.js _validateRequires accepts both null and [].
  var reqRows = document.querySelectorAll('.watch-require-row');
  var requires = [];
  for (var j = 0; j < reqRows.length; j++) {
    var rrow = reqRows[j];
    var rIdx = rrow.getAttribute('data-require-idx');
    var rTarget = (document.getElementById('watch-require-' + rIdx + '-target') || {}).value || '';
    var rType = (document.getElementById('watch-require-' + rIdx + '-target-type') || {}).value || '';
    var rCond = (document.getElementById('watch-require-' + rIdx + '-condition') || {}).value || '';
    if (!rTarget.trim() && !rType && !rCond) continue; // wholly blank row, skip
    if (!rTarget.trim()) { showErr('Requirement ' + (requires.length + 1) + ': target is required'); return; }
    if (!rType) { showErr('Requirement ' + (requires.length + 1) + ': target type is required'); return; }
    if (!rCond) { showErr('Requirement ' + (requires.length + 1) + ': condition is required'); return; }
    requires.push({ target: rTarget.trim(), targetType: rType, condition: rCond });
  }

  var payload = {
    target: target.trim(),
    targetType: targetType,
    condition: condition,
    interval: interval,
    owner: owner || 'human',
    project: project || null,
    description: description || null,
    notify: 'inbox',
    stopAfter: stopAfter,
    onNotMet: onNotMet || null,
    action: action,
  };
  if (requires.length > 0) payload.requires = requires;

  showToast('cmd-toast', 'Creating watch...', true);
  fetch('/api/watches', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload)
  }).then(async function(res) {
    var data = await res.json().catch(function() { return {}; });
    if (!res.ok || data.error) {
      showToast('cmd-toast', 'Error: ' + (data.error || ('HTTP ' + res.status)), false);
    } else {
      showToast('cmd-toast', 'Watch created: ' + (data.watch && data.watch.id || ''), true);
      closeModal();
      if (typeof refresh === 'function') refresh();
    }
  }).catch(function(err) {
    showToast('cmd-toast', 'Error: ' + err.message, false);
  });
}

// ─── Exports ────────────────────────────────────────────────────────────────

window.MinionsWatches = {
  renderWatches: renderWatches,
  openCreateWatchModal: openCreateWatchModal,
  openWatchDetail: openWatchDetail,
  submitWatch: submitWatch,
  toggleWatchPause: toggleWatchPause,
  deleteWatch: deleteWatch,
  _watchPrev: _watchPrev,
  _watchNext: _watchNext,
  _updateWatchConditionOptions: _updateWatchConditionOptions,
  // P-w14e7a8c — Phase 7.1: chain + requires editor handlers (called from
  // inline onclick attributes generated by _watchFormHtml / _addWatchStepRow
  // / _addRequireRow). Exposed on the namespace for testability.
  _addWatchStepRow: _addWatchStepRow,
  _removeWatchStepRow: _removeWatchStepRow,
  _updateWatchStepParamsHint: _updateWatchStepParamsHint,
  _addRequireRow: _addRequireRow,
  _removeRequireRow: _removeRequireRow,
  _updateRequireConditionOptions: _updateRequireConditionOptions,
  // P-w15f1d4b — Phase 7.2: history table helper exposed for testability.
  _renderWatchHistoryDetail: _renderWatchHistoryDetail,
  // W-mq1j5f9z00030b8f — target / title fallback helpers exposed for unit coverage.
  _formatWatchTarget: _formatWatchTarget,
  _watchTitleFallback: _watchTitleFallback,
};
