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

/**
 * framework.js — Mode-agnostic agent framework for NERF execution engine operations.
 *
 * Ported from execution-engine/framework.py. Foundation types for the multi-mode agent system:
 * - truncateOutput: standalone output truncation utility
 * - ValidationResult: output validation with errors, warnings, and scoring
 * - ModeAgentResult: structured result from agent execution
 * - ToolRegistry: register and dispatch tools with flexible context
 * - NullValidator: pass-through validator for modes without validation
 * - ModeAgentConfig: configuration for a mode-specific agent
 * - BaseAgent: generalized tool-use loop (extracted from SecurityAgent)
 *
 * All exports are named ESM exports. All imports lazy where needed.
 *
 * @module execution-engine/framework
 */

import { EventEmitter } from 'node:events';

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

// Maximum tool output length to prevent context window bloat
const MAX_OUTPUT_CHARS = 4000;

// ---------------------------------------------------------------------------
// Standalone utilities
// ---------------------------------------------------------------------------

/**
 * Truncate tool output to prevent context window bloat.
 *
 * @param {string} output
 * @param {number} [maxChars=4000]
 * @returns {string}
 */
export function truncateOutput(output, maxChars = MAX_OUTPUT_CHARS) {
  if (output.length <= maxChars) return output;
  return output.slice(0, maxChars) + `\n... [truncated, ${output.length} chars total]`;
}

// ---------------------------------------------------------------------------
// Result classes
// ---------------------------------------------------------------------------

/**
 * Outcome of validating a mode agent's result.
 */
export class ValidationResult {
  /**
   * @param {Object} opts
   * @param {boolean} opts.valid
   * @param {string[]} opts.errors
   * @param {string[]} opts.warnings
   * @param {number} [opts.score=1.0]
   * @param {Object} [opts.metadata={}]
   */
  constructor({ valid, errors, warnings, score = 1.0, metadata = {} }) {
    this.valid = valid;
    this.errors = errors;
    this.warnings = warnings;
    this.score = score;
    this.metadata = metadata;
  }
}

/**
 * Structured result from a mode agent execution.
 */
export class ModeAgentResult {
  /**
   * @param {Object} opts
   * @param {string} opts.mode
   * @param {Object} [opts.outputData={}]
   * @param {string} [opts.outputText='']
   * @param {string[]} [opts.steps=[]]
   * @param {number} [opts.tokensIn=0]
   * @param {number} [opts.tokensOut=0]
   * @param {number} [opts.toolCalls=0]
   * @param {number} [opts.turnsUsed=0]
   * @param {ValidationResult|null} [opts.validation=null]
   * @param {string|null} [opts.error=null]
   * @param {number} [opts.durationS=0]
   */
  constructor({
    mode,
    outputData = {},
    outputText = '',
    steps = [],
    tokensIn = 0,
    tokensOut = 0,
    toolCalls = 0,
    turnsUsed = 0,
    validation = null,
    error = null,
    durationS = 0,
  }) {
    this.mode = mode;
    this.outputData = outputData;
    this.outputText = outputText;
    this.steps = steps;
    this.tokensIn = tokensIn;
    this.tokensOut = tokensOut;
    this.toolCalls = toolCalls;
    this.turnsUsed = turnsUsed;
    this.validation = validation;
    this.error = error;
    this.durationS = durationS;
    /** @type {import('../gates/self-check.js').SelfCheckResult|null} */
    this.selfCheck = null;
  }
}

// ---------------------------------------------------------------------------
// ToolRegistry
// ---------------------------------------------------------------------------

/**
 * Registry for tool schemas and dispatch handlers.
 *
 * Tools are registered with an Anthropic-format schema and a handler callable.
 * The handler signature is (context, input) => string.
 */
export class ToolRegistry {
  constructor() {
    /** @type {Array<Object>} */
    this._schemas = [];
    /** @type {Map<string, Function>} */
    this._dispatch = new Map();
  }

  /**
   * Register a tool with its schema and handler.
   *
   * @param {string} name - Unique tool name
   * @param {Object} schema - Anthropic-format tool schema dict
   * @param {Function} handler - (context, input) => string
   * @throws {Error} If a tool with this name is already registered
   */
  register(name, schema, handler) {
    if (this._dispatch.has(name)) {
      const registered = [...this._dispatch.keys()].sort().join(', ');
      throw new Error(
        `Tool already registered: '${name}'. Registered tools: ${registered}`
      );
    }
    this._schemas.push(schema);
    this._dispatch.set(name, handler);
  }

  /**
   * Return a copy of all registered tool schemas.
   * @returns {Array<Object>}
   */
  getSchemas() {
    return [...this._schemas];
  }

