import { Router, type Request, type Response } from 'express';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import path from 'path';
import { authMiddleware } from '../middleware/auth';
import { requirePermission } from '../middleware/permissions';
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
import { requireAdmin } from '../middleware/tierGates';
import { enrollmentLimiter } from '../middleware/rateLimiters';
import { DeployedStackDeletionService } from '../services/DeployedStackDeletionService';
import { DatabaseService } from '../services/DatabaseService';
import { NodeRegistry } from '../services/NodeRegistry';
import { CacheService } from '../services/CacheService';
import { REMOTE_META_NAMESPACE, invalidateRemoteMetaCache } from '../helpers/cacheInvalidation';
import { getActiveCapabilities, getSenchoVersion, type RemoteMeta } from '../services/CapabilityRegistry';
import { PilotTunnelManager } from '../services/PilotTunnelManager';
import { PilotCloseCode } from '../pilot/protocol';
import { MeshProxyTunnelDialer } from '../services/MeshProxyTunnelDialer';
import { FleetUpdateTrackerService } from '../services/FleetUpdateTrackerService';
import { FleetSyncService } from '../services/FleetSyncService';
import { isValidRemoteUrl } from '../utils/validation';
import { getErrorMessage } from '../utils/errors';
import { toPublicNode } from '../helpers/publicNode';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
import { logDebugTiming } from '../utils/requestTiming';
import { BlueprintReconciler } from '../services/BlueprintReconciler';
import { recordPlacementShift, snapshotPlacementWith } from '../services/gitops/nodePlacementProducers';
import { projectCommittedRevisions } from '../helpers/gitopsResponse';
import { assertSafeOutboundUrl, safeRemoteFetch, UnsafeOutboundTargetError } from '../utils/outboundTarget';

const NODE_SCOPE_MESSAGE = 'API tokens cannot manage nodes.';
const REMOTE_META_CACHE_TTL = 3 * 60 * 1000;
const DEFAULT_COMPOSE_DIR = '/app/compose';
const DEFAULT_PILOT_COMPOSE_DIR = '/opt/docker/sencho';

function normalizePilotComposeDir(value: unknown): string | null {
  if (value !== undefined && value !== null && typeof value !== 'string') return null;
  const raw = typeof value === 'string' ? value.trim() : '';
  const candidate = raw || DEFAULT_PILOT_COMPOSE_DIR;
  if (!path.posix.isAbsolute(candidate) || /[\0\r\n]/.test(candidate)) return null;
  return path.posix.normalize(candidate);
}

function yamlString(value: string): string {
  return JSON.stringify(value);
}

/**
 * Pick the URL the pilot agent should dial. SENCHO_PUBLIC_URL wins when set
 * and well-formed, because the request Host header is only reachable from
 * the network the operator opened the dialog from. Pilots on a public cloud
 * cannot dial a LAN or loopback address, so an explicit public URL is the
 * only thing that lets the enrolled YAML work unmodified.
 */
function resolvePrimaryUrl(req: Request): string {
  const override = process.env.SENCHO_PUBLIC_URL?.trim();
  if (override) {
    const check = isValidRemoteUrl(override);
    if (check.valid) return override.replace(/\/$/, '');
    console.warn(`[Enrollment] SENCHO_PUBLIC_URL is set but invalid (${check.reason}); falling back to request host.`);
  }
  const protocol = req.protocol;
  const host = req.get('host') || 'localhost:1852';
  return `${protocol}://${host}`;
}

