import { omit } from 'lodash';
import { Op, Transaction, WhereOptions } from 'sequelize';
import { Metrics, SKILLS, getLevel } from '@wise-old-man/utils';
import { sequelize } from '../../../database';
import { Membership, NameChange, Participation, Player, Record, Snapshot } from '../../../database/models';
import { NameChangeStatus, Pagination } from '../../../types';
import { BadRequestError, NotFoundError, ServerError } from '../../errors';
import { buildQuery } from '../../util/query';
import * as jagexService from '../external/jagex.service';
import * as efficiencyService from './efficiency.service';
import * as playerService from './player.service';
import * as snapshotService from './snapshot.service';

/**
 * List all name changes, filtered by a specific status
 */
async function getList(username: string, status: number, pagination: Pagination): Promise<NameChange[]> {
  // Isn't a valid NameChangeStatus
  if (status && !NameChangeStatus[status]) {
    throw new BadRequestError('Invalid status.');
  }

  const query = buildQuery({ status });

  if (username && username.length > 0) {
    query[Op.or] = [
      {
        oldName: { [Op.iLike]: `${username}%` }
      },
      {
        newName: { [Op.iLike]: `${username}%` }
      }
    ];
  }

  const nameChanges = await NameChange.findAll({
    where: query,
    order: [['createdAt', 'DESC']],
    limit: pagination.limit,
    offset: pagination.offset
  });

  return nameChanges;
}

async function getPlayerNames(playerId: number): Promise<NameChange[]> {
  const nameChanges = await NameChange.findAll({
    where: { playerId, status: NameChangeStatus.APPROVED },
    order: [['resolvedAt', 'DESC']]
  });

  return nameChanges;
}

async function findAllForGroup(playerIds: number[], pagination: Pagination): Promise<NameChange[]> {
  const nameChanges = await NameChange.findAll({
    where: { playerId: playerIds, status: NameChangeStatus.APPROVED },
    include: [{ model: Player }],
    order: [['createdAt', 'DESC']],
    limit: pagination.limit,
    offset: pagination.offset
  });

  return nameChanges;
}

/**
 * Finds any neighbouring name changes (submitted around the same time),
 * the lower the date gap is, the more likely the name changes have
 * actually been submitted in bulk.
 */
async function findAllBundled(id: number, createdAt: Date) {
  const DATE_GAP_MS = 500;
  const minDate = createdAt.getTime() - DATE_GAP_MS / 2;
  const maxDate = createdAt.getTime() + DATE_GAP_MS / 2;

  const nameChanges = await NameChange.findAll({
    where: {
      id: { [Op.not]: id },
      createdAt: { [Op.between]: [minDate, maxDate] }
    },
    limit: 50
  });

  return nameChanges;
}

async function bulkSubmit(nameChanges: { oldName: string; newName: string }[]) {
  if (!nameChanges || !Array.isArray(nameChanges)) {
    throw new BadRequestError('Invalid name change list format.');
  }

  if (nameChanges.length === 0) {
    throw new BadRequestError('Empty name change list.');
  }

  if (nameChanges.some(n => !n.oldName || !n.newName)) {
    throw new BadRequestError('All name change objects must have "oldName" and "newName" properties.');
  }

  const submitted = await Promise.all(
    nameChanges.map(async ({ oldName, newName }) => {
      try {
        return await submit(oldName, newName);
      } catch (error) {
        return null;
      }
    })
  );

  const submittedCount = submitted.filter(s => !!s).length;

  if (submittedCount === 0) {
    throw new BadRequestError(`Could not find any valid name changes to submit.`);
  }

  return `Successfully submitted ${submittedCount}/${nameChanges.length} name changes.`;
}

/**
 * Submit a new name change request, from oldName to newName.
 */
