import * as sharp from 'sharp';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ApiError } from '../common/error/api-error';
import { ApiErrorCodes } from '../common/error/api-error-codes';
import { FileEntity } from '../entities/file.entity';
import { v4 as uuidv4 } from 'uuid';
import * as path from 'path';
import { createReadStream } from 'fs';
import { AccessDTO, UserAccessDTO } from './dto/user-access-dto';
import { ReadStream, Readable } from 'typeorm/platform/PlatformTools';
import { ConfigService } from '@nestjs/config';
import { ContentEntity } from '../entities/content.entity';
import * as fs from 'fs';
import * as crypto from 'crypto';
import * as fsExtra from 'fs-extra';
import { isUUID } from 'class-validator';

const THUMB_MAX_SIZE = 2000;

export type FileResult = {
  fileId: string;
  store: string;
  dir: string;
  title: string;
  size: number;
};

export enum SharpFit {
  COVER = 'cover',
  CONTAIN = 'contain',
  FILL = 'fill',
  INSIDE = 'inside',
  OUTSIDE = 'outside',
}

function getFileTokenAccessForDir(
  ti: UserAccessDTO,
  dir: string | null,
): AccessDTO {
  if (dir) {
    const res = ti.access.find((a) => a.dir === dir);
    if (res) return res;
  }
  const null_dir = ti.access.find((a) => a.dir === null);
  if (null_dir) return null_dir;

  return {
    dir: null,
    maxSize: 0,
    quota: 0,
    rights: 'r',
  };
}

function getFileHash(algorithm, path): Promise<string> {
  return new Promise(function (resolve, reject) {
    const hash = crypto.createHash(algorithm);
    const stream = fs.createReadStream(path);
    stream.on('error', (err) => reject(err));
    stream.on('end', () => resolve(hash.digest('base64')));
    stream.on('data', (chunk) => hash.update(chunk));
  });
}

@Injectable()
export class FileService {
  constructor(
    @InjectRepository(FileEntity)
    private readonly filesRepo: Repository<FileEntity>,
    @InjectRepository(ContentEntity)
    private readonly contentsRepo: Repository<ContentEntity>,
    private readonly configService: ConfigService,
  ) {}

  private _getFilePath(
    file_ent: {
      contentId: string;
      store: string;
      title: string;
    },
    type: 'storage' | 'thumbs',
  ) {
    const storage_dir = this.configService.get(
      type === 'storage' ? 'server.storageDir' : 'server.thumbsDir',
    );
    const project_folder = file_ent.store.replace(/[\\\/]/g, '');
    const filename = file_ent.contentId;
    return path.resolve(path.join(storage_dir, project_folder, filename));
  }

  private async _checkRightsToGetFile(
    store: string,
    id: string,
    user: UserAccessDTO | null,
  ) {
    if (user && user.store !== store) {
      throw new ApiError(`Wrong store`, ApiErrorCodes.ACCESS_DENIED);
    }

    const current_file = await this.filesRepo.findOne({
      store: store,
      id,
    });
    if (!current_file) {
      throw new ApiError(`File not found`, ApiErrorCodes.ENTITY_NOT_FOUND);
    }

    if (!user && current_file.isSecret) {
      throw new ApiError(`File not found`, ApiErrorCodes.ENTITY_NOT_FOUND);
    }

    const access = user
      ? getFileTokenAccessForDir(user, current_file.dir)
      : {
          dir: null,
          maxSize: 0,
          quota: 0,
          rights: 'r',
        };
    if (access.rights !== 'r' && access.rights !== 'rw') {
      throw new ApiError(`Cannot read file`, ApiErrorCodes.ACCESS_DENIED);
    }

    const filepath = this._getFilePath(current_file, 'storage');

    return {
      title: current_file.title,
      path: filepath,
    };
  }

