import { randomUUID } from 'node:crypto';

import { Injectable, Logger } from '@nestjs/common';
import { AiPromptRole, PrismaClient } from '@prisma/client';

import { FeatureManagementService } from '../../core/features';
import { QuotaService } from '../../core/quota';
import { PaymentRequiredException } from '../../fundamentals';
import { ChatMessageCache } from './message';
import { PromptService } from './prompt';
import {
  AvailableModel,
  ChatHistory,
  ChatMessage,
  ChatMessageSchema,
  ChatSessionOptions,
  ChatSessionState,
  getTokenEncoder,
  ListHistoriesOptions,
  PromptMessage,
  PromptParams,
  SubmittedMessage,
} from './types';

export class ChatSession implements AsyncDisposable {
  private stashMessageCount = 0;
  constructor(
    private readonly messageCache: ChatMessageCache,
    private readonly state: ChatSessionState,
    private readonly dispose?: (state: ChatSessionState) => Promise<void>,
    private readonly maxTokenSize = 3840
  ) {}

  get model() {
    return this.state.prompt.model;
  }

  get config() {
    const {
      sessionId,
      userId,
      workspaceId,
      docId,
      prompt: { name: promptName },
    } = this.state;

    return { sessionId, userId, workspaceId, docId, promptName };
  }

  get stashMessages() {
    if (!this.stashMessageCount) return [];
    return this.state.messages.slice(-this.stashMessageCount);
  }

  push(message: ChatMessage) {
    if (
      this.state.prompt.action &&
      this.state.messages.length > 0 &&
      message.role === 'user'
    ) {
      throw new Error('Action has been taken, no more messages allowed');
    }
    this.state.messages.push(message);
    this.stashMessageCount += 1;
  }

  revertLatestMessage() {
    const messages = this.state.messages;
    messages.splice(
      messages.findLastIndex(({ role }) => role === AiPromptRole.user) + 1
    );
  }

  async getMessageById(messageId: string) {
    const message = await this.messageCache.get(messageId);
    if (!message || message.sessionId !== this.state.sessionId) {
      throw new Error(`Message not found: ${messageId}`);
    }
    return message;
  }

  async pushByMessageId(messageId: string) {
    const message = await this.messageCache.get(messageId);
    if (!message || message.sessionId !== this.state.sessionId) {
      throw new Error(`Message not found: ${messageId}`);
    }

    this.push({
      role: 'user',
      content: message.content || '',
      attachments: message.attachments,
      params: message.params,
      createdAt: new Date(),
    });
  }

  pop() {
    return this.state.messages.pop();
  }

  private takeMessages(): ChatMessage[] {
    if (this.state.prompt.action) {
      const messages = this.state.messages;
      return messages.slice(messages.length - 1);
    }
    const ret = [];
    const messages = this.state.messages.slice();

    let size = this.state.prompt.tokens;
    while (messages.length) {
      const message = messages.pop();
      if (!message) break;

      size += this.state.prompt.encode(message.content);
      if (size > this.maxTokenSize) {
        break;
      }
      ret.push(message);
    }
    ret.reverse();

    return ret;
  }

  finish(params: PromptParams): PromptMessage[] {
    const messages = this.takeMessages();
    const firstMessage = messages.at(0);
    // if the message in prompt config contains {{content}},
    // we should combine it with the user message in the prompt
    if (
      messages.length === 1 &&
      firstMessage &&
      this.state.prompt.paramKeys.includes('content')
    ) {
      const normalizedParams = {
        ...params,
        ...firstMessage.params,
        content: firstMessage.content,
      };
      const finished = this.state.prompt.finish(
        normalizedParams,
        this.config.sessionId
      );
      finished[0].attachments = firstMessage.attachments;
      return finished;
    }

    return [
      ...this.state.prompt.finish(
        Object.keys(params).length ? params : firstMessage?.params || {},
        this.config.sessionId
      ),
      ...messages.filter(m => m.content?.trim() || m.attachments?.length),
    ];
  }

  async save() {
    await this.dispose?.({
      ...this.state,
      // only provide new messages
      messages: this.stashMessages,
    });
    this.stashMessageCount = 0;
  }

  async [Symbol.asyncDispose]() {
    await this.save?.();
  }
}

@Injectable()
export class ChatSessionService {
  private readonly logger = new Logger(ChatSessionService.name);

  constructor(
    private readonly db: PrismaClient,
    private readonly feature: FeatureManagementService,
    private readonly quota: QuotaService,
    private readonly messageCache: ChatMessageCache,
    private readonly prompt: PromptService
  ) {}

