/* Copyright (c) 2009 Google Inc.
 *
 * 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.google.appengine.demos.sticky.client.model;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;

import com.google.appengine.demos.sticky.client.model.Service.CreateObjectResult;
import com.google.appengine.demos.sticky.client.model.Service.UserInfoResult;
import com.google.gwt.core.client.GWT;
import com.google.gwt.user.client.rpc.AsyncCallback;

/**
 * Encapsulates the entire application data controller for the application. The
 * model controls all RPC to the server and is responsible for keeping client
 * side copies of data synchronized with the server.
 * 
 * @author knorton@google.com (Kelly Norton)
 */
public class Model {

  /**
   * An observer interface to deliver all data change events.
   */
  public interface DataObserver {

    /**
     * Called when a new {@link Note} is created.
     * 
     * @param note
     *          the note that was created
     */
    void onNoteCreated(Note note);

    /**
     * Called when a new {@link Surface} is created.
     * 
     * @param surface
     *          the surface that was created
     */
    void onSurfaceCreated(Surface surface);

    /**
     * Called when an initial list of {@link Note}s is returned from the server.
     * 
     * @param notes
     *          all the {@link Note}s on the currently selected {@link Surface}.
     */
    void onSurfaceNotesReceived(Note[] notes);

    /**
     * Called when the selected {@link Surface} changes.
     * 
     * @param nowSelected
     *          the surface that is now selected
     * @param wasSelected
     *          the surface that was previously selected
     */
    void onSurfaceSelected(Surface nowSelected, Surface wasSelected);

    /**
     * Called when the initial list of {@link Surface}s is returned from the
     * server.
     * 
     * @param surfaces
     *          all the surfaces where the current author is a member
     */
    void onSurfacesReceived(Surface[] surfaces);
  }

  /**
   * An observer interface used to get callbacks during the initial load of the
   * {@link Model}.
   */
  public interface LoadObserver {

    /**
     * Invoked when the {@link Model} loads successfully.
     * 
     * @param model
     *          the newly loaded model
     */
    void onModelLoaded(Model model);

    /**
     * Invoked when the model fails to load.
     */
    void onModelLoadFailed();
  }

  /**
   * An observer interface that provides callbacks useful for giving the user
   * feedback about calls to the server.
   */
  public interface StatusObserver {
    /**
     * Invoked when RPC calls begin to succeed again after a failure was
     * reported.
     */
    void onServerCameBack();

    /**
     * Invoked when RPC calls to the server are failing.
     */
    void onServerWentAway();

    /**
     * Invoked when current task has finished. This is often used to stop
     * displaying status Ui that was made visible in the
     * {@link StatusObserver#onTaskStarted(String)} callback.
     */
    void onTaskFinished();

    /**
     * Invoked when a task that requires user feedback starts.
     * 
     * @param description
     *          a description of the task that is starting
     */
    void onTaskStarted(String description);
  }

  /**
   * A simple callback for reporting success to the caller asynchronously. This
   * is used for call sites where the caller needs to know the result of an RPC.
   */
  public static interface SuccessCallback {
    void onResponse(boolean success);
  }

  /**
   * A task that manages the call to the server to add an author to a surface.
   */
  private class AddAuthorToSurfaceTask extends Task implements
      AsyncCallback<Service.AddAuthorToSurfaceResult> {
    private final Surface surface;

    private final String email;

    private final SuccessCallback callback;

    public AddAuthorToSurfaceTask(Surface surface, String email,
        SuccessCallback callback) {
      this.surface = surface;
      this.email = email;
      this.callback = callback;
    }

    public void onFailure(Throwable caught) {
      getQueue().taskFailed(this,
          caught instanceof Service.AccessDeniedException);
    }

    public void onSuccess(Service.AddAuthorToSurfaceResult result) {
      final boolean success = result != null;
      callback.onResponse(success);
      if (success) {
        surface.update(Model.this, result.getAuthorName(), result
            .getUpdatedAt());
      }
      getQueue().taskSucceeded(this);
    }

    @Override
    void execute() {
      api.addAuthorToSurface(surface.getKey(), email, this);
    }
  }

