package org.ggp.base.util.match;

import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;

import org.ggp.base.util.crypto.BaseCryptography.EncodedKeyPair;
import org.ggp.base.util.crypto.SignableJSON;
import org.ggp.base.util.game.Game;
import org.ggp.base.util.game.RemoteGameRepository;
import org.ggp.base.util.gdl.factory.GdlFactory;
import org.ggp.base.util.gdl.factory.exceptions.GdlFormatException;
import org.ggp.base.util.gdl.grammar.Gdl;
import org.ggp.base.util.gdl.grammar.GdlConstant;
import org.ggp.base.util.gdl.grammar.GdlFunction;
import org.ggp.base.util.gdl.grammar.GdlRelation;
import org.ggp.base.util.gdl.grammar.GdlSentence;
import org.ggp.base.util.gdl.grammar.GdlTerm;
import org.ggp.base.util.gdl.scrambler.GdlScrambler;
import org.ggp.base.util.gdl.scrambler.MappingGdlScrambler;
import org.ggp.base.util.gdl.scrambler.NoOpGdlScrambler;
import org.ggp.base.util.statemachine.Move;
import org.ggp.base.util.statemachine.Role;
import org.ggp.base.util.symbol.factory.SymbolFactory;
import org.ggp.base.util.symbol.factory.exceptions.SymbolFormatException;
import org.ggp.base.util.symbol.grammar.SymbolList;

import external.JSON.JSONArray;
import external.JSON.JSONException;
import external.JSON.JSONObject;

/**
 * Match encapsulates all of the information relating to a single match.
 * A match is a single play through a game, with a complete history that
 * lists what move each player made at each step through the match. This
 * also includes other relevant metadata about the match, including some
 * unique identifiers, configuration information, and so on.
 *
 * NOTE: Match objects created by a player, representing state read from
 * a server, are not completely filled out. For example, they only get an
 * ephemeral Game object, which has a rulesheet but no key or metadata.
 * Gamers which do not derive from StateMachineGamer also do not keep any
 * information on what states have been observed, because (somehow) they
 * are representing games without using state machines. In general, these
 * player-created Match objects shouldn't be sent out into the ecosystem.
 *
 * @author Sam
 */
public final class Match
{
    private final String matchId;
    private final String randomToken;
    private final String spectatorAuthToken;
    private String tournamentNameFromHost;
    private final int playClock;
    private final int startClock;
    private final int previewClock;
    private final Date startTime;
    private final Game theGame;
    private final List<List<GdlTerm>> moveHistory;
    private final List<Set<GdlSentence>> stateHistory;
    private final List<List<String>> errorHistory;
    private final List<Date> stateTimeHistory;
    private boolean isCompleted;
    private boolean isAborted;
    private final List<Integer> goalValues;
    private final int numRoles;

    private EncodedKeyPair theCryptographicKeys;
    private List<String> thePlayerNamesFromHost;
    private List<Boolean> isPlayerHuman;

    private GdlScrambler theGdlScrambler = new NoOpGdlScrambler();

    public Match(String matchId, int previewClock, int startClock, int playClock, Game theGame, String tournamentNameFromHost)
    {
        this.matchId = matchId;
        this.tournamentNameFromHost = tournamentNameFromHost;
        this.previewClock = previewClock;
        this.startClock = startClock;
        this.playClock = playClock;
        this.theGame = theGame;

        this.startTime = new Date();
        this.randomToken = getRandomString(32);
        this.spectatorAuthToken = getRandomString(12);
        this.isCompleted = false;
        this.isAborted = false;

        this.numRoles = Role.computeRoles(theGame.getRules()).size();

        this.moveHistory = new ArrayList<List<GdlTerm>>();
        this.stateHistory = new ArrayList<Set<GdlSentence>>();
        this.stateTimeHistory = new ArrayList<Date>();
        this.errorHistory = new ArrayList<List<String>>();

        this.goalValues = new ArrayList<Integer>();
    }

