import { Injectable, NotFoundException, ConflictException, Logger } from "@nestjs/common";
import { EntryStatus, Prisma } from "@prisma/client";
import slugify from "slugify";
import { PrismaService } from "../prisma/prisma.service";
import type { CreateEntryDto, UpdateEntryDto, EntryQueryDto } from "./dto";
import type { KnowledgeEntryWithTags, PaginatedEntries } from "./entities/knowledge-entry.entity";
import type {
  KnowledgeEntryVersionWithAuthor,
  PaginatedVersions,
} from "./entities/knowledge-entry-version.entity";
import { renderMarkdown } from "./utils/markdown";
import { LinkSyncService } from "./services/link-sync.service";
import { KnowledgeCacheService } from "./services/cache.service";
import { EmbeddingService } from "./services/embedding.service";
import { OllamaEmbeddingService } from "./services/ollama-embedding.service";
import { EmbeddingQueueService } from "./queues/embedding-queue.service";

/**
 * Service for managing knowledge entries
 */
@Injectable()
export class KnowledgeService {
  private readonly logger = new Logger(KnowledgeService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly linkSync: LinkSyncService,
    private readonly cache: KnowledgeCacheService,
    private readonly embedding: EmbeddingService,
    private readonly ollamaEmbedding: OllamaEmbeddingService,
    private readonly embeddingQueue: EmbeddingQueueService
  ) {}

