/*
 * 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 PluginImmer from './plugin-immer';
import PluginRandom from './plugin-random';
import PluginEvents from './plugin-events';
import PluginLog from './plugin-log';
import PluginSerializable from './plugin-serializable';
import type {
  AnyFn,
  DefaultPluginAPIs,
  PartialGameState,
  State,
  Game,
  Plugin,
  ActionShape,
  PlayerID,
} from '../types';
import { error } from '../core/logger';
import type { GameMethod } from '../core/game-methods';

interface PluginOpts {
  game: Game;
  isClient?: boolean;
}

/**
 * List of plugins that are always added.
 */
const CORE_PLUGINS = [PluginImmer, PluginRandom, PluginLog, PluginSerializable];
const DEFAULT_PLUGINS = [...CORE_PLUGINS, PluginEvents];

/**
 * Allow plugins to intercept actions and process them.
 */
export const ProcessAction = (
  state: State,
  action: ActionShape.Plugin,
  opts: PluginOpts
): State => {
  // TODO(#723): Extend error handling to plugins.
  opts.game.plugins
    .filter((plugin) => plugin.action !== undefined)
    .filter((plugin) => plugin.name === action.payload.type)
    .forEach((plugin) => {
      const name = plugin.name;
      const pluginState = state.plugins[name] || { data: {} };
      const data = plugin.action(pluginState.data, action.payload);

      state = {
        ...state,
        plugins: {
          ...state.plugins,
          [name]: { ...pluginState, data },
        },
      };
    });
  return state;
};

/**
 * The APIs created by various plugins are stored in the plugins
 * section of the state object:
 *
 * {
 *   G: {},
 *   ctx: {},
 *   plugins: {
 *     plugin-a: {
 *       data: {},  // this is generated by the plugin at Setup / Flush.
 *       api: {},   // this is ephemeral and generated by Enhance.
 *     }
 *   }
 * }
 *
 * This function retrieves plugin APIs and returns them as an object
 * for consumption as used by move contexts.
 */
export const GetAPIs = ({ plugins }: PartialGameState) =>
  Object.entries(plugins || {}).reduce((apis, [name, { api }]) => {
    apis[name] = api;
    return apis;
  }, {} as DefaultPluginAPIs);

/**
 * Applies the provided plugins to the given move / flow function.
 *
 * @param methodToWrap - The move function or hook to apply the plugins to.
 * @param methodType - The type of the move or hook being wrapped.
 * @param plugins - The list of plugins.
 */
export const FnWrap = (
  methodToWrap: AnyFn,
  methodType: GameMethod,
  plugins: Plugin[]
) => {
  return [...CORE_PLUGINS, ...plugins, PluginEvents]
    .filter((plugin) => plugin.fnWrap !== undefined)
    .reduce(
      (method: AnyFn, { fnWrap }: Plugin) => fnWrap(method, methodType),
      methodToWrap
    );
};

/**
 * Allows the plugin to generate its initial state.
 */
export const Setup = (
  state: PartialGameState,
  opts: PluginOpts
): PartialGameState => {
  [...DEFAULT_PLUGINS, ...opts.game.plugins]
    .filter((plugin) => plugin.setup !== undefined)
    .forEach((plugin) => {
      const name = plugin.name;
      const data = plugin.setup({
        G: state.G,
        ctx: state.ctx,
        game: opts.game,
      });

      state = {
        ...state,
        plugins: {
          ...state.plugins,
          [name]: { data },
        },
      };
    });
  return state;
};

/**
 * Invokes the plugin before a move or event.
 * The API that the plugin generates is stored inside
 * the `plugins` section of the state (which is subsequently
 * merged into ctx).
 */
export const Enhance = <S extends State | PartialGameState>(
  state: S,
  opts: PluginOpts & { playerID: PlayerID }
): S => {
  [...DEFAULT_PLUGINS, ...opts.game.plugins]
    .filter((plugin) => plugin.api !== undefined)
    .forEach((plugin) => {
      const name = plugin.name;
      const pluginState = state.plugins[name] || { data: {} };

      const api = plugin.api({
        G: state.G,
        ctx: state.ctx,
        data: pluginState.data,
        game: opts.game,
        playerID: opts.playerID,
      });

      state = {
        ...state,
        plugins: {
          ...state.plugins,
          [name]: { ...pluginState, api },
        },
      };
    });
  return state;
};

