/**
 * engine/steering.js — Durable agent-scoped steering inbox helpers.
 */

const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const shared = require('./shared');
const { wrapUntrusted, buildSource } = require('./untrusted-fence');

const AGENTS_DIR = path.join(shared.MINIONS_DIR, 'agents');

// W-mq066js7000fff1f-a (Gap D): generate a stable, URL-safe id for
// every new steering message so the SQL delivery-state row + the
// inbox file + downstream observability links share one identifier.
// Format: `steer-<10-char-base36>` — short enough for log lines, wide
// enough (~60 bits) to avoid practical collision under our write rate.
function _generateSteerId() {
  return `steer-${crypto.randomBytes(8).toString('hex').slice(0, 10)}`;
}

// W-mq066js7000fff1f-e (Gap E): identical-text dedupe window. POST
// /api/agents/steer collapses identical bodies within this window to
// the existing steerId instead of writing a duplicate inbox file.
const DEDUPE_WINDOW_MS = 5 * 60 * 1000;

// W-mq066js7000fff1f-e/f: status enum for the inbox frontmatter +
// supersede operations. Mirrors the steering_deliveries observability
// table (Gap D) so callers can speak the same vocabulary regardless
// of which storage backend is live.
const STATUS = Object.freeze({
  QUEUED: 'queued',
  LIVE_KILL: 'live_kill',
  DEFERRED: 'deferred',
  RE_SPAWNING: 're_spawning',
  DELIVERED: 'delivered',
  ACKNOWLEDGED: 'acknowledged',
  STRANDED: 'stranded',
  DROPPED: 'dropped',
});

const UNACKED_STATUSES = new Set([
  STATUS.QUEUED,
  STATUS.LIVE_KILL,
  STATUS.DEFERRED,
  STATUS.RE_SPAWNING,
  STATUS.STRANDED,
]);

// Statuses that should still appear as "live" in the dedupe lookup.
const DEDUPE_CANDIDATE_STATUSES = new Set([
  STATUS.QUEUED,
  STATUS.LIVE_KILL,
  STATUS.DEFERRED,
  STATUS.RE_SPAWNING,
  STATUS.DELIVERED,
]);

function agentInboxDir(agentId) {
  return path.join(AGENTS_DIR, agentId, 'inbox');
}

function agentAckDir(agentId) {
  return path.join(AGENTS_DIR, agentId, 'steering-ack');
}

function _createdAtFromPath(filePath, stat) {
  const base = path.basename(filePath);
  const m = base.match(/^steering-(\d+)/);
  if (m) {
    const n = Number(m[1]);
    if (Number.isFinite(n) && n > 0) return n;
  }
  return stat?.mtimeMs || Date.now();
}

function _stripFrontmatter(raw) {
  const text = String(raw || '');
  if (!text.startsWith('---\n')) return text;
  const end = text.indexOf('\n---\n', 4);
  return end >= 0 ? text.slice(end + 5) : text;
}

function _frontmatterValue(raw, key) {
  const text = String(raw || '');
  if (!text.startsWith('---\n')) return null;
  const end = text.indexOf('\n---\n', 4);
  if (end < 0) return null;
  const fm = text.slice(4, end).split(/\r?\n/);
  const prefix = key + ':';
  for (const line of fm) {
    if (line.startsWith(prefix)) return line.slice(prefix.length).trim();
  }
  return null;
}

function _messageFromRaw(raw) {
  let body = _stripFrontmatter(raw).trim();
  const forwarded = body.match(/Original steering from human:\s*([\s\S]*)$/i);
  if (forwarded) body = forwarded[1].trim();
  return body;
}

function _readEntry(filePath, legacy = false) {
  let stat;
  try { stat = fs.statSync(filePath); } catch { return null; }
  const raw = shared.safeRead(filePath);
  const fmCreatedAtMs = Number(_frontmatterValue(raw, 'createdAtMs'));
  const createdAtMs = Number.isFinite(fmCreatedAtMs) && fmCreatedAtMs > 0
    ? fmCreatedAtMs
    : _createdAtFromPath(filePath, stat);
  const steerId = _frontmatterValue(raw, 'steerId') || null;
  const status = _frontmatterValue(raw, 'status') || STATUS.QUEUED;
  const targetDispatchId = _frontmatterValue(raw, 'targetDispatchId') || null;
  const lastError = _frontmatterValue(raw, 'lastError') || null;
  const source = _frontmatterValue(raw, 'source') || 'human';
  return {
    path: filePath,
    file: path.basename(filePath),
    createdAtMs,
    createdAt: new Date(createdAtMs).toISOString(),
    raw,
    message: _messageFromRaw(raw),
    steerId,
    status,
    targetDispatchId,
    lastError,
    source,
    legacy,
  };
}

