/**
 * @file tools/imageTool.js
 * @description Tool for generating images using AI models (resolved dynamically from backend)
 */

import path from 'path';
import os from 'os';
import { promises as fs } from 'fs';
import { BaseTool } from './baseTool.js';
import { getGalleryService } from '../services/galleryService.js';
import {
  resolveTargetFormat,
  transcodeIfNeeded,
  extensionFor,
  ensureExtension,
  DEFAULT_FORMAT,
} from './imageFormat.js';

/**
 * Configuration constants for image generation
 */
const IMAGE_CONFIG = {
  DEFAULT_MODEL: null, // Resolved dynamically from modelsService via aiService
  DEFAULT_SIZE: '1024x1024',
  DEFAULT_QUALITY: 'standard',
  // Flux-family size rules (used when the effective model matches FLUX_MODELS).
  SIZE_MIN: 256,
  SIZE_MAX: 1440,
  SIZE_INCREMENT: 32,  // Must be multiples of 32

  /**
   * Per-model size rules. Two kinds of rule:
   *   { kind: 'enum',  values: ['1024x1024', ...] }      — exact match required
   *   { kind: 'range', min, max, increment }             — W×H in grid
   * Key matching is substring, case-insensitive (so 'gpt-image-1.5' matches
   * 'gpt-image-1.5-preview', etc.). The first matching entry wins; anything
   * else falls back to `default`.
   *
   * This table is the SINGLE SOURCE OF TRUTH for what the tool accepts
   * client-side. Previously the validator was hardcoded to Flux rules and
   * actively rejected valid gpt-image-1.5 sizes (1024x1536, 1536x1024, auto)
   * while the tool's own docstring advertised them. See _validateParameters.
   */
  MODEL_SIZES: {
    'gpt-image-1.5': { kind: 'enum',  values: ['1024x1024', '1024x1536', '1536x1024', 'auto'] },
    'gpt-image-1':   { kind: 'enum',  values: ['1024x1024', '1024x1536', '1536x1024', 'auto'] },
    'flux':          { kind: 'range', min: 256, max: 1440, increment: 32 },
    'default':       { kind: 'range', min: 256, max: 1440, increment: 32 },
  },

  /**
   * Size presets, model-aware. Each entry is a shape key; the resolver
   * picks the variant for the effective model. If the model isn't listed,
   * the `default` variant is used (Flux-shaped).
   *
   * Agents were previously confused because `portrait` → 1024x1440 always,
   * even when the tool silently auto-switched to gpt-image-1.5 (where that
   * size is invalid). Now `portrait` resolves to 1024x1536 for gpt-image.
   */
  SIZE_PRESETS: {
    'square':         { default: '1024x1024', 'gpt-image-1.5': '1024x1024' },
    'square_hd':      { default: '1440x1440', 'gpt-image-1.5': '1024x1024' },
    'portrait':       { default: '1024x1440', 'gpt-image-1.5': '1024x1536' },
    'landscape':      { default: '1440x1024', 'gpt-image-1.5': '1536x1024' },
    'portrait_4_3':   { default: '768x1024',  'gpt-image-1.5': '1024x1536' },
    'landscape_4_3':  { default: '1024x768',  'gpt-image-1.5': '1536x1024' },
    'small':          { default: '512x512',   'gpt-image-1.5': '1024x1024' },
  },

  VALID_FORMATS: ['png', 'jpg', 'jpeg', 'webp'],
  MAX_CONCURRENT: 3,
  QUEUE_LIMIT: 10,
  TEMP_CLEANUP_MS: 3600000, // 1 hour
  MAX_PROMPT_LENGTH: 4000,
  DOWNLOAD_TIMEOUT: 60000 // 60 seconds
};

/**
 * Resolve the effective model given the caller's inputs.
 *
 * The tool auto-switches to `gpt-image-1.5` when the caller asks for
 * transparency or supplies a sourceImage (only gpt-image-1.5 supports
 * those features). Previously this logic lived inside the async worker,
 * AFTER _validateParameters ran — so validation applied Flux rules even
 * when the real model would end up being gpt-image-1.5 and vice versa.
 * Hoisted here so validators, error messages, and preset resolution all
 * see the same truth.
 *
 * @param {object} params - imageParams with { model, transparency, sourceImage }
 * @returns {string} — the effective model id (lowercase)
 */
export function resolveEffectiveImageModel(params = {}) {
  const explicit = (params.model || '').toLowerCase();
  if (explicit && explicit !== 'auto') return explicit;
  if (params.transparency === true) return 'gpt-image-1.5';
  if (params.sourceImage)            return 'gpt-image-1.5';
  // Null → defer to downstream (aiService resolves from catalog). We
  // still return a sentinel so validators can use `default` rules.
  return '';
}

/**
 * Translate an image-generation error into an actionable message for the
 * agent. The backend (`aiService._generateImageOpenAI` / Flux) wraps HTTP
 * responses as `"HTTP 400: Bad Request - <provider text>"` and throws.
 * That passes through to the agent as raw stack text — agents don't know
 * whether they tripped a size rule, a moderation rule, or something else.
 *
 * This helper parses the incoming `Error` for the common signatures and
 * produces a message that includes (a) the effective model, (b) what
 * class of failure it was, and (c) a concrete next-action.
 *
 * @param {Error}  error - Thrown from aiService / downstream
 * @param {object} job   - The queued job (provides model + size + prompt)
 * @returns {string}
 */
