/**
 * Per-item dispatch: take one new item from a Job's connector result and
 * either trigger a Pipeline run or post a message to a Chat session.
 *
 * Template syntax: `{{item.<dotted.path>}}` — see docs/Jobs-Design.md §
 * Template syntax.
 */

import type { PipelineDispatchParams, ChatDispatchParams } from './types';
import { triggerPipeline } from '../pipeline-scheduler';

const CHAT_PORT = Number(process.env.CHAT_PORT) || 8408;

// ─── Template rendering ───────────────────────────────────

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 renderValue(v: unknown): string {
  if (v == null) return '';
  if (typeof v === 'string') return v;
  if (typeof v === 'number' || typeof v === 'boolean') return String(v);
  try { return JSON.stringify(v); } catch { return ''; }
}

export function renderTemplate(template: string, item: unknown): string {
  return template.replace(/\{\{\s*item(?:\.([^}\s]+))?\s*\}\}/g, (_, path) => {
    const v = path ? pickPath(item, path) : item;
    return renderValue(v);
  });
}

function renderTemplateMap(map: Record<string, string>, item: unknown): Record<string, string> {
  const out: Record<string, string> = {};
  for (const [k, v] of Object.entries(map)) {
    out[k] = renderTemplate(v, item);
  }
  return out;
}

// ─── Pipeline dispatch ────────────────────────────────────

/**
 * Trigger a pipeline run for one new item. `dedupKey` is the Job's per-item
 * key — purely for the audit row in job_dispatches. We DO NOT forward it to
 * triggerPipeline's own dedup column: Jobs already enforce de-duplication
 * at job_seen, and pipeline_runs has a UNIQUE constraint on
 * (project_path, workflow_name, dedup_key) that would block any 'Reset
 * dedup → Run now' replay (the row from the old run still occupies the
 * slot). Letting pipeline_runs.dedup_key be NULL means each dispatch
 * gets its own row.
 */
export function dispatchToPipeline(
  params: PipelineDispatchParams,
  item: unknown,
  _dedupKey: string,
  opts: { skills?: string[] } = {},
): { target_id: string; rendered_input: Record<string, string>; empty_keys: string[] } {
  const renderedInput = renderTemplateMap(params.input_template || {}, item);
  // Heads-up if a template key rendered to empty — almost always means the
  // user wrote {{item.foo}} but the item has no 'foo' field. The pipeline
  // node usually exits 1 with "ERROR: <key> is required" which is hard to
  // act on without knowing where the empty came from.
  const emptyKeys: string[] = [];
  for (const [k, v] of Object.entries(renderedInput)) {
    if (!String(v ?? '').trim()) emptyKeys.push(k);
  }
  if (emptyKeys.length > 0) {
    console.warn(
      `[jobs] dispatchToPipeline: ${emptyKeys.length} key(s) rendered to empty — ` +
      `pipeline will likely fail. keys=${emptyKeys.join(', ')}. ` +
      `Check the job's input_template — '{{item.X}}' values resolve to empty if X isn't on the item.`,
    );
  }
  const result = triggerPipeline(
    params.project_path,
    params.project_name,
    params.workflow_name,
    renderedInput,
    /* dedupKey */ undefined,        // intentionally no dedup_key — see comment above
    { skills: opts.skills },
  );
  return { target_id: result.pipelineId, rendered_input: renderedInput, empty_keys: emptyKeys };
}

// ─── Chat dispatch ────────────────────────────────────────

