/**
 * engine/watches.js -- Persistent watch jobs that survive engine restarts.
 * Zero dependencies -- uses only Node.js built-ins + engine/shared.js.
 *
 * Watches monitor targets (PRs, work items, meetings, plans, schedules,
 * pipelines, dispatches, agents, ...) for conditions (merged, build-fail,
 * completed, concluded, approved, etc.) and fire inbox notifications when
 * triggered.
 *
 * Target-type behavior is data-driven via a registry — see TARGET_TYPES below.
 * Each registered target type provides:
 *   - label:              human-friendly name shown in dashboard pickers
 *   - conditions:         array of condition keys this target type accepts
 *   - absoluteConditions: (optional) array of condition keys that should
 *                         fire-once when stopAfter=0 (compound state assertions
 *                         like "merged" / "completed"). Normalized into a Set
 *                         on the spec at registration time.
 *   - fetchEntity:        (target, state) => entity-or-null lookup
 *   - captureState:       (entity, prevState) => snapshot for change-detection
 *   - evaluate:           (condition, entity, prevState, target) =>
 *                         { triggered, message }
 *   - contextVars:        (optional) (entity, prevState, newState) =>
 *                         object of extra template vars merged into the
 *                         action ctx after buildTriggerContext. Used by
 *                         plugins to surface fields that aren't auto-promoted
 *                         from newState (e.g. {{prevExtracted}}) or to alias
 *                         scalars under nicer names.
 *
 * Built-in types: pr, work-item, meeting, plan, schedule, pipeline, dispatch,
 * agent. Additional types can be added at runtime via registerTargetType().
 *
 * Canonical plugin example: `watches.d/http.js` (W-mp7i22mu00191b07) — a
 * generic HTTP poller that demonstrates the full plugin contract including
 * `contextVars` (exposing `{{httpStatus}}`, `{{extracted}}`, `{{prevExtracted}}`,
 * etc. to action templates) and a background-fetch cache (because the engine
 * tick path is synchronous; plugins that need async I/O maintain their own
 * cache and return cached entities from the synchronous fetchEntity).
 *
 * ── Plugin folder (`watches.d/`) — W-mp7hg58e000b5212 ────────────────────────
 *
 * At engine boot, every `*.js` file in `<MINIONS_DIR>/watches.d/` is loaded
 * and its exports are passed to registerTargetType(). This lets users add
 * new watch target types without editing engine source.
 *
 * Each plugin file exports either:
 *
 *   module.exports = {
 *     name: 'gh-workflow-run',
 *     spec: { label, conditions, absoluteConditions?, fetchEntity, captureState, evaluate, ... },
 *   };
 *
 * or an array of those objects for multi-target plugins.
 *
 * Resolution: `path.join(MINIONS_DIR, 'watches.d')` so it works in both dev
 * (D:/squad) and installed (~/.minions) layouts. A missing folder is a silent
 * no-op (not an error). Each file is required inside its own try/catch — one
 * bad plugin must not break boot or block other plugins. Successful loads
 * log at INFO; registration failures log at WARN with the file path.
 *
 * Security: plugins are user-trusted JS (same trust level as `playbooks/` and
 * `pipelines/`); no sandboxing. Hot-reload is not supported — restart the
 * engine to pick up new plugin files (matches how playbooks already work).
 *
 * State stored in engine/watches.json — concurrency-safe via mutateJsonFileLocked.
 */

const fs = require('fs');
const path = require('path');
const shared = require('./shared');
const { safeJsonArr, mutateJsonFileLocked, mutateWatches, ts, uid, log, writeToInbox,
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS } = shared;
const watchActions = require('./watch-actions');

// Dynamic path — respects MINIONS_TEST_DIR for test isolation
function _watchesPath() { return path.join(shared.MINIONS_DIR, 'engine', 'watches.json'); }

// Default check interval: 5 minutes (300000ms). Engine tick runs every 10s
// (W-mpxpckey000laa4f), so watches are checked every N ticks where
// N = ceil(interval / tickInterval).
const DEFAULT_WATCH_INTERVAL = 300000;

// P-w12d8f3a — Phase 6.2: per-watch evaluation history is capped at 25
// entries (oldest-first). Bounded to keep watches.json compact:
// 25 entries × ~200 bytes × 100 watches ≈ 500 KB ceiling. History entries
// never contain full state snapshots — only the trigger reason — per the
// "history is small and bounded" design principle.
const WATCH_HISTORY_MAX = 25;

/** Find a PR by target (prNumber, canonical ID, or display ID). */
function findPrByTarget(pullRequests, target) {
  const t = String(target);
  return (pullRequests || []).find(p =>
    String(p.prNumber) === t || p.id === t || shared.getPrDisplayId(p) === t
  );
}

// ── Target type registry ─────────────────────────────────────────────────────
// Built-in target types are registered at module load (see bottom of file).
// Additional types can be added at runtime via registerTargetType(). The
// registry IS the allowlist for createWatch / dashboard validation — the old
// hardcoded "pr or work-item" check is gone.
const TARGET_TYPES = {};

/**
 * Register a watch target type.
 * @param {string} type - Unique key (e.g. 'pr', 'meeting', 'custom-thing')
 * @param {object} spec - { label, conditions, absoluteConditions?, fetchEntity, captureState, evaluate }
 *   - label:              string shown in dashboard pickers
 *   - conditions:         array of condition strings the type accepts
 *   - absoluteConditions: (optional) array of condition keys that should fire
 *                         once and auto-expire when stopAfter=0. Normalized
 *                         into a Set on the spec; defaults to an empty Set.
 *                         Each entry MUST also appear in `conditions`.
 *   - fetchEntity:        (target, state) => entity-or-null
 *   - captureState:       (entity, prevState) => snapshot object
 *   - evaluate:           (condition, entity, prevState, target) =>
 *                         { triggered, message }
 *   - contextVars:        (optional) (entity, prevState, newState) =>
 *                         object whose keys are merged into the action ctx
 *                         after buildTriggerContext. Use to expose plugin-
 *                         specific template vars that aren't auto-promoted
 *                         scalars from newState (e.g. previousState scalars
 *                         like {{prevExtracted}}). Errors are logged + swallowed.
 */
function registerTargetType(type, spec) {
  if (!type || typeof type !== 'string') throw new Error('registerTargetType: type must be a non-empty string');
  if (!spec || typeof spec !== 'object') throw new Error('registerTargetType: spec must be an object');
  for (const k of ['label', 'conditions', 'fetchEntity', 'captureState', 'evaluate']) {
    if (spec[k] === undefined) throw new Error(`registerTargetType(${type}): spec.${k} is required`);
  }
  if (!Array.isArray(spec.conditions) || spec.conditions.length === 0) {
    throw new Error(`registerTargetType(${type}): spec.conditions must be a non-empty array`);
  }
  // W-mp7hg58e000b5212 — normalize absoluteConditions into a Set for fast
  // .has() lookup at evaluate-time. Accepts an array (declarative spec form)
  // or pre-built Set; defaults to empty when omitted. Entries must be a
  // subset of `conditions` so a typo here doesn't silently no-op.
  let absoluteSet;
  if (spec.absoluteConditions === undefined || spec.absoluteConditions === null) {
    absoluteSet = new Set();
  } else if (spec.absoluteConditions instanceof Set) {
    absoluteSet = new Set(spec.absoluteConditions);
  } else if (Array.isArray(spec.absoluteConditions)) {
    absoluteSet = new Set(spec.absoluteConditions);
  } else {
    throw new Error(`registerTargetType(${type}): spec.absoluteConditions must be an array or Set`);
  }
  for (const c of absoluteSet) {
    if (!spec.conditions.includes(c)) {
      throw new Error(`registerTargetType(${type}): absoluteConditions entry '${c}' is not in conditions[]`);
    }
  }
  TARGET_TYPES[type] = { ...spec, absoluteConditions: absoluteSet };
}

/** Returns the registered spec for a target type, or null. */
function getTargetType(type) { return TARGET_TYPES[type] || null; }

/**
 * Returns the list of registered target types as a serializable array
 * suitable for `GET /api/watches/target-types` and dashboard pickers.
 */
function listTargetTypes() {
  return Object.keys(TARGET_TYPES).sort().map(key => ({
    value: key,
    label: TARGET_TYPES[key].label,
    conditions: [...TARGET_TYPES[key].conditions],
    description: TARGET_TYPES[key].description || '',
  }));
}