  /**
   * A task that manages the call to the server to create a new note.
   */
  private class CreateNoteTask extends Task implements
      AsyncCallback<CreateObjectResult> {
    private final Note note;

    private final Surface surface;

    public CreateNoteTask(Surface surface, Note note) {
      this.note = note;
      this.surface = surface;
    }

    @Override
    public void execute() {
      api.createNote(surface.getKey(), note.getX(), note.getY(), note
          .getWidth(), note.getHeight(), this);
    }

    public void onFailure(Throwable caught) {
      getQueue().taskFailed(this,
          caught instanceof Service.AccessDeniedException);
    }

    public void onSuccess(CreateObjectResult result) {
      if (surface == selectedSurface) {
        noteLoader.cacheNote(result.getKey(), note);
      }
      note.update(result.getKey(), result.getUpdateTime());
      getQueue().taskSucceeded(this);
    }
  }

  /**
   * A task to manages the call to the server to create a new surface.
   */
  private class CreateSurfaceTask extends Task implements
      AsyncCallback<Service.CreateObjectResult> {
    private final Surface surface;

    public CreateSurfaceTask(Surface surface) {
      this.surface = surface;
    }

    public void execute() {
      api.createSurface(surface.getTitle(), this);
    }

    public void onFailure(Throwable caught) {
      getQueue().taskFailed(this,
          caught instanceof Service.AccessDeniedException);
    }

    public void onSuccess(Service.CreateObjectResult result) {
      surfaceLoader.cacheSurface(result.getKey(), surface);
      surface.update(result.getKey(), result.getUpdateTime());
      getQueue().taskSucceeded(this);
    }
  }

  /**
   * Encapsulates a linked list node that is used by {@link TaskQueue} to keep
   * an ordered list of pending {@link Task}s.
   */
  private static class Node {
    private final Task task;

    private Node next;

    Node(Task task) {
      this.task = task;
    }

    void execute(TaskQueue queue) {
      task.execute(queue);
    }
  }

  /**
   * Encapsulates a task for writing data to the server. The tasks are managed
   * by the {@link TaskQueue} and are auto-retried on failure.
   */
  private abstract static class Task {
    private TaskQueue queue;

    abstract void execute();

    void execute(TaskQueue queue) {
      this.queue = queue;
      execute();
    }

    TaskQueue getQueue() {
      return queue;
    }
  }

  /**
   * Provides a mechanism to perform write tasks sequentially and retry tasks
   * that fail.
   */
  private class TaskQueue extends RetryTimer {

    private Node head, tail;

    public void post(Task task) {
      final Node node = new Node(task);
      if (isIdle()) {
        head = tail = node;
        executeHead();
      } else {
        enqueueTail(node);
      }
    }

    private void enqueueTail(Node node) {
      assert head != null && tail != null;
      assert node != null;
      tail = tail.next = node;
    }

    private void executeHead() {
      head.execute(this);
    }

    private void executeNext() {
      head = head.next;
      if (head != null) {
        executeHead();
      } else {
        tail = null;
      }
    }

    private boolean isIdle() {
      return head == null;
    }

    private void taskFailed(Task task, boolean fatal) {
      assert task == head.task;

      // Report a failure to the Model.
      onServerFailed(fatal);

      // Schedule a retry.
      retryLater();
    }

    private void taskSucceeded(Task task) {
      assert task == head.task;
      // Report a success to the Model.
      onServerSucceeded();

      // Reset the retry counter.
      resetRetryCount();

      // Move on to the next task.
      executeNext();
    }

    @Override
    protected void retry() {
      // Retry running the head task.
      executeHead();
    }
  }

  /**
   * A {@link Task} that manages the call to the server to update the contents
   * of a {@link Note}.
   */
  private class UpdateNoteContentTask extends Task implements
      AsyncCallback<Date> {
    private final String content;

    private final Note note;

    public UpdateNoteContentTask(Note note, String content) {
      this.note = note;
      this.content = content;
    }

    public void execute() {
      note.setContent(content);
      api.changeNoteContent(note.getKey(), content, this);
    }

    public void onFailure(Throwable caught) {
      getQueue().taskFailed(this,
          caught instanceof Service.AccessDeniedException);
    }

    public void onSuccess(Date lastUpdatedAt) {
      note.update(lastUpdatedAt);
      getQueue().taskSucceeded(this);
    }
  }