function mintPilotEnrollment(nodeId: number, req: Request): { token: string; expiresAt: number; composeYaml: string } {
  const db = DatabaseService.getInstance();
  const node = db.getNode(nodeId);
  if (!node) throw new Error('Node not found');
  const composeDir = normalizePilotComposeDir(node.compose_dir);
  if (!composeDir) throw new Error('Pilot compose directory must be an absolute path');
  const jwtSecret = db.getGlobalSettings().auth_jwt_secret;
  if (!jwtSecret) throw new Error('JWT secret not configured');

  const ttlSeconds = 15 * 60;
  const expiresAt = Date.now() + ttlSeconds * 1000;
  const enrollNonce = crypto.randomUUID();
  const token = jwt.sign(
    { scope: 'pilot_enroll', nodeId, enrollNonce },
    jwtSecret,
    { expiresIn: ttlSeconds },
  );
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
  db.createPilotEnrollment(nodeId, tokenHash, expiresAt);

  const primaryUrl = resolvePrimaryUrl(req);

  // Top-level `name` plus `container_name` make the agent container's HOSTNAME
  // equal to `sencho-agent`, which is how SelfUpdateService locates its own
  // compose context to enable remote self-update.
  const composeYaml = [
    `name: sencho-agent`,
    `services:`,
    `  agent:`,
    `    image: saelix/sencho:latest`,
    `    container_name: sencho-agent`,
    `    restart: unless-stopped`,
    `    volumes:`,
    `      - /var/run/docker.sock:/var/run/docker.sock`,
    `      - sencho-agent-data:/app/data`,
    `      - type: bind`,
    `        source: ${yamlString(composeDir)}`,
    `        target: ${yamlString(composeDir)}`,
    `    environment:`,
    `      SENCHO_MODE: pilot`,
    `      SENCHO_PRIMARY_URL: ${yamlString(primaryUrl)}`,
    `      SENCHO_ENROLL_TOKEN: ${yamlString(token)}`,
    `      COMPOSE_DIR: ${yamlString(composeDir)}`,
    ``,
    `volumes:`,
    `  sencho-agent-data:`,
    ``,
  ].join('\n');

  return { token, expiresAt, composeYaml };
}

export const nodesRouter = Router();

/**
 * Placement as it currently resolves, for every Blueprint.
 *
 * Taken either side of a cordon, and today the two snapshots are always equal,
 * so a cordon revises nothing and reports no revisions. That is the correct
 * answer, not a gap: a cordon suppresses *new* placements, while this snapshot
 * reports what each Blueprint asks for, and those are different questions.
 * `listDesiredNodes` accordingly does not read `cordoned` at all, and the
 * reconciler applies the cordon filter later, when it decides what to place.
 *
 * The comparison is kept rather than short-circuited because it is the same
 * shared helper the label routes use, where placement genuinely does move, and
 * because it is what would start reporting correctly if the desired set ever
 * became cordon-aware. Cheap either way: two in-memory selector evaluations.
 */
function snapshotBlueprintPlacement() {
  const db = DatabaseService.getInstance();
  const nodes = db.getNodes();
  return snapshotPlacementWith(
    (blueprint) => BlueprintReconciler.getInstance().listDesiredNodes(blueprint, nodes).map(n => n.id),
    db.listBlueprints(),
  );
}

nodesRouter.get('/', async (req: Request, res: Response) => {
  if (!requirePermission(req, res, 'node:read')) return;
  const startedAt = Date.now();
  let outcome: 'ok' | 'error' = 'ok';
  let count = 0;
  try {
    const nodes = DatabaseService.getInstance().getNodes();
    count = nodes.length;
    res.json(nodes.map(toPublicNode));
  } catch (error) {
    outcome = 'error';
    res.status(500).json({ error: 'Failed to fetch nodes' });
  } finally {
    // Gateway-owned: /api/nodes is proxy-exempt, so this always runs on the
    // control instance and gates on the gateway's developer_mode.
    logDebugTiming('[Nodes:debug]', {
      route: 'GET /nodes',
      count,
      elapsedMs: Date.now() - startedAt,
      outcome,
    });
  }
});

