/* eslint-disable, @typescript-eslint/no-non-null-assertion */
import { Injectable } from '@angular/core';
import {
  DELETED_MESSAGE_CONTENT,
  Message,
  User,
  Room,
  Reaction,
  BanEntry
} from '../../shared-kernel';
import type { ChatAttachmentMeta } from '../../shared-kernel';
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';

/** IndexedDB database name for the MetoYou application. */
const DATABASE_NAME = 'metoyou';
const ANONYMOUS_DATABASE_SCOPE = 'anonymous';
/** IndexedDB schema version - bump when adding/changing object stores. */
const DATABASE_VERSION = 2;
/** Names of every object store used by the application. */
const STORE_MESSAGES = 'messages';
const STORE_USERS = 'users';
const STORE_ROOMS = 'rooms';
const STORE_REACTIONS = 'reactions';
const STORE_BANS = 'bans';
const STORE_META = 'meta';
const STORE_ATTACHMENTS = 'attachments';
/** All object store names, used when clearing the entire database. */
const ALL_STORE_NAMES: string[] = [
  STORE_MESSAGES,
  STORE_USERS,
  STORE_ROOMS,
  STORE_REACTIONS,
  STORE_BANS,
  STORE_ATTACHMENTS,
  STORE_META
];

/**
 * IndexedDB-backed database service used when the app runs in a
 * plain browser (i.e. without Electron).
 *
 * Every public method mirrors the {@link DatabaseService} API so the
 * facade can delegate transparently.
 */
@Injectable({ providedIn: 'root' })
export class BrowserDatabaseService {
  /** Handle to the opened IndexedDB database, or `null` before {@link initialize}. */
  private database: IDBDatabase | null = null;
  private activeDatabaseName: string | null = null;

  /** Open (or create) the IndexedDB database. Safe to call multiple times. */
  async initialize(): Promise<void> {
    const databaseName = await this.resolveDatabaseName();

    if (this.database && this.activeDatabaseName === databaseName)
      return;

    this.closeDatabase();
    this.database = await this.openDatabase(databaseName);
    this.activeDatabaseName = databaseName;
  }

  /** Persist a single message. */
  async saveMessage(message: Message): Promise<void> {
    await this.put(STORE_MESSAGES, message);
  }

  /**
   * Retrieve messages for a room, sorted oldest-first.
   * @param roomId - Target room.
   * @param limit  - Maximum number of messages to return.
   * @param offset - Number of messages to skip (for pagination).
   */
  async getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
    const allRoomMessages = await this.getAllFromIndex<Message>(
      STORE_MESSAGES, 'roomId', roomId
    );

