import TranscodeBot from "./transcode/index.js";
import LogBot from "logbotjs";
import User from "./accounts/User.js";
import { Volume } from "./drives/Volume.js";
import { Thumbnail } from "./library/Thumbnail.js";

import MetaLibrary from "./library/MetaLibrary.js";
import { MetaFile } from "./library/MetaFile.js";
import { indexer } from "./media/Indexer.js";
import Task from "./media/Task.js";
import { MetaCopy } from "./library/MetaCopy.js";
import { TranscodeTask } from "./transcode/TranscodeTask.js";
import utility from "./system/utility.js";
import api from "../api/index.js";

import AccountManager from "./accounts/AccountManager.js";
import createTaskOptions from "./library/createTaskOptions.js";
import { MLInterface } from "./analyse/MLInterface.js";
import analyseMetaFileOptions from "./library/analyseMetaFileOptions.js";

import extensions from "../extensions/index.js";
import Extension from "./Extension.js";
import MetaLibraryOptions from "./library/MetaLibraryOptions.js";
import MetaLibraryUpdateOptions from "./library/MetaLibraryUpdateOptions.js";
import FolderOptions from "./library/FolderOptions.js";
import Transaction from "./database/Transaction.js";
import CancelToken from "./library/CancelToken.js";
import WrangleBotOptions from "./WrangleBotOptions.js";
import EventEmitter from "events";

import { config, finder } from "./system/index.js";
import { SearchLite } from "searchlite";
//load here, otherwise the config will be preloaded and the config will be overwritten
import { driveBot, DriveBot } from "./drives/DriveBot.js";

import { v4 as uuidv4 } from "uuid";
import DB from "./database/DB.js";
import { DB as Database } from "./database/DB.js";

interface ReturnObject {
  status: 200 | 400 | 500 | 404;
  message?: string;
  result?: any;
}

/**
 * WrangleBot Interface
 * @class WrangleBot
 */
class WrangleBot extends EventEmitter {
  static OPEN = "open";
  static CLOSED = "closed";

  pingInterval;
  ping;

  // libraries: Array<MetaLibrary> = [];

  /**
   * @type {DriveBot}
   */
  driveBot: DriveBot = driveBot;

  accountManager = AccountManager;

  finder = finder;

  ML;

  config = config;

  status = WrangleBot.CLOSED;

  /**
   * index
   */
  index: {
    libraries: MetaLibrary[];
    metaFiles: { [key: string]: MetaFile };
    metaCopies: { [key: string]: MetaCopy };
    copyTasks: { [key: string]: Task };
    transcodes: { [key: string]: TranscodeTask };
  } = {
    libraries: [],
    metaFiles: {},
    metaCopies: {},
    copyTasks: {},
    transcodes: {},
  };

  private thirdPartyExtensions: Extension[] = [];
  private servers: any;
  db: Database | any;

  constructor() {
    super();
  }

