/*
 * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
 *
 * SPDX-License-Identifier: AGPL-3.0-only
 */
import {
  NoteDto,
  NoteMetadataDto,
  NotePermissionsDto,
  SpecialGroup,
} from '@hedgedoc/commons';
import { Inject, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Knex } from 'knex';
import { InjectConnection } from 'nest-knexjs';

import { AliasService } from '../alias/alias.service';
import { DefaultAccessLevel } from '../config/default-access-level.enum';
import noteConfiguration, { NoteConfig } from '../config/note.config';
import {
  FieldNameAlias,
  FieldNameNote,
  Note,
  TableAlias,
  TableNote,
  User,
} from '../database/types';
import {
  ForbiddenIdError,
  GenericDBError,
  MaximumDocumentLengthExceededError,
  NotInDBError,
} from '../errors/errors';
import { NoteEventMap } from '../events';
import { GroupsService } from '../groups/groups.service';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { PermissionService } from '../permissions/permission.service';
import { RealtimeNoteStore } from '../realtime/realtime-note/realtime-note-store';
import { RealtimeNoteService } from '../realtime/realtime-note/realtime-note.service';
import { RevisionsService } from '../revisions/revisions.service';
import { UsersService } from '../users/users.service';
import { getPrimaryAlias } from './utils';

@Injectable()
export class NoteService {
  constructor(
    @InjectConnection()
    private readonly knex: Knex,

    private readonly logger: ConsoleLoggerService,
    @Inject(UsersService) private usersService: UsersService,
    @Inject(GroupsService) private groupsService: GroupsService,
    private revisionsService: RevisionsService,
    @Inject(noteConfiguration.KEY)
    private noteConfig: NoteConfig,
    @Inject(AliasService)
    private aliasService: AliasService,
    @Inject(PermissionService) private permissionService: PermissionService,
    private realtimeNoteService: RealtimeNoteService,
    private realtimeNoteStore: RealtimeNoteStore,
    private eventEmitter: EventEmitter2<NoteEventMap>,
  ) {
    this.logger.setContext(NoteService.name);
  }

  /**
   * Get all notes owned by a user
   *
   * @param userId The id of the user who owns the notes
   * @return Array of notes owned by the user
   */
  async getUserNotes(userId: number): Promise<Note[]> {
    // noinspection ES6RedundantAwait
    return await this.knex(TableNote)
      .select()
      .where(FieldNameNote.ownerId, userId);
  }

  /**
   * Creates a new note
   *
   * @param noteContent The content of the new note, in most cases an empty string
   * @param givenAlias An optional alias the note should have
   * @param ownerUserId The owner of the note
   * @return The newly created note
   * @throws {AlreadyInDBError} a note with the requested id or aliases already exists
   * @throws {ForbiddenIdError} the requested id or aliases is forbidden
   * @throws {MaximumDocumentLengthExceededError} the noteContent is longer than the maxDocumentLength
   * @thorws {GenericDBError} the database returned a non-expected value
   */
  async createNote(
    noteContent: string,
    ownerUserId: number,
    givenAlias?: string,
  ): Promise<number> {
    // Check if new note doesn't violate application constraints
    if (noteContent.length > this.noteConfig.maxDocumentLength) {
      throw new MaximumDocumentLengthExceededError();
    }
    return await this.knex.transaction(async (transaction) => {
      // Create note itself in the database
      const createdNotes = await transaction(TableNote).insert(
        {
          [FieldNameNote.ownerId]: ownerUserId,
          [FieldNameNote.version]: 2,
        },
        [FieldNameNote.id],
      );

      if (createdNotes.length !== 1) {
        throw new GenericDBError(
          'The note could not be created in the database',
          this.logger.getContext(),
          'createNote',
        );
      }

      const noteId = createdNotes[0][FieldNameNote.id];

      if (givenAlias !== undefined) {
        await this.aliasService.ensureAliasIsAvailable(givenAlias, transaction);
      }
      const newAlias =
        givenAlias === undefined
          ? this.aliasService.generateRandomAlias()
          : givenAlias;
      await this.aliasService.addAlias(noteId, newAlias, transaction);

      await this.revisionsService.createRevision(
        noteId,
        noteContent,
        transaction,
      );

      const isUserRegistered = await this.usersService.isRegisteredUser(
        ownerUserId,
        transaction,
      );

      const everyoneAccessLevel = isUserRegistered
        ? // Use the default access level from the config for registered users
          this.noteConfig.permissions.default.everyone
        : // If the owner is a guest, this is an anonymous note
          // Anonymous notes are always writeable by everyone
          DefaultAccessLevel.WRITE;

      const loggedInUsersAccessLevel =
        this.noteConfig.permissions.default.loggedIn;

      await this.permissionService.setGroupPermission(
        noteId,
        SpecialGroup.EVERYONE,
        everyoneAccessLevel,
        transaction,
      );

      await this.permissionService.setGroupPermission(
        noteId,
        SpecialGroup.LOGGED_IN,
        loggedInUsersAccessLevel,
        transaction,
      );

      return noteId;
    });
  }