  /**
   * A {@link Task} that manages the call to the server to update the position
   * of a {@link Note}.
   */
  private class UpdateNotePositionTask extends Task implements
      AsyncCallback<Date> {
    private final Note note;

    private final int x, y, width, height;

    public UpdateNotePositionTask(Note note, int x, int y, int w, int h) {
      this.note = note;
      this.x = x;
      this.y = y;
      this.width = w;
      this.height = h;
    }

    public void execute() {
      note.setX(x);
      note.setY(y);
      note.setWidth(width);
      note.setHeight(height);
      api.changeNotePosition(note.getKey(), x, y, width, height, this);
    }

    public void onFailure(Throwable caught) {
      getQueue().taskFailed(this,
          caught instanceof Service.AccessDeniedException);
    }

    public void onSuccess(Date result) {
      note.update(result);
      getQueue().taskSucceeded(this);
    }
  }

  /**
   * The period to use, in millisconds, for polling for updates to notes on the
   * currently selected surface.
   */
  private static final int GET_NOTES_POLLING_INTERVAL = 10000 /* ms. */;

  /**
   * The period to use, in milliseconds, for polling for updates to the list of
   * surfaces that the author is participating in.
   */
  private static final int GET_SURFACES_POLLING_INTERVAL = 20000 /* ms. */;

  /**
   * Provides an asynchronous factory for loading a {@link Model}.
   * 
   * @param loadObserver
   *          a callback to receive load events
   * @param statusObserver
   *          a callback to receive status events
   */
  public static void load(final LoadObserver loadObserver,
      final StatusObserver statusObserver) {
    final ServiceAsync api = GWT.create(Service.class);
    api.getUserInfo(new AsyncCallback<Service.UserInfoResult>() {
      public void onFailure(Throwable caught) {
        loadObserver.onModelLoadFailed();
      }

      public void onSuccess(UserInfoResult result) {
        loadObserver.onModelLoaded(new Model(result.getAuthor(), result
            .getSurface(), result.getLogoutUrl(), api, statusObserver));
      }
    });
  }

  native static void forceApplicationReload() /*-{
    $wnd.location.reload();
  }-*/;

  /**
   * An rpc proxy for making calls to the server.
   */
  private final ServiceAsync api;

  /**
   * The currently selected surface. This should never be null.
   */
  private Surface selectedSurface;

  /**
   * The currently logged in author.
   */
  private final Author author;

  /**
   * The list of the observers monitoring the model for data related events.
   */
  private final List<DataObserver> dataObservers = new ArrayList<DataObserver>();

  /**
   * The observer that is receiving status events.
   */
  private final StatusObserver statusObserver;

  /**
   * A url that can be used to log the current user out.
   */
  private final String logoutUrl;

  /**
   * A task queue to manage all writes to the server.
   */
  private final TaskQueue taskQueue = new TaskQueue();

  /**
   * Manages the initial loading of notes associated with the selected surface
   * and polls repeatedly for changes.
   */
  private final NoteLoader noteLoader = new NoteLoader(this,
      GET_NOTES_POLLING_INTERVAL);

  /**
   * Manages the initial loading of the list of surfaces for an author and
   * continues polling repeatedly for updates.
   */
  private final SurfaceLoader surfaceLoader = new SurfaceLoader(this,
      GET_SURFACES_POLLING_INTERVAL);

  /**
   * Indicates whether the RPC end point is currently responding.
   */
  private boolean offline;

  private Model(Author author, Surface selectedSurface, String logoutUrl,
      ServiceAsync api, StatusObserver statusObserver) {
    this.author = author;
    this.api = api;
    this.logoutUrl = logoutUrl;
    this.statusObserver = statusObserver;

    selectedSurface.initialize(this);
    selectSurface(selectedSurface);

    surfaceLoader.start();
  }

  /**
   * Add an {@link Author} as a member of a particular {@link Surface} and
   * persist that change to the server.
   * 
   * @param surface
   *          the surface to which the author will be added
   * @param email
   *          the email address of the person to add
   * @param callback
   *          a callback to report success/failure to the caller
   */
  public void addAuthorToSurface(Surface surface, String email,
      SuccessCallback callback) {
    taskQueue.post(new AddAuthorToSurfaceTask(surface, email, callback));
  }

