/*
 * $Id: GameState.java 5263 2009-03-09 13:36:43Z uckelman $
 *
 * Copyright (c) 2000-2003 by Rodney Kinney
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Library General Public
 * License (LGPL) as published by the Free Software Foundation.
 *
 * This library is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * Library General Public License for more details.
 *
 * You should have received a copy of the GNU Library General Public
 * License along with this library; if not, copies are available
 * at http://www.opensource.org.
 */
package VASSAL.build.module;

import java.awt.Cursor;
import java.awt.event.ActionEvent;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.JFrame;
import javax.swing.JOptionPane;

import org.jdesktop.swingworker.SwingWorker;

import VASSAL.build.GameModule;
import VASSAL.build.module.metadata.AbstractMetaData;
import VASSAL.build.module.metadata.MetaDataFactory;
import VASSAL.build.module.metadata.SaveMetaData;
import VASSAL.command.AddPiece;
import VASSAL.command.AlertCommand;
import VASSAL.command.Command;
import VASSAL.command.CommandEncoder;
import VASSAL.command.CommandFilter;
import VASSAL.command.ConditionalCommand;
import VASSAL.command.Logger;
import VASSAL.command.NullCommand;
import VASSAL.configure.DirectoryConfigurer;
import VASSAL.counters.GamePiece;
import VASSAL.i18n.Resources;
import VASSAL.launch.Launcher;
import VASSAL.tools.ArchiveWriter;
import VASSAL.tools.Deobfuscator;
import VASSAL.tools.ErrorDialog;
import VASSAL.tools.Obfuscator;
import VASSAL.tools.ReadErrorDialog;
import VASSAL.tools.ThrowableUtils;
import VASSAL.tools.WarningDialog;
import VASSAL.tools.WriteErrorDialog;
import VASSAL.tools.filechooser.FileChooser;
import VASSAL.tools.filechooser.LogAndSaveFileFilter;
import VASSAL.tools.io.FastByteArrayOutputStream;
import VASSAL.tools.io.IOUtils;
import VASSAL.tools.menu.MenuManager;

/**
 * The GameState represents the state of the game currently being played.
 * Only one game can be open at once.
 * @see GameModule#getGameState
 */
public class GameState implements CommandEncoder {
  protected Map<String,GamePiece> pieces = new HashMap<String,GamePiece>();
  protected List<GameComponent> gameComponents = new ArrayList<GameComponent>();
  protected List<GameSetupStep> setupSteps = new ArrayList<GameSetupStep>();
  protected Action loadGame, saveGame, newGame, closeGame;
  protected String lastSave;
  protected DirectoryConfigurer savedGameDirectoryPreference;

  public GameState() {}

  /**
   * Expects to be added to a GameModule.  Adds <code>New</code>,
   * <code>Load</code>, <code>Close</code>, and <code>Save</code>
   * entries to the <code>File</code> menu of the controls window
   */
  public void addTo(GameModule mod) {
    loadGame = new AbstractAction(Resources.getString("GameState.load_game")) {
      private static final long serialVersionUID = 1L;

      public void actionPerformed(ActionEvent e) {
        loadGame();
      }
    };
    // FIMXE: setting nmemonic from first letter could cause collisions in
    // some languages
    loadGame.putValue(Action.MNEMONIC_KEY, (int)Resources.getString("GameState.load_game.shortcut").charAt(0));

    saveGame = new AbstractAction(Resources.getString("GameState.save_game")) {
      private static final long serialVersionUID = 1L;

      public void actionPerformed(ActionEvent e) {
        saveGame();
      }
    };
    // FIMXE: setting nmemonic from first letter could cause collisions in
    // some languages
    saveGame.putValue(Action.MNEMONIC_KEY, (int)Resources.getString("GameState.save_game.shortcut").charAt(0));

    newGame = new AbstractAction(Resources.getString("GameState.new_game")) {
      private static final long serialVersionUID = 1L;

      public void actionPerformed(ActionEvent e) {
        setup(false);
        setup(true);
      }
    };
    // FIMXE: setting nmemonic from first letter could cause collisions in
    // some languages
    newGame.putValue(Action.MNEMONIC_KEY, (int)Resources.getString("GameState.new_game.shortcut").charAt(0));

    closeGame = new AbstractAction(
        Resources.getString("GameState.close_game")) {
      private static final long serialVersionUID = 1L;

      public void actionPerformed(ActionEvent e) {
        setup(false);
      }
    };
    // FIMXE: setting nmemonic from first letter could cause collisions in
    // some languages
    closeGame.putValue(Action.MNEMONIC_KEY, (int)Resources.getString("GameState.close_game.shortcut").charAt(0));

    final MenuManager mm = MenuManager.getInstance();
    mm.addAction("GameState.new_game", newGame);
    mm.addAction("GameState.load_game", loadGame);
    mm.addAction("GameState.save_game", saveGame);
    mm.addAction("GameState.close_game", closeGame);

    saveGame.setEnabled(gameStarting);
    closeGame.setEnabled(gameStarting);
  }

