import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../../prisma/prisma.service";
import { OllamaService } from "../../ollama/ollama.service";
import { EMBEDDING_DIMENSION } from "@mosaic/shared";

/**
 * Options for generating embeddings
 */
export interface EmbeddingOptions {
  /**
   * Model to use for embedding generation
   * @default "mxbai-embed-large" (produces 1024-dim vectors, requires padding to 1536)
   * Alternative: Custom fine-tuned model
   */
  model?: string;
}

/**
 * Service for generating and managing embeddings using Ollama
 *
 * This service replaces the OpenAI-based embedding service with Ollama
 * for local/self-hosted embedding generation.
 */
@Injectable()
export class OllamaEmbeddingService {
  private readonly logger = new Logger(OllamaEmbeddingService.name);
  private readonly defaultModel = "mxbai-embed-large";
  private configuredCache: boolean | null = null;

  constructor(
    private readonly prisma: PrismaService,
    private readonly ollama: OllamaService
  ) {}

  /**
   * Check if the service is properly configured
   * Caches the result for performance
   */
  async isConfigured(): Promise<boolean> {
    if (this.configuredCache !== null) {
      return this.configuredCache;
    }

    try {
      const health = await this.ollama.healthCheck();
      this.configuredCache = health.available;
      return health.available;
    } catch {
      this.configuredCache = false;
      return false;
    }
  }

  /**
   * Generate an embedding vector for the given text
   *
   * @param text - Text to embed
   * @param options - Embedding generation options
   * @returns Embedding vector (array of numbers)
   * @throws Error if Ollama service is not available
   */
  async generateEmbedding(text: string, options: EmbeddingOptions = {}): Promise<number[]> {
    const model = options.model ?? this.defaultModel;

    try {
      const response = await this.ollama.embed(text, model);

      if (response.embedding.length === 0) {
        throw new Error("No embedding returned from Ollama");
      }

      // Handle dimension mismatch by padding or truncating
      const embedding = this.normalizeEmbeddingDimension(response.embedding);

      if (embedding.length !== EMBEDDING_DIMENSION) {
        throw new Error(
          `Unexpected embedding dimension: ${embedding.length.toString()} (expected ${EMBEDDING_DIMENSION.toString()})`
        );
      }

      return embedding;
    } catch (error) {
      this.logger.error("Failed to generate embedding", error);
      throw error;
    }
  }

  /**
   * Normalize embedding dimension to match schema requirements
   * Pads with zeros if too short, truncates if too long
   *
   * @param embedding - Original embedding vector
   * @returns Normalized embedding vector with correct dimension
   */
  private normalizeEmbeddingDimension(embedding: number[]): number[] {
    if (embedding.length === EMBEDDING_DIMENSION) {
      return embedding;
    }

    if (embedding.length < EMBEDDING_DIMENSION) {
      // Pad with zeros
      const padded = [...embedding];
      while (padded.length < EMBEDDING_DIMENSION) {
        padded.push(0);
      }
      this.logger.warn(
        `Padded embedding from ${embedding.length.toString()} to ${EMBEDDING_DIMENSION.toString()} dimensions`
      );
      return padded;
    }

    // Truncate if too long
    this.logger.warn(
      `Truncated embedding from ${embedding.length.toString()} to ${EMBEDDING_DIMENSION.toString()} dimensions`
    );
    return embedding.slice(0, EMBEDDING_DIMENSION);
  }

  /**
   * Generate and store embedding for a knowledge entry
   *
   * @param entryId - ID of the knowledge entry
   * @param content - Content to embed (typically title + content)
   * @param options - Embedding generation options
   * @returns Created/updated embedding record
   */
  async generateAndStoreEmbedding(
    entryId: string,
    content: string,
    options: EmbeddingOptions = {}
  ): Promise<void> {
    const configured = await this.isConfigured();
    if (!configured) {
      this.logger.warn(`Skipping embedding generation for entry ${entryId} - Ollama not available`);
      return;
    }

    const model = options.model ?? this.defaultModel;
    const embedding = await this.generateEmbedding(content, { model });

    // Convert to Prisma-compatible format
    const embeddingString = `[${embedding.join(",")}]`;

    // Upsert the embedding
    await this.prisma.$executeRaw`
      INSERT INTO knowledge_embeddings (id, entry_id, embedding, model, created_at, updated_at)
      VALUES (
        gen_random_uuid(),
        ${entryId}::uuid,
        ${embeddingString}::vector(${EMBEDDING_DIMENSION}),
        ${model},
        NOW(),
        NOW()
      )
      ON CONFLICT (entry_id) DO UPDATE SET
        embedding = ${embeddingString}::vector(${EMBEDDING_DIMENSION}),
        model = ${model},
        updated_at = NOW()
    `;

    this.logger.log(`Generated and stored embedding for entry ${entryId} using model ${model}`);
  }

  /**
   * Batch process embeddings for multiple entries
   *
   * @param entries - Array of {id, content} objects
   * @param options - Embedding generation options
   * @returns Number of embeddings successfully generated
   */
  async batchGenerateEmbeddings(
    entries: { id: string; content: string }[],
    options: EmbeddingOptions = {}
  ): Promise<number> {
    const configured = await this.isConfigured();
    if (!configured) {
      this.logger.warn("Skipping batch embedding generation - Ollama not available");
      return 0;
    }

    let successCount = 0;

    for (const entry of entries) {
      try {
        await this.generateAndStoreEmbedding(entry.id, entry.content, options);
        successCount++;
      } catch (error) {
        this.logger.error(`Failed to generate embedding for entry ${entry.id}`, error);
      }
    }

    this.logger.log(
      `Batch generated ${successCount.toString()}/${entries.length.toString()} embeddings`
    );
    return successCount;
  }

  /**
   * Delete embedding for a knowledge entry
   *
   * @param entryId - ID of the knowledge entry
   */
  async deleteEmbedding(entryId: string): Promise<void> {
    await this.prisma.knowledgeEmbedding.deleteMany({
      where: { entryId },
    });

    this.logger.log(`Deleted embedding for entry ${entryId}`);
  }

  /**
   * Check if an entry has an embedding
   *
   * @param entryId - ID of the knowledge entry
   * @returns True if embedding exists
   */
  async hasEmbedding(entryId: string): Promise<boolean> {
    const embedding = await this.prisma.knowledgeEmbedding.findUnique({
      where: { entryId },
      select: { id: true },
    });

    return embedding !== null;
  }

  /**
   * Prepare content for embedding
   * Combines title and content with appropriate weighting
   *
   * @param title - Entry title
   * @param content - Entry content (markdown)
   * @returns Combined text for embedding
   */
  prepareContentForEmbedding(title: string, content: string): string {
    // Weight title more heavily by repeating it
    // This helps with semantic search matching on titles
    return `${title}\n\n${title}\n\n${content}`.trim();
  }
}