    public Match(String theJSON, Game theGame, String authToken) throws JSONException, SymbolFormatException, GdlFormatException {
        JSONObject theMatchObject = new JSONObject(theJSON);

        this.matchId = theMatchObject.getString("matchId");
        this.startClock = theMatchObject.getInt("startClock");
        this.playClock = theMatchObject.getInt("playClock");
        if (theGame == null) {
            this.theGame = RemoteGameRepository.loadSingleGame(theMatchObject.getString("gameMetaURL"));
            if (this.theGame == null) {
                throw new RuntimeException("Could not find metadata for game referenced in Match object: " + theMatchObject.getString("gameMetaURL"));
            }
        } else {
            this.theGame = theGame;
        }

        if (theMatchObject.has("previewClock")) {
            this.previewClock = theMatchObject.getInt("previewClock");
        } else {
            this.previewClock = -1;
        }

        this.startTime = new Date(theMatchObject.getLong("startTime"));
        this.randomToken = theMatchObject.getString("randomToken");
        this.spectatorAuthToken = authToken;
        this.isCompleted = theMatchObject.getBoolean("isCompleted");
        if (theMatchObject.has("isAborted")) {
            this.isAborted = theMatchObject.getBoolean("isAborted");
        } else {
            this.isAborted = false;
        }
        if (theMatchObject.has("tournamentNameFromHost")) {
            this.tournamentNameFromHost = theMatchObject.getString("tournamentNameFromHost");
        } else {
            this.tournamentNameFromHost = null;
        }

        this.numRoles = Role.computeRoles(this.theGame.getRules()).size();

        this.moveHistory = new ArrayList<List<GdlTerm>>();
        this.stateHistory = new ArrayList<Set<GdlSentence>>();
        this.stateTimeHistory = new ArrayList<Date>();
        this.errorHistory = new ArrayList<List<String>>();

        JSONArray theMoves = theMatchObject.getJSONArray("moves");
        for (int i = 0; i < theMoves.length(); i++) {
            List<GdlTerm> theMove = new ArrayList<GdlTerm>();
            JSONArray moveElements = theMoves.getJSONArray(i);
            for (int j = 0; j < moveElements.length(); j++) {
                theMove.add(GdlFactory.createTerm(moveElements.getString(j)));
            }
            moveHistory.add(theMove);
        }
        JSONArray theStates = theMatchObject.getJSONArray("states");
        for (int i = 0; i < theStates.length(); i++) {
            Set<GdlSentence> theState = new HashSet<GdlSentence>();
            SymbolList stateElements = (SymbolList) SymbolFactory.create(theStates.getString(i));
            for (int j = 0; j < stateElements.size(); j++)
            {
                theState.add((GdlSentence)GdlFactory.create("( true " + stateElements.get(j).toString() + " )"));
            }
            stateHistory.add(theState);
        }
        JSONArray theStateTimes = theMatchObject.getJSONArray("stateTimes");
        for (int i = 0; i < theStateTimes.length(); i++) {
            this.stateTimeHistory.add(new Date(theStateTimes.getLong(i)));
        }
        if (theMatchObject.has("errors")) {
            JSONArray theErrors = theMatchObject.getJSONArray("errors");
            for (int i = 0; i < theErrors.length(); i++) {
                List<String> theMoveErrors = new ArrayList<String>();
                JSONArray errorElements = theErrors.getJSONArray(i);
                for (int j = 0; j < errorElements.length(); j++)
                {
                    theMoveErrors.add(errorElements.getString(j));
                }
                errorHistory.add(theMoveErrors);
            }
        }

        this.goalValues = new ArrayList<Integer>();
        try {
            JSONArray theGoalValues = theMatchObject.getJSONArray("goalValues");
            for (int i = 0; i < theGoalValues.length(); i++) {
                this.goalValues.add(theGoalValues.getInt(i));
            }
        } catch (JSONException e) {}

        // TODO: Add a way to recover cryptographic public keys and signatures.
        // Or, perhaps loading a match into memory for editing should strip those?

        if (theMatchObject.has("playerNamesFromHost")) {
            thePlayerNamesFromHost = new ArrayList<String>();
            JSONArray thePlayerNames = theMatchObject.getJSONArray("playerNamesFromHost");
            for (int i = 0; i < thePlayerNames.length(); i++) {
                thePlayerNamesFromHost.add(thePlayerNames.getString(i));
            }
        }
        if (theMatchObject.has("isPlayerHuman")) {
            isPlayerHuman = new ArrayList<Boolean>();
            JSONArray isPlayerHumanArray = theMatchObject.getJSONArray("isPlayerHuman");
            for (int i = 0; i < isPlayerHumanArray.length(); i++) {
                isPlayerHuman.add(isPlayerHumanArray.getBoolean(i));
            }
        }
    }