  async open(options: WrangleBotOptions) {

    config.build(options.app_data_location);

    LogBot.log(100, "Opening WrangleBot instance ... ");
    this.$emit("notification", {
      title: "Opening WrangleBot",
      message: "WrangleBot is starting up",
    });

    if (!config) throw new Error("Config failed to load. Aborting. Delete the config file and restart the bot.");

    // get or set the port
    if (options.port) config.set("port", options.port);
    else throw new Error("No port provided");

    this.pingInterval = this.config.get("pingInterval") || 5000;

    try {
      await this.loadExtensions();

      let db;
      if (options.vault.sync_url && options.vault.token) {
        //CLOUD SYNC DB
        LogBot.log(100, "User supplied cloud database credentials. Attempting to connect to cloud database.");

        if (!options.vault.sync_url) throw new Error("No databaseURL provided");
        if (!options.vault.token) throw new Error("No token provided");

        //init db interface
        this.db = DB({
          url: options.vault.sync_url,
          token: options.vault.token,
        });
        //rebuild local model
        await DB().rebuildLocalModel();
        //connect to db websocket
        await this.db.connect(options.vault.token);

        if (options.vault.ai_url) {
          //init machine learning interface
          this.ML = MLInterface({
            url: options.vault.ai_url,
            token: options.vault.token,
          });
        }
      } else if (options.vault.token) {
        //LOCAL DB
        LogBot.log(100, "User supplied local database credentials. Attempting to connect to local database.");
        //init db interface for local use
        this.db = DB({
          token: options.vault.token,
        });
        //rebuild local model
        await DB().rebuildLocalModel();
      }

      if (this.db) {
        this.db.on("transaction", (transaction) => {
          this.applyTransaction(transaction);
        });

        this.db.on("notification", (notification) => {
          this.$emit("notification", notification);
        });

        //start Account Manager
        await AccountManager.init();

        //start Socket and REST API
        await this.startServer({
          port: options.port,
          secret: options.secret || this.config.get("jwt-secret"),
          mailConfig: options.mail || this.config.get("mail"),
        });

        await this.driveBot.updateDrives();
        this.driveBot.watch(); //start drive watching

        const libraries = this.getAvailableLibraries().map((l) => l.name);

        let i = 1;
        let total = libraries.length;

        for (let libraryName of libraries) {
          try {
            const str = " (" + i + "/" + total + ") Attempting to load MetaLibrary " + libraryName;

            this.$emit("notification", {
              title: str,
              message: `Loading library ${libraryName}`,
            });
            LogBot.log(100, str);

            const r = await this.loadOneLibrary(libraryName);

            if (r.status !== 200) {
              this.error(new Error("Could not load library: " + r.message));

              this.$emit("notification", {
                title: "Library failed to load",
                message: "Library " + libraryName + " was not loaded.",
              });
            } else {
              const str = " (" + i + "/" + total + ") Successfully loaded MetaLibrary " + libraryName;
              this.$emit("notification", {
                title: str,
                message: "Library " + libraryName + " loaded",
              });
              LogBot.log(200, str);
            }
          } catch (e: any) {
            this.error(new Error("Could not load library: " + e.message));
          }
          i++;
        }

        this.driveBot.on("removed", this.handleVolumeUnmount.bind(this));
        this.driveBot.on("added", this.handleVolumeMount.bind(this));

        this.status = WrangleBot.OPEN;

        LogBot.log(200, "WrangleBot instance opened successfully: http://localhost:" + options.port);

        this.$emit("notification", {
          title: "Howdy!",
          message: "WrangleBot is ready to wrangle",
        });
        this.$emit("ready", this);

        return this;
      } else {
        this.status = WrangleBot.CLOSED;

        this.$emit("notification", {
          title: "Could not connect to database",
          message: "WrangleBot could not connect to the database.",
        });

        this.$emit("error", new Error("Could not connect to database"));
        return null;
      }
    } catch (e: any) {
      LogBot.log(500, e.message);
      this.status = WrangleBot.CLOSED;
      this.$emit("error", e);

      this.$emit("notification", {
        title: "Could not connect to database",
        message: "WrangleBot could not connect to the database.",
      });

      return null;
    }
  }

  async close() {
    this.status = WrangleBot.CLOSED;
    clearInterval(this.ping);

    this.driveBot.stopWatching();

    this.servers.httpServer.close();
    this.servers.socketServer.close();

    return WrangleBot.CLOSED;
  }

  private async startServer(options: { port: number; mailConfig: Object; secret: string }) {
    this.servers = await api.init(this, options);
  }

  /**
   * UTILITY FUNCTIONS
   */

  $emit(event: string, ...args: any[]): Promise<boolean> {
    return new Promise((resolve, reject) => {
      this.runCustomScript(event, ...args)
        .then(() => {
          super.emit(event, ...args);
          resolve(true);
        })
        .catch((err) => {
          LogBot.log(500, err);
          resolve(false);
        });
    });
  }

  private async runCustomScript(event: string, ...args: any[]) {
    for (let extension of extensions) {
      if (extension.events.includes(event)) {
        await extension.handler(event, args, this);
      }
    }
    for (let extension of this.thirdPartyExtensions) {
      if (extension.events.includes(event)) {
        await extension.handler(event, args, this);
      }
    }
  }

  private async loadExtensions() {
    try {
      LogBot.log(100, "Loading extensions ... ");
      //scan the plugins folder in the wranglebot directory
      //and load the routes from the plugins
      const pathToPlugins = finder.getPathToUserData("custom/");
      const thirdPartyPluginsRAW = finder.getContentOfFolder(pathToPlugins);
      LogBot.log(100, "Found " + thirdPartyPluginsRAW.length + " third party plugins.");
      if (thirdPartyPluginsRAW.length > 0) {
        for (let folderName of thirdPartyPluginsRAW) {
          LogBot.log(100, "Loading plugin " + folderName + " ... ");
          const pathToPlugin = finder.getPathToUserData("custom/" + folderName);
          const folderContents = finder.getContentOfFolder(pathToPlugin);

          for (let pluginFolder of folderContents) {
            if (pluginFolder === "hooks") {
              const pathToPluginHooks = finder.getPathToUserData("custom/" + folderName + "/" + pluginFolder);
              const hookFolderContent = finder.getContentOfFolder(pathToPluginHooks);

              for (let scriptFileName of hookFolderContent) {
                LogBot.log(100, "Loading hook " + scriptFileName + " ... ");
                const script = (await import(pathToPluginHooks + "/" + scriptFileName)).default;

                if (!script.name || script.name === "") {
                  LogBot.log(404, "Plugin " + folderName + " does not have a valid name. Skipping ... ");
                  continue;
                }

                if (!script.description || script.description === "") {
                  LogBot.log(404, "Plugin " + folderName + " does not have a valid description. Skipping ... ");
                  continue;
                }

                if (!script.version || script.version.match(/^[0-9]+\.[0-9]+\.[0-9]+$/) === null) {
                  LogBot.log(404, "Plugin " + folderName + " does not have a valid version (/^[0-9]+\\.[0-9]+\\.[0-9]+$/). Skipping ... ");
                  continue;
                }

                if (!script.handler || !(script.handler instanceof Function)) {
                  LogBot.log(404, "Plugin " + folderName + " does not have a valid handler. Skipping ... ");
                  continue;
                }

                if (!script.events || script.events.length === 0) {
                  LogBot.log(404, "Plugin " + folderName + " does not have any events. Skipping ... ");
                  continue;
                }

                this.thirdPartyExtensions.push(script);
              }
            }
          }
        }
      }
    } catch (e: any) {
      LogBot.log(500, e.message);
    }
  }