  /**
   * Get the current content of the note.
   * @param noteId the note to use
   * @throws {NotInDBError} the note is not in the DB
   * @return {string} the content of the note
   */
  async getNoteContent(noteId: Note[FieldNameNote.id]): Promise<string> {
    const realtimeContent = this.realtimeNoteStore
      .find(noteId)
      ?.getRealtimeDoc()
      .getCurrentContent();
    if (realtimeContent) {
      return realtimeContent;
    }

    const latestRevision =
      await this.revisionsService.getLatestRevision(noteId);
    return latestRevision.content;
  }

  /**
   * Get a note by either their id or aliases.
   * @param alias the notes id or aliases
   * @throws {NotInDBError} there is no note with this id or aliases
   * @throws {ForbiddenIdError} the requested id or aliases is forbidden
   * @return the note id
   */
  async getNoteIdByAlias(alias: string, transaction?: Knex): Promise<number> {
    const dbActor = transaction ?? this.knex;
    const isForbidden = this.aliasService.isAliasForbidden(alias);
    if (isForbidden) {
      throw new ForbiddenIdError(
        `The note id or alias '${alias}' is forbidden by the administrator.`,
      );
    }

    this.logger.debug(`Trying to find note '${alias}'`, 'getNoteIdByAlias');

    /**
     * This query gets the note's aliases, owner, groupPermissions (and the groups), userPermissions (and the users) and tags and
     * then only gets the note, that either has a publicId :noteIdOrAlias or has any aliases with this name.
     **/
    const note = await dbActor(TableAlias)
      .select<Pick<Note, FieldNameNote.id>>(`${TableNote}.${FieldNameNote.id}`)
      .where(FieldNameAlias.alias, alias)
      .join(
        TableNote,
        `${TableAlias}.${FieldNameAlias.noteId}`,
        `${TableNote}.${FieldNameNote.id}`,
      )
      .first();

    if (note === undefined) {
      const message = `Could not find note '${alias}'`;
      this.logger.debug(message, 'getNoteIdByAlias');
      throw new NotInDBError(message);
    }
    this.logger.debug(`Found note '${alias}'`, 'getNoteIdByAlias');
    return note[FieldNameNote.id];
  }

  /**
   * Get all users that ever appeared as an author for the given note
   * @param note The note to search authors for
   */
  async getAuthorUsers(note: Note): Promise<User[]> {
    // return await this.userRepository
    //   .createQueryBuilder('user')
    //   .innerJoin('user.authors', 'author')
    //   .innerJoin('author.edits', 'edit')
    //   .innerJoin('edit.revisions', 'revision')
    //   .innerJoin('revision.note', 'note')
    //   .where('note.id = :id', { id: note.id })
    //   .getMany();
    return [];
  }