  private async setSession(state: ChatSessionState): Promise<string> {
    return await this.db.$transaction(async tx => {
      let sessionId = state.sessionId;

      // find existing session if session is chat session
      if (!state.prompt.action) {
        const { id, deletedAt } =
          (await tx.aiSession.findFirst({
            where: {
              userId: state.userId,
              workspaceId: state.workspaceId,
              docId: state.docId,
              prompt: { action: { equals: null } },
            },
            select: { id: true, deletedAt: true },
          })) || {};
        if (deletedAt) throw new Error(`Session is deleted: ${id}`);
        if (id) sessionId = id;
      }

      const haveSession = await tx.aiSession
        .count({
          where: {
            id: sessionId,
            userId: state.userId,
          },
        })
        .then(c => c > 0);

      if (haveSession) {
        // message will only exists when setSession call by session.save
        if (state.messages.length) {
          await tx.aiSessionMessage.createMany({
            data: state.messages.map(m => ({
              ...m,
              attachments: m.attachments || undefined,
              params: m.params || undefined,
              sessionId,
            })),
          });

          // only count message generated by user
          const userMessages = state.messages.filter(m => m.role === 'user');
          await tx.aiSession.update({
            where: { id: sessionId },
            data: {
              messageCost: { increment: userMessages.length },
              tokenCost: {
                increment: this.calculateTokenSize(
                  userMessages,
                  state.prompt.model as AvailableModel
                ),
              },
            },
          });
        }
      } else {
        await tx.aiSession.create({
          data: {
            id: sessionId,
            workspaceId: state.workspaceId,
            docId: state.docId,
            // connect
            userId: state.userId,
            promptName: state.prompt.name,
          },
        });
      }

      return sessionId;
    });
  }

  private async getSession(
    sessionId: string
  ): Promise<ChatSessionState | undefined> {
    return await this.db.aiSession
      .findUnique({
        where: { id: sessionId, deletedAt: null },
        select: {
          id: true,
          userId: true,
          workspaceId: true,
          docId: true,
          messages: {
            select: { role: true, content: true, createdAt: true },
            orderBy: { createdAt: 'asc' },
          },
          promptName: true,
        },
      })
      .then(async session => {
        if (!session) return;
        const prompt = await this.prompt.get(session.promptName);
        if (!prompt) throw new Error(`Prompt not found: ${session.promptName}`);

        const messages = ChatMessageSchema.array().safeParse(session.messages);

        return {
          sessionId: session.id,
          userId: session.userId,
          workspaceId: session.workspaceId,
          docId: session.docId,
          prompt,
          messages: messages.success ? messages.data : [],
        };
      });
  }

  // revert the latest messages not generate by user
  // after revert, we can retry the action
  async revertLatestMessage(sessionId: string) {
    await this.db.$transaction(async tx => {
      const id = await tx.aiSession
        .findUnique({
          where: { id: sessionId, deletedAt: null },
          select: { id: true },
        })
        .then(session => session?.id);
      if (!id) {
        throw new Error(`Session not found: ${sessionId}`);
      }
      const ids = await tx.aiSessionMessage
        .findMany({
          where: { sessionId: id },
          select: { id: true, role: true },
          orderBy: { createdAt: 'asc' },
        })
        .then(roles =>
          roles
            .slice(
              roles.findLastIndex(({ role }) => role === AiPromptRole.user) + 1
            )
            .map(({ id }) => id)
        );
      if (ids.length) {
        await tx.aiSessionMessage.deleteMany({ where: { id: { in: ids } } });
      }
    });
  }

  private calculateTokenSize(
    messages: PromptMessage[],
    model: AvailableModel
  ): number {
    const encoder = getTokenEncoder(model);
    return messages
      .map(m => encoder?.count(m.content) ?? 0)
      .reduce((total, length) => total + length, 0);
  }

  private async countUserMessages(userId: string): Promise<number> {
    const sessions = await this.db.aiSession.findMany({
      where: { userId },
      select: { messageCost: true, prompt: { select: { action: true } } },
    });
    return sessions
      .map(({ messageCost, prompt: { action } }) => (action ? 1 : messageCost))
      .reduce((prev, cost) => prev + cost, 0);
  }

  async listSessions(
    userId: string,
    workspaceId: string,
    options?: { docId?: string; action?: boolean }
  ): Promise<string[]> {
    return await this.db.aiSession
      .findMany({
        where: {
          userId,
          workspaceId,
          docId: workspaceId === options?.docId ? undefined : options?.docId,
          prompt: {
            action: options?.action ? { not: null } : null,
          },
          deletedAt: null,
        },
        select: { id: true },
      })
      .then(sessions => sessions.map(({ id }) => id));
  }