  getAvailableLibraries() {
    return DB().getMany("libraries", {});
  }

  private async addOneLibrary(options: MetaLibraryOptions) {
    if (!options.name) throw new Error("No name provided");

    if (options.pathToLibrary) {
      if (!finder.isReachable(options.pathToLibrary)) {
        throw new Error(options.pathToLibrary + " is not a valid path.");
      }
    }

    const allowedPaths = [
      //macos
      "/volumes/",
      "/users/",
      //linux
      "/media/",
      "/home/",
    ];
    const path = options.pathToLibrary.toLowerCase();

    //check if the folder already exists
    if (!finder.existsSync(path)) {
      //if it does not create the base folder
      finder.mkdirSync(path, { recursive: true });
    }

    const allowed = allowedPaths.some((p) => path.startsWith(p));
    if (!allowed) throw new Error("Path is not allowed");

    //check if lib exists in database
    if (this.index.libraries.find((l) => l.name.toLowerCase() === options.name.toLowerCase())) {
      throw new Error("Library with that name already exists");
    }

    if (this.index.libraries.find((l) => path.startsWith(l.pathToLibrary.toLowerCase()))) {
      throw new Error("Library in path already exists");
    }

    const metaLibrary = new MetaLibrary(this, options);

    //add library to runtime
    this.index.libraries.unshift(metaLibrary);

    //add metaLibrary in database
    await DB().updateOne("libraries", { name: metaLibrary.name }, metaLibrary.toJSON({ db: true }));

    metaLibrary.createFoldersOnDiskFromTemplate();

    this.$emit("metalibrary-new", metaLibrary);

    return metaLibrary;
  }

  private removeOneLibrary(name, save = true) {
    if (!this.index.libraries.find((l) => l.name === name)) {
      return {
        status: 404,
        error: "database with that name does not exist or has not been loaded",
      };
    }
    this.unloadOneLibrary(name);
    if (save) {
      return DB().removeOne("libraries", { name });
    }
    this.$emit("metalibrary-remove", name);
    return true;
  }

  private getOneLibrary(name) {
    const lib = this.index.libraries.find((l) => l.name === name);
    if (lib) return lib;
    return DB().getOne("libraries", { name });
  }

  private loadOneLibrary(name: string): Promise<ReturnObject> {
    return new Promise<ReturnObject>((resolve, reject) => {
      if (this.index.libraries.find((l) => l.name === name)) {
        resolve({
          status: 500,
          message: "I can not load a library, that has been loaded already.",
        });
      } else {
        const lib = this.getOneLibrary(name);
        if (lib) {
          const newMetaLibrary = new MetaLibrary(this, null);

          let readOnly = false;

          if (!finder.isReachable(lib.pathToLibrary)) {
            readOnly = true;
          }

          newMetaLibrary
            .rebuild(lib, readOnly)
            .then(() => {
              this.index.libraries.unshift(newMetaLibrary);
              resolve({
                status: 200,
                result: newMetaLibrary,
              });
            })
            .catch((e) => {
              resolve({
                status: 404,
                result: null,
                message: e.message,
              });
            });
        } else {
          const libraries = this.getAvailableLibraries();
          reject(new Error("No library named '" + name + "' found. Available libraries: ['" + libraries.map((lib) => lib.name).join(", '") + "']"));
        }
      }
    }).catch((e) => {
      this.error(e.message);
      return { status: 404, message: e.message };
    });
  }

  private unloadOneLibrary(name) {
    const search = SearchLite.find(this.index.libraries, "name", name);
    if (search.wasSuccess()) {
      this.index.libraries.splice(search.count, 1);
      this.config.set(
        "libraries",
        this.index.libraries.map((lib) => lib.name)
      );
      return {
        status: 200,
        message: "Library unloaded",
      };
    } else {
      return {
        status: 404,
        message: "No library with that name found",
      };
    }
  }

