/**
 * Jobs scheduler — ticks every 60s, runs each due Job, dispatches new items.
 *
 * Started by lib/init.ts on first request. Runs inside the Next.js worker
 * alongside pipeline-scheduler. Each tick is async — the loop never waits
 * for a job's connector call to finish (just kicks it off and advances
 * next_run_at). The job's own run row is updated when the work completes.
 */

import {
  ensureSchema, getDueJobs, hasInflightRun, startRun, finishRun,
  markSeen, recordDispatch, getJob, updateJob,
} from './store';
import type { Job, JobRunStatus, PipelineDispatchParams, ChatDispatchParams } from './types';
import { dispatchTool } from '@/lib/chat/tool-dispatcher';
import { dispatchToPipeline, dispatchToChat, dispatchToChatSummary } from './dispatcher';

const TICK_INTERVAL_MS = 60_000;

const schedulerKey = Symbol.for('forge-jobs-scheduler');
const g = globalThis as any;

export function startScheduler(): void {
  if (g[schedulerKey]) return;
  g[schedulerKey] = true;
  ensureSchema();
  console.log('[jobs-scheduler] started');
  void tick();
  setInterval(() => { void tick(); }, TICK_INTERVAL_MS).unref?.();
}

async function tick(): Promise<void> {
  let due: Job[] = [];
  try { due = getDueJobs(); } catch (e) { console.error('[jobs-scheduler] getDueJobs failed', e); return; }

  for (const job of due) {
    if (hasInflightRun(job.id)) {
      console.log(`[jobs] skip ${job.id} — previous run still inflight`);
      // Still advance the schedule so we re-evaluate at the next interval.
      advanceSchedule(job);
      continue;
    }
    advanceSchedule(job);
    // Kick off the run; don't await — long connector calls / pipeline triggers
    // shouldn't block the scheduler loop.
    const { runId } = prepareRun(job, 'schedule');
    void executeRun(job, runId).catch((e) => {
      console.error(`[jobs] runJob ${job.id} crashed`, e);
    });
  }
}

function toSqlIso(d: Date): string {
  return d.toISOString().replace('T', ' ').slice(0, 19);
}

/**
 * Compute and store the job's next_run_at based on schedule_kind.
 * For 'once' jobs we also auto-disable after the firing tick so they
 * don't fire repeatedly when their schedule_at time is in the past.
 */
function advanceSchedule(job: Job): void {
  const { setNextRunAt } = require('./store') as typeof import('./store');
  const now = Date.now();

  if (job.schedule_kind === 'manual') {
    // Manual jobs are filtered out of getDueJobs and should never get
    // here. Belt-and-suspenders: clear any stray next_run_at.
    setNextRunAt(job.id, null);
    return;
  }

  if (job.schedule_kind === 'once') {
    // One-shot: this tick is the fire. Disable for future, no next run.
    setNextRunAt(job.id, null);
    try { updateJob(job.id, { enabled: false }); } catch (e) {
      console.warn(`[jobs] failed to auto-disable one-shot ${job.id}:`, e);
    }
    return;
  }

  if (job.schedule_kind === 'cron' && job.schedule_cron) {
    try {
      const { CronExpressionParser } = require('cron-parser');
      const iter = CronExpressionParser.parse(job.schedule_cron, { currentDate: new Date(now) });
      const next = iter.next().toDate();
      setNextRunAt(job.id, toSqlIso(next));
      return;
    } catch (e) {
      console.warn(`[jobs] cron parse failed for ${job.id} (expr "${job.schedule_cron}"):`, (e as Error).message);
      // Fall through to period-style backoff so a broken cron doesn't
      // tight-loop the tick.
    }
  }

  // Default / 'period' path.
  const minutes = Math.max(1, job.schedule_interval_minutes || 30);
  const next = new Date(now + minutes * 60_000);
  setNextRunAt(job.id, toSqlIso(next));
}

/**
 * Execute one Job tick fully. Public for use by the scheduler loop and
 * the manual-fire API. The manual route prefers prepareRun + executeRun
 * (split below) so it can return the run id before the work finishes.
 */
export async function runJob(jobOrId: Job | string, trigger: 'schedule' | 'manual'): Promise<string> {
  const { job, runId } = prepareRun(jobOrId, trigger);
  await executeRun(job, runId);
  return runId;
}

/**
 * Insert a run row and return its id immediately. Use with executeRun(job, runId)
 * when you want fire-and-forget semantics (e.g. manual API trigger).
 */
export function prepareRun(jobOrId: Job | string, trigger: 'schedule' | 'manual'): { job: Job; runId: string } {
  const job = typeof jobOrId === 'string' ? getJob(jobOrId) : jobOrId;
  if (!job) throw new Error(`job not found: ${typeof jobOrId === 'string' ? jobOrId : '(null)'}`);
  const run = startRun(job.id, trigger);
  return { job, runId: run.id };
}