/**
 * Read all watches from disk.
 * @returns {Array<object>}
 */
function getWatches() {
  return safeJsonArr(_watchesPath());
}

/**
 * Create a new watch. Validates required fields and writes atomically.
 * @param {object} opts - Watch definition
 * @returns {object} - Created watch
 */
function createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet, action, requires }) {
  if (!target) throw new Error('target is required');
  if (!targetType || !TARGET_TYPES[targetType]) {
    throw new Error(`targetType must be one of: ${Object.keys(TARGET_TYPES).sort().join(', ')}`);
  }
  const tt = TARGET_TYPES[targetType];
  if (!condition || !tt.conditions.includes(condition)) {
    throw new Error(`condition must be one of: ${tt.conditions.join(', ')} (for targetType ${targetType})`);
  }
  if (action !== undefined && action !== null) {
    const actionErr = watchActions.validateAction(action);
    if (actionErr) throw new Error(actionErr);
  }
  // P-w10b6e2d — Phase 5: validate optional requires[] cross-target join.
  if (requires !== undefined && requires !== null) {
    const reqErr = _validateRequires(requires);
    if (reqErr) throw new Error(reqErr);
  }

  const watch = {
    id: 'watch-' + uid(),
    target,
    targetType,
    condition,
    interval: Math.max(60000, Number(interval) || DEFAULT_WATCH_INTERVAL),
    owner: owner || 'human',
    status: WATCH_STATUS.ACTIVE,
    description: description || `Watch ${target} for ${condition}`,
    project: project || null,
    notify: notify || 'inbox',
    stopAfter: Number(stopAfter) || 0,   // 0 = run forever; N = expire after N triggers
    onNotMet: onNotMet || null,          // null | 'notify' — action per poll when condition not met
    action: action || null,              // optional follow-up action — see engine/watch-actions.js
    // P-w10b6e2d — Phase 5: optional requires[] — when present every entry
    // must also evaluate truthy for the watch to fire. See evaluateWatch.
    requires: Array.isArray(requires) ? requires.map(r => ({
      target: r.target, targetType: r.targetType, condition: r.condition,
    })) : null,
    triggerCount: 0,
    created_at: ts(),
    last_checked: null,
    last_triggered: null,
    // P-w12d8f3a — Phase 6.2: bounded evaluation history (last
    // WATCH_HISTORY_MAX=25 checks, oldest-first). Appended in checkWatches
    // on every poll (fired AND not-fired). Powers GET /api/watches/:id/history
    // (P-w13c2b6f) without needing a separate file.
    _history: [],
  };

  mutateWatches((watches) => {
    watches.push(watch);
    return watches;
  });

  log('info', `Watch created: ${watch.id} → ${watch.target} (${watch.condition})`);
  return watch;
}

/**
 * Update a watch by ID. Only updates provided fields.
 * @param {string} id - Watch ID
 * @param {object} updates - Fields to update
 * @returns {object|null} - Updated watch or null if not found
 */
function updateWatch(id, updates) {
  if (!id) throw new Error('id is required');
  // Validate status before entering the lock — reject early, never persist invalid values
  if (updates.status !== undefined && !Object.values(WATCH_STATUS).includes(updates.status)) {
    log('warn', `Invalid watch status: ${updates.status}`);
    return null;
  }
  if (updates.action !== undefined && updates.action !== null) {
    const actionErr = watchActions.validateAction(updates.action);
    if (actionErr) throw new Error(actionErr);
  }
  // P-w10b6e2d — Phase 5: validate requires[] before entering the lock so a
  // bad shape never lands on disk. `[]` is allowed (clears the join).
  if (updates.requires !== undefined && updates.requires !== null) {
    const reqErr = _validateRequires(updates.requires);
    if (reqErr) throw new Error(reqErr);
  }
  let found = null;
  mutateWatches((watches) => {
    const watch = watches.find(w => w.id === id);
    if (!watch) return watches;
    // Only allow safe field updates
    const allowed = ['status', 'interval', 'description', 'notify', 'stopAfter', 'onNotMet', 'condition', 'action', 'requires'];
    for (const key of allowed) {
      if (updates[key] !== undefined) watch[key] = updates[key];
    }
    found = { ...watch };
    return watches;
  });
  return found;
}

/**
 * Delete a watch by ID.
 * @param {string} id
 * @returns {boolean} - true if deleted
 */
function deleteWatch(id) {
  if (!id) return false;
  let deleted = false;
  mutateWatches((watches) => {
    const idx = watches.findIndex(w => w.id === id);
    if (idx >= 0) {
      watches.splice(idx, 1);
      deleted = true;
    }
    return watches;
  });
  if (deleted) log('info', `Watch deleted: ${id}`);
  return deleted;
}

/**
 * Evaluate whether a watch condition is met given current state.
 * Dispatches to the registered target type's evaluator.
 * @param {object} watch - The watch object
 * @param {object} state - { pullRequests, workItems, meetings?, plans?, schedules?, scheduleRuns?, pipelineRuns?, dispatch?, agents?, config? }
 * @returns {{ triggered: boolean, message: string }}
 *
 * P-w10b6e2d — Phase 5: when `watch.requires[]` is present, every entry
 * must also evaluate truthy or the watch does not fire. Requirements are
 * evaluated against the same `state` object via a recursive call into
 * evaluateWatch with a synthetic mini-watch (no _lastState). The depth
 * cap is 1 — `_validateRequires` rejects requirement entries that
 * themselves declare a `requires` field at create/update time, so the
 * recursion can never go past one level here.
 */
function evaluateWatch(watch, state) {
  const { target, targetType, condition } = watch;
  const tt = TARGET_TYPES[targetType];
  if (!tt) return { triggered: false, message: `Unknown target type: ${targetType}` };
  if (!tt.conditions.includes(condition)) return { triggered: false, message: `Unknown condition: ${condition}` };

  const entity = tt.fetchEntity(target, state || {});
  if (!entity) {
    const targetStr = typeof target === 'string' ? target : JSON.stringify(target);
    return { triggered: false, message: `${tt.label} ${targetStr} not found` };
  }

  const prevState = watch._lastState || {};
  let primary;
  try {
    primary = tt.evaluate(condition, entity, prevState, target) || { triggered: false, message: '' };
  } catch (err) {
    log('warn', `evaluateWatch ${targetType}/${condition}: ${err.message}`);
    return { triggered: false, message: `evaluator error: ${err.message}` };
  }
  // Short-circuit when primary is not truthy — requires only constrain
  // an already-firing primary; an un-fired primary stays un-fired.
  if (!primary.triggered) return primary;

  // P-w10b6e2d — Phase 5: AND-join with each requires[] entry.
  if (Array.isArray(watch.requires) && watch.requires.length > 0) {
    for (let i = 0; i < watch.requires.length; i++) {
      const req = watch.requires[i];
      // Synthetic mini-watch — no _lastState, no nested requires (depth cap
      // enforced at create/update time). evaluateWatch handles missing
      // entity / unknown type / unknown condition the same way it does
      // for a top-level watch.
      const reqResult = evaluateWatch({
        target: req.target, targetType: req.targetType, condition: req.condition,
      }, state);
      if (!reqResult.triggered) {
        return {
          triggered: false,
          message: `${primary.message || 'primary truthy'} BUT requires[${i}] (${req.targetType}/${req.target}/${req.condition}) not met: ${reqResult.message || 'condition not met'}`,
        };
      }
    }
  }

  return primary;
}

/**
 * Validate a `watch.requires` array. Returns an error message string when
 * invalid, or null when valid. Enforces the Phase 5 contract:
 *   - must be an array (use `[]` to clear; null/undefined to omit entirely)
 *   - each entry must be a plain object with a non-empty target, a
 *     targetType registered in TARGET_TYPES, and a condition listed in
 *     that target type's `conditions` array
 *   - depth cap of 1: requirement entries MUST NOT themselves declare a
 *     `requires` field (otherwise we'd need cycle detection)
 */