export function _translateImageError(error, job = {}) {
  const raw = (error && error.message) || String(error || 'unknown error');
  const effectiveModel = resolveEffectiveImageModel(job) || job.model || 'auto';
  const size = job.size || IMAGE_CONFIG.DEFAULT_SIZE;
  const lower = raw.toLowerCase();

  // Content moderation family — providers use various phrasings.
  if (/moderat|content[_ -]policy|safety|flagged|unsafe/.test(lower)) {
    return (
      `Image generation rejected by content moderation (model=${effectiveModel}). ` +
      `The prompt was flagged. Rephrase rather than retrying verbatim: soften explicit ` +
      `adjectives (e.g. drop "blood", "gore", "bones"), describe style instead of violence, ` +
      `and avoid real-person likenesses. Original provider message: ${raw}`
    );
  }

  // Size-shaped rejections from the provider (leaks through when our
  // client-side validator missed, or when the catalogue changes).
  if (/size|dimension|unsupported|out of range|must be one of|invalid image size/.test(lower)) {
    const { rule } = _getModelSizeRule(effectiveModel);
    const valid = rule.kind === 'enum'
      ? `Valid sizes for ${effectiveModel}: ${rule.values.join(', ')}.`
      : `Valid for ${effectiveModel}: WIDTHxHEIGHT in ${rule.min}-${rule.max}, multiples of ${rule.increment}.`;
    return (
      `Image generation rejected: size "${size}" not accepted by ${effectiveModel}. ${valid} ` +
      `Original provider message: ${raw}`
    );
  }

  // Auth / quota.
  if (/unauthorized|forbidden|quota|rate.?limit|429|401|403/.test(lower)) {
    return (
      `Image generation failed for ${effectiveModel}: authentication or quota problem. ` +
      `Retrying with the same inputs will likely fail again — check API keys / billing. ` +
      `Original: ${raw}`
    );
  }

  // Fall-through: still tell the agent which model ran.
  return `Image generation failed (model=${effectiveModel}, size=${size}): ${raw}`;
}

/**
 * Look up the size rule for an effective model id. Substring match.
 * Always returns a rule (falls back to `default`).
 */
export function _getModelSizeRule(effectiveModel) {
  const key = String(effectiveModel || '').toLowerCase();
  for (const [needle, rule] of Object.entries(IMAGE_CONFIG.MODEL_SIZES)) {
    if (needle === 'default') continue;
    if (key.includes(needle)) return { modelKey: needle, rule };
  }
  return { modelKey: 'default', rule: IMAGE_CONFIG.MODEL_SIZES.default };
}

/**
 * ImageTool - Generate images using AI models
 * Supports queueing, async processing, and both temp/project directory storage
 */
export class ImageTool extends BaseTool {
  constructor(config = {}, logger = null) {
    super(config, logger);

    // Override tool ID
    this.id = 'image-gen';

    // Job queue and tracking
    this.queue = [];
    this.currentJob = null;
    this.completedJobs = new Map();
    this.isProcessing = false;

    // AIService will be injected later
    this.aiService = null;

    // AgentPool will be injected later (for saving to conversation history)
    this.agentPool = null;

    // Temp directory for images
    this.tempDir = path.join(os.tmpdir(), 'loxia-images');

    // Cleanup timers
    this.cleanupTimers = new Map();
  }

  /**
   * Set AI service for image generation
   * @param {AIService} aiService - AI service instance
   */
  setAIService(aiService) {
    this.aiService = aiService;
    this.logger?.info('AI Service set for ImageTool');
  }

  /**
   * Set Agent Pool for saving results to conversation history
   * @param {AgentPool} agentPool - AgentPool instance
   */
  setAgentPool(agentPool) {
    this.agentPool = agentPool;
    this.logger?.info('AgentPool set for ImageTool');
  }

  /**
   * Get tool description for agent system prompt
   * @returns {string} Formatted tool description
   */
  getDescription() {
    return `Tool: Image Generator - Generate and edit images using AI models

**Purpose:** Generate images from text descriptions, edit existing images, and create images with transparency. Images are saved to files and displayed in chat.

**CRITICAL: Automatic Execution**
- ANY \`\`\`json block with "toolId": "image-gen" will be EXECUTED IMMEDIATELY
- Just output the command when you want to generate or edit an image
- If generation fails, output a NEW command with corrections

**USAGE — Generate (default WebP):**
\`\`\`json
{
  "toolId": "image-gen",
  "parameters": {
    "prompt": "Detailed description of the image",
    "outputPath": "images/filename.webp",
    "size": "1024x1024"
  }
}
\`\`\`

**USAGE — Force PNG output (when you specifically need lossless / older-tool compat):**
\`\`\`json
{
  "toolId": "image-gen",
  "parameters": {
    "prompt": "Pixel-art icon set on white",
    "outputPath": "images/icons.png"
  }
}
\`\`\`
Or, equivalently, with the explicit field:
\`\`\`json
{
  "toolId": "image-gen",
  "parameters": { "prompt": "Pixel-art icon set", "outputType": "png" }
}
\`\`\`

**USAGE — Generate with Transparency (WebP supports alpha):**
\`\`\`json
{
  "toolId": "image-gen",
  "parameters": {
    "prompt": "A cute fox mascot on a transparent background",
    "model": "gpt-image-1.5",
    "transparency": true,
    "outputPath": "images/fox-mascot.webp"
  }
}
\`\`\`

**USAGE — Edit Existing Image:**
\`\`\`json
{
  "toolId": "image-gen",
  "parameters": {
    "prompt": "Remove the background and make it transparent",
    "sourceImage": "images/photo.png",
    "model": "gpt-image-1.5",
    "outputPath": "images/photo-nobg.webp"
  }
}
\`\`\`
(The \`sourceImage\` can be any common format — PNG, JPEG, WebP, etc. The OUTPUT format is whatever you specify on \`outputPath\` / \`outputType\`, defaulting to WebP.)

**Parameters:**
- **prompt** (required): Description of image to generate OR editing instruction
- **outputPath** (optional): Path to save image (permanent). Omit for temp file.
  The file extension determines output format (.webp / .png / .jpg / .jpeg).
- **outputType** (optional): Explicit format request when outputPath has no extension
  or is omitted: 'webp' | 'png' | 'jpg' | 'jpeg'. Default: 'webp'.
  An outputPath extension always wins over outputType if both are present.
- **size** (optional): WIDTHxHEIGHT or preset name. DIFFERENT MODELS ACCEPT DIFFERENT SIZES — read the rules below. Default: 1024x1024.
- **quality** (optional): "standard" or "hd" (default: standard)
- **model** (optional): "gpt-image-1.5" or a flux variant. Default: auto. Auto-switches to gpt-image-1.5 if transparency=true OR sourceImage is set.
- **sourceImage** (optional): Path to source image for editing (forces gpt-image-1.5)
- **mask** (optional): Path to mask image for editing (white=edit area, black=keep area)
- **transparency** (optional): true to generate with transparent background (forces gpt-image-1.5)

**Output format (NEW default: WebP):**
The tool transcodes provider bytes (typically PNG) into WebP by default —
smaller files, same visual quality. Force PNG with either an explicit
.png extension on outputPath OR \`outputType: "png"\`. Pick PNG when you
need lossless graphics or animated GIFs would otherwise be needed; WebP
is the better default for photo-style outputs.

⚠ SIZE RULES — read before picking a size:

  (A) gpt-image-1.5 (auto-selected on transparency=true or sourceImage, or set explicitly):
      size MUST be one of: 1024x1024, 1024x1536 (portrait), 1536x1024 (landscape), or "auto".
      Any other value is rejected; the error lists the valid options.
      Presets remap automatically — e.g. "portrait" → 1024x1536 on this model.

  (B) Flux models (the default when transparency is not required):
      size must be WIDTHxHEIGHT, both in 256-1440, both multiples of 32 (e.g. 1024x768, 1280x960).
      "auto" is NOT valid for Flux.

Presets (shape names — resolved per the chosen model):
  square, square_hd, portrait, landscape, portrait_4_3, landscape_4_3, small

Content moderation:
- Prompts with graphic violence, gore, explicit sexual content, or real-person likenesses are commonly rejected by the provider as "Bad request". If you hit one of those, REPHRASE — don't retry verbatim. Soften explicit adjectives (e.g. "dark fantasy queen with crimson rubies" works; "blood tears, bone crown, dripping" typically doesn't).

**Notes:**
- Images take 15-30 seconds to generate
- Be descriptive in prompts for better results
- Use gpt-image-1.5 when you need transparent backgrounds, image editing, or image-to-image transformations
- Max ${IMAGE_CONFIG.QUEUE_LIMIT} images in queue`;
  }

