/**
 * services/agent-channel.js — durable bidirectional channel between TuneVault
 * cloud and on-prem proxy agents.
 *
 * Owns: command enqueue, long-poll delivery, result routing, observability metrics.
 * Does NOT own: authentication (routes/agent.js), Oracle queries (oracle-proxy.py).
 *
 * Architecture (polling model — replaced pg LISTEN/NOTIFY for the Oracle ADB
 * migration; Oracle has no LISTEN/NOTIFY we can lean on):
 *   sendToAgent(id, req)   → INSERT into agent_command_queue
 *                          → poll getCompletedResult(requestId) every POLL_MS
 *                          → resolves when deliverResult() has written the result
 *
 *   waitForWork(id)        → expireStaleClaims + SELECT FOR UPDATE SKIP LOCKED
 *                          → if empty: re-poll the claim every POLL_MS up to holdSeconds
 *
 *   deliverResult()        → UPDATE agent_command_queue (status=completed, result)
 *
 * Why polling over LISTEN/NOTIFY:
 *   The durable state has always lived in the queue table; LISTEN/NOTIFY was only
 *   a wakeup optimisation. Oracle's SELECT ... FOR UPDATE SKIP LOCKED already gives
 *   at-most-once claim semantics, so a short poll interval reproduces the same
 *   behaviour with a small, bounded latency and no engine-specific signalling.
 *
 * Durability guarantee:
 *   Every command row survives Node restarts. Agents re-poll and pick up
 *   unclaimed rows automatically. Claimed rows revert after 60s (expireStaleClaims).
 */

'use strict';

const db = require('../db/agent-command-queue');

// Poll cadence for both the result wait (sendToAgent) and the work wait
// (waitForWork). 1s keeps agent round-trips responsive without hammering the DB;
// the durable queue means a missed tick just resolves on the next one.
const POLL_MS = 1000;

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// ── Public API ────────────────────────────────────────────────────────────────

/**
 * Check whether an agent polled recently (within last 30s).
 * Uses agent_channel_state.last_poll_at.
 *
 * @param {number} connectionId
 * @returns {Promise<boolean>}
 */
async function isAgentConnected(connectionId) {
  return db.isRecentlyPolled(connectionId);
}

/**
 * Send a request to a proxy agent and wait for the response.
 * Enqueues a durable command and resolves when the agent delivers the result.
 *
 * @param {number} connectionId
 * @param {object} request  — { method, path, body, headers }
 * @param {number} timeoutMs — max wait (default 120s)
 * @returns {Promise<{ statusCode: number, body: object }>}
 */
async function sendToAgent(connectionId, request, timeoutMs = 120_000) {
  console.log('[agent-channel] sendToAgent conn=%d path=%s timeout=%d', connectionId, request.path, timeoutMs);

  // 1. Enqueue the command and get a stable requestId
  const { requestId } = await db.enqueueCommand(connectionId, {
    method: request.method || 'POST',
    path: request.path || '/',
    body: request.body || {},
    headers: request.headers || {},
  });

  // 2. Poll for the result the agent will write via deliverResult()
  const deadline = Date.now() + timeoutMs;
  for (;;) {
    const result = await db.getCompletedResult(requestId);
    if (result) {
      return {
        statusCode: result.status_code || 200,
        body: result.body || {},
      };
    }
    if (Date.now() >= deadline) {
      throw new Error('Agent request timed out');
    }
    await sleep(POLL_MS);
  }
}

/**
 * Called when a proxy agent long-polls for work.
 * Returns a work item (or null after holdSeconds).
 *
 * @param {number} connectionId
 * @param {number} holdSeconds — max hold before returning empty (default 25s)
 * @returns {Promise<object|null>}
 */
