/**
 * Watch runner — the background ticker that drives long-task watches.
 *
 * Runs inside chat-standalone (single instance, guarded). Each tick it
 * polls every due active watch (single-flight, bounded concurrency),
 * evaluates done/fail/timeout, and on a terminal state runs the watch's
 * action: feed the result back into the originating chat session
 * (mode=chat, via the runChat callback), chain a tool (mode=tool), or
 * just record it (mode=none). Per-poll it emits ambient progress through
 * onProgress — that lands as a status chip, NOT a chat message.
 *
 * Guards (see design doc §3): max_polls, timeout, max_lifetime, single-
 * flight, consecutive-error cutoff, chain-depth on tool callbacks.
 */

import { dispatchTool } from '../chat/tool-dispatcher';
import { resolveTemplate, resolveDeep, getPath } from './template';
import {
  listDue, updateWatch, getWatch, type Watch, type WatchState,
} from './watch-store';

const TICK_MS = 20_000;            // scan cadence; each watch paces itself via next_poll_at
const MAX_CONCURRENT = 8;          // simultaneous in-flight polls
const MAX_CONSEC_ERRORS = 10;      // device unreachable (e.g. mid-reboot) tolerated this many polls

export interface WatchRunnerHooks {
  /** Emit ambient progress (status chip) for a watch's session. */
  onProgress?: (sessionId: string, payload: Record<string, unknown>) => void;
  /** Feed a completion message back into a chat session (assistant replies). */
  runChat?: (sessionId: string, text: string) => void;
}

function truthy(v: unknown): boolean {
  if (v == null || v === false || v === 0) return false;
  if (typeof v === 'string') { const s = v.trim().toLowerCase(); return s !== '' && s !== 'false' && s !== '0' && s !== 'null'; }
  return true;
}

function parseResult(content: string): any {
  try { return JSON.parse(content); } catch { return { _raw: content }; }
}

/** Heuristic: spot common "this work is finished" shapes from a poll
 *  result, regardless of whether the connector author thought to set
 *  `terminal: true` or pre-declare done conditions. Walks well-known
 *  state-bearing fields (state / status / phase / result / done /
 *  finished / complete / completed) and matches their values against
 *  a curated vocabulary used across CI, Jenkins, k8s, generic build
 *  systems, etc.
 *  Returns { failure } when a hit is found, null otherwise. Intended
 *  to run AFTER user's explicit done_match/done_path, so a caller who
 *  configured "done when status == running" (rare but legal) still
 *  wins. */
function detectTerminalState(obj: any): { failure: boolean; source: string; value: string } | null {
  if (!obj || typeof obj !== 'object') return null;
  // Boolean done-ish flags
  for (const f of ['done', 'finished', 'complete', 'completed']) {
    if (truthy(obj[f])) return { failure: false, source: f, value: 'true' };
  }
  // State-bearing fields with a terminal vocabulary
  const fields = ['state', 'status', 'phase', 'result', 'conclusion', 'lifecycle_state'];
  const failureWords = new Set([
    'failed', 'failure', 'error', 'errored', 'cancelled', 'canceled',
    'aborted', 'killed', 'terminated', 'timeout', 'timed_out', 'rejected',
    'unstable', 'broken',
  ]);
  const successWords = new Set([
    'done', 'success', 'succeeded', 'complete', 'completed', 'finished',
    'passed', 'ok', 'green', 'healthy',
  ]);
  for (const f of fields) {
    const raw = obj[f];
    if (raw == null) continue;
    const v = String(raw).toLowerCase().trim();
    if (!v) continue;
    if (failureWords.has(v)) return { failure: true, source: f, value: v };
    if (successWords.has(v)) return { failure: false, source: f, value: v };
  }
  return null;
}

const g = globalThis as any;