function _validateRequires(requires) {
  if (!Array.isArray(requires)) return 'requires must be an array of {target, targetType, condition}';
  for (let i = 0; i < requires.length; i++) {
    const r = requires[i];
    if (!r || typeof r !== 'object' || Array.isArray(r)) {
      return `requires[${i}] must be a {target, targetType, condition} object`;
    }
    if (!r.target || typeof r.target !== 'string') {
      return `requires[${i}].target is required (non-empty string)`;
    }
    if (!r.targetType || !TARGET_TYPES[r.targetType]) {
      return `requires[${i}].targetType must be one of: ${Object.keys(TARGET_TYPES).sort().join(', ')}`;
    }
    if (!r.condition || !TARGET_TYPES[r.targetType].conditions.includes(r.condition)) {
      return `requires[${i}].condition must be one of: ${TARGET_TYPES[r.targetType].conditions.join(', ')} (for targetType ${r.targetType})`;
    }
    if (r.requires !== undefined) {
      return `requires[${i}] must not contain a nested 'requires' field (depth cap is 1)`;
    }
  }
  return null;
}

/**
 * Check all active watches against current state. Called from engine tick.
 * @param {object} config - Engine config
 * @param {object} state - { pullRequests, workItems } from queries
 *
 * Order of operations when a watch fires:
 *   1. Increment trigger count and update last_triggered (inside the lock).
 *   2. Queue the legacy inbox notification (watch.notify === 'inbox').
 *   3. Queue the configured action (watch.action) for async invocation.
 *   4. Release the lock.
 *   5. Fire notifications first (so the human/agent inbox gets the alert),
 *      then fire actions in parallel — failures isolated per action so one
 *      bad action doesn't stop the others. Each action's result is written
 *      back onto the watch as `_lastActionResult` in a follow-up locked write.
 */
function checkWatches(config, state) {
  const now = Date.now();
  // Collect notifications and actions to fire AFTER lock is released — never do I/O inside the lock callback
  const notifications = [];
  const actionsToRun = [];

  mutateWatches((watches) => {
    if (watches.length === 0) return watches;

    for (const watch of watches) {
      try {
        if (watch.status !== WATCH_STATUS.ACTIVE) continue;

        // Check interval — skip if checked too recently
        if (watch.last_checked) {
          const elapsed = now - new Date(watch.last_checked).getTime();
          if (elapsed < watch.interval) continue;
        }

        watch.last_checked = ts();

        // Initialize baseline state on first check for change-detection conditions.
        // Without this, status-change/any conditions would have no previous state to compare.
        if (!watch._lastState || Object.keys(watch._lastState).length === 0) {
          watch._lastState = _captureState(watch, state);
        }

        const previousState = { ...watch._lastState };
        const result = evaluateWatch(watch, state);
        if (result.triggered) {
          watch.triggerCount = (watch.triggerCount || 0) + 1;
          watch.last_triggered = ts();
          watch._lastTriggerMessage = result.message;
          const newState = _captureState(watch, state);

          log('info', `Watch triggered: ${watch.id} — ${result.message}`);

          // Queue follow-up action for after-lock invocation. We snapshot the
          // watch + entity here so the async handler can run with the same
          // state that triggered the watch even if the live state changes.
          //
          // P-w8e9b1f4 — Phase 4: action may be either a single object
          // (`{type, ...}`) or an array-form chain (`[{type,...}, ...]`).
          //
          // P-w11a4c9e — Phase 6.1: NOTIFY is no longer a special case.
          // When `watch.notify === 'inbox'` (and the watch has an owner),
          // an implicit `{type:'notify'}` step is prepended to the chain
          // here so the legacy inbox alert and any explicit follow-up
          // action both run through the registered action surface.
          //
          // When there is NO implicit notify to prepend, the original
          // action shape is preserved (single-object stays single-object,
          // array stays array) so back-compat _lastActionResult shape
          // (`type: 'webhook'`, `dispatchedItemId`, …) is unchanged.
          const _explicitAction = watch.action;
          const _wantImplicitNotify = (watch.notify === 'inbox' && watch.owner);
          const _hasExplicitArray = Array.isArray(_explicitAction) && _explicitAction.length > 0;
          const _hasExplicitObject = _explicitAction && typeof _explicitAction === 'object'
            && !Array.isArray(_explicitAction) && _explicitAction.type;
          let _effectiveAction = null;
          if (_wantImplicitNotify && _hasExplicitArray) {
            _effectiveAction = [{ type: 'notify' }].concat(_explicitAction);
          } else if (_wantImplicitNotify && _hasExplicitObject) {
            _effectiveAction = [{ type: 'notify' }, _explicitAction];
          } else if (_wantImplicitNotify) {
            _effectiveAction = [{ type: 'notify' }];
          } else if (_hasExplicitArray || _hasExplicitObject) {
            _effectiveAction = _explicitAction;
          }
          if (_effectiveAction) {
            const tt = TARGET_TYPES[watch.targetType];
            const entity = tt ? tt.fetchEntity(watch.target, state || {}) : null;
            const snapshot = JSON.parse(JSON.stringify(watch));
            snapshot.action = _effectiveAction;
            actionsToRun.push({
              watchId: watch.id,
              snapshot,
              previousState,
              newState,
              entity: entity ? JSON.parse(JSON.stringify(entity)) : null,
              message: result.message,
            });
          }

          // Expire when stopAfter > 0 and trigger count reaches the limit.
          // Absolute conditions (merged, build-pass, etc.) auto-expire on first trigger
          // when stopAfter=0 — fire-once semantics. Change-based conditions run forever.
          // W-mp7hg58e000b5212 — resolves through the per-target-type
          // absoluteConditions Set (declared in each spec); falls back to the
          // legacy WATCH_ABSOLUTE_CONDITIONS union only when the watch's
          // targetType has been unregistered between create and check (defensive).
          const _absSpec = TARGET_TYPES[watch.targetType];
          const isAbsolute = _absSpec
            ? _absSpec.absoluteConditions.has(watch.condition)
            : WATCH_ABSOLUTE_CONDITIONS.has(watch.condition);
          if ((watch.stopAfter > 0 && watch.triggerCount >= watch.stopAfter) ||
              (watch.stopAfter === 0 && isAbsolute)) {
            watch.status = WATCH_STATUS.EXPIRED;
            log('info', `Watch expired (${isAbsolute && watch.stopAfter === 0 ? 'absolute condition fire-once' : 'stopAfter limit reached'}): ${watch.id}`);
          }
        } else if (watch.onNotMet === 'notify' && watch.owner) {
          // Queue per-poll notification when condition is not yet met — unique key per poll
          notifications.push({
            type: 'poll', owner: watch.owner,
            slug: `watch-poll-${watch.id}-${Date.now()}`,
            body: `## Watch Polling: ${watch.description}\n\nCondition not yet met (${watch.condition}) — still watching.\n\nWatch ID: ${watch.id} | Target: ${watch.target} | Checks so far: ${watch.triggerCount || 0}`,
          });
        }

        // Capture state for change detection on next check
        watch._lastState = _captureState(watch, state);

        // P-w12d8f3a — Phase 6.2: append a bounded evaluation history
        // entry on EVERY check (fired AND not-fired). Trim oldest first
        // when the buffer exceeds WATCH_HISTORY_MAX. action_results is
        // populated post-lock by _runActionTask once the action runs.
        if (!Array.isArray(watch._history)) watch._history = [];
        watch._history.push({
          checked_at: watch.last_checked,
          fired: !!result.triggered,
          reason: result.message || '',
          condition: watch.condition,
        });
        if (watch._history.length > WATCH_HISTORY_MAX) {
          watch._history.splice(0, watch._history.length - WATCH_HISTORY_MAX);
        }
      } catch (err) {
        log('warn', `Watch check error (${watch.id}): ${err.message}`);
      }
    }

    return watches;
  });

  // Fire notifications outside the lock — writeToInbox does disk I/O
  for (const n of notifications) {
    try {
      writeToInbox(n.owner, n.slug, n.body);
    } catch (err) {
      log('warn', `Watch notification error: ${err.message}`);
    }
  }

  // Fire follow-up actions outside the lock. Each action runs independently —
  // failures in one don't prevent others from firing. Results are best-effort
  // logged and persisted onto the watch as _lastActionResult / _lastActionAt.
  for (const task of actionsToRun) {
    _runActionTask(task).catch(err => log('warn', `Watch action task ${task.watchId} crashed: ${err.message}`));
  }
}