async function submit(oldName: string, newName: string): Promise<NameChange> {
  if (!playerService.isValidUsername(oldName)) {
    throw new BadRequestError('Invalid old name.');
  }

  if (!playerService.isValidUsername(newName)) {
    throw new BadRequestError('Invalid new name.');
  }

  if (playerService.sanitize(oldName) === playerService.sanitize(newName)) {
    throw new BadRequestError('Old name and new name cannot be the same.');
  }

  const stOldName = playerService.standardize(oldName);
  const stNewName = playerService.standardize(newName);

  // Check if a player with the "oldName" username is registered
  const oldPlayer = await playerService.find(stOldName);

  if (!oldPlayer) {
    throw new BadRequestError(`Player '${oldName}' is not tracked yet.`);
  }

  // If these are the same name, just different capitalizations, skip these checks
  if (stOldName !== stNewName) {
    // Check if there's any pending name changes for these names
    const pending = await NameChange.findOne({
      where: {
        oldName: { [Op.iLike]: stOldName },
        newName: { [Op.iLike]: stNewName },
        status: NameChangeStatus.PENDING
      }
    });

    if (pending) {
      throw new BadRequestError(`There's already a similar pending name change. (Id: ${pending.id})`);
    }

    const newPlayer = await playerService.find(stNewName);

    // To prevent people from submitting duplicate name change requests, which then
    // will waste time and resources to process and deny, it's best to check if this
    // exact same name change has been approved.
    if (newPlayer) {
      const lastChange = await NameChange.findOne({
        where: { playerId: newPlayer.id, status: NameChangeStatus.APPROVED },
        order: [['createdAt', 'DESC']]
      });

      if (lastChange && playerService.standardize(lastChange.oldName) === stOldName) {
        throw new BadRequestError(`Cannot submit a duplicate (approved) name change. (Id: ${lastChange.id})`);
      }
    }
  }

  // Create a new instance (a new name change request)
  const nameChange = await NameChange.create({
    playerId: oldPlayer.id,
    oldName: oldPlayer.displayName,
    newName: playerService.sanitize(newName)
  });

  return nameChange;
}

/**
 * Denies a pending name change request.
 */
async function deny(id: number): Promise<NameChange> {
  const nameChange = await NameChange.findOne({ where: { id } });

  if (!nameChange) {
    throw new NotFoundError('Name change id was not found.');
  }

  if (nameChange.status !== NameChangeStatus.PENDING) {
    throw new BadRequestError('Name change status must be PENDING');
  }

  nameChange.status = NameChangeStatus.DENIED;
  await nameChange.save();

  return nameChange;
}

/**
 * Approves a pending name change request,
 * and transfer all the oldName's data to the newName.
 */
async function approve(id: number): Promise<NameChange> {
  const nameChange = await NameChange.findOne({ where: { id } });

  if (!nameChange) {
    throw new NotFoundError('Name change id was not found.');
  }

  if (nameChange.status !== NameChangeStatus.PENDING) {
    throw new BadRequestError('Name change status must be PENDING');
  }

  const oldPlayer = await playerService.find(nameChange.oldName);
  const newPlayer = await playerService.find(nameChange.newName);

  if (!oldPlayer) {
    throw new ServerError('Old Player cannot be found in the database anymore.');
  }

  // Attempt to transfer data between both accounts
  await transferData(oldPlayer, newPlayer, nameChange.newName);

  // If successful, resolve the name change
  nameChange.status = NameChangeStatus.APPROVED;
  nameChange.resolvedAt = new Date();
  await nameChange.save();

  return nameChange;
}

