/*
 * Copyright 2018 The boardgame.io Authors
 *
 * Use of this source code is governed by a MIT-style
 * license that can be found in the LICENSE file or at
 * https://opensource.org/licenses/MIT.
 */

import type { CorsOptions } from 'cors';
import type Koa from 'koa';
import type Router from '@koa/router';
import koaBody from 'koa-body';
import { nanoid } from 'nanoid';
import cors from '@koa/cors';
import { createMatch, getFirstAvailablePlayerID, getNumPlayers } from './util';
import type { Auth } from './auth';
import type { Server, LobbyAPI, Game, StorageAPI } from '../types';

/**
 * Creates a new match.
 *
 * @param {object} db - The storage API.
 * @param {object} game - The game config object.
 * @param {number} numPlayers - The number of players.
 * @param {object} setupData - User-defined object that's available
 *                             during game setup.
 * @param {object } lobbyConfig - Configuration options for the lobby.
 * @param {boolean} unlisted - Whether the match should be excluded from public listing.
 */
const CreateMatch = async ({
  ctx,
  db,
  uuid,
  ...opts
}: {
  db: StorageAPI.Sync | StorageAPI.Async;
  ctx: Koa.BaseContext;
  uuid: () => string;
} & Parameters<typeof createMatch>[0]): Promise<string> => {
  const matchID = uuid();
  const match = createMatch(opts);

  if ('setupDataError' in match) {
    ctx.throw(400, match.setupDataError);
  } else {
    await db.createMatch(matchID, match);
    return matchID;
  }
};

/**
 * Create a metadata object without secret credentials to return to the client.
 *
 * @param {string} matchID - The identifier of the match the metadata belongs to.
 * @param {object} metadata - The match metadata object to strip credentials from.
 * @return - A metadata object without player credentials.
 */
const createClientMatchData = (
  matchID: string,
  metadata: Server.MatchData
): LobbyAPI.Match => {
  return {
    ...metadata,
    matchID,
    players: Object.values(metadata.players).map((player) => {
      // strip away credentials
      const { credentials, ...strippedInfo } = player;
      return strippedInfo;
    }),
  };
};

/** Utility extracting `string` from a query if it is `string[]`. */
const unwrapQuery = (
  query: undefined | string | string[]
): string | undefined => (Array.isArray(query) ? query[0] : query);

