import {inject, Injectable} from '@angular/core'
import {AuthService} from './auth.service'
import {
  addDoc,
  collection,
  collectionData,
  CollectionReference,
  deleteDoc,
  doc, docData,
  DocumentReference,
  Firestore,
  orderBy,
  query, updateDoc,
  where,
  DocumentData,
} from '@angular/fire/firestore'
import {IMessage} from '../../models/IMessage'
import {firstValueFrom, map, Observable} from 'rxjs'
import {IChannel} from '../../models/IChannel'
import {IProfile} from '../../models/IProfile'
import {User} from '@angular/fire/auth'

@Injectable({
  providedIn: 'root'
})
export class DatabaseService {

  #authService = inject(AuthService)
  #firestore = inject(Firestore)

  constructor() {
    this.#authService.currentUser.subscribe(u => this.#handleLogIn(u))
  }

  /**
   * Create a new message in the database.
   *
   * @param channel The channel on which the message must be placed.
   * @param message The message's content.
   */
  async sendMessage(channel: string, message: string): Promise<void> {
    const user = this.#authService.getUserUID()
    if (!user) {
      throw new Error('Authenticated')
    }

    const newMessage: IMessage = {
      content: message,
      user,
      displayName: this.#authService.getDisplayName(),
      profile: this.#authService.getProfilePic(),
      date: Date.now(),
    }

    await addDoc<IMessage, IMessage>(
      this.#getCollectionRef<IMessage>(channel),
      newMessage,
    )
  }

  /**
   * Retrieve a collection of messages, the messages are returned once, they won't be updated when the data
   * in the database changes.
   *
   * @param channel The channel that contains the message.
   */
  retrieveMessages(channel: string): Observable<IMessage[]> {
    return collectionData<IMessage>(
      query<IMessage, IMessage>(
        this.#getCollectionRef(channel),
        // Sort the messages by publication date, this is required because, by default, they are ordered by the ASCII
        // values of the characters that comprise the uuid.
        orderBy('date'),
        // It is impossible to filter on two or more columns without an index.
        // If this is required, write the query, then open the developer console, and click on the link in the error
        // message.
        // This will create an index on the combination of columns that were used in the query.
        // After the index is done building, the query will work.
        // where('user', '==', this.#authService.getUserUID()),
      ),
      {idField: 'id'},
    )
  }

  /**
   * Retrieve a singular message from firestore.
   *
   * @param channel The name of the channel (collection) in which the message is located.
   * @param id      The document id of the message that is to be retrieved.
   */
  retrieveMessage(channel: string, id: string): Observable<IMessage | undefined> {
    return docData<IMessage>(
      this.#getDocumentRef<IMessage>(channel, id),
    )
  }

  /**
   * Delete a specific message.
   *
   * @param channel   The channel that contains the message.
   * @param id The id of the message that is to be deleted.
   */
  async deleteMessage(channel: string, id: string): Promise<void> {
    await deleteDoc(this.#getDocumentRef(channel, id))
  }

  /**
   * Update an existing message with new data.
   *
   * @param channel The channel that contains the message.
   * @param id      The id of the message that is to be updated.
   * @param msg     The new data.
   */
  async updateMessage(channel: string, id: string, msg: IMessage): Promise<void> {
    // Note that the id is removed from the data that is sent to the server.
    // There is no point in including it in the data because this would mean it is
    // saved twice, once in the document, and once as the document identifier.
    const newMessage = {...msg}
    delete newMessage.id
    await updateDoc(this.#getDocumentRef(channel, id), newMessage)
  }

  /**
   * Retrieve an array containing all the channels in the chat app.
   */
  retrievePublicChannelList(): Observable<IChannel[]> {
    return collectionData<IChannel>(
      query<IChannel, IChannel>(
        this.#getCollectionRef('Channels'),
        where('isPublicChannel', '==', true),
      ),
    )
  }

  /**
   * Retrieve all users whose name matches the given search value.
   *
   * @param name
   */
  retrieveMatchingUsers(name: string): Promise<IProfile[]> {
    return firstValueFrom(collectionData<IProfile>(
      query<IProfile, IProfile>(
        this.#getCollectionRef<IProfile>('profiles'),
        where('name', '>=', name),
        where('name', '<=', name + '\uf8ff'),
      ),
    ).pipe(
      map<IProfile[], IProfile[]>(ps => ps.filter(p => p.id !== this.#authService.getUserUID())),
    ))
  }

  /**
   * Retrieve all the channels that the current user has been added to.
   */
  retrieveMyChannelList(): Observable<IChannel[]> {
    return collectionData<IChannel>(
      query<IChannel, IChannel>(
        this.#getCollectionRef('Channels'),
        where('users', 'array-contains', this.#authService.getUserUID()),
      ),
    )
  }

  /**
   * Create a new channel.
   *
   * @param name            The name of the new channel.
   * @param isPublicChannel Boolean indicating whether the channel is public.
   * @param users           The users that have been added to the channel.
   */
  async createChannel(name: string, isPublicChannel: boolean, users: string[] = []): Promise<void> {
    const owner = this.#authService.getUserUID()
    if (!owner) {
      throw new Error('Not authenticated')
    }

    const newChannel: IChannel = {
      name,
      isPublicChannel,
      owner,
      users: [owner].concat(...users),
    }
    await addDoc<IChannel, IChannel>(
      this.#getCollectionRef<IChannel>('Channels'),
      newChannel,
    )
  }

  /**
   * Retrieve a reference to a specific collection, this is required to retrieve data in that collection.
   * The type of the reference (<T>) can be set to the type of object that is stored in the collection.
   *
   * @param collectionName The name of the collection.
   * @private
   */
  #getCollectionRef<T extends DocumentData>(collectionName: string): CollectionReference<T, T> {
    return collection(this.#firestore, collectionName) as unknown as CollectionReference<T, T>
  }

  /**
   * Retrieve a reference to a specific document in a specific collection, this is required to perform read, update and
   * delete operations on the data in that collection.
   * The type of the reference (<T>) can be set to the type of object that is stored in the document.
   *
   * @param collectionName The name of the collection.
   * @param id             The id of the document to which the reference points.
   * @private
   */
  #getDocumentRef<T>(collectionName: string, id: string): DocumentReference<T> {
    return doc(this.#firestore, `${collectionName}/${id}`) as unknown as DocumentReference<T>
  }

  /**
   * If it's the first time the user logs in, a profile will be created.
   *
   * @param user The user that just logged in.
   * @private
   */
  async #handleLogIn(user: User | null): Promise<void> {
    if (!user) {
      return
    }

    const isFirstLogIn = await this.#isFirstLogIn(user)
    if (!isFirstLogIn && user.displayName) {
      return
    }

    const newProfile: IProfile = {
      name: user.displayName!,
      id: user.uid,
      email: user.email!,
      profilePhoto: user.photoURL,
    }

    await addDoc<IProfile, IProfile>(
      this.#getCollectionRef<IProfile>('profiles'),
      newProfile,
    )
  }

  /**
   * Check if this is the first time that the user logs in.
   *
   * @param user The user for which to check if a profile exists.
   * @private
   */
  async #isFirstLogIn(user: User): Promise<boolean> {
    const result = await firstValueFrom(
      collectionData<IProfile>(
        query<IProfile, IProfile>(
          this.#getCollectionRef('profiles'),
          where('id', '==', user.uid),
        ),
      ),
    )

    return result.length === 0
  }
}
