
import type { NodeOutput } from '../schema/index.js';

/**
 * Substitute workflow-level variables in a string.
 *
 * Replaces `${VAR_NAME}` patterns with values from the provided variables map.
 * Supports nested dot-notation access (e.g. `${env.API_KEY}`).
 */
export function substituteWorkflowVariables(
  template: string,
  variables: Record<string, unknown>,
): string {
  return template.replace(/\$\{([^}]+)\}/g, (_match, path: string) => {
    const parts = path.split('.');
    let current: unknown = variables;
    for (const part of parts) {
      if (current == null || typeof current !== 'object') return '';
      current = (current as Record<string, unknown>)[part];
    }
    return current != null ? String(current) : '';
  });
}

/**
 * Regex pattern for node output references: `$nodeId.output` or `$nodeId.output.field`.
 */
const NODE_OUTPUT_REF_REGEX =
  /\$([a-zA-Z_][\w-]*)\.output(?:\.([a-zA-Z_][\w]*))?/g;

/**
 * Substitute node output references in a prompt string.
 *
 * Resolves `$nodeId.output` → full text output, and
 * `$nodeId.output.field` → specific structured field.
 *
 * @param prompt - The prompt template containing references.
 * @param nodeOutputs - Map of node id → NodeOutput.
 * @param escapedForBash - If true, escapes special bash characters in the output.
 */