/**
 * Allows plugins to update their state after a move / event.
 */
const Flush = (state: State, opts: PluginOpts): State => {
  // We flush the events plugin first, then custom plugins and the core plugins.
  // This means custom plugins cannot use the events API but will be available in event hooks.
  // Note that plugins are flushed in reverse, to allow custom plugins calling each other.
  [...CORE_PLUGINS, ...opts.game.plugins, PluginEvents]
    .reverse()
    .forEach((plugin) => {
      const name = plugin.name;
      const pluginState = state.plugins[name] || { data: {} };

      if (plugin.flush) {
        const newData = plugin.flush({
          G: state.G,
          ctx: state.ctx,
          game: opts.game,
          api: pluginState.api,
          data: pluginState.data,
        });

        state = {
          ...state,
          plugins: {
            ...state.plugins,
            [plugin.name]: { data: newData },
          },
        };
      } else if (plugin.dangerouslyFlushRawState) {
        state = plugin.dangerouslyFlushRawState({
          state,
          game: opts.game,
          api: pluginState.api,
          data: pluginState.data,
        });

        // Remove everything other than data.
        const data = state.plugins[name].data;
        state = {
          ...state,
          plugins: {
            ...state.plugins,
            [plugin.name]: { data },
          },
        };
      }
    });

  return state;
};

/**
 * Allows plugins to indicate if they should not be materialized on the client.
 * This will cause the client to discard the state update and wait for the
 * master instead.
 */
export const NoClient = (state: State, opts: PluginOpts): boolean => {
  return [...DEFAULT_PLUGINS, ...opts.game.plugins]
    .filter((plugin) => plugin.noClient !== undefined)
    .map((plugin) => {
      const name = plugin.name;
      const pluginState = state.plugins[name];

      if (pluginState) {
        return plugin.noClient({
          G: state.G,
          ctx: state.ctx,
          game: opts.game,
          api: pluginState.api,
          data: pluginState.data,
        });
      }

      return false;
    })
    .includes(true);
};

/**
 * Allows plugins to indicate if the entire action should be thrown out
 * as invalid. This will cancel the entire state update.
 */
const IsInvalid = (
  state: State,
  opts: PluginOpts
): false | { plugin: string; message: string } => {
  const firstInvalidReturn = [...DEFAULT_PLUGINS, ...opts.game.plugins]
    .filter((plugin) => plugin.isInvalid !== undefined)
    .map((plugin) => {
      const { name } = plugin;
      const pluginState = state.plugins[name];

      const message = plugin.isInvalid({
        G: state.G,
        ctx: state.ctx,
        game: opts.game,
        data: pluginState && pluginState.data,
      });

      return message ? { plugin: name, message } : false;
    })
    .find((value) => value);
  return firstInvalidReturn || false;
};

/**
 * Update plugin state after move/event & check if plugins consider the update to be valid.
 * @returns Tuple of `[updatedState]` or `[originalState, invalidError]`.
 */
export const FlushAndValidate = (state: State, opts: PluginOpts) => {
  const updatedState = Flush(state, opts);
  const isInvalid = IsInvalid(updatedState, opts);
  if (!isInvalid) return [updatedState] as const;
  const { plugin, message } = isInvalid;
  error(`${plugin} plugin declared action invalid:\n${message}`);
  return [state, isInvalid] as const;
};

/**
 * Allows plugins to customize their data for specific players.
 * For example, a plugin may want to share no data with the client, or
 * want to keep some player data secret from opponents.
 */
export const PlayerView = (
  { G, ctx, plugins = {} }: State,
  { game, playerID }: PluginOpts & { playerID: PlayerID }
) => {
  [...DEFAULT_PLUGINS, ...game.plugins].forEach(({ name, playerView }) => {
    if (!playerView) return;

    const { data } = plugins[name] || { data: {} };
    const newData = playerView({ G, ctx, game, data, playerID });

    plugins = {
      ...plugins,
      [name]: { data: newData },
    };
  });

  return plugins;
};
