import {
  ObjectId,
} from 'bson';
import {
  NextFunction,
  Request,
  Response,
} from 'express';
import {
  Document,
  PopulateOptions,
  Types,
} from 'mongoose';

import ApiController from './api.controller';
import {
  isString,
  toNumber,
} from './helpers';
import {
  ApiDocument,
  ApiModel,
  IApiModel,
  IApiRequest,
} from './types';
import {
  ApiSortQuery,
  IApiParsedQuery,
} from './types/IApiQuery';

export type ServerResponsePromise = Promise<void | Response<any, Record<string, any>>>;

const isValidId = Types.ObjectId.isValid;

abstract class BaseController<T extends (ApiDocument)> extends ApiController<T> {
  protected filters: string[];

  constructor(model: ApiModel<T>) {
    super(model);
    this.filters = [ 'type', 'deleted' ];
  }

  public async index(req: IApiRequest, _res: Response, next: NextFunction): Promise<void> {
    let query: IApiParsedQuery = {
      ...req.query,
      _q: '',
      deleted: false,
      limit: 100,
      offset: 0,
      q: {},
      select: {},
      sort: null,
      total: {},
    };

    const processedQuery = this.processQuery(req.query, query);

    try {
      if (typeof this.model.parseQuery === 'function') {
        query = this.model.parseQuery(processedQuery);
      } else {
        query = this.parseQuery(processedQuery);
      }

    } catch (error) {
      return next(error);
    }

    query.populate = query.populate ? query.populate : [];
    req.modelQuery = {
      total: query.q,
      offset: query.offset,
      limit: query.limit,
    };

    return this.model.find(query.q)
      .limit(toNumber(query.limit))
      .skip(toNumber(query.offset))
      .sort(query.sort)
      .select(query.select)
      .populate(query.populate)
      .exec()
      .then((models: T[]) => {
        req.data = models;

        return next();
      })
      .catch((err) => next(err));
  }
  public async read(
    req: IApiRequest,
    res: Response,
    _next: NextFunction,
  ): ServerResponsePromise {
    if (this.hasModel<Document & IApiModel>(req.model)) {

      const model = req.model.toObject();

      return res.jsonp(model);
    } else {
      return this.respondModelMissingError(res);
    }
  }
  public async create(
    req: IApiRequest,
    res: Response,
    next: NextFunction,
  ): ServerResponsePromise {
    delete req.body._id;
    delete req.body.timestamps;

    // eslint-disable-next-line @typescript-eslint/naming-convention
    const Model = this.model;
    const entity = {
      ...req.body,
      timestamps: {
        created: {
          by: req.user?.username || 'missing',
        },
      },
    };

    const model: T = new Model(entity);

    return model.save()
      .then((resModel) => (res.status(201).json(resModel.toObject())))
      .catch((err) => this.respondValidationError(err, res, next));
  }
  public async update(
    req: IApiRequest,
    res: Response,
    next: NextFunction,
  ): ServerResponsePromise {
    if (req.body._id === null) {
      delete req.body._id;
    }
    delete req.body.timestamps;

    if (this.hasModel(req.model)) {
      const model: any = req.model;

      Object.keys(req.body).forEach((key) => {
        model[key] = req.body[key];
      });
      model.timestamps.updated.by = req.user?.username || 'missing';

      return model.save()
        .then((resModel: T) => res.status(200).json(resModel.toObject()))
        .catch((err: any) => this.respondValidationError(err, res, next));
    } else {
      return this.respondModelMissingError(res);
    }
  }
  public async softDelete(
    req: IApiRequest,
    res: Response,
    _next: NextFunction,
  ): ServerResponsePromise {
    if (this.hasModel(req.model)) {

      const model = req.model;
      model.mark.deleted = true;
      model.timestamps.updated.by = req.user?.username || 'missing';

      return model.save()
        .then((resModel) => res.status(200).jsonp(resModel.toObject()))
        .catch((err) => this.respondDeletionError(res, err));
    } else {
      return Promise.resolve(this.respondModelMissingError(res));
    }
  }
  public async delete(
    req: IApiRequest,
    res: Response,
    _next: NextFunction,
  ): ServerResponsePromise {
    if (this.hasModel(req.model)) {
      const model = req.model;
      return this.model.deleteOne({ _id: model._id })
        .then(() => res.status(200).jsonp(model.toObject()))
        .catch((err: unknown) => {
          if (err instanceof Error) {
            return this.respondDeletionError(res, err);
          } else {
            return this.respondDeletionError(res, new Error('Unknown error occurred while deleting'));
          }
        });
    } else {
      return this.respondModelMissingError(res);
    }
  }
  public async findById(
    req: IApiRequest,
    res: Response,
    next: NextFunction,
    id: string | number | ObjectId,
    _urlParam?: any,
    populate?: PopulateOptions[],
  ): ServerResponsePromise {
    if (isValidId(id)) {
      if (typeof populate === 'undefined') {
        populate = [];
      }

      return this.model
        .findById<ApiDocument>(id)
        // this for sure is not the right way. Inject type into method?
        // .findOne({}).populate<{ child: Child }>('child').orFail().then(doc => {
        // .populate<Pick<PopulatedParent, 'child'>>('child').orFail().then(doc
        .populate<any>(populate)
        .exec()
        .then((model) => {
          if (model === null) {
            return this.respondNotFound(id, res, this.model.modelName);
          } else if (this.hasModel<ApiDocument>(model)){
            req.model = model;

            return next();
          } else {
            return this.respondModelMissingError(res);
          }
        })
        .catch((err) => this.respondServerError(res, err));
    } else {
      return this.respondInvalidId(res);
    }
  }
  public async stats(req: IApiRequest, res: Response, next: NextFunction): ServerResponsePromise {
    return this.model.countDocuments()
      .then((result) => {
        if (typeof req.stats !== 'object') {
          req.stats = {};
        }
        req.stats[this.model.collection.name] = result;

        return next();
      })
      .catch((err) => this.respondServerError(res, err));
  }
  public statsResponse(req: IApiRequest, res: Response, _next: NextFunction): Response {
    if (typeof req.stats !== 'object') {
      req.stats = {};
    }

    return res.status(200).json(req.stats);
  }
  public async statistics(req: IApiRequest, res: Response, next: NextFunction): ServerResponsePromise {
    if (typeof this.model.statistics === 'function') {
      const query = req.dateRange || {};
      return this.model.statistics(query).then((result) => {
        if (typeof req.stats !== 'object') {
          req.stats = {};
        }
        req.stats[this.model.collection.name] = result;

        return next();
      })
        .catch((err) => this.respondServerError(res, err));
    } else {
      return this.stats(req, res, next);
    }
  }
  public parseDateRange(
    req: IApiRequest,
    _res: Response,
    next: NextFunction,
    _id: string,
    _urlParam: string,
  ): void {
    // FIXME: this function is called twice for /year/month ....
    const year = parseInt(req.params.year, 10);
    let month = parseInt(req.params.month, 10);
    let toMonth = 12;
    if (!isNaN(year)) {
      if (isNaN(month)) {
        month = 0;
      } else {
        month = Math.max(Math.min(month, 12), 1);
        toMonth = --month + 1;
      }
      let from: Date = new Date();
      from = new Date(from.setFullYear(year, month, 1));
      from = new Date(from.setHours(0, 0, 0, 0));
      let to = new Date(from.valueOf());
      to = new Date(to.setFullYear(year, toMonth, 1));

      if (typeof req.stats !== 'object') {
        req.stats = {};
      }
      req.stats.range = {
        from: from,
        to: to,
      };

      req.dateRange = { $and: [{ date: { $gte: from } }, { date: { $lt: to } }] };
    }

    return next();
  }