/** Internal: invoke a watch's action and persist the result back onto the watch. */
async function _runActionTask(task) {
  const ctx = watchActions.buildTriggerContext(task.snapshot, {
    previousState: task.previousState,
    newState: task.newState,
    entity: task.entity,
    message: task.message,
  });
  // W-mp7i22mu00191b07 — let the registered target type contribute extra
  // template vars (e.g. http plugin exposes {{httpStatus}}, {{prevExtracted}}).
  // Failures are swallowed so a misbehaving plugin can't poison action dispatch.
  const tt = TARGET_TYPES[task.snapshot.targetType];
  if (tt && typeof tt.contextVars === 'function') {
    try {
      const extras = tt.contextVars(task.entity, task.previousState, task.newState) || {};
      if (extras && typeof extras === 'object') Object.assign(ctx, extras);
    } catch (err) {
      log('warn', `${task.snapshot.targetType} contextVars failed: ${err.message}`);
    }
  }
  const result = await watchActions.runWatchAction(task.snapshot, ctx);
  const isChain = Array.isArray(task.snapshot.action);
  const actionLabel = isChain
    ? `chain[${task.snapshot.action.length}]`
    : (task.snapshot.action?.type || '?');
  log(result.ok ? 'info' : 'warn',
    `Watch ${task.watchId} action ${actionLabel}: ${result.summary || (result.ok ? 'ok' : 'failed')}`);
  // Persist back onto the watch (best-effort).
  // P-w8e9b1f4 — Phase 4: array-form actions persist a `steps[]` array
  // alongside the aggregate {ok, summary} so dashboards / downstream readers
  // can inspect each step's result. Single-action watches keep the legacy
  // `dispatchedItemId` shortcut for back-compat.
  try {
    mutateWatches((watches) => {
      const w = watches.find(x => x.id === task.watchId);
      if (w) {
        if (isChain) {
          w._lastActionResult = {
            type: 'chain',
            ok: !!result.ok,
            summary: result.summary || '',
            aborted: !!result.aborted,
            steps: Array.isArray(result.steps) ? result.steps.map(s => ({
              type: s.type || null,
              ok: !!s.ok,
              skipped: !!s.skipped,
              summary: s.summary || '',
              dispatchedItemId: (s.result && s.result.dispatchedItemId) || null,
            })) : [],
            at: ts(),
          };
        } else {
          w._lastActionResult = {
            type: task.snapshot.action?.type || null,
            ok: !!result.ok,
            summary: result.summary || '',
            dispatchedItemId: result.dispatchedItemId || null,
            at: ts(),
          };
        }

        // P-w12d8f3a — Phase 6.2: backfill action_results onto the most
        // recent (fired) history entry. The history append already
        // happened inside the checkWatches lock; here we attach a small
        // bounded summary so the GET /api/watches/:id/history surface
        // (P-w13c2b6f) can show whether the action succeeded. Step-level
        // detail stays on _lastActionResult — history keeps only the
        // aggregate to honor the "small + bounded" contract.
        if (Array.isArray(w._history) && w._history.length > 0) {
          const last = w._history[w._history.length - 1];
          if (last && last.fired) {
            last.action_results = {
              type: w._lastActionResult.type,
              ok: w._lastActionResult.ok,
              summary: w._lastActionResult.summary || '',
            };
          }
        }
      }
      return watches;
    });
  } catch (err) {
    log('warn', `Watch ${task.watchId} action result persist failed: ${err.message}`);
  }
}

/**
 * Internal: capture state snapshot for a watch target.
 * Dispatches to the registered target type's captureState.
 *
 * W-mq1n83pw000844b8 — Preserve prior state on transient fetchEntity null
 * (and on captureState exceptions / unknown target types). The old behavior
 * wiped to {} whenever the entity could not be resolved, which then made
 * line 431 re-initialize via captureState on the next tick, losing
 * type-specific dedup keys (gh-author-prs `numbers`, work-item
 * `_unchangedTicks`, pipeline `_stuckStageTicks`). The result was watches
 * re-firing for already-seen entities after every engine restart while a
 * plugin's background fetch cache warmed up. Preserving prevState is
 * harmless for types where fetchEntity-null means "entity deleted" because
 * evaluate already gates on entity being non-null, and is the bug-free
 * behavior for plugins with transient nulls (cache miss, network hiccup).
 */
function _captureState(watch, state) {
  const prevState = watch._lastState || {};
  const tt = TARGET_TYPES[watch.targetType];
  if (!tt) return prevState;
  const entity = tt.fetchEntity(watch.target, state || {});
  if (!entity) return prevState;
  try {
    // P-w5b8d2c9 — Phase 2.2: pass prevState so captureState can carry
    // forward unchanged-tick counters (e.g. _unchangedTicks for work-item
    // stalled, _stuckStageTicks for pipeline stuck-in-stage). Existing
    // captureState fns that take only 1 arg ignore this — backward-compat.
    const out = tt.captureState(entity, prevState);
    return (out && typeof out === 'object') ? out : prevState;
  } catch (err) {
    log('warn', `_captureState ${watch.targetType}: ${err.message}`);
    return prevState;
  }
}

// ── Built-in target type registrations ───────────────────────────────────────