  /**
   * Dispatch a tool call to its handler.
   *
   * @param {string} name - Tool name from the LLM's tool_use block
   * @param {Object} toolInput - Tool input parameters from the LLM
   * @param {*} [context=null] - Arbitrary context passed to the handler
   * @returns {string} Tool output string, truncated to MAX_OUTPUT_CHARS
   * @throws {Error} If tool name is not recognized
   */
  dispatch(name, toolInput, context = null) {
    const handler = this._dispatch.get(name);
    if (!handler) {
      const available = [...this._dispatch.keys()].sort().join(', ');
      throw new Error(`Unknown tool: '${name}'. Available: ${available}`);
    }

    let result;
    try {
      result = handler(context, toolInput);
    } catch (e) {
      result = `TOOL ERROR: ${e.constructor.name}: ${e.message}`;
    }

    return truncateOutput(String(result));
  }

  /**
   * Check if a tool is registered.
   * @param {string} name
   * @returns {boolean}
   */
  hasTool(name) {
    return this._dispatch.has(name);
  }

  /**
   * Return list of registered tool names.
   * @returns {string[]}
   */
  toolNames() {
    return [...this._dispatch.keys()];
  }
}

// ---------------------------------------------------------------------------
// Validator protocol (duck-type: any object with validate(result) method)
// NullValidator — pass-through validator
// ---------------------------------------------------------------------------

/**
 * Pass-through validator that always returns valid.
 * Used for modes that don't need output validation.
 */
export class NullValidator {
  /**
   * @param {ModeAgentResult} _result
   * @returns {ValidationResult}
   */
  validate(_result) {
    return new ValidationResult({ valid: true, errors: [], warnings: [], score: 1.0 });
  }
}

// ---------------------------------------------------------------------------
// Mode configuration
// ---------------------------------------------------------------------------

/**
 * Configuration for a mode-specific agent.
 */
export class ModeAgentConfig {
  /**
   * @param {Object} opts
   * @param {string} opts.mode
   * @param {ToolRegistry} opts.toolRegistry
   * @param {string} opts.systemPromptTemplate
   * @param {Object} opts.validator - Any object with validate(result) method
   * @param {number} [opts.maxTurns=30]
   * @param {number} [opts.maxTokensPerTurn=4096]
   * @param {string} [opts.outputFormat='json']
   * @param {boolean} [opts.requiresSandbox=false]
   * @param {Function|null} [opts.completionCheck=null] - (text: string) => boolean
   * @param {Function|null} [opts.outputParser=null] - (text: string) => Object
   * @param {import('./handoff.js').HandoffFilter|null} [opts.handoffFilter=null] - Context filter for incoming handoffs
   */
  constructor({
    mode,
    toolRegistry,
    systemPromptTemplate,
    validator,
    maxTurns = 30,
    maxTokensPerTurn = 4096,
    outputFormat = 'json',
    requiresSandbox = false,
    completionCheck = null,
    outputParser = null,
    handoffFilter = null,
  }) {
    this.mode = mode;
    this.toolRegistry = toolRegistry;
    this.systemPromptTemplate = systemPromptTemplate;
    this.validator = validator;
    this.maxTurns = maxTurns;
    this.maxTokensPerTurn = maxTokensPerTurn;
    this.outputFormat = outputFormat;
    this.requiresSandbox = requiresSandbox;
    this.completionCheck = completionCheck;
    this.outputParser = outputParser;
    this.handoffFilter = handoffFilter;
  }
}

// ---------------------------------------------------------------------------
// BaseAgent — generalized tool-use loop
// ---------------------------------------------------------------------------

/**
 * Mode-agnostic LLM agent with pluggable tools, prompts, and validators.
 *
 * Works with any Anthropic-SDK-compatible client (Ollama, Claude, OpenAI-compat).
 * Emits events: 'turn_start', 'turn_end', 'text', 'tool_call', 'tool_result', 'complete'.
 */
export class BaseAgent extends EventEmitter {
  /**
   * @param {Object} opts
   * @param {Object} opts.client - Anthropic-SDK-compatible LLM client
   * @param {string} opts.model - Model name
   * @param {ModeAgentConfig} opts.config - Mode-specific agent configuration
   * @param {*} [opts.context=null] - Arbitrary context passed to tool handlers
   * @param {Function|null} [opts.preToolHook=null] - (toolName, toolInput) => boolean
   */
  constructor({ client, model, config, context = null, preToolHook = null }) {
    super();
    this._client = client;
    this._model = model;
    this._config = config;
    this._context = context;
    this._preToolHook = preToolHook;
    /** @type {string[]} Queued steer instructions injected at next turn boundary */
    this._steerQueue = [];
    /** @type {boolean} When true, agent exits the tool-use loop cleanly */
    this._aborted = false;
  }

