import { Board, BoardState, OpponentStats, Play, SimpleBoard, SimpleOpponentStats } from "../Types";

import { Player } from "@player";
import { AugmentationName, GoColor, GoOpponent, GoPlayType, GoValidity } from "@enums";
import { Go, GoEvents } from "../Go";
import {
  getNewBoardState,
  getNewBoardStateFromSimpleBoard,
  makeMove,
  passTurn,
  updateCaptures,
} from "../boardState/boardState";
import { getNextTurn, handleNextTurn, resetGoPromises } from "../boardAnalysis/goAI";
import {
  clearAllPointHighlights,
  evaluateIfMoveIsValid,
  getControlledSpace,
  getPreviousMove,
  simpleBoardFromBoard,
  simpleBoardFromBoardString,
} from "../boardAnalysis/boardAnalysis";
import { forceEndGoGame, getOpponentStats, getScore, resetWinstreak } from "../boardAnalysis/scoring";
import { WHRNG } from "../../Casino/RNG";
import { getRecordKeys } from "../../Types/Record";
import { CalculateEffect, getEffectTypeForFaction } from "./effect";
import { newOpponentStats } from "../Constants";
import { helpers } from "../../Netscript/NetscriptHelpers";
import type { NetscriptContext } from "../../Netscript/APIWrapper";
import { errorMessage } from "../../Netscript/ErrorMessages";

/**
 * Check the move based on the current settings
 */
export function validateMove(ctx: NetscriptContext, x: number, y: number, methodName = "", settings = {}): void {
  Go.moveOrCheatViaApi = true;
  const check = {
    emptyNode: true,
    requireNonEmptyNode: false,
    repeat: true,
    onlineNode: true,
    requireOfflineNode: false,
    suicide: true,
    playAsWhite: false,
    pass: false,
    ...settings,
  };

  const moveString = methodName + (check.pass ? "" : ` ${x},${y}`) + (check.playAsWhite ? " (White)" : "") + ": ";
  const moveColor = check.playAsWhite ? GoColor.white : GoColor.black;

  if (check.playAsWhite) {
    validatePlayAsWhite(ctx);
  }
  validateTurn(ctx, moveString, moveColor);

  if (check.pass) {
    return;
  }

  const boardSize = Go.currentGame.board.length;
  if (x < 0 || x >= boardSize) {
    throw errorMessage(ctx, `Invalid column number (x = ${x}), column must be a number 0 through ${boardSize - 1}`);
  }
  if (y < 0 || y >= boardSize) {
    throw errorMessage(ctx, `Invalid row number (y = ${y}), row must be a number 0 through ${boardSize - 1}`);
  }

  const validity = evaluateIfMoveIsValid(Go.currentGame, x, y, moveColor);
  const point = Go.currentGame.board[x][y];
  if (!point && check.onlineNode) {
    throw errorMessage(
      ctx,
      `The node ${x},${y} is offline, so you cannot ${
        methodName === "removeRouter"
          ? "clear this point with removeRouter()"
          : methodName === "destroyNode"
          ? "destroy the node. (Attempted to destroyNode)"
          : "place a router there"
      }.`,
    );
  }
  if (validity === GoValidity.noSuicide && check.suicide) {
    throw errorMessage(
      ctx,
      `${moveString} ${validity}. That point has no neighboring empty nodes, and is not connected to a network with access to empty nodes, meaning it would be instantly captured if played there.`,
    );
  }
  if (validity === GoValidity.boardRepeated && check.repeat) {
    throw errorMessage(
      ctx,
      `${moveString} ${validity}. That move would repeat the previous board state, which is illegal as it leads to infinite loops.`,
    );
  }
  if (point?.color !== GoColor.empty && check.emptyNode) {
    throw errorMessage(
      ctx,
      `The point ${x},${y} is occupied by a router, so you cannot ${
        methodName === "destroyNode" ? "destroy this node. (Attempted to destroyNode)" : "place a router there"
      }`,
    );
  }

  if (point?.color === GoColor.empty && check.requireNonEmptyNode) {
    throw errorMessage(
      ctx,
      `The point ${x},${y} does not have a router on it, so you cannot clear this point with removeRouter().`,
    );
  }
  if (point && check.requireOfflineNode) {
    throw errorMessage(ctx, `The node ${x},${y} is not offline, so you cannot repair the node.`);
  }
}