  handleVolumeMount(volume) {
    for (let lib of this.index.libraries) {
      if (lib.pathToLibrary.startsWith(volume.mountpoint)) {
        lib.readOnly = false;
      }
    }
  }

  handleVolumeUnmount(volume) {
    for (let lib of this.index.libraries) {
      if (lib.pathToLibrary.startsWith(volume.mountpoint)) {
        lib.readOnly = true;
      }
    }
  }

  /* THUMBNAILS */

  /**
   * Generates Thumbnails from a list of MetaFiles
   *
   * @param library
   * @param {MetaFile[]} metaFiles
   * @param {Function|false} callback
   * @param finishCallback?
   * @returns {Promise<boolean>} resolve to false if there is no need to generate thumbnails or if there are no copies reachable
   */
  async generateThumbnails(library, metaFiles, callback = (progress) => {}, finishCallback = (success) => {}) {
    const callbackWrapper = function (p) {
      callback({ ...p, metaFile: currentFile });
    };
    let currentFile = metaFiles[0];

    if (metaFiles.length > 0) {
      for (let file of metaFiles) {
        currentFile = file;
        try {
          await this.generateThumbnail(library, file, null, callbackWrapper);
          finishCallback(file.id);
        } catch (e: any) {
          LogBot.log(500, "Error while generating thumbnail for file " + file.id + ": " + e.message);
          throw e;
        }
      }
      return true;
    } else {
      return false;
    }
  }

  /**
   * Generates a Thumbnail from a MetaFile if it is a video or photo
   *
   * @param {string} library      - the library name
   * @param {MetaFile} metaFile   - the metaFile to generate a thumbnail for
   * @param {MetaCopy} metaCopy   - if not provided or unreachable, the first reachable copy will be used
   * @param {Function} callback   - callback function to update the progress
   * @returns {Promise<boolean>}  rejects if there is no way to generate thumbnails or if there are no copies reachable
   */
  private async generateThumbnail(library, metaFile, metaCopy, callback: Function) {
    if (metaFile.fileType === "photo" || metaFile.fileType === "video") {
      //find the first copy that is has a reachable path
      let reachableMetaCopy;
      if (metaCopy && finder.existsSync(metaCopy.pathToBucket.file)) {
        reachableMetaCopy = metaCopy;
      } else {
        reachableMetaCopy = metaFile.copies.find((copy) => {
          return finder.existsSync(copy.pathToBucket.file);
        });
      }

      if (reachableMetaCopy) {
        const thumbnails: any = await TranscodeBot.generateThumbnails(reachableMetaCopy.pathToBucket.file, {
          callback,
          metaFile,
        });
        if (thumbnails) {
          LogBot.log(200, "Generated Thumbnails for <" + metaFile.name + ">");

          if (metaFile.thumbnails.length > 0) {
            LogBot.log(200, "Deleting old Thumbnails <" + metaFile.thumbnails.length + "> for <" + metaFile.name + ">");
            let thumbs: Thumbnail[] = Object.values(metaFile.thumbnails);
            for (let thumb of thumbs) {
              metaFile.removeOneThumbnail(thumb.id);
            }
            await DB().removeMany("thumbnails", { metafile: metaFile.id, library });
            await utility.twiddleThumbs(5); //wait 5 seconds to make sure the timestamp is incremented
            LogBot.log(200, "Deleted old Thumbnails now <" + metaFile.thumbnails.length + "> for <" + metaFile.name + ">");
          }

          LogBot.log(200, "Saving Thumbnails <" + thumbnails.length + "> for <" + metaFile.name + ">");

          for (let thumbnail of thumbnails) {
            metaFile.addThumbnail(thumbnail);
          }

          const thumbData: any[] = [];

          for (let thumb of metaFile.getThumbnails()) {
            thumbData.push(thumb.toJSON());
          }

          await DB().insertMany("thumbnails", { metaFile: metaFile.id, library }, thumbData);

          await utility.twiddleThumbs(5); //wait 5 seconds to make sure the timestamp is incremented

          await DB().updateOne(
            "metafiles",
            { id: metaFile.id, library },
            {
              thumbnails: metaFile.getThumbnails().map((t) => t.id),
            }
          );

          this.$emit("thumbnail-new", metaFile.getThumbnails());
          this.$emit("metafile-edit", metaFile);

          LogBot.log(200, "Saved Thumbnails <" + thumbnails.length + "> for <" + metaFile.name + "> in DB");

          return true;
        }
      } else {
        throw new Error("No reachable copy found. make sure a copy is reachable before generating thumbnails");
      }
    }
    throw new Error("Can't generate thumbnails for this file type");
  }

  private getManyTransactions(filter) {
    return DB().getTransactions(filter);
  }