async function getDetails(id: number) {
  const nameChange = await NameChange.findOne({ where: { id } });

  if (!nameChange) {
    throw new NotFoundError('Name change id was not found.');
  }

  const oldPlayer = await playerService.find(nameChange.oldName);
  const newPlayer = await playerService.find(nameChange.newName);

  if (!oldPlayer || nameChange.status !== NameChangeStatus.PENDING) {
    return { nameChange, data: {} };
  }

  let newHiscores;
  let oldHiscores;

  try {
    // Attempt to fetch hiscores data for the new name
    newHiscores = await jagexService.getHiscoresData(nameChange.newName);
  } catch (e) {
    // If te hiscores failed to load, abort mission
    if (e instanceof ServerError) throw e;
  }

  try {
    oldHiscores = await jagexService.getHiscoresData(nameChange.oldName);
  } catch (e) {
    // If te hiscores failed to load, abort mission
    if (e instanceof ServerError) throw e;
  }

  // Fetch the last snapshot from the old name
  const oldStats = await snapshotService.findLatest(oldPlayer.id);

  if (!oldStats) {
    throw new ServerError('Old stats could not be found.');
  }

  // Fetch either the first snapshot of the new name, or the current hiscores stats
  let newStats = newHiscores ? await snapshotService.fromRS(-1, newHiscores) : null;

  if (newPlayer) {
    // If the new name is already a tracked player and was tracked
    // since the old name's last snapshot, use this first "post change"
    // snapshot as a starting point
    const postChangeSnapshot = await snapshotService.findFirstSince(newPlayer.id, oldStats.createdAt);

    if (postChangeSnapshot) {
      newStats = postChangeSnapshot;
    }
  }

  const afterDate = newStats && newStats.createdAt ? newStats.createdAt : new Date();
  const timeDiff = afterDate.getTime() - oldStats.createdAt.getTime();
  const hoursDiff = timeDiff / 1000 / 60 / 60;

  const ehpDiff = newStats ? efficiencyService.calculateEHPDiff(oldStats, newStats) : 0;
  const ehbDiff = newStats ? efficiencyService.calculateEHBDiff(oldStats, newStats) : 0;

  const hasNegativeGains = newStats ? snapshotService.hasNegativeGains(oldStats, newStats) : false;

  return {
    nameChange,
    data: {
      isNewOnHiscores: !!newHiscores,
      isOldOnHiscores: !!oldHiscores,
      isNewTracked: !!newPlayer,
      hasNegativeGains,
      timeDiff,
      hoursDiff,
      ehpDiff,
      ehbDiff,
      oldStats: snapshotService.format(oldStats),
      newStats: snapshotService.format(newStats)
    }
  };
}

/**
 * Checks a name change for it's neighbours, to decide if it
 * should have a boost in approval rate due to having been submitted in bulk.
 * (Bulk submissions are more likely legit, as they were likely submitted via RL plugin)
 */
async function getBundleModifier(nameChange: NameChange): Promise<number> {
  const REGULAR_MODIFIER = 1;
  const BOOSTED_MODIFIER = 2;

  const neighbours = await findAllBundled(nameChange.id, nameChange.createdAt);

  if (!neighbours || neighbours.length === 0) {
    return REGULAR_MODIFIER;
  }

  const approvedCount = neighbours.filter(n => n.status === NameChangeStatus.APPROVED).length;
  const approvedRate = approvedCount / neighbours.length;

  return approvedRate >= 0.5 ? BOOSTED_MODIFIER : REGULAR_MODIFIER;
}

async function autoReview(id: number): Promise<void> {
  let details;

  try {
    details = await getDetails(id);
  } catch (error) {
    if (error.message === 'Old stats could not be found.') {
      await deny(id);
      return;
    }
  }

  if (!details || details.nameChange.status !== NameChangeStatus.PENDING) return;

  const { data, nameChange } = details;
  const { isNewOnHiscores, hasNegativeGains, hoursDiff, ehpDiff, ehbDiff, oldStats } = data;

  // If it's a capitalization change, auto-approve
  if (playerService.standardize(nameChange.oldName) === playerService.standardize(nameChange.newName)) {
    await approve(id);
    return;
  }

  // If this name change was submitted in a bulk submission, likely via
  // the RL plugin, and most of its "neighbour"/"bundled" name changes
  // have been approved, then let's assume this one is more likely to be legit
  // for this, we use a modifier to lower the approval requirements.
  const bundleModifier = await getBundleModifier(nameChange);

  // If new name is not on the hiscores
  if (!isNewOnHiscores) {
    await deny(id);
    return;
  }

  // If has lost exp/kills/scores, deny request
  if (hasNegativeGains) {
    await deny(id);
    return;
  }

  const baseMaxHours = 504;
  const extraHours = (oldStats[Metrics.OVERALL].experience / 2_000_000) * 168;

  // If the transition period is over (3 weeks + 1 week per each 2m exp)
  if (hoursDiff > (baseMaxHours + extraHours) * bundleModifier) {
    return;
  }

  // If has gained too much exp/kills
  if (ehpDiff + ehbDiff > hoursDiff * bundleModifier) {
    return;
  }

  const totalLevel = SKILLS.filter(s => s !== Metrics.OVERALL)
    .map(s => getLevel(oldStats[s].experience))
    .reduce((acc, cur) => acc + cur);

  // If is high level enough (high level swaps are harder to fake)
  if (totalLevel < 700 / bundleModifier) {
    return;
  }

  // All seems to be fine, auto approve
  await approve(id);
}

