// CTM OTLP/HTTP JSON log receiver + agent-hook fan-in.
//
// This is the authoritative state-signal source for Claude Code, Codex, and
// Gemini CLI sessions. The contract with CTM:
//   - init(broadcast, getSessions) once at server start
//   - handleLogs(req, res) on POST /v1/logs  (req.body pre-parsed to JSON)
//   - hookClaude / hookGemini / hookCodexStop from the /hook/* route handlers
//   - watchSession(id, bin) when a session is spawned so we can fire
//     session.needsSetup if no telemetry arrives within 10s
//   - clear(id) when a session exits
//   - hasAuthoritativeSource(id) → true once hooks or OTEL have fired for this
//     session. Used by server.js to gate the regex-fallback idle detector.
//
// Ported from /tmp/clideck/telemetry-receiver.js (~302 LOC) with these changes:
//   - "clideck_id" / "clideck.session_id" attributes renamed to "ctm_id" /
//     "ctm.session_id" to match our env-setter, but the legacy names still
//     work (for forward compat with anything emitting CliDeck-style attrs).
//   - broadcast payload uses CTM's existing WS envelope
//     { type: 'session.status', id, working, source, timestamp }.
//   - authoritative-source tracking added so the regex detector in server.js
//     can be suppressed once a real signal has landed.
//
// Credits: hook-first for Claude/Gemini + OTEL-driven state machine for Codex
// is CliDeck's design (https://github.com/rustykuntz/clideck). Timing nuance
// (300ms idle settle, pending-tool tracking, menu polling) preserved verbatim.

const authoritative = new Map(); // sessionId -> 'hook' | 'telemetry'
const activity = new Map();       // sessionId -> has received events
const lastEvent = new Map();      // sessionId -> last OTEL event name (+ kind)
const pendingSetup = new Map();   // sessionId -> { timer, bin }
const codexMenuPoll = new Map();
const codexPendingStop = new Map();
const codexOutputDone = new Map();
const codexPendingIdle = new Map();
const codexToolPhasePending = new Set();
const codexPendingTools = new Map(); // sessionId -> Set(callId)

let broadcastFn = null;
let sessionsFn = null;
let sessionTokenFn = null;

function init(broadcast, getSessions, onSessionToken) {
  broadcastFn = broadcast;
  sessionsFn = getSessions;
  sessionTokenFn = typeof onSessionToken === 'function' ? onSessionToken : null;
}

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

function parseAttrs(attrs) {
  const out = {};
  if (!attrs) return out;
  for (const a of attrs) {
    const v = a.value || {};
    out[a.key] = v.stringValue ?? v.intValue ?? v.doubleValue ?? v.boolValue ?? '';
  }
  return out;
}

function markAuthoritative(id, source) {
  if (!id) return;
  authoritative.set(id, source);
}

function broadcastStatus(id, working, source) {
  if (!broadcastFn) return;
  broadcastFn({ type: 'session.status', id, working: !!working, source, timestamp: Date.now() });
}

function reportSessionTokenConflict(ctmSessionId, agentSessionId, existingToken, serviceName, attrs, eventName, sess) {
  try {
    sessionTokenFn?.({
      ctmSessionId,
      agentSessionId,
      existingAgentSessionId: existingToken,
      serviceName,
      attrs: attrs || {},
      eventName,
      session: sess,
      conflict: true,
    });
  } catch (e) {
    console.error('[telemetry] session token conflict callback error:', e.message);
  }
}

function captureSessionToken(ctmSessionId, agentSessionId, serviceName, attrs, eventName, sess) {
  if (!ctmSessionId || !agentSessionId || !sess) return false;
  const existingToken = sess.sessionToken || '';
  if (existingToken === agentSessionId) return false;

  // First token normally wins. A later different token is an identity
  // invariant violation unless the provider marks it as an interactive
  // dominance event (Gemini can do this during init). Report the conflict to
  // CTM diagnostics instead of silently retargeting the tab.
  const canReplaceExisting = attrs?.interactive === true;
  if (existingToken && !canReplaceExisting) {
    reportSessionTokenConflict(ctmSessionId, agentSessionId, existingToken, serviceName, attrs, eventName, sess);
    return false;
  }

  sess.sessionToken = agentSessionId;
  const agent = serviceName || sess.label || ctmSessionId.slice(0, 8);
  console.log(`[telemetry] captured token ${String(agentSessionId).slice(0, 12)}… for ${agent} (${ctmSessionId.slice(0, 8)})`);
  broadcastFn?.({ type: 'session.sessionToken', id: ctmSessionId, token: agentSessionId });
  try {
    sessionTokenFn?.({
      ctmSessionId,
      agentSessionId,
      serviceName,
      attrs: attrs || {},
      eventName,
      session: sess,
    });
  } catch (e) {
    console.error('[telemetry] session token callback error:', e.message);
  }
  return true;
}