export function substituteNodeOutputRefs(
  prompt: string,
  nodeOutputs: Map<string, NodeOutput>,
  escapedForBash = false,
): string {
  return prompt.replace(NODE_OUTPUT_REF_REGEX, (_match, nodeId: string, field?: string) => {
    const output = nodeOutputs.get(nodeId);
    if (!output) {
      throw new OutputRefError(
        `Node output reference $${nodeId}.output not found. ` +
          `Available nodes: ${[...nodeOutputs.keys()].join(', ')}`,
      );
    }

    let value: string;
    if (field) {
      value = resolveNodeOutputField(output, field);
    } else {
      value = output.text ?? '';
    }

    if (escapedForBash) {
      value = value.replace(/(["'$`\\!])/g, '\\$1');
    }

    return value;
  });
}

/**
 * Resolve a specific field from a node's structured output.
 *
 * @throws OutputRefError if the field doesn't exist or output has no fields.
 */
export function resolveNodeOutputField(output: NodeOutput, field: string): string {
  if (!output.fields || !(field in output.fields)) {
    throw new OutputRefError(
      `Node ${output.nodeId} output does not have field "${field}". ` +
        `Available fields: ${output.fields ? Object.keys(output.fields).join(', ') : '(none)'}`,
    );
  }
  const value = output.fields[field];
  return value != null ? String(value) : '';
}

/**
 * Build a complete prompt string with context injection.
 *
 * Applies workflow variable substitution and node output reference
 * substitution to produce the final prompt sent to the AI provider.
 */
export function buildPromptWithContext(
  prompt: string,
  variables: Record<string, unknown>,
  nodeOutputs: Map<string, NodeOutput>,
  escapedForBash = false,
): string {
  let result = substituteWorkflowVariables(prompt, variables);
  result = substituteNodeOutputRefs(result, nodeOutputs, escapedForBash);
  return result;
}

/**
 * Evaluate a condition expression against the current workflow context.
 *
 * Supports simple expressions:
 * - Truthy/falsy string check (empty string = false)
 * - Comparison: `==`, `!=`, `>`, `<`, `>=`, `<=`
 * - Boolean literals: `true`, `false`
 * - Variable references: `${var}` resolved before evaluation
 *
 * @param condition - The condition string to evaluate.
 * @param variables - Workflow variables for substitution.
 * @returns Whether the condition is truthy.
 */
export function evaluateCondition(
  condition: string | undefined,
  variables: Record<string, unknown>,
): boolean {
  if (condition === undefined || condition === '') return true;

  // Substitute variables first
  const resolved = substituteWorkflowVariables(condition, variables).trim();

  // Boolean literals
  if (resolved === 'true') return true;
  if (resolved === 'false') return false;

  // Empty after substitution = falsy
  if (resolved === '') return false;

  // Comparison operators
  const comparisonMatch = resolved.match(/^(.+?)\s*(==|!=|>=|<=|>|<)\s*(.+)$/);
  if (comparisonMatch) {
    const [, left, op, right] = comparisonMatch;
    const leftVal = left!.trim();
    const rightVal = right!.trim();

    switch (op) {
      case '==':
        return leftVal === rightVal;
      case '!=':
        return leftVal !== rightVal;
      case '>=':
        return parseFloat(leftVal) >= parseFloat(rightVal);
      case '<=':
        return parseFloat(leftVal) <= parseFloat(rightVal);
      case '>':
        return parseFloat(leftVal) > parseFloat(rightVal);
      case '<':
        return parseFloat(leftVal) < parseFloat(rightVal);
    }
  }

  // Default: non-empty string is truthy
  return resolved.length > 0;
}

/** Error categories for classification. */
export type ErrorCategory = 'transient' | 'permanent' | 'timeout' | 'rate_limit' | 'unknown';

/**
 * Classify an error into a category for retry decisions.
 */
export function classifyError(error: unknown): ErrorCategory {
  if (error instanceof Error) {
    const msg = error.message.toLowerCase();

    // Timeout errors
    if (
      msg.includes('timeout') ||
      msg.includes('timed out') ||
      msg.includes('aborted') ||
      error.name === 'AbortError' ||
      error.name === 'TimeoutError'
    ) {
      return 'timeout';
    }

    // Rate limiting
    if (
      msg.includes('rate limit') ||
      msg.includes('429') ||
      msg.includes('too many requests')
    ) {
      return 'rate_limit';
    }

    // Permanent errors
    if (
      msg.includes('authentication') ||
      msg.includes('unauthorized') ||
      msg.includes('forbidden') ||
      msg.includes('401') ||
      msg.includes('403') ||
      msg.includes('invalid api key') ||
      msg.includes('permission denied')
    ) {
      return 'permanent';
    }

    // Transient errors
    if (
      msg.includes('network') ||
      msg.includes('econnreset') ||
      msg.includes('econnrefused') ||
      msg.includes('enotfound') ||
      msg.includes('socket hang up') ||
      msg.includes('500') ||
      msg.includes('502') ||
      msg.includes('503') ||
      msg.includes('504') ||
      msg.includes('internal server error') ||
      msg.includes('bad gateway') ||
      msg.includes('service unavailable') ||
      msg.includes('gateway timeout')
    ) {
      return 'transient';
    }
  }

  return 'unknown';
}

/**
 * Safely send a message to the platform, swallowing errors.
 *
 * Used for non-critical notifications where failure should not
 * abort the workflow.
 */
export async function safeSendMessage(
  platform: { sendMessage: (convId: string, msg: string, meta?: Record<string, unknown>) => Promise<void> },
  conversationId: string,
  message: string,
  metadata?: Record<string, unknown>,
): Promise<void> {
  try {
    await platform.sendMessage(conversationId, message, metadata);
  } catch {
    // Swallow — this is a best-effort notification
  }
}

/** Thrown when a node output reference cannot be resolved. */
export class OutputRefError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'OutputRefError';
  }
}

/** Thrown when a cycle is detected in the DAG. */
export class DagCycleError extends Error {
  constructor(nodeCount: number, layerSum: number) {
    super(
      `Cycle detected in DAG: ${nodeCount} nodes but only ${layerSum} reachable via topological sort`,
    );
    this.name = 'DagCycleError';
  }
}

/** Thrown when a node execution times out. */
export class NodeTimeoutError extends Error {
  constructor(nodeId: string, timeoutMs: number) {
    super(`Node "${nodeId}" timed out after ${timeoutMs}ms`);
    this.name = 'NodeTimeoutError';
  }
}

/** Thrown when an approval is rejected. */
export class ApprovalRejectedError extends Error {
  constructor(nodeId: string, reason?: string) {
    super(`Approval rejected for node "${nodeId}"${reason ? `: ${reason}` : ''}`);
    this.name = 'ApprovalRejectedError';
  }
}

/** Thrown when a loop exceeds its maximum iterations. */
export class LoopMaxIterationsError extends Error {
  constructor(nodeId: string, iterations: number) {
    super(`Loop node "${nodeId}" exceeded max iterations (${iterations})`);
    this.name = 'LoopMaxIterationsError';
  }
}

/**
 * Format a subprocess failure into a human-readable error message.
 */
export function formatSubprocessFailure(
  command: string,
  exitCode: number | null,
  stdout: string,
  stderr: string,
): string {
  const parts: string[] = [];
  parts.push(`Command failed: ${command}`);
  if (exitCode != null) parts.push(`Exit code: ${exitCode}`);
  if (stderr.trim()) parts.push(`stderr: ${stderr.trim()}`);
  if (stdout.trim()) parts.push(`stdout: ${stdout.trim()}`);
  return parts.join('\n');
}

/**
 * Sleep for a given number of milliseconds.
 */
export function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
 * Retry a function with exponential backoff.
 *
 * @param fn - The function to retry.
 * @param maxAttempts - Maximum number of attempts.
 * @param baseDelayMs - Base delay between retries in ms.
 * @param shouldRetry - Optional predicate to decide if a retry is warranted.
 */
export async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  maxAttempts: number,
  baseDelayMs = 1000,
  shouldRetry?: (error: unknown) => boolean,
): Promise<T> {
  let lastError: unknown;
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;
      if (shouldRetry && !shouldRetry(error)) throw error;
      if (attempt < maxAttempts) {
        const delay = baseDelayMs * Math.pow(2, attempt - 1);
        await sleep(delay);
      }
    }
  }
  throw lastError;
}