  /**
   * @return true if the game state is different from when it was last saved
   */
  public boolean isModified() {
    String s = saveString();
    return s != null && !s.equals(lastSave);
  }

  /**
   * Add a {@link GameComponent} to the list of objects that will
   * be notified when a game is started/ended
   */
  public void addGameComponent(GameComponent theComponent) {
    gameComponents.add(theComponent);
  }

  /**
   * Remove a {@link GameComponent} from the list of objects that will
   * be notified when a game is started/ended
   */
  public void removeGameComponent(GameComponent theComponent) {
    gameComponents.remove(theComponent);
  }
  
  /**
   * @return an enumeration of all {@link GameComponent} objects
   * that have been added to this GameState
   * @deprecated Use {@link #getGameComponents()} instead.
   */
  @SuppressWarnings("unchecked")
  @Deprecated
  public Enumeration getGameComponentsEnum() {
    return Collections.enumeration(gameComponents);
  }

  /**
   * @return a Collection of all {@link GameComponent} objects
   * that have been added to this GameState
   */
  public Collection<GameComponent> getGameComponents() {
    return Collections.unmodifiableCollection(gameComponents);
  }

  /** Add a {@link GameSetupStep} */
  public void addGameSetupStep(GameSetupStep step) {
    setupSteps.add(step);
  }
  
  /** Remove a {@link GameSetupStep} */
  public void removeGameSetupStep(GameSetupStep step) {
    setupSteps.remove(step);
  }

  /**
   * @return an iterator of all {@link GameSetupStep}s that are not
   * yet finished
   */
  public Iterator<GameSetupStep> getUnfinishedSetupSteps() {
    ArrayList<GameSetupStep> l = new ArrayList<GameSetupStep>();
    for (GameSetupStep step : setupSteps) {
      if (!step.isFinished()) {
        l.add(step);
      }
    }
    return l.iterator();
  }
  
  /* Using an instance variable allows us to shut down in the
     middle of a startup. */
  private boolean gameStarting = false;
  private boolean gameStarted = false;

  /**
   * Start/end a game.  Prompt to save if the game state has been
   * modified since last save.  Invoke {@link GameComponent#setup}
   * on all registered {@link GameComponent} objects.
   */
  public void setup(boolean gameStarting) {
    if (!gameStarting && gameStarted && isModified()) {
      switch (JOptionPane.showConfirmDialog(
        GameModule.getGameModule().getFrame(),
        Resources.getString("GameState.save_game_query"), //$NON-NLS-1$
        Resources.getString("GameState.game_modified"),   //$NON-NLS-1$
        JOptionPane.YES_NO_CANCEL_OPTION)) {
      case JOptionPane.YES_OPTION:
        saveGame();
        break;
      case JOptionPane.CANCEL_OPTION:
        return;
      }
    }

    this.gameStarting = gameStarting;
    if (!gameStarting) {
      pieces.clear();
    }

    newGame.setEnabled(!gameStarting);
    saveGame.setEnabled(gameStarting);
    closeGame.setEnabled(gameStarting);

    if (gameStarting) {
      loadGame.putValue(Action.NAME,
        Resources.getString("GameState.load_continuation"));
      GameModule.getGameModule().getWizardSupport().showGameSetupWizard();
    }
    else {
      loadGame.putValue(Action.NAME,
        Resources.getString("GameState.load_game"));
      GameModule.getGameModule().appendToTitle(null);
    }

    gameStarted = gameStarted && this.gameStarting;
    for (GameComponent gc : gameComponents) {
      gc.setup(this.gameStarting);
    }

    gameStarted = gameStarted || this.gameStarting;
    lastSave = gameStarting ? saveString() : null;
  }

  /** Return true if a game is currently in progress */
  public boolean isGameStarted() {
    return gameStarted;
  }

