// src/services/aiService.server.ts
/**
 * @file This file contains all server-side functions that directly interact with the Google AI (Gemini) API.
 * It is intended to be used only by the backend (e.g., server.ts) and should never be imported into client-side code.
 * The `.server.ts` naming convention helps enforce this separation.
 */

import { GoogleGenAI, type GenerateContentResponse, type Content, type Tool } from '@google/genai';
import fsPromises from 'node:fs/promises';
import type { Logger } from 'pino';
import { z } from 'zod';
import { pRateLimit } from 'p-ratelimit';
import type { FlyerItem, MasterGroceryItem, ExtractedFlyerItem } from '../types';

// Helper for consistent required string validation (handles missing/null/empty)
const requiredString = (message: string) => z.preprocess((val) => val ?? '', z.string().min(1, message));

// --- Zod Schemas for AI Response Validation (exported for the transformer) ---
const ExtractedFlyerItemSchema = z.object({
  item: z.string(),
  price_display: z.string(),
  price_in_cents: z.number().nullable(),
  quantity: z.string(),
  category_name: z.string(),
  master_item_id: z.number().nullish(), // .nullish() allows null or undefined
});

export const AiFlyerDataSchema = z.object({
  store_name: requiredString( "Store name cannot be empty" ),
  valid_from: z.string().nullable(),
  valid_to: z.string().nullable(),
  store_address: z.string().nullable(),
  items: z.array(ExtractedFlyerItemSchema),
});


/**
 * Defines the contract for a file system utility. This interface allows for
 * dependency injection, making the AIService testable without hitting the real file system.
 */
interface IFileSystem {
  readFile(path: string): Promise<Buffer>;
}

/**
 * Defines the contract for an AI model client. This allows for dependency injection,
 * making the AIService testable without making real API calls to Google.
 */
interface IAiClient {
  generateContent(request: {
    contents: Content[];
    tools?: Tool[];
  }): Promise<GenerateContentResponse>;
}

/**
 * Defines the shape of a single flyer item as returned by the AI.
 * This type is intentionally loose to accommodate potential null/undefined values
 * from the AI before they are cleaned and normalized.
 */
type RawFlyerItem = {
  item: string;
  price_display: string | null | undefined;
  price_in_cents: number | null;
  quantity: string | null | undefined;
  category_name: string | null | undefined;
  master_item_id?: number | null | undefined;
};

export class AIService {
  private aiClient: IAiClient;
  private fs: IFileSystem;
  private rateLimiter: <T>(fn: () => Promise<T>) => Promise<T>;
  private logger: Logger;

  constructor(logger: Logger, aiClient?: IAiClient, fs?: IFileSystem) {
    this.logger = logger;
    if (aiClient) {
      this.aiClient = aiClient;
    } else {
      // Determine if we are in any kind of test environment.
      // VITEST_POOL_ID is reliably set by Vitest during test runs.
      const isTestEnvironment = process.env.NODE_ENV === 'test' || !!process.env.VITEST_POOL_ID;

      const apiKey = process.env.GEMINI_API_KEY;
      if (!apiKey) {
        // Allow initialization without key in test/build environments if strictly needed
        if (!isTestEnvironment) {
          throw new Error('GEMINI_API_KEY environment variable not set for server-side AI calls.');
        }
      }
      // In test mode without injected client, we might not have a key. 
      // The stubs below protect against calling the undefined client.
      // This is the correct modern SDK pattern. We instantiate the main client.
      const genAI = apiKey ? new GoogleGenAI({ apiKey }) : null;
      
      // do not change "gemini-2.5-flash" - this is correct
      const modelName = 'gemini-2.5-flash';

      // We create a shim/adapter that matches the old structure but uses the new SDK call pattern.
      // This preserves the dependency injection pattern used throughout the class.
      this.aiClient = genAI ? {
        generateContent: (request) => {
          // The model name is now injected here, into every call, as the new SDK requires.
          return genAI.models.generateContent({ model: modelName, ...request });
        }
      } : { 
        // This is the updated mock for testing, matching the new response shape.
        generateContent: async () => ({ text: '[]' } as unknown as GenerateContentResponse) 
      };
    }

    this.fs = fs || fsPromises;

    // Initialize the rate limiter based on an environment variable.
    // Defaults to 5 requests per minute (60,000 ms) if not specified.
    const requestsPerMinute = parseInt(process.env.GEMINI_RPM || '5', 10);
    this.rateLimiter = pRateLimit({
      interval: 60 * 1000, // 1 minute
      rate: requestsPerMinute,
      concurrency: requestsPerMinute, // Allow up to `rate` requests to be running in parallel.
    });
    this.logger.info(`[AIService] Rate limiter initialized to ${requestsPerMinute} requests per minute.`);
  }