// PR — pull request status / build / comments / votes
registerTargetType(WATCH_TARGET_TYPE.PR, {
  label: 'Pull Request',
  description: 'Watch a pull request for merge, build, comment, or vote changes',
  conditions: [
    WATCH_CONDITION.MERGED, WATCH_CONDITION.BUILD_FAIL, WATCH_CONDITION.BUILD_PASS,
    WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
    WATCH_CONDITION.NEW_COMMENTS, WATCH_CONDITION.VOTE_CHANGE,
    // P-w4e2f6a1 — Phase 2.1: head/mergeability/draft/behind predicates.
    // Backed by Phase 1.1 captureState plumbing (headRefOid, mergeable,
    // isDraft, mergeStateStatus) and Phase 1.3 behindBy (read at fire-time
    // so this list is safe to advertise even before behindBy lands).
    WATCH_CONDITION.HEAD_COMMIT_CHANGE, WATCH_CONDITION.MERGEABLE_FLIPPED,
    WATCH_CONDITION.READY_FOR_MERGE, WATCH_CONDITION.BEHIND_MASTER,
    WATCH_CONDITION.DRAFT_FLIPPED,
  ],
  // W-mp7hg58e000b5212 — per-target absolute (fire-once when stopAfter=0).
  // Mirrors what shared.WATCH_ABSOLUTE_CONDITIONS used to centralize for PR.
  absoluteConditions: [
    WATCH_CONDITION.MERGED, WATCH_CONDITION.BUILD_FAIL, WATCH_CONDITION.BUILD_PASS,
    WATCH_CONDITION.READY_FOR_MERGE,
  ],
  fetchEntity: (target, state) => findPrByTarget(state.pullRequests, target),
  captureState: (pr) => ({
    status: pr.status, buildStatus: pr.buildStatus, reviewStatus: pr.reviewStatus,
    lastCommentDate: pr.humanFeedback?.lastProcessedCommentDate || null,
    // P-w1a3f9b2 — Phase 1.1: plumb head-commit / mergeability / draft / merge-state
    // so future predicates (Phase 2.1: head-commit-change, mergeable-flipped,
    // ready-for-merge, draft-flipped) can read them. Values are passed through
    // verbatim — including `undefined` for legacy PR objects that haven't been
    // re-polled yet — so symmetric prevState/newState comparisons stay false on
    // the first post-upgrade tick instead of synthesizing a phantom change.
    headRefOid: pr.headRefOid,
    mergeable: pr.mergeable,
    isDraft: pr.isDraft,
    mergeStateStatus: pr.mergeStateStatus,
  }),
  evaluate: (condition, pr, prevState, target) => {
    switch (condition) {
      case WATCH_CONDITION.MERGED:
        return { triggered: pr.status === 'merged', message: pr.status === 'merged' ? `PR ${target} was merged` : '' };
      case WATCH_CONDITION.BUILD_FAIL:
        return { triggered: pr.buildStatus === shared.BUILD_STATUS.FAILING, message: pr.buildStatus === shared.BUILD_STATUS.FAILING ? `PR ${target} build is failing` : '' };
      case WATCH_CONDITION.BUILD_PASS:
        return { triggered: pr.buildStatus === shared.BUILD_STATUS.PASSING, message: pr.buildStatus === shared.BUILD_STATUS.PASSING ? `PR ${target} build is passing` : '' };
      case WATCH_CONDITION.STATUS_CHANGE: {
        const changed = prevState.status !== undefined && prevState.status !== pr.status;
        return { triggered: changed, message: changed ? `PR ${target} status changed: ${prevState.status} → ${pr.status}` : '' };
      }
      case WATCH_CONDITION.ANY: {
        const anyChanged = prevState.status !== undefined && (
          prevState.status !== pr.status ||
          prevState.buildStatus !== pr.buildStatus ||
          prevState.reviewStatus !== pr.reviewStatus
        );
        return { triggered: anyChanged, message: anyChanged ? `PR ${target} changed` : '' };
      }
      case WATCH_CONDITION.NEW_COMMENTS: {
        const lastCommentDate = pr.humanFeedback?.lastProcessedCommentDate || null;
        const prevCommentDate = prevState.lastCommentDate || null;
        const hasNew = lastCommentDate && lastCommentDate !== prevCommentDate;
        return { triggered: !!hasNew, message: hasNew ? `PR ${target} has a new comment (${lastCommentDate})` : '' };
      }
      case WATCH_CONDITION.VOTE_CHANGE: {
        const changed = prevState.reviewStatus !== undefined && prevState.reviewStatus !== pr.reviewStatus;
        return { triggered: changed, message: changed ? `PR ${target} vote changed: ${prevState.reviewStatus} → ${pr.reviewStatus}` : '' };
      }
      // ── P-w4e2f6a1 — Phase 2.1 PR predicates ──────────────────────────
      case WATCH_CONDITION.HEAD_COMMIT_CHANGE: {
        // Fire only when both sides are defined, non-null SHAs that differ.
        // Suppresses post-upgrade phantom triggers when prevState is missing
        // headRefOid (legacy `_lastState`) or the poller hasn't populated it
        // yet on this tick.
        const prev = prevState.headRefOid;
        const cur = pr.headRefOid;
        const changed = !!(prev && cur && prev !== cur);
        return { triggered: changed, message: changed ? `PR ${target} head commit changed: ${prev} → ${cur}` : '' };
      }
      case WATCH_CONDITION.MERGEABLE_FLIPPED: {
        // engine/github.js:902-923 documents that mergeable=null means
        // "GitHub still computing" — NOT a positive signal. Only fire on
        // transitions between defined boolean values (true↔false). Any
        // transition involving null or undefined is ignored.
        const prev = prevState.mergeable;
        const cur = pr.mergeable;
        const isBool = (v) => v === true || v === false;
        const flipped = isBool(prev) && isBool(cur) && prev !== cur;
        return { triggered: flipped, message: flipped ? `PR ${target} mergeable flipped: ${prev} → ${cur}` : '' };
      }
      case WATCH_CONDITION.READY_FOR_MERGE: {
        // Compound predicate — fires when ALL gating signals line up.
        // Registered as an absolute condition (engine/shared.js
        // WATCH_ABSOLUTE_CONDITIONS) so it auto-expires on first trigger
        // when stopAfter=0. Treat isDraft !== true (catches null/undefined
        // legacy PRs that don't expose the field).
        const ready = pr.status === shared.PR_STATUS.ACTIVE
          && pr.reviewStatus === shared.REVIEW_STATUS.APPROVED
          && pr.buildStatus === shared.BUILD_STATUS.PASSING
          && pr.mergeable === true
          && pr.isDraft !== true;
        return { triggered: ready, message: ready ? `PR ${target} is ready for merge` : '' };
      }
      case WATCH_CONDITION.BEHIND_MASTER: {
        // Phase 1.3 will plumb pr.behindBy. Until then, this stays dormant
        // (typeof check rejects null/undefined). Treat null as not-behind
        // per the PRD spec — only a numeric > 0 fires.
        const b = pr.behindBy;
        const behind = typeof b === 'number' && b > 0;
        return { triggered: behind, message: behind ? `PR ${target} is behind master by ${b} commit${b === 1 ? '' : 's'}` : '' };
      }
      case WATCH_CONDITION.DRAFT_FLIPPED: {
        // Symmetric to mergeable-flipped: only fire on true↔false transitions
        // between defined boolean values. Suppresses post-upgrade phantom
        // triggers when isDraft was previously undefined.
        const prev = prevState.isDraft;
        const cur = pr.isDraft;
        const isBool = (v) => v === true || v === false;
        const flipped = isBool(prev) && isBool(cur) && prev !== cur;
        return { triggered: flipped, message: flipped ? `PR ${target} draft flipped: ${prev} → ${cur}` : '' };
      }
      default:
        return { triggered: false, message: `Unknown condition: ${condition}` };
    }
  },
});

// Work item — completed/failed/status-change
registerTargetType(WATCH_TARGET_TYPE.WORK_ITEM, {
  label: 'Work Item',
  description: 'Watch a work item for completion, failure, or status changes',
  conditions: [
    WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED,
    WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
    // P-w5b8d2c9 — Phase 2.2: stalled / retry-limit / dependency predicates.
    // Backed by Phase 1.2 captureState plumbing (retries, _pendingReason)
    // plus a Phase 2.2 _unchangedTicks counter computed inside captureState.
    WATCH_CONDITION.STALLED, WATCH_CONDITION.RETRY_LIMIT_REACHED,
    WATCH_CONDITION.DEPENDENCY_MET,
  ],
  // W-mp7hg58e000b5212 — per-target absolute (fire-once when stopAfter=0).
  absoluteConditions: [
    WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED,
    WATCH_CONDITION.RETRY_LIMIT_REACHED,
  ],
  fetchEntity: (target, state) => (state.workItems || []).find(w => w.id === target) || null,
  // P-w2c8d1e7 — Phase 1.2: surface retry count, pending-reason, and branch
  // for upcoming stalled / branch-aware predicates. `retries` aliases the
  // private `_retryCount` so guards can read a stable field name.
  // P-w5b8d2c9 — Phase 2.2: also carry forward `_unchangedTicks`, an
  // unbounded counter incremented each tick that the observable fields
  // (status / retries / _pendingReason / branch) all match prevState. Reset
  // to 0 on any change. Drives the `stalled` predicate.
  captureState: (wi, prev) => {
    const status = wi.status;
    const retries = wi._retryCount || 0;
    const pendingReason = wi._pendingReason || null;
    const branch = wi.branch || null;
    const unchanged = !!prev
      && prev.status === status
      && (prev.retries || 0) === retries
      && (prev._pendingReason || null) === pendingReason
      && (prev.branch || null) === branch;
    const unchangedTicks = unchanged ? ((prev._unchangedTicks || 0) + 1) : 0;
    return {
      status, retries, _pendingReason: pendingReason, branch,
      _unchangedTicks: unchangedTicks,
    };
  },
  evaluate: (condition, wi, prevState, target) => {
    switch (condition) {
      case WATCH_CONDITION.COMPLETED: {
        const done = shared.DONE_STATUSES.has(wi.status);
        return { triggered: done, message: done ? `Work item ${target} completed (${wi.status})` : '' };
      }
      case WATCH_CONDITION.FAILED: {
        const failed = wi.status === shared.WI_STATUS.FAILED;
        return { triggered: failed, message: failed ? `Work item ${target} failed` : '' };
      }
      case WATCH_CONDITION.STATUS_CHANGE: {
        const changed = prevState.status !== undefined && prevState.status !== wi.status;
        return { triggered: changed, message: changed ? `Work item ${target} status: ${prevState.status} → ${wi.status}` : '' };
      }
      case WATCH_CONDITION.ANY: {
        const anyChanged = prevState.status !== undefined && prevState.status !== wi.status;
        return { triggered: anyChanged, message: anyChanged ? `Work item ${target} changed (${wi.status})` : '' };
      }
      // ── P-w5b8d2c9 — Phase 2.2 work-item predicates ──────────────────
      case WATCH_CONDITION.STALLED: {
        // Suppress on terminal items — a done/failed work item is not
        // "stalled", it's finished. Otherwise fire when the carried-forward
        // _unchangedTicks counter has crossed the threshold.
        const isTerminal = shared.DONE_STATUSES.has(wi.status)
          || wi.status === shared.WI_STATUS.FAILED
          || wi.status === shared.WI_STATUS.CANCELLED;
        if (isTerminal) return { triggered: false, message: '' };
        const ticks = prevState._unchangedTicks || 0;
        const threshold = shared.WATCH_STALLED_DEFAULT_TICKS;
        const stalled = ticks >= threshold;
        return { triggered: stalled, message: stalled ? `Work item ${target} stalled (${wi.status}, no change for ${ticks} checks)` : '' };
      }
      case WATCH_CONDITION.RETRY_LIMIT_REACHED: {
        // Compound state assertion — registered as absolute so it auto-expires
        // after the first fire when stopAfter=0 (otherwise it would re-fire
        // every tick while the work item sits at maxRetries).
        const retries = wi._retryCount || 0;
        const limit = shared.ENGINE_DEFAULTS.maxRetries;
        const reached = retries >= limit;
        return { triggered: reached, message: reached ? `Work item ${target} hit retry limit (${retries} >= ${limit})` : '' };
      }
      case WATCH_CONDITION.DEPENDENCY_MET: {
        // Transition predicate: prevState had _pendingReason='dependency_unmet'
        // and current does not. Restrict to pending/dispatched statuses so
        // we don't confuse "dependency met" with "item terminated for other
        // reasons" (e.g. cancelled while waiting on a dep).
        const wasUnmet = prevState._pendingReason === 'dependency_unmet';
        const nowMet = (wi._pendingReason || null) !== 'dependency_unmet';
        const validStatus = wi.status === shared.WI_STATUS.PENDING
          || wi.status === shared.WI_STATUS.DISPATCHED;
        const fired = wasUnmet && nowMet && validStatus;
        return { triggered: fired, message: fired ? `Work item ${target} dependency met (status=${wi.status}, reason=${wi._pendingReason || 'none'})` : '' };
      }
      default:
        return { triggered: false, message: `Unknown condition: ${condition}` };
    }
  },
});