  async getFile(
    store: string,
    id: string,
    user: UserAccessDTO | null,
  ): Promise<{ title: string; stream: Readable }> {
    if (!isUUID(id)) {
      throw new ApiError('Wrong file id', ApiErrorCodes.PARAM_BAD_VALUE);
    }
    const file_info = await this._checkRightsToGetFile(store, id, user);
    const file_stream = createReadStream(file_info.path);
    return {
      title: file_info.title,
      stream: file_stream,
    };
  }

  async getThumbFile(
    store: string,
    id: string,
    user: UserAccessDTO | null,
    width: number,
    height: number,
    fit: SharpFit,
  ): Promise<{ title: string; stream: Readable }> {
    if (isNaN(width) || isNaN(height)) {
      throw new ApiError('Wrong size', ApiErrorCodes.PARAM_BAD_VALUE);
    }
    if (width <= 0 || height <= 0) {
      throw new ApiError('Wrong size', ApiErrorCodes.PARAM_BAD_VALUE);
    }
    if (width > THUMB_MAX_SIZE || height > THUMB_MAX_SIZE) {
      throw new ApiError('Too big thumbnail', ApiErrorCodes.PARAM_BAD_VALUE);
    }
    if (!['cover', 'contain', 'fill', 'inside', 'outside'].includes(fit)) {
      throw new ApiError('Wrong fit mode', ApiErrorCodes.PARAM_BAD_VALUE);
    }

    const file_info = await this._checkRightsToGetFile(store, id, user);

    const cache_filepath = this._getFilePath(
      {
        contentId: `${id}_thumb_${width}x${height}x${fit}`,
        store,
        title: '',
      },
      'thumbs',
    );

    if (fs.existsSync(cache_filepath)) {
      const file_stream = createReadStream(cache_filepath);
      return {
        stream: file_stream,
        title: file_info.title,
      };
    }

    const file_extname = path
      .extname(file_info.title)
      .substring(1)
      .toLowerCase();

    if (
      file_extname === 'gif' ||
      file_extname === 'svg' ||
      file_extname === 'bmp'
    ) {
      const file_stream = createReadStream(file_info.path);
      return {
        stream: file_stream,
        title: file_info.title,
      };
    } else if (!['jpg', 'jpeg', 'png'].includes(file_extname)) {
      throw new ApiError(
        `Available file extensions: 'gif', 'jpg', 'jpeg', 'png', 'bmp', 'svg'`,
        ApiErrorCodes.PARAM_BAD_VALUE,
      );
    }

    const resize_opts = {
      fit: fit as any,
      background: 'transparent',
    };
    const sharp_setup = sharp(file_info.path, {});
    sharp_setup.resize(width, height, resize_opts);

    const folder = path.dirname(cache_filepath);
    await fs.promises.mkdir(folder, {
      recursive: true,
    });

    const changed_file_buffer = await sharp_setup.toBuffer();
    await fs.promises.writeFile(cache_filepath, changed_file_buffer);

    const file_stream = Readable.from(changed_file_buffer);
    return {
      stream: file_stream,
      title: file_info.title,
    };
  }

  private async _getTakenSpace(
    store: string,
    dir: string | null,
  ): Promise<bigint> {
    const qb = this.filesRepo
      .createQueryBuilder('f')
      .innerJoin(
        ContentEntity,
        'c',
        'f.store = c.store AND f.content_id = c.id',
      )
      .where('f.store = :store', {
        store,
      });
    if (dir) {
      qb.andWhere('f.dir = :dir', {
        dir,
      });
    }
    const res = await qb.select('SUM(c.size) AS size').getRawOne();
    return res && res.size ? BigInt(res.size) : BigInt(0);
  }