function validatePlayAsWhite(ctx: NetscriptContext) {
  if (Go.currentGame.ai !== GoOpponent.none) {
    throw errorMessage(ctx, `${GoValidity.invalid}. You can only play as white when playing against 'No AI'`);
  }

  if (Go.currentGame.previousPlayer === GoColor.white) {
    throw errorMessage(
      ctx,
      `${GoValidity.notYourTurn}. You cannot play or pass as white until the opponent has played.`,
    );
  }
}

function validateTurn(ctx: NetscriptContext, moveString = "", color = GoColor.black) {
  if (Go.currentGame.previousPlayer === color) {
    throw errorMessage(
      ctx,
      `${moveString} ${GoValidity.notYourTurn}. Do you have multiple scripts running, or did you forget to await makeMove() or opponentNextTurn()`,
    );
  }
  if (Go.currentGame.previousPlayer === null) {
    throw errorMessage(
      ctx,
      `${moveString} ${GoValidity.gameOver}. You cannot make more moves. Start a new game using resetBoardState().`,
    );
  }
}

/**
 * Pass player's turn and await the opponent's response (or logs the end of the game if both players pass)
 */
export function handlePassTurn(ctx: NetscriptContext, passAsWhite = false) {
  const color = passAsWhite ? GoColor.white : GoColor.black;
  passTurn(Go.currentGame, color);
  helpers.log(ctx, () => "Go turn passed.");
  if (Go.currentGame.previousPlayer === null) {
    logEndGame(ctx);
  }
  return handleNextTurn(Go.currentGame, true);
}

/**
 * Validates and applies the player's router placement
 */
export function makePlayerMove(ctx: NetscriptContext, x: number, y: number, playAsWhite = false) {
  const boardState = Go.currentGame;
  const color = playAsWhite ? GoColor.white : GoColor.black;
  const validity = evaluateIfMoveIsValid(boardState, x, y, color);
  const moveWasMade = makeMove(boardState, x, y, color);

  if (validity !== GoValidity.valid || !moveWasMade) {
    throw errorMessage(ctx, `Invalid move: ${x} ${y}. ${validity}.`);
  }

  helpers.log(ctx, () => `Go move played: ${x}, ${y}${playAsWhite ? " (White)" : ""}`);
  return handleNextTurn(boardState, true);
}

/**
  Returns the promise that provides the opponent's move, once it finishes thinking.
 */
export function getOpponentNextMove(ctx: NetscriptContext, logOpponentMove = true, playAsWhite = false) {
  const playerColor = playAsWhite ? GoColor.white : GoColor.black;
  const nextTurn = getNextTurn(playerColor);
  // Only asynchronously log the opponent move if not disabled by the player
  if (logOpponentMove) {
    return nextTurn.then((move) => {
      if (move.type === GoPlayType.gameOver) {
        logEndGame(ctx);
      } else if (move.type === GoPlayType.pass) {
        helpers.log(ctx, () => `Opponent passed their turn. You can end the game by passing as well.`);
      } else if (move.type === GoPlayType.move) {
        helpers.log(ctx, () => `Opponent played move: ${move.x}, ${move.y}`);
      }
      return move;
    });
  }

  return nextTurn;
}

/**
 * Returns a grid of booleans indicating if the coordinates at that location are a valid move for the player
 */
export function getValidMoves(_boardState?: BoardState, playAsWhite = false) {
  const boardState = _boardState || Go.currentGame;
  const color = playAsWhite ? GoColor.white : GoColor.black;

  // If the game is over, or if it is not your turn, there are no valid moves
  if (!boardState.previousPlayer || boardState.previousPlayer === color) {
    return boardState.board.map((): boolean[] => Array(boardState.board.length).fill(false) as boolean[]);
  }

  // Map the board matrix into true/false values
  return boardState.board.map((column, x) =>
    column.reduce((validityArray: boolean[], point, y) => {
      const isValid = evaluateIfMoveIsValid(boardState, x, y, color) === GoValidity.valid;
      validityArray.push(isValid);
      return validityArray;
    }, []),
  );
}