  /**
   * Removes an Object from the runtime
   * if it already exists it will be overwritten
   *
   * @param {string} list i.e. copyTasks
   * @param {Object} item the object to remove
   * @return {0|1|-1} 0 if the item was not found, 1 if it was removed, -1 if the list does not exist
   */
  removeFromRuntime(list, item) {
    try {
      if (this.index[list]) {
        const foundItem = this.index[list][item.id];
        if (foundItem) {
          delete this.index[list][item.id];
          return 1;
        }
      }
      return -1;
    } catch (e) {
      console.error(e);
    }
  }

  /**
   * Adds an Object to the runtime
   * if it already exists it will be overwritten
   *
   * @param {string} list i.e. copyTasks
   * @param {{id:string}} item the object to add
   * @return {0|1|-1} 0 if the item was overwritten, 1 if it was added, -1 if the list does not exist
   */
  addToRuntime(list, item) {
    if (this.index[list]) {
      const alreadyExists = this.index[list][item.id];
      if (!alreadyExists) {
        this.index[list][item.id] = item;
        return 0;
      } else {
        this.index[list][item.id] = item;
        return 1;
      }
    }
    return -1;
  }

  /* LOGGING & DEBUGGING */

  error(message) {
    return LogBot.log(500, message, true);
  }

  notify(title, message) {
    this.$emit("notification", { title, message });
  }

  //**********************************
  //* API v2                         *
  //**********************************