/**
 * Do the actual tick work (connector call + dedup + dispatch). Updates the run
 * row, including a verbose plain-text execution log saved to `job_runs.log`
 * for inspection in the UI ("show log" expansion under each run row).
 *
 * Every meaningful step appends one timestamped line to a per-tick buffer;
 * we also mirror the high-level lines to console for live tailing via
 * `tail -f forge.log | grep [jobs]`.
 */
export async function executeRun(job: Job, runId: string): Promise<void> {
  const t0 = Date.now();
  let itemsSeen = 0, itemsNew = 0, itemsDispatched = 0;
  let runError: string | null = null;
  const callName = `${job.source_connector}.${job.source_tool}`;
  const lines: string[] = [];
  const logLine = (level: 'info' | 'warn' | 'error', msg: string, mirror = true) => {
    const ts = new Date().toISOString().slice(11, 23);
    const ms = String(Date.now() - t0).padStart(5, ' ') + 'ms';
    lines.push(`${ts}  ${ms}  ${level.toUpperCase().padEnd(5)} ${msg}`);
    if (mirror) {
      const tag = `[jobs] ${job.id} ${callName}`;
      if (level === 'error') console.error(tag, msg);
      else if (level === 'warn') console.warn(tag, msg);
      else console.log(tag, msg);
    }
  };
  const truncate = (s: string, n: number) => (s.length > n ? s.slice(0, n) + ` …(${s.length - n} bytes truncated)` : s);

  // Always persist whatever log we accumulated, even on uncaught throws.
  const persist = (extra: { status: JobRunStatus; error?: string | null; notes?: string | null }) => {
    finishRun(runId, {
      status: extra.status,
      items_seen: itemsSeen,
      items_new: itemsNew,
      items_dispatched: itemsDispatched,
      error: extra.error ?? null,
      notes: extra.notes ?? null,
      log: lines.join('\n'),
    });
  };

  try {
    const { __mark_existing_as_seen, ...sourceInput } = job.source_input as any;
    logLine('info', `tick start trigger=${(__mark_existing_as_seen ? 'backfill' : 'normal')} dispatch=${job.dispatch_type}`);
    logLine('info', `source input: ${JSON.stringify(sourceInput)}`);
    logLine('info', `calling connector ${callName}…`);

    const toolResult = await dispatchTool(
      { id: `job-${runId}`, name: callName, input: sourceInput },
      // We JSON.parse the response — the 8KB LLM-friendly truncation
      // would break parsing on any moderately large list (Todos, big MR
      // searches, etc.). Ask for the raw body.
      { noTruncation: true },
    );

    const respBytes = toolResult.content?.length ?? 0;
    logLine(toolResult.is_error ? 'error' : 'info',
      `connector returned ${toolResult.is_error ? 'is_error=true ' : ''}${respBytes} bytes`);
    logLine('info', `response preview:\n${truncate(toolResult.content || '(empty)', 600)}`, false);

    if (toolResult.is_error) {
      throw new Error(`connector ${callName} failed: ${toolResult.content.slice(0, 500)}`);
    }

    // ── Parse + extract items ────────────────────────────────────
    const parsed = safeParseJson(toolResult.content);
    if (parsed === undefined) {
      const note = `Connector returned non-JSON content (${respBytes} bytes). Preview: ${toolResult.content.slice(0, 200)}`;
      logLine('warn', note);
      persist({ status: 'ok', notes: note });
      return;
    }
    logLine('info', `parsed JSON; type=${Array.isArray(parsed) ? 'array' : typeof parsed}`);

    let items = pickPath(parsed, job.items_path) as unknown;
    // Detail-style connectors (mantis.get_bug, gitlab.get_issue, …) return a
    // single object, not an array. Treat that as a 1-item list so the same
    // Job machinery (dedup, dispatch, render) works for both list and detail
    // tools. This is what lets you test a pipeline end-to-end with one
    // known item without writing a separate code path.
    if (items && typeof items === 'object' && !Array.isArray(items)) {
      logLine('info', `items_path resolved to a single object — wrapping as 1-item list`);
      items = [items];
    }
    if (!Array.isArray(items)) {
      const topKeys = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
        ? Object.keys(parsed as Record<string, unknown>).join(', ')
        : Array.isArray(parsed) ? '(top-level is an array — leave items_path empty)' : typeof parsed;
      const note = `items_path "${job.items_path || '(empty)'}" did not resolve to an array or object. Top-level keys: ${topKeys}`;
      logLine('warn', note);
      persist({ status: 'ok', notes: note });
      return;
    }
    const itemsArr = items as unknown[];
    itemsSeen = itemsArr.length;
    logLine('info', `items_path="${job.items_path || '(root)'}" → ${itemsSeen} item(s)`);

    // ── Backfill guard ───────────────────────────────────────────
    if (__mark_existing_as_seen) {
      let backfilledKeys = 0, missingKey = 0;
      for (const item of itemsArr) {
        const key = pickDedupKey(item, job.dedup_field);
        if (key) { markSeen(job.id, key); backfilledKeys++; }
        else missingKey++;
      }
      updateJob(job.id, { source_input: sourceInput });
      logLine('info', `backfill complete: marked ${backfilledKeys} items as seen` + (missingKey ? `, ${missingKey} skipped (missing dedup_field "${job.dedup_field}")` : ''));
      const note = `Backfill (first tick): marked ${backfilledKeys} existing items as seen — no dispatches by design. Future ticks dispatch only NEW items. To re-process everything, click "Reset dedup".`;
      persist({ status: 'ok', notes: note });
      return;
    }

    // ── Chat summary mode — bundle all NEW items into ONE message ──
    // Useful when search_bugs returns N items and you want the LLM to
    // analyze + summarize, instead of getting N separate chat messages
    // (or just the first item if reuse_session was off).
    if (job.dispatch_type === 'chat' &&
        (job.dispatch_params as ChatDispatchParams).mode === 'summary') {
      const chatParams = job.dispatch_params as ChatDispatchParams;
      let dedupHits = 0, missingKey = 0;
      const newItems: unknown[] = [];
      for (const [idx, item] of itemsArr.entries()) {
        const key = pickDedupKey(item, job.dedup_field);
        if (!key) { missingKey++; logLine('warn', `[${idx}] item missing dedup_field "${job.dedup_field}" — skipping`); continue; }
        if (!markSeen(job.id, key)) { dedupHits++; continue; }
        itemsNew++;
        newItems.push(item);
      }
      if (newItems.length === 0) {
        const note = itemsSeen === 0
          ? 'Connector returned 0 items.'
          : `All ${itemsSeen} items already seen — nothing new to summarize.`;
        logLine('info', `summary mode: ${note}`);
        persist({ status: 'ok', notes: note });
        return;
      }
      logLine('info', `summary mode: bundling ${newItems.length} new item(s) into one chat message`);
      // Pull the connector's server-side total (e.g. mantis search_bugs
      // total_matching = "151" parsed from "Viewing Bugs (1 - 50 / 151)").
      // The {{count}} placeholder in the user's template now means "items
      // they're actually seeing"; {{total_matching}} is "how many matched
      // the filter on the server". Falls back to parsed.total when the
      // connector doesn't emit total_matching.
      const totalMatching = (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
        ? (parsed as any).total_matching ?? (parsed as any).total ?? null
        : null;
      try {
        const out = await dispatchToChatSummary(chatParams, newItems, job.id, { totalMatching, skills: job.skills });
        recordDispatch({
          job_run_id: runId,
          item_key: `summary-${runId}`,
          item_preview: `[${newItems.length} items folded into one message]`,
          dispatch_type: 'chat', dispatch_target_id: out.target_id, status: 'dispatched',
        });
        itemsDispatched = 1; // one logical dispatch, regardless of item count
        logLine('info', `summary → chat:${out.target_id} (${newItems.length} items)`);
      } catch (e) {
        const msg = e instanceof Error ? e.message : String(e);
        recordDispatch({
          job_run_id: runId, item_key: `summary-${runId}`, item_preview: '',
          dispatch_type: 'chat', dispatch_target_id: null, status: 'error', error: msg,
        });
        logLine('error', `summary dispatch failed: ${msg}`);
      }
      logLine('info', `tick done in ${Date.now() - t0}ms — ${itemsSeen} seen, ${itemsNew} new, ${itemsDispatched} dispatched (summary), ${dedupHits} dedup hits` + (missingKey ? `, ${missingKey} missing-key` : ''));
      persist({ status: 'ok', notes: null });
      return;
    }

    // ── Per-item dispatch ────────────────────────────────────────
    let dedupHits = 0, missingKey = 0;
    for (const [idx, item] of itemsArr.entries()) {
      const key = pickDedupKey(item, job.dedup_field);
      if (!key) {
        missingKey++;
        logLine('warn', `[${idx}] item missing dedup_field "${job.dedup_field}" — skipping`);
        continue;
      }
      const isNew = markSeen(job.id, key);
      if (!isNew) {
        dedupHits++;
        // Don't mirror to console — too chatty in the typical "0 new" case
        logLine('info', `[${idx}] ${key} — already seen, skip`, false);
        continue;
      }
      itemsNew++;
      const preview = renderItemPreview(item);
      logLine('info', `[${idx}] ${key} — new — dispatching ${job.dispatch_type}…`);
      const dispatchStart = Date.now();
      // Auto-install any skill named on the job into the dispatch
      // target project, so the spawned task can actually invoke them.
      // Skipped for chat dispatches — chat backend already sees globally-
      // installed skills and we don't have a target project here.
      if (job.skills && job.skills.length && job.dispatch_type === 'pipeline') {
        const targetProject = (job.dispatch_params as PipelineDispatchParams).project_path;
        if (targetProject) {
          for (const skillName of job.skills) {
            try {
              const { ensureInstalledInProject } = require('../skills');
              const r = await ensureInstalledInProject(skillName, targetProject);
              if (!r.installed) logLine('warn', `skill "${skillName}" not installable: ${r.reason}`);
            } catch (err) {
              logLine('warn', `skill "${skillName}" install failed: ${(err as Error).message}`);
            }
          }
        }
      }
      try {
        const out = job.dispatch_type === 'pipeline'
          ? dispatchToPipeline(job.dispatch_params as PipelineDispatchParams, item, key, { skills: job.skills })
          : await dispatchToChat(job.dispatch_params as ChatDispatchParams, item, job.id, { skills: job.skills });
        recordDispatch({
          job_run_id: runId, item_key: key, item_preview: preview,
          dispatch_type: job.dispatch_type, dispatch_target_id: out.target_id, status: 'dispatched',
        });
        itemsDispatched++;
        logLine('info', `[${idx}] ${key} → ${job.dispatch_type}:${out.target_id} (${Date.now() - dispatchStart}ms)`);
        const emptyKeys = (out as any).empty_keys as string[] | undefined;
        if (emptyKeys && emptyKeys.length > 0) {
          logLine('warn', `[${idx}] ${key} — these input_template keys rendered EMPTY (likely a {{item.X}} where X isn't on the item): ${emptyKeys.join(', ')}. Pipeline node will probably fail with 'is required' — fix by typing a literal value in the job's input_template.`);
        }
      } catch (e) {
        const msg = e instanceof Error ? e.message : String(e);
        recordDispatch({
          job_run_id: runId, item_key: key, item_preview: preview,
          dispatch_type: job.dispatch_type, dispatch_target_id: null, status: 'error', error: msg,
        });
        logLine('error', `[${idx}] ${key} dispatch failed: ${msg}`);
      }
    }

    let note: string | null = null;
    if (itemsDispatched === 0) {
      if (itemsSeen === 0) note = 'Connector returned 0 items.';
      else if (dedupHits === itemsSeen) note = `All ${itemsSeen} items were already seen (dedup hits). No new items to dispatch. Click "Reset dedup" to re-process everything.`;
      else if (missingKey === itemsSeen) note = `All ${itemsSeen} items lacked the dedup_field "${job.dedup_field}". Check the field name vs the connector's response shape.`;
    }

    logLine('info', `tick done in ${Date.now() - t0}ms — ${itemsSeen} seen, ${itemsNew} new, ${itemsDispatched} dispatched, ${dedupHits} dedup hits` + (missingKey ? `, ${missingKey} missing-key` : ''));
    persist({ status: 'ok', notes: note });
  } catch (e) {
    runError = e instanceof Error ? e.message : String(e);
    logLine('error', `tick failed: ${runError}`);
    persist({ status: 'error', error: runError });
  }
}

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

function safeParseJson(content: string): unknown | undefined {
  try { return JSON.parse(content); }
  catch {}
  // Strip the http-protocol preamble — `HTTP 200 OK · GET <url>\n[(...)]\n\n`
  // (and the truncation note line that sometimes follows) — and retry.
  // The LLM-facing payload bakes in a status line we don't want here.
  const firstJson = content.search(/[\[{]/);
  if (firstJson > 0) {
    try { return JSON.parse(content.slice(firstJson)); }
    catch {}
  }
  return undefined;
}

function pickPath(obj: unknown, path: string): unknown {
  if (!path) return obj;
  const parts = path.split('.');
  let cur: any = obj;
  for (const p of parts) {
    if (cur == null || typeof cur !== 'object') return undefined;
    cur = cur[p];
  }
  return cur;
}

function pickDedupKey(item: unknown, field: string): string | null {
  const v = pickPath(item, field);
  if (v == null) return null;
  return typeof v === 'string' ? v : String(v);
}

function renderItemPreview(item: unknown): string {
  try {
    const s = JSON.stringify(item);
    return s.length > 200 ? s.slice(0, 200) + '…' : s;
  } catch { return ''; }
}