  /**
   * Get all entries for a workspace (paginated and filterable)
   */
  async findAll(workspaceId: string, query: EntryQueryDto): Promise<PaginatedEntries> {
    const page = query.page ?? 1;
    const limit = query.limit ?? 20;
    const skip = (page - 1) * limit;

    // Build where clause
    const where: Prisma.KnowledgeEntryWhereInput = {
      workspaceId,
    };

    if (query.status) {
      where.status = query.status;
    }

    if (query.visibility) {
      where.visibility = query.visibility;
    }

    if (query.tag) {
      where.tags = {
        some: {
          tag: {
            slug: query.tag,
          },
        },
      };
    }

    if (query.search) {
      where.OR = [
        { title: { contains: query.search, mode: "insensitive" } },
        { content: { contains: query.search, mode: "insensitive" } },
      ];
    }

    // Build orderBy
    const sortField = query.sortBy ?? "updatedAt";
    const sortDirection = query.sortOrder ?? "desc";
    const orderBy: Prisma.KnowledgeEntryOrderByWithRelationInput = {
      [sortField]: sortDirection,
    };

    // Get total count
    const total = await this.prisma.knowledgeEntry.count({ where });

    // Get entries
    const entries = await this.prisma.knowledgeEntry.findMany({
      where,
      include: {
        tags: {
          include: {
            tag: true,
          },
        },
      },
      orderBy,
      skip,
      take: limit,
    });

    // Transform to response format
    const data: KnowledgeEntryWithTags[] = entries.map((entry) => ({
      id: entry.id,
      workspaceId: entry.workspaceId,
      slug: entry.slug,
      title: entry.title,
      content: entry.content,
      contentHtml: entry.contentHtml,
      summary: entry.summary,
      status: entry.status,
      visibility: entry.visibility,
      createdAt: entry.createdAt,
      updatedAt: entry.updatedAt,
      createdBy: entry.createdBy,
      updatedBy: entry.updatedBy,
      tags: entry.tags.map((et) => ({
        id: et.tag.id,
        name: et.tag.name,
        slug: et.tag.slug,
        color: et.tag.color,
      })),
    }));

    return {
      data,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit),
      },
    };
  }

  /**
   * Get a single entry by slug
   */
  async findOne(workspaceId: string, slug: string): Promise<KnowledgeEntryWithTags> {
    // Check cache first
    const cached = await this.cache.getEntry<KnowledgeEntryWithTags>(workspaceId, slug);
    if (cached) {
      return cached;
    }

    // Cache miss - fetch from database
    const entry = await this.prisma.knowledgeEntry.findUnique({
      where: {
        workspaceId_slug: {
          workspaceId,
          slug,
        },
      },
      include: {
        tags: {
          include: {
            tag: true,
          },
        },
      },
    });

    if (!entry) {
      throw new NotFoundException(`Knowledge entry with slug "${slug}" not found`);
    }

    const result: KnowledgeEntryWithTags = {
      id: entry.id,
      workspaceId: entry.workspaceId,
      slug: entry.slug,
      title: entry.title,
      content: entry.content,
      contentHtml: entry.contentHtml,
      summary: entry.summary,
      status: entry.status,
      visibility: entry.visibility,
      createdAt: entry.createdAt,
      updatedAt: entry.updatedAt,
      createdBy: entry.createdBy,
      updatedBy: entry.updatedBy,
      tags: entry.tags.map((et) => ({
        id: et.tag.id,
        name: et.tag.name,
        slug: et.tag.slug,
        color: et.tag.color,
      })),
    };

    // Populate cache
    await this.cache.setEntry(workspaceId, slug, result);

    return result;
  }

  /**
   * Create a new entry
   */
  async create(
    workspaceId: string,
    userId: string,
    createDto: CreateEntryDto
  ): Promise<KnowledgeEntryWithTags> {
    // Generate slug from title
    const baseSlug = this.generateSlug(createDto.title);
    const slug = await this.ensureUniqueSlug(workspaceId, baseSlug);

    // Render markdown to HTML with sanitization
    const contentHtml = await renderMarkdown(createDto.content);

    // Use transaction to ensure atomicity
    const result = await this.prisma.$transaction(async (tx) => {
      // Create entry
      const entry = await tx.knowledgeEntry.create({
        data: {
          workspaceId,
          slug,
          title: createDto.title,
          content: createDto.content,
          contentHtml,
          summary: createDto.summary ?? null,
          status: createDto.status ?? EntryStatus.DRAFT,
          visibility: createDto.visibility ?? "PRIVATE",
          createdBy: userId,
          updatedBy: userId,
        },
      });

      // Create initial version
      await tx.knowledgeEntryVersion.create({
        data: {
          entryId: entry.id,
          version: 1,
          title: entry.title,
          content: entry.content,
          summary: entry.summary,
          createdBy: userId,
          changeNote: createDto.changeNote ?? "Initial version",
        },
      });

      // Handle tags if provided
      if (createDto.tags && createDto.tags.length > 0) {
        await this.syncTags(tx, workspaceId, entry.id, createDto.tags);
      }

      // Fetch with tags
      return tx.knowledgeEntry.findUnique({
        where: { id: entry.id },
        include: {
          tags: {
            include: {
              tag: true,
            },
          },
        },
      });
    });

    if (!result) {
      throw new Error("Failed to create entry");
    }

    // Sync wiki links after entry creation
    await this.linkSync.syncLinks(workspaceId, result.id, createDto.content);

    // Generate and store embedding asynchronously (don't block the response)
    this.generateEntryEmbedding(result.id, result.title, result.content).catch((error: unknown) => {
      this.logger.warn(`Failed to generate embedding for entry - embedding will be missing`, {
        entryId: result.id,
        workspaceId,
        error: error instanceof Error ? error.message : String(error),
      });
    });

    // Invalidate search and graph caches (new entry affects search results)
    await this.cache.invalidateSearches(workspaceId);
    await this.cache.invalidateGraphs(workspaceId);

    return {
      id: result.id,
      workspaceId: result.workspaceId,
      slug: result.slug,
      title: result.title,
      content: result.content,
      contentHtml: result.contentHtml,
      summary: result.summary,
      status: result.status,
      visibility: result.visibility,
      createdAt: result.createdAt,
      updatedAt: result.updatedAt,
      createdBy: result.createdBy,
      updatedBy: result.updatedBy,
      tags: result.tags.map((et) => ({
        id: et.tag.id,
        name: et.tag.name,
        slug: et.tag.slug,
        color: et.tag.color,
      })),
    };
  }

  /**
   * Update an entry
   */
  async update(
    workspaceId: string,
    slug: string,
    userId: string,
    updateDto: UpdateEntryDto
  ): Promise<KnowledgeEntryWithTags> {
    // Find existing entry
    const existing = await this.prisma.knowledgeEntry.findUnique({
      where: {
        workspaceId_slug: {
          workspaceId,
          slug,
        },
      },
      include: {
        versions: {
          orderBy: {
            version: "desc",
          },
          take: 1,
        },
      },
    });

    if (!existing) {
      throw new NotFoundException(`Knowledge entry with slug "${slug}" not found`);
    }

    // If title is being updated, generate new slug if needed
    let newSlug = slug;
    if (updateDto.title && updateDto.title !== existing.title) {
      const baseSlug = this.generateSlug(updateDto.title);
      if (baseSlug !== slug) {
        newSlug = await this.ensureUniqueSlug(workspaceId, baseSlug, slug);
      }
    }

    // Render markdown if content is updated
    let contentHtml = existing.contentHtml;
    if (updateDto.content) {
      contentHtml = await renderMarkdown(updateDto.content);
    }

    // Build update data object conditionally
    const updateData: Prisma.KnowledgeEntryUpdateInput = {
      updatedBy: userId,
    };

    if (newSlug !== slug) {
      updateData.slug = newSlug;
    }
    if (updateDto.title !== undefined) {
      updateData.title = updateDto.title;
    }
    if (updateDto.content !== undefined) {
      updateData.content = updateDto.content;
      updateData.contentHtml = contentHtml;
    }
    if (updateDto.summary !== undefined) {
      updateData.summary = updateDto.summary ?? null;
    }
    if (updateDto.status !== undefined) {
      updateData.status = updateDto.status;
    }
    if (updateDto.visibility !== undefined) {
      updateData.visibility = updateDto.visibility;
    }

    // Use transaction to ensure atomicity
    const result = await this.prisma.$transaction(async (tx) => {
      // Update entry
      const entry = await tx.knowledgeEntry.update({
        where: {
          workspaceId_slug: {
            workspaceId,
            slug,
          },
        },
        data: updateData,
      });

      // Create new version if content or title changed
      if (updateDto.title || updateDto.content) {
        const latestVersion = existing.versions[0];
        const nextVersion = latestVersion ? latestVersion.version + 1 : 1;

        await tx.knowledgeEntryVersion.create({
          data: {
            entryId: entry.id,
            version: nextVersion,
            title: entry.title,
            content: entry.content,
            summary: entry.summary,
            createdBy: userId,
            changeNote: updateDto.changeNote ?? `Update version ${nextVersion.toString()}`,
          },
        });
      }

      // Handle tags if provided
      if (updateDto.tags !== undefined) {
        await this.syncTags(tx, workspaceId, entry.id, updateDto.tags);
      }

      // Fetch with tags
      return tx.knowledgeEntry.findUnique({
        where: { id: entry.id },
        include: {
          tags: {
            include: {
              tag: true,
            },
          },
        },
      });
    });

    if (!result) {
      throw new Error("Failed to update entry");
    }

    // Sync wiki links after entry update (only if content changed)
    if (updateDto.content !== undefined) {
      await this.linkSync.syncLinks(workspaceId, result.id, result.content);
    }

    // Regenerate embedding if content or title changed (async, don't block response)
    if (updateDto.content !== undefined || updateDto.title !== undefined) {
      this.generateEntryEmbedding(result.id, result.title, result.content).catch(
        (error: unknown) => {
          this.logger.warn(`Failed to generate embedding for entry - embedding will be missing`, {
            entryId: result.id,
            workspaceId,
            error: error instanceof Error ? error.message : String(error),
          });
        }
      );
    }

    // Invalidate caches
    // Invalidate old slug cache if slug changed
    if (newSlug !== slug) {
      await this.cache.invalidateEntry(workspaceId, slug);
    }
    // Invalidate new slug cache
    await this.cache.invalidateEntry(workspaceId, result.slug);
    // Invalidate search caches (content/title/tags may have changed)
    await this.cache.invalidateSearches(workspaceId);
    // Invalidate graph caches if links changed
    if (updateDto.content !== undefined) {
      await this.cache.invalidateGraphsForEntry(workspaceId, result.id);
    }

    return {
      id: result.id,
      workspaceId: result.workspaceId,
      slug: result.slug,
      title: result.title,
      content: result.content,
      contentHtml: result.contentHtml,
      summary: result.summary,
      status: result.status,
      visibility: result.visibility,
      createdAt: result.createdAt,
      updatedAt: result.updatedAt,
      createdBy: result.createdBy,
      updatedBy: result.updatedBy,
      tags: result.tags.map((et) => ({
        id: et.tag.id,
        name: et.tag.name,
        slug: et.tag.slug,
        color: et.tag.color,
      })),
    };
  }

  /**
   * Delete an entry (soft delete by setting status to ARCHIVED)
   */
  async remove(workspaceId: string, slug: string, userId: string): Promise<void> {
    const entry = await this.prisma.knowledgeEntry.findUnique({
      where: {
        workspaceId_slug: {
          workspaceId,
          slug,
        },
      },
    });

    if (!entry) {
      throw new NotFoundException(`Knowledge entry with slug "${slug}" not found`);
    }

    await this.prisma.knowledgeEntry.update({
      where: {
        workspaceId_slug: {
          workspaceId,
          slug,
        },
      },
      data: {
        status: EntryStatus.ARCHIVED,
        updatedBy: userId,
      },
    });

    // Invalidate caches
    await this.cache.invalidateEntry(workspaceId, slug);
    await this.cache.invalidateSearches(workspaceId);
    await this.cache.invalidateGraphsForEntry(workspaceId, entry.id);
  }

  /**
   * Generate a URL-friendly slug from a title
   */
  private generateSlug(title: string): string {
    return slugify(title, {
      lower: true,
      strict: true,
      trim: true,
    });
  }

  /**
   * Ensure slug is unique by appending a number if needed
   */
  private async ensureUniqueSlug(
    workspaceId: string,
    baseSlug: string,
    currentSlug?: string
  ): Promise<string> {
    let slug = baseSlug;
    let counter = 1;

    // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
    while (true) {
      // Check if slug exists (excluding current entry if updating)
      const existing = await this.prisma.knowledgeEntry.findUnique({
        where: {
          workspaceId_slug: {
            workspaceId,
            slug,
          },
        },
      });

      // Slug is available
      if (!existing) {
        return slug;
      }

      // If this is the current entry being updated, keep the slug
      if (currentSlug && existing.slug === currentSlug) {
        return slug;
      }

      // Try next variation
      slug = `${baseSlug}-${counter.toString()}`;
      counter++;

      // Safety limit to prevent infinite loops
      if (counter > 1000) {
        throw new ConflictException("Unable to generate unique slug after 1000 attempts");
      }
    }
  }

  /**
   * Get all versions for an entry (paginated)
   */
  async findVersions(
    workspaceId: string,
    slug: string,
    page = 1,
    limit = 20
  ): Promise<PaginatedVersions> {
    // Find the entry to get its ID
    const entry = await this.prisma.knowledgeEntry.findUnique({
      where: {
        workspaceId_slug: {
          workspaceId,
          slug,
        },
      },
    });

    if (!entry) {
      throw new NotFoundException(`Knowledge entry with slug "${slug}" not found`);
    }

    const skip = (page - 1) * limit;

    // Get total count
    const total = await this.prisma.knowledgeEntryVersion.count({
      where: { entryId: entry.id },
    });

    // Get versions with author information
    const versions = await this.prisma.knowledgeEntryVersion.findMany({
      where: { entryId: entry.id },
      include: {
        author: {
          select: {
            id: true,
            name: true,
            email: true,
          },
        },
      },
      orderBy: {
        version: "desc",
      },
      skip,
      take: limit,
    });

    // Transform to response format
    const data: KnowledgeEntryVersionWithAuthor[] = versions.map((v) => ({
      id: v.id,
      entryId: v.entryId,
      version: v.version,
      title: v.title,
      content: v.content,
      summary: v.summary,
      createdAt: v.createdAt,
      createdBy: v.createdBy,
      changeNote: v.changeNote,
      author: v.author,
    }));

    return {
      data,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit),
      },
    };
  }

  /**
   * Get a specific version of an entry
   */
  async findVersion(
    workspaceId: string,
    slug: string,
    version: number
  ): Promise<KnowledgeEntryVersionWithAuthor> {
    // Find the entry to get its ID
    const entry = await this.prisma.knowledgeEntry.findUnique({
      where: {
        workspaceId_slug: {
          workspaceId,
          slug,
        },
      },
    });

    if (!entry) {
      throw new NotFoundException(`Knowledge entry with slug "${slug}" not found`);
    }

    // Get the specific version
    const versionData = await this.prisma.knowledgeEntryVersion.findUnique({
      where: {
        entryId_version: {
          entryId: entry.id,
          version,
        },
      },
      include: {
        author: {
          select: {
            id: true,
            name: true,
            email: true,
          },
        },
      },
    });

    if (!versionData) {
      throw new NotFoundException(`Version ${version.toString()} not found for entry "${slug}"`);
    }

    return {
      id: versionData.id,
      entryId: versionData.entryId,
      version: versionData.version,
      title: versionData.title,
      content: versionData.content,
      summary: versionData.summary,
      createdAt: versionData.createdAt,
      createdBy: versionData.createdBy,
      changeNote: versionData.changeNote,
      author: versionData.author,
    };
  }

  /**
   * Restore a previous version of an entry
   */
  async restoreVersion(
    workspaceId: string,
    slug: string,
    version: number,
    userId: string,
    changeNote?: string
  ): Promise<KnowledgeEntryWithTags> {
    // Get the version to restore
    const versionToRestore = await this.findVersion(workspaceId, slug, version);

    // Find the current entry
    const entry = await this.prisma.knowledgeEntry.findUnique({
      where: {
        workspaceId_slug: {
          workspaceId,
          slug,
        },
      },
      include: {
        versions: {
          orderBy: {
            version: "desc",
          },
          take: 1,
        },
      },
    });

    if (!entry) {
      throw new NotFoundException(`Knowledge entry with slug "${slug}" not found`);
    }

    // Render markdown for the restored content
    const contentHtml = await renderMarkdown(versionToRestore.content);

    // Use transaction to ensure atomicity
    const result = await this.prisma.$transaction(async (tx) => {
      // Update entry with restored content
      const updated = await tx.knowledgeEntry.update({
        where: {
          workspaceId_slug: {
            workspaceId,
            slug,
          },
        },
        data: {
          title: versionToRestore.title,
          content: versionToRestore.content,
          contentHtml,
          summary: versionToRestore.summary,
          updatedBy: userId,
        },
      });

      // Create new version for the restore operation
      const latestVersion = entry.versions[0];
      const nextVersion = latestVersion ? latestVersion.version + 1 : 1;

      await tx.knowledgeEntryVersion.create({
        data: {
          entryId: updated.id,
          version: nextVersion,
          title: updated.title,
          content: updated.content,
          summary: updated.summary,
          createdBy: userId,
          changeNote: changeNote ?? `Restored from version ${version.toString()}`,
        },
      });

      // Fetch with tags
      return tx.knowledgeEntry.findUnique({
        where: { id: updated.id },
        include: {
          tags: {
            include: {
              tag: true,
            },
          },
        },
      });
    });

    if (!result) {
      throw new Error("Failed to restore version");
    }

    // Sync wiki links after restore
    await this.linkSync.syncLinks(workspaceId, result.id, result.content);

    // Invalidate caches (content changed, links may have changed)
    await this.cache.invalidateEntry(workspaceId, slug);
    await this.cache.invalidateSearches(workspaceId);
    await this.cache.invalidateGraphsForEntry(workspaceId, result.id);

    return {
      id: result.id,
      workspaceId: result.workspaceId,
      slug: result.slug,
      title: result.title,
      content: result.content,
      contentHtml: result.contentHtml,
      summary: result.summary,
      status: result.status,
      visibility: result.visibility,
      createdAt: result.createdAt,
      updatedAt: result.updatedAt,
      createdBy: result.createdBy,
      updatedBy: result.updatedBy,
      tags: result.tags.map((et) => ({
        id: et.tag.id,
        name: et.tag.name,
        slug: et.tag.slug,
        color: et.tag.color,
      })),
    };
  }

  /**
   * Sync tags for an entry (create missing tags, update associations)
   */
  private async syncTags(
    tx: Prisma.TransactionClient,
    workspaceId: string,
    entryId: string,
    tagNames: string[]
  ): Promise<void> {
    // Remove all existing tag associations
    await tx.knowledgeEntryTag.deleteMany({
      where: { entryId },
    });

    // If no tags provided, we're done
    if (tagNames.length === 0) {
      return;
    }

    // Build slug map: slug -> original tag name
    const slugToName = new Map<string, string>();
    for (const name of tagNames) {
      slugToName.set(this.generateSlug(name), name);
    }
    const tagSlugs = [...slugToName.keys()];

    // Batch fetch all existing tags in a single query (fixes N+1)
    const existingTags = await tx.knowledgeTag.findMany({
      where: {
        workspaceId,
        slug: { in: tagSlugs },
      },
    });

    // Determine which tags need to be created
    const existingSlugs = new Set(existingTags.map((t) => t.slug));
    const missingSlugs = tagSlugs.filter((s) => !existingSlugs.has(s));

    // Create missing tags
    const newTags = await Promise.all(
      missingSlugs.map((slug) => {
        const name = slugToName.get(slug) ?? slug;
        return tx.knowledgeTag.create({
          data: {
            workspaceId,
            name,
            slug,
          },
        });
      })
    );

    const allTags = [...existingTags, ...newTags];

    // Create tag associations in a single batch
    await tx.knowledgeEntryTag.createMany({
      data: allTags.map((tag) => ({
        entryId,
        tagId: tag.id,
      })),
    });
  }

  /**
   * Generate and store embedding for a knowledge entry
   * Private helper method called asynchronously after entry create/update
   * Queues the embedding generation job instead of processing synchronously
   */
  private async generateEntryEmbedding(
    entryId: string,
    title: string,
    content: string
  ): Promise<void> {
    const combinedContent = this.ollamaEmbedding.prepareContentForEmbedding(title, content);

    try {
      const jobId = await this.embeddingQueue.queueEmbeddingJob(entryId, combinedContent);
      this.logger.log(`Queued embedding job ${jobId} for entry ${entryId}`);
    } catch (error) {
      this.logger.error(`Failed to queue embedding job for entry ${entryId}`, error);
      throw error;
    }
  }

  /**
   * Batch generate embeddings for all entries in a workspace
   * Useful for populating embeddings for existing entries
   *
   * @param workspaceId - The workspace ID
   * @param status - Optional status filter (default: not ARCHIVED)
   * @returns Number of embeddings successfully generated
   */
  async batchGenerateEmbeddings(
    workspaceId: string,
    status?: EntryStatus
  ): Promise<{ total: number; success: number }> {
    const where: Prisma.KnowledgeEntryWhereInput = {
      workspaceId,
      status: status ?? { not: EntryStatus.ARCHIVED },
    };

    const entries = await this.prisma.knowledgeEntry.findMany({
      where,
      select: {
        id: true,
        title: true,
        content: true,
      },
    });

    const entriesForEmbedding = entries.map((entry) => ({
      id: entry.id,
      content: this.embedding.prepareContentForEmbedding(entry.title, entry.content),
    }));

    const successCount = await this.embedding.batchGenerateEmbeddings(entriesForEmbedding);

    return {
      total: entries.length,
      success: successCount,
    };
  }
}