  get query() {
    return {
      library: {
        many: (filters = {}) => {
          const libs = this.index.libraries.filter((lib) => {
            for (let key in filters) {
              if (lib[key] !== filters[key]) return false;
            }
            return true;
          });

          return {
            fetch: async () => {
              return libs;
            },
          };
        },
        one: (libraryId: string) => {
          const lib = this.index.libraries.find((l) => l.name === libraryId);
          if (!lib) throw new Error("Library is not loaded or does not exist.");

          return {
            fetch(): MetaLibrary {
              lib.query = this;
              return lib;
            },
            put: (options: MetaLibraryUpdateOptions): Boolean => {
              return lib.update(options);
            },
            delete: (): Boolean => {
              return this.removeOneLibrary(libraryId);
            },
            scan: async (): Promise<Task | false> => {
              return await lib.createCopyTaskForNewFiles();
            },
            transactions: {
              one: (id: string) => {
                return {
                  fetch: (): Transaction => {
                    const t = this.getManyTransactions({
                      id: id,
                    });
                    if (t.length > 0) {
                      return t[0];
                    }
                    throw new Error("Transaction not found.");
                  },
                };
              },
              many: (filter = {}) => {
                return {
                  fetch: (): Transaction[] => {
                    return this.getManyTransactions({
                      ...filter,
                      library: lib.name,
                    });
                  },
                };
              },
            },
            metafiles: {
              one: (metaFileId: string) => {
                const metafile = lib.getOneMetaFile(metaFileId);
                if (!metafile) throw new Error("Metafile not found.");

                return {
                  fetch(): MetaFile {
                    metafile.query = this;
                    return metafile;
                  },
                  delete: (): Boolean => {
                    return lib.removeOneMetaFile(metafile);
                  },
                  thumbnails: {
                    one: (id: string) => {
                      return {
                        fetch: (): Thumbnail => {
                          return metafile.getThumbnail(id);
                        },
                      };
                    },
                    many: (filters) => {
                      const thumbnails = metafile.getThumbnails(filters);
                      return {
                        fetch: (): Thumbnail[] => {
                          return thumbnails;
                        },
                        analyse: async (options) => {
                          return await metafile.analyse({
                            ...options,
                            frames: thumbnails.map((t) => t.id),
                          });
                        },
                      };
                    },
                    first: {
                      fetch: (): Thumbnail => {
                        return metafile.getThumbnails()[0];
                      },
                    },
                    center: {
                      fetch: (): Thumbnail => {
                        const thumbs = metafile.getThumbnails();
                        return thumbs[Math.floor(thumbs.length / 2)];
                      },
                    },
                    last: {
                      fetch: (): Thumbnail => {
                        const thumbs = metafile.getThumbnails();
                        return thumbs[thumbs.length - 1];
                      },
                    },
                    generate: async (): Promise<Boolean> => {
                      return await this.generateThumbnails(lib, metafile);
                    },
                  },
                  metacopies: {
                    one: (metaCopyId) => {
                      const metacopy = lib.getOneMetaCopy(metaFileId, metaCopyId);
                      if (!metacopy) throw new Error("Metacopy not found.");
                      return {
                        fetch(): MetaCopy {
                          metacopy.query = this;
                          return metacopy;
                        },
                        delete: (options = { deleteFile: false }) => {
                          return lib.removeOneMetaCopy(metacopy, options);
                        },
                      };
                    },
                    many: (filters = {}) => {
                      return {
                        fetch: () => {
                          return lib.getManyMetaCopies(metaFileId);
                        },
                      };
                    },
                    post: async (options): Promise<MetaCopy> => {
                      return await lib.addOneMetaCopy(options, metafile);
                    },
                  },
                  metadata: {
                    put: (options): Boolean => {
                      return lib.updateMetaDataOfFile(metafile, options.key, options.value);
                    },
                  },
                  analyse: async (options: analyseMetaFileOptions): Promise<{ response: Object }> => {
                    return await metafile.analyse(options);
                  },
                };
              },
              many: (filters) => {
                const files = lib.getManyMetaFiles(filters);
                return {
                  fetch: (): MetaFile[] => {
                    return files;
                  },
                  export: {
                    report: async (options): Promise<Boolean> => {
                      return await lib.generateOneReport(files, {
                        pathToExport: options.pathToExport ? options.pathToExport : lib.pathToLibrary + "/_Reports",
                        reportName: options.reportName || "Report",
                        logoPath: options.logoPath,
                        uniqueNames: options.uniqueNames,
                        format: options.format,
                        template: options.template,
                        credits: options.credits,
                      });
                    },
                  },
                };
              },
              post: async (metafile: MetaFile | Object | string): Promise<MetaFile> => {
                return await lib.addOneMetaFile(metafile);
              },
            },
            tasks: {
              one: (id) => {
                let task = lib.getOneTask(id);

                return {
                  fetch(): Task {
                    task.query = this;
                    return task;
                  },
                  run: async (callback: Function, cancelToken: CancelToken): Promise<Task> => {
                    return await lib.runOneTask(id, callback, cancelToken);
                  },
                  put: async (options) => {
                    return await lib.updateOneTask({ id, ...options });
                  },
                  delete: async () => {
                    return lib.removeOneTask(id);
                  },
                };
              },
              many: (filters = {}) => {
                return {
                  fetch() {
                    return lib.getManyTasks();
                  },
                  delete: async () => {
                    return await lib.removeManyTasks(filters);
                  },
                };
              },
              post: async (options: { label: string; jobs: { source: string; destinations?: string[] | null }[] }) => {
                return await lib.addOneTask(options);
              },
              generate: async (options: createTaskOptions) => {
                return await lib.generateOneTask(options);
              },
            },
            transcodes: {
              one: (id) => {
                let transcode = lib.getOneTranscodeTask(id);

                return {
                  fetch() {
                    transcode.query = this;
                    return transcode;
                  },
                  run: async (callback: Function, cancelToken: CancelToken): Promise<void> => {
                    await lib.runOneTranscodeTask(id, callback, cancelToken);
                  },
                  delete: (): Boolean => {
                    return lib.removeOneTranscodeTask(id);
                  },
                };
              },
              many: () => {
                return {
                  fetch(): TranscodeTask[] {
                    return lib.getManyTranscodeTasks();
                  },
                  // delete: async () => {
                  //   return lib.removeManyTranscodeTask({$ids : filters.$ids});
                  // },
                };
              },
              post: async (files: MetaFile[], options): Promise<TranscodeTask> => {
                return await lib.addOneTranscodeTask(files, options);
              },
            },
            folders: {
              put: async (options: FolderOptions): Promise<Boolean> => {
                return await lib.updateFolder(options.path, options.options);
              },
            },
          };
        },
        post: async (options: MetaLibraryOptions): Promise<MetaLibrary> => {
          return await this.addOneLibrary(options);
        },
        load: async (name: string) => {
          return await this.loadOneLibrary(name);
        },
        unload: (name: string) => {
          return this.unloadOneLibrary(name);
        },
      },
      users: {
        one: (options: { id: string }) => {
          if (!options.id) throw new Error("No id provided");

          const user = AccountManager.getOneUser(options.id);
          if (!user) throw new Error("No user found with that " + options.id);

          return {
            fetch(): User {
              user.query = this;
              return user;
            },
            put: (options) => {
              return AccountManager.updateUser(user, options);
            },
            allow: (libraryName: string) => {
              return AccountManager.allowAccess(user, libraryName);
            },
            revoke: (libraryName: string) => {
              return AccountManager.revokeAccess(user, libraryName);
            },
            reset: () => {
              return AccountManager.resetPassword(user);
            },
          };
        },
        many: (filters = {}): { fetch: Function } => {
          return {
            fetch(): User[] {
              return AccountManager.getAllUsers(filters);
            },
          };
        },
        post: async (options) => {
          return AccountManager.addOneUser({
            ...options,
            create: true,
          });
        },
      },
      volumes: {
        one: (id) => {
          const vol = this.driveBot.drives.find((d) => d.volumeId === id);
          if (!vol) throw new Error("Volume not found.");
          return {
            fetch(): Volume {
              vol.query = this;
              return vol;
            },
            eject: async () => {
              return await this.driveBot.eject(id);
            },
          };
        },
        many: (): { fetch(): Promise<Volume[]> } => {
          let driveWatcher = this.driveBot;
          return {
            async fetch(): Promise<Volume[]> {
              return await driveWatcher.getDrives();
            },
          };
        },
      },
      transactions: {
        one: (id) => {},
        many: (filter) => {
          return {
            fetch: async () => {
              return this.getManyTransactions(filter);
            },
          };
        },
      },
    };
  }