  public processQuery(
    query: Request['query'],
    defaultQuery: Readonly<IApiParsedQuery>,
  ): IApiParsedQuery {
    const modelQuery: IApiParsedQuery = {
      ...query,
      _q: query.q?.toString() || '',
      offset: 0,
      deleted: false,
      limit: 100,
      q: {},
      select: {},
      sort: null,
      total: {},
    };
    if (typeof query.offset === 'string') {
      modelQuery.offset = this.parsePagination(query.offset, defaultQuery.offset);
    }
    if (typeof query.limit === 'string') {
      modelQuery.limit = this.parsePagination(query.limit, defaultQuery.limit);
    }
    if (typeof query.sort === 'string') {
      modelQuery.sort = this.parseSort(query.sort);
    }
    if (typeof query.select === 'string') {
      modelQuery.select = query.select.split(' ').reduce((acc, cur) => ({
        ...acc,
        [cur]: true,
      }), {} as Record<string, boolean>);
    }
    if (typeof query.filter === 'string') {
      modelQuery.filter = this.parseFilter(query.filter);
    }
    if (typeof query.deleted === 'string') {
      modelQuery.deleted = query.deleted === 'true';
    }

    return modelQuery;
  }

  public parseSort(sort: string | null = null): ApiSortQuery | null {
    if (sort) {
      const parsedSort: ApiSortQuery = {};
      let _sort: Record<string, string | number> = {};
      try {
        _sort = JSON.parse(sort);

      } catch (error: unknown) {
        /* istanbul ignore next */
        if (error instanceof SyntaxError) {
          _sort = sort.split(' ')
            .filter(s => /^\w+$/.test(s))
            .reduce((acc: any, cur) => {
              acc[cur] = 1;
              return acc;
            }, {});
        } else {
          throw error;
        }
      }
      Object.entries(_sort).forEach(([ key, value ]) => {
        const order = isString(value) ? parseInt(value, 10) : value;
        parsedSort[key] = isNaN(order) ? 1 : Math.min(Math.max(order, -1), 1) as -1 | 1;
      });

      if (Object.keys(parsedSort).length === 0) {
        parsedSort['date'] = -1;
      }

      return parsedSort;
    } else {
      return null;
    }
  }
  public parseFilter(filterQuery: string | null = null): Record<string, string> {
    let filter: Record<string, string> = {};

    try {
      filter = filterQuery ? JSON.parse(filterQuery.replace(/\'/g, '"')) : {};
    } catch (e) {
      filter = {};
    }

    const allowedFilters: Record<string, string> = {};
    this.filters.forEach(f => {
      if (typeof filter[f] !== 'undefined' && filter[f] !== null) {
        allowedFilters[f] = filter[f].toString();
      }
    });

    return allowedFilters;
  }

  public parsePagination(value: string, defaultValue: string | number): number {
    const _value = toNumber(value);
    const _default = toNumber(defaultValue);

    return isNaN(_value) ? _default : _value;
  }


  public parseQuery(query: Partial<IApiParsedQuery>): IApiParsedQuery {
    return {
      _q: query._q || '',
      q: {},
      offset: query.offset || 0,
      limit: query.limit || 100,
      sort: query.sort
        ? {
          ...query.sort,
        }
        : null,
      filter: {
        ...query.filter,
      },
      populate: query.populate
        ? [ ...query.populate ]
        : [],
      deleted: !!query.deleted,
      select: query.select
        ? {
          ...query.select,
        }
        : {},
      total: {},
    };
  }

}

export default BaseController;