    /* Mutators */

    public void setCryptographicKeys(EncodedKeyPair k) {
        this.theCryptographicKeys = k;
    }

    public void enableScrambling() {
        theGdlScrambler = new MappingGdlScrambler(new Random(startTime.getTime()));
        for (Gdl rule : theGame.getRules()) {
            theGdlScrambler.scramble(rule);
        }
    }

    public void setPlayerNamesFromHost(List<String> thePlayerNames) {
        this.thePlayerNamesFromHost = thePlayerNames;
    }

    public List<String> getPlayerNamesFromHost() {
        return thePlayerNamesFromHost;
    }

    public void setWhichPlayersAreHuman(List<Boolean> isPlayerHuman) {
        this.isPlayerHuman = isPlayerHuman;
    }

    public void appendMoves(List<GdlTerm> moves) {
        moveHistory.add(moves);
    }

    public void appendMoves2(List<Move> moves) {
        // NOTE: This is appendMoves2 because it Java can't handle two
        // appendMove methods that both take List objects with different
        // templatized parameters.
        List<GdlTerm> theMoves = new ArrayList<GdlTerm>();
        for(Move m : moves) {
            theMoves.add(m.getContents());
        }
        appendMoves(theMoves);
    }

    public void appendState(Set<GdlSentence> state) {
        stateHistory.add(state);
        stateTimeHistory.add(new Date());
    }

    public void appendErrors(List<String> errors) {
        errorHistory.add(errors);
    }

    public void appendNoErrors() {
        List<String> theNoErrors = new ArrayList<String>();
        for (int i = 0; i < this.numRoles; i++) {
            theNoErrors.add("");
        }
        errorHistory.add(theNoErrors);
    }

    public void markCompleted(List<Integer> theGoalValues) {
        this.isCompleted = true;
        if (theGoalValues != null) {
            this.goalValues.addAll(theGoalValues);
        }
    }

    public void markAborted() {
        this.isAborted = true;
    }

    /* Complex accessors */

    public String toJSON() {
        JSONObject theJSON = new JSONObject();

        try {
            theJSON.put("matchId", matchId);
            theJSON.put("randomToken", randomToken);
            theJSON.put("startTime", startTime.getTime());
            theJSON.put("gameMetaURL", getGameRepositoryURL());
            theJSON.put("isCompleted", isCompleted);
            theJSON.put("isAborted", isAborted);
            theJSON.put("states", new JSONArray(renderArrayAsJSON(renderStateHistory(stateHistory), true)));
            theJSON.put("moves", new JSONArray(renderArrayAsJSON(renderMoveHistory(moveHistory), false)));
            theJSON.put("stateTimes", new JSONArray(renderArrayAsJSON(stateTimeHistory, false)));
            if (!errorHistory.isEmpty()) {
                theJSON.put("errors", new JSONArray(renderArrayAsJSON(renderErrorHistory(errorHistory), false)));
            }
            if (!goalValues.isEmpty()) {
                theJSON.put("goalValues", goalValues);
            }
            theJSON.put("previewClock", previewClock);
            theJSON.put("startClock", startClock);
            theJSON.put("playClock", playClock);
            if (thePlayerNamesFromHost != null) {
                theJSON.put("playerNamesFromHost", thePlayerNamesFromHost);
            }
            if (isPlayerHuman != null) {
                theJSON.put("isPlayerHuman", isPlayerHuman);
            }
            if (tournamentNameFromHost != null) {
                theJSON.put("tournamentNameFromHost", tournamentNameFromHost);
            }
            theJSON.put("scrambled", theGdlScrambler != null ? theGdlScrambler.scrambles() : false);
        } catch (JSONException e) {
            return null;
        }

        if (theCryptographicKeys != null) {
            try {
                SignableJSON.signJSON(theJSON, theCryptographicKeys.thePublicKey, theCryptographicKeys.thePrivateKey);
                if (!SignableJSON.isSignedJSON(theJSON)) {
                    throw new Exception("Could not recognize signed match: " + theJSON);
                }
                if (!SignableJSON.verifySignedJSON(theJSON)) {
                    throw new Exception("Could not verify signed match: " + theJSON);
                }
            } catch (Exception e) {
                System.err.println(e);
                theJSON.remove("matchHostPK");
                theJSON.remove("matchHostSignature");
            }
        }

        return theJSON.toString();
    }