nodesRouter.get('/scheduling-summary', authMiddleware, (req: Request, res: Response) => {
  if (!requirePermission(req, res, 'node:read')) return;
  try {
    const db = DatabaseService.getInstance();
    const scheduleSummary = db.getNodeSchedulingSummary();
    const updateSummary = db.getNodeUpdateSummary();

    const result: Record<number, {
      active_tasks: number;
      auto_update_enabled: boolean;
      next_run_at: number | null;
      stacks_with_updates: number;
    }> = {};

    for (const s of scheduleSummary) {
      result[s.node_id] = {
        active_tasks: s.active_tasks,
        auto_update_enabled: s.auto_update_enabled === 1,
        next_run_at: s.next_run_at,
        stacks_with_updates: 0,
      };
    }
    for (const u of updateSummary) {
      if (result[u.node_id]) {
        result[u.node_id].stacks_with_updates = u.stacks_with_updates;
      } else {
        result[u.node_id] = {
          active_tasks: 0,
          auto_update_enabled: false,
          next_run_at: null,
          stacks_with_updates: u.stacks_with_updates,
        };
      }
    }

    res.json(result);
  } catch (error) {
    console.error('Failed to fetch node scheduling summary:', error);
    res.status(500).json({ error: 'Failed to fetch node scheduling summary' });
  }
});

nodesRouter.get('/:id', async (req: Request, res: Response) => {
  if (!requirePermission(req, res, 'node:read', 'node', req.params.id as string)) return;
  try {
    const id = parseInt(req.params.id as string);
    const node = DatabaseService.getInstance().getNode(id);
    if (!node) {
      return res.status(404).json({ error: 'Node not found' });
    }
    res.json(toPublicNode(node));
  } catch (error) {
    res.status(500).json({ error: 'Failed to fetch node' });
  }
});

nodesRouter.post('/', enrollmentLimiter, async (req: Request, res: Response) => {
  if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return;
  if (!requirePermission(req, res, 'node:manage')) return;
  try {
    const { name, type, compose_dir, is_default, api_url, api_token, mode } = req.body;

    if (!name || typeof name !== 'string') {
      return res.status(400).json({ error: 'Node name is required' });
    }
    if (!type || !['local', 'remote'].includes(type)) {
      return res.status(400).json({ error: 'Node type must be "local" or "remote"' });
    }

    const resolvedMode: 'proxy' | 'pilot_agent' = type === 'remote' && mode === 'pilot_agent' ? 'pilot_agent' : 'proxy';
    const pilotComposeDir = resolvedMode === 'pilot_agent' ? normalizePilotComposeDir(compose_dir) : null;
    if (resolvedMode === 'pilot_agent' && !pilotComposeDir) {
      return res.status(400).json({ error: 'Pilot compose directory must be an absolute path' });
    }

    if (type === 'remote' && resolvedMode === 'proxy') {
      if (!api_url || typeof api_url !== 'string') {
        return res.status(400).json({ error: 'API URL is required for proxy-mode remote nodes' });
      }
      const urlCheck = isValidRemoteUrl(api_url);
      if (!urlCheck.valid) {
        return res.status(400).json({ error: urlCheck.reason });
      }
      try {
        await assertSafeOutboundUrl(api_url);
      } catch (error: unknown) {
        if (error instanceof UnsafeOutboundTargetError) {
          const message = error.reason === 'blocked'
            ? 'API URL target is not allowed'
            : 'API URL host could not be resolved';
          return res.status(400).json({ error: message });
        }
        throw error;
      }
    }

    const id = DatabaseService.getInstance().addNode({
      name,
      type,
      compose_dir: pilotComposeDir ?? (compose_dir || DEFAULT_COMPOSE_DIR),
      is_default: is_default || false,
      api_url: resolvedMode === 'pilot_agent' ? '' : (api_url || ''),
      api_token: resolvedMode === 'pilot_agent' ? '' : (api_token || ''),
      mode: resolvedMode,
    });

    NodeRegistry.getInstance().notifyNodeAdded(id);
    console.log(`[Nodes] Created ${type} node "${sanitizeForLog(name)}" (id=${id}, mode=${resolvedMode})`);

    // Backfill replicated security state on the new remote so an operator who
    // adds a node mid-life does not have to wait for the next policy edit
    // before scan_policies and cve_suppressions land. No-op for local nodes
    // and pilot-agent nodes (FleetSyncService.pushResource filters them out).
    if (type === 'remote' && resolvedMode === 'proxy') {
      FleetSyncService.getInstance().pushResourceAsync('scan_policies');
      FleetSyncService.getInstance().pushResourceAsync('cve_suppressions');
    }

    let enrollment: ReturnType<typeof mintPilotEnrollment> | null = null;
    if (resolvedMode === 'pilot_agent') {
      enrollment = mintPilotEnrollment(id, req);
    }

    const isPlainHttp = resolvedMode === 'proxy' && type === 'remote' && api_url && api_url.startsWith('http://');
    res.json({
      success: true,
      id,
      ...(enrollment && { enrollment }),
      ...(isPlainHttp && {
        warning: 'This node uses plain HTTP. Use HTTPS or a VPN for connections over the public internet.'
      })
    });
  } catch (error: unknown) {
    const message = error instanceof Error ? error.message : '';
    if (message.includes('A local node already exists')) {
      return res.status(409).json({ error: message });
    }
    if (message.includes('UNIQUE constraint')) {
      return res.status(409).json({ error: 'A node with that name already exists' });
    }
    console.error('Failed to create node:', error);
    res.status(500).json({ error: message || 'Failed to create node' });
  }
});