async function transferData(oldPlayer: Player, newPlayer: Player, newName: string): Promise<void> {
  const transitionDate = (await snapshotService.findLatest(oldPlayer.id))?.createdAt;

  await sequelize.transaction(async transaction => {
    if (newPlayer && oldPlayer.id !== newPlayer.id) {
      // Include only the data gathered after the name change transition started
      const createdFilter = {
        playerId: newPlayer.id,
        createdAt: { [Op.gte]: transitionDate }
      };

      const updatedFilter = {
        playerId: newPlayer.id,
        updatedAt: { [Op.gte]: transitionDate }
      };

      await transferRecords(updatedFilter, oldPlayer.id, transaction);
      await transferSnapshots(createdFilter, oldPlayer.id, transaction);
      await transferMemberships(createdFilter, oldPlayer.id, transaction);
      await transferParticipations(createdFilter, oldPlayer.id, transaction);

      // Transfer the player's country, if needed/possible
      if (newPlayer.country && !oldPlayer.country) {
        oldPlayer.country = newPlayer.country;
      }

      // Delete the new player account
      await newPlayer.destroy({ transaction });
    }

    // Update the player to the new username & displayName
    oldPlayer.username = playerService.standardize(newName);
    oldPlayer.displayName = playerService.sanitize(newName);
    oldPlayer.flagged = false;

    await oldPlayer.save({ transaction });
  });
}

async function transferSnapshots(filter: WhereOptions, targetId: number, transaction: Transaction) {
  // Fetch all of new player's snapshots (post transition date)
  const newSnapshots = await Snapshot.findAll({ where: filter });

  // Transfer all snapshots to the old player id
  const movedSnapshots = newSnapshots.map(s => {
    return omit({ ...s.toJSON(), playerId: targetId }, 'id');
  });

  // Add all these snapshots, ignoring duplicates
  await Snapshot.bulkCreate(movedSnapshots, { ignoreDuplicates: true, transaction });
}

async function transferParticipations(filter: WhereOptions, targetId: number, transaction: Transaction) {
  // Fetch all of new player's participations (post transition date)
  const newParticipations = await Participation.findAll({ where: filter });

  // Transfer all participations to the old player id
  const movedParticipations = newParticipations.map(ns => ({
    ...ns.toJSON(),
    playerId: targetId,
    startSnapshotId: null,
    endSnapshotId: null
  }));

  // Add all these participations, ignoring duplicates
  await Participation.bulkCreate(movedParticipations, { ignoreDuplicates: true, transaction });
}

async function transferMemberships(filter: WhereOptions, targetId: number, transaction: Transaction) {
  // Fetch all of new player's memberships (post transition date)
  const newMemberships = await Membership.findAll({ where: filter });

  // Transfer all memberships to the old player id
  const movedMemberships = newMemberships.map(ns => ({ ...ns.toJSON(), playerId: targetId }));

  // Add all these memberships, ignoring duplicates
  await Membership.bulkCreate(movedMemberships, { ignoreDuplicates: true, hooks: false, transaction });
}

async function transferRecords(filter: WhereOptions, targetId: number, transaction: Transaction) {
  // Fetch all of the old records, and the recent new records
  const oldRecords = await Record.findAll({ where: { playerId: targetId } });
  const newRecords = await Record.findAll({ where: filter });

  const outdated: { record: Record; newValue: number }[] = [];

  newRecords.forEach(n => {
    const oldEquivalent = oldRecords.find(r => r.metric === n.metric && r.period === n.period);

    // If the new player's record is higher than the old player's,
    // add the old one  to the outdated list
    if (oldEquivalent && oldEquivalent.value < n.value) {
      outdated.push({ record: oldEquivalent, newValue: n.value });
    }
  });

  // Update all "outdated records"
  await Promise.all(
    outdated.map(async ({ record, newValue }) => {
      await record.update({ value: newValue }, { transaction });
    })
  );
}

export {
  getList,
  getDetails,
  getPlayerNames,
  findAllForGroup,
  submit,
  bulkSubmit,
  deny,
  approve,
  autoReview
};