/**
 * Tailscale Manager — real implementation of the Tailscale API surface that
 * routes/tailscale.js and src/utilities/middleware.js have been calling into
 * via ctx.tailscale.* for months but always getting `null` back.
 *
 * Why this exists: the previous `getTailscaleStatus()` in src/app.js was a
 * hard-coded `return null` stub with a comment saying it would be populated
 * later. The route file calls tailscale.getStatus() / getLocalIP() /
 * isTailscaleIP() and got undefined back, silently returning empty device
 * lists. The tailscaleAuthMiddleware's allowedTailnet check (DC-121) was
 * dead code for the same reason.
 *
 * Strategy: shell out to the host's `tailscale` CLI and parse its JSON output.
 * `tailscale status --json` returns the full local node + peer map with all
 * the fields the dashboard cares about (TailscaleIPs, HostName, OS, Online,
 * LastSeen, UserID, KeyExpiry, Tags, etc.). Cache for 5 minutes to avoid
 * spawning a CLI on every request.
 *
 * Failure modes handled gracefully:
 *   - `tailscale` CLI not installed on host → return { installed: false }
 *   - tailscaled not running → return { installed: true, connected: false }
 *   - CLI exits non-zero → return null, log warning, fall through to caller
 *   - JSON malformed → return null, log error
 *
 * The `isTailscaleIP()` function re-exports the one from network-detector.js
 * (DC-031) so there's one source of truth for Tailscale CGNAT classification.
 */

'use strict';

const { execFile } = require('child_process');
const { promisify } = require('util');
const { isTailscaleIP } = require('../utilities/network-detector');

const execFileAsync = promisify(execFile);

const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const CLI_TIMEOUT_MS = 5000;
const CLI_BIN = process.env.TAILSCALE_BIN || '/usr/bin/tailscale';

let _cache = {
  data: null,
  fetchedAt: 0,
};

/**
 * Internal: invoke `tailscale status --json` and parse the result.
 * Returns null on any failure (caller decides how to present).
 */
async function _fetchStatusRaw() {
  try {
    const { stdout, stderr } = await execFileAsync(CLI_BIN, ['status', '--json'], {
      timeout: CLI_TIMEOUT_MS,
      maxBuffer: 4 * 1024 * 1024, // 4 MB — peer maps can be large
    });
    if (stderr && !stdout) {
      // CLI wrote to stderr and nothing to stdout — likely "tailscaled not running"
      return null;
    }
    return JSON.parse(stdout);
  } catch (err) {
    // ENOENT: tailscale not installed
    // EACCES: not in the right group
    // non-zero exit: tailscaled down
    // JSON parse: corrupted output
    return null;
  }
}

/**
 * Check whether the tailscale CLI is reachable on this host.
 * Result is cached separately because it's rare to install/uninstall.
 */
let _installedCache = { value: null, fetchedAt: 0 };
const INSTALLED_TTL_MS = 60 * 60 * 1000; // 1 hour

async function _isInstalled() {
  const now = Date.now();
  if (_installedCache.value !== null && (now - _installedCache.fetchedAt) < INSTALLED_TTL_MS) {
    return _installedCache.value;
  }
  try {
    await execFileAsync(CLI_BIN, ['version'], { timeout: 2000 });
    _installedCache = { value: true, fetchedAt: now };
    return true;
  } catch (err) {
    _installedCache = { value: false, fetchedAt: now };
    return false;
  }
}

/**
 * Get the full Tailscale status (self + peers + backend state).
 * Returns null if tailscale is not installed or tailscaled is not running.
 * Results are cached for 5 minutes.
 */
async function getStatus() {
  const now = Date.now();
  if (_cache.data !== null && (now - _cache.fetchedAt) < CACHE_TTL_MS) {
    return _cache.data;
  }

  const installed = await _isInstalled();
  if (!installed) {
    // Don't cache the negative result beyond the installed TTL
    return null;
  }

  const data = await _fetchStatusRaw();
  if (data !== null) {
    _cache = { data, fetchedAt: now };
  }
  return data;
}

/**
 * Get the local node's first Tailscale IPv4 address (e.g. "100.121.150.22").
 * Returns null if no Tailscale IPv4 is assigned.
 */