    public String toXML() {
        try {
            JSONObject theJSON = new JSONObject(toJSON());

            StringBuilder theXML = new StringBuilder();
            theXML.append("<match>");
            for (String key : JSONObject.getNames(theJSON)) {
                Object value = theJSON.get(key);
                if (value instanceof JSONObject) {
                    throw new RuntimeException("Unexpected embedded JSONObject in match JSON with tag " + key + "; could not convert to XML.");
                } else if (!(value instanceof JSONArray)) {
                    theXML.append(renderLeafXML(key, theJSON.get(key)));
                } else if (key.equals("states")) {
                    theXML.append(renderStateHistoryXML(stateHistory));
                } else if (key.equals("moves")) {
                    theXML.append(renderMoveHistoryXML(moveHistory));
                } else if (key.equals("errors")) {
                    theXML.append(renderErrorHistoryXML(errorHistory));
                } else {
                    theXML.append(renderArrayXML(key, (JSONArray)value));
                }
            }
            theXML.append("</match>");

            return theXML.toString();
        } catch (JSONException je) {
            return null;
        }
    }

    public List<GdlTerm> getMostRecentMoves() {
        if (moveHistory.isEmpty())
            return null;
        return moveHistory.get(moveHistory.size()-1);
    }

    public Set<GdlSentence> getMostRecentState() {
        if (stateHistory.isEmpty())
            return null;
        return stateHistory.get(stateHistory.size()-1);
    }

    public String getGameRepositoryURL() {
        return getGame().getRepositoryURL();
    }

    @Override
    public String toString() {
        return toJSON();
    }

    /* Simple accessors */

    public String getMatchId() {
        return matchId;
    }

    public String getRandomToken() {
        return randomToken;
    }

    public String getSpectatorAuthToken() {
        return spectatorAuthToken;
    }

    public Game getGame() {
        return theGame;
    }

    public List<List<GdlTerm>> getMoveHistory() {
        return moveHistory;
    }

    public List<Set<GdlSentence>> getStateHistory() {
        return stateHistory;
    }

    public List<Date> getStateTimeHistory() {
        return stateTimeHistory;
    }

    public List<List<String>> getErrorHistory() {
        return errorHistory;
    }

    public int getPreviewClock() {
        return previewClock;
    }

    public int getPlayClock() {
        return playClock;
    }

    public int getStartClock() {
        return startClock;
    }

    public Date getStartTime() {
        return startTime;
    }

    public String getTournamentNameFromHost() {
        return tournamentNameFromHost;
    }

    public boolean isCompleted() {
        return isCompleted;
    }

    public boolean isAborted() {
        return isAborted;
    }

    public List<Integer> getGoalValues() {
        return goalValues;
    }

    public GdlScrambler getGdlScrambler() {
        return theGdlScrambler;
    }

    /* Static methods */

    public static final String getRandomString(int nLength) {
        Random theGenerator = ThreadLocalRandom.current();
        String theString = "";
        for (int i = 0; i < nLength; i++) {
            int nVal = theGenerator.nextInt(62);
            if (nVal < 26) theString += (char)('a' + nVal);
            else if (nVal < 52) theString += (char)('A' + (nVal-26));
            else if (nVal < 62) theString += (char)('0' + (nVal-52));
        }
        return theString;
    }

    /* JSON rendering methods */

    private static final String renderArrayAsJSON(List<?> theList, boolean useQuotes) {
        String s = "[";
        for (int i = 0; i < theList.size(); i++) {
            Object o = theList.get(i);
            // AppEngine-specific, not needed yet: if (o instanceof Text) o = ((Text)o).getValue();
            if (o instanceof Date) o = ((Date)o).getTime();

            if (useQuotes) s += "\"";
            s += o.toString();
            if (useQuotes) s += "\"";

            if (i < theList.size() - 1)
                s += ", ";
        }
        return s + "]";
    }

    private static final List<String> renderStateHistory(List<Set<GdlSentence>> stateHistory) {
        List<String> renderedStates = new ArrayList<String>();
        for (Set<GdlSentence> aState : stateHistory) {
            renderedStates.add(renderStateAsSymbolList(aState));
        }
        return renderedStates;
    }