// ── Codex state-machine helpers (verbatim port) ──────────────────────────

function getPendingToolSet(id) {
  let set = codexPendingTools.get(id);
  if (!set) { set = new Set(); codexPendingTools.set(id, set); }
  return set;
}
function clearPendingTools(id) { codexPendingTools.delete(id); }
function addPendingTool(id, callId) { if (callId) getPendingToolSet(id).add(callId); }
function resolvePendingTool(id, callId) {
  if (!callId) return;
  const set = codexPendingTools.get(id);
  if (!set) return;
  set.delete(callId);
  if (!set.size) codexPendingTools.delete(id);
}
function hasPendingTools(id) { return !!codexPendingTools.get(id)?.size; }
function hasPendingToolState(id) { return codexToolPhasePending.has(id) || hasPendingTools(id); }

function startCodexMenuPoll(id) {
  cancelCodexMenuPoll(id);
  const started = Date.now();
  const poll = setInterval(() => {
    if (Date.now() - started > 3000) { cancelCodexMenuPoll(id); return; }
    broadcastFn?.({ type: 'terminal.capture', id });
  }, 500);
  codexMenuPoll.set(id, poll);
}
function cancelCodexMenuPoll(id) {
  const timer = codexMenuPoll.get(id);
  if (timer) { clearInterval(timer); codexMenuPoll.delete(id); }
}

function scheduleCodexIdle(id, source) {
  cancelCodexPendingIdle(id);
  const timer = setTimeout(() => {
    codexPendingIdle.delete(id);
    broadcastStatus(id, false, source);
  }, 300);
  codexPendingIdle.set(id, timer);
}
function cancelCodexPendingIdle(id) {
  const timer = codexPendingIdle.get(id);
  if (timer) { clearTimeout(timer); codexPendingIdle.delete(id); }
}

// Codex notify-hook handler — arms a pending-stop that the next response.completed will commit.
function armCodexStop(id) {
  codexPendingStop.set(id, Date.now());
  codexOutputDone.delete(id);
  markAuthoritative(id, 'hook');
}

// ── OTLP logs ingest ─────────────────────────────────────────────────────

/**
 * Resolve a session id for a log-record. Prefers ctm.session_id (set by our
 * OTEL_RESOURCE_ATTRIBUTES wiring); falls back to CliDeck-compat clideck.session_id;
 * lastly, for agents that drop resource attributes (Gemini) tries to match any
 * pending-setup session whose spawn command matches service.name.
 */
function resolveSessionId(resAttrs, serviceName) {
  const ctmId = resAttrs['ctm.session_id'] || resAttrs['clideck.session_id'];
  if (ctmId) return ctmId;
  for (const [id, pending] of pendingSetup) {
    const sess = sessionsFn?.()?.get(id);
    if (!sess) continue;
    if (!activity.has(id) && pending.bin && serviceName.includes(pending.bin)) return id;
  }
  return null;
}

function resolveSessionIdFromAgentToken(agentSessionId) {
  const token = String(agentSessionId || '').trim();
  if (!token) return null;
  const all = sessionsFn?.() || new Map();
  for (const [id, sess] of all) {
    if (!sess) continue;
    if (sess.sessionToken === token) return id;
    if (sess._claudeSessionId === token) return id;
    if (sess._providerResumeId === token) return id;
  }
  return null;
}

function resolveSessionIdForRecord(baseId, serviceName, attrs) {
  if (serviceName === 'codex_cli_rs') {
    const ownerId = resolveSessionIdFromAgentToken(attrs?.['conversation.id']);
    if (ownerId) return ownerId;
  }
  return baseId || null;
}