  get utility() {
    return {
      index: async (pathToFolder, types) => {
        return await indexer.index(pathToFolder, types);
      },
      list: (pathToFolder, options: { showHidden: boolean; filters: "both" | "files" | "folders"; recursive: boolean; depth: Number }) => {
        if (!pathToFolder) throw new Error("No path provided.");
        if (pathToFolder === "/") throw new Error("Cannot list root directory.");
        if (!options) {
          options = {
            showHidden: false,
            filters: "folders",
            recursive: false,
            depth: 0,
          };
        }
        return finder.getContentOfFolder(pathToFolder, options);
      },
      uuid() {
        return uuidv4();
      },
      luts() {
        const pathToLuts = config.getPathToUserData() + "/LUTs";
        if (finder.existsSync(pathToLuts)) {
          const files = finder.getContentOfFolder(pathToLuts).filter((f) => !f.startsWith("."));
          return files;
        } else {
          return [];
        }
      },
    };
  }

  private async applyTransaction(transaction) {
    try {
      if (transaction.$method === "updateOne") await this.applyTransactionUpdateOne(transaction);
      if (transaction.$method === "insertMany") await this.applyTransactionInsertMany(transaction);
      if (transaction.$method === "removeOne") await this.applyTransactionRemoveOne(transaction);
    } catch (e) {
      console.error(e);
      LogBot.log(500, "Error applying transaction", e);
    }
  }

  private async applyTransactionUpdateOne(transaction) {
    LogBot.log(100, "applying transaction: " + transaction.id);

    //LIBRARY ADDED/UPDATED
    if (transaction.$collection === "libraries") {
      let lib = this.index.libraries.find((l) => l.name === transaction.$query.name);
      if (lib) {
        await lib.update(transaction.$set, false);
        LogBot.log(200, `Library ${lib.name} updated.`);
      } else {
        const newMetaLibrary = new MetaLibrary(this, { ...transaction.$set, ...transaction.$query });
        this.addToRuntime("libraries", newMetaLibrary);
        LogBot.log(200, `Library ${newMetaLibrary.name} added.`);
      }
      this.servers.socketServer.inform("database", "libraries", "change");
    }
    //METAFILE ADDED/UPDATED
    if (transaction.$collection === "metafiles") {
      if (!transaction.$query.library) throw new Error("No library provided to update metaFile.");

      let lib = this.index.libraries.find((l) => l.name === transaction.$query.library);
      if (lib) {
        let file = lib.metaFiles.find((f) => f.id === transaction.$query.id);
        if (file) {
          file
            .update(transaction.$set, false)
            .then(() => {
              LogBot.log(200, "MetaFile updated");
              this.servers.socketServer.inform("database", "metafiles", "change");
            })
            .catch((e) => {
              console.error(e);
              LogBot.log(500, "Error updating metaFile", e);
            });
        } else {
          const newMetaFile = new MetaFile({ ...transaction.$set, ...transaction.$query });
          lib.metaFiles.push(newMetaFile);
          this.addToRuntime("metaFiles", newMetaFile);

          this.servers.socketServer.inform("database", "metafiles", "change");

          LogBot.log(200, "MetaFile created");
        }
      } else {
        throw new Error("Library not found.");
      }
    }

    //METACOPY ADDED/UPDATED
    if (transaction.$collection === "metacopies") {
      if (!transaction.$query.library) throw new Error("No library provided to update MetaCopy.");

      let lib = this.index.libraries.find((l) => l.name === transaction.$query.library);
      if (lib) {
        let copy = this.index.metaCopies[transaction.$query.id];
        if (copy) {
          copy.update(transaction.$set, false);
          LogBot.log(200, "MetaCopy updated", copy);

          this.servers.socketServer.inform("database", "metafiles", "change");
        } else {
          //find the file that this copy belongs to
          let file = lib.metaFiles.find((f) => f.id === transaction.$query.metaFile);
          if (!file) {
            LogBot.log(404, "MetaFile for MetaCopy not found");
            return;
          }

          const newMetaCopy = new MetaCopy({ ...transaction.$set, ...transaction.$query });
          file.addCopy(newMetaCopy);
          this.addToRuntime("metaCopies", newMetaCopy);

          this.servers.socketServer.inform("database", "metafiles", "change");
          LogBot.log(200, "MetaCopy created", newMetaCopy);
        }
      } else {
        throw new Error("Library not found.");
      }
    }
    //TASKS ADDED/UPDATED
    if (transaction.$collection === "tasks") {
      if (!transaction.$query.library) throw new Error("No library provided to update Task.");

      let lib = this.index.libraries.find((l) => l.name === transaction.$query.library);
      if (lib) {
        let task = this.index.copyTasks[transaction.$query.id];
        if (task) {
          task.update(transaction.$set, false);

          this.servers.socketServer.inform("database", "tasks", "change");
          LogBot.log(200, "Task updated", task);
        } else {
          const newTask = new Task({ ...transaction.$set, ...transaction.$query });
          lib.tasks.push(newTask);
          this.addToRuntime("copyTasks", newTask);

          this.servers.socketServer.inform("database", "tasks", "change");
          LogBot.log(200, "Task created", newTask);
        }
      } else {
        throw new Error("Library not found.");
      }
    }
    //TRANSCODES ADDED/UPDATED
    if (transaction.$collection === "transcodes") {
      if (!transaction.$query.library) throw new Error("No library provided to update Transcodes.");

      let lib = this.index.libraries.find((l) => l.name === transaction.$query.library);
      if (lib) {
        let transcode = this.index.transcodes[transaction.$query.id];
        if (transcode) {
          transcode.update(transaction.$set);
          this.servers.socketServer.inform("database", "transcodes", "change");
          LogBot.log(200, "Transcode updated", transcode);
        } else {
          const newTranscode = new TranscodeTask(null, { ...transaction.$set, ...transaction.$query });
          lib.transcodes.push(newTranscode);
          this.addToRuntime("transcodes", newTranscode);
          this.servers.socketServer.inform("database", "transcodes", "change");
          LogBot.log(200, "Transcode created", newTranscode);
        }
      } else {
        throw new Error("Library not found.");
      }
    }
  }

