/**
 * Tailscale Coordination API client
 *
 * Wraps the public Tailscale coordination server API at
 *   https://api.tailscale.com/api/v2/
 * used to manage the user's tailnet from DashCaddy (device list, invite
 * keys, ACL edits). Distinct from src/managers/tailscale-manager.js, which
 * queries the *local* tailscaled daemon via the `tailscale` CLI for
 * status/read-side data. This module is the write-side: it talks to
 * Tailscale's cloud, so it requires a personal API token (configured in
 * DashCaddy settings, encrypted via credentialManager).
 *
 * Auth model: every request carries
 *   Authorization: Bearer <apiToken>
 * The token is opaque to this module once retrieved; the route layer is
 * responsible for showing it to the user exactly once on create, never
 * echoing it in GET responses.
 *
 * Endpoints used (current as of Tailscale API v2, July 2026):
 *   GET    /api/v2/tailnet/{tailnet}/devices      list all devices
 *   GET    /api/v2/device/{deviceId}              one device
 *   DELETE /api/v2/device/{deviceId}              remove device from tailnet
 *   POST   /api/v2/tailnet/{tailnet}/keys         create pre-auth key
 *   GET    /api/v2/tailnet/{tailnet}/keys         list keys (metadata only)
 *   DELETE /api/v2/keys/{keyId}                   delete a key
 *   GET    /api/v2/tailnet/{tailnet}/users        list users
 *   GET    /api/v2/tailnet/{tailnet}/acl          read ACL (HuJSON)
 *   POST   /api/v2/tailnet/{tailnet}/acl          replace ACL (HuJSON)
 *
 * The `/api/v2/tailnet/-/preferences` endpoint that earlier versions of
 * this client used for ping() was retired by Tailscale (verified 2026-07-07).
 * ping() now hits /devices and derives the tailnet name from the
 * `MagicDNSSuffix` field on the first device.
 *
 * Failure modes:
 *   - No token set       → returns null from all methods; route layer decides UX
 *   - 401 / 403          → token invalid; surface as { error: 'unauthorized' }
 *   - 429                → rate limited; throw with retry-after info
 *   - 5xx                → transient; throw, route layer can retry
 *   - Network error      → throw; same as 5xx from the caller's POV
 *
 * Caching: device list is cached for 60 seconds (Tailnet state changes are
 * user-driven and rare; avoid hammering the API on dashboard polls). ACL,
 * users, keys, preferences are cached for 5 minutes. Writes invalidate
 * their own caches. The token-validity ping is cached separately for 1 hour.
 */

'use strict';

const https = require('https');

const API_BASE = 'api.tailscale.com';
const API_PREFIX = '/api/v2';
const DEFAULT_TIMEOUT_MS = 10000;

// Default TTLs (ms). Individual methods may override.
const TTL_DEVICES_MS = 60 * 1000;
const TTL_LIST_MS = 5 * 60 * 1000;
const TTL_PING_MS = 60 * 60 * 1000;

class TailscaleCoordError extends Error {
  constructor(message, { status, body, retryAfter, code } = {}) {
    super(message);
    this.name = 'TailscaleCoordError';
    this.status = status;
    this.body = body;
    this.retryAfter = retryAfter;
    this.code = code || _codeFromStatus(status);
  }
}

function _codeFromStatus(status) {
  if (status === 401 || status === 403) return 'unauthorized';
  if (status === 404) return 'not_found';
  if (status === 429) return 'rate_limited';
  if (status && status >= 500) return 'server_error';
  return 'unknown';
}

class TailscaleCoordClient {
  constructor({ apiToken, fetchImpl } = {}) {
    this.apiToken = apiToken || null;
    // Allow injection of a fetch-like function for tests. We only use the
    // subset of undici/fetch that maps cleanly to https.request — i.e.
    // a function returning { status, body, headers }.
    this.fetchImpl = fetchImpl || null;
    // cache: Map<cacheKey, { expiresAt: number, value: any }>
    this._cache = new Map();
    this._negativeCache = new Map();
  }

