/*
 Copyright (C) 2022-2024 3NSoft Inc.

 This program is free software: you can redistribute it and/or modify it under
 the terms of the GNU General Public License as published by the Free Software
 Foundation, either version 3 of the License, or (at your option) any later
 version.

 This program is distributed in the hope that it will be useful, but
 WITHOUT ANY WARRANTY; without even the implied warranty of
 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 See the GNU General Public License for more details.

 You should have received a copy of the GNU General Public License along with
 this program. If not, see <http://www.gnu.org/licenses/>.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NamedProcs } from '../../../shared-libs/processes/named-procs';
import { randomStr } from '../base/random';
import type {
  FileException,
  FileInfo,
  FolderInfo,
  ItemAttrs,
  LabelledFileStore,
  ReadonlyFS,
  WritableFS,
  XAttrsChanges,
} from './types';

export async function makeLabelledFileStoreIn(
  pathInAppFSs: string,
): Promise<LabelledFileStore> {
  const syncedFS = await w3n.storage!.getAppSyncedFS()
    .then(fs => fs.writableSubRoot(pathInAppFSs));
  const localFS = await w3n.storage!.getAppLocalFS()
    .then(fs => fs.writableSubRoot(pathInAppFSs));
  return await makeLabelledFileStore(syncedFS, localFS);
}

export function makeLabelledFileStore(
  syncedFS: WritableFS, localFS: WritableFS,
): Promise<LabelledFileStore> {

  return LabelledStore.makeSynced(syncedFS, localFS);
}

export interface FileStoreException extends web3n.RuntimeException {
  type: 'labelled-file-store';
  notFound?: true;
  notBlob?: true;
  notDirectory?: true;
}

function makeExc(fields: Partial<FileStoreException>): FileStoreException {
  const exc: FileStoreException = {
    runtimeException: true,
    type: 'labelled-file-store',
  };
  for (const [field, value] of Object.entries(fields)) {
    (exc as any)[field] = value;
  }
  return exc;
}

function wrapErr(err: any, message: string): FileStoreException {
  return makeExc({ cause: err, message });
}

const ID_ATTR_NAME = 'lfs:id';
const TYPE_ATTR_NAME = 'lfs:type';

const BUCKET_TOP_COUNT = 200;

const DATA_FOLDER_NAME = 'lfs_data';
const LAST_BUCKET_FNAME = 'lfs_last-bucket';
const FILE_NAME_LEN = 4;
const FOLDER_NAME_LEN = 2;
const FOLDER_DEPTH = 3;


class LabelledStore implements LabelledFileStore {
  private readonly procs = new NamedProcs();
  private bucket: string | undefined = undefined;
  private bucketCount = 0;

  private constructor(
    private readonly mainFS: WritableFS,
    private readonly dataFS: WritableFS,
    private readonly scratchFS: WritableFS,
  ) {
    Object.seal(this);
  }

  static async makeSynced(
    syncedFS: WritableFS, localFS: WritableFS,
  ): Promise<LabelledFileStore> {
    if (!syncedFS.v) {
      throw new Error(`Store needs versioned fs for data, while given one isn't.`);
    }
    if (syncedFS.type !== 'synced') {
      throw new Error(`Store needs synced fs for data, while given one isn't.`);
    }
    if (!localFS.v) {
      throw new Error(`Store needs versioned fs for local scratch fs, while given one isn't.`);
    }
    if (localFS.type !== 'local') {
      throw new Error(`Store needs local fs for local scratch fs, while given one isn't.`);
    }
    const dataFS = await syncedFS.writableSubRoot(DATA_FOLDER_NAME);
    const store = new LabelledStore(syncedFS, dataFS, localFS);
    await store.setBucket();
    return store.syncWrap();
  }

  private syncWrap(): LabelledFileStore {
    return {
      addBlob: this.addBlob.bind(this),
      addFolder: this.addFolder.bind(this),
      getBlob: id => this.procs.startOrChain(id, () => this.getBlob(id)),
      getFolderRO: id => this.procs.startOrChain(id,
        () => this.getFolderRO(id)),
      getFolderWR: id => this.procs.startOrChain(id,
        () => this.getFolderWR(id)),
      getInfo: id => this.procs.startOrChain(id, () => this.getInfo(id)),
      delete: id => this.procs.startOrChain(id, () => this.delete(id)),
      updateBlob: (id, blob) => this.procs.startOrChain(id,
        () => this.updateBlob(id, blob)),
      updateInfo: (id, info) => this.procs.startOrChain(id,
        () => this.updateInfo(id, info)),
    };
  }

  private async generateId(): Promise<string> {
    if (!this.bucket || (this.bucketCount > BUCKET_TOP_COUNT)) {
      await this.setBucket();
    }
    return `${this.bucket}/${randomStr(FILE_NAME_LEN)}`;
  }

  private async setBucket(): Promise<void> {
    try {
      if (this.bucket) {
        this.bucket = await newBucketInTree(this.dataFS, this.bucket);
        this.bucketCount = 0;
      } else {
        const lastBucket = await this.scratchFS.readTxtFile(
          LAST_BUCKET_FNAME,
        ).catch((exc: FileException) => {
          if (!exc.notFound) {
            throw exc;
          }
        });
        if (!lastBucket) {
          this.bucket = await newBucketTree(this.dataFS);
          this.bucketCount = 0;
        } else {
          const count = (await this.dataFS.listFolder(
            lastBucket,
          ).catch(async err => {
            await this.scratchFS.deleteFile(LAST_BUCKET_FNAME)
              .catch(noop);
            throw err;
          })).length;
          this.bucket = lastBucket;
          this.bucketCount = count;
          if (this.bucketCount > BUCKET_TOP_COUNT) {
            this.bucket = await newBucketInTree(this.dataFS, this.bucket);
            this.bucketCount = 0;
          }
        }
      }
      await this.scratchFS.writeTxtFile(LAST_BUCKET_FNAME, this.bucket);
    } catch (err) {
      throw wrapErr(err, `Fail to set store bucket`);
    }
  }

  async addBlob(blob: Blob, info?: ItemAttrs): Promise<string> {
    try {
      const id = await this.generateId();
      const bytes = new Uint8Array(await blob.arrayBuffer());
      await this.dataFS.writeBytes(
        id, bytes, { create: true, exclusive: true },
      );
      this.bucketCount += 1;
      const attrsChanges = ((info && (Object.keys(info).length > 0)) ?
        infoToAttrChanges(info) : { set: {} });
      attrsChanges.set![ID_ATTR_NAME] = id;
      attrsChanges.set![TYPE_ATTR_NAME] = blob.type;
      await this.dataFS.updateXAttrs(id, attrsChanges);
      return id;
    } catch (exc) {
      if ((exc as FileException).alreadyExists
        || (exc as FileException).isDirectory) {
        return this.addBlob(blob, info);
      } else {
        throw wrapErr(exc, `Fail to save file`);
      }
    }
  }

  async addFolder(folder: ReadonlyFS, info?: ItemAttrs): Promise<string> {
    try {
      const id = await this.generateId();
      await this.dataFS.saveFolder(folder, id);
      this.bucketCount += 1;
      const attrsChanges = ((info && (Object.keys(info).length > 0)) ?
        infoToAttrChanges(info) : { set: {} });
      attrsChanges.set![ID_ATTR_NAME] = id;
      await this.dataFS.updateXAttrs(id, attrsChanges);
      return id;
    } catch (exc) {
      if ((exc as FileException).alreadyExists
        || (exc as FileException).isDirectory) {
        return this.addFolder(folder, info);
      } else {
        throw wrapErr(exc, `Fail to save folder`);
      }
    }
  }

  async updateBlob(id: string, blob: Blob): Promise<void> {
    const bytes = new Uint8Array(await blob.arrayBuffer());
    await this.dataFS.writeBytes(id, bytes, { create: false })
      .catch((exc: FileException) => {
        if (exc.notFound) {
          throw makeExc({ notFound: true });
        } else if (exc.notFile) {
          throw makeExc({ notBlob: true });
        } else {
          throw wrapErr(exc, `Fail to update content of ${id}`);
        }
      });
    const attrsChanges: XAttrsChanges = { set: {} };
    attrsChanges.set![TYPE_ATTR_NAME] = blob.type;
    await this.dataFS.updateXAttrs(id, attrsChanges);
  }

  async updateInfo(id: string, info: ItemAttrs): Promise<void> {
    const attrsChanges = infoToAttrChanges(info);
    await this.dataFS.updateXAttrs(id, attrsChanges)
      .catch((exc: FileException) => {
        if (exc.notFound) {
          throw makeExc({ notFound: true });
        } else {
          throw wrapErr(exc, `Fail to update info of ${id}`);
        }
      });
  }

  async getBlob(id: string): Promise<Blob> {
    const bytes = await this.dataFS.readBytes(id)
      .catch((exc: FileException) => {
        if (exc.notFound) {
          throw makeExc({ notFound: true });
        } else if (exc.notFile) {
          throw makeExc({ notBlob: true });
        } else {
          throw wrapErr(exc, `Fail to read content of ${id}`);
        }
      });
    const type = await this.dataFS.getXAttr(id, TYPE_ATTR_NAME) as string;
    return new Blob([bytes ? bytes : new Uint8Array()], { type });
  }

  getFolderRO(id: string): Promise<ReadonlyFS> {
    return this.dataFS.readonlySubRoot(id)
      .catch((exc: FileException) => {
        if (exc.notFound) {
          throw makeExc({ notFound: true });
        } else if (exc.notDirectory) {
          throw makeExc({ notDirectory: true });
        } else {
          throw wrapErr(exc, `Fail to get folder of ${id}`);
        }
      });
  }

  getFolderWR(id: string): Promise<WritableFS> {
    return this.dataFS.writableSubRoot(id)
      .catch((exc: FileException) => {
        if (exc.notFound) {
          throw makeExc({ notFound: true });
        } else if (exc.notDirectory) {
          throw makeExc({ notDirectory: true });
        } else {
          throw wrapErr(exc, `Fail to get folder of ${id}`);
        }
      });
  }

  async getInfo(id: string): Promise<FileInfo | FolderInfo> {
    const stats = await this.dataFS.stat(id)
      .catch((exc: FileException) => {
        if (exc.notFound) {
          throw makeExc({ notFound: true });
        } else {
          throw wrapErr(exc, `Fail to read content of ${id}`);
        }
      });
    if (stats.isFile) {
      const xNames = (await this.dataFS.listXAttrs(id))
        .filter(xName => (xName !== ID_ATTR_NAME) && (xName !== TYPE_ATTR_NAME));
      const info: FileInfo = {
        id,
        version: stats.version!,
        ctime: stats.ctime!,
        mtime: stats.mtime!,
        isFile: true,
        size: stats.size!,
        type: await this.dataFS.getXAttr(id, TYPE_ATTR_NAME),
      };
      for (const xName of xNames) {
        (info as any)[xName] = await this.dataFS.getXAttr(id, xName);
      }
      return info;
    } else if (stats.isFolder) {
      const xNames = (await this.dataFS.listXAttrs(id))
        .filter(xName => (xName !== ID_ATTR_NAME));
      const info: FolderInfo = {
        id,
        version: stats.version!,
        ctime: stats.ctime!,
        mtime: stats.mtime!,
        isFolder: true,
      };
      for (const xName of xNames) {
        (info as any)[xName] = await this.dataFS.getXAttr(id, xName);
      }
      return info;
    } else {
      throw makeExc({
        notDirectory: true, notBlob: true,
        message: `Type of item ${id} is not recognized`,
        cause: stats,
      });
    }
  }

  async delete(id: string): Promise<void> {
    await this.dataFS.deleteFile(id)
      .catch((exc: FileException) => {
        if (!exc.notFound) {
          throw wrapErr(exc, `Fail to delete item with id ${id}`);
        }
      });
  }

}

Object.freeze(LabelledStore.prototype);
Object.freeze(LabelledStore);


function infoToAttrChanges(info: ItemAttrs): XAttrsChanges {
  let set: XAttrsChanges['set'] = undefined;
  let remove: XAttrsChanges['remove'] = undefined;
  for (const [field, value] of Object.entries(info)) {
    if (value === undefined) {
      if (!remove) {
        remove = [];
      }
      remove.push(field);
    } else {
      if (!set) {
        set = {};
      }
      set[field] = value;
    }
  }
  return { set, remove };
}

function noop() {
}

async function newBucketTree(dataFS: WritableFS): Promise<string> {
  let path = '';
  do {
    path = randomStr(FOLDER_NAME_LEN);
  } while (await dataFS.checkFolderPresence(path));
  for (let i = 1; i < FOLDER_DEPTH; i += 1) {
    path += `/${randomStr(FOLDER_NAME_LEN)}`;
  }
  await dataFS.makeFolder(path);
  return path;
}

async function newBucketInTree(
  dataFS: WritableFS, oldBucket: string,
): Promise<string> {
  let path = oldBucket;
  while (path.length > 0) {
    const sepInd = path.lastIndexOf('/');
    if (sepInd < 0) {
      break;
    }
    path = path.slice(0, sepInd);
    const lst = await dataFS.listFolder(path).catch(noop);
    if (lst && (lst.length < BUCKET_TOP_COUNT)) {
      return path;
    }
  }
  return newBucketTree(dataFS);
}