  /**
   * Read the game from a savefile.  The contents of the file is
   * sent to {@link GameModule#decode} and translated into a
   * {@link Command}, which is then executed.  The command read from the
   * file should be that returned by {@link #getRestoreCommand}.
   */
  public void loadGame() {
    final FileChooser fc = GameModule.getGameModule().getFileChooser();
    fc.addChoosableFileFilter(new LogAndSaveFileFilter());

    if (fc.showOpenDialog() != FileChooser.APPROVE_OPTION) return;

    final File f = fc.getSelectedFile();
    try {
      if (!f.exists()) throw new FileNotFoundException(
        "Unable to locate " + f.getPath());

      // Check the Save game for validity
      final AbstractMetaData metaData = MetaDataFactory.buildMetaData(f);
      if (metaData == null || ! (metaData instanceof SaveMetaData)) {
        WarningDialog.show("GameState.invalid_save_file", f.getPath());
        return;
      }
      
      // Check it belongs to this module and matches the version if is a
      // post 3.0 save file
      final SaveMetaData saveData = (SaveMetaData) metaData;
      if (saveData.getModuleData() != null) {
        final String saveModuleName = saveData.getModuleName();
        final String saveModuleVersion = saveData.getModuleVersion();
        final String moduleName = GameModule.getGameModule().getGameName();
        final String moduleVersion = GameModule.getGameModule().getGameVersion();
        String message = null;
        
        if (! saveModuleName.equals(moduleName)) {
          message = Resources.getString("GameState.load_module_mismatch", f.getName(), saveModuleName, moduleName);
        }
        else if (! saveModuleVersion.equals(moduleVersion)) {
          message = Resources.getString("GameState.load_version_mismatch", f.getName(), saveModuleVersion, moduleVersion);
        }
        if (message != null) {
          if (JOptionPane.showConfirmDialog(
              null,
              message,
              Resources.getString("GameState.load_mismatch"),              
              JOptionPane.YES_NO_OPTION,
              JOptionPane.QUESTION_MESSAGE) != JOptionPane.YES_OPTION) {
            GameModule.getGameModule().warn(Resources.getString("GameState.cancel_load", f.getName()));            
            return;
          }
        }
      }
      
      if (gameStarted) {
        loadContinuation(f);
      }
      else {
        loadGameInBackground(f);
      }
    }
    catch (IOException e) {
      ReadErrorDialog.error(e, f);
    }
/*
        String msg = Resources.getString("GameState.unable_to_load", f.getName());  //$NON-NLS-1$
        if (e.getMessage() != null) {
          msg += "\n" + e.getMessage();  //$NON-NLS-1$
        }
        JOptionPane.showMessageDialog(GameModule.getGameModule().getFrame(),
                               msg, Resources.getString("GameState.load_error"), JOptionPane.ERROR_MESSAGE);  //$NON-NLS-1$
    }
    else {
// FIXME: give more specific error message
// FIXME: maybe deprecate warn()?
      GameModule.getGameModule().warn(Resources.getString("GameState.unable_to_find", f.getPath()));  //$NON-NLS-1$
    }
*/
  }

  protected String saveString() {
    return GameModule.getGameModule().encode(getRestoreCommand());
  }


  /** Prompts the user for a file into which to save the game */
  public void saveGame() {
    GameModule.getGameModule().warn(Resources.getString("GameState.saving_game"));  //$NON-NLS-1$

    final File saveFile = getSaveFile();
    if (saveFile == null) {
      GameModule.getGameModule().warn(Resources.getString("GameState.save_canceled"));  //$NON-NLS-1$
    }
    else {
      try {
        saveGame(saveFile);
        GameModule.getGameModule().warn(Resources.getString("GameState.game_saved"));  //$NON-NLS-1$
      }
      catch (IOException e) {
        WriteErrorDialog.error(e, saveFile);
/*
        Logger.log(err);
        GameModule.getGameModule().warn(Resources.getString("GameState.save_failed"));  //$NON-NLS-1$
*/
      }
    }
  }

  public void setModified(boolean modified) {
    if (modified) {
      lastSave = null;
    }
    else {
      lastSave = saveString();
    }
  }

  private File getSaveFile() {
    final FileChooser fc = GameModule.getGameModule().getFileChooser();
    fc.selectDotSavFile();
    fc.addChoosableFileFilter(new LogAndSaveFileFilter());

    if (fc.showSaveDialog() != FileChooser.APPROVE_OPTION) return null;

    File file = fc.getSelectedFile();
    if (file.getName().indexOf('.') == -1)
      file = new File(file.getParent(), file.getName() + ".vsav");

    return file;
  }

  /**
   *  Add a {@link GamePiece} to the current game.
   * The GameState keeps track of all GamePieces in the system,
   * regardless of which {@link Map} they belong to (if any)
   */
  public void addPiece(GamePiece p) {
    if (p.getId() == null) {
      p.setId(getNewPieceId());
    }
    pieces.put(p.getId(), p);
  }

  /**
   * @return the {@link GamePiece} in the current game with the given id
   */
  public GamePiece getPieceForId(String id) {
    return id == null ? null : pieces.get(id);
  }