  // ---------- public config / introspection ----------

  isConfigured() {
    return !!this.apiToken;
  }

  /**
   * Set or clear the API token. Clears all caches because validity of
   * cached data depends on which token was used to fetch it.
   */
  setApiToken(token) {
    this.apiToken = token || null;
    this._cache.clear();
    this._negativeCache.clear();
  }

  // ---------- cache helpers ----------

  _cacheGet(key) {
    const entry = this._cache.get(key);
    if (!entry) return undefined;
    if (Date.now() >= entry.expiresAt) {
      this._cache.delete(key);
      return undefined;
    }
    return entry.value;
  }
  _cacheSet(key, value, ttlMs) {
    this._cache.set(key, { expiresAt: Date.now() + ttlMs, value });
    this._negativeCache.delete(key);
  }
  _cacheInvalidate(prefix) {
    for (const k of [...this._cache.keys()]) {
      if (k.startsWith(prefix)) this._cache.delete(k);
    }
  }

  // ---------- low-level HTTP ----------

  async _request(method, path, { body, query } = {}) {
    if (!this.apiToken) {
      throw new TailscaleCoordError('Tailscale API token not configured', { code: 'not_configured' });
    }
    const qs = query
      ? '?' + Object.entries(query)
          .filter(([, v]) => v !== undefined && v !== null)
          .map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v))
          .join('&')
      : '';
    const urlPath = API_PREFIX + path + qs;

    if (this.fetchImpl) {
      // Test path: callers pass a fetch-like impl that returns
      // { status, body, headers }. Body may be string (already serialized)
      // or undefined.
      const headers = {
        'Authorization': 'Bearer ' + this.apiToken,
        'Accept': 'application/json',
      };
      const hasBody = body !== undefined;
      if (hasBody) headers['Content-Type'] = 'application/json';
      const res = await this.fetchImpl(method, urlPath, {
        headers,
        body: hasBody ? JSON.stringify(body) : undefined,
      });
      return _parseResponse(res);
    }

    // Production path: native https.
    return await new Promise((resolve, reject) => {
      const opts = {
        hostname: API_BASE,
        port: 443,
        path: urlPath,
        method,
        headers: {
          'Authorization': 'Bearer ' + this.apiToken,
          'Accept': 'application/json',
          'User-Agent': 'DashCaddy/1.14.9 (+tailscale-coord)',
        },
        timeout: DEFAULT_TIMEOUT_MS,
      };
      let payload = null;
      if (body !== undefined) {
        payload = Buffer.from(JSON.stringify(body), 'utf8');
        opts.headers['Content-Type'] = 'application/json';
        opts.headers['Content-Length'] = payload.length;
      }
      const req = https.request(opts, (res) => {
        const chunks = [];
        res.on('data', (c) => chunks.push(c));
        res.on('end', () => {
          const raw = Buffer.concat(chunks).toString('utf8');
          resolve({
            status: res.statusCode,
            headers: res.headers,
            body: raw,
          });
        });
      });
      req.on('timeout', () => {
        req.destroy(new Error('timeout'));
      });
      req.on('error', (e) => {
        reject(new TailscaleCoordError('Network error: ' + e.message, { code: 'network_error' }));
      });
      if (payload) req.write(payload);
      req.end();
    }).then(_parseResponse);
  }

  // ---------- public methods ----------

  /**
   * Cheap liveness + token validity check. Hits
   *   GET /api/v2/tailnet/-/devices
   * and derives the tailnet name from the `MagicDNSSuffix` field on the
   * first device. Returns an object with { domain, deviceCount }.
   * Throws TailscaleCoordError with code=unauthorized on bad token.
   *
   * (Earlier versions hit /preferences — that endpoint was retired by
   * Tailscale in 2026. /devices is the next-lightest read endpoint that
   * still exists.)
   */
  async ping({ skipCache = false } = {}) {
    const cacheKey = 'ping';
    if (!skipCache) {
      const cached = this._cacheGet(cacheKey);
      if (cached !== undefined) return cached;
    }
    // When ping cache was stale but listDevices cache might still be fresh,
    // we still need a fresh device list to rebuild the ping response — so
    // always force-skip the listDevices cache here.
    const devs = await this.listDevices({ skipCache: true });
    // The Tailscale API puts the magic-DNS suffix on the `name` field, e.g.
    // `dns2-sami.tail3e209.ts.net`. Pull the last 3 components to extract
    // `tail3e209.ts.net`. Falls back to magicDNSSuffix if a future API
    // version exposes it explicitly.
    const firstDev = devs && devs[0];
    let domain = null;
    if (firstDev) {
      const name = firstDev.name || '';
      // Find the `ts.net` suffix and grab it + the segment before it.
      // Real tailnet suffixes are `tailXXXXX.ts.net` (3 components) or the
      // user's custom domain (could be 2+). Use a regex that captures
      // "<segment>.ts.net" or the last 2+ dot-separated parts of name.
      const m = name.match(/([a-z0-9-]+\.ts\.net)$/i);
      if (m) domain = m[1];
      else if (firstDev.magicDNSSuffix) domain = firstDev.magicDNSSuffix;
    }
    const result = { domain, deviceCount: devs.length };
    this._cacheSet(cacheKey, result, TTL_PING_MS);
    return result;
  }

  /**
   * List all devices in the tailnet. Returns the `devices` array from
   *   GET /api/v2/tailnet/-/devices
   * Cached for TTL_DEVICES_MS.
   */
  async listDevices({ skipCache = false } = {}) {
    const cacheKey = 'devices:list';
    if (!skipCache) {
      const cached = this._cacheGet(cacheKey);
      if (cached !== undefined) return cached;
    }
    const data = await this._request('GET', '/tailnet/-/devices');
    const devices = data.devices || [];
    this._cacheSet(cacheKey, devices, TTL_DEVICES_MS);
    return devices;
  }

  /**
   * Get one device by ID. NOT cached — call sites already have the device
   * list locally and want fresh data.
   */
  async getDevice(deviceId) {
    if (!deviceId) throw new TailscaleCoordError('deviceId required', { code: 'bad_input' });
    const data = await this._request('GET', '/device/' + encodeURIComponent(deviceId));
    return data;
  }

  /**
   * Delete a device from the tailnet. Invalidates device caches.
   *   DELETE /api/v2/device/{deviceId}
   * Returns { success: true } on 200.
   */
  async deleteDevice(deviceId) {
    if (!deviceId) throw new TailscaleCoordError('deviceId required', { code: 'bad_input' });
    await this._request('DELETE', '/device/' + encodeURIComponent(deviceId));
    this._cacheInvalidate('devices:');
    return { success: true };
  }

  /**
   * Create a pre-auth key. Used by the share-invite flow (Phase 2).
   *   POST /api/v2/tailnet/-/keys
   * Body fields accepted by Tailscale (only the ones we use):
   *   - reusable:    bool, default false
   *   - ephemeral:   bool, default false
   *   - preauthorized: bool, default true (device joins without admin approval)
   *   - tags:        string[], e.g. ['tag:guest-plex']
   *   - expirySeconds: int, max 7776000 (90 days)
   *   - description: string, free-form
   * Returns the full response: { id, key, created, expires, ... }.
   * The `key` field is shown to the user EXACTLY ONCE.
   * Invalidates keys:list cache.
   */
  async createAuthKey(opts = {}) {
    const body = {
      reusable: opts.reusable !== undefined ? !!opts.reusable : false,
      ephemeral: opts.ephemeral !== undefined ? !!opts.ephemeral : false,
      preauthorized: opts.preauthorized !== undefined ? !!opts.preauthorized : true,
    };
    if (Array.isArray(opts.tags) && opts.tags.length > 0) body.tags = opts.tags;
    if (typeof opts.description === 'string') body.description = opts.description;
    if (Number.isInteger(opts.expirySeconds) && opts.expirySeconds > 0) {
      body.expirySeconds = Math.min(opts.expirySeconds, 7776000);
    }
    const data = await this._request('POST', '/tailnet/-/keys', { body });
    this._cacheInvalidate('keys:');
    return data;
  }

  /**
   * List pre-auth keys. Note: the response includes metadata (id, created,
   * expires, description, capabilities) but never the secret value.
   *   GET /api/v2/tailnet/-/keys
   */
  async listAuthKeys({ skipCache = false } = {}) {
    const cacheKey = 'keys:list';
    if (!skipCache) {
      const cached = this._cacheGet(cacheKey);
      if (cached !== undefined) return cached;
    }
    const data = await this._request('GET', '/tailnet/-/keys');
    const keys = data.keys || [];
    this._cacheSet(cacheKey, keys, TTL_LIST_MS);
    return keys;
  }

  /**
   * Delete a pre-auth key. Invalidates keys:list cache.
   *   DELETE /api/v2/keys/{keyId}
   */
  async deleteAuthKey(keyId) {
    if (!keyId) throw new TailscaleCoordError('keyId required', { code: 'bad_input' });
    await this._request('DELETE', '/keys/' + encodeURIComponent(keyId));
    this._cacheInvalidate('keys:');
    return { success: true };
  }

  /**
   * List tailnet users (the human accounts).
   *   GET /api/v2/tailnet/-/users
   */
  async listUsers({ skipCache = false } = {}) {
    const cacheKey = 'users:list';
    if (!skipCache) {
      const cached = this._cacheGet(cacheKey);
      if (cached !== undefined) return cached;
    }
    const data = await this._request('GET', '/tailnet/-/users');
    const users = data.users || [];
    this._cacheSet(cacheKey, users, TTL_LIST_MS);
    return users;
  }

  /**
   * Read the current ACL as a HuJSON string. NOT cached — admins editing
   * ACLs want fresh data on every click.
   */
  async getAcl() {
    return await this._request('GET', '/tailnet/-/acl');
  }

  /**
   * Replace the ACL entirely. Caller is responsible for merging/validating
   * the HuJSON. Body must be the raw ACL object (not stringified).
   */
  async updateAcl(aclObject) {
    if (!aclObject || typeof aclObject !== 'object' || Array.isArray(aclObject)) {
      throw new TailscaleCoordError('ACL must be a non-array object', { code: 'bad_input' });
    }
    return await this._request('POST', '/tailnet/-/acl', { body: aclObject });
  }
}

function _parseResponse(res) {
  const { status, body, headers } = res;
  let parsed = body;
  const ct = (headers && headers['content-type']) || '';
  if (body && (ct.includes('application/json') || body.startsWith('{') || body.startsWith('['))) {
    try { parsed = JSON.parse(body); } catch (_e) { /* leave as string */ }
  }
  if (status >= 200 && status < 300) return parsed;
  // Extract retry-after if present
  const retryAfter = headers && (headers['retry-after'] || headers['Retry-After']);
  const message = (parsed && parsed.message) || (typeof parsed === 'string' ? parsed : 'HTTP ' + status);
  throw new TailscaleCoordError('Tailscale API ' + status + ': ' + message, {
    status,
    body: parsed,
    retryAfter,
  });
}

module.exports = {
  TailscaleCoordClient,
  TailscaleCoordError,
  // For tests: a factory that builds a new instance. Most call sites use the
  // singleton via context, but tests + scripts that want isolation can use
  // this directly.
  create: (opts) => new TailscaleCoordClient(opts),
};