'use strict';

const crypto = require('crypto');

/**
 * Licence client — single-instance enforcement (TECH_SPEC_V5 §3, licensing).
 *
 * A random instanceId is generated per process start. The bot claims the
 * licence at startup, heartbeats roughly every 60s to hold the claim, and
 * releases it on clean shutdown. A crashed instance's claim frees itself
 * server-side after about three minutes.
 *
 * ─────────────────────────────────────────────────────────────────────────────
 * FAIL-OPEN ON NETWORK, FAIL-CLOSED ON REJECTION
 *
 * This distinction is deliberate and is a promise made to customers on the
 * storefront FAQ: "if the licence server is unreachable, the bot keeps
 * trading — verification is not a per-trade gate."
 *
 *   - Server says no (revoked, or claimed by another instance) → refuse to run.
 *     That is a real answer and it is enforced.
 *   - Server cannot be reached (offline, DNS, timeout, 5xx) → log loudly and
 *     continue. A customer who paid should not lose a trading day because the
 *     operator's server had an outage. Holding their bot hostage to our uptime
 *     would make this a subscription with extra steps.
 *
 * Do not "harden" this into failing closed on network errors without an
 * explicit product decision — it would break a published promise.
 * ─────────────────────────────────────────────────────────────────────────────
 */

const HEARTBEAT_INTERVAL_MS = 60 * 1000;
const REQUEST_TIMEOUT_MS = 10000;

class LicenseRejected extends Error {
  constructor(message, code) {
    super(message);
    this.name = 'LicenseRejected';
    this.code = code;
  }
}

function createLicenseClient(env = process.env, { fetchImpl = globalThis.fetch, logger = console } = {}) {
  const key = env.LICENSE_KEY ? String(env.LICENSE_KEY).trim() : null;
  const baseUrl = env.LICENSE_SERVER_BASE_URL
    ? String(env.LICENSE_SERVER_BASE_URL).trim().replace(/\/+$/, '')
    : null;

  // Random per process start — two copies of the same install are distinct.
  const instanceId = crypto.randomBytes(16).toString('hex');

  let heartbeatTimer = null;
  let released = false;

  async function call(path, body) {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
    try {
      const res = await fetchImpl(baseUrl + path, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
        signal: controller.signal,
      });
      const text = await res.text();
      let data = null;
      try { data = JSON.parse(text); } catch { /* handled below */ }
      return { status: res.status, data };
    } finally {
      clearTimeout(timer);
    }
  }

  /**
   * Claims the licence. Throws LicenseRejected only when the server gives a
   * definite no. Any transport failure resolves with { enforced: false }.
   */
  async function verify() {
    if (!baseUrl || !key) {
      // Not configured. Explicitly unenforced rather than silently skipped, so
      // the startup log makes the state obvious.
      return { ok: true, enforced: false, reason: 'not_configured' };
    }

    let result;
    try {
      result = await call('/api/verify', { key, instanceId });
    } catch (err) {
      logger.warn(
        `Licence server unreachable (${err.name === 'AbortError' ? 'timed out' : err.message}). ` +
          'Continuing — verification is not a per-trade gate.'
      );
      return { ok: true, enforced: false, reason: 'unreachable' };
    }

    // A definite rejection. Enforced.
    if (result.status === 409) {
      throw new LicenseRejected(
        (result.data && result.data.error) ||
          'This licence is already in use by another running instance.',
        'in_use'
      );
    }
    if (result.status === 403) {
      throw new LicenseRejected(
        (result.data && result.data.error) || 'This licence has been revoked.',
        'revoked'
      );
    }
    if (result.status === 404) {
      throw new LicenseRejected('Licence key not recognised.', 'unknown_key');
    }

    // 5xx or anything unexpected — the server is unhealthy, not saying no.
    if (result.status >= 500 || !result.data || result.data.valid !== true) {
      logger.warn(
        `Licence server returned an unexpected response (${result.status}). ` +
          'Continuing — verification is not a per-trade gate.'
      );
      return { ok: true, enforced: false, reason: 'server_error' };
    }

    return { ok: true, enforced: true, reason: 'verified' };
  }

  async function heartbeat() {
    if (!baseUrl || !key || released) return;
    try {
      const result = await call('/api/heartbeat', { key, instanceId });
      if (result.status === 409) {
        // Another instance took the claim — most likely this one was considered
        // stale after a long pause. Surface it; do not silently keep trading
        // under a claim we no longer hold.
        logger.warn(
          'Licence claim was taken by another instance. Stop the other copy if ' +
            'this was not intended.'
        );
      }
    } catch (err) {
      // Transient. Next tick will retry.
    }
  }

  function startHeartbeat() {
    if (!baseUrl || !key || heartbeatTimer) return;
    heartbeatTimer = setInterval(heartbeat, HEARTBEAT_INTERVAL_MS);
    // Do not keep the process alive purely for the heartbeat.
    if (heartbeatTimer.unref) heartbeatTimer.unref();
  }

  function stopHeartbeat() {
    if (heartbeatTimer) {
      clearInterval(heartbeatTimer);
      heartbeatTimer = null;
    }
  }

  /** Releases the claim so the next start is not blocked by a stale one. */
  async function release() {
    released = true;
    stopHeartbeat();
    if (!baseUrl || !key) return;
    try {
      await call('/api/release', { key, instanceId });
    } catch (err) {
      // Best effort. The server frees a stale claim after ~3 minutes anyway.
    }
  }

  return {
    verify,
    heartbeat,
    startHeartbeat,
    stopHeartbeat,
    release,
    instanceId,
    isConfigured: Boolean(baseUrl && key),
  };
}

module.exports = { createLicenseClient, LicenseRejected, HEARTBEAT_INTERVAL_MS };