/**
 * Returns a grid with an ID for each contiguous chain of same-state nodes (excluding dead/offline nodes)
 */
export function getChains(_board?: Board) {
  const board = _board || Go.currentGame.board;
  const chains: string[] = [];
  // Turn the internal chain IDs into nice consecutive numbers for display to the player
  return board.map((column) =>
    column.reduce((chainIdArray: (number | null)[], point) => {
      if (!point) {
        chainIdArray.push(null);
        return chainIdArray;
      }
      if (!chains.includes(point.chain)) {
        chains.push(point.chain);
      }
      chainIdArray.push(chains.indexOf(point.chain));
      return chainIdArray;
    }, []),
  );
}

/**
 * Returns a grid of numbers representing the number of open-node connections each player-owned chain has.
 */
export function getLiberties(_board?: Board) {
  const board = _board || Go.currentGame.board;
  return board.map((column) =>
    column.map((point) => {
      if (!point?.liberties || point.color === GoColor.empty) {
        return -1;
      }
      return point.liberties.length;
    }, []),
  );
}

/**
 * Returns a grid indicating which player, if any, controls the empty nodes by fully encircling it with their routers
 */
export function getControlledEmptyNodes(_board?: Board) {
  const board = _board || Go.currentGame.board;
  const controlled = getControlledSpace(board);
  return controlled.map((column, x: number) =>
    column.reduce((ownedPoints: string, owner: GoColor, y: number) => {
      if (owner === GoColor.white) {
        return ownedPoints + "O";
      }
      if (owner === GoColor.black) {
        return ownedPoints + "X";
      }
      if (!board[x][y]) {
        return ownedPoints + "#";
      }
      if (board[x][y]?.color === GoColor.empty) {
        return ownedPoints + "?";
      }
      return ownedPoints + ".";
    }, ""),
  );
}

/**
 * Resets the active game to be a new board with "No AI" as the opponent. Applies the specified board state and komi to the new game.
 * Used for testing scenarios.
 */
export function setTestingBoardState(ctx: NetscriptContext, state: BoardState, komi?: number) {
  resetBoardState(ctx, GoOpponent.none, state.board.length);
  Go.currentGame = state;
  if (komi != undefined) {
    Go.currentGame.komiOverride = komi;
  }
  updateCaptures(Go.currentGame.board, Go.currentGame.previousPlayer ?? GoColor.white, true);
  GoEvents.emit();
}

/**
 * Returns all previous board states as SimpleBoards
 */
export function getHistory(): string[][] {
  return Go.currentGame.previousBoards.map((boardString): string[] => simpleBoardFromBoardString(boardString));
}

/**
 * Gets the status of the current game.
 * Shows the current player, current score, and the previous move coordinates.
 * Previous move will be null for a pass, or if there are no prior moves.
 *
 * Also provides the white player's komi (bonus starting score), and the amount of bonus cycles from offline time remaining
 */
export function getGameState() {
  const currentPlayer = getCurrentPlayer();
  const score = getScore(Go.currentGame);
  const previousMove = getPreviousMove();

  return {
    currentPlayer,
    whiteScore: score[GoColor.white].sum,
    blackScore: score[GoColor.black].sum,
    previousMove,
    komi: score[GoColor.white].komi,
    bonusCycles: Go.storedCycles,
  };
}

export function getMoveHistory(): SimpleBoard[] {
  return Go.currentGame.previousBoards.map((boardString) => simpleBoardFromBoardString(boardString));
}

/**
 * Returns 'None' if the game is over, otherwise returns the color of the current player's turn
 */
export function getCurrentPlayer(): "None" | "White" | "Black" {
  if (Go.currentGame.previousPlayer === null) {
    return "None";
  }
  return Go.currentGame.previousPlayer === GoColor.black ? GoColor.white : GoColor.black;
}

