// Copyright (c) 2026 defconxt. All rights reserved.
// Licensed under AGPL-3.0 — see LICENSE file for details.
// NERF is a trademark of defconxt.

/**
 * gateway.js — NERF Gateway orchestrator.
 *
 * Ports Python gateway/gateway.py:
 * - Gateway class with send() and async *sendStream()
 * - Config → mode detection → prompt assembly → RAG → LLM call → response
 * - Error handling for timeout, connection, status, and generic errors
 *
 * @module gateway/gateway
 */

import { loadConfig } from '../config.js';
import { validateConfig } from './config-validate.js';
import { makeClient } from './client.js';
import { isTransientError, classifyErrorLegacy } from './retry-client.js';
import { parseKeywordTable, detectMode, stripModePrefix } from './mode.js';
import { assembleSystemPrompt, loadRawClaudeMd } from './prompt.js';
import { PluginManager } from './plugins.js';

const debug = process.env.NERF_DEBUG === '1'
  ? (/** @type {string} */ msg) => process.stderr.write(`[bridge:node] ${msg}\n`)
  : () => {};

/**
 * Lazy-load the RAG retriever. Returns null if unavailable.
 * @returns {Promise<Object|null>}
 */
async function loadRetriever() {
  try {
    const { AdaptiveRetriever } = await import('../memory/index.js');
    const r = new AdaptiveRetriever();
    // Check if retriever has data
    if (typeof r.count === 'function' ? await r.count() > 0 : (r.count > 0)) {
      return r;
    }
    debug('Gateway: RAG collection empty — running without retrieval');
  } catch (exc) {
    debug(`Gateway: RAG unavailable (${exc.message}) — running without retrieval`);
  }
  return null;
}

export class Gateway {
  /**
   * @param {Object} [opts]
   * @param {string} [opts.backendOverride] - Override config.backend
   * @param {boolean} [opts.rag=true] - Enable RAG retrieval
   * @param {Object} [opts.configOpts] - Options passed to loadConfig
   * @param {Object} [opts.client] - Pre-built client (for testing)
   * @param {string} [opts.model] - Pre-set model (for testing)
   * @param {Record<string, string[]>} [opts.keywordTable] - Pre-built keyword table (for testing)
   * @param {string} [opts.repoRoot] - Explicit repo root for prompt loading
   */
  constructor(opts = {}) {
    this._opts = opts;
    this._client = opts.client || null;
    this._model = opts.model || null;
    this._keywordTable = opts.keywordTable || null;
    this._retriever = null;
    this._pluginManager = new PluginManager();
    this._initialized = false;
    this._initPromise = null;
    this._repoRoot = opts.repoRoot || undefined;
  }

  /**
   * Initialize the gateway — loads config, client, keyword table, and retriever.
   * Called lazily on first send/sendStream. Safe to call multiple times.
   *
   * @returns {Promise<void>}
   */
  async init() {
    if (this._initialized) return;
    if (this._initPromise) return this._initPromise;

    this._initPromise = this._doInit();
    await this._initPromise;
    this._initialized = true;
  }

  /** @private */
  async _doInit() {
    // Skip config/client setup if pre-built (testing)
    if (!this._client) {
      const rawConfig = loadConfig(this._opts.configOpts);
      const config = validateConfig(rawConfig);

      if (this._opts.backendOverride) {
        config.backend = this._opts.backendOverride;
        debug(`Gateway: backend overridden to '${this._opts.backendOverride}'`);
      }

      const { client, model } = await makeClient(config);
      this._client = client;
      this._model = model;
      debug(`Gateway: client ready (model=${this._model})`);
    }

    // Cache keyword table at init
    if (!this._keywordTable) {
      const rawClaudeMd = loadRawClaudeMd(this._repoRoot);
      this._keywordTable = parseKeywordTable(rawClaudeMd);
      debug(`Gateway: keyword table loaded (${Object.keys(this._keywordTable).length} modes)`);
    }

    // RAG retriever (lazy, graceful fallback)
    if (this._opts.rag !== false) {
      this._retriever = await loadRetriever();
      if (this._retriever) {
        debug('Gateway: RAG enabled');
      }
    }
  }

