/**
  We use this class to keep on top of streaming and filtering posts within a topic.

  @class PostStream
  @extends Ember.Object
  @namespace Discourse
  @module Discourse
**/
Discourse.PostStream = Em.Object.extend({

  /**
    Are we currently loading posts in any way?

    @property loading
  **/
  loading: Em.computed.or('loadingAbove', 'loadingBelow', 'loadingFilter', 'stagingPost'),

  notLoading: Em.computed.not('loading'),

  filteredPostsCount: Em.computed.alias('stream.length'),

  /**
    Have we loaded any posts?

    @property hasPosts
  **/
  hasPosts: Em.computed.gt('posts.length', 0),

  /**
    Do we have a stream list of post ids?

    @property hasStream
  **/
  hasStream: Em.computed.gt('filteredPostsCount', 0),

  /**
    Can we append more posts to our current stream?

    @property canAppendMore
  **/
  canAppendMore: Em.computed.and('notLoading', 'hasPosts', 'lastPostNotLoaded'),

  /**
    Can we prepend more posts to our current stream?

    @property canPrependMore
  **/
  canPrependMore: Em.computed.and('notLoading', 'hasPosts', 'firstPostNotLoaded'),

  /**
    Have we loaded the first post in the stream?

    @property firstPostLoaded
  **/
  firstPostLoaded: function() {
    if (!this.get('hasLoadedData')) { return false; }
    return !!this.get('posts').findProperty('id', this.get('firstPostId'));
  }.property('hasLoadedData', 'posts.[]', 'firstPostId'),

  firstPostNotLoaded: Em.computed.not('firstPostLoaded'),

  /**
    Returns the id of the first post in the set

    @property firstPostId
  **/
  firstPostId: function() {
    return this.get('stream')[0];
  }.property('stream.@each'),

  /**
    Returns the id of the last post in the set

    @property lastPostId
  **/
  lastPostId: function() {
    return _.last(this.get('stream'));
  }.property('stream.@each'),

  /**
    Have we loaded the last post in the stream?

    @property lastPostLoaded
  **/
  lastPostLoaded: function() {
    if (!this.get('hasLoadedData')) { return false; }
    return !!this.get('posts').findProperty('id', this.get('lastPostId'));
  }.property('hasLoadedData', 'posts.@each.id', 'lastPostId'),

  lastPostNotLoaded: Em.computed.not('lastPostLoaded'),

  /**
    Returns a JS Object of current stream filter options. It should match the query
    params for the stream.

    @property streamFilters
  **/
  streamFilters: function() {
    var result = {};
    if (this.get('bestOf')) { result.filter = "best_of"; }

    var userFilters = this.get('userFilters');
    if (userFilters) {
      var userFiltersArray = this.get('userFilters').toArray();
      if (userFiltersArray.length > 0) { result.username_filters = userFiltersArray; }
    }

    return result;
  }.property('userFilters.[]', 'bestOf'),

  /**
    The text describing the current filters. For display in the pop up at the bottom of the
    screen.

    @property filterDesc
  **/
  filterDesc: function() {
    var streamFilters = this.get('streamFilters');

    if (streamFilters.filter && streamFilters.filter === "best_of") {
      return I18n.t("topic.filters.best_of", {
        n_best_posts: I18n.t("topic.filters.n_best_posts", { count: this.get('filteredPostsCount') }),
        of_n_posts: I18n.t("topic.filters.of_n_posts", { count: this.get('topic.posts_count') })
      });
    } else if (streamFilters.username_filters) {
      return I18n.t("topic.filters.user", {
        n_posts: I18n.t("topic.filters.n_posts", { count: this.get('filteredPostsCount') }),
        by_n_users: I18n.t("topic.filters.by_n_users", { count: streamFilters.username_filters.length })
      });
    }
    return "";
  }.property('streamFilters.[]', 'topic.posts_count', 'posts.length'),

  hasNoFilters: Em.computed.empty('filterDesc'),


  /**
    Returns the window of posts above the current set in the stream, bound to the top of the stream.
    This is the collection we'll ask for when scrolling upwards.

    @property previousWindow
  **/
  previousWindow: function() {
    // If we can't find the last post loaded, bail
    var firstPost = _.first(this.get('posts'));
    if (!firstPost) { return []; }

    // Find the index of the last post loaded, if not found, bail
    var stream = this.get('stream');
    var firstIndex = this.indexOf(firstPost);
    if (firstIndex === -1) { return []; }

    var startIndex = firstIndex - Discourse.SiteSettings.posts_per_page;
    if (startIndex < 0) { startIndex = 0; }
    return stream.slice(startIndex, firstIndex);

  }.property('posts.@each', 'stream.@each'),

  /**
    Returns the window of posts below the current set in the stream, bound by the bottom of the
    stream. This is the collection we use when scrolling downwards.

    @property nextWindow
  **/
  nextWindow: function() {
    // If we can't find the last post loaded, bail
    var lastPost = _.last(this.get('posts'));
    if (!lastPost) { return []; }

    // Find the index of the last post loaded, if not found, bail
    var stream = this.get('stream');
    var lastIndex = this.indexOf(lastPost);
    if (lastIndex === -1) { return []; }
    if ((lastIndex + 1) >= this.get('filteredPostsCount')) { return []; }

    // find our window of posts
    return stream.slice(lastIndex+1, lastIndex+Discourse.SiteSettings.posts_per_page+1);
  }.property('posts.@each', 'stream.@each'),


  /**
    Cancel any active filters on the stream and refresh it.

    @method cancelFilter
    @returns {Ember.Deferred} a promise that resolves when the filter has been cancelled.
  **/
  cancelFilter: function() {
    this.set('bestOf', false);
    this.get('userFilters').clear();
    return this.refresh();
  },

  /**
    Toggle best of mode on the stream.

    @method toggleBestOf
    @returns {Ember.Deferred} a promise that resolves when the best of stream has loaded.
  **/
  toggleBestOf: function() {
    this.toggleProperty('bestOf');
    this.refresh();
  },

  /**
    Filter the stream to a particular user.

    @method toggleParticipant
    @returns {Ember.Deferred} a promise that resolves when the filtered stream has loaded.
  **/
  toggleParticipant: function(username) {
    var userFilters = this.get('userFilters');
    if (userFilters.contains(username)) {
      userFilters.remove(username);
    } else {
      userFilters.add(username);
    }
    return this.refresh();
  },

  /**
    Loads a new set of posts into the stream. If you provide a `nearPost` option and the post
    is already loaded, it will simply scroll there and load nothing.

    @method refresh
    @param {Object} opts Options for loading the stream
      @param {Integer} opts.nearPost The post we want to find other posts near to.
      @param {Boolean} opts.track_visit Whether or not to track this as a visit to a topic.
    @returns {Ember.Deferred} a promise that is resolved when the posts have been inserted into the stream.
  **/
  refresh: function(opts) {
    opts = opts || {};
    opts.nearPost = parseInt(opts.nearPost, 10);

    var topic = this.get('topic');
    var postStream = this;

    // Do we already have the post in our list of posts? Jump there.
    var postWeWant = this.get('posts').findProperty('post_number', opts.nearPost);
    if (postWeWant) {
      Discourse.TopicView.jumpToPost(topic.get('id'), opts.nearPost);
      return Ember.Deferred.create(function(p) { p.reject(); });
    }

    // TODO: if we have all the posts in the filter, don't go to the server for them.
    postStream.set('loadingFilter', true);

    opts = _.merge(opts, postStream.get('streamFilters'));

    // Request a topicView
    return Discourse.PostStream.loadTopicView(topic.get('id'), opts).then(function (json) {
      topic.updateFromJson(json);
      postStream.updateFromJson(json.post_stream);
      postStream.setProperties({ loadingFilter: false, loaded: true });

      if (opts.nearPost) {
        Discourse.TopicView.jumpToPost(topic.get('id'), opts.nearPost);
      }

      Discourse.URL.set('queryParams', postStream.get('streamFilters'));
    }, function(result) {
      postStream.errorLoading(result);
    });
  },
  hasLoadedData: Em.computed.and('hasPosts', 'hasStream'),

  /**
    Appends the next window of posts to the stream. Call it when scrolling downwards.

    @method appendMore
    @returns {Ember.Deferred} a promise that's resolved when the posts have been added.
  **/
  appendMore: function() {
    var postStream = this,
        rejectedPromise = Ember.Deferred.create(function(p) { p.reject(); });

    // Make sure we can append more posts
    if (!postStream.get('canAppendMore')) { return rejectedPromise; }

    var postIds = postStream.get('nextWindow');
    if (Ember.isEmpty(postIds)) { return rejectedPromise; }

    postStream.set('loadingBelow', true);
    return postStream.findPostsByIds(postIds).then(function(posts) {
      posts.forEach(function(p) {
        postStream.appendPost(p);
      });
      postStream.set('loadingBelow', false);
    });
  },

  /**
    Prepend the previous window of posts to the stream. Call it when scrolling upwards.

    @method appendMore
    @returns {Ember.Deferred} a promise that's resolved when the posts have been added.
  **/
  prependMore: function() {
    var postStream = this,
        rejectedPromise = Ember.Deferred.create(function(p) { p.reject(); });

    // Make sure we can append more posts
    if (!postStream.get('canPrependMore')) { return rejectedPromise; }

    var postIds = postStream.get('previousWindow');
    if (Ember.isEmpty(postIds)) { return rejectedPromise; }

    postStream.set('loadingAbove', true);
    return postStream.findPostsByIds(postIds.reverse()).then(function(posts) {
      posts.forEach(function(p) {
        postStream.prependPost(p);
      });
      postStream.set('loadingAbove', false);
    });
  },

  /**
    Stage a post for insertion in the stream. It should be rendered right away under the
    assumption that the post will succeed. We can then `commitPost` when it succeeds or
    `undoPost` when it fails.

    @method stagePost
    @param {Discourse.Post} the post to stage in the stream
    @param {Discourse.User} the user creating the post
  **/
  stagePost: function(post, user) {

    // We can't stage two posts simultaneously
    if (this.get('stagingPost')) { return false; }

    this.set('stagingPost', true);

    var topic = this.get('topic');
    topic.setProperties({
      posts_count: (topic.get('posts_count') || 0) + 1,
      last_posted_at: new Date(),
      'details.last_poster': user,
      highest_post_number: (topic.get('highest_post_number') || 0) + 1
    });

    post.setProperties({
      post_number: topic.get('highest_post_number'),
      topic: topic,
      created_at: new Date()
    });

    // If we're at the end of the stream, add the post
    if (this.get('lastPostLoaded')) {
      this.appendPost(post);
    }

    return true;
  },

  /**
    Commit the post we staged. Call this after a save succeeds.

    @method commitPost
    @param {Discourse.Post} the post we saved in the stream.
  **/
  commitPost: function(post) {
    this.appendPost(post);
    this.get('stream').addObject(post.get('id'));
    this.set('stagingPost', false);
  },

  /**
    Undo a post we've staged in the stream. Remove it from being rendered and revert the
    state we changed.

    @method undoPost
    @param {Discourse.Post} the post to undo from the stream
  **/
  undoPost: function(post) {
    this.posts.removeObject(post);

    var topic = this.get('topic');

    this.set('stagingPost', false);

    topic.setProperties({
      highest_post_number: (topic.get('highest_post_number') || 0) - 1,
      posts_count: (topic.get('posts_count') || 0) - 1
    });
  },

  /**
    Prepends a single post to the stream.

    @method prependPost
    @param {Discourse.Post} post The post we're prepending
    @returns {Discourse.Post} the post that was inserted.
  **/
  prependPost: function(post) {
    this.get('posts').unshiftObject(this.storePost(post));
    return post;
  },

  /**
    Appends a single post into the stream.

    @method appendPost
    @param {Discourse.Post} post The post we're appending
    @returns {Discourse.Post} the post that was inserted.
  **/
  appendPost: function(post) {
    this.get('posts').addObject(this.storePost(post));
    return post;
  },

  /**
    Removes posts from the stream.

    @method removePosts
    @param {Array} posts the collection of posts to remove
  **/
  removePosts: function(posts) {
    if (Em.isEmpty(posts)) { return; }

    var postIds = posts.map(function (p) { return p.get('id'); });

    this.get('stream').removeObjects(postIds);
    this.get('posts').removeObjects(posts);
  },

  /**
    Returns a post from the identity map if it's been inserted.

    @method findLoadedPost
    @param {Integer} id The post we want from the identity map.
    @returns {Discourse.Post} the post that was inserted.
  **/
  findLoadedPost: function(id) {
    return this.get('postIdentityMap').get(id);
  },

  /**
    Finds and adds a post to the stream by id. Typically this would happen if we receive a message
    from the message bus indicating there's a new post. We'll only insert it if we currently
    have no filters.

    @method triggerNewPostInStream
    @param {Integer} postId The id of the new post to be inserted into the stream
  **/
  triggerNewPostInStream: function(postId) {
    if (!postId) { return; }

    // We only trigger if there are no filters active
    if (!this.get('hasNoFilters')) { return; }

    var lastPostLoaded = this.get('lastPostLoaded');

    if (this.get('stream').indexOf(postId) === -1) {
      this.get('stream').addObject(postId);
      if (lastPostLoaded) { this.appendMore(); }
    }
  },

  /**
    Returns the closest post number given a postNumber that may not exist in the stream.
    For example, if the user asks for a post that's deleted or otherwise outside the range.
    This allows us to set the progress bar with the correct number.

    @method closestPostNumberFor
    @param {Integer} postNumber the post number we're looking for
  **/
  closestPostNumberFor: function(postNumber) {
    if (!this.get('hasPosts')) { return; }

    var closest = null;
    this.get('posts').forEach(function (p) {
      if (closest === postNumber) { return; }
      if (!closest) { closest = p.get('post_number'); }

      if (Math.abs(postNumber - p.get('post_number')) < Math.abs(closest - postNumber)) {
        closest = p.get('post_number');
      }
    });

    return closest;
  },

  /**
    @private

    Given a JSON packet, update this stream and the posts that exist in it.

    @param {Object} postStreamData The JSON data we want to update from.
    @method updateFromJson
  **/
  updateFromJson: function(postStreamData) {
    var postStream = this;

    var posts = this.get('posts');
    posts.clear();
    if (postStreamData) {
      // Load posts if present
      postStreamData.posts.forEach(function(p) {
        postStream.appendPost(Discourse.Post.create(p));
      });
      delete postStreamData.posts;

      // Update our attributes
      postStream.setProperties(postStreamData);
    }
  },

  /**
    @private

    Stores a post in our identity map, and sets up the references it needs to
    find associated objects like the topic. It might return a different reference
    than you supplied if the post has already been loaded.

    @method storePost
    @param {Discourse.Post} post The post we're storing in the identity map
    @returns {Discourse.Post} the post from the identity map
  **/
  storePost: function(post) {
    var postId = post.get('id');
    if (postId) {
      var postIdentityMap = this.get('postIdentityMap'),
          existing = postIdentityMap.get(post.get('id'));

      if (existing) {
        // If the post is in the identity map, update it and return the old reference.
        existing.updateFromPost(post);
        return existing;
      }

      post.set('topic', this.get('topic'));
      postIdentityMap.set(post.get('id'), post);
    }
    return post;
  },

  /**
    @private

    Given a list of postIds, returns a list of the posts we don't have in our
    identity map and need to load.

    @method listUnloadedIds
    @param {Array} postIds The post Ids we want to load from the server
    @returns {Array} the array of postIds we don't have loaded.
  **/
  listUnloadedIds: function(postIds) {
    var unloaded = Em.A(),
        postIdentityMap = this.get('postIdentityMap');
    postIds.forEach(function(p) {
      if (!postIdentityMap.has(p)) { unloaded.pushObject(p); }
    });
    return unloaded;
  },

  /**
    @private

    Returns a list of posts in order requested, by id.

    @method findPostsByIds
    @param {Array} postIds The post Ids we want to retrieve, in order.
    @returns {Ember.Deferred} a promise that will resolve to the posts in the order requested.
  **/
  findPostsByIds: function(postIds) {
    var unloaded = this.listUnloadedIds(postIds),
        postIdentityMap = this.get('postIdentityMap');

    // Load our unloaded posts by id
    return this.loadIntoIdentityMap(unloaded).then(function() {
      return postIds.map(function (p) {
        return postIdentityMap.get(p);
      });
    });
  },

  /**
    @private

    Loads a list of posts from the server and inserts them into our identity map.

    @method loadIntoIdentityMap
    @param {Array} postIds The post Ids we want to insert into the identity map.
    @returns {Ember.Deferred} a promise that will resolve to the posts in the order requested.
  **/
  loadIntoIdentityMap: function(postIds) {

    // If we don't want any posts, return a promise that resolves right away
    if (Em.isEmpty(postIds)) {
      return Ember.Deferred.promise(function (p) { p.resolve(); });
    }

    var url = "/t/" + this.get('topic.id') + "/posts.json",
        data = { post_ids: postIds },
        postStream = this,
        result = Em.A();

    return Discourse.ajax(url, {data: data}).then(function(result) {
      var posts = Em.get(result, "post_stream.posts");
      if (posts) {
        posts.forEach(function (p) {
          postStream.storePost(Discourse.Post.create(p));
        });
      }
    });
  },


  /**
    @private

    Returns the index of a particular post in the stream

    @method indexOf
    @param {Discourse.Post} post The post we're looking for
  **/
  indexOf: function(post) {
    return this.get('stream').indexOf(post.get('id'));
  },


  /**
    @private

    Handles an error loading a topic based on a HTTP status code. Updates
    the text to the correct values.

    @method errorLoading
    @param {Integer} status the HTTP status code
    @param {Discourse.Topic} topic The topic instance we were trying to load
  **/
  errorLoading: function(result) {
    var status = result.status;

    var topic = this.get('topic');
    topic.set('loadingFilter', false);
    topic.set('errorLoading', true);

    // If the result was 404 the post is not found
    if (status === 404) {
      topic.set('errorTitle', I18n.t('topic.not_found.title'));
      topic.set('errorBodyHtml', result.responseText);
      return;
    }

    // If the result is 403 it means invalid access
    if (status === 403) {
      topic.set('errorTitle', I18n.t('topic.invalid_access.title'));
      topic.set('message', I18n.t('topic.invalid_access.description'));
      return;
    }

    // Otherwise supply a generic error message
    topic.set('errorTitle', I18n.t('topic.server_error.title'));
    topic.set('message', I18n.t('topic.server_error.description'));
  }

});


Discourse.PostStream.reopenClass({

  create: function(args) {
    var postStream = this._super(args);
    postStream.setProperties({
      posts: Em.A(),
      stream: Em.A(),
      userFilters: Em.Set.create(),
      postIdentityMap: Em.Map.create(),
      bestOf: false,
      loaded: false,
      loadingAbove: false,
      loadingBelow: false,
      loadingFilter: false,
      stagingPost: false
    });
    return postStream;
  },

  loadTopicView: function(topicId, args) {
    var opts = _.merge({}, args);
    var url = Discourse.getURL("/t/") + topicId;
    if (opts.nearPost) {
      url += "/" + opts.nearPost;
    }
    delete opts.nearPost;

    return PreloadStore.getAndRemove("topic_" + topicId, function() {
      return Discourse.ajax(url + ".json", {data: opts});
    });

  }

});