export const configureRouter = ({
  router,
  db,
  auth,
  games,
  uuid = () => nanoid(11),
}: {
  router: Router<any, Server.AppCtx>;
  auth: Auth;
  games: Game[];
  uuid?: () => string;
  db: StorageAPI.Sync | StorageAPI.Async;
}) => {
  /**
   * List available games.
   *
   * @return - Array of game names as string.
   */
  router.get('/games', async (ctx) => {
    const body: LobbyAPI.GameList = games.map((game) => game.name);
    ctx.body = body;
  });

  /**
   * Create a new match of a given game.
   *
   * @param {string} name - The name of the game of the new match.
   * @param {number} numPlayers - The number of players.
   * @param {object} setupData - User-defined object that's available
   *                             during game setup.
   * @param {boolean} unlisted - Whether the match should be excluded from public listing.
   * @return - The ID of the created match.
   */
  router.post('/games/:name/create', koaBody(), async (ctx) => {
    // The name of the game (for example: tic-tac-toe).
    const gameName = ctx.params.name;
    // User-data to pass to the game setup function.
    const setupData = ctx.request.body.setupData;
    // Whether the game should be excluded from public listing.
    const unlisted = ctx.request.body.unlisted;
    // The number of players for this game instance.
    const numPlayers = Number.parseInt(ctx.request.body.numPlayers);

    const game = games.find((g) => g.name === gameName);
    if (!game) ctx.throw(404, 'Game ' + gameName + ' not found');

    if (
      ctx.request.body.numPlayers !== undefined &&
      (Number.isNaN(numPlayers) ||
        (game.minPlayers && numPlayers < game.minPlayers) ||
        (game.maxPlayers && numPlayers > game.maxPlayers))
    ) {
      ctx.throw(400, 'Invalid numPlayers');
    }

    const matchID = await CreateMatch({
      ctx,
      db,
      game,
      numPlayers,
      setupData,
      uuid,
      unlisted,
    });

    const body: LobbyAPI.CreatedMatch = { matchID };
    ctx.body = body;
  });

  /**
   * List matches for a given game.
   *
   * This does not return matches that are marked as unlisted.
   *
   * @param {string} name - The name of the game.
   * @return - Array of match objects.
   */
  router.get('/games/:name', async (ctx) => {
    const gameName = ctx.params.name;
    const isGameoverString = unwrapQuery(ctx.query.isGameover);
    const updatedBeforeString = unwrapQuery(ctx.query.updatedBefore);
    const updatedAfterString = unwrapQuery(ctx.query.updatedAfter);

    let isGameover: boolean | undefined;
    if (isGameoverString) {
      if (isGameoverString.toLowerCase() === 'true') {
        isGameover = true;
      } else if (isGameoverString.toLowerCase() === 'false') {
        isGameover = false;
      }
    }
    let updatedBefore: number | undefined;
    if (updatedBeforeString) {
      const parsedNumber = Number.parseInt(updatedBeforeString, 10);
      if (parsedNumber > 0) {
        updatedBefore = parsedNumber;
      }
    }
    let updatedAfter: number | undefined;
    if (updatedAfterString) {
      const parsedNumber = Number.parseInt(updatedAfterString, 10);
      if (parsedNumber > 0) {
        updatedAfter = parsedNumber;
      }
    }
    const matchList = await db.listMatches({
      gameName,
      where: {
        isGameover,
        updatedAfter,
        updatedBefore,
      },
    });
    const matches = [];
    for (const matchID of matchList) {
      const { metadata } = await (db as StorageAPI.Async).fetch(matchID, {
        metadata: true,
      });
      if (!metadata.unlisted) {
        matches.push(createClientMatchData(matchID, metadata));
      }
    }
    const body: LobbyAPI.MatchList = { matches };
    ctx.body = body;
  });

  /**
   * Get data about a specific match.
   *
   * @param {string} name - The name of the game.
   * @param {string} id - The ID of the match.
   * @return - A match object.
   */
  router.get('/games/:name/:id', async (ctx) => {
    const matchID = ctx.params.id;
    const { metadata } = await (db as StorageAPI.Async).fetch(matchID, {
      metadata: true,
    });
    if (!metadata) {
      ctx.throw(404, 'Match ' + matchID + ' not found');
    }
    const body: LobbyAPI.Match = createClientMatchData(matchID, metadata);
    ctx.body = body;
  });

  /**
   * Join a given match.
   *
   * @param {string} name - The name of the game.
   * @param {string} id - The ID of the match.
   * @param {string} playerID - The ID of the player who joins. If not sent, will be assigned to the first index available.
   * @param {string} playerName - The name of the player who joins.
   * @param {object} data - The default data of the player in the match.
   * @return - Player ID and credentials to use when interacting in the joined match.
   */
  router.post('/games/:name/:id/join', koaBody(), async (ctx) => {
    let playerID = ctx.request.body.playerID;
    const playerName = ctx.request.body.playerName;
    const data = ctx.request.body.data;
    const matchID = ctx.params.id;
    if (!playerName) {
      ctx.throw(403, 'playerName is required');
    }

    const { metadata } = await (db as StorageAPI.Async).fetch(matchID, {
      metadata: true,
    });
    if (!metadata) {
      ctx.throw(404, 'Match ' + matchID + ' not found');
    }

    if (typeof playerID === 'undefined' || playerID === null) {
      playerID = getFirstAvailablePlayerID(metadata.players);
      if (playerID === undefined) {
        const numPlayers = getNumPlayers(metadata.players);
        ctx.throw(
          409,
          `Match ${matchID} reached maximum number of players (${numPlayers})`
        );
      }
    }

    if (!metadata.players[playerID]) {
      ctx.throw(404, 'Player ' + playerID + ' not found');
    }
    if (metadata.players[playerID].name) {
      ctx.throw(409, 'Player ' + playerID + ' not available');
    }

    if (data) {
      metadata.players[playerID].data = data;
    }
    metadata.players[playerID].name = playerName;
    const playerCredentials = await auth.generateCredentials(ctx);
    metadata.players[playerID].credentials = playerCredentials;

    await db.setMetadata(matchID, metadata);

    const body: LobbyAPI.JoinedMatch = { playerID, playerCredentials };
    ctx.body = body;
  });

  /**
   * Leave a given match.
   *
   * @param {string} name - The name of the game.
   * @param {string} id - The ID of the match.
   * @param {string} playerID - The ID of the player who leaves.
   * @param {string} credentials - The credentials of the player who leaves.
   * @return - Nothing.
   */
  router.post('/games/:name/:id/leave', koaBody(), async (ctx) => {
    const matchID = ctx.params.id;
    const playerID = ctx.request.body.playerID;
    const credentials = ctx.request.body.credentials;
    const { metadata } = await (db as StorageAPI.Async).fetch(matchID, {
      metadata: true,
    });
    if (typeof playerID === 'undefined' || playerID === null) {
      ctx.throw(403, 'playerID is required');
    }

    if (!metadata) {
      ctx.throw(404, 'Match ' + matchID + ' not found');
    }
    if (!metadata.players[playerID]) {
      ctx.throw(404, 'Player ' + playerID + ' not found');
    }
    const isAuthorized = await auth.authenticateCredentials({
      playerID,
      credentials,
      metadata,
    });
    if (!isAuthorized) {
      ctx.throw(403, 'Invalid credentials ' + credentials);
    }

    delete metadata.players[playerID].name;
    delete metadata.players[playerID].credentials;
    const hasPlayers = Object.values(metadata.players).some(({ name }) => name);
    await (hasPlayers
      ? db.setMetadata(matchID, metadata) // Update metadata.
      : db.wipe(matchID)); // Delete match.
    ctx.body = {};
  });

  /**
   * Start a new match based on another existing match.
   *
   * @param {string} name - The name of the game.
   * @param {string} id - The ID of the match.
   * @param {string} playerID - The ID of the player creating the match.
   * @param {string} credentials - The credentials of the player creating the match.
   * @param {boolean} unlisted - Whether the match should be excluded from public listing.
   * @return - The ID of the new match.
   */
  router.post('/games/:name/:id/playAgain', koaBody(), async (ctx) => {
    const gameName = ctx.params.name;
    const matchID = ctx.params.id;
    const playerID = ctx.request.body.playerID;
    const credentials = ctx.request.body.credentials;
    const unlisted = ctx.request.body.unlisted;
    const { metadata } = await (db as StorageAPI.Async).fetch(matchID, {
      metadata: true,
    });

    if (typeof playerID === 'undefined' || playerID === null) {
      ctx.throw(403, 'playerID is required');
    }

    if (!metadata) {
      ctx.throw(404, 'Match ' + matchID + ' not found');
    }
    if (!metadata.players[playerID]) {
      ctx.throw(404, 'Player ' + playerID + ' not found');
    }
    const isAuthorized = await auth.authenticateCredentials({
      playerID,
      credentials,
      metadata,
    });
    if (!isAuthorized) {
      ctx.throw(403, 'Invalid credentials ' + credentials);
    }

    // Check if nextMatch is already set, if so, return that id.
    if (metadata.nextMatchID) {
      ctx.body = { nextMatchID: metadata.nextMatchID };
      return;
    }

    // User-data to pass to the game setup function.
    const setupData = ctx.request.body.setupData || metadata.setupData;
    // The number of players for this game instance.
    const numPlayers =
      Number.parseInt(ctx.request.body.numPlayers) ||
      // eslint-disable-next-line unicorn/explicit-length-check
      Object.keys(metadata.players).length;

    const game = games.find((g) => g.name === gameName);
    const nextMatchID = await CreateMatch({
      ctx,
      db,
      game,
      numPlayers,
      setupData,
      uuid,
      unlisted,
    });
    metadata.nextMatchID = nextMatchID;

    await db.setMetadata(matchID, metadata);

    const body: LobbyAPI.NextMatch = { nextMatchID };
    ctx.body = body;
  });

  const updatePlayerMetadata = async (ctx: Koa.Context) => {
    const matchID = ctx.params.id;
    const playerID = ctx.request.body.playerID;
    const credentials = ctx.request.body.credentials;
    const newName = ctx.request.body.newName;
    const data = ctx.request.body.data;
    const { metadata } = await (db as StorageAPI.Async).fetch(matchID, {
      metadata: true,
    });
    if (typeof playerID === 'undefined') {
      ctx.throw(403, 'playerID is required');
    }
    if (data === undefined && !newName) {
      ctx.throw(403, 'newName or data is required');
    }
    if (newName && typeof newName !== 'string') {
      ctx.throw(403, `newName must be a string, got ${typeof newName}`);
    }
    if (!metadata) {
      ctx.throw(404, 'Match ' + matchID + ' not found');
    }
    if (!metadata.players[playerID]) {
      ctx.throw(404, 'Player ' + playerID + ' not found');
    }
    const isAuthorized = await auth.authenticateCredentials({
      playerID,
      credentials,
      metadata,
    });
    if (!isAuthorized) {
      ctx.throw(403, 'Invalid credentials ' + credentials);
    }

    if (newName) {
      metadata.players[playerID].name = newName;
    }
    if (data) {
      metadata.players[playerID].data = data;
    }
    await db.setMetadata(matchID, metadata);
    ctx.body = {};
  };

  /**
   * Change the name of a player in a given match.
   *
   * @param {string} name - The name of the game.
   * @param {string} id - The ID of the match.
   * @param {string} playerID - The ID of the player.
   * @param {string} credentials - The credentials of the player.
   * @param {object} newName - The new name of the player in the match.
   * @return - Nothing.
   */
  router.post('/games/:name/:id/rename', koaBody(), async (ctx) => {
    console.warn(
      'This endpoint /rename is deprecated. Please use /update instead.'
    );
    await updatePlayerMetadata(ctx);
  });

  /**
   * Update the player's data for a given match.
   *
   * @param {string} name - The name of the game.
   * @param {string} id - The ID of the match.
   * @param {string} playerID - The ID of the player.
   * @param {string} credentials - The credentials of the player.
   * @param {object} newName - The new name of the player in the match.
   * @param {object} data - The new data of the player in the match.
   * @return - Nothing.
   */
  router.post('/games/:name/:id/update', koaBody(), updatePlayerMetadata);

  return router;
};