  /**
   * Send a message through the full NERF pipeline.
   *
   * Pipeline:
   * 1. Detect mode (explicit prefix or keyword scoring)
   * 2. If needs_clarification → return disambiguation string
   * 3. Assemble system prompt for detected mode
   * 4. Strip mode prefix from message
   * 5. Build messages list (history + new user message)
   * 6. Call LLM
   * 7. Prepend [MODE: X] header if missing
   * 8. Return response
   *
   * @param {string} message - User message text
   * @param {Array<{role: string, content: string}>} [history] - Prior messages
   * @returns {Promise<string>}
   */
  async send(message, history) {
    await this.init();

    // 1. Detect mode
    const [mode, needsClarification] = detectMode(message, this._keywordTable);

    // 2. Disambiguation short-circuit
    if (needsClarification) {
      debug(`Gateway: mode overlap detected (${mode}) — requesting clarification`);
      return `Multiple modes detected (${mode}). Are you approaching this offensively or defensively?`;
    }

    debug(`Gateway: routing to mode ${mode}`);

    // 3. Assemble system prompt
    let systemPrompt = assembleSystemPrompt(mode, this._repoRoot, this._pluginManager);

    // 3b. RAG retrieval
    if (this._retriever) {
      try {
        const chunks = typeof this._retriever.query === 'function'
          ? await this._retriever.query(message, { topK: 5 })
          : [];
        if (chunks && chunks.length > 0) {
          const contextBlock = chunks.map(c => c.content || c.text || '').filter(Boolean).join('\n\n');
          if (contextBlock) {
            systemPrompt = systemPrompt + '\n\n---\n\n' + contextBlock;
            debug(`Gateway: injected ${chunks.length} RAG chunks`);
          }
        }
      } catch (e) {
        debug(`Gateway: RAG query failed: ${e.message}`);
      }
    }

    // 4. Strip mode prefix
    const cleanMessage = stripModePrefix(message);

    // 5. Prepend [MODE: X] for LLM context
    const userContent = `[MODE: ${mode}] ${cleanMessage}`;

    // 6. Build messages list
    const messages = [];
    if (history) messages.push(...history);
    messages.push({ role: 'user', content: userContent });

    // 7. Call LLM
    try {
      const response = await this._client.messages.create({
        model: this._model,
        max_tokens: 4096,
        system: systemPrompt,
        messages,
      });

      let responseText = response.content[0].text;

      // 8. Ensure [MODE: X] header
      if (!responseText.startsWith('[MODE:')) {
        responseText = `[MODE: ${mode}]\n${responseText}`;
      }

      // Strip duplicate header if LLM echoed it twice
      const lines = responseText.split('\n', 3);
      if (lines.length >= 2 && lines[0].startsWith('[MODE:') && lines[1].startsWith('[MODE:')) {
        responseText = lines.length > 2
          ? [lines[0], ...responseText.split('\n').slice(2)].join('\n')
          : lines[0];
      }

      return responseText;

    } catch (err) {
      // Single retry on transient errors before returning error string
      const errMsg = err.message || String(err);
      if (isTransientError(errMsg)) {
        const { retryAfterMs } = classifyErrorLegacy(errMsg);
        const delay = Math.min(retryAfterMs, 30_000); // cap at 30s
        debug(`Gateway: transient error, retrying in ${delay}ms: ${errMsg}`);
        await new Promise(r => setTimeout(r, delay));
        try {
          const retryResponse = await this._client.messages.create({
            model: this._model,
            max_tokens: 4096,
            system: systemPrompt,
            messages,
          });
          let retryText = retryResponse.content[0].text;
          if (!retryText.startsWith('[MODE:')) {
            retryText = `[MODE: ${mode}]\n${retryText}`;
          }
          return retryText;
        } catch (retryErr) {
          return _handleLLMError(retryErr);
        }
      }
      return _handleLLMError(err);
    }
  }