  /**
   * Parse image generation parameters
   * @param {string|Object} content - Raw content or parsed object
   * @returns {Object} Parsed parameters
   */
  parseParameters(content) {
    // Handle JSON format
    if (typeof content === 'object' && content !== null) {
      return this._parseJSONParams(content);
    }

    // Handle string format
    if (typeof content === 'string') {
      const trimmed = content.trim();

      // Try to parse as JSON first
      if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
        try {
          const parsed = JSON.parse(trimmed);
          return this._parseJSONParams(parsed);
        } catch (err) {
          // Not valid JSON, fall through to XML parsing
        }
      }

      // Parse as XML
      return this._parseXMLParams(content);
    }

    throw new Error('Invalid parameter format');
  }

  /**
   * Parse JSON parameters
   * @private
   */
  _parseJSONParams(obj) {
    // Handle parameters wrapper (when called via toolId/parameters structure)
    if (obj.parameters) {
      obj = obj.parameters;
    }

    // Check for batch mode
    if (obj.batch && Array.isArray(obj.batch)) {
      return {
        batch: true,
        images: obj.batch.map(img => this._parseImageParams(img))
      };
    }

    return {
      batch: false,
      images: [this._parseImageParams(obj)]
    };
  }

  /**
   * Parse XML parameters
   * @private
   */
  _parseXMLParams(content) {
    const params = { batch: false, images: [] };

    // Check for batch mode
    const batchMatch = /<batch>([\s\S]*?)<\/batch>/i.exec(content);

    if (batchMatch) {
      params.batch = true;
      const batchContent = batchMatch[1];

      // Extract individual <image> blocks
      const imageRegex = /<image>([\s\S]*?)<\/image>/gi;
      let match;

      while ((match = imageRegex.exec(batchContent)) !== null) {
        params.images.push(this._parseXMLImage(match[1]));
      }
    } else {
      // Single image mode
      params.images.push(this._parseXMLImage(content));
    }

    if (params.images.length === 0) {
      throw new Error('No valid image parameters found');
    }

    return params;
  }

  /**
   * Parse single image parameters from object
   * @private
   */
  _parseImageParams(obj) {
    const outputPath = obj.outputPath || obj['output-path'] || null;
    // Optional explicit format request. Accepts 'webp' (default if no
    // outputPath ext), 'png', 'jpg' / 'jpeg'. The outputPath extension
    // wins if both are present (handled in resolveTargetFormat).
    const outputType = obj.outputType || obj['output-type'] || obj.format || null;

    return {
      prompt: obj.prompt || '',
      outputPath: outputPath,
      outputType: outputType,
      saveToProject: outputPath !== null, // If path specified, save to project
      model: obj.model || IMAGE_CONFIG.DEFAULT_MODEL,
      size: obj.size || IMAGE_CONFIG.DEFAULT_SIZE,
      quality: obj.quality || IMAGE_CONFIG.DEFAULT_QUALITY,
      sourceImage: obj.sourceImage || null,     // Edit mode: path to source image
      mask: obj.mask || null,                   // Edit mode: path to mask image
      transparency: obj.transparency || false   // Generate with transparent background
    };
  }

  /**
   * Parse single image parameters from XML string
   * @private
   */
  _parseXMLImage(xmlContent) {
    const extractTag = (tag) => {
      const regex = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i');
      const match = regex.exec(xmlContent);
      return match ? match[1].trim() : null;
    };

    const outputPath = extractTag('output-path') || null;
    const outputType = extractTag('output-type') || extractTag('format') || null;

    return {
      prompt: extractTag('prompt') || '',
      outputPath: outputPath,
      outputType: outputType,
      saveToProject: outputPath !== null, // If path specified, save to project
      model: extractTag('model') || IMAGE_CONFIG.DEFAULT_MODEL,
      size: extractTag('size') || IMAGE_CONFIG.DEFAULT_SIZE,
      quality: extractTag('quality') || IMAGE_CONFIG.DEFAULT_QUALITY,
      sourceImage: extractTag('source-image') || null,
      mask: extractTag('mask') || null,
      transparency: extractTag('transparency') === 'true'
    };
  }

  /**
   * Execute image generation
   * @param {Object|string} params - Parsed parameters object OR raw XML/JSON string
   * @param {Object} context - Execution context
   * @returns {Promise<Object>} Execution result
   */
  async execute(params, context = {}) {
    try {
      const { agentId, projectDir, directoryAccess, sessionId } = context;

      // Auto-detect and parse inputs (from TagParser or direct call)
      // parseParameters() normalizes input to { batch: bool, images: [...] } format
      if (typeof params === 'string') {
        this.logger?.info('ImageTool: Auto-parsing string parameters');
        params = this.parseParameters(params);
      } else if (typeof params === 'object' && params !== null && !params.images) {
        // Object params without 'images' array - needs parsing to normalize structure
        this.logger?.info('ImageTool: Normalizing object parameters');
        params = this.parseParameters(params);
      }

      // Validate parameters
      this._validateParameters(params);

      // Queue images
      const jobIds = [];

      for (const imageParams of params.images) {
        // Create job
        const jobId = this._generateJobId();

        const job = {
          jobId,
          agentId,
          sessionId,
          prompt: imageParams.prompt,
          outputPath: imageParams.outputPath,
          // Explicit format request — used by the save path to decide the
          // target format. Default behavior (no outputPath, no outputType):
          // WebP. See imageFormat.resolveTargetFormat for precedence.
          outputType: imageParams.outputType || null,
          saveToProject: imageParams.saveToProject,
          model: imageParams.model,
          size: imageParams.size,
          quality: imageParams.quality,
          projectDir: projectDir || process.cwd(),
          directoryAccess,
          // Carry the caller's per-agent image-gen config onto the job
          // so the async processor (which doesn't have the original
          // context) can still honor `saveToGallery` etc.
          toolConfig: context?.toolConfig || null,
          status: 'queued',
          createdAt: new Date().toISOString()
        };

        // Check queue limit
        if (this.queue.length >= IMAGE_CONFIG.QUEUE_LIMIT) {
          return {
            success: false,
            error: `Queue limit reached (${IMAGE_CONFIG.QUEUE_LIMIT} images). Please wait for current jobs to complete.`,
            queueLength: this.queue.length
          };
        }

        this.queue.push(job);
        jobIds.push(jobId);

        this.logger?.info(`Image generation job queued: ${jobId}`, {
          prompt: imageParams.prompt.substring(0, 50) + '...',
          queuePosition: this.queue.length
        });
      }

      // Start processing if not already running
      if (!this.isProcessing) {
        this._processQueue().catch(err => {
          this.logger?.error('Queue processing error:', err);
        });
      }

      // Return immediate response
      return {
        success: true,
        jobIds,
        queueLength: this.queue.length,
        message: params.batch
          ? `${jobIds.length} images queued for generation`
          : 'Image queued for generation',
        estimatedWaitTime: this._estimateWaitTime()
      };

    } catch (error) {
      this.logger?.error('Image generation error:', error);
      return {
        success: false,
        error: error.message
      };
    }
  }

  /**
   * Validate parameters
   * @private
   */
  _validateParameters(params) {
    if (!params.images || params.images.length === 0) {
      throw new Error('No images specified');
    }

    for (const img of params.images) {
      if (!img.prompt || img.prompt.trim().length === 0) {
        throw new Error('Image prompt is required');
      }

      if (img.prompt.length > IMAGE_CONFIG.MAX_PROMPT_LENGTH) {
        throw new Error(`Prompt too long (max ${IMAGE_CONFIG.MAX_PROMPT_LENGTH} characters)`);
      }

      // Resolve the effective model FIRST so size validation, preset
      // resolution, and error messages all see the model that will
      // actually run. Previously this logic lived downstream in the
      // async worker so Flux rules were applied even when the tool
      // would silently switch to gpt-image-1.5 (transparency=true,
      // sourceImage set, or explicit model) — and vice versa.
      const effectiveModel = resolveEffectiveImageModel(img);
      const { modelKey: ruleKey, rule } = _getModelSizeRule(effectiveModel);

      if (img.size) {
        // Step 1: expand preset names. Presets are model-aware — e.g.
        // `portrait` is 1024x1440 on Flux but 1024x1536 on gpt-image-1.5
        // (the latter only accepts specific enum values). Previously the
        // preset was resolved Flux-shaped and then rejected downstream.
        const presetEntry = IMAGE_CONFIG.SIZE_PRESETS[img.size];
        if (presetEntry) {
          img.size = presetEntry[ruleKey] || presetEntry.default;
        }

        // Step 2: per-model rule check.
        if (rule.kind === 'enum') {
          // gpt-image-1.5 et al — exact enum.
          if (!rule.values.includes(img.size)) {
            const modelLabel = effectiveModel || 'gpt-image-1.5';
            const autoHint = img.transparency
              ? ' (auto-selected because transparency=true)'
              : img.sourceImage
                ? ' (auto-selected because sourceImage was provided)'
                : '';
            throw new Error(
              `Image size "${img.size}" is not supported by ${modelLabel}${autoHint}. ` +
              `Valid sizes: ${rule.values.join(', ')}. ` +
              `For portrait orientation use "1024x1536"; for landscape use "1536x1024".`
            );
          }
        } else {
          // Range rule (Flux and default). `auto` is an enum-only value
          // that makes no sense for a range model — reject with a hint.
          if (img.size === 'auto') {
            throw new Error(
              `Image size "auto" is only valid for gpt-image-1.5. ` +
              `For the current model (${effectiveModel || 'flux'}) pick a WIDTHxHEIGHT ` +
              `between ${rule.min} and ${rule.max} in ${rule.increment}-pixel steps ` +
              `(e.g. 1024x1024).`
            );
          }
          const sizeMatch = img.size.match(/^(\d+)x(\d+)$/);
          if (!sizeMatch) {
            throw new Error(
              `Invalid size format: "${img.size}". ` +
              `Use WIDTHxHEIGHT (e.g. 1024x768) or one of these presets: ` +
              `${Object.keys(IMAGE_CONFIG.SIZE_PRESETS).join(', ')}.`
            );
          }
          const width  = parseInt(sizeMatch[1], 10);
          const height = parseInt(sizeMatch[2], 10);
          const { min, max, increment } = rule;
          const modelLabel = effectiveModel || 'flux';
          if (width < min || width > max) {
            throw new Error(
              `Width ${width} out of range for ${modelLabel}. Must be ${min}-${max}px ` +
              `(multiple of ${increment}).`
            );
          }
          if (height < min || height > max) {
            throw new Error(
              `Height ${height} out of range for ${modelLabel}. Must be ${min}-${max}px ` +
              `(multiple of ${increment}).`
            );
          }
          if (width % increment !== 0) {
            throw new Error(
              `Width ${width} must be a multiple of ${increment} for ${modelLabel}. ` +
              `Nearest valid: ${Math.round(width / increment) * increment}.`
            );
          }
          if (height % increment !== 0) {
            throw new Error(
              `Height ${height} must be a multiple of ${increment} for ${modelLabel}. ` +
              `Nearest valid: ${Math.round(height / increment) * increment}.`
            );
          }
        }
      }

      if (img.outputPath) {
        const ext = path.extname(img.outputPath).toLowerCase().replace('.', '');
        if (ext && !IMAGE_CONFIG.VALID_FORMATS.includes(ext)) {
          throw new Error(`Invalid format: ${ext}. Valid formats: ${IMAGE_CONFIG.VALID_FORMATS.join(', ')}`);
        }
      }
    }
  }

  /**
   * Process the image generation queue
   * @private
   */
  async _processQueue() {
    if (this.isProcessing) {
      return;
    }

    this.isProcessing = true;

    while (this.queue.length > 0) {
      const job = this.queue.shift();
      this.currentJob = job;

      this.logger?.info(`Processing image generation job: ${job.jobId}`);

      try {
        job.status = 'processing';

        // Generate the image
        const result = await this._generateImage(job);

        job.status = 'completed';
        job.result = result;
        job.completedAt = new Date().toISOString();

        // Store completed job
        this.completedJobs.set(job.jobId, job);

        // Broadcast result via WebSocket
        if (global.loxiaWebServer && job.sessionId) {
          // Determine which URL to use: local saved file or temporary AI URL
          let imageUrl;
          let isTemporary = false;

          this.logger?.info('Image generation result', {
            jobId: job.jobId,
            savedToDisk: result.savedToDisk,
            resolvedOutputPath: result.resolvedOutputPath,
            temporaryUrl: result.temporaryUrl?.substring(0, 80),
            downloadError: result.downloadError,
            isBase64Response: result.isBase64Response
          });

          if (result.savedToDisk && result.resolvedOutputPath) {
            // Image was saved successfully - use our server endpoint
            imageUrl = this._convertToWebUrl(result.resolvedOutputPath, job.sessionId);
            this.logger?.info('Using local server URL for image', { imageUrl });

            // Durable gallery copy (non-blocking, non-fatal).
            // Gated by per-agent `toolConfig.image-gen.saveToGallery`.
            // Default is ON — users who want to keep disk usage down can
            // opt out via the image-gen configurator in the agent modal.
            const saveToGallery = job.toolConfig?.saveToGallery !== false;
            if (saveToGallery) {
              try {
                let agentName = null;
                if (this.agentPool && job.agentId) {
                  try {
                    const a = await this.agentPool.getAgent(job.agentId);
                    agentName = a?.name || null;
                  } catch { /* non-fatal */ }
                }
                await getGalleryService(this.logger).saveImage({
                  sourcePath: result.resolvedOutputPath,
                  metadata: {
                    prompt: job.prompt,
                    model: job.model,
                    size: job.size,
                    quality: job.quality,
                    agentId: job.agentId,
                    agentName,
                    sessionId: job.sessionId,
                    jobId: job.jobId,
                    createdAt: job.completedAt || job.createdAt,
                  },
                });
              } catch (galErr) {
                this.logger?.warn?.('Gallery save failed (non-fatal)', {
                  jobId: job.jobId,
                  error: galErr.message,
                });
              }
            }
          } else if (result.temporaryUrl) {
            // Download failed - use temporary AI-generated URL (expires in ~1 hour)
            imageUrl = result.temporaryUrl;
            isTemporary = true;
            this.logger?.warn('Using temporary AI URL for image (local save failed)', {
              imageUrl: imageUrl.substring(0, 80),
              downloadError: result.downloadError
            });
          } else {
            this.logger?.error('No image URL available - neither local save nor temporary URL', {
              jobId: job.jobId,
              savedToDisk: result.savedToDisk,
              hasTemporaryUrl: !!result.temporaryUrl
            });
          }

          global.loxiaWebServer.broadcastToSession(job.sessionId, {
            type: 'imageGenerated',
            agentId: job.agentId,
            jobId: job.jobId,
            imageUrl,
            localPath: result.resolvedOutputPath,
            prompt: job.prompt,
            success: true,
            isTemporary, // Indicates if URL will expire
            savedToDisk: result.savedToDisk,
            downloadError: result.downloadError, // Include error if save failed
            timestamp: job.completedAt
          });

          this.logger?.info('Image generation broadcast sent', {
            jobId: job.jobId,
            imageUrl,
            localPath: result.resolvedOutputPath,
            savedToDisk: result.savedToDisk,
            isTemporary
          });

          // Save image result to conversation history for persistence
          if (this.agentPool && job.agentId) {
            try {
              const agent = await this.agentPool.getAgent(job.agentId);
              if (agent) {
                // Build message content with warnings if applicable
                let content = `Image generated: ${job.prompt}`;

                if (isTemporary) {
                  content += '\n\n⚠️ **Warning:** Image is using a temporary URL (expires in ~1 hour). Failed to save to disk.';
                  if (result.downloadError) {
                    content += `\n**Error:** ${result.downloadError}`;
                  }
                }

                // Create image message for conversation history
                const imageMessage = {
                  id: `img-result-${job.jobId}`,
                  role: 'assistant',
                  content,
                  timestamp: job.completedAt,
                  imageUrl, // CRITICAL: Include imageUrl so it persists across page refreshes
                  type: 'image-result',
                  toolId: 'image-gen',
                  status: 'completed',
                  isTemporary: isTemporary || false,
                  savedToDisk: result.savedToDisk !== false
                };

                // Add to full conversation
                agent.conversations.full.messages.push(imageMessage);
                agent.conversations.full.lastUpdated = job.completedAt;

                // Add to current model conversation if exists
                if (agent.currentModel && agent.conversations[agent.currentModel]) {
                  agent.conversations[agent.currentModel].messages.push(imageMessage);
                  agent.conversations[agent.currentModel].lastUpdated = job.completedAt;
                }

                // Update agent activity
                agent.lastActivity = job.completedAt;

                // Persist agent state to save conversation history
                await this.agentPool.persistAgentState(job.agentId);

                this.logger?.info('Image result saved to conversation history', {
                  agentId: job.agentId,
                  jobId: job.jobId,
                  messageId: imageMessage.id
                });

                // Queue tool result so agent "sees" the completion and can continue
                await this.agentPool.addToolResult(job.agentId, {
                  toolId: 'image-gen',
                  status: 'completed',
                  result: {
                    jobId: job.jobId,
                    prompt: job.prompt,
                    imageUrl,
                    localPath: result.resolvedOutputPath,
                    savedToDisk: result.savedToDisk,
                    isTemporary
                  },
                  timestamp: job.completedAt
                });

                this.logger?.info('Image result queued for agent processing', {
                  agentId: job.agentId,
                  jobId: job.jobId
                });
              }
            } catch (error) {
              this.logger?.error('Failed to save image result to conversation history', {
                error: error.message,
                agentId: job.agentId,
                jobId: job.jobId
              });
            }
          }
        }

        this.logger?.info(`Image generation completed: ${job.jobId}`, {
          outputPath: result.resolvedOutputPath
        });

      } catch (error) {
        this.logger?.error(`Image generation failed: ${job.jobId}`, error);

        job.status = 'failed';
        // Translate opaque backend errors into something actionable. The
        // downstream aiService wraps HTTP errors as "HTTP 400: Bad Request - …"
        // with the provider text tacked on; agents previously saw that
        // verbatim and had to guess whether it was a size mismatch, a
        // content-policy trip, or an auth problem. Route known signals
        // to a structured hint.
        job.error = _translateImageError(error, job);
        job.completedAt = new Date().toISOString();

        this.completedJobs.set(job.jobId, job);

        // Broadcast error to specific session
        if (global.loxiaWebServer && job.sessionId) {
          global.loxiaWebServer.broadcastToSession(job.sessionId, {
            type: 'imageGenerated',
            jobId: job.jobId,
            agentId: job.agentId,
            prompt: job.prompt,
            success: false,
            error: error.message,
            timestamp: job.completedAt
          });

          this.logger?.info('Image generation error broadcast sent', {
            jobId: job.jobId,
            sessionId: job.sessionId,
            error: error.message
          });
        }

        // Save error message to conversation history
        if (this.agentPool && job.agentId) {
          try {
            const agent = await this.agentPool.getAgent(job.agentId);
            if (agent) {
              // Create error message for conversation history
              const errorMessage = {
                id: `img-error-${job.jobId}`,
                role: 'system',
                content: `❌ Image generation failed: ${error.message}\n\n**Prompt:** ${job.prompt}`,
                timestamp: job.completedAt,
                type: 'error',
                toolId: 'image-gen',
                status: 'failed',
                jobId: job.jobId
              };

              // Add to full conversation
              agent.conversations.full.messages.push(errorMessage);
              agent.conversations.full.lastUpdated = job.completedAt;

              // Add to current model conversation if exists
              if (agent.currentModel && agent.conversations[agent.currentModel]) {
                agent.conversations[agent.currentModel].messages.push(errorMessage);
                agent.conversations[agent.currentModel].lastUpdated = job.completedAt;
              }

              // Update agent activity
              agent.lastActivity = job.completedAt;

              // Persist agent state to save conversation history
              await this.agentPool.persistAgentState(job.agentId);

              this.logger?.info('Image error saved to conversation history', {
                agentId: job.agentId,
                jobId: job.jobId,
                error: error.message
              });

              // Queue tool result so agent "sees" the failure and can handle it
              await this.agentPool.addToolResult(job.agentId, {
                toolId: 'image-gen',
                status: 'failed',
                error: error.message,
                result: {
                  jobId: job.jobId,
                  prompt: job.prompt
                },
                timestamp: job.completedAt
              });

              this.logger?.info('Image error queued for agent processing', {
                agentId: job.agentId,
                jobId: job.jobId
              });
            }
          } catch (historyError) {
            this.logger?.error('Failed to save image error to conversation history', {
              error: historyError.message,
              agentId: job.agentId,
              jobId: job.jobId
            });
          }
        }
      }
    }

    this.isProcessing = false;
    this.currentJob = null;
  }

  /**
   * Generate a single image
   * @private
   */
  async _generateImage(job) {
    // Check if AI service is available
    if (!this.aiService) {
      throw new Error('AI service not available. Image generation requires AI service.');
    }

    // Auto-select gpt-image-1.5 for transparency or editing
    if (job.transparency && !job.model) {
      job.model = 'gpt-image-1.5';
    }
    if (job.sourceImage && !job.model) {
      job.model = 'gpt-image-1.5';
    }

    // Resolve output path
    const resolvedOutputPath = await this._resolveOutputPath(job);

    // Ensure directory exists
    const outputDir = path.dirname(resolvedOutputPath);
    await fs.mkdir(outputDir, { recursive: true });

    let aiResult;

    // Edit mode: source image provided
    if (job.sourceImage) {
      this.logger?.info(`Editing image with ${job.model}`, {
        sourceImage: job.sourceImage,
        hasMask: !!job.mask
      });

      // Resolve source image path relative to project directory
      const projectDir = job.projectDir || process.cwd();
      const resolvedSourcePath = path.isAbsolute(job.sourceImage)
        ? job.sourceImage
        : path.resolve(projectDir, job.sourceImage);

      // Read source image and convert to base64
      const imageBuffer = await fs.readFile(resolvedSourcePath);
      const imageBase64 = imageBuffer.toString('base64');

      // Read mask if provided
      let maskBase64 = null;
      if (job.mask) {
        const resolvedMaskPath = path.isAbsolute(job.mask)
          ? job.mask
          : path.resolve(projectDir, job.mask);
        const maskBuffer = await fs.readFile(resolvedMaskPath);
        maskBase64 = maskBuffer.toString('base64');
      }

      aiResult = await this.aiService.editImage(job.prompt, imageBase64, {
        model: job.model,
        maskBase64,
        sessionId: job.sessionId
      });
    } else {
      // Standard generation mode
      this.logger?.info(`Generating image with ${job.model}`, {
        size: job.size,
        quality: job.quality,
        transparency: job.transparency
      });

      const options = {
        model: job.model,
        size: job.size,
        quality: job.quality,
        responseFormat: 'url', // Prefer URL, but Flux/GPT-Image returns b64_json
        sessionId: job.sessionId // CRITICAL: Pass sessionId for API key retrieval
      };

      aiResult = await this.aiService.generateImage(job.prompt, options);
    }

    // AIService returns: { url, b64_json, model, requestId, revisedPrompt }
    // Flux API returns b64_json (base64 encoded image)
    const imageUrl = aiResult?.url || aiResult?.imageUrl;
    const b64Json = aiResult?.b64_json;

    if (!imageUrl && !b64Json) {
      throw new Error('No image data received from AI service (no URL or base64)');
    }

    // Try to save image to disk
    let savedToDisk = false;
    let downloadError = null;
    let displayUrl = imageUrl; // URL for web display

    try {
      if (b64Json) {
        // Flux/GPT-Image response: Save base64 to disk, transcoding to
        // the resolved target format (default webp). If transcoding
        // fails, the helper falls back to writing original bytes — the
        // call doesn't fail, but a warning is logged via the logger.
        this.logger?.info(`Saving base64 image to disk: ${resolvedOutputPath}`);
        const rawBuffer = Buffer.from(b64Json, 'base64');
        const transcode = await transcodeIfNeeded(rawBuffer, job._targetFormat || DEFAULT_FORMAT, {
          quality: 85, logger: this.logger,
        });
        await fs.writeFile(resolvedOutputPath, transcode.buffer);
        if (transcode.transcoded) {
          this.logger?.info(`Image transcoded ${transcode.inputFormat} → ${transcode.outputFormat}`, {
            outputPath: resolvedOutputPath,
            inputBytes:  rawBuffer.length,
            outputBytes: transcode.buffer.length,
          });
        }

        // Verify the file was actually written
        const stat = await fs.stat(resolvedOutputPath);
        if (stat.size > 0) {
          savedToDisk = true;
          this.logger?.info(`Image saved successfully: ${resolvedOutputPath} (${stat.size} bytes)`);
        } else {
          this.logger?.warn(`Image file is empty after write: ${resolvedOutputPath}`);
        }

        // For web display, we'll use our local server endpoint (set below)
        displayUrl = null; // Will be converted to web URL later
      } else if (imageUrl) {
        // URL response: Download from URL, then transcode to target format.
        this.logger?.info(`Downloading image from URL: ${imageUrl.substring(0, 50)}...`);
        await this._downloadImage(imageUrl, resolvedOutputPath, job._targetFormat || DEFAULT_FORMAT);

        // Verify the file was actually written
        const stat = await fs.stat(resolvedOutputPath);
        if (stat.size > 0) {
          savedToDisk = true;
          this.logger?.info(`Image downloaded successfully: ${resolvedOutputPath} (${stat.size} bytes)`);
        } else {
          this.logger?.warn(`Downloaded image file is empty: ${resolvedOutputPath}`);
        }
        displayUrl = imageUrl;
      }

      // Schedule cleanup if temp file
      if (savedToDisk && !job.saveToProject) {
        this._scheduleCleanup(resolvedOutputPath, job.jobId);
      }
    } catch (error) {
      // Save failed, but we might still have a temporary URL
      downloadError = error.message;
      this.logger?.error(`Failed to save image to disk at ${resolvedOutputPath}: ${error.message}`);

      if (!imageUrl) {
        // No URL fallback for Flux - this is a real failure
        throw new Error(`Failed to save image: ${error.message}`);
      }
    }

    return {
      jobId: job.jobId,
      prompt: job.prompt,
      outputPath: job.outputPath,
      resolvedOutputPath: savedToDisk ? resolvedOutputPath : null,
      temporaryUrl: displayUrl, // AI-generated URL (valid for ~1 hour) or null for Flux
      savedToDisk,
      downloadError,
      success: true, // Image was generated successfully
      model: aiResult.model || job.model,
      size: job.size,
      usage: aiResult.usage,
      isBase64Response: !!b64Json // Flag to indicate Flux response
    };
  }

  /**
   * Resolve output path (temp or project directory)
   * @private
   */
  async _resolveOutputPath(job) {
    // Resolve target format ONCE here so the auto-generated filename
    // uses the right extension. Caller should also use job._targetFormat
    // (cached on the job) when transcoding bytes.
    const { format: targetFormat } = resolveTargetFormat({
      outputPath: job.outputPath,
      outputType: job.outputType,
    });
    job._targetFormat = targetFormat;
    const ext = extensionFor(targetFormat);

    if (job.saveToProject) {
      // Save to project directory
      const projectDir = job.projectDir || process.cwd();

      let outputPath = job.outputPath;
      if (!outputPath) {
        // Auto-generate filename — DEFAULT format is webp now (was png).
        // Agents that need PNG must pass an explicit outputPath with
        // .png extension OR outputType:'png'.
        const timestamp = Date.now();
        outputPath = `images/generated-${timestamp}.${ext}`;
      } else {
        // Append format extension when the agent supplied a name without one
        outputPath = ensureExtension(outputPath, targetFormat);
      }

      const resolvedPath = path.isAbsolute(outputPath)
        ? path.normalize(outputPath)
        : path.normalize(path.join(projectDir, outputPath));

      // Security: Check for path traversal
      if (!resolvedPath.startsWith(path.normalize(projectDir))) {
        throw new Error('Path traversal detected');
      }

      // Check directory access if provided
      if (job.directoryAccess) {
        // Simple check - file must be within allowed directories
        // Full implementation would use DirectoryAccessManager
        const relativePath = path.relative(projectDir, resolvedPath);
        if (relativePath.startsWith('..')) {
          throw new Error('Access denied: path outside project directory');
        }
      }

      return resolvedPath;
    } else {
      // Save to temp directory
      await fs.mkdir(this.tempDir, { recursive: true });

      // Temp dir path — same default-extension policy as project save.
      let filename = job.outputPath
        ? path.basename(ensureExtension(job.outputPath, targetFormat))
        : `generated-${job.jobId}.${ext}`;

      return path.join(this.tempDir, filename);
    }
  }

  /**
   * Download image from URL
   * @private
   */
  async _downloadImage(imageUrl, outputPath, targetFormat = DEFAULT_FORMAT) {
    try {
      const response = await fetch(imageUrl, {
        signal: AbortSignal.timeout(IMAGE_CONFIG.DOWNLOAD_TIMEOUT)
      });

      if (!response.ok) {
        throw new Error(`Failed to download image: HTTP ${response.status}`);
      }

      const buffer = Buffer.from(await response.arrayBuffer());
      // Transcode to target format if needed (defensive: fallback writes
      // original bytes if sharp can't load — see imageFormat.js).
      const transcode = await transcodeIfNeeded(buffer, targetFormat, {
        quality: 85, logger: this.logger,
      });
      await fs.writeFile(outputPath, transcode.buffer);
      if (transcode.transcoded) {
        this.logger?.info(`Downloaded image transcoded ${transcode.inputFormat} → ${transcode.outputFormat}`, {
          outputPath,
          inputBytes:  buffer.length,
          outputBytes: transcode.buffer.length,
        });
      } else {
        this.logger?.info(`Image saved to: ${outputPath}`);
      }

    } catch (error) {
      if (error.name === 'TimeoutError') {
        throw new Error('Image download timeout');
      } else if (error.name === 'TypeError') {
        throw new Error(`Network error: ${error.message}`);
      } else {
        throw new Error(`Download failed: ${error.message}`);
      }
    }
  }

  /**
   * Schedule cleanup of temp file
   * @private
   */
  _scheduleCleanup(filePath, jobId) {
    const timer = setTimeout(async () => {
      try {
        await fs.unlink(filePath);
        this.logger?.debug(`Cleaned up temp image: ${filePath}`);
        this.cleanupTimers.delete(jobId);
      } catch (error) {
        // File might already be deleted, ignore
      }
    }, IMAGE_CONFIG.TEMP_CLEANUP_MS);

    this.cleanupTimers.set(jobId, timer);
  }

  /**
   * Convert local file path to web-accessible URL
   * @private
   */
  _convertToWebUrl(localPath, sessionId) {
    // Extract just the filename from the path
    const filename = path.basename(localPath);

    // Construct web URL using the image serving endpoint
    // Assumes web server runs on port 8080 (can be made configurable)
    const port = global.loxiaWebServer?.port || 8080;
    let host = global.loxiaWebServer?.host || 'localhost';

    // Convert 0.0.0.0 (server binding address) to localhost (browser-accessible)
    // Browsers cannot connect to 0.0.0.0, even though servers can bind to it
    if (host === '0.0.0.0') {
      host = 'localhost';
    }

    return `http://${host}:${port}/api/images/${sessionId}/${filename}`;
  }

  /**
   * Estimate wait time based on queue
   * @private
   */
  _estimateWaitTime() {
    const avgGenerationTime = 30; // seconds
    const queuePosition = this.queue.length;

    if (queuePosition === 0) {
      return '~30 seconds';
    }

    const estimatedSeconds = queuePosition * avgGenerationTime;
    const minutes = Math.floor(estimatedSeconds / 60);
    const seconds = estimatedSeconds % 60;

    if (minutes > 0) {
      return `~${minutes}m ${seconds}s`;
    }
    return `~${seconds}s`;
  }

  /**
   * Generate unique job ID
   * @private
   */
  _generateJobId() {
    return `img-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
  }

  /**
   * Get job status
   * @param {string} jobId - Job ID
   * @returns {Object} Job status
   */
  getJobStatus(jobId) {
    // Check completed jobs
    if (this.completedJobs.has(jobId)) {
      return this.completedJobs.get(jobId);
    }

    // Check current job
    if (this.currentJob && this.currentJob.jobId === jobId) {
      return this.currentJob;
    }

    // Check queue
    const queuedJob = this.queue.find(job => job.jobId === jobId);
    if (queuedJob) {
      return queuedJob;
    }

    return {
      jobId,
      status: 'not_found'
    };
  }

  /**
   * Cleanup on shutdown
   */
  async cleanup() {
    this.logger?.info('Shutting down ImageTool');

    // Clear all cleanup timers
    for (const timer of this.cleanupTimers.values()) {
      clearTimeout(timer);
    }
    this.cleanupTimers.clear();

    // Mark queued jobs as cancelled
    for (const job of this.queue) {
      job.status = 'cancelled';
    }
    this.queue = [];
  }
}

export default ImageTool;