async function chatHttp(path: string, body?: unknown): Promise<any> {
  const res = await fetch(`http://127.0.0.1:${CHAT_PORT}${path}`, {
    method: body ? 'POST' : 'GET',
    headers: { 'content-type': 'application/json' },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok && res.status !== 202) {
    throw new Error(`chat ${path}: HTTP ${res.status} ${(await res.text().catch(() => '')).slice(0, 200)}`);
  }
  if (res.status === 204) return null;
  return res.json();
}

/** Resolve the user's persistent main session id (creates if missing). */
async function getMainSessionId(): Promise<string> {
  const r = await chatHttp('/api/sessions/main');
  const id = String(r?.session?.id || '');
  if (!id) throw new Error('main session lookup returned no id');
  return id;
}

/**
 * Per-job in-memory map of reused chat sessions, so all items in a `reuse_session: true`
 * job append to one session. Lives in this module (one per Next.js worker).
 */
const reusedSessionByJob = new Map<string, string>();

export async function dispatchToChat(
  params: ChatDispatchParams,
  item: unknown,
  jobId: string,
  opts: { skills?: string[] } = {},
): Promise<{ target_id: string }> {
  let sessionId: string | undefined;
  if (params.target === 'main') {
    sessionId = await getMainSessionId();
  } else if (params.reuse_session) {
    sessionId = reusedSessionByJob.get(jobId);
  }

  if (!sessionId) {
    const title = params.session_title_template
      ? renderTemplate(params.session_title_template, item)
      : 'Job dispatch';
    const created = await chatHttp('/api/sessions', {
      title,
      provider: params.agent_profile,
      meta: { kind: 'temp', source: `job:${jobId}` },
    });
    sessionId = String(created?.session?.id || '');
    if (!sessionId) throw new Error('chat session create returned no id');
    if (params.reuse_session) reusedSessionByJob.set(jobId, sessionId);
  }

  const rendered = renderTemplate(params.message_template, item);
  if (!rendered.trim()) throw new Error('rendered chat message is empty');
  // Skills get prepended as a one-line directive in the chat message —
  // the chat agent picks them up the same way the user would by typing
  // /skill-name themselves.
  const skillsPrefix = opts.skills && opts.skills.length
    ? `Use these Forge skills as appropriate for this task: ${opts.skills.map(s => '/' + s).join(', ')}.\n\n`
    : '';
  await chatHttp(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { text: skillsPrefix + rendered });
  return { target_id: sessionId };
}

/**
 * Summary-mode dispatch — fold ALL new items in this tick into ONE message
 * sent to a single chat session. Lets the LLM analyze + summarize whatever
 * came back from the connector instead of getting only the first item.
 *
 * message_template placeholders specific to this mode:
 *   {{items}}       — JSON.stringify of the full items array
 *   {{items_text}}  — one line per item ("id. title")
 *   {{count}}       — items.length
 * {{item.X}} also still works against items[0] for header lines like
 * "Today's report for {{item.project}}".
 */
export async function dispatchToChatSummary(
  params: ChatDispatchParams,
  items: unknown[],
  jobId: string,
  opts: { totalMatching?: number | string | null; skills?: string[] } = {},
): Promise<{ target_id: string; count: number }> {
  let sessionId: string | undefined;
  // Default target for summary mode is the main chat — that's where the
  // user's agent already lives. They don't want a new chat tab per tick.
  const target = params.target ?? 'main';
  if (target === 'main') {
    sessionId = await getMainSessionId();
  } else if (params.reuse_session) {
    sessionId = reusedSessionByJob.get(jobId);
  }

  if (!sessionId) {
    const title = params.session_title_template
      ? renderTemplate(params.session_title_template, items[0] ?? {})
      : `Job summary (${items.length})`;
    const created = await chatHttp('/api/sessions', {
      title, provider: params.agent_profile,
      meta: { kind: 'temp', source: `job:${jobId}` },
    });
    sessionId = String(created?.session?.id || '');
    if (!sessionId) throw new Error('chat session create returned no id');
    if (params.reuse_session) reusedSessionByJob.set(jobId, sessionId);
  }

  const itemsJson = JSON.stringify(items, null, 2);
  const itemsText = items.map((it, i) => {
    if (it && typeof it === 'object') {
      const o = it as Record<string, unknown>;
      const id = o.id ?? o.iid ?? o.number ?? (i + 1);
      const title = o.title || o.summary || o.name || '';
      return title ? `${id}. ${title}` : `${id}.`;
    }
    return `${i + 1}. ${String(it)}`;
  }).join('\n');

  let text = String(params.message_template || '');
  text = text.replace(/\{\{\s*items\s*\}\}/g, itemsJson);
  text = text.replace(/\{\{\s*items_text\s*\}\}/g, itemsText);
  // {{count}} = items shown in this dispatch (after dedup + limit).
  // {{total_matching}} = how many matched the filter server-side. Useful
  // when the connector returned only first N (e.g. mantis limit=20 but
  // 151 bugs match — caller wants "20 of 151 …").
  text = text.replace(/\{\{\s*count\s*\}\}/g, String(items.length));
  const tm = opts.totalMatching;
  text = text.replace(/\{\{\s*total_matching\s*\}\}/g, tm != null ? String(tm) : String(items.length));
  // {{item.X}} → first item, so "header" lines work the same way as per-item mode
  text = renderTemplate(text, items[0] ?? {});

  if (!text.trim()) throw new Error('rendered chat summary message is empty');
  const skillsPrefix = opts.skills && opts.skills.length
    ? `Use these Forge skills as appropriate for this task: ${opts.skills.map(s => '/' + s).join(', ')}.\n\n`
    : '';
  await chatHttp(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { text: skillsPrefix + text });
  return { target_id: sessionId, count: items.length };
}

export function clearReusedSession(jobId: string): void {
  reusedSessionByJob.delete(jobId);
}