  private async serverFileToGenerativePart(path: string, mimeType: string) {
    const fileData = await this.fs.readFile(path);
    return {
      inlineData: {
        data: fileData.toString("base64"),
        mimeType,
      },
    };
  }

  /**
   * Constructs the detailed prompt for the AI to extract flyer data.
   * @param masterItems A list of known grocery items to aid in matching.
   * @param submitterIp The IP address of the user who submitted the flyer.
   * @param userProfileAddress The profile address of the user.
   * @returns A formatted string to be used as the AI prompt.
   */
  private _buildFlyerExtractionPrompt(
    masterItems: MasterGroceryItem[],
    submitterIp?: string,
    userProfileAddress?: string
  ): string {
    let locationHint = '';
    if (userProfileAddress) {
      locationHint = `The user who uploaded this flyer has a profile address of "${userProfileAddress}". Use this as a strong hint for the store's location.`;
    } else if (submitterIp) {
      locationHint = `The user uploaded this flyer from an IP address that suggests a location. Use this as a general hint for the store's region.`;
    }

    return `
      Analyze the provided flyer image(s). Your task is to extract key information and a list of all sale items.
  
      First, identify the following core details for the entire flyer:
      - "store_name": The name of the grocery store (e.g., "Walmart", "No Frills").
      - "valid_from": The start date of the sale period in YYYY-MM-DD format. If not present, use null.
      - "valid_to": The end date of the sale period in YYYY-MM-DD format. If not present, use null.
      - "store_address": The physical address of the store if present. If not present, use null. ${locationHint}
  
      Second, extract each individual sale item. For each item, provide:
      - "item": The name of the product (e.g., "Coca-Cola Classic").
      - "price_display": The sale price as a string (e.g., "$2.99", "2 for $5.00"). If no price is explicitly displayed, use an empty string "".
      - "price_in_cents": The primary numeric price converted to cents (e.g., for "$2.99", use 299). If a price is "2 for $5.00", use 500. If no price, use null.
      - "quantity": A string describing the quantity or weight for the price (e.g., "12x355mL", "500g", "each"). If no quantity is explicitly displayed, use an empty string "".
      - "master_item_id": From the provided master list, find the best matching item and return its ID. If no good match is found, use null.
      - "category_name": The most appropriate category for the item (e.g., "Beverages", "Meat & Seafood"). If no clear category can be determined, use "Other/Miscellaneous".
  
      Here is the master list of grocery items to help with matching:
      - Total items in master list: ${masterItems.length}
      - Sample items: ${JSON.stringify(masterItems.slice(0, 5))}
      
      ---
      MASTER LIST:
      ${JSON.stringify(masterItems)}
  
      Return a single, valid JSON object with the keys "store_name", "valid_from", "valid_to", "store_address", and "items". The "items" key should contain an array of the extracted item objects.
      Do not include any other text, explanations, or markdown formatting.
      `;
  }

  /**
   * Safely parses a JSON object from a string, typically from an AI response.
   * @param responseText The raw text response from the AI.
   * @returns The parsed JSON object, or null if parsing fails.
   */
  private _parseJsonFromAiResponse<T>(responseText: string | undefined, logger: Logger): T | null {
    if (!responseText) return null;

    // Find the first occurrence of '{' or '[' and the last '}' or ']'
    const firstBrace = responseText.indexOf('{');
    const firstBracket = responseText.indexOf('[');
    const start = (firstBrace === -1 || (firstBracket !== -1 && firstBracket < firstBrace)) ? firstBracket : firstBrace;
    const end = responseText.lastIndexOf(start === firstBracket ? ']' : '}');

    if (start === -1 || end === -1) return null;

    const jsonString = responseText.substring(start, end + 1);
    try {
      return JSON.parse(jsonString) as T;
    } catch (e) {
      logger.error({ jsonString, error: e }, "Failed to parse JSON from AI response slice");
      return null;
    }
  }