async function getLocalIP() {
  const status = await getStatus();
  if (!status || !status.Self || !Array.isArray(status.Self.TailscaleIPs)) {
    return null;
  }
  return status.Self.TailscaleIPs.find(ip => ip && ip.includes('.') && !ip.includes(':')) || null;
}

/**
 * Force-refresh the status cache (e.g. after a config change).
 */
function invalidateCache() {
  _cache = { data: null, fetchedAt: 0 };
  _installedCache = { value: null, fetchedAt: 0 };
}

/**
 * Get a friendly structured summary suitable for the dashboard.
 * Returns:
 *   { installed: false } if CLI is missing
 *   { installed: true, connected: false, ... } if tailscaled is down
 *   { installed: true, connected: true, hostname, ip, peerCount, ... } on success
 */
async function getSummary() {
  const installed = await _isInstalled();
  if (!installed) {
    return { installed: false, connected: false, message: 'tailscale CLI not found' };
  }

  const status = await getStatus();
  if (!status) {
    return { installed: true, connected: false, message: 'tailscaled not reachable' };
  }

  return {
    installed: true,
    connected: status.BackendState === 'Running',
    backendState: status.BackendState || null,
    hostname: status.Self?.HostName || null,
    ip: status.Self?.TailscaleIPs?.find(ip => ip && ip.includes('.') && !ip.includes(':')) || null,
    ipv6: status.Self?.TailscaleIPs?.find(ip => ip && ip.includes(':')) || null,
    peerCount: Object.keys(status.Peer || {}).length,
    onlinePeerCount: Object.values(status.Peer || {}).filter(p => p.Online).length,
  };
}

/**
 * Get the enriched device list (peers) for the dashboard.
 * Each entry has the fields the dashboard UI cares about.
 */
async function getDevices() {
  const status = await getStatus();
  if (!status || !status.Peer) {
    return [];
  }
  return Object.entries(status.Peer).map(([id, peer]) => ({
    id,
    hostname: peer.HostName,
    dnsName: peer.DNSName,
    ip: peer.TailscaleIPs?.[0] || null,
    ips: peer.TailscaleIPs || [],
    os: peer.OS,
    online: !!peer.Online,
    lastSeen: peer.LastSeen || null,
    user: peer.UserID || null,
    tags: peer.Tags || [],
    keyExpiry: peer.KeyExpiry || null,
    isExitNode: !!peer.ExitNode,
    rxBytes: peer.RxBytes || 0,
    txBytes: peer.TxBytes || 0,
  }));
}

/**
 * OAuth access token retrieval — stub for now.
 * The OAuth flow is implemented in routes/tailscale.js but requires the
 * configured OAuth credentials from disk. The token exchange itself
 * happens in the route handler; this is a placeholder so ctx.tailscale has
 * a complete API surface. Returns null (no token cached) by default.
 */
// eslint-disable-next-line require-await -- stub, will gain await when OAuth flow lands
async function getAccessToken() {
  return null;
}

/**
 * Background sync timer — stub.
 * The Tailscale API sync (oauth-config + sync routes) uses an in-process
 * interval. This is a placeholder for parity with the ctx.tailscale surface.
 */
let _syncInterval = null;
function startSyncTimer(intervalMs = 5 * 60 * 1000, onSync = () => {}) {
  if (_syncInterval) return;
  _syncInterval = setInterval(() => {
    invalidateCache();
    onSync();
  }, intervalMs);
  if (_syncInterval.unref) _syncInterval.unref();
}
function stopSyncTimer() {
  if (_syncInterval) {
    clearInterval(_syncInterval);
    _syncInterval = null;
  }
}

/**
 * Force a sync from the Tailscale API — stub for now.
 * Real implementation would use OAuth credentials to fetch devices/ACL.
 */
// eslint-disable-next-line require-await -- stub, will gain await when API client lands
async function syncAPI() {
  invalidateCache();
  return { synced: true, at: new Date().toISOString() };
}

module.exports = {
  getStatus,
  getLocalIP,
  getSummary,
  getDevices,
  isTailscaleIP,
  invalidateCache,
  getAccessToken,
  startSyncTimer,
  stopSyncTimer,
  syncAPI,
  // Exposed for tests
  _CLI_BIN: CLI_BIN,
  _CACHE_TTL_MS: CACHE_TTL_MS,
};