export const configureApp = (
  app: Server.App,
  router: Router<any, Server.AppCtx>,
  origins: CorsOptions['origin']
): void => {
  app.use(
    cors({
      // Set Access-Control-Allow-Origin header for allowed origins.
      origin: (ctx) => {
        const origin = ctx.get('Origin');
        return isOriginAllowed(origin, origins) ? origin : '';
      },
    })
  );

  // If API_SECRET is set, then require that requests set an
  // api-secret header that is set to the same value.
  app.use(async (ctx, next) => {
    if (
      !!process.env.API_SECRET &&
      ctx.request.headers['api-secret'] !== process.env.API_SECRET
    ) {
      ctx.throw(403, 'Invalid API secret');
    }

    await next();
  });

  app.use(router.routes()).use(router.allowedMethods());
};

/**
 * Check if a request’s origin header is allowed for CORS.
 * Adapted from `cors` package: https://github.com/expressjs/cors
 * @param origin Request origin to test.
 * @param allowedOrigin Origin(s) that are allowed to connect via CORS.
 * @returns `true` if the origin matched at least one of the allowed origins.
 */
function isOriginAllowed(
  origin: string,
  allowedOrigin: CorsOptions['origin']
): boolean {
  if (Array.isArray(allowedOrigin)) {
    for (const entry of allowedOrigin) {
      if (isOriginAllowed(origin, entry)) {
        return true;
      }
    }
    return false;
  } else if (typeof allowedOrigin === 'string') {
    return origin === allowedOrigin;
  } else if (allowedOrigin instanceof RegExp) {
    return allowedOrigin.test(origin);
  } else {
    return !!allowedOrigin;
  }
}