nodesRouter.post('/:id/pilot/enroll', enrollmentLimiter, async (req: Request, res: Response) => {
  if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return;
  const nodeIdStr = req.params.id as string;
  if (!requirePermission(req, res, 'node:manage', 'node', nodeIdStr)) return;
  try {
    const nodeId = parseInt(nodeIdStr, 10);
    if (!Number.isFinite(nodeId)) {
      return res.status(400).json({ error: 'Invalid node id' });
    }
    const node = DatabaseService.getInstance().getNode(nodeId);
    if (!node) return res.status(404).json({ error: 'Node not found' });
    if (node.type !== 'remote' || node.mode !== 'pilot_agent') {
      return res.status(400).json({ error: 'Enrollment only applies to pilot-agent nodes' });
    }
    PilotTunnelManager.getInstance().closeTunnel(nodeId, PilotCloseCode.EnrollmentRegenerated, 'enrollment regenerated');
    const enrollment = mintPilotEnrollment(nodeId, req);
    res.json({ success: true, enrollment });
  } catch (error: unknown) {
    console.error('Failed to regenerate pilot enrollment:', error);
    const message = error instanceof Error ? error.message : 'Failed to regenerate enrollment';
    res.status(500).json({ error: message });
  }
});

nodesRouter.put('/:id', async (req: Request, res: Response) => {
  if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return;
  const nodeId = req.params.id as string;
  if (!requirePermission(req, res, 'node:manage', 'node', nodeId)) return;
  try {
    const id = parseInt(nodeId);
    const updates = req.body;

    // Keep the existing credential unless a real new token is supplied: the
    // stored token is never returned to the client, so an untouched edit form
    // must not clear it. Anything that is not a non-empty string (blank, null,
    // missing, wrong type) drops the field here (defense-in-depth alongside the
    // frontend) so it can never overwrite a configured token; only a non-empty
    // string rotates it.
    if (typeof updates.api_token !== 'string' || updates.api_token.trim() === '') {
      delete updates.api_token;
    }

    const existingNode = DatabaseService.getInstance().getNode(id);
    if (!existingNode) {
      return res.status(404).json({ error: 'Node not found' });
    }

    // updateNode writes any non-undefined name verbatim against a NOT NULL
    // UNIQUE column, so validate shape here with the same message as POST /
    // (stricter on blank names; the stored value is not trimmed, matching
    // create). An unchanged name is exempt so a node whose stored name
    // predates this validation can still be saved as-is.
    const nameChanged = updates.name !== undefined && updates.name !== existingNode.name;
    if (nameChanged && (typeof updates.name !== 'string' || updates.name.trim() === '')) {
      return res.status(400).json({ error: 'Node name is required' });
    }
    // Best-effort pre-check; the catch below remaps a racing UNIQUE violation
    // to the same 409.
    if (nameChanged && DatabaseService.getInstance().getNodes().some((n) => n.name === updates.name)) {
      return res.status(409).json({ error: 'A node with that name already exists' });
    }

    if (existingNode.mode === 'pilot_agent' && updates.compose_dir !== undefined) {
      const composeDir = normalizePilotComposeDir(updates.compose_dir);
      if (!composeDir) {
        return res.status(400).json({ error: 'Pilot compose directory must be an absolute path' });
      }
      updates.compose_dir = composeDir;
    }

    if (updates.type !== undefined && !['local', 'remote'].includes(updates.type)) {
      return res.status(400).json({ error: 'Node type must be "local" or "remote"' });
    }

    if (updates.api_url !== undefined && updates.api_url !== '') {
      const urlCheck = isValidRemoteUrl(updates.api_url);
      if (!urlCheck.valid) {
        return res.status(400).json({ error: urlCheck.reason });
      }
      try {
        await assertSafeOutboundUrl(updates.api_url);
      } catch (error: unknown) {
        if (error instanceof UnsafeOutboundTargetError) {
          const message = error.reason === 'blocked'
            ? 'API URL target is not allowed'
            : 'API URL host could not be resolved';
          return res.status(400).json({ error: message });
        }
        throw error;
      }
    }

    DatabaseService.getInstance().updateNode(id, updates);

    NodeRegistry.getInstance().evictConnection(id);
    NodeRegistry.getInstance().notifyNodeUpdated(id);
    console.log(`[Nodes] Updated node ${id} ("${sanitizeForLog(existingNode.name)}")`);

    // Trigger 2: if the api_token was rotated on a mesh-enabled proxy-mode
    // remote, close the existing callback bridge and re-dial. The next
    // ensureBridge mints a JWT with the fresh token fingerprint so the
    // remote's tunnel auth gate accepts the upgrade. Gate on actual value
    // change so a Save that only edits name / compose_dir does not cycle
    // the bridge (the frontend sends the full formData on every Save).
    const tokenChanged =
      typeof updates.api_token === 'string' &&
      updates.api_token !== existingNode.api_token;
    if (tokenChanged) {
      const meshEnabled = DatabaseService.getInstance().getNodeMeshEnabled(id);
      if (existingNode.type === 'remote' && existingNode.mode === 'proxy' && meshEnabled) {
        MeshProxyTunnelDialer.getInstance().closeBridge(id, 'peer token rotated');
        void MeshProxyTunnelDialer.getInstance().ensureBridge(id).catch((err) => {
          console.warn(`[Mesh] proactive re-bootstrap on token rotation failed for node ${id}: ${(err as Error).message}`);
        });
      }
    }

    const isPlainHttp = updates.api_url && updates.api_url.startsWith('http://');
    res.json({
      success: true,
      ...(isPlainHttp && {
        warning: 'This node uses plain HTTP. Use HTTPS or a VPN for connections over the public internet.'
      })
    });
  } catch (error: unknown) {
    const message = error instanceof Error ? error.message : '';
    if (message.includes('Node type cannot be changed')) {
      return res.status(400).json({ error: message });
    }
    if (message.includes('UNIQUE constraint')) {
      return res.status(409).json({ error: 'A node with that name already exists' });
    }
    console.error('Failed to update node:', error);
    res.status(500).json({ error: message || 'Failed to update node' });
  }
});