    return allRoomMessages
      .sort((first, second) => first.timestamp - second.timestamp)
      .slice(offset, offset + limit)
      .map((message) => this.normaliseMessage(message));
  }

  async getMessagesSince(roomId: string, sinceTimestamp: number): Promise<Message[]> {
    const allRoomMessages = await this.getAllFromIndex<Message>(
      STORE_MESSAGES, 'roomId', roomId
    );

    return allRoomMessages
      .filter((message) => message.timestamp > sinceTimestamp)
      .sort((first, second) => first.timestamp - second.timestamp)
      .map((message) => this.normaliseMessage(message));
  }

  /** Delete a message by its ID. */
  async deleteMessage(messageId: string): Promise<void> {
    await this.deleteRecord(STORE_MESSAGES, messageId);
  }

  /** Apply partial updates to an existing message. */
  async updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
    const existing = await this.get<Message>(STORE_MESSAGES, messageId);

    if (existing) {
      await this.put(STORE_MESSAGES, { ...existing,
        ...updates });
    }
  }

  /** Retrieve a single message by ID, or `null` if not found. */
  async getMessageById(messageId: string): Promise<Message | null> {
    const message = await this.get<Message>(STORE_MESSAGES, messageId);

    return message ? this.normaliseMessage(message) : null;
  }

  /** Remove every message belonging to a room. */
  async clearRoomMessages(roomId: string): Promise<void> {
    const messages = await this.getAllFromIndex<Message>(
      STORE_MESSAGES, 'roomId', roomId
    );
    const transaction = this.createTransaction(STORE_MESSAGES, 'readwrite');

    for (const message of messages) {
      transaction.objectStore(STORE_MESSAGES).delete(message.id);
    }

    await this.awaitTransaction(transaction);
  }

  /**
   * Persist a reaction, ignoring duplicates (same user + same emoji on
   * the same message).
   */
  async saveReaction(reaction: Reaction): Promise<void> {
    const existing = await this.getAllFromIndex<Reaction>(
      STORE_REACTIONS, 'messageId', reaction.messageId
    );
    const isDuplicate = existing.some(
      (entry) => entry.userId === reaction.userId && entry.emoji === reaction.emoji
    );

    if (!isDuplicate) {
      await this.put(STORE_REACTIONS, reaction);
    }
  }

  /** Remove a specific reaction (identified by user + emoji + message). */
  async removeReaction(messageId: string, userId: string, emoji: string): Promise<void> {
    const reactions = await this.getAllFromIndex<Reaction>(
      STORE_REACTIONS, 'messageId', messageId
    );
    const target = reactions.find(
      (entry) => entry.userId === userId && entry.emoji === emoji
    );

    if (target) {
      await this.deleteRecord(STORE_REACTIONS, target.id);
    }
  }

  /** Return all reactions for a given message. */
  async getReactionsForMessage(messageId: string): Promise<Reaction[]> {
    return this.getAllFromIndex<Reaction>(STORE_REACTIONS, 'messageId', messageId);
  }

  /** Persist a user record. */
  async saveUser(user: User): Promise<void> {
    await this.put(STORE_USERS, user);
  }

  /** Retrieve a user by ID, or `null` if not found. */
  async getUser(userId: string): Promise<User | null> {
    return (await this.get<User>(STORE_USERS, userId)) ?? null;
  }

  /** Retrieve the last-authenticated ("current") user, or `null`. */
  async getCurrentUser(): Promise<User | null> {
    const meta = await this.get<{ id: string; value: string }>(
      STORE_META, 'currentUserId'
    );

    if (!meta)
      return null;

    return this.getUser(meta.value);
  }

  /** Retrieve the persisted current user ID without loading the full user. */
  async getCurrentUserId(): Promise<string | null> {
    const meta = await this.get<{ id: string; value: string }>(
      STORE_META, 'currentUserId'
    );

    return meta?.value?.trim() || null;
  }

  /** Store which user ID is considered "current" (logged-in). */
  async setCurrentUserId(userId: string): Promise<void> {
    await this.put(STORE_META, { id: 'currentUserId',
      value: userId });
  }

  /**
   * Retrieve all known users.
   * @param _roomId - Accepted for API parity but currently unused.
   */
  async getUsersByRoom(_roomId: string): Promise<User[]> {
    return this.getAll<User>(STORE_USERS);
  }

  /** Apply partial updates to an existing user. */
  async updateUser(userId: string, updates: Partial<User>): Promise<void> {
    const existing = await this.get<User>(STORE_USERS, userId);

    if (existing) {
      await this.put(STORE_USERS, { ...existing,
        ...updates });
    }
  }

  /** Persist a room record. */
  async saveRoom(room: Room): Promise<void> {
    await this.put(STORE_ROOMS, room);
  }

  /** Retrieve a room by ID, or `null` if not found. */
  async getRoom(roomId: string): Promise<Room | null> {
    return (await this.get<Room>(STORE_ROOMS, roomId)) ?? null;
  }

  /** Return every persisted room. */
  async getAllRooms(): Promise<Room[]> {
    return this.getAll<Room>(STORE_ROOMS);
  }

  /** Delete a room and all of its messages. */
  async deleteRoom(roomId: string): Promise<void> {
    await this.deleteRecord(STORE_ROOMS, roomId);
    await this.clearRoomMessages(roomId);
  }

  /** Apply partial updates to an existing room. */
  async updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
    const existing = await this.get<Room>(STORE_ROOMS, roomId);

    if (existing) {
      await this.put(STORE_ROOMS, { ...existing,
        ...updates });
    }
  }

  /** Persist a ban entry. */
  async saveBan(ban: BanEntry): Promise<void> {
    await this.put(STORE_BANS, ban);
  }

  /** Remove a ban by the banned user's `oderId`. */
  async removeBan(oderId: string): Promise<void> {
    const allBans = await this.getAll<BanEntry>(STORE_BANS);
    const match = allBans.find((ban) => ban.oderId === oderId);

    if (match) {
      await this.deleteRecord(STORE_BANS, match.oderId);
    }
  }

  /**
   * Return active (non-expired) bans for a room.
   *
   * @param roomId - Room to query.
   */
  async getBansForRoom(roomId: string): Promise<BanEntry[]> {
    const allBans = await this.getAllFromIndex<BanEntry>(
      STORE_BANS, 'roomId', roomId
    );
    const now = Date.now();

    return allBans.filter(
      (ban) => !ban.expiresAt || ban.expiresAt > now
    );
  }

  /** Check whether a specific user is currently banned from a room. */
  async isUserBanned(userId: string, roomId: string): Promise<boolean> {
    const activeBans = await this.getBansForRoom(roomId);

    return activeBans.some((ban) => ban.oderId === userId);
  }

  /** Persist attachment metadata associated with a chat message. */
  async saveAttachment(attachment: ChatAttachmentMeta): Promise<void> {
    await this.put(STORE_ATTACHMENTS, attachment);
  }

  /** Return all attachment records associated with a message. */
  async getAttachmentsForMessage(messageId: string): Promise<ChatAttachmentMeta[]> {
    return this.getAllFromIndex<ChatAttachmentMeta>(STORE_ATTACHMENTS, 'messageId', messageId);
  }

  /** Return every persisted attachment record. */
  async getAllAttachments(): Promise<ChatAttachmentMeta[]> {
    return this.getAll<ChatAttachmentMeta>(STORE_ATTACHMENTS);
  }

  /** Delete every attachment record for a specific message. */
  async deleteAttachmentsForMessage(messageId: string): Promise<void> {
    const attachments = await this.getAllFromIndex<ChatAttachmentMeta>(
      STORE_ATTACHMENTS, 'messageId', messageId
    );
    const transaction = this.createTransaction(STORE_ATTACHMENTS, 'readwrite');

    for (const attachment of attachments) {
      transaction.objectStore(STORE_ATTACHMENTS).delete(attachment.id);
    }

    await this.awaitTransaction(transaction);
  }

  /** Wipe all persisted data in every object store. */
  async clearAllData(): Promise<void> {
    const transaction = this.createTransaction(ALL_STORE_NAMES, 'readwrite');

    for (const storeName of ALL_STORE_NAMES) {
      transaction.objectStore(storeName).clear();
    }

    await this.awaitTransaction(transaction);
  }

  private async resolveDatabaseName(): Promise<string> {
    const currentUserId = getStoredCurrentUserId();
    const scopedDatabaseName = this.createScopedDatabaseName(currentUserId);

    if (!currentUserId) {
      return scopedDatabaseName;
    }

    if (await this.databaseExists(scopedDatabaseName)) {
      return scopedDatabaseName;
    }

    const legacyCurrentUserId = await this.readCurrentUserIdFromDatabase(DATABASE_NAME);

    return legacyCurrentUserId === currentUserId
      ? DATABASE_NAME
      : scopedDatabaseName;
  }

  private createScopedDatabaseName(userId: string | null): string {
    return `${DATABASE_NAME}::${encodeURIComponent(userId || ANONYMOUS_DATABASE_SCOPE)}`;
  }

  private async databaseExists(name: string): Promise<boolean> {
    const hasDatabasesApi = typeof indexedDB.databases === 'function';

    if (!hasDatabasesApi) {
      return false;
    }

    const databases = await indexedDB.databases();

    return databases.some((database) => database.name === name);
  }

  private async readCurrentUserIdFromDatabase(databaseName: string): Promise<string | null> {
    if (!await this.databaseExists(databaseName)) {
      return null;
    }

    const database = await this.openDatabase(databaseName);

    try {
      const transaction = database.transaction(STORE_META, 'readonly');
      const request = transaction.objectStore(STORE_META).get('currentUserId');

      return await new Promise<string | null>((resolve, reject) => {
        request.onsuccess = () => resolve((request.result as { value?: string } | undefined)?.value?.trim() || null);
        request.onerror = () => reject(request.error);
      });
    } catch {
      return null;
    } finally {
      database.close();
    }
  }

  private openDatabase(databaseName: string): Promise<IDBDatabase> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(databaseName, DATABASE_VERSION);

      request.onerror = () => reject(request.error);
      request.onupgradeneeded = () => this.setupSchema(request.result);
      request.onsuccess = () => resolve(request.result);
    });
  }

  private closeDatabase(): void {
    this.database?.close();
    this.database = null;
    this.activeDatabaseName = null;
  }

  private setupSchema(database: IDBDatabase): void {
    const messagesStore = this.ensureStore(database, STORE_MESSAGES, { keyPath: 'id' });

    this.ensureIndex(messagesStore, 'roomId', 'roomId');
    this.ensureIndex(messagesStore, 'timestamp', 'timestamp');

    this.ensureStore(database, STORE_USERS, { keyPath: 'id' });

    const roomsStore = this.ensureStore(database, STORE_ROOMS, { keyPath: 'id' });

    this.ensureIndex(roomsStore, 'timestamp', 'timestamp');

    const reactionsStore = this.ensureStore(database, STORE_REACTIONS, { keyPath: 'id' });

    this.ensureIndex(reactionsStore, 'messageId', 'messageId');
    this.ensureIndex(reactionsStore, 'userId', 'userId');

    const bansStore = this.ensureStore(database, STORE_BANS, { keyPath: 'oderId' });

    this.ensureIndex(bansStore, 'roomId', 'roomId');
    this.ensureIndex(bansStore, 'expiresAt', 'expiresAt');

    this.ensureStore(database, STORE_META, { keyPath: 'id' });

    const attachmentsStore = this.ensureStore(database, STORE_ATTACHMENTS, { keyPath: 'id' });

    this.ensureIndex(attachmentsStore, 'messageId', 'messageId');
  }

  private ensureStore(
    database: IDBDatabase,
    name: string,
    options?: IDBObjectStoreParameters
  ): IDBObjectStore {
    if (database.objectStoreNames.contains(name)) {
      return (database.transaction(name, 'readonly') as IDBTransaction).objectStore(name);
    }

    return database.createObjectStore(name, options);
  }

  private ensureIndex(store: IDBObjectStore, name: string, keyPath: string): void {
    if (!store.indexNames.contains(name)) {
      store.createIndex(name, keyPath, { unique: false });
    }
  }

  private createTransaction(
    storeNames: string | string[],
    mode: IDBTransactionMode
  ): IDBTransaction {
    if (!this.database) {
      throw new Error('Database has not been initialized');
    }

    return this.database.transaction(storeNames, mode);
  }

  private awaitTransaction(transaction: IDBTransaction): Promise<void> {
    return new Promise((resolve, reject) => {
      transaction.oncomplete = () => resolve();
      transaction.onerror = () => reject(transaction.error);
      transaction.onabort = () => reject(transaction.error);
    });
  }

  private async put(storeName: string, value: unknown): Promise<void> {
    const transaction = this.createTransaction(storeName, 'readwrite');

    transaction.objectStore(storeName).put(value);

    await this.awaitTransaction(transaction);
  }

  private async get<T>(storeName: string, key: IDBValidKey): Promise<T | undefined> {
    const transaction = this.createTransaction(storeName, 'readonly');
    const request = transaction.objectStore(storeName).get(key);

    return new Promise<T | undefined>((resolve, reject) => {
      request.onsuccess = () => resolve(request.result as T | undefined);
      request.onerror = () => reject(request.error);
    });
  }

  private async getAll<T>(storeName: string): Promise<T[]> {
    const transaction = this.createTransaction(storeName, 'readonly');
    const request = transaction.objectStore(storeName).getAll();

    return new Promise<T[]>((resolve, reject) => {
      request.onsuccess = () => resolve((request.result as T[]) ?? []);
      request.onerror = () => reject(request.error);
    });
  }

  private async getAllFromIndex<T>(
    storeName: string,
    indexName: string,
    query: IDBValidKey | IDBKeyRange
  ): Promise<T[]> {
    const transaction = this.createTransaction(storeName, 'readonly');
    const request = transaction.objectStore(storeName)
      .index(indexName)
      .getAll(query);

    return new Promise<T[]>((resolve, reject) => {
      request.onsuccess = () => resolve((request.result as T[]) ?? []);
      request.onerror = () => reject(request.error);
    });
  }

  private async deleteRecord(storeName: string, key: IDBValidKey): Promise<void> {
    const transaction = this.createTransaction(storeName, 'readwrite');

    transaction.objectStore(storeName).delete(key);

    await this.awaitTransaction(transaction);
  }

  private normaliseMessage(message: Message): Message {
    if (message.content === DELETED_MESSAGE_CONTENT) {
      return { ...message,
        reactions: [] };
    }

    return message;
  }
}