  async extractItemsFromReceiptImage(
    imagePath: string,
    imageMimeType: string,
    logger: Logger = this.logger
  ): Promise<{ raw_item_description: string; price_paid_cents: number }[] | null> {
    const prompt = `
      Analyze the provided receipt image. Extract all purchased line items.
      For each item, identify its description and total price.
      Return the data as a valid JSON array of objects. Each object should have two keys:
      1. "raw_item_description": a string containing the item's name as written on the receipt.
      2. "price_paid_cents": an integer representing the total price for that line item in cents (do not include currency symbols).
  
      Example format:
      [
        { "raw_item_description": "ORGANIC BANANAS", "price_paid_cents": 129 },
        { "raw_item_description": "AVOCADO", "price_paid_cents": 299 }
      ]
  
      Only output the JSON array. Do not include any other text, explanations, or markdown formatting.
    `;

    const imagePart = await this.serverFileToGenerativePart(imagePath, imageMimeType);

    try {
      // Wrap the AI call with the rate limiter.
      const result = await this.rateLimiter(() => 
        this.aiClient.generateContent({
          contents: [{ parts: [{text: prompt}, imagePart] }]
        }));
      // The response from the SDK is structured, we need to access the text part.
      const text = result.text;
      const parsedJson = this._parseJsonFromAiResponse<{ raw_item_description: string; price_paid_cents: number }[]>(text, logger);

      if (!parsedJson) {
        throw new Error('AI response did not contain a valid JSON array.');
      }
      return parsedJson;
    } catch (apiError) {
      logger.error({ err: apiError }, "Google GenAI API call failed in extractItemsFromReceiptImage");
      throw apiError;
    }
  }

  async extractCoreDataFromFlyerImage(
    imagePaths: { path: string; mimetype: string }[],
    masterItems: MasterGroceryItem[],
    submitterIp?: string,
    userProfileAddress?: string,
    logger: Logger = this.logger
  ): Promise<{
      store_name: string;
      valid_from: string | null;
      valid_to: string | null;
      store_address: string | null;
      items: ExtractedFlyerItem[];
  }> {
      const prompt = this._buildFlyerExtractionPrompt(masterItems, submitterIp, userProfileAddress);

      const imageParts = await Promise.all(
          imagePaths.map(file => this.serverFileToGenerativePart(file.path, file.mimetype))
      );

      const totalImageSize = imageParts.reduce((acc, part) => acc + part.inlineData.data.length, 0);
      logger.info(`[aiService.server] Total base64 image data size for Gemini: ${(totalImageSize / (1024 * 1024)).toFixed(2)} MB`);
      
      try {
         logger.debug(`[aiService.server] Calling Gemini API for flyer processing with ${imageParts.length} image(s).`);
          const geminiCallStartTime = process.hrtime.bigint();
        
        // Wrap the AI call with the rate limiter.
        const result = await this.rateLimiter(() => 
          this.aiClient.generateContent({
            contents: [{ parts: [{ text: prompt }, ...imageParts] }]
          }));

        const geminiCallEndTime = process.hrtime.bigint();
        const durationMs = Number(geminiCallEndTime - geminiCallStartTime) / 1_000_000; // Corrected variable name
        logger.info(`[aiService.server] Gemini API call for flyer processing completed in ${durationMs.toFixed(2)} ms.`);

        const text = result.text;

        logger.debug(`[aiService.server] Raw Gemini response text (first 500 chars): ${text?.substring(0, 500)}`);
        
        const extractedData = this._parseJsonFromAiResponse<z.infer<typeof AiFlyerDataSchema>>(text, logger);

        if (!extractedData) {
          logger.error({ responseText: text }, "AI response for flyer processing did not contain a valid JSON object.");
          throw new Error('AI response did not contain a valid JSON object.');
        }

        // Normalize the items to create a clean data structure.
        const normalizedItems = Array.isArray(extractedData.items)
            ? this._normalizeExtractedItems(extractedData.items)
            : [];

        // Return a new, correctly typed object, rather than mutating the original.
        // This makes the data flow explicit and satisfies TypeScript.
        return { ...extractedData, items: normalizedItems };
    } catch (apiError) {
        logger.error({ err: apiError }, "Google GenAI API call failed in extractCoreDataFromFlyerImage");
        throw apiError;
    }
  }

  /**
   * Normalizes the raw items returned by the AI, ensuring fields are in the correct format.
   * @param items An array of raw flyer items from the AI.
   * @returns A normalized array of flyer items.
   */
  private _normalizeExtractedItems(items: RawFlyerItem[]): ExtractedFlyerItem[] {
    return items.map((item: RawFlyerItem) => ({
      ...item,
      price_display: item.price_display === null || item.price_display === undefined ? "" : String(item.price_display),
      quantity: item.quantity === null || item.quantity === undefined ? "" : String(item.quantity),
      category_name: item.category_name === null || item.category_name === undefined ? "Other/Miscellaneous" : String(item.category_name),
      master_item_id: item.master_item_id ?? undefined,
    }));
  }