/**
 * Handle post-game logging
 */
function logEndGame(ctx: NetscriptContext) {
  const boardState = Go.currentGame;
  const score = getScore(boardState);
  helpers.log(
    ctx,
    () =>
      `Subnet complete! Final score: ${boardState.ai}: ${score[GoColor.white].sum},  Player: ${
        score[GoColor.black].sum
      }`,
  );
}

/**
 * Clears the board, resets winstreak if applicable
 */
export function resetBoardState(ctx: NetscriptContext, opponent: GoOpponent, boardSize: number) {
  if (![5, 7, 9, 13].includes(boardSize) && opponent !== GoOpponent.w0r1d_d43m0n) {
    throw errorMessage(ctx, `Invalid subnet size requested (${boardSize}), size must be 5, 7, 9, or 13`);
  }

  if (opponent === GoOpponent.w0r1d_d43m0n && !Player.hasAugmentation(AugmentationName.TheRedPill, true)) {
    throw errorMessage(ctx, `Invalid opponent requested (${opponent}), this opponent has not yet been discovered`);
  }

  const oldBoardState = Go.currentGame;
  if (oldBoardState.previousPlayer !== null && oldBoardState.previousBoards.length) {
    resetWinstreak(oldBoardState.ai, false);
  }

  Go.currentGame = getNewBoardState(boardSize, opponent, true);
  resetGoPromises();
  clearAllPointHighlights(Go.currentGame);
  helpers.log(ctx, () => `New game started: ${opponent}, ${boardSize}x${boardSize}`);
  return simpleBoardFromBoard(Go.currentGame.board);
}

/**
 * Retrieve and clean up stats for each opponent played against
 */
export function getStats() {
  const statDetails: Partial<Record<GoOpponent, SimpleOpponentStats>> = {};
  for (const opponent of getRecordKeys(Go.stats)) {
    const details = getOpponentStats(opponent);
    const nodePower = getOpponentStats(opponent).nodePower;
    const effectPercent = (CalculateEffect(nodePower, opponent) - 1) * 100;
    const effectDescription = getEffectTypeForFaction(opponent);
    statDetails[opponent] = {
      wins: details.wins,
      losses: details.losses,
      winStreak: details.winStreak,
      highestWinStreak: details.highestWinStreak,
      rep: details.rep,
      bonusPercent: effectPercent,
      bonusDescription: effectDescription,
    };
  }

  return statDetails;
}

/**
 * Reset all win/loss numbers for the No AI opponent.
 * @param resetAll if true, reset win/loss records for all opponents. This leaves node power and bonuses unchanged.
 */
export function resetStats(resetAll = false) {
  if (resetAll) {
    for (const opponent of getRecordKeys(Go.stats)) {
      Go.stats[opponent] = {
        ...(Go.stats[opponent] as OpponentStats),
        wins: 0,
        losses: 0,
        winStreak: 0,
        oldWinStreak: 0,
        highestWinStreak: 0,
      };
    }
  } else {
    Go.stats[GoOpponent.none] = newOpponentStats();
  }
}

const boardValidity = {
  valid: "",
  badShape: "Invalid boardState: Board must be a square",
  badType: "Invalid boardState: Board must be an array of strings",
  badSize: "Invalid boardState: Board must be 5, 7, 9, 13, or 19 in size",
  badCharacters:
    'Invalid board state: unknown characters found. "X" represents black pieces, "O" white, "." empty points, and "#" offline nodes.',
  failedToCreateBoard: "Invalid board state: Failed to create board",
} as const;

/**
 * Validate the given SimpleBoard and prior board state (if present) and turn it into a full BoardState with updated analytics
 */