function _uniqueSteeringPath(inboxDir, createdAtMs) {
  let filePath = path.join(inboxDir, `steering-${createdAtMs}.md`);
  for (let i = 1; fs.existsSync(filePath); i++) {
    filePath = path.join(inboxDir, `steering-${createdAtMs}-${i}.md`);
  }
  return filePath;
}

// Contract block describing the ACK-file protocol. Injected into the prompt
// alongside any pending steering messages so the agent knows how to confirm
// it has read+addressed a labeled message. Mirrored verbatim into
// playbooks/shared-rules.md so runtimes see the contract even outside of a
// steering-resume spawn.
function ackContractBlock() {
  return [
    '### Steering ack protocol',
    '',
    'When you have read and addressed a steering message labeled `steer-<id>`, write an empty file at `${MINIONS_STEERING_ACK_DIR}/<id>.ack` before continuing.',
  ].join('\n');
}

function _renderFrontmatter(data) {
  const createdAtMs = Number(data.createdAtMs) || Date.now();
  const lines = [
    '---',
    `createdAt: ${new Date(createdAtMs).toISOString()}`,
    `createdAtMs: ${createdAtMs}`,
    `source: ${data.source || 'human'}`,
    `steerId: ${data.steerId}`,
    `status: ${data.status || STATUS.QUEUED}`,
  ];
  if (data.targetDispatchId) lines.push(`targetDispatchId: ${data.targetDispatchId}`);
  if (data.lastError) lines.push(`lastError: ${String(data.lastError).replace(/[\r\n]+/g, ' ').trim()}`);
  lines.push('---', '', String(data.message || '').trim(), '');
  return lines.join('\n');
}

function writeSteeringMessage(agentId, message, opts = {}) {
  const createdAtMs = Number(opts.createdAtMs) || Date.now();
  const inboxDir = agentInboxDir(agentId);
  fs.mkdirSync(inboxDir, { recursive: true });
  const filePath = _uniqueSteeringPath(inboxDir, createdAtMs);
  const steerId = String(opts.steerId || _generateSteerId());
  const source = opts.source || 'human';
  const trimmedMessage = String(message || '').trim();
  let bodyText = trimmedMessage;
  // F5 (W-mpeklod3000we69c): when the steering message originates from an
  // untrusted source (PR comment, watch payload, etc.), wrap the body in an
  // <UNTRUSTED-INPUT> fence so downstream prompt builders splice it as data.
  // Callers stay in control via opts.untrusted; the default false preserves
  // the legacy "human teammate writes verbatim" behavior of the dashboard
  // POST /api/agents/steer endpoint.
  if (opts.untrusted) {
    bodyText = wrapUntrusted(bodyText, buildSource('steering', {
      source,
      agentId,
      steerId,
    }));
  }
  const body = _renderFrontmatter({
    createdAtMs,
    source,
    steerId,
    status: opts.status || STATUS.QUEUED,
    targetDispatchId: opts.targetDispatchId || null,
    lastError: opts.lastError || null,
    message: bodyText,
  });
  shared.safeWrite(filePath, body);

  // W-mq066js7000fff1f-a (Gap D): insert a 'queued' row into the
  // observable delivery-state table. Best-effort — a SQLite failure
  // here must not block message delivery (the legacy heuristic ack
  // path still works for entries without a DB row).
  try {
    const store = require('./steering-store');
    store.insert({
      id: steerId,
      agentId,
      messageId: path.basename(filePath),
      dispatchId: opts.dispatchId || null,
      status: 'queued',
      source,
      runtime: opts.runtime || null,
      payloadExcerpt: trimmedMessage.slice(0, 200),
      createdAt: createdAtMs,
    });
  } catch { /* SQL unavailable — message still queued via inbox file */ }

  return _readEntry(filePath);
}