// Meeting — concluded (terminal status) / status-change / any
const MEETING_TERMINAL = new Set(['completed', 'archived']);
registerTargetType(WATCH_TARGET_TYPE.MEETING, {
  label: 'Meeting',
  description: 'Watch a meeting for conclusion or status changes',
  conditions: [WATCH_CONDITION.CONCLUDED, WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY],
  absoluteConditions: [WATCH_CONDITION.CONCLUDED],
  fetchEntity: (target, state) => (state.meetings || []).find(m => m && m.id === target) || null,
  captureState: (m) => ({ status: m.status }),
  evaluate: (condition, m, prevState, target) => {
    switch (condition) {
      case WATCH_CONDITION.CONCLUDED: {
        const concluded = MEETING_TERMINAL.has(String(m.status || '').toLowerCase());
        return { triggered: concluded, message: concluded ? `Meeting ${target} concluded (${m.status})` : '' };
      }
      case WATCH_CONDITION.STATUS_CHANGE: {
        const changed = prevState.status !== undefined && prevState.status !== m.status;
        return { triggered: changed, message: changed ? `Meeting ${target} status: ${prevState.status} → ${m.status}` : '' };
      }
      case WATCH_CONDITION.ANY: {
        const anyChanged = prevState.status !== undefined && prevState.status !== m.status;
        return { triggered: anyChanged, message: anyChanged ? `Meeting ${target} changed (${m.status})` : '' };
      }
      default:
        return { triggered: false, message: `Unknown condition: ${condition}` };
    }
  },
});

// Plan / PRD — approved/rejected/completed/status-change/any
function _findPlan(target, state) {
  const t = String(target);
  return (state.plans || []).find(p => {
    if (!p) return false;
    if (p._source === t || p._source === t + '.json') return true;
    if (p._sourcePlan === t || p._sourcePlan === t + '.md') return true;
    if (p.id === t) return true;
    return false;
  }) || null;
}
// P-w2c8d1e7 — Phase 1.2: derive item progress from the PRD's
// missing_features array. Returns 0 when the array is missing/empty so
// guards can compare numerically without null-checks.
function _planItemsTotal(p) {
  return Array.isArray(p?.missing_features) ? p.missing_features.length : 0;
}
function _planItemsDone(p) {
  if (!Array.isArray(p?.missing_features)) return 0;
  let n = 0;
  for (const item of p.missing_features) {
    if (item && shared.DONE_STATUSES.has(item.status)) n += 1;
  }
  return n;
}
// P-w5b8d2c9 — Phase 2.2: max retry count across plan items, drives the
// `item-failed-n-times` predicate. Treats null/undefined retries as 0 so
// the predicate stays dormant for plans that have no retry data.
function _planMaxItemRetries(p) {
  if (!Array.isArray(p?.missing_features)) return 0;
  let max = 0;
  for (const item of p.missing_features) {
    const rc = (item && item._retryCount) || 0;
    if (rc > max) max = rc;
  }
  return max;
}
registerTargetType(WATCH_TARGET_TYPE.PLAN, {
  label: 'Plan / PRD',
  description: 'Watch a plan/PRD for approval, rejection, completion, or status changes',
  conditions: [
    WATCH_CONDITION.APPROVED, WATCH_CONDITION.REJECTED, WATCH_CONDITION.COMPLETED,
    WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
    // P-w5b8d2c9 — Phase 2.2: progress / failure-rate predicates derived
    // from the PRD's missing_features array.
    WATCH_CONDITION.ALL_ITEMS_DONE, WATCH_CONDITION.ITEM_FAILED_N_TIMES,
  ],
  // W-mp7hg58e000b5212 — per-target absolute (fire-once when stopAfter=0).
  absoluteConditions: [
    WATCH_CONDITION.APPROVED, WATCH_CONDITION.REJECTED, WATCH_CONDITION.COMPLETED,
    WATCH_CONDITION.ALL_ITEMS_DONE, WATCH_CONDITION.ITEM_FAILED_N_TIMES,
  ],
  fetchEntity: _findPlan,
  // P-w2c8d1e7 — Phase 1.2: surface item progress derived from the PRD's
  // missing_features array so plan-level predicates (e.g. all-items-done)
  // can read it directly from _lastState.
  // P-w5b8d2c9 — Phase 2.2: also surface max_item_retries for the
  // item-failed-n-times predicate. All values default to 0 so legacy
  // `_lastState` shapes don't break.
  captureState: (p) => ({
    status: p.status,
    planStale: !!p.planStale,
    items_done: _planItemsDone(p),
    items_total: _planItemsTotal(p),
    max_item_retries: _planMaxItemRetries(p),
  }),
  evaluate: (condition, p, prevState, target) => {
    const status = String(p.status || '').toLowerCase();
    switch (condition) {
      case WATCH_CONDITION.APPROVED:
        return { triggered: status === shared.PLAN_STATUS.APPROVED, message: status === shared.PLAN_STATUS.APPROVED ? `Plan ${target} approved` : '' };
      case WATCH_CONDITION.REJECTED:
        return { triggered: status === shared.PLAN_STATUS.REJECTED, message: status === shared.PLAN_STATUS.REJECTED ? `Plan ${target} rejected` : '' };
      case WATCH_CONDITION.COMPLETED:
        return { triggered: status === shared.PLAN_STATUS.COMPLETED, message: status === shared.PLAN_STATUS.COMPLETED ? `Plan ${target} completed` : '' };
      case WATCH_CONDITION.STATUS_CHANGE: {
        const changed = prevState.status !== undefined && prevState.status !== p.status;
        return { triggered: changed, message: changed ? `Plan ${target} status: ${prevState.status} → ${p.status}` : '' };
      }
      case WATCH_CONDITION.ANY: {
        const anyChanged = prevState.status !== undefined &&
          (prevState.status !== p.status || prevState.planStale !== !!p.planStale);
        return { triggered: anyChanged, message: anyChanged ? `Plan ${target} changed (${p.status})` : '' };
      }
      // ── P-w5b8d2c9 — Phase 2.2 plan predicates ───────────────────────
      case WATCH_CONDITION.ALL_ITEMS_DONE: {
        // Compound state assertion — registered as absolute so it auto-expires
        // after first fire when stopAfter=0 (otherwise a fully-complete
        // plan would re-fire every tick). Requires items_total > 0 so an
        // empty PRD doesn't trigger the predicate vacuously.
        const total = _planItemsTotal(p);
        const done = _planItemsDone(p);
        const allDone = total > 0 && done === total;
        return { triggered: allDone, message: allDone ? `Plan ${target} all items done (${done}/${total})` : '' };
      }
      case WATCH_CONDITION.ITEM_FAILED_N_TIMES: {
        // Compound state assertion — fires when ANY missing_feature has a
        // _retryCount at or beyond ENGINE_DEFAULTS.maxRetries. Absolute so
        // the alert fires once when the threshold is reached.
        const maxRc = _planMaxItemRetries(p);
        const limit = shared.ENGINE_DEFAULTS.maxRetries;
        const fired = maxRc >= limit;
        return { triggered: fired, message: fired ? `Plan ${target} has item retried ${maxRc} times (>= ${limit})` : '' };
      }
      default:
        return { triggered: false, message: `Unknown condition: ${condition}` };
    }
  },
});