  private async applyTransactionInsertMany(transaction) {
    if (transaction.$collection === "thumbnails") {
      const metaFile = this.index.metaFiles[transaction.$query.metaFile];
      if (!metaFile) throw new Error("MetaFile not found.");

      for (let thumb of transaction.$set) {
        metaFile.addThumbnail(thumb);
      }
    }
  }

  private async applyTransactionRemoveOne(transaction) {
    if (transaction.$collection === "libraries") {
      let lib = this.index.libraries.find((l) => l.name === transaction.$query.name);
      if (lib) {
        this.removeOneLibrary(lib, false);
        this.servers.socketServer.inform("database", "libraries", "change");
        LogBot.log(200, `Library ${lib.name} removed.`);
      }
    } else if (transaction.$collection === "metafiles") {
      if (!transaction.$query.library) throw new Error("No library provided to remove metaFile.");

      let lib = this.index.libraries.find((l) => l.name === transaction.$query.library);
      if (lib) {
        let file = lib.metaFiles.find((f) => f.id === transaction.$query.id);
        if (file) {
          lib.removeOneMetaFile(file, false);
          this.servers.socketServer.inform("database", "metafiles", "change");
          LogBot.log(200, "MetaFile removed", file);
        }
      } else {
        throw new Error("Library not found.");
      }
    } else if (transaction.$collection === "metacopies") {
      if (!transaction.$query.library) throw new Error("No library provided to remove MetaCopy.");

      let lib = this.index.libraries.find((l) => l.name === transaction.$query.library);
      if (lib) {
        let copy = this.index.metaCopies[transaction.$query.id];
        if (copy) {
          lib.removeOneMetaCopy(copy, { deleteFile: false }, false);
          this.servers.socketServer.inform("database", "metafiles", "change");
          LogBot.log(200, "MetaCopy removed", copy);
        }
      } else {
        throw new Error("Library not found.");
      }
    } else if (transaction.$collection === "tasks") {
      if (!transaction.$query.library) throw new Error("No library provided to remove Task.");

      let lib = this.index.libraries.find((l) => l.name === transaction.$query.library);
      if (lib) {
        let task = this.index.copyTasks[transaction.$query.id];
        if (task) {
          lib.removeOneTask(task.id, "id", false);
          this.servers.socketServer.inform("database", "tasks", "change");
          LogBot.log(200, "Task removed", task);
        }
      } else {
        throw new Error("Library not found.");
      }
    } else if (transaction.$collection === "transcodes") {
      if (!transaction.$query.library) throw new Error("No library provided to remove Transcode.");

      let lib = this.index.libraries.find((l) => l.name === transaction.$query.library);
      if (lib) {
        let transcode = this.index.transcodes[transaction.$query.id];
        if (transcode) {
          lib.removeOneTranscodeTask(transcode.id, false);
          this.servers.socketServer.inform("database", "transcodes", "change");
          LogBot.log(200, "Transcode removed", transcode);
        }
      } else {
        throw new Error("Library not found.");
      }
    }
  }
}
const wb = new WrangleBot();
export default wb;
export { WrangleBot, config };