function handleLogs(req, res) {
  const body = req.body;
  if (!body || !body.resourceLogs) { res.writeHead(200).end('{}'); return; }

  for (const rl of body.resourceLogs) {
    const resAttrs = parseAttrs(rl.resource?.attributes);
    const serviceName = resAttrs['service.name'] || 'unknown';
    const baseResolvedId = resolveSessionId(resAttrs, serviceName);

    for (const sl of rl.scopeLogs || []) {
      for (const lr of sl.logRecords || []) {
        const attrs = parseAttrs(lr.attributes);
        const resolvedId = resolveSessionIdForRecord(baseResolvedId, serviceName, attrs);
        if (!resolvedId) continue;

        const firstEvent = !activity.has(resolvedId);
        cancelPendingSetup(resolvedId);
        activity.set(resolvedId, true);
        // Claude's OTEL is mainly for session-id capture; real state comes from hooks.
        // Codex's OTEL drives state. Mark 'telemetry' as the source for Codex only.
        if (serviceName === 'codex_cli_rs') markAuthoritative(resolvedId, 'telemetry');

        const sess = sessionsFn?.()?.get(resolvedId);
        const agent = serviceName || sess?.label || resolvedId.slice(0, 8);
        if (firstEvent) console.log(`[telemetry] first event from ${agent} (${resolvedId.slice(0, 8)})`);

        const eventName = attrs['event.name'];
        if (eventName) lastEvent.set(resolvedId, eventName + (attrs['event.kind'] ? ':' + attrs['event.kind'] : ''));

        // Codex: cancel pending-idle on any activity other than a response.completed
        if (serviceName === 'codex_cli_rs' && eventName) {
          const isTrustedCompletion = eventName === 'codex.sse_event' && attrs['event.kind'] === 'response.completed';
          if (!isTrustedCompletion) cancelCodexPendingIdle(resolvedId);
        }

        // Codex: user_prompt → working
        if (eventName === 'codex.user_prompt') {
          codexPendingStop.delete(resolvedId);
          codexOutputDone.delete(resolvedId);
          codexToolPhasePending.delete(resolvedId);
          clearPendingTools(resolvedId);
          broadcastStatus(resolvedId, true, 'telemetry');
        }

        // Our own agent (if anyone ever builds one)
        if (serviceName === 'ctm-agent' && eventName === 'ctm.turn_start') broadcastStatus(resolvedId, true, 'telemetry');
        if (serviceName === 'ctm-agent' && eventName === 'ctm.agent_idle') broadcastStatus(resolvedId, false, 'telemetry');

        // Codex: function-call phase announced — block idle until phase resolves
        if (eventName === 'codex.websocket_event' && attrs['event.kind'] === 'response.function_call_arguments.done') {
          codexToolPhasePending.add(resolvedId);
        }
        if (eventName === 'codex.websocket_event' && attrs['event.kind'] === 'response.output_item.done') {
          codexOutputDone.set(resolvedId, Date.now());
        }

        // Codex: response.completed — commit idle iff pending-stop armed + no tool phase live
        if (eventName === 'codex.sse_event' && attrs['event.kind'] === 'response.completed') {
          const pendingStopAt = codexPendingStop.get(resolvedId);
          if (hasPendingToolState(resolvedId)) {
            // tool phase still active — don't commit idle yet
          } else if (pendingStopAt && Date.now() - pendingStopAt <= 5000) {
            codexPendingStop.delete(resolvedId);
            codexOutputDone.delete(resolvedId);
            scheduleCodexIdle(resolvedId, 'telemetry-stop');
          } else {
            const outputDoneAt = codexOutputDone.get(resolvedId);
            if (outputDoneAt && Date.now() - outputDoneAt <= 5000) {
              codexOutputDone.delete(resolvedId);
              scheduleCodexIdle(resolvedId, 'telemetry-fallback');
            }
          }
          startCodexMenuPoll(resolvedId);
        }

        // Codex: tool_decision → user approved (or denied) a tool call
        if (eventName === 'codex.tool_decision') {
          codexPendingStop.delete(resolvedId);
          codexOutputDone.delete(resolvedId);
          if ((attrs.decision || '').toLowerCase() !== 'denied') {
            addPendingTool(resolvedId, attrs.call_id || attrs['call.id']);
          } else {
            codexToolPhasePending.delete(resolvedId);
          }
          cancelCodexMenuPoll(resolvedId);
          broadcastStatus(resolvedId, true, 'telemetry');
        }
        if (eventName === 'codex.tool_result') {
          codexToolPhasePending.delete(resolvedId);
          resolvePendingTool(resolvedId, attrs.call_id || attrs['call.id']);
        }
        if (eventName === 'codex.user_prompt' || (eventName === 'codex.sse_event' && attrs['event.kind'] !== 'response.completed')) {
          codexOutputDone.delete(resolvedId);
          cancelCodexMenuPoll(resolvedId);
        }

        // Capture agent's own session token for resume. Codex keys on conversation.id;
        // Claude/Gemini send session.id. Gemini also has an "interactive" flag that
        // distinguishes the real session from early non-interactive init events.
        const agentSessionId = serviceName === 'codex_cli_rs'
          ? attrs['conversation.id']
          : (attrs['session.id'] || attrs['conversation.id']);
        if (agentSessionId && sess) {
          captureSessionToken(resolvedId, agentSessionId, serviceName, attrs, eventName, sess);
        }
      }
    }
  }

  res.writeHead(200, { 'Content-Type': 'application/json' }).end('{}');
}

// ── Hook handlers (routed from server.js) ────────────────────────────────