export function validateBoardState(
  ctx: NetscriptContext,
  _boardState?: unknown,
  _priorBoardState?: unknown,
  playAsWhite = false,
): BoardState | undefined {
  const simpleBoard = getSimpleBoardFromUnknown(ctx, _boardState);
  const priorSimpleBoard = getSimpleBoardFromUnknown(ctx, _priorBoardState);

  if (!_boardState || !simpleBoard) {
    return undefined;
  }

  try {
    return getNewBoardStateFromSimpleBoard(
      simpleBoard,
      priorSimpleBoard,
      GoOpponent.none,
      playAsWhite ? GoColor.black : GoColor.white,
    );
  } catch (e) {
    throw errorMessage(ctx, boardValidity.failedToCreateBoard);
  }
}

/**
 * Check that the given boardState is a valid SimpleBoard, and return it if it is.
 */
function getSimpleBoardFromUnknown(ctx: NetscriptContext, _boardState: unknown): SimpleBoard | undefined {
  if (!_boardState) {
    return undefined;
  }
  if (!Array.isArray(_boardState)) {
    throw errorMessage(ctx, boardValidity.badType);
  }
  if ((_boardState as unknown[]).find((row) => typeof row !== "string")) {
    throw errorMessage(ctx, boardValidity.badType);
  }

  const boardState = _boardState as string[];

  if (boardState.find((row) => row.length !== boardState.length)) {
    throw errorMessage(ctx, boardValidity.badShape);
  }
  if (![5, 7, 9, 13, 19].includes(boardState.length)) {
    throw errorMessage(ctx, boardValidity.badSize);
  }
  if (boardState.find((row) => row.match(/[^XO#.]/))) {
    throw errorMessage(ctx, boardValidity.badCharacters);
  }
  return boardState as SimpleBoard;
}

/** Validate singularity access by throwing an error if the player does not have access. */
export function checkCheatApiAccess(ctx: NetscriptContext): void {
  const hasSourceFile = Player.activeSourceFileLvl(14) > 1;
  const isBitnodeFourteenTwo = Player.activeSourceFileLvl(14) === 1 && Player.bitNodeN === 14;
  if (!hasSourceFile && !isBitnodeFourteenTwo) {
    throw errorMessage(
      ctx,
      `The go.cheat API requires Source-File 14.2 to run, a power up you obtain later in the game.
      It will be very obvious when and how you can obtain it.`,
    );
  }
}

/**
 * Determines if the attempted cheat move is successful. If so, applies the cheat via the callback, and gets the opponent's response.
 *
 * If it fails, determines if the player's turn is skipped, or if the player is ejected from the subnet.
 */
export function determineCheatSuccess(
  ctx: NetscriptContext,
  callback: () => void,
  successRngOverride?: number,
  ejectRngOverride?: number,
  playAsWhite = false,
): Promise<Play> {
  const state = Go.currentGame;
  const rng = new WHRNG(Player.totalPlaytime);
  state.passCount = 0;
  const priorCheatCount = playAsWhite ? state.cheatCountForWhite : state.cheatCount;
  const playerColor = playAsWhite ? GoColor.white : GoColor.black;

  // If cheat is successful, run callback
  if ((successRngOverride ?? rng.random()) <= cheatSuccessChance(state.cheatCount, playAsWhite)) {
    callback();
  }
  // If there have been prior cheat attempts, and the cheat fails, there is a 10% chance of instantly ending the game
  else if (priorCheatCount && (ejectRngOverride ?? rng.random()) < 0.1 && state.ai !== GoOpponent.none) {
    helpers.log(ctx, () => `Cheat failed! You have been ejected from the subnet.`);
    forceEndGoGame(state);
    Player.giveAchievement("IPVGO_ANTICHEAT");
    return handleNextTurn(state, true);
  } else {
    // If the cheat fails, your turn is skipped
    helpers.log(ctx, () => `Cheat failed. Your turn has been skipped.`);
    passTurn(state, playerColor, false);
  }

  if (playAsWhite) {
    state.cheatCountForWhite++;
  } else {
    state.cheatCount++;
  }
  Go.currentGame.previousPlayer = playerColor;
  updateCaptures(Go.currentGame.board, playerColor, true);

  return handleNextTurn(state, true);
}

/**
 * Cheating success rate scales with player's crime success rate, and decreases with prior cheat attempts.
 *
 * The source file bonus is additive success chance on top of the other multipliers.
 *
 * Cheat success chance required for N cheats with 100% success rate in a game:
 *
 * 1 100% success rate cheat requires +66% increased crime success rate
 * 2 100% success cheats: +145% increased crime success rate
 * 3: +282%
 * 4: +535%
 * 5: +1027%
 * 7: +4278%
 * 10: +59,854%
 * 12: +534,704%
 * 15: +31,358,645%
 */
export function cheatSuccessChance(cheatCountOverride: number, playAsWhite = false) {
  const cheatCount =
    cheatCountOverride ?? (playAsWhite ? Go.currentGame.cheatCountForWhite : Go.currentGame.cheatCount);
  const sourceFileBonus = Player.activeSourceFileLvl(14) === 3 ? 0.25 : 0;
  const cheatCountScalar = (0.7 - 0.02 * cheatCount) ** cheatCount;
  return Math.max(Math.min(0.6 * cheatCountScalar * Player.mults.crime_success + sourceFileBonus, 1), 0);
}

/**
 * Attempts to remove an existing router from the board. Can fail. If failed, can immediately end the game
 */
export function cheatRemoveRouter(
  ctx: NetscriptContext,
  x: number,
  y: number,
  successRngOverride?: number,
  ejectRngOverride?: number,
  playAsWhite = false,
): Promise<Play> {
  const point = Go.currentGame.board[x][y];
  if (!point) {
    throw errorMessage(ctx, `Cheat failed. The point ${x},${y} is already offline.`);
  }
  return determineCheatSuccess(
    ctx,
    () => {
      point.color = GoColor.empty;
      helpers.log(ctx, () => `Cheat successful. The point ${x},${y} was cleared.`);
    },
    successRngOverride,
    ejectRngOverride,
    playAsWhite,
  );
}

/**
 * Attempts play two moves at once. Can fail. If failed, can immediately end the game
 */
export function cheatPlayTwoMoves(
  ctx: NetscriptContext,
  x1: number,
  y1: number,
  x2: number,
  y2: number,
  successRngOverride?: number,
  ejectRngOverride?: number,
  playAsWhite = false,
): Promise<Play> {
  const point1 = Go.currentGame.board[x1][y1];
  const point2 = Go.currentGame.board[x2][y2];

  if (!point1 || !point2) {
    throw errorMessage(ctx, `Cheat failed. One of the points ${x1},${y1} or ${x2},${y2} is already offline.`);
  }
  const playerColor = playAsWhite ? GoColor.white : GoColor.black;

  return determineCheatSuccess(
    ctx,
    () => {
      point1.color = playerColor;
      point2.color = playerColor;

      helpers.log(ctx, () => `Cheat successful. Two go moves played: ${x1},${y1} and ${x2},${y2}`);
    },
    successRngOverride,
    ejectRngOverride,
    playAsWhite,
  );
}

export function cheatRepairOfflineNode(
  ctx: NetscriptContext,
  x: number,
  y: number,
  successRngOverride?: number,
  ejectRngOverride?: number,
  playAsWhite = false,
): Promise<Play> {
  return determineCheatSuccess(
    ctx,
    () => {
      Go.currentGame.board[x][y] = {
        chain: "",
        liberties: null,
        y,
        color: GoColor.empty,
        x,
      };
      helpers.log(ctx, () => `Cheat successful. The point ${x},${y} was repaired.`);
    },
    successRngOverride,
    ejectRngOverride,
    playAsWhite,
  );
}

export function cheatDestroyNode(
  ctx: NetscriptContext,
  x: number,
  y: number,
  successRngOverride?: number,
  ejectRngOverride?: number,
  playAsWhite = false,
): Promise<Play> {
  return determineCheatSuccess(
    ctx,
    () => {
      Go.currentGame.board[x][y] = null;
      helpers.log(ctx, () => `Cheat successful. The point ${x},${y} was destroyed.`);
    },
    successRngOverride,
    ejectRngOverride,
    playAsWhite,
  );
}