nodesRouter.delete('/:id', async (req: Request, res: Response) => {
  if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return;
  const nodeIdParam = req.params.id as string;
  if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
  try {
    const id = parseInt(nodeIdParam);
    const existing = DatabaseService.getInstance().getNode(id);
    if (!existing) {
      return res.status(404).json({ error: 'Node not found' });
    }
    if (existing.is_default) {
      return res.status(400).json({ error: 'Cannot delete the default node' });
    }
    // Release any live tunnel or mesh bridge before deleting the record so it is
    // freed immediately rather than lingering until the peer disconnects. Close a
    // proxy-mode mesh bridge through its dialer FIRST: closeBridge removes the
    // bridge before closing it, so the dialer does not schedule a reactive redial
    // against a node that is about to disappear. closeTunnel then closes a
    // pilot-agent tunnel; both are no-ops when this node has no such bridge (a
    // local node, or one with no active connection). Mirrors the re-enroll path.
    MeshProxyTunnelDialer.getInstance().closeBridge(id, 'node deleted');
    PilotTunnelManager.getInstance().closeTunnel(id, PilotCloseCode.NormalClosure, 'node deleted');
    // Local-socket nodes: ready tombstone + recovery-row retirement in the same
    // transaction as the node delete, then sweep tags/paths. Remote hub records
    // create no Docker cleanup tombstone.
    const movedBlueprints = existing.type === 'local'
      ? await DeployedStackDeletionService.getInstance().deleteLocalNode(id)
      : DeployedStackDeletionService.getInstance().deleteNodeWithGitOps(id);
    NodeRegistry.getInstance().evictConnection(id);
    NodeRegistry.getInstance().notifyNodeRemoved(id);
    CacheService.getInstance().invalidate(`${REMOTE_META_NAMESPACE}:${id}`);
    FleetUpdateTrackerService.getInstance().delete(id);
    console.log(`[Nodes] Deleted node ${id} ("${sanitizeForLog(existing.name)}")`);
    res.json({ success: true, gitopsRevisions: projectCommittedRevisions(movedBlueprints, 'node delete') });
  } catch (error: unknown) {
    const message = error instanceof Error ? error.message : '';
    if (message.includes('Cannot delete the only local node')) {
      return res.status(400).json({ error: message });
    }
    console.error('Failed to delete node:', error);
    res.status(500).json({ error: message || 'Failed to delete node' });
  }
});