  /**
   * SERVER-SIDE FUNCTION
   * Extracts a specific piece of text from a cropped area of an image.
   * @param imagePath The path to the original image file on the server.
   * @param cropArea The coordinates and dimensions { x, y, width, height } to crop.
   * @param extractionType The type of data to extract, which determines the AI prompt.
   * @returns A promise that resolves to the extracted text.
   */
  async extractTextFromImageArea(
    imagePath: string,
    imageMimeType: string,
    cropArea: { x: number; y: number; width: number; height: number },
    extractionType: 'store_name' | 'dates' | 'item_details',
    logger: Logger = this.logger): Promise<{ text: string | undefined }> {
    // 1. Define prompts based on the extraction type
    const prompts = {
      store_name: 'What is the store name in this image? Respond with only the name.',
      dates: 'What are the sale dates in this image? Respond with the date range as text (e.g., "Jan 1 - Jan 7").',
      item_details: 'Extract the item name, price, and quantity from this image. Respond with the text as seen.',
    };

    const prompt = prompts[extractionType] || 'Extract the text from this image.';

    // 2. Crop the image using sharp
    const sharp = (await import('sharp')).default;
    const croppedImageBuffer = await sharp(imagePath)
      .extract({
        left: Math.round(cropArea.x),
        top: Math.round(cropArea.y),
        width: Math.round(cropArea.width),
        height: Math.round(cropArea.height),
      })
      .toBuffer();

    // 3. Convert cropped buffer to GenerativePart
    const imagePart = {
      inlineData: {
        data: croppedImageBuffer.toString('base64'),
        mimeType: imageMimeType,
      },
    };

    // 4. Call the AI model
    try {
      logger.info(`[aiService.server] Calling Gemini for targeted rescan of type: ${extractionType}`);
      // Wrap the AI call with the rate limiter.
      const result = await this.rateLimiter(() => 
        this.aiClient.generateContent({
          contents: [{ parts: [{ text: prompt }, imagePart] }]
        }));

      const text = result.text?.trim();
      logger.info(`[aiService.server] Gemini rescan completed. Extracted text: "${text}"`);
      return { text };
    } catch (apiError) {
      logger.error({ err: apiError }, `Google GenAI API call failed in extractTextFromImageArea for type ${extractionType}`);
      throw apiError;
    }
  }

  /**
   * SERVER-SIDE FUNCTION
   * Uses Google Maps grounding to find nearby stores and plan a shopping trip.
   * @param items The items from the flyer.
   * @param store The store associated with the flyer.
   * @param userLocation The user's current geographic coordinates.
   * @returns A text response with trip planning advice and a list of map sources.
   */
  async planTripWithMaps(items: FlyerItem[], store: { name: string } | undefined, userLocation: GeolocationCoordinates, logger: Logger = this.logger): Promise<{text: string; sources: { uri: string; title: string; }[]}> {
      const topItems = items.slice(0, 5).map(i => i.item).join(', ');
      const storeName = store?.name || 'the grocery store';
      
      try {
          // Wrap the AI call with the rate limiter.
          const result = await this.rateLimiter(() => this.aiClient.generateContent({
              contents: [{ parts: [{ text: `My current location is latitude ${userLocation.latitude}, longitude ${userLocation.longitude}.
              I have a shopping list with items like ${topItems}. Find the nearest ${storeName} to me and suggest the best route.
              Also, are there any other specialty stores nearby (like a bakery or butcher) that might have good deals on related items?`}]}],
               tools: [{ "googleSearch": {} }],
          }));
  
          // In a real implementation, you would render the map URLs from the sources.
          // The new SDK provides the search queries used, not a direct list of web attributions.
          // We will transform these queries into searchable links to fulfill the contract of the function.
          const searchQueries = result.candidates?.[0]?.groundingMetadata?.webSearchQueries || [];
          const sources = searchQueries.map((query: string) => ({
              uri: `https://www.google.com/search?q=${encodeURIComponent(query)}`,
              title: query
          }));

          return { text: result.text ?? '', sources };
        } catch (apiError) {
          logger.error({ err: apiError }, "Google GenAI API call failed in planTripWithMaps");
          throw apiError;
      }
  
      // Return a 501 Not Implemented error as this feature is disabled.
      throw new Error("The 'planTripWithMaps' feature is currently disabled due to API costs.");
    }
}

// Export a singleton instance of the service for use throughout the application.
import { logger } from './logger.server';
export const aiService = new AIService(logger);