  /**
   * Queue a steering instruction to inject at the next turn boundary.
   * The instruction is added as a system-level message before the next LLM call.
   *
   * @param {string} instruction - Steering instruction text
   */
  steer(instruction) {
    this._steerQueue.push(instruction);
    debug(`BaseAgent [${this._config.mode}] steer queued: ${instruction.slice(0, 100)}`);
  }

  /**
   * Signal the agent to abort cleanly at the next turn boundary.
   * Does not interrupt in-progress tool calls.
   */
  abort() {
    this._aborted = true;
    debug(`BaseAgent [${this._config.mode}] abort requested`);
  }

  /**
   * Run the agent against a task.
   *
   * Executes a multi-turn tool-use conversation loop:
   * 1. Render system prompt from config template + taskInput
   * 2. Send initial user message
   * 3. Process LLM responses: text blocks → step log, tool_use → dispatch
   * 4. Check completionCheck on every text block and tool output
   * 5. After loop: run outputParser, then validator
   *
   * @param {Object} taskInput - Dict of task parameters
   * @returns {Promise<ModeAgentResult>}
   */
  async run(taskInput) {
    const result = new ModeAgentResult({ mode: this._config.mode });
    const startTime = performance.now() / 1000;

    // Render system prompt from template.
    // Sanitize substitution values: strip characters that could escape
    // the template context (e.g. injecting new system prompt sections).
    let system;
    try {
      // Use split/join per key to avoid second-order template injection.
      // A regex replace() re-scans its replacement strings; split/join does not,
      // so an injected value containing {anotherKey} is never re-expanded.
      // CWE-116: Improper Encoding/Escaping prevention.
      let tmpl = this._config.systemPromptTemplate;
      const keyPattern = /\{(\w+)\}/g;
      const keys = [...new Set([...(tmpl.matchAll(keyPattern))].map(m => m[1]))];
      for (const key of keys) {
        if (!(key in taskInput)) throw new Error(key);
        let value = String(taskInput[key]);
        // Cap substitution length to prevent prompt stuffing
        if (value.length > 10000) {
          value = value.slice(0, 10000) + '\n[...truncated]';
        }
        // split/join: replaces all occurrences, never re-scans the value
        tmpl = tmpl.split(`{${key}}`).join(value);
      }
      system = tmpl;
    } catch (e) {
      result.error = `System prompt template error: missing key '${e.message}'`;
      result.durationS = (performance.now() / 1000) - startTime;
      return result;
    }

    // Build initial user message
    const userMessage = taskInput.user_message || 'Begin the task.';

    /** @type {Array<Object>} */
    const messages = [
      { role: 'user', content: userMessage },
    ];

    const tools = this._config.toolRegistry.getSchemas();
    let completed = false;
    let completedText = '';
    let lastAssistantText = '';

    // for...else pattern: exhausted flag
    let exhausted = true;

    for (let turn = 0; turn < this._config.maxTurns; turn++) {
      // Check abort flag at turn boundary
      if (this._aborted) {
        result.steps.push('[abort] Agent aborted by caller');
        debug(`BaseAgent [${this._config.mode}] aborted at turn ${turn + 1}`);
        exhausted = false;
        break;
      }

      // Inject queued steer instructions as user messages
      if (this._steerQueue.length > 0) {
        const steerText = this._steerQueue.join('\n\n');
        this._steerQueue = [];
        messages.push({ role: 'user', content: `[STEERING INSTRUCTION]\n${steerText}` });
        result.steps.push(`[steer] Injected steering: ${steerText.slice(0, 100)}`);
        debug(`BaseAgent [${this._config.mode}] injected steer message`);
      }

      result.turnsUsed = turn + 1;
      debug(`BaseAgent [${this._config.mode}] turn ${turn + 1}/${this._config.maxTurns}`);

      this.emit('turn_start', { turn: turn + 1, mode: this._config.mode });

      let response;
      try {
        response = await this._client.messages.create({
          model: this._model,
          max_tokens: this._config.maxTokensPerTurn,
          system,
          tools,
          messages,
        });
      } catch (e) {
        result.error = `LLM API error: ${e.constructor.name}: ${e.message}`;
        exhausted = false;
        break;
      }

      // Accumulate token usage
      const usage = response?.usage;
      if (usage) {
        result.tokensIn += usage.input_tokens || 0;
        result.tokensOut += usage.output_tokens || 0;
      }

      // Process response content blocks
      /** @type {Array<Object>} */
      const toolResults = [];
      /** @type {Array<Object>} */
      const assistantContent = [];
      /** @type {string[]} */
      const turnTextParts = [];

      for (const block of response.content) {
        if (block.type === 'text') {
          const text = block.text;
          assistantContent.push({ type: 'text', text });
          turnTextParts.push(text);
          result.steps.push(`[think] ${text.slice(0, 200)}`);

          this.emit('text', { text, turn: turn + 1 });

          // Check completion on text
          if (this._config.completionCheck !== null && this._config.completionCheck(text)) {
            completed = true;
            completedText = text;
          }
        } else if (block.type === 'tool_use') {
          const toolName = block.name;
          const toolInput = block.input;
          const toolUseId = block.id;
          result.toolCalls += 1;

          assistantContent.push({
            type: 'tool_use',
            id: toolUseId,
            name: toolName,
            input: toolInput,
          });

          // Pre-tool hook (supervised mode)
          if (this._preToolHook) {
            const approved = this._preToolHook(toolName, toolInput);
            if (!approved) {
              result.steps.push(`[blocked] ${toolName}`);
              toolResults.push({
                type: 'tool_result',
                tool_use_id: toolUseId,
                content: 'Tool execution blocked by supervisor.',
              });
              continue;
            }
          }

          // Build step description
          let stepDesc = `[tool] ${toolName}`;
          if (toolName === 'sandbox_exec') {
            stepDesc += `: ${(toolInput.command || '').slice(0, 80)}`;
          } else if (toolName === 'http_request') {
            stepDesc += `: ${toolInput.method || 'GET'} ${toolInput.url || ''}`;
          } else if (toolName === 'read_file') {
            stepDesc += `: ${toolInput.path || ''}`;
          }

          debug(`Dispatching: ${stepDesc}`);
          result.steps.push(stepDesc);

          this.emit('tool_call', { name: toolName, input: toolInput, turn: turn + 1 });

          // Dispatch tool (ToolRegistry catches handler exceptions)
          const toolOutput = this._config.toolRegistry.dispatch(
            toolName, toolInput, this._context,
          );

          // Check completion on tool output
          if (this._config.completionCheck !== null && this._config.completionCheck(toolOutput)) {
            completed = true;
            completedText = toolOutput;
          }

          toolResults.push({
            type: 'tool_result',
            tool_use_id: toolUseId,
            content: toolOutput,
          });

          this.emit('tool_result', { name: toolName, output: toolOutput, turn: turn + 1 });
        }
      }

      // Track last assistant text for outputParser
      if (turnTextParts.length > 0) {
        lastAssistantText = turnTextParts.join('');
      }

      // Add assistant response to message history
      messages.push({ role: 'assistant', content: assistantContent });

      this.emit('turn_end', { turn: turn + 1, textParts: turnTextParts, toolCalls: toolResults.length });

      // If completion check fired, we're done
      if (completed) {
        result.steps.push(
          `[complete] Completion check passed: ${completedText.slice(0, 200)}`
        );
        exhausted = false;
        break;
      }

      // If no tool_use in response, LLM is done
      const stopReason = response.stop_reason ?? null;
      if (stopReason !== 'tool_use') {
        debug(`Agent stopped: stop_reason=${stopReason}`);
        result.steps.push(`[end] Agent stopped (reason: ${stopReason})`);
        exhausted = false;
        break;
      }

      // Continue with tool results
      if (toolResults.length > 0) {
        messages.push({ role: 'user', content: toolResults });
      }
    }

    // Loop exhausted without break (for...else equivalent)
    if (exhausted) {
      result.steps.push(`[limit] Max turns (${this._config.maxTurns}) reached`);
      debug(`Agent exhausted ${this._config.maxTurns} turns`);
    }

    // --- Post-loop: output parsing ---
    if (this._config.outputParser !== null) {
      try {
        result.outputData = this._config.outputParser(lastAssistantText);
      } catch (e) {
        const parseErr = `Output parser error: ${e.constructor.name}: ${e.message}`;
        if (result.error) {
          result.error += `; ${parseErr}`;
        } else {
          result.error = parseErr;
        }
      }
    }

    // Set output text
    result.outputText = lastAssistantText;

    // --- Post-loop: validation ---
    result.validation = this._config.validator.validate(result);

    // --- Post-loop: self-check (hallucination/hedging detection) ---
    try {
      const { SelfChecker } = await import('../gates/self-check.js');
      const checker = new SelfChecker();
      result.selfCheck = checker.check(lastAssistantText);
      if (result.selfCheck.findings.length > 0) {
        debug(`Self-check: ${result.selfCheck.findings.length} findings, score ${result.selfCheck.score}`);
      }
    } catch {
      // Self-check is non-critical — don't fail the agent if it can't load
    }

    // --- Duration ---
    result.durationS = (performance.now() / 1000) - startTime;

    this.emit('complete', { result });

    return result;
  }
}