// Schedule — ran (lastRun changed) / enabled / disabled / status-change / any
function _findSchedule(target, state) {
  const schedules = (state.config?.schedules) || state.schedules || [];
  const sched = schedules.find(s => s && s.id === target) || null;
  if (!sched) return null;
  const runEntry = (state.scheduleRuns || {})[target];
  const lastRun = runEntry && typeof runEntry === 'object' ? runEntry.lastRun : (typeof runEntry === 'string' ? runEntry : null);
  // Synthesize an entity that combines schedule config + run state
  return { ...sched, _lastRun: lastRun || null };
}
registerTargetType(WATCH_TARGET_TYPE.SCHEDULE, {
  label: 'Schedule',
  description: 'Watch a scheduled task for runs or enable/disable changes',
  conditions: [
    WATCH_CONDITION.RAN, WATCH_CONDITION.ENABLED, WATCH_CONDITION.DISABLED,
    WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
  ],
  // W-mp7hg58e000b5212 — schedule has no absolute conditions today; all of
  // its keywords are change-based (RAN/ENABLED/DISABLED detect transitions).
  absoluteConditions: [],
  fetchEntity: _findSchedule,
  // P-w2c8d1e7 — Phase 1.2: surface next_run as null. Cron parsing in
  // engine/scheduler.js exposes parseCronExpr/.matches() but not a
  // next-fire calculator, so the snapshot field is reserved (always null
  // today) for forward-compat with future predicates that gain a helper.
  captureState: (s) => ({
    lastRun: s._lastRun || null,
    enabled: s.enabled !== false,
    next_run: null,
  }),
  evaluate: (condition, s, prevState, target) => {
    const enabled = s.enabled !== false;
    const lastRun = s._lastRun || null;
    switch (condition) {
      case WATCH_CONDITION.RAN: {
        const ran = !!lastRun && lastRun !== (prevState.lastRun || null);
        return { triggered: ran, message: ran ? `Schedule ${target} ran (${lastRun})` : '' };
      }
      case WATCH_CONDITION.ENABLED: {
        const flipped = prevState.enabled === false && enabled === true;
        return { triggered: flipped, message: flipped ? `Schedule ${target} was enabled` : '' };
      }
      case WATCH_CONDITION.DISABLED: {
        const flipped = prevState.enabled === true && enabled === false;
        return { triggered: flipped, message: flipped ? `Schedule ${target} was disabled` : '' };
      }
      case WATCH_CONDITION.STATUS_CHANGE: {
        const changed = prevState.enabled !== undefined && prevState.enabled !== enabled;
        return { triggered: changed, message: changed ? `Schedule ${target} enabled changed: ${prevState.enabled} → ${enabled}` : '' };
      }
      case WATCH_CONDITION.ANY: {
        const anyChanged = prevState.enabled !== undefined &&
          (prevState.enabled !== enabled || prevState.lastRun !== lastRun);
        return { triggered: anyChanged, message: anyChanged ? `Schedule ${target} changed` : '' };
      }
      default:
        return { triggered: false, message: `Unknown condition: ${condition}` };
    }
  },
});

// Pipeline — completed/failed/stage-complete/status-change/any. Target is
// the pipeline ID; we track the latest run.
function _findPipelineLatestRun(target, state) {
  const runsByPipeline = state.pipelineRuns || {};
  const list = runsByPipeline[target];
  if (!Array.isArray(list) || list.length === 0) return null;
  // The latest run is the last entry (startRun pushes; cleanup keeps last 10).
  return { ...list[list.length - 1], _pipelineId: target };
}
function _completedStageCount(run) {
  if (!run || !run.stages) return 0;
  let n = 0;
  for (const st of Object.values(run.stages)) {
    const s = String((st && st.status) || '').toLowerCase();
    if (s === 'completed' || s === 'failed' || s === 'stopped') n += 1;
  }
  return n;
}
// P-w2c8d1e7 — Phase 1.2: report the id of the first non-terminal stage
// in run.stages iteration order. run.stages is built from pipeline.stages
// in declaration order (engine/pipeline.js startRun ~line 84) so the first
// non-terminal entry is the currently-executing or next-pending stage.
const _PIPELINE_TERMINAL = new Set(['completed', 'failed', 'stopped']);
function _currentPipelineStageId(run) {
  if (!run || !run.stages || typeof run.stages !== 'object') return null;
  for (const [stageId, st] of Object.entries(run.stages)) {
    const s = String((st && st.status) || '').toLowerCase();
    if (!_PIPELINE_TERMINAL.has(s)) return stageId;
  }
  return null;
}
registerTargetType(WATCH_TARGET_TYPE.PIPELINE, {
  label: 'Pipeline',
  description: 'Watch the latest run of a pipeline for completion, failure, or stage progress',
  conditions: [
    WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED, WATCH_CONDITION.STAGE_COMPLETE,
    WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
    // P-w5b8d2c9 — Phase 2.2: stage-targeted predicates derived from the
    // current_stage_id snapshot field plus a Phase 2.2 _stuckStageTicks
    // counter computed inside captureState.
    WATCH_CONDITION.STAGE_ADVANCED, WATCH_CONDITION.STUCK_IN_STAGE,
  ],
  // W-mp7hg58e000b5212 — per-target absolute (fire-once when stopAfter=0).
  absoluteConditions: [WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED],
  fetchEntity: _findPipelineLatestRun,
  // P-w2c8d1e7 — Phase 1.2: surface current_stage_id (first non-terminal
  // stage in declaration order) for stage-targeted predicates.
  // P-w5b8d2c9 — Phase 2.2: also carry forward `_stuckStageTicks`, an
  // unbounded counter that increments each tick the same non-null
  // current_stage_id persists. Reset to 0 when the stage advances or when
  // current_stage_id is null (run has no live stage). Drives the
  // `stuck-in-stage` predicate.
  captureState: (run, prev) => {
    const runId = run.runId || null;
    const status = run.status || null;
    const completedStages = _completedStageCount(run);
    const currentStageId = _currentPipelineStageId(run);
    const sameStage = !!prev
      && currentStageId !== null
      && prev.current_stage_id === currentStageId
      && prev.runId === runId;
    const stuckStageTicks = sameStage ? ((prev._stuckStageTicks || 0) + 1) : 0;
    return {
      runId, status, completedStages, current_stage_id: currentStageId,
      _stuckStageTicks: stuckStageTicks,
    };
  },
  evaluate: (condition, run, prevState, target) => {
    switch (condition) {
      case WATCH_CONDITION.COMPLETED: {
        const done = run.status === shared.PIPELINE_STATUS.COMPLETED;
        return { triggered: done, message: done ? `Pipeline ${target} completed (run ${run.runId})` : '' };
      }
      case WATCH_CONDITION.FAILED: {
        const failed = run.status === shared.PIPELINE_STATUS.FAILED || run.status === shared.PIPELINE_STATUS.STOPPED;
        return { triggered: failed, message: failed ? `Pipeline ${target} failed (${run.status}, run ${run.runId})` : '' };
      }
      case WATCH_CONDITION.STAGE_COMPLETE: {
        const cur = _completedStageCount(run);
        const prev = prevState.completedStages || 0;
        const advanced = prevState.completedStages !== undefined && cur > prev && (prevState.runId === run.runId);
        return { triggered: advanced, message: advanced ? `Pipeline ${target} stage progress: ${prev} → ${cur}` : '' };
      }
      case WATCH_CONDITION.STATUS_CHANGE: {
        const changed = prevState.status !== undefined && prevState.status !== run.status;
        return { triggered: changed, message: changed ? `Pipeline ${target} status: ${prevState.status} → ${run.status}` : '' };
      }
      case WATCH_CONDITION.ANY: {
        const anyChanged = prevState.status !== undefined &&
          (prevState.status !== run.status || prevState.runId !== run.runId ||
            (prevState.completedStages || 0) !== _completedStageCount(run));
        return { triggered: anyChanged, message: anyChanged ? `Pipeline ${target} changed (${run.status})` : '' };
      }
      // ── P-w5b8d2c9 — Phase 2.2 pipeline predicates ───────────────────
      case WATCH_CONDITION.STAGE_ADVANCED: {
        // Triggers when current_stage_id changes within the same run.
        // Different runId means a new run started — handled by status-change
        // semantics, not by stage-advanced. Suppresses the
        // undefined-prev → defined-cur transition (post-upgrade phantom).
        const cur = _currentPipelineStageId(run);
        const prev = prevState.current_stage_id;
        const advanced = prev !== undefined && prev !== cur && prevState.runId === run.runId;
        return { triggered: advanced, message: advanced ? `Pipeline ${target} advanced stage: ${prev} → ${cur}` : '' };
      }
      case WATCH_CONDITION.STUCK_IN_STAGE: {
        // Fires when the carried-forward _stuckStageTicks counter has
        // crossed the threshold. Implicitly requires current_stage_id !==
        // null (counter resets to 0 when null per captureState).
        const ticks = prevState._stuckStageTicks || 0;
        const threshold = shared.WATCH_STUCK_STAGE_DEFAULT_TICKS;
        const stuck = ticks >= threshold;
        return { triggered: stuck, message: stuck ? `Pipeline ${target} stuck in stage ${prevState.current_stage_id} for ${ticks} checks` : '' };
      }
      default:
        return { triggered: false, message: `Unknown condition: ${condition}` };
    }
  },
});