  /**
   *  Remove a {@link GamePiece} from the current game
   */
  public void removePiece(String id) {
    if (id != null) {
      pieces.remove(id);
    }
  }

  /**
   * @return a String identifier guaranteed to be different from the
   * id of all GamePieces in the game
   *
   * @see GamePiece#getId
   */
  public String getNewPieceId() {
    long time = System.currentTimeMillis();
    String id = (new Long(time)).toString();
    while (pieces.get(id) != null) {
      time++;
      id = (new Long(time)).toString();
    }
    return id;
  }

  public void loadContinuation(File f) throws IOException {
    Command c = decodeSavedGame(f);
    CommandFilter filter = new CommandFilter() {
      protected boolean accept(Command c) {
        return c instanceof BasicLogger.LogCommand;
      }
    };
    c = filter.apply(c);
    if (c != null) {
      c.execute();
    }
  }

  /**
   * @return an Enumeration of all {@link GamePiece}s in the game
   * @deprecated Use {@link #getAllPieces()} instead.
   */
  @Deprecated
  public Enumeration<GamePiece> getPieces() {
    return Collections.enumeration(pieces.values());
  }

  /** @return a Collection of all {@link GamePiece}s in the game */
  public Collection<GamePiece> getAllPieces() {
    return pieces.values();
  }

  public static class SetupCommand extends Command {
    private boolean gameStarting;

    public SetupCommand(boolean gameStarting) {
      this.gameStarting = gameStarting;
    }

    public boolean isGameStarting() {
      return gameStarting;
    }

    protected void executeCommand() {
      GameModule.getGameModule().getGameState().setup(gameStarting);
    }

    protected Command myUndoCommand() {
      return null;
    }
  }
  
  public static final String SAVEFILE_ZIP_ENTRY = "savedGame";  //$NON-NLS-1$
  
  /**
   * Return a {@link Command} that, when executed, will restore the
   * game to its current state.  Invokes {@link GameComponent#getRestoreCommand}
   * on each registered {@link GameComponent} */
  public Command getRestoreCommand() {
    if (!saveGame.isEnabled()) {
      return null;
    }
    Command c = new SetupCommand(false);
    c.append(checkVersionCommand());
    c.append(getRestorePiecesCommand());
    for (GameComponent gc : gameComponents) {
      c.append(gc.getRestoreCommand());
    }
    c.append(new SetupCommand(true));
    return c;
  }

  private Command checkVersionCommand() {
    String runningVersion = GameModule.getGameModule().getAttributeValueString(GameModule.VASSAL_VERSION_RUNNING);
    ConditionalCommand.Condition cond = new ConditionalCommand.Lt(GameModule.VASSAL_VERSION_RUNNING, runningVersion);
    Command c = new ConditionalCommand(new ConditionalCommand.Condition[]{cond}, new AlertCommand(Resources.getString("GameState.version_mismatch", runningVersion)));  //$NON-NLS-1$
    String moduleName = GameModule.getGameModule().getAttributeValueString(GameModule.MODULE_NAME);
    String moduleVersion = GameModule.getGameModule().getAttributeValueString(GameModule.MODULE_VERSION);
    cond = new ConditionalCommand.Lt(GameModule.MODULE_VERSION, moduleVersion);
    c.append(new ConditionalCommand(new ConditionalCommand.Condition[]{cond}, new AlertCommand(Resources.getString("GameState.version_mismatch2", moduleName, moduleVersion ))));  //$NON-NLS-1$
    return c;
  }

  /**
   * A GameState recognizes instances of {@link SetupCommand}
   */
  public String encode(Command c) {
    if (c instanceof SetupCommand) {
      return ((SetupCommand) c).isGameStarting() ? END_SAVE : BEGIN_SAVE;
    }
    else {
      return null;
    }
  }

  /**
   * A GameState recognizes instances of {@link SetupCommand}
   */
  public Command decode(String theCommand) {
    if (BEGIN_SAVE.equals(theCommand)) {
      return new SetupCommand(false);
    }
    else if (END_SAVE.equals(theCommand)) {
      return new SetupCommand(true);
    }
    else {
      return null;
    }
  }
  public static final String BEGIN_SAVE = "begin_save";  //$NON-NLS-1$
  public static final String END_SAVE = "end_save";  //$NON-NLS-1$