  /**
   * Deletes a note
   *
   * @param noteId If of the note to delete
   * @throws {NotInDBError} if there is no note with this id
   */
  async deleteNote(noteId: Note[FieldNameNote.id]): Promise<void> {
    const numberOfDeletedNotes = await this.knex(TableNote)
      .where(FieldNameNote.id, noteId)
      .delete();
    if (numberOfDeletedNotes === 0) {
      throw new NotInDBError(
        `There is no note with the id '${noteId}' to delete.`,
      );
    }
  }

  /**
   *
   * Update the content of a note
   *
   * @param noteId - the note
   * @param noteContent - the new content
   * @return the note with a new revision and new content
   * @throws {NotInDBError} there is no note with this id or aliases
   */
  async updateNote(noteId: number, noteContent: string): Promise<void> {
    // TODO Disconnect realtime clients first
    await this.revisionsService.createRevision(noteId, noteContent);
    // TODO Reload realtime note
  }

  /**
   * @async
   * Calculate the updateUser (for the NoteDto) for a Note.
   * @param {Note} noteId - the note to use
   * @return {User} user to be used as updateUser in the NoteDto
   */
  async getLastUpdatedNoteUser(noteId: number): Promise<number> {
    const lastRevision = await this.revisionsService.getLatestRevision(noteId);
    // const edits = await lastRevision.edits;
    // if (edits.length > 0) {
    //   // Sort the last Revisions Edits by their updatedAt Date to get the latest one
    //   // the user of that Edit is the updateUser
    //   return await (
    //     await edits.sort(
    //       (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime(),
    //     )[0].author
    //   ).user;
    // }
    // // If there are no Edits, the owner is the updateUser
    // return await noteId.owner;
    return 0;
  }

  /**
   * Build NotePermissionsDto from a note.
   * @param {Note} note - the note to use
   * @return {NotePermissionsDto} the built NotePermissionDto
   */
  async toNotePermissionsDto(noteId: number): Promise<NotePermissionsDto> {
    const owner = await note.owner;
    const userPermissions = await note.userPermissions;
    const groupPermissions = await note.groupPermissions;
    return {
      owner: owner ? owner.username : null,
      sharedToUsers: await Promise.all(
        userPermissions.map(async (noteUserPermission) => ({
          username: (await noteUserPermission.user).username,
          canEdit: noteUserPermission.canEdit,
        })),
      ),
      sharedToGroups: await Promise.all(
        groupPermissions.map(async (noteGroupPermission) => ({
          groupName: (await noteGroupPermission.group).name,
          canEdit: noteGroupPermission.canEdit,
        })),
      ),
    };
  }

  /**
   * @async
   * Build NoteMetadataDto from a note.
   * @param {Note} note - the note to use
   * @return {NoteMetadataDto} the built NoteMetadataDto
   */
  async toNoteMetadataDto(note: Note): Promise<NoteMetadataDto> {
    const updateUser = await this.getLastUpdatedNoteUser(note);
    const latestRevision = await this.revisionsService.getLatestRevision(note);
    return {
      id: note.publicId,
      aliases: await Promise.all(
        (await note.aliases).map((alias) =>
          this.aliasService.toAliasDto(alias, note),
        ),
      ),
      primaryAddress: (await getPrimaryAlias(note)) ?? note.publicId,
      title: latestRevision.title,
      description: latestRevision.description,
      tags: (await latestRevision.tags).map((tag) => tag.name),
      createdAt: note.createdAt,
      editedBy: (await this.getAuthorUsers(note)).map((user) => user.username),
      permissions: await this.toNotePermissionsDto(note),
      version: note.version,
      updatedAt: latestRevision.createdAt,
      updateUsername: updateUser ? updateUser.username : null,
      viewCount: note.viewCount,
    };
  }

  /**
   * @async
   * Build NoteDto from a note.
   * @param {Note} note - the note to use
   * @return {NoteDto} the built NoteDto
   */
  async toNoteDto(note: Note): Promise<NoteDto> {
    return {
      content: await this.getNoteContent(note),
      metadata: await this.toNoteMetadataDto(note),
      editedByAtPosition: [],
    };
  }
}