nodesRouter.post('/:id/cordon', (req: Request, res: Response) => {
  if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return;
  const nodeIdParam = req.params.id as string;
  if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
  if (!/^[1-9]\d*$/.test(nodeIdParam)) {
    res.status(400).json({ error: 'Invalid node id' });
    return;
  }
  const id = parseInt(nodeIdParam, 10);
  const rawReason = (req.body && typeof req.body === 'object') ? (req.body as { reason?: unknown }).reason : undefined;
  let reason: string | null = null;
  if (rawReason !== undefined && rawReason !== null) {
    if (typeof rawReason !== 'string') {
      res.status(400).json({ error: 'reason must be a string' });
      return;
    }
    const trimmed = rawReason.trim();
    if (trimmed.length > 256) {
      res.status(400).json({ error: 'reason must be 256 characters or fewer' });
      return;
    }
    reason = trimmed.length > 0 ? trimmed : null;
  }
  try {
    const existing = DatabaseService.getInstance().getNode(id);
    if (!existing) {
      res.status(404).json({ error: 'Node not found' });
      return;
    }
    // The cordon and the placement it moves commit together.
    const { node: updated, moved } = DatabaseService.getInstance().getDb().transaction(() => {
      const before = snapshotBlueprintPlacement();
      const node = DatabaseService.getInstance().setNodeCordoned(id, true, reason);
      const moved = existing.cordoned
        ? []
        : recordPlacementShift(before, snapshotBlueprintPlacement(), req.user?.username ?? null, 'node_cordon');
      return { node, moved };
    })();
    if (isDebugEnabled()) console.log('[Federation:diag] cordoned node=%s reasonLen=%s', sanitizeForLog(id), sanitizeForLog(reason?.length ?? 0));
    res.set('cache-control', 'no-store').json({ ...updated, gitopsRevisions: projectCommittedRevisions(moved, 'node cordon') });
  } catch (error: unknown) {
    console.error('Failed to cordon node:', error);
    res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to cordon node' });
  }
});