    private static final List<String> renderMoveHistory(List<List<GdlTerm>> moveHistory) {
        List<String> renderedMoves = new ArrayList<String>();
        for (List<GdlTerm> aMove : moveHistory) {
            renderedMoves.add(renderArrayAsJSON(aMove, true));
        }
        return renderedMoves;
    }

    private static final List<String> renderErrorHistory(List<List<String>> errorHistory) {
        List<String> renderedErrors = new ArrayList<String>();
        for (List<String> anError : errorHistory) {
            renderedErrors.add(renderArrayAsJSON(anError, true));
        }
        return renderedErrors;
    }

    private static final String renderStateAsSymbolList(Set<GdlSentence> theState) {
        // Strip out the TRUE proposition, since those are implied for states.
        String s = "( ";
        for (GdlSentence sent : theState) {
            String sentString = sent.toString();
            s += sentString.substring(6, sentString.length()-2).trim() + " ";
        }
        return s + ")";
    }

    /* XML Rendering methods -- these are horribly inefficient and are included only for legacy/standards compatibility */

    private static final String renderLeafXML(String tagName, Object value) {
        return "<" + tagName + ">" + value.toString() + "</" + tagName + ">";
    }

    private static final String renderMoveHistoryXML(List<List<GdlTerm>> moveHistory) {
        StringBuilder theXML = new StringBuilder();
        theXML.append("<history>");
        for (List<GdlTerm> move : moveHistory) {
            theXML.append("<move>");
            for (GdlTerm action : move) {
                theXML.append(renderLeafXML("action", renderGdlToXML(action)));
            }
            theXML.append("</move>");
        }
        theXML.append("</history>");
        return theXML.toString();
    }

    private static final String renderErrorHistoryXML(List<List<String>> errorHistory) {
        StringBuilder theXML = new StringBuilder();
        theXML.append("<errorHistory>");
        for (List<String> errors : errorHistory) {
            theXML.append("<errors>");
            for (String error : errors) {
                theXML.append(renderLeafXML("error", error));
            }
            theXML.append("</errors>");
        }
        theXML.append("</errorHistory>");
        return theXML.toString();
    }

    private static final String renderStateHistoryXML(List<Set<GdlSentence>> stateHistory) {
        StringBuilder theXML = new StringBuilder();
        theXML.append("<herstory>");
        for (Set<GdlSentence> state : stateHistory) {
            theXML.append(renderStateXML(state));
        }
        theXML.append("</herstory>");
        return theXML.toString();
    }

    public static final String renderStateXML(Set<GdlSentence> state) {
        StringBuilder theXML = new StringBuilder();
        theXML.append("<state>");
        for (GdlSentence sentence : state) {
            theXML.append(renderGdlToXML(sentence));
        }
        theXML.append("</state>");
        return theXML.toString();
    }

    private static final String renderArrayXML(String tag, JSONArray arr) throws JSONException {
        StringBuilder theXML = new StringBuilder();
        for (int i = 0; i < arr.length(); i++) {
            theXML.append(renderLeafXML(tag, arr.get(i)));
        }
        return theXML.toString();
    }

    private static final String renderGdlToXML(Gdl gdl) {
        String rval = "";
        if(gdl instanceof GdlConstant) {
            GdlConstant c = (GdlConstant)gdl;
            return c.getValue();
        } else if(gdl instanceof GdlFunction) {
            GdlFunction f = (GdlFunction)gdl;
            if(f.getName().toString().equals("true"))
            {
                return "<fact>"+renderGdlToXML(f.get(0))+"</fact>";
            }
            else
            {
                rval += "<relation>"+f.getName()+"</relation>";
                for(int i=0; i<f.arity(); i++)
                    rval += "<argument>"+renderGdlToXML(f.get(i))+"</argument>";
                return rval;
            }
        } else if (gdl instanceof GdlRelation) {
            GdlRelation relation = (GdlRelation) gdl;
            if(relation.getName().toString().equals("true"))
            {
                for(int i=0; i<relation.arity(); i++)
                    rval+="<fact>"+renderGdlToXML(relation.get(i))+"</fact>";
                return rval;
            } else {
                rval+="<relation>"+relation.getName()+"</relation>";
                for(int i=0; i<relation.arity(); i++)
                    rval+="<argument>"+renderGdlToXML(relation.get(i))+"</argument>";
                return rval;
            }
        } else {
            System.err.println("gdlToXML Error: could not handle "+gdl.toString());
            return null;
        }
    }
}