function _updateEntryStatus(entry, newStatus, opts = {}) {
  if (!entry?.path) return null;
  const body = _renderFrontmatter({
    createdAtMs: entry.createdAtMs,
    source: entry.source || 'human',
    steerId: entry.steerId,
    status: newStatus,
    targetDispatchId: entry.targetDispatchId,
    lastError: opts.lastError !== undefined ? opts.lastError : entry.lastError,
    message: entry.message,
  });
  shared.safeWrite(entry.path, body);
  return _readEntry(entry.path);
}

function listAllSteeringEntries(agentId, opts = {}) {
  const includeLegacy = opts.includeLegacy !== false;
  const entries = [];
  const inboxDir = agentInboxDir(agentId);
  for (const file of shared.safeReadDir(inboxDir)) {
    if (!/^steering-.*\.md$/i.test(file)) continue;
    const entry = _readEntry(path.join(inboxDir, file), false);
    if (entry) entries.push(entry);
  }

  if (includeLegacy) {
    const legacyPath = path.join(AGENTS_DIR, agentId, 'steer.md');
    const legacy = _readEntry(legacyPath, true);
    if (legacy) entries.push(legacy);
  }

  entries.sort((a, b) => (a.createdAtMs - b.createdAtMs) || a.file.localeCompare(b.file));
  return entries;
}

function listUnreadSteeringMessages(agentId, opts = {}) {
  // Back-compat shape: "unread" = anything still pending agent attention
  // (queued/live_kill/deferred/re_spawning/delivered/stranded). Explicit
  // 'dropped' or 'acknowledged' rows are filtered so supersede/ack don't
  // resurrect carry-over messages on the next dispatch.
  return listAllSteeringEntries(agentId, opts).filter(entry => {
    const status = entry.status || STATUS.QUEUED;
    return status !== STATUS.DROPPED && status !== STATUS.ACKNOWLEDGED;
  });
}

// W-mq066js7000fff1f-f (Gap F): per-dispatch scoping. Callers that know
// which dispatch they're about to spawn can pass {currentDispatchId} so
// messages tagged for a different (older) dispatch are filtered out.
// Pre-spawn messages (targetDispatchId null) always pass through.
// Without currentDispatchId, no filtering happens — back-compat for
// callers that haven't been updated to pass the dispatch id yet.
function buildPendingSteeringPrompt(agentId, opts = {}) {
  const includePrior = opts.includePrior === true;
  const currentDispatchId = opts.currentDispatchId || null;
  const allEntries = listUnreadSteeringMessages(agentId).filter(entry => entry.message.trim());
  const entries = includePrior
    ? allEntries
    : allEntries.filter(entry => {
        // Pre-spawn / agent-scoped messages (null targetDispatchId) always
        // pick up on the next dispatch — the agent never had a chance to
        // hear them yet.
        if (!entry.targetDispatchId) return true;
        // No dispatch context → don't try to filter (back-compat).
        if (!currentDispatchId) return true;
        // Per-dispatch messages only belong to their tagged dispatch.
        return entry.targetDispatchId === currentDispatchId;
      });

  if (entries.length === 0) return { entries, prompt: '' };

  const sections = [
    '## Pending instructions from prior session',
    '',
    'These human steering messages were not confirmed processed before the previous session ended. Address them before continuing with the task.',
  ];
  entries.forEach((entry, idx) => {
    const label = entry.steerId ? `steer-${entry.steerId}` : '(no-id)';
    sections.push('', `### Message ${idx + 1} — ${label} — ${entry.createdAt}`, '', entry.message.trim());
  });
  sections.push('', ackContractBlock());
  return { entries, prompt: sections.join('\n') };
}