  async listHistories(
    userId: string,
    workspaceId?: string,
    docId?: string,
    options?: ListHistoriesOptions,
    withPrompt = false
  ): Promise<ChatHistory[]> {
    return await this.db.aiSession
      .findMany({
        where: {
          userId,
          workspaceId: workspaceId,
          docId: workspaceId === docId ? undefined : docId,
          prompt: {
            action: options?.action ? { not: null } : null,
          },
          id: options?.sessionId ? { equals: options.sessionId } : undefined,
          deletedAt: null,
        },
        select: {
          id: true,
          promptName: true,
          tokenCost: true,
          createdAt: true,
          messages: {
            select: {
              role: true,
              content: true,
              attachments: true,
              params: true,
              createdAt: true,
            },
            orderBy: {
              createdAt: 'asc',
            },
          },
        },
        take: options?.limit,
        skip: options?.skip,
        orderBy: { createdAt: 'desc' },
      })
      .then(sessions =>
        Promise.all(
          sessions.map(
            async ({ id, promptName, tokenCost, messages, createdAt }) => {
              try {
                const ret = ChatMessageSchema.array().safeParse(messages);
                if (ret.success) {
                  const prompt = await this.prompt.get(promptName);
                  if (!prompt) {
                    throw new Error(`Prompt not found: ${promptName}`);
                  }

                  // render system prompt
                  const preload = withPrompt
                    ? prompt
                        .finish(ret.data[0]?.params || {}, id)
                        .filter(({ role }) => role !== 'system')
                    : [];

                  // `createdAt` is required for history sorting in frontend, let's fake the creating time of prompt messages
                  (preload as ChatMessage[]).forEach((msg, i) => {
                    msg.createdAt = new Date(
                      createdAt.getTime() - preload.length - i - 1
                    );
                  });

                  return {
                    sessionId: id,
                    action: prompt.action || undefined,
                    tokens: tokenCost,
                    createdAt,
                    messages: preload.concat(ret.data),
                  };
                } else {
                  this.logger.error(
                    `Unexpected message schema: ${JSON.stringify(ret.error)}`
                  );
                }
              } catch (e) {
                this.logger.error('Unexpected error in listHistories', e);
              }
              return undefined;
            }
          )
        )
      )
      .then(histories =>
        histories.filter((v): v is NonNullable<typeof v> => !!v)
      );
  }

  async getQuota(userId: string) {
    const isCopilotUser = await this.feature.isCopilotUser(userId);

    let limit: number | undefined;
    if (!isCopilotUser) {
      const quota = await this.quota.getUserQuota(userId);
      limit = quota.feature.copilotActionLimit;
    }

    const used = await this.countUserMessages(userId);

    return { limit, used };
  }

  async checkQuota(userId: string) {
    const { limit, used } = await this.getQuota(userId);
    if (limit && Number.isFinite(limit) && used >= limit) {
      throw new PaymentRequiredException(
        `You have reached the limit of actions in this workspace, please upgrade your plan.`
      );
    }
  }

  async create(options: ChatSessionOptions): Promise<string> {
    const sessionId = randomUUID();
    const prompt = await this.prompt.get(options.promptName);
    if (!prompt) {
      this.logger.error(`Prompt not found: ${options.promptName}`);
      throw new Error('Prompt not found');
    }
    return await this.setSession({
      ...options,
      sessionId,
      prompt,
      messages: [],
    });
  }

  async cleanup(
    options: Omit<ChatSessionOptions, 'promptName'> & { sessionIds: string[] }
  ) {
    return await this.db.$transaction(async tx => {
      const sessions = await tx.aiSession.findMany({
        where: {
          id: { in: options.sessionIds },
          userId: options.userId,
          workspaceId: options.workspaceId,
          docId: options.docId,
          deletedAt: null,
        },
        select: { id: true, promptName: true },
      });
      const sessionIds = sessions.map(({ id }) => id);
      // cleanup all messages
      await tx.aiSessionMessage.deleteMany({
        where: { sessionId: { in: sessionIds } },
      });

      // only mark action session as deleted
      // chat session always can be reuse
      {
        const actionIds = (
          await Promise.all(
            sessions.map(({ id, promptName }) =>
              this.prompt
                .get(promptName)
                .then(prompt => ({ id, action: !!prompt?.action }))
            )
          )
        )
          .filter(({ action }) => action)
          .map(({ id }) => id);

        await tx.aiSession.updateMany({
          where: { id: { in: actionIds } },
          data: { deletedAt: new Date() },
        });
      }
    });
  }

  async createMessage(message: SubmittedMessage): Promise<string | undefined> {
    return await this.messageCache.set(message);
  }

  /**
   * usage:
   * ``` typescript
   * {
   *     // allocate a session, can be reused chat in about 12 hours with same session
   *     await using session = await session.get(sessionId);
   *     session.push(message);
   *     copilot.generateText(session.finish(), model);
   * }
   * // session will be disposed after the block
   * @param sessionId session id
   * @returns
   */
  async get(sessionId: string): Promise<ChatSession | null> {
    const state = await this.getSession(sessionId);
    if (state) {
      return new ChatSession(this.messageCache, state, async state => {
        await this.setSession(state);
      });
    }
    return null;
  }
}