nodesRouter.post('/:id/uncordon', (req: Request, res: Response) => {
  if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return;
  const nodeIdParam = req.params.id as string;
  if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
  if (!/^[1-9]\d*$/.test(nodeIdParam)) {
    res.status(400).json({ error: 'Invalid node id' });
    return;
  }
  const id = parseInt(nodeIdParam, 10);
  try {
    const existing = DatabaseService.getInstance().getNode(id);
    if (!existing) {
      res.status(404).json({ error: 'Node not found' });
      return;
    }
    const { node: updated, moved } = DatabaseService.getInstance().getDb().transaction(() => {
      const before = snapshotBlueprintPlacement();
      const node = DatabaseService.getInstance().setNodeCordoned(id, false, null);
      const moved = existing.cordoned
        ? recordPlacementShift(before, snapshotBlueprintPlacement(), req.user?.username ?? null, 'node_uncordon')
        : [];
      return { node, moved };
    })();
    res.set('cache-control', 'no-store').json({ ...updated, gitopsRevisions: projectCommittedRevisions(moved, 'node uncordon') });
  } catch (error: unknown) {
    console.error('Failed to uncordon node:', error);
    res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to uncordon node' });
  }
});

/**
 * Reset the FleetSync control anchor on a remote peer.
 *
 * Proxies POST /api/fleet/role/reanchor to the peer using its stored
 * Bearer token. On success, clears every sticky-error row for this node
 * so the next push re-attempts cleanly and the peer accepts the central's
 * fingerprint as the new anchor.
 *
 * Used when a peer was previously enrolled by a different central:
 * FleetSync keeps 409'ing every reconcile tick, the sticky flag halts
 * retries, and this endpoint is the one-click operator recovery.
 */
nodesRouter.post('/:id/fleet-sync/reset-anchor', async (req: Request, res: Response) => {
  if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return;
  const nodeIdParam = req.params.id as string;
  if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
  // Also requireAdmin so reset stays symmetric with GET /api/fleet/sync-status.
  // A node-admin must not reset anchors they cannot observe (status is admin-only).
  if (!requireAdmin(req, res)) return;
  try {
    const id = parseInt(nodeIdParam, 10);
    if (!Number.isFinite(id) || id <= 0) {
      res.status(400).json({ error: 'Invalid node id' });
      return;
    }
    const node = DatabaseService.getInstance().getNode(id);
    if (!node) {
      res.status(404).json({ error: 'Node not found' });
      return;
    }
    if (node.type !== 'remote' || node.mode !== 'proxy') {
      res.status(400).json({ error: 'Reset anchor only applies to proxy-mode remote nodes' });
      return;
    }
    if (!node.api_url || !node.api_token) {
      res.status(400).json({ error: 'Node is missing api_url or api_token' });
      return;
    }

    const baseUrl = node.api_url.replace(/\/$/, '');
    let peerResponse: globalThis.Response;
    try {
      peerResponse = await safeRemoteFetch(`${baseUrl}/api/fleet/role/reanchor`, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${node.api_token}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ override: true }),
        signal: AbortSignal.timeout(15_000),
      });
    } catch (networkErr) {
      const message = getErrorMessage(networkErr, 'Failed to reach peer');
      console.warn(`[Nodes] Reset anchor unreachable for node ${id}: ${message}`);
      res.status(504).json({ error: `Peer unreachable: ${message}` });
      return;
    }

    if (!peerResponse.ok) {
      const status = peerResponse.status;
      const body = await peerResponse.json().catch(() => ({}));
      const peerError = (body as { error?: string })?.error
        ?? `Peer returned HTTP ${status}`;
      if (status === 401 || status === 403) {
        console.warn(`[Nodes] Reset anchor rejected by peer ${id}: ${peerError}`);
        res.status(502).json({
          error: `Peer rejected the reanchor request: ${peerError}. The node's API token may need to be regenerated.`,
        });
        return;
      }
      res.status(502).json({ error: peerError });
      return;
    }

    DatabaseService.getInstance().clearFleetSyncStickyForNode(id);
    console.log(`[Nodes] Fleet-sync anchor reset on node ${id} ("${node.name}")`);
    res.json({ success: true });
  } catch (error: unknown) {
    console.error('Failed to reset fleet-sync anchor:', error);
    res.status(500).json({ error: getErrorMessage(error, 'Failed to reset fleet-sync anchor') });
  }
});