// W-mq066js7000fff1f-e (Gap E): dedupe lookup for POST /api/agents/steer.
// Returns the existing entry whose normalized body matches `message` and
// was created within `opts.windowMs` (default 5 min); null otherwise.
// Only entries in "live" delivery states qualify — dropped/acknowledged
// rows are ignored so a previously-superseded duplicate body is allowed
// to be re-sent.
function findRecentDuplicate(agentId, message, opts = {}) {
  const trimmed = String(message || '').trim();
  if (!trimmed) return null;
  const windowMs = Number(opts.windowMs) > 0 ? Number(opts.windowMs) : DEDUPE_WINDOW_MS;
  const now = Number(opts.now) > 0 ? Number(opts.now) : Date.now();
  const entries = listAllSteeringEntries(agentId);
  // Newest first — UI usually steers in a tight loop, the most recent
  // identical message is the one we want to deduplicate against.
  entries.sort((a, b) => b.createdAtMs - a.createdAtMs);
  for (const entry of entries) {
    const status = entry.status || STATUS.QUEUED;
    if (!DEDUPE_CANDIDATE_STATUSES.has(status)) continue;
    if (now - entry.createdAtMs > windowMs) continue;
    if (entry.message.trim() !== trimmed) continue;
    return entry;
  }
  return null;
}

// W-mq066js7000fff1f-e (Gap E): supersede prior steering messages.
// mode:
//   'all'      — drop every entry whose status is neither dropped nor
//                acknowledged (queued/live_kill/deferred/re_spawning/
//                delivered/stranded).
//   'unacked'  — drop queued/live_kill/deferred/re_spawning/stranded but
//                leave 'delivered' alone (agent already saw it).
//   <steerId>  — drop the single entry with that steerId.
// Returns the list of dropped {steerId, file, path, previousStatus}.
function supersedeMessages(agentId, mode, opts = {}) {
  if (!mode) return [];
  const newSteerId = opts.newSteerId || null;
  const reason = opts.reason || (newSteerId ? `superseded by ${newSteerId}` : 'superseded');
  const entries = listAllSteeringEntries(agentId);
  const dropped = [];
  for (const entry of entries) {
    const status = entry.status || STATUS.QUEUED;
    let target = false;
    if (mode === 'all') {
      if (status !== STATUS.ACKNOWLEDGED && status !== STATUS.DROPPED) target = true;
    } else if (mode === 'unacked') {
      if (UNACKED_STATUSES.has(status)) target = true;
    } else {
      // Specific steerId
      if (entry.steerId && entry.steerId === mode) target = true;
    }
    if (!target) continue;
    _updateEntryStatus(entry, STATUS.DROPPED, { lastError: reason });
    dropped.push({
      steerId: entry.steerId,
      file: entry.file,
      path: entry.path,
      previousStatus: status,
    });
  }
  return dropped;
}

function _eventTimestampMs(obj, observedAtMs) {
  const value = obj?.timestamp || obj?.createdAt || obj?.created_at || obj?.time || obj?.data?.timestamp;
  const parsed = value ? Date.parse(value) : NaN;
  if (Number.isFinite(parsed)) return parsed;
  return Number(observedAtMs) || Date.now();
}

function _isProcessEvidenceEvent(obj) {
  if (!obj || typeof obj !== 'object') return false;
  const type = String(obj.type || '');
  if (type === 'assistant' || type === 'tool_use') return true;
  if (type.startsWith('assistant.') || type.startsWith('tool.')) return true;
  if (Array.isArray(obj.message?.content)) {
    return obj.message.content.some(block => block?.type === 'text' || block?.type === 'tool_use');
  }
  return false;
}

function _processEvidenceTimes(rawOutput, observedAtMs) {
  const times = [];
  for (const line of String(rawOutput || '').split(/\r?\n/)) {
    const trimmed = line.trim();
    if (!trimmed.startsWith('{')) continue;
    try {
      const obj = JSON.parse(trimmed);
      if (_isProcessEvidenceEvent(obj)) times.push(_eventTimestampMs(obj, observedAtMs));
    } catch { /* ignore non-JSON output */ }
  }
  return times;
}

function sessionIdFromEvent(obj) {
  if (!obj || typeof obj !== 'object') return null;
  const candidates = [
    obj.session_id,
    obj.sessionId,
    obj.data?.session_id,
    obj.data?.sessionId,
  ];
  for (const value of candidates) {
    if (typeof value === 'string' && value.trim()) return value.trim();
  }
  if (obj.raw && obj.raw !== obj) return sessionIdFromEvent(obj.raw);
  return null;
}