function resolveIdFromHook(payload) {
  const all = sessionsFn?.() || new Map();
  const direct = payload?.ctm_id || payload?.clideck_id;
  if (direct && all.has(direct)) return direct;
  const tok = payload?.session_id;
  if (tok) {
    for (const [id, s] of all) if (s?.sessionToken === tok) return id;
  }
  return null;
}

function hookClaude(route, payload) {
  const id = resolveIdFromHook(payload);
  if (!id) return { matched: false };
  markAuthoritative(id, 'hook');
  if (route === 'start') { broadcastStatus(id, true, 'hook'); return { matched: true }; }
  if (route === 'stop' || route === 'idle') {
    broadcastStatus(id, false, 'hook');
    // After approval menu, Claude can already be idle before Stop hook arrives.
    // Force a terminal.capture so the UI gets a fresh snapshot of post-menu state.
    if (route === 'stop') {
      setTimeout(() => broadcastFn?.({ type: 'terminal.capture', id }), 500);
    }
    return { matched: true };
  }
  if (route === 'menu') {
    const sess = sessionsFn?.()?.get(id);
    const version = sess ? ((sess._menuVersion || 0) + 1) : 1;
    if (sess) sess._menuVersion = version;
    setTimeout(() => broadcastFn?.({ type: 'terminal.capture', id, menuVersion: version }), 500);
    return { matched: true };
  }
  return { matched: true };
}

function hookGemini(route, payload) {
  const id = resolveIdFromHook(payload);
  if (!id) return { matched: false };
  markAuthoritative(id, 'hook');
  const sess = sessionsFn?.()?.get(id);
  if (sess && payload.session_id) {
    captureSessionToken(id, payload.session_id, 'gemini_cli', payload || {}, `gemini.${route}`, sess);
  }
  if (route === 'menu') {
    // Gemini menu polling — mirrors CliDeck's startGeminiMenuPoll
    const started = Date.now();
    const poll = setInterval(() => {
      if (Date.now() - started > 3000) { clearInterval(poll); return; }
      broadcastFn?.({ type: 'terminal.capture', id });
    }, 500);
    return { matched: true };
  }
  broadcastStatus(id, route === 'start', 'hook');
  return { matched: true };
}

function hookCodexStop(payload) {
  const agentSessionId = payload?.session_id || payload?.['thread-id'] || payload?.thread_id;
  const id = resolveSessionIdFromAgentToken(agentSessionId) || resolveIdFromHook(payload);
  if (!id) return { matched: false };
  const sess = sessionsFn?.()?.get(id);
  if (agentSessionId && sess) {
    captureSessionToken(id, agentSessionId, 'codex_cli_rs', payload || {}, 'codex.notify', sess);
  }
  armCodexStop(id);
  return { matched: true };
}

// ── Setup-wizard helpers ─────────────────────────────────────────────────

function watchSession(sessionId, bin) {
  if (pendingSetup.has(sessionId)) return;
  const timer = setTimeout(() => {
    pendingSetup.delete(sessionId);
    if (!activity.has(sessionId) && !authoritative.has(sessionId)) {
      broadcastFn?.({ type: 'session.needsSetup', id: sessionId, bin });
    }
  }, 10000);
  pendingSetup.set(sessionId, { timer, bin });
}

function cancelPendingSetup(sessionId) {
  const pending = pendingSetup.get(sessionId);
  if (pending) { clearTimeout(pending.timer); pendingSetup.delete(sessionId); }
}

// ── Lifecycle ────────────────────────────────────────────────────────────

function clear(id) {
  activity.delete(id);
  authoritative.delete(id);
  lastEvent.delete(id);
  cancelCodexMenuPoll(id);
  cancelCodexPendingIdle(id);
  codexPendingStop.delete(id);
  codexOutputDone.delete(id);
  codexToolPhasePending.delete(id);
  clearPendingTools(id);
  cancelPendingSetup(id);
}

function hasEvents(id) { return activity.has(id); }
function getLastEvent(id) { return lastEvent.get(id) || ''; }
/** True if hooks or OTEL have fired for this session — gates the regex fallback. */
function hasAuthoritativeSource(id) { return authoritative.has(id); }
function getAuthoritativeSource(id) { return authoritative.get(id) || null; }

module.exports = {
  init,
  handleLogs,
  hookClaude,
  hookGemini,
  hookCodexStop,
  watchSession,
  armCodexStop,
  clear,
  hasEvents,
  getLastEvent,
  hasAuthoritativeSource,
  getAuthoritativeSource,
  // exposed for tests
  _test: {
    parseAttrs, resolveSessionId, codexPendingStop, codexPendingTools,
    resolveSessionIdFromAgentToken, resolveSessionIdForRecord,
    codexToolPhasePending, codexPendingIdle, codexMenuPoll,
  },
};
