import {Injectable} from '@angular/core';
import {environment} from '../../environments/environment';
import {HttpClient} from '@angular/common/http';
import {BehaviorSubject, Observable} from 'rxjs';
import ApiResponse from '../structures/ApiResponse';
import {MenuItem} from './db/MenuItem';
import {DatabaseService} from './db/database.service';
import {ConnectionService} from 'ng-connection-service';
import {Codium} from './db/Codium';
import {Database} from './db/Database';
import {DatabaseColumn} from './db/DatabaseColumn';
import {DatabaseEntry} from './db/DatabaseEntry';
import {FileGroup} from './db/FileGroup';
import {Page} from './db/Page';
import {PageNode} from './db/PageNode';
import {debug} from '../utility';

export class ResourceNotAvailableOfflineError extends Error {
  constructor(msg = 'This resource is not yet available offline on this device.') {
    super(msg);
  }
}

export class OfflinePrefetchWouldOverwriteLocalDataError extends Error {
  constructor(msg = 'Cannot run offline prefetch. There is un-synced local data that would be overwritten.') {
    super(msg);
  }
}

@Injectable({
  providedIn: 'root'
})
export class ApiService {
  public isAuthenticated = false;
  public isPublicUser = false;
  public systemBase?: string;
  protected baseEndpoint: string = environment.backendBase;
  protected statUrl: string = environment.statUrl;
  protected versionUrl: string = environment.versionUrl;
  protected offline = false;
  public readonly offline$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);

  get isOffline() {
    return this.offline;
  }

  constructor(
      protected http: HttpClient,
      protected db: DatabaseService,
      protected connection: ConnectionService,
  ) {
    this.startOfflineObserver();
  }

  protected stopOfflineObserverClosure?: any;

  public stopOfflineObserver() {
    if ( this.stopOfflineObserverClosure ) {
      this.stopOfflineObserverClosure();
    }
  }

  protected startOfflineObserver() {
    const passiveCheckTime = 120;
    const checkTimes = [5, 5, 10, 10, 15, 15, 20, 20, 30, 30, 30, 60, 60, 500];
    let currentCheckTimeIndex = 0;
    let hasNetConnection = true;
    let hasServerConnection = true;
    let online = true;
    let passiveCheckInterval;
    let activeCheckInterval;
    let stopped = false;

    this.stopOfflineObserverClosure = () => {
      stopped = true;
      clearInterval(passiveCheckInterval);
      clearInterval(activeCheckInterval);
    };

    const checkServerConnection = async () => {
      if ( !hasNetConnection ) {
        return false;
      }

      return this.checkOnline();
    };

    const startPassiveCheck = () => {
      passiveCheckInterval = setInterval(async () => {
        if ( hasNetConnection ) {
          const server = await checkServerConnection();
          if ( server !== hasServerConnection ) {
            hasServerConnection = server;
            await handleNetConnectionEvent();
          }
        }
      }, passiveCheckTime * 1000);
    };

    const doActiveCheck = async () => {
      if ( activeCheckInterval ) {
        clearInterval(activeCheckInterval);
        if ( currentCheckTimeIndex < (checkTimes.length - 1) ) {
          currentCheckTimeIndex += 1;
        }
      }

      if ( hasNetConnection ) {
        const server = await checkServerConnection();
        if ( server !== hasServerConnection ) {
          hasServerConnection = server;
          await handleNetConnectionEvent();
        } else {
          if ( activeCheckInterval ) {
            activeCheckInterval = setInterval(doActiveCheck, checkTimes[currentCheckTimeIndex] * 1000);
          }
        }
      }
    };

    const handleNetConnectionEvent = async () => {
      if ( stopped ) {
        return;
      }

      if ( online && (!hasNetConnection || !hasServerConnection) ) {
        online = false;
        await this.makeOffline();
        clearInterval(passiveCheckInterval);
        activeCheckInterval = setInterval(doActiveCheck, checkTimes[currentCheckTimeIndex] * 1000);
      } else if ( !online && (hasNetConnection && hasServerConnection) ) {
        if ( activeCheckInterval ) {
          clearInterval(activeCheckInterval);
        }

        online = true;
        currentCheckTimeIndex = 0;
        await this.makeOnline();
        startPassiveCheck();
      }
    };

    this.connection.monitor().subscribe(isConnected => {
      hasNetConnection = isConnected;
      handleNetConnectionEvent();
    });

    startPassiveCheck();

    this.checkOnline().then(server => {
      hasServerConnection = server;
      handleNetConnectionEvent();
    });
  }

  public forceRestart() {
    window.location.href = `${this.systemBase || '/'}start`;
  }

  public checkOnline(): Promise<boolean> {
    return new Promise(res => {
      fetch(this.statUrl).then(resp => {
        res(resp && (resp.ok || resp.type === 'opaque'));
      }).catch(e => {
        console.error('Check Online Error', e);
        res(false);
      });
    });
  }

  public async makeOffline() {
    const offlineKV = await this.db.getKeyValue('needs_online_sync');
    offlineKV.data = true;
    await offlineKV.save();

    this.offline = true;
    this.offline$.next(true);
  }

  public async needsSync() {
    if ( !this.isAuthenticated || this.isPublicUser ) {
      return false;
    }

    const offlineKV = await this.db.getKeyValue('needs_online_sync');
    return Boolean(offlineKV.data);
  }

  public async makeOnline() {
    this.offline = false;
    this.offline$.next(false);
  }

  public get(endpoint, params = {}): Observable<ApiResponse> {
    return this.request(endpoint, params, 'get');
  }

  public post(endpoint, body = {}): Observable<ApiResponse> {
    return this.request(endpoint, body, 'post');
  }

  public delete(endpoint, body = {}): Observable<ApiResponse> {
    return this.request(endpoint, body, 'delete');
  }

  public request(endpoint, params = {}, method: 'get'|'post'|'delete' = 'get'): Observable<ApiResponse> {
    return this._request(this._build_url(endpoint), params, method);
  }

  public stat(): Observable<ApiResponse> {
    return new Observable<ApiResponse>(sub => {
      (async () => {
        const statKV = await this.db.getKeyValue('host_stat');

        // If offline, look up the last stored stat for information
        if ( this.isOffline ) {
          if ( typeof statKV !== 'object' ) {
            throw new Error('No locally stored host stat found.');
          }

          sub.next(new ApiResponse(statKV.data));
          sub.complete();
        }

        // Otherwise, fetch the stat and cache it locally
        this._request(this.statUrl).subscribe(apiResponse => {
          statKV.data = {status: apiResponse.status, message: apiResponse.message, data: apiResponse.data};
          statKV.save().then(() => {
            sub.next(statKV.data);
            sub.complete();
          });
        });
      })();

    });
  }

  public version(): Promise<string> {
    return new Promise(async (res, rej) => {
      const versionKV = await this.db.getKeyValue('app_version');

      // If offline, look up the local app version.
      if ( this.isOffline ) {
        if ( versionKV ) {
          return res(versionKV.data);
        } else {
          return rej(new Error('No local app version found.'));
        }
      }

      // Otherwise, look up the app version and store it locally
      this._request(this.versionUrl).subscribe({
        next: async result => {
          const version = result.data.text.trim();

          versionKV.data = version;
          await versionKV.save();

          res(version);
        },
        error: rej,
      });
    });
  }

  protected _request(endpoint, params = {}, method: 'get'|'post'|'delete' = 'get'): Observable<ApiResponse> {
    return new Observable<ApiResponse>(sub => {
      let data: any = {};
      if ( method === 'get' ) {
        data.params = params;
      } else {
        data = params;
      }

      this.http[method](endpoint, data).subscribe({
        next: (response: any) => {
          sub.next(new ApiResponse(response));
        },
        error: (err) => {
          const response = {
            status: err.status,
            message: err.message,
            data: err.error,
          };

          sub.next(new ApiResponse(response));
        },
      });
    });
  }

  public _build_url(endpoint, base = this.baseEndpoint) {
    if ( !endpoint.startsWith('/') ) {
      endpoint = `/${endpoint}`;
    }

    return `${base.endsWith('/') ? base.slice(0, -1) : base}${endpoint}`;
  }

  public async getToken(): Promise<string> {
    return new Promise(async (res, rej) => {
      if ( this.isOffline ) {
        return rej(new ResourceNotAvailableOfflineError());
      }

      this.get('token').subscribe({
        next: response => {
          return res(response.data);
        },
        error: rej,
      });
    });
  }

  public async syncOfflineData() {
    const dirtyRecords = await this.db.getDirtyRecords();
    const uuidMap = await new Promise(async (res, rej) => {
      this.post('/offline/sync', { dirtyRecords }).subscribe({
        next: async result => {
          console.log('sync result', result);
          res(result.data);
        },
        error: rej,
      });
    });

    // For now, we're not going to handle this great.
    // Rather, we're going to wait until the server supports proper versioning and date-based offline updates.
    // In the meantime, just purge everything and reload.
    await this.prefetchOfflineData(true);

    const offlineKV = await this.db.getKeyValue('needs_online_sync');
    offlineKV.data = false;
    await offlineKV.save();

    // Pre-fetch menu items
    await this.getMenuItems();
  }

  public async prefetchOfflineData(overwriteLocalData = false) {
    if ( this.isOffline ) {
      throw new ResourceNotAvailableOfflineError();
    }

    if ( !overwriteLocalData && await this.needsSync() ) {
      throw new OfflinePrefetchWouldOverwriteLocalDataError();
    }

    const data: any = await new Promise((res, rej) => {
      this.get('/offline/prefetch').subscribe({
        next: result => {
          if ( !result.data ) {
            return rej(new TypeError('Unable to parse data from API response.'));
          }

          res(result.data);
        },
        error: rej,
      });
    });

    if ( Array.isArray(data.pages) ) {
      await this.db.pages.clear();

      for ( const rec of data.pages ) {
        const page = new Page(
            rec.UUID,
            rec.Name,
            rec.OrgUserId,
            rec.IsPublic,
            rec.IsVisibleInMenu,
            rec.ParentId,
            rec.NodeIds,
            rec.CreatedAt,
            rec.UpdatedAt,
            rec.Active,
            rec.CreatedUserId,
            rec.UpdateUserId,
            rec.ChildPageIds,
            rec.noDelete,
            rec.virtual
        );

        await page.save();
      }
    }

    if ( Array.isArray(data.pageNodes) ) {
      await this.db.pageNodes.clear();

      for ( const rec of data.pageNodes ) {
        const node = new PageNode(
            rec.UUID,
            rec.Type,
            rec.Value ? JSON.stringify(rec.Value) : JSON.stringify({}),
            rec.PageId,
            rec.CreatedAt,
            rec.UpdatedAt,
            rec.CreatedUserId,
            rec.UpdateUserId
        );

        await node.save();
      }
    }

    if ( Array.isArray(data.codiums) ) {
      await this.db.codiums.clear();

      for ( const rec of data.codiums ) {
        const code = new Codium(
            rec.Language,
            rec.NodeId,
            rec.PageId,
            rec.code,
            rec.UUID
        );

        await code.save();
      }
    }

    if ( Array.isArray(data.databases) ) {
      await this.db.databases.clear();

      for ( const rec of data.databases ) {
        const db = new Database(
            rec.Name,
            rec.NodeId,
            rec.PageId,
            rec.ColumnIds,
            rec.UUID,
            rec.Active
        );

        await db.save();
      }
    }

    if ( Array.isArray(data.databaseColumns) ) {
      await this.db.databaseColumns.clear();

      for ( const rec of data.databaseColumns ) {
        const col = new DatabaseColumn(
            rec.headerName,
            rec.field,
            rec.DatabaseId,
            rec.UUID,
            rec.Type,
            rec.additionalData
        );

        await col.save();
      }
    }

    if ( Array.isArray(data.databaseEntries) ) {
      await this.db.databaseEntries.clear();

      for ( const rec of data.databaseEntries ) {
        const entry = new DatabaseEntry(
            rec.DatabaseId,
            rec.UUID,
            JSON.stringify(rec.RowData || {})
        );

        await entry.save();
      }
    }

    if ( Array.isArray(data.fileGroups) ) {
      await this.db.fileGroups.clear();

      for ( const rec of data.fileGroups ) {
        const group = new FileGroup(
            rec.NodeId,
            rec.PageId,
            rec.FileIds,
            JSON.stringify(rec.files || []),
            rec.UUID
        );

        await group.save();
      }
    }
  }

  public getDeviceToken(): Promise<string> {
    return new Promise(async (res, rej) => {
      if ( this.isPublicUser ) {
        return res();
      }

      const tokenKV = await this.db.getKeyValue('device_token');
      if ( !tokenKV.data && this.isOffline ) {
        return rej(new ResourceNotAvailableOfflineError());
      }

      if ( tokenKV.data ) {
        const expDate = new Date(tokenKV.data.expiration_date);
        if (expDate > new Date()) {
          return res(tokenKV.data.token);
        }
      }

      this.get(`/session/device-token`).subscribe({
        next: async result => {
          tokenKV.data = result.data;
          await tokenKV.save();
          res(result.data.token);
        },
        error: rej,
      });
    });
  }

  public resumeSession(): Promise<void> {
    return new Promise(async (res, rej) => {
      if ( !this.isOffline ) {
        this.getDeviceToken().then(token => {
          debug('Got device token:', token);
          if ( !token ) {
            return res();
          }

          this.post(`/session/resume/${token}`).subscribe({
            next: result => {
              res();
            },
            error: rej,
          });
        }).catch(rej);
      }
    });
  }

  public getMenuItems(pageOnly: boolean = false, virtualRootPageId?: string): Promise<any[]> {
    return new Promise(async (res, rej) => {
      await this.db.createSchemata();

      // If offline, fetch the menu from the database
      if ( this.isOffline ) {
        const items = pageOnly ? await this.db.menuItems.where({ type: 'page' }).toArray() : await this.db.menuItems.toArray();
        const nodes = MenuItem.inflateTree(items as MenuItem[]);
        return res(nodes);
      }

      let loadUrl = '/menu/items';
      if ( virtualRootPageId ) {
        loadUrl += `?virtualRootPageId=${virtualRootPageId}`;
      }

      if ( pageOnly ) {
        loadUrl += (virtualRootPageId ? '&' : '?') + 'type=page';
      }

      // Download the latest menu items
      const tree: any[] = await new Promise(res2 => {
        this.get(loadUrl).subscribe({
          next: async result => {
            const nodes = result.data as any[];
            const items = MenuItem.deflateTree(nodes);

            // Update the locally stored nodes
            if ( !pageOnly ) {
              await this.db.menuItems.clear();
              await Promise.all(items.map(item => item.save()));
            }

            res2(nodes);
          },
          error: rej,
        });
      });

      res(tree);
    });
  }

  public getSessionData(): Promise<any> {
    return new Promise(async (res, rej) => {
      const sessionKV = await this.db.getKeyValue('session_data');
      const authenticatedUserKV = await this.db.getKeyValue('authenticated_user');

      // If offline, just return the locally cached session data
      if ( this.isOffline ) {
        if ( typeof sessionKV.data !== 'object' ) {
          return rej(new Error('No locally cached session data found.'));
        }

        return res(sessionKV.data);
      }

      // Otherwise, fetch the session data from the server and cache it locally
      this.get('/session').subscribe(async result => {
        // If the locally logged in user is not the one in the server session, purge everything and reset
        if (
            authenticatedUserKV.data
            && authenticatedUserKV.data?.user?.id
            && authenticatedUserKV.data.user.id !== result.data.user.id
        ) {
          await this.cleanSession(result.data);
          return res(result.data);
        }

        sessionKV.data = result.data;
        await sessionKV.save();
        res(sessionKV.data);
      });
    });
  }

  protected async cleanSession(data: any) {
    await this.db.purge();
    await new Promise((res, rej) => {
      this.stat().subscribe({
        next: res,
        error: rej,
      });
    });

    const sessionKV = await this.db.getKeyValue('session_data');
    sessionKV.data = data;
    await sessionKV.save();

    await this.getDeviceToken();
  }

  public saveSessionData(data: any): Promise<void> {
    return new Promise(async (res, rej) => {
      // Update the local session data
      const sessionKV = await this.db.getKeyValue('session_data');
      sessionKV.data = data;
      await sessionKV.save();

      // If we're not offline, then update the data on the server
      if ( !this.isOffline ) {
        await new Promise(res2 => {
          this.post('/session', data || {}).subscribe({
            next: res2,
            error: rej,
          });
        });
      }

      res();
    });
  }

  public deleteCodium(PageId: string, NodeId: string, CodiumId: string): Promise<void> {
    return new Promise(async (res, rej) => {
      const existingLocalCodiums = await this.db.codiums.where({ UUID: CodiumId }).toArray();
      const existingLocalCodium = existingLocalCodiums.length > 0 ? existingLocalCodiums[0] as Codium : undefined;

      if ( this.isOffline ) {
        if ( existingLocalCodium ) {
          existingLocalCodium.deleted = true;
          existingLocalCodium.needsServerUpdate = true;
          await existingLocalCodium.save();
          return res();
        } else {
          return rej(new ResourceNotAvailableOfflineError());
        }
      }

      this.post(`/code/${PageId}/${NodeId}/delete/${CodiumId}`).subscribe({
        next: async result => {
          if ( existingLocalCodium ) {
            await this.db.codiums.delete(existingLocalCodium.id);
          }
          res();
        },
        error: rej,
      });
    });
  }

  public saveCodium(PageId: string, NodeId: string, CodiumId: string, data: any): Promise<any> {
    return new Promise(async (res, rej) => {
      const existingLocalCodiums = await this.db.codiums.where({ UUID: CodiumId }).toArray();
      const existingLocalCodium = existingLocalCodiums.length > 0 ? existingLocalCodiums[0] as Codium : undefined;

      // If we're offline, update or create the local record
      if ( this.isOffline ) {
        if ( existingLocalCodium ) {
          existingLocalCodium.fillFromRecord(data);
          existingLocalCodium.needsServerUpdate = true;

          await existingLocalCodium.save();
          return res(existingLocalCodium.getSaveRecord());
        } else {
          const newLocalCodium = new Codium(
              data.Language,
              NodeId,
              PageId,
              data.code,
              data.UUID || Codium.getUUID(),
              true,
          );

          await newLocalCodium.save();
          return res(newLocalCodium.getSaveRecord());
        }
      }

      // If we're online, save the data and update our local records
      this.post(`/code/${PageId}/${NodeId}/set/${CodiumId}`, data).subscribe({
        next: async result => {
          if ( existingLocalCodium ) {
            existingLocalCodium.fillFromRecord(result.data);
            existingLocalCodium.needsServerUpdate = false;

            await existingLocalCodium.save();
            return res(result.data);
          } else {
            const newLocalCodium = new Codium(
                result.data.Language,
                result.data.NodeId,
                result.data.PageId,
                result.data.code,
                result.data.UUID,
            );

            await newLocalCodium.save();
            return res(result.data);
          }
        },
        error: rej,
      });
    });
  }

  public createCodium(PageId: string, NodeId: string): Promise<any> {
    return new Promise(async (res, rej) => {
      // If offline, create a new local DB record
      if ( this.isOffline ) {
        const newLocalCodium = new Codium(
            'javascript',
            NodeId,
            PageId,
            '',
            Codium.getUUID(),
            true
        );

        await newLocalCodium.save();
        return res(newLocalCodium.getSaveRecord());
      }

      // If online, create a new record on the server and sync it to the local db
      this.post(`/code/${PageId}/${NodeId}/create`).subscribe({
        next: async result => {
          const newLocalCodium = new Codium(
              result.data.Language,
              result.data.NodeId,
              result.data.PageId,
              result.data.code,
              result.data.UUID,
          );

          await newLocalCodium.save();
          res(result.data);
        },
        error: rej,
      });
    });
  }

  public getCodium(PageId: string, NodeId: string, CodiumId: string): Promise<any> {
    return new Promise(async (res, rej) => {
      const existingLocalCodiums = await this.db.codiums.where({ UUID: CodiumId }).toArray();
      const existingLocalCodium = existingLocalCodiums.length > 0 ? existingLocalCodiums[0] as Codium : undefined;

      // If offline, try to load it from the local DB
      if ( this.isOffline ) {
        if ( existingLocalCodium ) {
          return res(existingLocalCodium.getSaveRecord());
        } else {
          return rej(new ResourceNotAvailableOfflineError());
        }
      }

      // If online, fetch the codium and store/update it locally
      this.get(`/code/${PageId}/${NodeId}/get/${CodiumId}`).subscribe({
        next: async result => {
          if ( existingLocalCodium ) {
            existingLocalCodium.fillFromRecord(result.data);

            await existingLocalCodium.save();
            return res(result.data);
          } else {
            const newLocalCodium = new Codium(
                result.data.Language,
                result.data.NodeId,
                result.data.PageId,
                result.data.code,
                result.data.UUID
            );

            await newLocalCodium.save();
            return res(result.data);
          }
        },
        error: rej,
      });
    });
  }

  public deleteDatabase(PageId: string, NodeId: string, DatabaseId: string): Promise<void> {
    return new Promise(async (res, rej) => {
      const existingLocalDatabase = await this.db.databases.where({ UUID: DatabaseId }).first() as Database;

      if ( this.isOffline ) {
        if ( existingLocalDatabase ) {
          existingLocalDatabase.needsServerUpdate = true;
          existingLocalDatabase.deleted = true;
          await existingLocalDatabase.save();
          return res();
        } else {
          return rej(new ResourceNotAvailableOfflineError());
        }
      }

      this.post(`/db/${PageId}/${NodeId}/drop/${DatabaseId}`).subscribe({
        next: async result => {
          if ( existingLocalDatabase ) {
            await this.db.databases.delete(existingLocalDatabase.id);
            res();
          }
        },
        error: rej,
      });
    });
  }

  public getDatabaseEntries(PageId: string, NodeId: string, DatabaseId: string): Promise<any[]> {
    return new Promise(async (res, rej) => {
      if ( this.isOffline ) {
        const rows = (await this.db.databaseEntries.where({ DatabaseId }).toArray()) as DatabaseEntry[];
        return res(rows.filter(x => !x.deleted).map(x => x.inflateToRecord()));
      }

      this.get(`/db/${PageId}/${NodeId}/get/${DatabaseId}/data`).subscribe({
        next: async result => {
          // Resolve first so the GUI doesn't need to wait for the DB to sync to render
          res(result.data);

          for ( const row of result.data ) {
            const existingDatabaseEntry = await this.db.databaseEntries.where({
              DatabaseId, UUID: row.UUID,
            }).first() as DatabaseEntry;

            if ( existingDatabaseEntry ) {
              existingDatabaseEntry.fillFromRecord(row);
              await existingDatabaseEntry.save();
            } else {
              const newDatabaseEntry = new DatabaseEntry(
                  row.DatabaseId,
                  row.UUID,
                  JSON.stringify(row.RowData)
              );

              await newDatabaseEntry.save();
            }
          }
        },
        error: rej,
      });
    });
  }

  public getDatabaseColumns(PageId: string, NodeId: string, DatabaseId: string): Promise<any[]> {
    return new Promise(async (res, rej) => {
      // If offline, fetch the columns from the local database
      if ( this.isOffline ) {
        const columns = (await this.db.databaseColumns.where({ DatabaseId }).toArray()) as DatabaseColumn[];
        return res(columns.filter(x => !x.deleted).map(x => x.getSaveRecord()));
      }

      // If online, fetch the columns and sync the local database
      this.get(`/db/${PageId}/${NodeId}/get/${DatabaseId}/columns`).subscribe({
        next: async results => {
          // Resolve this first, so the UI doesn't need to wait for the db sync to render
          res(results.data);

          for ( const def of results.data ) {
            const existingColumnDef = await this.db.databaseColumns.where({
              DatabaseId, UUID: def.UUID,
            }).first() as DatabaseColumn;

            if ( existingColumnDef ) {
              existingColumnDef.fillFromRecord(def);
              await existingColumnDef.save();
            } else {
              const newColumnDef = new DatabaseColumn(
                  def.headerName,
                  def.field,
                  def.DatabaseId,
                  def.UUID,
                  def.Type,
                  def.additionalData,
              );

              await newColumnDef.save();
            }
          }
        },
        error: rej,
      });
    });
  }

  public createDatabase(PageId: string, NodeId: string): Promise<any> {
    return new Promise(async (res, rej) => {
      if ( this.isOffline ) {
        const newLocalDatabase = new Database(
            'New Database',
            NodeId,
            PageId,
            [],
            Database.getUUID(),
            true,
            true
        );

        await newLocalDatabase.save();
        return res(newLocalDatabase.getSaveRecord());
      }

      this.post(`/db/${PageId}/${NodeId}/create`).subscribe({
        next: async result => {
          const newLocalDatabase = new Database(
              result.data.Name,
              result.data.NodeId,
              result.data.PageId,
              result.data.ColumnIds,
              result.data.UUID,
              result.data.Active
          );

          await newLocalDatabase.save();
          return res(result.data);
        },
        error: rej,
      });
    });
  }

  public getDatabase(PageId: string, NodeId: string, DatabaseId: string): Promise<any> {
    return new Promise(async (res, rej) => {
      const existingLocalDatabases = await this.db.databases.where({ UUID: DatabaseId }).toArray();
      const existingLocalDatabase = existingLocalDatabases.length > 0 ? existingLocalDatabases[0] as Database : undefined;

      if ( this.isOffline ) {
        if ( existingLocalDatabase ) {
          return res(existingLocalDatabase.getSaveRecord());
        } else {
          return rej(new ResourceNotAvailableOfflineError());
        }
      }

      this.get(`/db/${PageId}/${NodeId}/get/${DatabaseId}`).subscribe({
        next: async result => {
          if ( existingLocalDatabase ) {
            existingLocalDatabase.fillFromRecord(result.data);

            await existingLocalDatabase.save();
            return res(result.data);
          } else {
            const newLocalDatabase = new Database(
                result.data.Name,
                result.data.NodeId,
                result.data.PageId,
                result.data.ColumnIds,
                result.data.UUID,
                result.data.Active
            );

            await newLocalDatabase.save();
            return res(result.data);
          }
        },
        error: rej,
      });
    });
  }

  public saveDatabaseEntries(PageId: string, NodeId: string, DatabaseId: string, rowData: any[]): Promise<any[]> {
    return new Promise(async (res, rej) => {
      const existingDatabaseEntries = await this.db.databaseEntries.where({ DatabaseId }).toArray() as DatabaseEntry[];
      if ( this.isOffline ) {
        const returnData = [];

        for ( const entry of existingDatabaseEntries ) {
          entry.deleted = true;
          entry.needsServerUpdate = true;
          await entry.save();
        }

        for ( const row of rowData ) {
          const newDatabaseEntry = new DatabaseEntry(
              DatabaseId,
              DatabaseEntry.getUUID(),
              JSON.stringify(row),
              true
          );

          await newDatabaseEntry.save();
          returnData.push(newDatabaseEntry.inflateToRecord());
        }

        return res(returnData);
      }

      this.post(`/db/${PageId}/${NodeId}/set/${DatabaseId}/data`, rowData).subscribe({
        next: async result => {
          await this.db.databaseEntries.bulkDelete(existingDatabaseEntries.map(x => x.id));

          for ( const row of result.data ) {
            const newDatabaseEntry = new DatabaseEntry(
                row.DatabaseId,
                row.UUID,
                JSON.stringify(row.RowData)
            );

            await newDatabaseEntry.save();
          }

          return res(result.data);
        },
        error: rej,
      });
    });
  }

  public saveDatabaseColumns(PageId: string, NodeId: string, DatabaseId: string, columns: any[]): Promise<void> {
    return new Promise(async (res, rej) => {
      if ( this.isOffline ) {
        for ( const def of columns ) {
          const existingColumnDef = await this.db.databaseColumns.where({
            DatabaseId, ...(def.UUID ? { UUID: def.UUID } : { headerName: def.headerName }),
          }).first() as DatabaseColumn;

          if ( existingColumnDef ) {
            existingColumnDef.fillFromRecord(def);
            existingColumnDef.needsServerUpdate = true;
            await existingColumnDef.save();
          } else {
            const newColumnDef = new DatabaseColumn(
                def.headerName,
                def.field,
                def.DatabaseId,
                def.UUID || DatabaseColumn.getUUID(),
                def.Type,
                def.additionalData,
                true
            );

            await newColumnDef.save();

            // Add the column to the database's column IDs
            const parentDatabase = await this.db.databases.where({ UUID: DatabaseId }).first() as Database;
            if ( parentDatabase ) {
              parentDatabase.ColumnIds.push(newColumnDef.UUID);
              await parentDatabase.save();
            }
          }
        }

        return res();
      }

      this.post(`/db/${PageId}/${NodeId}/set/${DatabaseId}/columns`, { columns }).subscribe({
        next: async results => {
          for ( const def of results.data ) {
            const existingColumnDef = await this.db.databaseColumns.where({
              DatabaseId, UUID: def.UUID,
            }).first() as DatabaseColumn;

            if ( existingColumnDef ) {
              existingColumnDef.fillFromRecord(def);
              existingColumnDef.needsServerUpdate = false;
              await existingColumnDef.save();
            } else {
              const newColumnDef = new DatabaseColumn(
                  def.headerName,
                  def.field,
                  def.DatabaseId,
                  def.UUID,
                  def.Type,
                  def.additionalData,
              );

              await newColumnDef.save();
            }
          }

          res();
        },
        error: rej,
      });
    });
  }

  public saveDatabaseName(PageId: string, NodeId: string, DatabaseId: string, name: string): Promise<void> {
    return new Promise(async (res, rej) => {
      const existingDatabase = await this.db.databases.where({ UUID: DatabaseId }).first() as Database;

      if ( this.isOffline ) {
        if ( existingDatabase ) {
          existingDatabase.Name = name;
          existingDatabase.needsServerUpdate = true;
          await existingDatabase.save();
          return res();
        } else {
          return rej(new ResourceNotAvailableOfflineError());
        }
      }

      this.post(`/db/${PageId}/${NodeId}/set/${DatabaseId}/name`, { Name: name }).subscribe({
        next: async result => {
          if ( existingDatabase ) {
            existingDatabase.Name = name;
            await existingDatabase.save();
            res();
          }
        },
        error: rej,
      });
    });
  }

  public deleteFileGroup(PageId: string, NodeId: string, FileGroupId: string): Promise<void> {
    return new Promise(async (res, rej) => {
      const existingFileGroup = await this.db.fileGroups.where({ UUID: FileGroupId }).first() as FileGroup;

      if ( this.isOffline ) {
        if ( existingFileGroup ) {
          existingFileGroup.deleted = true;
          existingFileGroup.needsServerUpdate = true;
          await existingFileGroup.save();
          return res();
        } else {
          return rej(new ResourceNotAvailableOfflineError());
        }
      }

      this.post(`/files/${PageId}/${NodeId}/delete/${FileGroupId}`).subscribe({
        next: async result => {
          if ( existingFileGroup ) {
            await this.db.fileGroups.delete(existingFileGroup.id);
            res();
          }
        },
        error: rej,
      });
    });
  }

  public createFileGroup(PageId: string, NodeId: string): Promise<any> {
    return new Promise(async (res, rej) => {
      if ( this.isOffline ) {
        const newFileGroup = new FileGroup(
            NodeId,
            PageId,
            [],
            JSON.stringify([]),
            FileGroup.getUUID(),
            true
        );

        await newFileGroup.save();
        return res(newFileGroup.inflateToRecord());
      }

      this.post(`/files/${PageId}/${NodeId}/create`).subscribe({
        next: async result => {
          const newFileGroup = new FileGroup(
              result.data.NodeId,
              result.data.PageId,
              result.data.FileIds,
              JSON.stringify(result.data.files),
              result.data.UUID
          );

          await newFileGroup.save();
          res(result.data);
        },
        error: rej,
      });
    });
  }

  public getFileGroup(PageId: string, NodeId: string, FileGroupId: string): Promise<any> {
    return new Promise(async (res, rej) => {
      const existingFileGroup = await this.db.fileGroups.where({ UUID: FileGroupId }).first() as FileGroup;
      if ( this.isOffline ) {
        if ( existingFileGroup ) {
          return res(existingFileGroup.inflateToRecord());
        } else {
          return rej(new ResourceNotAvailableOfflineError());
        }
      }

      this.get(`/files/${PageId}/${NodeId}/get/${FileGroupId}`).subscribe({
        next: async result => {
          if ( existingFileGroup ) {
            existingFileGroup.fillFromRecord(result.data);
            existingFileGroup.needsServerUpdate = false;
            await existingFileGroup.save();
          } else {
            const newFileGroup = new FileGroup(
                result.data.NodeId,
                result.data.PageId,
                result.data.FileIds,
                JSON.stringify(result.data.files),
                result.data.UUID
            );

            await newFileGroup.save();
          }

          res(result.data);
        },
        error: rej,
      });
    });
  }

  public createFileBox(PageId: string, NodeId: string, name: string, rootUUID?: string, parentUUID?: string): Promise<any> {
    return new Promise(async (res, rej) => {
      if ( this.isOffline ) {
        return rej(new ResourceNotAvailableOfflineError());
      }

      this.post(`/file-box/${PageId}/${NodeId}/create`, { name, rootUUID, parentUUID }).subscribe({
        next: async result => {
          res(result.data);
        },
        error: rej,
      });
    });
  }

  public getFileBox(PageId: string, NodeId: string, FileBoxId: string): Promise<any> {
    return new Promise(async (res, rej) => {
      if ( this.isOffline ) {
        return rej(new ResourceNotAvailableOfflineError());
      }

      this.get(`/file-box/${PageId}/${NodeId}/${FileBoxId}`).subscribe({
        next: async result => {
          res(result.data);
        },
        error: rej,
      });
    });
  }

  public getFileBoxFiles(PageId: string, NodeId: string, FileBoxId: string): Promise<any> {
    return new Promise(async (res, rej) => {
      if ( this.isOffline ) {
        return rej(new ResourceNotAvailableOfflineError());
      }

      this.get(`/file-box/${PageId}/${NodeId}/${FileBoxId}/files`).subscribe({
        next: async result => {
          res(result.data);
        },
        error: rej,
      });
    });
  }

  public getFileBoxChildren(PageId: string, NodeId: string, FileBoxId: string): Promise<any> {
    return new Promise(async (res, rej) => {
      if ( this.isOffline ) {
        return rej(new ResourceNotAvailableOfflineError());
      }

      this.get(`/file-box/${PageId}/${NodeId}/${FileBoxId}/children`).subscribe({
        next: async result => {
          res(result.data);
        },
        error: rej,
      });
    });
  }

  public updateFileBox(PageId: string, NodeId: string, FileBoxId: string, data: any): Promise<any> {
    return new Promise(async (res, rej) => {
      if ( this.isOffline ) {
        return rej(new ResourceNotAvailableOfflineError());
      }

      this.post(`/file-box/${PageId}/${NodeId}/${FileBoxId}`, data).subscribe({
        next: result => {
          res(result.data);
        },
        error: rej,
      });
    });
  }

  public updateFileBoxFile(PageId: string, NodeId: string, FileBoxId: string, FileBoxFileId: string, data: any): Promise<any> {
    return new Promise(async (res, rej) => {
      if ( this.isOffline ) {
        return rej(new ResourceNotAvailableOfflineError());
      }

      this.post(`/file-box/${PageId}/${NodeId}/${FileBoxId}/files/${FileBoxFileId}`, data).subscribe({
        next: result => {
          res(result.data);
        },
        error: rej,
      });
    });
  }

  public deleteFileBoxFile(PageId: string, NodeId: string, FileBoxId: string, FileBoxFileId: string): Promise<any> {
    return new Promise(async (res, rej) => {
      if ( this.isOffline ) {
        return rej(new ResourceNotAvailableOfflineError());
      }

      this.delete(`/file-box/${PageId}/${NodeId}/${FileBoxId}/files/${FileBoxFileId}`).subscribe({
        next: result => {
          res(result.data);
        },
        error: rej,
      });
    });
  }

  public deleteFileBox(PageId: string, NodeId: string, FileBoxId: string): Promise<any> {
    return new Promise(async (res, rej) => {
      if ( this.isOffline ) {
        return rej(new ResourceNotAvailableOfflineError());
      }

      this.delete(`/file-box/${PageId}/${NodeId}/${FileBoxId}`).subscribe({
        next: result => {
          res(result.data);
        },
        error: rej,
      });
    });
  }

  public uploadFileBoxFiles(PageId: string, NodeId: string, FileBoxId: string, formData: FormData): Promise<any> {
    return new Promise(async (res, rej) => {
      if ( this.isOffline ) {
        return rej(new ResourceNotAvailableOfflineError());
      }

      this.post(`/file-box/${PageId}/${NodeId}/${FileBoxId}/files`, formData).subscribe({
        next: async result => {
          return res(result.data);
        },
        error: rej,
      });
    });
  }

  public getFileBoxFileDownloadUrl(PageId: string, NodeId: string, FileBoxId: string, FileBoxFileId: string): string {
    return this._build_url(`/file-box/${PageId}/${NodeId}/${FileBoxId}/files/${FileBoxFileId}`);
  }

  public moveMenuNode(MovedPageId: string, ParentPageId: string): Promise<any> {
    return new Promise(async (res, rej) => {
      if ( this.isOffline ) {
        return;
      }

      this.post('/menu/move-node', { MovedPageId, ParentPageId }).subscribe({
        next: async result => {
          if ( result.data.status !== 200 ) {
            return rej(new Error (result.data.message || 'An unknown error has occurred.'));
          }

          res();
        },
        error: err => {
          rej(err);
        },
      });
    });
  }
}