async function waitForWork(connectionId, holdSeconds = 25) {
  const workerId = `worker-${process.pid}`;

  // Revert any stale claimed rows before we try to claim one
  await db.expireStaleClaims(connectionId);

  // Record the poll time for isAgentConnected()
  await db.upsertChannelState(connectionId);

  // Emit Datadog metrics (fire-and-forget)
  emitQueueMetrics().catch(() => {});

  // Claim immediately if something is pending, otherwise re-poll the claim until
  // holdSeconds elapses. SKIP LOCKED means a row another worker grabbed is simply
  // skipped; we keep waiting for the next one.
  const deadline = Date.now() + holdSeconds * 1000;
  for (;;) {
    const row = await db.claimNextCommand(connectionId, workerId);
    if (row) return row.payload; // payload already is the work item JSON

    if (Date.now() >= deadline) return null;
    await sleep(POLL_MS);
  }
}

/**
 * Called when a proxy agent submits the result for a previous request.
 * Persists the result; sendToAgent()'s poll loop then picks it up.
 *
 * @param {string} requestId
 * @param {number} connectionId
 * @param {object} result — { status_code, body }
 * @returns {Promise<boolean>} true if the command was found and updated
 */
async function deliverResult(requestId, connectionId, result) {
  const row = await db.findByRequestId(requestId, connectionId);
  if (!row) return false;

  await db.completeCommand(row.id, result);

  // Delivering a result is proof of life. Without this, an agent that spends 40s
  // executing an op looks offline to the next request purely because last_poll_at
  // is only written when a long-poll STARTS — which made TuneVault report a
  // perfectly healthy agent as unreachable whenever it was under load.
  db.upsertChannelState(connectionId).catch(() => {});
  return true;
}

/**
 * Cleanup when a connection is deleted or agent uninstalls.
 * No-op in the queue model — queued rows are cascade-deleted via FK.
 * Kept for API compatibility with callers.
 *
 * @param {number} connectionId
 */
function removeChannel(connectionId) {
  // The agent_command_queue FK ON DELETE CASCADE handles physical cleanup.
  // Nothing to do in-process — no Map to purge.
}

// ── Datadog observability metrics ─────────────────────────────────────────────

let _lastMetricEmit = 0;
const METRIC_INTERVAL_MS = 30_000;

async function emitQueueMetrics() {
  const now = Date.now();
  if (now - _lastMetricEmit < METRIC_INTERVAL_MS) return;
  _lastMetricEmit = now;

  const [depth, oldestSec] = await Promise.all([
    db.getQueueDepth(),
    db.getOldestPendingAgeSec(),
  ]);

  // Structured Datadog-compatible log lines (parsed by the log shipper)
  console.log(JSON.stringify({
    metric: 'pg_agent_queue_depth',
    value: depth,
    tags: ['service:tunevault'],
  }));
  console.log(JSON.stringify({
    metric: 'pg_agent_queue_oldest_pending_seconds',
    value: oldestSec,
    tags: ['service:tunevault'],
    alert: oldestSec > 30 ? 'warn' : 'ok',
  }));

  if (oldestSec > 30) {
    console.warn(`[agent-channel] ALERT: oldest pending command is ${oldestSec}s old`);
  }
}

/**
 * Fire-and-forget enqueue — returns { requestId } immediately without waiting
 * for the agent to respond. Used for long-running ops (stop/start all) where
 * the caller polls a job row for the result instead of blocking on HTTP.
 *
 * @param {number} connectionId
 * @param {object} request  — { method, path, body, headers }
 * @param {object} meta     — extra fields merged into payload (e.g. job_id)
 * @returns {Promise<{ requestId: string }>}
 */
async function enqueueCommand(connectionId, request, meta = {}) {
  const { requestId } = await db.enqueueCommand(connectionId, {
    method: request.method || 'POST',
    path: request.path || '/',
    body: request.body || {},
    headers: request.headers || {},
    ...meta,
  });
  return { requestId };
}

module.exports = {
  isAgentConnected,
  sendToAgent,
  enqueueCommand,
  waitForWork,
  deliverResult,
  removeChannel,
};