  /**
   * Send a message and yield response tokens as they arrive.
   *
   * Same pipeline as send() but uses streaming API. Yields strings.
   * The first yield is the [MODE: X] header.
   *
   * @param {string} message
   * @param {Array<{role: string, content: string}>} [history]
   * @returns {AsyncGenerator<string>}
   */
  async *sendStream(message, history) {
    await this.init();

    const [mode, needsClarification] = detectMode(message, this._keywordTable);

    if (needsClarification) {
      yield `Multiple modes detected (${mode}). Are you approaching this offensively or defensively?`;
      return;
    }

    let systemPrompt = assembleSystemPrompt(mode, this._repoRoot, this._pluginManager);

    // RAG retrieval
    if (this._retriever) {
      try {
        const chunks = typeof this._retriever.query === 'function'
          ? await this._retriever.query(message, { topK: 5 })
          : [];
        if (chunks && chunks.length > 0) {
          const contextBlock = chunks.map(c => c.content || c.text || '').filter(Boolean).join('\n\n');
          if (contextBlock) {
            systemPrompt = systemPrompt + '\n\n---\n\n' + contextBlock;
          }
        }
      } catch {
        // RAG query failed — continue without it
      }
    }

    const cleanMessage = stripModePrefix(message);
    const userContent = `[MODE: ${mode}] ${cleanMessage}`;

    const messages = [];
    if (history) messages.push(...history);
    messages.push({ role: 'user', content: userContent });

    let headerSent = false;

    try {
      // Use the Anthropic SDK streaming pattern:
      // stream = client.messages.stream({...})
      // Then consume via .on('text', cb) + await stream.finalMessage()
      // Or for OpenAI-compat adapter, iterate the async iterator.
      const stream = this._client.messages.stream({
        model: this._model,
        max_tokens: 4096,
        system: systemPrompt,
        messages,
      });

      // Check if the stream supports async iteration (OpenAI-compat adapter)
      let buffer = '';
      let dedupDone = false;

      if (stream[Symbol.asyncIterator]) {
        for await (const text of stream) {
          if (!headerSent) {
            yield `[MODE: ${mode}]\n`;
            headerSent = true;
          }
          // Dedup: skip LLM's own [MODE: X] header line if it matches ours
          if (!dedupDone) {
            buffer += text;
            // Wait until we have enough to check for a mode header
            if (buffer.includes('\n') || buffer.length > 50) {
              const stripped = buffer.replace(/^\[MODE:\s*\w+\]\s*\n?/, '');
              if (stripped !== buffer) {
                yield stripped;
              } else {
                yield buffer;
              }
              dedupDone = true;
              buffer = '';
            }
          } else {
            yield text;
          }
        }
        // Flush remaining buffer if stream ended before dedup check triggered
        if (!dedupDone && buffer) {
          const stripped = buffer.replace(/^\[MODE:\s*\w+\]\s*\n?/, '');
          yield stripped !== buffer ? stripped : buffer;
        }
      } else {
        // Anthropic SDK: use event-to-queue bridge via Promise chain
        // Create a queue-based async generator that bridges .on('text', cb) events
        const queue = [];
        let resolve = null;
        let done = false;
        let error = null;

        stream.on('text', (text) => {
          if (resolve) {
            const r = resolve;
            resolve = null;
            r({ value: text, done: false });
          } else {
            queue.push(text);
          }
        });

        // Drive the stream to completion
        const finalPromise = stream.finalMessage().then(() => {
          done = true;
          if (resolve) {
            const r = resolve;
            resolve = null;
            r({ value: undefined, done: true });
          }
        }).catch((err) => {
          error = err;
          done = true;
          if (resolve) {
            const r = resolve;
            resolve = null;
            r({ value: undefined, done: true });
          }
        });

        let sdkBuffer = '';
        let sdkDedupDone = false;

        while (true) {
          let text;
          if (queue.length > 0) {
            text = queue.shift();
          } else if (done) {
            break;
          } else {
            const result = await new Promise(r => { resolve = r; });
            if (result.done) break;
            text = result.value;
          }

          if (!headerSent) {
            yield `[MODE: ${mode}]\n`;
            headerSent = true;
          }

          // Dedup: skip LLM's own [MODE: X] header line if it matches ours
          if (!sdkDedupDone) {
            sdkBuffer += text;
            if (sdkBuffer.includes('\n') || sdkBuffer.length > 50) {
              const stripped = sdkBuffer.replace(/^\[MODE:\s*\w+\]\s*\n?/, '');
              yield stripped !== sdkBuffer ? stripped : sdkBuffer;
              sdkDedupDone = true;
              sdkBuffer = '';
            }
          } else {
            yield text;
          }
        }

        // Flush remaining buffer if stream ended before dedup check triggered
        if (!sdkDedupDone && sdkBuffer) {
          const stripped = sdkBuffer.replace(/^\[MODE:\s*\w+\]\s*\n?/, '');
          yield stripped !== sdkBuffer ? stripped : sdkBuffer;
        }

        await finalPromise;
        if (error) {
          yield _handleLLMError(error);
        }
      }

    } catch (err) {
      // Single retry on transient errors for streaming
      const errMsg = err.message || String(err);
      if (isTransientError(errMsg)) {
        const { retryAfterMs } = classifyErrorLegacy(errMsg);
        const delay = Math.min(retryAfterMs, 30_000);
        debug(`Gateway stream: transient error, retrying in ${delay}ms: ${errMsg}`);
        await new Promise(r => setTimeout(r, delay));
        try {
          const retryStream = this._client.messages.stream({
            model: this._model,
            max_tokens: 4096,
            system: systemPrompt,
            messages,
          });
          if (!headerSent) {
            yield `[MODE: ${mode}]\n`;
          }
          if (retryStream[Symbol.asyncIterator]) {
            for await (const text of retryStream) {
              yield text;
            }
          }
          return;
        } catch (retryErr) {
          yield _handleLLMError(retryErr);
          return;
        }
      }
      yield _handleLLMError(err);
    }
  }
}

/**
 * Format an LLM error into a user-visible [ERROR] string.
 *
 * @param {Error} err
 * @returns {string}
 */
function _handleLLMError(err) {
  const name = err?.constructor?.name || 'Error';

  if (name === 'APITimeoutError' || err.message?.includes('timed out')) {
    return '[ERROR] Backend timed out. Is the LLM backend running?';
  }
  if (name === 'APIConnectionError' || err.message?.includes('ECONNREFUSED')) {
    return `[ERROR] Cannot reach backend: ${err.message}`;
  }
  if (name === 'APIStatusError' || err.status) {
    return `[ERROR] Backend returned ${err.status}: ${err.message}`;
  }
  return `[ERROR] LLM call failed: ${err.message || err}`;
}
