/*
 * Copyright 2020 Advanced Software Technologies Lab at ETH Zurich, Switzerland
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.android.commands.monkey.ape.agent;

import static com.android.commands.monkey.ape.utils.Config.activityStableRestartThreshold;
import static com.android.commands.monkey.ape.utils.Config.enableXPathAction;
import static com.android.commands.monkey.ape.utils.Config.evolveModel;
import static com.android.commands.monkey.ape.utils.Config.fuzzingActivityVisitThreshold;
import static com.android.commands.monkey.ape.utils.Config.graphStableRestartThreshold;
import static com.android.commands.monkey.ape.utils.Config.stateStableRestartThreshold;

import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;

import com.android.commands.monkey.MonkeySourceApe;
import com.android.commands.monkey.ape.ActionFilter;
import com.android.commands.monkey.ape.BadStateException;
import com.android.commands.monkey.ape.BaseActionFilter;
import com.android.commands.monkey.ape.Subsequence;
import com.android.commands.monkey.ape.model.Action;
import com.android.commands.monkey.ape.model.ActionCounters;
import com.android.commands.monkey.ape.model.ActivityNode;
import com.android.commands.monkey.ape.model.Graph;
import com.android.commands.monkey.ape.model.GraphListener;
import com.android.commands.monkey.ape.model.Model;
import com.android.commands.monkey.ape.model.Model.ActionRecord;
import com.android.commands.monkey.ape.model.xpathaction.XPathActionController;
import com.android.commands.monkey.ape.model.ModelAction;
import com.android.commands.monkey.ape.model.State;
import com.android.commands.monkey.ape.model.StateTransition;
import com.android.commands.monkey.ape.tree.GUITree;
import com.android.commands.monkey.ape.tree.GUITreeAction;
import com.android.commands.monkey.ape.tree.GUITreeWidgetDiffer;
import com.android.commands.monkey.ape.utils.Logger;
import com.android.commands.monkey.ape.utils.Utils;

import android.content.ComponentName;
import android.graphics.Bitmap;
import android.graphics.Rect;
import android.os.SystemClock;
import android.view.accessibility.AccessibilityNodeInfo;

public abstract class StatefulAgent extends ApeAgent implements GraphListener {

    private static final boolean debug = false;


    protected State lastState;
    protected GUITree lastGUITree;
    protected ModelAction lastAction;
    protected GUITreeAction lastGUITreeAction;

    protected State currentState;
    protected GUITree currentGUITree;
    protected ModelAction currentAction;
    protected GUITreeAction currentGUITreeAction;

    protected State newState;
    protected GUITree newGUITree;
    protected ModelAction newAction;
    protected GUITreeAction newGUITreeAction;

    protected StateTransition currentStateTransition;

    protected Model model;

    private ActionCounters actionCounters = new ActionCounters();

    private LinkedList<StateTransition> actionBuffer = new LinkedList<StateTransition>();

    protected int graphStableCounter;
    protected int stateStableCounter;
    protected int activityStableCounter;
    private boolean appActivityJustStarted;


    protected GUITreeWidgetDiffer widgetDiffer = new GUITreeWidgetDiffer();

    protected ActionFilter validatedActionFilter = new BaseActionFilter() {
        @Override
        public boolean include(ModelAction action) {
            return validateNewAction(action) != null;
        }
    };
    private Set<State> refreshStatesExcludedFromRefreshCheck = new HashSet<>();
    private boolean currentStateRecovered;
    private boolean appActivityJustStartedFromClean;
    protected com.android.commands.monkey.ape.jev.JevRuntime jev;

    private final AgentStateRefresher stateRefresher = new AgentStateRefresher();
    private final AgentActionScorer actionScorer = new AgentActionScorer();
    private final AgentModelRefinement modelRefinement = new AgentModelRefinement();

    public StatefulAgent(MonkeySourceApe ape, Graph graph) {
        super(ape);
        graph.addListener(this);
        this.model = new Model(graph);
        this.timestamp = graph.getTimestamp();
        boolean jevEnabled = com.android.commands.monkey.ape.utils.Config.isJevDecisionEnabled();
        boolean telemetry = com.android.commands.monkey.ape.utils.Config.getBoolean("ape.jev.telemetry", false);
        boolean regionShadowAudit = com.android.commands.monkey.ape.utils.Config.getBoolean("ape.jev.region_shadow_audit", false);
        if (jevEnabled || telemetry || regionShadowAudit || com.android.commands.monkey.ape.utils.ApeSnapshot.enabled() || com.android.commands.monkey.ape.utils.Config.getBoolean("ape.jev.region_locator_shadow",false)) {
            this.jev = com.android.commands.monkey.ape.jev.JevRuntime.create(ape.getOutputDirectory(), ape, !jevEnabled);
        }
    }

    @Override
    public boolean appCrashed(String processName,int pid,String shortMsg,String longMsg,long timeMillis,String stackTrace) {
        if(jev!=null)jev.recordCrash(processName,pid,shortMsg,longMsg,timeMillis,stackTrace);
        return super.appCrashed(processName,pid,shortMsg,longMsg,timeMillis,stackTrace);
    }

    public void updateModel(Model newModel) {
        this.model = newModel;
        if (currentState != null) {
            currentState = model.update(currentGUITree);
        }
        if (currentAction != null) {
            currentAction = model.update(currentAction, currentGUITreeAction);
        }
        if (lastState != null) {
            lastState = model.update(lastGUITree);
        }
        if (lastAction != null) {
            lastAction = model.update(lastAction, lastGUITreeAction);
        }
        if (newState != null) {
            newState = model.update(newGUITree);
        }
        if (newAction != null) {
            newAction = model.update(newAction, newGUITreeAction);
        }
        if (!actionBuffer.isEmpty()) {
            Logger.println("Update action buffer...");
            LinkedList<StateTransition> newBuffer = new LinkedList<>();
            for (StateTransition st : actionBuffer) {
                Logger.println("Updating " + st);
                st = newModel.update(st);
                Logger.println("Updated " + st);
                newBuffer.add(st);
            }
            actionBuffer = newBuffer;
        }
        List<ActionRecord> actionHistory = getActionHistory();
        final int size = actionHistory.size();
        for (int i = 0; i < size; i++) {
            ActionRecord actionPair = actionHistory.get(i);
            Action action = actionPair.modelAction;
            GUITreeAction guiAction = actionPair.guiAction;
            if (action.isModelAction() && action.requireTarget()) {
                if (guiAction == null) {
                    throw new RuntimeException("Sanity check failed!");
                }
                action = newModel.update((ModelAction)action, guiAction);
                updateActionHistory(i, new ActionRecord(actionPair.clockTimestamp,
                        actionPair.agentTimestamp, action, guiAction));
            }
        }
        if (jev != null) jev.sync(model, getTimestamp());
    }

    public State getLastState() {
        return lastState;
    }

    public void setLastState(State lastState) {
        this.lastState = lastState;
    }

    public GUITree getLastGUITree() {
        return lastGUITree;
    }

    public void setLastGUITree(GUITree lastGUITree) {
        this.lastGUITree = lastGUITree;
    }

    public ModelAction getLastAction() {
        return lastAction;
    }

    public void setLastAction(ModelAction lastAction) {
        this.lastAction = lastAction;
    }

    public GUITreeAction getLastGUITreeAction() {
        return lastGUITreeAction;
    }

    public void setLastGUITreeAction(GUITreeAction lastGUITreeAction) {
        this.lastGUITreeAction = lastGUITreeAction;
    }

    public State getCurrentState() {
        return currentState;
    }

    public void setCurrentState(State currentState) {
        this.currentState = currentState;
    }

    public GUITree getCurrentGUITree() {
        return currentGUITree;
    }

    public void setCurrentGUITree(GUITree currentGUITree) {
        this.currentGUITree = currentGUITree;
    }

    public GUITreeAction getCurrentGUITreeAction() {
        return currentGUITreeAction;
    }

    public void setCurrentGUITreeAction(GUITreeAction currentGUITreeAction) {
        this.currentGUITreeAction = currentGUITreeAction;
    }

    public State getNewState() {
        return newState;
    }

    public void setNewState(State newState) {
        this.newState = newState;
    }

    public GUITree getNewGUITree() {
        return newGUITree;
    }

    protected ModelAction checkFuzzing(ModelAction action) {
        if (!action.requireTarget()) {
            if (!action.isBack()) {
                return action;
            }
        }
        if (action.getState() == null) {
            return action;
        }
        ActivityNode an = getGraph().getActivityNode(action.getState().getActivity());
        if (an == null) {
            return action;
        }
        if (an.getVisitedCount() < fuzzingActivityVisitThreshold) {
            this.disableFuzzing = true;
        }
        return action;
    }

    public void setNewGUITree(GUITree newGUITree) {
        this.newGUITree = newGUITree;
    }

    public void setNewAction(ModelAction newAction) {
        this.newAction = newAction;
    }

    public GUITreeAction getNewGUITreeAction() {
        return newGUITreeAction;
    }

    public void setNewGUITreeAction(GUITreeAction newGUITreeAction) {
        this.newGUITreeAction = newGUITreeAction;
    }

    public StateTransition getCurrentStateTransition() {
        return currentStateTransition;
    }

    public void setCurrentStateTransition(StateTransition currentStateTransition) {
        this.currentStateTransition = currentStateTransition;
    }

    public void setCurrentAction(ModelAction currentAction) {
        this.currentAction = currentAction;
    }

    public abstract void onBufferLoss(State actual, State expected);

    public abstract void onRefillBuffer(Subsequence path);

    protected void assertEmptyActionBuffer() {
        if (!actionBuffer.isEmpty()) {
            throw new IllegalStateException("Try actions in the buffer first");
        }
    }

    /** Stable mode flag, independent of decision-service health and telemetry. */
    public boolean jevDecisionEnabled() { return jev != null && jev.isDecisionEnabled(); }

    public void snapshotDrain() { if(jev==null)throw new IllegalStateException("Missing Snapshot observation runtime");jev.snapshotDrain(); }
    public void snapshotResume(boolean live,String namespace)throws java.io.IOException { jev.snapshotResume(live,namespace); }
    public org.json.JSONObject snapshotRuntimeEvidence() { return jev.snapshotEvidence(); }
    public org.json.JSONObject snapshotNativeEvidence()throws Exception {
        org.json.JSONArray states=new org.json.JSONArray();
        java.util.List<State> ordered=new java.util.ArrayList<State>(model.getGraph().getStates());
        java.util.Collections.sort(ordered,new java.util.Comparator<State>(){public int compare(State a,State b){return a.getGraphId().compareTo(b.getGraphId());}});
        for(State state:ordered) {
            org.json.JSONArray actions=new org.json.JSONArray();
            for(ModelAction action:state.getActions())actions.put(new org.json.JSONObject().put("id",action.getGraphId())
                .put("visits",action.getVisitedCount()).put("enabled",action.isEnabled()).put("valid",action.isValid())
                .put("unvisited",action.isUnvisited()).put("saturated",action.isSaturated()));
            states.put(new org.json.JSONObject().put("id",state.getGraphId()).put("visits",state.getVisitedCount()).put("actions",actions));
        }
        return new org.json.JSONObject().put("model_sha256",com.android.commands.monkey.ape.utils.ApeSnapshot.serialized(model))
            .put("graph_fields",com.android.commands.monkey.ape.utils.ApeSnapshot.fields(model.getGraph()))
            .put("states",states)
            .put("tree_history_size",model.getGraph().getTreeHistory().size()).put("action_history_size",model.getActionHistory().size())
            .put("model_version",model.getVersion()).put("step",getTimestamp());
    }

    protected void clearBuffer() {
        remoteGreedyNavigation = false;
        if (!actionBuffer.isEmpty()) {
            StateTransition transition = actionBuffer.removeFirst();
            getGraph().weakenStateTransition(transition.getSource(), transition.getAction(), transition.getTarget());
            actionBuffer.clear();
        }
        if(jev!=null)jev.navigationEnded(newState,getTimestamp(),"buffer_cleared");
    }

    protected boolean regionAvailable(ModelAction action) { return true; }

    protected boolean acceptSelectedAction(Action action,String strategy) {
        if(action==null)return false;
        if(jev!=null && action instanceof ModelAction)
            jev.shadowSelection((ModelAction)action,newGUITree,getTimestamp(),strategy);
        if(jevDecisionEnabled() && action instanceof ModelAction && !isRemoteGreedyNavigation(action)
                && !jev.gate((ModelAction)action,newGUITree,strategy)) {
            if(action==pathSelectedAction){jev.navigationEnded(newState,getTimestamp(),"region_gate_skipped");clearBuffer();pathSelectedAction=null;}
            return false;
        }
        return true;
    }
    public int actionBufferSize(){return actionBuffer.size();}
    public boolean regionEnabled(){return jevDecisionEnabled() && jev.region()!=null;}
    public boolean regionTelemetryEnabled(){return jev!=null && jev.region()!=null;}
    public void jevDirectTextInput(Action action,String text,boolean sent){if(jev!=null)jev.directTextInput(action,text,sent);}
    public void jevBeforeInput(Action action){if(jev!=null)jev.beforeInput(action);}
    protected void beginProbeSelection() { if(jev!=null)jev.beginProbeSelection(); }
    protected void endProbeSelection() { if(jev!=null)jev.endProbeSelection(); }
    protected boolean semanticReviewActive() { return jev!=null && jev.semanticReviewEnabled(); }
    protected boolean semanticReviewSynchronous() { return jev!=null && jev.semanticReviewSynchronous(); }
    protected ModelAction selectProbe() {
        if(jev==null)return null;
        return jev.nextProbe(newState,newGUITree,getTimestamp(),new com.android.commands.monkey.ape.jev.JevRuntime.Validator(){
            public boolean valid(ModelAction action){return validateResolvedAction(action);}
        });
    }
    private ModelAction pathSelectedAction;
    private boolean remoteGreedyNavigation;
    protected boolean isRemoteGreedyNavigation(Action action) {
        return remoteGreedyNavigation && action == pathSelectedAction;
    }
    protected ModelAction refillRemoteGreedyBuffer(Subsequence path) {
        ModelAction action = refillBuffer(path);
        remoteGreedyNavigation = true;
        return action;
    }
    protected String pendingSelectionStrategy;

    protected ModelAction refillBuffer(Subsequence seq) {
        onRefillBuffer(seq);
        clearBuffer();
        return fillBuffer(seq);
    }

    protected ModelAction fillBuffer(Subsequence seq) {
        seq.fillBuffer(actionBuffer);
        if(jev!=null)jev.navigationStarted(seq,getTimestamp());
        ModelAction action = seq.getFirstAction();
        int throttle = Math.max(action.getThrottle(), actionBuffer.peekFirst().getThrottle());
        action.setThrottle(throttle);
        pathSelectedAction=action;
        return action;
    }

    public Graph getGraph() {
        return model.getGraph();
    }

    /**
     * Verify last action and new state
     * 
     * @return
     */
    protected ModelAction selectNewActionFromBuffer() {
        if (enableXPathAction) {
            ModelAction xpathAction = XPathActionController.selectAction(newState, newGUITree);
            if (xpathAction != null && regionAvailable(xpathAction)) {
                return xpathAction;
            }
        }
        if (actionBuffer.isEmpty()) {
            return null;
        }
        StateTransition t = actionBuffer.removeFirst();
        ModelAction expectedCurrentAction = t.action;
        State expectedNewState = t.target;
        if (!expectedCurrentAction.equals(currentAction)) {
            Logger.iprintln("Inconsistent actions in action buffer: expected " + expectedCurrentAction + ", get "
                    + currentAction);
            clearBuffer();
            return null;
        }
        if (expectedNewState != null && !expectedNewState.equals(newState)) {
            Logger.iprintln("Inconsistent states in action buffer: expected " + expectedNewState + ", get " + newState);
            widgetDiffer.diff(newState, expectedNewState);
            widgetDiffer.print();
            if (!actionBuffer.isEmpty() && currentStateTransition.isSameActivity()) {
                // Two states have the same activity and have the same actions sets.
                ModelAction action = actionBuffer.peekFirst().action;
                ModelAction relocatedAction = newState.relocate(action);
                if (relocatedAction == null) {
                    getGraph().weakenStateTransition(currentState, currentAction, expectedNewState);
                    onBufferLoss(newState, expectedNewState);
                } else {
                    Logger.format("Relocate %s to %s ", action, relocatedAction);
                    int throttle = Math.max(relocatedAction.getThrottle(), actionBuffer.peekFirst().getThrottle()); // transition throttle
                    Logger.iformat("Buffer action throttle: original: %d, tracked: %d", relocatedAction.getThrottle(), actionBuffer.peekFirst().getThrottle());
                    relocatedAction.setThrottle(throttle);
                }
                clearBuffer();
                pathSelectedAction=relocatedAction;
                return relocatedAction;
            }
            getGraph().weakenStateTransition(currentState, currentAction, expectedNewState);
            onBufferLoss(newState, expectedNewState);
            clearBuffer();
            return null;
        }
        if (actionBuffer.isEmpty()) {
            remoteGreedyNavigation=false;
            pathSelectedAction=null;
            if(jev!=null)jev.navigationEnded(newState,getTimestamp(),"arrived");
            return null;
        }
        ModelAction action = actionBuffer.peekFirst().action;
        Logger.iprintln("Peek an action from buffer " + action);
        ModelAction check;
        if (action.getTarget() != null) {
            check = newState.getAction(action.getTarget(), action.getType());
        } else {
            check = newState.getAction(action.getType());
        }
        if (check != action) {
            expectedNewState.dumpState();
            newState.dumpState();
            clearBuffer();
            return null;
        }
        int throttle = Math.max(action.getThrottle(), actionBuffer.peekFirst().getThrottle()); // transition throttle
        Logger.iformat("Buffer action throttle: original: %d, tracked: %d", action.getThrottle(), actionBuffer.peekFirst().getThrottle());
        action.setThrottle(throttle);
        pathSelectedAction=action;
        return action;
    }

    public Rect getCurrentRootNodeBounds() {
        if (currentState == null) {
            return null;
        }
        return currentState.getLatestGUITree().getRootNode().getBoundsInScreen();
    }

    public ModelAction getCurrentAction() {
        return this.currentAction;
    }

    protected void resetTrace() {
        if (jev != null) jev.selectionFailed("native_trace_reset");
        this.clearBuffer();
        this.currentStateTransition = null;
        this.currentState = null;
        this.currentAction = null;
        this.currentGUITree = null;
        this.currentGUITreeAction = null;
        this.newState = null;
        this.newAction = null;
        this.newGUITree = null;
        this.newGUITreeAction = null;
        this.lastAction = null;
        this.lastGUITree = null;
        this.lastGUITreeAction = null;
        this.lastState = null;
    }

    public void jevTraceReset(String reason) {if(jev!=null)jev.selectionFailed(reason);}
    public void jevEventsGenerated(Action action,List<? extends com.android.commands.monkey.MonkeyEvent> queue,int first) {
        if(jev!=null)jev.eventsGenerated(action,queue,first);
    }
    public void jevEventInjected(com.android.commands.monkey.MonkeyEvent event,int code) {
        if(jev!=null)jev.eventInjected(event,code);
    }
    public void jevEventsDiscarded(List<? extends com.android.commands.monkey.MonkeyEvent> queue) {if(jev!=null)jev.eventsDiscarded(queue);}

    @Override
    public void startNewEpisode() {
        resetTrace();
        this.graphStableCounter = 0;
        this.stateStableCounter = 0;
        this.activityStableCounter = 0;
    }

    Bitmap captureBitmap() {
        return ape.captureBitmap();
    }

    protected State refreshNewState() {
        return stateRefresher.refreshNewState(this);
    }

    protected void checkAndRefreshNewState() {
        stateRefresher.checkAndRefreshNewState(this, refreshStatesExcludedFromRefreshCheck);
    }

    protected void preCheckTrivialNewState() {
        stateRefresher.preCheckTrivialNewState(this, refreshStatesExcludedFromRefreshCheck);
    }

    protected State buildAndValidateNewState(ComponentName topComp, AccessibilityNodeInfo info) {
        newState = buildState(topComp, info, captureBitmap());
        preCheckTrivialNewState();
        validateAllNewActions();
        newGUITree = newState.getLatestGUITree();
        newGUITree.setTimestamp(getTimestamp());
        return newState;
    }

    /**
     * 
     */
    protected Action updateStateInternal(ComponentName topComp, AccessibilityNodeInfo info) {
        recoverCurrentState();
        buildAndValidateNewState(topComp, info);
        preEvolveModel();
        getGraph().markVisited(newState, getTimestamp());
        updateGraph();
        // checkCircleTransition();
        checkNonDeterministicTransitions();
        if (newState.isUnvisited()) {
            getGraph().markVisited(newState, getTimestamp());
        }
        Action action = resolveNewAction();
        if (action.isModelAction()) {
            getGraph().markVisited((ModelAction) action, getTimestamp());
            moveForward();
        } else {
            jevTraceReset("non_model_action_"+action.getType().name());
            this.resetTrace();
        }
        if (debug) {
            return Action.NOP;
        }
        return action; // newAction are moved to currentAction in moveForward
    }

    public void notifyActionConsumed() {
        GUITree.releaseLoadedData();
    }

    protected void checkNonDeterministicTransitions() {
        if (!evolveModel) {
            return;
        }
        if (currentStateTransition == null) {
            return;
        }
        if (currentStateRecovered) {
            return;
        }
        Model newModel = model.resolveNonDeterministicTransitions(currentStateTransition);
        if (newModel != null) {
            Logger.iprintln("Model has been refined, reset stateful..");
            updateModel(newModel);
            validateAllNewActions();
        }
    }

    protected void preEvolveModel() {
        if (!evolveModel) {
            return;
        }
        {
            long begin = SystemClock.elapsedRealtimeNanos();
            modelRefinement.checkUnderAbstractedState(this);
            long end = SystemClock.elapsedRealtimeNanos();
            Logger.iformat("Pre-checking under-abstracted states takes %d ms", TimeUnit.NANOSECONDS.toMillis(end - begin));
        }
        {
            long begin = SystemClock.elapsedRealtimeNanos();
            modelRefinement.checkOverAbstractedState(this);
            long end = SystemClock.elapsedRealtimeNanos();
            Logger.iformat("Checking over-abstracted states takes %d ms", TimeUnit.NANOSECONDS.toMillis(end - begin));
        }
        {
            long begin = SystemClock.elapsedRealtimeNanos();
            modelRefinement.checkUnderAbstractedState(this);
            long end = SystemClock.elapsedRealtimeNanos();
            Logger.iformat("Post-checking under-abstracted states takes %d ms", TimeUnit.NANOSECONDS.toMillis(end - begin));
        }
    }

    protected State buildState(ComponentName topComp, AccessibilityNodeInfo rootInfo, Bitmap bitmap) {
        State observed=model.getState(topComp, rootInfo, bitmap);
        return observed;
    }

    public void onAppActivityStarted(ComponentName app, boolean clean) {
        String className = app.getClassName();
        Logger.iprintln("App Activity " + className + " started.");
        appActivityJustStarted = true;
        appActivityJustStartedFromClean = clean;
    }

    protected void recoverCurrentState() {
        currentStateRecovered = false;
        if (currentState != null) {
            return;
        }
        List<ActionRecord> history = getActionHistory();
        if (history.isEmpty()) {
            return;
        }
        ActionRecord record = null;
        for (int index = history.size() - 1; index >= 0; index--) {
            record = history.get(index);
            if (record.modelAction.canStartApp()) {
                // do nothing if is start
                return;
            }
            if (record.modelAction.isModelAction()) {
                break;
            }
        }
        if (record == null || !record.modelAction.isModelAction()) {
            return; // no valid action
        }
        ModelAction modelAction = (ModelAction) record.modelAction;
        GUITreeAction guiAction = record.guiAction;
        currentState = modelAction.getState();
        currentAction = modelAction;
        currentGUITree = guiAction.getGUITree();
        currentGUITreeAction = guiAction;
        Logger.iprintln("Recover current states and actions...");
        Logger.iformat("> recovered current state: %s", currentState);
        Logger.iformat("> recovered current action: %s", currentAction);
        currentStateRecovered = true;
    }

    protected void updateGraph() {
        if (currentState == null) {
            if (this.appActivityJustStarted) {
                Logger.iformat("Entry state: %s", newState);
                model.getGraph().addEntryGUITree(newGUITree);
                if(jev!=null)jev.regionRoot(newState);
                if (appActivityJustStartedFromClean) {
                    model.getGraph().addCleanEntryGUITree(newGUITree);
                }
                this.appActivityJustStartedFromClean = false;
                this.appActivityJustStarted = false;
            }
        }
        currentStateTransition = model.addTransition(currentState, currentAction, newState, currentGUITree,
                currentGUITreeAction, newGUITree);
        if (jev != null) jev.sync(model, getTimestamp());
        checkStable();
    }

    protected void checkStable() {
        Logger.format("Graph Stable Counter: graph (%d), state (%d), activity (%d)", graphStableCounter,
                stateStableCounter, activityStableCounter);
        if (graphStableCounter > 0) {
            if (onGraphStable(graphStableCounter)) {
                graphStableCounter = 0;
            }
        }
        if (stateStableCounter > 0) {
            if (onStateStable(stateStableCounter)) {
                stateStableCounter = 0;
            }
        }
        if (activityStableCounter > 0) {
            if (onActivityStable(activityStableCounter)) {
                activityStableCounter = 0;
            }
        }
    }

    public boolean onActivityStable(int counter) {
        if (counter > activityStableRestartThreshold) {
            Logger.format("Activity is stable for %d", counter);
            requestRestart();
            return true;
        }
        return false;
    }

    @Override
    public boolean onGraphStable(int counter) {
        if (counter > graphStableRestartThreshold) {
            Logger.format("Graph is stable for %d", counter);
            requestRestart();
            return true;
        }
        return false;
    }

    @Override
    public void onActivityStopped() {
        jevTraceReset("activity_stopped");
        this.currentAction = null;
        this.currentState = null;
        this.newAction = null;
        this.newState = null;
        this.lastAction = null;
        this.lastState = null;
        clearBuffer();
        clearCounters();
    }

    private void clearCounters() {
        graphStableCounter = 0;
        stateStableCounter = 0;
        activityStableCounter = 0;
    }

    @Override
    public boolean onStateStable(int counter) {
        if (counter > stateStableRestartThreshold) {
            Logger.format("State is stable for %d", counter);
            requestRestart();
            return true;
        }
        return false;
    }

    protected void doMoveForward() {
        // Do switch
        Logger.format("Last  state: %s", lastState);
        Logger.format("Last action: %s", lastAction);
        Logger.format("Curr  state: %s", currentState);
        Logger.format("Curr action: %s", currentAction);
        Logger.format("New   state: %s", newState);
        Logger.format("New  action: %s", newAction);

        lastState = currentState;
        lastAction = currentAction;
        lastGUITree = currentGUITree;
        lastGUITreeAction = currentGUITreeAction;

        currentState = newState;
        currentAction = newAction;
        currentGUITree = newGUITree;
        currentGUITreeAction = newGUITreeAction;

        newState = null;
        newAction = null;
        newGUITree = null;
        newGUITreeAction = null;
        currentStateTransition = null;
    }

    protected void moveForward() {
        doMoveForward();
    }

    public void onAddNode(State node) {
    }

    @Override
    public void onVisitStateTransition(StateTransition edge) {
        currentStateTransition = edge;
        switch (edge.getType()) {
        case NEW_ACTION:
        case NEW_ACTION_TARGET:
            graphStableCounter = 0;
            break;
        case EXISTING:
            graphStableCounter++;
            break;
        }
        if (edge.isCircle() && edge.getTheta() == 0) {
            stateStableCounter++;
        } else {
            stateStableCounter = 0;
        }
        if (edge.isSameActivity()) {
            activityStableCounter++;
        } else {
            activityStableCounter = 0;
        }
    }

    protected boolean validateResolvedAction(ModelAction action) {return ape.validateResolvedAction(action);}

    protected ModelAction validateNewAction(ModelAction action) {
        if (action == null) {
            return null;
        }
        // BadState recovery can build another GUI tree without advancing the step.
        // Decision-mode exact inputs must bind to that tree, not the previous observation.
        if (newState != null && action.getState() == newState && action.isResolvedAt(getTimestamp()) && action.isValid()
                && (!jevDecisionEnabled() || action.getResolvedGUITree() == newState.getLatestGUITree())) {
            return action;
        }
        action = newState.resolveAction(this, action, getThrottleForNewAction(newState, action));
        if (validateResolvedAction(action)) {
            action.setValid(true);
            return action;
        }
        Logger.wformat("Mark an action (%s) invalid", action);
        action.setValid(false);
        return null;
    }

    protected void validateAllNewActions() {
        Utils.assertNotNull(newState);
        for (ModelAction action : newState.getActions()) {
            validateNewAction(action);
        }
    }

    protected Action resolveNewAction() {
        Utils.assertNotNull(newState);
        if(jev!=null)jev.safePoint(model,newState,newGUITree,getTimestamp());
        adjustActionsByGUITree();
        // The legacy asynchronous semantic branch prefetches on page arrival;
        // synchronous review is deliberately deferred to Sata's post-local
        // selection barrier so navigation and native choices keep priority.
        if(jev!=null && semanticReviewActive() && !semanticReviewSynchronous())
            jev.prepareSemanticReviews(model,newState,newGUITree);
        if(regionTelemetryEnabled())jev.beforeSelection(model,newState,newGUITree,getTimestamp());
        pathSelectedAction=null;pendingSelectionStrategy=null;
        Action action=selectNewActionNonnull();Utils.assertNotNull(action);
        if(action.isModelAction()) {
            newAction=(ModelAction)action;newGUITreeAction=newAction.getResolvedGUITreeAction();Utils.assertNotNull(newGUITreeAction);
        }
        if(jevDecisionEnabled() && pendingSelectionStrategy!=null)jev.recordSelectionDecision(pendingSelectionStrategy,newState,action,actionBufferSize());
        return action;
    }

    protected void adjustActionsByGUITree() {
        actionScorer.adjustActionsByGUITree(this, newState, newGUITree, timestamp);
    }


    protected int getThrottleForNewAction(State state, ModelAction action) {
        return actionScorer.getThrottleForNewAction(this, state, action);
    }

    /**
     * 
     * @return must return a non-null action
     */
    protected ModelAction handleNullAction() {
        ModelAction action = newState.randomlyPickAction(getRandom(), validatedActionFilter);
        if (action != null) {
            ModelAction resolved = validateNewAction(action);
            if (resolved != null) {
                return resolved;
            }
        }
        Logger.wformat("No available action on current state.");
        throw new BadStateException("No available action on the current state");
    }

    protected ModelAction selectNewActionRandomly() {
        ModelAction action = newState.randomlyPickAction(getRandom());
        return action;
    }

    protected ModelAction selectNewValidActionRandomly() {
        ModelAction action = newState.randomlyPickValidAction(getRandom());
        return action;
    }

    protected abstract Action selectNewActionNonnull();

    public void tearDown() {
        if (jev != null) jev.close();
        super.tearDown();
        actionCounters.print();
        getGraph().printActivityNodes();
        model.getNamingManager().dump();
        model.printCounters();
    }

    public List<ActionRecord> getActionHistory() {
        return this.model.getActionHistory();
    }

    public void updateActionHistory(int index, ActionRecord record) {
        this.model.updateActionHistory(index, record);
    }

    public void appendToActionHistory(long clockTimestamp, Action action) {
        int agentTimestamp = getTimestamp();
        this.model.appendToActionHistory(clockTimestamp, action, agentTimestamp);
        //actionCounters.logEvent(action.getType());
    }

}