// Dispatch — completed/failed/status-change/any. Target is dispatch entry id.
function _findDispatchEntry(target, state) {
  const d = state.dispatch || {};
  for (const list of ['active', 'pending', 'completed']) {
    const entry = (d[list] || []).find(e => e && e.id === target);
    if (entry) return { ...entry, _list: list };
  }
  return null;
}
const DISPATCH_TERMINAL = new Set(['done', 'completed', 'failed', 'cancelled']);
registerTargetType(WATCH_TARGET_TYPE.DISPATCH, {
  label: 'Dispatch',
  description: 'Watch a dispatch entry for terminal status or status changes',
  conditions: [
    WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED,
    WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
  ],
  // W-mp7hg58e000b5212 — per-target absolute (fire-once when stopAfter=0).
  absoluteConditions: [WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED],
  fetchEntity: _findDispatchEntry,
  captureState: (e) => ({ status: e.status || e._list || null, list: e._list || null }),
  evaluate: (condition, e, prevState, target) => {
    const status = String(e.status || '').toLowerCase();
    const list = e._list || null;
    switch (condition) {
      case WATCH_CONDITION.COMPLETED: {
        const done = list === 'completed' && (status === '' || (DISPATCH_TERMINAL.has(status) && status !== 'failed'));
        return { triggered: done, message: done ? `Dispatch ${target} completed` : '' };
      }
      case WATCH_CONDITION.FAILED: {
        const failed = status === 'failed' || status === 'error';
        return { triggered: failed, message: failed ? `Dispatch ${target} failed` : '' };
      }
      case WATCH_CONDITION.STATUS_CHANGE: {
        const changed = prevState.status !== undefined && prevState.status !== (e.status || list);
        return { triggered: changed, message: changed ? `Dispatch ${target} status: ${prevState.status} → ${e.status || list}` : '' };
      }
      case WATCH_CONDITION.ANY: {
        const cur = e.status || list;
        const anyChanged = prevState.status !== undefined &&
          (prevState.status !== cur || prevState.list !== list);
        return { triggered: anyChanged, message: anyChanged ? `Dispatch ${target} changed (${cur})` : '' };
      }
      default:
        return { triggered: false, message: `Unknown condition: ${condition}` };
    }
  },
});

// Agent — activity-change / status-change / any. State.agents must be an
// array of agent objects (id + status), as produced by queries.getAgents.
function _findAgent(target, state) {
  return (state.agents || []).find(a => a && a.id === target) || null;
}
registerTargetType(WATCH_TARGET_TYPE.AGENT, {
  label: 'Agent',
  description: 'Watch an agent for activity transitions (idle ↔ working) or status changes',
  conditions: [
    WATCH_CONDITION.ACTIVITY_CHANGE, WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
  ],
  // W-mp7hg58e000b5212 — agent has no absolute conditions today; all of its
  // keywords are change-based.
  absoluteConditions: [],
  fetchEntity: _findAgent,
  // P-w2c8d1e7 — Phase 1.2: surface currentDispatchId so predicates can
  // distinguish "agent now working on a different dispatch" from a plain
  // status flip. Sourced from queries.getAgents() which derives it from
  // dispatch.json (active list) via getAgentStatus.dispatch_id.
  captureState: (a) => ({
    status: a.status || null,
    currentDispatchId: a.currentDispatchId || null,
  }),
  evaluate: (condition, a, prevState, target) => {
    const cur = a.status || null;
    switch (condition) {
      case WATCH_CONDITION.ACTIVITY_CHANGE: {
        // Triggers on transitions involving the 'working' status
        const wasWorking = prevState.status === 'working';
        const nowWorking = cur === 'working';
        const changed = prevState.status !== undefined && wasWorking !== nowWorking;
        return { triggered: changed, message: changed ? `Agent ${target} activity: ${prevState.status} → ${cur}` : '' };
      }
      case WATCH_CONDITION.STATUS_CHANGE: {
        const changed = prevState.status !== undefined && prevState.status !== cur;
        return { triggered: changed, message: changed ? `Agent ${target} status: ${prevState.status} → ${cur}` : '' };
      }
      case WATCH_CONDITION.ANY: {
        const anyChanged = prevState.status !== undefined && prevState.status !== cur;
        return { triggered: anyChanged, message: anyChanged ? `Agent ${target} changed (${cur})` : '' };
      }
      default:
        return { triggered: false, message: `Unknown condition: ${condition}` };
    }
  },
});

// ── Plugin folder discovery (W-mp7hg58e000b5212) ─────────────────────────────
// Auto-load `<MINIONS_DIR>/watches.d/*.js` at module load time, AFTER the 8
// built-in registerTargetType() calls so plugins can override built-ins by
// re-using their key (last-write-wins). Per the README block at the top of
// this file: missing folder is silent, per-file try/catch isolates failures,
// and INFO/WARN logs trace what loaded.
function _loadPluginTargetTypes() {
  const dir = path.join(shared.MINIONS_DIR, 'watches.d');
  let entries;
  try {
    entries = fs.readdirSync(dir);
  } catch (err) {
    if (err && err.code === 'ENOENT') return; // silent no-op
    log('warn', `watches.d/ readdir failed: ${err.message}`);
    return;
  }
  for (const fname of entries.sort()) {
    if (!fname.endsWith('.js')) continue;
    const fp = path.join(dir, fname);
    try {
      // Bust cache so re-loads (tests, future hot-reload) pick up file edits.
      try { delete require.cache[require.resolve(fp)]; } catch {}
      const mod = require(fp);
      const list = Array.isArray(mod) ? mod : [mod];
      for (const entry of list) {
        if (!entry || !entry.name || !entry.spec) {
          log('warn', `watches.d/${fname}: export missing { name, spec } — skipping entry`);
          continue;
        }
        try {
          registerTargetType(entry.name, entry.spec);
          log('info', `Loaded watch target type '${entry.name}' from watches.d/${fname}`);
        } catch (regErr) {
          log('warn', `watches.d/${fname}: registerTargetType('${entry.name}') failed: ${regErr.message}`);
        }
      }
    } catch (err) {
      log('warn', `watches.d/${fname}: load failed: ${err.message}`);
    }
  }
}
_loadPluginTargetTypes();

module.exports = {
  DEFAULT_WATCH_INTERVAL,
  getWatches,
  createWatch,
  updateWatch,
  deleteWatch,
  evaluateWatch,
  checkWatches,
  registerTargetType,
  getTargetType,
  listTargetTypes,
  // Re-exported from engine/watch-actions for callers that prefer a single
  // import surface (dashboard.js handlers, tests). See engine/watch-actions.js
  // for the canonical implementations and the action registry.
  registerActionType: watchActions.registerActionType,
  getActionType: watchActions.getActionType,
  listActionTypes: watchActions.listActionTypes,
  runWatchAction: watchActions.runWatchAction,
  runWatchActionChain: watchActions.runWatchActionChain,
  validateAction: watchActions.validateAction,
  _captureState,  // exported for testing
  _watchesPath,   // exported for testing — dynamic, respects MINIONS_TEST_DIR
  _runActionTask, // exported for testing — invoked by checkWatches per fired action
  _TARGET_TYPES: TARGET_TYPES, // exported for testing — direct registry access
  _loadPluginTargetTypes, // exported for testing — re-scans watches.d/
};