function sessionIdFromOutputLine(line) {
  const trimmed = String(line || '').trim();
  if (!trimmed.startsWith('{')) return null;
  try { return sessionIdFromEvent(JSON.parse(trimmed)); } catch { return null; }
}

function ackProcessedSteeringMessages(agentId, pendingEntries, rawOutput, opts = {}) {
  const entries = Array.isArray(pendingEntries) ? pendingEntries : [];
  if (entries.length === 0) return [];
  const times = _processEvidenceTimes(rawOutput, opts.observedAtMs);
  if (times.length === 0) return [];

  const acked = [];
  for (const entry of entries) {
    if (!entry?.path) continue;
    if (!times.some(t => t > entry.createdAtMs)) continue;
    shared.safeUnlink(entry.path);
    // W-mq066js7000fff1f-a (Gap D): transition the SQL delivery-state
    // row to 'acknowledged'. Entries without a steerId (legacy inbox
    // files written before migration 012) are still unlinked as
    // before — the heuristic ACK path remains the back-compat fallback.
    if (entry.steerId) {
      try {
        const store = require('./steering-store');
        store.updateStatus(entry.steerId, 'acknowledged');
      } catch { /* SQL unavailable — file ack still happened */ }
    }
    acked.push(entry);
  }
  return acked;
}

// Gap A (W-mq066js7000fff1f-b): scan agents/<id>/steering-ack/ for <id>.ack
// files written by the agent to confirm a labeled steering message has been
// read+addressed. For each ack, locate the matching inbox file by frontmatter
// `steerId:` and remove BOTH files via shared.safeUnlink (idempotent — second
// ack of the same id is a no-op). Returns the list of acked descriptors so
// callers can log per-message observability.
//
// Coexists with ackProcessedSteeringMessages: this is the explicit-contract
// path (agent writes <id>.ack); the timestamp-evidence heuristic remains the
// fallback for messages without a steerId or for runtimes/agents that don't
// honor the contract.
function ackSteeringFromAckDir(agentId, _opts = {}) {
  const ackDir = agentAckDir(agentId);
  const files = shared.safeReadDir(ackDir);
  if (!files.length) return [];

  const inboxDir = agentInboxDir(agentId);
  // Build a single steerId → inboxPath index once per scan so multiple acks
  // in the same tick share one inbox directory read.
  const inboxBySteerId = new Map();
  for (const file of shared.safeReadDir(inboxDir)) {
    if (!/^steering-.*\.md$/i.test(file)) continue;
    const p = path.join(inboxDir, file);
    const raw = shared.safeRead(p);
    const id = _frontmatterValue(raw, 'steerId');
    if (id) inboxBySteerId.set(id, p);
  }

  const acked = [];
  for (const file of files) {
    const m = /^(.+)\.ack$/i.exec(file);
    if (!m) continue;
    const steerId = m[1];
    const ackPath = path.join(ackDir, file);
    const inboxPath = inboxBySteerId.get(steerId) || null;
    if (inboxPath) shared.safeUnlink(inboxPath);
    shared.safeUnlink(ackPath);
    acked.push({ steerId, ackPath, inboxPath });
  }
  return acked;
}

// Ensure the per-agent steering-ack directory exists. Called by the engine
// pre-spawn so the env-injected MINIONS_STEERING_ACK_DIR is always a real
// writable path from the agent's perspective.
function ensureAgentAckDir(agentId) {
  const dir = agentAckDir(agentId);
  try { fs.mkdirSync(dir, { recursive: true }); } catch { /* best-effort */ }
  return dir;
}

module.exports = {
  agentInboxDir,
  agentAckDir,
  ensureAgentAckDir,
  ackContractBlock,
  writeSteeringMessage,
  listUnreadSteeringMessages,
  listAllSteeringEntries,
  buildPendingSteeringPrompt,
  findRecentDuplicate,
  supersedeMessages,
  sessionIdFromEvent,
  sessionIdFromOutputLine,
  ackProcessedSteeringMessages,
  ackSteeringFromAckDir,
  STATUS,
  DEDUPE_WINDOW_MS,
  // Exposed for unit tests only.
  _updateEntryStatus,
  _generateSteerId,
};