  async checkUploadStore(
    user: UserAccessDTO,
    store: string,
    dir: string | null,
  ): Promise<{ limit: bigint }> {
    if (user.store !== store) {
      throw new ApiError(`Wrong store`, ApiErrorCodes.ACCESS_DENIED);
    }

    if (!/^[-a-z0-9]+$/i.test(store)) {
      throw new ApiError(
        'store should be alphanumeric',
        ApiErrorCodes.PARAM_BAD_VALUE,
      );
    }

    const access = getFileTokenAccessForDir(user, dir);
    if (access.rights !== 'w' && access.rights !== 'rw') {
      throw new ApiError(`Cannot write file`, ApiErrorCodes.ACCESS_DENIED);
    }

    let limit = BigInt(access.maxSize);

    const total_space_dir = BigInt(access.quota);
    const taken_space_dir = await this._getTakenSpace(store, dir);
    const left_space_dir = total_space_dir - taken_space_dir;
    if (limit > left_space_dir) {
      limit = left_space_dir;
    }

    if (dir) {
      const access_null = getFileTokenAccessForDir(user, null);
      const total_space_null = BigInt(access_null.quota);
      const taken_space_null = await this._getTakenSpace(store, null);
      const left_space_null = total_space_null - taken_space_null;
      if (limit > left_space_null) {
        limit = left_space_null;
      }
    }

    return {
      limit,
    };
  }

  async saveUploadedFile(
    file: Express.Multer.File,
    user: UserAccessDTO,
    store: string,
    dir: string | null,
    isSecret: boolean,
  ): Promise<FileResult> {
    let file_moved = false;
    try {
      const hashFileBase64 = await getFileHash('md5', file.path);

      let content = await this.contentsRepo.findOne({
        where: {
          store: store,
          hash: hashFileBase64,
          size: file.size,
        },
      });
      if (!content) {
        const new_content_id = uuidv4();

        const filepath = this._getFilePath(
          {
            contentId: new_content_id,
            store: store,
            title: file.originalname,
          },
          'storage',
        );
        const folder = path.dirname(filepath);

        await fs.promises.mkdir(folder, {
          recursive: true,
        });
        await fsExtra.move(file.path, filepath, {
          overwrite: true,
        });
        file_moved = true;

        content = {
          store,
          id: new_content_id,
          hash: hashFileBase64,
          size: file.size.toString(),
        };
        await this.contentsRepo.insert(content);
      }

      const exists_file = await this.filesRepo.findOne({
        where: {
          store: store,
          contentId: content.id,
          dir: dir,
          isSecret: isSecret,
          title: file.originalname,
          uploadedBy: user.user,
        },
      });

      if (exists_file) {
        return {
          fileId: exists_file.id,
          dir: exists_file.dir,
          size: file.size,
          store: store,
          title: exists_file.title,
        };
      }

      const uploading_file_id = uuidv4();
      const uploading_file: Partial<FileEntity> = {
        id: uploading_file_id,
        contentId: content.id,
        store,
        uploadedBy: user.user,
        title: file.originalname,
        isSecret,
        dir,
      };
      await this.filesRepo.insert(uploading_file);

      return {
        fileId: uploading_file_id,
        store,
        dir: dir,
        title: file.originalname,
        size: file.size,
      };
    } finally {
      if (!file_moved) {
        await fs.promises.unlink(file.path);
      }
    }
  }

  async deleteFile(user: UserAccessDTO, store: string, fileId: string) {
    if (user.store !== store) {
      throw new ApiError(`Wrong store`, ApiErrorCodes.ACCESS_DENIED);
    }

    const current_file = await this.filesRepo.findOne({
      store: store,
      id: fileId,
    });
    if (!current_file) {
      throw new ApiError(`File not found`, ApiErrorCodes.ENTITY_NOT_FOUND);
    }

    const access = getFileTokenAccessForDir(user, current_file.dir);
    if (access.rights !== 'w' && access.rights !== 'rw') {
      throw new ApiError(`Cannot delete file`, ApiErrorCodes.ACCESS_DENIED);
    }

    await this.filesRepo.delete({
      store,
      id: fileId,
    });

    const any_uses_content_id = await this.filesRepo.findOne({
      where: {
        store,
        contentId: current_file.contentId,
      },
    });
    if (!any_uses_content_id) {
      const filepath = this._getFilePath(current_file, 'storage');
      await fs.promises.unlink(filepath);
      await this.contentsRepo.delete({
        store,
        id: current_file.contentId,
      });
    }
  }
}