  public void saveGame(File f) throws IOException {
    final FastByteArrayOutputStream out = new FastByteArrayOutputStream();
    final String save = saveString();
    new Obfuscator(save.getBytes("UTF-8")).write(out);  //$NON-NLS-1$
    lastSave = save;

    final ArchiveWriter saver = new ArchiveWriter(f.getPath());
    saver.addFile(SAVEFILE_ZIP_ENTRY, out.toInputStream());
    (new SaveMetaData()).save(saver);
    saver.write();
    Launcher.getInstance().sendSaveCmd(f);
    if (saver.getArchive() != null) {
      saver.getArchive().close();
    }
  }
  
  public void loadGameInBackground(final File f) {
    try {
      loadGameInBackground(f.getName(),
                           new BufferedInputStream(new FileInputStream(f)));
    }
    catch (IOException e) {
      ReadErrorDialog.error(e, f);
    }
  }

  public void loadGameInBackground(final String shortName,
                                   final InputStream in)  {
    GameModule.getGameModule().warn(
      Resources.getString("GameState.loading", shortName));  //$NON-NLS-1$

    final JFrame frame = GameModule.getGameModule().getFrame();
    frame.setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR));

    new SwingWorker<Command,Void>() {
      @Override
      public Command doInBackground() throws Exception {
        try {
          return decodeSavedGame(in);
        }
        finally {
          IOUtils.closeQuietly(in);
        }
      }

      @Override
      protected void done() {
        try {
          Command loadCommand = null;
          String msg = null;
          try {
            loadCommand = get();
  
            if (loadCommand != null) {
              msg = Resources.getString("GameState.loaded", shortName);  //$NON-NLS-1$
            }
            else {
              msg = Resources.getString("GameState.invalid_savefile", shortName);  //$NON-NLS-1$
            } 
          }
          catch (InterruptedException e) {
            ErrorDialog.bug(e);
          }
          // FIXME: review error message
          catch (ExecutionException e) {
// FIXME: This is a temporary hack to catch OutOfMemoryErrors; there should
// be a better, more uniform and more permanent way of handling these, since
// an OOME is neither a VASSAL bug, a module bug, nor due to bad data.
            final OutOfMemoryError oom =
              ThrowableUtils.getAncestor(OutOfMemoryError.class, e);
            if (oom != null) {
              ErrorDialog.bug(e);
            }
            else {
              VASSAL.tools.logging.Logger.log(e);
            }
            msg = Resources.getString("GameState.error_loading", shortName);
          }
  
          if (loadCommand != null) {
            loadCommand.execute();
          }

          GameModule.getGameModule().warn(msg);
          Logger logger = GameModule.getGameModule().getLogger();
          if (logger instanceof BasicLogger) {
            ((BasicLogger)logger).queryNewLogFile(true);
          }
        }
        finally {
          frame.setCursor(Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR));
        }
      }
    }.execute();
  }

  /**
   * @return a Command that, when executed, will add all pieces currently
   * in the game. Used when saving a game.
   */
  public Command getRestorePiecesCommand() {
    final ArrayList<GamePiece> pieceList = new ArrayList<GamePiece>();
    for (GamePiece p : pieces.values()) {
      int index = 0;
      if (p.getParent() == null) {
        index = pieceList.size();
      }
      // TODO remove stacks that were empty when the game was loaded and are still empty now 
      pieceList.add(index, p);
    }

    final Command c = new NullCommand();
    for (GamePiece p : pieceList) {
      c.append(new AddPiece(p));
    }
    return c;
  }

  /**
   * Read a saved game and translate it into a Command.  Executing the
   * command will load the saved game.
   *
   * @param fileName
   * @return
   * @throws IOException
   */
  public Command decodeSavedGame(File saveFile) throws IOException {
    return decodeSavedGame(
      new BufferedInputStream(new FileInputStream(saveFile)));
  }
  
  public Command decodeSavedGame(InputStream in) throws IOException {
    ZipInputStream zipInput = null;
    try {
      zipInput = new ZipInputStream(in);
      for (ZipEntry entry = zipInput.getNextEntry(); entry != null;
           entry = zipInput.getNextEntry()) {
        if (SAVEFILE_ZIP_ENTRY.equals(entry.getName())) {
          return GameModule.getGameModule().decode(
            new Deobfuscator(zipInput).getString());
        }
      }
      zipInput.close();
    }
    finally {
      IOUtils.closeQuietly(zipInput);
    }

// FIXME: give more specific error message
    throw new IOException("Invalid saveFile format");
  }

  public DirectoryConfigurer getSavedGameDirectoryPreference() {
    if (savedGameDirectoryPreference == null) {
      savedGameDirectoryPreference = new DirectoryConfigurer("savedGameDir", null);
      GameModule.getGameModule().getPrefs().addOption(null,savedGameDirectoryPreference);
    }
    return savedGameDirectoryPreference;
  }
}