export function startWatchRunner(hooks: WatchRunnerHooks = {}): void {
  if (g.__forgeWatchRunner) return;       // single instance per process
  const running = new Set<string>();      // watch ids currently polling (single-flight)

  const finish = (w: Watch, state: WatchState, obj: unknown, summary: string) => {
    const now = Date.now();
    updateWatch(w.id, { state, last_result: obj, last_text: summary }, now);
    // Terminal watch_status — tells the UI to drop the progress chip
    // immediately (otherwise it lingers until the 150s prune). The real
    // completion text goes via on_done below; this is just the chip kill.
    if (hooks.onProgress && w.session_id) {
      hooks.onProgress(w.session_id, { watch_id: w.id, state, done: true, text: summary });
    }
    const action = state === 'done' ? w.on_done : w.on_fail;
    const mode = action?.mode || 'chat';
    if (mode === 'none' || !action) return;
    const ctx = { poll: obj };
    if (mode === 'chat') {
      if (!w.session_id || !hooks.runChat) return;
      const msg = action.message ? resolveTemplate(action.message, ctx) : summary;
      const tag = state === 'done' ? '✅' : '⚠️';
      hooks.runChat(w.session_id, `[background watch ${w.label}] ${tag} ${msg}`);
    } else if (mode === 'tool' && action.tool) {
      const input = (resolveDeep(action.args || {}, ctx) as Record<string, unknown>) || {};
      void dispatchTool(
        { id: `watch-chain-${w.id}`, name: action.tool, input },
        { sessionId: w.session_id || undefined, chainDepth: Math.max(0, w.chain_depth - 1) } as any,
      ).catch((e) => console.error('[watch] on_done tool chain failed', w.id, e));
    }
  };

  const emitProgress = (w: Watch, obj: unknown, pollCount: number) => {
    if (!w.session_id || !hooks.onProgress) return;
    if (w.progress && w.progress.show === false) return;
    const tmpl = w.progress?.message || 'Watching {label} … poll {poll_count}/{max_polls}';
    const text = resolveTemplate(tmpl, { poll: obj, poll_count: pollCount, max_polls: w.max_polls, label: w.label });
    hooks.onProgress(w.session_id, { watch_id: w.id, state: 'active', poll_count: pollCount, text });
  };

  const pollOne = async (w: Watch): Promise<void> => {
    const now = Date.now();
    // Hard lifetime backstop.
    if (now - w.created_at > 2 * w.timeout_sec * 1000) {
      return finish(w, 'timed_out', w.last_result, `${w.label}: watch lifetime exceeded — please verify manually.`);
    }
    let res;
    try {
      // noTruncation=true so http-protocol polls skip the "HTTP 200 OK · GET …"
      // preamble — the body comes back as raw JSON so done_path/done_match can
      // see it. (Without this, every http-protocol watch would silently never
      // hit its done condition, e.g. jenkins.get_build never resolving.)
      // Empty connector_id → builtin (e.g. get_pipeline_status); dispatch
      // bare name so dispatchTool routes to the global BUILTINS table.
      const dispatchName = w.connector_id ? `${w.connector_id}.${w.poll_tool}` : w.poll_tool;
      res = await dispatchTool({ id: `watch-${w.id}-${w.polls}`, name: dispatchName, input: w.poll_args }, { noTruncation: true } as any);
    } catch (e) {
      res = { content: String(e), is_error: true };
    }

    if (res.is_error) {
      // Poll failed (often expected: device rebooting). Tolerate up to N
      // consecutive, then give up.
      const errs = w.err_count + 1;
      if (errs >= MAX_CONSEC_ERRORS) {
        return finish(w, 'errored', { error: res.content }, `${w.label}: ${MAX_CONSEC_ERRORS} consecutive poll errors — giving up. Last: ${String(res.content).slice(0, 200)}`);
      }
      updateWatch(w.id, { err_count: errs, next_poll_at: now + w.interval_sec * 1000 }, now);
      emitProgress(w, { _error: String(res.content).slice(0, 120) }, w.polls);
      return;
    }

    const obj = parseResult(res.content);
    const polls = w.polls + 1;

    // fail check
    if (w.fail_path && truthy(getPath(obj, w.fail_path))) {
      return finish(w, 'failed', obj, `${w.label}: failure condition met.`);
    }
    // Hard terminal check — if the poll tool itself says "this is a
    // terminal state" (cancelled / failed / done / etc.), believe it
    // regardless of the user-configured done condition. Without this,
    // a watch on get_pipeline_status with done_match={status:"done"}
    // would keep polling after the user cancels the pipeline, because
    // status="cancelled" never matches "done" — wasting polls until
    // max_polls / timeout. The builtin status tools (get_pipeline_status,
    // get_task_status) all set obj.terminal = true on cancelled/failed
    // too, so honoring it here drops the watch the moment the user
    // intervenes.
    if (truthy(getPath(obj, 'terminal'))) {
      const statusVal = String(getPath(obj, 'status') || '').toLowerCase();
      const isFailureLike = statusVal === 'failed' || statusVal === 'cancelled';
      return finish(
        w,
        isFailureLike ? 'failed' : 'done',
        obj,
        `${w.label}: ${statusVal || 'reached a terminal state'}.`,
      );
    }
    // done check (user-configured)
    let done = false;
    if (w.done_match) {
      const v = getPath(obj, w.done_match.path);
      if (w.done_match.equals != null) done = String(v) === String(w.done_match.equals);
      else if (w.done_match.contains != null) done = String(v ?? '').toLowerCase().includes(String(w.done_match.contains).toLowerCase());
    } else if (w.done_path) {
      done = truthy(getPath(obj, w.done_path));
    }
    if (done) {
      return finish(w, 'done', obj, `${w.label}: done.`);
    }
    // Heuristic terminal detection — fallback for connector pollers
    // that don't set obj.terminal AND whose authors didn't anticipate
    // a particular done condition. If the poll result has a common
    // "I'm finished" shape (state/status/phase/result with a known
    // terminal word, or done:true / finished:true), trust it. User's
    // explicit done_match/done_path runs first (above), so a watch
    // wanting "done when status==running" still works as intended.
    const term = detectTerminalState(obj);
    if (term) {
      return finish(
        w,
        term.failure ? 'failed' : 'done',
        obj,
        `${w.label}: detected ${term.source}=${term.value} — closing watch.`,
      );
    }
    // not done — bound by polls / timeout, else reschedule
    if (polls >= w.max_polls || now - w.created_at > w.timeout_sec * 1000) {
      return finish(w, 'timed_out', obj, `${w.label}: not done within ${w.max_polls} polls / ${w.timeout_sec}s — please verify manually.`);
    }
    // Persist the latest poll result + a tiny preview text on EVERY poll
    // (not just terminal) so the Monitor / DB shows what the watch is
    // actually seeing — crucial for diagnosing "polling forever, no done":
    // usually means the done condition refers to a field the poll's
    // result doesn't actually have.
    const previewKeys = obj && typeof obj === 'object' && !Array.isArray(obj)
      ? Object.keys(obj as Record<string, unknown>).slice(0, 8).join(', ')
      : typeof obj;
    updateWatch(w.id, {
      polls,
      err_count: 0,
      next_poll_at: now + w.interval_sec * 1000,
      last_result: obj,
      last_text: `poll ${polls}/${w.max_polls} · keys: ${previewKeys}`,
    }, now);
    emitProgress(w, obj, polls);
  };

  const tick = async () => {
    try {
      const slots = MAX_CONCURRENT - running.size;
      if (slots <= 0) return;
      const due = listDue(Date.now()).filter((w) => !running.has(w.id)).slice(0, slots);
      for (const w of due) {
        running.add(w.id);
        // Re-read inside to avoid acting on a row cancelled since listDue.
        void Promise.resolve()
          .then(() => { const cur = getWatch(w.id); return cur && cur.state === 'active' ? pollOne(cur) : undefined; })
          .catch((e) => console.error('[watch] poll error', w.id, e))
          .finally(() => running.delete(w.id));
      }
    } catch (e) {
      console.error('[watch] tick error', e);
    }
  };

  const timer = setInterval(() => { void tick(); }, TICK_MS);
  if (typeof timer.unref === 'function') timer.unref();
  g.__forgeWatchRunner = timer;
  console.log('[watch] runner started (tick ' + TICK_MS / 1000 + 's)');
}