nodesRouter.post('/:id/test', async (req: Request, res: Response) => {
  if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return;
  const nodeIdParam = req.params.id as string;
  if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
  try {
    const id = parseInt(nodeIdParam);
    const startedAt = Date.now();
    const result = await NodeRegistry.getInstance().testConnection(id);
    // An explicit test is the operator asking for fresh truth: drop the cached
    // /api/meta so the next read rebuilds version and capabilities live rather
    // than serving a value up to the remote-meta TTL old.
    invalidateRemoteMetaCache(id);
    if (!result.success) {
      console.warn(`[Nodes] Connection test failed for node ${id}: ${sanitizeForLog(result.error ?? 'unknown')}`);
    }
    if (isDebugEnabled()) {
      console.log(`[Nodes:diag] test node=${id} success=${result.success} ms=${Date.now() - startedAt}`);
    }
    res.json(result);
  } catch (error: unknown) {
    const message = error instanceof Error ? error.message : 'Connection test failed';
    res.status(500).json({ success: false, error: message });
  }
});

nodesRouter.get('/:id/meta', authMiddleware, async (req: Request, res: Response) => {
  if (!requirePermission(req, res, 'node:read', 'node', req.params.id as string)) return;
  const startedAt = Date.now();
  let outcome: 'ok' | 'error' = 'ok';
  let id = NaN;
  let nodeType = 'unknown';
  try {
    id = parseInt(req.params.id as string);
    const node = DatabaseService.getInstance().getNode(id);
    if (!node) {
      outcome = 'error';
      res.status(404).json({ error: 'Node not found' });
      return;
    }
    nodeType = node.type;

    if (node.type === 'local') {
      res.json({ version: getSenchoVersion(), capabilities: getActiveCapabilities() });
      return;
    }

    const cacheKey = `${REMOTE_META_NAMESPACE}:${id}`;
    const meta = await CacheService.getInstance().getOrFetch<RemoteMeta>(
      cacheKey,
      REMOTE_META_CACHE_TTL,
      async () => {
        const fetched = await NodeRegistry.getInstance().fetchMetaForNode(id);
        if (fetched.version === null) {
          throw new Error('Remote meta fetch returned null version');
        }
        return fetched;
      },
    );

    res.json(meta);
  } catch (error: unknown) {
    outcome = 'error';
    console.error('Failed to fetch node meta:', error);
    const message = getErrorMessage(error, 'Failed to fetch node metadata');
    res.status(500).json({ error: message });
  } finally {
    // Gateway-owned: the frontend fetches meta with localOnly, so this runs on
    // the control instance and gates on the gateway's developer_mode.
    logDebugTiming('[Nodes:debug]', {
      route: 'GET /nodes/:id/meta',
      node: id,
      type: nodeType,
      elapsedMs: Date.now() - startedAt,
      outcome,
    });
  }
});