  /**
   * Subsscribes a {@link DataObserver} to receive data related events from this
   * {@link Model}.
   * 
   * @param observer
   */
  public void addDataObserver(DataObserver observer) {
    dataObservers.add(observer);
  }

  /**
   * Creates a note with no content at a particular location on the
   * {@link Surface} and persists that change to the server.
   * 
   * @param x
   * @param y
   * @param width
   * @param height
   */
  public void createNote(int x, int y, int width, int height) {
    final Note note = new Note(this, x, y, width, height);
    notifyNoteCreated(note);
    taskQueue.post(new CreateNoteTask(getSelectedSurface(), note));
  }

  /**
   * Creates a {@link Surface} with the specified title and persists that change
   * to the server.
   * 
   * @param title
   */
  public void createSurface(String title) {
    final Surface surface = new Surface(this, title);
    notifySurfaceCreated(surface);
    taskQueue.post(new CreateSurfaceTask(surface));
    selectSurface(surface);
  }

  /**
   * Gets the currently logged in author.
   * 
   * @return
   */
  public Author getCurrentAuthor() {
    return author;
  }

  /**
   * Gets a url that can be used to log out the current user.
   * 
   * @return
   */
  public String getLogoutUrl() {
    return logoutUrl;
  }

  /**
   * Gets the currently selected surface.
   * 
   * @return
   */
  public Surface getSelectedSurface() {
    return selectedSurface;
  }

  /**
   * Selects the specified {@link Surface}. The newly selected surface should be
   * made visible in the Ui.
   * 
   * @param surface
   *          the surface to display
   */
  public void selectSurface(Surface surface) {
    if (surface == selectedSurface) {
      return;
    }

    final Surface wasSelected = selectedSurface;
    selectedSurface = surface;

    noteLoader.reset();

    notifySurfaceSelected(surface, wasSelected);
  }

  /**
   * Updates the contents of a {@link Note} and persists the change to the
   * server.
   * 
   * @param note
   * @param content
   */
  public void updateNoteContent(final Note note, String content) {
    taskQueue.post(new UpdateNoteContentTask(note, content));
  }

  /**
   * Updates the position of a {@link Note} and persists the change to the
   * server.
   * 
   * @param note
   * @param x
   * @param y
   * @param width
   * @param height
   */
  public void updateNotePosition(final Note note, int x, int y, int width,
      int height) {
    taskQueue.post(new UpdateNotePositionTask(note, x, y, width, height));
  }

  private void notifySurfaceSelected(Surface nowSelected, Surface wasSelected) {
    for (int i = 0, n = dataObservers.size(); i < n; ++i) {
      dataObservers.get(i).onSurfaceSelected(nowSelected, wasSelected);
    }
  }

  ServiceAsync getService() {
    return api;
  }

  StatusObserver getStatusObserver() {
    return statusObserver;
  }

  void notifyNoteCreated(Note note) {
    for (int i = 0, n = dataObservers.size(); i < n; ++i) {
      dataObservers.get(i).onNoteCreated(note);
    }
  }

  void notifySurfaceCreated(Surface surface) {
    for (int i = 0, n = dataObservers.size(); i < n; ++i) {
      dataObservers.get(i).onSurfaceCreated(surface);
    }
  }

  void notifySurfaceNotesReceived(Note[] notes) {
    for (int i = 0, n = dataObservers.size(); i < n; ++i) {
      dataObservers.get(i).onSurfaceNotesReceived(notes);
    }
  }

  void notifySurfacesReceived(Surface[] surfaces) {
    for (int i = 0, n = dataObservers.size(); i < n; ++i) {
      dataObservers.get(i).onSurfacesReceived(surfaces);
    }
  }

  /**
   * Invoked by tasks and loaders when RPC invocations begin to fail.
   */
  void onServerFailed(boolean fatal) {
    if (fatal) {
      forceApplicationReload();
      return;
    }

    if (!offline) {
      statusObserver.onServerWentAway();
      offline = true;
    }
  }

  /**
   * Invoked by tasks and loaders when RPC invocations succeed.
   */
  void onServerSucceeded() {
    if (offline) {
      statusObserver.onServerCameBack();
      offline = false;
    }
  }
}
